feat: 用户徽章与等级体系,积分经济管理重构

- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
2026-09-25 16:38:29 +08:00
parent 7b327bc8cc
commit 4ae57d9392
57 changed files with 3939 additions and 147 deletions

View File

@@ -2,6 +2,7 @@ package service
import (
"errors"
"log"
"time"
"github.com/freefire/jiang13-bbs/model"
@@ -32,7 +33,8 @@ func (s *PointsService) Balance(userID uint) (int, error) {
return u.Points, nil
}
// CreditTx 在事务内入账
// 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
@@ -43,7 +45,13 @@ func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID
return 0, err
}
bal := u.Points + delta
if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil {
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{
@@ -60,6 +68,31 @@ func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID
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 {
@@ -291,6 +324,11 @@ func transferEnds(a, b model.PointLedger) (fromID, toID uint) {
// 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
}
@@ -298,7 +336,7 @@ func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64
size = 20
}
var total int64
q := s.db.Model(&model.PointLedger{}).Where("user_id = ?", userID)
q := db.Model(&model.PointLedger{}).Where("user_id = ?", userID)
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
@@ -316,6 +354,33 @@ func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64
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"`