# 经济系统设计文档 > **创建日期:** 2026-04-15 > **项目:** TopFans 经济系统(水晶 / 等级 / 游戏币) > **服务:** userService (Go Dubbo-go) + taskService (Go) + 共享 PostgreSQL --- ## 一、设计目标 1. **水晶 (Crystal)** — 记录所有收入/消耗流水,支持查询历史 2. **等级 (Level)** — 通过「总上架时长」升级,解锁功能由配置控制 3. **游戏币 (Coin)** — 预留,与水晶结构一致 ### 1.1 经济模型 ``` 收入侧: 消耗侧: ├── 任务奖励(水晶) ├── 铸造藏品 ├── 展示收益(水晶) ├── 商城购物 ├── 点赞收益(水晶) └── 活动消耗 └── 运营发放(后台手动) ``` ### 1.2 等级成长系统 用户通过上架藏品累积「总上架时长」来提升等级,21级后需同时满足「总上架时长」和「搭子等级」要求。 **等级上限可动态配置**(存于数据库,非硬编码)。 #### 主等级成长表 | 等级 | 可上架藏品 | 升级所需「总上架时长」(小时) | 点赞押注数 | 搭子等级要求 | 升级奖励 | |------|-----------|--------------------------|-----------|------------|---------| | 1级 | 1 | - | - | - | - | | 2级 | 1 | 6 | 6 | - | 可配置 | | 3级 | 2 | 12 | 7 | - | 可配置 | | 4级 | 2 | 18 | 8 | - | 可配置 | | 5级 | 2 | 24 | 9 | - | 可配置 | | 6级 | 2 | 30 | 9 | - | 可配置 | | 7级 | 2 | 36 | 10 | - | 可配置 | | 8级 | 2 | 42 | 11 | - | 可配置 | | 9级 | 2 | 48 | 12 | - | 可配置 | | 10级 | 2 | 54 | 13 | - | 可配置 | | 11级 | 2 | 60 | 13 | - | 可配置 | | 12级 | 2 | 66 | 13 | - | 可配置 | | 13级 | 2 | 72 | 14 | - | 可配置 | | 14级 | 2 | 78 | 15 | - | 可配置 | | 15级 | 2 | 84 | 16 | - | 可配置 | | 16级 | 2 | 90 | 16 | - | 可配置 | | 17级 | 2 | 96 | 17 | - | 可配置 | | 18级 | 2 | 102 | 18 | - | 可配置 | | 19级 | 2 | 108 | 19 | - | 可配置 | | 20级 | 2 | 114 | 20 | - | 可配置 | | 21级+ | 待扩展 | 待扩展 | 待扩展 | 待扩展 | 可配置 | > **注意:** 21级后升级条件在 `level_upgrade_conditions` 表配置,搭子等级要求预留。 #### 等级上限配置表 (level_cap_config) ```sql CREATE TABLE level_cap_config ( id BIGSERIAL PRIMARY KEY, max_level INT NOT NULL DEFAULT 20, -- 当前等级上限 updated_at BIGINT NOT NULL ); INSERT INTO level_cap_config (max_level, updated_at) VALUES (20, UNIX_MILLIS()); ``` #### 搭子等级阈值配置表(预留) ```sql CREATE TABLE dazi_level_thresholds ( level INT PRIMARY KEY, -- 搭子等级 1-N upgrade_condition VARCHAR(100), -- 升级条件描述(如"任务积分"、"互动次数"等) condition_param INT DEFAULT 0, -- 条件参数(如需要完成任务数) description VARCHAR(100) ); ``` #### 等级升级条件配置表 ```sql CREATE TABLE level_upgrade_conditions ( level INT PRIMARY KEY, -- 主等级(如21级) require_total_hours BIGINT NOT NULL, -- 需要的总上架时长 require_dazi_level INT DEFAULT 0, -- 需要的搭子等级(0=不需要) description VARCHAR(100), -- 如 "21级需搭子21级" updated_at BIGINT NOT NULL ); -- 初始数据(21级示例,后续可扩展更多等级) INSERT INTO level_upgrade_conditions (level, require_total_hours, require_dazi_level, description, updated_at) VALUES (21, 120, 21, '21级:总时长120h + 搭子2级', UNIX_MILLIS()); ``` #### 用户搭子等级表(预留) ```sql CREATE TABLE user_dazi_level ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, dazi_level INT NOT NULL DEFAULT 1, updated_at BIGINT NOT NULL, UNIQUE(user_id, star_id) ); ``` --- ## 二、整体架构 ``` ┌──────────────────────────────────────────────────────────────┐ │ 移动端 / 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) ```sql 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/mint_reward/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); ``` > **Balanced Transaction Entry(含余额快照的复式记账)** > > 每条记录同时存储 `balance_before`(变化前余额快照)和 `balance_after`(变化后余额快照),与 `delta` 构成三重校验: > > 1. **自包含审计** — 任意一条记录都能独立验证 `balance_before + delta = balance_after`,不依赖其他记录 > 2. **数据一致性验证** — 可检查记录 N 的 `balance_before` 是否等于记录 N-1 的 `balance_after`,发现数据损坏或业务 bug > 3. **故障追溯** — 余额出错时,从任意一笔交易往前推算即可定位问题 > 4. **防错误传播** — 若只存 `delta`,计算 `balance_after` 时的 bug 会导致后续所有余额错乱;有 `balance_after` 作为 checkpoint,错误被隔离在某一条,不会扩散 ### 3.2 游戏币交易流水表 (coin_transaction_records) > **预留:** 当前 coin_balance 全为 0,未实际使用。建表 + Go模型预留,等游戏币真用时再接调用方。 ```sql 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 等级阈值配置表 (level_thresholds) ```sql CREATE TABLE level_thresholds ( level INT PRIMARY KEY, -- 等级 1-20 max_exhibition_hours BIGINT NOT NULL, -- 升级到该等级需要的累计上架时长(小时) like_bet_count INT NOT NULL, -- 升级后解锁的点赞押注次数 description VARCHAR(100) -- 如 "2级粉丝" ); -- 初始数据(20级满级) INSERT INTO level_thresholds (level, max_exhibition_hours, like_bet_count, description) VALUES (1, 0, 0, '1级新手'), (2, 6, 6, '2级粉丝'), (3, 12, 7, '3级真爱'), (4, 18, 8, '4级铁粉'), (5, 24, 9, '5级钻石粉'), (6, 30, 9, '6级钻石粉'), (7, 36, 10, '7级钻石粉'), (8, 42, 11, '8级钻石粉'), (9, 48, 12, '9级钻石粉'), (10, 54, 13, '10级钻石粉'), (11, 60, 13, '11级钻石粉'), (12, 66, 13, '12级钻石粉'), (13, 72, 14, '13级钻石粉'), (14, 78, 15, '14级钻石粉'), (15, 84, 16, '15级钻石粉'), (16, 90, 16, '16级钻石粉'), (17, 96, 17, '17级钻石粉'), (18, 102, 18, '18级钻石粉'), (19, 108, 19, '19级钻石粉'), (20, 114, 20, '20级终极粉'); ``` ### 3.4 升级奖励配置表 (level_up_reward_config) ```sql CREATE TABLE level_up_reward_config ( id BIGSERIAL PRIMARY KEY, level INT NOT NULL, -- 等级 2-20+ reward_type VARCHAR(50) NOT NULL, -- 奖励类型:crystal(水晶) / like_bet_count(点赞押注数) / exhibition_count(上架藏品数) / badge(勋章) / 其他 reward_value BIGINT NOT NULL DEFAULT 0, -- 奖励值 is_enabled BOOLEAN DEFAULT true, -- 功能开关 updated_at BIGINT NOT NULL, UNIQUE(level, reward_type) -- 同一等级同类型奖励唯一 ); -- 初始数据示例(可由运营后台动态调整) -- reward_type: crystal=水晶, like_bet_count=点赞押注数 INSERT INTO level_up_reward_config (level, reward_type, reward_value, is_enabled, updated_at) VALUES (2, 'crystal', 10, true, UNIX_MILLIS()), (2, 'like_bet_count', 1, true, UNIX_MILLIS()), (3, 'crystal', 20, true, UNIX_MILLIS()), (3, 'like_bet_count', 1, true, UNIX_MILLIS()), (4, 'crystal', 30, true, UNIX_MILLIS()), (4, 'like_bet_count', 1, true, UNIX_MILLIS()), (5, 'crystal', 50, true, UNIX_MILLIS()), (5, 'like_bet_count', 1, true, UNIX_MILLIS()), (6, 'crystal', 80, true, UNIX_MILLIS()), (7, 'crystal', 120, true, UNIX_MILLIS()), (8, 'crystal', 180, true, UNIX_MILLIS()), (9, 'crystal', 280, true, UNIX_MILLIS()), (10, 'crystal', 500, true, UNIX_MILLIS()), (11, 'crystal', 500, true, UNIX_MILLIS()), (12, 'crystal', 500, true, UNIX_MILLIS()), (13, 'crystal', 500, true, UNIX_MILLIS()), (14, 'crystal', 500, true, UNIX_MILLIS()), (15, 'crystal', 500, true, UNIX_MILLIS()), (16, 'crystal', 500, true, UNIX_MILLIS()), (17, 'crystal', 500, true, UNIX_MILLIS()), (18, 'crystal', 500, true, UNIX_MILLIS()), (19, 'crystal', 500, true, UNIX_MILLIS()), (20, 'crystal', 500, true, UNIX_MILLIS()); ``` ### 3.5 用户累计上架时长表 (user_exhibition_hours) ```sql CREATE TABLE user_exhibition_hours ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, total_exhibition_hours BIGINT NOT NULL DEFAULT 0, -- 累计上架时长(小时) updated_at BIGINT NOT NULL, UNIQUE(user_id, star_id) ); ``` ### 3.6 等级上限配置表 (level_cap_config) > **等级上限可动态配置,非硬编码** ```sql CREATE TABLE level_cap_config ( id BIGSERIAL PRIMARY KEY, max_level INT NOT NULL DEFAULT 20, -- 当前等级上限(可动态调整) updated_at BIGINT NOT NULL ); INSERT INTO level_cap_config (max_level, updated_at) VALUES (20, UNIX_MILLIS()); ``` ### 3.7 等级升级条件配置表 (level_upgrade_conditions) > **用于21级及以上的升级条件配置,需同时满足总上架时长和搭子等级要求** ```sql CREATE TABLE level_upgrade_conditions ( level INT PRIMARY KEY, -- 主等级(如21级) require_total_hours BIGINT NOT NULL, -- 需要的总上架时长 require_dazi_level INT DEFAULT 0, -- 需要的搭子等级(0=不需要) description VARCHAR(100), -- 如 "21级需搭子21级" updated_at BIGINT NOT NULL ); -- 初始数据(21级示例,后续可扩展更多等级) INSERT INTO level_upgrade_conditions (level, require_total_hours, require_dazi_level, description, updated_at) VALUES (21, 120, 21, '21级:总时长120h + 搭子21级', UNIX_MILLIS()); ``` ### 3.8 搭子等级阈值配置表(预留) > **搭子等级升级规则待定,此表结构预留** ```sql CREATE TABLE dazi_level_thresholds ( level INT PRIMARY KEY, -- 搭子等级 1-N upgrade_condition VARCHAR(100), -- 升级条件描述(如"任务积分"、"互动次数"等) condition_param INT DEFAULT 0, -- 条件参数(如需要完成任务数) description VARCHAR(100) ); ``` ### 3.9 用户搭子等级表(预留) > **搭子等级记录,待搭子系统开发后启用** ```sql CREATE TABLE user_dazi_level ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, dazi_level INT NOT NULL DEFAULT 1, updated_at BIGINT NOT NULL, UNIQUE(user_id, star_id) ); ``` --- ## 四、change_type 分类 ### 4.1 水晶 (crystal_transaction_records.change_type) | 值 | 含义 | delta 方向 | |---|---|---| | `task_reward` | 任务奖励 | + | | `mint_cost` | 铸造消耗 | - | | `mint_reward` | 铸造奖励(基础+阶梯) | + | | `exhibition_revenue` | 上架展示收益 | + | | `like_bet_revenue` | 点赞押注收益 | + | | `level_up_bonus` | 升级奖励(由调用方主动查询配置后发放) | + | | `manual_adjust` | 手动调整(运营) | +/- | ### 4.2 source_id 填写规则 `source_id` 填触发这笔流水的**源头业务ID**: | change_type | source_id 填什么 | |---|---| | `task_reward` | `task_definitions.id`(任务ID) | | `mint_cost` | `mint_orders.order_id`(铸造订单号) | | `mint_reward` | `mint_orders.order_id`(铸造订单号) | | `exhibition_revenue` | `exhibition_revenue_records.id`(上架展示收益) | | `like_bet_revenue` | `like_bet_records.id`(点赞押注收益,记录押注者和藏品信息) | | `level_up_bonus` | 触发升级的上架收益记录ID | | `manual_adjust` | 运营后台操作记录ID(预留) | --- ## 五、核心方法改造 > **并发控制策略(重要)** > > 经济系统对数据一致性要求极高,同一用户对同一偶像的操作可能并发发生(如多个任务同时完成、铸造和展示收益同时触发)。采用 ** pessimistic locking(悲观锁)** 策略: > > - **SELECT FOR UPDATE** — 事务内先锁住 `FanProfile` 行,其他事务必须等待锁释放才能操作 > - **锁粒度** — `user_id + star_id` 组合锁(最小化锁竞争) > - **事务超时** — 设置合理超时(建议 5s),避免死锁长时间阻塞 > - **禁止长时间锁** — 只在余额计算和写入期间持锁,不做额外 IO 操作 ### 5.1 UpdateCrystalBalance **现有签名:** ```go UpdateCrystalBalance(userID, starID int64, delta int64) (int64, error) ``` **新签名:** ```go UpdateCrystalBalance( userID int64, starID int64, delta int64, changeType string, sourceID string, description string, ) (int64, error) ``` **内部逻辑(事务内):** 1. `SELECT FOR UPDATE` 锁定 `FanProfile` 行(`user_id = ? AND star_id = ?`),**只读一次** 2. 查询当前余额 `balance_before` 3. 计算 `balance_after = balance_before + delta` 4. 写入 `crystal_transaction_records` 5. 更新 `FanProfile.CrystalBalance` 6. 返回新余额(事务提交后锁自动释放) **事务边界代码:** ```go func (s *userService) UpdateCrystalBalance( userID int64, starID int64, delta int64, changeType string, sourceID string, description string, ) (int64, error) { var newBalance int64 err := s.db.Transaction(func(tx *gorm.DB) error { // 1. SELECT FOR UPDATE 加行锁 var profile models.FanProfile if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ? AND star_id = ?", userID, starID). First(&profile).Error; err != nil { return err } // 2. 计算余额变化 balanceBefore := profile.CrystalBalance balanceAfter := balanceBefore + delta // 3. 写入水晶流水 record := &model.CrystalTransactionRecord{ UserID: userID, StarID: starID, ChangeType: changeType, Delta: delta, BalanceBefore: balanceBefore, BalanceAfter: balanceAfter, SourceID: sourceID, Description: description, CreatedAt: time.Now().UnixMilli(), } if err := tx.Create(record).Error; err != nil { return err } // 4. 更新 FanProfile if err := tx.Model(&models.FanProfile{}). Where("user_id = ? AND star_id = ?", userID, starID). Update("crystal_balance", balanceAfter).Error; err != nil { return err } newBalance = balanceAfter return nil }) return newBalance, err } ``` --- ### 5.2 AddExhibitionHours **功能:** 用户下架藏品时,累加该藏品的实际上架时长,并检查是否触发升级。 **签名:** ```go AddExhibitionHours( userID int64, starID int64, exhibitionHours int64, sourceID string, ) (newLevel int32, levelDelta int32, crystalReward int64, err error) ``` **返回值说明:** - `newLevel` — 新的等级 - `levelDelta` — 等级变化量(正数=升级,0=无变化) - `rewards` — 升级奖励列表(类型+值,可能有多种奖励) **内部逻辑(事务内):** 1. `SELECT FOR UPDATE` 锁定 `FanProfile` 行(`user_id = ? AND star_id = ?`) 2. 查询/创建 `user_exhibition_hours` 记录,累加上架时长 3. 从 `level_thresholds`(含缓存)计算新等级 4. 如有升级(newLevel > profile.Level): - 更新 `FanProfile.Level` - 查询 `level_up_reward_config` 获取该等级所有奖励 - 遍历奖励类型,逐个发放: - `crystal` → 写入水晶流水 + 更新 `FanProfile.CrystalBalance` - `like_bet_count` → 更新用户点赞押注次数 - 其他类型待处理 5. **返回 (newLevel, levelDelta, rewards, nil)** > **注意:** 升级奖励由配置表 `level_up_reward_config` 动态决定,支持多种奖励类型组合。 --- ### 5.3 等级计算 (CalculateLevel) ```go // CalculateLevel 根据累计上架时长计算当前等级(等级上限从 level_cap_config 动态获取) func CalculateLevel(totalHours int64, thresholds map[int32]int64, maxLevel int32) int32 { for level := maxLevel; level >= 1; level-- { if totalHours >= thresholds[level] { return level } } return 1 } ``` - 阈值从 `level_thresholds` 表加载,带缓存(TTL 5分钟) - 等级上限 `maxLevel` 从 `level_cap_config` 表动态获取,非硬编码 - 21级+的升级条件在 `level_upgrade_conditions` 表配置,需同时满足总上架时长和搭子等级 --- ### 5.4 获取等级配置 (GetLevelConfig) **功能:** 获取指定等级的完整配置(可上架藏品数、点赞押注数)。 ```go GetLevelConfig(level int32) (*LevelConfig, error) ``` --- ## 六、调用方改造 ### 6.1 taskService — 每日任务奖励 **daily_task_service.go** — `ClaimDailyTask` / `ClaimAllDailyTasks`: ```go // 发放水晶 newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, def.CrystalReward, "task_reward", strconv.FormatInt(def.ID, 10), fmt.Sprintf("每日任务奖励: %s", def.Name)) ``` ### 6.2 taskService — 引导阶段奖励 **onboarding_service.go** — `ClaimStageReward`: ```go // 发放水晶 newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, stage.CrystalReward, "onboarding_reward", sourceID, fmt.Sprintf("引导阶段%d奖励", stage)) ``` ### 6.3 taskService — 展示收益 **revenue_service.go** — `ClaimRevenue` / `ClaimAllRevenue`: 展示收益包含两部分: 1. **上架收益 R1** = R0 × T × [100% + Buff(n)],其中 n 为作品获得的点赞数 2. **点赞押注收益 R2** = [1 + (N - i)] × R3,由点赞者获得 ```go // 上架收益 = 单位小时收益 × 时长 × (100% + Buff加成) // Buff(n): n<5=0%, 5≤n<10=10%, 10≤n<30=20%, n≥30=30% crystalAmount := r0 * t * (100 + getBuffPercent(likeCount)) / 100 newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, crystalAmount, "exhibition_revenue", strconv.FormatInt(record.ID, 10), fmt.Sprintf("展示收益 #%d(点赞数:%d)", record.ID, likeCount)) // 累加上架时长并检查升级(藏品下架时调用) newLevel, levelDelta, crystalReward, err := s.userClient.AddExhibitionHours(ctx, userID, starID, t, strconv.FormatInt(record.ID, 10)) // 发放升级水晶奖励(如有升级且配置了奖励) if levelDelta > 0 && crystalReward > 0 { s.userClient.UpdateCrystalBalance(ctx, userID, starID, crystalReward, "level_up_bonus", strconv.FormatInt(record.ID, 10), fmt.Sprintf("升级到%d级奖励", newLevel)) } ``` #### 获取Buff百分比的伪代码 ```go func getBuffPercent(likeCount int) int { switch { case likeCount >= 30: return 30 case likeCount >= 10: return 20 case likeCount >= 5: return 10 default: return 0 } } ``` ### 6.4 assetService — 铸造扣水晶 **mint_service.go** — `CreateMintOrder`: ```go newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, -mintFee, "mint_cost", orderID, fmt.Sprintf("铸造藏品 #%s", orderID)) ``` --- ## 七、新增 Repository ### 7.1 Ledger Repository(流水读写) > **当前阶段只做写入,List 查询暂不做(后续按需添加)** ```go // CrystalLedgerRepository WriteCrystalRecord(tx *gorm.DB, record *model.CrystalTransactionRecord) error // CoinLedgerRepository(预留,delta 写 0) WriteCoinRecord(tx *gorm.DB, record *model.CoinTransactionRecord) error ``` > **List 查询方法暂不做,等运营后台有需求时再加。** ### 7.2 LevelThresholdRepository ```go // GetAllThresholds 获取所有等级阈值(带内存缓存,TTL 5分钟) // 缓存加载失败时 panic(与 logger 初始化失败同等待遇) GetAllThresholds() (map[int32]*model.LevelThreshold, error) // GetThresholdByLevel 获取指定等级阈值 GetThresholdByLevel(level int32) (*model.LevelThreshold, error) ``` ### 7.3 LevelUpRewardConfigRepository ```go // GetRewardByLevel 获取指定等级的升级奖励配置 GetRewardByLevel(level int32) (*model.LevelUpRewardConfig, error) // UpdateReward 更新指定等级的升级奖励配置 UpdateReward(level int32, reward int64, isEnabled bool) error ``` ### 7.4 UserExhibitionHoursRepository ```go // GetOrCreate 获取用户累计上架时长记录(不存在则创建) GetOrCreate(tx *gorm.DB, userID, starID int64) (*model.UserExhibitionHours, error) // AddHours 累加时长并返回更新后的值 AddHours(tx *gorm.DB, userID, starID int64, hours int64) (int64, error) ``` --- ## 八、LevelThreshold 缓存设计 ```go 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 — UpdateCrystalBalanceResponse 现有已返回 `new_balance`,无需改动。 ### 9.2 user.proto — AddExhibitionHoursResponse ```protobuf message AddExhibitionHoursResponse { topfans.common.BaseResponse base = 1; int32 new_level = 2; // 新的等级 int32 level_delta = 3; // 等级变化量(正数=升级,0=无变化) int64 crystal_reward = 4; // 升级奖励水晶数(无升级时为0) } ``` ### 9.3 user.proto — GetLevelConfigRequest / GetLevelConfigResponse ```protobuf message GetLevelConfigRequest { int32 level = 1; } message GetLevelConfigResponse { topfans.common.BaseResponse base = 1; LevelConfig config = 2; } message LevelConfig { int32 level = 1; int32 max_exhibition_count = 2; // 可上架藏品数 int32 like_bet_count = 3; // 点赞押注次数 } ``` --- ## 十、事务边界设计 ### 10.1 AddExhibitionHours 事务边界 ```go func (s *userService) AddExhibitionHours( userID int64, starID int64, hours int64, sourceID string, ) (newLevel int32, levelDelta int32, crystalReward int64, err error) { err = s.db.Transaction(func(tx *gorm.DB) error { // 1. SELECT FOR UPDATE 加行锁 var profile models.FanProfile if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ? AND star_id = ?", userID, starID). First(&profile).Error; err != nil { return err } // 2. 查询/创建 user_exhibition_hours 记录 var exhibitionHours model.UserExhibitionHours err = tx.Where("user_id = ? AND star_id = ?", userID, starID). First(&exhibitionHours).Error if errors.Is(err, gorm.ErrRecordNotFound) { exhibitionHours = model.UserExhibitionHours{ UserID: userID, StarID: starID, TotalExhibitionHours: 0, UpdatedAt: time.Now().UnixMilli(), } } else if err != nil { return err } // 3. 累加上架时长 exhibitionHours.TotalExhibitionHours += hours exhibitionHours.UpdatedAt = time.Now().UnixMilli() // 4. 读取等级阈值(从缓存) thresholds, err := s.levelThresholdCache.GetAll() if err != nil { return err } // 5. 计算新等级 newLevel = CalculateLevel(exhibitionHours.TotalExhibitionHours, thresholds) levelDelta = newLevel - profile.Level // 6. 如有升级,处理奖励 if levelDelta > 0 { // 查询升级奖励配置 rewardConfig, err := s.levelUpRewardConfigRepo.GetRewardByLevel(newLevel) if err == nil && rewardConfig.IsEnabled && rewardConfig.CrystalReward > 0 { crystalReward = rewardConfig.CrystalReward // 写入水晶流水 crystalRecord := &model.CrystalTransactionRecord{ UserID: userID, StarID: starID, ChangeType: "level_up_bonus", Delta: crystalReward, BalanceBefore: profile.CrystalBalance, BalanceAfter: profile.CrystalBalance + crystalReward, SourceID: sourceID, Description: fmt.Sprintf("升级到%d级奖励", newLevel), CreatedAt: time.Now().UnixMilli(), } if err := tx.Create(crystalRecord).Error; err != nil { return err } // 更新 FanProfile 等级和水晶余额 profile.CrystalBalance += crystalReward } // 更新 FanProfile 等级 profile.Level = newLevel } // 7. 保存/更新 user_exhibition_hours if err := tx.Save(&exhibitionHours).Error; err != nil { return err } // 8. 更新 FanProfile if err := tx.Model(&models.FanProfile{}). Where("user_id = ? AND star_id = ?", userID, starID). Updates(map[string]interface{}{ "level": profile.Level, "crystal_balance": profile.CrystalBalance, }).Error; err != nil { return err } return nil }) return newLevel, levelDelta, crystalReward, err } ``` --- ## 十一、项目文件结构 ``` backend/ ├── pkg/models/ │ ├── user.go # 修改:新增 CrystalTransactionRecord / CoinTransactionRecord 模型 │ └── level.go # 新增:LevelThreshold / LevelUpRewardConfig / UserExhibitionHours 模型 ├── scripts/ │ └── 20260415_economic_tables.sql # 新增:所有新建表的 DDL + level_thresholds 初始数据 ├── services/userService/ │ ├── repository/ │ │ ├── fan_profile_repository.go # 修改:UpdateCrystalBalance 签名 │ │ ├── crystal_tx_repository.go # 新增:水晶流水写入 │ │ ├── coin_tx_repository.go # 新增:游戏币流水写入(预留) │ │ └── level_threshold_repository.go # 新增:等级阈值查询(含缓存) │ │ │ ├── service/ │ │ └── user_service.go # 修改:AddExhibitionHours 方法 │ │ │ └── client/ │ └── user_rpc_client.go # 修改:UpdateCrystalBalance / AddExhibitionHours 签名 ├── services/taskService/ │ ├── service/ │ │ ├── daily_task_service.go # 修改:调用新签名 │ │ ├── onboarding_service.go # 修改:调用新签名 │ │ └── revenue_service.go # 修改:调用新签名 + AddExhibitionHours │ │ │ └── client/ │ └── user_rpc_client.go # 修改:UpdateCrystalBalance / AddExhibitionHours 返回值 ├── services/assetService/ │ └── service/ │ └── mint_service.go # 修改:调用新签名 ├── proto/ │ └── user.proto # 修改:AddExhibitionHoursResponse / GetLevelConfigResponse └── pkg/proto/ └── user/ ├── user.pb.go # 重新生成 └── user.triple.go # 重新生成 ``` --- ## 十二、SQL 建表脚本 脚本路径:`backend/scripts/20260415_economic_tables.sql` ```sql -- ============================================================ -- 经济系统建表脚本 -- 执行方式: psql -h -U -d -f backend/scripts/20260415_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 level_thresholds ( level INT PRIMARY KEY, max_exhibition_hours BIGINT NOT NULL, -- 升级到该等级需要的累计上架时长(小时) like_bet_count INT NOT NULL, -- 升级后解锁的点赞押注次数 description VARCHAR(100) ); -- 插入初始数据(20级满级) INSERT INTO level_thresholds (level, max_exhibition_hours, like_bet_count, description) VALUES (1, 0, 0, '1级新手'), (2, 6, 6, '2级粉丝'), (3, 12, 7, '3级真爱'), (4, 18, 8, '4级铁粉'), (5, 24, 9, '5级钻石粉'), (6, 30, 9, '6级钻石粉'), (7, 36, 10, '7级钻石粉'), (8, 42, 11, '8级钻石粉'), (9, 48, 12, '9级钻石粉'), (10, 54, 13, '10级钻石粉'), (11, 60, 13, '11级钻石粉'), (12, 66, 13, '12级钻石粉'), (13, 72, 14, '13级钻石粉'), (14, 78, 15, '14级钻石粉'), (15, 84, 16, '15级钻石粉'), (16, 90, 16, '16级钻石粉'), (17, 96, 17, '17级钻石粉'), (18, 102, 18, '18级钻石粉'), (19, 108, 19, '19级钻石粉'), (20, 114, 20, '20级终极粉') ON CONFLICT (level) DO NOTHING; -- 升级奖励配置表 CREATE TABLE IF NOT EXISTS level_up_reward_config ( id BIGSERIAL PRIMARY KEY, level INT NOT NULL UNIQUE, level INT NOT NULL, reward_type VARCHAR(50) NOT NULL, reward_value BIGINT NOT NULL DEFAULT 0, is_enabled BOOLEAN DEFAULT true, updated_at BIGINT NOT NULL, UNIQUE(level, reward_type) ); -- 插入初始数据示例(可由运营后台动态调整) INSERT INTO level_up_reward_config (level, reward_type, reward_value, is_enabled, updated_at) VALUES (2, 'crystal', 10, true, UNIX_MILLIS()), (2, 'like_bet_count', 1, true, UNIX_MILLIS()), (3, 'crystal', 20, true, UNIX_MILLIS()), (3, 'like_bet_count', 1, true, UNIX_MILLIS()), (4, 'crystal', 30, true, UNIX_MILLIS()), (4, 'like_bet_count', 1, true, UNIX_MILLIS()), (5, 'crystal', 50, true, UNIX_MILLIS()), (5, 'like_bet_count', 1, true, UNIX_MILLIS()), (6, 'crystal', 80, true, UNIX_MILLIS()), (7, 'crystal', 120, true, UNIX_MILLIS()), (8, 'crystal', 180, true, UNIX_MILLIS()), (9, 'crystal', 280, true, UNIX_MILLIS()), (10, 'crystal', 500, true, UNIX_MILLIS()), (11, 'crystal', 500, true, UNIX_MILLIS()), (12, 'crystal', 500, true, UNIX_MILLIS()), (13, 'crystal', 500, true, UNIX_MILLIS()), (14, 'crystal', 500, true, UNIX_MILLIS()), (15, 'crystal', 500, true, UNIX_MILLIS()), (16, 'crystal', 500, true, UNIX_MILLIS()), (17, 'crystal', 500, true, UNIX_MILLIS()), (18, 'crystal', 500, true, UNIX_MILLIS()), (19, 'crystal', 500, true, UNIX_MILLIS()), (20, 'crystal', 500, true, UNIX_MILLIS()) ON CONFLICT (level, reward_type) DO NOTHING; -- 用户累计上架时长表 CREATE TABLE IF NOT EXISTS user_exhibition_hours ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, total_exhibition_hours BIGINT NOT NULL DEFAULT 0, updated_at BIGINT NOT NULL, UNIQUE(user_id, star_id) ); -- 等级上限配置表 CREATE TABLE IF NOT EXISTS level_cap_config ( id BIGSERIAL PRIMARY KEY, max_level INT NOT NULL DEFAULT 20, updated_at BIGINT NOT NULL ); INSERT INTO level_cap_config (max_level, updated_at) VALUES (20, UNIX_MILLIS()); -- 等级升级条件配置表(21级+) CREATE TABLE IF NOT EXISTS level_upgrade_conditions ( level INT PRIMARY KEY, require_total_hours BIGINT NOT NULL, require_dazi_level INT DEFAULT 0, description VARCHAR(100), updated_at BIGINT NOT NULL ); -- 初始数据(21级示例) INSERT INTO level_upgrade_conditions (level, require_total_hours, require_dazi_level, description, updated_at) VALUES (21, 120, 21, '21级:总时长120h + 搭子21级', UNIX_MILLIS()); -- 搭子等级阈值配置表(预留) CREATE TABLE IF NOT EXISTS dazi_level_thresholds ( level INT PRIMARY KEY, upgrade_condition VARCHAR(100), condition_param INT DEFAULT 0, description VARCHAR(100) ); -- 用户搭子等级表(预留) CREATE TABLE IF NOT EXISTS user_dazi_level ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, dazi_level INT NOT NULL DEFAULT 1, updated_at BIGINT NOT NULL, UNIQUE(user_id, star_id) ); ``` --- ## 十三、铸造奖励系统 ### 13.1 需求概述 在现有经济系统**收入侧**新增"铸造奖励"模块: - 用户铸造藏品成功后,获得固定水晶返还(后台可配置) - 累计铸造次数达到阶梯时,额外奖励固定水晶 - 按偶像(star_id)独立累计,管理员可手动重置/开关 ### 13.2 数据库设计 #### 13.2.1 铸造奖励配置表(mint_reward_config) ```sql CREATE TABLE mint_reward_config ( id BIGSERIAL PRIMARY KEY, star_id BIGINT NOT NULL, -- 偶像ID,0=全服默认配置 base_reward BIGINT NOT NULL DEFAULT 0, -- 每次铸造基础返还水晶数 is_enabled BOOLEAN DEFAULT true, -- 功能开关 updated_at BIGINT NOT NULL, UNIQUE(star_id) ); -- 初始数据(0=全服默认) INSERT INTO mint_reward_config (star_id, base_reward, is_enabled, updated_at) VALUES (0, 0, false, UNIX_MILLIS()); ``` #### 13.2.2 铸造阶梯奖励配置表(mint_milestone_config) ```sql CREATE TABLE mint_milestone_config ( id BIGSERIAL PRIMARY KEY, star_id BIGINT NOT NULL, -- 偶像ID,0=全服默认配置 milestone_count INT NOT NULL, -- 累计次数阈值(10/30/100...) bonus_reward BIGINT NOT NULL, -- 达到该阶梯时额外奖励水晶 created_at BIGINT NOT NULL, UNIQUE(star_id, milestone_count) ); -- 初始数据示例 INSERT INTO mint_milestone_config (star_id, milestone_count, bonus_reward, created_at) VALUES (0, 10, 5, UNIX_MILLIS()), (0, 30, 15, UNIX_MILLIS()), (0, 100, 50, UNIX_MILLIS()); ``` #### 13.2.3 用户铸造累计表(user_mint_count) ```sql CREATE TABLE user_mint_count ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, mint_count INT NOT NULL DEFAULT 0, -- 累计铸造次数 last_milestone INT NOT NULL DEFAULT 0, -- 上次已领取的阶梯(避免重复领取) updated_at BIGINT NOT NULL, UNIQUE(user_id, star_id) ); ``` ### 13.3 核心逻辑 #### 13.3.1 铸造奖励发放时机 在 `assetService.CreateMintOrder` 铸造成功后调用,采用异步方式不阻塞铸造主流程: ```go // 铸造成功后发放奖励 func (s *MintService) CreateMintOrder(...) (orderID string, err error) { // ... 现有铸造逻辑 ... // 铸造成功,异步发放奖励 go func() { if err := s.mintRewardService.GrantMintReward(ctx, userID, starID, orderID); err != nil { logger.Logger.Error("Failed to grant mint reward", zap.Error(err)) } }() return orderID, nil } ``` #### 13.3.2 奖励计算示例 配置: - `base_reward = 10`(每次铸造返 10 水晶) - 阶梯:10次+5,30次+15,100次+50 用户铸造路径: | 铸造次数 | 基础奖励 | 阶梯奖励 | 本次总奖励 | |---|---|---|---| | 第1次 | +10 | 0 | +10 | | 第10次 | +10 | +5(达到10阶) | +15 | | 第30次 | +10 | +15(达到30阶) | +25 | | 第31次 | +10 | 0 | +10 | ### 13.4 管理员操作 #### 13.4.1 功能开关 ```go // 关闭/开启指定偶像的铸造奖励 SetMintRewardConfig(starID int64, isEnabled bool, baseReward int64) error ``` #### 13.4.2 阶梯配置 ```go // 添加/修改阶梯 SetMilestone(starID int64, milestone int, bonus int64) error // 删除阶梯 RemoveMilestone(starID int64, milestone int) error ``` #### 13.4.3 重置用户累计 ```go // 重置指定用户的累计铸造次数 ResetMintCount(userID, starID int64) error // 重置指定偶像下所有用户的累计铸造次数 ResetAllMintCount(starID int64) error ``` ### 13.5 Proto 接口(管理后台调用) `asset.proto` 新增以下接口: ```protobuf // 设置铸造奖励配置(开关 + 基础奖励) message SetMintRewardConfigRequest { int64 star_id = 1; // 偶像ID,0=全服默认 bool is_enabled = 2; // 是否开启 int64 base_reward = 3; // 每次铸造基础返还水晶数 } message SetMintRewardConfigResponse { topfans.common.BaseResponse base = 1; } // 添加/修改阶梯奖励 message SetMilestoneRequest { int64 star_id = 1; // 偶像ID,0=全服默认 int32 milestone_count = 2; // 累计次数阈值 int64 bonus_reward = 3; // 达到阶梯时额外奖励水晶 } message SetMilestoneResponse { topfans.common.BaseResponse base = 1; } // 删除阶梯奖励 message RemoveMilestoneRequest { int64 star_id = 1; int32 milestone_count = 2; } message RemoveMilestoneResponse { topfans.common.BaseResponse base = 1; } // 重置单个用户铸造累计 message ResetMintCountRequest { int64 user_id = 1; int64 star_id = 2; } message ResetMintCountResponse { topfans.common.BaseResponse base = 1; } // 重置偶像下所有用户铸造累计 message ResetAllMintCountRequest { int64 star_id = 1; } message ResetAllMintCountResponse { topfans.common.BaseResponse base = 1; } // 查询铸造奖励配置(后台管理用) message GetMintRewardConfigRequest { int64 star_id = 1; } message GetMintRewardConfigResponse { topfans.common.BaseResponse base = 1; MintRewardConfig config = 2; } message MintRewardConfig { int64 star_id = 1; bool is_enabled = 2; int64 base_reward = 3; repeated Milestone milestones = 4; } message Milestone { int32 milestone_count = 1; int64 bonus_reward = 2; } ``` ### 13.6 事务边界 铸造奖励在 `GrantMintReward` 内独立事务,不与铸造订单共用事务。铸造订单本身成功与否不依赖奖励发放——奖励失败只记录 error log,不回滚铸造。 `user_mint_count` 的累加同样需要 `SELECT FOR UPDATE` 锁,保证同一用户对同一偶像的铸造奖励和累计不出现并发问题。 ```go func (s *MintRewardService) GrantMintReward(ctx context.Context, userID, starID int64, orderID string) error { return s.db.Transaction(func(tx *gorm.DB) error { // 1. 查询铸造奖励配置:优先查 star_id,未配置则查全服默认(star_id=0) var config model.MintRewardConfig err := tx.Where("star_id = ?", starID).First(&config).Error if errors.Is(err, gorm.ErrRecordNotFound) { // star_id 未配置,使用全服默认 if err := tx.Where("star_id = ?", int64(0)).First(&config).Error; err != nil { return err // 全服默认也没有,直接返回 } } else if err != nil { return err } // 2. 检查开关 if !config.IsEnabled || config.BaseReward <= 0 { return nil } // 3. SELECT FOR UPDATE 锁定 FanProfile(一次性获取最新余额,后续所有奖励基于此快照累加) var profile models.FanProfile if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ? AND star_id = ?", userID, starID). First(&profile).Error; err != nil { return err } currentBalance := profile.CrystalBalance // 4. 计算本次总奖励:基础奖励 + 所有新达成的阶梯奖励(一次性统一下账) totalReward := config.BaseReward // 5. 查询当前铸造累计 + SELECT FOR UPDATE 锁 var mintCount model.UserMintCount isNew := false err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ? AND star_id = ?", userID, starID). First(&mintCount).Error if errors.Is(err, gorm.ErrRecordNotFound) { isNew = true mintCount = model.UserMintCount{ UserID: userID, StarID: starID, MintCount: 0, LastMilestone: 0, UpdatedAt: time.Now().UnixMilli(), } } else if err != nil { return err } // 累加本次铸造 mintCount.MintCount++ mintCount.UpdatedAt = time.Now().UnixMilli() // 6. 查询所有未领取的阶梯:star_id 配置优先,全服默认补充(同一阶梯只取一次) // 先按 milestone_count 分组合并,star_id>0 的覆盖 star_id=0 的 var milestones []model.MintMilestoneConfig tx.Raw(` SELECT COALESCE(MAX(star_id), 0) as star_id, milestone_count, MAX(bonus_reward) as bonus_reward FROM mint_milestone_config WHERE star_id IN (0, ?) AND milestone_count <= ? AND milestone_count > ? GROUP BY milestone_count HAVING MAX(star_id) = ? ORDER BY milestone_count ASC `, starID, mintCount.MintCount, mintCount.LastMilestone, starID). Scan(&milestones) // 7. 累加阶梯奖励,并更新 last_milestone for _, m := range milestones { totalReward += m.BonusReward mintCount.LastMilestone = m.MilestoneCount } // 8. 如果有奖励,统一下账(一条流水 + 一次余额更新) if totalReward > 0 { newBalance := currentBalance + totalReward crystalRecord := &model.CrystalTransactionRecord{ UserID: userID, StarID: starID, ChangeType: "mint_reward", Delta: totalReward, BalanceBefore: currentBalance, BalanceAfter: newBalance, SourceID: orderID, Description: "铸造奖励", CreatedAt: time.Now().UnixMilli(), } if err := tx.Create(crystalRecord).Error; err != nil { return err } if err := tx.Model(&models.FanProfile{}). Where("user_id = ? AND star_id = ?", userID, starID). Update("crystal_balance", newBalance).Error; err != nil { return err } } // 9. 保存铸造累计记录 if isNew { if err := tx.Create(&mintCount).Error; err != nil { return err } } else { if err := tx.Save(&mintCount).Error; err != nil { return err } } return nil }) } ``` > **设计要点:** > - 步骤3一次性对 `FanProfile` 加锁并获取 `currentBalance` 快照,所有奖励基于同一余额快照累加,避免重复加锁 > - 步骤8统一下账:基础奖励 + 阶梯奖励合并为一条 `crystal_transaction_records`(`delta = totalReward`),减少流水记录数量,同时保证原子性 > - 阶梯奖励的 `bonus_reward` 分开记录在 `mint_milestone_config`,流水记录只记最终 `totalReward`,避免一条铸造产生多条奖励流水 ### 13.7 项目文件结构 ``` backend/ ├── pkg/models/ │ ├── mint_reward.go # 新增:MintRewardConfig / MintMilestoneConfig / UserMintCount 模型 │ ├── scripts/ │ └── 20260415_mint_reward_tables.sql # 新增:mint_reward_config / mint_milestone_config / user_mint_count DDL │ ├── services/assetService/ │ ├── repository/ │ │ ├── mint_reward_config_repository.go # 新增 │ │ ├── mint_milestone_config_repository.go # 新增 │ │ └── user_mint_count_repository.go # 新增 │ │ │ ├── client/ │ │ └── user_rpc_client.go # 修改:新增 UpdateCrystalBalance RPC 调用 │ │ │ └── service/ │ ├── mint_service.go # 修改:铸造成功后调用 GrantMintReward │ └── mint_reward_service.go # 新增:铸造奖励核心逻辑(含 GrantMintReward + 管理员方法) │ ├── services/userService/ │ └── client/ │ └── user_rpc_client.go # 修改:AddExperience / UpdateCrystalBalance 签名 │ ├── proto/ │ ├── user.proto # 修改:AddExperienceResponse 增加 new_level, level_delta │ ├── task.proto # 修改:ClaimDailyTaskResponse / ClaimAllDailyTasksResponse │ │ 增加 new_level, level_delta │ └── asset.proto # 新增:铸造奖励管理接口 + MintRewardConfig 数据结构 │ └── pkg/proto/ ├── user/ │ ├── user.pb.go # 重新生成 │ └── user.triple.go # 重新生成 ├── task/ │ ├── task.pb.go # 重新生成 │ └── task.triple.go # 重新生成 └── asset/ ├── asset.pb.go # 新增 └── asset.triple.go # 新增 ``` ## 十四、展示收益系统(初始阶段) ### 14.1 上架收益 用户「铸爱」后的作品,一旦在主广场进行展出,每小时都可以获得展出收益。一次上架时间为6小时。上架期间,当作品被点赞的次数达到一定数值,会提供收益加成。 时间结束后,作品自动下架,需要手动进行再次上架。上架期间,用户不可手动下架作品。 #### 14.1.1 核心参数 | 参数 | 含义 | 默认值 | |---|---|---| | R0 | 单位小时收益 | 5 水晶/小时 | | T | 上架时长 | 6 小时 | | Buff(n) | 点赞加成Buff,随当时点赞总数(n)而变化 | 见下方阶梯 | #### 14.1.2 单次上架结算公式 ``` R1 = R0 × T × [100% + Buff(n)] ``` #### 14.1.3 Buff 阶梯定义 | 点赞数 n | Buff(n) | |---|---| | n < 5 | 0% | | 5 ≤ n < 10 | 10% | | 10 ≤ n < 30 | 20% | | n ≥ 30 | 30%(封顶) | #### 14.1.4 示例计算 | 点赞数 | 基础收益 (R0×T) | Buff加成 | 最终收益 R1 | |---|---|---|---| | 0 | 30 | 0% | 30 | | 3 | 30 | 0% | 30 | | 5 | 30 | 10% | 33 | | 10 | 30 | 20% | 36 | | 30 | 30 | 30% | 39 | --- ### 14.2 点赞押注收益 一个藏品的奖金,按照每个玩家到场后,该藏品新增的点赞数来分配。 你押注之后,别人用脚投票继续赞它,说明你"看准了",你理应分得多;如果之后无人问津,说明你看走眼,收益自然低。 #### 14.2.1 核心参数 | 参数 | 含义 | 默认值 | |---|---|---| | N | 藏品下架时的总点赞数 | - | | i | 你是第几位押注者(1=第一个点赞) | - | | N-i | 你押注之后,该藏品又获得的新点赞数 | - | | R3 | 新增一个赞,提供多少奖励 | 新增1点赞 / 2 水晶 | #### 14.2.2 个人结算公式 ``` R2 = [1 + (N - i)] × R3 ``` **R2 最高为 100** #### 14.2.3 示例计算 | N (总点赞) | i (押注顺序) | N-i (新增点赞) | R2 (个人收益) | |---|---|---|---| | 10 | 5 | 5 | 18 | | 20 | 1 | 19 | 60 | | 50 | 10 | 40 | 123 → 100 (封顶) | | 100 | 50 | 50 | 153 → 100 (封顶) | --- ### 14.3 全局测算参数 #### 14.3.1 核心参数定义 | 参数 | 含义 | 默认值 | |---|---|---| | U | 日活用户 | 10000 | | L1 | 单个用户点赞押注额度 | 20 次/日 | | W1 | 用户可以同时上架的作品数 | 2 个/次 | | P1 | 上架参与度 | 0.6 | | P2 | 点赞参与度 | 0.7 | #### 14.3.2 重要假设 - 作品优劣占比:优秀 20%、一般 60%、较差 20% - 用户点赞倾向:优秀 80%、一般 20%、较差 0% #### 14.3.3 全局统计公式 **单日上架作品数:** ``` W = U × W1 × (24/T) × P1 ``` **单日总点赞押注数:** ``` L = U × L1 × P2 ``` **各类型作品点赞总数:** ``` 优秀:Lg = U × L1 × P2 × 80% 一般:Ln = U × L1 × P2 × 20% 较差:Lb = 0 ``` **各类型作品平均点赞数:** ``` 优秀:Lg_avg = Lg / (U × P1 × 20%) 一般:Ln_avg = Ln / (U × P1 × 60%) 较差:Lb_avg = 0 ``` **各类型作品产生的平均点赞收益:** ``` 优秀:R2g = Lg_avg × (Lg_avg + 1) / 2 一般:R2n = Ln_avg × (Ln_avg + 1) / 2 较差:R2b = 0 ``` (注:点赞收益按等差数列计算,因为粗算,即使优秀作品的平均点赞数离100较远,仍采用此公式) **各类型作品点赞总收益:** ``` 优秀:R2G = R2g × U × P1 × 20% 一般:R2N = R2n × U × P1 × 60% 较差:R2B = 0 ``` --- ### 14.4 全局 Buff 期望值估算 #### 14.4.1 泊松分布参数 基于每日点赞总额按作品类型分配,计算各类作品的期望获赞数(λ): ``` 优秀作品 λ_a ≈ Lg_avg 一般作品 λ_b ≈ Ln_avg 较差作品 λ_c = 0 ``` #### 14.4.2 Buff 期望值公式 对单类作品,Buff 期望值采用差分公式: ``` E[Buff|λ] = 10% × P(n≥5) + 10% × P(n≥10) + 10% × P(n≥30) ``` 其中 P(n≥k) 服从泊松分布累积概率: ``` P(n≥k) = 1 - POISSON.DIST(k-1, λ, TRUE) ``` #### 14.4.3 全局加权平均 Buff ``` E[Buff] = 20% × E[Buff|λ_a] + 60% × E[Buff|λ_b] + 20% × E[Buff|λ_c] ``` #### 14.4.4 全局 Buff 估算结果 | 作品类型 | 占比 | 期望Buff | |---|---|---| | 优秀作品 | 20% | ≈ 22.84% | | 一般作品 | 60% | ≈ 0.75% | | 较差作品 | 20% | 0% | | **全局加权** | 100% | **≈ 5.02%** | --- ### 14.5 上架收益全局估算 上架作品产生的总收益公式: ``` R1U = R0 × T × [100% + Buff(N)] × (24/T) × U × W1 × P1 ``` 其中 Buff(N) 为全局加权平均 Buff ≈ 5.05% --- ## 十五、后续扩展预留 1. **coin_transaction_records** — 等游戏币有实际用途时启用(当前 delta 写 0) 2. **流水分页查询 API** — `GET /api/economy/crystal-history` 等(当前只记录不查询) 3. **铸造奖励上限** — 单日/单周铸造奖励上限防刷(等风控需求明确后实现) 4. **阶梯百分比奖励** — 当前为固定额外水晶,后续可扩展为 base_reward 的百分比 5. **重置策略自动化** — 支持按周期(每日/每周)自动重置(当前为手动重置)