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

63 KiB
Raw Blame History

周边扫码验真 + 加入藏品 — 设计

  • 日期:2026-07-09
  • 作者:Claude Fable 5(与项目 owner 协作)
  • 状态:v3(全局自审后修订,与现有 model 完全对齐)
  • 范围:frontend/(uni-app + Vue 3,appid: __UNI__F199FF4)+ backend/(Dubbo + Gateway 双层)+ 部署侧 .well-known/ 与 H5 静态资源(由运维配置)
  • v3 修订要点(相对 v2,全局自审后):
    1. 路径改复数:/api/v1/asset/.../api/v1/assets/...(对齐现有 router.go:315v1.Group("/assets") 约定)
    2. 删除冗余 UNIQUE INDEX:asset_registry 已有 uk_registry_owner_star_type_asset UNIQUE (owner_uid, star_id, asset_type, asset_id) 约束,partial index 完全冗余
    3. 删除无效 CHECK 约束操作:asset_type VARCHAR(20) NOT NULL 无 CHECK 约束(见 migrate_create_collection_activity_registry_tables.sql:61),DROP+ADD 是空操作
    4. 新增 peripheral_info:周边验真详情(brand/company/hash/verifier/first_verified_at)从 assets 表拆出,因 assets 80% 行 brand 为空,语义不符
    5. 修正 Asset 字段引用:asset.Company/Hash/Brand/Image/Verifier/FirstVerifiedAtpkg/models/asset.go 全不存在,改为从 peripheral_info 读;asset.Imageasset.CoverURL(实际字段名)
    6. 修正 AssetRegistry 字段引用:asset_registry 没有 CoverImage/VerifiedAt/VerifiedHash,mint 时间改用 created_at,封面图复用 assets.cover_url
    7. InsertPeripheralRegistry 返回 created_at:RETURNING id, created_at 一次拿到,避免 service 二次查询算 minted_at
  • v2 修订要点(相对 v1):
    1. REST 入口从 backend/services/assetService/handler/ 改为 backend/gateway/controller/,业务逻辑下沉到 backend/services/assetService/service/
    2. asset_registry.source 列改为复用现有 asset_type 列,新增枚举值 'peripheral';user_id 全部重命名为 owner_uid
    3. Migration 文件路径改为 backend/migrations/2026_07_10_001_peripheral_mint.sql(对齐现有时间戳命名)
    4. nginx 部分移交运维,本设计不再写 nginx 配置;只列出 .well-known/ 与 H5 静态资源的部署清单

★ 方案概述(必读)

要解决的问题

业务问题:

  • 用户扫描实体周边二维码,不知道真伪 — 需要一个可信的验真展示页(显示公司、链上哈希、验证次数、品牌、物品图片、验证人)
  • 用户确认是正品后,希望一键把这个"实体周边"加为自己的"数字藏品" — 当前流程没有入口,用户必须重新走完整铸爱链路(图片上传 + AI 生成 + 上链),代价太高
  • 同一个周边可能在多个场景被扫:已经在 app 内的用户、还没装 app 的微信用户 — 两种场景的入口体验应该统一

技术问题:

  • 没有任何现有 QR 扫描代码(grep plus.barcode / uni.scanCode 均无匹配),所有逻辑从零搭
  • 验真数据对外公开,对内需要登录;但放在同一个接口会逼 H5 也强制登录 — 必须分开两条路径的鉴权策略
  • 用户的"加入藏品"既要走"扫码到验真"的全新链路,又要复用现有的铸爱资产模型(避免重复定义)
  • iOS UniversalLinks 和 Android App Links 当前未配置,新功能需要直接在 topfans.online 根域部署 well-known + assetlinks.json

整体实现路径

