31f2f07c5c
PC 收银台此前硬编码「请使用微信扫码支付」,只能走微信 Native。 改动: - 支付方式选择器(微信支付 / 支付宝),切换即重新发起支付并清掉上一张码—— 同一订单号换支付类型重复下单会被渠道拒(微信回 OUT_TRADE_NO_USED), 两种支付信息同时留在页面上只会让用户困惑。 - 支付宝走电脑网站支付:后端返回自提交表单,用 document.write 写进新窗口。 刻意不用 v-html——它插入的脚本标签不会执行(HTML 规范如此), 表单永远不会自动提交。原收银台页留在原标签页继续轮询查单。 - 弹窗被拦截时给出可行动提示,并保留「前往支付宝付款」按钮兜底。 - 回到一张曾用支付宝发起过的待支付订单时,默认选中支付宝, 避免又拿微信去撞一次「订单号已使用」。 - useHjcPayStatus 增加 channel 参数,仅供提示文案说对「微信侧/支付宝侧」。 **状态词表不变**——后端已把两个渠道的状态映射成渠道无关的 PaymentStatus, 前端再加一份支付宝词表等于把渠道知识泄漏进 UI。顺带把这层理由写进注释。 - 修正代码注释里对 ADR 0009 的引用:工作区 docs/adr 下并没有这一篇, 相关决策现记录在 ADR 0008。 验证:eslint 0 error;npm run build:dev 构建通过。
186 lines
6.9 KiB
TypeScript
186 lines
6.9 KiB
TypeScript
/**
|
||
* 支付状态机(hjc-web 购标支付步骤与收银台共用)。
|
||
*
|
||
* 唯一事实来源是**支付渠道侧**:轮询 `GET /api/tender/pay-status`,
|
||
* 由后端向渠道查单后给出结论。不要退回 `mark-paid`——那个接口不查单,
|
||
* 用户点一下就能把订单置为已支付(见 ADR 0008)。
|
||
*
|
||
* **渠道无关**:下面两个状态集合用的是后端 `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 }
|
||
}
|