Files
jiang13-bbs/backend/service/leaderboard.go
freefire cab8230803 feat: 新增排行榜模块
- 新增 leaderboard 服务与 handler、前端排行榜页面
- 路由与首页入口适配
2026-09-25 18:28:12 +08:00

346 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}