阶段 内容 预估时间
Phase 1:后端验真接口 GET /api/v1/asset/{id}/verification 的 handler / service / repo + 单元测试 1 天
Phase 2:后端 mint 接口 POST /api/v1/asset/{id}/mint-to-my-collection + asset_registry.source 字段 + UNIQUE(asset_id, user_id, source) 索引 + 单元/集成测试 1.5 天
Phase 3:前端扫码入口 + Vue 页 pages/scan/verify.vue + utils/scanLaunch.js + utils/api.js + pages.json 注册 + 广场头部扫码按钮 1 天
Phase 4:外部 H5 页面 frontend/static/verify/verify.html + nginx rewrite /verify/*verify.html + 部署脚本 0.5 天
Phase 5:Deep link 配置 iOS associatedDomainsapplinks:topfans.online(根域覆盖所有子域)+ Android assetlinks.json 部署在根域 + App.vue#onLaunch 监听 plus.runtime.arguments 1 天
Phase 6:联调 + 真机验收 双端扫码模拟、外部跳 app、外部未装 app 下载引导、错误码覆盖 1 天
合计 6 天

关键决策

决策 理由 详见
二维码 URL 形态 = https://topfans.online/verify/{assetId} 用户指定;https 才能触发 UniversalLinks;只用此一个域 §2
app 内 Vue 页 + 外部 H5 页双端同后端 用户明确"内部是内部页面打开,外部是 HTML 页面";内容由后端统一保证一致 §3
同后端接口:GET /api/v1/asset/{id}/verification 用户确认;前端只需渲染数据,无需在前端复刻"内容一致"逻辑 §4
鉴权分流:app 内必传 JWT,H5 不强制登录 用户确认"app 内需要登录,H5 不需要" §4.3
Deep link 同时配 iOS UniversalLinks + Android App Links 单一方案 OS 拦截失败会回退到浏览器,体验降级 §5
加入藏品走简化版 mint:POST /mint-to-my-collection,不走 castlove AI 链路 用户明确"不走这个链路,直接后端生成";跳过 AI 调用、任务、上链,直接 INSERT asset_registry §6
"请对比实物"是纯文字提示,无勾选逻辑 用户明确"只是小提示,不是选中按钮";用户的"自负责任"通过页面的免责文案兜底 §3
MVP 仅 1 个周边类型 + 1 个 mint 类型,不抽象 PeripheralProvider / MintStrategy YAGNI(项目有"先犯错再优化"的教训,2026-06-29 V2 文档踩过抽象坑);MVP 阶段写死 §10
不集成举报(R2) 用户中途明确"这个页面没有举报按钮";ReportModal/submitReportApi 留作未来按需扩展 (本设计外)

核心架构图(TL;DR)

                     扫码(任意端)
                         │
                         ▼
           https://topfans.online/verify/{assetId}
                         │
        ┌────────────────┴─────────────────┐
        ▼                                  ▼
   app 内 uni.scanCode              微信/外部浏览器
        │                                  │
   scanLaunch.js                    nginx rewrite → verify.html
   parse URL → 登录                 (H5 直接渲染,不强制登录)
   → navigateTo                          │
        │                                  │
        ▼                                  ▼
   pages/scan/verify.vue              verify.html(静态)
   (Vue 验真页)                      (验真信息 + 同款提示 + 加藏品按钮)
   - 验真卡片                           │
   - "请对比实物" 纯文字                │
   - [加入我的藏品]                       │
        └────────────────┬─────────────────┘
                         ▼
            POST /api/v1/asset/{id}/mint-to-my-collection
                         │
                         ▼
              后端:验真 → 查重 → INSERT asset_registry
                         │
                         ▼
              返回 { instance_id, cover_image, ... }
                         │
                         ▼
                  toast 成功 → 跳 asset-detail

文档说明

  • 适用范围:frontend/(uni-app 端扫码 + H5 验真页)+ backend/(assetService 提供 2 个新接口)+ nginx/(H5 静态服务 + deep link)。仅涉及周边扫码这一个场景,包含后续扩展的"活动票券 / 防伪证书 / 数字身份证"等其他扫码场景
  • 工作量估算:约 6 天(后端 2.5 天 + 前端 2.5 天 + deep link 1 天),约 700 行新代码(后端 350 + 前端 350)
  • 前置版本/历史:无。这是项目首个 QR 扫描功能;现成的 ReportModal/submitReportApi/castlove/create.vue 等模块按需复用
  • 目标读者:前端开发(扫码 / 验真页 / H5)、后端开发(2 个新接口)、运维 / DevOps(deep link 域名证书 + assetlinks.json + nginx rewrite)、QA(双端扫码验收)

§1 文件改动清单

1.1 前端新增

文件 行数估算 职责
frontend/pages/scan/verify.vue ~180 app 内验真页(<script setup>,不引入 this)
frontend/utils/scanLaunch.js ~90 扫码结果解析 + 登录校验 + 路由跳转(纯函数 + 副作用分文件)
frontend/static/verify/verify.html ~250 外部 H5 验真页(原生 HTML+JS,沿用 download.html 范式)
frontend/static/verify/verify.css ~120 verify.vue 视觉对齐的 H5 样式(单独 CSS,H5 没法 scoped)

前端新增合计:约 640 行(不含前端单测,见 §8.1 测试策略)

★ 不写前端 Vitest 单元测试(对齐项目既定策略,见 memory share-impl-test-policy):本项目前端 ROI 低,纯函数 canvas 在 happy-dom/jsdom 下行为不可靠;scanLaunch.js 的核心逻辑 parseAuthenticUrl 由后端 verification_repo_test.go 间接覆盖(URL 不合法 → 404 → 验真页展示空态)。

1.2 前端修改

文件 改动
frontend/pages.json 注册 pages/scan/verify
frontend/utils/api.js 追加 getAssetVerificationApi(assetId) + mintToMyCollectionApi(assetId)
frontend/manifest.json iOS associatedDomains 数组新增 applinks:topfans.online(本功能第一次启用 UniversalLinks)
frontend/App.vue onLaunch / onShow 监听 plus.runtime.arguments,deep link 解析后跳验真页
frontend/pages/square/square.vue 或其 header 子组件 头部加扫码图标按钮

1.3 后端新增(对齐现有 Dubbo + Gateway 分层)

文件 行数估算 职责
backend/gateway/controller/peripheral_controller.go ~80 GET /api/v1/assets/:asset_id/verification + POST /api/v1/assets/:asset_id/mint-to-my-collection 入口
backend/gateway/dto/peripheral_dto.go ~50 VerificationResponseDTO / MintResponseDTO
backend/services/assetService/service/peripheral_service.go ~180 verify → 查重 → 限频 → INSERT asset_registry → 异步刷 verify_count,返回 instance_id(核心业务逻辑,可被 Dubbo 调用)
backend/services/assetService/service/peripheral_service_test.go ~80 unit 测:首次成功 / 重复返 50004 / 限频 50012
backend/services/assetService/repository/peripheral_repo.go ~150 纯 SQL:GetAssetForVerification / GetPeripheralInfo / ExistsRegistry / CountRecentMint / InsertPeripheralRegistry / RefreshVerifyCount(CountPeripheralMints 仅 RefreshVerifyCount 内部用,不导出)
backend/services/assetService/repository/peripheral_repo_test.go ~50 unit 测:asset 存在 / 已下架 / peripheral_info 缺失 / 已加入查重
backend/gateway/controller/peripheral_controller.go ~80 REST 入口(包含 handleServiceError 共用错误处理)
backend/gateway/controller/peripheral_controller_test.go ~60 controller 单测:happy path 200 / 50003 / 50004 / 50012 错误码映射(按 CLAUDE.md 接口开发规范 §9 要求)
backend/pkg/models/peripheral_info.go ~30 PeripheralInfo Go struct(对应 peripheral_info 表)
backend/migrations/2026_07_10_001_peripheral_mint.sql ~50 新建 peripheral_info 表 + assets.verify_count 列 + 序列同步(无冗余 UNIQUE INDEX,依赖已有约束)
backend/gateway/router/router.go(修改) ~20 /api/v1/assets group 下注册 GET /:asset_id/verification + POST /:asset_id/mint-to-my-collection

后端新增合计:约 610 行(含测试)

与现有模式的对应关系:

  • Dubbo 服务(assetService)持有业务逻辑 PeripheralService,可被其他 Dubbo 消费者复用
  • REST 网关(gateway)通过 pbAsset.NewAssetService() 或本地 import 调用 service.PeripheralService(MVP 阶段直接本地 import,不发布 Dubbo 接口)
  • 错误码统一用 response.ErrorWithCode(c, code, msg)(沿用现有 pkg/errors + gateway/pkg/response)
  • 业务错误码 50003/50004/50012 走 pkg/errors 的 ErrorCode enum,不在 controller 硬编码

1.4 部署改动(由运维侧负责,本设计不写 nginx 配置)

资源 部署位置 改动内容
frontend/static/verify/verify.html 静态托管(CDN 或 nginx alias) 随前端构建产物一起发布
frontend/static/verify/verify.css 同上 同上
https://topfans.online/.well-known/apple-app-site-association 根域 .well-known/ 目录 iOS UniversalLinks 注册(JSON 格式见 §5.1)
https://topfans.online/.well-known/assetlinks.json 同上 Android App Links 注册(JSON 格式见 §5.2)
topfans.online HTTPS 证书 域名证书 必须有效,UniversalLinks/App Links 都要求 HTTPS

由运维负责的具体 nginx / CDN 配置(本设计仅列要求):

  • GET /verify/{id} → 重写到 /verify/verify.html(让 H5 渲染验真页)
  • GET /verify/{file} → alias 到静态资源目录
  • GET /.well-known/{file} → 静态托管,Content-Type: application/json

为何不在本设计写 nginx:项目顶层无 nginx/ 目录,实际部署由运维 runbook 维护(参见部署侧 k8s/docker/);本设计仅声明"对外表现"和"资源清单",避免重复维护配置漂移。


§2 URL 与解析

2.1 URL 形态

https://topfans.online/verify/{assetId}
  • 域名:topfans.online(用户指定唯一域名;无子域)
  • 协议:https(UniversalLinks 强制要求)
  • 前缀:/verify/(用户选定)
  • 路径段:{assetId}(int64,与现有 share/asset-qrcode/{assetId} 同源)

2.2 app 内解析

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 入口:解析 + 登录 + 跳转(系统唤起,解析失败静默吞掉)
 * 与 onScanResult 的差异:
 *   - 错误时 NO toast(避免 iOS UniversalLinks 在不匹配 URL 上误弹错);
 *   - 登录失败时,不直接跳 portal(可能用户在 H5 页面已登录但 app 未登录,
 *     此时先尝试用 H5 token 同步,见 §5.3 注释;MVP 直接复用 portal 流程即可)
 */
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}` })
}

2.3 外部解析(浏览器)

  • verify.html 通过 window.location.pathname.match(/^\/authentic\/(\d+)$/) 提取 assetId
  • 无登录要求,但点击"加入我的藏品" 走 deep link 跳 app(见 §5)

§3 UI 设计

3.1 验真页布局(app 内 + H5 共用同一布局思路)

┌───────────────────────────────────┐
│  ← 返回          周边验真          │  ← 标题栏(自定义 nav,沿用项目风格)
├───────────────────────────────────┤
│  ┌─────────────┐                  │
│  │             │  TopFans         │
│  │  物品缩略图  │  ──────          │
│  │             │  已通过 TopFans   │
│  └─────────────┘  官方验真         │
│                                   │
│  ─────────────────────────────    │
│  品牌   TopFans × 某某文化         │
│  公司   上海某某文化              │
│  链上哈希  0x9a3f...e7b2          │
│  验证次数  17 次                  │
│  验证人   官方认证中心            │
│  首次验证  2026-05-12             │
│  ─────────────────────────────    │
│                                   │
│  💡 加入前请对比实物,确认一致后再添加│  ← 提示(纯文字,无 checkbox)
│                                   │
│  ┌─────────────────────────────┐  │
│  │     加入我的藏品              │  │  ← 主按钮
│  └─────────────────────────────┘  │
└───────────────────────────────────┘

3.2 app 内 Vue 页(pages/scan/verify.vue)

<template>
  <view class="verify-page">
    <!-- 自定义顶部导航(项目无通用 NavBar 组件,用内联实现) -->
    <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>

      <!-- 信息卡片(用 v-for 渲染,避免新建 InfoRow 组件) -->
      <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()
})

// 派生字段:把 data 摊平为展示行(label + value)
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>

3.3 H5 页(verify/verify.html)

<!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>
  <style>
    /* 与 verify.vue 视觉一致的 CSS(单独文件 verify.css) */
  </style>
</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 = 后端 API gateway 域名(MVP 部署在 api.topfans.com)
    // ★ 与扫码 deep link 域 topfans.online 是两个不同服务:
    //   - topfans.online 仅负责 .well-known 文件 + nginx rewrite H5
    //   - api.topfans.com 负责后端 JSON 接口
    // ★ 注入方式:H5 静态 HTML 没法读 .env,采用构建期注入
    //   (CI 把 VITE_API_BASE_URL 替换为全局常量,详见 §9)
    const API_BASE = '__API_BASE__'   // CI 替换占位符
    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}`
      // 触发 deep link:跳走成功 → 页面 hidden;1.5s 后页面仍 visible → 显示下载引导
      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>

