diff --git a/internal/controller/comment_action_controller.go b/internal/controller/comment_action_controller.go new file mode 100644 index 0000000..3253699 --- /dev/null +++ b/internal/controller/comment_action_controller.go @@ -0,0 +1,63 @@ +package controller + +import ( + "errors" + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// Delete DELETE /api/comments/:id +func (ctrl *CommentController) Delete(c *gin.Context) { + uid, role, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + commentID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的评论ID") + return + } + + // 权限检查由 service 层统一处理 + isAdmin := model.HasMinRole(role, model.RoleAdmin) + if err := ctrl.commentService.Delete(uint(commentID), uid, isAdmin); err != nil { + if errors.Is(err, common.ErrPermissionDenied) { + common.Error(c, http.StatusForbidden, err.Error()) + return + } + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + + common.OkMessage(c, "删除成功") +} + +// SearchUsers GET /api/users/search?q=keyword +func (ctrl *CommentController) SearchUsers(c *gin.Context) { + _, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + q := c.Query("q") + if q == "" { + common.Ok(c, []model.UserSearchResult{}) + return + } + + users, err := ctrl.commentService.SearchUsers(q) + if err != nil { + common.Error(c, http.StatusInternalServerError, "搜索用户失败") + return + } + + common.Ok(c, users) +} diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go index 5234f09..a33df8b 100644 --- a/internal/controller/comment_controller.go +++ b/internal/controller/comment_controller.go @@ -1,25 +1,5 @@ package controller -import ( - "bytes" - "errors" - "image" - "image/jpeg" - "image/png" - "io" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "metazone.cc/metalab/internal/common" - "metazone.cc/metalab/internal/model" - - "github.com/gin-gonic/gin" -) - // CommentController 评论控制器 type CommentController struct { commentService commentUseCase @@ -29,258 +9,3 @@ type CommentController struct { func NewCommentController(cs commentUseCase) *CommentController { return &CommentController{commentService: cs} } - -// ListRootComments 获取文章顶级评论 GET /api/posts/:id/comments -func (ctrl *CommentController) ListRootComments(c *gin.Context) { - postID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的文章ID") - return - } - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - - comments, total, err := ctrl.commentService.ListRootComments(uint(postID), page, pageSize) - if err != nil { - common.Error(c, http.StatusInternalServerError, "获取评论失败") - return - } - - common.Ok(c, gin.H{ - "items": comments, - "total": total, - "page": page, - "total_pages": common.PageCount(total, pageSize), - }) -} - -// ListReplies 获取顶级评论的全部回复 GET /api/posts/:id/comments/:root_id/replies -func (ctrl *CommentController) ListReplies(c *gin.Context) { - rootID, err := strconv.ParseUint(c.Param("root_id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的评论ID") - return - } - - replies, err := ctrl.commentService.ListReplies(uint(rootID)) - if err != nil { - common.Error(c, http.StatusInternalServerError, "获取回复失败") - return - } - - common.Ok(c, gin.H{"replies": replies}) -} - -// CreateRoot POST /api/posts/:id/comments -func (ctrl *CommentController) CreateRoot(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - postID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的文章ID") - return - } - - var req struct { - Body string `json:"body" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - common.Error(c, http.StatusBadRequest, "请输入评论内容") - return - } - - comment, err := ctrl.commentService.CreateRoot(uid, uint(postID), req.Body) - if err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) - return - } - - common.Ok(c, comment) -} - -// CreateReply POST /api/comments/:id/reply -func (ctrl *CommentController) CreateReply(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - parentID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的评论ID") - return - } - - var req struct { - Body string `json:"body" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - common.Error(c, http.StatusBadRequest, "请输入回复内容") - return - } - - reply, err := ctrl.commentService.CreateReply(uid, uint(parentID), req.Body) - if err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) - return - } - - common.Ok(c, reply) -} - -// Delete DELETE /api/comments/:id -func (ctrl *CommentController) Delete(c *gin.Context) { - uid, role, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - commentID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的评论ID") - return - } - - // 权限检查由 service 层统一处理 - isAdmin := model.HasMinRole(role, model.RoleAdmin) - if err := ctrl.commentService.Delete(uint(commentID), uid, isAdmin); err != nil { - if errors.Is(err, common.ErrPermissionDenied) { - common.Error(c, http.StatusForbidden, err.Error()) - return - } - common.Error(c, http.StatusBadRequest, err.Error()) - return - } - - common.OkMessage(c, "删除成功") -} - -// SearchUsers GET /api/users/search?q=keyword -func (ctrl *CommentController) SearchUsers(c *gin.Context) { - _, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - q := c.Query("q") - if q == "" { - common.Ok(c, []model.UserSearchResult{}) - return - } - - users, err := ctrl.commentService.SearchUsers(q) - if err != nil { - common.Error(c, http.StatusInternalServerError, "搜索用户失败") - return - } - - common.Ok(c, users) -} - -// UploadImage 上传评论图片 POST /api/comments/upload-image -// 需要 LV4+(Exp ≥ 1500),复用帖子图片上传的 WebP 转码逻辑 -func (ctrl *CommentController) UploadImage(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - // LV4+ 检查(Exp ≥ 1500) - expVal, exists := c.Get("exp") - if !exists { - common.Error(c, http.StatusForbidden, "无法获取经验值") - return - } - exp := expVal.(int) - if exp < 1500 { - common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片") - return - } - - file, header, err := c.Request.FormFile("file") - if err != nil { - common.Error(c, http.StatusBadRequest, "请选择文件") - return - } - defer file.Close() - - // 检查扩展名 - ext := strings.ToLower(filepath.Ext(header.Filename)) - allowedExt := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true} - if !allowedExt[ext] { - common.Error(c, http.StatusBadRequest, "仅支持 jpg/jpeg/png/gif/webp 格式") - return - } - - // 限制 5MB - if header.Size > 5<<20 { - common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB") - return - } - - data, err := io.ReadAll(file) - if err != nil { - common.Error(c, http.StatusInternalServerError, "读取文件失败") - return - } - - storageDir := "storage/uploads/posts" - if err := os.MkdirAll(storageDir, 0755); err != nil { - common.Error(c, http.StatusInternalServerError, "存储初始化失败") - return - } - - ts := time.Now().UnixMilli() - isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png" - - var savePath, url string - - // JPEG/PNG 转 WebP - if isJPEGPNG { - img, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - common.Error(c, http.StatusBadRequest, "图片格式无效") - return - } - webpData := encodeWebPBinary(img, len(data)) - if webpData != nil && len(webpData) < len(data) { - filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp" - savePath = filepath.Join(storageDir, filename) - os.WriteFile(savePath, webpData, 0644) - url = "/uploads/posts/" + filename - } - } - - // 回退存储 - if savePath == "" { - dataOut := data - if isJPEGPNG { - decImg, _, decErr := image.Decode(bytes.NewReader(data)) - if decErr == nil { - var buf bytes.Buffer - if ext == ".png" { - png.Encode(&buf, decImg) - } else { - jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92}) - } - dataOut = buf.Bytes() - } - } - filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext - savePath = filepath.Join(storageDir, filename) - os.WriteFile(savePath, dataOut, 0644) - url = "/uploads/posts/" + filename - } - - // 返回标准 JSON(前端将 URL 插入评论文本) - common.Ok(c, gin.H{"url": url}) -} diff --git a/internal/controller/comment_list_controller.go b/internal/controller/comment_list_controller.go new file mode 100644 index 0000000..ac51ab0 --- /dev/null +++ b/internal/controller/comment_list_controller.go @@ -0,0 +1,52 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// ListRootComments 获取文章顶级评论 GET /api/posts/:id/comments +func (ctrl *CommentController) ListRootComments(c *gin.Context) { + postID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的文章ID") + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + comments, total, err := ctrl.commentService.ListRootComments(uint(postID), page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取评论失败") + return + } + + common.Ok(c, gin.H{ + "items": comments, + "total": total, + "page": page, + "total_pages": common.PageCount(total, pageSize), + }) +} + +// ListReplies 获取顶级评论的全部回复 GET /api/posts/:id/comments/:root_id/replies +func (ctrl *CommentController) ListReplies(c *gin.Context) { + rootID, err := strconv.ParseUint(c.Param("root_id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的评论ID") + return + } + + replies, err := ctrl.commentService.ListReplies(uint(rootID)) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取回复失败") + return + } + + common.Ok(c, gin.H{"replies": replies}) +} diff --git a/internal/controller/comment_upload_controller.go b/internal/controller/comment_upload_controller.go new file mode 100644 index 0000000..251f394 --- /dev/null +++ b/internal/controller/comment_upload_controller.go @@ -0,0 +1,119 @@ +package controller + +import ( + "bytes" + "image" + "image/jpeg" + "image/png" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// UploadImage 上传评论图片 POST /api/comments/upload-image +// 需要 LV4+(Exp ≥ 1500),复用帖子图片上传的 WebP 转码逻辑 +func (ctrl *CommentController) UploadImage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + // LV4+ 检查(Exp ≥ 1500) + expVal, exists := c.Get("exp") + if !exists { + common.Error(c, http.StatusForbidden, "无法获取经验值") + return + } + exp := expVal.(int) + if exp < 1500 { + common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片") + return + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + common.Error(c, http.StatusBadRequest, "请选择文件") + return + } + defer file.Close() + + // 检查扩展名 + ext := strings.ToLower(filepath.Ext(header.Filename)) + allowedExt := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true} + if !allowedExt[ext] { + common.Error(c, http.StatusBadRequest, "仅支持 jpg/jpeg/png/gif/webp 格式") + return + } + + // 限制 5MB + if header.Size > 5<<20 { + common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB") + return + } + + data, err := io.ReadAll(file) + if err != nil { + common.Error(c, http.StatusInternalServerError, "读取文件失败") + return + } + + storageDir := "storage/uploads/posts" + if err := os.MkdirAll(storageDir, 0755); err != nil { + common.Error(c, http.StatusInternalServerError, "存储初始化失败") + return + } + + ts := time.Now().UnixMilli() + isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png" + + var savePath, url string + + // JPEG/PNG 转 WebP + if isJPEGPNG { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + common.Error(c, http.StatusBadRequest, "图片格式无效") + return + } + webpData := encodeWebPBinary(img, len(data)) + if webpData != nil && len(webpData) < len(data) { + filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp" + savePath = filepath.Join(storageDir, filename) + os.WriteFile(savePath, webpData, 0644) + url = "/uploads/posts/" + filename + } + } + + // 回退存储 + if savePath == "" { + dataOut := data + if isJPEGPNG { + decImg, _, decErr := image.Decode(bytes.NewReader(data)) + if decErr == nil { + var buf bytes.Buffer + if ext == ".png" { + png.Encode(&buf, decImg) + } else { + jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92}) + } + dataOut = buf.Bytes() + } + } + filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext + savePath = filepath.Join(storageDir, filename) + os.WriteFile(savePath, dataOut, 0644) + url = "/uploads/posts/" + filename + } + + // 返回标准 JSON(前端将 URL 插入评论文本) + common.Ok(c, gin.H{"url": url}) +} diff --git a/internal/controller/comment_write_controller.go b/internal/controller/comment_write_controller.go new file mode 100644 index 0000000..74121db --- /dev/null +++ b/internal/controller/comment_write_controller.go @@ -0,0 +1,72 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// CreateRoot POST /api/posts/:id/comments +func (ctrl *CommentController) CreateRoot(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + postID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的文章ID") + return + } + + var req struct { + Body string `json:"body" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "请输入评论内容") + return + } + + comment, err := ctrl.commentService.CreateRoot(uid, uint(postID), req.Body) + if err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + + common.Ok(c, comment) +} + +// CreateReply POST /api/comments/:id/reply +func (ctrl *CommentController) CreateReply(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + parentID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的评论ID") + return + } + + var req struct { + Body string `json:"body" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "请输入回复内容") + return + } + + reply, err := ctrl.commentService.CreateReply(uid, uint(parentID), req.Body) + if err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + + common.Ok(c, reply) +} diff --git a/internal/controller/post_api_controller.go b/internal/controller/post_api_controller.go new file mode 100644 index 0000000..96dfaf0 --- /dev/null +++ b/internal/controller/post_api_controller.go @@ -0,0 +1,109 @@ +package controller + +import ( + "errors" + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// Submit API 提交审核 +func (ctrl *PostController) Submit(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + post, err := ctrl.postService.GetByID(uint(id)) + if err != nil { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + + if post.UserID != uid { + common.Error(c, http.StatusForbidden, "无权操作此帖子") + return + } + + if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil { + if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "提交审核失败") + } + return + } + + common.OkMessage(c, "已提交审核") +} + +// ListAPI 帖子列表 JSON API +func (ctrl *PostController) ListAPI(c *gin.Context) { + keyword := c.Query("keyword") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + posts, total, err := ctrl.postService.List(keyword, page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取帖子列表失败") + return + } + + common.Ok(c, model.PostListResult{ + Items: posts, + Total: total, + Page: page, + TotalPages: common.PageCount(total, pageSize), + }) +} + +// ShowAPI 帖子详情 JSON API +func (ctrl *PostController) ShowAPI(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + post, err := ctrl.postService.GetByID(uint(id)) + if err != nil { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + + uid, role, _ := common.GetGinUser(c) + + // 权限控制:非 approved 帖子仅作者和 moderator+ 可见 + if post.Status != model.PostStatusApproved { + if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + } + + // 已登录用户阅读计数(已发布帖子,自动去重+防刷) + if uid > 0 && post.Status == model.PostStatusApproved { + ctrl.postService.RecordRead(uid, post.ID) + } + + // 访客阅读计数(基于 Cookie 标识去重+防刷) + if uid == 0 && post.Status == model.PostStatusApproved { + vid := ctrl.getVisitorID(c) + ctrl.postService.RecordGuestRead(vid, post.ID) + } + + ctrl.applyShortcode(post) + + common.Ok(c, post) +} diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go index e93a44e..2941c75 100644 --- a/internal/controller/post_controller.go +++ b/internal/controller/post_controller.go @@ -4,21 +4,8 @@ import ( "bytes" "crypto/rand" "encoding/hex" - "errors" - "fmt" "image" - "image/jpeg" - "image/png" - _ "image/gif" // 仅注册 GIF 解码器,不重编码(会丢失动画) - "io" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" - "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/service" @@ -37,337 +24,11 @@ func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostContr return &PostController{postService: ps, shortcodeSvc: scs} } -// ListPage 帖子列表页 -func (ctrl *PostController) ListPage(c *gin.Context) { - keyword := c.Query("keyword") - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - - posts, total, err := ctrl.postService.List(keyword, page, pageSize) - if err != nil { - c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{ - "Title": "社区帖子", - "Error": "加载帖子列表失败", - })) - return - } - - totalPages := common.PageCount(total, pageSize) - prevPage := page - 1 - if prevPage < 1 { - prevPage = 1 - } - nextPage := page + 1 - if nextPage > totalPages { - nextPage = totalPages - } - - c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{ - "Title": "社区帖子", - "Posts": posts, - "Total": total, - "Page": page, - "TotalPages": totalPages, - "PrevPage": prevPage, - "NextPage": nextPage, - "Keyword": keyword, - "ExtraCSS": "/static/css/posts.css", - })) -} - -// ShowPage 帖子详情页 -func (ctrl *PostController) ShowPage(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ - "Title": "未找到", - "ExtraCSS": "/static/css/posts.css", - })) - return - } - - post, err := ctrl.postService.GetByID(uint(id)) - if err != nil { - c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ - "Title": "未找到", - "ExtraCSS": "/static/css/posts.css", - })) - return - } - - uid, role, _ := common.GetGinUser(c) - - // 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态) - if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { - // 未登录用户:引导登录后回到当前帖子 - if uid == 0 { - common.RedirectToLogin(c) - return - } - c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ - "Title": "未找到", - "ExtraCSS": "/static/css/posts.css", - })) - return - } - - // 已登录用户阅读计数(已发布帖子,自动去重+防刷) - if uid > 0 && post.Status == model.PostStatusApproved { - ctrl.postService.RecordRead(uid, post.ID) - } - - // 访客阅读计数(基于 Cookie 标识去重+防刷) - if uid == 0 && post.Status == model.PostStatusApproved { - vid := ctrl.getVisitorID(c) - ctrl.postService.RecordGuestRead(vid, post.ID) - } - - ctrl.applyShortcode(post) - - // Open Graph 社交分享数据 - ogImage := common.ExtractFirstImage(post.Body) - if ogImage != "" && !strings.HasPrefix(ogImage, "http") { - prefix := "" - if !strings.HasPrefix(ogImage, "/") { - prefix = "/" - } - ogImage = "https://" + c.Request.Host + prefix + ogImage - } - ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id) - - c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{ - "Title": post.Title, - "Post": post, - "UID": uid, - "StatusNames": common.PostStatusDisplayNames, - "ExtraCSS": "/static/css/posts.css", - "OgTitle": post.Title, - "OgDescription": post.Excerpt, - "OgImage": ogImage, - "OgURL": ogURL, - })) -} - - -// Submit API 提交审核 -func (ctrl *PostController) Submit(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - post, err := ctrl.postService.GetByID(uint(id)) - if err != nil { - common.Error(c, http.StatusNotFound, "帖子不存在") - return - } - - if post.UserID != uid { - common.Error(c, http.StatusForbidden, "无权操作此帖子") - return - } - - if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil { - if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) { - common.Error(c, http.StatusBadRequest, err.Error()) - } else { - common.Error(c, http.StatusInternalServerError, "提交审核失败") - } - return - } - - common.OkMessage(c, "已提交审核") -} - -// ListAPI 帖子列表 JSON API -func (ctrl *PostController) ListAPI(c *gin.Context) { - keyword := c.Query("keyword") - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - - posts, total, err := ctrl.postService.List(keyword, page, pageSize) - if err != nil { - common.Error(c, http.StatusInternalServerError, "获取帖子列表失败") - return - } - - common.Ok(c, model.PostListResult{ - Items: posts, - Total: total, - Page: page, - TotalPages: common.PageCount(total, pageSize), - }) -} - -// ShowAPI 帖子详情 JSON API -func (ctrl *PostController) ShowAPI(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - post, err := ctrl.postService.GetByID(uint(id)) - if err != nil { - common.Error(c, http.StatusNotFound, "帖子不存在") - return - } - - uid, role, _ := common.GetGinUser(c) - - // 权限控制:非 approved 帖子仅作者和 moderator+ 可见 - if post.Status != model.PostStatusApproved { - if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { - common.Error(c, http.StatusNotFound, "帖子不存在") - return - } - } - - // 已登录用户阅读计数(已发布帖子,自动去重+防刷) - if uid > 0 && post.Status == model.PostStatusApproved { - ctrl.postService.RecordRead(uid, post.ID) - } - - // 访客阅读计数(基于 Cookie 标识去重+防刷) - if uid == 0 && post.Status == model.PostStatusApproved { - vid := ctrl.getVisitorID(c) - ctrl.postService.RecordGuestRead(vid, post.ID) - } - - ctrl.applyShortcode(post) - - common.Ok(c, post) -} - // allowedImageExt 允许的图片扩展名 var allowedImageExt = map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, } -// UploadImage 上传帖子图片(需登录,multipart/form-data) -// JPEG/PNG 自动转 WebP quality 80 存储,保留原尺寸不 resize -func (ctrl *PostController) UploadImage(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - file, header, err := c.Request.FormFile("file") - if err != nil { - common.Error(c, http.StatusBadRequest, "请选择文件") - return - } - defer file.Close() - - // 检查扩展名 - ext := strings.ToLower(filepath.Ext(header.Filename)) - if !allowedImageExt[ext] { - common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式") - return - } - - // 限制 5MB - maxSize := int64(5 << 20) - if header.Size > maxSize { - common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB") - return - } - - // 读取全部数据(后续 WebP 转换需要) - data, err := io.ReadAll(file) - if err != nil { - common.Error(c, http.StatusInternalServerError, "读取文件失败") - return - } - - // 存储路径 - storageDir := "storage/uploads/posts" - if err := os.MkdirAll(storageDir, 0755); err != nil { - common.Error(c, http.StatusInternalServerError, "存储初始化失败") - return - } - - ts := time.Now().UnixMilli() - var savePath, url string - - isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png" - isWebP := ext == ".webp" - isGIF := ext == ".gif" - - // 强制解码验证文件是否为有效图片(防伪扩展名、图片投毒),验证失败直接拒绝 - // JPEG/PNG:解码后做 WebP 压缩(二分查找质量,目标 ≤ 原文件大小) - if isJPEGPNG { - img, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件") - return - } - webpData := encodeWebPBinary(img, len(data)) - if webpData != nil && len(webpData) < len(data) { - filename := fmt.Sprintf("%d_%d.webp", uid, ts) - savePath = filepath.Join(storageDir, filename) - if err := os.WriteFile(savePath, webpData, 0644); err == nil { - url = "/uploads/posts/" + filename - } - } - } - - // GIF:仅解码验证,不转换 - if isGIF { - if _, _, err := image.Decode(bytes.NewReader(data)); err != nil { - common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件") - return - } - } - - // WebP:仅解码验证,不重复编码 - if isWebP { - if _, err := webp.Decode(bytes.NewReader(data)); err != nil { - common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件") - return - } - } - - // 回退存储:JPEG/PNG 重编码剥离元数据;WebP 已验证直接存;GIF 不重编(丢动画) - if savePath == "" { - dataOut := data - - if isJPEGPNG { - decImg, _, decErr := image.Decode(bytes.NewReader(data)) - if decErr == nil { - var buf bytes.Buffer - var encErr error - if ext == ".png" { - encErr = png.Encode(&buf, decImg) - } else { - encErr = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92}) - } - if encErr == nil && buf.Len() > 0 { - dataOut = buf.Bytes() - } - } - } - - filename := fmt.Sprintf("%d_%d%s", uid, ts, ext) - savePath = filepath.Join(storageDir, filename) - if err := os.WriteFile(savePath, dataOut, 0644); err != nil { - common.Error(c, http.StatusInternalServerError, "保存图片失败") - return - } - url = "/uploads/posts/" + filename - } - - common.VditorUploadOk(c, map[string]string{header.Filename: url}) -} - // visitorCookieName Cookie 名称 const visitorCookieName = "visitor_id" @@ -426,4 +87,3 @@ func encodeWebPBinary(img image.Image, targetBytes int) []byte { } return best } - diff --git a/internal/controller/post_page_controller.go b/internal/controller/post_page_controller.go new file mode 100644 index 0000000..f4e455b --- /dev/null +++ b/internal/controller/post_page_controller.go @@ -0,0 +1,124 @@ +package controller + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// ListPage 帖子列表页 +func (ctrl *PostController) ListPage(c *gin.Context) { + keyword := c.Query("keyword") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + posts, total, err := ctrl.postService.List(keyword, page, pageSize) + if err != nil { + c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{ + "Title": "社区帖子", + "Error": "加载帖子列表失败", + })) + return + } + + totalPages := common.PageCount(total, pageSize) + prevPage := page - 1 + if prevPage < 1 { + prevPage = 1 + } + nextPage := page + 1 + if nextPage > totalPages { + nextPage = totalPages + } + + c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{ + "Title": "社区帖子", + "Posts": posts, + "Total": total, + "Page": page, + "TotalPages": totalPages, + "PrevPage": prevPage, + "NextPage": nextPage, + "Keyword": keyword, + "ExtraCSS": "/static/css/posts.css", + })) +} + +// ShowPage 帖子详情页 +func (ctrl *PostController) ShowPage(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ + "Title": "未找到", + "ExtraCSS": "/static/css/posts.css", + })) + return + } + + post, err := ctrl.postService.GetByID(uint(id)) + if err != nil { + c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ + "Title": "未找到", + "ExtraCSS": "/static/css/posts.css", + })) + return + } + + uid, role, _ := common.GetGinUser(c) + + // 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态) + if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { + // 未登录用户:引导登录后回到当前帖子 + if uid == 0 { + common.RedirectToLogin(c) + return + } + c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ + "Title": "未找到", + "ExtraCSS": "/static/css/posts.css", + })) + return + } + + // 已登录用户阅读计数(已发布帖子,自动去重+防刷) + if uid > 0 && post.Status == model.PostStatusApproved { + ctrl.postService.RecordRead(uid, post.ID) + } + + // 访客阅读计数(基于 Cookie 标识去重+防刷) + if uid == 0 && post.Status == model.PostStatusApproved { + vid := ctrl.getVisitorID(c) + ctrl.postService.RecordGuestRead(vid, post.ID) + } + + ctrl.applyShortcode(post) + + // Open Graph 社交分享数据 + ogImage := common.ExtractFirstImage(post.Body) + if ogImage != "" && !strings.HasPrefix(ogImage, "http") { + prefix := "" + if !strings.HasPrefix(ogImage, "/") { + prefix = "/" + } + ogImage = "https://" + c.Request.Host + prefix + ogImage + } + ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id) + + c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{ + "Title": post.Title, + "Post": post, + "UID": uid, + "StatusNames": common.PostStatusDisplayNames, + "ExtraCSS": "/static/css/posts.css", + "OgTitle": post.Title, + "OgDescription": post.Excerpt, + "OgImage": ogImage, + "OgURL": ogURL, + })) +} diff --git a/internal/controller/post_upload_controller.go b/internal/controller/post_upload_controller.go new file mode 100644 index 0000000..14bd68c --- /dev/null +++ b/internal/controller/post_upload_controller.go @@ -0,0 +1,138 @@ +package controller + +import ( + "bytes" + "fmt" + "image" + _ "image/gif" // 仅注册 GIF 解码器,不重编码(会丢失动画) + "image/jpeg" + "image/png" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "metazone.cc/metalab/internal/common" + + "github.com/chai2010/webp" + "github.com/gin-gonic/gin" +) + +// UploadImage 上传帖子图片(需登录,multipart/form-data) +// JPEG/PNG 自动转 WebP quality 80 存储,保留原尺寸不 resize +func (ctrl *PostController) UploadImage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + common.Error(c, http.StatusBadRequest, "请选择文件") + return + } + defer file.Close() + + // 检查扩展名 + ext := strings.ToLower(filepath.Ext(header.Filename)) + if !allowedImageExt[ext] { + common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式") + return + } + + // 限制 5MB + maxSize := int64(5 << 20) + if header.Size > maxSize { + common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB") + return + } + + // 读取全部数据(后续 WebP 转换需要) + data, err := io.ReadAll(file) + if err != nil { + common.Error(c, http.StatusInternalServerError, "读取文件失败") + return + } + + // 存储路径 + storageDir := "storage/uploads/posts" + if err := os.MkdirAll(storageDir, 0755); err != nil { + common.Error(c, http.StatusInternalServerError, "存储初始化失败") + return + } + + ts := time.Now().UnixMilli() + var savePath, url string + + isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png" + isWebP := ext == ".webp" + isGIF := ext == ".gif" + + // 强制解码验证文件是否为有效图片(防伪扩展名、图片投毒),验证失败直接拒绝 + // JPEG/PNG:解码后做 WebP 压缩(二分查找质量,目标 ≤ 原文件大小) + if isJPEGPNG { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件") + return + } + webpData := encodeWebPBinary(img, len(data)) + if webpData != nil && len(webpData) < len(data) { + filename := fmt.Sprintf("%d_%d.webp", uid, ts) + savePath = filepath.Join(storageDir, filename) + if err := os.WriteFile(savePath, webpData, 0644); err == nil { + url = "/uploads/posts/" + filename + } + } + } + + // GIF:仅解码验证,不转换 + if isGIF { + if _, _, err := image.Decode(bytes.NewReader(data)); err != nil { + common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件") + return + } + } + + // WebP:仅解码验证,不重复编码 + if isWebP { + if _, err := webp.Decode(bytes.NewReader(data)); err != nil { + common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件") + return + } + } + + // 回退存储:JPEG/PNG 重编码剥离元数据;WebP 已验证直接存;GIF 不重编(丢动画) + if savePath == "" { + dataOut := data + + if isJPEGPNG { + decImg, _, decErr := image.Decode(bytes.NewReader(data)) + if decErr == nil { + var buf bytes.Buffer + var encErr error + if ext == ".png" { + encErr = png.Encode(&buf, decImg) + } else { + encErr = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92}) + } + if encErr == nil && buf.Len() > 0 { + dataOut = buf.Bytes() + } + } + } + + filename := fmt.Sprintf("%d_%d%s", uid, ts, ext) + savePath = filepath.Join(storageDir, filename) + if err := os.WriteFile(savePath, dataOut, 0644); err != nil { + common.Error(c, http.StatusInternalServerError, "保存图片失败") + return + } + url = "/uploads/posts/" + filename + } + + common.VditorUploadOk(c, map[string]string{header.Filename: url}) +} diff --git a/internal/controller/studio_action_controller.go b/internal/controller/studio_action_controller.go new file mode 100644 index 0000000..415aa69 --- /dev/null +++ b/internal/controller/studio_action_controller.go @@ -0,0 +1,133 @@ +package controller + +import ( + "log" + "net/http" + "strconv" + "time" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// Delete 删除帖子 +func (ctrl *StudioController) Delete(c *gin.Context) { + uid, role, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + post, getErr := ctrl.postService.GetByID(uint(id)) + if getErr != nil { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { + common.Error(c, http.StatusForbidden, "无权操作此帖子") + return + } + + authorID := post.UserID + + if err := ctrl.postService.Delete(uint(id)); err != nil { + common.Error(c, http.StatusInternalServerError, "删除失败") + return + } + + // 删稿扣作者域能(无视负数,有限重试后静默忽略) + if ctrl.energySvc != nil { + const maxRetries = 3 + backoff := 100 * time.Millisecond + for i := 0; i < maxRetries; i++ { + if err := ctrl.energySvc.DeductOnDeletePost(authorID, uint(id)); err == nil { + break + } + if i < maxRetries-1 { + time.Sleep(backoff) + backoff *= 2 + } else { + log.Printf("删稿扣域能失败(user=%d, post=%d): %v", authorID, id, err) + } + } + } + + common.OkMessage(c, "删除成功") +} + +// Submit 提交审核 +func (ctrl *StudioController) Submit(c *gin.Context) { + uid, role, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + post, getErr := ctrl.postService.GetByID(uint(id)) + if getErr != nil { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { + common.Error(c, http.StatusForbidden, "无权操作此帖子") + return + } + + if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + + common.OkMessage(c, "已提交审核") +} + +// TrendsAPI 创作中心趋势数据 API GET /api/studio/trends?period=7d|30d|90d +func (ctrl *StudioController) TrendsAPI(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + if ctrl.trendsSvc == nil { + common.Error(c, http.StatusInternalServerError, "趋势服务不可用") + return + } + + period := c.DefaultQuery("period", "7d") + var days int + switch period { + case "7d": + days = 7 + case "30d": + days = 30 + case "90d": + days = 90 + default: + common.Error(c, http.StatusBadRequest, "无效的时间范围,支持 7d/30d/90d") + return + } + + since := time.Now().AddDate(0, 0, -days).Format("2006-01-02") + + result, err := ctrl.trendsSvc.GetTrends(uid, since) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取趋势数据失败") + return + } + + common.Ok(c, result) +} diff --git a/internal/controller/studio_api_controller.go b/internal/controller/studio_api_controller.go new file mode 100644 index 0000000..ee89320 --- /dev/null +++ b/internal/controller/studio_api_controller.go @@ -0,0 +1,54 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// OverviewAPI 创作中心数据概览 +func (ctrl *StudioController) OverviewAPI(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + overview, err := ctrl.postService.GetOverview(uid) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取数据失败") + return + } + + common.Ok(c, overview) +} + +// ListPostsAPI 我的帖子列表(支持状态筛选) +func (ctrl *StudioController) ListPostsAPI(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + status := c.Query("status") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取列表失败") + return + } + + common.Ok(c, gin.H{ + "items": posts, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": common.PageCount(total, pageSize), + }) +} diff --git a/internal/controller/studio_controller.go b/internal/controller/studio_controller.go index d30389b..a8645e7 100644 --- a/internal/controller/studio_controller.go +++ b/internal/controller/studio_controller.go @@ -1,16 +1,7 @@ package controller import ( - "log" - "net/http" - "strconv" - "time" - - "metazone.cc/metalab/internal/common" - "metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/service" - - "github.com/gin-gonic/gin" ) // trendsUseCase StudioController 对趋势服务的最小依赖(ISP) @@ -20,9 +11,9 @@ type trendsUseCase interface { // StudioController 创作中心控制器(SSR 页面 + API) type StudioController struct { - postService studioUseCase - energySvc energyDeducter - trendsSvc trendsUseCase + postService studioUseCase + energySvc energyDeducter + trendsSvc trendsUseCase } // NewStudioController 构造函数 @@ -41,415 +32,3 @@ func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioContro ctrl.trendsSvc = svc return ctrl } - -// ============================== -// SSR 页面方法 -// ============================== - -// OverviewPage 创作中心首页(数据概览) -func (ctrl *StudioController) OverviewPage(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.RedirectToLogin(c) - return - } - - overview, err := ctrl.postService.GetOverview(uid) - if err != nil { - c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{ - "Title": "创作中心", - "Error": "加载数据失败", - "ActiveTab": "overview", - "ExtraCSS": "/static/css/studio.css", - })) - return - } - - c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{ - "Title": "创作中心", - "Overview": overview, - "StatusNames": common.PostStatusDisplayNames, - "ActiveTab": "overview", - "ExtraCSS": "/static/css/studio.css", - })) -} - -// PostsPage 内容管理页 -func (ctrl *StudioController) PostsPage(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.RedirectToLogin(c) - return - } - - status := c.Query("status") - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - - posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize) - if err != nil { - c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{ - "Title": "内容管理 - 创作中心", - "Error": "加载失败", - "ActiveTab": "posts", - "ExtraCSS": "/static/css/studio.css", - })) - return - } - - overview, _ := ctrl.postService.GetOverview(uid) - - c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{ - "Title": "内容管理 - 创作中心", - "Posts": posts, - "Total": total, - "Page": page, - "PageSize": pageSize, - "TotalPages": common.PageCount(total, pageSize), - "CurrentStatus": status, - "Overview": overview, - "StatusNames": common.PostStatusDisplayNames, - "ActiveTab": "posts", - "ExtraCSS": "/static/css/studio.css", - })) -} - -// DraftsPage 草稿箱页面 -func (ctrl *StudioController) DraftsPage(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.RedirectToLogin(c) - return - } - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - - posts, total, err := ctrl.postService.ListByUser(uid, model.PostStatusDraft, page, pageSize) - if err != nil { - c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{ - "Title": "草稿箱 - 创作中心", - "Error": "加载失败", - "ActiveTab": "drafts", - "ExtraCSS": "/static/css/studio.css", - })) - return - } - - c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{ - "Title": "草稿箱 - 创作中心", - "Posts": posts, - "Total": total, - "Page": page, - "PageSize": pageSize, - "TotalPages": common.PageCount(total, pageSize), - "StatusNames": common.PostStatusDisplayNames, - "ActiveTab": "drafts", - "ExtraCSS": "/static/css/studio.css", - })) -} - -// WritePage 写文章页面 -func (ctrl *StudioController) WritePage(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.RedirectToLogin(c) - return - } - - // 支持编辑模式:传入 post_id 参数 - postIDStr := c.Query("id") - if postIDStr != "" { - id, err := strconv.ParseUint(postIDStr, 10, 64) - if err == nil { - post, err := ctrl.postService.GetByID(uint(id)) - if err == nil { - _, role, _ := common.GetGinUser(c) - if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { - c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ - "Title": "未找到", - })) - return - } - c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{ - "Title": "编辑文章 - 创作中心", - "Post": post, - "ActiveTab": "write", - "ExtraCSS": "/static/css/studio.css", - })) - return - } - } - } - - c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{ - "Title": "写文章 - 创作中心", - "ActiveTab": "write", - "ExtraCSS": "/static/css/studio.css", - })) -} - -// AnalyticsPage 数据分析页面 -func (ctrl *StudioController) AnalyticsPage(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.RedirectToLogin(c) - return - } - - overview, err := ctrl.postService.GetOverview(uid) - if err != nil { - c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{ - "Title": "数据分析 - 创作中心", - "Error": "加载失败", - "ActiveTab": "analytics", - "ExtraCSS": "/static/css/studio.css", - })) - return - } - - c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{ - "Title": "数据分析 - 创作中心", - "Overview": overview, - "ActiveTab": "analytics", - "ExtraCSS": "/static/css/studio.css", - })) -} - -// ============================== -// API 方法 -// ============================== - -// OverviewAPI 创作中心数据概览 -func (ctrl *StudioController) OverviewAPI(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - overview, err := ctrl.postService.GetOverview(uid) - if err != nil { - common.Error(c, http.StatusInternalServerError, "获取数据失败") - return - } - - common.Ok(c, overview) -} - -// ListPostsAPI 我的帖子列表(支持状态筛选) -func (ctrl *StudioController) ListPostsAPI(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - status := c.Query("status") - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - - posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize) - if err != nil { - common.Error(c, http.StatusInternalServerError, "获取列表失败") - return - } - - common.Ok(c, gin.H{ - "items": posts, - "total": total, - "page": page, - "page_size": pageSize, - "total_pages": common.PageCount(total, pageSize), - }) -} - -// Create 创建/发布帖子 -func (ctrl *StudioController) Create(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - // LV0 用户不允许投稿 - if expVal, exists := c.Get("exp"); exists { - if exp, ok := expVal.(int); ok && exp < 1 { - common.Error(c, http.StatusForbidden, "经验不足,Lv1 解锁投稿") - return - } - } - - var req model.PostCreateRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.Error(c, http.StatusBadRequest, "标题和正文不能为空") - return - } - - post, err := ctrl.postService.Create(uid, req.Title, req.Body) - if err != nil { - common.Error(c, http.StatusInternalServerError, "发布失败") - return - } - - common.OkWithMessage(c, post, "发布成功") -} - -// Update 编辑帖子 -func (ctrl *StudioController) Update(c *gin.Context) { - uid, role, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - var req model.PostUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.Error(c, http.StatusBadRequest, "标题和正文不能为空") - return - } - - post, getErr := ctrl.postService.GetByID(uint(id)) - if getErr != nil { - common.Error(c, http.StatusNotFound, "帖子不存在") - return - } - if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { - common.Error(c, http.StatusForbidden, "无权操作此帖子") - return - } - - if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil { - common.Error(c, http.StatusInternalServerError, "编辑失败") - return - } - - common.OkMessage(c, "编辑成功") -} - -// Delete 删除帖子 -func (ctrl *StudioController) Delete(c *gin.Context) { - uid, role, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - post, getErr := ctrl.postService.GetByID(uint(id)) - if getErr != nil { - common.Error(c, http.StatusNotFound, "帖子不存在") - return - } - if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { - common.Error(c, http.StatusForbidden, "无权操作此帖子") - return - } - - authorID := post.UserID - - if err := ctrl.postService.Delete(uint(id)); err != nil { - common.Error(c, http.StatusInternalServerError, "删除失败") - return - } - - // 删稿扣作者域能(无视负数,有限重试后静默忽略) - if ctrl.energySvc != nil { - const maxRetries = 3 - backoff := 100 * time.Millisecond - for i := 0; i < maxRetries; i++ { - if err := ctrl.energySvc.DeductOnDeletePost(authorID, uint(id)); err == nil { - break - } - if i < maxRetries-1 { - time.Sleep(backoff) - backoff *= 2 - } else { - log.Printf("删稿扣域能失败(user=%d, post=%d): %v", authorID, id, err) - } - } - } - - common.OkMessage(c, "删除成功") -} - -// Submit 提交审核 -func (ctrl *StudioController) Submit(c *gin.Context) { - uid, role, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - post, getErr := ctrl.postService.GetByID(uint(id)) - if getErr != nil { - common.Error(c, http.StatusNotFound, "帖子不存在") - return - } - if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { - common.Error(c, http.StatusForbidden, "无权操作此帖子") - return - } - - if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) - return - } - - common.OkMessage(c, "已提交审核") -} - -// TrendsAPI 创作中心趋势数据 API GET /api/studio/trends?period=7d|30d|90d -func (ctrl *StudioController) TrendsAPI(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - if ctrl.trendsSvc == nil { - common.Error(c, http.StatusInternalServerError, "趋势服务不可用") - return - } - - period := c.DefaultQuery("period", "7d") - var days int - switch period { - case "7d": - days = 7 - case "30d": - days = 30 - case "90d": - days = 90 - default: - common.Error(c, http.StatusBadRequest, "无效的时间范围,支持 7d/30d/90d") - return - } - - since := time.Now().AddDate(0, 0, -days).Format("2006-01-02") - - result, err := ctrl.trendsSvc.GetTrends(uid, since) - if err != nil { - common.Error(c, http.StatusInternalServerError, "获取趋势数据失败") - return - } - - common.Ok(c, result) -} diff --git a/internal/controller/studio_page_controller.go b/internal/controller/studio_page_controller.go new file mode 100644 index 0000000..9544a2e --- /dev/null +++ b/internal/controller/studio_page_controller.go @@ -0,0 +1,114 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// OverviewPage 创作中心首页(数据概览) +func (ctrl *StudioController) OverviewPage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.RedirectToLogin(c) + return + } + + overview, err := ctrl.postService.GetOverview(uid) + if err != nil { + c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{ + "Title": "创作中心", + "Error": "加载数据失败", + "ActiveTab": "overview", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + + c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{ + "Title": "创作中心", + "Overview": overview, + "StatusNames": common.PostStatusDisplayNames, + "ActiveTab": "overview", + "ExtraCSS": "/static/css/studio.css", + })) +} + +// PostsPage 内容管理页 +func (ctrl *StudioController) PostsPage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.RedirectToLogin(c) + return + } + + status := c.Query("status") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize) + if err != nil { + c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{ + "Title": "内容管理 - 创作中心", + "Error": "加载失败", + "ActiveTab": "posts", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + + overview, _ := ctrl.postService.GetOverview(uid) + + c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{ + "Title": "内容管理 - 创作中心", + "Posts": posts, + "Total": total, + "Page": page, + "PageSize": pageSize, + "TotalPages": common.PageCount(total, pageSize), + "CurrentStatus": status, + "Overview": overview, + "StatusNames": common.PostStatusDisplayNames, + "ActiveTab": "posts", + "ExtraCSS": "/static/css/studio.css", + })) +} + +// DraftsPage 草稿箱页面 +func (ctrl *StudioController) DraftsPage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.RedirectToLogin(c) + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + posts, total, err := ctrl.postService.ListByUser(uid, model.PostStatusDraft, page, pageSize) + if err != nil { + c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{ + "Title": "草稿箱 - 创作中心", + "Error": "加载失败", + "ActiveTab": "drafts", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + + c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{ + "Title": "草稿箱 - 创作中心", + "Posts": posts, + "Total": total, + "Page": page, + "PageSize": pageSize, + "TotalPages": common.PageCount(total, pageSize), + "StatusNames": common.PostStatusDisplayNames, + "ActiveTab": "drafts", + "ExtraCSS": "/static/css/studio.css", + })) +} diff --git a/internal/controller/studio_post_api_controller.go b/internal/controller/studio_post_api_controller.go new file mode 100644 index 0000000..a818232 --- /dev/null +++ b/internal/controller/studio_post_api_controller.go @@ -0,0 +1,80 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// Create 创建/发布帖子 +func (ctrl *StudioController) Create(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + // LV0 用户不允许投稿 + if expVal, exists := c.Get("exp"); exists { + if exp, ok := expVal.(int); ok && exp < 1 { + common.Error(c, http.StatusForbidden, "经验不足,Lv1 解锁投稿") + return + } + } + + var req model.PostCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "标题和正文不能为空") + return + } + + post, err := ctrl.postService.Create(uid, req.Title, req.Body) + if err != nil { + common.Error(c, http.StatusInternalServerError, "发布失败") + return + } + + common.OkWithMessage(c, post, "发布成功") +} + +// Update 编辑帖子 +func (ctrl *StudioController) Update(c *gin.Context) { + uid, role, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + var req model.PostUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "标题和正文不能为空") + return + } + + post, getErr := ctrl.postService.GetByID(uint(id)) + if getErr != nil { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { + common.Error(c, http.StatusForbidden, "无权操作此帖子") + return + } + + if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil { + common.Error(c, http.StatusInternalServerError, "编辑失败") + return + } + + common.OkMessage(c, "编辑成功") +} diff --git a/internal/controller/studio_write_controller.go b/internal/controller/studio_write_controller.go new file mode 100644 index 0000000..c0c30f5 --- /dev/null +++ b/internal/controller/studio_write_controller.go @@ -0,0 +1,77 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// WritePage 写文章页面 +func (ctrl *StudioController) WritePage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.RedirectToLogin(c) + return + } + + // 支持编辑模式:传入 post_id 参数 + postIDStr := c.Query("id") + if postIDStr != "" { + id, err := strconv.ParseUint(postIDStr, 10, 64) + if err == nil { + post, err := ctrl.postService.GetByID(uint(id)) + if err == nil { + _, role, _ := common.GetGinUser(c) + if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { + c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ + "Title": "未找到", + })) + return + } + c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{ + "Title": "编辑文章 - 创作中心", + "Post": post, + "ActiveTab": "write", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + } + } + + c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{ + "Title": "写文章 - 创作中心", + "ActiveTab": "write", + "ExtraCSS": "/static/css/studio.css", + })) +} + +// AnalyticsPage 数据分析页面 +func (ctrl *StudioController) AnalyticsPage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.RedirectToLogin(c) + return + } + + overview, err := ctrl.postService.GetOverview(uid) + if err != nil { + c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{ + "Title": "数据分析 - 创作中心", + "Error": "加载失败", + "ActiveTab": "analytics", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + + c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{ + "Title": "数据分析 - 创作中心", + "Overview": overview, + "ActiveTab": "analytics", + "ExtraCSS": "/static/css/studio.css", + })) +} diff --git a/internal/service/audit_review.go b/internal/service/audit_review.go new file mode 100644 index 0000000..66bef82 --- /dev/null +++ b/internal/service/audit_review.go @@ -0,0 +1,104 @@ +package service + +import ( + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" +) + +// review 通用审核操作 +func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, reason string) error { + // 1. 查审核记录 + submission, err := s.auditRepo.FindByID(submissionID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return common.ErrAuditNotFound + } + return err + } + + // 2. 必须 pending + if submission.Status != model.AuditStatusPending { + return common.ErrAuditNotPending + } + + // 3. 查审核人信息 + reviewer, err := s.userRepo.FindByID(reviewerID) + if err != nil { + return common.ErrUserNotFound + } + + // 4. 标记审核结果 + submission.ReviewedBy = &reviewerID + submission.ReviewerName = &reviewer.Username + if approved { + submission.Status = model.AuditStatusApproved + // 更新 User 表对应字段 + if err := s.applyApproval(submission); err != nil { + return err + } + // 通知用户审核通过 + if s.notifier != nil { + s.notifier.NotifyAuditApproved(submission.UserID, submission.AuditType, submission.ID) + } + } else { + submission.Status = model.AuditStatusRejected + submission.RejectReason = &reason + // 通知用户审核被拒(改名审核追加域能返还提示) + if s.notifier != nil { + reasonMsg := reason + if submission.AuditType == model.AuditTypeUsername { + reasonMsg += "。扣除的域能已被返还" + } + s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID) + } + // 改名审核被拒 → 返还域能 + if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil { + _ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID) + } + } + return s.auditRepo.Update(submission) +} + +// applyApproval 将审核通过的内容写入 User 表 +// 用户名审批前再次检查冲突(防止提交到审批期间被其他用户抢占) +func (s *AuditService) applyApproval(submission *model.AuditSubmission) error { + user, err := s.userRepo.FindByID(submission.UserID) + if err != nil { + return common.ErrUserNotFound + } + + switch submission.AuditType { + case model.AuditTypeUsername: + exists, err := s.userRepo.ExistsByUsername(submission.NewValue) + if err != nil { + return err + } + if exists { + return common.ErrUsernameTaken + } + user.Username = submission.NewValue + case model.AuditTypeAvatar: + user.Avatar = submission.NewValue + case model.AuditTypeBio: + user.Bio = submission.NewValue + } + if err := s.userRepo.Update(user); err != nil { + return err + } + + // 触发首次任务奖励(静默失败不影响主流程) + if s.levelSvc != nil { + switch submission.AuditType { + case model.AuditTypeUsername: + _, _, _ = s.levelSvc.CompleteTask(submission.UserID, "username") + case model.AuditTypeAvatar: + _, _, _ = s.levelSvc.CompleteTask(submission.UserID, "avatar") + case model.AuditTypeBio: + _, _, _ = s.levelSvc.CompleteTask(submission.UserID, "bio") + } + } + + return nil +} diff --git a/internal/service/audit_service.go b/internal/service/audit_service.go index d1a4f3a..7d5e226 100644 --- a/internal/service/audit_service.go +++ b/internal/service/audit_service.go @@ -4,8 +4,6 @@ import ( "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/model" - - "gorm.io/gorm" ) // auditRepo AuditService 所需的最小仓储接口(ISP) @@ -169,102 +167,6 @@ func (s *AuditService) Reject(reviewerID uint, submissionID uint, reason string) return s.review(reviewerID, submissionID, false, reason) } -// review 通用审核操作 -func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, reason string) error { - // 1. 查审核记录 - submission, err := s.auditRepo.FindByID(submissionID) - if err != nil { - if err == gorm.ErrRecordNotFound { - return common.ErrAuditNotFound - } - return err - } - - // 2. 必须 pending - if submission.Status != model.AuditStatusPending { - return common.ErrAuditNotPending - } - - // 3. 查审核人信息 - reviewer, err := s.userRepo.FindByID(reviewerID) - if err != nil { - return common.ErrUserNotFound - } - - // 4. 标记审核结果 - submission.ReviewedBy = &reviewerID - submission.ReviewerName = &reviewer.Username - if approved { - submission.Status = model.AuditStatusApproved - // 更新 User 表对应字段 - if err := s.applyApproval(submission); err != nil { - return err - } - // 通知用户审核通过 - if s.notifier != nil { - s.notifier.NotifyAuditApproved(submission.UserID, submission.AuditType, submission.ID) - } - } else { - submission.Status = model.AuditStatusRejected - submission.RejectReason = &reason - // 通知用户审核被拒(改名审核追加域能返还提示) - if s.notifier != nil { - reasonMsg := reason - if submission.AuditType == model.AuditTypeUsername { - reasonMsg += "。扣除的域能已被返还" - } - s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID) - } - // 改名审核被拒 → 返还域能 - if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil { - _ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID) - } - } - return s.auditRepo.Update(submission) -} - -// applyApproval 将审核通过的内容写入 User 表 -// 用户名审批前再次检查冲突(防止提交到审批期间被其他用户抢占) -func (s *AuditService) applyApproval(submission *model.AuditSubmission) error { - user, err := s.userRepo.FindByID(submission.UserID) - if err != nil { - return common.ErrUserNotFound - } - - switch submission.AuditType { - case model.AuditTypeUsername: - exists, err := s.userRepo.ExistsByUsername(submission.NewValue) - if err != nil { - return err - } - if exists { - return common.ErrUsernameTaken - } - user.Username = submission.NewValue - case model.AuditTypeAvatar: - user.Avatar = submission.NewValue - case model.AuditTypeBio: - user.Bio = submission.NewValue - } - if err := s.userRepo.Update(user); err != nil { - return err - } - - // 触发首次任务奖励(静默失败不影响主流程) - if s.levelSvc != nil { - switch submission.AuditType { - case model.AuditTypeUsername: - _, _, _ = s.levelSvc.CompleteTask(submission.UserID, "username") - case model.AuditTypeAvatar: - _, _, _ = s.levelSvc.CompleteTask(submission.UserID, "avatar") - case model.AuditTypeBio: - _, _, _ = s.levelSvc.CompleteTask(submission.UserID, "bio") - } - } - - return nil -} - // List 分页查询审核列表 func (s *AuditService) List(auditType, status string, page, pageSize int) (*AuditListResult, error) { p := common.Pagination{Page: page, PageSize: pageSize} diff --git a/internal/service/auth_password.go b/internal/service/auth_password.go new file mode 100644 index 0000000..2c57be5 --- /dev/null +++ b/internal/service/auth_password.go @@ -0,0 +1,128 @@ +package service + +import ( + "regexp" + "time" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +var pwLetter = regexp.MustCompile(`[a-zA-Z]`) +var pwDigit = regexp.MustCompile(`\d`) + +// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对 +var dummyHash []byte + +func init() { + hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12) + if err != nil { + panic("failed to generate bcrypt dummy hash: " + err.Error()) + } + dummyHash = hash +} + +// validatePassword 密码强度:至少 8 位 + 包含字母 + 包含数字 +func validatePassword(pw string) error { + if len(pw) < 8 || !pwLetter.MatchString(pw) || !pwDigit.MatchString(pw) { + return common.ErrWeakPassword + } + return nil +} + +// ChangePassword 修改密码 +// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录) +func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return common.ErrUserNotFound + } + + if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil { + return common.ErrIncorrectPassword + } + + if err := validatePassword(newPassword); err != nil { + return err + } + + hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost) + if err != nil { + return err + } + + user.PasswordHash = hash + if err := s.userRepo.Update(user); err != nil { + return err + } + + // 删除所有 session,强制所有设备重新登录 + return s.invalidateSessions(userID) +} + +// DeleteAccount 用户自主注销 +func (s *AuthService) DeleteAccount(userID uint, password, reason string) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return common.ErrUserNotFound + } + + if user.Role == model.RoleOwner { + return common.ErrOwnerCannotDelete + } + + if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil { + return common.ErrIncorrectPassword + } + + user.Status = model.StatusDeleted + user.DeleteReason = reason + + if err := s.userRepo.Update(user); err != nil { + return err + } + + return s.invalidateSessions(userID) +} + +// invalidateSessions 销毁某用户的所有 session(内部方法) +func (s *AuthService) invalidateSessions(userID uint) error { + return s.sessionManager.DestroyByUID(userID) +} + +// InvalidateSessions 公开方法:销毁用户所有 session(供 admin 等外部调用) +func (s *AuthService) InvalidateSessions(userID uint) error { + return s.sessionManager.DestroyByUID(userID) +} + +// ConfirmRestore 二次确认恢复已注销账号 +func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) { + user, err := s.userRepo.FindByEmail(req.Email) + if err != nil { + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) + return nil, common.ErrInvalidCred + } + + if user.Status != model.StatusDeleted { + return nil, common.ErrUserNotFound + } + + if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil { + return nil, common.ErrInvalidCred + } + + // 恢复账号 + user.Status = model.StatusActive + user.DeletedAt = gorm.DeletedAt{} + now := time.Now() + user.LastLoginIP = loginIP + user.LastLoginAt = &now + if err := s.userRepo.Update(user); err != nil { + return nil, err + } + + return user, nil +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index a9dafde..529fe27 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -12,7 +12,6 @@ import ( "metazone.cc/metalab/internal/session" "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" ) // AuthService 认证业务逻辑 @@ -28,31 +27,9 @@ func NewAuthService(userRepo userAuthStore, sm *session.Manager, cfg *config.Con return &AuthService{userRepo: userRepo, sessionManager: sm, cfg: cfg, siteSettings: siteSettings} } -var pwLetter = regexp.MustCompile(`[a-zA-Z]`) -var pwDigit = regexp.MustCompile(`\d`) - // usernamePattern 用户名合法字符:中文、英文大小写、数字、下划线、连字符 var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`) -// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对 -var dummyHash []byte - -func init() { - hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12) - if err != nil { - panic("failed to generate bcrypt dummy hash: " + err.Error()) - } - dummyHash = hash -} - -// validatePassword 密码强度:至少 8 位 + 包含字母 + 包含数字 -func validatePassword(pw string) error { - if len(pw) < 8 || !pwLetter.MatchString(pw) || !pwDigit.MatchString(pw) { - return common.ErrWeakPassword - } - return nil -} - // IsRegistrationEnabled 检查是否允许新用户注册 func (s *AuthService) IsRegistrationEnabled() bool { return s.siteSettings.IsRegistrationEnabled() @@ -164,35 +141,6 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User return user, nil } -// ConfirmRestore 二次确认恢复已注销账号 -func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) { - user, err := s.userRepo.FindByEmail(req.Email) - if err != nil { - _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) - return nil, common.ErrInvalidCred - } - - if user.Status != model.StatusDeleted { - return nil, common.ErrUserNotFound - } - - if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil { - return nil, common.ErrInvalidCred - } - - // 恢复账号 - user.Status = model.StatusActive - user.DeletedAt = gorm.DeletedAt{} - now := time.Now() - user.LastLoginIP = loginIP - user.LastLoginAt = &now - if err := s.userRepo.Update(user); err != nil { - return nil, err - } - - return user, nil -} - // CheckEmail 检查邮箱是否已被注册 func (s *AuthService) CheckEmail(email string) (bool, error) { return s.userRepo.ExistsByEmail(email) @@ -203,71 +151,6 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) { return s.userRepo.FindByID(userID) } -// ChangePassword 修改密码 -// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录) -func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error { - user, err := s.userRepo.FindByID(userID) - if err != nil { - return common.ErrUserNotFound - } - - if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil { - return common.ErrIncorrectPassword - } - - if err := validatePassword(newPassword); err != nil { - return err - } - - hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost) - if err != nil { - return err - } - - user.PasswordHash = hash - if err := s.userRepo.Update(user); err != nil { - return err - } - - // 删除所有 session,强制所有设备重新登录 - return s.invalidateSessions(userID) -} - -// DeleteAccount 用户自主注销 -func (s *AuthService) DeleteAccount(userID uint, password, reason string) error { - user, err := s.userRepo.FindByID(userID) - if err != nil { - return common.ErrUserNotFound - } - - if user.Role == model.RoleOwner { - return common.ErrOwnerCannotDelete - } - - if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil { - return common.ErrIncorrectPassword - } - - user.Status = model.StatusDeleted - user.DeleteReason = reason - - if err := s.userRepo.Update(user); err != nil { - return err - } - - return s.invalidateSessions(userID) -} - -// invalidateSessions 销毁某用户的所有 session(内部方法) -func (s *AuthService) invalidateSessions(userID uint) error { - return s.sessionManager.DestroyByUID(userID) -} - -// InvalidateSessions 公开方法:销毁用户所有 session(供 admin 等外部调用) -func (s *AuthService) InvalidateSessions(userID uint) error { - return s.sessionManager.DestroyByUID(userID) -} - // UpdateProfile 修改个人资料 func (s *AuthService) UpdateProfile(userID uint, username, bio string) error { user, err := s.userRepo.FindByID(userID) diff --git a/internal/service/energy_admin.go b/internal/service/energy_admin.go new file mode 100644 index 0000000..684ba57 --- /dev/null +++ b/internal/service/energy_admin.go @@ -0,0 +1,92 @@ +package service + +import ( + "fmt" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" +) + +// AdminAdjust 后台调整用户域能 +// mode: "admin_transfer"(受公户余额约束)| "system_operation"(不受约束,不碰公户) +func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error { + if mode == "" { + mode = model.FundTypeAdminTransfer + } + + return s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + + // admin_transfer:公户出账需检查余额(行锁防 TOCTOU 竞态) + isAdminTransfer := mode == model.FundTypeAdminTransfer + if isAdminTransfer && amount > 0 && s.fundRepo != nil { + txFundRepo := s.fundRepo.WithTx(tx) + totalCost := amount * len(userIDs) + balance, err := txFundRepo.LockAndGetBalance() + if err != nil { + return fmt.Errorf("查询公户余额失败: %w", err) + } + if balance < totalCost { + return common.ErrInsufficientFund + } + } + + for _, uid := range userIDs { + if err := txRepo.AddEnergy(uid, amount); err != nil { + return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err) + } + + opUID := operatorUID + energyLog := &model.EnergyLog{ + UserID: uid, + Amount: amount, + Type: model.EnergyTypeAdminAdjust, + Description: description, + OperatorUID: &opUID, + } + if err := txRepo.CreateEnergyLog(energyLog); err != nil { + return fmt.Errorf("创建调整流水失败: %w", err) + } + } + + // admin_transfer:公户扣款 + 公户流水(system_operation 不碰公户) + if isAdminTransfer && s.fundRepo != nil { + txFundRepo := s.fundRepo.WithTx(tx) + totalCost := amount * len(userIDs) + if err := txFundRepo.IncrBalance(-totalCost); err != nil { + return fmt.Errorf("公户余额调整失败: %w", err) + } + + totalDisplay := float64(totalCost) / 10 + fundLog := &model.FundLog{ + Amount: -totalCost, + Type: mode, + OperatorUID: &operatorUID, + Description: fmt.Sprintf("%s:调整 %d 名用户域能 %.1f,说明:%s", getFundModeName(mode), len(userIDs), totalDisplay, description), + } + if len(userIDs) > 0 { + fundLog.RelatedType = "user" + fundLog.RelatedID = userIDs[0] + } + if err := txFundRepo.CreateLog(fundLog); err != nil { + return fmt.Errorf("创建公户流水失败: %w", err) + } + } + + return nil + }) +} + +// getFundModeName 返回操作模式中文名 +func getFundModeName(mode string) string { + switch mode { + case model.FundTypeAdminTransfer: + return "管理转出" + case model.FundTypeSystemOperation: + return "系统操作" + default: + return "后台调整" + } +} diff --git a/internal/service/energy_energize.go b/internal/service/energy_energize.go new file mode 100644 index 0000000..1b6c2c2 --- /dev/null +++ b/internal/service/energy_energize.go @@ -0,0 +1,190 @@ +package service + +import ( + "fmt" + "time" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" +) + +// Energize 赋能文章 +// 返回:获得的经验值、错误 +func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, error) { + // 验证赋能量 + if amount != EnergyEnergize1 && amount != EnergyEnergize2 { + return 0, common.ErrInvalidEnergyAmount + } + + var expGained int + var needExpUpdate bool + var expToday time.Time + var expTotal int + + err := s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + var txFundRepo FundStore + if s.fundRepo != nil { + txFundRepo = s.fundRepo.WithTx(tx) + } + + // 1. 检查帖子存在,获取作者 + post, err := txRepo.FindPostByID(postID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return common.ErrPostNotFound + } + return fmt.Errorf("查询帖子失败: %w", err) + } + + // 2. 不能给自己赋能 + if post.UserID == energizerID { + return common.ErrCannotEnergizeSelf + } + + // 3. 检查赋能者域能余额(owner 跳过) + energizer, err := txRepo.FindUserByID(energizerID) + if err != nil { + return fmt.Errorf("查询用户失败: %w", err) + } + if energizer.Role != model.RoleOwner && energizer.Energy < amount { + return common.ErrInsufficientEnergy + } + + // 4. 检查单用户单文章赋能总量 ≤ MaxEnergizePerPost + total, err := txRepo.SumUserPostEnergy(energizerID, postID) + if err != nil { + return fmt.Errorf("查询赋能记录失败: %w", err) + } + if total+amount > MaxEnergizePerPost { + return common.ErrEnergizeLimitReached + } + + // 5. 扣赋能者域能 + if err := txRepo.AddEnergy(energizerID, -amount); err != nil { + return fmt.Errorf("扣减域能失败: %w", err) + } + + // 6. 增加被赋能者(文章作者)域能(赋能量的十分之一) + receivedAmount := amount / 10 // 1 域能→0.1,2 域能→0.2 + if err := txRepo.AddEnergy(post.UserID, receivedAmount); err != nil { + return fmt.Errorf("增加被赋能者域能失败: %w", err) + } + + // 7. 更新 Post.total_energy_received + if err := txRepo.IncrPostEnergy(postID, amount); err != nil { + return fmt.Errorf("更新文章赋能计数失败: %w", err) + } + + // 8. 插入赋能记录 + eLog := &model.PostEnergizeLog{ + UserID: energizerID, + PostID: postID, + Amount: amount, + } + if err := txRepo.CreateEnergizeLog(eLog); err != nil { + return fmt.Errorf("创建赋能记录失败: %w", err) + } + + // 9. 写入域能流水 ×2 + amountDisplay := float64(amount) / 10 + receivedDisplay := float64(receivedAmount) / 10 + + energizerLog := &model.EnergyLog{ + UserID: energizerID, + Amount: -amount, + Type: model.EnergyTypeEnergize, + RelatedType: "post", + RelatedID: postID, + Description: fmt.Sprintf("给文章 %d 赋能 %.1f 域能", postID, amountDisplay), + } + if err := txRepo.CreateEnergyLog(energizerLog); err != nil { + return fmt.Errorf("创建赋能者流水失败: %w", err) + } + + energizedLog := &model.EnergyLog{ + UserID: post.UserID, + Amount: receivedAmount, + Type: model.EnergyTypeEnergized, + RelatedType: "post", + RelatedID: postID, + Description: fmt.Sprintf("文章 %d 被赋能 +%.1f 域能", postID, receivedDisplay), + } + if err := txRepo.CreateEnergyLog(energizedLog); err != nil { + return fmt.Errorf("创建被赋能者流水失败: %w", err) + } + + // 10. 赋能税收入公户(差价 = amount - receivedAmount) + if txFundRepo != nil { + tax := amount - receivedAmount + if tax > 0 { + if err := txFundRepo.IncrBalance(tax); err != nil { + return fmt.Errorf("公户入账失败: %w", err) + } + taxDisplay := float64(tax) / 10 + fundLog := &model.FundLog{ + Amount: tax, + Type: model.FundTypeEnergizeTax, + RelatedType: "post", + RelatedID: postID, + Description: fmt.Sprintf("赋能文章 #%d 税收 %.1f 域能,赋能者 uid=%d", postID, taxDisplay, energizerID), + } + if err := txFundRepo.CreateLog(fundLog); err != nil { + return fmt.Errorf("创建公户流水失败: %w", err) + } + } + } + + // 11. 计算经验值(溢出截断)—— 在事务内读取以保证一致性 + loc, _ := time.LoadLocation("Asia/Shanghai") + now := time.Now().In(loc) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + + summary, err := txRepo.GetDailyEnergizeExp(energizerID, today) + currentDaily := 0 + if err == nil && summary != nil { + currentDaily = summary.ExpEarned + } + + expGain := amount // 1域能(DB10) → 10经验, 2域能(DB20) → 20经验 + expRoom := DailyEnergizeExpCap - currentDaily + if expRoom < 0 { + expRoom = 0 + } + if expGain > expRoom { + expGain = expRoom + } + + expGained = expGain + if expGain > 0 { + needExpUpdate = true + expToday = today + expTotal = currentDaily + expGain + } + return nil + }) + if err != nil { + return 0, err + } + + // 12. 事务提交后,在事务外增加经验 + 更新每日汇总 + if needExpUpdate { + if s.expSvc != nil { + if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil { + return expGained, fmt.Errorf("赋能成功,但增加经验失败: %w", err) + } + } + newSummary := &model.DailyExpSummary{ + UserID: energizerID, + Date: expToday, + ExpEarned: expTotal, + } + if err := s.repo.UpsertDailyExp(newSummary); err != nil { + return expGained, fmt.Errorf("赋能成功,但更新经验汇总失败: %w", err) + } + } + + return expGained, nil +} diff --git a/internal/service/energy_operations.go b/internal/service/energy_operations.go new file mode 100644 index 0000000..e57b4a8 --- /dev/null +++ b/internal/service/energy_operations.go @@ -0,0 +1,149 @@ +package service + +import ( + "fmt" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" +) + +// CheckInEnergy 签到获得域能(LV0 用户不调用此方法) +func (s *EnergyService) CheckInEnergy(userID uint) error { + if err := s.repo.AddEnergy(userID, EnergyCheckIn); err != nil { + return fmt.Errorf("增加签到域能失败: %w", err) + } + + log := &model.EnergyLog{ + UserID: userID, + Amount: EnergyCheckIn, + Type: model.EnergyTypeSignIn, + Description: "每日签到 +1.0 域能", + } + return s.repo.CreateEnergyLog(log) +} + +// DeductOnRename 改名扣域能(首次改名免费,不调用此方法) +// 事务包裹:扣用户域能 + 入公户 + 写流水 +func (s *EnergyService) DeductOnRename(userID uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + + // 检查域能余额(非 owner 需 ≥ 0) + user, err := txRepo.FindUserByID(userID) + if err != nil { + return fmt.Errorf("查询用户失败: %w", err) + } + if user.Role != model.RoleOwner && user.Energy+EnergyRename < 0 { + return common.ErrInsufficientEnergy + } + + if err := txRepo.AddEnergy(userID, EnergyRename); err != nil { + return fmt.Errorf("扣减改名域能失败: %w", err) + } + + // 公户入账 + if s.fundRepo != nil { + txFundRepo := s.fundRepo.WithTx(tx) + if err := txFundRepo.IncrBalance(-EnergyRename); err != nil { // EnergyRename = -60,入公户 +60 + return fmt.Errorf("公户入账失败: %w", err) + } + fundLog := &model.FundLog{ + Amount: -EnergyRename, + Type: model.FundTypeRenameDeduction, + RelatedType: "user", + RelatedID: userID, + Description: fmt.Sprintf("用户 uid=%d 改名消耗 6.0 域能", userID), + } + if err := txFundRepo.CreateLog(fundLog); err != nil { + return fmt.Errorf("创建公户流水失败: %w", err) + } + } + + log := &model.EnergyLog{ + UserID: userID, + Amount: EnergyRename, + Type: model.EnergyTypeRename, + Description: "改名消耗 6.0 域能", + } + return txRepo.CreateEnergyLog(log) + }) +} + +// RefundOnRenameReject 改名审核被拒,从公户返还域能 +func (s *EnergyService) RefundOnRenameReject(userID uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + + // 从公户支出(允许透支) + if s.fundRepo != nil { + txFundRepo := s.fundRepo.WithTx(tx) + if err := txFundRepo.IncrBalance(-EnergyRenameRefund); err != nil { + return fmt.Errorf("公户出账失败: %w", err) + } + fundLog := &model.FundLog{ + Amount: -EnergyRenameRefund, + Type: model.FundTypeRenameRefund, + RelatedType: "user", + RelatedID: userID, + Description: fmt.Sprintf("用户 uid=%d 改名审核被拒,返还 6.0 域能", userID), + } + if err := txFundRepo.CreateLog(fundLog); err != nil { + return fmt.Errorf("创建公户流水失败: %w", err) + } + } + + if err := txRepo.AddEnergy(userID, EnergyRenameRefund); err != nil { + return fmt.Errorf("返还改名域能失败: %w", err) + } + + log := &model.EnergyLog{ + UserID: userID, + Amount: EnergyRenameRefund, + Type: model.EnergyTypeRenameRefund, + Description: "改名审核被拒,返还 6.0 域能", + } + return txRepo.CreateEnergyLog(log) + }) +} + +// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数) +// 事务包裹:扣用户域能 + 入公户 + 写流水 +func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + + if err := txRepo.AddEnergy(authorUserID, EnergyDeletePost); err != nil { + return fmt.Errorf("扣减删稿域能失败: %w", err) + } + + // 公户入账 + if s.fundRepo != nil { + txFundRepo := s.fundRepo.WithTx(tx) + if err := txFundRepo.IncrBalance(-EnergyDeletePost); err != nil { // EnergyDeletePost = -20,入公户 +20 + return fmt.Errorf("公户入账失败: %w", err) + } + fundLog := &model.FundLog{ + Amount: -EnergyDeletePost, + Type: model.FundTypeDeletePost, + RelatedType: "post", + RelatedID: postID, + Description: fmt.Sprintf("用户 uid=%d 删除文章 #%d 消耗 2.0 域能", authorUserID, postID), + } + if err := txFundRepo.CreateLog(fundLog); err != nil { + return fmt.Errorf("创建公户流水失败: %w", err) + } + } + + log := &model.EnergyLog{ + UserID: authorUserID, + Amount: EnergyDeletePost, + Type: model.EnergyTypeDeletePost, + RelatedType: "post", + RelatedID: postID, + Description: "删除文章消耗 2.0 域能", + } + return txRepo.CreateEnergyLog(log) + }) +} diff --git a/internal/service/energy_query.go b/internal/service/energy_query.go new file mode 100644 index 0000000..6ea6768 --- /dev/null +++ b/internal/service/energy_query.go @@ -0,0 +1,85 @@ +package service + +import ( + "time" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" +) + +// GetEnergyInfo 获取用户域能余额和每日赋能经验状态 +func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) { + user, err := s.repo.FindUserByID(userID) + if err != nil { + return nil, err + } + + loc, _ := time.LoadLocation("Asia/Shanghai") + now := time.Now().In(loc) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + + dailyExp := 0 + summary, err := s.repo.GetDailyEnergizeExp(userID, today) + if err == nil && summary != nil { + dailyExp = summary.ExpEarned + } + + return &EnergyInfo{ + Energy: user.Energy, + DailyEnergizeExp: dailyExp, + DailyExpCap: DailyEnergizeExpCap, + DailyExpCapReached: dailyExp >= DailyEnergizeExpCap, + }, nil +} + +// GetEnergyLogs 分页查询用户域能流水(近 N 天) +func (s *EnergyService) GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error) { + p := common.Pagination{Page: page, PageSize: pageSize} + p.DefaultPagination() + + total, err := s.repo.CountEnergyLogsByUser(userID, days) + if err != nil { + return nil, 0, err + } + + logs, err := s.repo.ListEnergyLogsByUser(userID, days, p.Offset(), p.PageSize) + if err != nil { + return nil, 0, err + } + if logs == nil { + logs = []model.EnergyLog{} + } + return logs, total, nil +} + +// GetAdminEnergyLogs 后台查询全部域能流水 +func (s *EnergyService) GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) { + p := common.Pagination{Page: page, PageSize: pageSize} + p.DefaultPagination() + return s.repo.ListAllEnergyLogs(energyType, p.Offset(), p.PageSize) +} + +// GetFundBalance 获取公户余额 +func (s *EnergyService) GetFundBalance() (int, error) { + if s.fundRepo == nil { + return 0, nil + } + return s.fundRepo.GetBalance() +} + +// GetFundLogs 分页查询公户流水 +func (s *EnergyService) GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) { + p := common.Pagination{Page: page, PageSize: pageSize} + p.DefaultPagination() + if s.fundRepo == nil { + return []model.FundLog{}, 0, nil + } + logs, total, err := s.fundRepo.ListLogs(logType, p.Offset(), p.PageSize) + if err != nil { + return nil, 0, err + } + if logs == nil { + logs = []model.FundLog{} + } + return logs, total, nil +} diff --git a/internal/service/energy_service.go b/internal/service/energy_service.go index c94559e..e94afdf 100644 --- a/internal/service/energy_service.go +++ b/internal/service/energy_service.go @@ -1,12 +1,6 @@ package service import ( - "fmt" - "time" - - "metazone.cc/metalab/internal/common" - "metazone.cc/metalab/internal/model" - "gorm.io/gorm" ) @@ -59,480 +53,3 @@ const ( DailyEnergizeExpCap = 50 // 每日赋能经验上限 MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能(×10) ) - -// Energize 赋能文章 -// 返回:获得的经验值、错误 -func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, error) { - // 验证赋能量 - if amount != EnergyEnergize1 && amount != EnergyEnergize2 { - return 0, common.ErrInvalidEnergyAmount - } - - var expGained int - var needExpUpdate bool - var expToday time.Time - var expTotal int - - err := s.db.Transaction(func(tx *gorm.DB) error { - txRepo := s.repo.WithTx(tx) - var txFundRepo FundStore - if s.fundRepo != nil { - txFundRepo = s.fundRepo.WithTx(tx) - } - - // 1. 检查帖子存在,获取作者 - post, err := txRepo.FindPostByID(postID) - if err != nil { - if err == gorm.ErrRecordNotFound { - return common.ErrPostNotFound - } - return fmt.Errorf("查询帖子失败: %w", err) - } - - // 2. 不能给自己赋能 - if post.UserID == energizerID { - return common.ErrCannotEnergizeSelf - } - - // 3. 检查赋能者域能余额(owner 跳过) - energizer, err := txRepo.FindUserByID(energizerID) - if err != nil { - return fmt.Errorf("查询用户失败: %w", err) - } - if energizer.Role != model.RoleOwner && energizer.Energy < amount { - return common.ErrInsufficientEnergy - } - - // 4. 检查单用户单文章赋能总量 ≤ MaxEnergizePerPost - total, err := txRepo.SumUserPostEnergy(energizerID, postID) - if err != nil { - return fmt.Errorf("查询赋能记录失败: %w", err) - } - if total+amount > MaxEnergizePerPost { - return common.ErrEnergizeLimitReached - } - - // 5. 扣赋能者域能 - if err := txRepo.AddEnergy(energizerID, -amount); err != nil { - return fmt.Errorf("扣减域能失败: %w", err) - } - - // 6. 增加被赋能者(文章作者)域能(赋能量的十分之一) - receivedAmount := amount / 10 // 1 域能→0.1,2 域能→0.2 - if err := txRepo.AddEnergy(post.UserID, receivedAmount); err != nil { - return fmt.Errorf("增加被赋能者域能失败: %w", err) - } - - // 7. 更新 Post.total_energy_received - if err := txRepo.IncrPostEnergy(postID, amount); err != nil { - return fmt.Errorf("更新文章赋能计数失败: %w", err) - } - - // 8. 插入赋能记录 - eLog := &model.PostEnergizeLog{ - UserID: energizerID, - PostID: postID, - Amount: amount, - } - if err := txRepo.CreateEnergizeLog(eLog); err != nil { - return fmt.Errorf("创建赋能记录失败: %w", err) - } - - // 9. 写入域能流水 ×2 - amountDisplay := float64(amount) / 10 - receivedDisplay := float64(receivedAmount) / 10 - - energizerLog := &model.EnergyLog{ - UserID: energizerID, - Amount: -amount, - Type: model.EnergyTypeEnergize, - RelatedType: "post", - RelatedID: postID, - Description: fmt.Sprintf("给文章 %d 赋能 %.1f 域能", postID, amountDisplay), - } - if err := txRepo.CreateEnergyLog(energizerLog); err != nil { - return fmt.Errorf("创建赋能者流水失败: %w", err) - } - - energizedLog := &model.EnergyLog{ - UserID: post.UserID, - Amount: receivedAmount, - Type: model.EnergyTypeEnergized, - RelatedType: "post", - RelatedID: postID, - Description: fmt.Sprintf("文章 %d 被赋能 +%.1f 域能", postID, receivedDisplay), - } - if err := txRepo.CreateEnergyLog(energizedLog); err != nil { - return fmt.Errorf("创建被赋能者流水失败: %w", err) - } - - // 10. 赋能税收入公户(差价 = amount - receivedAmount) - if txFundRepo != nil { - tax := amount - receivedAmount - if tax > 0 { - if err := txFundRepo.IncrBalance(tax); err != nil { - return fmt.Errorf("公户入账失败: %w", err) - } - taxDisplay := float64(tax) / 10 - fundLog := &model.FundLog{ - Amount: tax, - Type: model.FundTypeEnergizeTax, - RelatedType: "post", - RelatedID: postID, - Description: fmt.Sprintf("赋能文章 #%d 税收 %.1f 域能,赋能者 uid=%d", postID, taxDisplay, energizerID), - } - if err := txFundRepo.CreateLog(fundLog); err != nil { - return fmt.Errorf("创建公户流水失败: %w", err) - } - } - } - - // 11. 计算经验值(溢出截断)—— 在事务内读取以保证一致性 - loc, _ := time.LoadLocation("Asia/Shanghai") - now := time.Now().In(loc) - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) - - summary, err := txRepo.GetDailyEnergizeExp(energizerID, today) - currentDaily := 0 - if err == nil && summary != nil { - currentDaily = summary.ExpEarned - } - - expGain := amount // 1域能(DB10) → 10经验, 2域能(DB20) → 20经验 - expRoom := DailyEnergizeExpCap - currentDaily - if expRoom < 0 { - expRoom = 0 - } - if expGain > expRoom { - expGain = expRoom - } - - expGained = expGain - if expGain > 0 { - needExpUpdate = true - expToday = today - expTotal = currentDaily + expGain - } - return nil - }) - if err != nil { - return 0, err - } - - // 12. 事务提交后,在事务外增加经验 + 更新每日汇总 - if needExpUpdate { - if s.expSvc != nil { - if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil { - return expGained, fmt.Errorf("赋能成功,但增加经验失败: %w", err) - } - } - newSummary := &model.DailyExpSummary{ - UserID: energizerID, - Date: expToday, - ExpEarned: expTotal, - } - if err := s.repo.UpsertDailyExp(newSummary); err != nil { - return expGained, fmt.Errorf("赋能成功,但更新经验汇总失败: %w", err) - } - } - - return expGained, nil -} - -// CheckInEnergy 签到获得域能(LV0 用户不调用此方法) -func (s *EnergyService) CheckInEnergy(userID uint) error { - if err := s.repo.AddEnergy(userID, EnergyCheckIn); err != nil { - return fmt.Errorf("增加签到域能失败: %w", err) - } - - log := &model.EnergyLog{ - UserID: userID, - Amount: EnergyCheckIn, - Type: model.EnergyTypeSignIn, - Description: "每日签到 +1.0 域能", - } - return s.repo.CreateEnergyLog(log) -} - -// DeductOnRename 改名扣域能(首次改名免费,不调用此方法) -// 事务包裹:扣用户域能 + 入公户 + 写流水 -func (s *EnergyService) DeductOnRename(userID uint) error { - return s.db.Transaction(func(tx *gorm.DB) error { - txRepo := s.repo.WithTx(tx) - - // 检查域能余额(非 owner 需 ≥ 0) - user, err := txRepo.FindUserByID(userID) - if err != nil { - return fmt.Errorf("查询用户失败: %w", err) - } - if user.Role != model.RoleOwner && user.Energy+EnergyRename < 0 { - return common.ErrInsufficientEnergy - } - - if err := txRepo.AddEnergy(userID, EnergyRename); err != nil { - return fmt.Errorf("扣减改名域能失败: %w", err) - } - - // 公户入账 - if s.fundRepo != nil { - txFundRepo := s.fundRepo.WithTx(tx) - if err := txFundRepo.IncrBalance(-EnergyRename); err != nil { // EnergyRename = -60,入公户 +60 - return fmt.Errorf("公户入账失败: %w", err) - } - fundLog := &model.FundLog{ - Amount: -EnergyRename, - Type: model.FundTypeRenameDeduction, - RelatedType: "user", - RelatedID: userID, - Description: fmt.Sprintf("用户 uid=%d 改名消耗 6.0 域能", userID), - } - if err := txFundRepo.CreateLog(fundLog); err != nil { - return fmt.Errorf("创建公户流水失败: %w", err) - } - } - - log := &model.EnergyLog{ - UserID: userID, - Amount: EnergyRename, - Type: model.EnergyTypeRename, - Description: "改名消耗 6.0 域能", - } - return txRepo.CreateEnergyLog(log) - }) -} - -// RefundOnRenameReject 改名审核被拒,从公户返还域能 -func (s *EnergyService) RefundOnRenameReject(userID uint) error { - return s.db.Transaction(func(tx *gorm.DB) error { - txRepo := s.repo.WithTx(tx) - - // 从公户支出(允许透支) - if s.fundRepo != nil { - txFundRepo := s.fundRepo.WithTx(tx) - if err := txFundRepo.IncrBalance(-EnergyRenameRefund); err != nil { - return fmt.Errorf("公户出账失败: %w", err) - } - fundLog := &model.FundLog{ - Amount: -EnergyRenameRefund, - Type: model.FundTypeRenameRefund, - RelatedType: "user", - RelatedID: userID, - Description: fmt.Sprintf("用户 uid=%d 改名审核被拒,返还 6.0 域能", userID), - } - if err := txFundRepo.CreateLog(fundLog); err != nil { - return fmt.Errorf("创建公户流水失败: %w", err) - } - } - - if err := txRepo.AddEnergy(userID, EnergyRenameRefund); err != nil { - return fmt.Errorf("返还改名域能失败: %w", err) - } - - log := &model.EnergyLog{ - UserID: userID, - Amount: EnergyRenameRefund, - Type: model.EnergyTypeRenameRefund, - Description: "改名审核被拒,返还 6.0 域能", - } - return txRepo.CreateEnergyLog(log) - }) -} - -// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数) -// 事务包裹:扣用户域能 + 入公户 + 写流水 -func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error { - return s.db.Transaction(func(tx *gorm.DB) error { - txRepo := s.repo.WithTx(tx) - - if err := txRepo.AddEnergy(authorUserID, EnergyDeletePost); err != nil { - return fmt.Errorf("扣减删稿域能失败: %w", err) - } - - // 公户入账 - if s.fundRepo != nil { - txFundRepo := s.fundRepo.WithTx(tx) - if err := txFundRepo.IncrBalance(-EnergyDeletePost); err != nil { // EnergyDeletePost = -20,入公户 +20 - return fmt.Errorf("公户入账失败: %w", err) - } - fundLog := &model.FundLog{ - Amount: -EnergyDeletePost, - Type: model.FundTypeDeletePost, - RelatedType: "post", - RelatedID: postID, - Description: fmt.Sprintf("用户 uid=%d 删除文章 #%d 消耗 2.0 域能", authorUserID, postID), - } - if err := txFundRepo.CreateLog(fundLog); err != nil { - return fmt.Errorf("创建公户流水失败: %w", err) - } - } - - log := &model.EnergyLog{ - UserID: authorUserID, - Amount: EnergyDeletePost, - Type: model.EnergyTypeDeletePost, - RelatedType: "post", - RelatedID: postID, - Description: "删除文章消耗 2.0 域能", - } - return txRepo.CreateEnergyLog(log) - }) -} - -// AdminAdjust 后台调整用户域能 -// mode: "admin_transfer"(受公户余额约束)| "system_operation"(不受约束,不碰公户) -func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error { - if mode == "" { - mode = model.FundTypeAdminTransfer - } - - return s.db.Transaction(func(tx *gorm.DB) error { - txRepo := s.repo.WithTx(tx) - - // admin_transfer:公户出账需检查余额(行锁防 TOCTOU 竞态) - isAdminTransfer := mode == model.FundTypeAdminTransfer - if isAdminTransfer && amount > 0 && s.fundRepo != nil { - txFundRepo := s.fundRepo.WithTx(tx) - totalCost := amount * len(userIDs) - balance, err := txFundRepo.LockAndGetBalance() - if err != nil { - return fmt.Errorf("查询公户余额失败: %w", err) - } - if balance < totalCost { - return common.ErrInsufficientFund - } - } - - for _, uid := range userIDs { - if err := txRepo.AddEnergy(uid, amount); err != nil { - return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err) - } - - opUID := operatorUID - energyLog := &model.EnergyLog{ - UserID: uid, - Amount: amount, - Type: model.EnergyTypeAdminAdjust, - Description: description, - OperatorUID: &opUID, - } - if err := txRepo.CreateEnergyLog(energyLog); err != nil { - return fmt.Errorf("创建调整流水失败: %w", err) - } - } - - // admin_transfer:公户扣款 + 公户流水(system_operation 不碰公户) - if isAdminTransfer && s.fundRepo != nil { - txFundRepo := s.fundRepo.WithTx(tx) - totalCost := amount * len(userIDs) - if err := txFundRepo.IncrBalance(-totalCost); err != nil { - return fmt.Errorf("公户余额调整失败: %w", err) - } - - totalDisplay := float64(totalCost) / 10 - fundLog := &model.FundLog{ - Amount: -totalCost, - Type: mode, - OperatorUID: &operatorUID, - Description: fmt.Sprintf("%s:调整 %d 名用户域能 %.1f,说明:%s", getFundModeName(mode), len(userIDs), totalDisplay, description), - } - if len(userIDs) > 0 { - fundLog.RelatedType = "user" - fundLog.RelatedID = userIDs[0] - } - if err := txFundRepo.CreateLog(fundLog); err != nil { - return fmt.Errorf("创建公户流水失败: %w", err) - } - } - - return nil - }) -} - -// getFundModeName 返回操作模式中文名 -func getFundModeName(mode string) string { - switch mode { - case model.FundTypeAdminTransfer: - return "管理转出" - case model.FundTypeSystemOperation: - return "系统操作" - default: - return "后台调整" - } -} - -// GetEnergyInfo 获取用户域能余额和每日赋能经验状态 -func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) { - user, err := s.repo.FindUserByID(userID) - if err != nil { - return nil, err - } - - loc, _ := time.LoadLocation("Asia/Shanghai") - now := time.Now().In(loc) - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) - - dailyExp := 0 - summary, err := s.repo.GetDailyEnergizeExp(userID, today) - if err == nil && summary != nil { - dailyExp = summary.ExpEarned - } - - return &EnergyInfo{ - Energy: user.Energy, - DailyEnergizeExp: dailyExp, - DailyExpCap: DailyEnergizeExpCap, - DailyExpCapReached: dailyExp >= DailyEnergizeExpCap, - }, nil -} - -// GetEnergyLogs 分页查询用户域能流水(近 N 天) -func (s *EnergyService) GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error) { - p := common.Pagination{Page: page, PageSize: pageSize} - p.DefaultPagination() - - total, err := s.repo.CountEnergyLogsByUser(userID, days) - if err != nil { - return nil, 0, err - } - - logs, err := s.repo.ListEnergyLogsByUser(userID, days, p.Offset(), p.PageSize) - if err != nil { - return nil, 0, err - } - if logs == nil { - logs = []model.EnergyLog{} - } - return logs, total, nil -} - -// GetAdminEnergyLogs 后台查询全部域能流水 -func (s *EnergyService) GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) { - p := common.Pagination{Page: page, PageSize: pageSize} - p.DefaultPagination() - return s.repo.ListAllEnergyLogs(energyType, p.Offset(), p.PageSize) -} - -// GetFundBalance 获取公户余额 -func (s *EnergyService) GetFundBalance() (int, error) { - if s.fundRepo == nil { - return 0, nil - } - return s.fundRepo.GetBalance() -} - -// GetFundLogs 分页查询公户流水 -func (s *EnergyService) GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) { - p := common.Pagination{Page: page, PageSize: pageSize} - p.DefaultPagination() - if s.fundRepo == nil { - return []model.FundLog{}, 0, nil - } - logs, total, err := s.fundRepo.ListLogs(logType, p.Offset(), p.PageSize) - if err != nil { - return nil, 0, err - } - if logs == nil { - logs = []model.FundLog{} - } - return logs, total, nil -} diff --git a/internal/service/favorite_items.go b/internal/service/favorite_items.go new file mode 100644 index 0000000..a0382e0 --- /dev/null +++ b/internal/service/favorite_items.go @@ -0,0 +1,146 @@ +package service + +import ( + "fmt" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" +) + +// AddToFolder 将文章加入收藏夹(会先移除该文章在其他收藏夹的收藏) +func (s *FavoriteService) AddToFolder(userID, folderID, postID uint) error { + // 检查收藏夹归属 + f, err := s.repo.FindFolderByID(folderID) + if err != nil { + return common.ErrPostNotFound + } + if f.UserID != userID { + return common.ErrPermissionDenied + } + + return s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + + // 如果该文章已在其他收藏夹中,先移除 + existing, err := txRepo.FindItemByUserAndPost(userID, postID) + if err == nil && existing.FolderID != folderID { + if err := txRepo.DeleteItem(existing.FolderID, postID); err != nil { + return fmt.Errorf("移除旧收藏: %w", err) + } + // 旧收藏夹没有增加计数,所以不需要减 + } + + // 检查是否已在目标收藏夹中 + if err == nil && existing.FolderID == folderID { + return fmt.Errorf("文章已在此收藏夹中") + } + + item := &model.FolderItem{ + FolderID: folderID, + PostID: postID, + UserID: userID, + } + if err := txRepo.CreateItem(item); err != nil { + return fmt.Errorf("添加收藏: %w", err) + } + + return txRepo.IncrPostFavoritesCount(postID, 1) + }) +} + +// RemoveFromFolder 从收藏夹移除文章 +func (s *FavoriteService) RemoveFromFolder(userID, folderID, postID uint) error { + f, err := s.repo.FindFolderByID(folderID) + if err != nil { + return common.ErrPostNotFound + } + if f.UserID != userID { + return common.ErrPermissionDenied + } + + return s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + if err := txRepo.DeleteItem(folderID, postID); err != nil { + return fmt.Errorf("移除收藏: %w", err) + } + return txRepo.IncrPostFavoritesCount(postID, -1) + }) +} + +// AddFavorite 收藏文章(自动选择或创建默认收藏夹) +func (s *FavoriteService) AddFavorite(userID, postID uint, folderID *uint) error { + var targetFolderID uint + + if folderID != nil && *folderID > 0 { + targetFolderID = *folderID + } else { + // 使用或创建默认收藏夹 + f, err := s.ensureDefaultFolder(userID) + if err != nil { + return err + } + targetFolderID = f.ID + } + + return s.AddToFolder(userID, targetFolderID, postID) +} + +// RemoveFavorite 取消收藏文章 +func (s *FavoriteService) RemoveFavorite(userID, postID uint) error { + existing, err := s.repo.FindItemByUserAndPost(userID, postID) + if err != nil { + return fmt.Errorf("未收藏该文章") + } + return s.RemoveFromFolder(userID, existing.FolderID, postID) +} + +// ListFolderItems 列收藏夹内的文章 +func (s *FavoriteService) ListFolderItems(userID, folderID uint, page, pageSize int) (*FolderItemsResult, error) { + f, err := s.repo.FindFolderByID(folderID) + if err != nil { + return nil, common.ErrPostNotFound + } + + // 权限检查:非本人 + 非公开 → 拒绝 + if f.UserID != userID && !f.IsPublic { + return nil, common.ErrPermissionDenied + } + + p := common.Pagination{Page: page, PageSize: pageSize} + p.DefaultPagination() + + items, total, err := s.repo.ListItemsByFolder(folderID, p.Offset(), p.PageSize) + if err != nil { + return nil, fmt.Errorf("查询收藏内容: %w", err) + } + if items == nil { + items = []model.FolderItem{} + } + + return &FolderItemsResult{ + Items: items, + Total: total, + Page: p.Page, + TotalPages: common.PageCount(total, p.PageSize), + }, nil +} + +// ToggleFavorite 切换收藏(有则取消,无则加入默认收藏夹) +func (s *FavoriteService) ToggleFavorite(userID, postID uint) (bool, error) { + existing, err := s.repo.FindItemByUserAndPost(userID, postID) + if err == nil { + // 已收藏,取消 + if err := s.RemoveFromFolder(userID, existing.FolderID, postID); err != nil { + return false, err + } + return false, nil + } + + // 未收藏,加入默认 + if err := s.AddFavorite(userID, postID, nil); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/service/favorite_service.go b/internal/service/favorite_service.go index 110c680..5265e56 100644 --- a/internal/service/favorite_service.go +++ b/internal/service/favorite_service.go @@ -206,124 +206,6 @@ func (s *FavoriteService) DeleteFolder(userID, folderID uint) error { }) } -// AddToFolder 将文章加入收藏夹(会先移除该文章在其他收藏夹的收藏) -func (s *FavoriteService) AddToFolder(userID, folderID, postID uint) error { - // 检查收藏夹归属 - f, err := s.repo.FindFolderByID(folderID) - if err != nil { - return common.ErrPostNotFound - } - if f.UserID != userID { - return common.ErrPermissionDenied - } - - return s.db.Transaction(func(tx *gorm.DB) error { - txRepo := s.repo.WithTx(tx) - - // 如果该文章已在其他收藏夹中,先移除 - existing, err := txRepo.FindItemByUserAndPost(userID, postID) - if err == nil && existing.FolderID != folderID { - if err := txRepo.DeleteItem(existing.FolderID, postID); err != nil { - return fmt.Errorf("移除旧收藏: %w", err) - } - // 旧收藏夹没有增加计数,所以不需要减 - } - - // 检查是否已在目标收藏夹中 - if err == nil && existing.FolderID == folderID { - return fmt.Errorf("文章已在此收藏夹中") - } - - item := &model.FolderItem{ - FolderID: folderID, - PostID: postID, - UserID: userID, - } - if err := txRepo.CreateItem(item); err != nil { - return fmt.Errorf("添加收藏: %w", err) - } - - return txRepo.IncrPostFavoritesCount(postID, 1) - }) -} - -// RemoveFromFolder 从收藏夹移除文章 -func (s *FavoriteService) RemoveFromFolder(userID, folderID, postID uint) error { - f, err := s.repo.FindFolderByID(folderID) - if err != nil { - return common.ErrPostNotFound - } - if f.UserID != userID { - return common.ErrPermissionDenied - } - - return s.db.Transaction(func(tx *gorm.DB) error { - txRepo := s.repo.WithTx(tx) - if err := txRepo.DeleteItem(folderID, postID); err != nil { - return fmt.Errorf("移除收藏: %w", err) - } - return txRepo.IncrPostFavoritesCount(postID, -1) - }) -} - -// AddFavorite 收藏文章(自动选择或创建默认收藏夹) -func (s *FavoriteService) AddFavorite(userID, postID uint, folderID *uint) error { - var targetFolderID uint - - if folderID != nil && *folderID > 0 { - targetFolderID = *folderID - } else { - // 使用或创建默认收藏夹 - f, err := s.ensureDefaultFolder(userID) - if err != nil { - return err - } - targetFolderID = f.ID - } - - return s.AddToFolder(userID, targetFolderID, postID) -} - -// RemoveFavorite 取消收藏文章 -func (s *FavoriteService) RemoveFavorite(userID, postID uint) error { - existing, err := s.repo.FindItemByUserAndPost(userID, postID) - if err != nil { - return fmt.Errorf("未收藏该文章") - } - return s.RemoveFromFolder(userID, existing.FolderID, postID) -} - -// ListFolderItems 列收藏夹内的文章 -func (s *FavoriteService) ListFolderItems(userID, folderID uint, page, pageSize int) (*FolderItemsResult, error) { - f, err := s.repo.FindFolderByID(folderID) - if err != nil { - return nil, common.ErrPostNotFound - } - - // 权限检查:非本人 + 非公开 → 拒绝 - if f.UserID != userID && !f.IsPublic { - return nil, common.ErrPermissionDenied - } - - p := common.Pagination{Page: page, PageSize: pageSize} - p.DefaultPagination() - - items, total, err := s.repo.ListItemsByFolder(folderID, p.Offset(), p.PageSize) - if err != nil { - return nil, fmt.Errorf("查询收藏内容: %w", err) - } - if items == nil { - items = []model.FolderItem{} - } - - return &FolderItemsResult{ - Items: items, - Total: total, - Page: p.Page, - TotalPages: common.PageCount(total, p.PageSize), - }, nil -} - // GetPostFavoriteStatus 查询文章收藏状态 func (s *FavoriteService) GetPostFavoriteStatus(userID, postID uint) (*FavoriteStatus, error) { item, err := s.repo.FindItemByUserAndPost(userID, postID) @@ -343,21 +225,3 @@ func (s *FavoriteService) GetPostFavoriteStatus(userID, postID uint) (*FavoriteS FolderName: folderName, }, nil } - -// ToggleFavorite 切换收藏(有则取消,无则加入默认收藏夹) -func (s *FavoriteService) ToggleFavorite(userID, postID uint) (bool, error) { - existing, err := s.repo.FindItemByUserAndPost(userID, postID) - if err == nil { - // 已收藏,取消 - if err := s.RemoveFromFolder(userID, existing.FolderID, postID); err != nil { - return false, err - } - return false, nil - } - - // 未收藏,加入默认 - if err := s.AddFavorite(userID, postID, nil); err != nil { - return false, err - } - return true, nil -} diff --git a/internal/service/notification_notify.go b/internal/service/notification_notify.go new file mode 100644 index 0000000..ec3f0aa --- /dev/null +++ b/internal/service/notification_notify.go @@ -0,0 +1,125 @@ +package service + +import ( + "fmt" + "sort" + + "metazone.cc/metalab/internal/model" +) + +// checkAndNotifyAggregation 检查并生成点赞聚合通知 +func (s *NotificationService) checkAndNotifyAggregation(userID uint) { + summaries, err := s.repo.GetUnnotifiedDailyLikeSummaries(userID) + if err != nil || len(summaries) == 0 { + return + } + + for _, sm := range summaries { + count := countUIDs(sm.LikerUIDs) + var content string + if count <= 3 { + content = fmt.Sprintf("你的文章被 %d 人点赞了", count) + } else { + content = fmt.Sprintf("你的文章被 %d 人点赞了", count) + } + _ = s.Create(userID, model.NotifyLikeAggregated, + "点赞汇总通知", + content, + nil, nil) + _ = s.repo.MarkDailyLikeSummaryNotified(sm.ID) + } +} + +func countUIDs(uids string) int { + if uids == "" { + return 0 + } + count := 1 + for _, c := range uids { + if c == ',' { + count++ + } + } + return count +} + +// categoryTypes 分类→通知类型列表 +func categoryTypes(category string) []string { + switch category { + case "system": + return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyLevelUp} + case "mention": + return []string{model.NotifyComment, model.NotifyCommentReply} + case "like": + return []string{model.NotifyLikeAggregated} + case "follow": + return []string{model.NotifyFollow} + default: + return []string{} + } +} + +// sortNotifications 按 CreatedAt 降序排序 +func sortNotifications(items []model.Notification) { + sort.Slice(items, func(i, j int) bool { + return items[i].CreatedAt.After(items[j].CreatedAt) + }) +} + +// NotifyAuditApproved 审核通过通知 +func (s *NotificationService) NotifyAuditApproved(userID uint, auditType string, relatedID uint) { + typeName := model.AuditTypeNames[auditType] + if typeName == "" { + typeName = auditType + } + _ = s.Create(userID, model.NotifyAuditApproved, + "审核已通过", + "你的"+typeName+"修改已通过审核", + &relatedID, nil) +} + +// NotifyAuditRejected 审核拒绝通知 +func (s *NotificationService) NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint) { + typeName := model.AuditTypeNames[auditType] + if typeName == "" { + typeName = auditType + } + content := "你的" + typeName + "修改被拒绝" + if reason != "" { + content += ",理由:" + reason + } + _ = s.Create(userID, model.NotifyAuditRejected, + "审核未通过", + content, + &relatedID, nil) +} + +// NotifyPostApproved 稿件审核通过通知 +func (s *NotificationService) NotifyPostApproved(userID uint, postTitle string, postID uint) { + title := "稿件审核通过" + content := "你的稿件《" + postTitle + "》已通过审核,现已公开发布" + _ = s.Create(userID, model.NotifyPostApproved, title, content, &postID, nil) +} + +// NotifyPostRejected 稿件审核拒绝通知 +func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason string, postID uint) { + title := "稿件审核未通过" + content := "你的稿件《" + postTitle + "》未通过审核" + if reason != "" { + content += ",理由:" + reason + } + _ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil) +} + +// NotifyFollow 关注通知(followerID 关注了 followeeID) +func (s *NotificationService) NotifyFollow(followerID, followeeID uint) { + // 获取关注者用户名 + followerName := s.getUsername(followerID) + if followerName == "" { + followerName = "一位用户" + } + title := "关注通知" + content := followerName + " 关注了你" + relID := followerID + _ = s.Create(followeeID, model.NotifyFollow, title, content, &relID, nil) +} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 891b818..d5b1dc3 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -2,8 +2,6 @@ package service import ( "encoding/json" - "fmt" - "sort" "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" @@ -167,65 +165,6 @@ func (s *NotificationService) ListByCategory(userID uint, category string, page, }, nil } -// checkAndNotifyAggregation 检查并生成点赞聚合通知 -func (s *NotificationService) checkAndNotifyAggregation(userID uint) { - summaries, err := s.repo.GetUnnotifiedDailyLikeSummaries(userID) - if err != nil || len(summaries) == 0 { - return - } - - for _, sm := range summaries { - count := countUIDs(sm.LikerUIDs) - var content string - if count <= 3 { - content = fmt.Sprintf("你的文章被 %d 人点赞了", count) - } else { - content = fmt.Sprintf("你的文章被 %d 人点赞了", count) - } - _ = s.Create(userID, model.NotifyLikeAggregated, - "点赞汇总通知", - content, - nil, nil) - _ = s.repo.MarkDailyLikeSummaryNotified(sm.ID) - } -} - -func countUIDs(uids string) int { - if uids == "" { - return 0 - } - count := 1 - for _, c := range uids { - if c == ',' { - count++ - } - } - return count -} - -// categoryTypes 分类→通知类型列表 -func categoryTypes(category string) []string { - switch category { - case "system": - return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyLevelUp} - case "mention": - return []string{model.NotifyComment, model.NotifyCommentReply} - case "like": - return []string{model.NotifyLikeAggregated} - case "follow": - return []string{model.NotifyFollow} - default: - return []string{} - } -} - -// sortNotifications 按 CreatedAt 降序排序 -func sortNotifications(items []model.Notification) { - sort.Slice(items, func(i, j int) bool { - return items[i].CreatedAt.After(items[j].CreatedAt) - }) -} - // CountUnread 统计未读消息数(排除用户禁用的通知类型) func (s *NotificationService) CountUnread(userID uint) (int64, error) { excludeTypes := s.getDisabledTypes(userID) @@ -263,64 +202,6 @@ func (s *NotificationService) MarkAllRead(userID uint) error { return s.repo.MarkAllRead(userID) } -// NotifyAuditApproved 审核通过通知 -func (s *NotificationService) NotifyAuditApproved(userID uint, auditType string, relatedID uint) { - typeName := model.AuditTypeNames[auditType] - if typeName == "" { - typeName = auditType - } - _ = s.Create(userID, model.NotifyAuditApproved, - "审核已通过", - "你的"+typeName+"修改已通过审核", - &relatedID, nil) -} - -// NotifyAuditRejected 审核拒绝通知 -func (s *NotificationService) NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint) { - typeName := model.AuditTypeNames[auditType] - if typeName == "" { - typeName = auditType - } - content := "你的" + typeName + "修改被拒绝" - if reason != "" { - content += ",理由:" + reason - } - _ = s.Create(userID, model.NotifyAuditRejected, - "审核未通过", - content, - &relatedID, nil) -} - -// NotifyPostApproved 稿件审核通过通知 -func (s *NotificationService) NotifyPostApproved(userID uint, postTitle string, postID uint) { - title := "稿件审核通过" - content := "你的稿件《" + postTitle + "》已通过审核,现已公开发布" - _ = s.Create(userID, model.NotifyPostApproved, title, content, &postID, nil) -} - -// NotifyPostRejected 稿件审核拒绝通知 -func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason string, postID uint) { - title := "稿件审核未通过" - content := "你的稿件《" + postTitle + "》未通过审核" - if reason != "" { - content += ",理由:" + reason - } - _ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil) -} - -// NotifyFollow 关注通知(followerID 关注了 followeeID) -func (s *NotificationService) NotifyFollow(followerID, followeeID uint) { - // 获取关注者用户名 - followerName := s.getUsername(followerID) - if followerName == "" { - followerName = "一位用户" - } - title := "关注通知" - content := followerName + " 关注了你" - relID := followerID - _ = s.Create(followeeID, model.NotifyFollow, title, content, &relID, nil) -} - // getUsername 获取用户名(辅助方法) func (s *NotificationService) getUsername(userID uint) string { name, err := s.repo.GetUsernameByID(userID) diff --git a/internal/service/post_audit_service.go b/internal/service/post_audit_service.go new file mode 100644 index 0000000..2ca9377 --- /dev/null +++ b/internal/service/post_audit_service.go @@ -0,0 +1,186 @@ +package service + +import ( + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" +) + +// Update 编辑帖子(权限在 controller 层检查) +func (s *PostService) Update(postID uint, title, body string) error { + post, err := s.findPost(postID) + if err != nil { + return err + } + + if post.Status == model.PostStatusPending || post.IsLocked { + return common.ErrPostCannotEdit + } + + // 已通过帖子:编辑内容保存为待审修订,不影响当前展示 + if post.Status == model.PostStatusApproved { + post.PendingTitle = title + post.PendingBody = body + post.RejectReason = "" // 清空旧退回理由 + return s.repo.Update(post) + } + + // 草稿/已退回:直接修改原文(现有逻辑) + post.Title = title + post.Body = body + post.Excerpt = generateExcerpt(body) + + if post.Status == model.PostStatusRejected { + post.Status = model.PostStatusDraft + post.RejectReason = "" + } + + return s.repo.Update(post) +} + +// Delete 软删除(权限在 controller 层检查) +func (s *PostService) Delete(postID uint) error { + return s.repo.SoftDelete(postID) +} + +// SubmitForAudit 提交审核:draft → pending +func (s *PostService) SubmitForAudit(postID uint) error { + post, err := s.findPost(postID) + if err != nil { + return err + } + if post.IsLocked { + return common.ErrPostCannotEdit + } + if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected { + return common.ErrPostCannotSubmit + } + post.Status = model.PostStatusPending + return s.repo.Update(post) +} + +// Approve 审核通过:pending → approved;或修订审核通过复制 Pending 到正式字段 +func (s *PostService) Approve(postID uint) error { + post, err := s.findPost(postID) + if err != nil { + return err + } + + wasRevision := post.PendingBody != "" + + // 修订审核:将待审内容覆盖到正式字段 + if wasRevision { + post.Title = post.PendingTitle + post.Body = post.PendingBody + post.Excerpt = generateExcerpt(post.PendingBody) + post.PendingTitle = "" + post.PendingBody = "" + post.Status = model.PostStatusApproved + post.RejectReason = "" + if err := s.repo.Update(post); err != nil { + return err + } + // 通知作者修订审核通过 + if s.notifier != nil { + s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID) + } + return nil + } + + // 首次审核通过 + if post.Status != model.PostStatusPending { + return common.ErrPostCannotApprove + } + post.Status = model.PostStatusApproved + post.RejectReason = "" + if err := s.repo.Update(post); err != nil { + return err + } + // 通知作者审核通过 + if s.notifier != nil { + s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID) + } + return nil +} + +// Reject 退回:pending/approved → rejected;修订退回则清空 Pending 保持 approved +func (s *PostService) Reject(postID uint, reason string) error { + post, err := s.findPost(postID) + if err != nil { + return err + } + if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved { + return common.ErrPostCannotReject + } + + // 修订退回:清空待审修订,保持已发布内容不变 + if post.PendingBody != "" { + post.PendingTitle = "" + post.PendingBody = "" + post.RejectReason = reason + if err := s.repo.Update(post); err != nil { + return err + } + // 通知作者修订被退回 + if s.notifier != nil { + s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID) + } + return nil + } + + // 普通退回(首次审核) + post.Status = model.PostStatusRejected + post.RejectReason = reason + if err := s.repo.Update(post); err != nil { + return err + } + // 通知作者审核被退回 + if s.notifier != nil { + s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID) + } + return nil +} + +// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态 +func (s *PostService) Lock(postID uint, reason string) error { + post, err := s.findPost(postID) + if err != nil { + return err + } + // 待审核帖子不允许锁定(审核流程中) + if post.Status == model.PostStatusPending { + return common.ErrPostCannotLock + } + post.IsLocked = true + post.LockReason = reason + return s.repo.Update(post) +} + +// Unlock 解锁:清除 IsLocked 标志,恢复可编辑 +func (s *PostService) Unlock(postID uint) error { + post, err := s.findPost(postID) + if err != nil { + return err + } + if !post.IsLocked { + return common.ErrPostCannotUnlock + } + post.IsLocked = false + post.LockReason = "" + return s.repo.Update(post) +} + +// Restore 恢复软删除 +func (s *PostService) Restore(postID uint) error { + err := s.repo.Restore(postID) + if err != nil { + return err + } + return nil +} + +// ListPendingRevisions 查询有待审修订的帖子(approved 且 PendingBody 不为空) +func (s *PostService) ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error) { + return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) { + return s.repo.FindPendingRevisions(keyword, offset, limit) + }) +} diff --git a/internal/service/post_helpers.go b/internal/service/post_helpers.go new file mode 100644 index 0000000..90381e1 --- /dev/null +++ b/internal/service/post_helpers.go @@ -0,0 +1,66 @@ +package service + +import ( + "regexp" + "strings" + "unicode/utf8" +) + +// mdPatterns Markdown 语法正则(用于生成纯文本摘要) +var mdPatterns = []*regexp.Regexp{ + // 代码块 ```...``` + regexp.MustCompile("(?s)```[^`]*```"), + // 行内代码 `...` + regexp.MustCompile("`[^`]+`"), + // 图片 ![alt](url) + regexp.MustCompile(`!\[.*?\]\(.*?\)`), + // 链接 [text](url) + regexp.MustCompile(`\[([^\]]*)\]\(.*?\)`), + // 标题标记 # + regexp.MustCompile(`^#{1,6}\s+`), + // 粗体/斜体/删除线标记 + regexp.MustCompile(`\*{1,3}|_{1,3}|~~`), + // 引用 > + regexp.MustCompile(`^>\s?`), + // 列表标记 + regexp.MustCompile(`^[\s]*[-*+]\s+`), + regexp.MustCompile(`^[\s]*\d+\.\s+`), + // 分割线 + regexp.MustCompile(`^[-*_]{3,}\s*$`), +} + +// generateExcerpt 从 Markdown 生成纯文本摘要(最多 300 字符) +// MD 作为纯文本存储无 XSS 风险,前端由 Vditor 渲染 +func generateExcerpt(md string) string { + text := md + + // 按行处理,保留行结构 + lines := strings.Split(text, "\n") + var cleaned []string + for _, line := range lines { + cleanedLine := line + for _, re := range mdPatterns { + if re == mdPatterns[2] { + // 链接保留文字: [text](url) → text + cleanedLine = re.ReplaceAllString(cleanedLine, "$1") + } else { + cleanedLine = re.ReplaceAllString(cleanedLine, "") + } + } + cleanedLine = strings.TrimSpace(cleanedLine) + if cleanedLine != "" { + cleaned = append(cleaned, cleanedLine) + } + } + + result := strings.Join(cleaned, " ") + + // 按 rune 截断,不切中文字符 + const maxChars = 300 + if utf8.RuneCountInString(result) > maxChars { + runes := []rune(result) + result = string(runes[:maxChars]) + "..." + } + + return result +} diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 683d4a6..53d94b6 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -3,9 +3,6 @@ package service import ( "errors" "fmt" - "regexp" - "strings" - "unicode/utf8" "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/config" @@ -14,65 +11,6 @@ import ( "gorm.io/gorm" ) -// mdPatterns Markdown 语法正则(用于生成纯文本摘要) -var mdPatterns = []*regexp.Regexp{ - // 代码块 ```...``` - regexp.MustCompile("(?s)```[^`]*```"), - // 行内代码 `...` - regexp.MustCompile("`[^`]+`"), - // 图片 ![alt](url) - regexp.MustCompile(`!\[.*?\]\(.*?\)`), - // 链接 [text](url) - regexp.MustCompile(`\[([^\]]*)\]\(.*?\)`), - // 标题标记 # - regexp.MustCompile(`^#{1,6}\s+`), - // 粗体/斜体/删除线标记 - regexp.MustCompile(`\*{1,3}|_{1,3}|~~`), - // 引用 > - regexp.MustCompile(`^>\s?`), - // 列表标记 - regexp.MustCompile(`^[\s]*[-*+]\s+`), - regexp.MustCompile(`^[\s]*\d+\.\s+`), - // 分割线 - regexp.MustCompile(`^[-*_]{3,}\s*$`), -} - -// generateExcerpt 从 Markdown 生成纯文本摘要(最多 300 字符) -// MD 作为纯文本存储无 XSS 风险,前端由 Vditor 渲染 -func generateExcerpt(md string) string { - text := md - - // 按行处理,保留行结构 - lines := strings.Split(text, "\n") - var cleaned []string - for _, line := range lines { - cleanedLine := line - for _, re := range mdPatterns { - if re == mdPatterns[2] { - // 链接保留文字: [text](url) → text - cleanedLine = re.ReplaceAllString(cleanedLine, "$1") - } else { - cleanedLine = re.ReplaceAllString(cleanedLine, "") - } - } - cleanedLine = strings.TrimSpace(cleanedLine) - if cleanedLine != "" { - cleaned = append(cleaned, cleanedLine) - } - } - - result := strings.Join(cleaned, " ") - - // 按 rune 截断,不切中文字符 - const maxChars = 300 - if utf8.RuneCountInString(result) > maxChars { - runes := []rune(result) - result = string(runes[:maxChars]) + "..." - } - - return result -} - // PostService 帖子业务逻辑 type PostService struct { repo postStore @@ -202,189 +140,6 @@ func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) { }, nil } -// Update 编辑帖子(权限在 controller 层检查) -func (s *PostService) Update(postID uint, title, body string) error { - post, err := s.findPost(postID) - if err != nil { - return err - } - - if post.Status == model.PostStatusPending || post.IsLocked { - return common.ErrPostCannotEdit - } - - // 已通过帖子:编辑内容保存为待审修订,不影响当前展示 - if post.Status == model.PostStatusApproved { - post.PendingTitle = title - post.PendingBody = body - post.RejectReason = "" // 清空旧退回理由 - return s.repo.Update(post) - } - - // 草稿/已退回:直接修改原文(现有逻辑) - post.Title = title - post.Body = body - post.Excerpt = generateExcerpt(body) - - if post.Status == model.PostStatusRejected { - post.Status = model.PostStatusDraft - post.RejectReason = "" - } - - return s.repo.Update(post) -} - -// Delete 软删除(权限在 controller 层检查) -func (s *PostService) Delete(postID uint) error { - return s.repo.SoftDelete(postID) -} - -// SubmitForAudit 提交审核:draft → pending -func (s *PostService) SubmitForAudit(postID uint) error { - post, err := s.findPost(postID) - if err != nil { - return err - } - if post.IsLocked { - return common.ErrPostCannotEdit - } - if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected { - return common.ErrPostCannotSubmit - } - post.Status = model.PostStatusPending - return s.repo.Update(post) -} - -// Approve 审核通过:pending → approved;或修订审核通过复制 Pending 到正式字段 -func (s *PostService) Approve(postID uint) error { - post, err := s.findPost(postID) - if err != nil { - return err - } - - wasRevision := post.PendingBody != "" - - // 修订审核:将待审内容覆盖到正式字段 - if wasRevision { - post.Title = post.PendingTitle - post.Body = post.PendingBody - post.Excerpt = generateExcerpt(post.PendingBody) - post.PendingTitle = "" - post.PendingBody = "" - post.Status = model.PostStatusApproved - post.RejectReason = "" - if err := s.repo.Update(post); err != nil { - return err - } - // 通知作者修订审核通过 - if s.notifier != nil { - s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID) - } - return nil - } - - // 首次审核通过 - if post.Status != model.PostStatusPending { - return common.ErrPostCannotApprove - } - post.Status = model.PostStatusApproved - post.RejectReason = "" - if err := s.repo.Update(post); err != nil { - return err - } - // 通知作者审核通过 - if s.notifier != nil { - s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID) - } - return nil -} - -// Reject 退回:pending/approved → rejected;修订退回则清空 Pending 保持 approved -func (s *PostService) Reject(postID uint, reason string) error { - post, err := s.findPost(postID) - if err != nil { - return err - } - if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved { - return common.ErrPostCannotReject - } - - // 修订退回:清空待审修订,保持已发布内容不变 - if post.PendingBody != "" { - post.PendingTitle = "" - post.PendingBody = "" - post.RejectReason = reason - if err := s.repo.Update(post); err != nil { - return err - } - // 通知作者修订被退回 - if s.notifier != nil { - s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID) - } - return nil - } - - // 普通退回(首次审核) - post.Status = model.PostStatusRejected - post.RejectReason = reason - if err := s.repo.Update(post); err != nil { - return err - } - // 通知作者审核被退回 - if s.notifier != nil { - s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID) - } - return nil -} - -// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态 -func (s *PostService) Lock(postID uint, reason string) error { - post, err := s.findPost(postID) - if err != nil { - return err - } - // 待审核帖子不允许锁定(审核流程中) - if post.Status == model.PostStatusPending { - return common.ErrPostCannotLock - } - post.IsLocked = true - post.LockReason = reason - return s.repo.Update(post) -} - -// Unlock 解锁:清除 IsLocked 标志,恢复可编辑 -func (s *PostService) Unlock(postID uint) error { - post, err := s.findPost(postID) - if err != nil { - return err - } - if !post.IsLocked { - return common.ErrPostCannotUnlock - } - post.IsLocked = false - post.LockReason = "" - return s.repo.Update(post) -} - -// Restore 恢复软删除 -func (s *PostService) Restore(postID uint) error { - err := s.repo.Restore(postID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return common.ErrPostNotFound - } - return err - } - return nil -} - -// ListPendingRevisions 查询有待审修订的帖子(approved 且 PendingBody 不为空) -func (s *PostService) ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error) { - return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) { - return s.repo.FindPendingRevisions(keyword, offset, limit) - }) -} - // IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+) func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool { return userID == postUserID || model.HasMinRole(role, model.RoleModerator)