From 955c5f45c3d97185d47e492c81e279d2da442059 Mon Sep 17 00:00:00 2001 From: zheng020 Date: Thu, 21 May 2026 14:18:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A03d=E6=97=8B=E8=BD=AC=E7=9A=84?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docs/藏品升级系统设计方案.md | 1184 ++++++++++++++++++ frontend/pages/asset-detail/asset-detail.vue | 17 +- 2 files changed, 1185 insertions(+), 16 deletions(-) create mode 100644 backend/docs/藏品升级系统设计方案.md 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 + + + + +``` + +### 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; }