3.4 视觉一致性约束

维度 app 内 H5
主色(成功绿) #3ddc84 #3ddc84
背景 #0a0a0a #0a0a0a
字体 系统默认(UniApp) -apple-system, sans-serif
字号 rpx(适配移动端) px(固定 14px/16px)

视觉细节不强行统一,通过后端返回数据 + 相同的色板编号保证"内容"层面一致;排版差异在不破坏品牌识别度的前提下允许。


§4 数据契约

4.1 验真详情接口

接口:GET /api/v1/assets/:asset_id/verification

Request:

  • Path param:assetId(int64)
  • Header:Authorization: Bearer <jwt>可选(app 内必传,H5 不传)

Response:

{
  "code": 0,
  "data": {
    "asset_id":     12345,
    "company":      "上海某某文化",
    "hash":         "0x9a3f...e7b2",
    "verify_count": 17,
    "brand":        "TopFans × 某某",
    "image":        "https://cdn.topfans.com/asset/12345.jpg",
    "verifier":     "官方认证中心",
    "verified_at":  1715600000,
    "source_url":   "https://topfans.online/verify/12345"
  }
}

字段语义:

字段 含义 来源
verified_at 该 asset 首次被纳入验真系统的 unix 秒时间戳(前端显示为"首次验证 YYYY-MM-DD");DB 存的是毫秒(peripheral_info.first_verified_at),service 层 /1000 转秒 peripheral_info.first_verified_at(只写一次,运营录入)
verify_count 该周边当前被"加入我的藏品"的累计人次(不是 page view 计数) assets.verify_count(缓存列,由 RefreshVerifyCount 异步刷新,见 §6.1 + §6.3)
source_url 该 asset 对应的唯一验真 URL(等于 https://topfans.online/verify/{asset_id}),用于分享/防伪展示,前端不做强制使用 后端根据 path 参数拼出

verify_count 写入规则(明确,避免歧义):

  • 何时 +1:asset_registry 在 asset_type='peripheral' 下 INSERT 成功一次 → 异步触发 RefreshVerifyCount 刷新 assets.verify_count
  • 何时不变:用户重复 GET /verification 不计数;已 mint 过的用户重新扫也不计数(防重已拦截)
  • 并发安全:用 PostgreSQL INSERT ... RETURNING + 后续 SELECT COUNT(*) FROM asset_registry WHERE asset_id=? AND asset_type='peripheral' 一次性算出最新值,再 UPDATE assets SET verify_count = ?(事务包裹,避免并发丢更新)
  • 直接做 assets.verify_count = assets.verify_count + 1(并发下丢更新)
  • 在 GET /verification 时实时 COUNT(*)(验真页是热点,实时 COUNT 拖慢响应)
  • ⚠️ 短暂不一致可接受:mint 成功后到 RefreshVerifyCount 完成前,验真页可能显示旧 verify_count(几十 ms ~ 1s)。MVP 阶段用户感知不到,后续如需强一致可改为同步刷新

为什么这样定义:MVP 不上链,不需要严格"链上验证次数";展示给用户看"这个周边有多少人加过"的语义就够;数值在用户感知上是"真实热度"而非"页面访问量",因此跟随 mint 写入而非 page view 计数。

错误码:

code msg 端行为
50003 物品不存在或已下架 验真页展示空态
401 未登录(app 内) 跳 portal,redirect 回验真页
-1(网络) toast + retry 按钮

4.2 加入藏品接口(简化版 mint)

接口:POST /api/v1/assets/:asset_id/mint-to-my-collection

Request:

  • Path param:assetId(int64)
  • Header:Authorization: Bearer <jwt>必传(强制登录)
  • Body:{}(空对象,无强制字段)

Response(成功):

{
  "code": 0,
  "data": {
    "instance_id": 98765,            // 后端生成的用户专属数字藏品 ID
    "asset_id":    12345,            // 对应周边
    "minted_at":   1715600000,       // ★ 此用户的 mint 时间(区别于验真的 verified_at)
    "cover_image": "https://cdn.../98765.jpg"
  }
}

⚠️ verified_at(验真接口)是周边首次被纳入验真系统的时间;minted_at(mint 接口)是当前用户 mint 这一份 instance 的时间。两条接口的字段名错开,避免前端误用。

错误码:

code msg 端行为
401 未登录 H5:显示下载引导;app 内:跳 portal
50003 物品不存在或已下架 toast "此物品暂不可添加"
50004 您已添加过此周边 toast 该 msg,按钮改为"查看我的藏品"
50011 不能对自己添加 (不应出现,防御性)
50012 今日提交过于频繁,请稍后再试 toast 该 msg,按钮 disabled 10s

4.3 鉴权分流策略

验真接口 JWT mint 接口 JWT
app 内 必传 必传
H5(浏览器) 可不传(返回公开脱敏数据) 强制:H5 调用被 token 缺失拦截,前端改走 deep link 跳 app
H5(跳进 app) 复用 app 内的 token 复用 app 内的 token

4.4 后端接口分工

文件 职责
controller backend/gateway/controller/peripheral_controller.go 解析 path、调用 service、组装响应、错误码映射(response.ErrorWithCode)
dto backend/gateway/dto/peripheral_dto.go VerificationResponseDTO / MintResponseDTO(独立 DTO,不复用 DB model)
service backend/services/assetService/service/peripheral_service.go verify → 查重 → 限频 → INSERT asset_registry → 异步刷 verify_count → 返回 instance_id(伪代码与字段语义见 §6.2)
repository backend/services/assetService/repository/peripheral_repo.go 纯 SQL:查 asset / asset_registry
router backend/gateway/router/router.go(修改) /api/v1/assets group 下注册 GET /:asset_id/verification + POST /:asset_id/mint-to-my-collection
middleware middleware/auth_middleware.go(已有) 解析 JWT,挂载到 ctx(已存在)

为什么 service 放在 assetService 而不是 gateway:

  • 项目现有约定:gateway 调用 assetService(service 层)的本地 import 模式已有先例(asset_controller.go:73 直接调用 service.NewMinimaxService())
  • peripheral 业务逻辑未来可能被其他 Dubbo 消费者复用(订单、统计、推荐),放 assetService 更便于发布 Dubbo 接口
  • MVP 阶段直接本地 import,后续如有需要再通过 pbAsset.NewAssetService() 走 Dubbo

5.0 概念入门(完全白话版)

这一节面向"第一次接触深度链接"的同事,5 分钟说明白原理。已了解 iOS UniversalLinks / Android App Links 的可直接跳到 §5.1。

为什么需要这两个 JSON?

用户微信扫了周边二维码,如果手机里已经装了 TopFans app,他希望:

  • 自动打开 TopFans app,直接看到验真页
  • 不要先掉进浏览器(浏览器看到的是 H5 降级版)

要让 OS 实现"自动跳 app",OS 必须能判断:这条 https://topfans.online/verify/12345 应该交给 com.topfans.app,不是浏览器

但 OS 不能让任何 app 随口说"这条 URL 归我"(否则恶意 app 就能抢走任何链接),所以 OS 要求网站出证明:

  • 网站放一份声明:"我(topfans.online)允许 com.topfans.app 接管我的 /verify/* 路径"
  • app 安装包内也带声明:"我想接管 *.topfans.online"
  • 两端对得上 → OS 同意跳 app
  • 对不上 → OS 走浏览器(用户看到 H5)

这两个声明书就是 apple-app-site-associationassetlinks.json,都放在 https://topfans.online/.well-known/ 目录下(RFC 8615 标准路径,OS 只信任这里)。

为什么需要两份?(iOS 和 Android 各一份)

文件 谁看 作用
apple-app-site-association iOS "这个域名归这个 iOS app 接管"
assetlinks.json Android "这个域名归这个 Android app 接管"

格式不同是因为 iOS / Android 鉴权机制不同:

  • iOS:通过 TEAMID.BundleID 找 app + app 包内 associatedDomains 找域名
  • Android:通过 package_name + 安装包 SHA256 签名指纹(防伪关键)找 app

nginx 在这里扮演什么角色?

nginx 是纯静态文件服务器,就一个职责:

用户扫码 → OS 看到 URL → OS 不信任自动跳 app → 
OS 用 GET 请求 fetch https://topfans.online/.well-known/xxx →
nginx 把声明书文件递给 OS → OS 校验里面的 app 信息 →
对得上 → 跳 app;对不上 → 走浏览器

nginx 配置上要做的事 —— 一般默认就有,不用改:

location /.well-known/ {
    root /var/www/topfans/;   # 文件夹里放着这两个 JSON
}

或者更简单:把两个 JSON 丢进服务器某个目录,能用浏览器访问到它们的 URL 即可。不需要反向代理 / 重写规则。

完整时序图

┌──────┐         ┌─────────┐        ┌──────────┐        ┌─────────┐
│ 用户 │         │  iOS /  │        │  nginx   │        │ 你 app  │
│ 扫码 │         │ Android │        │  服务器   │        │         │
└──┬───┘         └────┬────┘        └────┬─────┘        └────┬────┘
   │                  │                 │                  │
   ▼                  │                 │                  │
拿到 URL               │                 │                  │
topfans.online/        │                 │                  │
authentic/12345        │                 │                  │
   │                  ▼                 │                  │
   │          "这 URL 我要交给 app?       │                  │
   │           先 fetch 域名声明书"        │                  │
   │                  │                 │                  │
   │                  ▼   GET /.well-known/xxx                │
   │                  │ ─────────────▶ │                  │
   │                  │                │── 返回 JSON       │
   │                  │ ◀───────────── │                  │
   │                  │                 │                  │
   │                  ▼ 校验 JSON:        │                  │
   │                  "appIDs 包含我?     │                  │
   │                   + 路径匹配?         │                  │
   │                   + app 真安装?"      │                  │
   │                  │                 │                  │
   │                  ▼ OK              │                  │
   │              启 app ────────────────────────────────▶ │
   │                                        onLaunch 接收参数 │
   │                                        → 跳验真页        │

TL;DR

问题 答案
这两个 JSON 是啥 "网站授权 app 接管 URL"的声明书
为什么需要 2 份 一份给 iOS 一份给 Android,格式不同但作用一样
nginx 在这里干啥 纯静态托管文件,让手机能下载到这两个 JSON
文件放哪 服务器上 /.well-known/ 目录下(RFC 8615 标准路径)
漏掉会怎样 手机直接走浏览器(用户看到 H5 降级版)
改 JSON 要不要重发 app 要 — app 装包内有 associatedDomains 配置,得新版包

字段速查(后续 §5.1/§5.2 详细字段含义对照这里)

字段 文件 含义
applinks.apps ios-aasa 老格式残留,留空数组
applinks.details[].appIDs ios-aasa ["TEAMID.BundleID"] = 允许接管的 iOS app
applinks.details[].components[]./ ios-aasa URL 路径匹配模式,* 通配
relation android-al 关系类型,用 delegate_permission/common.handle_all_urls
target.namespace android-al android_app
target.package_name android-al Android app 包名
target.sha256_cert_fingerprints android-al APK 签名指纹,OS 校验安装包签名与此相符才信任

frontend/manifest.json 修改(原有 share.weixin.UniversalLinks 字段保留不动;新增 iOS associatedDomains):

"ios": {
  "urltypes": [{"urlschemes": ["topfans"]}],
  "associatedDomains": [
    "applinks:topfans.online"         // ← 本功能域名
  ]
}

为何不动 share.weixin.UniversalLinks:share 段下的 UniversalLinks 是 iOS Share Extension 用的,与 UniversalLinks 系统机制不是同一回事(后者走 associatedDomains)。沿用现有值不删,只在新字段加本功能。

部署 https://topfans.online/.well-known/apple-app-site-association:

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

TEAMID 占位说明:TEAMID = Apple Developer 后台 Team ID,绝对不能用占位字符串提交;发布前从 https://developer.apple.com/account 取真实值替换。占位发布会导致 UniversalLinks 完全失效。

单一域名:本功能只用 topfans.online 一个域名,无子域。well-known 部署在此域名下即可,不用考虑子域覆盖问题。

部署 根域 https://topfans.online/.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>"]
  }
}]

