feat: 收藏夹系统 + Studio 趋势图 + 通知偏好设置

- 新增收藏夹系统 (Folder/FolderItem 模型、CRUD API、前端管理页)
- 新增文章详情页收藏按钮 (书签图标、ToggleFavorite 一键收藏)
- 新增 Studio 趋势图 (赋能值/评论/点赞/收藏 4 线图, 7d/30d/90d)
- 新增通知偏好设置页 (评论/回复/点赞/关注 4 类开关)
- 后端通知列表支持按偏好过滤 (排除已关闭的通知类型)
- 下载 Chart.js 到本地静态资源 (static/js/chart.umd.min.js)
- 补充收藏夹卡片、开关滑块、趋势图网格等 CSS 样式
This commit is contained in:
2026-06-01 17:33:59 +08:00
parent 680df7371a
commit 4d212a8f8a
27 changed files with 2005 additions and 33 deletions

View File

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

View File

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

View File

@ -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 标记单条消息为已读

View File

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

View File

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