1220 lines
43 KiB
Go
1220 lines
43 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
|
||
}
|
||
|
||
// 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]
|
||
}
|