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

@@ -0,0 +1,159 @@
package service
import (
"errors"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newBadgeTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Badge{}, &model.UserBadge{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestBadgeGrantIdempotent(t *testing.T) {
db := newBadgeTestDB(t)
s := NewBadgeService(db)
user := model.User{Username: "alice", Password: "x"}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
badge, err := s.Create(" 骨灰玩家 ", "", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if badge.Name != "骨灰玩家" || badge.Color != DefaultBadgeColor {
t.Fatalf("名称未规整/默认色不符: %+v", badge)
}
if err := s.Grant(badge.ID, user.ID, 9); err != nil {
t.Fatalf("Grant: %v", err)
}
// 重复授予应幂等成功且不产生新行
if err := s.Grant(badge.ID, user.ID, 9); err != nil {
t.Fatalf("Grant twice: %v", err)
}
var count int64
db.Model(&model.UserBadge{}).Where("badge_id = ?", badge.ID).Count(&count)
if count != 1 {
t.Fatalf("授予记录=%d want 1", count)
}
// 不存在的用户
if err := s.Grant(badge.ID, 999, 9); !errors.Is(err, ErrBadgeUserGone) {
t.Fatalf("不存在用户应 ErrBadgeUserGone, got %v", err)
}
// 不存在的徽章
if err := s.Grant(999, user.ID, 9); !errors.Is(err, ErrBadgeNotFound) {
t.Fatalf("不存在徽章应 ErrBadgeNotFound, got %v", err)
}
// ForUser 带 Badge 定义
rows, err := s.ForUser(user.ID)
if err != nil || len(rows) != 1 {
t.Fatalf("ForUser: %v rows=%d", err, len(rows))
}
if rows[0].Badge.Name != "骨灰玩家" {
t.Fatalf("Preload Badge 失败: %+v", rows[0])
}
// Revoke 后再撤销 → NotFound
if err := s.Revoke(badge.ID, user.ID); err != nil {
t.Fatalf("Revoke: %v", err)
}
if err := s.Revoke(badge.ID, user.ID); !errors.Is(err, ErrBadgeNotFound) {
t.Fatal("重复撤销应 ErrBadgeNotFound")
}
}
func TestBadgeDeleteCascadeAndListCount(t *testing.T) {
db := newBadgeTestDB(t)
s := NewBadgeService(db)
u1 := model.User{Username: "u1", Password: "x"}
u2 := model.User{Username: "u2", Password: "x"}
if err := db.Create(&u1).Error; err != nil {
t.Fatalf("seed u1: %v", err)
}
if err := db.Create(&u2).Error; err != nil {
t.Fatalf("seed u2: %v", err)
}
b1, _ := s.Create("元老", "medal", "gold")
b2, _ := s.Create("萌新", "", "")
if err := s.Grant(b1.ID, u1.ID, 9); err != nil {
t.Fatalf("Grant b1: %v", err)
}
if err := s.Grant(b1.ID, u2.ID, 9); err != nil {
t.Fatalf("Grant b1 u2: %v", err)
}
if err := s.Grant(b2.ID, u1.ID, 9); err != nil {
t.Fatalf("Grant b2: %v", err)
}
// List 计数
list, err := s.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 2 {
t.Fatalf("徽章数=%d want 2", len(list))
}
for _, b := range list {
want := int64(2)
if b.ID == b2.ID {
want = 1
}
if b.Holders != want {
t.Fatalf("徽章 %d 持有数=%d want %d", b.ID, b.Holders, want)
}
}
// 删除级联:徽章与其授予记录一起消失
if err := s.Delete(b1.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
var count int64
db.Model(&model.UserBadge{}).Where("badge_id = ?", b1.ID).Count(&count)
if count != 0 {
t.Fatalf("删除后授予记录=%d want 0", count)
}
if err := s.Delete(b1.ID); !errors.Is(err, ErrBadgeNotFound) {
t.Fatal("重复删除应 ErrBadgeNotFound")
}
// 名称唯一冲突
if _, err := s.Create("萌新", "", ""); !errors.Is(err, ErrBadgeNameExists) {
t.Fatalf("重名应 ErrBadgeNameExists, got %v", err)
}
}
func TestBadgeValidation(t *testing.T) {
s := NewBadgeService(newBadgeTestDB(t))
// 空名称 / 超长名称 / 非法颜色
if _, err := s.Create(" ", "", ""); !errors.Is(err, ErrBadgeInvalid) {
t.Fatal("空名称应被拒绝")
}
long := make([]rune, MaxBadgeNameRunes+1)
for i := range long {
long[i] = '字'
}
if _, err := s.Create(string(long), "", ""); !errors.Is(err, ErrBadgeInvalid) {
t.Fatal("超长名称应被拒绝")
}
if _, err := s.Create("x", "", "pink"); !errors.Is(err, ErrBadgeInvalid) {
t.Fatal("色板外颜色应被拒绝")
}
}