docs: 经济系统设计文档
This commit is contained in:
parent
4e13c86aa4
commit
f934655fa6
170
docs/generic-moseying-bird.md
Normal file
170
docs/generic-moseying-bird.md
Normal file
@ -0,0 +1,170 @@
|
||||
# 经验升级系统实现计划
|
||||
|
||||
## Context
|
||||
|
||||
后端目前有 `FanProfile.Level` 和 `FanProfile.Experience` 两个字段,但**没有任何根据经验值自动计算等级的逻辑**。用户希望:
|
||||
1. 等级阈值可配置(**数据库配置**)
|
||||
2. 经验值增加后自动计算并更新等级
|
||||
3. 在 `AddExperience` 的事务内部触发升级检查(保证原子性)
|
||||
4. **10级满级,满级后溢出丢弃**
|
||||
5. 等级变化需要返回给前端
|
||||
|
||||
## 关键文件
|
||||
|
||||
- [backend/pkg/models/user.go](backend/pkg/models/user.go#L50-L77) — `FanProfile` 模型定义,包含 `Level` 和 `Experience`
|
||||
- [backend/services/userService/repository/fan_profile_repository.go](backend/services/userService/repository/fan_profile_repository.go#L396-L441) — `UpdateExperience` 方法,最佳插入点
|
||||
- [backend/services/userService/repository/fan_profile_repository.go](backend/services/userService/repository/fan_profile_repository.go) — 新增 `GetLevelThreshold` 方法
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. 数据库等级阈值表
|
||||
|
||||
新建 `backend/services/userService/repository/level_threshold.go`:
|
||||
|
||||
```go
|
||||
type LevelThreshold struct {
|
||||
Level int32 `gorm:"primaryKey;column:level"`
|
||||
ExpRequired int64 `gorm:"not null;column:exp_required"`
|
||||
}
|
||||
|
||||
func (r *fanProfileRepository) GetLevelThresholds() (map[int32]int64, error) {
|
||||
var thresholds []LevelThreshold
|
||||
if err := r.db.Order("level ASC").Find(&thresholds).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[int32]int64)
|
||||
for _, t := range thresholds {
|
||||
result[t.Level] = t.ExpRequired
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
**建表 SQL**(需执行):
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS level_thresholds (
|
||||
level INT PRIMARY KEY,
|
||||
exp_required BIGINT NOT NULL
|
||||
);
|
||||
INSERT INTO level_thresholds (level, exp_required) VALUES
|
||||
(1, 0), (2, 100), (3, 300), (4, 600), (5, 1000),
|
||||
(6, 1500), (7, 2100), (8, 2800), (9, 3600), (10, 4500);
|
||||
```
|
||||
|
||||
### 2. 新增 `CalculateLevel` 函数
|
||||
|
||||
```go
|
||||
const MaxLevel = 10
|
||||
|
||||
// 根据经验值计算当前等级(10级满级,溢出返回10)
|
||||
func CalculateLevel(experience int64, thresholds map[int32]int64) int32 {
|
||||
for level := MaxLevel; level >= 1; level-- {
|
||||
if exp >= thresholds[level] {
|
||||
return level
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 修改 `UpdateExperience` 事务
|
||||
|
||||
在 [fan_profile_repository.go:396-441](backend/services/userService/repository/fan_profile_repository.go#L396-L441) 的事务末尾:
|
||||
|
||||
```go
|
||||
// 计算新等级
|
||||
newLevel := CalculateLevel(newExperience, thresholds) // thresholds 从缓存或传入
|
||||
|
||||
// 如果等级有提升,更新 level 字段
|
||||
if newLevel > profile.Level {
|
||||
tx.Model(&models.FanProfile{}).
|
||||
Where("user_id = ? AND star_id = ?", userID, starID).
|
||||
Updates(map[string]interface{}{
|
||||
"experience": newExperience,
|
||||
"level": newLevel,
|
||||
})
|
||||
} else {
|
||||
tx.Model(&models.FanProfile{}).
|
||||
Where("user_id = ? AND star_id = ?", userID, starID).
|
||||
Update("experience", newExperience)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 修改 `AddExperienceResponse`
|
||||
|
||||
[proto/user.proto](backend/proto/user.proto#L214-L225) 的 `AddExperienceResponse` 增加 `new_level` 字段:
|
||||
|
||||
```protobuf
|
||||
message AddExperienceResponse {
|
||||
topfans.common.BaseResponse base = 1;
|
||||
int64 new_experience = 2;
|
||||
int32 new_level = 3; // 新增
|
||||
}
|
||||
```
|
||||
|
||||
重新生成 `user.pb.go` 和 `user.triple.go`。
|
||||
|
||||
### 5. 修改 `userService.AddExperience`
|
||||
|
||||
[user_service.go:854-861](backend/services/userService/service/user_service.go#L854-L861) 返回 `newLevel`:
|
||||
|
||||
```go
|
||||
// 4. 计算新等级
|
||||
newLevel := CalculateLevel(newExperience, thresholds)
|
||||
|
||||
// 5. 构建响应
|
||||
return &pb.AddExperienceResponse{
|
||||
Base: &pbCommon.BaseResponse{...},
|
||||
NewExperience: newExperience,
|
||||
NewLevel: newLevel,
|
||||
}, nil
|
||||
```
|
||||
|
||||
### 6. 修改 `user_rpc_client.go`
|
||||
|
||||
[user_rpc_client.go:43-58](backend/services/taskService/client/user_rpc_client.go#L43-L58) 返回值增加 `newLevel`:
|
||||
|
||||
```go
|
||||
func (c *userServiceClient) AddExperience(ctx context.Context, userID, starID int64, delta int64) (int64, int32, error) {
|
||||
resp, err := c.client.AddExperience(ctx, &pbUser.AddExperienceRequest{
|
||||
UserId: userID, StarId: starID, Delta: delta,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
|
||||
return 0, 0, fmt.Errorf("AddExperience failed with code: %d", resp.Base.Code)
|
||||
}
|
||||
return resp.NewExperience, resp.NewLevel, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 修改 `ClaimDailyTaskResponse` 和 `ClaimAllDailyTasksResponse`
|
||||
|
||||
在 [proto/task.proto](backend/proto/task.proto) 中两个 response 增加 `new_level` 字段,重新生成 pb 文件。
|
||||
|
||||
## 验证方案
|
||||
|
||||
1. **编译验证**:`cd backend && go build ./...`
|
||||
2. **数据库**:确认 `level_thresholds` 表存在且有数据
|
||||
3. **手动测试**:调用 `/api/v1/tasks/daily/claim`,观察返回的 `experience` 和 `level` 是否正确
|
||||
4. **边界测试**:
|
||||
- 经验刚好达到阈值时是否升级
|
||||
- 满级后经验是否溢出丢弃
|
||||
|
||||
## 涉及修改的文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `backend/services/userService/repository/level_threshold.go` | **新建** — 等级阈值表模型和查询方法 |
|
||||
| `backend/services/userService/repository/fan_profile_repository.go` | 修改 `UpdateExperience` 事务,增加等级计算和更新 |
|
||||
| `backend/services/userService/service/user_service.go` | 修改 `AddExperience` 返回 `newLevel` |
|
||||
| `backend/services/userService/config/` | 可能需要新增缓存配置 |
|
||||
| `backend/proto/user.proto` | `AddExperienceResponse` 增加 `new_level` 字段 |
|
||||
| `backend/proto/task.proto` | `ClaimDailyTaskResponse` / `ClaimAllDailyTasksResponse` 增加 `new_level` |
|
||||
| `backend/pkg/proto/user/user.pb.go` | 重新生成 |
|
||||
| `backend/pkg/proto/user/user.triple.go` | 重新生成 |
|
||||
| `backend/pkg/proto/task/task.pb.go` | 重新生成 |
|
||||
| `backend/pkg/proto/task/task.triple.go` | 重新生成 |
|
||||
| `backend/services/taskService/client/user_rpc_client.go` | `AddExperience` 返回值增加 `newLevel` |
|
||||
| `backend/services/taskService/service/daily_task_service.go` | 调用处增加 `newLevel` 处理 |
|
||||
781
docs/superpowers/specs/2026-04-15-economic-system-design.md
Normal file
781
docs/superpowers/specs/2026-04-15-economic-system-design.md
Normal file
@ -0,0 +1,781 @@
|
||||
# 经济系统设计文档
|
||||
|
||||
> **创建日期:** 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/exhibition_revenue/level_up_bonus/manual_adjust
|
||||
delta BIGINT NOT NULL, -- 正数=收入,负数=消耗
|
||||
balance_before BIGINT NOT NULL, -- 变化前余额
|
||||
balance_after BIGINT NOT NULL, -- 变化后余额
|
||||
source_id VARCHAR(100), -- 关联业务ID(如 task_definition.id, order_id)
|
||||
description VARCHAR(255), -- 可读描述
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_crystal_tx_user_star ON crystal_transaction_records(user_id, star_id);
|
||||
CREATE INDEX ix_crystal_tx_created ON crystal_transaction_records(created_at DESC);
|
||||
CREATE INDEX ix_crystal_tx_change_type ON crystal_transaction_records(change_type);
|
||||
```
|
||||
|
||||
### 3.2 游戏币交易流水表 (coin_transaction_records)
|
||||
|
||||
> **预留:** 当前 coin_balance 全为 0,未实际使用。建表 + Go模型预留,等游戏币真用时再接调用方。
|
||||
|
||||
```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 DEFAULT 0, -- 升级了多少级(正数=升级,负数=降级,0=无变化)
|
||||
source_id VARCHAR(100), -- 关联业务ID
|
||||
description VARCHAR(255),
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_exp_tx_user_star ON exp_transaction_records(user_id, star_id);
|
||||
CREATE INDEX ix_exp_tx_created ON exp_transaction_records(created_at DESC);
|
||||
```
|
||||
|
||||
### 3.4 等级变化记录表 (level_change_records)
|
||||
|
||||
```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,可用于跳级
|
||||
trigger_type VARCHAR(30) NOT NULL, -- exp_gain/manual/admin_adjust
|
||||
exp_at_change BIGINT NOT NULL, -- 触发等级变化时的经验值
|
||||
reward_claimed BOOLEAN DEFAULT false, -- 升级奖励是否已领取(预留)
|
||||
source_id VARCHAR(100), -- 触发来源(如 task_definition.id)
|
||||
description VARCHAR(255),
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_level_change_user_star ON level_change_records(user_id, star_id);
|
||||
CREATE INDEX ix_level_change_created ON level_change_records(created_at DESC);
|
||||
|
||||
-- ============================================================
|
||||
-- 后续扩展索引(如有查询需求可添加):
|
||||
-- 索引B: 查某用户在某时间范围的等级变化
|
||||
-- CREATE INDEX ix_level_change_user_star_time
|
||||
-- ON level_change_records(user_id, star_id, created_at DESC);
|
||||
--
|
||||
-- 索引C: 运营后台查某等级的所有升级记录
|
||||
-- CREATE INDEX ix_level_change_level_after ON level_change_records(level_after);
|
||||
--
|
||||
-- 索引D: 发放升级奖励(按未领取状态查)
|
||||
-- CREATE INDEX ix_level_change_reward_claimed ON level_change_records(reward_claimed)
|
||||
-- WHERE reward_claimed = false;
|
||||
-- ============================================================
|
||||
```
|
||||
|
||||
### 3.5 等级阈值配置表 (level_thresholds)
|
||||
|
||||
```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` | 铸造消耗 | - |
|
||||
| `exhibition_revenue` | 展示收益 | + |
|
||||
| `level_up_bonus` | 升级奖励(由调用方主动发放,AddExperience 不自动发) | + |
|
||||
| `manual_adjust` | 手动调整(运营) | +/- |
|
||||
|
||||
### 4.2 经验 (exp_transaction_records.change_type)
|
||||
|
||||
| 值 | 含义 | delta 方向 |
|
||||
|---|---|---|
|
||||
| `task_reward` | 任务奖励 | + |
|
||||
| `onboarding_reward` | 引导阶段奖励 | + |
|
||||
| `manual_adjust` | 手动调整 | +/- |
|
||||
|
||||
### 4.3 等级变化触发类型 (level_change_records.trigger_type)
|
||||
|
||||
| 值 | 含义 |
|
||||
|---|---|
|
||||
| `exp_gain` | 经验增长触发 |
|
||||
| `manual` | 手动调整 |
|
||||
| `admin_adjust` | 管理员操作 |
|
||||
|
||||
### 4.4 source_id 填写规则
|
||||
|
||||
`source_id` 填触发这笔流水的**源头业务ID**:
|
||||
|
||||
| change_type | source_id 填什么 |
|
||||
|---|---|
|
||||
| `task_reward` | `task_definitions.id`(任务ID) |
|
||||
| `mint_cost` | `mint_orders.order_id`(铸造订单号) |
|
||||
| `exhibition_revenue` | `exhibition_revenue_records.id` |
|
||||
| `level_up_bonus` | **与触发升级的经验增加 source_id 相同**(即触发升级的那笔 task/id) |
|
||||
| `manual_adjust` | 运营后台操作记录ID(预留) |
|
||||
|
||||
---
|
||||
|
||||
## 五、核心方法改造
|
||||
|
||||
### 5.1 UpdateCrystalBalance
|
||||
|
||||
**现有签名:**
|
||||
```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. 查询当前余额 `balance_before`
|
||||
2. 计算 `balance_after = balance_before + delta`
|
||||
3. 写入 `crystal_transaction_records`
|
||||
4. 更新 `FanProfile.CrystalBalance`
|
||||
5. 返回新余额
|
||||
|
||||
---
|
||||
|
||||
### 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. 读取当前 `FanProfile`(含 experience 和 level),**只读一次**
|
||||
2. 计算 `exp_after = profile.Experience + delta`
|
||||
3. 从 `level_thresholds`(含缓存)计算新等级
|
||||
4. 写入 `exp_transaction_records`(含 level_before / level_after / level_delta)
|
||||
5. 更新 `FanProfile.Experience`(如等级有变,同时更新 `FanProfile.Level`)
|
||||
6. **如有升级**(newLevel > profile.Level):写入 `level_change_records`
|
||||
7. **返回 (newExp, newLevel, levelDelta, nil),不自动发放升级水晶奖励**
|
||||
|
||||
> **注意:** 升级水晶奖励由**调用方主动查询 `level_thresholds[newLevel].crystal_reward` 后发放**,不在 AddExperience 内自动发放。
|
||||
|
||||
---
|
||||
|
||||
### 5.3 等级计算 (CalculateLevel)
|
||||
|
||||
```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`:
|
||||
|
||||
```go
|
||||
newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, record.CrystalAmount,
|
||||
"exhibition_revenue",
|
||||
strconv.FormatInt(record.ID, 10),
|
||||
fmt.Sprintf("展示收益 #%d", record.ID))
|
||||
```
|
||||
|
||||
### 6.4 assetService — 铸造扣水晶
|
||||
|
||||
**mint_service.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. 读取当前 profile(只读一次)
|
||||
var profile models.FanProfile
|
||||
if err := tx.Where("user_id = ? AND star_id = ?", userID, starID).
|
||||
First(&profile).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 计算新经验值
|
||||
newExp = profile.Experience + delta
|
||||
if newExp < 0 {
|
||||
newExp = 0
|
||||
}
|
||||
|
||||
// 3. 读取等级阈值(从缓存)
|
||||
thresholds, err := s.levelThresholdCache.GetAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. 计算新等级
|
||||
newLevel = CalculateLevel(newExp, thresholds)
|
||||
levelDelta = newLevel - profile.Level
|
||||
|
||||
// 5. 写入 exp_transaction_records
|
||||
expRecord := &model.ExpTransactionRecord{
|
||||
UserID: userID,
|
||||
StarID: starID,
|
||||
ChangeType: changeType,
|
||||
Delta: delta,
|
||||
ExpBefore: profile.Experience,
|
||||
ExpAfter: newExp,
|
||||
LevelBefore: profile.Level,
|
||||
LevelAfter: newLevel,
|
||||
LevelDelta: levelDelta,
|
||||
SourceID: sourceID,
|
||||
Description: description,
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := tx.Create(expRecord).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6. 更新 FanProfile(经验+等级)
|
||||
updates := map[string]interface{}{
|
||||
"experience": newExp,
|
||||
}
|
||||
if newLevel != profile.Level {
|
||||
updates["level"] = newLevel
|
||||
}
|
||||
if err := tx.Model(&models.FanProfile{}).
|
||||
Where("user_id = ? AND star_id = ?", userID, starID).
|
||||
Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 7. 如有升级,写入 level_change_records
|
||||
if newLevel > profile.Level {
|
||||
levelRecord := &model.LevelChangeRecord{
|
||||
UserID: userID,
|
||||
StarID: starID,
|
||||
LevelBefore: profile.Level,
|
||||
LevelAfter: newLevel,
|
||||
LevelDelta: levelDelta,
|
||||
TriggerType: "exp_gain",
|
||||
ExpAtChange: newExp,
|
||||
SourceID: sourceID,
|
||||
Description: description,
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := tx.Create(levelRecord).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return newExp, newLevel, levelDelta, err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、项目文件结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── pkg/models/
|
||||
│ ├── user.go # 修改:新增 CrystalTransactionRecord / CoinTransactionRecord /
|
||||
│ │ # ExpTransactionRecord / LevelChangeRecord 模型
|
||||
│ └── level_threshold.go # 新增:LevelThreshold 模型
|
||||
│
|
||||
├── scripts/
|
||||
│ └── v002_economic_tables.sql # 新增:所有新建表的 DDL + level_thresholds 初始数据
|
||||
│
|
||||
├── services/userService/
|
||||
│ ├── repository/
|
||||
│ │ ├── fan_profile_repository.go # 修改:UpdateCrystalBalance / AddExperience 签名
|
||||
│ │ ├── crystal_tx_repository.go # 新增:水晶流水写入
|
||||
│ │ ├── coin_tx_repository.go # 新增:游戏币流水写入(预留)
|
||||
│ │ ├── exp_tx_repository.go # 新增:经验流水写入
|
||||
│ │ ├── level_change_repository.go # 新增:等级变化写入
|
||||
│ │ └── level_threshold_repository.go # 新增:阈值查询(含缓存)
|
||||
│ │
|
||||
│ ├── service/
|
||||
│ │ └── user_service.go # 修改:AddExperience 返回 newLevel, levelDelta
|
||||
│ │
|
||||
│ └── client/
|
||||
│ └── user_rpc_client.go # 修改:UpdateCrystalBalance / AddExperience 签名
|
||||
│
|
||||
├── services/taskService/
|
||||
│ ├── service/
|
||||
│ │ ├── daily_task_service.go # 修改:调用新签名 + 发放升级奖励
|
||||
│ │ ├── onboarding_service.go # 修改:调用新签名 + 发放升级奖励
|
||||
│ │ └── revenue_service.go # 修改:调用新签名
|
||||
│ │
|
||||
│ └── client/
|
||||
│ └── user_rpc_client.go # 修改:UpdateCrystalBalance / AddExperience 返回值
|
||||
│
|
||||
├── services/assetService/
|
||||
│ └── service/
|
||||
│ └── mint_service.go # 修改:调用新签名
|
||||
│
|
||||
├── proto/
|
||||
│ ├── user.proto # 修改:AddExperienceResponse 增加 new_level, level_delta
|
||||
│ └── task.proto # 修改:ClaimDailyTaskResponse / ClaimAllDailyTasksResponse
|
||||
│ # 增加 new_level, level_delta
|
||||
│
|
||||
└── pkg/proto/
|
||||
├── user/
|
||||
│ ├── user.pb.go # 重新生成
|
||||
│ └── user.triple.go # 重新生成
|
||||
└── task/
|
||||
├── task.pb.go # 重新生成
|
||||
└── task.triple.go # 重新生成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、SQL 建表脚本
|
||||
|
||||
脚本路径:`backend/scripts/v002_economic_tables.sql`
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- 经济系统建表脚本
|
||||
-- 执行方式: psql -h <host> -U <user> -d <db> -f backend/scripts/v002_economic_tables.sql
|
||||
-- ============================================================
|
||||
|
||||
-- 水晶交易流水表
|
||||
CREATE TABLE IF NOT EXISTS crystal_transaction_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
star_id BIGINT NOT NULL,
|
||||
change_type VARCHAR(30) NOT NULL,
|
||||
delta BIGINT NOT NULL,
|
||||
balance_before BIGINT NOT NULL,
|
||||
balance_after BIGINT NOT NULL,
|
||||
source_id VARCHAR(100),
|
||||
description VARCHAR(255),
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_crystal_tx_user_star ON crystal_transaction_records(user_id, star_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_crystal_tx_created ON crystal_transaction_records(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_crystal_tx_change_type ON crystal_transaction_records(change_type);
|
||||
|
||||
-- 游戏币交易流水表(预留)
|
||||
CREATE TABLE IF NOT EXISTS coin_transaction_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
star_id BIGINT NOT NULL,
|
||||
change_type VARCHAR(30) NOT NULL,
|
||||
delta BIGINT NOT NULL,
|
||||
balance_before BIGINT NOT NULL,
|
||||
balance_after BIGINT NOT NULL,
|
||||
source_id VARCHAR(100),
|
||||
description VARCHAR(255),
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_coin_tx_user_star ON coin_transaction_records(user_id, star_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_coin_tx_created ON coin_transaction_records(created_at DESC);
|
||||
|
||||
-- 经验交易流水表
|
||||
CREATE TABLE IF NOT EXISTS exp_transaction_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
star_id BIGINT NOT NULL,
|
||||
change_type VARCHAR(30) NOT NULL,
|
||||
delta BIGINT NOT NULL,
|
||||
exp_before BIGINT NOT NULL,
|
||||
exp_after BIGINT NOT NULL,
|
||||
level_before INT NOT NULL,
|
||||
level_after INT NOT NULL,
|
||||
level_delta INT DEFAULT 0,
|
||||
source_id VARCHAR(100),
|
||||
description VARCHAR(255),
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_exp_tx_user_star ON exp_transaction_records(user_id, star_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_exp_tx_created ON exp_transaction_records(created_at DESC);
|
||||
|
||||
-- 等级变化记录表
|
||||
CREATE TABLE IF NOT EXISTS level_change_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
star_id BIGINT NOT NULL,
|
||||
level_before INT NOT NULL,
|
||||
level_after INT NOT NULL,
|
||||
level_delta INT NOT NULL,
|
||||
trigger_type VARCHAR(30) NOT NULL,
|
||||
exp_at_change BIGINT NOT NULL,
|
||||
reward_claimed BOOLEAN DEFAULT false,
|
||||
source_id VARCHAR(100),
|
||||
description VARCHAR(255),
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_level_change_user_star ON level_change_records(user_id, star_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_level_change_created ON level_change_records(created_at DESC);
|
||||
|
||||
-- 后续扩展索引(如有查询需求可添加):
|
||||
-- CREATE INDEX ix_level_change_user_star_time ON level_change_records(user_id, star_id, created_at DESC);
|
||||
-- CREATE INDEX ix_level_change_level_after ON level_change_records(level_after);
|
||||
-- CREATE INDEX ix_level_change_reward_claimed ON level_change_records(reward_claimed) WHERE reward_claimed = false;
|
||||
|
||||
-- 等级阈值配置表
|
||||
CREATE TABLE IF NOT EXISTS level_thresholds (
|
||||
level INT PRIMARY KEY,
|
||||
exp_required BIGINT NOT NULL,
|
||||
crystal_reward BIGINT DEFAULT 0,
|
||||
description VARCHAR(100)
|
||||
);
|
||||
|
||||
-- 插入初始数据(10级满级)
|
||||
INSERT INTO level_thresholds (level, exp_required, crystal_reward, description) VALUES
|
||||
(1, 0, 0, '1级新手'),
|
||||
(2, 100, 10, '2级粉丝'),
|
||||
(3, 300, 20, '3级真爱'),
|
||||
(4, 600, 30, '4级铁粉'),
|
||||
(5, 1000, 50, '5级钻石粉'),
|
||||
(6, 1500, 80, '6级钻石粉'),
|
||||
(7, 2100, 120, '7级钻石粉'),
|
||||
(8, 2800, 180, '8级钻石粉'),
|
||||
(9, 3600, 280, '9级钻石粉'),
|
||||
(10, 4500, 500, '10级终极粉')
|
||||
ON CONFLICT (level) DO NOTHING;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十三、后续扩展预留
|
||||
|
||||
1. **coin_transaction_records** — 等游戏币有实际用途时启用(当前 delta 写 0)
|
||||
2. **level_change_records.reward_claimed** — 升级奖励领取状态(等运营后台需要手动补发时启用)
|
||||
3. **流水分页查询 API** — `GET /api/economy/crystal-history` 等(当前只记录不查询)
|
||||
4. **level_thresholds.crystal_reward** — 升级奖励水晶已在表中配置
|
||||
5. **扩展索引** — 见 3.4 节注释
|
||||
Loading…
Reference in New Issue
Block a user