topfans/docs/superpowers/specs/2026-04-15-economic-system-design.md
2026-04-15 17:04:42 +08:00

28 KiB
Raw Permalink Blame History

经济系统设计文档

创建日期: 2026-04-15 项目: TopFans 经济系统(水晶 / 经验 / 游戏币) 服务: userService (Go Dubbo-go) + taskService (Go) + 共享 PostgreSQL


一、设计目标

  1. 水晶 (Crystal) — 记录所有收入/消耗流水,支持查询历史
  2. 经验 (Experience) — 记录经验变化 + 自动计算等级(数据库阈值配置)
  3. 游戏币 (Coin) — 预留,与水晶结构一致
  4. 等级变化 — 记录每次升级,用于运营分析

1.1 经济模型

收入侧:                    消耗侧:
├── 任务奖励(水晶)         ├── 铸造藏品
├── 展示收益(水晶)         ├── 商城购物(预留)
├── 升级奖励(水晶)         ├── 应援活动道具(预留)
└── 运营发放(后台手动)      └── 后期其他功能

二、整体架构

┌──────────────────────────────────────────────────────────────┐
│                     移动端 / Gateway                          │
└──────────────────────────┬───────────────────────────────────┘
                           │ HTTP / Triple
                           ▼
┌──────────────────────────────────────────────────────────────┐
│                   userService (Go)                           │
│                                                              │
│  ┌────────────────┐    ┌────────────────────────────────┐  │
│  │ UpdateCrystal  │    │ AddExperience                   │  │
│  │ Balance        │    │ + CalculateLevel (查DB阈值)     │  │
│  │ + WriteLedger  │    │ + WriteExpLedger                │  │
│  └────────┬───────┘    │ + CheckLevelUp → WriteLevelChange│  │
│           │            └──────────────┬───────────────────┘  │
│  ┌────────▼──────────────────────────▼───────────────────┐  │
│  │              Repository Layer (GORM)                   │  │
│  │  FanProfileRepository / LedgerRepositories            │  │
│  └───────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘
           │
           ▼
┌──────────────────────────────────────────────────────────────┐
│                      PostgreSQL                              │
│  ┌─────────────────┐  ┌──────────────────────────────────┐ │
│  │ FanProfile      │  │ crystal_transaction_records       │ │
│  │ (balance only) │  │ coin_transaction_records (预留)   │ │
│  └─────────────────┘  │ exp_transaction_records          │ │
│                       │ level_change_records             │ │
│                       │ level_thresholds                 │ │
│                       └──────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

三、数据库设计

3.1 水晶交易流水表 (crystal_transaction_records)

CREATE TABLE crystal_transaction_records (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    star_id BIGINT NOT NULL,
    change_type VARCHAR(30) NOT NULL,          -- task_reward/mint_cost/exhibition_revenue/level_up_bonus/manual_adjust
    delta BIGINT NOT NULL,                      -- 正数=收入,负数=消耗
    balance_before BIGINT NOT NULL,              -- 变化前余额
    balance_after BIGINT NOT NULL,              -- 变化后余额
    source_id VARCHAR(100),                     -- 关联业务ID如 task_definition.id, order_id
    description VARCHAR(255),                   -- 可读描述
    created_at BIGINT NOT NULL
);

CREATE INDEX ix_crystal_tx_user_star ON crystal_transaction_records(user_id, star_id);
CREATE INDEX ix_crystal_tx_created ON crystal_transaction_records(created_at DESC);
CREATE INDEX ix_crystal_tx_change_type ON crystal_transaction_records(change_type);

3.2 游戏币交易流水表 (coin_transaction_records)

预留: 当前 coin_balance 全为 0未实际使用。建表 + Go模型预留等游戏币真用时再接调用方。

CREATE TABLE coin_transaction_records (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    star_id BIGINT NOT NULL,
    change_type VARCHAR(30) NOT NULL,
    delta BIGINT NOT NULL,
    balance_before BIGINT NOT NULL,
    balance_after BIGINT NOT NULL,
    source_id VARCHAR(100),
    description VARCHAR(255),
    created_at BIGINT NOT NULL
);

CREATE INDEX ix_coin_tx_user_star ON coin_transaction_records(user_id, star_id);
CREATE INDEX ix_coin_tx_created ON coin_transaction_records(created_at DESC);

3.3 经验交易流水表 (exp_transaction_records)

