346 lines
11 KiB
Go
346 lines
11 KiB
Go
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
|
||
}
|