topfans/docs/superpowers/plans/2026-05-25-asset-level-system.md

37 KiB
Raw Blame History

藏品升级系统 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现藏品等级系统,支持 N/R/SR/SSR/UR 五级体系,双轨升级条件(展出时长+点赞),赛季制降序机制

Architecture: 在 pkg/models 新建共享模型,在 assetService 下新建 repository、service、worker、provider与现有 AssetService、AssetLikeService、RevenueService 集成

Tech Stack: Go (GORM), PostgreSQL, Worker/定时任务


一、文件结构概览

backend/pkg/models/
└── asset_level.go              # 新增AssetLevel, AssetLevelRecord, AssetLevelChangeLog, Season, SeasonDecayConfig 模型(共享)

backend/services/assetService/
├── repository/
│   ├── asset_level_repository.go           # 新增
│   ├── season_repository.go                # 新增
│   └── season_decay_config_repository.go    # 新增
├── service/
│   └── asset_level_service.go              # 新增
├── worker/
│   └── season_reset_worker.go              # 新增
└── provider/
    └── asset_level_provider.go             # 新增:依赖注入

二、任务分解

Task 1: 创建数据库迁移文件

Files:

  • Create: backend/docs/migrations/2026-05-25_create_asset_level_tables.sql

  • Step 1: 创建迁移 SQL 文件

-- 藏品等级配置表
CREATE TABLE IF NOT EXISTS asset_levels (
    id BIGSERIAL PRIMARY KEY,
    level VARCHAR(10) NOT NULL UNIQUE,
    level_order INT NOT NULL,
    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
);

-- 藏品等级记录表
CREATE TABLE IF NOT EXISTS 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_id VARCHAR(50),
    updated_at BIGINT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_asset_level_season ON asset_level_records(season_id);
CREATE INDEX IF NOT EXISTS idx_asset_level_current_level ON asset_level_records(current_level);

-- 藏品等级变化日志表
CREATE TABLE IF NOT EXISTS 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,
    trigger_hours INT DEFAULT 0,
    trigger_likes INT DEFAULT 0,
    change_reason VARCHAR(255),
    created_at BIGINT NOT NULL
);

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

-- 赛季配置表
CREATE TABLE IF NOT EXISTS seasons (
    id VARCHAR(50) PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    duration_days INT NOT NULL DEFAULT 84,
    start_time BIGINT NOT NULL,
    end_time BIGINT NOT NULL,
    reset_strategy VARCHAR(20) DEFAULT 'percentage_decay',
    reset_level BOOLEAN DEFAULT TRUE,
    status VARCHAR(20) DEFAULT 'active',
    created_at BIGINT NOT NULL,
    updated_at BIGINT NOT NULL
);

-- 赛季降序百分比配置表
CREATE TABLE IF NOT EXISTS season_decay_config (
    id BIGSERIAL PRIMARY KEY,
    season_id VARCHAR(50) NOT NULL,
    level VARCHAR(10) NOT NULL,
    preserve_percent INT NOT NULL DEFAULT 100,
    updated_at BIGINT NOT NULL,
    CONSTRAINT uk_season_level UNIQUE (season_id, level)
);

-- 初始化藏品等级配置数据
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;

-- 初始化赛季配置数据
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;

