mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-04 00:06:22 +00:00
⚡ 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:
6
.gitignore
vendored
6
.gitignore
vendored
@ -7,4 +7,8 @@ configs/
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
# data
|
# data
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# dist
|
||||||
|
server
|
||||||
|
main
|
8
internal/dto/label.go
Normal file
8
internal/dto/label.go
Normal 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
16
internal/dto/post.go
Normal 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"`
|
||||||
|
}
|
@ -11,11 +11,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UseAuth() app.HandlerFunc {
|
func UseAuth(block bool) app.HandlerFunc {
|
||||||
return func(ctx context.Context, c *app.RequestContext) {
|
return func(ctx context.Context, c *app.RequestContext) {
|
||||||
// For cookie
|
// For cookie
|
||||||
token := string(c.Cookie("token"))
|
token := string(c.Cookie("token"))
|
||||||
refreshToken := string(c.Cookie("refresh_token"))
|
refreshToken := string(c.Cookie("refresh_token"))
|
||||||
|
|
||||||
|
// 尝试用普通 token 认证
|
||||||
tokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(token)
|
tokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(token)
|
||||||
if err == nil && tokenClaims != nil {
|
if err == nil && tokenClaims != nil {
|
||||||
ctx = context.WithValue(ctx, "user_id", tokenClaims.UserID)
|
ctx = context.WithValue(ctx, "user_id", tokenClaims.UserID)
|
||||||
@ -23,27 +25,41 @@ func UseAuth() app.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// token 失效 使用refresh token重新签发和鉴权
|
// token 失效 使用 refresh token 重新签发和鉴权
|
||||||
refreshTokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(refreshToken)
|
refreshTokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(refreshToken)
|
||||||
if err == nil && refreshTokenClaims != nil {
|
if err == nil && refreshTokenClaims != nil {
|
||||||
ok, err := isStatefulJwtValid(refreshTokenClaims)
|
ok, err := isStatefulJwtValid(refreshTokenClaims)
|
||||||
if err == nil && ok {
|
if err == nil && ok {
|
||||||
ctx = context.WithValue(ctx, "user_id", refreshTokenClaims.UserID) // 修改这里,使用refreshTokenClaims
|
ctx = context.WithValue(ctx, "user_id", refreshTokenClaims.UserID)
|
||||||
c.Next(ctx)
|
|
||||||
newTokenClaims := utils.Jwt.NewClaims(refreshTokenClaims.UserID, refreshTokenClaims.SessionKey, refreshTokenClaims.Stateful, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, 30)*int(time.Hour)))
|
// 生成新 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()
|
newToken, err := newTokenClaims.ToString()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ctxutils.SetTokenCookie(c, newToken)
|
ctxutils.SetTokenCookie(c, newToken)
|
||||||
} else {
|
} else {
|
||||||
resps.InternalServerError(c, resps.ErrInternalServerError)
|
resps.InternalServerError(c, resps.ErrInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Next(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有认证方式都失败,返回未授权错误
|
// 所有认证方式都失败
|
||||||
resps.UnAuthorized(c, resps.ErrUnauthorized)
|
if block {
|
||||||
c.Abort()
|
// 若需要阻断,返回未授权错误并中止请求
|
||||||
|
resps.UnAuthorized(c, resps.ErrUnauthorized)
|
||||||
|
c.Abort()
|
||||||
|
} else {
|
||||||
|
// 若不需要阻断,继续请求但不设置用户ID
|
||||||
|
c.Next(ctx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,11 +4,13 @@ import "gorm.io/gorm"
|
|||||||
|
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
UserID uint `gorm:"index"` // 评论的用户ID
|
UserID uint `gorm:"index"` // 评论的用户ID
|
||||||
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
|
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
|
||||||
TargetID uint `gorm:"index"` // 目标ID
|
TargetID uint `gorm:"index"` // 目标ID
|
||||||
TargetType string `gorm:"index"` // 目标类型,如 "post", "page"
|
TargetType string `gorm:"index"` // 目标类型,如 "post", "page"
|
||||||
ReplyID uint `gorm:"index"` // 回复的评论ID
|
ReplyID uint `gorm:"index"` // 回复的评论ID
|
||||||
Content string `gorm:"type:text"` // 评论内容
|
Content string `gorm:"type:text"` // 评论内容
|
||||||
Depth int `gorm:"default:0"` // 评论的层级深度
|
Depth int `gorm:"default:0"` // 评论的层级深度
|
||||||
|
LikeCount uint64
|
||||||
|
CommentCount uint64
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,8 @@ import "gorm.io/gorm"
|
|||||||
|
|
||||||
type Label struct {
|
type Label struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Key string `gorm:"uniqueIndex"` // 标签键,唯一标识
|
Key string `gorm:"uniqueIndex"` // 标签键,唯一标识
|
||||||
Value string `gorm:"type:text"` // 标签值,描述标签的内容
|
Value string `gorm:"type:text"` // 标签值,描述标签的内容
|
||||||
Color string `gorm:"type:text"` // 前端可用颜色代码
|
Color string `gorm:"type:text"` // 前端可用颜色代码
|
||||||
|
TailwindClassName string `gorm:"type:text"` // Tailwind CSS 的类名,用于前端样式
|
||||||
}
|
}
|
||||||
|
42
internal/model/like.go
Normal file
42
internal/model/like.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -4,9 +4,13 @@ import "gorm.io/gorm"
|
|||||||
|
|
||||||
type Post struct {
|
type Post struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
UserID uint `gorm:"index"` // 发布者的用户ID
|
UserID uint `gorm:"index"` // 发布者的用户ID
|
||||||
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
|
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
|
||||||
Title string `gorm:"type:text;not null"` // 帖子标题
|
Title string `gorm:"type:text;not null"` // 帖子标题
|
||||||
Content 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;"` // 关联的标签
|
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
|
||||||
|
IsPrivate bool `gorm:"default:false"` // 是否为私密帖子
|
||||||
|
LikeCount uint64
|
||||||
|
CommentCount uint64
|
||||||
|
VisitorCount uint64
|
||||||
}
|
}
|
||||||
|
@ -126,8 +126,10 @@ func migrate() error {
|
|||||||
return GetDB().AutoMigrate(
|
return GetDB().AutoMigrate(
|
||||||
&model.Comment{},
|
&model.Comment{},
|
||||||
&model.Label{},
|
&model.Label{},
|
||||||
|
&model.Like{},
|
||||||
&model.OidcConfig{},
|
&model.OidcConfig{},
|
||||||
&model.Post{},
|
&model.Post{},
|
||||||
&model.Session{},
|
&model.Session{},
|
||||||
&model.User{})
|
&model.User{},
|
||||||
|
&model.UserOpenID{})
|
||||||
}
|
}
|
||||||
|
86
internal/repo/like.go
Normal file
86
internal/repo/like.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
@ -7,8 +7,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func registerLabelRoutes(group *route.RouterGroup) {
|
func registerLabelRoutes(group *route.RouterGroup) {
|
||||||
labelGroup := group.Group("/label").Use(middleware.UseAuth())
|
labelGroup := group.Group("/label").Use(middleware.UseAuth(true))
|
||||||
labelGroupWithoutAuth := group.Group("/label")
|
labelGroupWithoutAuth := group.Group("/label").Use(middleware.UseAuth(false))
|
||||||
{
|
{
|
||||||
labelGroupWithoutAuth.GET("/l/:id", v1.Label.Get)
|
labelGroupWithoutAuth.GET("/l/:id", v1.Label.Get)
|
||||||
labelGroupWithoutAuth.GET("/list", v1.Label.List)
|
labelGroupWithoutAuth.GET("/list", v1.Label.List)
|
||||||
|
@ -9,8 +9,8 @@ import (
|
|||||||
// page 页面API路由
|
// page 页面API路由
|
||||||
|
|
||||||
func registerPageRoutes(group *route.RouterGroup) {
|
func registerPageRoutes(group *route.RouterGroup) {
|
||||||
postGroup := group.Group("/page").Use(middleware.UseAuth())
|
postGroup := group.Group("/page").Use(middleware.UseAuth(true))
|
||||||
postGroupWithoutAuth := group.Group("/page")
|
postGroupWithoutAuth := group.Group("/page").Use(middleware.UseAuth(false))
|
||||||
{
|
{
|
||||||
postGroupWithoutAuth.GET("/p/:id", v1.Page.Get)
|
postGroupWithoutAuth.GET("/p/:id", v1.Page.Get)
|
||||||
postGroupWithoutAuth.GET("/list", v1.Page.List)
|
postGroupWithoutAuth.GET("/list", v1.Page.List)
|
||||||
|
@ -9,12 +9,11 @@ import (
|
|||||||
// post 文章API路由
|
// post 文章API路由
|
||||||
|
|
||||||
func registerPostRoutes(group *route.RouterGroup) {
|
func registerPostRoutes(group *route.RouterGroup) {
|
||||||
postGroup := group.Group("/post").Use(middleware.UseAuth())
|
postGroup := group.Group("/post").Use(middleware.UseAuth(true))
|
||||||
postGroupWithoutAuth := group.Group("/post")
|
postGroupWithoutAuth := group.Group("/post").Use(middleware.UseAuth(false))
|
||||||
{
|
{
|
||||||
postGroupWithoutAuth.GET("/p/:id", v1.Post.Get)
|
postGroupWithoutAuth.GET("/p/:id", v1.Post.Get)
|
||||||
postGroupWithoutAuth.GET("/list", v1.Post.List)
|
postGroupWithoutAuth.GET("/list", v1.Post.List)
|
||||||
|
|
||||||
postGroup.POST("/p", v1.Post.Create)
|
postGroup.POST("/p", v1.Post.Create)
|
||||||
postGroup.PUT("/p", v1.Post.Update)
|
postGroup.PUT("/p", v1.Post.Update)
|
||||||
postGroup.DELETE("/p", v1.Post.Delete)
|
postGroup.DELETE("/p", v1.Post.Delete)
|
||||||
|
@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func registerUserRoutes(group *route.RouterGroup) {
|
func registerUserRoutes(group *route.RouterGroup) {
|
||||||
userGroup := group.Group("/user").Use(middleware.UseAuth())
|
userGroup := group.Group("/user").Use(middleware.UseAuth(true))
|
||||||
userGroupWithoutAuth := group.Group("/user")
|
userGroupWithoutAuth := group.Group("/user")
|
||||||
userGroupWithoutAuthNeedsCaptcha := userGroupWithoutAuth.Use(middleware.UseCaptcha())
|
userGroupWithoutAuthNeedsCaptcha := userGroupWithoutAuth.Use(middleware.UseCaptcha())
|
||||||
{
|
{
|
||||||
|
@ -24,4 +24,7 @@ const (
|
|||||||
|
|
||||||
OidcUri = "/user/oidc/login" // OIDC登录URI
|
OidcUri = "/user/oidc/login" // OIDC登录URI
|
||||||
DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl
|
DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl
|
||||||
|
|
||||||
|
TargetTypePost = "post"
|
||||||
|
TargetTypeComment = "comment"
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user