package mysql import ( "context" "database/sql" "encoding/json" "errors" "fmt" "hash/fnv" "strconv" "strings" "time" mysqldriver "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/growth" ) const ( // MySQL 会主动回滚死锁受害事务;重试必须从 BeginTx 开始重建完整事务,不能在旧 tx 上续跑。 growthTransactionMaxAttempts = 5 // 四次等待分别以 10/20/30/40ms 为基线,再加入 0-10ms 的稳定抖动,既限制请求预算也避免热点事件同步重撞。 growthTransactionRetryStep = 10 * time.Millisecond growthTransactionRetryJitterWindow = 10 * time.Millisecond ) type queryer interface { QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } // ListLevelOverview 返回 App 等级总览;缺失账户按 0 级、0 累计渲染。 func (r *Repository) ListLevelOverview(ctx context.Context, userID int64, nowMS int64) (domain.Overview, error) { if r == nil || r.db == nil { return domain.Overview{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tracks, err := r.listActiveLevelTracks(ctx, r.db) if err != nil { return domain.Overview{}, err } items := make([]domain.TrackOverview, 0, len(tracks)) for _, track := range tracks { overview, err := r.levelTrackOverview(ctx, userID, track, nowMS) if err != nil { return domain.Overview{}, err } items = append(items, overview) } return domain.Overview{Tracks: items, ServerTimeMS: nowMS}, nil } // GetLevelTrack 返回单轨道完整阈值、等级段和当前用户摘要。 func (r *Repository) GetLevelTrack(ctx context.Context, userID int64, track string, nowMS int64) (domain.TrackDetail, error) { if r == nil || r.db == nil { return domain.TrackDetail{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } levelTrack, err := r.getActiveLevelTrack(ctx, r.db, track) if err != nil { return domain.TrackDetail{}, err } overview, err := r.levelTrackOverview(ctx, userID, levelTrack, nowMS) if err != nil { return domain.TrackDetail{}, err } rules, err := r.listActiveLevelRules(ctx, r.db, track) if err != nil { return domain.TrackDetail{}, err } tiers, err := r.listActiveLevelTiers(ctx, r.db, track) if err != nil { return domain.TrackDetail{}, err } return domain.TrackDetail{Overview: overview, Rules: rules, Tiers: tiers, ServerTimeMS: nowMS}, nil } // BatchGetUserLevelDisplayProfiles 返回资料卡和房间列表需要的等级展示投影。 func (r *Repository) BatchGetUserLevelDisplayProfiles(ctx context.Context, userIDs []int64, nowMS int64) (domain.LevelDisplayProfiles, error) { if r == nil || r.db == nil { return domain.LevelDisplayProfiles{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } userIDs = uniquePositiveInt64s(userIDs) if len(userIDs) == 0 { return domain.LevelDisplayProfiles{Profiles: []domain.UserLevelDisplayProfile{}, ServerTimeMS: nowMS}, nil } profiles := make(map[int64]domain.UserLevelDisplayProfile, len(userIDs)) for _, userID := range userIDs { profiles[userID] = defaultLevelDisplayProfile(userID) } args := make([]any, 0, len(userIDs)+1) args = append(args, appcode.FromContext(ctx)) for _, userID := range userIDs { args = append(args, userID) } rows, err := r.db.QueryContext(ctx, ` SELECT user_id, wealth_level, wealth_tier_id, wealth_avatar_frame_resource_id, wealth_badge_resource_id, wealth_badge_source_level, game_level, game_tier_id, game_avatar_frame_resource_id, game_badge_resource_id, game_badge_source_level, charm_level, charm_tier_id, charm_avatar_frame_resource_id, charm_badge_resource_id, charm_badge_source_level, updated_at_ms FROM user_level_display_profiles WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`, args...) if err != nil { return domain.LevelDisplayProfiles{}, err } defer rows.Close() for rows.Next() { var item domain.UserLevelDisplayProfile var updatedAtMS int64 if err := rows.Scan( &item.UserID, &item.Wealth.Level, &item.Wealth.TierID, &item.Wealth.AvatarFrameResourceID, &item.Wealth.BadgeResourceID, &item.Wealth.BadgeSourceLevel, &item.Game.Level, &item.Game.TierID, &item.Game.AvatarFrameResourceID, &item.Game.BadgeResourceID, &item.Game.BadgeSourceLevel, &item.Charm.Level, &item.Charm.TierID, &item.Charm.AvatarFrameResourceID, &item.Charm.BadgeResourceID, &item.Charm.BadgeSourceLevel, &updatedAtMS, ); err != nil { return domain.LevelDisplayProfiles{}, err } item.Wealth.Track = domain.TrackWealth item.Game.Track = domain.TrackGame item.Charm.Track = domain.TrackCharm item.Wealth.UpdatedAtMS = updatedAtMS item.Game.UpdatedAtMS = updatedAtMS item.Charm.UpdatedAtMS = updatedAtMS profiles[item.UserID] = item } if err := rows.Err(); err != nil { return domain.LevelDisplayProfiles{}, err } adminProfiles, err := r.BatchGetUserLevelAdminProfiles(ctx, userIDs, nowMS) if err != nil { return domain.LevelDisplayProfiles{}, err } adminTracks := make(map[int64]map[string]domain.AdminTrackProfile, len(adminProfiles.Profiles)) for _, profile := range adminProfiles.Profiles { byTrack := make(map[string]domain.AdminTrackProfile, len(profile.Tracks)) for _, track := range profile.Tracks { byTrack[track.Track] = track } adminTracks[profile.UserID] = byTrack } rulesByTrack := make(map[string][]domain.Rule, 2) tiersByTrack := make(map[string][]domain.Tier, 2) for _, track := range []string{domain.TrackWealth, domain.TrackCharm} { rules, err := r.listActiveLevelRules(ctx, r.db, track) if err != nil { return domain.LevelDisplayProfiles{}, err } tiers, err := r.listActiveLevelTiers(ctx, r.db, track) if err != nil { return domain.LevelDisplayProfiles{}, err } rulesByTrack[track] = rules tiersByTrack[track] = tiers } result := make([]domain.UserLevelDisplayProfile, 0, len(userIDs)) for _, userID := range userIDs { item := profiles[userID] // 物化资料仍是真实账户快照;整批 admin profile 只在读取时覆盖有效 wealth/charm overlay, // 既让到期毫秒立即生效,也避免房间资料批量接口按用户和轨道产生 N+1。 for _, track := range []string{domain.TrackWealth, domain.TrackCharm} { adminTrack := adminTracks[userID][track] if adminTrack.TemporaryLevelID == "" { continue } rules := rulesByTrack[track] tiers := tiersByTrack[track] tier := tierForLevel(tiers, adminTrack.DisplayLevel) badgeID, badgeLevel := levelRuleBadgeForLevel(rules, adminTrack.DisplayLevel) display := domain.LevelDisplayTrackProfile{ Track: track, Level: adminTrack.DisplayLevel, TierID: tier.TierID, AvatarFrameResourceID: levelRuleAvatarFrameForLevel(rules, adminTrack.DisplayLevel, tier.DisplayAvatarFrameResourceID), BadgeResourceID: badgeID, BadgeSourceLevel: badgeLevel, UpdatedAtMS: nowMS, } if track == domain.TrackWealth { item.Wealth = display } else { item.Charm = display } } result = append(result, item) } return domain.LevelDisplayProfiles{Profiles: result, ServerTimeMS: nowMS}, nil } // ListLevelConfig 返回后台等级配置页需要的轨道、单级阈值和等级段展示配置。 func (r *Repository) ListLevelConfig(ctx context.Context, query domain.ConfigQuery, nowMS int64) (domain.Config, error) { if r == nil || r.db == nil { return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tracks, err := r.listLevelTracks(ctx, r.db, query.Track, query.Status) if err != nil { return domain.Config{}, err } rules, err := r.listLevelRules(ctx, r.db, query.Track, query.Status) if err != nil { return domain.Config{}, err } tiers, err := r.listLevelTiers(ctx, r.db, query.Track, query.Status) if err != nil { return domain.Config{}, err } return domain.Config{Tracks: tracks, Rules: rules, Tiers: tiers, ServerTimeMS: nowMS}, nil } // ListLevelRewards 分页返回用户等级奖励发放状态。 func (r *Repository) ListLevelRewards(ctx context.Context, query domain.RewardQuery) ([]domain.RewardJob, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } where := []string{"app_code = ?", "user_id = ?"} args := []any{appcode.FromContext(ctx), query.UserID} if query.Track != "" { where = append(where, "track = ?") args = append(args, query.Track) } if query.Status != "" { where = append(where, "status = ?") args = append(args, query.Status) } clause := "WHERE " + strings.Join(where, " AND ") var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM growth_level_reward_jobs `+clause, args...).Scan(&total); err != nil { return nil, 0, err } pageSize := normalizePageSize(query.PageSize) page := query.Page if page <= 0 { page = 1 } args = append(args, pageSize, (page-1)*pageSize) rows, err := r.db.QueryContext(ctx, levelRewardSelectSQL()+clause+` ORDER BY created_at_ms DESC, reward_job_id DESC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, 0, err } defer rows.Close() items, err := scanLevelRewards(rows) if err != nil { return nil, 0, err } return items, total, nil } // ConsumeLevelEvent 在同一事务内完成事件幂等、累计值、升级历史、奖励任务和展示投影。 func (r *Repository) ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent, nowMS int64) (domain.EventResult, error) { var lastErr error for attempt := 0; attempt < growthTransactionMaxAttempts; attempt++ { result, err := r.consumeLevelEventOnce(ctx, event, nowMS) if err == nil { return result, nil } if !isRetryableGrowthTransactionError(err) { return domain.EventResult{}, err } lastErr = err if attempt == growthTransactionMaxAttempts-1 { break } // MQ 会并发回放同一热门用户的礼物事件;本地短退避先吸收瞬时死锁冲突,避免每次 1213 都升级为 broker 秒级重试。 timer := time.NewTimer(growthTransactionRetryDelay(event.EventID, attempt)) select { case <-ctx.Done(): if !timer.Stop() { <-timer.C } return domain.EventResult{}, ctx.Err() case <-timer.C: } } return domain.EventResult{}, lastErr } // consumeLevelEventOnce 只执行一次完整事务;调用方只会在 MySQL 已回滚的 1213 上重新 BeginTx。 func (r *Repository) consumeLevelEventOnce(ctx context.Context, event domain.ValueEvent, nowMS int64) (domain.EventResult, error) { if r == nil || r.db == nil { return domain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.EventResult{}, err } defer func() { _ = tx.Rollback() }() inserted, err := r.insertLevelValueEvent(ctx, tx, event, nowMS) if err != nil { return domain.EventResult{}, err } if !inserted { if err := tx.Commit(); err != nil { return domain.EventResult{}, err } return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusDuplicate}, nil } account, err := r.ensureLevelAccountForUpdate(ctx, tx, event.UserID, event.Track, nowMS) if err != nil { return domain.EventResult{}, err } previousLevel := account.CurrentLevel previousTierID := account.CurrentTierID nextTotal := account.TotalValue + event.ValueDelta rules, err := r.listActiveLevelRules(ctx, tx, event.Track) if err != nil { return domain.EventResult{}, err } tiers, err := r.listActiveLevelTiers(ctx, tx, event.Track) if err != nil { return domain.EventResult{}, err } nextLevel := levelForValue(rules, nextTotal) nextTier := tierForLevel(tiers, nextLevel) nextTierID := nextTier.TierID levelChanged := nextLevel != previousLevel || nextTierID != previousTierID levelUpdatedAtMS := account.LevelUpdatedAtMS if nextLevel != previousLevel { levelUpdatedAtMS = nowMS } tierUpdatedAtMS := account.TierUpdatedAtMS if nextTierID != previousTierID { tierUpdatedAtMS = nowMS } if err := r.updateLevelAccount(ctx, tx, event.UserID, event.Track, nextTotal, nextLevel, nextTierID, levelUpdatedAtMS, tierUpdatedAtMS, nowMS); err != nil { return domain.EventResult{}, err } rewardJobCount := int64(0) if levelChanged { if err := r.insertLevelHistory(ctx, tx, event, previousLevel, nextLevel, previousTierID, nextTierID, nextTotal, nowMS); err != nil { return domain.EventResult{}, err } count, err := r.createLevelRewardJobs(ctx, tx, event.UserID, event.Track, previousLevel, nextLevel, previousTierID, nextTier, rules, nowMS) if err != nil { return domain.EventResult{}, err } rewardJobCount = count } // While an overlay is active, only value events whose business time falls in [start,end) advance its displayed // value. The real account above was updated independently and remains the sole permanent growth fact. if temporary, found, err := r.activeTemporaryLevelForUpdate(ctx, tx, event.UserID, event.Track, nowMS); err != nil { return domain.EventResult{}, err } else if found && event.OccurredAtMS >= temporary.StartedAtMS && event.OccurredAtMS < temporary.ExpiresAtMS { windowValue, err := r.temporaryWindowValue(ctx, tx, event.UserID, event.Track, temporary.StartedAtMS, temporary.ExpiresAtMS) if err != nil { return domain.EventResult{}, err } previousDisplayLevel := levelForValue(rules, temporary.TargetRequiredValue+windowValue-event.ValueDelta) nextDisplayLevel := levelForValue(rules, temporary.TargetRequiredValue+windowValue) if nextDisplayLevel > previousDisplayLevel { count, err := r.createTemporaryRewardJobs(ctx, tx, event.UserID, event.Track, nextDisplayLevel, temporary.TemporaryLevelID, temporary.Generation, temporary.ExpiresAtMS, rules, tiers, nowMS) if err != nil { return domain.EventResult{}, err } rewardJobCount += count } } badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel) avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, nextLevel, nextTier.DisplayAvatarFrameResourceID) if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS); err != nil { return domain.EventResult{}, err } if err := tx.Commit(); err != nil { return domain.EventResult{}, err } return domain.EventResult{ EventID: event.EventID, Status: domain.EventStatusConsumed, LevelChanged: levelChanged, PreviousLevel: previousLevel, NewLevel: nextLevel, RewardJobCount: rewardJobCount, }, nil } // SetUserLevel 在一个事务里把用户某条轨道直接设置到目标等级阈值。 // 经理中心需要支持降级,所以这里不能复用只接受正增量的 ConsumeLevelEvent;但仍写 growth_level_value_events 保留幂等事件和审计来源。 func (r *Repository) SetUserLevel(ctx context.Context, command domain.SetUserLevelCommand, nowMS int64) (domain.SetUserLevelResult, error) { if r == nil || r.db == nil { return domain.SetUserLevelResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } eventID := "manager_level:" + command.CommandID tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.SetUserLevelResult{}, err } defer func() { _ = tx.Rollback() }() account, err := r.ensureLevelAccountForUpdate(ctx, tx, command.UserID, command.Track, nowMS) if err != nil { return domain.SetUserLevelResult{}, err } rules, err := r.listActiveLevelRules(ctx, tx, command.Track) if err != nil { return domain.SetUserLevelResult{}, err } targetValue, ok := requiredValueForLevel(rules, command.Level) if !ok { return domain.SetUserLevelResult{}, xerr.New(xerr.NotFound, "level rule not found") } tiers, err := r.listActiveLevelTiers(ctx, tx, command.Track) if err != nil { return domain.SetUserLevelResult{}, err } delta := targetValue - account.TotalValue inserted, err := r.insertLevelValueEvent(ctx, tx, domain.ValueEvent{ EventID: eventID, SourceEventID: command.CommandID, SourceService: "manager_center", SourceEventType: "ManagerSetUserLevel", UserID: command.UserID, Track: command.Track, MetricType: "manager_set_level", ValueDelta: delta, OccurredAtMS: nowMS, DimensionsJSON: normalizeJSONMap(map[string]any{ "operator_user_id": command.OperatorUserID, "target_level": command.Level, "reason": command.Reason, }), }, nowMS) if err != nil { return domain.SetUserLevelResult{}, err } if !inserted { if err := tx.Commit(); err != nil { return domain.SetUserLevelResult{}, err } return domain.SetUserLevelResult{ EventID: eventID, Status: domain.EventStatusDuplicate, Track: command.Track, NewLevel: account.CurrentLevel, TotalValue: account.TotalValue, ServerTimeMS: nowMS, }, nil } previousLevel := account.CurrentLevel previousTierID := account.CurrentTierID nextLevel := levelForValue(rules, targetValue) nextTier := tierForLevel(tiers, nextLevel) nextTierID := nextTier.TierID levelUpdatedAtMS := account.LevelUpdatedAtMS if nextLevel != previousLevel { levelUpdatedAtMS = nowMS } tierUpdatedAtMS := account.TierUpdatedAtMS if nextTierID != previousTierID { tierUpdatedAtMS = nowMS } if err := r.updateLevelAccount(ctx, tx, command.UserID, command.Track, targetValue, nextLevel, nextTierID, levelUpdatedAtMS, tierUpdatedAtMS, nowMS); err != nil { return domain.SetUserLevelResult{}, err } if err := r.insertLevelHistory(ctx, tx, domain.ValueEvent{EventID: eventID, UserID: command.UserID, Track: command.Track}, previousLevel, nextLevel, previousTierID, nextTierID, targetValue, nowMS); err != nil { return domain.SetUserLevelResult{}, err } // 经理直接设置等级时要补齐从 1 级到目标等级之间缺失的奖励;唯一键保证已发过的等级不会重复入队。 rewardJobCount, err := r.createLevelRewardJobs(ctx, tx, command.UserID, command.Track, 0, nextLevel, 0, nextTier, rules, nowMS) if err != nil { return domain.SetUserLevelResult{}, err } badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel) avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, nextLevel, nextTier.DisplayAvatarFrameResourceID) if err := r.upsertLevelDisplayProfile(ctx, tx, command.UserID, command.Track, nextLevel, nextTier, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS); err != nil { return domain.SetUserLevelResult{}, err } if err := tx.Commit(); err != nil { return domain.SetUserLevelResult{}, err } return domain.SetUserLevelResult{ EventID: eventID, Status: domain.EventStatusConsumed, Track: command.Track, PreviousLevel: previousLevel, NewLevel: nextLevel, TotalValue: targetValue, RewardJobCount: rewardJobCount, ServerTimeMS: nowMS, }, nil } func (r *Repository) UpsertLevelTrack(ctx context.Context, command domain.TrackCommand, nowMS int64) (domain.Track, bool, error) { if r == nil || r.db == nil { return domain.Track{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } result, err := r.db.ExecContext(ctx, ` INSERT INTO growth_level_tracks ( app_code, track, name, status, sort_order, display_config_json, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), status = VALUES(status), sort_order = VALUES(sort_order), display_config_json = VALUES(display_config_json), updated_at_ms = VALUES(updated_at_ms)`, appcode.FromContext(ctx), command.Track, command.Name, command.Status, command.SortOrder, command.DisplayConfigJSON, nowMS, nowMS, ) if err != nil { return domain.Track{}, false, err } affected, _ := result.RowsAffected() item, err := r.getLevelTrack(ctx, r.db, command.Track) return item, affected == 1, err } func (r *Repository) UpsertLevelRule(ctx context.Context, command domain.RuleCommand, nowMS int64) (domain.Rule, bool, error) { if r == nil || r.db == nil { return domain.Rule{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.Rule{}, false, err } defer func() { _ = tx.Rollback() }() result, err := tx.ExecContext(ctx, ` INSERT INTO growth_level_rules ( app_code, track, level, required_value, name, status, reward_resource_group_id, sort_order, display_config_json, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE required_value = VALUES(required_value), name = VALUES(name), status = VALUES(status), reward_resource_group_id = VALUES(reward_resource_group_id), sort_order = VALUES(sort_order), display_config_json = VALUES(display_config_json), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`, appcode.FromContext(ctx), command.Track, command.Level, command.RequiredValue, command.Name, command.Status, command.RewardResourceGroupID, command.SortOrder, command.DisplayConfigJSON, command.OperatorAdminID, command.OperatorAdminID, nowMS, nowMS, ) if err != nil { return domain.Rule{}, false, err } if err := r.validateActiveLevelRules(ctx, tx, command.Track); err != nil { return domain.Rule{}, false, err } if err := tx.Commit(); err != nil { return domain.Rule{}, false, err } affected, _ := result.RowsAffected() item, err := r.getLevelRule(ctx, command.Track, command.Level) return item, affected == 1, err } func (r *Repository) UpsertLevelTier(ctx context.Context, command domain.TierCommand, nowMS int64) (domain.Tier, bool, error) { if r == nil || r.db == nil { return domain.Tier{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.Tier{}, false, err } defer func() { _ = tx.Rollback() }() created := false tierID := command.TierID if tierID <= 0 { result, err := tx.ExecContext(ctx, ` INSERT INTO growth_level_tiers ( app_code, track, min_level, max_level, name, display_avatar_frame_resource_id, display_badge_resource_id, reward_resource_group_id, status, display_config_json, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), command.Track, command.MinLevel, command.MaxLevel, command.Name, command.DisplayAvatarFrameResourceID, command.DisplayBadgeResourceID, command.RewardResourceGroupID, command.Status, command.DisplayConfigJSON, command.OperatorAdminID, command.OperatorAdminID, nowMS, nowMS, ) if err != nil { return domain.Tier{}, false, err } tierID, err = result.LastInsertId() if err != nil { return domain.Tier{}, false, err } created = true } else { result, err := tx.ExecContext(ctx, ` UPDATE growth_level_tiers SET track = ?, min_level = ?, max_level = ?, name = ?, display_avatar_frame_resource_id = ?, display_badge_resource_id = ?, reward_resource_group_id = ?, status = ?, display_config_json = ?, updated_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND tier_id = ?`, command.Track, command.MinLevel, command.MaxLevel, command.Name, command.DisplayAvatarFrameResourceID, command.DisplayBadgeResourceID, command.RewardResourceGroupID, command.Status, command.DisplayConfigJSON, command.OperatorAdminID, nowMS, appcode.FromContext(ctx), tierID, ) if err != nil { return domain.Tier{}, false, err } affected, _ := result.RowsAffected() if affected == 0 { return domain.Tier{}, false, xerr.New(xerr.NotFound, "level tier not found") } } if err := r.validateActiveLevelTiers(ctx, tx, command.Track); err != nil { return domain.Tier{}, false, err } if err := tx.Commit(); err != nil { return domain.Tier{}, false, err } item, err := r.getLevelTier(ctx, tierID) return item, created, err } func (r *Repository) ClaimPendingLevelRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() rows, err := tx.QueryContext(ctx, levelRewardSelectSQL()+` WHERE app_code = ? AND status IN ('pending', 'failed', 'running') AND next_retry_at_ms <= ? AND (reward_origin = 'organic' OR is_permanent = 1 OR temporary_expires_at_ms > ?) AND (locked_until_ms = 0 OR locked_until_ms <= ?) ORDER BY created_at_ms ASC, reward_job_id ASC LIMIT ? FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), nowMS, nowMS, nowMS, batchSize, ) if err != nil { return nil, err } jobs, err := scanLevelRewards(rows) _ = rows.Close() if err != nil { return nil, err } lockUntil := nowMS + lockTTL.Milliseconds() for _, job := range jobs { if _, err := tx.ExecContext(ctx, ` UPDATE growth_level_reward_jobs SET status = 'running', attempt_count = attempt_count + 1, locked_by = ?, locked_until_ms = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, workerID, lockUntil, nowMS, appcode.FromContext(ctx), job.RewardJobID, ); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } return jobs, nil } func (r *Repository) MarkLevelRewardGranted(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() firstGrantID := "" for _, grant := range grants { if strings.TrimSpace(grant.WalletGrantID) == "" { continue } if firstGrantID == "" { firstGrantID = grant.WalletGrantID } // Each external action has its own wallet command. INSERT IGNORE makes a crash after wallet success safe to replay. _, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO growth_level_reward_wallet_grants (app_code, reward_job_id, grant_kind, wallet_grant_id, revoke_status, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, 'none', ?, ?)`, appcode.FromContext(ctx), rewardJobID, grant.GrantKind, grant.WalletGrantID, nowMS, nowMS) if err != nil { return err } } _, err = tx.ExecContext(ctx, ` UPDATE growth_level_reward_jobs SET status = 'granted', wallet_grant_id = ?, locked_by = '', locked_until_ms = 0, failure_reason = '', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, firstGrantID, nowMS, appcode.FromContext(ctx), rewardJobID, ) if err != nil { return err } var temporaryID string var rewardLevel int32 var permanent bool if err := tx.QueryRowContext(ctx, `SELECT temporary_level_id, reward_level, is_permanent FROM growth_level_reward_jobs WHERE app_code = ? AND reward_job_id = ? FOR UPDATE`, appcode.FromContext(ctx), rewardJobID).Scan(&temporaryID, &rewardLevel, &permanent); err != nil { return err } if temporaryID != "" && !permanent { var status string var finalLevel int32 if err := tx.QueryRowContext(ctx, `SELECT status, final_level FROM growth_temporary_levels WHERE app_code = ? AND temporary_level_id = ?`, appcode.FromContext(ctx), temporaryID).Scan(&status, &finalLevel); err != nil { return err } if status != domain.TemporaryLevelStatusActive && rewardLevel > finalLevel { // A worker can finish after the overlay expires. Persist the grants, then immediately expose them to revoke worker. revokeStatus := domain.RevokeStatusPending if firstGrantID == "" { revokeStatus = domain.RevokeStatusSkipped } if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET revoke_status = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, revokeStatus, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil { return err } if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'pending', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil { return err } } } return tx.Commit() } func (r *Repository) MarkLevelRewardFailed(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, failureReason string, nextRetryAtMS int64, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() for _, grant := range grants { if strings.TrimSpace(grant.WalletGrantID) == "" { continue } // Partial wallet success is a durable revoke fact even though the parent reward remains retryable. if _, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO growth_level_reward_wallet_grants (app_code, reward_job_id, grant_kind, wallet_grant_id, revoke_status, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, 'none', ?, ?)`, appcode.FromContext(ctx), rewardJobID, grant.GrantKind, grant.WalletGrantID, nowMS, nowMS); err != nil { return err } } result, err := tx.ExecContext(ctx, ` UPDATE growth_level_reward_jobs SET status = 'failed', next_retry_at_ms = ?, locked_by = '', locked_until_ms = 0, failure_reason = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND status = 'running'`, nextRetryAtMS, truncateGrowthFailure(failureReason), nowMS, appcode.FromContext(ctx), rewardJobID, ) if err != nil { return err } if affected, err := result.RowsAffected(); err != nil { return err } else if affected == 0 { return xerr.New(xerr.Conflict, "level reward job is no longer running") } var temporaryID string var rewardLevel int32 var permanent bool if err := tx.QueryRowContext(ctx, `SELECT temporary_level_id, reward_level, is_permanent FROM growth_level_reward_jobs WHERE app_code = ? AND reward_job_id = ? FOR UPDATE`, appcode.FromContext(ctx), rewardJobID).Scan(&temporaryID, &rewardLevel, &permanent); err != nil { return err } if temporaryID != "" && !permanent { var status string var finalLevel int32 if err := tx.QueryRowContext(ctx, `SELECT status, final_level FROM growth_temporary_levels WHERE app_code = ? AND temporary_level_id = ?`, appcode.FromContext(ctx), temporaryID).Scan(&status, &finalLevel); err != nil { return err } if status != domain.TemporaryLevelStatusActive && rewardLevel > finalLevel { // Expiry/supersede can race the wallet calls. If it already won, expose the successful prefix directly to // the revoke worker instead of leaving a failed parent whose assets are invisible forever. var persistedGrantCount int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM growth_level_reward_wallet_grants WHERE app_code = ? AND reward_job_id = ?`, appcode.FromContext(ctx), rewardJobID).Scan(&persistedGrantCount); err != nil { return err } revokeStatus := domain.RevokeStatusSkipped if persistedGrantCount > 0 { revokeStatus = domain.RevokeStatusPending } if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET status = 'canceled', revoke_status = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, revokeStatus, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil { return err } if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'pending', revoke_next_retry_at_ms = 0, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND revoke_status IN ('none','failed')`, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil { return err } } } return tx.Commit() } func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track domain.Track, nowMS int64) (domain.TrackOverview, error) { account, exists, err := r.getLevelAccount(ctx, userID, track.Track) if err != nil { return domain.TrackOverview{}, err } if !exists { account = domain.Account{UserID: userID, Track: track.Track} } rules, err := r.listActiveLevelRules(ctx, r.db, track.Track) if err != nil { return domain.TrackOverview{}, err } tiers, err := r.listActiveLevelTiers(ctx, r.db, track.Track) if err != nil { return domain.TrackOverview{}, err } displayLevel := account.CurrentLevel displayValue := account.TotalValue if temporary, found, err := r.activeTemporaryLevelForRead(ctx, userID, track.Track, nowMS); err != nil { return domain.TrackOverview{}, err } else if found { increment, err := r.temporaryWindowValue(ctx, r.db, userID, track.Track, temporary.StartedAtMS, temporary.ExpiresAtMS) if err != nil { return domain.TrackOverview{}, err } displayValue = temporary.TargetRequiredValue + increment displayLevel = levelForValue(rules, displayValue) if displayLevel < temporary.TargetLevel { displayLevel = temporary.TargetLevel } } tier := tierForLevel(tiers, displayLevel) badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, displayLevel) avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, displayLevel, tier.DisplayAvatarFrameResourceID) currentRequired, nextLevel, nextRequired := levelProgress(rules, displayLevel) pendingCount, err := r.pendingLevelRewardCount(ctx, userID, track.Track) if err != nil { return domain.TrackOverview{}, err } return domain.TrackOverview{ Track: track.Track, Name: track.Name, Level: displayLevel, TierID: tier.TierID, TotalValue: displayValue, CurrentLevelRequiredValue: currentRequired, NextLevel: nextLevel, NextLevelRequiredValue: nextRequired, DisplayAvatarFrameResourceID: avatarFrameResourceID, DisplayBadgeResourceID: badgeResourceID, DisplayBadgeSourceLevel: badgeSourceLevel, RewardPendingCount: pendingCount, SortOrder: track.SortOrder, }, nil } func (r *Repository) insertLevelValueEvent(ctx context.Context, tx *sql.Tx, event domain.ValueEvent, nowMS int64) (bool, error) { _, err := tx.ExecContext(ctx, ` INSERT INTO growth_level_value_events ( app_code, value_event_id, source_event_id, source_service, source_event_type, user_id, track, metric_type, value_delta, occurred_at_ms, dimensions_json, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), event.EventID, event.SourceEventID, event.SourceService, event.SourceEventType, event.UserID, event.Track, event.MetricType, event.ValueDelta, event.OccurredAtMS, event.DimensionsJSON, nowMS, ) if err == nil { return true, nil } if isMySQLDuplicate(err) { return false, nil } return false, err } func (r *Repository) ensureLevelAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, track string, nowMS int64) (domain.Account, error) { // INSERT IGNORE 命中已有主键时会先持有共享记录锁,随后 FOR UPDATE 再升级排他锁;并发礼物落到同一 // (app_code,user_id,track) 时,多个事务会各持 S 锁并互等 X 锁。空更新 upsert 从第一条语句就取得 X 锁, // 让热点账户串行进入后续读改写,同时不改变账户累计值或更新时间。 _, err := tx.ExecContext(ctx, ` INSERT INTO user_growth_level_accounts ( app_code, user_id, track, total_value, current_level, current_tier_id, level_updated_at_ms, tier_updated_at_ms, version, updated_at_ms ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, ?) ON DUPLICATE KEY UPDATE user_id = VALUES(user_id)`, appcode.FromContext(ctx), userID, track, nowMS, ) if err != nil { return domain.Account{}, err } row := tx.QueryRowContext(ctx, ` SELECT app_code, user_id, track, total_value, current_level, current_tier_id, level_updated_at_ms, tier_updated_at_ms, version, updated_at_ms FROM user_growth_level_accounts WHERE app_code = ? AND user_id = ? AND track = ? FOR UPDATE`, appcode.FromContext(ctx), userID, track, ) return scanLevelAccount(row) } func isRetryableGrowthTransactionError(err error) bool { var mysqlErr *mysqldriver.MySQLError if !errors.As(err, &mysqlErr) { return false } // 1213 会立即回滚死锁受害事务,适合短退避后本地重建。1205 可能已等待完整的 // innodb_lock_wait_timeout;在无 deadline 的 MQ 回调里重试它会让唯一消费协程继续占用数分钟,应交给 broker 延迟重投。 return mysqlErr.Number == 1213 } func growthTransactionRetryDelay(eventID string, attempt int) time.Duration { if attempt < 0 { attempt = 0 } hash := fnv.New32a() _, _ = hash.Write([]byte(eventID)) _, _ = hash.Write([]byte{byte(attempt)}) jitterSlots := uint32(growthTransactionRetryJitterWindow/time.Millisecond) + 1 jitter := time.Duration(hash.Sum32()%jitterSlots) * time.Millisecond return time.Duration(attempt+1)*growthTransactionRetryStep + jitter } func (r *Repository) updateLevelAccount(ctx context.Context, tx *sql.Tx, userID int64, track string, totalValue int64, level int32, tierID int64, levelUpdatedAtMS int64, tierUpdatedAtMS int64, nowMS int64) error { _, err := tx.ExecContext(ctx, ` UPDATE user_growth_level_accounts SET total_value = ?, current_level = ?, current_tier_id = ?, level_updated_at_ms = ?, tier_updated_at_ms = ?, version = version + 1, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND track = ?`, totalValue, level, tierID, levelUpdatedAtMS, tierUpdatedAtMS, nowMS, appcode.FromContext(ctx), userID, track, ) return err } func (r *Repository) insertLevelHistory(ctx context.Context, tx *sql.Tx, event domain.ValueEvent, previousLevel int32, nextLevel int32, previousTierID int64, nextTierID int64, totalValue int64, nowMS int64) error { _, err := tx.ExecContext(ctx, ` INSERT INTO user_growth_level_history ( app_code, history_id, user_id, track, previous_level, new_level, previous_tier_id, new_tier_id, total_value, value_event_id, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), idgen.New("glhist"), event.UserID, event.Track, previousLevel, nextLevel, previousTierID, nextTierID, totalValue, event.EventID, nowMS, ) return err } func (r *Repository) createLevelRewardJobs(ctx context.Context, tx *sql.Tx, userID int64, track string, previousLevel int32, nextLevel int32, previousTierID int64, nextTier domain.Tier, rules []domain.Rule, nowMS int64) (int64, error) { created := int64(0) for _, rule := range rules { avatarFrameResourceID := avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON) shortBadgeResourceID := shortBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON) if rule.Level <= previousLevel || rule.Level > nextLevel || (rule.RewardResourceGroupID <= 0 && avatarFrameResourceID <= 0 && shortBadgeResourceID <= 0) { continue } changed, err := r.insertLevelRewardJob(ctx, tx, userID, track, domain.RewardSourceLevel, fmt.Sprintf("level:%d", rule.Level), rule.RewardResourceGroupID, nowMS) if err != nil { return 0, err } if changed { created++ } } if nextTier.TierID > 0 && nextTier.TierID != previousTierID && nextTier.RewardResourceGroupID > 0 { changed, err := r.insertLevelRewardJob(ctx, tx, userID, track, domain.RewardSourceTier, fmt.Sprintf("tier:%d", nextTier.TierID), nextTier.RewardResourceGroupID, nowMS) if err != nil { return 0, err } if changed { created++ } } return created, nil } func (r *Repository) insertLevelRewardJob(ctx context.Context, tx *sql.Tx, userID int64, track string, sourceType string, sourceID string, resourceGroupID int64, nowMS int64) (bool, error) { // Natural growth permanently earns only the matching reward attached to the currently active overlay. Superseded, // expired or already-revoking generations must finish revoke and get a fresh organic job; otherwise multiple old // grants (or an RPC-timeout revoke that actually succeeded) could all be promoted into duplicate permanent assets. promoted, err := tx.ExecContext(ctx, ` UPDATE growth_level_reward_jobs j JOIN growth_temporary_levels t ON t.app_code = j.app_code AND t.temporary_level_id = j.temporary_level_id SET j.is_permanent = 1, j.revoke_status = 'skipped', j.updated_at_ms = ? WHERE j.app_code = ? AND j.user_id = ? AND j.track = ? AND j.reward_source_type = ? AND j.reward_source_id = ? AND j.reward_origin = 'temporary' AND j.is_permanent = 0 AND j.status IN ('pending','failed','running','granted') AND j.revoke_status = 'none' AND t.status = 'active' AND t.started_at_ms <= ? AND t.expires_at_ms > ?`, nowMS, appcode.FromContext(ctx), userID, track, sourceType, sourceID, nowMS, nowMS) if err != nil { return false, err } if affected, err := promoted.RowsAffected(); err != nil { return false, err } else if affected > 0 { _, err = tx.ExecContext(ctx, ` UPDATE growth_level_reward_wallet_grants g JOIN growth_level_reward_jobs j ON j.app_code = g.app_code AND j.reward_job_id = g.reward_job_id JOIN growth_temporary_levels t ON t.app_code = j.app_code AND t.temporary_level_id = j.temporary_level_id SET g.revoke_status = 'skipped', g.revoke_locked_by = '', g.revoke_locked_until_ms = 0, g.updated_at_ms = ? WHERE j.app_code = ? AND j.user_id = ? AND j.track = ? AND j.reward_source_type = ? AND j.reward_source_id = ? AND j.reward_origin = 'temporary' AND j.is_permanent = 1 AND t.status = 'active' AND t.started_at_ms <= ? AND t.expires_at_ms > ? AND g.revoke_status IN ('none','pending','failed')`, nowMS, appcode.FromContext(ctx), userID, track, sourceType, sourceID, nowMS, nowMS) return false, err } rewardJobID := idgen.New("greward") rewardLevel, _ := levelFromGrowthRewardSourceID(sourceID) result, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO growth_level_reward_jobs ( app_code, reward_job_id, user_id, track, reward_source_type, reward_source_id, resource_group_id, wallet_command_id, reward_origin, grant_generation, reward_level, is_permanent, status, next_retry_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'organic', 0, ?, 1, 'pending', 0, ?, ?)`, appcode.FromContext(ctx), rewardJobID, userID, track, sourceType, sourceID, resourceGroupID, "growth_level_reward:"+rewardJobID, rewardLevel, nowMS, nowMS, ) if err != nil { return false, err } affected, err := result.RowsAffected() return affected > 0, err } func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx, userID int64, track string, level int32, tier domain.Tier, avatarFrameResourceID int64, badgeResourceID int64, badgeSourceLevel int32, nowMS int64) error { columnPrefix := "" switch track { case domain.TrackWealth: columnPrefix = "wealth" case domain.TrackGame: columnPrefix = "game" case domain.TrackCharm: columnPrefix = "charm" default: return xerr.New(xerr.InvalidArgument, "track is invalid") } query := fmt.Sprintf(` INSERT INTO user_level_display_profiles ( app_code, user_id, %s_level, %s_tier_id, %s_avatar_frame_resource_id, %s_badge_resource_id, %s_badge_source_level, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE %s_level = VALUES(%s_level), %s_tier_id = VALUES(%s_tier_id), %s_avatar_frame_resource_id = VALUES(%s_avatar_frame_resource_id), %s_badge_resource_id = VALUES(%s_badge_resource_id), %s_badge_source_level = VALUES(%s_badge_source_level), updated_at_ms = VALUES(updated_at_ms)`, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, ) _, err := tx.ExecContext(ctx, query, appcode.FromContext(ctx), userID, level, tier.TierID, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS) return err } func (r *Repository) listActiveLevelTracks(ctx context.Context, q queryer) ([]domain.Track, error) { rows, err := q.QueryContext(ctx, ` SELECT app_code, track, name, status, sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_at_ms, updated_at_ms FROM growth_level_tracks WHERE app_code = ? AND status = 'active' ORDER BY sort_order ASC, track ASC`, appcode.FromContext(ctx), ) if err != nil { return nil, err } defer rows.Close() return scanLevelTracks(rows) } func (r *Repository) listLevelTracks(ctx context.Context, q queryer, track string, status string) ([]domain.Track, error) { where := []string{"app_code = ?"} args := []any{appcode.FromContext(ctx)} if track != "" { where = append(where, "track = ?") args = append(args, track) } if status != "" { where = append(where, "status = ?") args = append(args, status) } rows, err := q.QueryContext(ctx, ` SELECT app_code, track, name, status, sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_at_ms, updated_at_ms FROM growth_level_tracks WHERE `+strings.Join(where, " AND ")+` ORDER BY sort_order ASC, track ASC`, args...) if err != nil { return nil, err } defer rows.Close() return scanLevelTracks(rows) } func (r *Repository) getActiveLevelTrack(ctx context.Context, q queryer, track string) (domain.Track, error) { item, err := r.getLevelTrackWithQuery(ctx, q, track, true) if errors.Is(err, sql.ErrNoRows) { return domain.Track{}, xerr.New(xerr.NotFound, "level track not found") } return item, err } func (r *Repository) getLevelTrack(ctx context.Context, q queryer, track string) (domain.Track, error) { item, err := r.getLevelTrackWithQuery(ctx, q, track, false) if errors.Is(err, sql.ErrNoRows) { return domain.Track{}, xerr.New(xerr.NotFound, "level track not found") } return item, err } func (r *Repository) getLevelTrackWithQuery(ctx context.Context, q queryer, track string, activeOnly bool) (domain.Track, error) { statusClause := "" if activeOnly { statusClause = " AND status = 'active'" } row := q.QueryRowContext(ctx, ` SELECT app_code, track, name, status, sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_at_ms, updated_at_ms FROM growth_level_tracks WHERE app_code = ? AND track = ?`+statusClause, appcode.FromContext(ctx), track, ) return scanLevelTrack(row) } func (r *Repository) listActiveLevelRules(ctx context.Context, q queryer, track string) ([]domain.Rule, error) { rows, err := q.QueryContext(ctx, levelRuleSelectSQL()+` WHERE app_code = ? AND track = ? AND status = 'active' ORDER BY level ASC`, appcode.FromContext(ctx), track, ) if err != nil { return nil, err } defer rows.Close() return scanLevelRules(rows) } func (r *Repository) listLevelRules(ctx context.Context, q queryer, track string, status string) ([]domain.Rule, error) { where := []string{"app_code = ?"} args := []any{appcode.FromContext(ctx)} if track != "" { where = append(where, "track = ?") args = append(args, track) } if status != "" { where = append(where, "status = ?") args = append(args, status) } rows, err := q.QueryContext(ctx, levelRuleSelectSQL()+` WHERE `+strings.Join(where, " AND ")+` ORDER BY track ASC, level ASC`, args...) if err != nil { return nil, err } defer rows.Close() return scanLevelRules(rows) } func (r *Repository) listActiveLevelTiers(ctx context.Context, q queryer, track string) ([]domain.Tier, error) { rows, err := q.QueryContext(ctx, levelTierSelectSQL()+` WHERE app_code = ? AND track = ? AND status = 'active' ORDER BY min_level ASC, max_level ASC`, appcode.FromContext(ctx), track, ) if err != nil { return nil, err } defer rows.Close() return scanLevelTiers(rows) } func (r *Repository) listLevelTiers(ctx context.Context, q queryer, track string, status string) ([]domain.Tier, error) { where := []string{"app_code = ?"} args := []any{appcode.FromContext(ctx)} if track != "" { where = append(where, "track = ?") args = append(args, track) } if status != "" { where = append(where, "status = ?") args = append(args, status) } rows, err := q.QueryContext(ctx, levelTierSelectSQL()+` WHERE `+strings.Join(where, " AND ")+` ORDER BY track ASC, min_level ASC, max_level ASC`, args...) if err != nil { return nil, err } defer rows.Close() return scanLevelTiers(rows) } func (r *Repository) getLevelRule(ctx context.Context, track string, level int32) (domain.Rule, error) { row := r.db.QueryRowContext(ctx, levelRuleSelectSQL()+` WHERE app_code = ? AND track = ? AND level = ?`, appcode.FromContext(ctx), track, level, ) return scanLevelRule(row) } func (r *Repository) getLevelTier(ctx context.Context, tierID int64) (domain.Tier, error) { row := r.db.QueryRowContext(ctx, levelTierSelectSQL()+` WHERE app_code = ? AND tier_id = ?`, appcode.FromContext(ctx), tierID, ) return scanLevelTier(row) } func (r *Repository) getLevelAccount(ctx context.Context, userID int64, track string) (domain.Account, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, user_id, track, total_value, current_level, current_tier_id, level_updated_at_ms, tier_updated_at_ms, version, updated_at_ms FROM user_growth_level_accounts WHERE app_code = ? AND user_id = ? AND track = ?`, appcode.FromContext(ctx), userID, track, ) account, err := scanLevelAccount(row) if errors.Is(err, sql.ErrNoRows) { return domain.Account{}, false, nil } return account, err == nil, err } func (r *Repository) validateActiveLevelRules(ctx context.Context, tx *sql.Tx, track string) error { rules, err := r.listActiveLevelRules(ctx, tx, track) if err != nil { return err } lastRequired := int64(-1) for _, rule := range rules { if rule.RequiredValue <= lastRequired { return xerr.New(xerr.Conflict, "active level required_value must be strictly increasing") } lastRequired = rule.RequiredValue } return nil } func (r *Repository) validateActiveLevelTiers(ctx context.Context, tx *sql.Tx, track string) error { tiers, err := r.listActiveLevelTiers(ctx, tx, track) if err != nil { return err } lastMax := int32(0) for index, tier := range tiers { if index > 0 && tier.MinLevel <= lastMax { return xerr.New(xerr.Conflict, "active level tiers cannot overlap") } lastMax = tier.MaxLevel } return nil } func (r *Repository) pendingLevelRewardCount(ctx context.Context, userID int64, track string) (int64, error) { var count int64 err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM growth_level_reward_jobs WHERE app_code = ? AND user_id = ? AND track = ? AND status IN ('pending', 'failed', 'running')`, appcode.FromContext(ctx), userID, track, ).Scan(&count) return count, err } func levelForValue(rules []domain.Rule, totalValue int64) int32 { level := int32(0) for _, rule := range rules { if totalValue >= rule.RequiredValue && rule.Level > level { level = rule.Level } } return level } func requiredValueForLevel(rules []domain.Rule, level int32) (int64, bool) { for _, rule := range rules { if rule.Level == level && rule.Status == domain.StatusActive { return rule.RequiredValue, true } } return 0, false } func tierForLevel(tiers []domain.Tier, level int32) domain.Tier { if level < 0 { return domain.Tier{} } for _, tier := range tiers { if level >= tier.MinLevel && level <= tier.MaxLevel { return tier } } return domain.Tier{} } func normalizeJSONMap(value map[string]any) string { data, err := json.Marshal(value) if err != nil { return "{}" } return string(data) } func tierByID(tiers []domain.Tier, tierID int64) domain.Tier { if tierID <= 0 { return domain.Tier{} } for _, tier := range tiers { if tier.TierID == tierID { return tier } } return domain.Tier{} } func levelProgress(rules []domain.Rule, currentLevel int32) (int64, int32, int64) { currentRequired := int64(0) nextLevel := int32(0) nextRequired := int64(0) for _, rule := range rules { if rule.Level == currentLevel { currentRequired = rule.RequiredValue continue } if rule.Level > currentLevel && (nextLevel == 0 || rule.Level < nextLevel) { nextLevel = rule.Level nextRequired = rule.RequiredValue } } return currentRequired, nextLevel, nextRequired } func levelRuleBadgeForLevel(rules []domain.Rule, currentLevel int32) (int64, int32) { if currentLevel < 0 { return 0, 0 } badgeResourceID := int64(0) badgeSourceLevel := int32(0) for _, rule := range rules { if rule.Level > currentLevel || rule.Level < badgeSourceLevel { continue } candidate := longBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON) if candidate <= 0 { continue } // 等级徽章按当前等级以内最高配置生效;后续低等级配置不再覆盖更高等级展示。 badgeResourceID = candidate badgeSourceLevel = rule.Level } return badgeResourceID, badgeSourceLevel } func levelRuleAvatarFrameForLevel(rules []domain.Rule, currentLevel int32, fallbackResourceID int64) int64 { if currentLevel < 0 { return fallbackResourceID } avatarFrameResourceID := int64(0) sourceLevel := int32(0) for _, rule := range rules { if rule.Level > currentLevel || rule.Level < sourceLevel { continue } candidate := avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON) if candidate <= 0 { continue } // 等级头像框按当前等级以内最高配置覆盖;没有每级配置时保留等级段头像框兼容旧展示。 avatarFrameResourceID = candidate sourceLevel = rule.Level } if avatarFrameResourceID > 0 { return avatarFrameResourceID } return fallbackResourceID } func longBadgeResourceIDFromDisplayConfig(raw string) int64 { return positiveInt64FromDisplayConfig(raw, "long_badge_resource_id") } func avatarFrameResourceIDFromDisplayConfig(raw string) int64 { return positiveInt64FromDisplayConfig(raw, "avatar_frame_resource_id") } func shortBadgeResourceIDFromDisplayConfig(raw string) int64 { return positiveInt64FromDisplayConfig(raw, "short_badge_resource_id") } func positiveInt64FromDisplayConfig(raw string, key string) int64 { payload := map[string]any{} if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil { return 0 } value, ok := payload[key] if !ok { return 0 } switch typed := value.(type) { case float64: if typed > 0 { return int64(typed) } case string: parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) if err == nil && parsed > 0 { return parsed } } return 0 } func defaultLevelDisplayProfile(userID int64) domain.UserLevelDisplayProfile { return domain.UserLevelDisplayProfile{ UserID: userID, Wealth: domain.LevelDisplayTrackProfile{ Track: domain.TrackWealth, }, Game: domain.LevelDisplayTrackProfile{ Track: domain.TrackGame, }, Charm: domain.LevelDisplayTrackProfile{ Track: domain.TrackCharm, }, } } func uniquePositiveInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) result := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} result = append(result, value) } return result } func placeholders(count int) string { if count <= 0 { return "" } items := make([]string, count) for index := range items { items[index] = "?" } return strings.Join(items, ",") } func scanLevelTracks(rows *sql.Rows) ([]domain.Track, error) { items := make([]domain.Track, 0) for rows.Next() { item, err := scanLevelTrack(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func scanLevelTrack(row rowScanner) (domain.Track, error) { var item domain.Track err := row.Scan(&item.AppCode, &item.Track, &item.Name, &item.Status, &item.SortOrder, &item.DisplayConfigJSON, &item.CreatedAtMS, &item.UpdatedAtMS) return item, err } func scanLevelRules(rows *sql.Rows) ([]domain.Rule, error) { items := make([]domain.Rule, 0) for rows.Next() { item, err := scanLevelRule(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func scanLevelRule(row rowScanner) (domain.Rule, error) { var item domain.Rule err := row.Scan( &item.AppCode, &item.Track, &item.Level, &item.RequiredValue, &item.Name, &item.Status, &item.RewardResourceGroupID, &item.SortOrder, &item.DisplayConfigJSON, &item.CreatedByAdminID, &item.UpdatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, ) return item, err } func scanLevelTiers(rows *sql.Rows) ([]domain.Tier, error) { items := make([]domain.Tier, 0) for rows.Next() { item, err := scanLevelTier(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func scanLevelTier(row rowScanner) (domain.Tier, error) { var item domain.Tier err := row.Scan( &item.AppCode, &item.TierID, &item.Track, &item.MinLevel, &item.MaxLevel, &item.Name, &item.DisplayAvatarFrameResourceID, &item.DisplayBadgeResourceID, &item.RewardResourceGroupID, &item.Status, &item.DisplayConfigJSON, &item.CreatedByAdminID, &item.UpdatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, ) return item, err } func scanLevelAccount(row rowScanner) (domain.Account, error) { var item domain.Account err := row.Scan( &item.AppCode, &item.UserID, &item.Track, &item.TotalValue, &item.CurrentLevel, &item.CurrentTierID, &item.LevelUpdatedAtMS, &item.TierUpdatedAtMS, &item.Version, &item.UpdatedAtMS, ) return item, err } func scanLevelRewards(rows *sql.Rows) ([]domain.RewardJob, error) { items := make([]domain.RewardJob, 0) for rows.Next() { item, err := scanLevelReward(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func scanLevelReward(row rowScanner) (domain.RewardJob, error) { var item domain.RewardJob err := row.Scan( &item.AppCode, &item.RewardJobID, &item.UserID, &item.Track, &item.RewardSourceType, &item.RewardSourceID, &item.ResourceGroupID, &item.WalletCommandID, &item.WalletGrantID, &item.RewardOrigin, &item.TemporaryLevelID, &item.GrantGeneration, &item.RewardLevel, &item.Permanent, &item.TemporaryExpiresAtMS, &item.RevokeStatus, &item.Status, &item.AttemptCount, &item.FailureReason, &item.CreatedAtMS, &item.UpdatedAtMS, ) return item, err } func levelRuleSelectSQL() string { return `SELECT app_code, track, level, required_value, name, status, reward_resource_group_id, sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms FROM growth_level_rules ` } func levelTierSelectSQL() string { return `SELECT app_code, tier_id, track, min_level, max_level, name, display_avatar_frame_resource_id, display_badge_resource_id, reward_resource_group_id, status, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms FROM growth_level_tiers ` } func levelRewardSelectSQL() string { return `SELECT app_code, reward_job_id, user_id, track, reward_source_type, reward_source_id, resource_group_id, wallet_command_id, wallet_grant_id, reward_origin, temporary_level_id, grant_generation, reward_level, is_permanent, temporary_expires_at_ms, revoke_status, status, attempt_count, failure_reason, created_at_ms, updated_at_ms FROM growth_level_reward_jobs ` } func truncateGrowthFailure(value string) string { value = strings.TrimSpace(value) if len(value) <= 255 { return value } return value[:255] }