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

@@ -61,6 +61,7 @@ func InitDB(dsn string) error {
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
&Badge{}, &UserBadge{},
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
&ImportRecord{},
); err != nil {
@@ -73,6 +74,10 @@ func InitDB(dsn string) error {
return fmt.Errorf("站长角色迁移失败: %w", err)
}
// 用户等级:按积分流水回填累计获得积分(经验值,LevelOf 定级依据)。
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
backfillUserTotalPoints(db)
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
@@ -334,6 +339,35 @@ func ensureOwnerRole(db *gorm.DB) error {
return nil
}
// backfillUserTotalPoints 按积分流水回填累计获得积分(经验值)。
// 口径与 CreditTx 增量一致:仅计正入账,排除悬赏托管退回(bounty_refund)。
// 每次启动执行,幂等自愈;失败仅记日志不阻断启动(等级纯展示,可下次启动修复)。
func backfillUserTotalPoints(db *gorm.DB) {
var tableCount int64
if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'point_ledgers'`).
Scan(&tableCount).Error; err != nil {
log.Printf("[model] 回填累计获得积分前置检查失败: %v", err)
return
}
if tableCount == 0 {
return
}
res := db.Exec(`
UPDATE users SET total_points = COALESCE((
SELECT SUM(delta) FROM point_ledgers
WHERE point_ledgers.user_id = users.id AND delta > 0 AND reason <> 'bounty_refund'
), 0)
WHERE deleted_at IS NULL
`)
if res.Error != nil {
log.Printf("[model] 回填累计获得积分失败: %v", res.Error)
return
}
if res.RowsAffected > 0 {
log.Printf("[model] 已按积分流水回填累计获得积分 %d 行", res.RowsAffected)
}
}
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。

90
backend/model/level.go Normal file
View File

@@ -0,0 +1,90 @@
package model
import (
"sync/atomic"
)
// 用户等级:按「累计获得积分」(User.TotalPoints)定级,消费不减经验。
// 等级体系(数量/阈值/颜色)由管理员在后台配置,持久化于 site_settings 的 levels_config 键;
// 进程内持有一份原子快照供 LevelOf 纯算术计算,配置保存时刷新,无需重启。
// LevelDef 单级配置:Threshold 为该级累计积分下限(Lv1 恒为 0),Color 为展示色板 key
type LevelDef struct {
Threshold int `json:"threshold"`
Color string `json:"color"`
}
const (
// MinLevelCount / MaxLevelCount 等级数量边界(前端 CSS 变量按 MaxLevelCount 档固定下发)
MinLevelCount = 2
MaxLevelCount = 12
)
// DefaultLevelDefs 默认 7 级(与历史硬编码阶梯一致),缺行/配置非法时回落
func DefaultLevelDefs() []LevelDef {
return []LevelDef{
{Threshold: 0, Color: "neutral"},
{Threshold: 50, Color: "info"},
{Threshold: 200, Color: "teal"},
{Threshold: 500, Color: "ok"},
{Threshold: 1200, Color: "accent"},
{Threshold: 3000, Color: "purple"},
{Threshold: 8000, Color: "gold"},
}
}
// levelSnapshot 当前生效的等级配置([]LevelDef)
var levelSnapshot atomic.Value
func init() {
levelSnapshot.Store(DefaultLevelDefs())
}
// ValidLevelDefs 结构校验:数量 2–12、阈值非负且严格递增、首级为 0(色板合法性由 service 层校验)
func ValidLevelDefs(defs []LevelDef) bool {
if len(defs) < MinLevelCount || len(defs) > MaxLevelCount {
return false
}
if defs[0].Threshold != 0 {
return false
}
for i, d := range defs {
if d.Threshold < 0 {
return false
}
if i > 0 && d.Threshold <= defs[i-1].Threshold {
return false
}
}
return true
}
// ApplyLevelDefs 刷新进程内等级快照;非法/空配置回落默认表
func ApplyLevelDefs(defs []LevelDef) {
if !ValidLevelDefs(defs) {
defs = DefaultLevelDefs()
}
levelSnapshot.Store(append([]LevelDef(nil), defs...))
}
// LevelDefs 当前生效的等级配置
func LevelDefs() []LevelDef {
if v, ok := levelSnapshot.Load().([]LevelDef); ok && len(v) > 0 {
return v
}
return DefaultLevelDefs()
}
// LevelOf 由累计获得积分计算等级,恒返回 1..len(当前配置)
func LevelOf(totalPoints int) int {
defs := LevelDefs()
level := 1
for i := 1; i < len(defs); i++ {
if totalPoints >= defs[i].Threshold {
level = i + 1
} else {
break
}
}
return level
}

106
backend/model/level_test.go Normal file
View File

@@ -0,0 +1,106 @@
package model
import "testing"
// withLevelDefs 临时切换等级快照并在测试结束后恢复现场(快照为进程级全局态)
func withLevelDefs(t *testing.T, defs []LevelDef) {
t.Helper()
prev := LevelDefs()
t.Cleanup(func() { ApplyLevelDefs(prev) })
ApplyLevelDefs(defs)
}
func TestLevelOfDefault(t *testing.T) {
withLevelDefs(t, DefaultLevelDefs())
cases := []struct {
total int
want int
}{
{0, 1},
{49, 1},
{50, 2},
{51, 2},
{199, 2},
{200, 3},
{500, 4},
{1200, 5},
{3000, 6},
{7999, 6},
{8000, 7},
{1 << 30, 7},
{-5, 1},
}
for _, c := range cases {
if got := LevelOf(c.total); got != c.want {
t.Errorf("LevelOf(%d) = %d, want %d", c.total, got, c.want)
}
}
}
func TestLevelOfCustomConfig(t *testing.T) {
withLevelDefs(t, []LevelDef{
{Threshold: 0, Color: "neutral"},
{Threshold: 10, Color: "gold"},
{Threshold: 100, Color: "info"},
})
cases := []struct {
total int
want int
}{
{0, 1},
{9, 1},
{10, 2},
{99, 2},
{100, 3},
{1 << 20, 3},
}
for _, c := range cases {
if got := LevelOf(c.total); got != c.want {
t.Errorf("LevelOf(%d) = %d, want %d", c.total, got, c.want)
}
}
}
func TestApplyLevelDefsFallback(t *testing.T) {
withLevelDefs(t, DefaultLevelDefs())
// 非法配置(首级阈值非 0、数量不足、非递增)一律回落默认表
invalid := [][]LevelDef{
{{Threshold: 10, Color: "gold"}, {Threshold: 50, Color: "info"}},
{{Threshold: 0, Color: "neutral"}},
{{Threshold: 0, Color: "neutral"}, {Threshold: 50, Color: "info"}, {Threshold: 50, Color: "ok"}},
{{Threshold: 0, Color: "neutral"}, {Threshold: -1, Color: "info"}},
}
for _, defs := range invalid {
ApplyLevelDefs(defs)
got := LevelDefs()
if len(got) != len(DefaultLevelDefs()) {
t.Fatalf("ApplyLevelDefs(invalid) 未回落默认表: got %d levels", len(got))
}
}
}
func TestValidLevelDefs(t *testing.T) {
valid := []LevelDef{{Threshold: 0, Color: "neutral"}, {Threshold: 1, Color: "gold"}}
if !ValidLevelDefs(valid) {
t.Errorf("ValidLevelDefs(合法两极) = false")
}
tooMany := make([]LevelDef, MaxLevelCount+1)
for i := range tooMany {
tooMany[i] = LevelDef{Threshold: i, Color: "gold"}
}
if ValidLevelDefs(tooMany) {
t.Errorf("ValidLevelDefs(超上限) = true")
}
}
func TestLevelDefsBoundaryCount(t *testing.T) {
withLevelDefs(t, DefaultLevelDefs())
defs := make([]LevelDef, MaxLevelCount)
for i := range defs {
defs[i] = LevelDef{Threshold: i * 10, Color: "gold"}
}
ApplyLevelDefs(defs)
if got := LevelOf(1 << 30); got != MaxLevelCount {
t.Errorf("LevelOf(极大值) = %d, want %d", got, MaxLevelCount)
}
}

View File

@@ -123,11 +123,22 @@ type User struct {
Banned bool `gorm:"default:false" json:"banned"`
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 管理员颁发的徽章;仅显式 Preload("User.Badges.Badge") 的查询点加载,未加载时省略
Badges []UserBadge `gorm:"foreignKey:UserID" json:"badges,omitempty"`
}
// AfterFind 由累计获得积分计算等级(纯算术,随行加载,所有序列化 User 的接口自动携带 level)
func (u *User) AfterFind(tx *gorm.DB) error {
u.Level = LevelOf(u.TotalPoints)
return nil
}
// RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测)
@@ -276,6 +287,32 @@ type PointLedger struct {
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// Badge 管理员自定义徽章定义(名称/图标/颜色)
type Badge struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:32;not null;uniqueIndex" json:"name"` // ≤16 字(runes 校验见 service 层)
Icon string `gorm:"size:32;not null;default:''" json:"icon"` // lucide 图标名(前端白名单渲染,未知回落 Medal)
Color string `gorm:"size:16;not null;default:''" json:"color"` // CSS token 色板键(gold/accent/ok/danger/info/purple/teal)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UserBadge 徽章授予记录(同一徽章每用户至多一枚)
type UserBadge struct {
ID uint `gorm:"primaryKey" json:"id"`
BadgeID uint `gorm:"not null;uniqueIndex:idx_badge_user" json:"badge_id"`
UserID uint `gorm:"not null;uniqueIndex:idx_badge_user;index" json:"user_id"`
Badge Badge `gorm:"foreignKey:BadgeID" json:"badge"` // 嵌套 Preload("User.Badges.Badge") 时填充
AwardedBy uint `json:"awarded_by"`
AwardedAt time.Time `json:"awarded_at"`
}
// 徽章色板键(对应前端 CSS token --<color> / --<color>-soft)
var BadgeColors = map[string]bool{
"gold": true, "accent": true, "ok": true, "danger": true,
"info": true, "purple": true, "teal": true,
}
// 积分流水原因
const (
PointReasonCheckin = "checkin"
@@ -285,6 +322,11 @@ const (
PointReasonBountyEscrow = "bounty_escrow"
PointReasonBountyRefund = "bounty_refund"
PointReasonBountyAward = "bounty_award"
PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭)
PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭)
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次)
PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额)
)
// PostPollVote 投票记录(多选时同一用户多行)
@@ -396,6 +438,7 @@ const (
NotificationTypeRejected = "rejected" // 内容审核未通过
NotificationTypeMention = "mention" // 群聊中 @ 了你(PostID=0,用 RoomID/MessageID)
NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者)
NotificationTypeBadge = "badge" // 获得管理员颁发的徽章
NotificationTypeDeleted = "deleted" // 帖子被管理员删除
)
@@ -414,8 +457,9 @@ type Notification struct {
CreatedAt time.Time `json:"created_at"`
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"`
// PostID 允许为 0(mention/badge 等无关联帖子),禁建 FK 约束,否则 post_id=0 插入违反外键
Post Post `gorm:"foreignKey:PostID;constraint:-" json:"post,omitempty"`
Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"`
}
// Announcement 站点公告(后台文章管理:支持草稿、标签与标签预设色)