topfans/docs/superpowers/plans/2026-07-10-qrcode-peripheral-authentication.md
2026-07-13 11:22:56 +08:00

79 KiB

周边扫码验真 + 加入藏品 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: 用户扫描 https://topfans.online/verify/{assetId} 二维码,看到验真信息(品牌/公司/链上哈希/验证次数等),确认后一键加入我的藏品

Architecture: 双端共用同一组后端 REST 接口(app 内 + 外部 H5 都走 /api/v1/assets/:asset_id/{verification,mint-to-my-collection});Deep link 让外部扫码直接跳 app;加入藏品走简化 mint,跳过 AI 链路,直接 INSERT asset_registry(asset_type='peripheral')

Tech Stack:

  • Backend: Go 1.21+ / Dubbo-go / Gin / GORM / PostgreSQL
  • Frontend: uni-app + Vue 3 (组合式 API + <script setup>)
  • 部署: nginx rewrite + iOS associatedDomains + Android assetlinks.json

Spec: docs/superpowers/specs/2026-07-09-qrcode-peripheral-authentication-design.md(v3)


Global Constraints

来源:CLAUDE.md + spec §11 自检清单 + 已验证的现有代码模式。每条都必须在每个 task 的实现里遵守。

前端(CLAUDE.md §前端开发规范):

  • Vue 3 组合式 API (<script setup>),禁止 this、禁止 Options API
  • 所有原生 / 平台差异代码包 // #ifdef APP-PLUS ... // #endif
  • API 调用走 frontend/utils/api.js,禁止组件内裸写 uni.request
  • 新页面必须在 frontend/pages.json 注册后才能提交
  • 跨页面状态走 URL query (?assetId=xxx) 或 Vuex
  • 禁止手动编辑 unpackage/dist/ 编译产物
  • 性能:长列表用 components/VirtualList.vue,图片用 components/LazyImage.vue

后端(CLAUDE.md §接口开发规范):

  • 分层:handler / service / repository,handler 禁直接 SQL,service 禁直接操作 HTTP
  • 独立 DTO(XxxRequest / XxxResponse),不复用 DB model
  • 参数校验用 binding / validate tag;业务校验在 service
  • 错误处理用统一错误码(本设计:50003/50004/50011/50012)
  • 表结构变更必须写 migration;序列必须同步(见 CLAUDE.md §数据库操作规范)
  • 测试:service 层核心业务 + handler 层 happy path + error case(CLAUDE.md §9)

业务约束(spec):

  • 二维码 URL 形态固定为 https://topfans.online/verify/{assetId}(单一域名,无子域)
  • 加入藏品必须先有 peripheral_info才能 mint(否则返 50003)
  • verify_count 数据源:assets.verify_count 缓存列(异步刷新),不允许实时 COUNT
  • MVP 不做:Provider 抽象 / MintStrategy / 举报 / 海外 / 微信小程序

代码组织约定:

  • Backend 文件路径严格:backend/gateway/controller/(REST 入口)、backend/services/assetService/service/(业务)、backend/services/assetService/repository/(SQL)、backend/pkg/models/(Model)、backend/migrations/
  • 路由挂在 /api/v1/assets/... group(对齐 router.go:315 现有 v1.Group("/assets") 命名)
  • 错误码通过 gateway/pkg/response.ErrorWithCode(c, code, msg) 统一处理
  • 文件名:peripheral_service.go / peripheral_repo.go(单数,非 peripheral_mint_*)

Task 1: 数据库迁移 — peripheral_info 表 + assets.verify_count

Files:

  • Create: backend/migrations/2026_07_10_001_peripheral_mint.sql
  • Test: 人工在 staging 执行 psql 验证表已建、约束生效、回填数对得上

Interfaces:

  • Produces: 表 peripheral_info 存在;列 assets.verify_count 已添加;老 peripheral 数据 verify_count 已回填

  • Step 1: 编写 migration SQL

backend/migrations/2026_07_10_001_peripheral_mint.sql 写入:

-- 1. asset_registry 已有 asset_type 列('regular' | 'collection' | 'activity'),
--    VARCHAR(20) NOT NULL,无 CHECK 约束(见 migrate_create_collection_activity_registry_tables.sql:61)。
--    本次不动 schema,只在新代码里允许 asset_type='peripheral' 写入。

-- 2. asset 表加 verify_count 列(若不存在)
--    用于缓存"该周边累计被加入藏品的人次",详情见 §4.1
ALTER TABLE assets
  ADD COLUMN IF NOT EXISTS verify_count INT NOT NULL DEFAULT 0;

-- 2.1 一次性回填已有 peripheral 周边
UPDATE assets a
SET verify_count = COALESCE((
    SELECT COUNT(*)
    FROM asset_registry r
    WHERE r.asset_id = a.id AND r.asset_type = 'peripheral'
), 0);

