49 KiB
经济系统设计文档
创建日期: 2026-04-15 项目: TopFans 经济系统(水晶 / 经验 / 游戏币) 服务: userService (Go Dubbo-go) + taskService (Go) + 共享 PostgreSQL
一、设计目标
- 水晶 (Crystal) — 记录所有收入/消耗流水,支持查询历史
- 经验 (Experience) — 记录经验变化 + 自动计算等级(数据库阈值配置)
- 游戏币 (Coin) — 预留,与水晶结构一致
- 等级变化 — 记录每次升级,用于运营分析
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)
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构成三重校验:
- 自包含审计 — 任意一条记录都能独立验证
balance_before + delta = balance_after,不依赖其他记录- 数据一致性验证 — 可检查记录 N 的
balance_before是否等于记录 N-1 的balance_after,发现数据损坏或业务 bug- 故障追溯 — 余额出错时,从任意一笔交易往前推算即可定位问题
- 防错误传播 — 若只存
delta,计算balance_after时的 bug 会导致后续所有余额错乱;有balance_after作为 checkpoint,错误被隔离在某一条,不会扩散
3.2 游戏币交易流水表 (coin_transaction_records)
预留: 当前 coin_balance 全为 0,未实际使用。建表 + Go模型预留,等游戏币真用时再接调用方。
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)
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)
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)
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
现有签名:
UpdateCrystalBalance(userID, starID int64, delta int64) (int64, error)
新签名:
UpdateCrystalBalance(
userID int64,
starID int64,
delta int64,
changeType string,
sourceID string,
description string,
) (int64, error)
内部逻辑(事务内):
SELECT FOR UPDATE锁定FanProfile行(user_id = ? AND star_id = ?),只读一次- 查询当前余额
balance_before - 计算
balance_after = balance_before + delta - 写入
crystal_transaction_records - 更新
FanProfile.CrystalBalance - 返回新余额(事务提交后锁自动释放)
事务边界代码:
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
现有签名:
AddExperience(userID, starID int64, delta int64) (int64, error)
新签名:
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=无变化)
内部逻辑(事务内):
SELECT FOR UPDATE锁定FanProfile行(user_id = ? AND star_id = ?),只读一次- 计算
exp_after = profile.Experience + delta - 从
level_thresholds(含缓存)计算新等级 - 写入
exp_transaction_records(含 level_before / level_after / level_delta) - 更新
FanProfile.Experience(如等级有变,同时更新FanProfile.Level) - 如有升级(newLevel > profile.Level):写入
level_change_records - 返回 (newExp, newLevel, levelDelta, nil),不自动发放升级水晶奖励
注意: 升级水晶奖励由调用方主动查询
level_thresholds[newLevel].crystal_reward后发放,不在 AddExperience 内自动发放。
5.3 等级计算 (CalculateLevel)
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:
// 发放水晶
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:
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:
展示收益包含两部分:
- 上架收益 R1 = R0 × T × [100% + Buff(n)],其中 n 为作品获得的点赞数
- 点赞押注收益 R2 = [1 + (N - i)] × R3,由点赞者获得
// 上架收益 = 单位小时收益 × 时长 × (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百分比的伪代码
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:
newBalance, err := s.userClient.UpdateCrystalBalance(ctx, userID, starID, -mintFee,
"mint_cost",
orderID,
fmt.Sprintf("铸造藏品 #%s", orderID))
七、新增 Repository
7.1 Ledger Repository(流水读写)
当前阶段只做写入,List 查询暂不做(后续按需添加)
// 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
// GetAllThresholds 获取所有等级阈值(带内存缓存,TTL 5分钟)
// 缓存加载失败时 panic(与 logger 初始化失败同等待遇)
GetAllThresholds() (map[int32]*model.LevelThreshold, error)
// GetThresholdByLevel 获取指定等级阈值
GetThresholdByLevel(level int32) (*model.LevelThreshold, error)
八、LevelThreshold 缓存设计
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
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
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 事务边界
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
-- ============================================================
-- 经济系统建表脚本
-- 执行方式: 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)
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)
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)
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 铸造成功后调用,采用异步方式不阻塞铸造主流程:
// 铸造成功后发放奖励
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 功能开关
// 关闭/开启指定偶像的铸造奖励
SetMintRewardConfig(starID int64, isEnabled bool, baseReward int64) error
13.4.2 阶梯配置
// 添加/修改阶梯
SetMilestone(starID int64, milestone int, bonus int64) error
// 删除阶梯
RemoveMilestone(starID int64, milestone int) error
13.4.3 重置用户累计
// 重置指定用户的累计铸造次数
ResetMintCount(userID, starID int64) error
// 重置指定偶像下所有用户的累计铸造次数
ResetAllMintCount(starID int64) error
13.5 Proto 接口(管理后台调用)
asset.proto 新增以下接口:
// 设置铸造奖励配置(开关 + 基础奖励)
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 锁,保证同一用户对同一偶像的铸造奖励和累计不出现并发问题。
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%
十五、后续扩展预留
- coin_transaction_records — 等游戏币有实际用途时启用(当前 delta 写 0)
- 流水分页查询 API —
GET /api/economy/crystal-history等(当前只记录不查询) - 铸造奖励上限 — 单日/单周铸造奖励上限防刷(等风控需求明确后实现)
- 阶梯百分比奖励 — 当前为固定额外水晶,后续可扩展为 base_reward 的百分比
- 重置策略自动化 — 支持按周期(每日/每周)自动重置(当前为手动重置)