docs: 经济系统设计文档

This commit is contained in:
zerosaturation 2026-04-15 17:04:42 +08:00
parent 4e13c86aa4
commit f934655fa6
2 changed files with 951 additions and 0 deletions

View 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` 处理 |

View 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 节注释