# 周边扫码验真 + 加入藏品 — 设计
- **日期**: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:315` 的 `v1.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/FirstVerifiedAt` 在 `pkg/models/asset.go` 全不存在,改为从 `peripheral_info` 读;`asset.Image` → `asset.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 `associatedDomains` 加 `applinks: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 内验真页(`
```
### 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/assets/:asset_id/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/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 ` — **必传**(强制登录)
- 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 后端接口分工
| 层 | 文件 | 职责 |
|----|------|------|
| 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 Deep Link 配置
### 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-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": [
{ "/": "/verify/*", "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 = '/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.js` 的 `onDeepLinkTo` 见 §2.2。其与 `onScanResult` 的核心差异:**解析失败时 NO toast**(避免 OS 唤起时被截获的旧 URL 误弹错);成功路径相同,共用私有 `navigateToVerify(assetId)`。
---
## §6 加入藏品后端实现
### 6.1 数据库变更
`backend/migrations/2026_07_10_001_peripheral_mint.sql`:
```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 int64`、`AssetType 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`
```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 核心逻辑
```go
// 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`):
```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` 随前端构建产物发布 | 前端(构建)| 运维(CDN)|
| 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 `associatedDomains` 含 `applinks:topfans.online`,CI 把 `verify.html` 中的 `__API_BASE__` 占位符替换为实际 API 域名 | 前端 |
| 6 | 新版本 app 包通过审核上架(iOS 审核通常 1-3 天)| 运维 |
| 7 | 真机端到端验证(见 §8.3 清单)| QA |
**CI 注入 API_BASE**(`verify.html` 是静态文件,无法读 `.env`):
- 在前端 CI 流水线(`Makefile` 或 `package.json#build`)中加入 sed 替换:
```bash
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 §前端开发规范)
- [x] 新组件用 `