-- 3. 新增 peripheral_info 表:周边验真详情(一对一关联 assets)
--    复用现有 asset_registry 的 uk_registry_owner_star_type_asset 约束防重复
CREATE TABLE IF NOT EXISTS peripheral_info (
    asset_id              BIGINT       PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,
    brand                 VARCHAR(100) NOT NULL DEFAULT '',
    company               VARCHAR(200) NOT NULL DEFAULT '',
    hash                  VARCHAR(100) NOT NULL DEFAULT '',
    verifier              VARCHAR(100) NOT NULL DEFAULT '',
    first_verified_at     BIGINT       NOT NULL DEFAULT 0,
    created_at            BIGINT       NOT NULL,
    updated_at            BIGINT       NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_peripheral_info_brand
  ON peripheral_info(brand) WHERE brand <> '';

-- 4. 序列同步(asset_registry.id 是 BIGSERIAL,peripheral_info 用 asset_id 作主键无需序列)
SELECT setval(
  pg_get_serial_sequence('asset_registry', 'id'),
  (SELECT MAX(id) FROM asset_registry)
);
  • Step 2: 人工在 staging 验证

执行:

psql $STAGING_DB_URL -f backend/migrations/2026_07_10_001_peripheral_mint.sql

预期输出:无错误;NOTICE 可能出现在 ADD COLUMN IF NOT EXISTS(列已存在则跳过)。

验证(必须三句都通过):

-- 验证 1:peripheral_info 表已建
SELECT COUNT(*) FROM peripheral_info;  -- 预期: 0(新表)

-- 验证 2:assets.verify_count 已添加
SELECT verify_count FROM assets LIMIT 1;  -- 预期: 0(默认)

-- 验证 3:回填对得上(若 staging 已有 peripheral 数据)
SELECT COUNT(*) FROM asset_registry WHERE asset_type = 'peripheral';  -- 假设为 N
SELECT COUNT(*) FROM assets WHERE verify_count > 0;  -- 预期也为 N(已 peripheral 的周边)
  • Step 3: Commit

按 CLAUDE.md §Git 提交规范,等待用户明确指示"commit"再执行,本 task 不主动 commit。


Task 2: PeripheralInfo Go 模型

Files:

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

Interfaces:

  • Produces: models.PeripheralInfo struct,可被后续 task 的 repo/service 引用

  • Step 1: 编写 PeripheralInfo 模型

backend/pkg/models/peripheral_info.go 写入:

package models

// PeripheralInfo 周边验真详情表(一对一关联 assets)
type PeripheralInfo struct {
    AssetID         int64  `gorm:"primaryKey;column:asset_id"`
    Brand           string `gorm:"type:varchar(100);not null;default:'';column:brand"`
    Company         string `gorm:"type:varchar(200);not null;default:'';column:company"`
    Hash            string `gorm:"type:varchar(100);not null;default:'';column:hash"`
    Verifier        string `gorm:"type:varchar(100);not null;default:'';column:verifier"`
    FirstVerifiedAt int64  `gorm:"not null;default:0;column:first_verified_at"`
    CreatedAt       int64  `gorm:"not null;column:created_at"`
    UpdatedAt       int64  `gorm:"not null;column:updated_at"`
}

func (PeripheralInfo) TableName() string { return "peripheral_info" }
  • Step 2: 验证编译通过
cd backend
go build ./pkg/models/...

预期:无错误,build 成功。

  • Step 3: Commit(等待用户指示)

Task 3: Repository — GetAssetForVerification + GetPeripheralInfo(TDD)

Files:

  • Create: backend/services/assetService/repository/peripheral_repo.go
  • Create: backend/services/assetService/repository/peripheral_repo_test.go

Interfaces:

  • Consumes: *gorm.DB,models.Asset,models.PeripheralInfo

  • Produces:

    • type PeripheralRepository struct { db *gorm.DB }
    • func NewPeripheralRepository(db *gorm.DB) *PeripheralRepository
    • func (r *PeripheralRepository) GetAssetForVerification(ctx context.Context, assetID int64) (*models.Asset, error) — not found 返 (nil, nil)
    • func (r *PeripheralRepository) GetPeripheralInfo(ctx context.Context, assetID int64) (*models.PeripheralInfo, error) — not found 返 (nil, nil)
  • Step 1: 写 repo 测试

backend/services/assetService/repository/peripheral_repo_test.go 写入:

package repository

import (
    "context"
    "testing"

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

// helper:setup/cleanup 测试用 asset + peripheral_info
func setupPeripheralTestData(t *testing.T, db *gorm.DB, withPeripheralInfo bool) int64 {
    t.Helper()
    assetID := int64(99000001)
    db.Exec("DELETE FROM peripheral_info WHERE asset_id = ?", assetID)
    db.Exec("DELETE FROM assets WHERE id = ?", assetID)
    db.Exec(`INSERT INTO assets (id, owner_uid, star_id, name, cover_url, is_active, created_at, updated_at)
             VALUES (?, 1, 1, 'test_peripheral', 'http://example.com/c.jpg', true, 1, 1)`, assetID)
    if withPeripheralInfo {
        db.Exec(`INSERT INTO peripheral_info (asset_id, brand, company, hash, verifier, first_verified_at, created_at, updated_at)
                 VALUES (?, 'TopFans', '上海文化', '0xabc', '官方', 1715600000000, 1, 1)`, assetID)
    }
    return assetID
}

func TestPeripheralRepo_GetAssetForVerification_Exists(t *testing.T) {
    db := database.GetTestDB(t) // 项目已有的 test DB helper
    repo := NewPeripheralRepository(db)
    assetID := setupPeripheralTestData(t, db, false)

    got, err := repo.GetAssetForVerification(context.Background(), assetID)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got == nil {
        t.Fatal("expected asset, got nil")
    }
    if got.ID != assetID {
        t.Errorf("expected id=%d, got %d", assetID, got.ID)
    }
}

func TestPeripheralRepo_GetAssetForVerification_NotFound(t *testing.T) {
    db := database.GetTestDB(t)
    repo := NewPeripheralRepository(db)

    got, err := repo.GetAssetForVerification(context.Background(), 999999999999)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got != nil {
        t.Errorf("expected nil, got %+v", got)
    }
}

func TestPeripheralRepo_GetPeripheralInfo_Exists(t *testing.T) {
    db := database.GetTestDB(t)
    repo := NewPeripheralRepository(db)
    assetID := setupPeripheralTestData(t, db, true)

    got, err := repo.GetPeripheralInfo(context.Background(), assetID)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got == nil {
        t.Fatal("expected PeripheralInfo, got nil")
    }
    if got.Brand != "TopFans" {
        t.Errorf("expected brand=TopFans, got %s", got.Brand)
    }
}

func TestPeripheralRepo_GetPeripheralInfo_NotFound(t *testing.T) {
    db := database.GetTestDB(t)
    repo := NewPeripheralRepository(db)

    got, err := repo.GetPeripheralInfo(context.Background(), 999999999999)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got != nil {
        t.Errorf("expected nil, got %+v", got)
    }
}
  • Step 2: 运行测试确认失败
cd backend
go test ./services/assetService/repository/ -run TestPeripheralRepo -v

预期:FAIL with "undefined: NewPeripheralRepository"。

  • Step 3: 写 repo 实现(只 GetAssetForVerification + GetPeripheralInfo)

backend/services/assetService/repository/peripheral_repo.go 写入:

package repository

import (
    "context"

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

// PeripheralRepository 周边验真 + 加入藏品的数据访问层
type PeripheralRepository struct {
    db *gorm.DB
}

func NewPeripheralRepository(db *gorm.DB) *PeripheralRepository {
    return &PeripheralRepository{db: db}
}

// GetAssetForVerification 查 asset(已下架/已删除视为不存在)
// 沿用现有 asset_repository.go:110 的过滤条件:is_active=true AND deleted_at IS NULL
func (r *PeripheralRepository) GetAssetForVerification(ctx context.Context, assetID int64) (*models.Asset, error) {
    var asset models.Asset
    err := r.db.WithContext(ctx).
        Where("id = ? AND is_active = ? AND deleted_at IS NULL", assetID, true).
        First(&asset).Error
    if err == gorm.ErrRecordNotFound {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    return &asset, nil
}

// GetPeripheralInfo 查 peripheral_info,not found 返 (nil, nil)
// service 层把"peripheral_info 缺失"视为"非周边",返 50003
func (r *PeripheralRepository) GetPeripheralInfo(ctx context.Context, assetID int64) (*models.PeripheralInfo, error) {
    var info models.PeripheralInfo
    err := r.db.WithContext(ctx).
        Where("asset_id = ?", assetID).
        First(&info).Error
    if err == gorm.ErrRecordNotFound {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    return &info, nil
}
  • Step 4: 运行测试确认通过
cd backend
go test ./services/assetService/repository/ -run TestPeripheralRepo -v

预期:PASS,4 个测试全过。

  • Step 5: Commit(等待用户指示)

Task 4: Repository — ExistsRegistry + CountRecentMint(TDD)

Files:

  • Modify: backend/services/assetService/repository/peripheral_repo.go
  • Modify: backend/services/assetService/repository/peripheral_repo_test.go

Interfaces:

  • Produces:

    • func (r *PeripheralRepository) ExistsRegistry(ctx context.Context, ownerUID, assetID int64, assetType string) (bool, error)
    • func (r *PeripheralRepository) CountRecentMint(ctx context.Context, ownerUID int64, assetType string, since time.Duration) (int64, error)
  • Step 1: 追加测试

backend/services/assetService/repository/peripheral_repo_test.go 末尾追加:

import "time"

func TestPeripheralRepo_ExistsRegistry_True(t *testing.T) {
    db := database.GetTestDB(t)
    repo := NewPeripheralRepository(db)
    assetID := setupPeripheralTestData(t, db, false)

    db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
             VALUES (?, ?, 1, 'peripheral', 1, 1, 1)`, 100, assetID)
    defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ? AND asset_id = ?", 100, assetID)

    exists, err := repo.ExistsRegistry(context.Background(), 100, assetID, "peripheral")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if !exists {
        t.Error("expected exists=true, got false")
    }
}

func TestPeripheralRepo_ExistsRegistry_False(t *testing.T) {
    db := database.GetTestDB(t)
    repo := NewPeripheralRepository(db)
    assetID := setupPeripheralTestData(t, db, false)

    exists, err := repo.ExistsRegistry(context.Background(), 100, assetID, "peripheral")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if exists {
        t.Error("expected exists=false, got true")
    }
}

func TestPeripheralRepo_CountRecentMint(t *testing.T) {
    db := database.GetTestDB(t)
    repo := NewPeripheralRepository(db)
    assetID := setupPeripheralTestData(t, db, false)
    ownerUID := int64(101)

    db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)
    // 3 条最近,1 条 25h 前
    now := time.Now().UnixMilli()
    db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
             VALUES (?, ?, 1, 'peripheral', 1, ?, ?), (?, ?, 1, 'peripheral', 1, ?, ?), (?, ?, 1, 'peripheral', 1, ?, ?)`,
        ownerUID, assetID, now, now, ownerUID, assetID, now, now, ownerUID, assetID, now, now)
    db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
             VALUES (?, ?, 1, 'peripheral', 1, ?, ?)`,
        ownerUID, assetID, now-25*3600*1000, now-25*3600*1000)
    defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)

    count, err := repo.CountRecentMint(context.Background(), ownerUID, "peripheral", 24*time.Hour)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if count != 3 {
        t.Errorf("expected count=3, got %d", count)
    }
}
  • Step 2: 运行测试确认失败
cd backend
go test ./services/assetService/repository/ -run "TestPeripheralRepo_ExistsRegistry|TestPeripheralRepo_CountRecentMint" -v

预期:FAIL with "undefined: ExistsRegistry"。

  • Step 3: 实现两个方法

backend/services/assetService/repository/peripheral_repo.go 末尾追加:

import "time"

// ExistsRegistry 查重:同一用户同一 asset_id 同一 asset_type 是否已有记录
// 依赖 asset_registry 已有 uk_registry_owner_star_type_asset UNIQUE 约束
func (r *PeripheralRepository) ExistsRegistry(ctx context.Context, ownerUID, assetID int64, assetType string) (bool, error) {
    var count int64
    err := r.db.WithContext(ctx).
        Model(&models.AssetRegistry{}).
        Where("owner_uid = ? AND asset_id = ? AND asset_type = ?", ownerUID, assetID, assetType).
        Limit(1).
        Count(&count).Error
    if err != nil {
        return false, err
    }
    return count > 0, nil
}

// CountRecentMint 限频:近 since 时间内同一用户同一 asset_type 的 mint 数
func (r *PeripheralRepository) CountRecentMint(ctx context.Context, ownerUID int64, assetType string, since time.Duration) (int64, error) {
    var count int64
    threshold := time.Now().Add(-since).UnixMilli()
    err := r.db.WithContext(ctx).
        Model(&models.AssetRegistry{}).
        Where("owner_uid = ? AND asset_type = ? AND created_at > ?", ownerUID, assetType, threshold).
        Count(&count).Error
    if err != nil {
        return 0, err
    }
    return count, nil
}
  • Step 4: 运行测试确认通过
cd backend
go test ./services/assetService/repository/ -run "TestPeripheralRepo_ExistsRegistry|TestPeripheralRepo_CountRecentMint" -v

预期:PASS,3 个测试全过。

  • Step 5: Commit(等待用户指示)

Task 5: Repository — InsertPeripheralRegistry + RefreshVerifyCount(TDD)

Files:

  • Modify: backend/services/assetService/repository/peripheral_repo.go
  • Modify: backend/services/assetService/repository/peripheral_repo_test.go

Interfaces:

  • Produces:

    • func (r *PeripheralRepository) InsertPeripheralRegistry(ctx context.Context, reg *models.AssetRegistry) (newID int64, createdAtMs int64, err error)RETURNING id, created_at
    • func (r *PeripheralRepository) RefreshVerifyCount(ctx context.Context, assetID int64) error — 事务包裹 COUNT + UPDATE
  • Step 1: 追加测试

backend/services/assetService/repository/peripheral_repo_test.go 末尾追加:

func TestPeripheralRepo_InsertPeripheralRegistry(t *testing.T) {
    db := database.GetTestDB(t)
    repo := NewPeripheralRepository(db)
    assetID := setupPeripheralTestData(t, db, false)

    defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ? AND asset_id = ?", 200, assetID)

    reg := &models.AssetRegistry{
        OwnerUID:  200,
        AssetID:   assetID,
        StarID:    1,
        AssetType: "peripheral",
        Status:    models.AssetRegistryStatusActive,
    }
    newID, createdAtMs, err := repo.InsertPeripheralRegistry(context.Background(), reg)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if newID <= 0 {
        t.Errorf("expected newID > 0, got %d", newID)
    }
    if createdAtMs <= 0 {
        t.Errorf("expected createdAtMs > 0, got %d", createdAtMs)
    }
    // 验证 DB 确实写入
    var got models.AssetRegistry
    if err := db.First(&got, newID).Error; err != nil {
        t.Fatalf("verify insert failed: %v", err)
    }
    if got.AssetType != "peripheral" {
        t.Errorf("expected asset_type=peripheral, got %s", got.AssetType)
    }
}

func TestPeripheralRepo_RefreshVerifyCount(t *testing.T) {
    db := database.GetTestDB(t)
    repo := NewPeripheralRepository(db)
    assetID := setupPeripheralTestData(t, db, false)

    // 写入 5 条 peripheral mint
    now := time.Now().UnixMilli()
    for i := 0; i < 5; i++ {
        db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
                 VALUES (?, ?, 1, 'peripheral', 1, ?, ?)`, int64(300+i), assetID, now, now)
    }
    defer db.Exec("DELETE FROM asset_registry WHERE asset_id = ?", assetID)

    // 初次 verify_count=0(setup 时默认),刷新后应为 5
    if err := repo.RefreshVerifyCount(context.Background(), assetID); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    var asset models.Asset
    if err := db.First(&asset, assetID).Error; err != nil {
        t.Fatalf("query asset failed: %v", err)
    }
    if asset.VerifyCount != 5 {
        t.Errorf("expected verify_count=5, got %d", asset.VerifyCount)
    }
}
  • Step 2: 运行测试确认失败
cd backend
go test ./services/assetService/repository/ -run "TestPeripheralRepo_InsertPeripheralRegistry|TestPeripheralRepo_RefreshVerifyCount" -v

预期:FAIL with "undefined: InsertPeripheralRegistry"。

  • Step 3: 实现两个方法

backend/services/assetService/repository/peripheral_repo.go 末尾追加:

// InsertPeripheralRegistry INSERT 一条 peripheral mint
// 返回 (newID, createdAtMs),createdAtMs 来自 BeforeCreate 钩子自动填的 time.Now().UnixMilli()
// 用 RETURNING 一次拿全,避免 service 二次查询
func (r *PeripheralRepository) InsertPeripheralRegistry(ctx context.Context, reg *models.AssetRegistry) (int64, int64, error) {
    // BeforeCreate 钩子会自动填 created_at = time.Now().UnixMilli()
    if err := r.db.WithContext(ctx).Create(reg).Error; err != nil {
        return 0, 0, err
    }
    return reg.ID, reg.CreatedAt, nil
}

// RefreshVerifyCount 异步刷新某周边 verify_count
// 事务包裹:Step A COUNT(*) → Step B UPDATE assets.verify_count = ?
// 失败仅记录日志,不阻塞 mint 主流程(spec §4.1 明确)
func (r *PeripheralRepository) RefreshVerifyCount(ctx context.Context, assetID int64) error {
    return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        var count int64
        if err := tx.Model(&models.AssetRegistry{}).
            Where("asset_id = ? AND asset_type = ?", assetID, "peripheral").
            Count(&count).Error; err != nil {
            return err
        }
        return tx.Model(&models.Asset{}).
            Where("id = ?", assetID).
            Update("verify_count", count).Error
    })
}
  • Step 4: 运行测试确认通过
