diff --git a/docs/generic-moseying-bird.md b/docs/generic-moseying-bird.md new file mode 100644 index 0000000..e42f1f9 --- /dev/null +++ b/docs/generic-moseying-bird.md @@ -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` 处理 | diff --git a/docs/superpowers/specs/2026-04-15-economic-system-design.md b/docs/superpowers/specs/2026-04-15-economic-system-design.md new file mode 100644 index 0000000..f55c5f9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-economic-system-design.md @@ -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 -U -d -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 节注释