1326 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
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) {
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
}
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) {
// 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) {
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]
}