cd backend
go test ./services/assetService/repository/ -run "TestPeripheralRepo_InsertPeripheralRegistry|TestPeripheralRepo_RefreshVerifyCount" -v

预期:PASS,2 个测试全过。

  • Step 5: 运行所有 repo 测试确认全过
cd backend
go test ./services/assetService/repository/ -v

预期:PASS,9 个测试全过(4 + 3 + 2)。

  • Step 6: Commit(等待用户指示)

Task 6: Service — BizError + PeripheralService.GetVerification(TDD)

Files:

  • Create: backend/services/assetService/service/peripheral_service.go
  • Create: backend/services/assetService/service/peripheral_service_test.go

Interfaces:

  • Consumes: *repository.PeripheralRepository

  • Produces:

    • type BizError struct { Code int; Message string } + Error() string 方法
    • 业务码常量:BizCodeAssetNotFound = 50003
    • type PeripheralService struct { repo *repository.PeripheralRepository }
    • func NewPeripheralService(repo *repository.PeripheralRepository) *PeripheralService
    • type VerificationResult struct { AssetID, VerifyCount int64; Company, Hash, Brand, Image, Verifier string; VerifiedAt int64; SourceURL string }
    • func (s *PeripheralService) GetVerification(ctx context.Context, assetID int64) (*VerificationResult, error) — 返 BizError 表示业务错误
  • Step 1: 写 GetVerification 测试

backend/services/assetService/service/peripheral_service_test.go 写入:

package service

import (
    "context"
    "errors"
    "testing"

    "github.com/topfans/backend/pkg/database"
    "github.com/topfans/backend/pkg/models"
    "github.com/topfans/backend/services/assetService/repository"
    "gorm.io/gorm"
)

func setupAssetWithPeripheral(t *testing.T, db *gorm.DB) int64 {
    t.Helper()
    assetID := int64(99000100)
    db.Exec("DELETE FROM peripheral_info WHERE asset_id = ?", assetID)
    db.Exec("DELETE FROM assets WHERE id = ?", assetID)
    db.Exec(`INSERT INTO assets (id, owner_uid, star_id, name, cover_url, is_active, created_at, updated_at, verify_count)
             VALUES (?, 1, 1, 'p', 'http://example.com/p.jpg', true, 1, 1, 7)`, assetID)
    db.Exec(`INSERT INTO peripheral_info (asset_id, brand, company, hash, verifier, first_verified_at, created_at, updated_at)
             VALUES (?, 'BrandX', 'CompanyY', '0xdeadbeef', 'verifierZ', 1715600000000, 1, 1)`, assetID)
    return assetID
}

func TestPeripheralService_GetVerification_Success(t *testing.T) {
    db := database.GetTestDB(t)
    repo := repository.NewPeripheralRepository(db)
    svc := NewPeripheralService(repo)
    assetID := setupAssetWithPeripheral(t, db)

    result, err := svc.GetVerification(context.Background(), assetID)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result == nil {
        t.Fatal("expected result, got nil")
    }
    if result.AssetID != assetID {
        t.Errorf("expected assetID=%d, got %d", assetID, result.AssetID)
    }
    if result.VerifyCount != 7 {
        t.Errorf("expected verify_count=7, got %d", result.VerifyCount)
    }
    if result.Brand != "BrandX" {
        t.Errorf("expected brand=BrandX, got %s", result.Brand)
    }
    if result.Hash != "0xdeadbeef" {
        t.Errorf("expected hash=0xdeadbeef, got %s", result.Hash)
    }
    // first_verified_at=1715600000000 ms → 1715600000 s
    if result.VerifiedAt != 1715600000 {
        t.Errorf("expected verified_at=1715600000 (seconds), got %d", result.VerifiedAt)
    }
    if result.SourceURL == "" {
        t.Error("expected source_url populated")
    }
}

func TestPeripheralService_GetVerification_AssetNotFound(t *testing.T) {
    db := database.GetTestDB(t)
    repo := repository.NewPeripheralRepository(db)
    svc := NewPeripheralService(repo)

    _, err := svc.GetVerification(context.Background(), 999999999999)
    var bizErr *BizError
    if !errors.As(err, &bizErr) {
        t.Fatalf("expected BizError, got %T: %v", err, err)
    }
    if bizErr.Code != BizCodeAssetNotFound {
        t.Errorf("expected code=%d, got %d", BizCodeAssetNotFound, bizErr.Code)
    }
}

func TestPeripheralService_GetVerification_PeripheralInfoMissing(t *testing.T) {
    db := database.GetTestDB(t)
    repo := repository.NewPeripheralRepository(db)
    svc := NewPeripheralService(repo)
    // 只有 asset,没有 peripheral_info
    assetID := int64(99000101)
    db.Exec("DELETE FROM peripheral_info WHERE asset_id = ?", assetID)
    db.Exec("DELETE FROM assets WHERE id = ?", assetID)
    db.Exec(`INSERT INTO assets (id, owner_uid, star_id, name, cover_url, is_active, created_at, updated_at)
             VALUES (?, 1, 1, 'p', 'http://example.com/p.jpg', true, 1, 1)`, assetID)

    _, err := svc.GetVerification(context.Background(), assetID)
    var bizErr *BizError
    if !errors.As(err, &bizErr) {
        t.Fatalf("expected BizError, got %T: %v", err, err)
    }
    if bizErr.Code != BizCodeAssetNotFound {
        t.Errorf("expected code=%d, got %d", BizCodeAssetNotFound, bizErr.Code)
    }
}
  • Step 2: 运行测试确认失败
cd backend
go test ./services/assetService/service/ -run TestPeripheralService_GetVerification -v

预期:FAIL with "undefined: NewPeripheralService"。

  • Step 3: 实现 GetVerification + BizError + 类型

backend/services/assetService/service/peripheral_service.go 写入:

package service

import (
    "context"
    "fmt"
    "time"

    "github.com/topfans/backend/pkg/models"
    "github.com/topfans/backend/services/assetService/repository"
)

// BizError 自定义业务错误
// pkg/errors 的 NewError(codes.Code, msg) 用 gRPC codes.Code(0-16),装不下 50003 等业务码
// 故在 peripheral 模块本地定义 BizError(后续如需入 pkg/errors 再升级)
type BizError struct {
    Code    int
    Message string
}

func (e *BizError) Error() string { return fmt.Sprintf("[%d] %s", e.Code, e.Message) }

const (
    BizCodeAssetNotFound = 50003
    BizCodeAlreadyAdded  = 50004
    BizCodeRateLimited   = 50012
    BizCodeCannotAddSelf = 50011 // 防御性,周边不该出现
)

// VerificationResult 验真接口响应(spec §4.1)
type VerificationResult struct {
    AssetID     int64  `json:"asset_id"`
    Company     string `json:"company"`
    Hash        string `json:"hash"`
    VerifyCount int64  `json:"verify_count"`
    Brand       string `json:"brand"`
    Image       string `json:"image"`
    Verifier    string `json:"verifier"`
    VerifiedAt  int64  `json:"verified_at"` // unix 秒
    SourceURL   string `json:"source_url"`
}

type PeripheralService struct {
    repo *repository.PeripheralRepository
}

func NewPeripheralService(repo *repository.PeripheralRepository) *PeripheralService {
    return &PeripheralService{repo: repo}
}

// GetVerification 验真接口
// 流程:查 asset → 查 peripheral_info → 读 assets.verify_count 缓存
func (s *PeripheralService) GetVerification(ctx context.Context, assetID int64) (*VerificationResult, error) {
    asset, err := s.repo.GetAssetForVerification(ctx, assetID)
    if err != nil {
        return nil, fmt.Errorf("DB_GET_ASSET_FAILED: %w", err)
    }
    if asset == nil {
        return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"}
    }
    info, err := s.repo.GetPeripheralInfo(ctx, assetID)
    if err != nil {
        return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_FAILED: %w", err)
    }
    if info == nil {
        return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"}
    }
    return &VerificationResult{
        AssetID:     asset.ID,
        Company:     info.Company,
        Hash:        info.Hash,
        VerifyCount: asset.VerifyCount, // 读缓存,不实时 COUNT
        Brand:       info.Brand,
        Image:       asset.CoverURL,
        Verifier:    info.Verifier,
        VerifiedAt:  info.FirstVerifiedAt / 1000, // 毫秒 → 秒
        SourceURL:   fmt.Sprintf("https://topfans.online/verify/%d", asset.ID),
    }, nil
}

