package service import ( "sort" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // 榜单周期与统计维度(handler 侧做白名单校验) const ( PeriodWeek = "week" PeriodMonth = "month" PeriodAll = "all" MetricAll = "all" // 综合:周/月=四项合计,总榜=累计积分 MetricPost = "post" // 发帖最多 MetricComment = "comment" // 回复最多 MetricLike = "like" // 获赞最多 MetricAnswer = "answer" // 最佳答案(回答被采纳) ) // LeaderboardEntry 排行榜单条(领奖台/列表通用) type LeaderboardEntry struct { Rank int `json:"rank"` ID uint `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` TotalPoints int `json:"total_points"` // 累计获得积分(经验,定级依据) Points int `json:"points"` // 当前积分余额 Level int `json:"level"` // AfterFind 按 TotalPoints 计算 PostCount int64 `json:"post_count"` CommentCount int64 `json:"comment_count"` LikeCount int64 `json:"like_count"` // 其 published 帖获赞之和 AnswerCount int64 `json:"answer_count"` Score int64 `json:"score"` // 排名依据:见 scoreOf } // LeaderboardResult 榜单响应 type LeaderboardResult struct { Period string `json:"period"` // week | month | all Metric string `json:"metric"` // all | post | comment | like | answer Entries []LeaderboardEntry `json:"entries"` } // MyRank 当前用户在指定榜单的排名(含与上一周期的名次对比) type MyRank struct { Period string `json:"period"` Metric string `json:"metric"` Rank int `json:"rank"` // 1 起;0 = 未上榜 Total int `json:"total"` // 上榜总人数 Score int64 `json:"score"` // 当前得分 Change int `json:"change"` // 名次变化:正=上升,负=下降,0=持平 HasChange bool `json:"has_change"` } // LeaderboardService 用户贡献榜 type LeaderboardService struct { db *gorm.DB } func NewLeaderboardService(db *gorm.DB) *LeaderboardService { return &LeaderboardService{db: db} } // statsRow 用户内容统计聚合行 type statsRow struct { UserID uint PostCount int64 CommentCount int64 LikeCount int64 AnswerCount int64 } // contentStats 聚合发帖/回帖/获赞(posts+comments+post 获赞三臂 UNION ALL 单查询)。 // from/to 任一非 nil 时各臂加时间窗;SQL 范式与 overview.go 活跃用户聚合一致。 func (s *LeaderboardService) contentStats(from, to *time.Time) (map[uint]statsRow, error) { clause := "" var timeArgs []interface{} if from != nil { clause += " AND created_at >= ?" timeArgs = append(timeArgs, *from) } if to != nil { clause += " AND created_at < ?" timeArgs = append(timeArgs, *to) } postArm := "SELECT user_id, 1 AS is_post, 0 AS like_sum FROM posts WHERE status = ?" + clause + " AND deleted_at IS NULL" commentArm := "SELECT user_id, 0 AS is_post, 0 AS like_sum FROM comments WHERE status = ?" + clause + " AND deleted_at IS NULL" likeArm := "SELECT user_id, 0 AS is_post, like_count AS like_sum FROM posts WHERE status = ?" + clause + " AND deleted_at IS NULL" // 占位符顺序与三臂拼接顺序严格对应 args := []interface{}{} for i := 0; i < 3; i++ { args = append(args, model.ContentStatusPublished) args = append(args, timeArgs...) } sql := "SELECT a.user_id AS user_id, SUM(a.is_post) AS post_count, SUM(1 - a.is_post) AS comment_count, SUM(a.like_sum) AS like_count" + " FROM (" + postArm + " UNION ALL " + commentArm + " UNION ALL " + likeArm + ") AS a" + " JOIN users AS u ON u.id = a.user_id WHERE u.banned = ? AND u.deleted_at IS NULL GROUP BY a.user_id" args = append(args, false) var rows []statsRow if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil { return nil, err } out := make(map[uint]statsRow, len(rows)) for _, r := range rows { out[r.UserID] = r } return out, nil } // answerStats 最佳答案统计:问答/悬赏帖中其评论被采纳(type_meta.accepted_comment_id)的条数, // 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计) func (s *LeaderboardService) answerStats(from, to *time.Time) (map[uint]int64, error) { clause := "" var timeArgs []interface{} if from != nil { clause += " AND p.created_at >= ?" timeArgs = append(timeArgs, *from) } if to != nil { clause += " AND p.created_at < ?" timeArgs = append(timeArgs, *to) } accepted := "COALESCE(NULLIF(NULLIF(p.type_meta, '')::jsonb ->> 'accepted_comment_id', ''), '0')::bigint" sql := "SELECT c.user_id AS user_id, COUNT(*) AS answer_count FROM posts p" + " JOIN comments c ON c.id = " + accepted + " JOIN users u ON u.id = c.user_id" + " WHERE p.post_type IN (?, ?) AND p.status = ? AND p.deleted_at IS NULL" + " AND " + accepted + " > 0 AND c.deleted_at IS NULL AND u.banned = ? AND u.deleted_at IS NULL" + clause + " GROUP BY c.user_id" args := []interface{}{model.PostTypeQuestion, model.PostTypeBounty, model.ContentStatusPublished, false} args = append(args, timeArgs...) var rows []struct { UserID uint AnswerCount int64 } if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil { return nil, err } out := make(map[uint]int64, len(rows)) for _, r := range rows { out[r.UserID] = r.AnswerCount } return out, nil } // statsFor 内容统计 + 最佳答案合并 func (s *LeaderboardService) statsFor(from, to *time.Time) (map[uint]statsRow, error) { stats, err := s.contentStats(from, to) if err != nil { return nil, err } answers, err := s.answerStats(from, to) if err != nil { return nil, err } for id, n := range answers { r := stats[id] r.AnswerCount = n stats[id] = r } return stats, nil } // scoreOf 指标 → 排名得分;综合 = 四项合计(总榜-全部走累计积分口径,见 boardBetween) func scoreOf(metric string, st statsRow) int64 { switch metric { case MetricPost: return st.PostCount case MetricComment: return st.CommentCount case MetricLike: return st.LikeCount case MetricAnswer: return st.AnswerCount default: return st.PostCount + st.CommentCount + st.LikeCount + st.AnswerCount } } // entryFromUser 由用户行构造榜单条目(不含统计) func entryFromUser(rank int, u model.User) LeaderboardEntry { return LeaderboardEntry{ Rank: rank, ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, TotalPoints: u.TotalPoints, Points: u.Points, Level: u.Level, } } // periodBounds 周期 → [from, to) 时间窗与上一周期窗口;总榜两值均 nil func periodBounds(period string, now time.Time) (from, to, prevFrom, prevTo *time.Time) { switch period { case PeriodWeek: w := startOfWeek(now) pw := w.AddDate(0, 0, -7) return &w, nil, &pw, &w case PeriodMonth: m := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) pm := m.AddDate(0, -1, 0) return &m, nil, &pm, &m default: return nil, nil, nil, nil } } // boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。 // 「总榜+全部」走累计积分口径(total_points),其余走统计聚合口径。 func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) { if metric == MetricAll && from == nil { var users []model.User if err := s.db. Where("banned = ? AND total_points > 0", false). Order("total_points DESC, id ASC"). Find(&users).Error; err != nil { return nil, err } stats, err := s.statsFor(nil, nil) if err != nil { return nil, err } entries := make([]LeaderboardEntry, 0, len(users)) for _, u := range users { e := entryFromUser(0, u) e.Score = int64(u.TotalPoints) if r, ok := stats[u.ID]; ok { e.PostCount, e.CommentCount, e.LikeCount, e.AnswerCount = r.PostCount, r.CommentCount, r.LikeCount, r.AnswerCount } entries = append(entries, e) } return entries, nil } stats, err := s.statsFor(from, to) if err != nil { return nil, err } entries := make([]LeaderboardEntry, 0, len(stats)) for id, r := range stats { score := scoreOf(metric, r) if score <= 0 { continue } entries = append(entries, LeaderboardEntry{ID: id, Score: score, PostCount: r.PostCount, CommentCount: r.CommentCount, LikeCount: r.LikeCount, AnswerCount: r.AnswerCount}) } if len(entries) == 0 { return entries, nil } ids := make([]uint, 0, len(entries)) for _, e := range entries { ids = append(ids, e.ID) } var users []model.User if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil { return nil, err } info := make(map[uint]model.User, len(users)) for _, u := range users { info[u.ID] = u } full := make([]LeaderboardEntry, 0, len(entries)) for _, e := range entries { u, ok := info[e.ID] if !ok { continue } ne := entryFromUser(0, u) ne.PostCount, ne.CommentCount, ne.LikeCount, ne.AnswerCount = e.PostCount, e.CommentCount, e.LikeCount, e.AnswerCount ne.Score = e.Score full = append(full, ne) } sort.Slice(full, func(i, j int) bool { if full[i].Score != full[j].Score { return full[i].Score > full[j].Score } if full[i].TotalPoints != full[j].TotalPoints { return full[i].TotalPoints > full[j].TotalPoints } return full[i].ID < full[j].ID }) return full, nil } // Board 指定周期×维度的 Top N 榜单 func (s *LeaderboardService) Board(period, metric string, limit int) (*LeaderboardResult, error) { from, to, _, _ := periodBounds(period, time.Now()) full, err := s.boardBetween(from, to, period, metric) if err != nil { return nil, err } if len(full) > limit { full = full[:limit] } for i := range full { full[i].Rank = i + 1 } return &LeaderboardResult{Period: period, Metric: metric, Entries: full}, nil } // MyRank 当前用户在指定榜单的排名;周/月榜带与上一周期的名次对比(正=上升) func (s *LeaderboardService) MyRank(period, metric string, userID uint) (*MyRank, error) { from, to, prevFrom, prevTo := periodBounds(period, time.Now()) full, err := s.boardBetween(from, to, period, metric) if err != nil { return nil, err } res := &MyRank{Period: period, Metric: metric, Total: len(full)} for i, e := range full { if e.ID == userID { res.Rank = i + 1 res.Score = e.Score break } } if period == PeriodWeek || period == PeriodMonth { prev, err := s.boardBetween(prevFrom, prevTo, period, metric) if err != nil { return nil, err } var prevRank int for i, e := range prev { if e.ID == userID { prevRank = i + 1 break } } if res.Rank > 0 && prevRank > 0 { res.HasChange = true res.Change = prevRank - res.Rank } } return res, nil }