构建期自动从 manifest.jsonandroid.permissions / package 拼出,可放入 CI。

5.3 App 端监听启动参数

frontend/App.vue#onLaunchonShow(深链被 resume 时也要触发):

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

scanLaunch.jsonDeepLinkTo 见 §2.2。其与 onScanResult 的核心差异:解析失败时 NO toast(避免 OS 唤起时被截获的旧 URL 误弹错);成功路径相同,共用私有 navigateToVerify(assetId)


§6 加入藏品后端实现

6.1 数据库变更

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' 写入。
--    如未来想加 CHECK 约束,见 §10.2 演进路径。

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

-- 2.1 一次性回填已有 peripheral 周边(已存在的 peripheral_mint 数据)
--    ★ 该 UPDATE 在 migration 部署时执行一次;后续维护由 RefreshVerifyCount 异步刷
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)
--    ★ 为什么不用扩展 assets 表:assets 表是"用户上传的藏品",brand/company/hash/verifier
--      是"周边防伪信息",语义不同,放在一起会让 assets 表 80% 行 brand 为空。
--    ★ peripheral_info 必须先于 asset_registry INSERT 存在(每条周边扫码 mint 都需要 peripheral_info 行)
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 '',    -- 链上哈希(预留,当前 MVP 不上链,可填模拟值)
    verifier              VARCHAR(100) NOT NULL DEFAULT '',    -- 验证人(如 "官方认证中心")
    first_verified_at     BIGINT       NOT NULL DEFAULT 0,     -- 首次纳入验真系统的 unix 毫秒时间戳
    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 已有约束