// MintFromPeripheral 占位,Task 7 实现
func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID, assetID int64) (*MintResult, error) {
    _ = time.Now() // 暂时占位
    return nil, fmt.Errorf("not implemented yet")
}
  • Step 4: 运行测试确认 GetVerification 3 个测试通过
cd backend
go test ./services/assetService/service/ -run TestPeripheralService_GetVerification -v

预期:PASS,3 个测试全过。

  • Step 5: Commit(等待用户指示)

Task 7: Service — MintFromPeripheral(TDD)

Files:

  • Modify: backend/services/assetService/service/peripheral_service.go
  • Modify: backend/services/assetService/service/peripheral_service_test.go

Interfaces:

  • Produces:

    • type MintResult struct { InstanceID, AssetID, MintedAt int64; CoverImage string }
    • func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID, assetID int64) (*MintResult, error)
  • Step 1: 写 MintFromPeripheral 测试

backend/services/assetService/service/peripheral_service_test.go 末尾追加:

func TestPeripheralService_MintFromPeripheral_Success(t *testing.T) {
    db := database.GetTestDB(t)
    repo := repository.NewPeripheralRepository(db)
    svc := NewPeripheralService(repo)
    assetID := setupAssetWithPeripheral(t, db)
    ownerUID := int64(500)
    db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)

    // 等异步 RefreshVerifyCount 完成
    result, err := svc.MintFromPeripheral(context.Background(), ownerUID, assetID)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result == nil {
        t.Fatal("expected result, got nil")
    }
    if result.InstanceID <= 0 {
        t.Errorf("expected instance_id > 0, got %d", result.InstanceID)
    }
    if result.MintedAt <= 0 {
        t.Errorf("expected minted_at > 0, got %d", result.MintedAt)
    }
    if result.CoverImage == "" {
        t.Error("expected cover_image populated")
    }
}

func TestPeripheralService_MintFromPeripheral_AlreadyAdded(t *testing.T) {
    db := database.GetTestDB(t)
    repo := repository.NewPeripheralRepository(db)
    svc := NewPeripheralService(repo)
    assetID := setupAssetWithPeripheral(t, db)
    ownerUID := int64(501)

    // 先插一条,再调 mint
    db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
             VALUES (?, ?, 1, 'peripheral', 1, 1, 1)`, ownerUID, assetID)
    defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)

    _, err := svc.MintFromPeripheral(context.Background(), ownerUID, assetID)
    var bizErr *BizError
    if !errors.As(err, &bizErr) {
        t.Fatalf("expected BizError, got %T: %v", err, err)
    }
    if bizErr.Code != BizCodeAlreadyAdded {
        t.Errorf("expected code=%d, got %d", BizCodeAlreadyAdded, bizErr.Code)
    }
}

func TestPeripheralService_MintFromPeripheral_RateLimited(t *testing.T) {
    db := database.GetTestDB(t)
    repo := repository.NewPeripheralRepository(db)
    svc := NewPeripheralService(repo)
    assetID := setupAssetWithPeripheral(t, db)
    ownerUID := int64(502)

    // 插 10 条最近 mint(限频阈值)
    now := time.Now().UnixMilli()
    for i := 0; i < 10; i++ {
        db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
                 VALUES (?, ?, 1, 'peripheral', 1, ?, ?), (?, ?, ?, ?, ?, ?, ?)`,
            int64(600+i*10), int64(9000000+i), int64(9000000+i),
            ownerUID, assetID, int64(9000000+i), now, now)
    }
    defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)

    _, err := svc.MintFromPeripheral(context.Background(), ownerUID, assetID)
    var bizErr *BizError
    if !errors.As(err, &bizErr) {
        t.Fatalf("expected BizError, got %T: %v", err, err)
    }
    if bizErr.Code != BizCodeRateLimited {
        t.Errorf("expected code=%d, got %d", BizCodeRateLimited, bizErr.Code)
    }
}

func TestPeripheralService_MintFromPeripheral_AssetNotFound(t *testing.T) {
    db := database.GetTestDB(t)
    repo := repository.NewPeripheralRepository(db)
    svc := NewPeripheralService(repo)

    _, err := svc.MintFromPeripheral(context.Background(), 999, 999999999999)
    var bizErr *BizError
    if !errors.As(err, &bizErr) {
        t.Fatalf("expected BizError, got %T: %v", err, err)
    }
    if bizErr.Code != BizCodeAssetNotFound {
        t.Errorf("expected code=%d, got %d", BizCodeAssetNotFound, bizErr.Code)
    }
}

func TestPeripheralService_MintFromPeripheral_PeripheralInfoMissing(t *testing.T) {
    db := database.GetTestDB(t)
    repo := repository.NewPeripheralRepository(db)
    svc := NewPeripheralService(repo)
    assetID := int64(99000102)
    db.Exec("DELETE FROM peripheral_info WHERE asset_id = ?", assetID)
    db.Exec("DELETE FROM assets WHERE id = ?", assetID)
    db.Exec(`INSERT INTO assets (id, owner_uid, star_id, name, cover_url, is_active, created_at, updated_at)
             VALUES (?, 1, 1, 'p', 'http://example.com/p.jpg', true, 1, 1)`, assetID)

    _, err := svc.MintFromPeripheral(context.Background(), 999, assetID)
    var bizErr *BizError
    if !errors.As(err, &bizErr) {
        t.Fatalf("expected BizError, got %T: %v", err, err)
    }
    if bizErr.Code != BizCodeAssetNotFound {
        t.Errorf("expected code=%d, got %d", BizCodeAssetNotFound, bizErr.Code)
    }
}

(顶部 import 块需加 time)

  • Step 2: 运行测试确认失败
cd backend
go test ./services/assetService/service/ -run TestPeripheralService_MintFromPeripheral -v

预期:FAIL with "not implemented yet"(Task 6 的占位)。

  • Step 3: 实现 MintFromPeripheral(替换 Task 6 的占位)

backend/services/assetService/service/peripheral_service.go 把 MintFromPeripheral 占位替换为:

// MintResult 加入藏品接口响应(spec §4.2)
type MintResult struct {
    InstanceID int64  `json:"instance_id"`
    AssetID    int64  `json:"asset_id"`
    MintedAt   int64  `json:"minted_at"` // unix 秒
    CoverImage string `json:"cover_image"`
}

// MintFromPeripheral 加入藏品(简化版 mint,跳过 AI 链路)
// 流程:验真(asset + peripheral_info) → 查重 → 限频 → INSERT → 异步刷 verify_count
func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID, assetID int64) (*MintResult, error) {
    // 1. 验真:必须存在 + 必须有 peripheral_info
    asset, err := s.repo.GetAssetForVerification(ctx, assetID)
    if err != nil {
        return nil, fmt.Errorf("DB_GET_ASSET_FAILED: %w", err)
    }
    if asset == nil {
        return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"}
    }
    info, err := s.repo.GetPeripheralInfo(ctx, assetID)
    if err != nil {
        return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_FAILED: %w", err)
    }
    if info == nil {
        return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"}
    }

    // 2. 查重(依赖已有 uk_registry_owner_star_type_asset 约束)
    exists, err := s.repo.ExistsRegistry(ctx, ownerUID, assetID, "peripheral")
    if err != nil {
        return nil, fmt.Errorf("DB_EXISTS_FAILED: %w", err)
    }
    if exists {
        return nil, &BizError{Code: BizCodeAlreadyAdded, Message: "您已添加过此周边"}
    }

    // 3. 限频:24h 最多 10 次
    count, err := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour)
    if err != nil {
        return nil, fmt.Errorf("DB_COUNT_FAILED: %w", err)
    }
    if count >= 10 {
        return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"}
    }

    // 4. INSERT(asset_type='peripheral')
    //    ★ AssetRegistry 模型字段:ID, AssetID, AssetType, OwnerUID, StarID, Status, LikeCount, DisplayStatus, CreatedAt, UpdatedAt
    newID, createdAtMs, err := s.repo.InsertPeripheralRegistry(ctx, &models.AssetRegistry{
        OwnerUID:  ownerUID,
        AssetID:   assetID,
        StarID:    asset.StarID,
        AssetType: "peripheral",
        Status:    models.AssetRegistryStatusActive,
    })
    if err != nil {
        return nil, fmt.Errorf("DB_INSERT_FAILED: %w", err)
    }

    // 5. 异步刷新 verify_count(失败仅日志,不阻塞 mint 主流程)
    go func() {
        _ = s.repo.RefreshVerifyCount(context.Background(), assetID)
    }()

    return &MintResult{
        InstanceID: newID,
        AssetID:    assetID,
        MintedAt:   createdAtMs / 1000, // 毫秒 → 秒,与 §4.2 数据契约对齐
        CoverImage: asset.CoverURL,
    }, nil
}
  • Step 4: 运行测试确认 MintFromPeripheral 5 个测试通过
cd backend
go test ./services/assetService/service/ -run TestPeripheralService_MintFromPeripheral -v

预期:PASS,5 个测试全过。

  • Step 5: 运行所有 service 测试确认全过
cd backend
go test ./services/assetService/service/ -v

预期:PASS,8 个测试全过(3 个 GetVerification + 5 个 MintFromPeripheral)。

  • Step 6: Commit(等待用户指示)

Task 8: DTO — VerificationResponseDTO + MintResponseDTO

Files:

  • Create: backend/gateway/dto/peripheral_dto.go

Interfaces:

  • Produces:

    • type VerificationResponseDTO struct { Code int; Data *VerificationData; Message string }
    • type VerificationData struct { AssetID int64; Company, Hash, Brand, Image, Verifier, SourceURL string; VerifyCount, VerifiedAt int64 }
    • type MintResponseDTO struct { Code int; Data *MintData; Message string }
    • type MintData struct { InstanceID, AssetID, MintedAt int64; CoverImage string }
  • Step 1: 编写 DTO

