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

39 KiB
Raw Blame History

数字藏品升级系统设计方案

文档版本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

CREATE TABLE asset_levels (
    id BIGSERIAL PRIMARY KEY,
    level VARCHAR(10) NOT NULL UNIQUE,      -- N, R, SR, SSR, UR
    level_order INT NOT NULL,               -- 等级顺序1-5用于排序和比较
    hourly_revenue INT NOT NULL,            -- 每小时基础收益(水晶)
    require_hours INT NOT NULL,             -- 升级到该等级所需累计展出时长(小时)
    require_likes INT NOT NULL,             -- 升级到该等级所需累计点赞数
    is_initial BOOLEAN DEFAULT FALSE,       -- 是否为初始等级
    created_at BIGINT NOT NULL,
    updated_at BIGINT NOT NULL
);

COMMENT ON TABLE asset_levels IS '藏品等级配置表,定义各等级的基础参数和升级条件';

3.1.2 藏品等级记录表 asset_level_records

CREATE TABLE asset_level_records (
    id BIGSERIAL PRIMARY KEY,
    asset_id BIGINT NOT NULL UNIQUE,
    current_level VARCHAR(10) NOT NULL DEFAULT 'N',
    -- 赛季内累计(用于等级计算,会在赛季降序时按百分比衰减)
    season_exhibition_hours INT NOT NULL DEFAULT 0,   -- 赛季内累计展出时长(小时)
    season_likes INT NOT NULL DEFAULT 0,             -- 赛季内累计点赞数
    -- 历史累计(不受赛季影响,用于赛季降序计算)
    lifetime_exhibition_hours INT NOT NULL DEFAULT 0,  -- 历史累计展出时长(跨赛季保留)
    lifetime_likes INT NOT NULL DEFAULT 0,             -- 历史累计点赞数(跨赛季,按百分比衰减)
    -- 降序计算用:基于历史累计计算降序后的赛季值
    -- season_xxx = lifetime_xxx * preserve_percent / 100
    season_id VARCHAR(50),                              -- 当前赛季ID
    updated_at BIGINT NOT NULL,
    CONSTRAINT uk_asset_level_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
);

CREATE INDEX idx_asset_level_season ON asset_level_records(season_id);
CREATE INDEX idx_asset_level_current_level ON asset_level_records(current_level);

COMMENT ON TABLE asset_level_records IS '藏品等级记录表,记录每个藏品的当前等级和成长值;
赛季内累计season_xxx用于等级计算赛季降序时按百分比衰减
历史累计lifetime_xxx跨赛季保留赛季降序时按百分比继承到赛季内累计';

3.1.3 藏品等级变化日志表 asset_level_change_logs

CREATE TABLE asset_level_change_logs (
    id BIGSERIAL PRIMARY KEY,
    asset_id BIGINT NOT NULL,
    from_level VARCHAR(10),
    to_level VARCHAR(10) NOT NULL,
    trigger_type VARCHAR(20) NOT NULL,      -- exhibition_complete: 展出完成, like_update: 点赞更新, season_reset: 赛季重置
    trigger_hours INT DEFAULT 0,            -- 触发升级时的时长
    trigger_likes INT DEFAULT 0,            -- 触发升级时的点赞数
    change_reason VARCHAR(255),             -- 详细原因描述
    created_at BIGINT NOT NULL
);

CREATE INDEX idx_asset_level_log_asset ON asset_level_change_logs(asset_id);
CREATE INDEX idx_asset_level_log_created ON asset_level_change_logs(created_at DESC);

COMMENT ON TABLE asset_level_change_logs IS '藏品等级变化日志表,记录所有等级变动历史';

3.1.4 赛季配置表 seasons(新增)

CREATE TABLE seasons (
    id VARCHAR(50) PRIMARY KEY,
    name VARCHAR(100) NOT NULL,             -- 赛季名称,如"第一赛季"
    duration_days INT NOT NULL DEFAULT 84,  -- 赛季持续天数可配置默认84天≈12周
    start_time BIGINT NOT NULL,             -- 开始时间(毫秒时间戳)
    end_time BIGINT NOT NULL,               -- 结束时间(毫秒时间戳,由 start_time + duration_days 计算得出)
    reset_strategy VARCHAR(20) DEFAULT 'percentage_decay',  -- reset_all / percentage_decay / keep_all
    reset_level BOOLEAN DEFAULT TRUE,       -- 是否重置等级
    status VARCHAR(20) DEFAULT 'active',    -- active/ended
    created_at BIGINT NOT NULL,
    updated_at BIGINT NOT NULL
);

COMMENT ON TABLE seasons IS '赛季配置表,支持百分比降序策略;
注意end_time 由 CalculateEndTime() 方法计算得出,或在创建/更新时同步写入';

3.1.5 赛季降序百分比配置表 season_decay_config(新增)

