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:
270
internal/controller/favorite_controller.go
Normal file
270
internal/controller/favorite_controller.go
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
85
internal/controller/settings_api_notify.go
Normal file
85
internal/controller/settings_api_notify.go
Normal file
@ -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, "通知偏好已更新")
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user