topfans/backend/docs/藏品升级系统设计方案.md

1184 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 数字藏品升级系统设计方案
> 文档版本v1.0
> 创建日期2026-05-21
> 状态:待评审
---
## 一、背景与目标
### 1.1 需求背景
当前系统中的"等级"体系是针对**用户**的等级1-20级基于用户累计展出时长进行升级。现需要新增一套针对**单个藏品**的等级体系,形成双轨制升级机制。
核心目标:
1. 通过藏品等级激励用户持续创作和"铸爱"
2. 高等级藏品享有更高的展出收益,形成经济正循环
3. 赛季制设计避免等级通货膨胀,保持玩家竞争动力
### 1.2 设计原则
- **双轨驱动**:升级需同时满足"累计展出时长"和"累计点赞数"两个条件
- **可配置性**:等级门槛、收益参数可通过数据库配置,无需硬编码
- **可追溯性**:所有等级变化记录完整留存,支持审计
- **赛季隔离**:赛季结束后执行等级回退,支持多种回退策略
---
## 二、等级体系设计
### 2.1 等级定义
藏品共分 5 个等级,初始等级为 **N**
| 等级 | 名称 | 英文 | 每小时收益 | 升级所需展出总时长 | 升级所需累计点赞数 |
|:----:|:----:|:----:|:--------:|:----------------:|:----------------:|
| 1 | N | Normal | 5 水晶/小时 | 0 小时(初始) | 0初始 |
| 2 | R | Rare | 7 水晶/小时 | 24 小时 | 20 赞 |
| 3 | SR | Super Rare | 12 水晶/小时 | 120 小时 | 500 赞 |
| 4 | SSR | Super² Rare | 18 水晶/小时 | 360 小时 | 10,000 赞 |
| 5 | UR | Ultimate Rare | 30 水晶/小时 | 720 小时 | 100,000 赞 |
> 注:升级条件为**同时满足**时长和点赞两个门槛。
### 2.2 升级路线图
```
N ─[24h + 20赞]─→ R ─[96h + 480赞]─→ SR ─[240h + 9500赞]─→ SSR ─[360h + 90000赞]─→ UR
```
### 2.3 收益对比(以展出 1 小时为例)
| 等级 | 基础收益 | 5赞Buff(+10%) | 10赞Buff(+20%) | 30赞Buff(+30%) |
|:----:|:-------:|:------------:|:--------------:|:--------------:|
| N | 5 | 5.5 | 6.0 | 6.5 |
| R | 7 | 7.7 | 8.4 | 9.1 |
| SR | 12 | 13.2 | 14.4 | 15.6 |
| SSR | 18 | 19.8 | 21.6 | 23.4 |
| UR | 30 | 33.0 | 36.0 | 39.0 |
> Buff 计算公式:`R = 基础收益 × T × (100% + Buff%)`,其中 T 为展出时长(小时)
---
## 三、数据模型设计
### 3.1 新增数据库表
#### 3.1.1 藏品等级配置表 `asset_levels`
```sql
CREATE TABLE asset_levels (
id BIGSERIAL PRIMARY KEY,
level VARCHAR(10) NOT NULL UNIQUE, -- N, R, SR, SSR, UR
level_order INT NOT NULL, -- 等级顺序1-5用于排序和比较
hourly_revenue INT NOT NULL, -- 每小时基础收益(水晶)
require_hours INT NOT NULL, -- 升级到该等级所需累计展出时长(小时)
require_likes INT NOT NULL, -- 升级到该等级所需累计点赞数
is_initial BOOLEAN DEFAULT FALSE, -- 是否为初始等级
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
COMMENT ON TABLE asset_levels IS '藏品等级配置表,定义各等级的基础参数和升级条件';
```
#### 3.1.2 藏品等级记录表 `asset_level_records`
```sql
CREATE TABLE asset_level_records (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL UNIQUE,
current_level VARCHAR(10) NOT NULL DEFAULT 'N',
-- 赛季内累计(用于等级计算,会在赛季降序时按百分比衰减)
season_exhibition_hours INT NOT NULL DEFAULT 0, -- 赛季内累计展出时长(小时)
season_likes INT NOT NULL DEFAULT 0, -- 赛季内累计点赞数
-- 历史累计(不受赛季影响,用于赛季降序计算)
lifetime_exhibition_hours INT NOT NULL DEFAULT 0, -- 历史累计展出时长(跨赛季保留)
lifetime_likes INT NOT NULL DEFAULT 0, -- 历史累计点赞数(跨赛季,按百分比衰减)
-- 降序计算用:基于历史累计计算降序后的赛季值
-- season_xxx = lifetime_xxx * preserve_percent / 100
season_id VARCHAR(50), -- 当前赛季ID
updated_at BIGINT NOT NULL,
CONSTRAINT uk_asset_level_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
);
CREATE INDEX idx_asset_level_season ON asset_level_records(season_id);
CREATE INDEX idx_asset_level_current_level ON asset_level_records(current_level);
COMMENT ON TABLE asset_level_records IS '藏品等级记录表,记录每个藏品的当前等级和成长值;
赛季内累计season_xxx用于等级计算赛季降序时按百分比衰减
历史累计lifetime_xxx跨赛季保留赛季降序时按百分比继承到赛季内累计';
```
#### 3.1.3 藏品等级变化日志表 `asset_level_change_logs`
```sql
CREATE TABLE asset_level_change_logs (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL,
from_level VARCHAR(10),
to_level VARCHAR(10) NOT NULL,
trigger_type VARCHAR(20) NOT NULL, -- exhibition_complete: 展出完成, like_update: 点赞更新, season_reset: 赛季重置
trigger_hours INT DEFAULT 0, -- 触发升级时的时长
trigger_likes INT DEFAULT 0, -- 触发升级时的点赞数
change_reason VARCHAR(255), -- 详细原因描述
created_at BIGINT NOT NULL
);
CREATE INDEX idx_asset_level_log_asset ON asset_level_change_logs(asset_id);
CREATE INDEX idx_asset_level_log_created ON asset_level_change_logs(created_at DESC);
COMMENT ON TABLE asset_level_change_logs IS '藏品等级变化日志表,记录所有等级变动历史';
```
#### 3.1.4 赛季配置表 `seasons`(新增)
```sql
CREATE TABLE seasons (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL, -- 赛季名称,如"第一赛季"
duration_days INT NOT NULL DEFAULT 84, -- 赛季持续天数可配置默认84天≈12周
start_time BIGINT NOT NULL, -- 开始时间(毫秒时间戳)
end_time BIGINT NOT NULL, -- 结束时间(毫秒时间戳,由 start_time + duration_days 计算得出)
reset_strategy VARCHAR(20) DEFAULT 'percentage_decay', -- reset_all / percentage_decay / keep_all
reset_level BOOLEAN DEFAULT TRUE, -- 是否重置等级
status VARCHAR(20) DEFAULT 'active', -- active/ended
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
COMMENT ON TABLE seasons IS '赛季配置表,支持百分比降序策略;
注意end_time 由 CalculateEndTime() 方法计算得出,或在创建/更新时同步写入';
```
#### 3.1.5 赛季降序百分比配置表 `season_decay_config`(新增)
```sql
CREATE TABLE season_decay_config (
id BIGSERIAL PRIMARY KEY,
season_id VARCHAR(50) NOT NULL, -- 赛季ID
level VARCHAR(10) NOT NULL, -- 等级N/R/SR/SSR/UR
preserve_percent INT NOT NULL DEFAULT 100, -- 降序时保留百分比0-100
updated_at BIGINT NOT NULL,
CONSTRAINT uk_season_level UNIQUE (season_id, level)
);
COMMENT ON TABLE season_decay_config IS '赛季降序百分比配置每个等级的保留比例0-100
注意:每个等级在每个赛季都有独立的保留比例配置';
-- 示例数据(可按需调整)
INSERT INTO season_decay_config (season_id, level, preserve_percent, updated_at) VALUES
('season_1', 'N', 100, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)), -- N级保持100%(不降序)
('season_1', 'R', 80, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)), -- R级保留80%
('season_1', 'SR', 70, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)), -- SR级保留70%
('season_1', 'SSR', 60, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)), -- SSR级保留60%
('season_1', 'UR', 50, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)) -- UR级保留50%
ON CONFLICT (season_id, level) DO NOTHING;
```
```
### 3.2 模型定义
#### pkg/models/asset_level.go
```go
package models
import "time"
// AssetLevel 藏品等级配置
type AssetLevel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Level string `gorm:"type:varchar(10);unique;not null"`
LevelOrder int `gorm:"not null"`
HourlyRevenue int `gorm:"not null"`
RequireHours int `gorm:"not null"`
RequireLikes int `gorm:"not null"`
IsInitial bool `gorm:"default:false"`
CreatedAt int64 `gorm:"not null"`
UpdatedAt int64 `gorm:"not null"`
}
func (AssetLevel) TableName() string { return "asset_levels" }
// AssetLevelRecord 藏品等级记录
type AssetLevelRecord struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
AssetID int64 `gorm:"unique;not null"`
CurrentLevel string `gorm:"type:varchar(10);not null;default:'N'"`
// 赛季内累计(用于等级计算,赛季降序时按百分比衰减)
SeasonExhibitionHours int `gorm:"default:0;not null"`
SeasonLikes int `gorm:"default:0;not null"`
// 历史累计(跨赛季保留,赛季降序时按百分比继承到赛季内累计)
LifetimeExhibitionHours int `gorm:"default:0;not null"`
LifetimeLikes int `gorm:"default:0;not null"`
SeasonID string `gorm:"type:varchar(50)"`
UpdatedAt int64 `gorm:"not null"`
}
func (AssetLevelRecord) TableName() string { return "asset_level_records" }
// AssetLevelChangeLog 等级变化日志
type AssetLevelChangeLog struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
AssetID int64 `gorm:"not null;index"`
FromLevel string `gorm:"type:varchar(10)"`
ToLevel string `gorm:"type:varchar(10);not null"`
TriggerType string `gorm:"type:varchar(20);not null"`
TriggerHours int `gorm:"default:0"`
TriggerLikes int `gorm:"default:0"`
ChangeReason string `gorm:"type:varchar(255)"`
CreatedAt int64 `gorm:"not null;index"`
}
func (AssetLevelChangeLog) TableName() string { return "asset_level_change_logs" }
// Season 赛季配置
type Season struct {
ID string `gorm:"primaryKey;type:varchar(50)"`
Name string `gorm:"type:varchar(100);not null"`
DurationDays int `gorm:"not null;default:84"` // 赛季持续天数(可配置)
StartTime int64 `gorm:"not null"`
EndTime int64 `gorm:"not null"`
ResetStrategy string `gorm:"type:varchar(20);default:'percentage_decay'"` // reset_all / percentage_decay / keep_all
ResetLevel bool `gorm:"default:true"`
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedAt int64 `gorm:"not null"`
UpdatedAt int64 `gorm:"not null"`
}
func (Season) TableName() string { return "seasons" }
// 计算赛季结束时间
func (s *Season) CalculateEndTime() int64 {
return s.StartTime + int64(s.DurationDays)*86400000
}
// BeforeCreate 创建前钩子:自动计算 end_time
func (s *Season) BeforeCreate(tx *gorm.DB) error {
now := time.Now().UnixMilli()
s.CreatedAt = now
s.UpdatedAt = now
s.EndTime = s.CalculateEndTime()
if s.Status == "" {
s.Status = "active"
}
return nil
}
// BeforeUpdate 更新前钩子:自动更新 end_time
func (s *Season) BeforeUpdate(tx *gorm.DB) error {
s.UpdatedAt = time.Now().UnixMilli()
s.EndTime = s.CalculateEndTime()
return nil
}
// SeasonDecayConfig 赛季降序百分比配置
type SeasonDecayConfig struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
SeasonID string `gorm:"type:varchar(50);not null;uniqueIndex:uk_season_level"`
Level string `gorm:"type:varchar(10);not null;uniqueIndex:uk_season_level"`
PreservePercent int `gorm:"not null;default:100"` // 降序时保留百分比0-100
UpdatedAt int64 `gorm:"not null"`
}
func (SeasonDecayConfig) TableName() string { return "season_decay_config" }
// 等级常量
const (
LevelN = "N"
LevelR = "R"
LevelSR = "SR"
LevelSSR = "SSR"
LevelUR = "UR"
)
// 等级顺序映射
var LevelOrderMap = map[string]int{
LevelN: 1,
LevelR: 2,
LevelSR: 3,
LevelSSR: 4,
LevelUR: 5,
}
```
### 3.3 初始数据
```sql
-- 藏品等级配置初始数据
INSERT INTO asset_levels (level, level_order, hourly_revenue, require_hours, require_likes, is_initial, created_at, updated_at) VALUES
('N', 1, 5, 0, 0, TRUE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('R', 2, 7, 24, 20, FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('SR', 3, 12, 120, 500, FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('SSR', 4, 18, 360, 10000, FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('UR', 5, 30, 720, 100000, FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000))
ON CONFLICT (level) DO NOTHING;
-- 赛季配置初始数据duration_days 可按需调整)
INSERT INTO seasons (id, name, duration_days, start_time, end_time, reset_strategy, reset_level, status, created_at, updated_at) VALUES
('season_1', '第一赛季', 84, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000) + 86400000 * 84, 'percentage_decay', TRUE, 'active', ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000))
ON CONFLICT (id) DO NOTHING;
COMMENT ON COLUMN seasons.reset_strategy IS '重置策略: reset_all(全部重置N) / percentage_decay(按配置百分比降序) / keep_all(保持不变)';
```
---
## 四、服务层设计
### 4.1 AssetLevelService 接口
```go
// service/asset_level_service.go
package service
// AssetLevelService 藏品等级服务接口
type AssetLevelService interface {
// GetOrCreateRecord 获取或创建藏品等级记录
GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error)
// GetRecordByAssetID 根据资产ID获取等级记录
GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error)
// GetLevelConfig 获取等级配置
GetLevelConfig(level string) (*models.AssetLevel, error)
// GetAllLevels 获取所有等级配置(按顺序)
GetAllLevels() ([]*models.AssetLevel, error)
// AddExhibitionHours 增加展出时长并检查升级
AddExhibitionHours(assetID int64, hours int) (string, bool, error)
// AddLikes 增加点赞数并检查升级
AddLikes(assetID int64, count int) (string, bool, error)
// RemoveLikes 减少点赞数(用于取消点赞)
RemoveLikes(assetID int64, count int) (string, bool, error)
// CalculateRevenue 计算单次展出收益(基于藏品等级)
CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error)
// SeasonReset 赛季重置(赛季结束时调用)
SeasonReset(seasonID string) error
// GetCurrentSeason 获取当前赛季
GetCurrentSeason() (*models.Season, error)
// getDefaultSeason 获取默认赛季(当无活跃赛季时返回默认赛季)
getDefaultSeason() *models.Season
}
```
### 4.2 核心业务逻辑
#### 4.2.1 升级检查逻辑
```go
// service/asset_level_service.go
// CheckUpgrade 检查是否可以升级(使用赛季内累计)
// 升级条件:同时满足累计时长和累计点赞数门槛
func (s *assetLevelService) CheckUpgrade(record *models.AssetLevelRecord) (string, bool) {
levels, err := s.GetAllLevels()
if err != nil {
return record.CurrentLevel, false
}
currentOrder := models.LevelOrderMap[record.CurrentLevel]
// 从高到低检查(优先匹配最高可达到的等级)
// 确保升级目标等级比当前等级高
for i := len(levels) - 1; i >= 0; i-- {
level := levels[i]
// 必须是更高的等级
if level.LevelOrder <= currentOrder {
continue
}
// 检查是否满足该等级的升级条件(同时满足时长和点赞)
if record.SeasonExhibitionHours >= level.RequireHours &&
record.SeasonLikes >= level.RequireLikes {
return level.Level, true
}
}
return record.CurrentLevel, false
}
// CheckDowngrade 检查是否需要降级(使用赛季内累计)
func (s *assetLevelService) CheckDowngrade(record *models.AssetLevelRecord) (string, bool) {
levels, err := s.GetAllLevels()
if err != nil {
return record.CurrentLevel, false
}
currentOrder := models.LevelOrderMap[record.CurrentLevel]
// 找到当前等级的配置
currentLevelConfig := s.getLevelConfigByOrder(currentOrder)
// 检查当前等级条件是否还满足
if currentLevelConfig != nil &&
record.SeasonExhibitionHours >= currentLevelConfig.RequireHours &&
record.SeasonLikes >= currentLevelConfig.RequireLikes {
// 当前等级条件仍满足,不降级
return record.CurrentLevel, false
}
// 条件不满足,找到满足条件的最高等级
newLevel := models.LevelN
for _, level := range levels {
if record.SeasonExhibitionHours >= level.RequireHours &&
record.SeasonLikes >= level.RequireLikes {
newLevel = level.Level
}
}
if newLevel != record.CurrentLevel {
return newLevel, true
}
return record.CurrentLevel, false
}
// getLevelConfigByOrder 根据等级顺序获取等级配置
func (s *assetLevelService) getLevelConfigByOrder(order int) *models.AssetLevel {
levels, _ := s.GetAllLevels()
for _, level := range levels {
if level.LevelOrder == order {
return level
}
}
return nil
}
// AddExhibitionHours 增加展出时长
// 注意:时长和点赞的衰减在赛季重置时处理,不在此处
func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int) (string, bool, error) {
record, err := s.GetOrCreateRecord(assetID)
if err != nil {
return "", false, err
}
oldLevel := record.CurrentLevel
// 如果是首次展出SeasonID为空关联当前赛季
if record.SeasonID == "" {
season, err := s.GetCurrentSeason()
if err != nil {
season = s.getDefaultSeason()
}
record.SeasonID = season.ID
}
// 赛季内累计 += 本次时长
record.SeasonExhibitionHours += hours
// 历史累计永久保留
record.LifetimeExhibitionHours += hours
// 检查升级(使用赛季内累计)
newLevel, upgraded := s.CheckUpgrade(record)
if upgraded {
record.CurrentLevel = newLevel
}
if err := s.repo.Save(record); err != nil {
return "", false, err
}
// 记录日志
if upgraded && newLevel != oldLevel {
s.logLevelChange(record.AssetID, oldLevel, newLevel,
"exhibition_complete", record.SeasonExhibitionHours, record.SeasonLikes,
fmt.Sprintf("展出完成,时长+%d小时赛季内:%d 历史:%d", hours, record.SeasonExhibitionHours, record.LifetimeExhibitionHours))
}
return newLevel, upgraded, nil
}
```
#### 4.2.2 点赞更新逻辑
```go
// AddLikes 增加点赞数
func (s *assetLevelService) AddLikes(assetID int64, count int) (string, bool, error) {
record, err := s.GetOrCreateRecord(assetID)
if err != nil {
return "", false, err
}
oldLevel := record.CurrentLevel
// 如果是首次获赞SeasonID为空关联当前赛季
if record.SeasonID == "" {
season, err := s.GetCurrentSeason()
if err != nil {
season = s.getDefaultSeason()
}
record.SeasonID = season.ID
}
// 赛季内累计 += 本次点赞
record.SeasonLikes += count
// 历史累计(用于降序计算)
record.LifetimeLikes += count
// 检查升级(使用赛季内累计)
newLevel, upgraded := s.CheckUpgrade(record)
if upgraded {
record.CurrentLevel = newLevel
}
if err := s.repo.Save(record); err != nil {
return "", false, err
}
// 记录日志
if upgraded && newLevel != oldLevel {
s.logLevelChange(record.AssetID, oldLevel, newLevel,
"like_update", record.SeasonExhibitionHours, record.SeasonLikes,
fmt.Sprintf("点赞数达到%d触发升级", record.SeasonLikes))
}
return newLevel, upgraded, nil
}
// RemoveLikes 减少点赞数(用于取消点赞)
func (s *assetLevelService) RemoveLikes(assetID int64, count int) (string, bool, error) {
record, err := s.GetOrCreateRecord(assetID)
if err != nil {
return "", false, err
}
oldLevel := record.CurrentLevel
// 赛季内累计 -= 本次点赞
record.SeasonLikes -= count
if record.SeasonLikes < 0 {
record.SeasonLikes = 0
}
// 历史累计也减少(用于降序计算)
record.LifetimeLikes -= count
if record.LifetimeLikes < 0 {
record.LifetimeLikes = 0
}
// 检查是否需要降级(使用赛季内累计)
newLevel, downgraded := s.CheckDowngrade(record)
if downgraded {
record.CurrentLevel = newLevel
}
if err := s.repo.Save(record); err != nil {
return "", false, err
}
// 记录日志
if downgraded && newLevel != oldLevel {
s.logLevelChange(record.AssetID, oldLevel, newLevel,
"like_remove", record.SeasonExhibitionHours, record.SeasonLikes,
fmt.Sprintf("取消点赞,点赞数降至%d触发降级", record.SeasonLikes))
}
return newLevel, downgraded, nil
}
```
#### 4.2.3 收益计算逻辑
```go
// CalculateRevenue 基于藏品等级计算收益
func (s *assetLevelService) CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) {
record, err := s.GetRecordByAssetID(assetID)
if err != nil || record == nil {
// 无等级记录,使用默认配置
return s.calculateDefaultRevenue(likeCount, startTime, endTime, revenueBoostBps)
}
levelConfig, err := s.GetLevelConfig(record.CurrentLevel)
if err != nil {
return s.calculateDefaultRevenue(likeCount, startTime, endTime, revenueBoostBps)
}
// 计算展出时长(小时)
T := (endTime - startTime) / 3600000
if T <= 0 {
T = 1
}
// 基础收益 = 等级小时收益 × 时长
R0 := int64(levelConfig.HourlyRevenue)
baseRevenue := R0 * T
// 计算Buff加成
buff := CalculateBuff(likeCount)
buffedRevenue := baseRevenue * (100 + int64(buff)) / 100
// 应用永久收益提升
if revenueBoostBps > 0 {
boost := buffedRevenue * int64(revenueBoostBps) / 10000
buffedRevenue += boost
}
return buffedRevenue, nil
}
func (s *assetLevelService) calculateDefaultRevenue(likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) {
R0 := int64(5) // 默认N级收益
T := (endTime - startTime) / 3600000
if T <= 0 {
T = 1
}
baseRevenue := R0 * T
buff := CalculateBuff(likeCount)
buffedRevenue := baseRevenue * (100 + int64(buff)) / 100
if revenueBoostBps > 0 {
boost := buffedRevenue * int64(revenueBoostBps) / 10000
buffedRevenue += boost
}
return buffedRevenue, nil
}
```
### 4.3 赛季重置逻辑(百分比降序:时长 + 点赞均按百分比衰减)
```go
// SeasonReset 赛季结束时执行百分比降序
// 规则:累计展出时长和点赞均按百分比衰减,然后继承到新赛季
// 注意:此方法在新赛季开始前调用,将旧赛季数据迁移到新赛季
func (s *assetLevelService) SeasonReset(seasonID string) error {
season, err := s.seasonRepo.GetByID(seasonID)
if err != nil || season == nil {
return fmt.Errorf("season not found: %s", seasonID)
}
if !season.ResetLevel {
return nil // 该赛季不重置等级
}
// 获取该赛季的降序百分比配置
decayConfig, err := s.seasonDecayConfigRepo.GetBySeason(seasonID)
if err != nil {
return err
}
// 构建等级→百分比映射
decayPercentMap := make(map[string]int)
for _, cfg := range decayConfig {
decayPercentMap[cfg.Level] = cfg.PreservePercent
}
// 获取当前赛季的所有藏品等级记录
records, err := s.repo.GetBySeason(seasonID)
if err != nil {
return err
}
// 计算新赛季ID基于旧赛季ID递增
nextSeasonID := s.calculateNextSeasonID(seasonID)
// 获取或创建新赛季配置(使用相同的降序配置模板)
nextSeason, err := s.seasonRepo.GetOrCreate(nextSeasonID, season.DurationDays, season.ResetStrategy)
for _, record := range records {
oldLevel := record.CurrentLevel
oldHours := record.SeasonExhibitionHours // 保存旧值用于日志
oldLikes := record.SeasonLikes // 保存旧值用于日志
// 获取该等级的降序保留百分比
preservePercent := decayPercentMap[record.CurrentLevel]
if preservePercent <= 0 {
preservePercent = 0
} else if preservePercent >= 100 {
preservePercent = 100
}
// 规则1累计展出时长按百分比衰减与点赞一致
// season_exhibition_hours = lifetime_exhibition_hours * preserve_percent / 100
record.SeasonExhibitionHours = record.LifetimeExhibitionHours * preservePercent / 100
// 规则2点赞按百分比衰减
// season_likes = lifetime_likes * preserve_percent / 100
record.SeasonLikes = record.LifetimeLikes * preservePercent / 100
// 重新计算等级(可能在降序后触发降级)
newLevel := s.recalculateLevelAfterDecay(record)
record.CurrentLevel = newLevel
// 更新为新赛季ID
record.SeasonID = nextSeasonID
if err := s.repo.Save(record); err != nil {
logger.Logger.Error("SeasonReset: failed to update record",
zap.Int64("asset_id", record.AssetID),
zap.Error(err))
continue
}
// 记录日志
if oldLevel != newLevel {
s.logLevelChange(record.AssetID, oldLevel, newLevel,
"season_decay", record.SeasonExhibitionHours, record.SeasonLikes,
fmt.Sprintf("赛季%s降序保留%d%%(时长:%d→%d点赞:%d→%d原等级%s→%s",
seasonID, preservePercent,
oldHours, record.SeasonExhibitionHours,
oldLikes, record.SeasonLikes,
oldLevel, newLevel))
} else {
logger.Logger.Info("SeasonReset: asset preserved",
zap.Int64("asset_id", record.AssetID),
zap.String("level", oldLevel),
zap.Int("season_exhibition_hours", record.SeasonExhibitionHours),
zap.Int("season_likes", record.SeasonLikes),
zap.Int("lifetime_exhibition_hours", record.LifetimeExhibitionHours),
zap.Int("lifetime_likes", record.LifetimeLikes))
}
}
// 更新旧赛季状态为已结束
season.Status = "ended"
if err := s.seasonRepo.Save(season); err != nil {
logger.Logger.Error("SeasonReset: failed to update old season status",
zap.String("season_id", seasonID),
zap.Error(err))
}
return nil
}
// calculateNextSeasonID 计算下一个赛季ID
func (s *assetLevelService) calculateNextSeasonID(currentSeasonID string) string {
// 假设 season_1 -> season_2
if len(currentSeasonID) > 0 && currentSeasonID[:7] == "season_" {
num := 0
fmt.Sscanf(currentSeasonID[7:], "%d", &num)
return fmt.Sprintf("season_%d", num+1)
}
return currentSeasonID + "_next"
}
// recalculateLevelAfterDecay 降序后重新计算等级
// 等级由 season_exhibition_hours 和 season_likes 共同决定
func (s *assetLevelService) recalculateLevelAfterDecay(record *models.AssetLevelRecord) string {
levels, err := s.GetAllLevels()
if err != nil {
return record.CurrentLevel // 保持当前等级
}
// 从低到高检查,找到符合条件的最高等级
newLevel := models.LevelN
for _, level := range levels {
if record.SeasonExhibitionHours >= level.RequireHours &&
record.SeasonLikes >= level.RequireLikes {
newLevel = level.Level
}
}
return newLevel
}
```
---
## 五、集成点设计
### 5.1 与 AssetService 集成
```go
// services/assetService/service/asset_service.go
// CreateAsset 创建资产时初始化等级记录
func (s *assetService) CreateAsset(ctx context.Context, req *CreateAssetRequest) (*CreateAssetResponse, error) {
asset, err := s.assetRepo.Create(assetModel)
if err != nil {
return nil, err
}
// 初始化藏品等级记录赛季ID在首次展出时更新
levelRecord := &models.AssetLevelRecord{
AssetID: asset.ID,
CurrentLevel: models.LevelN,
// SeasonID 初始为空,在首次展出时由 AssetLevelService 更新
}
if err := s.assetLevelRepo.Create(levelRecord); err != nil {
logger.Logger.Error("Failed to create asset level record",
zap.Int64("asset_id", asset.ID),
zap.Error(err))
// 不阻断资产创建流程,等级记录失败不影响主流程
}
return &CreateAssetResponse{AssetID: asset.ID}, nil
}
```
### 5.2 与 AssetLikeService 集成
```go
// services/assetService/service/asset_like_service.go
// LikeAsset 点赞成功后更新藏品等级
func (s *AssetLikeService) LikeAsset(ctx context.Context, assetID, userID, starID int64) (int32, error) {
// ... 现有点赞逻辑 ...
// 点赞成功后,更新藏品等级记录
if s.assetLevelService != nil {
newLevel, upgraded, err := s.assetLevelService.AddLikes(assetID, 1)
if err != nil {
logger.Logger.Warn("Failed to update asset level on like",
zap.Int64("asset_id", assetID),
zap.Error(err))
} else if upgraded {
logger.Logger.Info("Asset upgraded due to likes",
zap.Int64("asset_id", assetID),
zap.String("new_level", newLevel))
// TODO: 可发送通知给藏品所有者
}
}
return asset.LikeCount, nil
}
// UnlikeAsset 取消点赞时更新藏品等级
func (s *AssetLikeService) UnlikeAsset(ctx context.Context, assetID, userID, starID int64) (int32, error) {
// ... 现有取消点赞逻辑 ...
// 取消点赞成功后,减少藏品等级记录点赞数
if s.assetLevelService != nil {
_, _, err := s.assetLevelService.RemoveLikes(assetID, 1)
if err != nil {
logger.Logger.Warn("Failed to update asset level on unlike",
zap.Int64("asset_id", assetID),
zap.Error(err))
}
}
return asset.LikeCount, nil
}
```
### 5.3 与 RevenueService 集成
```go
// services/taskService/service/revenue_service.go
// OnExhibitionCompleted 展出完成时更新藏品等级
func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnExhibitionCompletedRequest) (*pb.OnExhibitionCompletedResponse, error) {
// ... 现有逻辑(创建收益记录等)...
// 更新藏品等级记录(展出时长)
if s.assetLevelService != nil {
exhibitionHours := (req.ExpireAt - req.StartTime) / 3600000
if exhibitionHours > 0 {
newLevel, upgraded, err := s.assetLevelService.AddExhibitionHours(req.AssetId, int(exhibitionHours))
if err != nil {
logger.Logger.Warn("Failed to update asset level on exhibition complete",
zap.Int64("asset_id", req.AssetId),
zap.Error(err))
} else if upgraded {
logger.Logger.Info("Asset upgraded due to exhibition",
zap.Int64("asset_id", req.AssetId),
zap.String("new_level", newLevel))
}
}
}
return resp, nil
}
// CalculateExhibitionRevenue 修改为调用藏品等级服务
func CalculateAssetLevelRevenue(assetLevelService AssetLevelService, assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) int64 {
revenue, _ := assetLevelService.CalculateRevenue(assetID, likeCount, startTime, endTime, revenueBoostBps)
return revenue
}
```
---
## 六、API 接口设计
### 6.1 HTTP API通过 Gateway
```protobuf
// proto/asset_level.proto
message GetAssetLevelRequest {
int64 asset_id = 1;
}
message GetAssetLevelResponse {
BaseResponse base = 1;
AssetLevelData data = 2;
}
message AssetLevelData {
int64 asset_id = 1;
string current_level = 2; // N, R, SR, SSR, UR
string season_id = 3; // 当前赛季ID
int season_exhibition_hours = 4; // 赛季内累计展出时长(用于等级计算)
int season_likes = 5; // 赛季内累计点赞数(用于等级计算)
int lifetime_exhibition_hours = 6; // 历史累计展出时长(永久保留)
int lifetime_likes = 7; // 历史累计点赞数(用于降序计算)
LevelConfig next_level = 8; // 下一等级信息为nil表示已满级
int hours_to_next_level = 9; // 距离下一等级还需要的时长(赛季内)
int likes_to_next_level = 10; // 距离下一等级还需要的点赞数(赛季内)
}
message LevelConfig {
string level = 1;
int hourly_revenue = 2;
int require_hours = 3;
int require_likes = 4;
}
message GetAssetLevelHistoryRequest {
int64 asset_id = 1;
int page = 2;
int page_size = 3;
}
message GetAssetLevelHistoryResponse {
BaseResponse base = 1;
repeated LevelChangeItem items = 2;
int64 total = 3;
}
message LevelChangeItem {
string from_level = 1;
string to_level = 2;
string trigger_type = 3;
int trigger_hours = 4;
int trigger_likes = 5;
string change_reason = 6;
int64 created_at = 7;
}
```
---
## 七、赛季制设计
### 7.1 赛季流程
```
赛季开始
藏品展出/获赞 → 累计成长值
赛季结束触发器(定时任务)
执行赛季重置逻辑
新赛季开始
```
### 7.2 赛季结束策略:时长 + 点赞均按百分比衰减
| 字段 | 处理规则 |
|------|----------|
| 累计展出时长 | **按百分比衰减**,通过 `season_decay_config` 配置每个等级的保留比例 |
| 累计点赞数 | **按百分比衰减**,通过 `season_decay_config` 配置每个等级的保留比例 |
> 两个字段独立计算衰减,互不影响
#### 降序示例
假设某 UR 藏品历史累计720 小时展出时长100,000 点赞
| 等级 | 保留比例 | 降序后赛季时长 | 降序后赛季点赞 | 降序后等级 | 说明 |
|:----:|:-------:|:------------:|:------------:|:---------:|:-----|
| N | 100% | 720h | 100,000 | N | 时长720h<24h保持N |
| R | 80% | 576h | 80,000 | R | 时长576h24h点赞80,00020仍为R |
| SR | 70% | 504h | 70,000 | SR | 时长504h120h点赞70,000500仍为SR |
| SSR | 60% | 432h | 60,000 | SR | 时长432h120h但点赞60,000<10,000降为SR |
| UR | 50% | 360h | 50,000 | SSR | 时长360h360h但点赞50,000<100,000降为SSR |
### 7.3 定时任务
```go
// services/taskService/worker/season_worker.go
package worker
// SeasonResetWorker 赛季重置定时任务
type SeasonResetWorker struct {
seasonRepo repository.SeasonRepository
levelService service.AssetLevelService
}
func (w *SeasonResetWorker) Run() {
// 每小时检查是否有赛季结束
seasons, err := w.seasonRepo.GetEndedSeasons()
if err != nil {
logger.Logger.Error("SeasonResetWorker: failed to get ended seasons", zap.Error(err))
return
}
for _, season := range seasons {
if err := w.levelService.SeasonReset(season.ID); err != nil {
logger.Logger.Error("SeasonResetWorker: failed to reset season",
zap.String("season_id", season.ID),
zap.Error(err))
continue
}
logger.Logger.Info("SeasonResetWorker: season reset completed",
zap.String("season_id", season.ID))
}
// 创建新赛季
w.createNextSeasonIfNeeded()
}
```
---
## 八、监控与运营
### 8.1 关键指标
| 指标 | 描述 | 告警阈值 |
|------|------|----------|
| asset_level_upgrade_total | 藏品升级总次数 | - |
| asset_level_distribution | 各等级藏品数量分布 | - |
| season_reset_success_rate | 赛季重置成功率 | < 99% |
| upgrade_trigger_exhibition | 展出触发升级数 | - |
| upgrade_trigger_like | 点赞触发升级数 | - |
### 8.2 日志规范
```go
// 等级升级日志
logger.Logger.Info("asset_level_upgraded",
zap.Int64("asset_id", assetID),
zap.String("from_level", fromLevel),
zap.String("to_level", toLevel),
zap.String("trigger_type", triggerType),
zap.Int("total_hours", totalHours),
zap.Int("total_likes", totalLikes))
// 赛季重置日志
logger.Logger.Info("season_reset_completed",
zap.String("season_id", seasonID),
zap.Int("total_assets", totalAssets),
zap.Int("preserved_count", preservedCount),
zap.Int("reset_count", resetCount))
```
---
## 九、与现有系统的区别
| 维度 | 现有用户等级系统 | 藏品等级系统新增 |
|------|----------------|---------------------|
| 升级主体 | 用户 (FanProfile) | 藏品 (Asset) |
| 等级标识 | 1-20 数字 | N/R/SR/SSR/UR |
| 升级条件 | 仅累计时长 | 时长 + 点赞双轨 |
| 赛季制 | | 支持 |
| 收益影响 | 用户水晶余额 | 藏品展出收益单价 |
| 等级回退 | | 赛季结束回退 |
| 初始等级 | 1级 | N级 |
---
## 十、前端展示规范
### 10.1 等级标签样式
藏品等级采用**标签文字 + 不同颜色背景**的方式展示
| 等级 | 标签文字 | 背景色 | 文字色 | 使用场景 |
|:----:|:--------:|:------:|:------:|:---------|
| N | N | #9CA3AF (灰色) | #FFFFFF | 普通藏品 |
| R | R | #3B82F6 (蓝色) | #FFFFFF | 稀有藏品 |
| SR | SR | #8B5CF6 (紫色) | #FFFFFF | 超稀有藏品 |
| SSR | SSR | #F59E0B (橙色) | #FFFFFF | 超超稀有藏品 |
| UR | UR | #EF4444 (红色) | #FFFFFF | 终极稀有藏品 |
### 10.2 展示位置
- 藏品卡片右下角显示等级标签
- 藏品详情页标题下方显示等级标签
- 藏品列表支持按等级筛选
### 10.3 样式实现
```vue
<!-- 等级标签组件 -->
<template>
<view class="asset-level-badge" :class="`level-${level}`">
{{ level }}
</view>
</template>
<style scoped>
.asset-level-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: bold;
color: #ffffff;
}
.level-N { background-color: #9CA3AF; }
.level-R { background-color: #3B82F6; }
.level-SR { background-color: #8B5CF6; }
.level-SSR { background-color: #F59E0B; }
.level-UR { background-color: #EF4444; }
</style>
```
### 10.4 等级名称映射
```javascript
const LEVEL_NAMES = {
'N': 'Normal',
'R': 'Rare',
'SR': 'Super Rare',
'SSR': 'Super² Rare',
'UR': 'Ultimate Rare'
}
const LEVEL_NAMES_CN = {
'N': '普通',
'R': '稀有',
'SR': '超稀有',
'SSR': '超超稀有',
'UR': '终极稀有'
}
```
---
## 附录 A数据库变更汇总
```sql
-- 新增表
CREATE TABLE asset_levels (...);
CREATE TABLE asset_level_records (...);
CREATE TABLE asset_level_change_logs (...);
CREATE TABLE seasons (...);
-- 初始化数据
INSERT INTO asset_levels (...);
INSERT INTO seasons (...);
-- 无需修改现有表结构
```
---
## 附录 B推荐阅读
- [资产服务设计方案](./资产服务设计文档.md)
- [展馆服务设计方案](./展馆服务设计方案.md)
- [资产点赞功能实现总结](./资产点赞功能完整实现总结.md)