topfans/docs/superpowers/plans/2026-07-21-exhibition-hours-idempotency.md
2026-07-21 21:14:22 +08:00

46 KiB
Raw Blame History

累计时长幂等 (批次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 NOTHINGRowsAffected==0 则跳过累加(防御纵深)——同样规则覆盖 asset-level 累加(新增 asset_exhibition_hours_log(source_id UNIQUE))。cleanup_worker.go 已在批次1.1 plan 中删除,但当前 user_exhibition_hours1075 行)/ asset_level_records2214 行)的真实累计值已被超 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 += hoursL551-558升级奖励发放L617-651也无 sourceID 幂等;sourceID 仅写到 crystal_transaction_records.SourceID
backend/services/userService/mq/consumer.go:111-131 isAlreadyProcessed 仅查 crystal_transaction_recordschange_type='exhibition_revenue'source_id不是真正的幂等层(漏 cover 7-1.x 路径):依赖上游要么走收益单链(收益记录的 source_id 与累计的 source_id 同值 "exhibition_"),要么直连 RPC无幂等
backend/services/taskService/service/revenue_service.go:487-524 514s.userRPCClient.AddExhibitionHours(... sourceID="exhibition_<exhibition_id>") 传了 sourceID但 513s.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>0source_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.goAddExhibitionHours 事务内先插幂等 logON CONFLICT DO NOTHINGRowsAffected==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.goAssetLevelService.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-runSELECT 出每个 (user_id,star_id)asset_id 的"重算差值 → 推荐下修等级 / 冲销水晶金额"。
  • backend/scripts/fix_exhibition_hours_recalc.sql新建。执行:用 exhibition_revenue_records (exhibition_id, asset_id) distinct JOIN exhibitions 重算 user_exhibition_hoursasset_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=1asset_log_uks=1last_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第二次调用会再 +5total=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_records SourceID 仍写入原 sourceID"exhibition_"),确保 Task 6 存量冲销脚本能按 sourceID 找到误发奖励。

  • models.ExhibitionHoursLogimport 进来;如已在 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 -v Expected: 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_graph callers_of AddExhibitionHoursfan_profile_repository.AddExhibitionHours 全部调用方:
    • userService/provider/user_provider.go:862gRPCuser_service.go:958 → 仓库层 —— 仅源码入口,零业务改动。
    • userService/mq/consumer.go:84MQ user:accumulate-hours)→ 仓库层 —— sourceID 来自 payload原本就唯一幂等闸口新增对它无害。
    • taskService/client/user_rpc_client.go:65RPC 客户端)→ gRPC provider —— 唯一实际传参的调用方;revenue_service.go:487 仍写 sourceID="exhibition_<id>"
  • query_graph callers_of AssetLevelService.AddExhibitionHourstaskService/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.sqldry-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 hoursasset_level_records.season 是当前赛季口径,
--     这里"重算 = 单赛季累计"——若一资产在本赛季跨多个 exhibitionsdistinct 合并;
--     历史赛季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 > 0reverse_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:862gRPC 入口,转发)+ 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:513Task 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_hourspkg/models/level.go:12与脚本一致。
  • 测试依赖:本仓库测试通过 setupTestDB 连真实 top-fans 库(见 user_repository_test.go:13-30asset_level_service_test.go 现有 TestCalculateBuff 是纯函数无 DB 依赖Task 3 测试补 DB 路径,需提供 testGetDBForAssetLevel 辅助或扩 levelRepo.GetDB()Step 2 ⚠️)。
  • Placeholder 扫描:无 TBD。
  • 优先分类
    • P0: Task 1migration、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