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:
363
internal/service/favorite_service.go
Normal file
363
internal/service/favorite_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user