1449 lines
49 KiB
Markdown
1449 lines
49 KiB
Markdown
# 经济系统设计文档
|
||
|
||
> **创建日期:** 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)
|
||
|
||
```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 经验变化记录表 (exp_transaction_records)
|
||
|
||
```sql
|
||
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 NOT NULL, -- 正数=升级,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)
|
||
|
||
```sql
|
||
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,跳级时可能大于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), -- 与触发升级的经验流水 source_id 相同(即触发升级的那笔 exp_transaction_records.source_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)
|
||
|
||
```sql
|
||
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` | 铸造消耗 | - |
|
||
| `mint_reward` | 铸造奖励(基础+阶梯) | + |
|
||
| `exhibition_revenue` | 上架展示收益 | + |
|
||
| `like_bet_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`(铸造订单号) |
|
||
| `mint_reward` | `mint_orders.order_id`(铸造订单号) |
|
||
| `exhibition_revenue` | `exhibition_revenue_records.id`(上架展示收益) |
|
||
| `like_bet_revenue` | `like_bet_records.id`(点赞押注收益,记录押注者和藏品信息) |
|
||
| `level_up_bonus` | **与触发升级的经验增加 source_id 相同**(即触发升级的那笔 exp_transaction_records.source_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 AddExperience
|
||
|
||
**现有签名:**
|
||
```go
|
||
AddExperience(userID, starID int64, delta int64) (int64, error)
|
||
```
|
||
|
||
**新签名:**
|
||
```go
|
||
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. `SELECT FOR UPDATE` 锁定 `FanProfile` 行(`user_id = ? AND star_id = ?`),**只读一次**
|
||
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)
|
||
|
||
```go
|
||
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.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))
|
||
|
||
// 发放经验
|
||
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.go** — `ClaimStageReward`:
|
||
|
||
```go
|
||
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.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))
|
||
```
|
||
|
||
#### 获取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
|
||
|
||
// ExpLedgerRepository
|
||
WriteExpRecord(tx *gorm.DB, record *model.ExpTransactionRecord) error
|
||
|
||
// LevelChangeRepository
|
||
WriteLevelChange(tx *gorm.DB, record *model.LevelChangeRecord) error
|
||
```
|
||
|
||
> **List 查询方法暂不做,等运营后台有需求时再加。**
|
||
|
||
### 7.2 LevelThresholdRepository
|
||
|
||
```go
|
||
// GetAllThresholds 获取所有等级阈值(带内存缓存,TTL 5分钟)
|
||
// 缓存加载失败时 panic(与 logger 初始化失败同等待遇)
|
||
GetAllThresholds() (map[int32]*model.LevelThreshold, error)
|
||
|
||
// GetThresholdByLevel 获取指定等级阈值
|
||
GetThresholdByLevel(level int32) (*model.LevelThreshold, 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 — AddExperienceResponse
|
||
|
||
```protobuf
|
||
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
|
||
|
||
```protobuf
|
||
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 事务边界
|
||
|
||
```go
|
||
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. 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. 计算新经验值
|
||
newExp = profile.Experience + delta
|
||
|
||
// 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/
|
||
│ └── 20260415_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/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 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 NOT NULL, -- 正数=升级,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;
|
||
```
|
||
|
||
---
|
||
|
||
## 十三、铸造奖励系统
|
||
|
||
### 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. **重置策略自动化** — 支持按周期(每日/每周)自动重置(当前为手动重置)
|