Files
jiang13-bbs/backend/service/leaderboard.go
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

401 lines
12 KiB
Go
Raw 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
}
// earnedPoints 周期内累计获得积分:point_ledgers 正入账合计(排除 bounty_refund,
// 与 users.total_points 启动回填口径一致),仅统计未封禁用户。
// 签到、发帖/回复奖励、被推荐、悬赏采纳等均按实际入账分值计入贡献值
func (s *LeaderboardService) earnedPoints(from, to *time.Time) (map[uint]int64, error) {
clause := ""
var timeArgs []interface{}
if from != nil {
clause += " AND l.created_at >= ?"
timeArgs = append(timeArgs, *from)
}
if to != nil {
clause += " AND l.created_at < ?"
timeArgs = append(timeArgs, *to)
}
sql := "SELECT l.user_id AS user_id, SUM(l.delta) AS pts FROM point_ledgers l" +
" JOIN users AS u ON u.id = l.user_id" +
" WHERE l.delta > 0 AND l.reason <> ? AND u.banned = ? AND u.deleted_at IS NULL" + clause +
" GROUP BY l.user_id"
args := []interface{}{model.PointReasonBountyRefund, false}
args = append(args, timeArgs...)
var rows []struct {
UserID uint
Pts 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.Pts
}
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 / earnedPoints)
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
}
return 0
}
// 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,周/月=周期内获得积分,见 earnedPoints),
// 细分维度走统计聚合口径。
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
}
// 周/月·全部:贡献值=周期内累计获得积分,候选集并入仅签到/获奖无内容的用户
usePts := metric == MetricAll && from != nil
pts := map[uint]int64{}
if usePts {
if pts, err = s.earnedPoints(from, to); err != nil {
return nil, err
}
}
cand := make(map[uint]statsRow, len(stats)+len(pts))
for id, r := range stats {
cand[id] = r
}
for id := range pts {
if _, ok := cand[id]; !ok {
cand[id] = statsRow{}
}
}
entries := make([]LeaderboardEntry, 0, len(cand))
for id, r := range cand {
score := scoreOf(metric, r)
if usePts {
score = pts[id]
}
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
}