From 345a22e5fa137dd62239b3f29f0538ec24711d0e Mon Sep 17 00:00:00 2001 From: zheng020 Date: Fri, 10 Jul 2026 11:46:34 +0800 Subject: [PATCH] =?UTF-8?q?docs:=E5=A2=9E=E5=8A=A0=E5=91=A8=E8=BE=B9?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...qrcode-peripheral-authentication-design.md | 1073 +++++++++++++++++ frontend/manifest.json | 2 +- 2 files changed, 1074 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-07-09-qrcode-peripheral-authentication-design.md diff --git a/docs/superpowers/specs/2026-07-09-qrcode-peripheral-authentication-design.md b/docs/superpowers/specs/2026-07-09-qrcode-peripheral-authentication-design.md new file mode 100644 index 0000000..8b46309 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-qrcode-peripheral-authentication-design.md @@ -0,0 +1,1073 @@ +# 周边扫码验真 + 加入藏品 — 设计 + +- **日期**:2026-07-09 +- **作者**:Claude Fable 5(与项目 owner 协作) +- **状态**:待用户 review +- **范围**:`frontend/`(uni-app + Vue 3,`appid: __UNI__F199FF4`)+ `backend/`(Go 微服务)+ `nginx/`(deep link 配置) + +--- + +## ★ 方案概述(必读) + +### 要解决的问题 + +**业务问题**: +- 用户扫描实体周边二维码,不知道真伪 — 需要一个可信的验真展示页(显示公司、链上哈希、验证次数、品牌、物品图片、验证人) +- 用户确认是正品后,希望一键把这个"实体周边"加为自己的"数字藏品" — 当前流程没有入口,用户必须重新走完整铸爱链路(图片上传 + 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 `/authentic/*` → `verify.html` + 部署脚本 | 0.5 天 | +| Phase 5:Deep link 配置 | iOS `associatedDomains` 加 `applinks:topfans.online`(根域覆盖所有子域)+ Android `assetlinks.json` 部署在根域 + `App.vue#onLaunch` 监听 `plus.runtime.arguments` | 1 天 | +| Phase 6:联调 + 真机验收 | 双端扫码模拟、外部跳 app、外部未装 app 下载引导、错误码覆盖 | 1 天 | +| **合计** | | **6 天** | + +### 关键决策 + +| 决策 | 理由 | 详见 | +|------|------|------| +| 二维码 URL 形态 = `https://topfans.online/authentic/{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/authentic/{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 内验真页(` + + +``` + +### 3.3 H5 页(`verify/verify.html`) + +```html + + + + + + TopFans — 周边验真 + + + +
+
+ + ✓ 已通过验真 +
+
+ +
+
💡 加入前请对比实物,确认一致后再添加
+ + +
+ + + + +``` + +### 3.4 视觉一致性约束 + +| 维度 | app 内 | H5 | +|------|--------|----| +| 主色(成功绿)| `#3ddc84` | `#3ddc84` | +| 背景 | `#0a0a0a` | `#0a0a0a` | +| 字体 | 系统默认(UniApp)| `-apple-system, sans-serif` | +| 字号 | rpx(适配移动端)| px(固定 14px/16px)| + +视觉细节不强行统一,通过后端返回数据 + 相同的色板编号保证"内容"层面一致;排版差异在不破坏品牌识别度的前提下允许。 + +--- + +## §4 数据契约 + +### 4.1 验真详情接口 + +**接口**:`GET /api/v1/asset/{assetId}/verification` + +**Request**: +- Path param:`assetId`(int64) +- Header:`Authorization: Bearer ` — **可选**(app 内必传,H5 不传) + +**Response**: +```jsonc +{ + "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/authentic/12345" + } +} +``` + +**字段语义**: +| 字段 | 含义 | 来源 | +|------|------|------| +| `verified_at` | 该 asset 首次被纳入验真系统的 unix 秒时间戳(前端显示为"首次验证 YYYY-MM-DD")| `asset.first_verified_at`(只写一次)| +| `verify_count` | **该周边当前被"加入我的藏品"的累计人次**(不是 page view 计数) | `asset_registry WHERE source='peripheral_scan' GROUP BY asset_id` 的总数 | +| `source_url` | 该 asset 对应的唯一验真 URL(等于 `https://topfans.online/authentic/{asset_id}`),用于分享/防伪展示,前端不做强制使用 | 后端根据 path 参数拼出 | + +**`verify_count` 写入规则(明确,避免歧义)**: +- ✅ **何时 +1**:asset_registry 在 source='peripheral_scan' 下 INSERT 成功一次(即用户首次成功 mint) +- ✅ **何时不变**:用户重复 GET `/verification` 不计数;已 mint 过的用户重新扫也不计数(防重已拦截) +- ✅ **并发安全**:用 PostgreSQL `INSERT ... RETURNING` + 后续 `SELECT COUNT(*) FROM asset_registry WHERE asset_id=? AND source='peripheral_scan'` 异步刷新到 `asset.verify_count`(避免每次 mint 都同步刷,见 §6 repository 契约) +- ❌ **不**直接做 `asset.verify_count = asset.verify_count + 1`(并发下丢更新) + +**为什么这样定义**:MVP 不上链,不需要严格"链上验证次数";展示给用户看"这个周边有多少人加过"的语义就够;数值在用户感知上是"真实热度"而非"页面访问量",因此跟随 mint 写入而非 page view 计数。 + +**错误码**: +| code | msg | 端行为 | +|------|-----|--------| +| 50003 | 物品不存在或已下架 | 验真页展示空态 | +| 401 | 未登录(app 内) | 跳 portal,redirect 回验真页 | +| -1(网络)| — | toast + retry 按钮 | + +### 4.2 加入藏品接口(简化版 mint) + +**接口**:`POST /api/v1/asset/{assetId}/mint-to-my-collection` + +**Request**: +- Path param:`assetId`(int64) +- Header:`Authorization: Bearer ` — **必传**(强制登录) +- Body:`{}`(空对象,无强制字段) + +**Response(成功)**: +```jsonc +{ + "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 后端接口分工 + +| 层 | 文件 | 职责 | +|----|------|------| +| handler | `verification.go` | 解析 path、调用 service、组装响应、错误码映射 | +| handler | `peripheral_mint.go` | 同上 | +| service | `verification_service.go` | 调 repository,组装 verification 字段 | +| service | `peripheral_mint_service.go` | verify → 查重 → 限频 → INSERT `asset_registry` → 异步刷 `verify_count` → 返回 `instance_id`(伪代码与字段语义见 §6.2)| +| repository | `verification_repo.go` | 纯 SQL:查 `asset` + `verification` 关联表 | +| repository | `peripheral_mint_repo.go` | 纯 SQL:INSERT `asset_registry` + 防重复 | +| middleware | `auth_middleware.go`(已有) | 解析 JWT,挂载到 ctx | + +--- + +## §5 Deep Link 配置 + +### 5.0 概念入门(完全白话版) + +> 这一节面向"第一次接触深度链接"的同事,5 分钟说明白原理。已了解 iOS UniversalLinks / Android App Links 的可直接跳到 §5.1。 + +#### 为什么需要这两个 JSON? + +用户微信扫了周边二维码,如果手机里已经装了 TopFans app,他希望: +- ✅ 自动打开 TopFans app,直接看到验真页 +- ❌ 不要先掉进浏览器(浏览器看到的是 H5 降级版) + +要让 OS 实现"自动跳 app",OS 必须能判断:**这条 `https://topfans.online/authentic/12345` 应该交给 `com.topfans.app`,不是浏览器**。 + +但 OS 不能让任何 app 随口说"这条 URL 归我"(否则恶意 app 就能抢走任何链接),所以 OS 要求**网站出证明**: + +- 网站放一份声明:`"我(topfans.online)允许 com.topfans.app 接管我的 /authentic/* 路径"` +- app 安装包内也带声明:`"我想接管 *.topfans.online"` +- **两端对得上** → OS 同意跳 app +- **对不上** → OS 走浏览器(用户看到 H5) + +这两个**声明书**就是 `apple-app-site-association` 和 `assetlinks.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 配置上要做的事 —— 一般**默认就有,不用改**: +```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 校验安装包签名与此相符才信任 | + +### 5.1 iOS UniversalLinks + +`frontend/manifest.json` 修改(原有 `share.weixin.UniversalLinks` 字段保留不动;新增 iOS `associatedDomains`): +```jsonc +"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`: +```json +{ + "applinks": { + "apps": [], + "details": [{ + "appIDs": ["TEAMID.com.topfans.app"], + "components": [ + { "/": "/authentic/*", "comment": "周边验真扫码" } + ] + }] + } +} +``` + +> **`TEAMID` 占位说明**:`TEAMID` = Apple Developer 后台 Team ID,**绝对不能**用占位字符串提交;发布前从 `https://developer.apple.com/account` 取真实值替换。占位发布会导致 UniversalLinks 完全失效。 + +> **单一域名**:本功能只用 `topfans.online` 一个域名,无子域。well-known 部署在此域名下即可,不用考虑子域覆盖问题。 + +### 5.2 Android App Links + +部署 **根域** `https://topfans.online/.well-known/assetlinks.json`: +```json +[{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.topfans.app", + "sha256_cert_fingerprints": [""] + } +}] +``` + +构建期自动从 `manifest.json` 的 `android.permissions` / `package` 拼出,可放入 CI。 + +### 5.3 App 端监听启动参数 + +`frontend/App.vue#onLaunch` 与 `onShow`(深链被 resume 时也要触发): +```js +// #ifdef APP-PLUS +function handleLaunchOptions(options) { + if (!options) return + // iOS UniversalLinks: options.path = '/authentic/12345' + // Android App Links: options.url = 'https://topfans.online/authentic/12345' + const raw = options.url || (options.path ? `https://topfans.online${options.path}` : '') + if (raw && raw.includes('/authentic/')) { + // 跳登录(若未登录)或跳验真页 + 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.js` 的 `onDeepLinkTo` 见 §2.2。其与 `onScanResult` 的核心差异:**解析失败时 NO toast**(避免 OS 唤起时被截获的旧 URL 误弹错);成功路径相同,共用私有 `navigateToVerify(assetId)`。 + +--- + +## §6 加入藏品后端实现 + +### 6.1 数据库变更 + +`backend/scripts/migrations/migrate_peripheral_mint.sql`: +```sql +-- 1. asset_registry 增加 source 字段 +-- (其它列 user_id / asset_id / cover_image / verified_at / verified_hash +-- 来自先前的 migrate_create_collection_activity_registry_tables.sql, +-- 已存在,本迁移不动它们) +ALTER TABLE asset_registry + ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'castlove'; + +-- 2. 防重复:同一周边同一用户只能加一次 +CREATE UNIQUE INDEX idx_asset_registry_user_source + ON asset_registry(user_id, asset_id, source) + WHERE source = 'peripheral_scan'; + +-- 3. asset 表若已存在 verify_count 列(已有),不需新增;否则: +-- ALTER TABLE asset ADD COLUMN verify_count INT NOT NULL DEFAULT 0; + +-- 4. 序列同步(沿用项目规范,见 CLAUDE.md) +SELECT setval( + pg_get_serial_sequence('asset_registry', 'id'), + (SELECT MAX(id) FROM asset_registry) +); +``` + +### 6.2 service 核心逻辑 + +```go +// peripheral_mint_service.go 伪代码 +func (s *Service) MintFromPeripheral(ctx, userID int64, assetID int64) (*MintResult, *bizerr.Error) { + // 1. 验真:必须存在且未下架 + asset, err := s.repo.GetAssetByID(ctx, assetID) + if err != nil || asset == nil || asset.DeletedAt != nil { + return nil, bizerr.New(50003, "物品不存在或已下架") + } + + // 2. 查重 + exists, err := s.repo.ExistsRegistry(ctx, userID, assetID, "peripheral_scan") + if exists { + return nil, bizerr.New(50004, "您已添加过此周边") + } + + // 3. 限频:24h 最多 10 次 + count, _ := s.repo.CountRecentMint(ctx, userID, 24*time.Hour) + if count >= 10 { + return nil, bizerr.New(50012, "今日提交过于频繁,请稍后再试") + } + + // 4. INSERT(不开 AI、不上链、不进 mint_orders) + // 注意:AssetRegistry.VerifiedAt 是当前用户本次"领证"时间,作用是支持 + // audit 表查询"用户首次领证时刻";**不**等于 asset.first_verified_at(后者 + // 是 asset 表的"该周边首次通过验真"的时间,见 §4.1 字段语义) + newID, err := s.repo.InsertPeripheralRegistry(ctx, &AssetRegistry{ + UserID: userID, + AssetID: assetID, + Source: "peripheral_scan", + CoverImage: asset.Image, + VerifiedAt: time.Now(), + VerifiedHash: asset.Hash, + }) + if err != nil { + return nil, bizerr.Wrap(err, "DB_INSERT_FAILED") + } + + // 5. 异步刷新 verify_count:用 SELECT COUNT(*) FROM asset_registry + // WHERE asset_id=? AND source='peripheral_scan' 一次性算出最新值, + // 再 UPDATE asset SET verify_count = ? + // (与 §4.1 字段语义对齐;失败仅日志,不阻塞 mint 成功) + go func() { + _ = s.repo.RefreshVerifyCount(context.Background(), assetID) + }() + + return &MintResult{ + InstanceID: newID, + AssetID: assetID, + MintedAt: time.Now().Unix(), // ★ 见 §4.2 字段命名说明(区别于 verified_at) + CoverImage: asset.Image, + }, nil +} +``` + +### 6.3 Repository 契约(`peripheral_mint_repo.go`) + +| 方法 | SQL 概要 | 返回 | +|------|----------|------| +| `GetAssetByID(ctx, assetID)` | `SELECT … FROM asset WHERE id=? AND deleted_at IS NULL` | `*Asset`, not found → `nil, nil` | +| `ExistsRegistry(ctx, userID, assetID, source)` | `SELECT 1 FROM asset_registry WHERE user_id=? AND asset_id=? AND source=? LIMIT 1` | `bool` | +| `CountRecentMint(ctx, userID, since)` | `SELECT COUNT(*) FROM asset_registry WHERE user_id=? AND source='peripheral_scan' AND created_at > ?` | `int64`(限频用)| +| `InsertPeripheralRegistry(ctx, *AssetRegistry)` | `INSERT INTO asset_registry(user_id, asset_id, source, cover_image, verified_at, verified_hash) VALUES (…) RETURNING id` | `int64`(新 ID)| +| `RefreshVerifyCount(ctx, assetID)` | 内部两步:**Step A** `SELECT COUNT(*) FROM asset_registry WHERE asset_id=? AND source='peripheral_scan';` **Step B** `UPDATE asset SET verify_count = ? WHERE id=?;`(包在事务里) | `error`(失败仅日志)| +| `GetVerificationByID(ctx, assetID)`(verification_repo 用)| `SELECT … FROM asset WHERE id=? AND deleted_at IS NULL` + JOIN 验真关联信息 | `*VerificationRow` | + +**verify_count 写入策略(全 spec 唯一)**: +- ✅ INSERT 成功 → 异步触发 `RefreshVerifyCount`(`SELECT COUNT(*)` + `UPDATE`,事务包裹) +- ❌ 不再用 `UPDATE … SET verify_count = verify_count + 1`(并发下丢更新,已删) +- ❌ 不在 mint 主路径上同步刷(避免热路径耗时) + +### 6.4 不做的事情(YAGNI) + +- ❌ 不调用任何 AI 图片生成 +- ❌ 不创建 `mint_orders` 工单 +- ❌ 不上链(`asset_registry` 只记录归属,不写链) +- ❌ 不做异步任务分发 +- ❌ 不引入 Provider 抽象 / MintStrategy 模式 + +未来如需扩展(激光卡 / 光栅卡 / 盲盒),在 `peripheral_mint_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 对应): +- `verification_repo_test.go`:asset 存在、asset 已下架、image 为空 +- `peripheral_mint_service_test.go`:首次 mint 成功、重复 mint 返 50004、限频触发 50012 + +**前端**: +- `scanLaunch.js`:URL 解析正确性(login=true/false, URL 各种变体) +- `verify.vue`:data 渲染、点击"加入我的藏品" 跳转、错误码映射到 toast +- `verify.html`:`window.location.pathname` 解析、fetch 成功/失败、下载引导兜底 + +### 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 `migrate_peripheral_mint.sql` 在 staging / prod 跑(先于代码上线)| DBA / 后端 | +| 2 | 后端 2 个新接口部署到 staging | 后端 | +| 3 | `verify/verify.html` + `nginx conf` 部署到 CDN(prod)| 前端 / 运维 | +| 4 | `topfans.online/.well-known/apple-app-site-association` + `assetlinks.json` 上线(**必须在步骤 6 前**)| 运维 | +| 5 | 前端 `pages/scan/verify.vue` + `scanLaunch.js` 提交,manifest `associatedDomains` 含 `applinks:topfans.online` | 前端 | +| 6 | 新版本 app 包通过审核上架(iOS 审核通常 1-3 天)| 运维 | +| 7 | 真机端到端验证(见 §8.3 清单)| QA | + +**⚠️ 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/authentic/*` → 浏览器打开 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_mint_service.go` 加 if 分支,或拆 `mint_strategy = enum('peripheral', 'laser', 'lenticular')`,在 `source` 列区分 | +| 验真页加举报 | 复用 `ReportModal`,`_v.html` 加区块,backend `/mint-to-my-collection` 同源加 `report_target_type='peripheral'` | +| 海外支持 | 新增 `topfans.global`,前端 API_BASE 通过配置中心下推 | +| 微信内嵌 H5 不下载直接小程序体验 | 接 `wx.miniapp.navigateTo`,走小程序扫码组件 | + +--- + +## §11 自检清单(提交前过一遍,见 CLAUDE.md) + +- [x] 新组件用 `