Files
hjc-web/app/composables/useHjcPayStatus.ts
T
weicw-dshsa 57543cfade feat(order): 收银台与购标流程改为先选支付方式再发起支付
- 新增 useHjcPayFlow(select/code 两段状态机)与 HjcPayPanel(两处共用):
  进页只查单、不发起支付,不预选支付方式;出码后不再有切换控件,
  只能显式「更换支付方式」回到选择态(不自动重发、不停查单)
- 发起失败留在选择态并保留所选(渠道侧什么都没产生,没有理由锁住用户)
- 收银台 pages/tender/pay.vue 只留订单侧职责:取单、终态屏、超时锁、付款成功跳列表
- 购标流程 BuyDocument:下单后停在选择态(不再按默认微信自动发起),
  补支付剩余时间与超时锁,超时后回表单步重新下单
- 订单详情:只有已完成订单才显示支付方式渠道名,其余显示「—」;
  标签按前缀判,修掉 ALIPAY_QR 被显示成「微信支付」
- useHjcPayStatus:新增结构化 channelClosed——「渠道单已关闭」在选择态说
  「重新选择支付方式」、出码态才说「重新获取二维码」,不靠匹配 message 字符串

Refs .scratch/cashier-pay-method-selection/issues/01-pay-method-before-pay.md
2026-09-30 09:16:36 +08:00

207 lines
8.2 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)
/**
* 渠道侧那张支付单已经终态、且不是成功(FAILED / CANCELLED / TIMEOUT / REFUND_FAILED)。
*
* 为什么要单独给一个结构化标记、而不是只给 `error` 里那句文案:收银台现在分两段
* (选择态 / 出码态),同一件事在**还没出过码**时该说"请重新选择支付方式",出过码才该说
* "请重新获取二维码"。让调用方去匹配那句 message 是一改就静默失效的写法,
* 所以**状态**归这里、**措辞**归调用方(见 `useHjcPayFlow` 的 failureText)。
*/
const channelClosed = 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
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 }
}