- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
241 lines
6.4 KiB
Go
241 lines
6.4 KiB
Go
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")
|
||
}
|