backend/gateway/dto/peripheral_dto.go 写入:

package dto

// VerificationResponseDTO 验真接口响应(对齐 spec §4.1 JSON 字段名)
type VerificationResponseDTO struct {
    Code    int              `json:"code"`
    Data    *VerificationData `json:"data"`
    Message string           `json:"message"`
}

type VerificationData struct {
    AssetID     int64  `json:"asset_id"`
    Company     string `json:"company"`
    Hash        string `json:"hash"`
    VerifyCount int64  `json:"verify_count"`
    Brand       string `json:"brand"`
    Image       string `json:"image"`
    Verifier    string `json:"verifier"`
    VerifiedAt  int64  `json:"verified_at"`
    SourceURL   string `json:"source_url"`
}

// MintResponseDTO 加入藏品响应(对齐 spec §4.2 JSON 字段名)
type MintResponseDTO struct {
    Code    int       `json:"code"`
    Data    *MintData `json:"data"`
    Message string    `json:"message"`
}

type MintData struct {
    InstanceID int64  `json:"instance_id"`
    AssetID    int64  `json:"asset_id"`
    MintedAt   int64  `json:"minted_at"`
    CoverImage string `json:"cover_image"`
}
  • Step 2: 验证编译
cd backend
go build ./gateway/dto/...

预期:无错误。

  • Step 3: Commit(等待用户指示)

Task 9: Controller — PeripheralController + handleServiceError

Files:

  • Create: backend/gateway/controller/peripheral_controller.go

Interfaces:

  • Consumes: *service.PeripheralService(直接 import service,不通过 Dubbo,MVP 阶段)

  • Produces:

    • type PeripheralController struct { svc *service.PeripheralService }
    • func NewPeripheralController(svc *service.PeripheralService) *PeripheralController
    • func (ctrl *PeripheralController) GetVerification(c *gin.Context) — REST 入口:GET /api/v1/assets/:asset_id/verification
    • func (ctrl *PeripheralController) MintFromPeripheral(c *gin.Context) — REST 入口:POST /api/v1/assets/:asset_id/mint-to-my-collection
    • func (ctrl *PeripheralController) handleServiceError(c *gin.Context, err error) — 内部辅助
  • Step 1: 编写 controller

backend/gateway/controller/peripheral_controller.go 写入:

package controller

import (
    "errors"
    "net/http"
    "strconv"

    "github.com/gin-gonic/gin"
    "go.uber.org/zap"

    "github.com/topfans/backend/gateway/dto"
    "github.com/topfans/backend/gateway/pkg/response"
    "github.com/topfans/backend/pkg/logger"
    "github.com/topfans/backend/services/assetService/service"
)

// PeripheralController 周边验真 + 加入藏品 controller
type PeripheralController struct {
    svc *service.PeripheralService
}

func NewPeripheralController(svc *service.PeripheralService) *PeripheralController {
    return &PeripheralController{svc: svc}
}

// GetVerification GET /api/v1/assets/:asset_id/verification
// H5 调用不强制 JWT(spec §4.3);app 内自带 JWT 但本端点不强校验
func (ctrl *PeripheralController) GetVerification(c *gin.Context) {
    assetID, err := strconv.ParseInt(c.Param("asset_id"), 10, 64)
    if err != nil || assetID <= 0 {
        response.Error(c, http.StatusBadRequest, "asset_id 格式错误")
        return
    }

    result, err := ctrl.svc.GetVerification(c.Request.Context(), assetID)
    if err != nil {
        ctrl.handleServiceError(c, err)
        return
    }

    response.Success(c, &dto.VerificationData{
        AssetID:     result.AssetID,
        Company:     result.Company,
        Hash:        result.Hash,
        VerifyCount: result.VerifyCount,
        Brand:       result.Brand,
        Image:       result.Image,
        Verifier:    result.Verifier,
        VerifiedAt:  result.VerifiedAt,
        SourceURL:   result.SourceURL,
    })
}

// MintFromPeripheral POST /api/v1/assets/:asset_id/mint-to-my-collection
// 强制 JWT,由 router 层的 AuthMiddleware 拦截未登录
func (ctrl *PeripheralController) MintFromPeripheral(c *gin.Context) {
    assetID, err := strconv.ParseInt(c.Param("asset_id"), 10, 64)
    if err != nil || assetID <= 0 {
        response.Error(c, http.StatusBadRequest, "asset_id 格式错误")
        return
    }

    userID, exists := c.Get("user_id")
    if !exists {
        response.Error(c, http.StatusUnauthorized, "未登录")
        return
    }
    ownerUID, ok := userID.(int64)
    if !ok {
        response.Error(c, http.StatusInternalServerError, "user_id 类型错误")
        return
    }

    result, err := ctrl.svc.MintFromPeripheral(c.Request.Context(), ownerUID, assetID)
    if err != nil {
        ctrl.handleServiceError(c, err)
        return
    }

    response.Success(c, &dto.MintData{
        InstanceID: result.InstanceID,
        AssetID:    result.AssetID,
        MintedAt:   result.MintedAt,
        CoverImage: result.CoverImage,
    })
}

// handleServiceError 统一错误处理
// BizError → response.ErrorWithCode(50003/50004/50012)
// 其他 → 500 服务繁忙
func (ctrl *PeripheralController) handleServiceError(c *gin.Context, err error) {
    var bizErr *service.BizError
    if errors.As(err, &bizErr) {
        response.ErrorWithCode(c, bizErr.Code, bizErr.Message)
        return
    }
    logger.Logger.Error("peripheral service error", zap.Error(err))
    response.Error(c, http.StatusInternalServerError, "服务繁忙")
}
  • Step 2: 验证编译
cd backend
go build ./gateway/controller/...

预期:无错误(若缺 service 类型,TODO 检查 service package export 是否正确)。

  • Step 3: Commit(等待用户指示)

Task 10: Controller 单测(CLAUDE.md §9 要求)

Files:

  • Create: backend/gateway/controller/peripheral_controller_test.go

Interfaces:

  • Consumes: *PeripheralController(可直接用 mock service 或 integration test)

  • Produces: 单元测试函数覆盖 happy path + 4 个错误码

  • Step 1: 编写 controller 测试

backend/gateway/controller/peripheral_controller_test.go 写入:

package controller

import (
    "context"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strconv"
    "testing"

    "github.com/gin-gonic/gin"
    "github.com/topfans/backend/pkg/database"
    "github.com/topfans/backend/services/assetService/repository"
    "github.com/topfans/backend/services/assetService/service"
)

func newTestPeripheralController(t *testing.T) *PeripheralController {
    db := database.GetTestDB(t)
    repo := repository.NewPeripheralRepository(db)
    svc := service.NewPeripheralService(repo)
    return NewPeripheralController(svc)
}

func setupVerifiedAsset(t *testing.T) (int64, int64) {
    db := database.GetTestDB(t)
    assetID := int64(99000200)
    ownerUID := int64(700)
    db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)
    db.Exec("DELETE FROM peripheral_info WHERE asset_id = ?", assetID)
    db.Exec("DELETE FROM assets WHERE id = ?", assetID)
    db.Exec(`INSERT INTO assets (id, owner_uid, star_id, name, cover_url, is_active, created_at, updated_at, verify_count)
             VALUES (?, 1, 1, 'p', 'http://example.com/p.jpg', true, 1, 1, 3)`, assetID)
    db.Exec(`INSERT INTO peripheral_info (asset_id, brand, company, hash, verifier, first_verified_at, created_at, updated_at)
             VALUES (?, 'B', 'C', '0xh', 'V', 1715600000000, 1, 1)`, assetID)
    return assetID, ownerUID
}

func doRequest(t *testing.T, ctrl *PeripheralController, method, path string, body string, setUser bool) *httptest.ResponseRecorder {
    gin.SetMode(gin.TestMode)
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest(method, path, nil)
    if body != "" {
        c.Request = httptest.NewRequest(method, path, nil)
    }
    if setUser {
        c.Set("user_id", int64(700))
    }

    if method == "GET" {
        ctrl.GetVerification(c)
    } else if method == "POST" {
        ctrl.MintFromPeripheral(c)
    }
    return w
}

func TestPeripheralController_GetVerification_HappyPath(t *testing.T) {
    assetID, _ := setupVerifiedAsset(t)
    ctrl := newTestPeripheralController(t)
    w := doRequest(t, ctrl, "GET", "/api/v1/assets/"+strconv.FormatInt(assetID, 10)+"/verification", "", false)

    if w.Code != http.StatusOK {
        t.Fatalf("expected 200, got %d, body=%s", w.Code, w.Body.String())
    }
    var resp struct {
        Code    int                    `json:"code"`
        Data    map[string]interface{} `json:"data"`
        Message string                 `json:"message"`
    }
    if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
        t.Fatalf("unmarshal: %v", err)
    }
    if resp.Code != 0 {
        t.Errorf("expected code=0, got %d", resp.Code)
    }
    if resp.Data["verify_count"].(float64) != 3 {
        t.Errorf("expected verify_count=3, got %v", resp.Data["verify_count"])
    }
}

func TestPeripheralController_GetVerification_AssetNotFound(t *testing.T) {
    ctrl := newTestPeripheralController(t)
    w := doRequest(t, ctrl, "GET", "/api/v1/assets/999999999999/verification", "", false)

    if w.Code != http.StatusOK { // response.ErrorWithCode 仍返 200,业务码在 body
        t.Fatalf("expected HTTP 200, got %d", w.Code)
    }
    var resp struct{ Code int `json:"code"` }
    json.Unmarshal(w.Body.Bytes(), &resp)
    if resp.Code != 50003 {
        t.Errorf("expected business code 50003, got %d", resp.Code)
    }
}

func TestPeripheralController_Mint_HappyPath(t *testing.T) {
    assetID, ownerUID := setupVerifiedAsset(t)
    db := database.GetTestDB(t)
    defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)

    ctrl := newTestPeripheralController(t)
    gin.SetMode(gin.TestMode)
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("POST", "/api/v1/assets/"+strconv.FormatInt(assetID, 10)+"/mint-to-my-collection", nil)
    c.Set("user_id", ownerUID)
    ctrl.MintFromPeripheral(c)

    if w.Code != http.StatusOK {
        t.Fatalf("expected 200, got %d, body=%s", w.Code, w.Body.String())
    }
    var resp struct {
        Code int                    `json:"code"`
        Data map[string]interface{} `json:"data"`
    }
    json.Unmarshal(w.Body.Bytes(), &resp)
    if resp.Code != 0 {
        t.Errorf("expected code=0, got %d", resp.Code)
    }
    if int64(resp.Data["instance_id"].(float64)) <= 0 {
        t.Error("expected instance_id > 0")
    }
}

