Files
hjc-web/app/composables/useHjcPayStatus.ts
T
weicw-dshsa 5bbe383736 fix(tender): 去掉购标流程里写死的「2 小时内支付」文案
收银台那处上一轮已改;购标流程的支付步骤(BuyDocument)里还剩一处,
两处都会与新的支付时限(默认 30 分钟)自相矛盾。

支付时限由后端配置、且随本单剩余窗口变化,页面上写死一个数字迟早与事实不符,
所以一律改成「在支付时限内」。同时更正 useHjcPayStatus 里"本地计时 2 小时"
的注释:它现在只是**兜底值**,优先用后端随订单下发的 payDeadline。
2026-09-29 18:16:27 +08:00

194 lines
7.4 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,
* 且下次接入新渠道还要再改一遍。
*/
/**
* 支付码有效期的**兜底**值(从调用 `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<string | null>(null)
const paid = ref(false)
const querying = ref(false)
/** 查单本身失败的提示(网络/业务失败),与订单是否付款无关 */
const error = ref('')
/** 支付码是否已超出计时(优先用后端下发的到期时刻,拿不到时才用本地兜底值) */
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()
}
}
/**
* 开始(或重新开始)轮询。
*
* @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
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, channelLabel, start, stop, query }
}