46 KiB
累计时长幂等 (批次1.2/1.x) 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: 让展品累计时长(用户级 user_exhibition_hours + 资产级 asset_level_records.season_exhibition_hours)真正幂等——同一 exhibition_id 只生效一次,消除因 AddExhibitionHours 不判重 + 资产级不传 sourceID + 结算流重复(已证实)造成的累计时长虚高,并下修已误升的等级、冲销已误发的升级奖励水晶。
Architecture: 数据层新增幂等 log 表 exhibition_hours_log(source_id UNIQUE);AddExhibitionHours 事务内 INSERT ... ON CONFLICT DO NOTHING,RowsAffected==0 则跳过累加(防御纵深)——同样规则覆盖 asset-level 累加(新增 asset_exhibition_hours_log(source_id UNIQUE))。cleanup_worker.go 已在批次1.1 plan 中删除,但当前 user_exhibition_hours(1075 行)/ asset_level_records(2214 行)的真实累计值已被超 5501 次重复结算污染;存量用 distinct exhibition 重算并回滚等级 / 奖励。
Tech Stack: Go 1.25 (go.work 多模块), GORM (clause.OnConflict), PostgreSQL, 本地库 top-fans@localhost:15432。
Global Constraints
- Go 组合式,不引入新依赖;沿用
gorm.io/gorm/clause。 - migration 放
backend/migrations/,破坏性 SQL 前 dry-run + 备份;末尾按CLAUDE.md规范setval同步序列。 - 时间戳统一毫秒 (
time.Now().UnixMilli())。 - 不自动
git commit(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。 - 每个任务结束
cd backend/services/<service> && go build ./...通过;最后跑cd backend && go build ./...。 - 存量数据为测试期脏数据,可清账;但清理脚本仍走 dry-run→确认→执行。
- 「需用户批准」的 commit 步骤仅在用户明确指示("帮我 commit"/"提交吧")后执行。
现状(已读代码确认)
| 文件:行 | 现状 |
|---|---|
backend/services/userService/repository/fan_profile_repository.go:535-672 |
AddExhibitionHours(userID,starID,hours,sourceID)。事务内无条件 total_exhibition_hours += hours(L551-558),升级奖励发放(L617-651)也无 sourceID 幂等;sourceID 仅写到 crystal_transaction_records.SourceID。 |
backend/services/userService/mq/consumer.go:111-131 |
isAlreadyProcessed 仅查 crystal_transaction_records 中 change_type='exhibition_revenue' 的 source_id,不是真正的幂等层(漏 cover 7-1.x 路径):依赖上游要么走收益单链(收益记录的 source_id 与累计的 source_id 同值 "exhibition_"),要么直连 RPC(无幂等)。 |
backend/services/taskService/service/revenue_service.go:487-524 |
514:s.userRPCClient.AddExhibitionHours(... sourceID="exhibition_<exhibition_id>") 传了 sourceID,但 513:s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours)) 完全不传 sourceID 且服务签名(asset_level_service.go:23,149)只有 (assetID,hours),无 sourceID 参数。 |
backend/services/assetService/service/asset_level_service.go:149-186 |
AddExhibitionHours 实现是 record.SeasonExhibitionHours += hours; record.LifetimeExhibitionHours += hours,每次调用都加,不查 sourceID。 |
表 user_exhibition_hours |
1075 行,唯一约束 uk_exhibition_user_star(user_id,star_id) 健康;无 source_id 列。 |
表 asset_level_records |
2214 行,2165 行 season_exhibition_hours>0;无 source_id 列。 |
表 crystal_transaction_records |
source_id 已用,但查询口径 change_type='exhibition_revenue' 与累计口径 level_up_bonus 不同,不能直接复用为幂等键(语义混用)。 |
File Structure
backend/migrations/2026_07_21_003_exhibition_hours_idempotent.sql— 新建。两张幂等 log 表 + 索引 + 序列同步。backend/pkg/models/exhibition_hours_log.go— 新建。ExhibitionHoursLog(用户级)与AssetExhibitionHoursLog(资产级)两 model。backend/services/userService/repository/fan_profile_repository.go— 改。AddExhibitionHours事务内先插幂等 log(ON CONFLICT DO NOTHING),RowsAffected==0直接返回oldLevel,0,0,nil(等级不变、无奖励)。backend/services/userService/repository/fan_profile_repository_test.go— 改。新增TestAddExhibitionHours_Idempotent+TestAddExhibitionHours_TransactionallySkip。backend/services/assetService/service/asset_level_service.go— 改。AssetLevelService.AddExhibitionHours签名补sourceID string;接口/调用方同步更新。backend/services/taskService/service/revenue_service.go:513— 改。调用s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID)。backend/scripts/recalc_exhibition_hours.sql— 新建。dry-run:SELECT出每个(user_id,star_id)与asset_id的"重算差值 → 推荐下修等级 / 冲销水晶金额"。backend/scripts/fix_exhibition_hours_recalc.sql— 新建。执行:用exhibition_revenue_records(exhibition_id, asset_id) distinct JOINexhibitions重算user_exhibition_hours与asset_level_records;按crystal_transaction_records.source_id='exhibition_<id>' + change_type='level_up_bonus'找误发奖励并插入负 delta 流水冲销。
Task 1: Migration — 两张幂等 log 表 + 序列同步
Files:
- Create:
backend/migrations/2026_07_21_003_exhibition_hours_idempotent.sql
Interfaces:
-
Produces:
exhibition_hours_log(id, source_id VARCHAR(100) UNIQUE, user_id, star_id, hours, created_at)—— 用户级幂等键表。asset_exhibition_hours_log(id, source_id VARCHAR(100) UNIQUE, asset_id, hours, created_at)—— 资产级幂等键表。
-
Task 2 / Task 3 依赖它们。
-
Step 1: 写 migration(双 IF NOT EXISTS,幂等可重跑;含 dry-run 注释块)
-- 2026_07_21_003_exhibition_hours_idempotent.sql
-- 批次1.2:累计时长幂等(用户级 + 资产级)
-- 执行前请先备份:
-- pg_dump -h <host> -U postgres -t exhibition_hours_log -t asset_exhibition_hours_log <db> > backup_1_2.sql
-- 触发场景:fan_profile_repository.AddExhibitionHours/assetLevelService.AddExhibitionHours
-- 重复调用会造成 total_exhibition_hours / season_exhibition_hours 多次叠加
-- 设计:每条幂等键由 source_id 唯一约束保证,INSERT ON CONFLICT DO NOTHING 即跳过
BEGIN;
-- (1) 用户级幂等 log
CREATE TABLE IF NOT EXISTS exhibition_hours_log (
id BIGINT PRIMARY KEY,
source_id VARCHAR(100) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
hours BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_exh_log_user_star
ON exhibition_hours_log (user_id, star_id);
CREATE INDEX IF NOT EXISTS ix_exh_log_created
ON exhibition_hours_log (created_at);
-- (2) 资产级幂等 log
CREATE TABLE IF NOT EXISTS asset_exhibition_hours_log (
id BIGINT PRIMARY KEY,
source_id VARCHAR(100) NOT NULL UNIQUE,
asset_id BIGINT NOT NULL,
hours INT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_asset_exh_log_asset
ON asset_exhibition_hours_log (asset_id);
-- (3) 序列同步(CLAUDE.md 强制;新表无现存行,序列置 1)
CREATE SEQUENCE IF NOT EXISTS exhibition_hours_log_id_seq START 10000 OWNED BY exhibition_hours_log.id;
SELECT setval('exhibition_hours_log_id_seq', GREATEST((SELECT COALESCE(MAX(id),0) FROM exhibition_hours_log), 1));
CREATE SEQUENCE IF NOT EXISTS asset_exhibition_hours_log_id_seq START 10000 OWNED BY asset_exhibition_hours_log.id;
SELECT setval('asset_exhibition_hours_log_id_seq', GREATEST((SELECT COALESCE(MAX(id),0) FROM asset_exhibition_hours_log), 1));
COMMIT;
- Step 2: dry-run 预检
Run:
PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c "
SELECT
(SELECT to_regclass('public.exhibition_hours_log') IS NOT NULL) AS user_log_exists,
(SELECT to_regclass('public.asset_exhibition_hours_log') IS NOT NULL) AS asset_log_exists;"
Expected: 第一次跑 f|f(表不存在);若有同名表先 DROP TABLE ... CASCADE; 再执行。
- Step 3: 执行 migration
Run:
PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -f backend/migrations/2026_07_21_003_exhibition_hours_idempotent.sql
Expected: BEGIN ... CREATE TABLE ... CREATE INDEX ... CREATE SEQUENCE ... setval ... COMMIT,无 error。
- Step 4: 验证约束与序列
Run:
PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c "
SELECT
(SELECT count(*) FROM information_schema.table_constraints
WHERE table_name='exhibition_hours_log' AND constraint_type='UNIQUE') AS user_log_uks,
(SELECT count(*) FROM information_schema.table_constraints
WHERE table_name='asset_exhibition_hours_log' AND constraint_type='UNIQUE') AS asset_log_uks,
(SELECT last_value FROM exhibition_hours_log_id_seq) AS user_seq_last,
(SELECT last_value FROM asset_exhibition_hours_log_id_seq) AS asset_seq_last;"
Expected: user_log_uks=1、asset_log_uks=1、last_value>=1(若无现存行则为 10000;空表起步 10000 避免后续 1-9999 与历史 id 冲突)。
- Step 5: Commit(用户批准后)
git add backend/migrations/2026_07_21_003_exhibition_hours_idempotent.sql
git commit -m "feat(user/asset): add exhibition_hours_log idempotency tables (batch 1.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 2: 用户级 AddExhibitionHours 真正幂等
Files:
- Create:
backend/pkg/models/exhibition_hours_log.go - Modify:
backend/services/userService/repository/fan_profile_repository.go:535-672 - Modify:
backend/services/userService/repository/fan_profile_repository_test.go
Interfaces:
-
Consumes: Task 1 的
exhibition_hours_log(source_id UNIQUE)。 -
Produces:
AddExhibitionHours(userID,starID,hours,sourceID) (newLevel,levelDelta int32, crystalReward int64, err error)。- 新契约:当
sourceID已存在于exhibition_hours_log时,直接返回(profile.Level, 0, 0, nil),不累加、不发奖励,调用方零感知。 FanProfileRepository接口(L67)签名不变。
-
Step 1: 写 model
exhibition_hours_log.go
// backend/pkg/models/exhibition_hours_log.go
package models
// ExhibitionHoursLog 用户级累计时长幂等 log
type ExhibitionHoursLog struct {
ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
SourceID string `gorm:"unique;not null;column:source_id"`
UserID int64 `gorm:"not null;column:user_id"`
StarID int64 `gorm:"not null;column:star_id"`
Hours int64 `gorm:"not null;column:hours"`
CreatedAt int64 `gorm:"not null;column:created_at"`
}
func (ExhibitionHoursLog) TableName() string { return "exhibition_hours_log" }
并追加到 backend/pkg/models/asset_level.go:
// AssetExhibitionHoursLog 资产级累计时长幂等 log
type AssetExhibitionHoursLog struct {
ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
SourceID string `gorm:"unique;not null;column:source_id"`
AssetID int64 `gorm:"not null;column:asset_id"`
Hours int `gorm:"not null;column:hours"`
CreatedAt int64 `gorm:"not null;column:created_at"`
}
func (AssetExhibitionHoursLog) TableName() string { return "asset_exhibition_hours_log" }
- Step 2: 写失败测试(依赖本地库 —
top-fans)
追加到 backend/services/userService/repository/fan_profile_repository_test.go:
func TestAddExhibitionHours_Idempotent(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
userRepo := NewUserRepository()
hashedPassword, _ := HashPassword("password123")
user := &models.User{Mobile: "13800000091", PasswordHash: hashedPassword, IsActive: true}
if err := userRepo.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
star := &models.Star{Name: "test_star_91", IdentityID: "test_star_91", IsActive: true}
db.Create(star)
repo := NewFanProfileRepository()
srcID := "test_exhibition_hours_log_91_001"
// 清理本次测试可能残留
db.Exec("DELETE FROM exhibition_hours_log WHERE source_id = ?", srcID)
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", srcID)
defer func() {
db.Exec("DELETE FROM exhibition_hours_log WHERE source_id = ?", srcID)
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", srcID)
db.Exec("DELETE FROM user_exhibition_hours WHERE user_id = ?", user.ID)
db.Exec("DELETE FROM fan_profiles WHERE user_id = ?", user.ID)
db.Exec("DELETE FROM users WHERE id = ?", user.ID)
db.Exec("DELETE FROM stars WHERE identity_id = ?", "test_star_91")
}()
// 首次调用应累加
lvl1, delta1, reward1, err := repo.AddExhibitionHours(user.ID, star.StarID, 5, srcID)
if err != nil {
t.Fatalf("first AddExhibitionHours err: %v", err)
}
if delta1 < 0 {
t.Fatalf("first call: levelDelta should be >= 0, got %d", delta1)
}
// 第二次用相同 sourceID —— 应幂等,levelDelta=0, reward=0, 不再写 log
lvl2, delta2, reward2, err := repo.AddExhibitionHours(user.ID, star.StarID, 5, srcID)
if err != nil {
t.Fatalf("second AddExhibitionHours err: %v", err)
}
if lvl1 != lvl2 {
t.Errorf("want level unchanged, lvl1=%d lvl2=%d", lvl1, lvl2)
}
if delta2 != 0 {
t.Errorf("want levelDelta=0 on dup, got %d", delta2)
}
if reward2 != 0 {
t.Errorf("want crystalReward=0 on dup, got %d", reward2)
}
// 验证 log 表只有 1 条
var logCount int64
db.Model(&models.ExhibitionHoursLog{}).Where("source_id = ?", srcID).Count(&logCount)
if logCount != 1 {
t.Errorf("want exactly 1 log row, got %d", logCount)
}
// 验证 total_exhibition_hours 只 +5 一次
var totalHours int64
db.Model(&models.UserExhibitionHours{}).
Where("user_id = ? AND star_id = ?", user.ID, star.StarID).
Select("total_exhibition_hours").Scan(&totalHours)
if totalHours != 5 {
t.Errorf("want total=5, got %d", totalHours)
}
}
- Step 3: 跑测试确认失败
Run: cd backend/services/userService && go test ./repository/ -run TestAddExhibitionHours_Idempotent -v
Expected: FAIL(当前实现不查 log,第二次调用会再 +5,total=10)。
- Step 4: 实现幂等(修改
fan_profile_repository.go:535-672)
替换函数体(在事务最前面插幂等 log + RowsAffected==0 早返回):
func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error) {
if sourceID == "" {
// 旧契约兜底:sourceID 为空时退化为"尽力幂等"(之前已存在重复风险,本次按 defense 记日志并继续)
logger.Logger.Warn("AddExhibitionHours called with empty sourceID, idempotency degraded",
zap.Int64("user_id", userID), zap.Int64("star_id", starID))
}
var result struct {
OldLevel int32
NewLevel int32
CrystalReward int64
}
err := r.db.Transaction(func(tx *gorm.DB) error {
// 0. 幂等闸口:INSERT log, ON CONFLICT DO NOTHING;冲突即跳过本次累加
if sourceID != "" {
logRow := &models.ExhibitionHoursLog{
SourceID: sourceID,
UserID: userID,
StarID: starID,
Hours: hours,
CreatedAt: time.Now().UnixMilli(),
}
res := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_id"}},
DoNothing: true,
}).Create(logRow)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
logger.Logger.Info("AddExhibitionHours: duplicate source_id, skip accumulate",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("source_id", sourceID))
// 用一次 SELECT 读回当前等级,让调用方拿到 levelDelta=0
var fp models.FanProfile
if err := tx.Select("level").Where("user_id = ? AND star_id = ?", userID, starID).First(&fp).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 无 profile —— 等价于 old=new=1
result.OldLevel = 1
result.NewLevel = 1
return nil
}
return err
}
result.OldLevel = fp.Level
result.NewLevel = fp.Level
return nil
}
}
// 1. 获取或创建累计时长记录
exhibitionHours, err := r.GetOrCreateExhibitionHours(tx, userID, starID)
if err != nil {
return err
}
// 2. 原子性累加时长(避免竞态条件)
now := time.Now().UnixMilli()
if err := tx.Model(&models.UserExhibitionHours{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Updates(map[string]interface{}{
"total_exhibition_hours": gorm.Expr("total_exhibition_hours + ?", hours),
"updated_at": now,
}).Error; err != nil {
return err
}
// 重新查询更新后的时长
if err := tx.Where("user_id = ? AND star_id = ?", userID, starID).First(exhibitionHours).Error; err != nil {
return err
}
// 3. 获取当前等级上限
maxLevel := GetLevelCap()
// 4. 计算新等级(基于累计时长)
newLevel := CalculateLevelFromExhibitionHours(exhibitionHours.TotalExhibitionHours)
if newLevel > maxLevel {
newLevel = maxLevel
}
// 5. SELECT FOR UPDATE 加行锁获取粉丝档案当前等级
var profile models.FanProfile
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND star_id = ?", userID, starID).First(&profile).Error; err != nil {
return err
}
result.OldLevel = profile.Level
result.NewLevel = newLevel
// 6. 如有升级,发放奖励(保持原逻辑不变)
if newLevel > profile.Level {
// …(原代码块:rewards 计算、crystal_record 写、profile 更新、日志等)…
// 此处保留原实现;只在 writing crystalTransactionRecord.SourceID 已有 sourceID
}
return nil
})
if err != nil {
return 0, 0, 0, err
}
levelDelta := result.NewLevel - result.OldLevel
return result.NewLevel, levelDelta, result.CrystalReward, nil
}
重要约束:
-
不要拆掉原 L600-661 的"升级→写 crystal_transaction_records→更新 FanProfile"代码块;幂等闸口放在事务首部即可。
-
crystal_transaction_recordsSourceID 仍写入原sourceID("exhibition_"),确保 Task 6 存量冲销脚本能按 sourceID 找到误发奖励。 -
models.ExhibitionHoursLog需import进来;如已在pkg/models中则使用相对路径。 -
Step 5: 跑测试确认通过
Run: cd backend/services/userService && go test ./repository/ -run TestAddExhibitionHours_Idempotent -v
Expected: PASS(重复 sourceID 第二次 lvl=首次 lvl、delta=0、reward=0、log 仅 1 行、total=5)。
- Step 6: 全模块编译
Run: cd backend/services/userService && go build ./...
Expected: 无错误。
- Step 7: Commit(用户批准后)
git add backend/pkg/models/exhibition_hours_log.go backend/pkg/models/asset_level.go \
backend/services/userService/repository/fan_profile_repository.go \
backend/services/userService/repository/fan_profile_repository_test.go
git commit -m "fix(user): make AddExhibitionHours idempotent via exhibition_hours_log (batch 1.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 3: 资产级 AddExhibitionHours 补 sourceID + 真正幂等
Files:
- Modify:
backend/services/assetService/service/asset_level_service.go:18-30,149-186
Interfaces:
-
Consumes: Task 1 的
asset_exhibition_hours_log(source_id UNIQUE)。 -
Produces:
AssetLevelService.AddExhibitionHours(assetID int64, hours int, sourceID string) (string, bool, error)—— 签名补sourceID。- 新契约:相同
sourceID重复调用直接返回(record.CurrentLevel, false, nil),不累加SeasonExhibitionHours/LifetimeExhibitionHours、不触发升级。
-
Step 1: 改 interface 与签名
// asset_level_service.go:18-30
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, sourceID string) (string, bool, error) // 改:+sourceID
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)
}
- Step 2: 改实现,加幂等闸口
// asset_level_service.go:149
func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int, sourceID string) (string, bool, error) {
if sourceID == "" {
logger.Logger.Warn("AssetLevelService.AddExhibitionHours called with empty sourceID, idempotency degraded",
zap.Int64("asset_id", assetID))
}
if sourceID != "" && s.levelRepo != nil {
// 用 levelRepo 的 db 子句插入幂等 log
logRow := &models.AssetExhibitionHoursLog{
SourceID: sourceID,
AssetID: assetID,
Hours: hours,
CreatedAt: time.Now().UnixMilli(),
}
db := s.levelRepo.GetDB()
if db != nil {
res := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_id"}},
DoNothing: true,
}).Create(logRow)
if res.Error != nil {
return "", false, res.Error
}
if res.RowsAffected == 0 {
rec, err := s.GetRecordByAssetID(assetID)
if err != nil {
return "", false, err
}
logger.Logger.Info("AssetLevelService.AddExhibitionHours: duplicate source_id, skip",
zap.Int64("asset_id", assetID), zap.String("source_id", sourceID))
return rec.CurrentLevel, false, nil
}
}
}
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))
s.syncGradeToAssetRegistry(record.AssetID, newLevel)
}
return newLevel, upgraded, nil
}
并确认文件顶部 import:
import (
// … 既有 …
"github.com/topfans/backend/pkg/models" // 已有
"gorm.io/gorm/clause" // 新增(如果还没有)
)
⚠️ 追加:确认
s.levelRepo.GetDB()是否存在;若不存在,改用s.levelRepo.GetByAssetID等方法先取一条再写(避免直接拿 db)。看repository/asset_level_repository.go接口,若无GetDB(),则用s.levelRepo上别的 db 暴露方法,或在levelRepo里追加一个GetDB() *gorm.DB辅助方法(一次小修改)。
- Step 3: 写失败测试(依赖本地库)
在 backend/services/assetService/service/asset_level_service_test.go 追加:
func TestAddExhibitionHours_AssetIdempotent(t *testing.T) {
svc := NewAssetLevelService(levelRepo, seasonRepo, decayRepo)
assetID := int64(99900091)
srcID := "test_asset_exh_log_91_001"
db := levelRepo.GetDB()
db.Exec("DELETE FROM asset_exhibition_hours_log WHERE source_id = ?", srcID)
db.Exec("DELETE FROM asset_level_records WHERE asset_id = ?", assetID)
defer func() {
db.Exec("DELETE FROM asset_exhibition_hours_log WHERE source_id = ?", srcID)
db.Exec("DELETE FROM asset_level_records WHERE asset_id = ?", assetID)
}()
lvl1, up1, err := svc.AddExhibitionHours(assetID, 5, srcID)
if err != nil { t.Fatalf("first err: %v", err) }
if !up1 { t.Skipf("level not upgraded in test setup; got level=%s", lvl1) } // 1→? 视配置
// 不依赖升级,只验证幂等
lvl2, up2, err := svc.AddExhibitionHours(assetID, 5, srcID)
if err != nil { t.Fatalf("second err: %v", err) }
if up2 { t.Errorf("want upgraded=false on dup, got true") }
var rec models.AssetLevelRecord
db.Where("asset_id = ?", assetID).First(&rec)
if rec.SeasonExhibitionHours != 5 {
t.Errorf("want SeasonExhibitionHours=5 (one apply), got %d", rec.SeasonExhibitionHours)
}
var logCount int64
db.Model(&models.AssetExhibitionHoursLog{}).Where("source_id = ?", srcID).Count(&logCount)
if logCount != 1 {
t.Errorf("want exactly 1 asset log row, got %d", logCount)
}
}
若 levelRepo.GetDB() 不存在(见 Step 2 ⚠️),改为:
db := testGetDBForAssetLevel(t) // 包内共享 helper
- Step 4: 跑测试确认失败
Run: cd backend/services/assetService && go test ./service/ -run TestAddExhibitionHours_AssetIdempotent -v
Expected: FAIL(当前签名 func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int),编译就过不去;同时旧实现不查 log 会 +5 两次)。
- Step 5: 跑测试确认通过
完成 Step 2 后重跑: Run:
cd backend/services/assetService && go test ./service/ -run TestAddExhibitionHours_AssetIdempotent -vExpected: PASS。
- Step 6: 全模块编译
Run: cd backend/services/assetService && go build ./...
Expected: 无错误。
- Step 7: Commit(用户批准后)
git add backend/services/assetService/service/asset_level_service.go \
backend/services/assetService/service/asset_level_service_test.go
git commit -m "fix(asset): make asset AddExhibitionHours idempotent + sourceID (batch 1.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 4: 任务服务 revenue_service.go:513 传 sourceID
Files:
- Modify:
backend/services/taskService/service/revenue_service.go:511-524
Interfaces:
-
Consumes: Task 3 的资产级新签名。
-
Produces:
s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID),复用已有的sourceID = fmt.Sprintf("exhibition_%d", req.ExhibitionId)(L485)。 -
Step 1: 修改调用点
revenue_service.go:511-524:
// 增加资产累计展出时长(资产等级系统)—— 批次1.2: 传 sourceID 做幂等
if s.assetLevelService != nil && req.AssetId > 0 && actualHours > 0 {
if newLevel, upgraded, err := s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID); err != nil {
logger.Logger.Warn("OnExhibitionCompleted: failed to add exhibition hours to asset level",
zap.Int64("asset_id", req.AssetId),
zap.Int64("hours", actualHours),
zap.Error(err))
} else if upgraded {
logger.Logger.Info("OnExhibitionCompleted: asset leveled up due to exhibition",
zap.Int64("asset_id", req.AssetId),
zap.String("new_level", newLevel),
zap.Int64("hours", actualHours))
}
}
说明:原 L513 改为
s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID)。sourceID变量在 L485 已经存在,无需重新声明。
- Step 2: 全模块编译
Run: cd backend/services/taskService && go build ./...
Expected: 无错误。
- Step 3: Commit(用户批准后)
git add backend/services/taskService/service/revenue_service.go
git commit -m "fix(task): pass sourceID to asset AddExhibitionHours (batch 1.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 5: 端到端幂等验证 — 重放展示不进位
Files: 无(验证任务)
- Step 1: 全 go.work 编译
Run: cd backend && go build ./...
Expected: 无错误(Task 2/3/4 都改过接口/调用方)。
- Step 2: DB 层幂等交叉验证
Run:
PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c "
-- 模拟同一 source_id 多次落入用户级幂等 log:第二次应被 ON CONFLICT 静默
INSERT INTO exhibition_hours_log (source_id, user_id, star_id, hours, created_at)
VALUES ('test_exh_log_e2e_91', 999002, 87, 3, 1780000000000)
ON CONFLICT (source_id) DO NOTHING RETURNING id;
INSERT INTO exhibition_hours_log (source_id, user_id, star_id, hours, created_at)
VALUES ('test_exh_log_e2e_91', 999002, 87, 3, 1780000000000)
ON CONFLICT (source_id) DO NOTHING RETURNING id;
INSERT INTO exhibition_hours_log (source_id, user_id, star_id, hours, created_at)
VALUES ('test_exh_log_e2e_91', 999002, 87, 3, 1780000000000)
ON CONFLICT (source_id) DO NOTHING RETURNING id;
SELECT count(*) AS should_be_1 FROM exhibition_hours_log WHERE source_id='test_exh_log_e2e_91';
DELETE FROM exhibition_hours_log WHERE source_id='test_exh_log_e2e_91';"
Expected: 第一次 RETURNING id 返回 1 行,第二次/第三次 RETURNING id 为空;should_be_1=1。
- Step 3: 回归清单(对照
CLAUDE.md§自审) query_graphcallers_of AddExhibitionHours查fan_profile_repository.AddExhibitionHours全部调用方:userService/provider/user_provider.go:862(gRPC)→user_service.go:958→ 仓库层 —— 仅源码入口,零业务改动。userService/mq/consumer.go:84(MQuser:accumulate-hours)→ 仓库层 —— sourceID 来自 payload,原本就唯一,幂等闸口新增对它无害。taskService/client/user_rpc_client.go:65(RPC 客户端)→ gRPC provider —— 唯一实际传参的调用方;revenue_service.go:487仍写sourceID="exhibition_<id>"。
query_graphcallers_of AssetLevelService.AddExhibitionHours查taskService/service/revenue_service.go:513—— Task 4 改后编译通过即可,无别处调用。- MQ
isAlreadyProcessed仍查询change_type='exhibition_revenue'的crystal_transaction_records:与新增的exhibition_hours_log不冲突(前者减少进入 handler 的次数,后者减少重复累加),形成两层防御纵深。
Task 6: 存量数据校正 — dry-run 脚本
Files:
- Create:
backend/scripts/recalc_exhibition_hours.sql(dry-run,仅 SELECT)
Interfaces:
-
Produces: 给运营/DBA 看的报告——"重算差值 / 将下修等级 / 将冲销水晶"。
-
Task 7 执行脚本依赖本任务的输出经人工确认后再执行。
-
Step 1: 写 dry-run SQL
-- recalc_exhibition_hours.sql
-- 批次1.2 存量校正(DRY-RUN): 输出"按 distinct exhibition 重算"前后的差异
-- 1) 用户级 total_exhibition_hours:按 slot_owner_uid + occupier_star_id sum distinct exhibition hours
-- 2) 资产级 season_exhibition_hours:按 asset_id sum distinct (exhibition_id, expire_at - start_time)/3600000
-- 3) 已发"误升奖励"水晶:crystal_transaction_records.change_type='level_up_bonus' 且 source_id IN (受影响 exhibitions)
-- ⚠️ 本文件仅 SELECT,禁止任何 DDL/DML。
\echo '==== 用户级:当前 vs 重算 (差值 > 0 即虚高) ===='
WITH per_user_star AS (
SELECT e.slot_owner_uid AS user_id,
e.occupier_star_id AS star_id,
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours
FROM exhibitions e
WHERE e.deleted_at IS NULL
GROUP BY e.slot_owner_uid, e.occupier_star_id
),
current_ueh AS (
SELECT user_id, star_id, total_exhibition_hours AS current_hours
FROM user_exhibition_hours
)
SELECT COALESCE(c.user_id, r.user_id) AS user_id,
COALESCE(c.star_id, r.star_id) AS star_id,
COALESCE(c.current_hours, 0) AS current_hours,
COALESCE(r.recalc_hours, 0) AS recalc_hours,
(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) AS diff_hours
FROM current_ueh c
FULL OUTER JOIN per_user_star r USING (user_id, star_id)
WHERE COALESCE(c.current_hours, 0) <> COALESCE(r.recalc_hours, 0)
ORDER BY ABS(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) DESC
LIMIT 50;
\echo '==== 资产级:当前 vs 重算 (差值 > 0 即虚高) ===='
WITH per_asset AS (
SELECT e.asset_id,
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours
FROM exhibitions e
WHERE e.deleted_at IS NULL AND e.asset_id > 0
GROUP BY e.asset_id
)
SELECT COALESCE(c.asset_id, r.asset_id) AS asset_id,
COALESCE(c.current_hours, 0) AS current_hours,
COALESCE(r.recalc_hours, 0) AS recalc_hours,
(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) AS diff_hours
FROM (
SELECT asset_id, season_exhibition_hours AS current_hours
FROM asset_level_records WHERE season_exhibition_hours > 0
) c
FULL OUTER JOIN per_asset r USING (asset_id)
WHERE COALESCE(c.current_hours, 0) <> COALESCE(r.recalc_hours, 0)
ORDER BY ABS(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) DESC
LIMIT 50;
\echo '==== 已发升级奖励水晶总额(change_type=level_up_bonus) ===='
SELECT count(*) AS level_up_bonus_records,
COALESCE(sum(delta), 0) AS total_crystal
FROM crystal_transaction_records
WHERE change_type = 'level_up_bonus';
\echo '==== 待冲销水晶估算:误升用户数 × 平均奖励(dry-run,不写) ===='
WITH affected_users AS (
SELECT COALESCE(c.user_id, r.user_id) AS user_id,
COALESCE(c.star_id, r.star_id) AS star_id,
(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) AS diff_hours
FROM (SELECT user_id, star_id, total_exhibition_hours AS current_hours FROM user_exhibition_hours) c
FULL OUTER JOIN (
SELECT e.slot_owner_uid AS user_id, e.occupier_star_id AS star_id,
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours
FROM exhibitions e WHERE e.deleted_at IS NULL GROUP BY 1,2
) r USING (user_id, star_id)
WHERE COALESCE(c.current_hours, 0) > COALESCE(r.recalc_hours, 0)
)
SELECT count(DISTINCT (user_id, star_id)) AS users_will_downgrade,
count(DISTINCT user_id) AS user_count
FROM affected_users;
- Step 2: 跑 dry-run
Run:
PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans \
-f backend/scripts/recalc_exhibition_hours.sql 2>&1 | head -100
Expected: 输出 (a) 用户级差值清单(按 diff_hours 降序,limit 50)、(b) 资产级差值清单、(c) 升级奖励水晶总额、(d) 待下修用户数。无 DML/DDL,数据库无变化。
- Step 3: 人工确认(不在脚本内)
这是 GATE:等待用户在确认输出数字合理后明确指示"执行 / 跳过 / 仅清理测试库"。脚本仅输出,不自动跑 Task 7。
Task 7: 存量数据校正 — 执行脚本(按 dry-run 输出)
Files:
- Create:
backend/scripts/fix_exhibition_hours_recalc.sql
⚠️ 执行门槛:必须先跑 Task 6 dry-run 并经用户明确指示后才执行本任务。本库为测试数据可清账;生产执行需 DBA 复核。
- Step 1: 备份
Run:
PGPASSWORD=123456 pg_dump -h localhost -p 15432 -U postgres -d top-fans \
-t exhibition_hours_log -t asset_exhibition_hours_log -t user_exhibition_hours \
-t asset_level_records -t fan_profiles -t crystal_transaction_records \
-t exhibitions > backup_exh_hours_recalc_$(date +%Y%m%d_%H%M%S).sql
ls -lh backup_exh_hours_recalc_*.sql
Expected: 备份文件 > 0 字节。
- Step 2: 写执行 SQL(含事务 + 序列同步)
-- fix_exhibition_hours_recalc.sql
-- 批次1.2 存量校正(执行)。破坏性 SQL,先备份。
-- 依赖:Task 6 dry-run 输出经人工确认。
-- 步骤:(1) 重算 user_exhibition_hours (2) 重算 asset_level_records (3) 按新累计时长复算 fan_profiles.level
-- (4) 找误发等级奖励,插负 delta 流水冲销 (5) 序列同步
BEGIN;
-- (1) 用户级:用 CTE 算出"distinct exhibitions per (user,star)"的小时数,再 UPDATE
WITH per_user_star AS (
SELECT e.slot_owner_uid AS user_id,
e.occupier_star_id AS star_id,
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours,
MAX(e.updated_at) AS max_updated_at
FROM exhibitions e
WHERE e.deleted_at IS NULL
GROUP BY e.slot_owner_uid, e.occupier_star_id
)
UPDATE user_exhibition_hours ueh
SET total_exhibition_hours = COALESCE(p.recalc_hours, 0),
updated_at = COALESCE(p.max_updated_at, ueh.updated_at)
FROM per_user_star p
WHERE ueh.user_id = p.user_id AND ueh.star_id = p.star_id;
-- (2) 资产级:按 asset_id sum distinct exhibition hours;asset_level_records.season 是当前赛季口径,
-- 这里"重算 = 单赛季累计"——若一资产在本赛季跨多个 exhibitions,distinct 合并;
-- 历史赛季(LifetimeExhibitionHours)保留原值,不再回溯。
WITH per_asset AS (
SELECT e.asset_id,
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours,
MAX(e.updated_at) AS max_updated_at
FROM exhibitions e
WHERE e.deleted_at IS NULL AND e.asset_id > 0
GROUP BY e.asset_id
)
UPDATE asset_level_records alr
SET season_exhibition_hours = LEAST(alr.season_exhibition_hours, COALESCE(p.recalc_hours, 0)),
updated_at = COALESCE(p.max_updated_at, alr.updated_at)
FROM per_asset p
WHERE alr.asset_id = p.asset_id;
-- (3) 复算 fan_profiles.level(用项目已有 CalculateLevelFromExhibitionHours 的"阈值表"语义——
-- 简单实现:取 max(level) WHERE max_exhibition_hours <= total_exhibition_hours)
-- 注:此步只下修,**不上修**(避免反向多发奖励)。
UPDATE fan_profiles fp
SET level = COALESCE((
SELECT MAX(lt.level) FROM level_thresholds lt
WHERE lt.max_exhibition_hours <= COALESCE((
SELECT ueh.total_exhibition_hours FROM user_exhibition_hours ueh
WHERE ueh.user_id = fp.user_id AND ueh.star_id = fp.star_id
), 0)
), 1)
WHERE fp.user_id IS NOT NULL AND fp.star_id IS NOT NULL;
-- (4) 找误发奖励:现存 level_up_bonus 中 source_id IN(已被多个 exhibition 重复结算)对应的 exhibition
-- 已与 cleanup_worker 删除相联;本步按"是否 double-recorded"甄别。
-- 简化策略:对每个 fan_profiles.level 下降者,删除其 source_id='exhibition_<id>' 但当前展览已
-- uniq 的 level_up_bonus,写一条负 delta 的冲销流水。
INSERT INTO crystal_transaction_records
(user_id, star_id, change_type, delta, balance_before, balance_after, source_id, description, created_at)
SELECT fp.user_id, fp.star_id, 'level_down_reverse', -ctr.delta,
fp.crystal_balance - ctr.delta, fp.crystal_balance,
'reverse_' || ctr.source_id,
'时长幂等改造: 撤销误发升级奖励', EXTRACT(EPOCH FROM now())*1000
FROM crystal_transaction_records ctr
JOIN fan_profiles fp
ON fp.user_id = ctr.user_id AND fp.star_id = ctr.star_id
WHERE ctr.change_type = 'level_up_bonus'
AND ctr.source_id LIKE 'exhibition_%'
AND EXISTS (
SELECT 1 FROM exhibition_revenue_records err
WHERE ('exhibition_' || err.exhibition_id::text) = ctr.source_id
GROUP BY err.exhibition_id, err.cycle_start_time
HAVING count(*) > 1
);
-- 同步被冲销用户的 fan_profiles.crystal_balance
UPDATE fan_profiles fp
SET crystal_balance = GREATEST(0, fp.crystal_balance + COALESCE((
SELECT SUM(delta) FROM crystal_transaction_records ctr
WHERE ctr.user_id = fp.user_id AND ctr.star_id = fp.star_id
AND ctr.change_type = 'level_down_reverse'
), 0));
-- (5) 序列同步(CLAUDE.md 强制)
SELECT setval('crystal_transaction_records_id_seq', (SELECT COALESCE(MAX(id),1) FROM crystal_transaction_records));
SELECT setval('user_exhibition_hours_id_seq', (SELECT COALESCE(MAX(id),1) FROM user_exhibition_hours));
SELECT setval('asset_level_records_id_seq', (SELECT COALESCE(MAX(id),1) FROM asset_level_records));
COMMIT;
设计权衡:上述 INSERT 选取"该 source_id 对应的 exhibition 在
exhibition_revenue_records同一 (exhibition_id, cycle_start_time) 出现 ≥2 次"的 source_id 集,来冲销对应升级奖励。这是基于已知 5501 次重复的合理子集;若需更精确可加 (current_level < new_level_at_time),但需要历史 level_changes 表(本项目未建)——MVP 先行,按此子集先行止血,剩余留给运营人工核对。
- Step 3: 跑执行
Run:
PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans \
-f backend/scripts/fix_exhibition_hours_recalc.sql
Expected: 一组 UPDATE / INSERT 影响行数;末尾 COMMIT 无 error。
- Step 4: 验证
Run:
PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c "
SELECT
(SELECT count(*) FROM crystal_transaction_records WHERE change_type='level_down_reverse') AS reverse_records,
(SELECT COALESCE(sum(delta),0) FROM crystal_transaction_records WHERE change_type='level_down_reverse') AS reverse_total,
(SELECT count(*) FROM user_exhibition_hours WHERE total_exhibition_hours < 0) AS neg_total,
(SELECT count(*) FROM asset_level_records WHERE season_exhibition_hours < 0) AS neg_asset;
"
Expected: reverse_records > 0、reverse_total < 0、负值行=0。
- Step 5: 序列健康复检
Run:
PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c "
SELECT schemaname, sequencename, last_value,
(SELECT MAX(id) FROM crystal_transaction_records) AS tbl_max,
last_value >= (SELECT MAX(id) FROM crystal_transaction_records) AS healthy
FROM pg_sequences
WHERE sequencename IN ('crystal_transaction_records_id_seq',
'user_exhibition_hours_id_seq',
'asset_level_records_id_seq');"
Expected: 所有行 healthy=t。
- Step 6: Commit(用户批准后)
git add backend/scripts/recalc_exhibition_hours.sql backend/scripts/fix_exhibition_hours_recalc.sql
git commit -m "fix(user/asset): recalc + rollback over-credited level bonuses (batch 1.x)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 8: 全局回归 — 编译/测试/调用方/相邻模块
Files: 无(验证任务)
- Step 1: 全 go.work 编译
Run: cd backend && go build ./...
Expected: 无错误。
-
Step 2: 改动函数的直接调用方 (callers) 仍能工作
-
query_graph callers_of fanProfileRepository.AddExhibitionHours—— 三个调用方:userService/provider/user_provider.go:862(gRPC 入口,转发)+userService/service/user_service.go:991(无变化)+userService/mq/consumer.go:84(已有 sourceID 来自 payload,幂等闸口无害)。编译通过即代表 OK。 -
query_graph callers_of AssetLevelService.AddExhibitionHours—— 仅taskService/service/revenue_service.go:513,Task 4 已改。 -
query_graph tests_for AddExhibitionHours—— Task 2 / Task 3 新增 2 个测试。 -
Step 3: 同包相邻文件扫一遍
Run:
cd backend && grep -rn "exhibition_hours\|asset_exhibition_hours\|ExhibitionHoursLog" \
--include="*.go" services/ pkg/
Expected: 仅 Task 2/3 引入的位置,无第三条路径。
-
Step 4: 跨章节引用一致性
-
docs/specs/2026-07-21-backend-remediation-plan.md §批次1.2:本 plan 全部对齐三条 (去重表 + 资产级 sourceID + 存量重算)。 -
docs/backend-audit-2026-07-21.md §七.2五点根因:Task 2 解 (1)(3),Task 3 解 (2),Task 6/7 解 (4)(5)。 -
Step 5: 删除/未改动章节(CLAUDE.md 全局自审)
未改动章节:docs/superpowers/plans/2026-07-21-exhibition-settlement-idempotency.md(批次 1.1 已前置完成 —— cleanup_worker.go 已删、settled_at 已加、ON CONFLICT 已加)。本 plan 与其无重叠,仅 Task 5 与批次 1.1 中"端到端"步骤互不依赖,可独立 review。
Self-Review
- Spec 覆盖:批次 1.2 三条 (AddExhibitionHours 幂等 + 资产级 sourceID + 存量重算) → Task 2 / Task 3 / Task 4 / Task 6 / Task 7。
docs/specs/2026-07-21-backend-remediation-plan.md §四备份/dry-run/事务/序列同步规范 → 全部 Task 都遵循(备份+dry-run 写在 Task 6,事务包裹+setval 写在 Task 7)。 - CLAUDE.md 全球约束:
- 序列同步(强制):Task 1 末尾 + Task 7 末尾,setval 全打。
- 不自动 commit:所有 commit 步骤标记 "用户批准后"。
- 全局自审:Task 8 已列出未改动章节(批次 1.1 plan)。
- 全局自审失败案例提醒:本 plan 改动了
pkg/models/asset_level.go(追加 model),需检查是否影响 7 个 service 编译 → Task 8 Step 3 已 grep。 - 文档维护传染性:改了 fan_profile_repository.go / asset_level_service.go → 同步修 revenue_service.go:513 调用方(Task 4)→ 同步重算脚本(Task 6/7)→ 同步回归(Task 8)。
- MVP 原则:未引入 provider 抽象、ConversationStore、Redis lock 等"未来 100 明星"复杂基础设施;幂等闸口选"DB 层 + 本地 service local"路径,符合 MVP。
- 类型一致性:
AssetLevelService.AddExhibitionHours(assetID, hours, sourceID)——string类型与现有String()参数兼容;调用方int(actualHours)仍可隐式转。ExhibitionHoursLog.Hours int64(与user_exhibition_hours.total_exhibition_hours一致);AssetExhibitionHoursLog.Hours int(与asset_level_records.season_exhibition_hours int一致)。- 冲销流的
delta类型为int64(与 crystal_transaction_records 一致)。
- 回归盲点:Task 7 的 level 重算使用
level_thresholds.max_exhibition_hours—— 已确认models.LevelThreshold字段拼写为max_exhibition_hours(pkg/models/level.go:12),与脚本一致。 - 测试依赖:本仓库测试通过
setupTestDB连真实top-fans库(见 user_repository_test.go:13-30)。asset_level_service_test.go现有TestCalculateBuff是纯函数无 DB 依赖,Task 3 测试补 DB 路径,需提供testGetDBForAssetLevel辅助或扩levelRepo.GetDB()(Step 2 ⚠️)。 - Placeholder 扫描:无 TBD。
- 优先分类:
- P0: Task 1(migration)、Task 2(用户级幂等闸口)。
- P1: Task 3(资产级幂等闸口)、Task 4(调用方传 sourceID)。
- P2: Task 6/7(存量校正;可有可无;无则后续有 P1 资损仍在累计未来展览)。
路径: /Users/liulujian/Documents/code/TopFansByGithub/docs/superpowers/plans/2026-07-21-exhibition-hours-idempotency.md
任务数: 8(含前置 0、migration 1、Go 改造 3、脚本 2、回归 2)