39 KiB
39 KiB
数字藏品升级系统设计方案
文档版本:v1.0 创建日期:2026-05-21 状态:待评审
一、背景与目标
1.1 需求背景
当前系统中的"等级"体系是针对用户的等级(1-20级),基于用户累计展出时长进行升级。现需要新增一套针对单个藏品的等级体系,形成双轨制升级机制。
核心目标:
- 通过藏品等级激励用户持续创作和"铸爱"
- 高等级藏品享有更高的展出收益,形成经济正循环
- 赛季制设计避免等级通货膨胀,保持玩家竞争动力
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
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
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
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(新增)
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(新增)
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 初始数据
-- 藏品等级配置初始数据
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 接口
// 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 升级检查逻辑
// 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 点赞更新逻辑
// 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 收益计算逻辑
// 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 赛季重置逻辑(百分比降序:时长 + 点赞均按百分比衰减)
// 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 集成
// 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 集成
// 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 集成
// 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)
// 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 | 时长576h≥24h,点赞80,000≥20,仍为R |
| SR | 70% | 504h | 70,000 | SR | 时长504h≥120h,点赞70,000≥500,仍为SR |
| SSR | 60% | 432h | 60,000 | SR | 时长432h≥120h,但点赞60,000<10,000,降为SR |
| UR | 50% | 360h | 50,000 | SSR | 时长360h≥360h,但点赞50,000<100,000,降为SSR |
7.3 定时任务
// 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 日志规范
// 等级升级日志
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 样式实现
<!-- 等级标签组件 -->
<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 等级名称映射
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:数据库变更汇总
-- 新增表
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 (...);
-- 无需修改现有表结构