48 KiB
周边扫码验真 + 加入藏品 — 设计
- 日期: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和 AndroidApp 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 内验真页(<script setup>,不引入 this) |
frontend/utils/scanLaunch.js |
~90 | 扫码结果解析 + 登录校验 + 路由跳转(纯函数 + 副作用分文件易测试) |
frontend/utils/scanLaunch.test.js |
~60 | unit 测:parseAuthenticUrl 各种 URL/host/数字边界 + onScanResult 登录分流 |
frontend/static/verify/verify.html |
~250 | 外部 H5 验真页(原生 HTML+JS,沿用 download.html 范式) |
frontend/static/verify/verify.css |
~120 | 与 verify.vue 视觉对齐的 H5 样式(单独 CSS,H5 没法 scoped) |
前端新增合计:约 580 行(含测试)
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 后端新增
| 文件 | 行数估算 | 职责 |
|---|---|---|
backend/services/assetService/handler/verification.go |
~60 | GET /api/v1/asset/{id}/verification 入口 |
backend/services/assetService/service/verification_service.go |
~100 | 校验 → 查 asset → 组装响应 |
backend/services/assetService/repository/verification_repo.go |
~80 | 纯 SQL 查 asset + verification 信息 |
backend/services/assetService/handler/peripheral_mint.go |
~80 | POST /api/v1/asset/{id}/mint-to-my-collection 入口 |
backend/services/assetService/service/peripheral_mint_service.go |
~150 | verify → 查重 → 限频 → INSERT asset_registry,返回 instance_id |
backend/services/assetService/repository/peripheral_mint_repo.go |
~120 | 纯 SQL:ExistsRegistry / CountRecentMint / InsertPeripheralRegistry / IncrementVerifyCount |
backend/services/assetService/repository/verification_repo_test.go |
~50 | unit 测:asset 存在 / 已下架 / image 为空 |
backend/services/assetService/service/peripheral_mint_service_test.go |
~80 | unit 测:首次成功 / 重复返 50004 / 限频 50012 |
backend/scripts/migrations/migrate_peripheral_mint.sql |
~30 | asset_registry.source 列 + UNIQUE(asset_id, user_id, source) 索引 + asset.verify_count 列(若不存在) |
后端新增合计:约 750 行(含测试)
1.4 部署改动
| 文件 | 改动 |
|---|---|
nginx/conf.d/topfans-api.conf (新建) |
location /authentic/ { try_files $uri /verify/verify.html; } + location /verify/ { alias /var/www/topfans/static/verify/; try_files $uri $uri/ /verify/verify.html; } |
https://topfans.online/.well-known/apple-app-site-association (新建) |
iOS UniversalLinks 注册 |
https://topfans.online/.well-known/assetlinks.json (新建) |
Android App Links 注册 |
topfans.online HTTPS 证书 |
必须有效,UniversalLinks/App Links 都要求 HTTPS |
注:nginx 的 alias 路径要保证容器/服务器上真实存在(/var/www/topfans/static/verify/verify.html)。如果用 CDN,改 CDN 的 rewrite 规则(逻辑同上:/authentic/* → /verify/verify.html)。
§2 URL 与解析
2.1 URL 形态
https://topfans.online/authentic/{assetId}
- 域名:
topfans.online(用户指定唯一域名;无子域) - 协议:
https(UniversalLinks 强制要求) - 前缀:
/authentic/(用户选定) - 路径段:
{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('/authentic/')) {
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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 接口
const API_BASE = 'https://api.topfans.com' // ★ 部署时按实际环境替换
fetch(`${API_BASE}/api/v1/asset/${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://authentic/${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/asset/{assetId}/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/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 <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 后端接口分工
| 层 | 文件 | 职责 |
|---|---|---|
| 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 配置上要做的事 —— 一般默认就有,不用改:
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):
"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": [
{ "/": "/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:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.topfans.app",
"sha256_cert_fingerprints": ["<RELEASE_KEY_SHA256>"]
}
}]
构建期自动从 manifest.json 的 android.permissions / package 拼出,可放入 CI。
5.3 App 端监听启动参数
frontend/App.vue#onLaunch 与 onShow(深链被 resume 时也要触发):
// #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:
-- 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 核心逻辑
// 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 渲染、点击"加入我的藏品" 跳转、错误码映射到 toastverify.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 必备,缺一不可):
https://topfans.online/.well-known/apple-app-site-association+assetlinks.json已上线- 用户设备上装的 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)
- 新组件用
<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/编译产物 - 文档开头有 "★ 方案概述" + 要解决的问题 + 实现路径 + 关键决策 + 核心架构图
- 严格遵守 MVP 先行原则(无 Provider 抽象 / 无 MintStrategy,见关键决策表 / §10)|
- 同后端接口(app 走 api.js,H5 走 fetch),与用户原话对齐
- 双端页面 + 同源数据 + 同色板,内容一致
- "请对比实物" 是纯文字提示,无勾选逻辑(与用户明确对齐)
- 未集成举报(用户中途移除)
- 加入藏品走简化 mint,不走 castlove AI 链路(与用户明确对齐)