请使用微信扫码支付
订单号:{{ orderNo }}
-
¥{{ formatMoney(totalAmount) }}
-
- 扫码链接:
{{ codeUrl }}
+
¥{{ formatMoney(totalAmount) }}
+
+
+
+
+
+
+ 请在 2 小时内用微信扫描上方二维码完成支付
+ 二维码已过期,请重新获取后再扫码
+
+
+ 暂未获取到支付二维码,请点击下方按钮重新获取
-
@@ -84,7 +111,8 @@ const loggedIn = ref(false)
const tender = shallowRef
(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
}
}
diff --git a/package-lock.json b/package-lock.json
index c9a0d93..c6699b0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"ant-design-vue": "^4.2.6",
"dayjs": "^1.11.20",
"nuxt": "^4.2.2",
+ "uqr": "^0.1.3",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
diff --git a/package.json b/package.json
index 662d323..b2c27de 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"ant-design-vue": "^4.2.6",
"dayjs": "^1.11.20",
"nuxt": "^4.2.2",
+ "uqr": "^0.1.3",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 23f6861..76bb13d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,12 +20,19 @@ importers:
nuxt:
specifier: ^4.2.2
version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.5)(@vue/compiler-sfc@3.5.40)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.5(jiti@2.7.0))(ioredis@5.11.1)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(terser@5.49.0)(typescript@6.0.3)(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
+ uqr:
+ specifier: ^0.1.3
+ version: 0.1.3
vue:
specifier: ^3.5.26
version: 3.5.40(typescript@6.0.3)
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.40(typescript@6.0.3))
+ optionalDependencies:
+ '@oxc-parser/binding-darwin-arm64':
+ specifier: ^0.105.0
+ version: 0.105.0
devDependencies:
'@eslint/js':
specifier: ^9.39.2
@@ -51,10 +58,6 @@ importers:
typescript-eslint:
specifier: ^8.50.1
version: 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
- optionalDependencies:
- '@oxc-parser/binding-darwin-arm64':
- specifier: ^0.105.0
- version: 0.105.0
packages:
diff --git a/server/api/tender/mark-paid.put.ts b/server/api/tender/pay-status.get.ts
similarity index 52%
rename from server/api/tender/mark-paid.put.ts
rename to server/api/tender/pay-status.get.ts
index 72dc06e..89a9793 100644
--- a/server/api/tender/mark-paid.put.ts
+++ b/server/api/tender/pay-status.get.ts
@@ -1,37 +1,39 @@
import { $fetch } from 'ofetch'
-import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
+import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
- * 标记订单已支付(幂等)并触发一站式推送
- * PUT /api/tender/mark-paid { orderNo }
- * 代理到 mp-api /api/hjc/order/mark-paid;需登录态。
+ * 查询微信侧支付状态(已支付则后端顺带修复本地订单状态)
+ * GET /api/tender/pay-status?orderNo=xxx
+ * 代理到 mp-api /api/hjc/order/pay-status/{orderNo};需登录态。
+ *
+ * 这是 hjc-web **唯一**的支付确认通道:它向微信查单,以微信侧的结论为准。
+ * 不要退回 mark-paid——那个接口不查单,点一下就能把订单置为已支付(见 ADR 0009)。
*
* 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
- const body = await readBody(event)
+ const { orderNo } = getQuery(event)
const cookieToken = getCookie(event, 'hjc_token')
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
try {
- return await $fetch('/hjc/order/mark-paid', {
+ return await $fetch(`/hjc/order/pay-status/${encodeURIComponent(String(orderNo ?? ''))}`, {
baseURL: config.public.modulesApiBase,
- method: 'PUT',
+ method: 'GET',
headers: {
TenantId: ctx.tenantId,
...(auth ? { Authorization: auth } : {})
},
- query: { TenantId: ctx.tenantId },
- body
+ query: { TenantId: ctx.tenantId }
})
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
- statusMessage: error?.statusMessage || 'Failed to mark paid'
+ statusMessage: error?.statusMessage || 'Failed to query pay status'
})
}
})