--    已有 uk_registry_owner_star_type_asset UNIQUE (owner_uid, star_id, asset_type, asset_id)
--    已覆盖 peripheral 防重复(因 peripheral 必须写入 star_id,见 §6.2)
--    ★ 不需要新增 UNIQUE INDEX(避免与已有约束冗余,导致 INSERT 性能下降)

-- 5. 序列同步(沿用项目规范,见 CLAUDE.md)
SELECT setval(
  pg_get_serial_sequence('asset_registry', 'id'),
  (SELECT MAX(id) FROM asset_registry)
);

字段语义对齐:

  • 文档全文用 user_id 表示"用户 ID";实际写入 asset_registry.owner_uid(已有列)
  • 文档全文用 source 表示"周边扫码来源";实际写入 asset_registry.asset_type = 'peripheral'(新增枚举值)
  • 模型层的 Go struct 不动(OwnerUID int64AssetType string),由 service 层在 INSERT 时写入 asset_type='peripheral'
  • 周边验真详情(brand/company/hash/verifier/first_verified_at)从 peripheral_info 表查,不放在 assets 表上
  • verify_count 缓存在 assets.verify_count,由 RefreshVerifyCount 异步刷新

新增 Go 模型:backend/pkg/models/peripheral_info.go

package models

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" }

