diff --git a/internal/controller/admin/admin_energy_api_controller.go b/internal/controller/admin/admin_energy_api_controller.go new file mode 100644 index 0000000..6ccaa1a --- /dev/null +++ b/internal/controller/admin/admin_energy_api_controller.go @@ -0,0 +1,140 @@ +package admin + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// AdjustEnergy 调整用户域能 +func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) { + operatorUID, ok := c.Get("uid") + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + var req struct { + UserIDs []uint `json:"user_ids" binding:"required,min=1"` + Amount int `json:"amount" binding:"required"` + Description string `json:"description" binding:"required,min=1,max=500"` + Mode string `json:"mode"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description") + return + } + + // 默认模式为 admin_transfer + if req.Mode == "" { + req.Mode = model.FundTypeAdminTransfer + } + + // system_operation 仅 owner 可用 + if req.Mode == model.FundTypeSystemOperation { + role, _ := c.Get("role") + if !model.HasMinRole(role.(string), model.RoleOwner) { + common.Error(c, http.StatusForbidden, "仅站长可使用系统操作模式") + return + } + } + + if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil { + if err == common.ErrInsufficientFund { + common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作") + return + } + common.Error(c, http.StatusInternalServerError, "调整失败") + return + } + + common.OkMessage(c, "域能调整成功") +} + +// ListEnergyLogs 查询域能日志 +func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) { + energyType := c.Query("type") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + logs, total, err := ac.energySvc.GetAdminEnergyLogs(energyType, page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + + // 转换为展示友好的视图 + type logView struct { + model.EnergyLog + EnergyDisplay float64 `json:"energy_display"` + TypeName string `json:"type_name"` + } + views := make([]logView, len(logs)) + for i, l := range logs { + views[i] = logView{ + EnergyLog: l, + EnergyDisplay: float64(l.Amount) / 10, + TypeName: model.EnergyLogDisplayNames[l.Type], + } + } + + common.Ok(c, gin.H{ + "items": views, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": common.PageCount(total, pageSize), + }) +} + +// GetFundBalance 获取公户余额 +func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) { + balance, err := ac.energySvc.GetFundBalance() + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + common.Ok(c, gin.H{ + "balance": balance, + "balance_display": float64(balance) / 10, + }) +} + +// ListFundLogs 查询公户流水 +func (ac *AdminEnergyController) ListFundLogs(c *gin.Context) { + logType := c.Query("type") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + logs, total, err := ac.energySvc.GetFundLogs(logType, page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + + type logView struct { + model.FundLog + AmountDisplay float64 `json:"amount_display"` + TypeName string `json:"type_name"` + } + views := make([]logView, len(logs)) + for i, l := range logs { + views[i] = logView{ + FundLog: l, + AmountDisplay: float64(l.Amount) / 10, + TypeName: model.FundLogDisplayNames[l.Type], + } + } + + common.Ok(c, gin.H{ + "items": views, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": common.PageCount(total, pageSize), + }) +} diff --git a/internal/controller/admin/admin_energy_controller.go b/internal/controller/admin/admin_energy_controller.go index 2dd7784..f0e0ade 100644 --- a/internal/controller/admin/admin_energy_controller.go +++ b/internal/controller/admin/admin_energy_controller.go @@ -2,7 +2,6 @@ package admin import ( "net/http" - "strconv" "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" @@ -10,14 +9,6 @@ import ( "github.com/gin-gonic/gin" ) -// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:4 个方法) -type adminEnergyUseCase interface { - AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error - GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) - GetFundBalance() (int, error) - GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) -} - // AdminEnergyController 后台域能管理控制器 type AdminEnergyController struct { energySvc adminEnergyUseCase @@ -62,132 +53,3 @@ func (ac *AdminEnergyController) FundLogPage(c *gin.Context) { "LogTypes": model.FundLogDisplayNames, })) } - -// AdjustEnergy 调整用户域能 -func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) { - operatorUID, ok := c.Get("uid") - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - var req struct { - UserIDs []uint `json:"user_ids" binding:"required,min=1"` - Amount int `json:"amount" binding:"required"` - Description string `json:"description" binding:"required,min=1,max=500"` - Mode string `json:"mode"` - } - if err := c.ShouldBindJSON(&req); err != nil { - common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description") - return - } - - // 默认模式为 admin_transfer - if req.Mode == "" { - req.Mode = model.FundTypeAdminTransfer - } - - // system_operation 仅 owner 可用 - if req.Mode == model.FundTypeSystemOperation { - role, _ := c.Get("role") - if !model.HasMinRole(role.(string), model.RoleOwner) { - common.Error(c, http.StatusForbidden, "仅站长可使用系统操作模式") - return - } - } - - if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil { - if err == common.ErrInsufficientFund { - common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作") - return - } - common.Error(c, http.StatusInternalServerError, "调整失败") - return - } - - common.OkMessage(c, "域能调整成功") -} - -// ListEnergyLogs 查询域能日志 -func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) { - energyType := c.Query("type") - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - - logs, total, err := ac.energySvc.GetAdminEnergyLogs(energyType, page, pageSize) - if err != nil { - common.Error(c, http.StatusInternalServerError, "查询失败") - return - } - - // 转换为展示友好的视图 - type logView struct { - model.EnergyLog - EnergyDisplay float64 `json:"energy_display"` - TypeName string `json:"type_name"` - } - views := make([]logView, len(logs)) - for i, l := range logs { - views[i] = logView{ - EnergyLog: l, - EnergyDisplay: float64(l.Amount) / 10, - TypeName: model.EnergyLogDisplayNames[l.Type], - } - } - - common.Ok(c, gin.H{ - "items": views, - "total": total, - "page": page, - "page_size": pageSize, - "total_pages": common.PageCount(total, pageSize), - }) -} - -// GetFundBalance 获取公户余额 -func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) { - balance, err := ac.energySvc.GetFundBalance() - if err != nil { - common.Error(c, http.StatusInternalServerError, "查询失败") - return - } - common.Ok(c, gin.H{ - "balance": balance, - "balance_display": float64(balance) / 10, - }) -} - -// ListFundLogs 查询公户流水 -func (ac *AdminEnergyController) ListFundLogs(c *gin.Context) { - logType := c.Query("type") - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - - logs, total, err := ac.energySvc.GetFundLogs(logType, page, pageSize) - if err != nil { - common.Error(c, http.StatusInternalServerError, "查询失败") - return - } - - type logView struct { - model.FundLog - AmountDisplay float64 `json:"amount_display"` - TypeName string `json:"type_name"` - } - views := make([]logView, len(logs)) - for i, l := range logs { - views[i] = logView{ - FundLog: l, - AmountDisplay: float64(l.Amount) / 10, - TypeName: model.FundLogDisplayNames[l.Type], - } - } - - common.Ok(c, gin.H{ - "items": views, - "total": total, - "page": page, - "page_size": pageSize, - "total_pages": common.PageCount(total, pageSize), - }) -} diff --git a/internal/controller/admin/admin_post_action_controller.go b/internal/controller/admin/admin_post_action_controller.go new file mode 100644 index 0000000..01030e7 --- /dev/null +++ b/internal/controller/admin/admin_post_action_controller.go @@ -0,0 +1,124 @@ +package admin + +import ( + "errors" + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// Approve 审核通过 +func (ctrl *AdminPostController) Approve(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + if err := ctrl.postService.Approve(uint(id)); err != nil { + if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "审核失败") + } + return + } + + common.OkMessage(c, "审核通过") +} + +// Reject 退回 +func (ctrl *AdminPostController) Reject(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + var req model.PostRejectRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "请填写退回理由") + return + } + + if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil { + if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "退回失败") + } + return + } + + common.OkMessage(c, "已退回") +} + +// Lock 锁定 +func (ctrl *AdminPostController) Lock(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + var req model.PostLockRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "请填写锁定理由") + return + } + + if err := ctrl.postService.Lock(uint(id), req.Reason); err != nil { + if errors.Is(err, common.ErrPostNotFound) || errors.Is(err, common.ErrPostCannotLock) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "锁定失败") + } + return + } + + common.OkMessage(c, "已锁定") +} + +// Unlock 解锁 +func (ctrl *AdminPostController) Unlock(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + if err := ctrl.postService.Unlock(uint(id)); err != nil { + if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "解锁失败") + } + return + } + + common.OkMessage(c, "已解锁") +} + +// Restore 恢复软删除 +func (ctrl *AdminPostController) Restore(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + if err := ctrl.postService.Restore(uint(id)); err != nil { + if errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "恢复失败") + } + return + } + + common.OkMessage(c, "已恢复") +} diff --git a/internal/controller/admin/admin_post_controller.go b/internal/controller/admin/admin_post_controller.go index 7e7633d..6bd4a29 100644 --- a/internal/controller/admin/admin_post_controller.go +++ b/internal/controller/admin/admin_post_controller.go @@ -1,12 +1,10 @@ package admin import ( - "errors" "net/http" "strconv" "metazone.cc/metalab/internal/common" - "metazone.cc/metalab/internal/model" "github.com/gin-gonic/gin" ) @@ -61,115 +59,3 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) { "ExtraCSS": "/admin/static/css/posts.css", })) } - -// Approve 审核通过 -func (ctrl *AdminPostController) Approve(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - if err := ctrl.postService.Approve(uint(id)); err != nil { - if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) { - common.Error(c, http.StatusBadRequest, err.Error()) - } else { - common.Error(c, http.StatusInternalServerError, "审核失败") - } - return - } - - common.OkMessage(c, "审核通过") -} - -// Reject 退回 -func (ctrl *AdminPostController) Reject(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - var req model.PostRejectRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.Error(c, http.StatusBadRequest, "请填写退回理由") - return - } - - if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil { - if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) { - common.Error(c, http.StatusBadRequest, err.Error()) - } else { - common.Error(c, http.StatusInternalServerError, "退回失败") - } - return - } - - common.OkMessage(c, "已退回") -} - -// Lock 锁定 -func (ctrl *AdminPostController) Lock(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - var req model.PostLockRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.Error(c, http.StatusBadRequest, "请填写锁定理由") - return - } - - if err := ctrl.postService.Lock(uint(id), req.Reason); err != nil { - if errors.Is(err, common.ErrPostNotFound) || errors.Is(err, common.ErrPostCannotLock) { - common.Error(c, http.StatusBadRequest, err.Error()) - } else { - common.Error(c, http.StatusInternalServerError, "锁定失败") - } - return - } - - common.OkMessage(c, "已锁定") -} - -// Unlock 解锁 -func (ctrl *AdminPostController) Unlock(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - if err := ctrl.postService.Unlock(uint(id)); err != nil { - if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) { - common.Error(c, http.StatusBadRequest, err.Error()) - } else { - common.Error(c, http.StatusInternalServerError, "解锁失败") - } - return - } - - common.OkMessage(c, "已解锁") -} - -// Restore 恢复软删除 -func (ctrl *AdminPostController) Restore(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的帖子 ID") - return - } - - if err := ctrl.postService.Restore(uint(id)); err != nil { - if errors.Is(err, common.ErrPostNotFound) { - common.Error(c, http.StatusBadRequest, err.Error()) - } else { - common.Error(c, http.StatusInternalServerError, "恢复失败") - } - return - } - - common.OkMessage(c, "已恢复") -} diff --git a/internal/controller/admin/interfaces.go b/internal/controller/admin/interfaces.go index 625c5c4..a626d88 100644 --- a/internal/controller/admin/interfaces.go +++ b/internal/controller/admin/interfaces.go @@ -52,3 +52,11 @@ type adminCommentUseCase interface { ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error) Delete(commentID uint, requesterID uint, isAdmin bool) error } + +// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:4 个方法) +type adminEnergyUseCase interface { + AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error + GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) + GetFundBalance() (int, error) + GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) +} diff --git a/internal/controller/favorite_controller.go b/internal/controller/favorite_controller.go index 4f9187e..cffc03a 100644 --- a/internal/controller/favorite_controller.go +++ b/internal/controller/favorite_controller.go @@ -5,25 +5,10 @@ import ( "strconv" "metazone.cc/metalab/internal/common" - "metazone.cc/metalab/internal/model" - "metazone.cc/metalab/internal/service" "github.com/gin-gonic/gin" ) -// favoriteUseCase FavoriteController 对 FavoriteService 的最小依赖(ISP) -type favoriteUseCase interface { - ListFolders(userID uint) (*service.FavoriteListResult, error) - CreateFolder(userID uint, name, description string, isPublic bool) (*model.Folder, error) - UpdateFolder(userID, folderID uint, name, description string, isPublic bool) error - DeleteFolder(userID, folderID uint) error - ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error) - AddFavorite(userID, postID uint, folderID *uint) error - RemoveFavorite(userID, postID uint) error - ToggleFavorite(userID, postID uint) (bool, error) - GetPostFavoriteStatus(userID, postID uint) (*service.FavoriteStatus, error) -} - // FavoriteController 收藏夹 API 控制器 type FavoriteController struct { svc favoriteUseCase @@ -135,136 +120,3 @@ func (fc *FavoriteController) DeleteFolder(c *gin.Context) { common.OkMessage(c, "收藏夹已删除") } - -// ListFolderItems 获取收藏夹内文章列表 GET /api/folders/:id/posts -func (fc *FavoriteController) ListFolderItems(c *gin.Context) { - uid, _, _ := common.GetGinUser(c) // 允许未登录查看公开收藏夹 - - folderID, 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")) - - result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize) - if err != nil { - if err == common.ErrPermissionDenied { - common.Error(c, http.StatusForbidden, "该收藏夹未公开") - return - } - common.Error(c, http.StatusInternalServerError, "获取失败") - return - } - - common.Ok(c, result) -} - -// AddToFolder 收藏文章 POST /api/folders/:id/posts -func (fc *FavoriteController) AddToFolder(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - folderID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID") - return - } - - var req struct { - PostID uint `json:"post_id"` - } - if err := c.ShouldBindJSON(&req); err != nil || req.PostID == 0 { - common.Error(c, http.StatusBadRequest, "参数错误") - return - } - - if err := fc.svc.AddFavorite(uid, req.PostID, func() *uint { v := uint(folderID); return &v }()); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) - return - } - - common.OkMessage(c, "收藏成功") -} - -// RemoveFromFolder 取消收藏 DELETE /api/folders/:id/posts/:postId -func (fc *FavoriteController) RemoveFromFolder(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - folderID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID") - return - } - - postID, err := strconv.ParseUint(c.Param("postId"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的文章 ID") - return - } - - if err := fc.svc.RemoveFavorite(uid, uint(postID)); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) - return - } - - _ = folderID // 移除不依赖具体收藏夹 - common.OkMessage(c, "已取消收藏") -} - -// GetPostStatus 查询文章收藏状态 GET /api/posts/:id/folder-status -func (fc *FavoriteController) GetPostStatus(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Ok(c, gin.H{"favorited": false}) - return - } - - postID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - common.Error(c, http.StatusBadRequest, "无效的文章 ID") - return - } - - status, err := fc.svc.GetPostFavoriteStatus(uid, uint(postID)) - if err != nil { - common.Error(c, http.StatusInternalServerError, "查询失败") - return - } - - common.Ok(c, status) -} - -// ToggleFavorite 切换收藏 POST /api/posts/:id/favorite -func (fc *FavoriteController) ToggleFavorite(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 - } - - isFavored, err := fc.svc.ToggleFavorite(uid, uint(postID)) - if err != nil { - common.Error(c, http.StatusInternalServerError, "操作失败") - return - } - - common.Ok(c, gin.H{ - "favorited": isFavored, - }) -} diff --git a/internal/controller/favorite_item_controller.go b/internal/controller/favorite_item_controller.go new file mode 100644 index 0000000..0f0720d --- /dev/null +++ b/internal/controller/favorite_item_controller.go @@ -0,0 +1,143 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// ListFolderItems 获取收藏夹内文章列表 GET /api/folders/:id/posts +func (fc *FavoriteController) ListFolderItems(c *gin.Context) { + uid, _, _ := common.GetGinUser(c) // 允许未登录查看公开收藏夹 + + folderID, 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")) + + result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize) + if err != nil { + if err == common.ErrPermissionDenied { + common.Error(c, http.StatusForbidden, "该收藏夹未公开") + return + } + common.Error(c, http.StatusInternalServerError, "获取失败") + return + } + + common.Ok(c, result) +} + +// AddToFolder 收藏文章 POST /api/folders/:id/posts +func (fc *FavoriteController) AddToFolder(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + folderID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID") + return + } + + var req struct { + PostID uint `json:"post_id"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.PostID == 0 { + common.Error(c, http.StatusBadRequest, "参数错误") + return + } + + if err := fc.svc.AddFavorite(uid, req.PostID, func() *uint { v := uint(folderID); return &v }()); err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + + common.OkMessage(c, "收藏成功") +} + +// RemoveFromFolder 取消收藏 DELETE /api/folders/:id/posts/:postId +func (fc *FavoriteController) RemoveFromFolder(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + folderID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID") + return + } + + postID, err := strconv.ParseUint(c.Param("postId"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的文章 ID") + return + } + + if err := fc.svc.RemoveFavorite(uid, uint(postID)); err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + + _ = folderID // 移除不依赖具体收藏夹 + common.OkMessage(c, "已取消收藏") +} + +// GetPostStatus 查询文章收藏状态 GET /api/posts/:id/folder-status +func (fc *FavoriteController) GetPostStatus(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Ok(c, gin.H{"favorited": false}) + return + } + + postID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的文章 ID") + return + } + + status, err := fc.svc.GetPostFavoriteStatus(uid, uint(postID)) + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + + common.Ok(c, status) +} + +// ToggleFavorite 切换收藏 POST /api/posts/:id/favorite +func (fc *FavoriteController) ToggleFavorite(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 + } + + isFavored, err := fc.svc.ToggleFavorite(uid, uint(postID)) + if err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + + common.Ok(c, gin.H{ + "favorited": isFavored, + }) +} diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index b154cc7..ff6696f 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -1,6 +1,8 @@ package controller import ( + "io" + "metazone.cc/metalab/internal/middleware" "metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/service" @@ -130,3 +132,56 @@ type followUseCase interface { ListFollowers(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error) ListFollowing(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error) } + +// favoriteUseCase FavoriteController 对 FavoriteService 的最小依赖(ISP:9 个方法) +type favoriteUseCase interface { + ListFolders(userID uint) (*service.FavoriteListResult, error) + CreateFolder(userID uint, name, description string, isPublic bool) (*model.Folder, error) + UpdateFolder(userID, folderID uint, name, description string, isPublic bool) error + DeleteFolder(userID, folderID uint) error + ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error) + AddFavorite(userID, postID uint, folderID *uint) error + RemoveFavorite(userID, postID uint) error + ToggleFavorite(userID, postID uint) (bool, error) + GetPostFavoriteStatus(userID, postID uint) (*service.FavoriteStatus, error) +} + +// profileProvider SettingsController 对 AuthService 的最小依赖(ISP:4 个方法) +type profileProvider interface { + GetProfile(userID uint) (*model.User, error) + UpdateProfile(userID uint, username, bio string) error + ChangePassword(userID uint, currentPassword, newPassword string) error + DeleteAccount(userID uint, password, reason string) error +} + +// avatarProvider SettingsController 头像上传对 Service 的最小依赖(ISP:2 个方法) +type avatarProvider interface { + ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) + ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) +} + +// auditSubmittable SettingsController 对 AuditService 的依赖(ISP:4 个方法) +type auditSubmittable interface { + ShouldAudit(userID uint) (bool, error) + SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error + Submit(userID uint, auditType, newValue string) error + GetPendingTypes(userID uint) ([]string, error) +} + +// sessionConfig SettingsController 对配置的最小依赖(ISP:2 个方法) +type sessionConfig interface { + GetIdleTimeout() int + GetRememberTimeout() int +} + +// taskCompleter SettingsController 对 LevelService 的依赖(ISP:2 个方法) +type taskCompleter interface { + CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) + HasCompletedTask(userID uint, taskType string) (bool, error) +} + +// notifyPrefReader SettingsController 通知偏好的接口(ISP) +type notifyPrefReader interface { + GetNotifyPrefs(userID uint) (map[string]bool, error) + UpdateNotifyPref(userID uint, key string, enabled bool) error +} diff --git a/internal/controller/settings_api_audit.go b/internal/controller/settings_api_audit.go new file mode 100644 index 0000000..5862d98 --- /dev/null +++ b/internal/controller/settings_api_audit.go @@ -0,0 +1,28 @@ +package controller + +import ( + "net/http" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// AuditStatus 查询当前用户的待审核类型(需登录) +func (sc *SettingsController) AuditStatus(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + types, err := sc.auditService.GetPendingTypes(uid.(uint)) + if err != nil { + common.Ok(c, gin.H{"pending_types": []string{}}) + return + } + if types == nil { + types = []string{} + } + common.Ok(c, gin.H{"pending_types": types}) +} diff --git a/internal/controller/settings_api_notify.go b/internal/controller/settings_api_notify.go index 495590c..dff7e55 100644 --- a/internal/controller/settings_api_notify.go +++ b/internal/controller/settings_api_notify.go @@ -9,12 +9,6 @@ import ( "github.com/gin-gonic/gin" ) -// notifyPrefReader 通知偏好读写的接口(ISP) -type notifyPrefReader interface { - GetNotifyPrefs(userID uint) (map[string]bool, error) - UpdateNotifyPref(userID uint, key string, enabled bool) error -} - // SetNotifyPrefReader 注入通知偏好读写器 func (sc *SettingsController) SetNotifyPrefReader(reader notifyPrefReader) { sc.notifyPrefReader = reader diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go index 19800f8..59f78cb 100644 --- a/internal/controller/settings_controller.go +++ b/internal/controller/settings_controller.go @@ -1,7 +1,6 @@ package controller import ( - "io" "net/http" "time" @@ -12,40 +11,6 @@ import ( "github.com/gin-gonic/gin" ) -// profileProvider SettingsController 对 Service 层的最小依赖(ISP:4 个方法) -type profileProvider interface { - GetProfile(userID uint) (*model.User, error) - UpdateProfile(userID uint, username, bio string) error - ChangePassword(userID uint, currentPassword, newPassword string) error - DeleteAccount(userID uint, password, reason string) error -} - -// avatarProvider 头像上传对 Service 层的最小依赖(ISP:2 个方法) -type avatarProvider interface { - ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) - ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) -} - -// auditSubmittable 个人设置对审核服务的依赖(ISP:4 个方法) -type auditSubmittable interface { - ShouldAudit(userID uint) (bool, error) - SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error - Submit(userID uint, auditType, newValue string) error - GetPendingTypes(userID uint) ([]string, error) -} - -// sessionConfig 登录管理对配置的最小依赖(ISP:2 个方法) -type sessionConfig interface { - GetIdleTimeout() int // 分钟 - GetRememberTimeout() int // 分钟 -} - -// taskCompleter 设置页对等级服务的依赖(用于首次任务奖励 + 检查任务) -type taskCompleter interface { - CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) - HasCompletedTask(userID uint, taskType string) (bool, error) -} - // SettingsController 个人设置控制器 type SettingsController struct { authService profileProvider @@ -142,22 +107,3 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data)) } - -// AuditStatus 查询当前用户的待审核类型(需登录) -func (sc *SettingsController) AuditStatus(c *gin.Context) { - uid, exists := c.Get("uid") - if !exists { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - types, err := sc.auditService.GetPendingTypes(uid.(uint)) - if err != nil { - common.Ok(c, gin.H{"pending_types": []string{}}) - return - } - if types == nil { - types = []string{} - } - common.Ok(c, gin.H{"pending_types": types}) -} diff --git a/internal/controller/space_controller.go b/internal/controller/space_controller.go index a3e9ad2..659e675 100644 --- a/internal/controller/space_controller.go +++ b/internal/controller/space_controller.go @@ -5,8 +5,6 @@ import ( "strconv" "metazone.cc/metalab/internal/common" - "metazone.cc/metalab/internal/model" - "metazone.cc/metalab/internal/service" "github.com/gin-gonic/gin" ) @@ -121,153 +119,3 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) { c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, data)) } - -// loadArticlesData 加载文章列表数据 -func (ctrl *SpaceController) loadArticlesData(c *gin.Context, data gin.H, uid uint, page, pageSize int) { - posts, total, err := ctrl.spaceService.GetPostsByUser(uid, page, pageSize) - if err != nil { - posts = []model.Post{} - total = 0 - } - totalPages := common.PageCount(total, pageSize) - data["Posts"] = posts - data["Total"] = total - data["Page"] = page - data["TotalPages"] = totalPages - data["PrevPage"] = max(1, page-1) - data["NextPage"] = min(totalPages, page+1) -} - -// loadFollowingData 加载关注列表数据 -func (ctrl *SpaceController) loadFollowingData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) { - if ctrl.followSvc == nil { - data["FollowingResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} - return - } - res, err := ctrl.followSvc.ListFollowing(targetUID, currentUID, page, pageSize) - if err != nil || res == nil { - res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} - } - data["FollowingResult"] = res -} - -// loadFollowersData 加载粉丝列表数据 -func (ctrl *SpaceController) loadFollowersData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) { - if ctrl.followSvc == nil { - data["FollowersResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} - return - } - res, err := ctrl.followSvc.ListFollowers(targetUID, currentUID, page, pageSize) - if err != nil || res == nil { - res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} - } - data["FollowersResult"] = res -} - -// loadCollectionsData 加载收藏夹数据 -func (ctrl *SpaceController) loadCollectionsData(c *gin.Context, data gin.H, uid uint, page, pageSize int) { - if ctrl.favoriteSvc == nil { - data["FoldersResult"] = &service.FavoriteListResult{Folders: []model.Folder{}} - return - } - - // 收藏夹列表 - folders, err := ctrl.favoriteSvc.ListFolders(uid) - if err != nil || folders == nil { - folders = &service.FavoriteListResult{Folders: []model.Folder{}} - } - data["FoldersResult"] = folders - - // 默认展示第一个收藏夹的内容 - folderParam := c.DefaultQuery("folder", "") - var activeFolderID uint - if folderParam != "" { - fid, parseErr := strconv.ParseUint(folderParam, 10, 64) - if parseErr == nil { - activeFolderID = uint(fid) - } - } - if activeFolderID == 0 && len(folders.Folders) > 0 { - activeFolderID = folders.Folders[0].ID - } - - var folderItems *service.FolderItemsResult - if activeFolderID > 0 { - items, itemErr := ctrl.favoriteSvc.ListFolderItems(uid, activeFolderID, page, pageSize) - if itemErr != nil || items == nil { - folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0} - } else { - folderItems = items - } - } - if folderItems == nil { - folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0} - } - data["FolderItems"] = folderItems - data["ActiveFolderID"] = activeFolderID - - // 找到当前激活的收藏夹对象传给模板 - var activeFolder *model.Folder - for i := range folders.Folders { - if folders.Folders[i].ID == activeFolderID { - activeFolder = &folders.Folders[i] - break - } - } - data["ActiveFolder"] = activeFolder - - data["Page"] = page - data["TotalPages"] = folderItems.TotalPages - data["PrevPage"] = max(1, page-1) - data["NextPage"] = min(folderItems.TotalPages, page+1) -} - -// UpdatePrivacy PUT /api/space/privacy — 更新隐私设置 -func (ctrl *SpaceController) UpdatePrivacy(c *gin.Context) { - uid, _, ok := common.GetGinUser(c) - if !ok { - common.Error(c, http.StatusUnauthorized, "请先登录") - return - } - - var req struct { - Field string `json:"field"` - Value bool `json:"value"` - } - if err := c.ShouldBindJSON(&req); err != nil { - common.Error(c, http.StatusBadRequest, "参数错误") - return - } - - // 支持的字段映射(仅处理已实现的字段) - allowedFields := map[string]string{ - "follow_list": "follow_list_public", - "follower_list": "follower_list_public", - } - - column, ok := allowedFields[req.Field] - if !ok { - common.OkMessage(c, "已保存") // 未实现字段直接返回成功,前端不感知 - return - } - - user, err := ctrl.spaceService.GetSpaceUser(uid) - if err != nil || user == nil { - common.Error(c, http.StatusNotFound, "用户不存在") - return - } - - switch column { - case "follow_list_public": - user.FollowListPublic = req.Value - case "follower_list_public": - user.FollowerListPublic = req.Value - } - - if err := ctrl.spaceService.UpdateUser(user); err != nil { - common.Error(c, http.StatusInternalServerError, "保存失败") - return - } - - common.OkMessage(c, "隐私设置已更新") -} diff --git a/internal/controller/space_loaders.go b/internal/controller/space_loaders.go new file mode 100644 index 0000000..5542184 --- /dev/null +++ b/internal/controller/space_loaders.go @@ -0,0 +1,162 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" + + "github.com/gin-gonic/gin" +) + +// loadArticlesData 加载文章列表数据 +func (ctrl *SpaceController) loadArticlesData(c *gin.Context, data gin.H, uid uint, page, pageSize int) { + posts, total, err := ctrl.spaceService.GetPostsByUser(uid, page, pageSize) + if err != nil { + posts = []model.Post{} + total = 0 + } + totalPages := common.PageCount(total, pageSize) + data["Posts"] = posts + data["Total"] = total + data["Page"] = page + data["TotalPages"] = totalPages + data["PrevPage"] = max(1, page-1) + data["NextPage"] = min(totalPages, page+1) +} + +// loadFollowingData 加载关注列表数据 +func (ctrl *SpaceController) loadFollowingData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) { + if ctrl.followSvc == nil { + data["FollowingResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} + return + } + res, err := ctrl.followSvc.ListFollowing(targetUID, currentUID, page, pageSize) + if err != nil || res == nil { + res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} + } + data["FollowingResult"] = res +} + +// loadFollowersData 加载粉丝列表数据 +func (ctrl *SpaceController) loadFollowersData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) { + if ctrl.followSvc == nil { + data["FollowersResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} + return + } + res, err := ctrl.followSvc.ListFollowers(targetUID, currentUID, page, pageSize) + if err != nil || res == nil { + res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} + } + data["FollowersResult"] = res +} + +// loadCollectionsData 加载收藏夹数据 +func (ctrl *SpaceController) loadCollectionsData(c *gin.Context, data gin.H, uid uint, page, pageSize int) { + if ctrl.favoriteSvc == nil { + data["FoldersResult"] = &service.FavoriteListResult{Folders: []model.Folder{}} + return + } + + // 收藏夹列表 + folders, err := ctrl.favoriteSvc.ListFolders(uid) + if err != nil || folders == nil { + folders = &service.FavoriteListResult{Folders: []model.Folder{}} + } + data["FoldersResult"] = folders + + // 默认展示第一个收藏夹的内容 + folderParam := c.DefaultQuery("folder", "") + var activeFolderID uint + if folderParam != "" { + fid, parseErr := strconv.ParseUint(folderParam, 10, 64) + if parseErr == nil { + activeFolderID = uint(fid) + } + } + if activeFolderID == 0 && len(folders.Folders) > 0 { + activeFolderID = folders.Folders[0].ID + } + + var folderItems *service.FolderItemsResult + if activeFolderID > 0 { + items, itemErr := ctrl.favoriteSvc.ListFolderItems(uid, activeFolderID, page, pageSize) + if itemErr != nil || items == nil { + folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0} + } else { + folderItems = items + } + } + if folderItems == nil { + folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0} + } + data["FolderItems"] = folderItems + data["ActiveFolderID"] = activeFolderID + + // 找到当前激活的收藏夹对象传给模板 + var activeFolder *model.Folder + for i := range folders.Folders { + if folders.Folders[i].ID == activeFolderID { + activeFolder = &folders.Folders[i] + break + } + } + data["ActiveFolder"] = activeFolder + + data["Page"] = page + data["TotalPages"] = folderItems.TotalPages + data["PrevPage"] = max(1, page-1) + data["NextPage"] = min(folderItems.TotalPages, page+1) +} + +// UpdatePrivacy PUT /api/space/privacy — 更新隐私设置 +func (ctrl *SpaceController) UpdatePrivacy(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + var req struct { + Field string `json:"field"` + Value bool `json:"value"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误") + return + } + + // 支持的字段映射(仅处理已实现的字段) + allowedFields := map[string]string{ + "follow_list": "follow_list_public", + "follower_list": "follower_list_public", + } + + column, ok := allowedFields[req.Field] + if !ok { + common.OkMessage(c, "已保存") // 未实现字段直接返回成功,前端不感知 + return + } + + user, err := ctrl.spaceService.GetSpaceUser(uid) + if err != nil || user == nil { + common.Error(c, http.StatusNotFound, "用户不存在") + return + } + + switch column { + case "follow_list_public": + user.FollowListPublic = req.Value + case "follower_list_public": + user.FollowerListPublic = req.Value + } + + if err := ctrl.spaceService.UpdateUser(user); err != nil { + common.Error(c, http.StatusInternalServerError, "保存失败") + return + } + + common.OkMessage(c, "隐私设置已更新") +} diff --git a/internal/repository/comment_mention_repo.go b/internal/repository/comment_mention_repo.go new file mode 100644 index 0000000..fe68042 --- /dev/null +++ b/internal/repository/comment_mention_repo.go @@ -0,0 +1,137 @@ +package repository + +import ( + "strconv" + + "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" +) + +// CreateMention 创建@提及记录 +func (r *CommentRepo) CreateMention(mention *model.CommentMention) error { + return r.db.Create(mention).Error +} + +// FindMentionsByComment 查询某条评论的@提及列表 +func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMention, error) { + var list []model.CommentMention + err := r.db.Where("comment_id = ?", commentID).Find(&list).Error + return list, err +} + +// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/exp +func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct { + UID string + Username string + Exp int +}, error) { + type result struct { + UID uint + Username string + Exp int + } + var list []result + + threshold := model.LevelThresholds[minLevel] + + err := r.db.Table("users"). + Select("uid, username, exp"). + Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold). + Order("exp DESC"). + Limit(limit). + Find(&list).Error + + var res []struct { + UID string + Username string + Exp int + } + for _, u := range list { + res = append(res, struct { + UID string + Username string + Exp int + }{UID: strconv.FormatUint(uint64(u.UID), 10), Username: u.Username, Exp: u.Exp}) + } + return res, err +} + +// GetUserUIDByID 通过 uint ID 获取 user 的 UID 字符串 +func (r *CommentRepo) GetUserUIDByID(userID uint) (string, error) { + var uid uint + err := r.db.Table("users").Select("uid").Where("uid = ?", userID).Scan(&uid).Error + if err != nil { + return "", err + } + return strconv.FormatUint(uint64(uid), 10), nil +} + +// GetUsernameByID 通过 userID 获取用户名(用于通知内容) +func (r *CommentRepo) GetUsernameByID(userID uint) (string, error) { + var username string + err := r.db.Table("users").Select("username").Where("uid = ?", userID).Scan(&username).Error + return username, err +} + +// FindPostIDByComment 查询评论所属的文章ID +func (r *CommentRepo) FindPostIDByComment(commentID uint) (uint, error) { + var postID uint + err := r.db.Table("comments").Select("post_id").Where("id = ?", commentID).Scan(&postID).Error + return postID, err +} + +// UpdateRootID 更新评论的 root_id 字段(顶级评论创建后回填) +func (r *CommentRepo) UpdateRootID(id, rootID uint) error { + return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("root_id", rootID).Error +} + +// FindPostAuthorID 查询文章的作者 user_id +func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) { + var authorID uint + err := r.db.Table("posts").Select("user_id").Where("id = ?", postID).Scan(&authorID).Error + return authorID, err +} + +// AggregateCommentsByAuthor 按日期聚合评论量(作者所有文章在时间段内的评论) +func (r *CommentRepo) AggregateCommentsByAuthor(userID uint, since string) ([]service.TrendPoint, error) { + var results []service.TrendPoint + err := r.db.Table("comments"). + Select("TO_CHAR(comments.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value"). + Joins("JOIN posts ON posts.id = comments.post_id"). + Where("posts.user_id = ? AND comments.created_at >= ?", userID, since). + Group("TO_CHAR(comments.created_at, 'YYYY-MM-DD')"). + Order("date ASC"). + Scan(&results).Error + if results == nil { + results = []service.TrendPoint{} + } + return results, err +} + +// ListAllComments 后台评论列表(支持关键词搜索、分页) +func (r *CommentRepo) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) { + var total int64 + q := r.db.Table("comments"). + Joins("LEFT JOIN users ON users.uid = comments.user_id"). + Joins("LEFT JOIN posts ON posts.id = comments.post_id") + + if !showDeleted { + q = q.Where("comments.is_deleted = false") + } + if keyword != "" { + like := "%" + keyword + "%" + q = q.Where("comments.body LIKE ? OR users.username LIKE ? OR posts.title LIKE ?", like, like, like) + } + + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + var rows []model.AdminCommentRow + err := q. + Select("comments.id, comments.post_id, posts.title as post_title, comments.body, users.username as author_name, comments.is_deleted, comments.created_at, comments.updated_at"). + Order("comments.created_at DESC"). + Offset(offset).Limit(limit). + Scan(&rows).Error + return rows, total, err +} diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index bac053c..eb935ff 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -2,8 +2,6 @@ package repository import ( "metazone.cc/metalab/internal/model" - "metazone.cc/metalab/internal/service" - "strconv" "gorm.io/gorm" ) @@ -173,132 +171,3 @@ func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, err := q.Count(&count).Error return int(count), err } - -// CreateMention 创建@提及记录 -func (r *CommentRepo) CreateMention(mention *model.CommentMention) error { - return r.db.Create(mention).Error -} - -// FindMentionsByComment 查询某条评论的@提及列表 -func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMention, error) { - var list []model.CommentMention - err := r.db.Where("comment_id = ?", commentID).Find(&list).Error - return list, err -} - -// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/exp -func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct { - UID string - Username string - Exp int -}, error) { - type result struct { - UID uint - Username string - Exp int - } - var list []result - - threshold := model.LevelThresholds[minLevel] - - err := r.db.Table("users"). - Select("uid, username, exp"). - Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold). - Order("exp DESC"). - Limit(limit). - Find(&list).Error - - var res []struct { - UID string - Username string - Exp int - } - for _, u := range list { - res = append(res, struct { - UID string - Username string - Exp int - }{UID: strconv.FormatUint(uint64(u.UID), 10), Username: u.Username, Exp: u.Exp}) - } - return res, err -} - -// GetUserUIDByID 通过 uint ID 获取 user 的 UID 字符串 -func (r *CommentRepo) GetUserUIDByID(userID uint) (string, error) { - var uid uint - err := r.db.Table("users").Select("uid").Where("uid = ?", userID).Scan(&uid).Error - if err != nil { - return "", err - } - return strconv.FormatUint(uint64(uid), 10), nil -} - -// GetUsernameByID 通过 userID 获取用户名(用于通知内容) -func (r *CommentRepo) GetUsernameByID(userID uint) (string, error) { - var username string - err := r.db.Table("users").Select("username").Where("uid = ?", userID).Scan(&username).Error - return username, err -} - -// FindPostIDByComment 查询评论所属的文章ID -func (r *CommentRepo) FindPostIDByComment(commentID uint) (uint, error) { - var postID uint - err := r.db.Table("comments").Select("post_id").Where("id = ?", commentID).Scan(&postID).Error - return postID, err -} - -// UpdateRootID 更新评论的 root_id 字段(顶级评论创建后回填) -func (r *CommentRepo) UpdateRootID(id, rootID uint) error { - return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("root_id", rootID).Error -} - -// FindPostAuthorID 查询文章的作者 user_id -func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) { - var authorID uint - err := r.db.Table("posts").Select("user_id").Where("id = ?", postID).Scan(&authorID).Error - return authorID, err -} - -// AggregateCommentsByAuthor 按日期聚合评论量(作者所有文章在时间段内的评论) -func (r *CommentRepo) AggregateCommentsByAuthor(userID uint, since string) ([]service.TrendPoint, error) { - var results []service.TrendPoint - err := r.db.Table("comments"). - Select("TO_CHAR(comments.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value"). - Joins("JOIN posts ON posts.id = comments.post_id"). - Where("posts.user_id = ? AND comments.created_at >= ?", userID, since). - Group("TO_CHAR(comments.created_at, 'YYYY-MM-DD')"). - Order("date ASC"). - Scan(&results).Error - if results == nil { - results = []service.TrendPoint{} - } - return results, err -} - -// ListAllComments 后台评论列表(支持关键词搜索、分页) -func (r *CommentRepo) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) { - var total int64 - q := r.db.Table("comments"). - Joins("LEFT JOIN users ON users.uid = comments.user_id"). - Joins("LEFT JOIN posts ON posts.id = comments.post_id") - - if !showDeleted { - q = q.Where("comments.is_deleted = false") - } - if keyword != "" { - like := "%" + keyword + "%" - q = q.Where("comments.body LIKE ? OR users.username LIKE ? OR posts.title LIKE ?", like, like, like) - } - - if err := q.Count(&total).Error; err != nil { - return nil, 0, err - } - - var rows []model.AdminCommentRow - err := q. - Select("comments.id, comments.post_id, posts.title as post_title, comments.body, users.username as author_name, comments.is_deleted, comments.created_at, comments.updated_at"). - Order("comments.created_at DESC"). - Offset(offset).Limit(limit). - Scan(&rows).Error - return rows, total, err -} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 279923c..b60fc37 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -1,12 +1,9 @@ package repository import ( - "time" - "metazone.cc/metalab/internal/model" "gorm.io/gorm" - "gorm.io/gorm/clause" ) // PostRepo 帖子数据访问 @@ -84,55 +81,6 @@ func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, i return r.pageResults(query, offset, limit) } -// CountUserPosts 统计某用户的已发布文章数 -func (r *PostRepo) CountUserPosts(userID uint) (int64, error) { - var count int64 - err := r.db.Model(&model.Post{}). - Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, model.PostStatusApproved). - Count(&count).Error - return count, err -} - -// GetUserStats 获取用户的文章统计数据(总获赞/总收藏/总阅读) -func (r *PostRepo) GetUserStats(userID uint) (likes, favorites, reads int64, err error) { - type stats struct { - TotalLikes int64 - TotalFavorites int64 - TotalReads int64 - } - var s stats - err = r.db.Model(&model.Post{}). - Select("COALESCE(SUM(likes_count), 0) AS total_likes, COALESCE(SUM(favorites_count), 0) AS total_favorites, COALESCE(SUM(views_count), 0) AS total_reads"). - Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, model.PostStatusApproved). - Scan(&s).Error - return s.TotalLikes, s.TotalFavorites, s.TotalReads, err -} - -// overviewRow GetOverviewByUserID 单次 GROUP BY 查询结果行 -type overviewRow struct { - TotalPosts int64 - DraftCount int64 - PendingCount int64 - ApprovedCount int64 - RejectedCount int64 -} - -// GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数 -func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount int64, err error) { - var row overviewRow - err = r.db.Model(&model.Post{}). - Select(` - COUNT(*) AS total_posts, - COALESCE(SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END), 0) AS draft_count, - COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_count, - COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) AS approved_count, - COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count - `). - Where("user_id = ? AND deleted_at IS NULL", userID). - Scan(&row).Error - return row.TotalPosts, row.DraftCount, row.PendingCount, row.ApprovedCount, row.RejectedCount, err -} - // FindByUserIDAndStatus 分页查询某用户指定状态的帖子(空 status=全状态) func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) { query := r.buildQuery("", status). @@ -218,52 +166,3 @@ func (r *PostRepo) Restore(id uint) error { } return nil } - -// readCooldown 两次阅读记录最小间隔 -const readCooldown = 2 * time.Second - -// RecordRead 记录已登录用户阅读文章(防刷量:取去重+冷却) -// 返回 true 表示首次阅读(新记录),false 表示重复或冷却中 -func (r *PostRepo) RecordRead(userID, postID uint) (bool, error) { - // 冷却检查:同用户两次阅读之间至少间隔 2 秒 - var lastRead model.PostReadLog - if err := r.db.Where("user_id = ?", userID). - Order("read_at DESC").First(&lastRead).Error; err == nil { - if time.Since(lastRead.ReadAt) < readCooldown { - return false, nil - } - } - - result := r.db.Clauses(clause.OnConflict{DoNothing: true}). - Create(&model.PostReadLog{UserID: userID, PostID: postID}) - if result.Error != nil { - return false, result.Error - } - return result.RowsAffected > 0, nil -} - -// RecordGuestRead 记录访客阅读文章(基于 Cookie visitor_id 去重+冷却) -// 返回 true 表示首次阅读(新记录),false 表示重复或冷却中 -func (r *PostRepo) RecordGuestRead(visitorID string, postID uint) (bool, error) { - // 冷却检查:同访客两次阅读之间至少间隔 2 秒 - var lastRead model.PostGuestReadLog - if err := r.db.Where("visitor_id = ?", visitorID). - Order("read_at DESC").First(&lastRead).Error; err == nil { - if time.Since(lastRead.ReadAt) < readCooldown { - return false, nil - } - } - - result := r.db.Clauses(clause.OnConflict{DoNothing: true}). - Create(&model.PostGuestReadLog{VisitorID: visitorID, PostID: postID}) - if result.Error != nil { - return false, result.Error - } - return result.RowsAffected > 0, nil -} - -// IncrementViewsCount 阅读数 +1 -func (r *PostRepo) IncrementViewsCount(postID uint) error { - return r.db.Model(&model.Post{}).Where("id = ?", postID). - Update("views_count", gorm.Expr("views_count + 1")).Error -} diff --git a/internal/repository/post_stats_repo.go b/internal/repository/post_stats_repo.go new file mode 100644 index 0000000..f6fda64 --- /dev/null +++ b/internal/repository/post_stats_repo.go @@ -0,0 +1,108 @@ +package repository + +import ( + "time" + + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// CountUserPosts 统计某用户的已发布文章数 +func (r *PostRepo) CountUserPosts(userID uint) (int64, error) { + var count int64 + err := r.db.Model(&model.Post{}). + Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, model.PostStatusApproved). + Count(&count).Error + return count, err +} + +// GetUserStats 获取用户的文章统计数据(总获赞/总收藏/总阅读) +func (r *PostRepo) GetUserStats(userID uint) (likes, favorites, reads int64, err error) { + type stats struct { + TotalLikes int64 + TotalFavorites int64 + TotalReads int64 + } + var s stats + err = r.db.Model(&model.Post{}). + Select("COALESCE(SUM(likes_count), 0) AS total_likes, COALESCE(SUM(favorites_count), 0) AS total_favorites, COALESCE(SUM(views_count), 0) AS total_reads"). + Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, model.PostStatusApproved). + Scan(&s).Error + return s.TotalLikes, s.TotalFavorites, s.TotalReads, err +} + +// overviewRow GetOverviewByUserID 单次 GROUP BY 查询结果行 +type overviewRow struct { + TotalPosts int64 + DraftCount int64 + PendingCount int64 + ApprovedCount int64 + RejectedCount int64 +} + +// GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数 +func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount int64, err error) { + var row overviewRow + err = r.db.Model(&model.Post{}). + Select(` + COUNT(*) AS total_posts, + COALESCE(SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END), 0) AS draft_count, + COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_count, + COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) AS approved_count, + COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count + `). + Where("user_id = ? AND deleted_at IS NULL", userID). + Scan(&row).Error + return row.TotalPosts, row.DraftCount, row.PendingCount, row.ApprovedCount, row.RejectedCount, err +} + +// readCooldown 两次阅读记录最小间隔 +const readCooldown = 2 * time.Second + +// RecordRead 记录已登录用户阅读文章(防刷量:取去重+冷却) +// 返回 true 表示首次阅读(新记录),false 表示重复或冷却中 +func (r *PostRepo) RecordRead(userID, postID uint) (bool, error) { + // 冷却检查:同用户两次阅读之间至少间隔 2 秒 + var lastRead model.PostReadLog + if err := r.db.Where("user_id = ?", userID). + Order("read_at DESC").First(&lastRead).Error; err == nil { + if time.Since(lastRead.ReadAt) < readCooldown { + return false, nil + } + } + + result := r.db.Clauses(clause.OnConflict{DoNothing: true}). + Create(&model.PostReadLog{UserID: userID, PostID: postID}) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +// RecordGuestRead 记录访客阅读文章(基于 Cookie visitor_id 去重+冷却) +// 返回 true 表示首次阅读(新记录),false 表示重复或冷却中 +func (r *PostRepo) RecordGuestRead(visitorID string, postID uint) (bool, error) { + // 冷却检查:同访客两次阅读之间至少间隔 2 秒 + var lastRead model.PostGuestReadLog + if err := r.db.Where("visitor_id = ?", visitorID). + Order("read_at DESC").First(&lastRead).Error; err == nil { + if time.Since(lastRead.ReadAt) < readCooldown { + return false, nil + } + } + + result := r.db.Clauses(clause.OnConflict{DoNothing: true}). + Create(&model.PostGuestReadLog{VisitorID: visitorID, PostID: postID}) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +// IncrementViewsCount 阅读数 +1 +func (r *PostRepo) IncrementViewsCount(postID uint) error { + return r.db.Model(&model.Post{}).Where("id = ?", postID). + Update("views_count", gorm.Expr("views_count + 1")).Error +}