2026-06-08 19:00:23 +08:00

1220 lines
43 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"
"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
}
// 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)
if err := r.upsertLevelDisplayProfile(ctx, tx, command.UserID, command.Track, nextLevel, nextTier, 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)
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 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 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]
}