feat: 收银台支持支付宝支付(微信/支付宝选择器)

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 构建通过。
This commit is contained in:
2026-09-20 19:18:02 +08:00
parent 0d6410c17f
commit 31f2f07c5c
4 changed files with 232 additions and 110 deletions
+28 -12
View File
@@ -1,25 +1,33 @@
/**
* 支付状态机(hjc-web 购标支付步骤用)。
* 支付状态机(hjc-web 购标支付步骤与收银台共用)。
*
* 唯一事实来源是**微信侧**:轮询 `GET /api/tender/pay-status`,由后端向微信查单后给出结论。
* 不要退回 `mark-paid`——那个接口不查单,用户点一下就能把订单置为已支付(见 ADR 0009)。
* 唯一事实来源是**支付渠道侧**:轮询 `GET /api/tender/pay-status`,
* 由后端向渠道查单后给出结论。不要退回 `mark-paid`——那个接口不查单,
* 用户点一下就能把订单置为已支付(见 ADR 0008)。
*
* **渠道无关**:下面两个状态集合用的是后端 `PaymentStatus` 的枚举名。微信的
* `trade_state` 与支付宝的 `trade_status` 都已由各自的支付策略映射到该枚举
* (`WechatJsapiStrategy#convertWechatPaymentStatus` / `AlipayStrategy#convertAlipayPaymentStatus`)。
* 因此**不要**在这里加支付宝的状态词表——那等于把渠道知识泄漏进 UI,
* 且下次接入新渠道还要再改一遍。
*/
/**
* 微信 Native 支付码的有效期。
* 支付码的有效期。
*
* 微信侧默认 2 小时,且**服务端不下发过期时间**(`PaymentResponse.wechatNative()` 不设 `expireTime`),
* 微信 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 {
@@ -35,8 +43,13 @@ export interface HjcPayQueryResult {
/**
* @param redirect 401 跳登录页时带上,便于登录后回跳
* @param channel 支付渠道(`'wechat'` / `'alipay'`),只用于把提示文案里的
* 「微信侧 / 支付宝侧」说对。**状态词表不因此变化**——见文件头注释。
*/
export function useHjcPayStatus(redirect: () => string) {
export function useHjcPayStatus(
redirect: () => string,
channel: () => 'wechat' | 'alipay' = () => 'wechat'
) {
const { handleAuthCode } = useHjcAuth()
/**
* 与 `orders.vue` 同理:服务端渲染期间发出的站内请求必须走 `useRequestFetch()`,
@@ -45,6 +58,9 @@ export function useHjcPayStatus(redirect: () => string) {
*/
const requestFetch = useRequestFetch()
/** 提示文案里的渠道名;只影响措辞,不影响任何判定 */
const channelLabel = computed(() => (channel() === 'alipay' ? '支付宝侧' : '微信侧'))
const status = ref<string | null>(null)
const paid = ref(false)
const querying = ref(false)
@@ -97,9 +113,9 @@ export function useHjcPayStatus(redirect: () => string) {
paid.value = true
stop()
} else if (name && DEAD_STATUS.has(name)) {
// 微信侧已终态且不是成功:这张支付码已经没用了,继续轮询只是空转
// 渠道侧已终态且不是成功:这张支付码已经没用了,继续轮询只是空转
stop()
error.value = '该支付单在微信侧已关闭,请重新获取二维码'
error.value = `该支付单在${channelLabel.value}已关闭,请重新获取二维码`
}
return { handled: false, status: name, settled: paid.value }
} catch (e: any) {
@@ -165,5 +181,5 @@ export function useHjcPayStatus(redirect: () => string) {
document.removeEventListener('visibilitychange', onVisibilityChange)
})
return { status, paid, querying, error, expired, start, stop, query }
return { status, paid, querying, error, expired, channelLabel, start, stop, query }
}