CREATE TABLE exp_transaction_records (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    star_id BIGINT NOT NULL,
    change_type VARCHAR(30) NOT NULL,           -- task_reward/onboarding_reward/manual_adjust
    delta BIGINT NOT NULL,                      -- 正数=获得,负数=消耗
    exp_before BIGINT NOT NULL,
    exp_after BIGINT NOT NULL,
    level_before INT NOT NULL,                  -- 变化前等级
    level_after INT NOT NULL,                   -- 变化后等级
    level_delta INT DEFAULT 0,                  -- 升级了多少级(正数=升级,负数=降级0=无变化)
    source_id VARCHAR(100),                     -- 关联业务ID
    description VARCHAR(255),
    created_at BIGINT NOT NULL
);

CREATE INDEX ix_exp_tx_user_star ON exp_transaction_records(user_id, star_id);
CREATE INDEX ix_exp_tx_created ON exp_transaction_records(created_at DESC);

3.4 等级变化记录表 (level_change_records)

CREATE TABLE level_change_records (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    star_id BIGINT NOT NULL,
    level_before INT NOT NULL,
    level_after INT NOT NULL,
    level_delta INT NOT NULL,                   -- 通常为 +1可用于跳级
    trigger_type VARCHAR(30) NOT NULL,          -- exp_gain/manual/admin_adjust
    exp_at_change BIGINT NOT NULL,               -- 触发等级变化时的经验值
    reward_claimed BOOLEAN DEFAULT false,        -- 升级奖励是否已领取(预留)
    source_id VARCHAR(100),                      -- 触发来源(如 task_definition.id
    description VARCHAR(255),
    created_at BIGINT NOT NULL
);

CREATE INDEX ix_level_change_user_star ON level_change_records(user_id, star_id);
CREATE INDEX ix_level_change_created ON level_change_records(created_at DESC);

-- ============================================================
-- 后续扩展索引(如有查询需求可添加):
-- 索引B: 查某用户在某时间范围的等级变化
-- CREATE INDEX ix_level_change_user_star_time
--     ON level_change_records(user_id, star_id, created_at DESC);
--
-- 索引C: 运营后台查某等级的所有升级记录
-- CREATE INDEX ix_level_change_level_after ON level_change_records(level_after);
--
-- 索引D: 发放升级奖励(按未领取状态查)
-- CREATE INDEX ix_level_change_reward_claimed ON level_change_records(reward_claimed)
--     WHERE reward_claimed = false;
-- ============================================================

3.5 等级阈值配置表 (level_thresholds)

CREATE TABLE level_thresholds (
    level INT PRIMARY KEY,                      -- 等级 1-10
    exp_required BIGINT NOT NULL,                -- 达到该等级需要的累计经验
    crystal_reward BIGINT DEFAULT 0,             -- 升级到该等级时奖励的水晶
    description VARCHAR(100)                     -- 如 "2级粉丝"
);

-- 初始数据10级满级
INSERT INTO level_thresholds (level, exp_required, crystal_reward, description) VALUES
(1, 0, 0, '1级新手'),
(2, 100, 10, '2级粉丝'),
(3, 300, 20, '3级真爱'),
(4, 600, 30, '4级铁粉'),
(5, 1000, 50, '5级钻石粉'),
(6, 1500, 80, '6级钻石粉'),
(7, 2100, 120, '7级钻石粉'),
(8, 2800, 180, '8级钻石粉'),
(9, 3600, 280, '9级钻石粉'),
(10, 4500, 500, '10级终极粉');

四、change_type 分类

4.1 水晶 (crystal_transaction_records.change_type)

含义 delta 方向
task_reward 任务奖励 +
mint_cost 铸造消耗 -
exhibition_revenue 展示收益 +
level_up_bonus 升级奖励由调用方主动发放AddExperience 不自动发) +
manual_adjust 手动调整(运营) +/-

4.2 经验 (exp_transaction_records.change_type)

含义 delta 方向
task_reward 任务奖励 +
onboarding_reward 引导阶段奖励 +
manual_adjust 手动调整 +/-

4.3 等级变化触发类型 (level_change_records.trigger_type)

含义
exp_gain 经验增长触发
manual 手动调整
admin_adjust 管理员操作

4.4 source_id 填写规则

source_id 填触发这笔流水的源头业务ID

