1097 lines
38 KiB
Go
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]
|
|
}
|