Files
hjc-web/app/composables/useHjcPayStatus.ts
T

186 lines
6.9 KiB
TypeScript
Raw 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 0010)。
*
* **渠道无关**:下面两个状态集合用的是后端 `PaymentStatus` 的枚举名。微信的
* `trade_state` 与支付宝的 `trade_status` 都已由各自的支付策略映射到该枚举
* (`WechatJsapiStrategy#convertWechatPaymentStatus` / `AlipayStrategy#convertAlipayPaymentStatus`)。
* 因此**不要**在这里加支付宝的状态词表——那等于把渠道知识泄漏进 UI,
* 且下次接入新渠道还要再改一遍。
*/
/**
* 支付码的有效期。
*
* 微信 Native 默认 2 小时;支付宝当面付的超时由下单时的 `timeout_express` 决定。
* 服务端都不下发过期时刻(`PaymentResponse` 不设 `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 跳登录页时带上,便于登录后回跳
* @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<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 = `该支付单在${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()
}
}
/** 开始(或重新开始)轮询,并重置 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, channelLabel, start, stop, query }
}