func TestPeripheralController_Mint_AlreadyAdded(t *testing.T) {
    assetID, ownerUID := setupVerifiedAsset(t)
    db := database.GetTestDB(t)
    db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
             VALUES (?, ?, 1, 'peripheral', 1, 1, 1)`, ownerUID, assetID)
    defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)

    ctrl := newTestPeripheralController(t)
    gin.SetMode(gin.TestMode)
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("POST", "/api/v1/assets/"+strconv.FormatInt(assetID, 10)+"/mint-to-my-collection", nil)
    c.Set("user_id", ownerUID)
    ctrl.MintFromPeripheral(c)

    var resp struct{ Code int `json:"code"` }
    json.Unmarshal(w.Body.Bytes(), &resp)
    if resp.Code != 50004 {
        t.Errorf("expected code 50004, got %d", resp.Code)
    }
}

func TestPeripheralController_Mint_Unauthorized(t *testing.T) {
    assetID, _ := setupVerifiedAsset(t)
    ctrl := newTestPeripheralController(t)
    gin.SetMode(gin.TestMode)
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    c.Request = httptest.NewRequest("POST", "/api/v1/assets/"+strconv.FormatInt(assetID, 10)+"/mint-to-my-collection", nil)
    // 不设 user_id
    ctrl.MintFromPeripheral(c)

    if w.Code != http.StatusUnauthorized {
        t.Errorf("expected HTTP 401, got %d", w.Code)
    }
}
  • Step 2: 运行测试
cd backend
go test ./gateway/controller/ -run TestPeripheralController -v

预期:PASS,5 个测试全过。

  • Step 3: Commit(等待用户指示)

Task 11: Router — 注册 2 个端点

Files:

  • Modify: backend/gateway/router/router.go(修改 SetupRouter 函数)

Interfaces:

  • 在 SetupRouter 末尾追加:

    • 公开 group:publicAssets := v1.Group("/assets") (无 AuthMiddleware)
    • publicAssets.GET("/:asset_id/verification", peripheralCtrl.GetVerification)
    • 在已有 assets group(已 AuthMiddleware)末尾追加 assets.POST("/:asset_id/mint-to-my-collection", peripheralCtrl.MintFromPeripheral)
  • Step 1: 修改 SetupRouter 接收 PeripheralController

router.go 第 73 行附近(assetCtrl, err := controller.NewAssetController(...) 后)追加:

// 周边验真 + 加入藏品 controller
peripheralRepo := repository.NewPeripheralRepository(database.GetDB())
peripheralSvc := service.NewPeripheralService(peripheralRepo)
peripheralCtrl, err := controller.NewPeripheralController(peripheralSvc)
if err != nil {
    return nil, err
}

(根据实际 import 路径调整 repositoryservicedatabase 的 package alias)

  • Step 2: 注册 GET /:asset_id/verification 到公开 group

router.go 第 196 行附近(public group 内部)追加新 group:

// 周边验真(公开,H5 可访问,无需 JWT)
publicAssets := v1.Group("/assets")
{
    publicAssets.GET("/:asset_id/verification", peripheralCtrl.GetVerification)
}
  • Step 3: 注册 POST /:asset_id/mint-to-my-collection 到已有 assets group

router.go 第 339 行附近(assets group 内部 BindAssetMaterials 后)追加:

            assets.POST("/:asset_id/mint-to-my-collection", peripheralCtrl.MintFromPeripheral)
  • Step 4: 验证编译 + 启动服务能注册路由
cd backend
go build -o /tmp/gateway-test ./gateway/cmd
/tmp/gateway-test --help 2>&1 | head -5  # 或项目实际启动命令

预期:build 成功;启动时日志显示路由注册成功(包括新 2 个端点)。

或单元测试:

cd backend
go test ./gateway/router/... -v  # 若 router 已有测试

预期:PASS(若无 router 测试,跳过)。

  • Step 5: Commit(等待用户指示)

Task 12: 前端 utils — scanLaunch.js

Files:

  • Create: frontend/utils/scanLaunch.js

Interfaces:

  • Produces:

    • export function parseAuthenticUrl(rawUrl: string): { ok: true, assetId: number } | { ok: false, reason: string }
    • export async function onScanResult(rawUrl: string): Promise<void> — 解析失败 toast,成功跳验真页(未登录跳 portal)
    • export async function onDeepLinkTo(rawUrl: string): Promise<void> — 解析失败静默,成功跳验真页
  • Step 1: 编写 scanLaunch.js

frontend/utils/scanLaunch.js 写入:

/**
 * 扫码结果处理(纯函数 + 副作用拆分)
 * @param {string} rawUrl  uni.scanCode 回调里的 result 字符串
 * @returns {{ ok: true, assetId: number } | { ok: false, reason: string }}
 */
export function parseAuthenticUrl(rawUrl) {
  let u
  try {
    u = new URL(rawUrl)
  } catch {
    return { ok: false, reason: '二维码格式不正确' }
  }
  if (u.host !== 'topfans.online' || !u.pathname.startsWith('/verify/')) {
    return { ok: false, reason: '二维码格式不正确' }
  }
  const assetId = Number(u.pathname.split('/')[2])
  if (!Number.isInteger(assetId) || assetId <= 0) {
    return { ok: false, reason: '二维码格式不正确' }
  }
  return { ok: true, assetId }
}

/**
 * 入口:解析 + 登录 + 跳转(用户主动扫码,解析失败要 toast 提示)
 */
export async function onScanResult(rawUrl) {
  const parsed = parseAuthenticUrl(rawUrl)
  if (!parsed.ok) {
    uni.showToast({ title: parsed.reason, icon: 'none' })
    return
  }
  await navigateToVerify(parsed.assetId)
}

/**
 * Deep link 入口:解析 + 登录 + 跳转(系统唤起,解析失败静默吞掉)
 */
export async function onDeepLinkTo(rawUrl) {
  const parsed = parseAuthenticUrl(rawUrl)
  if (!parsed.ok) {
    // 静默:系统唤起常因剪贴板/分享被截获的旧 URL 出现,不应弹 toast
    return
  }
  await navigateToVerify(parsed.assetId)
}

/**
 * 私有:已登录跳验真页,未登录跳 portal(带 redirect)
 */
async function navigateToVerify(assetId) {
  const token = uni.getStorageSync('jwt') || ''
  if (!token) {
    return uni.navigateTo({
      url: `/pages/login/portal?redirect=${encodeURIComponent(
        `/pages/scan/verify?assetId=${assetId}`
      )}`
    })
  }
  uni.navigateTo({ url: `/pages/scan/verify?assetId=${assetId}` })
}
  • Step 2: 手动验证(不走 Vitest,按项目策略)

按 §8.3 手动验收清单:

  • parseAuthenticUrl('https://topfans.online/verify/12345'){ok: true, assetId: 12345}

  • parseAuthenticUrl('https://topfans.online/verify/abc'){ok: false, reason: '...'}

  • parseAuthenticUrl('https://example.com/foo'){ok: false, reason: '...'}

  • parseAuthenticUrl('not-a-url'){ok: false, reason: '...'}

  • Step 3: Commit(等待用户指示)


Task 13: 前端 utils/api.js — 追加 2 个 API 函数

Files:

  • Modify: frontend/utils/api.js

Interfaces:

  • Produces:

    • export function getAssetVerificationApi(assetId: number): Promise<VerificationData>
    • export function mintToMyCollectionApi(assetId: number): Promise<MintData>
  • Step 1: 阅读现有 api.js 风格

grep -n "^export function" frontend/utils/api.js | head -10

(查清现有 export 风格与 response 解析模式)

  • Step 2: 在 api.js 末尾追加
/**
 * 获取周边验真详情
 * @param {number} assetId
 * @returns {Promise<VerificationData>}
 */
export function getAssetVerificationApi(assetId) {
  return request({
    url: `/api/v1/assets/${assetId}/verification`,
    method: 'GET'
  })
}

/**
 * 加入我的藏品(简化版 mint)
 * @param {number} assetId
 * @returns {Promise<MintData>}
 */
export function mintToMyCollectionApi(assetId) {
  return request({
    url: `/api/v1/assets/${assetId}/mint-to-my-collection`,
    method: 'POST',
    data: {}
  })
}

(根据实际 request 包装函数名调整,若项目用 uni.request 直调,沿用现有风格)

  • Step 3: 手动验证

按 §8.3 清单,在 dev 环境调用两个 API,确认请求 URL/方法/headers 对得上 controller。

  • Step 4: Commit(等待用户指示)

Task 14: 前端 pages.json — 注册 scan/verify 页面

Files:

  • Modify: frontend/pages.json

Interfaces:

  • Produces: pages 数组新增 { "path": "pages/scan/verify", "style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } } }

  • Step 1: 在 pages.json 末尾追加新页面

打开 frontend/pages.json,在 pages 数组最后追加(按字母顺序放在 pages/square/* 之后):

,{
    "path": "pages/scan/verify",
    "style": {
        "navigationStyle": "custom",
        "app-plus": {
            "bounce": "none"
        }
    }
}

(注意前一个条目末尾的逗号;如果 pages.json 是 pages/square/square 结尾,加逗号再追加新条目)

  • Step 2: 验证编译
cd frontend
npm run build:app-plus  # 或项目实际构建命令

预期:无错误,新页面被识别。

  • Step 3: Commit(等待用户指示)

Task 15: 前端 pages/scan/verify.vue — app 内验真页

Files:

  • Create: frontend/pages/scan/verify.vue

Interfaces:

  • Consumes: pages/scan/verify?assetId=12345(URL query)

  • Produces: 验真卡片 + "加入我的藏品" 按钮 → 调 mintToMyCollectionApi → 成功跳 pages/asset-detail/asset-detail?assetId=${instance_id}

  • Step 1: 编写 verify.vue

frontend/pages/scan/verify.vue 写入(spec §3.2 的代码):

<template>
  <view class="verify-page">
    <view class="nav-bar">
      <view class="nav-back" @tap="goBack"></view>
      <text class="nav-title">周边验真</text>
    </view>

    <view v-if="loading" class="loading">
      <text>加载中…</text>
    </view>

    <view v-else-if="error" class="error">
      <text>{{ error }}</text>
      <button @tap="loadData">重试</button>
    </view>

    <view v-else-if="data" class="content">
      <view class="header">
        <image class="thumb" :src="data.image || '/static/nft/collection.png'" mode="aspectFill" />
        <view class="badge"><text>✓ 已通过验真</text></view>
      </view>

      <view class="info-card">
        <view v-for="row in infoRows" :key="row.label" class="info-row">
          <text class="info-label">{{ row.label }}</text>
          <text class="info-value" :class="{ 'info-hash': row.hash }">{{ row.value || '—' }}</text>
        </view>
      </view>

      <view class="tip-row">
        <text class="tip-icon">💡</text>
        <text class="tip-text">加入前请对比实物,确认一致后再添加</text>
      </view>

      <button
        class="mint-btn"
        :loading="submitting"
        :disabled="submitting"
        @tap="handleAddToCollection"
      >
        加入我的藏品
      </button>
    </view>
  </view>
</template>

<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getAssetVerificationApi, mintToMyCollectionApi } from '@/utils/api.js'

const assetId = ref(0)
const data = ref(null)
const loading = ref(true)
const error = ref('')
const submitting = ref(false)

onLoad(({ assetId: id }) => {
  assetId.value = Number(id)
  loadData()
})

const infoRows = computed(() => {
  if (!data.value) return []
  const d = data.value
  return [
    { label: '品牌', value: d.brand },
    { label: '公司', value: d.company },
    { label: '链上哈希', value: d.hash, hash: true },
    { label: '验证次数', value: `${d.verify_count} 次` },
    { label: '验证人', value: d.verifier },
    { label: '首次验证', value: formatDate(d.verified_at) }
  ]
})

function goBack() {
  uni.navigateBack({ delta: 1, fail: () => uni.switchTab({ url: '/pages/square/square' }) })
}

async function loadData() {
  loading.value = true
  error.value = ''
  try {
    const res = await getAssetVerificationApi(assetId.value)
    if (!res) throw new Error('此物品暂无验真信息')
    data.value = res
  } catch (e) {
    error.value = e.message || '加载失败'
  } finally {
    loading.value = false
  }
}

async function handleAddToCollection() {
  if (submitting.value) return
  submitting.value = true
  try {
    const res = await mintToMyCollectionApi(assetId.value)
    uni.showToast({ title: '已加入我的藏品', icon: 'success' })
    setTimeout(() => {
      uni.navigateTo({
        url: `/pages/asset-detail/asset-detail?assetId=${res.instance_id}`
      })
    }, 800)
  } catch (e) {
    uni.showToast({ title: e.message || '添加失败', icon: 'none' })
  } finally {
    submitting.value = false
  }
}

function formatDate(ts) {
  if (!ts) return ''
  const d = new Date(ts * 1000)
  return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
</script>

<style scoped>
.verify-page { background: #0a0a0a; min-height: 100vh; padding: 24rpx; }
.nav-bar { display: flex; align-items: center; height: 88rpx; padding: 0 24rpx; color: #fff; }
.nav-back { font-size: 40rpx; padding-right: 24rpx; }
.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 600; }
.loading, .error { color: #aaa; text-align: center; padding: 80rpx 0; }
.info-card { background: #1a1a1a; border-radius: 16rpx; padding: 24rpx; margin-top: 24rpx; }
.info-row { display: flex; justify-content: space-between; padding: 16rpx 0; border-bottom: 1rpx solid #2a2a2a; }
.info-row:last-child { border-bottom: none; }
.info-label { color: #888; font-size: 26rpx; }
.info-value { color: #fff; font-size: 28rpx; max-width: 60%; word-break: break-all; text-align: right; }
.info-value.info-hash { font-family: monospace; font-size: 22rpx; color: #3ddc84; }
.tip-row { display: flex; align-items: center; gap: 8rpx; padding: 24rpx 8rpx; color: #888; font-size: 26rpx; }
.mint-btn { background: #3ddc84; color: #000; border-radius: 100rpx; margin-top: 32rpx; font-weight: 600; }
</style>
  • Step 2: 验证编译
cd frontend
npm run build:app-plus

预期:无错误。

  • Step 3: 真机手动验收(按 §8.3 清单)

  • Step 4: Commit(等待用户指示)


Files:

  • Modify: frontend/App.vue

Interfaces:

  • Produces: onLaunch / onShow / newintent 监听 deep link,调用 scanLaunch.js#onDeepLinkTo

  • Step 1: 阅读现有 App.vue 结构

cat frontend/App.vue | head -50
  • Step 2: 在 App.vue#onLaunch / onShow 加入 deep link 处理

App.vue<script> 块(export default 内)追加:

// #ifdef APP-PLUS
function handleLaunchOptions(options) {
  if (!options) return
  // iOS UniversalLinks: options.path = '/verify/12345'
  // Android App Links: options.url   = 'https://topfans.online/verify/12345'
  const raw = options.url || (options.path ? `https://topfans.online${options.path}` : '')
  if (raw && raw.includes('/verify/')) {
    import('@/utils/scanLaunch.js').then(({ onDeepLinkTo }) => onDeepLinkTo(raw))
  }
}

