/** * 支付状态机(hjc-web 购标支付步骤与收银台共用)。 * * 唯一事实来源是**支付渠道侧**:轮询 `GET /api/tender/pay-status`, * 由后端向渠道查单后给出结论。不要退回 `mark-paid`——那个接口不查单, * 用户点一下就能把订单置为已支付(见 ADR 0010)。 * * **渠道无关**:下面两个状态集合用的是后端 `PaymentStatus` 的枚举名。微信的 * `trade_state` 与支付宝的 `trade_status` 都已由各自的支付策略映射到该枚举 * (`WechatJsapiStrategy#convertWechatPaymentStatus` / `AlipayStrategy#convertAlipayPaymentStatus`)。 * 因此**不要**在这里加支付宝的状态词表——那等于把渠道知识泄漏进 UI, * 且下次接入新渠道还要再改一遍。 */ /** * 支付码有效期的**兜底**值(从调用 `start` 的那一刻起算)。 * * 优先用后端随订单下发的 `payDeadline`——那才是权威值,且与后端判定超时、渠道单失效 * 是同一个时刻(见 ADR 0019),`start` 的第二个参数就是传它。 * 拿不到到期时刻时才退回这个本地计时(例如购标流程里还没取到订单对象就被调用)。 */ 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 跳登录页时带上,便于登录后回跳 * @param channel 支付渠道(`'wechat'` / `'alipay'`),只用于把提示文案里的 * 「微信侧 / 支付宝侧」说对。**状态词表不因此变化**——见文件头注释。 */ export function useHjcPayStatus( redirect: () => string, channel: () => 'wechat' | 'alipay' = () => 'wechat' ) { const { handleAuthCode } = useHjcAuth() /** * 与 `orders.vue` 同理:服务端渲染期间发出的站内请求必须走 `useRequestFetch()`, * 否则代理拿不到浏览器 Cookie,会被后端按匿名调用判成 `code=401`, * 再经 `handleAuthCode` 变成"把自己登出"。客户端侧它等价于 `$fetch`,行为不变。 */ const requestFetch = useRequestFetch() /** 提示文案里的渠道名;只影响措辞,不影响任何判定 */ const channelLabel = computed(() => (channel() === 'alipay' ? '支付宝侧' : '微信侧')) const status = ref(null) const paid = ref(false) const querying = ref(false) /** 查单本身失败的提示(网络/业务失败),与订单是否付款无关 */ const error = ref('') /** 支付码是否已超出计时(优先用后端下发的到期时刻,拿不到时才用本地兜底值) */ const expired = ref(false) /** * 渠道侧那张支付单已经终态、且不是成功(FAILED / CANCELLED / TIMEOUT / REFUND_FAILED)。 * * 为什么要单独给一个结构化标记、而不是只给 `error` 里那句文案:收银台现在分两段 * (选择态 / 出码态),同一件事在**还没出过码**时该说"请重新选择支付方式",出过码才该说 * "请重新获取二维码"。让调用方去匹配那句 message 是一改就静默失效的写法, * 所以**状态**归这里、**措辞**归调用方(见 `useHjcPayFlow` 的 failureText)。 */ const channelClosed = ref(false) /** 轮询是否应当进行(已开始、未付款、未过期) */ let active = false let timer: ReturnType | 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 { 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 channelClosed.value = false if (name && SETTLED_STATUS.has(name)) { paid.value = true stop() } else if (name && DEAD_STATUS.has(name)) { // 渠道侧已终态且不是成功:这张支付码已经没用了,继续轮询只是空转。 // 这里的文案是按"已经出过码"写的;还没出过码的调用方用 channelClosed 换成自己的措辞。 stop() channelClosed.value = true error.value = `该支付单在${channelLabel.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() } } /** * 开始(或重新开始)轮询。 * * @param no 订单号 * @param deadlineAtMs 该订单支付时限的到期时刻(毫秒)。**传后端下发的 `payDeadline`**—— * 用本地倒计时代替它,两者会在边界上错开。不传则退回 {@link HJC_PAY_QR_TTL_MS} */ function start(no: string, deadlineAtMs?: number | null) { orderNo = no paid.value = false expired.value = false channelClosed.value = false error.value = '' status.value = null deadline = typeof deadlineAtMs === 'number' && Number.isFinite(deadlineAtMs) ? deadlineAtMs : 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, channelClosed, channelLabel, start, stop, query } }