-- 初始化赛季降序百分比配置
INSERT INTO season_decay_config (season_id, level, preserve_percent, updated_at) VALUES
('season_1', 'N', 100, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('season_1', 'R', 80, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('season_1', 'SR', 70, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('season_1', 'SSR', 60, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
('season_1', 'UR', 50, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000))
ON CONFLICT (season_id, level) DO NOTHING;
  • Step 2: 运行迁移

Run: psql -h $DB_HOST -U $DB_USER -d $DB_NAME -f backend/docs/migrations/2026-05-25_create_asset_level_tables.sql Expected: 输出应包含 CREATE TABLEINSERT 执行成功信息

  • Step 3: 提交
git add backend/docs/migrations/2026-05-25_create_asset_level_tables.sql
git commit -m "feat: add asset level system database migration"

Task 2: 创建模型定义

Files:

  • Create: backend/pkg/models/asset_level.go

  • Step 1: 创建模型文件

package models

import (
    "time"

    "gorm.io/gorm"
)

// 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'"`
    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" }

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

// BeforeCreate 创建前钩子
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 更新前钩子
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"`
    UpdatedAt       int64  `gorm:"not null"`
}

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

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

// LevelOrderMap 等级顺序映射
var LevelOrderMap = map[string]int{
    LevelN:   1,
    LevelR:   2,
    LevelSR:  3,
    LevelSSR: 4,
    LevelUR:  5,
}
  • Step 2: 提交
git add backend/pkg/models/asset_level.go
git commit -m "feat: add asset level models"

Task 3: 创建 Repository 层

Files:

  • Create: backend/services/assetService/repository/asset_level_repository.go

  • Create: backend/services/assetService/repository/season_repository.go

  • Create: backend/services/assetService/repository/season_decay_config_repository.go

  • Step 1: 创建 asset_level_repository.go

package repository

import (
    "github.com/topfans/backend/pkg/models"
    "gorm.io/gorm"
)

type AssetLevelRepository struct {
    db *gorm.DB
}

func NewAssetLevelRepository(db *gorm.DB) *AssetLevelRepository {
    return &AssetLevelRepository{db: db}
}

func (r *AssetLevelRepository) Create(record *models.AssetLevelRecord) error {
    return r.db.Create(record).Error
}

func (r *AssetLevelRepository) Save(record *models.AssetLevelRecord) error {
    return r.db.Save(record).Error
}

func (r *AssetLevelRepository) GetByAssetID(assetID int64) (*models.AssetLevelRecord, error) {
    var record models.AssetLevelRecord
    err := r.db.Where("asset_id = ?", assetID).First(&record).Error
    if err != nil {
        return nil, err
    }
    return &record, nil
}

func (r *AssetLevelRepository) GetBySeason(seasonID string) ([]*models.AssetLevelRecord, error) {
    var records []*models.AssetLevelRecord
    err := r.db.Where("season_id = ?", seasonID).Find(&records).Error
    return records, err
}

func (r *AssetLevelRepository) GetAllLevels() ([]*models.AssetLevel, error) {
    var levels []*models.AssetLevel
    err := r.db.Order("level_order ASC").Find(&levels).Error
    return levels, err
}

func (r *AssetLevelRepository) GetLevelConfig(level string) (*models.AssetLevel, error) {
    var config models.AssetLevel
    err := r.db.Where("level = ?", level).First(&config).Error
    if err != nil {
        return nil, err
    }
    return &config, nil
}

func (r *AssetLevelRepository) CreateChangeLog(log *models.AssetLevelChangeLog) error {
    return r.db.Create(log).Error
}

func (r *AssetLevelRepository) GetChangeLogs(assetID int64, limit, offset int) ([]*models.AssetLevelChangeLog, error) {
    var logs []*models.AssetLevelChangeLog
    err := r.db.Where("asset_id = ?", assetID).
        Order("created_at DESC").
        Limit(limit).
        Offset(offset).
        Find(&logs).Error
    return logs, err
}
  • Step 2: 创建 season_repository.go
package repository

import (
    "github.com/topfans/backend/pkg/models"
    "gorm.io/gorm"
)

type SeasonRepository struct {
    db *gorm.DB
}

func NewSeasonRepository(db *gorm.DB) *SeasonRepository {
    return &SeasonRepository{db: db}
}

func (r *SeasonRepository) GetByID(seasonID string) (*models.Season, error) {
    var season models.Season
    err := r.db.Where("id = ?", seasonID).First(&season).Error
    if err != nil {
        return nil, err
    }
    return &season, nil
}

func (r *SeasonRepository) GetActiveSeason() (*models.Season, error) {
    var season models.Season
    err := r.db.Where("status = ?", "active").First(&season).Error
    if err != nil {
        return nil, err
    }
    return &season, nil
}

func (r *SeasonRepository) GetEndedSeasons() ([]*models.Season, error) {
    var seasons []*models.Season
    err := r.db.Where("status = ? AND end_time < ?", "active", gorm.Expr("NOW()")).Find(&seasons).Error
    return seasons, err
}

func (r *SeasonRepository) Save(season *models.Season) error {
    return r.db.Save(season).Error
}

func (r *SeasonRepository) Create(season *models.Season) error {
    return r.db.Create(season).Error
}

func (r *SeasonRepository) GetOrCreate(seasonID string, durationDays int, resetStrategy string) (*models.Season, error) {
    season, err := r.GetByID(seasonID)
    if err == nil {
        return season, nil
    }
    if err == gorm.ErrRecordNotFound {
        season = &models.Season{
            ID:            seasonID,
            DurationDays:  durationDays,
            ResetStrategy: resetStrategy,
            ResetLevel:    true,
            Status:        "active",
        }
        if err := r.Create(season); err != nil {
            return nil, err
        }
        return season, nil
    }
    return nil, err
}
  • Step 3: 创建 season_decay_config_repository.go
package repository

import (
    "github.com/topfans/backend/pkg/models"
    "gorm.io/gorm"
)

type SeasonDecayConfigRepository struct {
    db *gorm.DB
}

func NewSeasonDecayConfigRepository(db *gorm.DB) *SeasonDecayConfigRepository {
    return &SeasonDecayConfigRepository{db: db}
}

func (r *SeasonDecayConfigRepository) GetBySeason(seasonID string) ([]*models.SeasonDecayConfig, error) {
    var configs []*models.SeasonDecayConfig
    err := r.db.Where("season_id = ?", seasonID).Find(&configs).Error
    return configs, err
}

func (r *SeasonDecayConfigRepository) GetBySeasonAndLevel(seasonID, level string) (*models.SeasonDecayConfig, error) {
    var config models.SeasonDecayConfig
    err := r.db.Where("season_id = ? AND level = ?", seasonID, level).First(&config).Error
    if err != nil {
        return nil, err
    }
    return &config, nil
}
  • Step 4: 提交
git add backend/services/assetService/repository/asset_level_repository.go
git add backend/services/assetService/repository/season_repository.go
git add backend/services/assetService/repository/season_decay_config_repository.go
git commit -m "feat: add asset level repositories"

Task 4: 创建 Service 层

Files:

  • Create: backend/services/assetService/service/asset_level_service.go

  • Step 1: 创建 asset_level_service.go

package service

import (
    "fmt"
    "time"

    "github.com/topfans/backend/pkg/logger"
    "github.com/topfans/backend/pkg/models"
    "github.com/topfans/backend/services/assetService/repository"
    "go.uber.org/zap"
    "gorm.io/gorm"
)

type AssetLevelService interface {
    GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error)
    GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error)
    GetLevelConfig(level string) (*models.AssetLevel, error)
    GetAllLevels() ([]*models.AssetLevel, error)
    AddExhibitionHours(assetID int64, hours int) (string, bool, error)
    AddLikes(assetID int64, count int) (string, bool, error)
    RemoveLikes(assetID int64, count int) (string, bool, error)
    CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error)
    SeasonReset(seasonID string) error
    GetCurrentSeason() (*models.Season, error)
    GetChangeLogs(assetID int64, page, pageSize int) ([]*models.AssetLevelChangeLog, error)
}

