Files
jiang13-bbs/backend/service/points.go
freefire 4ae57d9392 feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
2026-09-25 16:38:29 +08:00

743 lines
19 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 (
"errors"
"log"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrInsufficientPoints = errors.New("积分不足")
ErrInvalidPoints = errors.New("积分数量无效")
)
// PointsService 用户积分账户
type PointsService struct {
db *gorm.DB
}
func NewPointsService(db *gorm.DB) *PointsService {
return &PointsService{db: db}
}
// Balance 查询可用积分
func (s *PointsService) Balance(userID uint) (int, error) {
var u model.User
if err := s.db.Select("id", "points").First(&u, userID).Error; err != nil {
return 0, err
}
return u.Points, nil
}
// CreditTx 在事务内入账;入账同时累加累计获得积分(经验值,等级依据)。
// bounty_refund 是悬赏托管退回(自己托给自己的钱),不计入经验,与启动回填口径一致。
func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
if delta <= 0 {
return 0, ErrInvalidPoints
}
var u model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id", "points").First(&u, userID).Error; err != nil {
return 0, err
}
bal := u.Points + delta
updates := map[string]interface{}{
"points": bal,
}
if reason != model.PointReasonBountyRefund {
updates["total_points"] = gorm.Expr("total_points + ?", delta)
}
if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
return 0, err
}
if err := tx.Create(&model.PointLedger{
UserID: userID,
Delta: delta,
Balance: bal,
Reason: reason,
RefType: refType,
RefID: refID,
Note: note,
}).Error; err != nil {
return 0, err
}
return bal, nil
}
// RewardIfConfigured 可配置奖励入账:规则分>0 且当日该 reason 正入账笔数未超 cap 时发放;
// cap<=0 不限。计数按本地日统计 point_ledgers 同 reason 正入账笔数。
// 并发下允许低概率超发 1-2 笔,论坛场景可接受(不做唯一索引级严格)。
// 发放失败仅记日志不阻断主流程(奖励非关键路径)。
func RewardIfConfigured(tx *gorm.DB, userID uint, points, cap int, reason, refType string, refID uint, note string) {
if points <= 0 {
return
}
if cap > 0 {
var used int64
if err := tx.Model(&model.PointLedger{}).
Where("user_id = ? AND reason = ? AND delta > 0 AND created_at >= ?", userID, reason, localDay(time.Now())).
Count(&used).Error; err != nil {
log.Printf("[points] 奖励上限检查失败(忽略): user=%d reason=%s err=%v", userID, reason, err)
return
}
if used >= int64(cap) {
return
}
}
if _, err := CreditTx(tx, userID, points, reason, refType, refID, note); err != nil {
log.Printf("[points] 奖励入账失败(忽略): user=%d reason=%s err=%v", userID, reason, err)
}
}
// DebitTx 在事务内扣款
func DebitTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
if delta <= 0 {
return 0, ErrInvalidPoints
}
var u model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id", "points").First(&u, userID).Error; err != nil {
return 0, err
}
if u.Points < delta {
return 0, ErrInsufficientPoints
}
bal := u.Points - delta
if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil {
return 0, err
}
if err := tx.Create(&model.PointLedger{
UserID: userID,
Delta: -delta,
Balance: bal,
Reason: reason,
RefType: refType,
RefID: refID,
Note: note,
}).Error; err != nil {
return 0, err
}
return bal, nil
}
// Credit 入账
func (s *PointsService) Credit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
var bal int
err := s.db.Transaction(func(tx *gorm.DB) error {
var e error
bal, e = CreditTx(tx, userID, delta, reason, refType, refID, note)
return e
})
return bal, err
}
// Debit 扣款
func (s *PointsService) Debit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
var bal int
err := s.db.Transaction(func(tx *gorm.DB) error {
var e error
bal, e = DebitTx(tx, userID, delta, reason, refType, refID, note)
return e
})
return bal, err
}
// LedgerItem 流水展示项
type LedgerItem struct {
ID uint `json:"id"`
Delta int `json:"delta"`
Balance int `json:"balance"`
Reason string `json:"reason"`
RefType string `json:"ref_type"`
RefID uint `json:"ref_id"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
}
// LedgerParty 管理端流水相关用户摘要
type LedgerParty struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// AdminLedgerItem 管理端最近流水:单边入账带 user;用户互转合并为 from → to
type AdminLedgerItem struct {
ID uint `json:"id"`
Delta int `json:"delta"`
Balance int `json:"balance"`
Reason string `json:"reason"`
RefType string `json:"ref_type"`
RefID uint `json:"ref_id"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
User *LedgerParty `json:"user,omitempty"`
From *LedgerParty `json:"from,omitempty"`
To *LedgerParty `json:"to,omitempty"`
}
const (
adminRecentLedgerLimit = 15
adminRecentLedgerFetch = 32
ledgerPairMaxDist = 8
)
func absInt(n int) int {
if n < 0 {
return -n
}
return n
}
func ledgerPartyOf(u model.User) LedgerParty {
return LedgerParty{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}
}
func copyLedgerParty(p LedgerParty) *LedgerParty {
if p.ID == 0 {
return nil
}
cp := p
return &cp
}
// complementaryLedgerRef 用户互转对向 ref_type;非互转返回空
func complementaryLedgerRef(reason, refType string) string {
switch reason {
case model.PointReasonUnlockPost:
if refType == "post" {
return "post_earn"
}
if refType == "post_earn" {
return "post"
}
case model.PointReasonDownloadFile:
if refType == "attachment" {
return "attachment_earn"
}
if refType == "attachment_earn" {
return "attachment"
}
}
return ""
}
func ledgersComplement(a, b model.PointLedger) bool {
comp := complementaryLedgerRef(a.Reason, a.RefType)
if comp == "" || b.Reason != a.Reason || b.RefID != a.RefID || b.RefType != comp {
return false
}
if absInt(a.Delta) != absInt(b.Delta) || absInt(a.Delta) == 0 {
return false
}
return (a.Delta > 0) != (b.Delta > 0)
}
func idDist(a, b uint) int {
if a > b {
return int(a - b)
}
return int(b - a)
}
func findComplementIndex(rows []model.PointLedger, i int, used []bool) int {
r := rows[i]
if complementaryLedgerRef(r.Reason, r.RefType) == "" {
return -1
}
best := -1
bestDist := ledgerPairMaxDist + 1
for j := range rows {
if j == i || used[j] || !ledgersComplement(r, rows[j]) {
continue
}
d := idDist(r.ID, rows[j].ID)
if d == 0 || d > ledgerPairMaxDist || d >= bestDist {
continue
}
bestDist = d
best = j
}
return best
}
// pairLedgerIndices 将用户互转的支出/收益配成一对;返回 [本条, 对向](无对向则为 -1)
func pairLedgerIndices(rows []model.PointLedger) [][2]int {
used := make([]bool, len(rows))
out := make([][2]int, 0, len(rows))
for i := range rows {
if used[i] {
continue
}
j := findComplementIndex(rows, i, used)
if j >= 0 {
used[i] = true
used[j] = true
out = append(out, [2]int{i, j})
continue
}
used[i] = true
out = append(out, [2]int{i, -1})
}
return out
}
func mergePairedLedger(a, b model.PointLedger) model.PointLedger {
newer, older := a, b
if b.ID > a.ID {
newer, older = b, a
}
out := newer
out.Delta = absInt(newer.Delta)
if newer.Delta < 0 {
out.Delta = absInt(older.Delta)
}
debit, credit := a, b
if a.Delta > 0 {
debit, credit = b, a
}
if debit.Note != "" {
out.Note = debit.Note
} else {
out.Note = credit.Note
}
return out
}
func transferEnds(a, b model.PointLedger) (fromID, toID uint) {
if a.Delta < 0 {
return a.UserID, b.UserID
}
if b.Delta < 0 {
return b.UserID, a.UserID
}
if a.Delta > 0 {
return b.UserID, a.UserID
}
return a.UserID, b.UserID
}
// Ledger 分页查询本人积分流水
func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) {
return ledgerPage(s.db, userID, page, size)
}
// ledgerPage 积分流水分页查询(本人/管理端共用口径:id DESC,size 1..50 缺省 20)
func ledgerPage(db *gorm.DB, userID uint, page, size int) ([]LedgerItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
var total int64
q := db.Model(&model.PointLedger{}).Where("user_id = ?", userID)
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.PointLedger
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, 0, err
}
out := make([]LedgerItem, 0, len(rows))
for _, r := range rows {
out = append(out, LedgerItem{
ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
})
}
return out, total, nil
}
// AdminUserLedger 管理端查询任意用户积分流水
func (s *PointsService) AdminUserLedger(userID uint, page, size int) ([]LedgerItem, int64, error) {
return ledgerPage(s.db, userID, page, size)
}
// AdminAdjustPoints 管理员手动调整积分:加分复用 CreditTx(同步累加累计获得→可升级),
// 扣分复用 DebitTx(仅扣余额、不降级);reason 统一 admin_adjust,note 为调整理由。
// 返回调整后余额、累计获得与等级。
func (s *PointsService) AdminAdjustPoints(userID uint, delta int, note string) (bal, total, level int, err error) {
err = s.db.Transaction(func(tx *gorm.DB) error {
if delta > 0 {
bal, err = CreditTx(tx, userID, delta, model.PointReasonAdminAdjust, "admin", 0, note)
} else {
bal, err = DebitTx(tx, userID, -delta, model.PointReasonAdminAdjust, "admin", 0, note)
}
return err
})
if err != nil {
return 0, 0, 0, err
}
var u model.User
if err := s.db.Select("id", "total_points").First(&u, userID).Error; err != nil {
return 0, 0, 0, err
}
return bal, u.TotalPoints, model.LevelOf(u.TotalPoints), nil
}
// DeltaDay 单日积分发放与消耗
type DeltaDay struct {
Date string `json:"date"`
Granted int64 `json:"granted"`
Spent int64 `json:"spent"`
}
// PointsEconomyStats 全站积分与类型帖概览(管理端)
type PointsEconomyStats struct {
TotalBalance int64 `json:"total_balance"`
LedgerCount int64 `json:"ledger_count"`
CheckinToday int64 `json:"checkin_today"`
OpenBounties int64 `json:"open_bounties"`
EscrowedPoints int64 `json:"escrowed_points"`
PostsByType map[string]int64 `json:"posts_by_type"`
RecentLedger []AdminLedgerItem `json:"recent_ledger"`
CheckinSeries []DayCount `json:"checkin_series"`
LedgerByReason []NamedCount `json:"ledger_by_reason"`
DeltaSeries []DeltaDay `json:"delta_series"`
NetDelta7d int64 `json:"net_delta_7d"`
}
// AdminEconomyStats 管理端经济看板
func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
st := &PointsEconomyStats{
PostsByType: map[string]int64{},
CheckinSeries: make([]DayCount, 0),
LedgerByReason: make([]NamedCount, 0),
DeltaSeries: make([]DeltaDay, 0),
RecentLedger: make([]AdminLedgerItem, 0),
}
if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL").
Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.Checkin{}).
Where("checkin_date::date = CURRENT_DATE").
Count(&st.CheckinToday).Error; err != nil {
return nil, err
}
type row struct {
PostType string
Cnt int64
}
var rows []row
if err := s.db.Model(&model.Post{}).
Select("post_type, count(*) as cnt").
Where("deleted_at IS NULL").
Group("post_type").Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
pt := model.NormalizePostType(r.PostType)
st.PostsByType[pt] += r.Cnt
}
var bountyPosts []model.Post
if err := s.db.Select("id, type_meta").
Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
Find(&bountyPosts).Error; err != nil {
return nil, err
}
for _, p := range bountyPosts {
m, err := parseBountyMeta(p.TypeMeta)
if err != nil {
continue
}
if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired {
st.OpenBounties++
st.EscrowedPoints += int64(m.Points)
}
}
recent, err := s.buildAdminRecentLedger()
if err != nil {
return nil, err
}
st.RecentLedger = recent
now := time.Now()
day0 := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
start14 := day0.AddDate(0, 0, -13)
start7 := day0.AddDate(0, 0, -6)
type dayCnt struct {
Day string
Cnt int64
}
var checkinRows []dayCnt
if err := s.db.Raw(`
SELECT to_char(checkin_date, 'YYYY-MM-DD') AS day, count(*) AS cnt
FROM checkins
WHERE checkin_date >= ? AND checkin_date <= ?
GROUP BY day ORDER BY day`, start14, day0).Scan(&checkinRows).Error; err != nil {
return nil, err
}
checkinMap := map[string]int64{}
for _, r := range checkinRows {
checkinMap[r.Day] = r.Cnt
}
st.CheckinSeries = fillDaySeries(start14, day0, checkinMap)
type reasonRow struct {
Reason string
Cnt int64
}
var reasons []reasonRow
if err := s.db.Model(&model.PointLedger{}).
Select("reason, count(*) as cnt").
Group("reason").
Order("cnt DESC").
Limit(12).Scan(&reasons).Error; err != nil {
return nil, err
}
for _, r := range reasons {
st.LedgerByReason = append(st.LedgerByReason, NamedCount{
Key: r.Reason, Label: r.Reason, Count: r.Cnt,
})
}
type deltaRow struct {
Day string
Granted int64
Spent int64
}
var deltas []deltaRow
if err := s.db.Raw(`
SELECT to_char(created_at, 'YYYY-MM-DD') AS day,
COALESCE(SUM(CASE WHEN delta > 0 THEN delta ELSE 0 END), 0) AS granted,
COALESCE(SUM(CASE WHEN delta < 0 THEN -delta ELSE 0 END), 0) AS spent
FROM point_ledgers
WHERE created_at >= ? AND created_at < ?
GROUP BY day ORDER BY day`,
start14, day0.AddDate(0, 0, 1)).Scan(&deltas).Error; err != nil {
return nil, err
}
deltaMap := map[string]DeltaDay{}
for _, r := range deltas {
deltaMap[r.Day] = DeltaDay{Date: r.Day, Granted: r.Granted, Spent: r.Spent}
}
for d := start14; !d.After(day0); d = d.AddDate(0, 0, 1) {
k := dateKey(d)
dd := deltaMap[k]
dd.Date = k
st.DeltaSeries = append(st.DeltaSeries, dd)
if !d.Before(start7) {
st.NetDelta7d += dd.Granted - dd.Spent
}
}
return st, nil
}
func (s *PointsService) lookupLedgerMate(r model.PointLedger) (*model.PointLedger, error) {
comp := complementaryLedgerRef(r.Reason, r.RefType)
if comp == "" || r.RefID == 0 {
return nil, nil
}
amt := absInt(r.Delta)
if amt == 0 {
return nil, nil
}
lo, hi := uint(1), r.ID+uint(ledgerPairMaxDist)
if r.ID > uint(ledgerPairMaxDist) {
lo = r.ID - uint(ledgerPairMaxDist)
}
var mates []model.PointLedger
q := s.db.Where("reason = ? AND ref_id = ? AND ref_type = ? AND id BETWEEN ? AND ? AND id <> ?",
r.Reason, r.RefID, comp, lo, hi, r.ID)
if r.Delta > 0 {
q = q.Where("delta = ?", -amt)
} else {
q = q.Where("delta = ?", amt)
}
if err := q.Find(&mates).Error; err != nil {
return nil, err
}
best := -1
bestDist := ledgerPairMaxDist + 1
for i := range mates {
d := idDist(r.ID, mates[i].ID)
if d == 0 || d > ledgerPairMaxDist || d >= bestDist {
continue
}
bestDist = d
best = i
}
if best < 0 {
return nil, nil
}
return &mates[best], nil
}
func (s *PointsService) loadLedgerParties(ids []uint) (map[uint]LedgerParty, error) {
out := map[uint]LedgerParty{}
if len(ids) == 0 {
return out, nil
}
seen := map[uint]struct{}{}
uniq := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
uniq = append(uniq, id)
}
if len(uniq) == 0 {
return out, nil
}
var users []model.User
if err := s.db.Unscoped().Select("id", "username", "nickname", "avatar").
Where("id IN ?", uniq).Find(&users).Error; err != nil {
return nil, err
}
for _, u := range users {
out[u.ID] = ledgerPartyOf(u)
}
return out, nil
}
func (s *PointsService) bountyPayers(rows []model.PointLedger) (map[uint]uint, error) {
ids := make([]uint, 0)
seen := map[uint]struct{}{}
for _, r := range rows {
if r.Reason != model.PointReasonBountyAward || r.RefType != "post" || r.RefID == 0 {
continue
}
if _, ok := seen[r.RefID]; ok {
continue
}
seen[r.RefID] = struct{}{}
ids = append(ids, r.RefID)
}
out := map[uint]uint{}
if len(ids) == 0 {
return out, nil
}
var posts []model.Post
if err := s.db.Unscoped().Select("id", "user_id").Where("id IN ?", ids).Find(&posts).Error; err != nil {
return nil, err
}
for _, p := range posts {
out[p.ID] = p.UserID
}
return out, nil
}
func (s *PointsService) buildAdminRecentLedger() ([]AdminLedgerItem, error) {
out := make([]AdminLedgerItem, 0, adminRecentLedgerLimit)
var recent []model.PointLedger
if err := s.db.Order("id DESC").Limit(adminRecentLedgerFetch).Find(&recent).Error; err != nil {
return nil, err
}
if len(recent) == 0 {
return out, nil
}
pairs := pairLedgerIndices(recent)
outside := map[int]model.PointLedger{}
for _, p := range pairs {
if p[1] >= 0 {
continue
}
r := recent[p[0]]
if complementaryLedgerRef(r.Reason, r.RefType) == "" {
continue
}
mate, err := s.lookupLedgerMate(r)
if err != nil {
return nil, err
}
if mate != nil {
outside[p[0]] = *mate
}
}
payers, err := s.bountyPayers(recent)
if err != nil {
return nil, err
}
userIDs := make([]uint, 0, len(recent)+len(outside)+len(payers))
for _, r := range recent {
userIDs = append(userIDs, r.UserID)
}
for _, m := range outside {
userIDs = append(userIDs, m.UserID)
}
for _, uid := range payers {
userIDs = append(userIDs, uid)
}
parties, err := s.loadLedgerParties(userIDs)
if err != nil {
return nil, err
}
party := func(id uint) *LedgerParty {
if id == 0 {
return nil
}
if p, ok := parties[id]; ok {
return copyLedgerParty(p)
}
return &LedgerParty{ID: id, Nickname: "已注销"}
}
itemFrom := func(r model.PointLedger) AdminLedgerItem {
return AdminLedgerItem{
ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
User: party(r.UserID),
}
}
for _, p := range pairs {
if len(out) >= adminRecentLedgerLimit {
break
}
a := recent[p[0]]
var mate *model.PointLedger
if p[1] >= 0 {
m := recent[p[1]]
mate = &m
} else if m, ok := outside[p[0]]; ok {
mate = &m
}
if mate != nil {
merged := mergePairedLedger(a, *mate)
fromID, toID := transferEnds(a, *mate)
item := itemFrom(merged)
item.Delta = absInt(merged.Delta)
item.User = nil
item.From = party(fromID)
item.To = party(toID)
out = append(out, item)
continue
}
item := itemFrom(a)
if a.Reason == model.PointReasonBountyAward && a.Delta > 0 {
if payerID := payers[a.RefID]; payerID > 0 && payerID != a.UserID {
item.From = party(payerID)
item.To = party(a.UserID)
item.User = nil
item.Delta = a.Delta
}
}
out = append(out, item)
}
return out, nil
}