1652 lines
55 KiB
Markdown
1652 lines
55 KiB
Markdown
# 经济系统设计文档
|
||
|
||
> **创建日期:** 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 <host> -U <user> -d <db> -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. **重置策略自动化** — 支持按周期(每日/每周)自动重置(当前为手动重置)
|