type assetLevelService struct {
    levelRepo       *repository.AssetLevelRepository
    seasonRepo      *repository.SeasonRepository
    decayConfigRepo *repository.SeasonDecayConfigRepository
}

func NewAssetLevelService(
    levelRepo *repository.AssetLevelRepository,
    seasonRepo *repository.SeasonRepository,
    decayConfigRepo *repository.SeasonDecayConfigRepository,
) AssetLevelService {
    return &assetLevelService{
        levelRepo:       levelRepo,
        seasonRepo:      seasonRepo,
        decayConfigRepo: decayConfigRepo,
    }
}

func (s *assetLevelService) GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error) {
    record, err := s.levelRepo.GetByAssetID(assetID)
    if err == nil {
        return record, nil
    }
    if err == gorm.ErrRecordNotFound {
        record = &models.AssetLevelRecord{
            AssetID:      assetID,
            CurrentLevel: models.LevelN,
        }
        if err := s.levelRepo.Create(record); err != nil {
            return nil, err
        }
        return record, nil
    }
    return nil, err
}

func (s *assetLevelService) GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error) {
    return s.levelRepo.GetByAssetID(assetID)
}

func (s *assetLevelService) GetLevelConfig(level string) (*models.AssetLevel, error) {
    return s.levelRepo.GetLevelConfig(level)
}

