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

241 lines
6.4 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"
"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")
}