190 lines
7.8 KiB
JavaScript
190 lines
7.8 KiB
JavaScript
/**
|
|
* 扫码结果处理(纯函数 + 副作用拆分)
|
|
* @param {string} rawUrl uni.scanCode 回调里的 result 字符串
|
|
* @returns {{ ok: true, encryptedCode: string, sign: string, rawUrl: string } | { ok: false, reason: string }}
|
|
*
|
|
* ★ 兼容处理(2026-07-10 加):
|
|
* 1) 剥首尾空白(扫码 app 有时会带 \n / 空格)
|
|
* 2) 缺 scheme 时补 http://(Android 扫码常剥 scheme)
|
|
* 3) try/catch + decodeURIComponent 兜底(被二次编码的 URL)
|
|
* 4) host 大小写不敏感(toLowerCase 比较)
|
|
* 5) path 自动 strip 末尾 /(扫码有时会带 trailing slash)
|
|
* 6) JSON 包装 {url: "..."} 拆包
|
|
* 7) topfans:// 自定义 scheme(从 H5 唤起 app 时用)
|
|
*
|
|
* ★ stage 1 强制(2026-07-17 加):必须同时取到 encrypted_code(32 hex) + sign(32 hex)
|
|
* 老版明文 code / asset_id 一律拒绝(verify.vue onLoad 会再校验一次)
|
|
*/
|
|
export function parseVerifyUrl(rawUrl) {
|
|
// ★ 诊断:在函数入口打印 raw 输入,方便定位扫码 app 返回什么
|
|
console.log('[parseVerifyUrl] 📥 扫描原始数据:', JSON.stringify({
|
|
type: typeof rawUrl,
|
|
length: typeof rawUrl === 'string' ? rawUrl.length : null,
|
|
value: typeof rawUrl === 'string' ? rawUrl.slice(0, 200) : rawUrl,
|
|
isJSON: typeof rawUrl === 'string' && rawUrl.trim().startsWith('{'),
|
|
}))
|
|
|
|
if (!rawUrl || typeof rawUrl !== 'string') {
|
|
console.warn('[parseVerifyUrl] invalid input type:', typeof rawUrl)
|
|
return { ok: false, reason: '二维码格式不正确' }
|
|
}
|
|
|
|
// 1) 剥空白
|
|
let s = rawUrl.trim()
|
|
if (!s) {
|
|
console.warn('[parseVerifyUrl] empty after trim')
|
|
return { ok: false, reason: '二维码格式不正确' }
|
|
}
|
|
|
|
// 2) JSON 包装拆包(部分扫码 app 返 {url: "..."})
|
|
if (s.startsWith('{') && s.endsWith('}')) {
|
|
try {
|
|
const obj = JSON.parse(s)
|
|
const extracted = obj.url || obj.URL || obj.link || obj.data
|
|
if (extracted && typeof extracted === 'string') {
|
|
s = extracted.trim()
|
|
}
|
|
} catch { /* 不是合法 JSON,继续 */ }
|
|
}
|
|
|
|
// 3) 解码(如果整段被 URL 编码了,先 decode 一次)
|
|
if (s.includes('%3A') || s.includes('%2F')) {
|
|
try { s = decodeURIComponent(s) } catch { /* 保持原样 */ }
|
|
}
|
|
|
|
// 4) 补 scheme(只有缺时才补,保留 https/http/topfans)
|
|
if (!/^(https?|topfans):\/\//i.test(s)) {
|
|
s = 'http://' + s
|
|
}
|
|
|
|
// 5) 解析 — 用正则手动拆解(绕开 uniapp Android 老基座 `new URL` 不可用问题)
|
|
// 格式: <proto>://<host>[/<path>][?query][#fragment]
|
|
// 例: http://topfans.online/verify/88100001
|
|
// 或: topfans://verify/88100001
|
|
// 或: topfans.online/verify/88100001 (无 scheme,前面已补 http://)
|
|
const urlMatch = s.match(/^((?:https?|topfans):)\/\/([^\/\?#]+)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/i)
|
|
if (!urlMatch) {
|
|
console.warn('[parseVerifyUrl] URL 解析失败(正则不匹配):', s)
|
|
return { ok: false, reason: '二维码格式不正确' }
|
|
}
|
|
const proto = urlMatch[1].toLowerCase() // 已带 ":"(如 "http:")
|
|
const host = urlMatch[2].toLowerCase()
|
|
const rawPath = urlMatch[3] || '/'
|
|
|
|
// 6) 协议 + host 校验(支持 http/https UniversalLink + topfans 自定义 scheme)
|
|
const isValid =
|
|
((proto === 'http:' || proto === 'https:') && host === 'topfans.online') ||
|
|
(proto === 'topfans:' && host === 'verify')
|
|
if (!isValid) {
|
|
console.warn('[parseVerifyUrl] host check fail:', proto, '//', host)
|
|
return { ok: false, reason: '二维码格式不正确' }
|
|
}
|
|
|
|
// 7) path 必须以 /verify/ 开头,strip 末尾 /
|
|
let pathname = rawPath
|
|
if (proto === 'topfans:' && host) {
|
|
pathname = '/' + host + pathname
|
|
}
|
|
if (pathname.endsWith('/')) pathname = pathname.slice(0, -1)
|
|
if (!pathname.startsWith('/verify/') && pathname !== '/verify') {
|
|
console.warn('[parseVerifyUrl] path check fail:', pathname)
|
|
return { ok: false, reason: '二维码格式不正确' }
|
|
}
|
|
|
|
// 8) 提取 code(/verify/{code})
|
|
const segs = pathname.split('/')
|
|
const code = segs[2] || ''
|
|
if (!code) {
|
|
console.warn('[parseVerifyUrl] code empty:', segs)
|
|
return { ok: false, reason: '二维码格式不正确' }
|
|
}
|
|
|
|
// 8.5) 提取 sign(?sign=...)—— stage 1 强制要求,缺失或格式不对直接拒
|
|
const queryPart = urlMatch[4] || ''
|
|
const signMatch = queryPart.match(/[?&]sign=([^&#]+)/i)
|
|
const sign = signMatch ? decodeURIComponent(signMatch[1]).trim() : ''
|
|
if (!/^[0-9a-f]{32}$/i.test(sign)) {
|
|
console.warn('[parseVerifyUrl] sign missing/invalid:', sign || '(空)')
|
|
return { ok: false, reason: '二维码格式不正确' }
|
|
}
|
|
|
|
// 8.6) code 也要是 32 hex(stage 1 契约:encrypted_code = HMAC-SHA256(realCode)[:32])
|
|
if (!/^[0-9a-f]{32}$/i.test(code)) {
|
|
console.warn('[parseVerifyUrl] code not 32-hex:', code)
|
|
return { ok: false, reason: '二维码格式不正确' }
|
|
}
|
|
|
|
console.log('[parseVerifyUrl] ✅ encryptedCode=' + code + ', sign=' + sign.slice(0, 8) + '...')
|
|
return { ok: true, encryptedCode: code, sign, rawUrl }
|
|
}
|
|
|
|
/**
|
|
* 入口:解析 + 登录 + 跳转(用户主动扫码,解析失败要 toast 提示)
|
|
*/
|
|
export async function onScanResult(rawUrl) {
|
|
const parsed = parseVerifyUrl(rawUrl)
|
|
if (!parsed.ok) {
|
|
uni.showToast({ title: parsed.reason, icon: 'none' })
|
|
return
|
|
}
|
|
await navigateToVerify(parsed.encryptedCode, parsed.sign)
|
|
}
|
|
|
|
/**
|
|
* Deep link 入口:解析 + 登录 + 跳转(系统唤起,解析失败静默吞掉)
|
|
*/
|
|
export async function onDeepLinkTo(rawUrl) {
|
|
const parsed = parseVerifyUrl(rawUrl)
|
|
if (!parsed.ok) {
|
|
// 静默:系统唤起常因剪贴板/分享被截获的旧 URL 出现,不应弹 toast
|
|
return
|
|
}
|
|
await navigateToVerify(parsed.encryptedCode, parsed.sign)
|
|
}
|
|
|
|
/**
|
|
* 私有:把 deep link 落到验真页。
|
|
*
|
|
* ★ 设计意图(2026-07-21):用户要求"每次都进",所以不去重。
|
|
* - 已登录 + 非 quickLogin 流程:直接 reLaunch 到验真页(立刻进入)
|
|
* - 已登录 + quickLogin 流程中(冷启动 reAuth):只落盘,等 quickLogin 走完
|
|
* → square → welcome → handleEnterTopfans 消费 → 再跳验真页
|
|
* (避免和 quickLogin reLaunch 撞车触发 "do not operate continuously")
|
|
* - 未登录:navigateTo portal,登录后由 portal/square 链路消费 pending_scan_url
|
|
*
|
|
* ★ stage 1 调整(2026-07-17):参数从 (code) 改为 (encryptedCode, sign),
|
|
* verify.vue 的 onLoad 强校验 options.encryptedCode + options.sign,
|
|
* query 串必须用 encryptedCode / sign 两个名字(参见 verify.vue:62)
|
|
*/
|
|
async function navigateToVerify(encryptedCode, sign) {
|
|
const token = uni.getStorageSync('access_token') || ''
|
|
const targetUrl = `/pages/scan/verify?encryptedCode=${encodeURIComponent(String(encryptedCode))}&sign=${encodeURIComponent(String(sign))}`
|
|
|
|
// 1) 始终落盘目标 URL(消费时机:square welcome 点击 / 直接 reLaunch)
|
|
uni.setStorageSync('pending_scan_url', targetUrl)
|
|
|
|
// 2) 有 token:看是否在 quickLogin reAuth 流程中(冷启动)
|
|
if (token) {
|
|
const app = getApp()
|
|
const shouldQuickLogin = !!(app && app.globalData && app.globalData.shouldShowQuickLogin)
|
|
if (!shouldQuickLogin) {
|
|
// warm start 或 quickLogin 已完成:直接 reLaunch 到验真页,无需走 welcome
|
|
// (用 reLaunch 而非 navigateTo:从任何页面都能切,栈干净)
|
|
uni.removeStorageSync('pending_scan_url') // 已经直接跳了,清掉,避免 welcome 误消费
|
|
return uni.reLaunch({ url: targetUrl })
|
|
}
|
|
// 冷启动 + quickLogin 流程中:不主动 reLaunch,等 quickLogin → square → welcome → handleEnterTopfans 消费
|
|
return
|
|
}
|
|
|
|
// 3) 未登录:让用户先登录。navigateTo portal(带 redirect 兜底 login.vue)
|
|
const pages = getCurrentPages()
|
|
const lastRoute = pages.length > 0 ? pages[pages.length - 1].route || '' : ''
|
|
if (lastRoute.indexOf('pages/login/portal') === 0) {
|
|
return
|
|
}
|
|
return uni.navigateTo({
|
|
url: `/pages/login/portal?redirect=${encodeURIComponent(targetUrl)}`
|
|
})
|
|
}
|