# 数字藏品升级系统设计方案 > 文档版本: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 | 时长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 定时任务 ```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 ``` ### 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)