package mysql import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/growth" ) 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 } result := make([]domain.UserLevelDisplayProfile, 0, len(userIDs)) for _, userID := range userIDs { result = append(result, profiles[userID]) } 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) { 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 } 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') AND next_retry_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, 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, walletGrantID string, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } _, err := r.db.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 = ?`, walletGrantID, nowMS, appcode.FromContext(ctx), rewardJobID, ) return err } func (r *Repository) MarkLevelRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } _, err := r.db.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 <> 'granted'`, nextRetryAtMS, truncateGrowthFailure(failureReason), nowMS, appcode.FromContext(ctx), rewardJobID, ) return err } 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 } tier := tierByID(tiers, account.CurrentTierID) badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, account.CurrentLevel) avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, account.CurrentLevel, tier.DisplayAvatarFrameResourceID) currentRequired, nextLevel, nextRequired := levelProgress(rules, account.CurrentLevel) 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: account.CurrentLevel, TierID: account.CurrentTierID, TotalValue: account.TotalValue, 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) { _, err := tx.ExecContext(ctx, ` INSERT IGNORE 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, ?)`, 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 (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) { rewardJobID := idgen.New("greward") 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, status, next_retry_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`, appcode.FromContext(ctx), rewardJobID, userID, track, sourceType, sourceID, resourceGroupID, "growth_level_reward:"+rewardJobID, 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.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, 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] }