diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 9f33f29..7c3594d 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -48,10 +48,19 @@ type studioUseCase interface { IsPostAccessible(userID uint, role string, postUserID uint) bool } -// spaceUseCase SpaceController 对 SpaceService 的最小依赖(ISP:2 个方法) +// spaceUseCase SpaceController 对 SpaceService 的最小依赖(ISP:5 个方法) type spaceUseCase interface { GetSpaceUser(uid uint) (*model.User, error) GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error) + CountUserPosts(uid uint) (int64, error) + GetUserStats(uid uint) (likes, favorites int64, err error) + UpdateUser(user *model.User) error +} + +// favoriteUseCaseForSpace SpaceController 对 FavoriteService 的最小依赖(ISP:收藏夹相关) +type favoriteUseCaseForSpace interface { + ListFolders(userID uint) (*service.FavoriteListResult, error) + ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error) } // sessionManager 登录管理对会话管理的最小依赖(ISP:4 个方法) diff --git a/internal/controller/space_controller.go b/internal/controller/space_controller.go index 7dfeca5..7d1f063 100644 --- a/internal/controller/space_controller.go +++ b/internal/controller/space_controller.go @@ -11,10 +11,11 @@ import ( "github.com/gin-gonic/gin" ) -// SpaceController 用户空间控制器 +// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置) type SpaceController struct { - spaceService spaceUseCase - followSvc followUseCase + spaceService spaceUseCase + followSvc followUseCase + favoriteSvc favoriteUseCaseForSpace } // NewSpaceController 构造函数 @@ -27,6 +28,11 @@ func (ctrl *SpaceController) WithFollowService(svc followUseCase) { ctrl.followSvc = svc } +// WithFavoriteService 注入收藏夹服务(可选依赖) +func (ctrl *SpaceController) WithFavoriteService(svc favoriteUseCaseForSpace) { + ctrl.favoriteSvc = svc +} + // MySpace 自己的空间(/space)— 需登录,重定向到 /space/{uid} func (ctrl *SpaceController) MySpace(c *gin.Context) { uid, _, ok := common.GetGinUser(c) @@ -37,7 +43,7 @@ func (ctrl *SpaceController) MySpace(c *gin.Context) { c.Redirect(http.StatusFound, "/space/"+strconv.FormatUint(uint64(uid), 10)) } -// ShowSpace 查看他人或自己的空间(/space/:uid)— 无需认证 +// ShowSpace 统一空间页面(/space/:uid?tab=articles|following|followers|collections|settings) func (ctrl *SpaceController) ShowSpace(c *gin.Context) { uidStr := c.Param("uid") uid, err := strconv.ParseUint(uidStr, 10, 64) @@ -60,61 +66,193 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) { return } - // 分页 - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - if page < 1 { - page = 1 - } - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) - if pageSize < 1 || pageSize > 50 { - pageSize = 10 - } - - posts, total, err := ctrl.spaceService.GetPostsByUser(uint(uid), page, pageSize) - if err != nil { - c.HTML(http.StatusInternalServerError, "space/index.html", common.BuildPageData(c, gin.H{ - "Title": "加载失败", - "Error": "加载帖子失败,请稍后重试", - "ExtraCSS": "/static/css/space.css", - })) - return - } - - totalPages := common.PageCount(total, pageSize) - prevPage := page - 1 - if prevPage < 1 { - prevPage = 1 - } - nextPage := page + 1 - if nextPage > totalPages { - nextPage = totalPages - } - - // 检查是否是自己的空间 + // 当前登录用户 currentUID, _, _ := common.GetGinUser(c) isOwnSpace := currentUID == uint(uid) - // 获取关注列表数据 - var followingResult *service.FollowListResult - if ctrl.followSvc != nil { - res, err := ctrl.followSvc.ListFollowing(uint(uid), currentUID, 1, 24) - if err != nil || res == nil { - res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} - } - followingResult = res + // 当前激活的 Tab + tab := c.DefaultQuery("tab", "articles") + + // 分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { page = 1 } + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "18")) + if pageSize < 1 || pageSize > 50 { pageSize = 18 } + + // 构建基础数据(含用户统计) + postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid)) + totalLikes, totalFavorites, _ := ctrl.spaceService.GetUserStats(uint(uid)) + + data := gin.H{ + "Title": spaceUser.Username + " 的空间", + "SpaceUser": spaceUser, + "IsOwnSpace": isOwnSpace, + "ActiveTab": tab, + "CurrentUID": currentUID, + "ExtraCSS": "/static/css/space.css", + "PostCount": postCount, + "TotalLikes": totalLikes, + "TotalFavorites": totalFavorites, + "TotalReads": int64(0), // 暂无阅读量统计 } - c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, gin.H{ - "Title": spaceUser.Username + " 的空间", - "SpaceUser": spaceUser, - "Posts": posts, - "Total": total, - "Page": page, - "TotalPages": totalPages, - "PrevPage": prevPage, - "NextPage": nextPage, - "IsOwnSpace": isOwnSpace, - "FollowingResult": followingResult, - "ExtraCSS": "/static/css/space.css", - })) + // ---- 根据 Tab 加载对应数据 ---- + switch tab { + case "following": + ctrl.loadFollowingData(c, data, uint(uid), currentUID, page, pageSize) + case "followers": + ctrl.loadFollowersData(c, data, uint(uid), currentUID, page, pageSize) + case "collections": + if !isOwnSpace { + // 访客不可看收藏夹,重定向到文章 + c.Redirect(http.StatusFound, "/space/"+uidStr+"?tab=articles") + return + } + ctrl.loadCollectionsData(c, data, currentUID, page, pageSize) + case "settings": + if !isOwnSpace { + c.Redirect(http.StatusFound, "/space/"+uidStr+"?tab=articles") + return + } + // 设置页无需额外数据,模板中直接渲染隐私开关 + default: // articles + ctrl.loadArticlesData(c, data, uint(uid), page, pageSize) + } + + 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 + + // 默认展示第一个收藏夹的内容 + activeFolderID := c.DefaultQuery("folder", "") + if activeFolderID == "" && len(folders.Folders) > 0 { + activeFolderID = strconv.FormatUint(uint64(folders.Folders[0].ID), 10) + } + + var folderItems *service.FolderItemsResult + if activeFolderID != "" { + fid, parseErr := strconv.ParseUint(activeFolderID, 10, 64) + if parseErr == nil { + items, itemErr := ctrl.favoriteSvc.ListFolderItems(uid, uint(fid), 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 + 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/model/user.go b/internal/model/user.go index d443f37..e934bd3 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -20,9 +20,10 @@ type User struct { Energy int `gorm:"default:0" json:"energy"` // 域能余额(可为负数,乘10存储) // 关注系统 - FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器) - FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器) - FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开 + FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器) + FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器) + FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开 + FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开 // 通知偏好 (JSON string) NotifyPrefs string `gorm:"type:text;default:'{\"comment\":true,\"comment_reply\":true,\"like_aggregated\":true,\"follow\":true}'" json:"-"` diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index b60fc37..5e2de54 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -81,6 +81,29 @@ 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 int64, err error) { + type stats struct { + TotalLikes int64 + TotalFavorites 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"). + Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, model.PostStatusApproved). + Scan(&s).Error + return s.TotalLikes, s.TotalFavorites, err +} + // FindByUserIDAndStatus 分页查询某用户指定状态的帖子(空 status=全状态) func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) { query := r.buildQuery("", status). diff --git a/internal/router/api.go b/internal/router/api.go index 9fbbcf8..291c307 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -176,6 +176,15 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite) } + // --- 空间/隐私设置 API(需登录) --- + spaceAPI := r.Group("/api/space") + spaceAPI.Use(d.authMdw.Required()) + spaceAPI.Use(d.authMdw.BannedWriteGuard()) + spaceAPI.Use(middleware.CSRF(cfg)) + { + spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy) + } + // --- Studio 趋势 API(需登录) --- trendsAPI := r.Group("/api/studio") trendsAPI.Use(d.authMdw.Required()) diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index fad6bb5..4da40bb 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -39,7 +39,6 @@ type dependencies struct { adminCommentCtrl *adminCtrl.AdminCommentController reactionCtrl *controller.ReactionController followCtrl *controller.FollowController - followPageCtrl *controller.FollowPageController favoriteCtrl *controller.FavoriteController favoriteService *service.FavoriteService trendsService *service.TrendsService diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 080f7a8..ecf6caf 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -80,17 +80,17 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting followService := service.NewFollowService(db, followRepo) followService.SetNotifier(notificationService) // 关注通知 followCtrl := controller.NewFollowController(followService) - followPageCtrl := controller.NewFollowPageController(followService) + + // 收藏夹系统(需在 SpaceController 之前初始化) + favoriteRepo := repository.NewFavoriteRepo(db) + favoriteService := service.NewFavoriteService(db, favoriteRepo) + favoriteCtrl := controller.NewFavoriteController(favoriteService) // 用户空间 spaceService := service.NewSpaceService(userRepo, postRepo) spaceCtrl := controller.NewSpaceController(spaceService) spaceCtrl.WithFollowService(followService) - - // 收藏夹系统 - favoriteRepo := repository.NewFavoriteRepo(db) - favoriteService := service.NewFavoriteService(db, favoriteRepo) - favoriteCtrl := controller.NewFavoriteController(favoriteService) + spaceCtrl.WithFavoriteService(favoriteService) // Studio 趋势图 trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo) @@ -115,7 +115,6 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting adminCommentCtrl: adminCommentCtrl, reactionCtrl: reactionCtrl, followCtrl: followCtrl, - followPageCtrl: followPageCtrl, favoriteCtrl: favoriteCtrl, favoriteService: favoriteService, trendsService: trendsService, diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 61040dd..1cb5acd 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -45,10 +45,6 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { pages.GET("/space", d.spaceCtrl.MySpace) pages.GET("/space/:uid", d.spaceCtrl.ShowSpace) - // 关注/粉丝列表页 - pages.GET("/space/:uid/followers", d.followPageCtrl.FollowersPage) - pages.GET("/space/:uid/following", d.followPageCtrl.FollowingPage) - // 帖子页面 pages.GET("/posts", d.postCtrl.ListPage) pages.GET("/posts/:id", d.postCtrl.ShowPage) diff --git a/internal/service/repository.go b/internal/service/repository.go index 1e9407b..681d9bc 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -46,11 +46,14 @@ type postStore interface { // spaceUserStore SpaceService 所需的最小用户仓储接口 type spaceUserStore interface { FindByID(id uint) (*model.User, error) + Update(user *model.User) error } // spacePostStore SpaceService 所需的最小帖子仓储接口 type spacePostStore interface { FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) + CountUserPosts(userID uint) (int64, error) + GetUserStats(userID uint) (likes, favorites int64, err error) } // postAdminStatStore AdminService 所需的帖子统计接口(ISP:2 个方法) diff --git a/internal/service/space_service.go b/internal/service/space_service.go index 90aa6fc..3011250 100644 --- a/internal/service/space_service.go +++ b/internal/service/space_service.go @@ -23,3 +23,18 @@ func (s *SpaceService) GetPostsByUser(uid uint, page, pageSize int) ([]model.Pos offset := (page - 1) * pageSize return s.postRepo.FindByUserID(uid, offset, pageSize) } + +// CountUserPosts 统计用户已发布文章数 +func (s *SpaceService) CountUserPosts(uid uint) (int64, error) { + return s.postRepo.CountUserPosts(uid) +} + +// GetUserStats 获取用户文章统计数据(总获赞、总收藏) +func (s *SpaceService) GetUserStats(uid uint) (likes, favorites int64, err error) { + return s.postRepo.GetUserStats(uid) +} + +// UpdateUser 更新用户信息(用于隐私设置等) +func (s *SpaceService) UpdateUser(user *model.User) error { + return s.userRepo.Update(user) +} diff --git a/templates/MetaLab-2026/html/follow/followers.html b/templates/MetaLab-2026/html/follow/followers.html deleted file mode 100644 index 370ef05..0000000 --- a/templates/MetaLab-2026/html/follow/followers.html +++ /dev/null @@ -1,47 +0,0 @@ -{{template "layout/header.html" .}} - -
- -{{template "layout/nav.html" .}} - -该用户设置了关注列表不公开
-暂无粉丝
-