func (s *assetLevelService) GetAllLevels() ([]*models.AssetLevel, error) {
    return s.levelRepo.GetAllLevels()
}

func (s *assetLevelService) GetCurrentSeason() (*models.Season, error) {
    return s.seasonRepo.GetActiveSeason()
}

func (s *assetLevelService) getDefaultSeason() *models.Season {
    return &models.Season{
        ID:            "season_1",
        Name:          "第一赛季",
        DurationDays:  84,
        ResetStrategy: "percentage_decay",
        ResetLevel:    true,
        Status:        "active",
    }
}

// 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]

    for _, level := range levels {
        if level.LevelOrder != currentOrder {
            continue
        }
        if record.SeasonExhibitionHours >= level.RequireHours &&
           record.SeasonLikes >= level.RequireLikes {
            return record.CurrentLevel, false
        }
    }

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

    return newLevel, newLevel != record.CurrentLevel
}

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

    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.levelRepo.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小时", hours))
    }

    return newLevel, upgraded, nil
}

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

    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.levelRepo.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
}

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.levelRepo.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
}

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 := 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)
    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
}

// CalculateBuff 根据点赞数计算Buff百分比
func CalculateBuff(likeCount int) int {
    switch {
    case likeCount >= 30:
        return 30
    case likeCount >= 10:
        return 20
    case likeCount >= 5:
        return 10
    default:
        return 0
    }
}

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
    }

    decayConfigs, err := s.decayConfigRepo.GetBySeason(seasonID)
    if err != nil {
        return err
    }

    decayPercentMap := make(map[string]int)
    for _, cfg := range decayConfigs {
        decayPercentMap[cfg.Level] = cfg.PreservePercent
    }

    records, err := s.levelRepo.GetBySeason(seasonID)
    if err != nil {
        return err
    }

    nextSeasonID := s.calculateNextSeasonID(seasonID)
    _, err = s.seasonRepo.GetOrCreate(nextSeasonID, season.DurationDays, season.ResetStrategy)
    if err != nil {
        return err
    }

    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
        }

        record.SeasonExhibitionHours = record.LifetimeExhibitionHours * preservePercent / 100
        record.SeasonLikes = record.LifetimeLikes * preservePercent / 100

        newLevel := s.recalculateLevelAfterDecay(record)
        record.CurrentLevel = newLevel
        record.SeasonID = nextSeasonID

        if err := s.levelRepo.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%%", seasonID, preservePercent))
        }
    }

    season.Status = "ended"
    return s.seasonRepo.Save(season)
}

func (s *assetLevelService) calculateNextSeasonID(currentSeasonID string) string {
    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"
}

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
}

