feat(tender): 支付链接改为站内二维码,支付确认改走微信侧查单
- BuyDocument:支付步骤原先把后端 Native 下单返回的 codeUrl(weixin://wxpay/...)当明文 链接渲染成一个 <a target="_blank">,而 weixin:// 在浏览器里点击没有任何有效行为——标题写 着「请使用微信扫码支付」,页面上却没有二维码,PC 端 Native 支付实质不可用。改为渲染真正 的二维码,并删掉那个链接。 - HjcPayQrcode(新):uqr 站内生成,renderSVG() → data:image/svg+xml → <img :src>。不用 v-html(避开 vue/no-v-html),不用第三方外链(hjc-h5 用的 api.qrserver.com 在境内不可靠, 且等于把支付凭据交给第三方),也不改后端契约。 - useHjcPayStatus(新):支付结果一律以**微信侧**为准——轮询 pay-status(3s,页面隐藏暂停, 2 小时上限),手动「我已完成支付」按钮也走同一条查单通道。原先只有 mark-paid 一条路,而它 不向微信查单(HjcOrderController 的 javadoc 自陈「订单置为已支付只有 /mark-paid 一条路」), 用户一分钱不付、点一下就能把订单置为已支付并触发一站式推送。故 hjc-web 弃用它,并删除 server/api/tender/mark-paid.put.ts,不留这个入口给下一个人误用。 - 查单的另一好处:微信已支付而本地仍未支付时它会就地补写状态(幂等)。于是「付了钱但页面被 关掉/断网」这类漏单能被认出来——进入支付步骤即开始轮询,正是为了认出这种订单。 - 拿不到二维码时(例如租户支付配置错误)不再显示空白卡片,而是失败原因 +「重新获取二维码」。 微信 Native 码默认 2 小时且服务端不下发 expireTime,故按发起支付时刻本地计时,过期把二维码 置灰并提示重新获取;「重新获取二维码」重调 /api/tender/pay(同 out_trade_no 的 Native→ Native 重下微信允许)。 - 由此 hjc-web 与 hjc-h5/小程序端的支付确认语义**有意不一致**(那两端仍在用 mark-paid,改动 它们等于同时动三端线上支付路径)。理由与边界记在 ADR 0009。 - 依赖:新增 uqr;两份锁文件同步更新(pnpm 装,package-lock.json 同步)。 - 删除 BUY_TENDER_MAP_REPORT.md:530 行历史快照,结论与现状全面矛盾(称购买流程是桩函数、 /buy 不可用、没有登录流程、pay 代理路径写错),已实际误导过一次调研;历史价值留在 git 里。
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<img
|
||||
v-if="dataUrl"
|
||||
:src="dataUrl"
|
||||
:width="size"
|
||||
:height="size"
|
||||
:alt="alt"
|
||||
class="mx-auto block"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { renderSVG } from 'uqr'
|
||||
|
||||
/**
|
||||
* 支付二维码(纯展示)。内容是后端下发的**支付链接** `codeUrl`,不是网址——
|
||||
* 它只能被微信扫,点不开。见 CONTEXT.md 的「支付链接」与「支付二维码」。
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 要编码的内容(支付链接 `codeUrl`);为空时不渲染任何东西 */
|
||||
value?: string
|
||||
/** 边长(像素) */
|
||||
size?: number
|
||||
alt?: string
|
||||
}>(), {
|
||||
value: '',
|
||||
size: 240,
|
||||
alt: '微信支付二维码'
|
||||
})
|
||||
|
||||
/**
|
||||
* 输出 `data:` URL 而不是 `v-html`:
|
||||
* 内容是本地编码出的 SVG、不含任何外部引用,同时避开 `vue/no-v-html`。
|
||||
* 纠错级别取 M(约 15%),与主流收银台二维码一致——支付码是短串,密度代价很小。
|
||||
*/
|
||||
const dataUrl = computed(() => {
|
||||
const text = (props.value || '').trim()
|
||||
if (!text) return ''
|
||||
const svg = renderSVG(text, { border: 2, ecc: 'M' })
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 支付状态机(hjc-web 购标支付步骤用)。
|
||||
*
|
||||
* 唯一事实来源是**微信侧**:轮询 `GET /api/tender/pay-status`,由后端向微信查单后给出结论。
|
||||
* 不要退回 `mark-paid`——那个接口不查单,用户点一下就能把订单置为已支付(见 ADR 0009)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 微信 Native 支付码的有效期。
|
||||
*
|
||||
* 微信侧默认 2 小时,且**服务端不下发过期时间**(`PaymentResponse.wechatNative()` 不设 `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 跳登录页时带上,便于登录后回跳
|
||||
*/
|
||||
export function useHjcPayStatus(redirect: () => string) {
|
||||
const { handleAuthCode } = useHjcAuth()
|
||||
/**
|
||||
* 与 `orders.vue` 同理:服务端渲染期间发出的站内请求必须走 `useRequestFetch()`,
|
||||
* 否则代理拿不到浏览器 Cookie,会被后端按匿名调用判成 `code=401`,
|
||||
* 再经 `handleAuthCode` 变成"把自己登出"。客户端侧它等价于 `$fetch`,行为不变。
|
||||
*/
|
||||
const requestFetch = useRequestFetch()
|
||||
|
||||
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 = '该支付单在微信侧已关闭,请重新获取二维码'
|
||||
}
|
||||
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, start, stop, query }
|
||||
}
|
||||
@@ -51,14 +51,41 @@
|
||||
<div v-else-if="step === 'pay'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
|
||||
<h1 class="text-xl font-bold text-gray-900">请使用微信扫码支付</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">订单号:{{ orderNo }}</p>
|
||||
<p class="my-6 text-2xl font-bold text-blue-600">¥{{ formatMoney(totalAmount) }}</p>
|
||||
<div class="rounded-md bg-gray-50 p-4 text-sm break-all text-gray-700">
|
||||
扫码链接:<a :href="codeUrl" target="_blank" class="text-blue-600 hover:underline">{{ codeUrl }}</a>
|
||||
<p class="my-4 text-2xl font-bold text-blue-600">¥{{ formatMoney(totalAmount) }}</p>
|
||||
|
||||
<template v-if="codeUrl">
|
||||
<!-- 过期只是置灰提示,仍留在页面上:万一用户是刚过点就扫的,还能看见自己扫的是哪张码 -->
|
||||
<div :class="payExpired ? 'opacity-30' : ''">
|
||||
<HjcPayQrcode :value="codeUrl" :size="240" />
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-gray-500">请在 2 小时内用微信扫描上方二维码完成支付</p>
|
||||
<p v-if="payExpired" class="mt-2 text-sm text-amber-600">二维码已过期,请重新获取后再扫码</p>
|
||||
</template>
|
||||
<div v-else class="rounded-md bg-gray-50 px-4 py-6 text-sm text-gray-500">
|
||||
暂未获取到支付二维码,请点击下方按钮重新获取
|
||||
</div>
|
||||
<button class="mt-6 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700" :disabled="marking" @click="markPaid">
|
||||
{{ marking ? '确认中...' : '支付已完成,确认订单' }}
|
||||
|
||||
<button
|
||||
v-if="codeUrl"
|
||||
class="mt-6 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||
:disabled="confirming"
|
||||
@click="confirmPaid"
|
||||
>
|
||||
{{ confirming ? '确认中...' : '我已完成支付,确认订单' }}
|
||||
</button>
|
||||
<p v-if="error" class="mt-3 text-sm text-red-500">{{ error }}</p>
|
||||
<button
|
||||
class="mt-6 w-full rounded-md py-3 font-medium disabled:opacity-50"
|
||||
:class="codeUrl
|
||||
? 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'"
|
||||
:disabled="refreshing"
|
||||
@click="refreshQr"
|
||||
>
|
||||
{{ refreshing ? '获取中...' : (codeUrl ? '重新获取二维码' : '获取支付二维码') }}
|
||||
</button>
|
||||
|
||||
<p v-if="error || payError" class="mt-3 text-sm text-red-500">{{ error || payError }}</p>
|
||||
<p v-else-if="querying" class="mt-3 text-sm text-gray-400">正在确认支付状态...</p>
|
||||
</div>
|
||||
|
||||
<!-- 完成 -->
|
||||
@@ -84,7 +111,8 @@ const loggedIn = ref(false)
|
||||
const tender = shallowRef<Tender | null>(null)
|
||||
const notFound = ref(false)
|
||||
const submitting = ref(false)
|
||||
const marking = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const confirming = ref(false)
|
||||
const error = ref('')
|
||||
const step = ref<'form' | 'pay' | 'done'>('form')
|
||||
const orderNo = ref('')
|
||||
@@ -94,6 +122,24 @@ const totalAmount = ref(0)
|
||||
const form = reactive({ contactName: '', contactPhone: '', contactEmail: '', quantity: 1 })
|
||||
const redirect = computed(() => `/buy?id=${id}`)
|
||||
|
||||
/**
|
||||
* 支付确认一律以**微信侧**为准(轮询 + 手动按钮都走它)。
|
||||
* 这里刻意不再调用 `mark-paid`:那个接口不查单,用户点一下就能把订单置为已支付(见 ADR 0009)。
|
||||
*/
|
||||
const {
|
||||
paid,
|
||||
querying,
|
||||
error: payError,
|
||||
expired: payExpired,
|
||||
start: startPayPolling,
|
||||
query: queryPayStatus
|
||||
} = useHjcPayStatus(() => redirect.value)
|
||||
|
||||
/** 微信侧确认已付款后,页面自己走到完成态——用户不必再点任何按钮 */
|
||||
watch(paid, (v) => {
|
||||
if (v) step.value = 'done'
|
||||
})
|
||||
|
||||
function formatMoney(n?: number | string) {
|
||||
return Number(n ?? 0).toFixed(2)
|
||||
}
|
||||
@@ -126,19 +172,22 @@ async function submit() {
|
||||
orderNo.value = order.data?.orderNo || ''
|
||||
totalAmount.value = order.data?.totalAmount || 0
|
||||
// 发起支付
|
||||
const pay: any = await $fetch('/api/tender/pay', { method: 'POST', body: { orderNo: orderNo.value } })
|
||||
const payAuth = await handleAuthCode(pay, redirect.value)
|
||||
const payRes: any = await $fetch('/api/tender/pay', { method: 'POST', body: { orderNo: orderNo.value } })
|
||||
const payAuth = await handleAuthCode(payRes, redirect.value)
|
||||
if (payAuth.handled) {
|
||||
error.value = payAuth.message || ''
|
||||
return
|
||||
}
|
||||
// 订单已创建:即使发起支付失败也停在支付步骤,避免用户重提交产生重复订单
|
||||
step.value = 'pay'
|
||||
if (pay?.code === 0) {
|
||||
codeUrl.value = pay.data?.codeUrl || ''
|
||||
if (payRes?.code === 0) {
|
||||
codeUrl.value = payRes.data?.codeUrl || ''
|
||||
} else {
|
||||
error.value = pay?.message || '发起支付失败,请稍后重试'
|
||||
error.value = payRes?.message || '发起支付失败,请稍后重试'
|
||||
}
|
||||
// 无论这次有没有拿到支付码都开始查单:订单可能**已经**在微信侧付过了
|
||||
//(例如上一次支付成功但页面被关掉),查单会把它认出来并直接进完成态。
|
||||
startPayPolling(orderNo.value)
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.message || e?.message || '下单失败'
|
||||
} finally {
|
||||
@@ -146,25 +195,61 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function markPaid() {
|
||||
/**
|
||||
* 手动确认(二维码扫不了时的兜底):**也走查单**,不代替微信下结论。
|
||||
* 微信侧没收到钱就明确这么说,而不是像改造前那样把它当成"已支付"。
|
||||
*/
|
||||
async function confirmPaid() {
|
||||
error.value = ''
|
||||
marking.value = true
|
||||
confirming.value = true
|
||||
try {
|
||||
const res: any = await $fetch('/api/tender/mark-paid', { method: 'PUT', body: { orderNo: orderNo.value } })
|
||||
const res = await queryPayStatus()
|
||||
if (res.handled) {
|
||||
// 401 已跳登录页;403 只提示
|
||||
error.value = res.message || ''
|
||||
return
|
||||
}
|
||||
if (res.settled) {
|
||||
step.value = 'done'
|
||||
return
|
||||
}
|
||||
error.value = res.message || '微信侧还未收到付款,请完成扫码支付后再试'
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新获取支付二维码。
|
||||
*
|
||||
* 微信允许对同一 `out_trade_no`(这里就是 hjc 订单号)重复发起 Native 下单,
|
||||
* 所以"刷新二维码"是重新调 `/api/tender/pay` 拿一张新码,并**重置 2 小时计时**。
|
||||
*/
|
||||
async function refreshQr() {
|
||||
error.value = ''
|
||||
refreshing.value = true
|
||||
try {
|
||||
const res: any = await $fetch('/api/tender/pay', { method: 'POST', body: { orderNo: orderNo.value } })
|
||||
const auth = await handleAuthCode(res, redirect.value)
|
||||
if (auth.handled) {
|
||||
error.value = auth.message || ''
|
||||
return
|
||||
}
|
||||
if (res?.code !== 0) {
|
||||
error.value = res?.message || '确认失败'
|
||||
error.value = res?.message || '获取支付二维码失败,请稍后重试'
|
||||
return
|
||||
}
|
||||
step.value = 'done'
|
||||
codeUrl.value = res.data?.codeUrl || ''
|
||||
if (res.data?.amount) totalAmount.value = Number(res.data.amount)
|
||||
if (!codeUrl.value) {
|
||||
error.value = '未获取到支付二维码,请稍后重试'
|
||||
return
|
||||
}
|
||||
startPayPolling(orderNo.value)
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.message || e?.message || '确认失败'
|
||||
error.value = e?.data?.message || e?.message || '获取支付二维码失败,请稍后重试'
|
||||
} finally {
|
||||
marking.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user