57543cfade
- 新增 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
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
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
|
||
}
|
||
}
|