6.2 service 核心逻辑

// peripheral_service.go 伪代码
// (放 backend/services/assetService/service/,被 gateway controller 通过本地 import 调用)
//
// ★ 所需 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 是 enum(0-16),装不下 50003 等业务码。
// 折中:在 service 文件内自定义 BizError 类型(只为 peripheral 模块用,不入 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 // 防御性,周边不该出现
)

// PeripheralService 周边验真 + 加入藏品服务
type PeripheralService struct {
    repo *repository.PeripheralRepository
}
func NewPeripheralService(repo *repository.PeripheralRepository) *PeripheralService {
    return &PeripheralService{repo: repo}
}

func (s *PeripheralService) GetVerification(ctx context.Context, assetID int64) (*VerificationResult, error) {
  // 1. 查 asset(已下架 / 已删除视为不存在)
  asset, err := s.repo.GetAssetForVerification(ctx, assetID)
  if err != nil {
    return nil, err
  }
  if asset == nil {
    return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"}
  }

  // 2. 查 peripheral_info(验真详情:brand/company/hash/verifier/first_verified_at)
  //    peripheral_info 不存在视为"未纳入验真系统",返回 50003
  info, err := s.repo.GetPeripheralInfo(ctx, assetID)
  if err != nil {
    return nil, err
  }
  if info == nil {
    return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"}
  }

  // 3. 查 verify_count(读缓存列 assets.verify_count,见 §4.1 + §6.3)
  //    不实时 COUNT(*) —— 验真页是热点,实时 COUNT 拖慢响应
  //    mint 后的异步 RefreshVerifyCount 会更新此值(短暂延迟可接受,见 §4.1)
  return &VerificationResult{
    AssetID:     asset.ID,
    Company:     info.Company,
    Hash:        info.Hash,
    VerifyCount: asset.VerifyCount,
    Brand:       info.Brand,
    Image:       asset.CoverURL,    // ★ Asset 表用 CoverURL(不是 Image)
    Verifier:    info.Verifier,
    VerifiedAt:  info.FirstVerifiedAt / 1000,  // 毫秒 → 秒,前端显示用
    SourceURL:   fmt.Sprintf("https://topfans.online/verify/%d", asset.ID),
  }, nil
}

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, err
  }
  if asset == nil {
    return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"}
  }
  info, err := s.repo.GetPeripheralInfo(ctx, assetID)
  if err != nil {
    return nil, 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 exists {
    return nil, &BizError{Code: BizCodeAlreadyAdded, Message: "您已添加过此周边"}
  }

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

  // 4. INSERT(不开 AI、不上链、不进 mint_orders)
  //    ★ AssetRegistry 模型实际只有以下字段(见 pkg/models/asset_registry.go):
  //      ID, AssetID, AssetType, OwnerUID, StarID, Status, LikeCount, DisplayStatus, CreatedAt, UpdatedAt
  //      没有 CoverImage / VerifiedAt / VerifiedHash!封面图从 assets.cover_url 取,mint 时间用 created_at
  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:SELECT COUNT(*) + UPDATE assets(事务包裹)
  go func() {
    _ = s.repo.RefreshVerifyCount(context.Background(), assetID)
  }()

  // minted_at 用 DB 返回的 created_at(毫秒)→ 转秒,与 §4.2 数据契约对齐
  return &MintResult{
    InstanceID: newID,
    AssetID:    assetID,
    MintedAt:   createdAtMs / 1000,
    CoverImage: asset.CoverURL,
  }, nil
}

controller 错误码处理(配套 peripheral_controller.go):

import (
    "errors"
    "net/http"
    "github.com/gin-gonic/gin"
    "github.com/topfans/backend/gateway/pkg/response"
    "github.com/topfans/backend/pkg/logger"
    "go.uber.org/zap"
)

func (ctrl *PeripheralController) handleServiceError(c *gin.Context, err error) {
    var bizErr *BizError
    if errors.As(err, &bizErr) {
        response.ErrorWithCode(c, bizErr.Code, bizErr.Message)  // 50003/50004/50012
        return
    }
    logger.Logger.Error("peripheral service error", zap.Error(err))
    response.Error(c, http.StatusInternalServerError, "服务繁忙")
}

为什么 BizError 只放 peripheral_service.go:

  • pkg/errors 的现有 sentinels 都是字符串 error(ErrInvalidUserID 等),不带 Code 字段
  • 入 pkg/errors 需要全项目审视(MVP 范围外)
  • peripheral 是新模块,先用本地 BizError 类型,后续如有需要再升级到 pkg/errors
  • controller 通过 errors.As 解 BizError,无需关心具体 code 数值

6.3 Repository 契约(peripheral_repo.go)

方法 SQL 概要 返回
GetAssetForVerification(ctx, assetID) SELECT id, star_id, cover_url, is_active, deleted_at FROM assets WHERE id=? AND is_active=true AND deleted_at IS NULL(沿用现有 asset_repository.go:110 模式) *Asset, not found → nil, nil
GetPeripheralInfo(ctx, assetID) SELECT asset_id, brand, company, hash, verifier, first_verified_at FROM peripheral_info WHERE asset_id=? *PeripheralInfo, not found → nil, nil(service 层视为"非周边")
ExistsRegistry(ctx, ownerUID, assetID, assetType) SELECT 1 FROM asset_registry WHERE owner_uid=? AND asset_id=? AND asset_type=? LIMIT 1 bool
CountRecentMint(ctx, ownerUID, assetType, since) SELECT COUNT(*) FROM asset_registry WHERE owner_uid=? AND asset_type=? AND created_at > ? int64(限频用)
InsertPeripheralRegistry(ctx, *AssetRegistry) INSERT INTO asset_registry(owner_uid, asset_id, star_id, asset_type, status) VALUES (…) RETURNING id, created_at (newID int64, createdAtMs int64, err error)(返回 created_at 供 service 算 minted_at)
RefreshVerifyCount(ctx, assetID) 事务包裹:Step A SELECT COUNT(*) FROM asset_registry WHERE asset_id=? AND asset_type='peripheral'; Step B UPDATE assets SET verify_count = ? WHERE id=?;(CountPeripheralMints 仅作 Step A 内部调用,不导出) error(失败仅日志)