change_type source_id 填什么
task_reward task_definitions.id任务ID
mint_cost mint_orders.order_id(铸造订单号)
exhibition_revenue exhibition_revenue_records.id
level_up_bonus 与触发升级的经验增加 source_id 相同(即触发升级的那笔 task/id
manual_adjust 运营后台操作记录ID预留

五、核心方法改造

5.1 UpdateCrystalBalance

现有签名:

UpdateCrystalBalance(userID, starID int64, delta int64) (int64, error)

新签名:

UpdateCrystalBalance(
    userID int64,
    starID int64,
    delta int64,
    changeType string,
    sourceID string,
    description string,
) (int64, error)

内部逻辑(事务内):

  1. 查询当前余额 balance_before
  2. 计算 balance_after = balance_before + delta
  3. 写入 crystal_transaction_records
  4. 更新 FanProfile.CrystalBalance
  5. 返回新余额

5.2 AddExperience

现有签名:

AddExperience(userID, starID int64, delta int64) (int64, error)

新签名:

AddExperience(
    userID int64,
    starID int64,
    delta int64,
    changeType string,
    sourceID string,
    description string,
) (newExp int64, newLevel int32, levelDelta int32, err error)

返回值说明:

  • newExp — 新的经验值
  • newLevel — 新的等级
  • levelDelta — 等级变化量(正数=升级,负数=降级0=无变化)

内部逻辑(事务内):

  1. 读取当前 FanProfile(含 experience 和 level只读一次
  2. 计算 exp_after = profile.Experience + delta
  3. level_thresholds(含缓存)计算新等级
  4. 写入 exp_transaction_records(含 level_before / level_after / level_delta
  5. 更新 FanProfile.Experience(如等级有变,同时更新 FanProfile.Level
  6. 如有升级newLevel > profile.Level写入 level_change_records
  7. 返回 (newExp, newLevel, levelDelta, nil),不自动发放升级水晶奖励

注意: 升级水晶奖励由调用方主动查询 level_thresholds[newLevel].crystal_reward 后发放,不在 AddExperience 内自动发放。


5.3 等级计算 (CalculateLevel)

const MaxLevel = 10

// CalculateLevel 根据累计经验计算当前等级10级满级超出阈值不再升级
func CalculateLevel(exp int64, thresholds map[int32]int64) int32 {
    for level := MaxLevel; level >= 1; level-- {
        if exp >= thresholds[level] {
            return level
        }
    }
    return 1
}
  • 阈值从 level_thresholds 表加载带缓存TTL 5分钟
  • 10级满级超出阈值的经验不会导致等级变化

六、调用方改造

6.1 taskService — 每日任务奖励

daily_task_service.goClaimDailyTask / ClaimAllDailyTasks:

// 发放水晶
newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, def.CrystalReward,
    "task_reward",
    strconv.FormatInt(def.ID, 10),
    fmt.Sprintf("每日任务奖励: %s", def.Name))

// 发放经验
newExp, newLevel, levelDelta, err := s.userClient.AddExperience(ctx, userID, starID, def.ExpReward,
    "task_reward",
    strconv.FormatInt(def.ID, 10),
    fmt.Sprintf("每日任务奖励: %s", def.Name))

// 发放升级水晶奖励(由调用方主动查、主动发)
if levelDelta > 0 {
    threshold := s.getLevelThreshold(newLevel)
    if threshold != nil && threshold.CrystalReward > 0 {
        s.userClient.UpdateCrystalBalance(ctx, userID, starID, threshold.CrystalReward,
            "level_up_bonus",
            strconv.FormatInt(def.ID, 10),  // source_id 与触发升级的经验来源相同
            fmt.Sprintf("升级到%d级奖励", newLevel))
    }
}

6.2 taskService — 引导阶段奖励

onboarding_service.goClaimStageReward:

newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, stage.CrystalReward,
    "onboarding_reward", sourceID, fmt.Sprintf("引导阶段%d奖励", stage))

newExp, newLevel, levelDelta, err := s.userClient.AddExperience(ctx, userID, starID, stage.ExpReward,
    "onboarding_reward", sourceID, fmt.Sprintf("引导阶段%d奖励", stage))

// 发放升级水晶奖励
if levelDelta > 0 {
    threshold := s.getLevelThreshold(newLevel)
    if threshold != nil && threshold.CrystalReward > 0 {
        s.userClient.UpdateCrystalBalance(ctx, userID, starID, threshold.CrystalReward,
            "level_up_bonus", sourceID, fmt.Sprintf("升级到%d级奖励", newLevel))
    }
}

6.3 taskService — 展示收益

revenue_service.goClaimRevenue / ClaimAllRevenue:

newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, record.CrystalAmount,
    "exhibition_revenue",
    strconv.FormatInt(record.ID, 10),
    fmt.Sprintf("展示收益 #%d", record.ID))

6.4 assetService — 铸造扣水晶

mint_service.goCreateMintOrder:

newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, -mintFee,
    "mint_cost",
    orderID,
    fmt.Sprintf("铸造藏品 #%s", orderID))

