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") }