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

240
backend/service/badge.go Normal file
View File

@@ -0,0 +1,240 @@
package service
import (
"errors"
"strings"
"time"
"unicode/utf8"
"gorm.io/gorm"
"github.com/freefire/jiang13-bbs/model"
)
// 徽章业务错误
var (
ErrBadgeNotFound = errors.New("徽章不存在")
ErrBadgeNameExists = errors.New("徽章名称已存在")
ErrBadgeInvalid = errors.New("徽章参数无效")
ErrBadgeGranted = errors.New("用户已持有该徽章")
ErrBadgeUserGone = errors.New("用户不存在")
)
const (
MaxBadgeNameRunes = 16
DefaultBadgeColor = "accent"
)
// BadgeWithHolders 徽章 + 持有人数(后台管理列表用)
type BadgeWithHolders struct {
model.Badge
Holders int64 `json:"holders"`
}
// BadgeService 管理员自定义徽章
type BadgeService struct {
db *gorm.DB
notif *NotificationService
setting *SettingService
}
func NewBadgeService(db *gorm.DB) *BadgeService {
return &BadgeService{db: db}
}
// WithNotification 注入通知服务(授予时通知用户)
func (s *BadgeService) WithNotification(n *NotificationService) *BadgeService {
s.notif = n
return s
}
// validateBadge 校验并规整名称/图标/颜色
func validateBadge(name, icon, color string) (string, string, string, error) {
name = strings.TrimSpace(name)
if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > MaxBadgeNameRunes {
return "", "", "", ErrBadgeInvalid
}
icon = strings.TrimSpace(icon)
if utf8.RuneCountInString(icon) > 32 {
return "", "", "", ErrBadgeInvalid
}
color = strings.TrimSpace(color)
if color == "" {
color = DefaultBadgeColor
}
if !model.BadgeColors[color] {
return "", "", "", ErrBadgeInvalid
}
return name, icon, color, nil
}
// List 全部徽章 + 持有数
func (s *BadgeService) List() ([]BadgeWithHolders, error) {
var badges []model.Badge
if err := s.db.Order("id ASC").Find(&badges).Error; err != nil {
return nil, err
}
type cnt struct {
BadgeID uint
N int64
}
var counts []cnt
if err := s.db.Model(&model.UserBadge{}).
Select("badge_id, count(1) AS n").
Group("badge_id").Scan(&counts).Error; err != nil {
return nil, err
}
m := make(map[uint]int64, len(counts))
for _, c := range counts {
m[c.BadgeID] = c.N
}
out := make([]BadgeWithHolders, 0, len(badges))
for _, b := range badges {
out = append(out, BadgeWithHolders{Badge: b, Holders: m[b.ID]})
}
return out, nil
}
// Create 新建徽章
func (s *BadgeService) Create(name, icon, color string) (*model.Badge, error) {
name, icon, color, err := validateBadge(name, icon, color)
if err != nil {
return nil, err
}
b := model.Badge{Name: name, Icon: icon, Color: color}
if err := s.db.Create(&b).Error; err != nil {
if isUniqueConflict(err) {
return nil, ErrBadgeNameExists
}
return nil, err
}
return &b, nil
}
// Update 修改徽章(不影响已授予记录)
func (s *BadgeService) Update(id uint, name, icon, color string) (*model.Badge, error) {
name, icon, color, err := validateBadge(name, icon, color)
if err != nil {
return nil, err
}
var b model.Badge
if err := s.db.First(&b, id).Error; err != nil {
return nil, ErrBadgeNotFound
}
b.Name, b.Icon, b.Color = name, icon, color
if err := s.db.Save(&b).Error; err != nil {
if isUniqueConflict(err) {
return nil, ErrBadgeNameExists
}
return nil, err
}
return &b, nil
}
// Delete 删除徽章并级联删除全部授予记录。
// postgres 下 AutoMigrate 生成的 FK 为 NO ACTION,必须先删引用行再删徽章
func (s *BadgeService) Delete(id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("badge_id = ?", id).Delete(&model.UserBadge{}).Error; err != nil {
return err
}
res := tx.Delete(&model.Badge{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrBadgeNotFound
}
return nil
})
}
// Grant 授予徽章(幂等:已持有直接返回成功);同时发站内通知
func (s *BadgeService) Grant(badgeID, userID, operatorID uint) error {
var badge model.Badge
if err := s.db.First(&badge, badgeID).Error; err != nil {
return ErrBadgeNotFound
}
var user model.User
if err := s.db.Select("id", "nickname").First(&user, userID).Error; err != nil {
return ErrBadgeUserGone
}
err := s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&model.UserBadge{}).
Where("badge_id = ? AND user_id = ?", badgeID, userID).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil // 幂等
}
ub := model.UserBadge{BadgeID: badgeID, UserID: userID, AwardedBy: operatorID, AwardedAt: time.Now()}
return tx.Create(&ub).Error
})
if err != nil {
return err
}
if s.notif != nil {
s.notif.Create(userID, operatorID, model.NotificationTypeBadge, 0, 0, "获得徽章「"+badge.Name+"」")
}
return nil
}
// Revoke 撤销授予
func (s *BadgeService) Revoke(badgeID, userID uint) error {
res := s.db.Where("badge_id = ? AND user_id = ?", badgeID, userID).Delete(&model.UserBadge{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrBadgeNotFound
}
return nil
}
// BadgeHolder 持有者视图(后台授予面板用)
type BadgeHolder struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AwardedAt time.Time `json:"awarded_at"`
}
// Holders 某徽章全部持有者(含用户名)
func (s *BadgeService) Holders(badgeID uint) ([]BadgeHolder, error) {
var rows []BadgeHolder
if err := s.db.Table("user_badges").
Select("user_badges.user_id, users.username, users.nickname, user_badges.awarded_at").
Joins("JOIN users ON users.id = user_badges.user_id").
Where("user_badges.badge_id = ? AND users.deleted_at IS NULL", badgeID).
Order("user_badges.awarded_at DESC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// ForUser 用户持有徽章(含定义与授予时间)
func (s *BadgeService) ForUser(userID uint) ([]model.UserBadge, error) {
var rows []model.UserBadge
if err := s.db.Preload("Badge").
Where("user_id = ?", userID).
Order("awarded_at ASC").
Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// isUniqueConflict 唯一索引冲突(名称重复等):postgres 报 duplicate key,
// sqlite(测试内存库)报 UNIQUE constraint failed
func isUniqueConflict(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return errors.Is(err, gorm.ErrDuplicatedKey) ||
strings.Contains(msg, "duplicate key") ||
strings.Contains(msg, "unique constraint")
}