为什么导出 RefreshVerifyCount 但不导出 CountPeripheralMints:

  • service 层只在 mint 后调 RefreshVerifyCount(异步),不需要知道"实时 COUNT"
  • 验真页读 assets.verify_count 缓存值,不需要 SQL 实时聚合
  • CountPeripheralMints 是 RefreshVerifyCount 的实现细节,封装在 repo 内部

verify_count 写入策略(全 spec 唯一):

  • INSERT 成功 → 异步触发 RefreshVerifyCount(SELECT COUNT(*) + UPDATE,事务包裹)
  • 不再用 UPDATE … SET verify_count = verify_count + 1(并发下丢更新,已删)
  • 不在 mint 主路径上同步刷(避免热路径耗时)

为什么 InsertPeripheralRegistry 返回 created_at:

  • mint 接口需要返回 minted_at(unix 秒,见 §4.2)
  • AssetRegistry 模型 BeforeCreate 钩子自动填 time.Now().UnixMilli(),service 层无法提前预知
  • RETURNING id, created_at 一次拿到,避免 service 二次查询

6.4 不做的事情(YAGNI)

  • 不调用任何 AI 图片生成
  • 不创建 mint_orders 工单
  • 不上链(asset_registry 只记录归属,不写链)
  • 不做异步任务分发
  • 不引入 Provider 抽象 / MintStrategy 模式

未来如需扩展(激光卡 / 光栅卡 / 盲盒),在 peripheral_service.go写第二个 if 分支,而非抽象工厂。


§7 错误处理

7.1 错误码矩阵

场景 code msg app 内行为 H5 行为
扫到非预期 URL - "二维码格式不正确" toast + 留在当前页 (H5 不会被非预期 URL 唤起)
未登录(扫码时) - - 跳 portal,redirect 回验真页 (H5 无登录拦截)
未登录(H5 点"加入") 401 - (H5 走 deep link 跳 app) 跳 app,app 内检查 token
asset 不存在 50003 "物品不存在或已下架" 空态页 "此物品暂无验真信息"
已添加过 50004 "您已添加过此周边" 按钮改 "查看我的藏品",跳 myWorks 同 app 内
网络错误 -1 "网络错误,请稍后重试" toast + retry 同 app 内
服务器 500 - "服务繁忙" toast + retry 同 app 内

7.2 数据容错

字段 后端返回 前端展示
brand "" "—"
company "" "—"
image "" 占位图 /static/nft/collection.png(沿用项目范式)
verified_at 0 "—"
hash "" "—"

§8 测试 / 验收

8.1 单元测试

后端(测试文件与 §1.3 对应):

  • peripheral_repo_test.go:asset 存在 / 已下架 / peripheral_info 缺失 / 已加入查重命中
  • peripheral_service_test.go:首次 mint 成功 / 重复 mint 返 50004 / 限频触发 50012 / 验真 50003
  • peripheral_controller_test.go:happy path 200 / 错误码映射 50003/50004/50012 / 未登录 401(按 CLAUDE.md 接口开发规范 §9 要求,handler 层必须覆盖)

前端(按项目策略,不写 Vitest,走手动验收):

  • 不写 scanLaunch.test.js / verify.vue.test.js / verify.html 单测
  • 见 §8.3 手动验收清单(覆盖 URL 解析 / 渲染 / 错误码映射 / 下载引导兜底)

前端(按项目策略,不写 Vitest,走手动验收):

  • 不写 scanLaunch.test.js / verify.vue.test.js / verify.html 单测
  • 见 §8.3 手动验收清单(覆盖 URL 解析 / 渲染 / 错误码映射 / 下载引导兜底)

8.2 集成测试

  • 模拟 GET /verification 返回不同 code(0 / 50003 / 401),验证 UI 表现
  • 模拟 POST /mint-to-my-collection 返回 50004,验证按钮文案切换
  • iOS 模拟器触发 deep link → 启动路由正确
  • Android 模拟器触发 deep link → app 在前台 / 后台时都能正确接住

8.3 手动验收清单

app 内:

  • 广场头部扫码按钮可见可点(图标对得上其他头部按钮风格)
  • 扫到非项目 URL 弹 "二维码格式不正确"
  • 未登录扫码跳 portal,登录后回到验真页
  • 验真页信息展示顺序:品牌 / 公司 / 链上哈希 / 验证次数 / 验证人 / 首次验证
  • "💡 加入前请对比实物,确认一致后再添加" 提示显示(纯文字,无 checkbox)
  • "加入我的藏品" 首次点击 → toast 成功 + 跳 asset-detail
  • 重复点击 → toast "您已添加过此周边" + 按钮变 "查看我的藏品"
  • 验真页 asset 不存在时空态正确

H5:

  • 微信扫码直接打开 H5
  • H5 渲染与 app 内一致(数据同源)
  • H5 "加入我的藏品" 触发 deep link,已装 app 跳 app
  • H5 未装 app(降级测试:关闭 app 模拟)1.5s 后显示下载引导

Deep link:

  • iOS UniversalLinks:微信扫 → 跳 app → 进入验真页(无需走 H5)
  • Android App Links:同上
  • app 在后台被唤起,提取 deep link 参数 → 跳验真页
  • app 已关闭被冷启动,提取 deep link 参数 → 跳验真页

兼容:

  • iOS 13+ iPhone 真机
  • Android 8+ 真机
  • 微信内置浏览器 / Safari / Chrome

§9 部署 / 上线

