Files
weicw1996 5b6e1b67bc feat(tender): 支付链接改为站内二维码,支付确认改走微信侧查单
- BuyDocument:支付步骤原先把后端 Native 下单返回的 codeUrl(weixin://wxpay/...)当明文
  链接渲染成一个 <a target="_blank">,而 weixin:// 在浏览器里点击没有任何有效行为——标题写
  着「请使用微信扫码支付」,页面上却没有二维码,PC 端 Native 支付实质不可用。改为渲染真正
  的二维码,并删掉那个链接。
- HjcPayQrcode(新):uqr 站内生成,renderSVG() → data:image/svg+xml → <img :src>。不用
  v-html(避开 vue/no-v-html),不用第三方外链(hjc-h5 用的 api.qrserver.com 在境内不可靠,
  且等于把支付凭据交给第三方),也不改后端契约。
- useHjcPayStatus(新):支付结果一律以**微信侧**为准——轮询 pay-status(3s,页面隐藏暂停,
  2 小时上限),手动「我已完成支付」按钮也走同一条查单通道。原先只有 mark-paid 一条路,而它
  不向微信查单(HjcOrderController 的 javadoc 自陈「订单置为已支付只有 /mark-paid 一条路」),
  用户一分钱不付、点一下就能把订单置为已支付并触发一站式推送。故 hjc-web 弃用它,并删除
  server/api/tender/mark-paid.put.ts,不留这个入口给下一个人误用。
- 查单的另一好处:微信已支付而本地仍未支付时它会就地补写状态(幂等)。于是「付了钱但页面被
  关掉/断网」这类漏单能被认出来——进入支付步骤即开始轮询,正是为了认出这种订单。
- 拿不到二维码时(例如租户支付配置错误)不再显示空白卡片,而是失败原因 +「重新获取二维码」。
  微信 Native 码默认 2 小时且服务端不下发 expireTime,故按发起支付时刻本地计时,过期把二维码
  置灰并提示重新获取;「重新获取二维码」重调 /api/tender/pay(同 out_trade_no 的 Native→
  Native 重下微信允许)。
- 由此 hjc-web 与 hjc-h5/小程序端的支付确认语义**有意不一致**(那两端仍在用 mark-paid,改动
  它们等于同时动三端线上支付路径)。理由与边界记在 ADR 0009。
- 依赖:新增 uqr;两份锁文件同步更新(pnpm 装,package-lock.json 同步)。
- 删除 BUY_TENDER_MAP_REPORT.md:530 行历史快照,结论与现状全面矛盾(称购买流程是桩函数、
  /buy 不可用、没有登录流程、pay 代理路径写错),已实际误导过一次调研;历史价值留在 git 里。
2026-09-17 02:32:39 +08:00

170 lines
5.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 支付状态机(hjc-web 购标支付步骤用)。
*
* 唯一事实来源是**微信侧**:轮询 `GET /api/tender/pay-status`,由后端向微信查单后给出结论。
* 不要退回 `mark-paid`——那个接口不查单,用户点一下就能把订单置为已支付(见 ADR 0009)。
*/
/**
* 微信 Native 支付码的有效期。
*
* 微信侧默认 2 小时,且**服务端不下发过期时间**`PaymentResponse.wechatNative()` 不设 `expireTime`),
* 所以只能以「本次拿到支付码的时刻」本地计时;到点停止轮询并提示重新获取。
*/
export const HJC_PAY_QR_TTL_MS = 2 * 60 * 60 * 1000
/** 轮询间隔:查单是以微信为准的代价,取到「扫完到页面反应」不至于让人以为卡住 */
const POLL_INTERVAL_MS = 3000
/** 微信侧终态:不用再付了 */
const SETTLED_STATUS = new Set(['SUCCESS', 'REFUNDED'])
/** 微信侧终态(坏的那种):这单在微信那边已经付不了了,继续轮询没有意义 */
const DEAD_STATUS = new Set(['FAILED', 'CANCELLED', 'TIMEOUT', 'REFUND_FAILED'])
export interface HjcPayQueryResult {
/** 是否已按登录态问题处理(401 已跳登录 / 403 已给出提示) */
handled: boolean
/** 微信侧状态名;没查到结论时为 null */
status: string | null
/** 是否已付款(SUCCESS / REFUNDED */
settled: boolean
/** 需要展示给用户的文案(登录态提示、业务失败、或"仍未收到付款" */
message?: string
}
/**
* @param redirect 401 跳登录页时带上,便于登录后回跳
*/
export function useHjcPayStatus(redirect: () => string) {
const { handleAuthCode } = useHjcAuth()
/**
* 与 `orders.vue` 同理:服务端渲染期间发出的站内请求必须走 `useRequestFetch()`
* 否则代理拿不到浏览器 Cookie,会被后端按匿名调用判成 `code=401`
* 再经 `handleAuthCode` 变成"把自己登出"。客户端侧它等价于 `$fetch`,行为不变。
*/
const requestFetch = useRequestFetch()
const status = ref<string | null>(null)
const paid = ref(false)
const querying = ref(false)
/** 查单本身失败的提示(网络/业务失败),与订单是否付款无关 */
const error = ref('')
/** 支付码是否已超出本地计时(2 小时) */
const expired = ref(false)
/** 轮询是否应当进行(已开始、未付款、未过期) */
let active = false
let timer: ReturnType<typeof setInterval> | null = null
let orderNo = ''
let deadline = 0
function pauseTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function ensureTimer() {
if (!active || timer) return
timer = setInterval(() => { void tick() }, POLL_INTERVAL_MS)
}
/** 查一次微信侧状态。手动确认按钮也复用它——两条路必须同源,否则又变成两种语义 */
async function query(): Promise<HjcPayQueryResult> {
if (!orderNo) {
return { handled: false, status: null, settled: false, message: '缺少订单号' }
}
querying.value = true
try {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200
const res: any = await requestFetch('/api/tender/pay-status', { query: { orderNo } })
const auth = await handleAuthCode(res, redirect())
if (auth.handled) {
// 401 已跳登录页;403 只提示。两种情况都不该继续轮询
stop()
return { handled: true, status: null, settled: false, message: auth.message }
}
if (res?.code !== 0) {
error.value = res?.message || '支付状态查询失败'
return { handled: false, status: null, settled: false, message: error.value }
}
error.value = ''
const name = res.data?.paymentStatus ? String(res.data.paymentStatus) : null
status.value = name
if (name && SETTLED_STATUS.has(name)) {
paid.value = true
stop()
} else if (name && DEAD_STATUS.has(name)) {
// 微信侧已终态且不是成功:这张支付码已经没用了,继续轮询只是空转
stop()
error.value = '该支付单在微信侧已关闭,请重新获取二维码'
}
return { handled: false, status: name, settled: paid.value }
} catch (e: any) {
error.value = e?.data?.statusMessage || e?.data?.message || e?.message || '支付状态查询失败'
return { handled: false, status: null, settled: false, message: error.value }
} finally {
querying.value = false
}
}
async function tick() {
if (!active) return
if (Date.now() > deadline) {
// 到点:停止轮询并标记过期,由页面提示"重新获取二维码"
active = false
pauseTimer()
expired.value = true
return
}
if (querying.value) return // 上一次还没回来,不要叠加请求
await query()
if (paid.value) {
active = false
pauseTimer()
}
}
/** 开始(或重新开始)轮询,并重置 2 小时计时 */
function start(no: string) {
orderNo = no
paid.value = false
expired.value = false
error.value = ''
status.value = null
deadline = Date.now() + HJC_PAY_QR_TTL_MS
active = true
pauseTimer()
ensureTimer()
void tick() // 立刻查一次,别让第一个 3 秒白等
}
function stop() {
active = false
pauseTimer()
}
/** 页面切到后台时停表,回来再续上——收银台开着不动不该持续打接口 */
function onVisibilityChange() {
if (!import.meta.client) return
if (document.visibilityState === 'hidden') {
pauseTimer()
} else if (active) {
ensureTimer()
void tick()
}
}
onMounted(() => {
document.addEventListener('visibilitychange', onVisibilityChange)
})
onUnmounted(() => {
stop()
document.removeEventListener('visibilitychange', onVisibilityChange)
})
return { status, paid, querying, error, expired, start, stop, query }
}