package userbadge import ( "context" "database/sql" "errors" "net/http" "strings" "time" "chatapp3-golang/internal/model" "gorm.io/gorm" ) type badgePictureRow struct { BadgeID int64 `gorm:"column:badge_id"` SourceType sql.NullString `gorm:"column:source_type"` DisplayType sql.NullString `gorm:"column:display_type"` BadgeLevel sql.NullInt64 `gorm:"column:badge_level"` Milestone sql.NullInt64 `gorm:"column:milestone"` BadgeName sql.NullString `gorm:"column:badge_name"` BadgeKey sql.NullString `gorm:"column:badge_key"` SelectURL sql.NullString `gorm:"column:select_url"` NotSelectURL sql.NullString `gorm:"column:not_select_url"` AnimationURL sql.NullString `gorm:"column:animation_url"` ExpireTime time.Time `gorm:"column:expire_time"` UpdateTime time.Time `gorm:"column:update_time"` } // Current returns the current user's badge list split by long and short display type. func (s *Service) Current(ctx context.Context, user AuthUser) (*CurrentBadgeListResponse, error) { if s == nil || s.db == nil { return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable") } sysOrigin, err := normalizeUser(user) if err != nil { return nil, err } return s.currentForUser(ctx, sysOrigin, user.UserID) } // Other returns another user's badge list in the current authenticated sysOrigin. func (s *Service) Other(ctx context.Context, viewer AuthUser, targetUserID int64) (*CurrentBadgeListResponse, error) { if s == nil || s.db == nil { return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable") } sysOrigin, err := normalizeUser(viewer) if err != nil { return nil, err } if targetUserID <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_target_user_id", "target userId is required") } return s.currentForUser(ctx, sysOrigin, targetUserID) } func (s *Service) currentForUser(ctx context.Context, sysOrigin string, userID int64) (*CurrentBadgeListResponse, error) { now := s.now() vipLongBadges, vipShortBadges, vipBadgeIDs, err := s.loadVIPStateBadges(ctx, sysOrigin, userID, now) if err != nil { return nil, err } backpackLongBadges, backpackShortBadges, err := s.loadBackpackBadges(ctx, sysOrigin, userID, vipBadgeIDs, now) if err != nil { return nil, err } longBadges := append(vipLongBadges, backpackLongBadges...) shortBadges := append(vipShortBadges, backpackShortBadges...) badges := make([]CurrentBadgeItem, 0, len(longBadges)+len(shortBadges)) badges = append(badges, longBadges...) badges = append(badges, shortBadges...) return &CurrentBadgeListResponse{ UserID: userID, SysOrigin: sysOrigin, Badges: badges, LongBadges: longBadges, ShortBadges: shortBadges, }, nil } func (s *Service) loadVIPStateBadges(ctx context.Context, sysOrigin string, userID int64, now time.Time) ([]CurrentBadgeItem, []CurrentBadgeItem, map[int64]struct{}, error) { var state model.UserVipState err := s.db.WithContext(ctx). Where("sys_origin = ? AND user_id = ?", sysOrigin, userID). First(&state).Error if errors.Is(err, gorm.ErrRecordNotFound) { return []CurrentBadgeItem{}, []CurrentBadgeItem{}, nil, nil } if err != nil { return nil, nil, nil, err } if !isActiveVIPState(state, now) { return []CurrentBadgeItem{}, []CurrentBadgeItem{}, nil, nil } type vipStateBadgeResource struct { id int64 defaultType string name string url string } resources := []vipStateBadgeResource{ {id: state.BadgeResourceID, defaultType: badgeDisplayTypeLong, name: state.BadgeName, url: state.BadgeURL}, {id: state.ShortBadgeResourceID, defaultType: badgeDisplayTypeShort, name: state.ShortBadgeName, url: state.ShortBadgeURL}, } longBadges := make([]CurrentBadgeItem, 0, 1) shortBadges := make([]CurrentBadgeItem, 0, 1) excludeIDs := make(map[int64]struct{}, len(resources)) updateAt := firstNonZeroTime(state.UpdateTime, state.StartAt, state.CreateTime) for _, resource := range resources { if resource.id <= 0 { continue } if _, exists := excludeIDs[resource.id]; exists { continue } excludeIDs[resource.id] = struct{}{} row, err := s.loadBadgePicture(ctx, sysOrigin, resource.id) if err != nil { return nil, nil, nil, err } displayType := normalizeBadgeDisplayType(nullString(row.DisplayType), nullString(row.SourceType), resource.defaultType) if displayType == "" { continue } item := CurrentBadgeItem{ ID: formatID(resource.id), BadgeID: formatID(resource.id), Type: displayType, SourceType: nullString(row.SourceType), BadgeLevel: nullInt(row.BadgeLevel), BadgeKey: nullString(row.BadgeKey), Level: state.Level, LevelCode: state.LevelCode, DisplayName: state.DisplayName, Use: true, ExpireTime: formatMillis(state.ExpireAt), ExpireAt: formatTime(state.ExpireAt), UpdateTime: formatMillis(updateAt), UpdatedAt: formatTime(updateAt), } item.BadgeName = firstNonEmpty(nullString(row.BadgeName), resource.name, state.DisplayName) item.Name = item.BadgeName item.SelectURL = firstNonEmpty(nullString(row.SelectURL), resource.url) item.NotSelectURL = nullString(row.NotSelectURL) item.AnimationURL = nullString(row.AnimationURL) item.URL = firstNonEmpty(item.SelectURL, resource.url) if row.Milestone.Valid && row.Milestone.Int64 > 0 { item.Milestone = formatID(row.Milestone.Int64) } if item.Name == "" && item.URL == "" { continue } if displayType == badgeDisplayTypeLong { longBadges = append(longBadges, item) } else { shortBadges = append(shortBadges, item) } } return longBadges, shortBadges, excludeIDs, nil } func (s *Service) loadBackpackBadges(ctx context.Context, sysOrigin string, userID int64, excludeBadgeIDs map[int64]struct{}, now time.Time) ([]CurrentBadgeItem, []CurrentBadgeItem, error) { rows := make([]badgePictureRow, 0) query := s.db.WithContext(ctx). Table("user_badge_backpack AS backpack"). Select(` backpack.badge_id AS badge_id, backpack.expire_time AS expire_time, backpack.update_time AS update_time, config.type AS source_type, config.display_type AS display_type, config.badge_level AS badge_level, config.milestone AS milestone, config.badge_name AS badge_name, config.badge_key AS badge_key, picture.select_url AS select_url, picture.not_select_url AS not_select_url, picture.animation_url AS animation_url `). Joins("JOIN sys_badge_config AS config ON config.id = backpack.badge_id"). Joins("JOIN sys_badge_picture_config AS picture ON picture.badge_config_id = config.id AND picture.sys_origin = ?", sysOrigin). Where("backpack.user_id = ?", userID). Where("backpack.is_use_props = ?", true). Where("config.is_del = ?", false). Where("(backpack.expire_type = ? OR backpack.expire_time > ?)", expireTypePermanent, now). Order("backpack.update_time DESC, backpack.id DESC") if len(excludeBadgeIDs) > 0 { ids := make([]int64, 0, len(excludeBadgeIDs)) for id := range excludeBadgeIDs { ids = append(ids, id) } query = query.Where("backpack.badge_id NOT IN ?", ids) } if err := query.Scan(&rows).Error; err != nil { return nil, nil, err } longBadges := make([]CurrentBadgeItem, 0) shortBadges := make([]CurrentBadgeItem, 0, len(rows)) for _, row := range rows { displayType := normalizeBadgeDisplayType(nullString(row.DisplayType), nullString(row.SourceType), "") if displayType == "" { continue } item := CurrentBadgeItem{ ID: formatID(row.BadgeID), BadgeID: formatID(row.BadgeID), Type: displayType, SourceType: nullString(row.SourceType), Name: nullString(row.BadgeName), BadgeName: nullString(row.BadgeName), BadgeKey: nullString(row.BadgeKey), BadgeLevel: nullInt(row.BadgeLevel), URL: nullString(row.SelectURL), SelectURL: nullString(row.SelectURL), NotSelectURL: nullString(row.NotSelectURL), AnimationURL: nullString(row.AnimationURL), Use: true, ExpireTime: formatMillis(row.ExpireTime), ExpireAt: formatTime(row.ExpireTime), UpdateTime: formatMillis(row.UpdateTime), UpdatedAt: formatTime(row.UpdateTime), } if row.Milestone.Valid && row.Milestone.Int64 > 0 { item.Milestone = formatID(row.Milestone.Int64) } if displayType == badgeDisplayTypeLong { longBadges = append(longBadges, item) } else { shortBadges = append(shortBadges, item) } } return longBadges, shortBadges, nil } func (s *Service) loadBadgePicture(ctx context.Context, sysOrigin string, badgeID int64) (badgePictureRow, error) { var row badgePictureRow err := s.db.WithContext(ctx). Table("sys_badge_config AS config"). Select(` config.id AS badge_id, config.type AS source_type, config.display_type AS display_type, config.badge_level AS badge_level, config.milestone AS milestone, config.badge_name AS badge_name, config.badge_key AS badge_key, picture.select_url AS select_url, picture.not_select_url AS not_select_url, picture.animation_url AS animation_url `). Joins("LEFT JOIN sys_badge_picture_config AS picture ON picture.badge_config_id = config.id AND picture.sys_origin = ?", sysOrigin). Where("config.id = ? AND config.is_del = ?", badgeID, false). Scan(&row).Error return row, err } func isActiveVIPState(state model.UserVipState, now time.Time) bool { return strings.EqualFold(strings.TrimSpace(state.Status), statusActive) && state.Level > 0 && state.ExpireAt.After(now) } func nullString(value sql.NullString) string { if !value.Valid { return "" } return strings.TrimSpace(value.String) } func nullInt(value sql.NullInt64) int { if !value.Valid { return 0 } return int(value.Int64) }