七、新增 Repository

7.1 Ledger Repository流水读写

当前阶段只做写入List 查询暂不做(后续按需添加)

// CrystalLedgerRepository
WriteCrystalRecord(tx *gorm.DB, record *model.CrystalTransactionRecord) error

// CoinLedgerRepository预留delta 写 0
WriteCoinRecord(tx *gorm.DB, record *model.CoinTransactionRecord) error

// ExpLedgerRepository
WriteExpRecord(tx *gorm.DB, record *model.ExpTransactionRecord) error

// LevelChangeRepository
WriteLevelChange(tx *gorm.DB, record *model.LevelChangeRecord) error

List 查询方法暂不做,等运营后台有需求时再加。

7.2 LevelThresholdRepository

// GetAllThresholds 获取所有等级阈值带内存缓存TTL 5分钟
// 缓存加载失败时 panic与 logger 初始化失败同等待遇)
GetAllThresholds() (map[int32]*model.LevelThreshold, error)

// GetThresholdByLevel 获取指定等级阈值
GetThresholdByLevel(level int32) (*model.LevelThreshold, error)

八、LevelThreshold 缓存设计

type levelThresholdCache struct {
    mu       sync.RWMutex
    data     map[int32]*LevelThreshold
    ttl      time.Duration
    loadedAt time.Time
}

func (c *levelThresholdCache) GetAll() (map[int32]*LevelThreshold, error) {
    c.mu.RLock()
    if time.Since(c.loadedAt) < c.ttl && len(c.data) > 0 {
        result := c.data
        c.mu.RUnlock()
        return result, nil
    }
    c.mu.RUnlock()

    c.mu.Lock()
    defer c.mu.Unlock()
    // 双重检查
    thresholds, err := repo.GetAllThresholds()
    if err != nil {
        logger.Logger.Error("Failed to load level thresholds", zap.Error(err))
        return nil, err
    }
    if len(thresholds) == 0 {
        logger.Logger.Error("level_thresholds table is empty")
        return nil, errors.New("level_thresholds table is empty")
    }
    c.data = thresholds
    c.loadedAt = time.Now()
    return c.data, nil
}
  • TTL 5分钟
  • 启动预热: 服务启动时调用一次 GetAll(),失败则 panic
  • panic 策略: 与 logger 初始化失败保持一致,保证阈值配置正确才启动

九、Proto 改造

9.1 user.proto — AddExperienceResponse

message AddExperienceResponse {
  topfans.common.BaseResponse base = 1;
  int64 new_experience = 2;
  int32 new_level = 3;
  int32 level_delta = 4;  // 新增:等级变化量(正数=升级,负数=降级0=无变化)
}

9.2 user.proto — UpdateCrystalBalanceResponse

现有已返回 new_balance,无需改动。

9.3 task.proto — ClaimDailyTaskResponse / ClaimAllDailyTasksResponse

message ClaimDailyTaskResponse {
  topfans.common.BaseResponse base = 1;
  int64 new_crystal_balance = 2;
  int64 new_experience = 3;
  int32 new_level = 4;
  int32 level_delta = 5;  // 新增
}

十、事务边界设计

10.1 AddExperience 事务边界

