diff --git a/.gitignore b/.gitignore index c5619fb..89ce366 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ configs/ .env # data -data/ \ No newline at end of file +data/ + +# dist +server +main \ No newline at end of file diff --git a/internal/dto/label.go b/internal/dto/label.go new file mode 100644 index 0000000..8679657 --- /dev/null +++ b/internal/dto/label.go @@ -0,0 +1,8 @@ +package dto + +type LabelDto struct { + Key string `json:"key"` + Value string `json:"value"` + Color string `json:"color"` + TailwindClassName string `json:"tailwind_class_name"` +} diff --git a/internal/dto/post.go b/internal/dto/post.go new file mode 100644 index 0000000..50ed6b3 --- /dev/null +++ b/internal/dto/post.go @@ -0,0 +1,16 @@ +package dto + +type PostDto struct { + UserID uint `json:"user_id"` // 发布者的用户ID + Title string `json:"title"` // 帖子标题 + Content string `json:"content"` + Labels []LabelDto `json:"labels"` // 关联的标签 + IsPrivate bool `json:"is_private"` // 是否为私密帖子 +} + +type CreatePostReq struct { + Title string `json:"title"` + Content string `json:"content"` + Labels []LabelDto `json:"labels"` + IsPrivate bool `json:"is_private"` +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 14c2aef..30182c2 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -11,11 +11,13 @@ import ( "time" ) -func UseAuth() app.HandlerFunc { +func UseAuth(block bool) app.HandlerFunc { return func(ctx context.Context, c *app.RequestContext) { // For cookie token := string(c.Cookie("token")) refreshToken := string(c.Cookie("refresh_token")) + + // 尝试用普通 token 认证 tokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(token) if err == nil && tokenClaims != nil { ctx = context.WithValue(ctx, "user_id", tokenClaims.UserID) @@ -23,27 +25,41 @@ func UseAuth() app.HandlerFunc { return } - // token 失效 使用refresh token重新签发和鉴权 + // token 失效 使用 refresh token 重新签发和鉴权 refreshTokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(refreshToken) if err == nil && refreshTokenClaims != nil { ok, err := isStatefulJwtValid(refreshTokenClaims) if err == nil && ok { - ctx = context.WithValue(ctx, "user_id", refreshTokenClaims.UserID) // 修改这里,使用refreshTokenClaims - c.Next(ctx) - newTokenClaims := utils.Jwt.NewClaims(refreshTokenClaims.UserID, refreshTokenClaims.SessionKey, refreshTokenClaims.Stateful, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, 30)*int(time.Hour))) + ctx = context.WithValue(ctx, "user_id", refreshTokenClaims.UserID) + + // 生成新 token + newTokenClaims := utils.Jwt.NewClaims( + refreshTokenClaims.UserID, + refreshTokenClaims.SessionKey, + refreshTokenClaims.Stateful, + time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, 30)*int(time.Hour)), + ) newToken, err := newTokenClaims.ToString() if err == nil { ctxutils.SetTokenCookie(c, newToken) } else { resps.InternalServerError(c, resps.ErrInternalServerError) } + + c.Next(ctx) return } } - // 所有认证方式都失败,返回未授权错误 - resps.UnAuthorized(c, resps.ErrUnauthorized) - c.Abort() + // 所有认证方式都失败 + if block { + // 若需要阻断,返回未授权错误并中止请求 + resps.UnAuthorized(c, resps.ErrUnauthorized) + c.Abort() + } else { + // 若不需要阻断,继续请求但不设置用户ID + c.Next(ctx) + } } } diff --git a/internal/model/comment.go b/internal/model/comment.go index 2c63adc..fdaa9c8 100644 --- a/internal/model/comment.go +++ b/internal/model/comment.go @@ -4,11 +4,13 @@ import "gorm.io/gorm" type Comment struct { gorm.Model - UserID uint `gorm:"index"` // 评论的用户ID - User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户 - TargetID uint `gorm:"index"` // 目标ID - TargetType string `gorm:"index"` // 目标类型,如 "post", "page" - ReplyID uint `gorm:"index"` // 回复的评论ID - Content string `gorm:"type:text"` // 评论内容 - Depth int `gorm:"default:0"` // 评论的层级深度 + UserID uint `gorm:"index"` // 评论的用户ID + User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户 + TargetID uint `gorm:"index"` // 目标ID + TargetType string `gorm:"index"` // 目标类型,如 "post", "page" + ReplyID uint `gorm:"index"` // 回复的评论ID + Content string `gorm:"type:text"` // 评论内容 + Depth int `gorm:"default:0"` // 评论的层级深度 + LikeCount uint64 + CommentCount uint64 } diff --git a/internal/model/label.go b/internal/model/label.go index a0831c4..b4ce9b4 100644 --- a/internal/model/label.go +++ b/internal/model/label.go @@ -4,7 +4,8 @@ import "gorm.io/gorm" type Label struct { gorm.Model - Key string `gorm:"uniqueIndex"` // 标签键,唯一标识 - Value string `gorm:"type:text"` // 标签值,描述标签的内容 - Color string `gorm:"type:text"` // 前端可用颜色代码 + Key string `gorm:"uniqueIndex"` // 标签键,唯一标识 + Value string `gorm:"type:text"` // 标签值,描述标签的内容 + Color string `gorm:"type:text"` // 前端可用颜色代码 + TailwindClassName string `gorm:"type:text"` // Tailwind CSS 的类名,用于前端样式 } diff --git a/internal/model/like.go b/internal/model/like.go new file mode 100644 index 0000000..1983818 --- /dev/null +++ b/internal/model/like.go @@ -0,0 +1,42 @@ +package model + +import ( + "fmt" + "github.com/snowykami/neo-blog/pkg/constant" + "gorm.io/gorm" +) + +type Like struct { + gorm.Model + TargetType string + TargetID uint + UserID uint +} + +// AfterCreate 点赞后更新被点赞对象的计数 +func (l *Like) AfterCreate(tx *gorm.DB) (err error) { + switch l.TargetType { + case constant.TargetTypePost: + return tx.Model(&Post{}).Where("id = ?", l.TargetID). + UpdateColumn("like_count", gorm.Expr("like_count + ?", 1)).Error + case constant.TargetTypeComment: + return tx.Model(&Comment{}).Where("id = ?", l.TargetID). + UpdateColumn("like_count", gorm.Expr("like_count + ?", 1)).Error + default: + return fmt.Errorf("不支持的目标类型: %s", l.TargetType) + } +} + +// AfterDelete 取消点赞后更新被点赞对象的计数 +func (l *Like) AfterDelete(tx *gorm.DB) (err error) { + switch l.TargetType { + case constant.TargetTypePost: + return tx.Model(&Post{}).Where("id = ?", l.TargetID). + UpdateColumn("like_count", gorm.Expr("GREATEST(like_count - ?, 0)", 1)).Error + case constant.TargetTypeComment: + return tx.Model(&Comment{}).Where("id = ?", l.TargetID). + UpdateColumn("like_count", gorm.Expr("GREATEST(like_count - ?, 0)", 1)).Error + default: + return fmt.Errorf("不支持的目标类型: %s", l.TargetType) + } +} diff --git a/internal/model/post.go b/internal/model/post.go index 6b4f224..2a09dc3 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -4,9 +4,13 @@ import "gorm.io/gorm" type Post struct { gorm.Model - UserID uint `gorm:"index"` // 发布者的用户ID - User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户 - Title string `gorm:"type:text;not null"` // 帖子标题 - Content string `gorm:"type:text;not null"` // 帖子内容 - Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签 + UserID uint `gorm:"index"` // 发布者的用户ID + User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户 + Title string `gorm:"type:text;not null"` // 帖子标题 + Content string `gorm:"type:text;not null"` // 帖子内容 + Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签 + IsPrivate bool `gorm:"default:false"` // 是否为私密帖子 + LikeCount uint64 + CommentCount uint64 + VisitorCount uint64 } diff --git a/internal/repo/init.go b/internal/repo/init.go index 25bbac9..e7cf5dc 100644 --- a/internal/repo/init.go +++ b/internal/repo/init.go @@ -126,8 +126,10 @@ func migrate() error { return GetDB().AutoMigrate( &model.Comment{}, &model.Label{}, + &model.Like{}, &model.OidcConfig{}, &model.Post{}, &model.Session{}, - &model.User{}) + &model.User{}, + &model.UserOpenID{}) } diff --git a/internal/repo/like.go b/internal/repo/like.go new file mode 100644 index 0000000..581c0c1 --- /dev/null +++ b/internal/repo/like.go @@ -0,0 +1,86 @@ +package repo + +import ( + "errors" + "github.com/snowykami/neo-blog/internal/model" + "github.com/snowykami/neo-blog/pkg/constant" + "gorm.io/gorm" +) + +type likeRepo struct{} + +var Like = &likeRepo{} + +// Like 用户点赞,幂等 +func (l *likeRepo) Like(userID, targetID uint, targetType string) error { + err := l.checkTargetType(targetType) + if err != nil { + return err + } + var existingLike model.Like + err = GetDB().Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).First(&existingLike).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + like := &model.Like{ + TargetType: targetType, + TargetID: targetID, + UserID: userID, + } + + return GetDB().Create(like).Error +} + +// UnLike 取消点赞 +func (l *likeRepo) UnLike(userID, targetID uint, targetType string) error { + err := l.checkTargetType(targetType) + if err != nil { + return err + } + return GetDB().Where("target_type = ? AND target_id = ? AND user_id = ?", + targetType, targetID, userID).Delete(&model.Like{}).Error +} + +// IsLiked 检查是否点赞 +func (l *likeRepo) IsLiked(userID, targetID uint, targetType string) (bool, error) { + err := l.checkTargetType(targetType) + if err != nil { + return false, err + } + var like model.Like + err = GetDB().Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).First(&like).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return true, nil +} + +// Count 点赞计数 +func (l *likeRepo) Count(targetID uint, targetType string) (int64, error) { + err := l.checkTargetType(targetType) + if err != nil { + return 0, err + } + var count int64 + err = GetDB().Model(&model.Like{}).Where("target_type = ? AND target_id = ?", targetType, targetID).Count(&count).Error + if err != nil { + return 0, err + } + return count, nil +} + +func (l *likeRepo) checkTargetType(targetType string) error { + switch targetType { + case constant.TargetTypePost, constant.TargetTypeComment: + return nil + default: + return errors.New("invalid target type") + } +} diff --git a/internal/router/apiv1/label.go b/internal/router/apiv1/label.go index c37685b..18138e3 100644 --- a/internal/router/apiv1/label.go +++ b/internal/router/apiv1/label.go @@ -7,8 +7,8 @@ import ( ) func registerLabelRoutes(group *route.RouterGroup) { - labelGroup := group.Group("/label").Use(middleware.UseAuth()) - labelGroupWithoutAuth := group.Group("/label") + labelGroup := group.Group("/label").Use(middleware.UseAuth(true)) + labelGroupWithoutAuth := group.Group("/label").Use(middleware.UseAuth(false)) { labelGroupWithoutAuth.GET("/l/:id", v1.Label.Get) labelGroupWithoutAuth.GET("/list", v1.Label.List) diff --git a/internal/router/apiv1/page.go b/internal/router/apiv1/page.go index 9cd8c64..fa700b5 100644 --- a/internal/router/apiv1/page.go +++ b/internal/router/apiv1/page.go @@ -9,8 +9,8 @@ import ( // page 页面API路由 func registerPageRoutes(group *route.RouterGroup) { - postGroup := group.Group("/page").Use(middleware.UseAuth()) - postGroupWithoutAuth := group.Group("/page") + postGroup := group.Group("/page").Use(middleware.UseAuth(true)) + postGroupWithoutAuth := group.Group("/page").Use(middleware.UseAuth(false)) { postGroupWithoutAuth.GET("/p/:id", v1.Page.Get) postGroupWithoutAuth.GET("/list", v1.Page.List) diff --git a/internal/router/apiv1/post.go b/internal/router/apiv1/post.go index ccb4a31..cc8075f 100644 --- a/internal/router/apiv1/post.go +++ b/internal/router/apiv1/post.go @@ -9,12 +9,11 @@ import ( // post 文章API路由 func registerPostRoutes(group *route.RouterGroup) { - postGroup := group.Group("/post").Use(middleware.UseAuth()) - postGroupWithoutAuth := group.Group("/post") + postGroup := group.Group("/post").Use(middleware.UseAuth(true)) + postGroupWithoutAuth := group.Group("/post").Use(middleware.UseAuth(false)) { postGroupWithoutAuth.GET("/p/:id", v1.Post.Get) postGroupWithoutAuth.GET("/list", v1.Post.List) - postGroup.POST("/p", v1.Post.Create) postGroup.PUT("/p", v1.Post.Update) postGroup.DELETE("/p", v1.Post.Delete) diff --git a/internal/router/apiv1/user.go b/internal/router/apiv1/user.go index 96578a0..008bf46 100644 --- a/internal/router/apiv1/user.go +++ b/internal/router/apiv1/user.go @@ -7,7 +7,7 @@ import ( ) func registerUserRoutes(group *route.RouterGroup) { - userGroup := group.Group("/user").Use(middleware.UseAuth()) + userGroup := group.Group("/user").Use(middleware.UseAuth(true)) userGroupWithoutAuth := group.Group("/user") userGroupWithoutAuthNeedsCaptcha := userGroupWithoutAuth.Use(middleware.UseCaptcha()) { diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index 00f9c3a..c00b63e 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -24,4 +24,7 @@ const ( OidcUri = "/user/oidc/login" // OIDC登录URI DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl + + TargetTypePost = "post" + TargetTypeComment = "comment" )