Files
hjc-web/app/composables/useHjcPayFlow.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

322 lines
12 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.
import {
hjcFormatCountdown,
hjcPayDeadlineMs,
hjcPayRemainMs
} from '~/utils/orderStatus'
/**
* 收银台 / 购标流程的支付流程状态机(hjc-web)。
*
* **为什么分两段**(`select` → `code`):买家先明确选「微信支付 / 支付宝」,点「立即支付」才
* 向渠道发起。改造前是"进页就按默认微信发起 + 页面上留个切换器",两个后果都被这次改掉:
* ①收银台替买家做了决定;②切换即重新发起一次**真实的渠道下单**,订单上的 `payMethod`
* (甲方对账、订单详情展示用)跟着被覆盖——而它建单时就是 `WECHAT_NATIVE`,与买家选过什么
* 无关(见 `CONTEXT.md` 的「支付渠道」)。H5 收银台本来就是"选择后发起"的形状,这里是向它对齐。
*
* 三条约束,改这个文件前先读懂:
* - **不预选**:`payType` 初值是 `null`,没选之前「立即支付」不可用——预选一个就等于替买家决定。
* - **出码即锁**:拿到 `codeUrl` / `alipayForm` 后进 `code` 段,页面上**不再有**切换控件;
* 要换只能 {@link changePayMethod} 显式回到选择态(清掉当前支付信息、**不自动重发**)。
* - **失败留在选择态**:发起失败说明渠道侧什么都没产生,没有理由把用户锁在一个没成功的方式上。
*
* 锁是**页面会话级**的:订单现有字段区分不了"从没选过"与"选过微信没付"(`pay_method` 建单即
* `WECHAT_NATIVE`),所以刷新/重进必然回到选择态。要做服务端硬锁就得在订单上加字段,本次不做。
*/
export type HjcPayMethod = 'wechat' | 'alipay'
export type HjcPayPhase = 'select' | 'code'
export interface HjcPayFlowOptions {
/** 订单号(收银台从路由取,购标流程下单成功后拿到) */
orderNo: () => string
/** 401 跳登录页时带上,便于登录后回跳 */
redirect: () => string
/**
* 该订单支付时限的到期时刻(后端下发的原始串)。**拿不到时既不显示倒计时、也不锁页面**——
* 拿一个假值去锁死收银台比不显示倒计时糟得多。
*/
deadline?: () => string | null | undefined
}
export function useHjcPayFlow(options: HjcPayFlowOptions) {
const { handleAuthCode } = useHjcAuth()
const orderNo = () => String(options.orderNo() || '')
const deadlineMs = () => hjcPayDeadlineMs({ payDeadline: options.deadline?.() ?? null })
/** 当前选择。**不预选**:`null` = 买家还没选,「立即支付」在这个状态下不可用 */
const payType = ref<HjcPayMethod | null>(null)
/** 选择态 / 出码态。出码后不能再直接改方式,只能走 `changePayMethod` */
const phase = ref<HjcPayPhase>('select')
const payOptions: Array<{ value: HjcPayMethod; label: string }> = [
{ value: 'wechat', label: '微信支付' },
{ value: 'alipay', label: '支付宝' }
]
const codeUrl = ref('')
/** 支付宝电脑网站支付的自提交表单(后端只在该渠道下返回) */
const alipayForm = ref('')
const starting = ref(false)
const refreshing = ref(false)
const confirming = ref(false)
/** 动作本身的失败(发起 / 重新获取 / 手动确认),与"查单失败"分开 */
const error = ref('')
/** 提示文案里的渠道名(只影响措辞,不影响判定) */
const channelLabel = computed(() => (payType.value === 'alipay' ? '支付宝' : '微信'))
const qrAlt = computed(() => (payType.value === 'alipay' ? '支付宝支付二维码' : '微信支付二维码'))
/**
* 支付确认一律以**渠道侧**为准(轮询 + 手动确认都走它)。
* 绝不调用 `mark-paid`:那个接口不查单,点一下就能把订单置为已支付(见 ADR 0010)。
*
* 渠道名传当前选择;还没选时按微信传——它只影响提示里的"微信侧 / 支付宝侧",
* 状态词表不因此变化(见 `useHjcPayStatus` 的文件头注释)。
*/
const {
paid,
querying,
error: payError,
expired: payExpired,
channelClosed,
start: beginQueryPolling,
query: queryPayStatus
} = useHjcPayStatus(() => options.redirect(), () => payType.value ?? 'wechat')
/**
* 倒计时用的"当前时刻"。
*
* 每秒只更新一个 ref(而不是每个用到它的地方各自 setInterval),且**秒级**就够了:
* 支付时限是 30 分钟量级,没必要为 1 秒的精度付出多次渲染。
*/
const now = ref(Date.now())
let clock: ReturnType<typeof setInterval> | null = null
onMounted(() => {
clock = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (clock) {
clearInterval(clock)
clock = null
}
})
const remainMs = computed(() => hjcPayRemainMs({ payDeadline: options.deadline?.() ?? null }, now.value))
const remainText = computed(() => (remainMs.value == null ? '' : hjcFormatCountdown(remainMs.value)))
/** 剩余不足一分钟时把倒计时标红(与 H5 收银台同一口径) */
const remainWarn = computed(() => remainMs.value != null && remainMs.value <= 60_000)
/** 到期时刻已知且已归零:页面锁死,不再出码、不再轮询(到期时刻未知时**不算**超时) */
const timedOut = computed(() => remainMs.value !== null && remainMs.value <= 0)
/**
* 选择支付方式。出码后调它无效——锁定只能由 `changePayMethod` 解开,
* 否则"锁定"就等于没锁(模板在 `code` 段也确实不再渲染这两个按钮)。
*/
function select(method: HjcPayMethod) {
if (phase.value === 'code') return
payType.value = method
error.value = ''
}
/**
* 渠道错误码 → 可行动的中文提示。
*
* `OUT_TRADE_NO_USED` = 同一订单号换了支付类型重复下单。微信内(JSAPI)与 PC(Native)
* 各发起一次时必现,而"先选方式再发起"恰恰让用户更容易自己撞上它(比如在微信内付过一半,
* 又来 PC 选微信)。错误码由后端从支付异常的 cause 链上取,**不要**去匹配 message 字符串——
* 那种写法会在错误包装方式一改时静默失效。
*/
function payErrorMessage(res: any) {
if (res?.data?.wechatCode === 'OUT_TRADE_NO_USED') {
return '该订单已在微信内发起过支付,请回到微信内的收银台继续完成付款。'
}
return res?.message || '发起支付失败,请稍后重试'
}
/**
* 向渠道发起(或重新发起)一次支付。
*
* - `ok` 拿到了支付信息
* - `auth` 登录态问题(401 已跳登录页 / 403 已给出提示)——调用方**不要再起查单**
* - `fail` 其它失败(渠道拒单、网络、没返回支付信息),原因已写进 `error`
*/
async function issuePayment(): Promise<'ok' | 'auth' | 'fail'> {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
const res: any = await $fetch('/api/tender/pay', {
method: 'POST',
body: { orderNo: orderNo(), payType: payType.value }
})
const auth = await handleAuthCode(res, options.redirect())
if (auth.handled) {
error.value = auth.message || ''
return 'auth'
}
if (res?.code !== 0) {
error.value = payErrorMessage(res)
return 'fail'
}
codeUrl.value = res.data?.codeUrl || ''
alipayForm.value = res.data?.alipayForm || ''
if (!codeUrl.value && !alipayForm.value) {
error.value = '未获取到支付信息,请稍后重试'
return 'fail'
}
return 'ok'
}
/**
* 「立即支付」。
*
* 没选方式、没有订单号、已过支付时限都不发:后端也会拒(它按"本单剩余窗口"算,剩余不足
* 一分钟即拒绝),前端先拦住只是省一次注定失败的请求。
*/
async function start() {
if (!payType.value || !orderNo() || timedOut.value) return
error.value = ''
starting.value = true
let issued: 'ok' | 'auth' | 'fail' = 'fail'
try {
issued = await issuePayment()
if (issued === 'ok') {
phase.value = 'code'
// 网页支付:立刻把表单送去新页面,用户不必再点一次
if (alipayForm.value) openAlipayForm()
}
} catch (e: any) {
error.value = e?.data?.message || e?.message || '发起支付失败,请稍后重试'
} finally {
starting.value = false
// 401 已经把我们送去登录页了,再起查单只会又打一次匿名请求(改造前这条路径也是不查单的)
if (issued !== 'auth') {
// 无论这次有没有拿到支付信息都重新开始查单:订单可能**已经**在渠道侧付过了
//(例如在微信内付掉、或上一次支付成功但页面被关掉)。上一次查单若因渠道单已关闭
// 而停过表,这里也顺带恢复——否则换了方式再付就再也没人查了。
startPolling()
}
}
}
/** 「重新获取」:对**已经锁定的那个方式**再要一次支付信息,不成为换方式的后门 */
async function refresh() {
if (!payType.value || !orderNo() || timedOut.value) return
error.value = ''
refreshing.value = true
let issued: 'ok' | 'auth' | 'fail' = 'fail'
try {
issued = await issuePayment()
if (issued === 'ok') {
phase.value = 'code'
if (alipayForm.value) openAlipayForm()
}
} catch (e: any) {
error.value = e?.data?.message || e?.message || '获取支付信息失败,请稍后重试'
} finally {
refreshing.value = false
if (issued !== 'auth') startPolling()
}
}
/**
* 「更换支付方式」:显式回到选择态。
*
* 清掉当前支付信息、**不自动重发**(重发等于又替买家决定了一次),但**不停查单**——
* 买家可能刚把上一张码付掉,停了就漏掉那笔钱。刚才的选择保留着,他只改想改的那一半。
*/
function changePayMethod() {
if (phase.value !== 'code') return
phase.value = 'select'
codeUrl.value = ''
alipayForm.value = ''
error.value = ''
}
/** 手动确认(扫码后的兜底):**也走查单**,不代替渠道下结论 */
async function confirm() {
error.value = ''
confirming.value = true
try {
const res = await queryPayStatus()
if (res.handled) {
error.value = res.message || ''
return
}
// 已付款:`paid` 被置真,面板据此通知页面(收银台跳订单列表 / 购标流程进完成态)
if (res.settled) return
error.value = res.message || `${channelLabel.value}侧还未收到付款,请完成支付后再试`
} finally {
confirming.value = false
}
}
/**
* 把支付宝返回的自提交表单写进一个新页面。
*
* 用 `document.write` 而不是 `v-html`:表单里带一段自动提交的脚本,而 `v-html` 插入的
* 脚本标签**不会执行**(HTML 规范如此),表单永远不会自动提交。写进新窗口则脚本正常执行,
* 且原页面留在本标签页继续轮询查单。
*
* 弹窗被拦截时不报"失败"——按钮复用本函数,用户允许弹窗后再点一次即可。
* 注意:本注释不要写出脚本标签的字面量——SFC 的 script 块会被它提前闭合。
*/
function openAlipayForm() {
if (!alipayForm.value) return
const w = window.open('', '_blank')
if (!w) {
error.value = '浏览器拦截了新窗口,请允许弹出窗口后点「前往支付宝付款」'
return
}
w.document.open()
w.document.write(alipayForm.value)
w.document.close()
error.value = ''
}
/**
* 开始查单轮询——**只查单,不发起支付**。
*
* 进页就开:这是认出"渠道侧其实已经付了、只是本地还没回写"那类订单的唯一路径。
* 改造前它挂在自动发起支付之后,现在发起点变成用户点按钮,它必须独立存在。
*/
function startPolling() {
if (!orderNo()) return
// 到期时刻传后端下发的那个:让轮询的终点与后端判定超时、渠道单失效对齐(ADR 0019)
beginQueryPolling(orderNo(), deadlineMs())
}
return {
// 状态
phase,
payType,
payOptions,
channelLabel,
qrAlt,
codeUrl,
alipayForm,
starting,
refreshing,
confirming,
error,
paid,
querying,
payError,
payExpired,
channelClosed,
remainMs,
remainText,
remainWarn,
timedOut,
// 动作
select,
start,
refresh,
changePayMethod,
confirm,
openAlipayForm,
startPolling
}
}