func (s *userService) AddExperience(
    userID int64, starID int64, delta int64,
    changeType, sourceID, description string,
) (newExp int64, newLevel int32, levelDelta int32, err error) {

    err = s.db.Transaction(func(tx *gorm.DB) error {
        // 1. 读取当前 profile只读一次
        var profile models.FanProfile
        if err := tx.Where("user_id = ? AND star_id = ?", userID, starID).
            First(&profile).Error; err != nil {
            return err
        }

        // 2. 计算新经验值
        newExp = profile.Experience + delta
        if newExp < 0 {
            newExp = 0
        }

        // 3. 读取等级阈值(从缓存)
        thresholds, err := s.levelThresholdCache.GetAll()
        if err != nil {
            return err
        }

        // 4. 计算新等级
        newLevel = CalculateLevel(newExp, thresholds)
        levelDelta = newLevel - profile.Level

        // 5. 写入 exp_transaction_records
        expRecord := &model.ExpTransactionRecord{
            UserID:       userID,
            StarID:       starID,
            ChangeType:   changeType,
            Delta:        delta,
            ExpBefore:    profile.Experience,
            ExpAfter:     newExp,
            LevelBefore:  profile.Level,
            LevelAfter:   newLevel,
            LevelDelta:   levelDelta,
            SourceID:     sourceID,
            Description:  description,
            CreatedAt:     time.Now().UnixMilli(),
        }
        if err := tx.Create(expRecord).Error; err != nil {
            return err
        }

        // 6. 更新 FanProfile经验+等级)
        updates := map[string]interface{}{
            "experience": newExp,
        }
        if newLevel != profile.Level {
            updates["level"] = newLevel
        }
        if err := tx.Model(&models.FanProfile{}).
            Where("user_id = ? AND star_id = ?", userID, starID).
            Updates(updates).Error; err != nil {
            return err
        }

        // 7. 如有升级,写入 level_change_records
        if newLevel > profile.Level {
            levelRecord := &model.LevelChangeRecord{
                UserID:       userID,
                StarID:       starID,
                LevelBefore: profile.Level,
                LevelAfter:  newLevel,
                LevelDelta:  levelDelta,
                TriggerType: "exp_gain",
                ExpAtChange: newExp,
                SourceID:    sourceID,
                Description: description,
                CreatedAt:   time.Now().UnixMilli(),
            }
            if err := tx.Create(levelRecord).Error; err != nil {
                return err
            }
        }

        return nil
    })

    return newExp, newLevel, levelDelta, err
}

十一、项目文件结构

backend/
├── pkg/models/
│   ├── user.go               # 修改:新增 CrystalTransactionRecord / CoinTransactionRecord /
│   │                           #        ExpTransactionRecord / LevelChangeRecord 模型
│   └── level_threshold.go    # 新增LevelThreshold 模型
│
├── scripts/
│   └── v002_economic_tables.sql  # 新增:所有新建表的 DDL + level_thresholds 初始数据
│
├── services/userService/
│   ├── repository/
│   │   ├── fan_profile_repository.go       # 修改UpdateCrystalBalance / AddExperience 签名
│   │   ├── crystal_tx_repository.go        # 新增:水晶流水写入
│   │   ├── coin_tx_repository.go          # 新增:游戏币流水写入(预留)
│   │   ├── exp_tx_repository.go           # 新增:经验流水写入
│   │   ├── level_change_repository.go      # 新增:等级变化写入
│   │   └── level_threshold_repository.go   # 新增:阈值查询(含缓存)
│   │
│   ├── service/
│   │   └── user_service.go                # 修改AddExperience 返回 newLevel, levelDelta
│   │
│   └── client/
│       └── user_rpc_client.go              # 修改UpdateCrystalBalance / AddExperience 签名
│
├── services/taskService/
│   ├── service/
│   │   ├── daily_task_service.go           # 修改:调用新签名 + 发放升级奖励
│   │   ├── onboarding_service.go           # 修改:调用新签名 + 发放升级奖励
│   │   └── revenue_service.go              # 修改:调用新签名
│   │
│   └── client/
│       └── user_rpc_client.go              # 修改UpdateCrystalBalance / AddExperience 返回值
│
├── services/assetService/
│   └── service/
│       └── mint_service.go                 # 修改:调用新签名
│
├── proto/
│   ├── user.proto              # 修改AddExperienceResponse 增加 new_level, level_delta
│   └── task.proto              # 修改ClaimDailyTaskResponse / ClaimAllDailyTasksResponse
│                               #        增加 new_level, level_delta
│
└── pkg/proto/
    ├── user/
    │   ├── user.pb.go          # 重新生成
    │   └── user.triple.go      # 重新生成
    └── task/
        ├── task.pb.go           # 重新生成
        └── task.triple.go       # 重新生成

十二、SQL 建表脚本

脚本路径:backend/scripts/v002_economic_tables.sql

-- ============================================================
-- 经济系统建表脚本
-- 执行方式: psql -h <host> -U <user> -d <db> -f backend/scripts/v002_economic_tables.sql
-- ============================================================

-- 水晶交易流水表
CREATE TABLE IF NOT EXISTS crystal_transaction_records (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    star_id BIGINT NOT NULL,
    change_type VARCHAR(30) NOT NULL,
    delta BIGINT NOT NULL,
    balance_before BIGINT NOT NULL,
    balance_after BIGINT NOT NULL,
    source_id VARCHAR(100),
    description VARCHAR(255),
    created_at BIGINT NOT NULL
);

