diff --git a/cmd/server/main.go b/cmd/server/main.go index 83d8f29..b8e1861 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,7 +27,7 @@ func main() { if err != nil { log.Fatalf("连接数据库失败: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } diff --git a/internal/controller/favorite_controller.go b/internal/controller/favorite_controller.go new file mode 100644 index 0000000..4f9187e --- /dev/null +++ b/internal/controller/favorite_controller.go @@ -0,0 +1,270 @@ +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" +) + +// 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 +} + +// NewFavoriteController 构造函数 +func NewFavoriteController(svc favoriteUseCase) *FavoriteController { + return &FavoriteController{svc: svc} +} + +// ListFolders 我的收藏夹列表 GET /api/folders +func (fc *FavoriteController) ListFolders(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + result, err := fc.svc.ListFolders(uid) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取收藏夹失败") + return + } + + common.Ok(c, result) +} + +// CreateFolder 创建收藏夹 POST /api/folders +func (fc *FavoriteController) CreateFolder(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + var req struct { + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误") + return + } + + f, err := fc.svc.CreateFolder(uid, req.Name, req.Description, req.IsPublic) + if err != nil { + if err.Error() == "收藏夹名称已存在" || err.Error() == "收藏夹名称不能为空" || err.Error() == "收藏夹名称不能超过 50 个字符" { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + common.Error(c, http.StatusInternalServerError, "创建失败") + return + } + + common.OkWithMessage(c, f, "收藏夹创建成功") +} + +// UpdateFolder 修改收藏夹 PUT /api/folders/:id +func (fc *FavoriteController) UpdateFolder(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 { + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误") + return + } + + if err := fc.svc.UpdateFolder(uid, uint(folderID), req.Name, req.Description, req.IsPublic); err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + + common.OkMessage(c, "收藏夹已更新") +} + +// DeleteFolder 删除收藏夹 DELETE /api/folders/:id +func (fc *FavoriteController) DeleteFolder(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 + } + + if err := fc.svc.DeleteFolder(uid, uint(folderID)); err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + + 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/settings_api_notify.go b/internal/controller/settings_api_notify.go new file mode 100644 index 0000000..495590c --- /dev/null +++ b/internal/controller/settings_api_notify.go @@ -0,0 +1,85 @@ +package controller + +import ( + "net/http" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "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 +} + +// GetNotifyPrefs 获取通知偏好 GET /api/settings/notify-prefs +func (sc *SettingsController) GetNotifyPrefs(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + if sc.notifyPrefReader == nil { + common.Error(c, http.StatusInternalServerError, "服务不可用") + return + } + + prefs, err := sc.notifyPrefReader.GetNotifyPrefs(uid.(uint)) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取失败") + return + } + + common.Ok(c, prefs) +} + +// UpdateNotifyPref 更新通知偏好 PUT /api/settings/notify-prefs +func (sc *SettingsController) UpdateNotifyPref(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + if sc.notifyPrefReader == nil { + common.Error(c, http.StatusInternalServerError, "服务不可用") + return + } + + var req struct { + Key string `json:"key"` + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误") + return + } + + // 验证 key 是否合法 + allowedKeys := map[string]bool{ + model.NotifyComment: true, + model.NotifyCommentReply: true, + model.NotifyLikeAggregated: true, + model.NotifyFollow: true, + } + if !allowedKeys[req.Key] { + common.Error(c, http.StatusBadRequest, "无效的通知类型") + return + } + + if err := sc.notifyPrefReader.UpdateNotifyPref(uid.(uint), req.Key, req.Enabled); err != nil { + common.Error(c, http.StatusInternalServerError, "更新失败") + return + } + + common.OkMessage(c, "通知偏好已更新") +} diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go index 7b07d63..19800f8 100644 --- a/internal/controller/settings_controller.go +++ b/internal/controller/settings_controller.go @@ -48,13 +48,14 @@ type taskCompleter interface { // SettingsController 个人设置控制器 type SettingsController struct { - authService profileProvider - avatarService avatarProvider - auditService auditSubmittable - sessionMgr sessionManager - sessionCfg sessionConfig - levelSvc taskCompleter - energySvc energyRenameHandler + authService profileProvider + avatarService avatarProvider + auditService auditSubmittable + sessionMgr sessionManager + sessionCfg sessionConfig + levelSvc taskCompleter + energySvc energyRenameHandler + notifyPrefReader notifyPrefReader } // NewSettingsController 构造函数 @@ -78,7 +79,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { uid := uidVal.(uint) tab := c.Param("tab") - if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" { + if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" && tab != "favorites" && tab != "notify" { c.String(http.StatusNotFound, "页面不存在") return } diff --git a/internal/controller/studio_controller.go b/internal/controller/studio_controller.go index 2cd9ad6..d30389b 100644 --- a/internal/controller/studio_controller.go +++ b/internal/controller/studio_controller.go @@ -8,14 +8,21 @@ import ( "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" "github.com/gin-gonic/gin" ) +// trendsUseCase StudioController 对趋势服务的最小依赖(ISP) +type trendsUseCase interface { + GetTrends(userID uint, since string) (*service.TrendResult, error) +} + // StudioController 创作中心控制器(SSR 页面 + API) type StudioController struct { postService studioUseCase energySvc energyDeducter + trendsSvc trendsUseCase } // NewStudioController 构造函数 @@ -29,6 +36,12 @@ func (ctrl *StudioController) WithEnergyService(svc energyDeducter) *StudioContr return ctrl } +// WithTrendsService 链式注入趋势服务 +func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioController { + ctrl.trendsSvc = svc + return ctrl +} + // ============================== // SSR 页面方法 // ============================== @@ -402,3 +415,41 @@ func (ctrl *StudioController) Submit(c *gin.Context) { 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/model/folder.go b/internal/model/folder.go new file mode 100644 index 0000000..22ba297 --- /dev/null +++ b/internal/model/folder.go @@ -0,0 +1,30 @@ +package model + +import "time" + +// Folder 收藏夹 +type Folder struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Name string `gorm:"type:varchar(50);not null" json:"name"` + Description string `gorm:"type:varchar(200);default:''" json:"description"` + IsPublic bool `gorm:"default:false" json:"is_public"` + IsDefault bool `gorm:"default:false" json:"is_default"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // 联表查询填充(非数据库字段) + ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"` +} + +// FolderItem 收藏记录 +type FolderItem struct { + ID uint `gorm:"primarykey" json:"id"` + FolderID uint `gorm:"index;not null" json:"folder_id"` + PostID uint `gorm:"index;not null" json:"post_id"` + UserID uint `gorm:"index;not null" json:"user_id"` + CreatedAt time.Time `json:"created_at"` + + // 联表查询填充 + PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"` +} diff --git a/internal/model/post.go b/internal/model/post.go index b8f2bb8..5b6257d 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -24,6 +24,7 @@ type Post struct { TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数) LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器) DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见) + FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器) DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index 1c64376..bac053c 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -2,6 +2,7 @@ package repository import ( "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" "strconv" "gorm.io/gorm" @@ -258,6 +259,22 @@ func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, 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 diff --git a/internal/repository/energy_repo.go b/internal/repository/energy_repo.go index bef5dd5..2d7ffac 100644 --- a/internal/repository/energy_repo.go +++ b/internal/repository/energy_repo.go @@ -146,3 +146,19 @@ func (r *EnergyRepo) ListAllEnergyLogs(energyType string, offset, limit int) ([] err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error return logs, total, err } + +// AggregateEnergyByAuthor 按日期聚合赋能值(作者所有文章在时间段内的赋能记录) +func (r *EnergyRepo) AggregateEnergyByAuthor(userID uint, since string) ([]service.TrendPoint, error) { + var results []service.TrendPoint + err := r.db.Table("post_energize_logs"). + Select("TO_CHAR(post_energize_logs.created_at, 'YYYY-MM-DD') AS date, COALESCE(SUM(post_energize_logs.amount), 0) AS value"). + Joins("JOIN posts ON posts.id = post_energize_logs.post_id"). + Where("posts.user_id = ? AND post_energize_logs.created_at >= ?", userID, since). + Group("TO_CHAR(post_energize_logs.created_at, 'YYYY-MM-DD')"). + Order("date ASC"). + Scan(&results).Error + if results == nil { + results = []service.TrendPoint{} + } + return results, err +} diff --git a/internal/repository/favorite_repo.go b/internal/repository/favorite_repo.go new file mode 100644 index 0000000..c3b879c --- /dev/null +++ b/internal/repository/favorite_repo.go @@ -0,0 +1,157 @@ +package repository + +import ( + "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" + + "gorm.io/gorm" +) + +// FavoriteRepo 收藏夹数据访问 +type FavoriteRepo struct { + db *gorm.DB +} + +// NewFavoriteRepo 构造函数 +func NewFavoriteRepo(db *gorm.DB) *FavoriteRepo { + return &FavoriteRepo{db: db} +} + +// WithTx 基于事务连接创建 FavoriteRepo +func (r *FavoriteRepo) WithTx(tx *gorm.DB) service.FavoriteStore { + return &FavoriteRepo{db: tx} +} + +// ======================== Folder CRUD ======================== + +// CreateFolder 创建收藏夹 +func (r *FavoriteRepo) CreateFolder(folder *model.Folder) error { + return r.db.Create(folder).Error +} + +// FindFolderByID 按 ID 查找收藏夹 +func (r *FavoriteRepo) FindFolderByID(id uint) (*model.Folder, error) { + var f model.Folder + err := r.db.First(&f, id).Error + if err != nil { + return nil, err + } + return &f, nil +} + +// ListFoldersByUser 列用户的收藏夹(附项目数) +func (r *FavoriteRepo) ListFoldersByUser(userID uint) ([]model.Folder, error) { + var folders []model.Folder + err := r.db.Table("folders"). + Select("folders.*, COUNT(folder_items.id) AS item_count"). + Joins("LEFT JOIN folder_items ON folder_items.folder_id = folders.id"). + Where("folders.user_id = ?", userID). + Group("folders.id"). + Order("folders.is_default DESC, folders.created_at ASC"). + Scan(&folders).Error + return folders, err +} + +// UpdateFolder 更新收藏夹 +func (r *FavoriteRepo) UpdateFolder(folder *model.Folder) error { + return r.db.Save(folder).Error +} + +// DeleteFolder 删除收藏夹 +func (r *FavoriteRepo) DeleteFolder(id uint) error { + return r.db.Where("id = ?", id).Delete(&model.Folder{}).Error +} + +// DeleteItemsByFolder 删除收藏夹内所有记录 +func (r *FavoriteRepo) DeleteItemsByFolder(folderID uint) error { + return r.db.Where("folder_id = ?", folderID).Delete(&model.FolderItem{}).Error +} + +// CountByNameAndUser 检查同用户下名称是否重复(排除自身) +func (r *FavoriteRepo) CountByNameAndUser(userID uint, name string, excludeID uint) (int64, error) { + var count int64 + q := r.db.Model(&model.Folder{}).Where("user_id = ? AND name = ?", userID, name) + if excludeID > 0 { + q = q.Where("id != ?", excludeID) + } + err := q.Count(&count).Error + return count, err +} + +// HasDefaultFolder 检查用户是否有默认收藏夹 +func (r *FavoriteRepo) HasDefaultFolder(userID uint) (bool, error) { + var count int64 + err := r.db.Model(&model.Folder{}).Where("user_id = ? AND is_default = ?", userID, true).Count(&count).Error + return count > 0, err +} + +// ======================== FolderItem CRUD ======================== + +// CreateItem 添加收藏 +func (r *FavoriteRepo) CreateItem(item *model.FolderItem) error { + return r.db.Create(item).Error +} + +// DeleteItem 取消收藏 +func (r *FavoriteRepo) DeleteItem(folderID, postID uint) error { + return r.db.Where("folder_id = ? AND post_id = ?", folderID, postID).Delete(&model.FolderItem{}).Error +} + +// FindItemByUserAndPost 查找用户对某文章的收藏记录 +func (r *FavoriteRepo) FindItemByUserAndPost(userID, postID uint) (*model.FolderItem, error) { + var item model.FolderItem + err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&item).Error + if err != nil { + return nil, err + } + return &item, nil +} + +// ListItemsByFolder 列收藏夹内文章(分页,JOIN posts 获取标题) +func (r *FavoriteRepo) ListItemsByFolder(folderID uint, offset, limit int) ([]model.FolderItem, int64, error) { + var total int64 + if err := r.db.Model(&model.FolderItem{}).Where("folder_id = ?", folderID).Count(&total).Error; err != nil { + return nil, 0, err + } + + var items []model.FolderItem + err := r.db.Table("folder_items"). + Select("folder_items.*, posts.title AS post_title"). + Joins("LEFT JOIN posts ON posts.id = folder_items.post_id"). + Where("folder_items.folder_id = ?", folderID). + Order("folder_items.created_at DESC"). + Offset(offset).Limit(limit). + Scan(&items).Error + return items, total, err +} + +// CountItemsByFolder 统计收藏夹内文章数 +func (r *FavoriteRepo) CountItemsByFolder(folderID uint) (int64, error) { + var count int64 + err := r.db.Model(&model.FolderItem{}).Where("folder_id = ?", folderID).Count(&count).Error + return count, err +} + +// ======================== Post 计数器 ======================== + +// IncrPostFavoritesCount 原子更新文章收藏数 +func (r *FavoriteRepo) IncrPostFavoritesCount(postID uint, delta int) error { + return r.db.Model(&model.Post{}). + Where("id = ?", postID). + UpdateColumn("favorites_count", gorm.Expr("favorites_count + ?", delta)).Error +} + +// ======================== 趋势聚合 ======================== + +// AggregateFavoritesByAuthor 按日期聚合收藏量(对于某用户的文章) +func (r *FavoriteRepo) AggregateFavoritesByAuthor(userID uint, since string) ([]service.TrendPoint, error) { + var results []service.TrendPoint + err := r.db.Table("folder_items"). + Select("TO_CHAR(folder_items.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value"). + Joins("JOIN posts ON posts.id = folder_items.post_id"). + Where("posts.user_id = ? AND folder_items.created_at >= ?", userID, since). + Group("TO_CHAR(folder_items.created_at, 'YYYY-MM-DD')"). + Order("date ASC"). + Scan(&results).Error + return results, err +} diff --git a/internal/repository/notification_repo.go b/internal/repository/notification_repo.go index 1111e96..6c983fc 100644 --- a/internal/repository/notification_repo.go +++ b/internal/repository/notification_repo.go @@ -105,3 +105,43 @@ func (r *NotificationRepo) GetUsernameByID(userID uint) (string, error) { err := r.db.Select("username").First(&user, userID).Error return user.Username, err } + +// GetNotifyPrefs 获取用户通知偏好(NotifyPrefs 字段原始 JSON) +func (r *NotificationRepo) GetNotifyPrefs(userID uint) (string, error) { + var prefs string + err := r.db.Table("users").Select("notify_prefs").Where("uid = ?", userID).Scan(&prefs).Error + return prefs, err +} + +// ListByUserExcludeTypes 分页查询用户消息(排除指定通知类型) +func (r *NotificationRepo) ListByUserExcludeTypes(userID uint, excludeTypes []string, offset, limit int) ([]model.Notification, error) { + var list []model.Notification + q := r.db.Where("user_id = ?", userID) + if len(excludeTypes) > 0 { + q = q.Where("notify_type NOT IN ?", excludeTypes) + } + err := q.Order("created_at DESC").Offset(offset).Limit(limit).Find(&list).Error + return list, err +} + +// CountByUserExcludeTypes 统计用户消息总数(排除指定通知类型) +func (r *NotificationRepo) CountByUserExcludeTypes(userID uint, excludeTypes []string) (int64, error) { + var count int64 + q := r.db.Model(&model.Notification{}).Where("user_id = ?", userID) + if len(excludeTypes) > 0 { + q = q.Where("notify_type NOT IN ?", excludeTypes) + } + err := q.Count(&count).Error + return count, err +} + +// CountUnreadExcludeTypes 统计用户未读消息数(排除指定通知类型) +func (r *NotificationRepo) CountUnreadExcludeTypes(userID uint, excludeTypes []string) (int64, error) { + var count int64 + q := r.db.Model(&model.Notification{}).Where("user_id = ? AND is_read = false", userID) + if len(excludeTypes) > 0 { + q = q.Where("notify_type NOT IN ?", excludeTypes) + } + err := q.Count(&count).Error + return count, err +} diff --git a/internal/repository/reaction_repo.go b/internal/repository/reaction_repo.go index 01334cc..6510b98 100644 --- a/internal/repository/reaction_repo.go +++ b/internal/repository/reaction_repo.go @@ -119,3 +119,19 @@ func (r *ReactionRepo) GetUnnotifiedSummaries(userID uint) ([]model.DailyLikeSum func (r *ReactionRepo) MarkSummaryNotified(id uint) error { return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error } + +// AggregateLikesByAuthor 按日期聚合点赞量(作者所有文章在时间段内的点赞) +func (r *ReactionRepo) AggregateLikesByAuthor(userID uint, since string) ([]service.TrendPoint, error) { + var results []service.TrendPoint + err := r.db.Table("post_likes"). + Select("TO_CHAR(post_likes.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value"). + Joins("JOIN posts ON posts.id = post_likes.post_id"). + Where("posts.user_id = ? AND post_likes.created_at >= ?", userID, since). + Group("TO_CHAR(post_likes.created_at, 'YYYY-MM-DD')"). + Order("date ASC"). + Scan(&results).Error + if results == nil { + results = []service.TrendPoint{} + } + return results, err +} diff --git a/internal/router/api.go b/internal/router/api.go index 6671d0d..9fbbcf8 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -25,6 +25,9 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession) settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions) settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark) + // 通知偏好 + settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs) + settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref) } // --- 消息中心 API(需登录) --- @@ -145,4 +148,38 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { { followAPI.POST("/follow", d.followCtrl.Toggle) } + + // --- 收藏夹 API --- + // 公开:文章收藏状态(未登录返回 false) + r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus) + // 公开:查看公开收藏夹内容 + r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems) + // 需登录:收藏操作 + folderAPI := r.Group("/api/folders") + folderAPI.Use(d.authMdw.Required()) + folderAPI.Use(d.authMdw.BannedWriteGuard()) + folderAPI.Use(middleware.CSRF(cfg)) + { + folderAPI.GET("", d.favoriteCtrl.ListFolders) + folderAPI.POST("", d.favoriteCtrl.CreateFolder) + folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder) + folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder) + folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder) + folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder) + } + // 需登录:切换收藏(Toggle) + favToggleAPI := r.Group("/api/posts/:id") + favToggleAPI.Use(d.authMdw.Required()) + favToggleAPI.Use(d.authMdw.BannedWriteGuard()) + favToggleAPI.Use(middleware.CSRF(cfg)) + { + favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite) + } + + // --- Studio 趋势 API(需登录) --- + trendsAPI := r.Group("/api/studio") + trendsAPI.Use(d.authMdw.Required()) + { + trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI) + } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index fe56011..fad6bb5 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -40,6 +40,9 @@ type dependencies struct { reactionCtrl *controller.ReactionController followCtrl *controller.FollowController followPageCtrl *controller.FollowPageController + favoriteCtrl *controller.FavoriteController + favoriteService *service.FavoriteService + trendsService *service.TrendsService } func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) ( diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index e69cf5c..d0252aa 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -40,6 +40,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc) settingsCtrl.WithEnergyService(energyService) + settingsCtrl.SetNotifyPrefReader(authService) siteSettingController := adminCtrl.NewSiteSettingController( service.NewSiteSettingService(siteSettings), &service.AuditDefaults{ @@ -85,6 +86,15 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting spaceService := service.NewSpaceService(userRepo, postRepo) spaceCtrl := controller.NewSpaceController(spaceService) + // 收藏夹系统 + favoriteRepo := repository.NewFavoriteRepo(db) + favoriteService := service.NewFavoriteService(db, favoriteRepo) + favoriteCtrl := controller.NewFavoriteController(favoriteService) + + // Studio 趋势图 + trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo) + studioCtrl.WithTrendsService(trendsService) + maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr) return &dependencies{ @@ -105,5 +115,8 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting reactionCtrl: reactionCtrl, followCtrl: followCtrl, followPageCtrl: followPageCtrl, + favoriteCtrl: favoriteCtrl, + favoriteService: favoriteService, + trendsService: trendsService, } } diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index bdc436c..a9dafde 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "regexp" "time" "unicode/utf8" @@ -309,3 +310,29 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error { } return s.userRepo.Update(user) } + +// GetNotifyPrefs 获取用户通知偏好 +func (s *AuthService) GetNotifyPrefs(userID uint) (map[string]bool, error) { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, common.ErrUserNotFound + } + return ParseNotifyPrefs(user.NotifyPrefs), nil +} + +// UpdateNotifyPref 更新单个通知偏好项 +func (s *AuthService) UpdateNotifyPref(userID uint, key string, enabled bool) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return common.ErrUserNotFound + } + + prefs := ParseNotifyPrefs(user.NotifyPrefs) + prefs[key] = enabled + data, err := json.Marshal(prefs) + if err != nil { + return err + } + user.NotifyPrefs = string(data) + return s.userRepo.Update(user) +} diff --git a/internal/service/favorite_service.go b/internal/service/favorite_service.go new file mode 100644 index 0000000..110c680 --- /dev/null +++ b/internal/service/favorite_service.go @@ -0,0 +1,363 @@ +package service + +import ( + "fmt" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" +) + +// FavoriteStore FavoriteService 所需的最小仓储接口(ISP) +type FavoriteStore interface { + // Folder + CreateFolder(folder *model.Folder) error + FindFolderByID(id uint) (*model.Folder, error) + ListFoldersByUser(userID uint) ([]model.Folder, error) + UpdateFolder(folder *model.Folder) error + DeleteFolder(id uint) error + DeleteItemsByFolder(folderID uint) error + CountByNameAndUser(userID uint, name string, excludeID uint) (int64, error) + HasDefaultFolder(userID uint) (bool, error) + // FolderItem + CreateItem(item *model.FolderItem) error + DeleteItem(folderID, postID uint) error + FindItemByUserAndPost(userID, postID uint) (*model.FolderItem, error) + ListItemsByFolder(folderID uint, offset, limit int) ([]model.FolderItem, int64, error) + CountItemsByFolder(folderID uint) (int64, error) + // Post counter + IncrPostFavoritesCount(postID uint, delta int) error + // Trend + AggregateFavoritesByAuthor(userID uint, since string) ([]TrendPoint, error) + // Tx + WithTx(tx *gorm.DB) FavoriteStore +} + +// FavoriteListResult 收藏夹列表 + 收藏内容 +type FavoriteListResult struct { + Folders []model.Folder `json:"folders"` +} + +// FolderItemsResult 收藏夹内容 +type FolderItemsResult struct { + Items []model.FolderItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` +} + +// FavoriteStatus 某文章的收藏状态 +type FavoriteStatus struct { + Favorited bool `json:"favorited"` + FolderID *uint `json:"folder_id,omitempty"` + FolderName *string `json:"folder_name,omitempty"` +} + +// FavoriteService 收藏夹业务逻辑 +type FavoriteService struct { + db *gorm.DB + repo FavoriteStore +} + +// NewFavoriteService 构造函数 +func NewFavoriteService(db *gorm.DB, repo FavoriteStore) *FavoriteService { + return &FavoriteService{db: db, repo: repo} +} + +// ensureDefaultFolder 确保用户有默认收藏夹(不存在则创建) +func (s *FavoriteService) ensureDefaultFolder(userID uint) (*model.Folder, error) { + has, err := s.repo.HasDefaultFolder(userID) + if err != nil { + return nil, fmt.Errorf("查询默认收藏夹: %w", err) + } + if has { + folders, err := s.repo.ListFoldersByUser(userID) + if err != nil { + return nil, fmt.Errorf("查询收藏夹列表: %w", err) + } + for _, f := range folders { + if f.IsDefault { + return &f, nil + } + } + } + + // 创建默认收藏夹 + f := &model.Folder{ + UserID: userID, + Name: "默认", + Description: "自动创建的默认收藏夹", + IsPublic: false, + IsDefault: true, + } + if err := s.repo.CreateFolder(f); err != nil { + return nil, fmt.Errorf("创建默认收藏夹: %w", err) + } + return f, nil +} + +// ListFolders 获取用户的所有收藏夹 +func (s *FavoriteService) ListFolders(userID uint) (*FavoriteListResult, error) { + folders, err := s.repo.ListFoldersByUser(userID) + if err != nil { + return nil, fmt.Errorf("查询收藏夹: %w", err) + } + if folders == nil { + folders = []model.Folder{} + } + return &FavoriteListResult{Folders: folders}, nil +} + +// CreateFolder 创建收藏夹 +func (s *FavoriteService) CreateFolder(userID uint, name, description string, isPublic bool) (*model.Folder, error) { + if name == "" { + return nil, fmt.Errorf("收藏夹名称不能为空") + } + if len(name) > 50 { + return nil, fmt.Errorf("收藏夹名称不能超过 50 个字符") + } + + // 检查名称是否重复 + count, err := s.repo.CountByNameAndUser(userID, name, 0) + if err != nil { + return nil, fmt.Errorf("检查名称: %w", err) + } + if count > 0 { + return nil, fmt.Errorf("收藏夹名称已存在") + } + + f := &model.Folder{ + UserID: userID, + Name: name, + Description: description, + IsPublic: isPublic, + } + if err := s.repo.CreateFolder(f); err != nil { + return nil, fmt.Errorf("创建收藏夹: %w", err) + } + return f, nil +} + +// UpdateFolder 修改收藏夹(默认收藏夹不可改名/改描述) +func (s *FavoriteService) UpdateFolder(userID, folderID uint, name, description string, isPublic bool) error { + f, err := s.repo.FindFolderByID(folderID) + if err != nil { + return common.ErrPostNotFound // 复用错误哨兵 + } + if f.UserID != userID { + return common.ErrPermissionDenied + } + + if f.IsDefault { + // 默认收藏夹只允许修改公开性 + f.IsPublic = isPublic + return s.repo.UpdateFolder(f) + } + + if name == "" { + return fmt.Errorf("收藏夹名称不能为空") + } + + // 检查名称重复 + count, err := s.repo.CountByNameAndUser(userID, name, folderID) + if err != nil { + return fmt.Errorf("检查名称: %w", err) + } + if count > 0 { + return fmt.Errorf("收藏夹名称已存在") + } + + f.Name = name + f.Description = description + f.IsPublic = isPublic + return s.repo.UpdateFolder(f) +} + +// DeleteFolder 删除收藏夹(默认不可删) +func (s *FavoriteService) DeleteFolder(userID, folderID uint) error { + f, err := s.repo.FindFolderByID(folderID) + if err != nil { + return common.ErrPostNotFound + } + if f.UserID != userID { + return common.ErrPermissionDenied + } + if f.IsDefault { + return fmt.Errorf("默认收藏夹不可删除") + } + + return s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + + // 先清理 items 的 fans 计数 + items, _, _ := txRepo.ListItemsByFolder(folderID, 0, 10000) + for _, item := range items { + if err := txRepo.IncrPostFavoritesCount(item.PostID, -1); err != nil { + return fmt.Errorf("更新文章收藏数: %w", err) + } + } + + if err := txRepo.DeleteItemsByFolder(folderID); err != nil { + return fmt.Errorf("删除收藏内容: %w", err) + } + + return txRepo.DeleteFolder(folderID) + }) +} + +// 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) + if err != nil { + return &FavoriteStatus{Favorited: false}, nil + } + + f, err := s.repo.FindFolderByID(item.FolderID) + var folderName *string + if err == nil { + folderName = &f.Name + } + + return &FavoriteStatus{ + Favorited: true, + FolderID: &item.FolderID, + 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_service.go b/internal/service/notification_service.go index bf1b467..891b818 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "fmt" "sort" @@ -8,20 +9,45 @@ import ( "metazone.cc/metalab/internal/model" ) +// ParseNotifyPrefs 解析用户通知偏好 JSON 字符串 +func ParseNotifyPrefs(raw string) map[string]bool { + prefs := map[string]bool{ + model.NotifyComment: true, + model.NotifyCommentReply: true, + model.NotifyLikeAggregated: true, + model.NotifyFollow: true, + } + if raw == "" { + return prefs + } + var parsed map[string]bool + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return prefs + } + for k, v := range parsed { + prefs[k] = v + } + return prefs +} + // notifRepo 通知服务所需的最小仓储接口(ISP) type notifRepo interface { Create(n *model.Notification) error ListByUser(userID uint, offset, limit int) ([]model.Notification, error) ListByUserAndType(userID uint, notifyType string, offset, limit int) ([]model.Notification, error) + ListByUserExcludeTypes(userID uint, excludeTypes []string, offset, limit int) ([]model.Notification, error) CountByUser(userID uint) (int64, error) + CountByUserExcludeTypes(userID uint, excludeTypes []string) (int64, error) CountByUserAndType(userID uint, notifyType string) (int64, error) CountUnread(userID uint) (int64, error) + CountUnreadExcludeTypes(userID uint, excludeTypes []string) (int64, error) CountUnreadByType(userID uint, notifyType string) (int64, error) MarkRead(id, userID uint) error MarkAllRead(userID uint) error GetUnnotifiedDailyLikeSummaries(userID uint) ([]model.DailyLikeSummary, error) MarkDailyLikeSummaryNotified(id uint) error GetUsernameByID(userID uint) (string, error) + GetNotifyPrefs(userID uint) (string, error) } // NotificationService 消息/通知业务逻辑 @@ -56,12 +82,14 @@ func (s *NotificationService) List(userID uint, page, pageSize int) (*model.Noti p := common.Pagination{Page: page, PageSize: pageSize} p.DefaultPagination() - total, err := s.repo.CountByUser(userID) + excludeTypes := s.getDisabledTypes(userID) + + total, err := s.repo.CountByUserExcludeTypes(userID, excludeTypes) if err != nil { return nil, err } - items, err := s.repo.ListByUser(userID, p.Offset(), p.PageSize) + items, err := s.repo.ListByUserExcludeTypes(userID, excludeTypes, p.Offset(), p.PageSize) if err != nil { return nil, err } @@ -198,9 +226,31 @@ func sortNotifications(items []model.Notification) { }) } -// CountUnread 统计未读消息数 +// CountUnread 统计未读消息数(排除用户禁用的通知类型) func (s *NotificationService) CountUnread(userID uint) (int64, error) { - return s.repo.CountUnread(userID) + excludeTypes := s.getDisabledTypes(userID) + return s.repo.CountUnreadExcludeTypes(userID, excludeTypes) +} + +// getDisabledTypes 获取用户禁用的通知类型列表 +func (s *NotificationService) getDisabledTypes(userID uint) []string { + raw, err := s.repo.GetNotifyPrefs(userID) + if err != nil || raw == "" { + return nil + } + prefs := ParseNotifyPrefs(raw) + var disabled []string + for _, t := range []string{ + model.NotifyComment, + model.NotifyCommentReply, + model.NotifyLikeAggregated, + model.NotifyFollow, + } { + if !prefs[t] { + disabled = append(disabled, t) + } + } + return disabled } // MarkRead 标记单条消息为已读 diff --git a/internal/service/repository.go b/internal/service/repository.go index 387b9ee..1e9407b 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -185,3 +185,29 @@ type FollowStore interface { GetFollowListPublic(userID uint) (bool, error) WithTx(tx *gorm.DB) FollowStore } + +// TrendPoint 趋势数据点 +type TrendPoint struct { + Date string `json:"date"` + Value int `json:"value"` +} + +// EnergyTrendStore 赋能趋势聚合(ISP) +type EnergyTrendStore interface { + AggregateEnergyByAuthor(userID uint, since string) ([]TrendPoint, error) +} + +// CommentTrendStore 评论趋势聚合(ISP) +type CommentTrendStore interface { + AggregateCommentsByAuthor(userID uint, since string) ([]TrendPoint, error) +} + +// LikeTrendStore 点赞趋势聚合(ISP) +type LikeTrendStore interface { + AggregateLikesByAuthor(userID uint, since string) ([]TrendPoint, error) +} + +// FavoriteTrendStore 收藏趋势聚合(ISP) +type FavoriteTrendStore interface { + AggregateFavoritesByAuthor(userID uint, since string) ([]TrendPoint, error) +} diff --git a/internal/service/trends_service.go b/internal/service/trends_service.go new file mode 100644 index 0000000..303a218 --- /dev/null +++ b/internal/service/trends_service.go @@ -0,0 +1,59 @@ +package service + +import "fmt" + +// TrendResult 趋势返回结构 +type TrendResult struct { + Energy []TrendPoint `json:"energy"` + Comments []TrendPoint `json:"comments"` + Likes []TrendPoint `json:"likes"` + Favorites []TrendPoint `json:"favorites"` +} + +// TrendsService Studio 趋势分析服务 +type TrendsService struct { + energyRepo EnergyTrendStore + commentRepo CommentTrendStore + likeRepo LikeTrendStore + favoriteRepo FavoriteTrendStore +} + +// NewTrendsService 构造函数 +func NewTrendsService(energyRepo EnergyTrendStore, commentRepo CommentTrendStore, likeRepo LikeTrendStore, favoriteRepo FavoriteTrendStore) *TrendsService { + return &TrendsService{ + energyRepo: energyRepo, + commentRepo: commentRepo, + likeRepo: likeRepo, + favoriteRepo: favoriteRepo, + } +} + +// GetTrends 获取指定时间范围的趋势数据 +func (s *TrendsService) GetTrends(userID uint, since string) (*TrendResult, error) { + energy, err := s.energyRepo.AggregateEnergyByAuthor(userID, since) + if err != nil { + return nil, fmt.Errorf("查询赋能趋势: %w", err) + } + + comments, err := s.commentRepo.AggregateCommentsByAuthor(userID, since) + if err != nil { + return nil, fmt.Errorf("查询评论趋势: %w", err) + } + + likes, err := s.likeRepo.AggregateLikesByAuthor(userID, since) + if err != nil { + return nil, fmt.Errorf("查询点赞趋势: %w", err) + } + + favorites, err := s.favoriteRepo.AggregateFavoritesByAuthor(userID, since) + if err != nil { + return nil, fmt.Errorf("查询收藏趋势: %w", err) + } + + return &TrendResult{ + Energy: energy, + Comments: comments, + Likes: likes, + Favorites: favorites, + }, nil +} diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index 3696afe..668ac29 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -31,7 +31,7 @@
- +