diff --git a/backend/docs/藏品升级系统设计方案.md b/backend/docs/藏品升级系统设计方案.md
new file mode 100644
index 0000000..d9d3172
--- /dev/null
+++ b/backend/docs/藏品升级系统设计方案.md
@@ -0,0 +1,1184 @@
+# 数字藏品升级系统设计方案
+
+> 文档版本: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
+
+
+
+ {{ level }}
+
+
+
+
+```
+
+### 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)
\ No newline at end of file
diff --git a/frontend/pages/asset-detail/asset-detail.vue b/frontend/pages/asset-detail/asset-detail.vue
index 424405b..f021fe3 100644
--- a/frontend/pages/asset-detail/asset-detail.vue
+++ b/frontend/pages/asset-detail/asset-detail.vue
@@ -968,21 +968,6 @@ onUnmounted(() => {
transform-origin: center center;
}
-.card-wrapper::after {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: conic-gradient(from 0deg at 50% 50%, transparent 0deg, rgba(255, 131, 153, 0.7) 30deg, transparent 60deg);
- opacity: 0;
- animation: card-glint 15s ease-in-out infinite;
- pointer-events: none;
- border-radius: 48rpx;
- mix-blend-mode: overlay;
-}
-
@keyframes card-3d-flip {
0% {
transform: rotateY(0deg);
@@ -1013,7 +998,7 @@ onUnmounted(() => {
width: 78%;
height: 96%;
border-radius: 64rpx;
- transform: translate(-50%, -50%) ;
+ transform: translate(-50%, -50%) rotate(-10deg);
z-index: 2;
overflow: hidden;
}