CREATE INDEX IF NOT EXISTS ix_crystal_tx_user_star ON crystal_transaction_records(user_id, star_id);
CREATE INDEX IF NOT EXISTS ix_crystal_tx_created ON crystal_transaction_records(created_at DESC);
CREATE INDEX IF NOT EXISTS ix_crystal_tx_change_type ON crystal_transaction_records(change_type);

-- 游戏币交易流水表(预留)
CREATE TABLE IF NOT EXISTS coin_transaction_records (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    star_id BIGINT NOT NULL,
    change_type VARCHAR(30) NOT NULL,
    delta BIGINT NOT NULL,
    balance_before BIGINT NOT NULL,
    balance_after BIGINT NOT NULL,
    source_id VARCHAR(100),
    description VARCHAR(255),
    created_at BIGINT NOT NULL
);

CREATE INDEX IF NOT EXISTS ix_coin_tx_user_star ON coin_transaction_records(user_id, star_id);
CREATE INDEX IF NOT EXISTS ix_coin_tx_created ON coin_transaction_records(created_at DESC);

-- 经验交易流水表
CREATE TABLE IF NOT EXISTS exp_transaction_records (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    star_id BIGINT NOT NULL,
    change_type VARCHAR(30) NOT NULL,
    delta BIGINT NOT NULL,
    exp_before BIGINT NOT NULL,
    exp_after BIGINT NOT NULL,
    level_before INT NOT NULL,
    level_after INT NOT NULL,
    level_delta INT DEFAULT 0,
    source_id VARCHAR(100),
    description VARCHAR(255),
    created_at BIGINT NOT NULL
);

CREATE INDEX IF NOT EXISTS ix_exp_tx_user_star ON exp_transaction_records(user_id, star_id);
CREATE INDEX IF NOT EXISTS ix_exp_tx_created ON exp_transaction_records(created_at DESC);

-- 等级变化记录表
CREATE TABLE IF NOT EXISTS level_change_records (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    star_id BIGINT NOT NULL,
    level_before INT NOT NULL,
    level_after INT NOT NULL,
    level_delta INT NOT NULL,
    trigger_type VARCHAR(30) NOT NULL,
    exp_at_change BIGINT NOT NULL,
    reward_claimed BOOLEAN DEFAULT false,
    source_id VARCHAR(100),
    description VARCHAR(255),
    created_at BIGINT NOT NULL
);

CREATE INDEX IF NOT EXISTS ix_level_change_user_star ON level_change_records(user_id, star_id);
CREATE INDEX IF NOT EXISTS ix_level_change_created ON level_change_records(created_at DESC);

-- 后续扩展索引(如有查询需求可添加):
-- CREATE INDEX ix_level_change_user_star_time ON level_change_records(user_id, star_id, created_at DESC);
-- CREATE INDEX ix_level_change_level_after ON level_change_records(level_after);
-- CREATE INDEX ix_level_change_reward_claimed ON level_change_records(reward_claimed) WHERE reward_claimed = false;

-- 等级阈值配置表
CREATE TABLE IF NOT EXISTS level_thresholds (
    level INT PRIMARY KEY,
    exp_required BIGINT NOT NULL,
    crystal_reward BIGINT DEFAULT 0,
    description VARCHAR(100)
);

-- 插入初始数据10级满级
INSERT INTO level_thresholds (level, exp_required, crystal_reward, description) VALUES
(1, 0, 0, '1级新手'),
(2, 100, 10, '2级粉丝'),
(3, 300, 20, '3级真爱'),
(4, 600, 30, '4级铁粉'),
(5, 1000, 50, '5级钻石粉'),
(6, 1500, 80, '6级钻石粉'),
(7, 2100, 120, '7级钻石粉'),
(8, 2800, 180, '8级钻石粉'),
(9, 3600, 280, '9级钻石粉'),
(10, 4500, 500, '10级终极粉')
ON CONFLICT (level) DO NOTHING;

十三、后续扩展预留

  1. coin_transaction_records — 等游戏币有实际用途时启用(当前 delta 写 0
  2. level_change_records.reward_claimed — 升级奖励领取状态(等运营后台需要手动补发时启用)
  3. 流水分页查询 APIGET /api/economy/crystal-history 等(当前只记录不查询)
  4. level_thresholds.crystal_reward — 升级奖励水晶已在表中配置
  5. 扩展索引 — 见 3.4 节注释