feat: 新增排行榜模块
- 新增 leaderboard 服务与 handler、前端排行榜页面 - 路由与首页入口适配
This commit is contained in:
@@ -37,6 +37,7 @@ type Handlers struct {
|
||||
Ads *service.AdService
|
||||
Sidebar *service.SidebarService
|
||||
Badge *service.BadgeService
|
||||
LeaderboardSvc *service.LeaderboardService
|
||||
}
|
||||
|
||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||
|
||||
71
backend/handler/leaderboard.go
Normal file
71
backend/handler/leaderboard.go
Normal 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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
345
backend/service/leaderboard.go
Normal file
345
backend/service/leaderboard.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user