const launchOpt = plus.runtime.launchOptions || {}
handleLaunchOptions(launchOpt)

plus.globalEvent.addEventListener('newintent', e => {
  handleLaunchOptions(e.intent?.data || {})
})
// #endif

并把 onShow 也接入:

onShow() {
  // #ifdef APP-PLUS
  handleLaunchOptions(plus.runtime.arguments || {})
  // #endif
}
  • Step 3: 验证编译
cd frontend
npm run build:app-plus

预期:无错误。

  • Step 4: 真机手动验收 deep link(按 §8.3 清单)

  • Step 5: Commit(等待用户指示)


Task 17: 前端 manifest.json — iOS associatedDomains + Android intent-filters

Files:

  • Modify: frontend/manifest.json

Interfaces:

  • Produces:

    • ios.associatedDomains 数组新增 "applinks:topfans.online"
    • android.intentFilters 新增 topfans.online 的 autoVerify host
  • Step 1: 阅读现有 manifest.json

cat frontend/manifest.json | grep -A 20 '"ios"\|"android"' | head -40
  • Step 2: 追加 iOS associatedDomains

ios 块追加(若无则新建):

"associatedDomains": [
    "applinks:topfans.online"
]
  • Step 3: 追加 Android intent-filters

android 块追加:

"intentFilters": [
    {
        "scheme": "https",
        "host": "topfans.online",
        "pathPrefix": "/verify/",
        "autoVerify": true
    }
]

(若项目已有 intent-filters,合并到数组)

  • Step 4: 验证编译
cd frontend
npm run build:app-plus

预期:无错误。

  • Step 5: Commit(等待用户指示)

Task 18: 前端 pages/square/square.vue — 头部扫码按钮

Files:

  • Modify: frontend/pages/square/square.vue(或其 header 子组件,如 components/BannerCarousel.vue)

Interfaces:

  • Produces: 头部新增"扫码"图标按钮,点击调 uni.scanCode + scanLaunch.js#onScanResult

  • Step 1: 阅读现有 square header

grep -n "nav-bar\|header\|scanCode\|search" frontend/pages/square/square.vue | head -10
  • Step 2: 在 header 区域加扫码按钮

在头部 <view class="nav-bar"> 内追加:

<view class="scan-btn" @tap="handleScan">
  <text class="scan-icon">📷</text>
</view>

<script setup> 块追加:

import { onScanResult } from '@/utils/scanLaunch.js'

function handleScan() {
  uni.scanCode({
    success: (res) => onScanResult(res.result),
    fail: () => uni.showToast({ title: '扫码失败', icon: 'none' })
  })
}

<style scoped> 追加(对齐其他头部按钮风格):