func (s *assetLevelService) logLevelChange(assetID int64, fromLevel, toLevel, triggerType string, triggerHours, triggerLikes int, reason string) {
    log := &models.AssetLevelChangeLog{
        AssetID:      assetID,
        FromLevel:    fromLevel,
        ToLevel:      toLevel,
        TriggerType:  triggerType,
        TriggerHours: triggerHours,
        TriggerLikes: triggerLikes,
        ChangeReason: reason,
        CreatedAt:    time.Now().UnixMilli(),
    }
    if err := s.levelRepo.CreateChangeLog(log); err != nil {
        logger.Logger.Error("Failed to create level change log",
            zap.Int64("asset_id", assetID),
            zap.Error(err))
    }
}

func (s *assetLevelService) GetChangeLogs(assetID int64, page, pageSize int) ([]*models.AssetLevelChangeLog, error) {
    offset := (page - 1) * pageSize
    return s.levelRepo.GetChangeLogs(assetID, pageSize, offset)
}
  • Step 2: 提交
git add backend/services/assetService/service/asset_level_service.go
git commit -m "feat: add asset level service with upgrade/downgrade logic"

Task 5: 创建 Season Reset Worker

Files:

  • Create: backend/services/assetService/worker/season_reset_worker.go

  • Step 1: 创建 season_reset_worker.go

package worker

import (
    "time"

    "github.com/topfans/backend/pkg/logger"
    "github.com/topfans/backend/services/assetService/repository"
    "github.com/topfans/backend/services/assetService/service"
    "go.uber.org/zap"
)

type SeasonResetWorker struct {
    seasonRepo   *repository.SeasonRepository
    levelService service.AssetLevelService
}

func NewSeasonResetWorker(
    seasonRepo *repository.SeasonRepository,
    levelService service.AssetLevelService,
) *SeasonResetWorker {
    return &SeasonResetWorker{
        seasonRepo:   seasonRepo,
        levelService: levelService,
    }
}

func (w *SeasonResetWorker) Run() {
    now := time.Now().UnixMilli()

    // 获取已结束的赛季
    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 season.EndTime > now {
            continue // 还未真正结束
        }

        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))
    }
}
  • Step 2: 提交
git add backend/services/assetService/worker/season_reset_worker.go
git commit -m "feat: add season reset worker"

Task 6: 创建 Provider依赖注入

Files:

  • Create: backend/services/assetService/provider/asset_level_provider.go

  • Step 1: 创建 provider

package provider

import (
    "github.com/topfans/backend/services/assetService/repository"
    "github.com/topfans/backend/services/assetService/service"
    "github.com/topfans/backend/services/assetService/worker"
    "gorm.io/gorm"
)

type AssetLevelProvider struct {
    LevelService service.AssetLevelService
    db           *gorm.DB
}

func NewAssetLevelProvider(db *gorm.DB) *AssetLevelProvider {
    levelRepo := repository.NewAssetLevelRepository(db)
    seasonRepo := repository.NewSeasonRepository(db)
    decayConfigRepo := repository.NewSeasonDecayConfigRepository(db)

    levelService := service.NewAssetLevelService(levelRepo, seasonRepo, decayConfigRepo)

    return &AssetLevelProvider{
        LevelService: levelService,
        db:           db,
    }
}

func (p *AssetLevelProvider) GetLevelService() service.AssetLevelService {
    return p.LevelService
}

func (p *AssetLevelProvider) GetSeasonResetWorker() *worker.SeasonResetWorker {
    seasonRepo := repository.NewSeasonRepository(p.db)
    return worker.NewSeasonResetWorker(seasonRepo, p.LevelService)
}
  • Step 2: 提交
git add backend/services/assetService/provider/asset_level_provider.go
git commit -m "feat: add asset level provider for dependency injection"

Task 7: 集成到现有服务

前置条件: Task 6 (Provider) 必须先完成,以便获取 AssetLevelService 实例

