feat: 新增排行榜模块

- 新增 leaderboard 服务与 handler、前端排行榜页面
- 路由与首页入口适配
This commit is contained in:
2026-09-25 18:28:12 +08:00
parent 4ae57d9392
commit cab8230803
9 changed files with 1112 additions and 2 deletions

View File

@@ -37,6 +37,7 @@ type Handlers struct {
Ads *service.AdService
Sidebar *service.SidebarService
Badge *service.BadgeService
LeaderboardSvc *service.LeaderboardService
}
// resolvePublishStatus 决定新帖/新评的初始状态:

View File

@@ -0,0 +1,71 @@
package handler
import (
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// 榜单参数白名单
var (
lbPeriods = map[string]bool{service.PeriodWeek: true, service.PeriodMonth: true, service.PeriodAll: true}
lbMetrics = map[string]bool{service.MetricAll: true, service.MetricPost: true,
service.MetricComment: true, service.MetricLike: true, service.MetricAnswer: true}
)
// lbParams 解析并校验 period/metric,非法值回退默认并标记 400
func lbParams(c *gin.Context) (period, metric string, bad bool) {
period = c.DefaultQuery("period", service.PeriodAll)
metric = c.DefaultQuery("metric", service.MetricAll)
if !lbPeriods[period] || !lbMetrics[metric] {
return "", "", true
}
return period, metric, false
}
// Leaderboard 用户贡献榜:period=week|month|all × metric=all|post|comment|like|answer(公开接口)
func (h *Handlers) Leaderboard(c *gin.Context) {
period, metric, bad := lbParams(c)
if bad {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的榜单参数"})
return
}
limit := 50
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
if limit > 100 {
limit = 100
}
res, err := h.LeaderboardSvc.Board(period, metric, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取排行榜失败"})
return
}
c.JSON(http.StatusOK, res)
}
// LeaderboardMe 当前用户在指定榜单的排名(可选登录;游客返回 me:null)
func (h *Handlers) LeaderboardMe(c *gin.Context) {
period, metric, bad := lbParams(c)
if bad {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的榜单参数"})
return
}
if v, ok := c.Get("user"); ok {
if claims, ok := v.(*service.UserClaims); ok && claims.ID > 0 {
res, err := h.LeaderboardSvc.MyRank(period, metric, claims.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取排名失败"})
return
}
c.JSON(http.StatusOK, gin.H{"me": res})
return
}
}
c.JSON(http.StatusOK, gin.H{"me": nil})
}

View File

@@ -75,6 +75,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
uploadSvc.WithOperations(ops)
postFileSvc.WithOperations(ops)
pointsSvc := service.NewPointsService(model.DB)
leaderboardSvc := service.NewLeaderboardService(model.DB)
adminUserSvc := service.NewAdminUserService(model.DB)
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
moderationSvc := service.NewModerationService(model.DB, notifSvc)
@@ -126,6 +127,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Ads: adSvc,
Sidebar: service.NewSidebarService(model.DB),
Badge: badgeSvc,
LeaderboardSvc: leaderboardSvc,
}
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
notifSvc.OnNotifyNew = func(userID uint) {
@@ -175,6 +177,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/pages", h.SitePagesList)
pubAPI.GET("/pages/:slug", h.SitePageDetail)
pubAPI.GET("/settings", h.PublicSettings)
pubAPI.GET("/leaderboard", h.Leaderboard)
pubAPI.GET("/leaderboard/me", h.LeaderboardMe)
pubAPI.GET("/site-state", h.SiteState)
pubAPI.GET("/media/:object", h.PublicObject)
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)

View File

@@ -0,0 +1,345 @@
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
}