.scan-btn { padding: 0 16rpx; }
.scan-icon { font-size: 36rpx; color: #fff; }
  • Step 3: 真机手动验收

按 §8.3:广场头部扫码按钮可见可点(图标对得上其他头部按钮风格);扫到非项目 URL 弹 "二维码格式不正确"。

  • Step 4: Commit(等待用户指示)

Task 19: 前端 H5 — verify.html + verify.css

Files:

  • Create: frontend/static/verify/verify.html
  • Create: frontend/static/verify/verify.css

Interfaces:

  • Produces: H5 验真页(原生 HTML+JS),__API_BASE__ 占位符由 CI 替换

  • Step 1: 编写 verify.css

frontend/static/verify/verify.css 写入:

* { box-sizing: border-box; }
body { margin: 0; background: #0a0a0a; color: #fff; font-family: -apple-system, sans-serif; padding: 24px 16px; max-width: 480px; margin: 0 auto; }
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.thumb { width: 120px; height: 120px; border-radius: 12px; object-fit: cover; background: #2a2a2a; }
.badge { background: #3ddc84; color: #000; padding: 6px 12px; border-radius: 100px; font-size: 14px; font-weight: 600; }
.info-card { background: #1a1a1a; border-radius: 16px; padding: 24px; }
.row { display: flex; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid #2a2a2a; font-size: 16px; }
.row:last-child { border-bottom: none; }
.row span:first-child { color: #888; }
.row .hash { font-family: monospace; font-size: 13px; color: #3ddc84; word-break: break-all; max-width: 60%; text-align: right; }
.tip-row { display: flex; align-items: center; gap: 8px; padding: 24px 8px; color: #888; font-size: 14px; }
.mint-btn { display: block; width: 100%; padding: 16px; background: #3ddc84; color: #000; border: none; border-radius: 100px; font-size: 18px; font-weight: 600; cursor: pointer; margin-top: 24px; }
.mint-btn:disabled { background: #555; color: #999; cursor: not-allowed; }
.download-tip { margin-top: 24px; padding: 16px; background: #2a2a2a; border-radius: 12px; text-align: center; font-size: 14px; }
.download-tip a { color: #3ddc84; text-decoration: none; font-weight: 600; }
.error-text { color: #ff6b6b; text-align: center; padding: 40px 0; font-size: 16px; }
  • Step 2: 编写 verify.html

frontend/static/verify/verify.html 写入(spec §3.3 的代码):

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>TopFans — 周边验真</title>
  <link rel="stylesheet" href="verify.css">
</head>
<body>
  <div id="app">
    <div class="header">
      <img id="thumb" class="thumb" src="" alt="">
      <span class="badge">✓ 已通过验真</span>
    </div>
    <div class="info-card" id="info-card">
      <!-- 字段由 JS 渲染 -->
    </div>
    <div class="tip-row">💡 加入前请对比实物,确认一致后再添加</div>
    <button id="mint-btn" class="mint-btn">加入我的藏品</button>
    <div id="download-tip" class="download-tip" style="display:none">
      请下载 TopFans App: <a href="/download.html">立即下载</a>
    </div>
  </div>

  <script>
    // 1. 解析路径
    const match = window.location.pathname.match(/^\/authentic\/(\d+)$/)
    if (!match) {
      document.getElementById('app').textContent = '二维码格式不正确'
      throw new Error('invalid path')
    }
    const assetId = Number(match[1])

    // 2. 渲染函数
    function renderData(d) {
      document.getElementById('thumb').src = d.image || '/static/nft/collection.png'
      document.getElementById('info-card').innerHTML = `
        <div class="row"><span>品牌</span><span>${escapeHtml(d.brand  || '—')}</span></div>
        <div class="row"><span>公司</span><span>${escapeHtml(d.company || '—')}</span></div>
        <div class="row"><span>链上哈希</span><span class="hash">${escapeHtml(d.hash  || '—')}</span></div>
        <div class="row"><span>验证次数</span><span>${d.verify_count} 次</span></div>
        <div class="row"><span>验证人</span><span>${escapeHtml(d.verifier || '—')}</span></div>
        <div class="row"><span>首次验证</span><span>${formatDate(d.verified_at)}</span></div>
      `
    }
    function renderError(msg) {
      document.getElementById('info-card').innerHTML =
        `<div class="error-text">${escapeHtml(msg)}</div>`
    }
    function escapeHtml(s) {
      return String(s).replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]))
    }
    function formatDate(ts) {
      if (!ts) return '—'
      const d = new Date(ts * 1000)
      return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
    }

    // 3. 拉数据(API_BASE 由 CI 替换,见 §9)
    const API_BASE = '__API_BASE__'
    fetch(`${API_BASE}/api/v1/assets/${assetId}/verification`)
      .then(r => r.json())
      .then(json => {
        if (json.code === 0 && json.data) renderData(json.data)
        else renderError(json.message || '此物品暂无验真信息')
      })
      .catch(() => renderError('网络错误,请稍后重试'))

    // 4. 加入我的藏品按钮(走 deep link 跳 app)
    document.getElementById('mint-btn').addEventListener('click', () => {
      const url = `topfans://verify/${assetId}`
      document.addEventListener('visibilitychange', showDownloadTip, { once: true })
      setTimeout(showDownloadTip, 1500)
      function showDownloadTip() {
        if (document.visibilityState === 'visible') {
          document.getElementById('download-tip').style.display = 'block'
        }
      }
      window.location.href = url
    })
  </script>
</body>
</html>
  • Step 3: 手动验证(CI 替换前)

本地用浏览器打开 frontend/static/verify/verify.html(可能需要起 static server),确认:

  • 路径不匹配时显示"二维码格式不正确"

  • 路径匹配但 fetch 失败时显示"网络错误"

  • Step 4: Commit(等待用户指示)


Task 20: 部署 — .well-known/ 声明文件

Files:

  • Create: frontend/static/.well-known/apple-app-site-association
  • Create: frontend/static/.well-known/assetlinks.json

Interfaces:

  • Produces: 2 个 JSON 声明文件,部署到 topfans.online/.well-known/ 后 OS 才能识别 deep link

  • Step 1: 创建 apple-app-site-association

frontend/static/.well-known/apple-app-site-association 写入(不带 .json 后缀):

{
  "applinks": {
    "apps": [],
    "details": [{
      "appIDs": ["TEAMID.com.topfans.app"],
      "components": [
        { "/": "/verify/*", "comment": "周边验真扫码" }
      ]
    }]
  }
}

⚠️ 发布前必须把 TEAMID 替换为 Apple Developer 后台真实 Team ID;占位发布会导致 UniversalLinks 失效。

  • Step 2: 创建 assetlinks.json

frontend/static/.well-known/assetlinks.json 写入:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.topfans.app",
    "sha256_cert_fingerprints": ["<RELEASE_KEY_SHA256>"]
  }
}]

⚠️ 发布前必须把 <RELEASE_KEY_SHA256> 替换为正式 Release Key 的 SHA256(不是 debug key)。

  • Step 3: 提交运维上线

把 2 个文件交给运维:

  1. 部署到 https://topfans.online/.well-known/apple-app-site-association
  2. 部署到 https://topfans.online/.well-known/assetlinks.json
  3. nginx Content-Type: application/json(iOS 严格要求)

上线顺序必须严格串行(见 §9 部署):

  1. .well-known/ 上线
  2. nginx rewrite (/verify/*/verify/verify.html)上线
  3. 新版本 app 包(含 associatedDomains + intentFilters)上架
  • Step 4: Commit(等待用户指示)

Task 21: 部署 — CI 注入 API_BASE

Files:

  • Modify: CI 配置文件(Makefilepackage.json#build)

Interfaces:

  • Produces: 构建流水线在 frontend/build / npm run build 时执行 sed 替换 verify.html__API_BASE__ 占位符

  • Step 1: 阅读现有 CI / Makefile / package.json#build

cat frontend/Makefile 2>/dev/null | head -30
cat frontend/package.json | grep -A 5 '"scripts"' | head -20
  • Step 2: 在 build 脚本中追加 sed 替换

package.jsonscripts.build / scripts.build:app-plus 中追加(以 && 串联):

"build:verify": "sed -i.bak 's|__API_BASE__|'\"$VITE_API_BASE_URL\"'|g' frontend/static/verify/verify.html"

(在原 build 命令前插入;若用 Makefile,把 sed 加进 build target)

  • Step 3: 验证替换效果
cd frontend
VITE_API_BASE_URL=https://api.topfans.com npm run build:verify
grep "__API_BASE__" frontend/static/verify/verify.html  # 预期:无匹配(已被替换)
grep "https://api.topfans.com" frontend/static/verify/verify.html  # 预期:1 处
  • Step 4: Commit(等待用户指示)

Task 22: 端到端联调(无 Vitest,按 §8.3 手动清单)

Files:

  • Modify: 无(纯手动验收)

Interfaces:

  • 验证 spec §8.3 的所有清单项

  • Step 1: app 内验收(按 spec §8.3 app 内清单)

  • 广场头部扫码按钮可见可点

  • 扫到非项目 URL 弹 "二维码格式不正确"

  • 未登录扫码跳 portal,登录后回到验真页

  • 验真页信息展示顺序正确(品牌/公司/链上哈希/验证次数/验证人/首次验证)

  • "💡 加入前请对比实物..." 纯文字提示显示

  • "加入我的藏品" 首次点击 → toast 成功 + 跳 asset-detail

  • 重复点击 → toast "您已添加过此周边" + 按钮变 "查看我的藏品"

  • 验真页 asset 不存在时空态正确

  • Step 2: H5 验收(按 §8.3 H5 清单)

  • 微信扫码直接打开 H5

  • H5 渲染与 app 内一致(数据同源)

  • H5 "加入我的藏品" 触发 deep link,已装 app 跳 app

  • H5 未装 app(降级测试)1.5s 后显示下载引导

  • Step 3: Deep link 验收(按 §8.3 Deep link 清单)

  • iOS UniversalLinks:微信扫 → 跳 app → 进入验真页

  • Android App Links:同上

  • app 在后台被唤起,提取 deep link 参数 → 跳验真页

  • app 已关闭被冷启动,提取 deep link 参数 → 跳验真页

  • Step 4: 兼容验收

  • iOS 13+ iPhone 真机

  • Android 8+ 真机

  • 微信内置浏览器 / Safari / Chrome

  • Step 5: 报告验收结果

把验收清单的勾选结果写一份 docs/superpowers/verification/2026-07-10-qrcode-peripheral-auth-acceptance.md,标注任何 fail 项。


Self-Review(plan 完成后的内检)

Spec coverage(检查每条 spec 章节都有 task 覆盖):

Spec 章节 对应 Task
§1.1 前端新增 Task 12, 14, 15, 19
§1.2 前端修改 Task 13, 16, 17, 18
§1.3 后端新增 Task 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
§1.4 部署改动 Task 20, 21
§2 URL 与解析 Task 12
§3 UI 设计 Task 15, 19
§4 数据契约 Task 8 (DTO 字段对应 §4.1/§4.2 JSON)
§5 Deep Link Task 16, 17, 20
§6 后端实现 Task 1, 2, 3, 4, 5, 6, 7
§7 错误处理 Task 9 (handleServiceError), Task 6, 7 (BizError)
§8 测试 Task 3, 4, 5, 10 (后端单测), Task 22 (手动验收)
§9 部署 Task 20, 21
§10 MVP 边界 各 task 严格遵守(无抽象、无 MintStrategy)
§11 自检清单 全局约束已写入每个 task

Type consistency:

  • BizError.Code int,BizCodeAssetNotFound = 50003 常量 ✓
  • InsertPeripheralRegistry 返回 (newID, createdAtMs, err)
  • MintResult.MintedAt = createdAtMs / 1000
  • VerificationResult.VerifiedAt = info.FirstVerifiedAt / 1000

Placeholder scan: 无 TBD / TODO / "implement later" / "fill in details"。

No dead references: 所有 type/function 都在前面的 task 定义。


Execution Handoff

Plan 已保存到 docs/superpowers/plans/2026-07-10-qrcode-peripheral-authentication.md,22 个 task,覆盖前后端完整链路。

两种执行方式:

  1. Subagent-Driven(推荐) — 每个 task 派发独立 subagent,task 间做两阶段 review,快速迭代
  2. Inline Execution — 在当前会话按顺序执行,带 checkpoint 复盘

请告诉我选哪种方式?或者先看看 plan 有没有需要调整的地方?