From 2a0db41dbfc8e493ff78ec0a9eb15331ac737aaf Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 25 May 2026 12:25:45 +0800 Subject: [PATCH] fix: wire AssetLevelService into existing service constructors --- backend/services/assetService/main.go | 9 +- backend/services/taskService/main.go | 10 +- .../taskService/service/revenue_service.go | 3 +- .../plans/2026-05-25-asset-level-system.md | 1259 +++++++++++++++++ 4 files changed, 1277 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-25-asset-level-system.md diff --git a/backend/services/assetService/main.go b/backend/services/assetService/main.go index afbd43e..56d6d3f 100644 --- a/backend/services/assetService/main.go +++ b/backend/services/assetService/main.go @@ -132,11 +132,16 @@ func main() { userClient := assetClient.NewUserServiceClient(userServiceClient) logger.Logger.Info("User Service RPC client initialized") + // 创建 Provider 层实例(用于获取 AssetLevelService) + assetLevelProvider := provider.NewAssetLevelProvider(database.GetDB()) + assetLevelSvc := assetLevelProvider.GetLevelService() + logger.Logger.Info("AssetLevelProvider initialized") + // 创建 Service 层实例 registryRepo := starbookRepo.NewAssetRegistryRepository(database.GetDB()) assetService := service.NewAssetService(assetRepo, mintOrderRepo, assetLikeRepo, userClient, database.GetDB(), registryRepo) - mintService := service.NewMintService(assetRepo, mintOrderRepo, userClient, database.GetDB(), config.GlobalAssetConfig, registryRepo, mintCostRepo, userMintCountRepo) - assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB()) + mintService := service.NewMintService(assetRepo, mintOrderRepo, userClient, database.GetDB(), config.GlobalAssetConfig, registryRepo, mintCostRepo, userMintCountRepo, assetLevelSvc) + assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB(), assetLevelSvc) rankingService := service.NewRankingService(rankingRepo, userClient) materialService := service.NewMaterialService(materialRepo, relationRepo) logger.Logger.Info("Service layer initialized") diff --git a/backend/services/taskService/main.go b/backend/services/taskService/main.go index d2a05cf..64189c1 100644 --- a/backend/services/taskService/main.go +++ b/backend/services/taskService/main.go @@ -17,6 +17,8 @@ import ( pb "github.com/topfans/backend/pkg/proto/task" pbGallery "github.com/topfans/backend/pkg/proto/gallery" pbUser "github.com/topfans/backend/pkg/proto/user" + assetLevelRepo "github.com/topfans/backend/services/assetService/repository" + assetLevelSvc "github.com/topfans/backend/services/assetService/service" "github.com/topfans/backend/services/taskService/client" "github.com/topfans/backend/services/taskService/config" "github.com/topfans/backend/services/taskService/model" @@ -105,9 +107,15 @@ func main() { logger.Logger.Info("Gallery RPC client initialized") // 6. Init services + // Create AssetLevelService for revenue calculations + assetLevelRepository := assetLevelRepo.NewAssetLevelRepository(db) + seasonRepository := assetLevelRepo.NewSeasonRepository(db) + seasonDecayConfigRepository := assetLevelRepo.NewSeasonDecayConfigRepository(db) + assetLevelService := assetLevelSvc.NewAssetLevelService(assetLevelRepository, seasonRepository, seasonDecayConfigRepository) + dailySvc := service.NewDailyTaskService(dailyRepo, userRPCClient) onboardingSvc := service.NewOnboardingService(onboardingRepo, dailyRepo, userRPCClient) - revenueSvc := service.NewRevenueService(revenueRepo, userRPCClient, galleryRPCClient) + revenueSvc := service.NewRevenueService(revenueRepo, userRPCClient, galleryRPCClient, assetLevelService) logger.Logger.Info("Services initialized") // 7. Init worker(goroutine 中启动) diff --git a/backend/services/taskService/service/revenue_service.go b/backend/services/taskService/service/revenue_service.go index 9c42c37..d33200a 100644 --- a/backend/services/taskService/service/revenue_service.go +++ b/backend/services/taskService/service/revenue_service.go @@ -7,6 +7,7 @@ import ( pbCommon "github.com/topfans/backend/pkg/proto/common" "github.com/topfans/backend/pkg/logger" + "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/task" "github.com/topfans/backend/services/taskService/client" "github.com/topfans/backend/services/taskService/model" @@ -25,7 +26,7 @@ type RevenueService interface { // AssetLevelService 资产等级服务接口(定义在assetService) type AssetLevelService interface { - GetOrCreateRecord(assetID int64) (interface{}, error) + GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error) AddExhibitionHours(assetID int64, hours int) (string, bool, error) } diff --git a/docs/superpowers/plans/2026-05-25-asset-level-system.md b/docs/superpowers/plans/2026-05-25-asset-level-system.md new file mode 100644 index 0000000..8399c15 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-asset-level-system.md @@ -0,0 +1,1259 @@ +# 藏品升级系统 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 文件** + +```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 TABLE` 和 `INSERT` 执行成功信息 + +- [ ] **Step 3: 提交** + +```bash +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: 创建模型文件** + +```go +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: 提交** + +```bash +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** + +```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** + +```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** + +```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: 提交** + +```bash +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** + +```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: 提交** + +```bash +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** + +```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: 提交** + +```bash +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** + +```go +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: 提交** + +```bash +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 字段: + +```go +type assetService struct { + // ... existing fields ... + assetLevelService service.AssetLevelService +} +``` + +在 CreateAsset 方法中,资产创建成功后初始化等级记录: + +```go +// 在 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: + +```go +// 点赞成功后,更新藏品等级记录 +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: + +```go +// 取消点赞成功后,减少藏品等级记录点赞数 +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: + +```go +// 更新藏品等级记录(展出时长) +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: 提交** + +```bash +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: 创建测试文件** + +```go +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: 提交** + +```bash +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?**