Files:

  • Modify: backend/services/assetService/service/asset_service.go - 注入 AssetLevelService创建资产时初始化等级记录
  • Modify: backend/services/assetService/service/asset_like_service.go - 注入 AssetLevelService点赞/取消点赞时更新等级
  • Modify: backend/services/taskService/service/revenue_service.go - 注入 AssetLevelService展出完成时更新等级

集成说明:

  1. assetService 集成: 在 assetService 的 main.go 或 provider 中创建 AssetLevelProvider然后通过构造函数或字段注入到 AssetService 和 AssetLikeService

  2. taskService 集成: 类似地,在 taskService 中创建并注入 AssetLevelService

  • Step 1: 修改 asset_service.go

在 AssetService 结构体中添加 AssetLevelService 字段:

type assetService struct {
    // ... existing fields ...
    assetLevelService service.AssetLevelService
}

在 CreateAsset 方法中,资产创建成功后初始化等级记录:

// 在 CreateAsset 成功返回后添加
if s.assetLevelService != nil {
    levelRecord := &models.AssetLevelRecord{
        AssetID:      asset.ID,
        CurrentLevel: models.LevelN,
    }
    if err := s.assetLevelService.GetOrCreateRecord(asset.ID); err != nil {
        logger.Logger.Warn("Failed to create asset level record",
            zap.Int64("asset_id", asset.ID),
            zap.Error(err))
    }
}
  • Step 2: 修改 asset_like_service.go

在 LikeAsset 成功后调用 AddLikes

// 点赞成功后,更新藏品等级记录
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))
    }
}

在 UnlikeAsset 成功后调用 RemoveLikes

// 取消点赞成功后,减少藏品等级记录点赞数
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))
    }
}
  • Step 3: 修改 revenue_service.go 的 OnExhibitionCompleted

在展出完成时调用 AddExhibitionHours

// 更新藏品等级记录(展出时长)
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))
        }
    }
}
  • Step 4: 提交
git add backend/services/assetService/service/asset_service.go
git add backend/services/assetService/service/asset_like_service.go
git add backend/services/taskService/service/revenue_service.go
git commit -m "feat: integrate asset level system with existing services"

Task 8: 添加单元测试

Files:

  • Create: backend/services/assetService/service/asset_level_service_test.go

  • Step 1: 创建测试文件

package service

import (
    "testing"
)

func TestCalculateBuff(t *testing.T) {
    tests := []struct {
        likeCount int
        expected   int
    }{
        {0, 0},
        {4, 0},
        {5, 10},
        {9, 10},
        {10, 20},
        {29, 20},
        {30, 30},
        {100, 30},
    }

    for _, tt := range tests {
        result := CalculateBuff(tt.likeCount)
        if result != tt.expected {
            t.Errorf("CalculateBuff(%d) = %d, want %d", tt.likeCount, result, tt.expected)
        }
    }
}

func TestCheckUpgrade(t *testing.T) {
    // TODO: 实现完整的升级测试
}
  • Step 2: 运行测试

Run: go test ./backend/services/assetService/service/... -v Expected: PASS

  • Step 3: 提交
git add backend/services/assetService/service/asset_level_service_test.go
git commit -m "test: add asset level service unit tests"

三、计划完成摘要

Task Description Status
1 数据库迁移 Pending
2 模型定义 Pending
3 Repository 层 Pending
4 Service 层 Pending
5 Season Reset Worker Pending
6 Provider Pending
7 集成到现有服务 Pending
8 单元测试 Pending

四、修正说明

本次计划基于以下修正:

  1. 导入路径修正: 使用正确的模块路径 github.com/topfans/backend/...
  2. 模型位置修正: 模型放在共享的 pkg/models/ 目录下,而非 service 本地
  3. Provider 类型修正: AssetLevelService 是接口类型,直接存储接口而非指针
  4. 缺失 import 修正: season_decay_config_repository.go 添加了 gorm.io/gorm 导入
  5. 依赖注入说明: Task 7 明确说明需要先完成 Task 6 才能进行集成

Plan complete. Two execution options:

1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration

2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?