CREATE TABLE season_decay_config (
    id BIGSERIAL PRIMARY KEY,
    season_id VARCHAR(50) NOT NULL,         -- 赛季ID
    level VARCHAR(10) NOT NULL,            -- 等级N/R/SR/SSR/UR
    preserve_percent INT NOT NULL DEFAULT 100,  -- 降序时保留百分比0-100
    updated_at BIGINT NOT NULL,
    CONSTRAINT uk_season_level UNIQUE (season_id, level)
);

COMMENT ON TABLE season_decay_config IS '赛季降序百分比配置每个等级的保留比例0-100
注意:每个等级在每个赛季都有独立的保留比例配置';

-- 示例数据(可按需调整)
INSERT INTO season_decay_config (season_id, level, preserve_percent, updated_at) VALUES
('season_1', 'N', 100, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),   -- N级保持100%(不降序)
('season_1', 'R', 80, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),    -- R级保留80%
('season_1', 'SR', 70, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),   -- SR级保留70%
('season_1', 'SSR', 60, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)), -- SSR级保留60%
('season_1', 'UR', 50, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000))   -- UR级保留50%
ON CONFLICT (season_id, level) DO NOTHING;

### 3.2 模型定义

#### pkg/models/asset_level.go

```go
package models

import "time"

// AssetLevel 藏品等级配置
type AssetLevel struct {
    ID            int64  `gorm:"primaryKey;autoIncrement"`
    Level         string `gorm:"type:varchar(10);unique;not null"`
    LevelOrder    int    `gorm:"not null"`
    HourlyRevenue int    `gorm:"not null"`
    RequireHours  int    `gorm:"not null"`
    RequireLikes  int    `gorm:"not null"`
    IsInitial     bool   `gorm:"default:false"`
    CreatedAt     int64  `gorm:"not null"`
    UpdatedAt     int64  `gorm:"not null"`
}

func (AssetLevel) TableName() string { return "asset_levels" }

// AssetLevelRecord 藏品等级记录
type AssetLevelRecord struct {
    ID                     int64  `gorm:"primaryKey;autoIncrement"`
    AssetID                int64  `gorm:"unique;not null"`
    CurrentLevel           string `gorm:"type:varchar(10);not null;default:'N'"`
    // 赛季内累计(用于等级计算,赛季降序时按百分比衰减)
    SeasonExhibitionHours int    `gorm:"default:0;not null"`
    SeasonLikes            int    `gorm:"default:0;not null"`
    // 历史累计(跨赛季保留,赛季降序时按百分比继承到赛季内累计)
    LifetimeExhibitionHours int  `gorm:"default:0;not null"`
    LifetimeLikes          int    `gorm:"default:0;not null"`
    SeasonID               string `gorm:"type:varchar(50)"`
    UpdatedAt              int64  `gorm:"not null"`
}

func (AssetLevelRecord) TableName() string { return "asset_level_records" }

// AssetLevelChangeLog 等级变化日志
type AssetLevelChangeLog struct {
    ID            int64  `gorm:"primaryKey;autoIncrement"`
    AssetID       int64  `gorm:"not null;index"`
    FromLevel     string `gorm:"type:varchar(10)"`
    ToLevel       string `gorm:"type:varchar(10);not null"`
    TriggerType   string `gorm:"type:varchar(20);not null"`
    TriggerHours  int    `gorm:"default:0"`
    TriggerLikes  int    `gorm:"default:0"`
    ChangeReason  string `gorm:"type:varchar(255)"`
    CreatedAt     int64  `gorm:"not null;index"`
}

func (AssetLevelChangeLog) TableName() string { return "asset_level_change_logs" }

// Season 赛季配置
type Season struct {
    ID           string `gorm:"primaryKey;type:varchar(50)"`
    Name         string `gorm:"type:varchar(100);not null"`
    DurationDays int    `gorm:"not null;default:84"`         // 赛季持续天数(可配置)
    StartTime    int64  `gorm:"not null"`
    EndTime      int64  `gorm:"not null"`
    ResetStrategy string `gorm:"type:varchar(20);default:'percentage_decay'"`  // reset_all / percentage_decay / keep_all
    ResetLevel   bool   `gorm:"default:true"`
    Status       string `gorm:"type:varchar(20);default:'active'"`
    CreatedAt    int64  `gorm:"not null"`
    UpdatedAt    int64  `gorm:"not null"`
}

func (Season) TableName() string { return "seasons" }

// 计算赛季结束时间
func (s *Season) CalculateEndTime() int64 {
    return s.StartTime + int64(s.DurationDays)*86400000
}

// BeforeCreate 创建前钩子:自动计算 end_time
func (s *Season) BeforeCreate(tx *gorm.DB) error {
    now := time.Now().UnixMilli()
    s.CreatedAt = now
    s.UpdatedAt = now
    s.EndTime = s.CalculateEndTime()
    if s.Status == "" {
        s.Status = "active"
    }
    return nil
}

// BeforeUpdate 更新前钩子:自动更新 end_time
func (s *Season) BeforeUpdate(tx *gorm.DB) error {
    s.UpdatedAt = time.Now().UnixMilli()
    s.EndTime = s.CalculateEndTime()
    return nil
}

// SeasonDecayConfig 赛季降序百分比配置
type SeasonDecayConfig struct {
    ID              int64  `gorm:"primaryKey;autoIncrement"`
    SeasonID        string `gorm:"type:varchar(50);not null;uniqueIndex:uk_season_level"`
    Level           string `gorm:"type:varchar(10);not null;uniqueIndex:uk_season_level"`
    PreservePercent int    `gorm:"not null;default:100"`  // 降序时保留百分比0-100
    UpdatedAt       int64  `gorm:"not null"`
}

func (SeasonDecayConfig) TableName() string { return "season_decay_config" }

// 等级常量
const (
    LevelN  = "N"
    LevelR  = "R"
    LevelSR = "SR"
    LevelSSR = "SSR"
    LevelUR = "UR"
)

// 等级顺序映射
var LevelOrderMap = map[string]int{
    LevelN:   1,
    LevelR:   2,
    LevelSR:  3,
    LevelSSR: 4,
    LevelUR:  5,
}

3.3 初始数据

-- 藏品等级配置初始数据
INSERT INTO asset_levels (level, level_order, hourly_revenue, require_hours, require_likes, is_initial, created_at, updated_at) VALUES
('N',   1,  5,    0,       0,       TRUE,  ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('R',   2,  7,    24,      20,      FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('SR',  3,  12,   120,     500,     FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('SSR', 4,  18,   360,     10000,   FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('UR',  5,  30,   720,     100000,  FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000))
ON CONFLICT (level) DO NOTHING;

-- 赛季配置初始数据duration_days 可按需调整)
INSERT INTO seasons (id, name, duration_days, start_time, end_time, reset_strategy, reset_level, status, created_at, updated_at) VALUES
('season_1', '第一赛季', 84, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000) + 86400000 * 84, 'percentage_decay', TRUE, 'active', ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000))
ON CONFLICT (id) DO NOTHING;
COMMENT ON COLUMN seasons.reset_strategy IS '重置策略: reset_all(全部重置N) / percentage_decay(按配置百分比降序) / keep_all(保持不变)';

四、服务层设计

4.1 AssetLevelService 接口

// service/asset_level_service.go

package service

// AssetLevelService 藏品等级服务接口
type AssetLevelService interface {
    // GetOrCreateRecord 获取或创建藏品等级记录
    GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error)

    // GetRecordByAssetID 根据资产ID获取等级记录
    GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error)

    // GetLevelConfig 获取等级配置
    GetLevelConfig(level string) (*models.AssetLevel, error)

    // GetAllLevels 获取所有等级配置(按顺序)
    GetAllLevels() ([]*models.AssetLevel, error)

    // AddExhibitionHours 增加展出时长并检查升级
    AddExhibitionHours(assetID int64, hours int) (string, bool, error)

    // AddLikes 增加点赞数并检查升级
    AddLikes(assetID int64, count int) (string, bool, error)

    // RemoveLikes 减少点赞数(用于取消点赞)
    RemoveLikes(assetID int64, count int) (string, bool, error)

    // CalculateRevenue 计算单次展出收益(基于藏品等级)
    CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error)

    // SeasonReset 赛季重置(赛季结束时调用)
    SeasonReset(seasonID string) error

    // GetCurrentSeason 获取当前赛季
    GetCurrentSeason() (*models.Season, error)

    // getDefaultSeason 获取默认赛季(当无活跃赛季时返回默认赛季)
    getDefaultSeason() *models.Season
}

4.2 核心业务逻辑

4.2.1 升级检查逻辑

// service/asset_level_service.go

// CheckUpgrade 检查是否可以升级(使用赛季内累计)
// 升级条件:同时满足累计时长和累计点赞数门槛
func (s *assetLevelService) CheckUpgrade(record *models.AssetLevelRecord) (string, bool) {
    levels, err := s.GetAllLevels()
    if err != nil {
        return record.CurrentLevel, false
    }

    currentOrder := models.LevelOrderMap[record.CurrentLevel]

    // 从高到低检查(优先匹配最高可达到的等级)
    // 确保升级目标等级比当前等级高
    for i := len(levels) - 1; i >= 0; i-- {
        level := levels[i]

        // 必须是更高的等级
        if level.LevelOrder <= currentOrder {
            continue
        }

        // 检查是否满足该等级的升级条件(同时满足时长和点赞)
        if record.SeasonExhibitionHours >= level.RequireHours &&
           record.SeasonLikes >= level.RequireLikes {
            return level.Level, true
        }
    }

    return record.CurrentLevel, false
}

// CheckDowngrade 检查是否需要降级(使用赛季内累计)
func (s *assetLevelService) CheckDowngrade(record *models.AssetLevelRecord) (string, bool) {
    levels, err := s.GetAllLevels()
    if err != nil {
        return record.CurrentLevel, false
    }

    currentOrder := models.LevelOrderMap[record.CurrentLevel]

    // 找到当前等级的配置
    currentLevelConfig := s.getLevelConfigByOrder(currentOrder)

    // 检查当前等级条件是否还满足
    if currentLevelConfig != nil &&
       record.SeasonExhibitionHours >= currentLevelConfig.RequireHours &&
       record.SeasonLikes >= currentLevelConfig.RequireLikes {
        // 当前等级条件仍满足,不降级
        return record.CurrentLevel, false
    }

    // 条件不满足,找到满足条件的最高等级
    newLevel := models.LevelN
    for _, level := range levels {
        if record.SeasonExhibitionHours >= level.RequireHours &&
           record.SeasonLikes >= level.RequireLikes {
            newLevel = level.Level
        }
    }

    if newLevel != record.CurrentLevel {
        return newLevel, true
    }

    return record.CurrentLevel, false
}

// getLevelConfigByOrder 根据等级顺序获取等级配置
func (s *assetLevelService) getLevelConfigByOrder(order int) *models.AssetLevel {
    levels, _ := s.GetAllLevels()
    for _, level := range levels {
        if level.LevelOrder == order {
            return level
        }
    }
    return nil
}

// AddExhibitionHours 增加展出时长
// 注意:时长和点赞的衰减在赛季重置时处理,不在此处
func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int) (string, bool, error) {
    record, err := s.GetOrCreateRecord(assetID)
    if err != nil {
        return "", false, err
    }

    oldLevel := record.CurrentLevel

    // 如果是首次展出SeasonID为空关联当前赛季
    if record.SeasonID == "" {
        season, err := s.GetCurrentSeason()
        if err != nil {
            season = s.getDefaultSeason()
        }
        record.SeasonID = season.ID
    }

    // 赛季内累计 += 本次时长
    record.SeasonExhibitionHours += hours
    // 历史累计永久保留
    record.LifetimeExhibitionHours += hours

    // 检查升级(使用赛季内累计)
    newLevel, upgraded := s.CheckUpgrade(record)
    if upgraded {
        record.CurrentLevel = newLevel
    }

    if err := s.repo.Save(record); err != nil {
        return "", false, err
    }

    // 记录日志
    if upgraded && newLevel != oldLevel {
        s.logLevelChange(record.AssetID, oldLevel, newLevel,
            "exhibition_complete", record.SeasonExhibitionHours, record.SeasonLikes,
            fmt.Sprintf("展出完成,时长+%d小时赛季内:%d 历史:%d", hours, record.SeasonExhibitionHours, record.LifetimeExhibitionHours))
    }

    return newLevel, upgraded, nil
}

4.2.2 点赞更新逻辑

// AddLikes 增加点赞数
func (s *assetLevelService) AddLikes(assetID int64, count int) (string, bool, error) {
    record, err := s.GetOrCreateRecord(assetID)
    if err != nil {
        return "", false, err
    }

    oldLevel := record.CurrentLevel

    // 如果是首次获赞SeasonID为空关联当前赛季
    if record.SeasonID == "" {
        season, err := s.GetCurrentSeason()
        if err != nil {
            season = s.getDefaultSeason()
        }
        record.SeasonID = season.ID
    }

    // 赛季内累计 += 本次点赞
    record.SeasonLikes += count
    // 历史累计(用于降序计算)
    record.LifetimeLikes += count

    // 检查升级(使用赛季内累计)
    newLevel, upgraded := s.CheckUpgrade(record)
    if upgraded {
        record.CurrentLevel = newLevel
    }

    if err := s.repo.Save(record); err != nil {
        return "", false, err
    }

    // 记录日志
    if upgraded && newLevel != oldLevel {
        s.logLevelChange(record.AssetID, oldLevel, newLevel,
            "like_update", record.SeasonExhibitionHours, record.SeasonLikes,
            fmt.Sprintf("点赞数达到%d触发升级", record.SeasonLikes))
    }

    return newLevel, upgraded, nil
}

// RemoveLikes 减少点赞数(用于取消点赞)
func (s *assetLevelService) RemoveLikes(assetID int64, count int) (string, bool, error) {
    record, err := s.GetOrCreateRecord(assetID)
    if err != nil {
        return "", false, err
    }

    oldLevel := record.CurrentLevel

    // 赛季内累计 -= 本次点赞
    record.SeasonLikes -= count
    if record.SeasonLikes < 0 {
        record.SeasonLikes = 0
    }
    // 历史累计也减少(用于降序计算)
    record.LifetimeLikes -= count
    if record.LifetimeLikes < 0 {
        record.LifetimeLikes = 0
    }

    // 检查是否需要降级(使用赛季内累计)
    newLevel, downgraded := s.CheckDowngrade(record)
    if downgraded {
        record.CurrentLevel = newLevel
    }

    if err := s.repo.Save(record); err != nil {
        return "", false, err
    }

    // 记录日志
    if downgraded && newLevel != oldLevel {
        s.logLevelChange(record.AssetID, oldLevel, newLevel,
            "like_remove", record.SeasonExhibitionHours, record.SeasonLikes,
            fmt.Sprintf("取消点赞,点赞数降至%d触发降级", record.SeasonLikes))
    }

    return newLevel, downgraded, nil
}

4.2.3 收益计算逻辑

// CalculateRevenue 基于藏品等级计算收益
func (s *assetLevelService) CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) {
    record, err := s.GetRecordByAssetID(assetID)
    if err != nil || record == nil {
        // 无等级记录,使用默认配置
        return s.calculateDefaultRevenue(likeCount, startTime, endTime, revenueBoostBps)
    }

    levelConfig, err := s.GetLevelConfig(record.CurrentLevel)
    if err != nil {
        return s.calculateDefaultRevenue(likeCount, startTime, endTime, revenueBoostBps)
    }

    // 计算展出时长(小时)
    T := (endTime - startTime) / 3600000
    if T <= 0 {
        T = 1
    }

    // 基础收益 = 等级小时收益 × 时长
    R0 := int64(levelConfig.HourlyRevenue)
    baseRevenue := R0 * T

    // 计算Buff加成
    buff := CalculateBuff(likeCount)
    buffedRevenue := baseRevenue * (100 + int64(buff)) / 100

    // 应用永久收益提升
    if revenueBoostBps > 0 {
        boost := buffedRevenue * int64(revenueBoostBps) / 10000
        buffedRevenue += boost
    }

    return buffedRevenue, nil
}

func (s *assetLevelService) calculateDefaultRevenue(likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) {
    R0 := int64(5) // 默认N级收益
    T := (endTime - startTime) / 3600000
    if T <= 0 {
        T = 1
    }

    baseRevenue := R0 * T
    buff := CalculateBuff(likeCount)
    buffedRevenue := baseRevenue * (100 + int64(buff)) / 100

    if revenueBoostBps > 0 {
        boost := buffedRevenue * int64(revenueBoostBps) / 10000
        buffedRevenue += boost
    }

    return buffedRevenue, nil
}

4.3 赛季重置逻辑(百分比降序:时长 + 点赞均按百分比衰减)

// SeasonReset 赛季结束时执行百分比降序
// 规则:累计展出时长和点赞均按百分比衰减,然后继承到新赛季
// 注意:此方法在新赛季开始前调用,将旧赛季数据迁移到新赛季
func (s *assetLevelService) SeasonReset(seasonID string) error {
    season, err := s.seasonRepo.GetByID(seasonID)
    if err != nil || season == nil {
        return fmt.Errorf("season not found: %s", seasonID)
    }

    if !season.ResetLevel {
        return nil // 该赛季不重置等级
    }

    // 获取该赛季的降序百分比配置
    decayConfig, err := s.seasonDecayConfigRepo.GetBySeason(seasonID)
    if err != nil {
        return err
    }

    // 构建等级→百分比映射
    decayPercentMap := make(map[string]int)
    for _, cfg := range decayConfig {
        decayPercentMap[cfg.Level] = cfg.PreservePercent
    }

    // 获取当前赛季的所有藏品等级记录
    records, err := s.repo.GetBySeason(seasonID)
    if err != nil {
        return err
    }

    // 计算新赛季ID基于旧赛季ID递增
    nextSeasonID := s.calculateNextSeasonID(seasonID)

    // 获取或创建新赛季配置(使用相同的降序配置模板)
    nextSeason, err := s.seasonRepo.GetOrCreate(nextSeasonID, season.DurationDays, season.ResetStrategy)

    for _, record := range records {
        oldLevel := record.CurrentLevel
        oldHours := record.SeasonExhibitionHours  // 保存旧值用于日志
        oldLikes := record.SeasonLikes             // 保存旧值用于日志

        // 获取该等级的降序保留百分比
        preservePercent := decayPercentMap[record.CurrentLevel]
        if preservePercent <= 0 {
            preservePercent = 0
        } else if preservePercent >= 100 {
            preservePercent = 100
        }

        // 规则1累计展出时长按百分比衰减与点赞一致
        // season_exhibition_hours = lifetime_exhibition_hours * preserve_percent / 100
        record.SeasonExhibitionHours = record.LifetimeExhibitionHours * preservePercent / 100

        // 规则2点赞按百分比衰减
        // season_likes = lifetime_likes * preserve_percent / 100
        record.SeasonLikes = record.LifetimeLikes * preservePercent / 100

        // 重新计算等级(可能在降序后触发降级)
        newLevel := s.recalculateLevelAfterDecay(record)
        record.CurrentLevel = newLevel

        // 更新为新赛季ID
        record.SeasonID = nextSeasonID

        if err := s.repo.Save(record); err != nil {
            logger.Logger.Error("SeasonReset: failed to update record",
                zap.Int64("asset_id", record.AssetID),
                zap.Error(err))
            continue
        }

        // 记录日志
        if oldLevel != newLevel {
            s.logLevelChange(record.AssetID, oldLevel, newLevel,
                "season_decay", record.SeasonExhibitionHours, record.SeasonLikes,
                fmt.Sprintf("赛季%s降序保留%d%%(时长:%d→%d点赞:%d→%d原等级%s→%s",
                    seasonID, preservePercent,
                    oldHours, record.SeasonExhibitionHours,
                    oldLikes, record.SeasonLikes,
                    oldLevel, newLevel))
        } else {
            logger.Logger.Info("SeasonReset: asset preserved",
                zap.Int64("asset_id", record.AssetID),
                zap.String("level", oldLevel),
                zap.Int("season_exhibition_hours", record.SeasonExhibitionHours),
                zap.Int("season_likes", record.SeasonLikes),
                zap.Int("lifetime_exhibition_hours", record.LifetimeExhibitionHours),
                zap.Int("lifetime_likes", record.LifetimeLikes))
        }
    }

    // 更新旧赛季状态为已结束
    season.Status = "ended"
    if err := s.seasonRepo.Save(season); err != nil {
        logger.Logger.Error("SeasonReset: failed to update old season status",
            zap.String("season_id", seasonID),
            zap.Error(err))
    }

    return nil
}

// calculateNextSeasonID 计算下一个赛季ID
func (s *assetLevelService) calculateNextSeasonID(currentSeasonID string) string {
    // 假设 season_1 -> season_2
    if len(currentSeasonID) > 0 && currentSeasonID[:7] == "season_" {
        num := 0
        fmt.Sscanf(currentSeasonID[7:], "%d", &num)
        return fmt.Sprintf("season_%d", num+1)
    }
    return currentSeasonID + "_next"
}

// recalculateLevelAfterDecay 降序后重新计算等级
// 等级由 season_exhibition_hours 和 season_likes 共同决定
func (s *assetLevelService) recalculateLevelAfterDecay(record *models.AssetLevelRecord) string {
    levels, err := s.GetAllLevels()
    if err != nil {
        return record.CurrentLevel // 保持当前等级
    }

    // 从低到高检查,找到符合条件的最高等级
    newLevel := models.LevelN
    for _, level := range levels {
        if record.SeasonExhibitionHours >= level.RequireHours &&
           record.SeasonLikes >= level.RequireLikes {
            newLevel = level.Level
        }
    }

    return newLevel
}

五、集成点设计

5.1 与 AssetService 集成

// services/assetService/service/asset_service.go

// CreateAsset 创建资产时初始化等级记录
func (s *assetService) CreateAsset(ctx context.Context, req *CreateAssetRequest) (*CreateAssetResponse, error) {
    asset, err := s.assetRepo.Create(assetModel)
    if err != nil {
        return nil, err
    }

    // 初始化藏品等级记录赛季ID在首次展出时更新
    levelRecord := &models.AssetLevelRecord{
        AssetID:      asset.ID,
        CurrentLevel: models.LevelN,
        // SeasonID 初始为空,在首次展出时由 AssetLevelService 更新
    }
    if err := s.assetLevelRepo.Create(levelRecord); err != nil {
        logger.Logger.Error("Failed to create asset level record",
            zap.Int64("asset_id", asset.ID),
            zap.Error(err))
        // 不阻断资产创建流程,等级记录失败不影响主流程
    }

    return &CreateAssetResponse{AssetID: asset.ID}, nil
}

5.2 与 AssetLikeService 集成

// services/assetService/service/asset_like_service.go

// LikeAsset 点赞成功后更新藏品等级
func (s *AssetLikeService) LikeAsset(ctx context.Context, assetID, userID, starID int64) (int32, error) {
    // ... 现有点赞逻辑 ...

    // 点赞成功后,更新藏品等级记录
    if s.assetLevelService != nil {
        newLevel, upgraded, err := s.assetLevelService.AddLikes(assetID, 1)
        if err != nil {
            logger.Logger.Warn("Failed to update asset level on like",
                zap.Int64("asset_id", assetID),
                zap.Error(err))
        } else if upgraded {
            logger.Logger.Info("Asset upgraded due to likes",
                zap.Int64("asset_id", assetID),
                zap.String("new_level", newLevel))
            // TODO: 可发送通知给藏品所有者
        }
    }

    return asset.LikeCount, nil
}

// UnlikeAsset 取消点赞时更新藏品等级
func (s *AssetLikeService) UnlikeAsset(ctx context.Context, assetID, userID, starID int64) (int32, error) {
    // ... 现有取消点赞逻辑 ...

    // 取消点赞成功后,减少藏品等级记录点赞数
    if s.assetLevelService != nil {
        _, _, err := s.assetLevelService.RemoveLikes(assetID, 1)
        if err != nil {
            logger.Logger.Warn("Failed to update asset level on unlike",
                zap.Int64("asset_id", assetID),
                zap.Error(err))
        }
    }

    return asset.LikeCount, nil
}

5.3 与 RevenueService 集成

// services/taskService/service/revenue_service.go

// OnExhibitionCompleted 展出完成时更新藏品等级
func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnExhibitionCompletedRequest) (*pb.OnExhibitionCompletedResponse, error) {
    // ... 现有逻辑(创建收益记录等)...

    // 更新藏品等级记录(展出时长)
    if s.assetLevelService != nil {
        exhibitionHours := (req.ExpireAt - req.StartTime) / 3600000
        if exhibitionHours > 0 {
            newLevel, upgraded, err := s.assetLevelService.AddExhibitionHours(req.AssetId, int(exhibitionHours))
            if err != nil {
                logger.Logger.Warn("Failed to update asset level on exhibition complete",
                    zap.Int64("asset_id", req.AssetId),
                    zap.Error(err))
            } else if upgraded {
                logger.Logger.Info("Asset upgraded due to exhibition",
                    zap.Int64("asset_id", req.AssetId),
                    zap.String("new_level", newLevel))
            }
        }
    }

    return resp, nil
}

// CalculateExhibitionRevenue 修改为调用藏品等级服务
func CalculateAssetLevelRevenue(assetLevelService AssetLevelService, assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) int64 {
    revenue, _ := assetLevelService.CalculateRevenue(assetID, likeCount, startTime, endTime, revenueBoostBps)
    return revenue
}

六、API 接口设计

6.1 HTTP API通过 Gateway

// proto/asset_level.proto

message GetAssetLevelRequest {
    int64 asset_id = 1;
}

message GetAssetLevelResponse {
    BaseResponse base = 1;
    AssetLevelData data = 2;
}

message AssetLevelData {
    int64 asset_id = 1;
    string current_level = 2;              // N, R, SR, SSR, UR
    string season_id = 3;                  // 当前赛季ID
    int season_exhibition_hours = 4;       // 赛季内累计展出时长(用于等级计算)
    int season_likes = 5;                  // 赛季内累计点赞数(用于等级计算)
    int lifetime_exhibition_hours = 6;     // 历史累计展出时长(永久保留)
    int lifetime_likes = 7;                // 历史累计点赞数(用于降序计算)
    LevelConfig next_level = 8;            // 下一等级信息为nil表示已满级
    int hours_to_next_level = 9;           // 距离下一等级还需要的时长(赛季内)
    int likes_to_next_level = 10;          // 距离下一等级还需要的点赞数(赛季内)
}

message LevelConfig {
    string level = 1;
    int hourly_revenue = 2;
    int require_hours = 3;
    int require_likes = 4;
}

message GetAssetLevelHistoryRequest {
    int64 asset_id = 1;
    int page = 2;
    int page_size = 3;
}

message GetAssetLevelHistoryResponse {
    BaseResponse base = 1;
    repeated LevelChangeItem items = 2;
    int64 total = 3;
}

message LevelChangeItem {
    string from_level = 1;
    string to_level = 2;
    string trigger_type = 3;
    int trigger_hours = 4;
    int trigger_likes = 5;
    string change_reason = 6;
    int64 created_at = 7;
}

七、赛季制设计

7.1 赛季流程

赛季开始
    ↓
藏品展出/获赞 → 累计成长值
    ↓
赛季结束触发器(定时任务)
    ↓
执行赛季重置逻辑
    ↓
新赛季开始

7.2 赛季结束策略:时长 + 点赞均按百分比衰减

字段 处理规则
累计展出时长 按百分比衰减,通过 season_decay_config 配置每个等级的保留比例
累计点赞数 按百分比衰减,通过 season_decay_config 配置每个等级的保留比例

两个字段独立计算衰减,互不影响

降序示例

假设某 UR 藏品历史累计720 小时展出时长100,000 点赞

等级 保留比例 降序后赛季时长 降序后赛季点赞 降序后等级 说明
N 100% 720h 100,000 N 时长720h<24h保持N
R 80% 576h 80,000 R 时长576h≥24h点赞80,000≥20仍为R
SR 70% 504h 70,000 SR 时长504h≥120h点赞70,000≥500仍为SR
SSR 60% 432h 60,000 SR 时长432h≥120h但点赞60,000<10,000降为SR
UR 50% 360h 50,000 SSR 时长360h≥360h但点赞50,000<100,000降为SSR

7.3 定时任务

// services/taskService/worker/season_worker.go

package worker

// SeasonResetWorker 赛季重置定时任务
type SeasonResetWorker struct {
    seasonRepo   repository.SeasonRepository
    levelService service.AssetLevelService
}

func (w *SeasonResetWorker) Run() {
    // 每小时检查是否有赛季结束
    seasons, err := w.seasonRepo.GetEndedSeasons()
    if err != nil {
        logger.Logger.Error("SeasonResetWorker: failed to get ended seasons", zap.Error(err))
        return
    }

    for _, season := range seasons {
        if err := w.levelService.SeasonReset(season.ID); err != nil {
            logger.Logger.Error("SeasonResetWorker: failed to reset season",
                zap.String("season_id", season.ID),
                zap.Error(err))
            continue
        }

        logger.Logger.Info("SeasonResetWorker: season reset completed",
            zap.String("season_id", season.ID))
    }

    // 创建新赛季
    w.createNextSeasonIfNeeded()
}

八、监控与运营

8.1 关键指标

指标 描述 告警阈值
asset_level_upgrade_total 藏品升级总次数 -
asset_level_distribution 各等级藏品数量分布 -
season_reset_success_rate 赛季重置成功率 < 99%
upgrade_trigger_exhibition 展出触发升级数 -
upgrade_trigger_like 点赞触发升级数 -

8.2 日志规范

// 等级升级日志
logger.Logger.Info("asset_level_upgraded",
    zap.Int64("asset_id", assetID),
    zap.String("from_level", fromLevel),
    zap.String("to_level", toLevel),
    zap.String("trigger_type", triggerType),
    zap.Int("total_hours", totalHours),
    zap.Int("total_likes", totalLikes))

// 赛季重置日志
logger.Logger.Info("season_reset_completed",
    zap.String("season_id", seasonID),
    zap.Int("total_assets", totalAssets),
    zap.Int("preserved_count", preservedCount),
    zap.Int("reset_count", resetCount))

九、与现有系统的区别

维度 现有用户等级系统 藏品等级系统(新增)
升级主体 用户 (FanProfile) 藏品 (Asset)
等级标识 1-20 数字 N/R/SR/SSR/UR
升级条件 仅累计时长 时长 + 点赞双轨
赛季制 支持
收益影响 用户水晶余额 藏品展出收益单价
等级回退 赛季结束回退
初始等级 1级 N级

十、前端展示规范

10.1 等级标签样式

藏品等级采用标签文字 + 不同颜色背景的方式展示:

等级 标签文字 背景色 文字色 使用场景
N N #9CA3AF (灰色) #FFFFFF 普通藏品
R R #3B82F6 (蓝色) #FFFFFF 稀有藏品
SR SR #8B5CF6 (紫色) #FFFFFF 超稀有藏品
SSR SSR #F59E0B (橙色) #FFFFFF 超超稀有藏品
UR UR #EF4444 (红色) #FFFFFF 终极稀有藏品

10.2 展示位置

  • 藏品卡片:右下角显示等级标签
  • 藏品详情页:标题下方显示等级标签
  • 藏品列表:支持按等级筛选

10.3 样式实现

<!-- 等级标签组件 -->
<template>
  <view class="asset-level-badge" :class="`level-${level}`">
    {{ level }}
  </view>
</template>

<style scoped>
.asset-level-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4rpx 12rpx;
  border-radius: 8rpx;
  font-size: 24rpx;
  font-weight: bold;
  color: #ffffff;
}

.level-N   { background-color: #9CA3AF; }
.level-R   { background-color: #3B82F6; }
.level-SR  { background-color: #8B5CF6; }
.level-SSR { background-color: #F59E0B; }
.level-UR  { background-color: #EF4444; }
</style>

10.4 等级名称映射

const LEVEL_NAMES = {
  'N': 'Normal',
  'R': 'Rare',
  'SR': 'Super Rare',
  'SSR': 'Super² Rare',
  'UR': 'Ultimate Rare'
}

const LEVEL_NAMES_CN = {
  'N': '普通',
  'R': '稀有',
  'SR': '超稀有',
  'SSR': '超超稀有',
  'UR': '终极稀有'
}

附录 A数据库变更汇总

-- 新增表
CREATE TABLE asset_levels (...);
CREATE TABLE asset_level_records (...);
CREATE TABLE asset_level_change_logs (...);
CREATE TABLE seasons (...);

-- 初始化数据
INSERT INTO asset_levels (...);
INSERT INTO seasons (...);

-- 无需修改现有表结构

附录 B推荐阅读