2026-06-04 10:06:49 +08:00

1097 lines
38 KiB
Go

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)
if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, 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
}
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)
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: tier.DisplayAvatarFrameResourceID,
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 {
if rule.Level <= previousLevel || rule.Level > nextLevel || rule.RewardResourceGroupID <= 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, 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, tier.DisplayAvatarFrameResourceID, 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 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 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 longBadgeResourceIDFromDisplayConfig(raw string) int64 {
payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return 0
}
value, ok := payload["long_badge_resource_id"]
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]
}