add like functionality with Like model, implement like/unlike methods, and update Post and Comment models to track like counts

This commit is contained in:
2025-07-22 22:45:55 +08:00
parent cbe73121f2
commit 562b9bd17f
15 changed files with 216 additions and 33 deletions

6
.gitignore vendored
View File

@ -7,4 +7,8 @@ configs/
.env
# data
data/
data/
# dist
server
main

8
internal/dto/label.go Normal file
View File

@ -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"`
}

16
internal/dto/post.go Normal file
View File

@ -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"`
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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 的类名,用于前端样式
}

42
internal/model/like.go Normal file
View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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{})
}

86
internal/repo/like.go Normal file
View File

@ -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")
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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())
{

View File

@ -24,4 +24,7 @@ const (
OidcUri = "/user/oidc/login" // OIDC登录URI
DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl
TargetTypePost = "post"
TargetTypeComment = "comment"
)