步骤 内容 负责人
1 migration 2026_07_10_001_peripheral_mint.sql 在 staging / prod 跑(先于代码上线) DBA / 后端
2 后端 2 个新接口部署到 staging(assetService + gateway) 后端
3 verify/verify.html + verify.css 随前端构建产物发布 前端(构建)
4 topfans.online/.well-known/apple-app-site-association + assetlinks.json 上线 + nginx rewrite(/verify/*/verify/verify.html)(必须在步骤 6 前) 运维
5 前端 pages/scan/verify.vue + scanLaunch.js 提交,manifest associatedDomainsapplinks:topfans.online,CI 把 verify.html 中的 __API_BASE__ 占位符替换为实际 API 域名 前端
6 新版本 app 包通过审核上架(iOS 审核通常 1-3 天) 运维
7 真机端到端验证(见 §8.3 清单) QA

CI 注入 API_BASE(verify.html 是静态文件,无法读 .env):

  • 在前端 CI 流水线(Makefilepackage.json#build)中加入 sed 替换:
    sed -i.bak "s|__API_BASE__|${VITE_API_BASE_URL}|g" \
      frontend/static/verify/verify.html
    
  • 开发环境用 https://dev-api.topfans.online,生产用 https://api.topfans.com

⚠️ UniversalLinks 生效条件(BOTH 必备,缺一不可):

  1. https://topfans.online/.well-known/apple-app-site-association + assetlinks.json 已上线
  2. 用户设备上装的 app 是含新 associatedDomains(applinks:topfans.online)的新版本

概念入门见 §5.0 — 这是给"刚接触深度链接的同事"的 5 分钟白话版,后续运维 / QA 上线时如果对这两个 JSON 文件有疑问,先看 §5.0 再上手。

如果只做了 1 没做 2:用户在老 app 上扫 topfans.online/verify/* → 浏览器打开 H5(降级体验)。 如果只做了 2 没做 1:iOS 会缓存"无匹配"决策 ~24 小时,期间不重试。 因此上线顺序必须是 1→4→5→6 严格串行(步骤 4 必须在 6 前完成)。

Android 注意事项:

  • assetlinks.json 的 SHA256 必须用正式 Release Key(不是 debug key),打 debug 包测时临时加 debug SHA,提交前删掉
  • Android 12+ 默认 verified_links 默认 reject,可手动到"设置 → 应用 → 默认打开链接"开启

域名证书:

  • topfans.online 必须 HTTPS 且证书有效,UniversalLinks / App Links 强制要求
  • 部署 .well-known 文件用 Content-Type: application/json 的 mime(iOS 严格要求)

§10 MVP 边界与未来演进

10.1 MVP 明确不做

  • 抽象 PeripheralProvider / MintStrategy 接口(只有一种周边)
  • 多 task 类型(限频是硬编码 10/24h,不动配置)
  • 激光卡 / 光栅卡 / 盲盒等不同 mint 流(本期只支持周边扫码直接 mint)
  • 验真页举报按钮(用户明确移除)
  • 海外域名支持(MVP 不覆盖海外)
  • 微信小程序扫一扫
  • H5 SEO / SSR

10.2 可能的演进路径(留作未来 spec,不实现)

场景 改动
增加新周边类型(激光卡/光栅卡) peripheral_service.go 加 if 分支,或拆 mint_strategy = enum('peripheral', 'laser', 'lenticular'),在 asset_type 列区分
验真页加举报 复用 ReportModal,verify.html 加区块,backend /mint-to-my-collection 同源加 report_target_type='peripheral'
海外支持 新增 topfans.global,前端 API_BASE 通过配置中心下推
微信内嵌 H5 不下载直接小程序体验 wx.miniapp.navigateTo,走小程序扫码组件
service 上 Dubbo 当前 MVP 直接本地 import,业务规模扩大后通过 pbAsset 发布 Dubbo 接口给其他消费者复用

§11 自检清单(提交前过一遍,见 CLAUDE.md)

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

  • 新组件用 <script setup>setup() 组合式 API,无 this
  • 所有原生 / 平台差异代码包了 #ifdef APP-PLUS
  • 接口调用走 utils/api.js,未在组件里裸写 uni.request
  • 跨页面状态走 URL query(?assetId=xxx)或 Vuex
  • 新页面 pages/scan/verify 已在 pages.json 注册(待提交)
  • 长列表 / 大图场景无(单卡)
  • 未改动 unpackage/dist/ 编译产物

11.2 设计决策对齐(用户原话)

  • 文档开头有 "★ 方案概述" + 要解决的问题 + 实现路径 + 关键决策 + 核心架构图
  • 严格遵守 MVP 先行原则(无 Provider 抽象 / 无 MintStrategy,见关键决策表 / §10)
  • 同后端接口(app 走 api.js,H5 走 fetch),与用户原话对齐
  • 双端页面 + 同源数据 + 同色板,内容一致
  • "请对比实物" 是纯文字提示,无勾选逻辑(与用户明确对齐)
  • 未集成举报(用户中途移除)
  • 加入藏品走简化 mint,不走 castlove AI 链路(与用户明确对齐)
  • 不写前端 Vitest 单元测试(对齐项目策略 share-impl-test-policy,走手动验收)

11.3 与现有代码结构对齐(v2 + v3 修订)

  • REST 入口在 backend/gateway/controller/,业务逻辑在 backend/services/assetService/service/(沿用现有 Dubbo + Gateway 分层)
  • 路径用复数 /api/v1/assets/:asset_id/...(对齐 router.go:315 现有 group 命名)
  • 复用现有 asset_registry.owner_uid / asset_type 列,新增枚举值 'peripheral',不动 user_id / source
  • migration 放 backend/migrations/YYYY_MM_DD_NNN_*.sql 命名规范
  • nginx 配置移交运维,本设计只列 .well-known/ 与 H5 静态资源部署清单
  • 错误码通过 response.ErrorWithCode 统一处理,不在 controller 硬编码 50003/50004/50012
  • verify.html__API_BASE__ 由 CI 替换,避免硬编码域名
  • 周边验真详情(brand/company/hash/verifier/first_verified_at)放 peripheral_info 表,不在 assets 表加列
  • 复用 asset_registry 已有 UNIQUE 约束,不新增冗余 partial index
  • service 代码引用的 Asset / AssetRegistry 字段全部对得上 pkg/models/asset.go / asset_registry.go 实际定义(全局自审后已修正)
  • verify_count 数据源统一为 assets.verify_count 缓存列(不再实时 COUNT),迁移一次性回填 + RefreshVerifyCount 异步刷(第三轮自审修复)
  • controller 层有 happy path + 错误码映射单测(peripheral_controller_test.go,补 CLAUDE.md 接口开发规范 §9 要求)|