From e3f412c5474311a471345ea508db4875c9ec648e Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 17 九月 2026 15:47:59 +0800
Subject: [PATCH] feat(payment): 支付按支付尝试收敛,取消与放弃以关单回执为准
---
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java | 343 ++++++++++++++-
rencaika/pages/payment-confirm/payment-confirm.uvue | 79 +++
rencaika/pages/pending-payment/pending-payment.uvue | 144 +++++
talent-card-server/scripts/dev-schema-v149-payment-attempt.sql | 20
talent-card-server/src/main/resources/db/migration/V149__payment_attempt_convergence.sql | 40 +
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java | 87 +++
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderAbandonRequest.java | 12
talent-card-server/scripts/apply-sql-file.ps1 | 80 +++
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalController.java | 32 +
talent-card-server/scripts/ApplySqlFile.java | 117 +++++
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java | 5
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingUsePortalService.java | 22
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSettlementTests.java | 2
rencaika/services/portal.uts | 8
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java | 33 +
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentIssueRecorder.java | 18
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java | 214 ++++++++-
17 files changed, 1,135 insertions(+), 121 deletions(-)
diff --git a/rencaika/pages/payment-confirm/payment-confirm.uvue b/rencaika/pages/payment-confirm/payment-confirm.uvue
index 222c8df..c8557a6 100644
--- a/rencaika/pages/payment-confirm/payment-confirm.uvue
+++ b/rencaika/pages/payment-confirm/payment-confirm.uvue
@@ -40,8 +40,8 @@
<text class="actual-label">实际支付</text>
</view>
- <view class="confirm-button" hover-class="confirm-button-active" @tap="confirmPayment">
- <text>确认支付</text>
+ <view class="confirm-button" :class="isSubmitting ? 'confirm-button-disabled' : ''" hover-class="confirm-button-active" @tap="confirmPayment">
+ <text>{{isSubmitting ? '支付中...' : '确认支付'}}</text>
</view>
<view class="safe-bottom"></view>
</view>
@@ -51,7 +51,7 @@
<script setup lang="uts">
import { safeBack } from '../../utils/navigation.uts'
- import { createAppOrder, getOrderCouponQuote } from '../../services/portal.uts'
+ import { abandonPendingOrderPayment, createAppOrder, createPendingOrderPayment, getOrderCouponQuote } from '../../services/portal.uts'
import { resolveApiUrl } from '../../services/http.uts'
import { getSession } from '../../services/session.uts'
@@ -180,25 +180,87 @@
const selectedCouponId: any = uni.getStorageSync(couponStorageKey())
const couponId = selectedCouponId != null ? '' + selectedCouponId : ''
createAppOrder(merchantId.value, productId.value, couponId, createOrderIdempotencyKey.value, (data: any) => {
- isSubmitting.value = false
uni.removeStorageSync(couponStorageKey())
const realOrderNo = data != null && data.orderNo != null ? '' + data.orderNo : ''
if (realOrderNo == '') {
+ isSubmitting.value = false
uni.showToast({ title: '订单创建失败,请稍后重试', icon: 'none' })
return
}
if (data != null && data.paid === true) {
+ isSubmitting.value = false
uni.redirectTo({ url: '/pages/payment-success/payment-success?orderId=' + encodeURIComponent(realOrderNo) })
return
}
- uni.redirectTo({
- url: '/pages/pending-payment/pending-payment?orderId=' + encodeURIComponent(realOrderNo)
- + '&talentId=' + encodeURIComponent(talentId)
- })
+ // 一次点击即支付:用户既然点了「确认支付」,就不要再让他到第二页点同一个按钮——
+ // 建单之后立刻发起预支付并拉起收银台,只有放弃或失败才落到待支付页。
+ startPayment(realOrderNo, talentId, data != null && data.priceVersion != null ? Number(data.priceVersion) : 1)
}, (error) => {
isSubmitting.value = false
uni.showToast({ title: error.message, icon: 'none' })
})
+ }
+ const startPayment = (orderNo: string, talentId: string, priceVersion: number) => {
+ createPendingOrderPayment(orderNo, talentId, priceVersion, (data: any) => {
+ if (data != null && data.paymentStatus == 'PAID') {
+ openPaymentSuccess(orderNo)
+ return
+ }
+ if (data != null && data.paymentStatus == 'PAYMENT_PROCESSING') {
+ // 关单中或结果未定:落到待支付页由它等结论(那里会显示「支付处理中」并退避轮询)。
+ openPendingPayment(orderNo, talentId, true)
+ return
+ }
+ const params: any = data != null ? data.paymentParams : null
+ if (params == null || params.timeStamp == null || params.nonceStr == null || params.package == null || params.paySign == null) {
+ uni.showToast({ title: '支付参数获取失败,请在待支付页重试', icon: 'none' })
+ openPendingPayment(orderNo, talentId, false)
+ return
+ }
+ presentCashier(orderNo, talentId, params)
+ }, (error: any) => {
+ uni.showToast({ title: error.message, icon: 'none' })
+ openPendingPayment(orderNo, talentId, false)
+ })
+ }
+ const presentCashier = (orderNo: string, talentId: string, params: any) => {
+ // #ifdef MP-WEIXIN
+ uni.requestPayment({
+ provider: 'wxpay',
+ timeStamp: '' + params.timeStamp,
+ nonceStr: '' + params.nonceStr,
+ package: '' + params.package,
+ signType: params.signType != null ? '' + params.signType : 'RSA',
+ paySign: '' + params.paySign,
+ success: () => openPendingPayment(orderNo, talentId, true),
+ fail: (error: any) => {
+ const message = error != null && error.errMsg != null ? '' + error.errMsg : ''
+ if (message.indexOf('cancel') >= 0) {
+ // 用户关掉了收银台:告诉服务端一声,让它立刻尝试关单(服务端不据此判定收款)。
+ abandonPendingOrderPayment(orderNo, talentId, '', () => openPendingPayment(orderNo, talentId, false), () => openPendingPayment(orderNo, talentId, false))
+ return
+ }
+ uni.showToast({ title: '支付未完成,请在待支付页继续', icon: 'none' })
+ openPendingPayment(orderNo, talentId, false)
+ }
+ })
+ // #endif
+ // #ifndef MP-WEIXIN
+ uni.showToast({ title: '请在微信小程序中完成支付', icon: 'none' })
+ openPendingPayment(orderNo, talentId, false)
+ // #endif
+ }
+ const openPendingPayment = (orderNo: string, talentId: string, waitingPayment: boolean) => {
+ isSubmitting.value = false
+ uni.redirectTo({
+ url: '/pages/pending-payment/pending-payment?orderId=' + encodeURIComponent(orderNo)
+ + '&talentId=' + encodeURIComponent(talentId)
+ + (waitingPayment ? '&waiting=1' : '')
+ })
+ }
+ const openPaymentSuccess = (orderNo: string) => {
+ isSubmitting.value = false
+ uni.redirectTo({ url: '/pages/payment-success/payment-success?orderId=' + encodeURIComponent(orderNo) })
}
</script>
@@ -238,5 +300,6 @@
.confirm-button { display: flex; width: 514rpx; height: 86rpx; margin: 146rpx auto 0; border-radius: 45rpx; background-image: linear-gradient(90deg, #FF4C32 0%, #FF841C 100%); justify-content: center; align-items: center; }
.confirm-button text { color: #FFFFFF; font-size: 30rpx; font-weight: 600; }
.confirm-button-active { opacity: .82; transform: scale(.99); }
+ .confirm-button-disabled { opacity: .62; transform: none; }
.safe-bottom { height: 44rpx; height: calc(44rpx + env(safe-area-inset-bottom)); }
</style>
diff --git a/rencaika/pages/pending-payment/pending-payment.uvue b/rencaika/pages/pending-payment/pending-payment.uvue
index 87483c3..e3c42d7 100644
--- a/rencaika/pages/pending-payment/pending-payment.uvue
+++ b/rencaika/pages/pending-payment/pending-payment.uvue
@@ -43,9 +43,10 @@
<view v-if="!loading && !loadFailed" class="payment-footer">
<text class="actual-label">实际支付</text>
<text class="actual-amount">¥ {{actualPayAmountText}}</text>
- <text v-if="paymentChecking" class="payment-checking">支付结果确认中</text>
- <view class="pay-button" :class="isExpired || isSubmitting ? 'disabled' : ''" hover-class="pay-button-active" @tap="confirmPayment"><text>{{isExpired ? '订单已超时' : (isSubmitting ? '处理中' : '确认支付')}}</text></view>
- <view v-if="!isExpired && !isSubmitting" class="cancel-button" hover-class="cancel-button-active" @tap="cancelOrder"><text>取消订单</text></view>
+ <text v-if="statusHint != ''" class="payment-checking">{{statusHint}}</text>
+ <view class="pay-button" :class="payDisabled ? 'disabled' : ''" hover-class="pay-button-active" @tap="confirmPayment"><text>{{payButtonText}}</text></view>
+ <view v-if="cancelVisible" class="cancel-button" hover-class="cancel-button-active" @tap="cancelOrder"><text>取消订单</text></view>
+ <view v-if="refreshVisible" class="refresh-button" hover-class="cancel-button-active" @tap="refreshPaymentStatus"><text>刷新支付状态</text></view>
</view>
</view>
</template>
@@ -53,7 +54,7 @@
<script setup lang="uts">
import { apiEnvironment } from '../../config/api.uts'
import { safeBack } from '../../utils/navigation.uts'
- import { cancelPendingOrder, createPendingOrderPayment, getPendingOrder, getPendingOrderPaymentStatus } from '../../services/portal.uts'
+ import { abandonPendingOrderPayment, cancelPendingOrder, createPendingOrderPayment, getPendingOrder, getPendingOrderPaymentStatus } from '../../services/portal.uts'
const navTop = ref(28)
const navHeight = ref(32)
@@ -80,6 +81,11 @@
const remainingSeconds = ref(300)
const isExpired = ref(false)
const paymentChecking = ref(false)
+ // 关单中:用户已经点过取消,服务端正在把预支付单关掉,有关闭或已收款的结论时才落地。
+ // 「支付结果不确定」只约束它所属的那一次支付尝试,所以这里既不给永久报错,也不等于订单已取消。
+ const cancellationPending = ref(false)
+ // 支付处理中:这张预支付单还可能有结论(通道在处理),页面按可等待渲染并继续问。
+ const paymentProcessing = ref(false)
// 后续由待支付订单详情接口返回。true 为可选优惠券,false 保留后台返回的固定商品优惠。
const couponSelectable = ref(true)
const couponText = ref('无')
@@ -91,6 +97,23 @@
const discountAmountText = computed(() => discountAmount.value.toFixed(2))
const actualPayAmountText = computed(() => actualPayAmount.value.toFixed(2))
const subsidyAmountText = computed(() => subsidyAmount.value.toFixed(2))
+ const payDisabled = computed(() => isExpired.value || isSubmitting.value || cancellationPending.value || paymentProcessing.value)
+ const payButtonText = computed(() : string => {
+ if (cancellationPending.value) return '取消处理中'
+ if (paymentProcessing.value) return '支付处理中'
+ if (isSubmitting.value) return '处理中'
+ // 这个按钮在收银台放弃或失败之后才被用到,所以叫「继续支付」而不是「确认支付」。
+ return isExpired.value ? '订单已关闭' : '继续支付'
+ })
+ const statusHint = computed(() : string => {
+ if (cancellationPending.value) return '取消处理中,请稍候'
+ if (paymentProcessing.value) return '支付处理中,请稍候'
+ if (paymentChecking.value) return '正在确认支付结果'
+ if (isExpired.value) return '支付时间已结束,订单已关闭'
+ return ''
+ })
+ const cancelVisible = computed(() : boolean => !isExpired.value && !isSubmitting.value && !cancellationPending.value && !paymentProcessing.value)
+ const refreshVisible = computed(() : boolean => isExpired.value || cancellationPending.value || paymentProcessing.value)
const countdownText = computed(() => {
if (!hasExpiry.value) return '--:--'
const minutes = Math.floor(remainingSeconds.value / 60)
@@ -102,8 +125,16 @@
let countdownTimer = 0
let paymentPollingTimer = 0
let paymentPollingAttempts = 0
+ let paymentPollingElapsedMs = 0
let currentPaymentNo = ''
let expiryCancellationRequested = false
+ // 从「确认支付」页刚提交过一次支付(waiting=1):打开页面就直接进入等待结论的状态,
+ // 而不是先显示一次「继续支付」按钮让用户以为钱没付出去。
+ let waitingForSubmittedPayment = false
+ // 支付结果的等待:轮询退避到大约 45 秒,之后交给「刷新支付状态」。
+ // 微信支付通知正常 1–3 秒到达,失败大多是网络或通道慢,7.5 秒就把用户推去「使用记录」太短了。
+ const PAYMENT_POLLING_BUDGET_MS = 45000
+ const PAYMENT_POLLING_MAX_DELAY_SECONDS = 10
onLoad((options: any) => {
// #ifdef MP-WEIXIN
@@ -116,6 +147,7 @@
// #endif
if (options != null && options['orderId'] != null && options['orderId'] != '') orderId.value = options['orderId'] as string
talentId.value = options != null && options['talentId'] != null ? options['talentId'] as string : (apiEnvironment == 'development' ? '2' : '')
+ waitingForSubmittedPayment = options != null && options['waiting'] == '1'
loadOrder()
})
onShow(() => {
@@ -171,7 +203,9 @@
stopCountdown()
}
// 回调没到达时「待支付」只说明本地不知道。打开页面时问一次通道,钱已经收下就进支付成功页。
- if (data.canPay === true) checkPaymentStatus(false)
+ // 刚提交过一次支付(waiting=1)时继续等结论:微信支付通知正常 1–3 秒到达,
+ // 退避轮询到时间上限再让用户手动刷新,不再 7.5 秒就把人推去「使用记录」。
+ if (data.canPay === true) checkPaymentStatus(waitingForSubmittedPayment)
}, (error: any) => { loading.value = false; loadFailed.value = true; loadErrorText.value = error.message })
}
const startCountdown = () => {
@@ -193,6 +227,32 @@
if (paymentPollingTimer == 0) return
clearTimeout(paymentPollingTimer)
paymentPollingTimer = 0
+ }
+ // 退避轮询:先 1 秒,再一次 2 秒、3 秒……单次最长 10 秒,累计约 45 秒后停下让用户手动刷新。
+ // 服务端每次都会真去通道问(主动查询为权威),所以继续等是有意义的,不是空等。
+ const schedulePaymentPoll = () => {
+ stopPaymentPolling()
+ paymentPollingAttempts += 1
+ const delaySeconds = Math.min(paymentPollingAttempts, PAYMENT_POLLING_MAX_DELAY_SECONDS)
+ paymentPollingElapsedMs += delaySeconds * 1000
+ if (paymentPollingElapsedMs > PAYMENT_POLLING_BUDGET_MS) {
+ paymentChecking.value = false
+ isSubmitting.value = false
+ return
+ }
+ paymentChecking.value = true
+ paymentPollingTimer = setTimeout(() => checkPaymentStatus(true), delaySeconds * 1000)
+ }
+ const refreshPaymentStatus = () => {
+ if (isExpired.value && !cancellationPending.value && !paymentProcessing.value) {
+ loadOrder()
+ return
+ }
+ // 手动刷新重新给一次等待预算:服务端每次都会真去通道问,再等一轮是有意义的。
+ stopPaymentPolling()
+ paymentPollingAttempts = 0
+ paymentPollingElapsedMs = 0
+ checkPaymentStatus(true)
}
const expireOrder = () => {
if (expiryCancellationRequested || orderId.value == '' || talentId.value == '') {
@@ -219,13 +279,14 @@
uni.navigateTo({ url: '/pages/coupon-select/coupon-select?source=pending-payment&orderId=' + encodeURIComponent(orderId.value) + '&talentId=' + encodeURIComponent(talentId.value) + '&merchantId=' + encodeURIComponent(merchantId.value) + '&productId=' + encodeURIComponent(productId.value) + '&priceVersion=' + priceVersion.value })
}
const confirmPayment = () => {
- if (isSubmitting.value) return
+ if (isSubmitting.value || cancellationPending.value || paymentProcessing.value) return
if (isExpired.value) {
- uni.showToast({ title: '订单已超时,请重新下单', icon: 'none' })
+ uni.showToast({ title: '订单已关闭,请重新下单', icon: 'none' })
return
}
stopPaymentPolling()
paymentPollingAttempts = 0
+ paymentPollingElapsedMs = 0
paymentChecking.value = false
isSubmitting.value = true
createPendingOrderPayment(orderId.value, talentId.value, priceVersion.value, (data: any) => {
@@ -235,10 +296,15 @@
return
}
if (data != null && data.paymentStatus == 'PAYMENT_PROCESSING') {
+ // 关单中或结果未定:服务端会继续问通道,页面按可等待渲染并退避轮询,
+ // 而不是抛一句「支付创建确认中」让用户自己去猜。
isSubmitting.value = false
- uni.showToast({ title: '支付创建确认中,请稍后查看订单状态', icon: 'none' })
+ paymentProcessing.value = true
+ schedulePaymentPoll()
return
}
+ paymentProcessing.value = false
+ // 服务端已持有一张可复用的预支付单时直接把参数交回来,这里不需要再建单。
const params: any = data != null ? data.paymentParams : null
if (params == null || params.timeStamp == null || params.nonceStr == null || params.package == null || params.paySign == null) {
isSubmitting.value = false
@@ -252,15 +318,25 @@
})
}
const cancelOrder = () => {
- if (isSubmitting.value || isExpired.value) return
+ if (isSubmitting.value || isExpired.value || cancellationPending.value) return
uni.showModal({
title: '取消订单',
content: '确定取消当前订单吗?',
success: (result: any) => {
if (result.confirm !== true) return
isSubmitting.value = true
- cancelPendingOrder(orderId.value, talentId.value, () => {
+ cancelPendingOrder(orderId.value, talentId.value, (data: any) => {
isSubmitting.value = false
+ // 关单没有结论时服务端不会取消订单,只把取消意图记下来(cancellationPending):
+ // 页面显示「取消处理中」,后台每分钟重试关单,有关闭或已收款的结论时再落地。
+ if (data != null && data.cancellationPending === true) {
+ cancellationPending.value = true
+ stopPaymentPolling()
+ paymentPollingAttempts = 0
+ paymentPollingElapsedMs = 0
+ schedulePaymentPoll()
+ return
+ }
stopCountdown()
uni.redirectTo({ url: '/pages/order-cancelled/order-cancelled?orderId=' + encodeURIComponent(orderId.value) + '&talentId=' + encodeURIComponent(talentId.value) })
}, (error: any) => {
@@ -285,9 +361,26 @@
success: () => checkPaymentStatus(true),
fail: (error: any) => {
const message = error != null && error.errMsg != null ? '' + error.errMsg : ''
+ // 用户关掉收银台(errMsg 含 cancel)时告诉服务端一声,让它立刻尝试关单。
+ // 这只是个信号:服务端不据此判定收款,钱收没收下以通道回执为准。
+ if (message.indexOf('cancel') >= 0) {
+ abandonPendingOrderPayment(orderId.value, talentId.value, currentPaymentNo, (data: any) => {
+ isSubmitting.value = false
+ if (data != null && data.paymentStatus == 'PAYMENT_PROCESSING') {
+ paymentProcessing.value = true
+ schedulePaymentPoll()
+ return
+ }
+ uni.showToast({ title: '已取消支付', icon: 'none' })
+ }, () => {
+ isSubmitting.value = false
+ uni.showToast({ title: '已取消支付', icon: 'none' })
+ })
+ return
+ }
+ isSubmitting.value = false
+ uni.showToast({ title: '支付未完成,请稍后查看订单状态', icon: 'none' })
checkPaymentStatus(false)
- if (message.indexOf('cancel') >= 0) uni.showToast({ title: '已取消支付', icon: 'none' })
- else uni.showToast({ title: '支付未完成,请稍后查看订单状态', icon: 'none' })
}
})
// #endif
@@ -302,20 +395,31 @@
openPaymentSuccess()
return
}
- if (data != null && data.paymentStatus == 'PAYMENT_PENDING' && pollWhenPending && paymentPollingAttempts < 5) {
- paymentChecking.value = true
- paymentPollingAttempts += 1
- stopPaymentPolling()
- paymentPollingTimer = setTimeout(() => checkPaymentStatus(true), 1500)
+ if (data != null && data.paymentStatus == 'PAYMENT_PROCESSING') {
+ // 关单中/结果未定:这是可等待的状态,不是错误。继续退避轮询到有结论或到达时间上限。
+ isSubmitting.value = false
+ paymentProcessing.value = true
+ if (pollWhenPending) schedulePaymentPoll()
+ else paymentChecking.value = false
+ return
+ }
+ paymentProcessing.value = false
+ if (data != null && data.paymentStatus == 'PAYMENT_PENDING' && pollWhenPending) {
+ schedulePaymentPoll()
return
}
paymentChecking.value = false
isSubmitting.value = false
- if (pollWhenPending) uni.showToast({ title: '支付结果确认中,请稍后在订单中查看', icon: 'none' })
+ if (data != null && data.paymentStatus == 'CANCELLED') {
+ // 本地流水已关闭/关不掉:订单本身可能仍是待支付,重新读一次订单就能拿到最新状态。
+ loadOrder()
+ return
+ }
+ if (pollWhenPending) uni.showToast({ title: '支付结果还没确认,可点「刷新支付状态」再查一次', icon: 'none' })
}, () => {
paymentChecking.value = false
isSubmitting.value = false
- if (pollWhenPending) uni.showToast({ title: '支付状态查询失败,请稍后在订单中查看', icon: 'none' })
+ if (pollWhenPending) uni.showToast({ title: '支付状态查询失败,可点「刷新支付状态」重试', icon: 'none' })
})
}
const openPaymentSuccess = () => {
@@ -378,4 +482,6 @@
.pay-button.disabled { background-image: none; background-color: #C7CBD1; }
.cancel-button { display: flex; min-width: 160rpx; height: 58rpx; margin-top: 18rpx; border-width: 1rpx; border-style: solid; border-color: #FF6D32; border-radius: 30rpx; justify-content: center; align-items: center; }
.cancel-button text { color: #FF6730; font-size: 25rpx; font-weight: 600; } .cancel-button-active { opacity: .55; }
+ .refresh-button { display: flex; min-width: 160rpx; height: 58rpx; margin-top: 18rpx; border-width: 1rpx; border-style: solid; border-color: #C7CBD1; border-radius: 30rpx; justify-content: center; align-items: center; }
+ .refresh-button text { color: #6B7280; font-size: 25rpx; font-weight: 600; }
</style>
diff --git a/rencaika/services/portal.uts b/rencaika/services/portal.uts
index b7bb011..4e3d6f2 100644
--- a/rencaika/services/portal.uts
+++ b/rencaika/services/portal.uts
@@ -302,6 +302,14 @@
return request<any>({ path: getApiEndpoint('appPendingOrders') + encodeURIComponent(orderNo) + '/payment', method: 'GET', data: data }, onSuccess, onError)
}
+// 「放弃支付」:用户在收银台里点了关闭时告诉服务端。服务端只据此触发关单、不据此判定收款,
+// 所以调用方不要把它的返回当成支付结论(页面照常按待支付渲染,真正的结论由支付状态查询给出)。
+export const abandonPendingOrderPayment = (orderNo: string, talentId: string, paymentNo: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
+ const data: any = { talentId: Number(talentId) }
+ if (paymentNo != '') data.paymentNo = paymentNo
+ return request<any>({ path: getApiEndpoint('appPendingOrders') + encodeURIComponent(orderNo) + '/payment/abandon', method: 'POST', data: data }, onSuccess, onError)
+}
+
export const getPendingUseOrder = (orderNo: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appPendingUseOrders') + encodeURIComponent(orderNo), method: 'GET', skipAuth: true, data: { talentId: talentId } }, onSuccess, onError)
export const getCompletedOrder = (orderNo: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appCompletedOrders') + encodeURIComponent(orderNo), method: 'GET', skipAuth: true, data: { talentId: talentId } }, onSuccess, onError)
export const getCancelledOrder = (orderNo: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appCancelledOrders') + encodeURIComponent(orderNo), method: 'GET', skipAuth: true, data: { talentId: talentId } }, onSuccess, onError)
diff --git a/talent-card-server/scripts/ApplySqlFile.java b/talent-card-server/scripts/ApplySqlFile.java
new file mode 100644
index 0000000..15f2133
--- /dev/null
+++ b/talent-card-server/scripts/ApplySqlFile.java
@@ -0,0 +1,117 @@
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Executes a .sql file statement by statement, so a schema change can be applied to a database whose
+ * structure Flyway does not maintain (the shared dev database runs with {@code flyway.enabled=false}).
+ *
+ * <p>Run through {@code scripts/apply-sql-file.ps1}; this program exists because the checkout has no
+ * MySQL client and no Docker, while the JDBC driver is already required by the application.
+ *
+ * <p>It understands the client-side {@code DELIMITER} directive, so the very same
+ * {@code db/migration/V*.sql} file can be replayed by hand when a deployment skipped it.
+ */
+public final class ApplySqlFile {
+
+ public static void main(String[] args) throws Exception {
+ if (args.length < 4) {
+ System.err.println("usage: ApplySqlFile <jdbcUrl> <user> <password> <sqlFile> [--dry-run]");
+ System.exit(2);
+ }
+ boolean dryRun = args.length > 4 && "--dry-run".equals(args[4].trim());
+ String url = args[0];
+ String user = args[1];
+ String password = args[2];
+ Path file = Path.of(args[3]);
+ if (!Files.isReadable(file)) {
+ System.err.println("Cannot read " + file.toAbsolutePath());
+ System.exit(2);
+ }
+
+ List<String> statements = split(Files.readString(file, StandardCharsets.UTF_8));
+ if (statements.isEmpty()) {
+ System.out.println("No statements in " + file.getFileName() + "; nothing to do.");
+ return;
+ }
+ if (dryRun) {
+ for (String sql : statements) System.out.println("would execute: " + firstLine(sql));
+ System.out.println("dry run: " + statements.size()
+ + " statement(s) in " + file.getFileName() + " were not executed");
+ return;
+ }
+
+ int executed = 0;
+ try (Connection connection = DriverManager.getConnection(url, user, password);
+ Statement statement = connection.createStatement()) {
+ for (String sql : statements) {
+ System.out.println("executing: " + firstLine(sql));
+ statement.execute(sql);
+ executed++;
+ }
+ } catch (SQLException exception) {
+ System.err.println("failed after " + executed + " of " + statements.size() + " statements: "
+ + exception.getMessage());
+ throw exception;
+ }
+ System.out.println("applied " + executed + " statement(s) from " + file.getFileName());
+ }
+
+ /**
+ * Splits a script into statements, honouring {@code DELIMITER} and skipping line comments.
+ *
+ * <p>Statements are cut at the active terminator only when it is outside quotes, because dollar
+ * quoted procedure bodies and Chinese descriptions legitimately contain semicolons.
+ */
+ static List<String> split(String script) {
+ List<String> statements = new ArrayList<>();
+ StringBuilder current = new StringBuilder();
+ String delimiter = ";";
+ char quote = 0;
+ for (String rawLine : script.split("\r?\n", -1)) {
+ String line = rawLine;
+ String trimmed = line.trim();
+ if (quote == 0 && (trimmed.startsWith("--") || trimmed.startsWith("#"))) continue;
+ if (quote == 0 && trimmed.regionMatches(true, 0, "DELIMITER", 0, "DELIMITER".length())) {
+ delimiter = trimmed.substring("DELIMITER".length()).trim();
+ continue;
+ }
+ for (int index = 0; index < line.length(); index++) {
+ char character = line.charAt(index);
+ if (quote != 0) {
+ if (character == quote) quote = 0;
+ } else if (character == '\'' || character == '"' || character == '`') {
+ quote = character;
+ }
+ current.append(character);
+ if (quote == 0 && endsWith(current, delimiter)) {
+ current.setLength(current.length() - delimiter.length());
+ String sql = current.toString().trim();
+ if (!sql.isEmpty()) statements.add(sql);
+ current.setLength(0);
+ }
+ }
+ current.append('\n');
+ }
+ String tail = current.toString().trim();
+ if (!tail.isEmpty()) statements.add(tail);
+ return statements;
+ }
+
+ private static boolean endsWith(CharSequence value, String suffix) {
+ return suffix.length() > 0 && value.length() >= suffix.length()
+ && value.toString().endsWith(suffix);
+ }
+
+ private static String firstLine(String sql) {
+ int newline = sql.indexOf('\n');
+ String head = (newline < 0 ? sql : sql.substring(0, newline)).trim();
+ return head.length() > 120 ? head.substring(0, 120) + " ..." : head;
+ }
+}
diff --git a/talent-card-server/scripts/apply-sql-file.ps1 b/talent-card-server/scripts/apply-sql-file.ps1
new file mode 100644
index 0000000..58e102a
--- /dev/null
+++ b/talent-card-server/scripts/apply-sql-file.ps1
@@ -0,0 +1,80 @@
+# Executes a .sql file against a database whose structure Flyway does not maintain.
+#
+# Why this exists: the shared dev database (talent_card) runs with spring.flyway.enabled=false, and
+# its structure is not maintained by the migrations. Only the merchant-onboarding tables are patched
+# by a startup initializer, so a new column on another table has to be applied by hand or the first
+# request that touches it fails with "Unknown column ...". See docs/agents/testing.md.
+#
+# Usage:
+# .\scripts\apply-sql-file.ps1 -File scripts\dev-schema-v149-payment-attempt.sql -Yes
+# .\scripts\apply-sql-file.ps1 -File src\main\resources\db\migration\V149__payment_attempt_convergence.sql -Yes
+# .\scripts\apply-sql-file.ps1 -File <file> # prints the statements without executing
+#
+# The target defaults to the shared dev database. It is a shared database, so the script executes
+# nothing unless -Yes is given, and it prints the exact target and statements first.
+#
+# Do NOT point this at the test database: that schema is rebuilt from db/migration by the test run,
+# and hand-applied DDL there makes the migrations and the history table disagree.
+param(
+ [Parameter(Mandatory = $true)][string] $File,
+ [switch] $Yes,
+ [string] $Database = 'talent_card',
+ [string] $DatabaseHost = '192.168.110.13',
+ [int] $Port = 3306,
+ [string] $User = 'root',
+ [string] $Password = '123456'
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Find-MySqlDriver {
+ $roots = @()
+ if ($env:MAVEN_REPO_LOCAL) { $roots += $env:MAVEN_REPO_LOCAL }
+ $roots += (Join-Path $env:USERPROFILE '.m2\repository')
+ foreach ($root in $roots) {
+ if (-not $root -or -not (Test-Path $root)) { continue }
+ # 本地仓库里是旧 artifactId(mysql-connector-java-8.0.23.jar),上游后来改名为
+ # mysql-connector-j,两个都要能匹配到。
+ $jar = Get-ChildItem -Path $root -Recurse -Filter 'mysql-connector-*.jar' -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -notmatch 'sources|javadoc' } |
+ Sort-Object FullName -Descending |
+ Select-Object -First 1
+ if ($jar) { return $jar.FullName }
+ }
+ return $null
+}
+
+function Get-JavaExecutable {
+ $candidates = @()
+ if ($env:JAVA_HOME) { $candidates += (Join-Path $env:JAVA_HOME 'bin\java.exe') }
+ $candidates += 'java'
+ foreach ($candidate in $candidates) {
+ $command = Get-Command $candidate -ErrorAction SilentlyContinue
+ if ($command) { return $command.Source }
+ }
+ throw 'No java executable found. Set JAVA_HOME or add java to PATH.'
+}
+
+$script = Join-Path $PSScriptRoot $File
+if (-not (Test-Path $script)) { $script = (Resolve-Path $File -ErrorAction SilentlyContinue) }
+if (-not $script -or -not (Test-Path $script)) { throw "SQL file not found: $File" }
+
+$driver = Find-MySqlDriver
+if (-not $driver) {
+ throw 'mysql-connector-j was not found in the Maven local repository. Run ".\mvnw.cmd -o test-compile" once so Maven downloads the project dependencies.'
+}
+$java = Get-JavaExecutable
+$url = "jdbc:mysql://${DatabaseHost}:${Port}/${Database}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false"
+$program = Join-Path $PSScriptRoot 'ApplySqlFile.java'
+
+Write-Host "Target: ${Database} on ${DatabaseHost}:${Port} (host/port/database are all overridable)" -ForegroundColor Cyan
+Write-Host "File: $script" -ForegroundColor Cyan
+
+if (-not $Yes) {
+ Write-Host 'Dry run: the statements are printed but not executed. Add -Yes to execute.' -ForegroundColor Yellow
+ & $java --class-path $driver $program $url $User $Password $script '--dry-run'
+ exit $LASTEXITCODE
+}
+
+& $java --class-path $driver $program $url $User $Password $script
+if ($LASTEXITCODE -ne 0) { throw "Apply failed (exit $LASTEXITCODE). Driver: $driver" }
diff --git a/talent-card-server/scripts/dev-schema-v149-payment-attempt.sql b/talent-card-server/scripts/dev-schema-v149-payment-attempt.sql
new file mode 100644
index 0000000..19ae7b6
--- /dev/null
+++ b/talent-card-server/scripts/dev-schema-v149-payment-attempt.sql
@@ -0,0 +1,20 @@
+-- 开发库(局域网共享的 talent_card,MySQL 5.7,Flyway 关闭)手工补齐 V149 的两列。
+--
+-- 为什么需要这份文件:开发库的结构不由迁移维护,而现有启动初始化器只补商户进件相关的表,
+-- 不覆盖 payment_transaction。只写迁移会让开发库缺列,对账任务与支付接口直接以
+-- Unknown column 500(见 docs/agents/testing.md 记录的同类事故)。
+--
+-- 与 V149__payment_attempt_convergence.sql 逐列等价,只是不含存储过程(JDBC 无法直接执行
+-- DELIMITER 批处理),所以只能执行一次:重复执行会在「列已存在」上报错,这是安全的失败。
+--
+-- 执行方式(本机没有 mysql 客户端,用项目已有的 JDBC 驱动):
+-- cd talent-card-server
+-- .\scripts\apply-sql-file.ps1 -File scripts\dev-schema-v149-payment-attempt.sql
+
+alter table payment_transaction
+ add column last_reconciled_at datetime default null comment '最近一次对账处理时间(公平轮转与退避依据)';
+
+alter table payment_transaction
+ add column close_attempts int not null default 0 comment '已发出的关单请求次数(重试上限依据)';
+
+create index idx_payment_transaction_reconcile on payment_transaction (status, last_reconciled_at);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
index 8f2225b..3be239c 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
@@ -46,13 +46,23 @@
}
public Map<String, Object> beginMiniProgramPayment(String orderNo, Long talentId, int priceVersion, String idempotencyKey) {
- // 上一次支付可能已经被通道收下、回调却没到。那笔钱必须先落成本地已支付:否则这里会再发起一次
- // 支付(同一笔订单付第二次),或者把用户送进「订单已经支付,请勿重复发起支付」的死胡同。
+ // 复用优先:本地已经持有这张订单的预支付单的支付参数、订单又还待支付时,直接把它交回客户端。
+ // 这里刻意**不查通道**:查通道只会得到「处理中」这种没有结论的答复,而本地已有可用凭证——
+ // 拿这个没有结论的答复把整笔订单停摆,正是用户报的死锁(见 docs/adr/0003 的 D3)。
+ Map<String, Object> reusable = reusablePrepay(orderNo, talentId);
+ if (reusable != null) {
+ return pendingResult(string(reusable.get("paymentNo")), string(reusable.get("clientPaymentPayload")));
+ }
+
+ // 本地没有可复用的凭证时,通道是唯一知道钱有没有收下的地方。上一次支付可能已经被通道收下、
+ // 回调却没到:那笔钱必须先落成本地已支付,否则这里会再发起一次支付(同一笔订单付第二次),
+ // 或者把用户送进「订单已经支付,请勿重复发起支付」的死胡同。
PendingPaymentConfirmation confirmation = reconciliationService.confirmPendingPayments(orderNo);
if (confirmation.collected()) return completedResult(confirmation.paymentNo());
- if (confirmation.state() == PendingPaymentState.UNCONFIRMED) {
- throw new BusinessException(ErrorCode.CONFLICT, "支付结果确认中,请稍后再试");
- }
+ // 通道答不出「钱收没收下」时不再抛错:那个不确定只属于这一次支付尝试,不该把用户堵在一句
+ // 看不懂的报错上。返回可等待状态,页面渲染成「支付处理中,请稍候」。
+ if (confirmation.state() == PendingPaymentState.UNCONFIRMED) return processingResult(confirmation.paymentNo());
+
PreparedPayment prepared = transactions.execute(status -> prepare(orderNo, talentId, priceVersion, idempotencyKey));
if (prepared == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "支付初始化失败");
if (prepared.completed()) return completedResult(prepared.paymentNo());
@@ -86,6 +96,53 @@
String clientPayload = writeJson(clientPayload(response));
transactions.executeWithoutResult(status -> markPrepayCreated(prepared.paymentNo(), responseCode(response), responseText(response, ""), clientPayload));
return pendingResult(prepared.paymentNo(), clientPayload);
+ }
+
+ /**
+ * 本地是否还持有这张订单的预支付单的支付参数。
+ *
+ * <p>四个条件缺一不可:订单仍待支付且未过期、流水属于「本地尚未定局」的状态、支付参数还在本地、
+ * 流水金额与订单当前实付一致。金额必须相等是因为预支付单是按金额建的:改了优惠券之后再拿旧单
+ * 去收款会收错钱。流水上没有记 {@code price_version},金额相等是这里能拿到的最强判据。
+ *
+ * <p>{@code CLOSING}(关单中)与 {@code CANCEL_UNCONFIRMED}(关不掉的预支付单)同样在可复用之列:
+ * 它们正是「通道侧这张单还可能被支付」的状态,此时新建一张预支付单等于同一笔订单可能被付两次
+ * (D2),所以只能复用。
+ */
+ private Map<String, Object> reusablePrepay(String orderNo, Long talentId) {
+ List<Map<String, Object>> rows = rows("""
+ select p.payment_no, p.client_payment_payload
+ from payment_transaction p
+ join benefit_order o on o.id = p.order_id
+ where p.provider = ? and p.order_no = ? and o.talent_id = ?
+ and o.deleted = 0 and o.order_status = 0
+ and (o.expires_at is null or o.expires_at > now())
+ and p.status in ('PREPAY_CREATED', 'CLOSING', 'CANCEL_UNCONFIRMED')
+ and coalesce(p.client_payment_payload, '') <> ''
+ and p.amount = o.pay_amount
+ order by p.id desc
+ limit 1
+ """, PROVIDER, orderNo, talentId);
+ return rows.isEmpty() ? null : rows.get(0);
+ }
+
+ /**
+ * 这张订单上是否还有一张**可能被支付**的预支付单。
+ *
+ * <p>新建预支付单的前置不变量:上一张必须已经被证明不可能再被支付(已关闭,或通道明确说失败/
+ * 交易不存在)。{@code CLOSING} 与 {@code CANCEL_UNCONFIRMED} 恰恰是「关不掉的预支付单」——通道
+ * 既没说关掉、也没说钱收下了,它照样能被付进来。此时新建等于给同一笔订单留下两张可被支付的
+ * 预支付单,通道确认两笔都成功时没有多收款退回流程(审计 §9 已复现过两次预授权)。
+ */
+ private String blockingAttempt(long orderId) {
+ List<Map<String, Object>> rows = rows("""
+ select payment_no from payment_transaction
+ where order_id = ? and provider = ?
+ and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')
+ order by id desc
+ limit 1
+ """, orderId, PROVIDER);
+ return rows.isEmpty() ? "" : string(rows.get(0).get("paymentNo"));
}
private PreparedPayment prepare(String orderNo, Long talentId, int priceVersion, String idempotencyKey) {
@@ -141,10 +198,23 @@
String paymentNo = string(existing.get("paymentNo"));
if ("SUCCEEDED".equals(status)) return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, true, "", false, false);
if ("PREPAY_CREATED".equals(status)) {
+ // 支付参数没留在本地就没法把它复用给客户端,而这张预支付单可能已经可以被支付:
+ // 既不新建也不报错,交给对账通道确认后再给结论。
+ if (string(existing.get("clientPaymentPayload")).isBlank()) {
+ return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
+ }
return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, string(existing.get("clientPaymentPayload")), false, false);
+ }
+ if ("CLOSING".equals(status) || "CANCEL_UNCONFIRMED".equals(status)) {
+ // 关单中/关不掉的预支付单:通道还可能为它收钱,绝不能再建一张。
+ return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
}
if ("CREATED".equals(status) || "CREATE_UNKNOWN".equals(status)) {
return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
+ }
+ String blocking = blockingAttempt(number(order.get("id")));
+ if (!blocking.isBlank()) {
+ return new PreparedPayment(blocking, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
}
int nextAttempt = (int) number(existing.get("attemptNo")) + 1;
String retryPaymentNo = BusinessIdGenerator.next("P");
@@ -152,6 +222,10 @@
return new PreparedPayment(retryPaymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order));
}
+ String blocking = blockingAttempt(number(order.get("id")));
+ if (!blocking.isBlank()) {
+ return new PreparedPayment(blocking, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
+ }
String paymentNo = BusinessIdGenerator.next("P");
insertPaymentTransaction(paymentNo, order, orderNo, talentId, payAmount, huifuId, idempotencyKey, 1);
return new PreparedPayment(paymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order));
@@ -277,7 +351,8 @@
case "CLOSED", "CANCEL_UNCONFIRMED" -> "CANCELLED";
case "CREATE_FAILED" -> "PAYMENT_CREATE_FAILED";
case "CREATED", "PREPAY_CREATED" -> "PAYMENT_PENDING";
- case "CREATE_UNKNOWN" -> "PAYMENT_CONFIRMING";
+ // 关单中与创建结果未定都是「还在确认」:客户端渲染成可等待的文案,而不是一个报错。
+ case "CLOSING", "CREATE_UNKNOWN" -> "PAYMENT_PROCESSING";
default -> "UNKNOWN";
};
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentIssueRecorder.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentIssueRecorder.java
index 0cda09c..73786de 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentIssueRecorder.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentIssueRecorder.java
@@ -1,5 +1,7 @@
package com.talentcard.server.business.payment;
+import java.util.List;
+
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -30,7 +32,23 @@
""", type, PROVIDER, businessNo, emptyToNull(orderNo), emptyToNull(expected), emptyToNull(actual), amount, truncate(message));
}
+ /**
+ * 把一笔业务仍未结的问题记为已解决。
+ *
+ * <p>唯一键是 {@code (issue_type, provider, business_no, status)},而同一笔业务会被「解掉、又出现、
+ * 再解掉」(对账每一轮都可能把同一条问题关掉再开出来)。直接把 open 行改成 resolved 会撞上已经存在
+ * 的那条 resolved 行,所以先把上一条结论行清掉再落新结论。删除是有条件的:只有当确实存在一条 open 行
+ * (也就是真的要有新结论)时才动它,人工写下的结论不会因为一次无关的对账轮次被抹掉。
+ */
public void resolve(String type, String businessNo, String by) {
+ List<Long> superseded = jdbc.queryForList("""
+ select r.id from payment_reconciliation_issue r
+ where r.issue_type = ? and r.provider = ? and r.business_no = ? and r.status = 'resolved'
+ and exists (select 1 from payment_reconciliation_issue open_issue
+ where open_issue.issue_type = r.issue_type and open_issue.provider = r.provider
+ and open_issue.business_no = r.business_no and open_issue.status = 'open')
+ """, Long.class, type, PROVIDER, businessNo);
+ for (Long id : superseded) jdbc.update("delete from payment_reconciliation_issue where id = ?", id);
jdbc.update("update payment_reconciliation_issue set status = 'resolved', resolved_at = now(), resolved_by = ? where issue_type = ? and provider = ? and business_no = ? and status = 'open'", by, type, PROVIDER, businessNo);
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
index f418157..68863bc 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
@@ -27,12 +27,25 @@
* {@link ProviderPaymentFact} and settled by {@link PaymentSettlementService}, so both paths produce
* the same local result. A query that cannot confirm the payment leaves the transaction pending and
* records an open issue instead of guessing.
+ *
+ * <p>取消与放弃以通道的**关单回执**为准,而不是把查单当成关单的前置门:关单回执同时回答「关掉了没有」
+ * 和「原交易是不是已经成功」,比查单更强。查单只会说「处理中」,旧实现因此连关单都不试,用户手上那张
+ * 预支付单一直能付进来,订单也永远既付不了又退不出(判定见 {@code docs/adr/0003})。
*/
@Service
public class PaymentReconciliationService {
private static final String PROVIDER = "HUIFU";
/** 订单已取消、但通道侧的预支付单还没确认关掉的流水:对账任务要一直重试关单,直到有结论。 */
private static final String CANCEL_UNCONFIRMED_STATUS = "CANCEL_UNCONFIRMED";
+ /** 已发出关单请求、通道只回「处理中」的支付尝试:可等待、可重试的中间态,不是终点。 */
+ private static final String CLOSING_STATUS = "CLOSING";
+ /** 关单重试上限:到上限停止重试,把这次尝试落成「取消未确认」并结束订单。 */
+ private static final int MAX_CLOSE_ATTEMPTS = 3;
+ /** 关单快车道间隔(分钟)。
+ *
+ * <p>通道不允许关闭一分钟以内的订单,所以它同时也是最短的重试间隔:比这更密只会拿到同样的拒绝。
+ */
+ private static final int CLOSE_RETRY_MINUTES = 1;
private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
private final JdbcTemplate jdbc;
private final HuifuMiniProgramPaymentGateway paymentGateway;
@@ -83,38 +96,65 @@
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public PendingPaymentConfirmation confirmPendingPayments(String orderNo) {
- return decidePendingPayments(orderNo, false);
+ return decidePendingPayments(orderNo, false, null);
}
/**
* 取消订单前的通道确认:除了确认这笔订单有没有收到钱,通道说没收款的预支付单还要在通道侧关掉。
*
* <p>只把本地流水标成已关闭是不够的:用户手上那张预支付单在通道侧还活着,照样能付进来,钱收了
- * 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就拒绝取消,
- * 说还在处理中就让用户稍后重试,只有确认关掉、或通道明确说这笔请求从未收到钱且后续也无法关单
- * (见 {@link #closePayment})时才继续取消。
+ * 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就落成已支付并拒绝
+ * 取消,说还在处理中就让这次尝试停在 {@code CLOSING} 并交给后台重试,只有确认关掉、或通道明确拒绝
+ * 关单(见 {@link #keepWatchableAfterRefusedClose})时才继续取消。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public PendingPaymentConfirmation closePendingPayments(String orderNo) {
- return decidePendingPayments(orderNo, true);
+ return decidePendingPayments(orderNo, true, null);
}
- private PendingPaymentConfirmation decidePendingPayments(String orderNo, boolean closeWhenUncollected) {
- List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
- select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at,
- coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
- from payment_transaction p
- join benefit_order o on o.id = p.order_id
- where p.provider = ? and p.order_no = ? and p.status not in ('SUCCEEDED', 'CLOSED')
- order by p.id asc
- """, PROVIDER, orderNo));
+ /**
+ * 「放弃支付」:客户端在收银台里点了关闭,请求服务端立刻把这笔尝试关掉。
+ *
+ * <p><b>只触发关单,绝不结算。</b>客户端关于钱的陈述不可信——它说「我放弃了」只能当作一个关单信号,
+ * 收款与否一律以关单回执为准。因此这里刻意不调用 {@link #confirmPayment} 的确认分支:即使回执说原
+ * 交易已经成功,也只记一条问题记录,让后续的查单与对账把它落成真实的资金事实。
+ *
+ * @param paymentNo 客户端手上那张预支付单;为空时处理该订单所有未定局的尝试。
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public PendingPaymentConfirmation abandonPendingPayments(String orderNo, String paymentNo) {
PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, "");
- for (Map<String, Object> row : rows) {
+ for (Map<String, Object> row : undecidedPayments(orderNo, paymentNo)) {
+ PendingPaymentConfirmation current = closePayment(row, false);
+ if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current;
+ }
+ return confirmation;
+ }
+
+ private PendingPaymentConfirmation decidePendingPayments(String orderNo, boolean closeWhenUncollected, String paymentNo) {
+ PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, "");
+ for (Map<String, Object> row : undecidedPayments(orderNo, paymentNo)) {
PendingPaymentConfirmation current = confirmPayment(row, closeWhenUncollected);
if (current.collected()) return current;
if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current;
}
return confirmation;
+ }
+
+ /** 一笔订单里本地还没定局的支付尝试(含 CLOSING 与 CANCEL_UNCONFIRMED,它们的预支付单仍可能被支付)。 */
+ private List<Map<String, Object>> undecidedPayments(String orderNo, String paymentNo) {
+ String filter = paymentNo == null || paymentNo.isBlank() ? "" : " and p.payment_no = ?";
+ Object[] args = paymentNo == null || paymentNo.isBlank()
+ ? new Object[] { PROVIDER, orderNo }
+ : new Object[] { PROVIDER, orderNo, paymentNo };
+ return DatabaseRows.normalize(jdbc.queryForList("""
+ select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at,
+ p.close_attempts,
+ coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
+ from payment_transaction p
+ join benefit_order o on o.id = p.order_id
+ where p.provider = ? and p.order_no = ? and p.status not in ('SUCCEEDED', 'CLOSED')
+ """ + filter + " order by p.id asc", args));
}
/**
@@ -124,6 +164,10 @@
* failed, one it registered but never started, and a request number it does not know at all.
* Everything else — money in flight, an unreadable answer, a query that never got through —
* leaves the money question open rather than closed.
+ *
+ * <p>通道**答了话**(查单说处理中,或说没收过钱)时一律再用关单回执下一次结论:关单回执比查单
+ * 强,它同时说明「关掉没有」和「原交易是不是已经成功」。只有查单根本没答上来(通道不可达)时才
+ * 停在未定,因为那种情况下关单也一样问不出东西。
*/
private PendingPaymentConfirmation confirmPayment(Map<String, Object> transaction, boolean closeWhenUncollected) {
String paymentNo = text(transaction.get("paymentNo"));
@@ -161,34 +205,43 @@
// Money was collected but local rules refused it: it is still money, so the question stays open.
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
}
- if (result.notCollected()) {
- return closeWhenUncollected
- ? closePayment(transaction)
- : new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
- }
- return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
+ if (closeWhenUncollected) return closePayment(transaction, true);
+ return new PendingPaymentConfirmation(
+ result.notCollected() ? PendingPaymentState.NONE : PendingPaymentState.UNCONFIRMED, paymentNo);
}
/**
- * 通道说这笔请求没收过钱:把预支付单在通道侧关掉,取消才算真的取消。
+ * 用关单回执给这次支付尝试下结论。
*
* <p>关不掉的预支付单仍然可能被支付,所以「关单没成功」不能当作「可以放心取消」:
* <ul>
* <li>确认关单成功:本地记为已关闭,可以取消。</li>
- * <li>关单回执说原交易已经成功:钱可能在,只是查单没查到——拒绝取消并记一条问题记录给人工。</li>
- * <li>关单处理中:拒绝取消,让用户稍后重试(下次会重新确认)。</li>
+ * <li>关单回执说原交易已经成功:钱可能在,只是查单没查到——{@code followUpOnSucceededOriginalTrade}
+ * 为真时再查一次,查得到金额就落成已支付(这一支的结论是「已支付并拒绝取消」);仍然查不到就让
+ * 这次尝试停在 {@code CLOSING} 等后台重试,并记一条问题记录给人工。</li>
+ * <li>关单处理中:这次尝试停在新状态 {@code CLOSING}(关单中)——它既不能当已关闭(通道还可能
+ * 收钱),也不该是终点,做成可等待、可重试的中间态。</li>
* <li>其余拒绝(未满一分钟不允许关单、原交易不支持关单、关单请求本身失败):允许取消,但流水记为
* {@code CANCEL_UNCONFIRMED} 而不是已关闭——对账任务仍会盯着这笔请求,万一用户后来真的付了,
* 会留下问题记录让人处理,而不是无声地变成一笔没有订单的悬账。</li>
* </ul>
+ *
+ * @param followUpOnSucceededOriginalTrade 关单回执说原交易已成功时是否接着查通道并落成已支付。
+ * 取消那条路为真;「放弃支付」那条路为假——它只被允许触发关单,绝不产生任何收款判定。
*/
- private PendingPaymentConfirmation closePayment(Map<String, Object> transaction) {
+ private PendingPaymentConfirmation closePayment(Map<String, Object> transaction, boolean followUpOnSucceededOriginalTrade) {
String paymentNo = text(transaction.get("paymentNo"));
String orderNo = text(transaction.get("orderNo"));
long amount = number(transaction.get("amount"));
+ if (number(transaction.get("closeAttempts")) >= MAX_CLOSE_ATTEMPTS) {
+ // 关单重试已到上限:不再打通道(同一条坏数据不该被无限重试),直接给这次尝试一个终止条件。
+ giveUpClosing(transaction);
+ return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
+ }
String huifuId = text(transaction.get("huifuId"));
Map<String, Object> response;
try {
+ recordCloseAttempt(paymentNo);
response = paymentGateway.closeMiniProgramPayment(new HuifuPaymentCloseCommand(
BusinessIdGenerator.next("C"), paymentNo, huifuId, text(transaction.get("providerTransactionNo")),
requestDate(transaction.get("createdAt"))));
@@ -205,14 +258,62 @@
issues.issue("PAYMENT_CLOSE_CONFLICT", paymentNo, orderNo, "closed at provider",
"original trade already succeeded", amount,
"Provider refused to close the payment because the original trade already succeeded");
+ if (followUpOnSucceededOriginalTrade) {
+ // 关单回执说原交易已经成功,那就再问一次通道:问得到金额就把它落成已支付(这一支的结论是
+ // 「拒绝取消」),问不到就只能留在关单中,绝不能因为查单没查到就说钱不在。
+ PendingPaymentConfirmation settled = confirmPayment(transaction, false);
+ if (settled.collected()) return settled;
+ }
+ markClosing(transaction, close.code(), close.message());
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
}
if (close.processing()) {
issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close processing", amount,
"Provider is still closing the payment");
+ markClosing(transaction, close.code(), close.message());
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
}
return keepWatchableAfterRefusedClose(transaction, close.code(), close.message(), amount);
+ }
+
+ /**
+ * 关单重试到上限:给这次尝试一个终止条件,同时不丢掉任何盯着钱的东西。
+ *
+ * <p>到上限时通道既没说关掉、也没说钱收下了,而这张预支付单还可能被支付。此时订单不能继续挂着
+ * 「待支付」——那样它既付不了(不允许在同一订单上再建一张预支付单)也永远不会被自动取消,变成一笔
+ * 僵尸订单。所以这里把订单落成已取消,并把这次尝试留成 {@code CANCEL_UNCONFIRMED}:
+ * 订单状态不由自动取消的排除表管理,而是由这次明确的「不再重试」结论结束;
+ * 预支付单继续被对账盯着,万一用户后来真的付了,会记成「取消后才收到钱」的问题记录,
+ * 交给悬账自动退款与人工处理,而不是无声地留下没有出口的悬账。
+ */
+ private void giveUpClosing(Map<String, Object> transaction) {
+ String paymentNo = text(transaction.get("paymentNo"));
+ String orderNo = text(transaction.get("orderNo"));
+ long amount = number(transaction.get("amount"));
+ if (CLOSING_STATUS.equals(text(transaction.get("status")))) {
+ markCancelledTransaction(paymentNo, CANCEL_UNCONFIRMED_STATUS, "CLOSE_ATTEMPTS_EXHAUSTED",
+ "Provider still reports the payment as processing after " + MAX_CLOSE_ATTEMPTS + " close attempts");
+ }
+ issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close attempts exhausted", amount,
+ "Payment could not be closed after " + MAX_CLOSE_ATTEMPTS + " attempts; the prepay stays watchable");
+ jdbc.update("""
+ update benefit_order set order_status = 2
+ where order_no = ? and deleted = 0 and order_status = 0
+ """, orderNo);
+ }
+
+ /** 关单请求已发出:计数要落库,否则重试上限永远到不了,一条坏数据会被无限重试。 */
+ private void recordCloseAttempt(String paymentNo) {
+ jdbc.update("update payment_transaction set close_attempts = close_attempts + 1 where payment_no = ?", paymentNo);
+ }
+
+ private void markClosing(Map<String, Object> transaction, String code, String message) {
+ jdbc.update("""
+ update payment_transaction
+ set status = 'CLOSING', provider_response_code = ?, provider_response_message = ?
+ where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED',
+ 'CANCEL_UNCONFIRMED', 'CLOSING')
+ """, code, message, text(transaction.get("paymentNo")));
}
private PendingPaymentConfirmation keepWatchableAfterRefusedClose(Map<String, Object> transaction, String code,
@@ -229,7 +330,7 @@
update payment_transaction
set status = ?, closed_at = coalesce(closed_at, now()),
provider_response_code = ?, provider_response_message = ?
- where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED', 'CANCEL_UNCONFIRMED')
+ where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED', 'CANCEL_UNCONFIRMED', 'CLOSING')
""", status, code, message, paymentNo);
}
@@ -256,36 +357,83 @@
}
}
+ /**
+ * 取这一轮要处理的未定局流水。
+ *
+ * <p>按 {@code last_reconciled_at} 轮转(空值优先、其次最久没被处理的),取代旧实现的「最早 100 条」:
+ * 旧写法下未决流水一旦积压超过上限,最新出现的那笔——也就是用户刚卡住的那笔——永远轮不到,而死锁
+ * 唯一的自愈通道就是对账。配上每行的退避时间,已经处理过的行不会立刻又被选中。
+ *
+ * <p>{@code CLOSING} 与 {@code CANCEL_UNCONFIRMED} 走快车道({@value #CLOSE_RETRY_MINUTES} 分钟一轮,
+ * 与关单接口「一分钟以内不允许关单」的限制同源);其它未定局流水沿用调用方给的等待时长。
+ */
private int reconcilePayments(int pendingAfterMinutes) {
+ int pendingMinutes = Math.max(1, pendingAfterMinutes);
List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at,
+ p.close_attempts,
coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
from payment_transaction p
join benefit_order o on o.id = p.order_id
- where p.provider = ? and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')
- and p.created_at <= date_sub(now(), interval ? minute)
- order by p.id asc limit 100
- """, PROVIDER, Math.max(1, pendingAfterMinutes)));
+ where p.provider = ?
+ and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')
+ and (
+ (p.status in ('CLOSING', 'CANCEL_UNCONFIRMED')
+ and p.created_at <= date_sub(now(), interval ? minute)
+ and (p.last_reconciled_at is null or p.last_reconciled_at <= date_sub(now(), interval ? minute)))
+ or (p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
+ and p.created_at <= date_sub(now(), interval ? minute)
+ and (p.last_reconciled_at is null or p.last_reconciled_at <= date_sub(now(), interval ? minute)))
+ )
+ order by p.last_reconciled_at is null desc, p.last_reconciled_at asc, p.id asc
+ limit 100
+ """, PROVIDER, CLOSE_RETRY_MINUTES, CLOSE_RETRY_MINUTES, pendingMinutes, pendingMinutes));
for (Map<String, Object> row : rows) {
- if (CANCEL_UNCONFIRMED_STATUS.equals(text(row.get("status")))) retryClose(row);
- else reconcilePayment(row, pendingAfterMinutes);
+ try {
+ String status = text(row.get("status"));
+ // 关不掉/关单中的尝试走关单补偿(它们需要的是「关掉」而不是「查状态」),
+ // 其余未定局流水只查一次,查不到结论就继续等。
+ if (CLOSING_STATUS.equals(status) || CANCEL_UNCONFIRMED_STATUS.equals(status)) retryClose(row);
+ else reconcilePayment(row, pendingAfterMinutes);
+ } finally {
+ // 处理完就盖上轮转时间戳:否则最久没被处理的一行会一直排在最前面,把后面的行饿死。
+ markReconciled(text(row.get("paymentNo")));
+ }
}
return rows.size();
}
+ private void markReconciled(String paymentNo) {
+ jdbc.update("update payment_transaction set last_reconciled_at = now() where payment_no = ?", paymentNo);
+ }
+
/**
* 关单补偿:取消订单时通道不让关(未满一分钟、通道临时拒绝、关单请求失败)而留下的
- * {@code CANCEL_UNCONFIRMED},等过了限制再关一次——不关掉,用户手上那张预支付单一直能付进来。
+ * {@code CANCEL_UNCONFIRMED},以及通道答「处理中」而停在 {@code CLOSING} 的尝试,等过了限制再关一次
+ * ——不关掉,用户手上那张预支付单一直能付进来。
*
* <p>先确认、再关单:通道说这笔请求没收过钱就关掉,落成已关闭;通道说钱已经收下了则绝不再尝试关单,
* 交给结算与问题记录把它变成一笔看得见、可退的账。两种情况都说明这一轮有了结论,把上一轮留下的
* 关单问题记录结掉。
+ *
+ * <p>到重试上限之后不再发关单请求,只查一次:关单已经问不出更多结论,再打通道只是重复。
*/
private void retryClose(Map<String, Object> transaction) {
String paymentNo = text(transaction.get("paymentNo"));
+ if (number(transaction.get("closeAttempts")) >= MAX_CLOSE_ATTEMPTS) {
+ // 到上限:先给还停在「关单中」的尝试一个终止条件(落成已取消并结束订单),之后只查不关。
+ if (CLOSING_STATUS.equals(text(transaction.get("status")))) giveUpClosing(transaction);
+ else watchOnly(transaction);
+ return;
+ }
issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
confirmPayment(transaction, true);
}
+ /** 只向通道确认、不再关单:给已经放弃关单的尝试留一条发现「钱其实收下了」的路。 */
+ private void watchOnly(Map<String, Object> transaction) {
+ PendingPaymentConfirmation confirmation = confirmPayment(transaction, false);
+ if (confirmation.collected()) issues.resolve("PAYMENT_PENDING_TIMEOUT", text(transaction.get("paymentNo")), "system");
+ }
private void reconcilePayment(Map<String, Object> transaction, int pendingAfterMinutes) {
String paymentNo = text(transaction.get("paymentNo"));
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java
index 62c6684..0bbbd06 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java
@@ -97,12 +97,13 @@
if (!alreadySucceeded) {
// CANCEL_UNCONFIRMED 也在这里:订单取消后用户又把预支付单付了,钱必须落成已支付的事实,
- // 再让下面的订单状态判断把它记成「取消后才收到钱」的问题记录。
+ // 再让下面的订单状态判断把它记成「取消后才收到钱」的问题记录。CLOSING(关单中)同理:
+ // 它只说明我们发出过关单请求,通道完全可能已经把钱收下了。
int paymentUpdated = jdbc.update("""
update payment_transaction
set status = 'SUCCEEDED', provider_transaction_no = coalesce(nullif(?, ''), provider_transaction_no),
provider_response_code = ?, provider_response_message = ?, paid_at = coalesce(paid_at, now())
- where id = ? and status in ('CREATED', 'CREATE_FAILED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')
+ where id = ? and status in ('CREATED', 'CREATE_FAILED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED', 'CLOSING')
""", fact.providerTransactionNo(), fact.responseCode(), fact.responseMessage(), paymentId);
if (paymentUpdated == 0) return SettlementOutcome.NOT_SETTLEABLE;
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderAbandonRequest.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderAbandonRequest.java
new file mode 100644
index 0000000..165e313
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderAbandonRequest.java
@@ -0,0 +1,12 @@
+package com.talentcard.server.business.portal;
+
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * 客户端「放弃支付」信号:用户在收银台里点了关闭、或支付发起失败,请服务端立刻尝试关单。
+ *
+ * <p>{@code paymentNo} 是客户端手上那张预支付单,可为空(为空时处理该订单所有未定局的尝试)。
+ * 这个信号只用来触发关单,绝不用于判定收款——客户端关于钱的陈述不可信,结论一律以通道回执为准。
+ */
+public record PendingOrderAbandonRequest(@NotNull Long talentId, String paymentNo) {
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalController.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalController.java
index 23cc2f1..5c124ca 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalController.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalController.java
@@ -1,11 +1,15 @@
package com.talentcard.server.business.portal;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.List;
import com.talentcard.server.business.auth.TalentAuthService;
import com.talentcard.server.business.auth.TalentSessionPrincipal;
import com.talentcard.server.business.payment.HuifuPaymentService;
+import com.talentcard.server.business.payment.PaymentReconciliationService;
+import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentConfirmation;
+import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentState;
import com.talentcard.server.common.ApiResponse;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -25,11 +29,14 @@
public class PendingOrderPortalController {
private final PendingOrderPortalService pendingOrderPortalService;
private final HuifuPaymentService huifuPaymentService;
+ private final PaymentReconciliationService paymentReconciliationService;
private final TalentAuthService talentAuthService;
- public PendingOrderPortalController(PendingOrderPortalService pendingOrderPortalService, HuifuPaymentService huifuPaymentService, TalentAuthService talentAuthService) {
+ public PendingOrderPortalController(PendingOrderPortalService pendingOrderPortalService, HuifuPaymentService huifuPaymentService,
+ PaymentReconciliationService paymentReconciliationService, TalentAuthService talentAuthService) {
this.pendingOrderPortalService = pendingOrderPortalService;
this.huifuPaymentService = huifuPaymentService;
+ this.paymentReconciliationService = paymentReconciliationService;
this.talentAuthService = talentAuthService;
}
@@ -85,6 +92,29 @@
return ApiResponse.success(huifuPaymentService.paymentStatus(orderNo, talentId, paymentNo));
}
+ /**
+ * 放弃支付:客户端在收银台里点了关闭时调用,服务端立刻尝试关单。
+ *
+ * <p>只触发关单、绝不结算:客户端说「我放弃了」只当作信号,钱收没收下一律以通道回执为准。
+ */
+ @PostMapping("/{orderNo}/payment/abandon")
+ public ApiResponse<Map<String, Object>> abandonPayment(
+ @PathVariable String orderNo,
+ @Valid @RequestBody PendingOrderAbandonRequest request,
+ @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization
+ ) {
+ ensureSessionTalent(authorization, request.talentId());
+ PendingPaymentConfirmation confirmation =
+ paymentReconciliationService.abandonPendingPayments(orderNo, request.paymentNo());
+ Map<String, Object> result = new LinkedHashMap<>();
+ result.put("paymentNo", confirmation.paymentNo());
+ // 关单有结论(关掉了/通道拒绝关单但仍然允许继续)就是 CANCELLED;
+ // 通道说还在处理中就是 PAYMENT_PROCESSING——后台会接着关,客户端按可等待渲染。
+ result.put("paymentStatus", confirmation.state() == PendingPaymentState.UNCONFIRMED
+ ? "PAYMENT_PROCESSING" : "CANCELLED");
+ return ApiResponse.success(result);
+ }
+
private void ensureSessionTalent(String authorization, Long talentId) {
TalentSessionPrincipal principal = talentAuthService.authenticate(authorization);
if (!principal.talentId().equals(talentId)) throw new BusinessException(ErrorCode.FORBIDDEN, "无权操作其他人才用户的订单");
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java
index 09aa8b2..99c8f5f 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java
@@ -50,6 +50,10 @@
* 到期时间不等于没收到钱。这些订单交给对账任务先向通道问清(已收款就落成已支付,通道说失败就
* 把流水置为已失败),下一轮清理自然会收掉它们。到期就自动取消会把已经收下的钱变成一笔没有
* 订单、也无法退款的悬账。
+ *
+ * <p>{@code CANCEL_UNCONFIRMED} 与 {@code CLOSING} 一并排除:它们是「关不掉的预支付单」,
+ * 通道既没说关掉也没说钱收下了,所以在通道侧仍然可能被支付。自动取消这样一笔订单,等于把已经
+ * 收下的钱变成没有出口的悬账。它们由关单补偿给出终止条件(重试上限后落成已取消并保留盯防)。
*/
@Transactional
public int expireDueOrders(Long talentId) {
@@ -60,7 +64,8 @@
and o.expires_at is not null and o.expires_at <= current_timestamp
and not exists (
select 1 from payment_transaction p
- where p.order_id = o.id and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
+ where p.order_id = o.id
+ and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')
)
""";
if (talentId == null) return jdbcTemplate.update(sql);
@@ -73,7 +78,8 @@
ensurePayable(order, request.priceVersion());
Integer prepayCount = jdbcTemplate.queryForObject("""
select count(*) from payment_transaction
- where order_no = ? and talent_id = ? and status in ('CREATED', 'PREPAY_CREATED', 'SUCCEEDED')
+ where order_no = ? and talent_id = ?
+ and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED', 'SUCCEEDED')
""", Integer.class, orderNo, request.talentId());
if (prepayCount != null && prepayCount > 0) {
throw new BusinessException(ErrorCode.CONFLICT, "支付已发起,不能再修改优惠券");
@@ -233,13 +239,17 @@
if (longNumber(order.get("orderStatus")) == 2) return view(order);
if (longNumber(order.get("orderStatus")) != 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可取消");
// 回调没到达时「待支付」只说明本地不知道,钱可能已经被通道收下。取消是不可逆的,所以先问通道:
- // 已经收下的钱必须落成已支付(人才端才有退款入口),问不清楚就先不取消;通道说没收款的预支付单
- // 还要在通道侧关掉,否则用户手上的预支付单仍然能付进来。这一问必须排在本地写状态之前——
- // 它在自己的事务里,本方法一旦先加锁,它就会等自己的锁。
+ // 已经收下的钱必须落成已支付(人才端才有退款入口);通道说没收款的预支付单还要在通道侧关掉,
+ // 否则用户手上的预支付单仍然能付进来。这一问必须排在本地写状态之前——它在自己的事务里,
+ // 本方法一旦先加锁,它就会等自己的锁。
PendingPaymentConfirmation confirmation = paymentReconciliationService.closePendingPayments(orderNo);
if (confirmation.collected()) throw new BusinessException(ErrorCode.CONFLICT, "订单已支付,不能取消");
if (confirmation.state() == PendingPaymentState.UNCONFIRMED) {
- throw new BusinessException(ErrorCode.CONFLICT, "支付结果确认中,暂时不能取消,请稍后再试");
+ // 关单没有结论(通道答「处理中」或干脆没答上来):这次「支付结果不确定」只属于它所属的
+ // 那一次支付尝试,不该把整笔订单停摆成一个永久报错。取消的意图先被接受并记录,订单本身
+ // 不动——后台每分钟重试关单,有关闭或已收款的结论时再落地(关掉了就取消,钱收下了就
+ // 翻成已支付并告知用户)。页面按「取消处理中」渲染。
+ return cancellationPending(orderNo, talentId, confirmation.paymentNo());
}
// 过了支付时限的订单在这里结束:条件写在 SQL 里,跨实例只有一个赢家。
expireOrder(orderNo, talentId);
@@ -270,6 +280,15 @@
return view(find(orderNo, talentId));
}
+ /** 取消已受理、等通道关单结论:订单仍是待支付,人才端按「取消处理中」渲染。 */
+ private Map<String, Object> cancellationPending(String orderNo, Long talentId, String paymentNo) {
+ Map<String, Object> result = view(find(orderNo, talentId));
+ result.put("cancellationPending", true);
+ result.put("paymentNo", paymentNo);
+ result.put("paymentStatus", "PAYMENT_PROCESSING");
+ return result;
+ }
+
/** 单个订单的到期结束:条件写在 SQL 里,跨实例只有一个赢家。未定局的支付尝试照上面同样的理由排除。 */
private void expireOrder(String orderNo, Long talentId) {
jdbcTemplate.update("""
@@ -279,7 +298,7 @@
and o.expires_at is not null and o.expires_at <= current_timestamp
and not exists (
select 1 from payment_transaction p
- where p.order_id = o.id and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
+ where p.order_id = o.id and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')
)
""", orderNo, talentId);
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingUsePortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingUsePortalService.java
index 20c604d..c0de9d1 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingUsePortalService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingUsePortalService.java
@@ -24,6 +24,15 @@
@Service
public class PendingUsePortalService {
+ /** 核销码刷新间隔(秒):人才端按它轮换码。 */
+ private static final int CODE_REFRESH_SECONDS = 60;
+ /**
+ * 新旧码的重叠期(秒)。
+ *
+ * <p>轮换后上一张码在这个窗口内仍然有效:商户扫到的一瞬间恰好赶上翻转,就不会再报「核销凭证已失效」。
+ * 30 秒覆盖了「扫到 → 解码 → 上传」这段真实耗时,又远短于刷新间隔,不至于让旧码长期可用。
+ */
+ private static final int CODE_OVERLAP_SECONDS = 30;
private final JdbcTemplate jdbc; private final OrderService orderService; private final HuifuRefundService refundService;
public PendingUsePortalService(JdbcTemplate jdbc, OrderService orderService, HuifuRefundService refundService) {
this.jdbc = jdbc; this.orderService = orderService; this.refundService = refundService;
@@ -58,10 +67,13 @@
Map<String,Object> order = find(orderNo, talentId);
// 退款中或已退款的订单不能再发核销码:钱要退给人才,商户那边也核销不了,发了只会让双方白跑一趟。
if (number(order.get("orderStatus")) != 1 || number(order.get("verifyStatus")) == 1 || number(order.get("refundStatus")) != 0 || "STORE_CONSUME".equals(String.valueOf(order.get("orderType")))) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销");
- jdbc.update("update order_dynamic_verification_code set status = 2 where order_id = ? and status = 0", order.get("id"));
- String token = UUID.randomUUID().toString().replace("-", ""); String display = String.format("%06d", new Random().nextInt(1_000_000)); Instant expires = Instant.now().plusSeconds(60);
+ // 这里**不再**把上一张码立刻作废(旧实现一生成新码就把 status=0 的旧码置 2)。商户扫到的那一瞬间
+ // 恰好赶上轮换,就会报「核销凭证已失效,请重新扫码」——而那只是我们自己把码提前作废了。改成让上一张
+ // 码在一个重叠期内继续有效:轮换的安全目的是防截图转卖,一个短暂的重叠窗口不会明显削弱它,
+ // 但能消掉这类失败。重叠期之外,码仍然按 expires_at 自动失效。
+ String token = UUID.randomUUID().toString().replace("-", ""); String display = String.format("%06d", new Random().nextInt(1_000_000)); Instant expires = Instant.now().plusSeconds(CODE_REFRESH_SECONDS + CODE_OVERLAP_SECONDS);
jdbc.update("insert into order_dynamic_verification_code(code_token, display_code, order_id, talent_id, expires_at, status) values (?, ?, ?, ?, ?, 0)", token, display, order.get("id"), talentId, Timestamp.from(expires));
- return Map.of("token", token, "displayCode", display, "imagePath", "/api/app/pending-use-orders/verification-codes/" + token + "/image", "expiresAt", expires.toEpochMilli(), "refreshAfterSeconds", 60);
+ return Map.of("token", token, "displayCode", display, "imagePath", "/api/app/pending-use-orders/verification-codes/" + token + "/image", "expiresAt", expires.toEpochMilli(), "refreshAfterSeconds", CODE_REFRESH_SECONDS);
}
@Transactional(readOnly = true) public byte[] codeImage(String token) {
Integer count = jdbc.queryForObject("select count(*) from order_dynamic_verification_code where code_token = ? and status = 0 and expires_at > now()", Integer.class, token);
@@ -71,8 +83,8 @@
@Transactional public Map<String,Object> refund(String orderNo, PendingUseRefundRequest request) {
Map<String,Object> order = find(orderNo, request.talentId());
if (number(order.get("orderStatus")) != 1 || (number(order.get("verifyStatus")) == 1 && !"STORE_CONSUME".equals(String.valueOf(order.get("orderType"))))) throw new BusinessException(ErrorCode.CONFLICT, "该订单不满足退款条件");
- Integer existing = jdbc.queryForObject("select count(*) from order_refund where deleted = 0 and order_id = ? and refund_status in (1,2)", Integer.class, order.get("id"));
- if (existing != null && existing > 0) throw new BusinessException(ErrorCode.CONFLICT, "订单已提交退款申请");
+ // 能不能退由退款服务在订单行锁里按金额判断(已成功金额 + 处理中金额 < 实付),这里不再自己判一遍:
+ // 旧的前置「存在退款中或已退款就拒绝」会让首笔部分退款之后剩余金额永远退不了(审计 §14)。
return orderService.refund(orderNo, new OrderRefundRequest(BigDecimal.ZERO, request.reason() == null ? "用户申请退款" : request.reason(), "人才端用户"));
}
private Map<String,Object> find(String orderNo, Long talentId) {
diff --git a/talent-card-server/src/main/resources/db/migration/V149__payment_attempt_convergence.sql b/talent-card-server/src/main/resources/db/migration/V149__payment_attempt_convergence.sql
new file mode 100644
index 0000000..134cfe3
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V149__payment_attempt_convergence.sql
@@ -0,0 +1,40 @@
+-- 支付尝试按「这一次尝试」收敛所需要的两列(判定见 docs/adr/0003)。
+--
+-- last_reconciled_at 对账任务的公平轮转与退避依据。旧实现按「最早 100 条」取未决流水,一旦积压
+-- 超过上限,最新出现的那笔(也就是用户刚卡住的那笔)永远轮不到——而死锁唯一
+-- 的自愈通道就是对账。改为「空值优先、升序、带退避」之后每笔都会轮到。
+-- close_attempts 关单重试计数。关单回执说「处理中」时支付尝试落在 CLOSING,后台每分钟重试
+-- 一次;到上限就不再重试(保留 CANCEL_UNCONFIRMED 盯防与问题记录),避免一条
+-- 坏数据让后台永远重试同一笔。
+--
+-- 这两列必须同样落到开发库:开发库(局域网共享的 talent_card)Flyway 是关闭的、结构不由迁移维护,
+-- 所以另有 scripts/dev-schema-v149-payment-attempt.sql(可手工执行的等价 DDL)与
+-- scripts/apply-dev-schema.ps1(本机没有 mysql 客户端,用项目已有的 JDBC 驱动执行)。
+--
+-- 用存储过程按 exists 判断逐列追加,使脚本可以重复执行(本地开发的库常常手工加过列)。
+DROP PROCEDURE IF EXISTS add_payment_attempt_convergence_columns;
+DELIMITER //
+CREATE PROCEDURE add_payment_attempt_convergence_columns()
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+ AND column_name = 'last_reconciled_at') THEN
+ ALTER TABLE payment_transaction
+ ADD COLUMN last_reconciled_at DATETIME DEFAULT NULL COMMENT '最近一次对账处理时间(公平轮转与退避依据)';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+ AND column_name = 'close_attempts') THEN
+ ALTER TABLE payment_transaction
+ ADD COLUMN close_attempts INT NOT NULL DEFAULT 0 COMMENT '已发出的关单请求次数(重试上限依据)';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+ AND index_name = 'idx_payment_transaction_reconcile') THEN
+ CREATE INDEX idx_payment_transaction_reconcile
+ ON payment_transaction (status, last_reconciled_at);
+ END IF;
+END //
+DELIMITER ;
+CALL add_payment_attempt_convergence_columns();
+DROP PROCEDURE add_payment_attempt_convergence_columns;
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSettlementTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSettlementTests.java
index 14c8996..0cfba38 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSettlementTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSettlementTests.java
@@ -79,7 +79,7 @@
suffix = String.valueOf(System.nanoTime());
when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
// Only the transaction a test creates itself may look old enough for reconciliation.
- jdbc.update("update payment_transaction set created_at = now() where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')");
+ jdbc.update("update payment_transaction set created_at = now(), last_reconciled_at = null where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')");
jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "payment-openid-" + suffix, "Payment user", "13900000001");
talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
jdbc.update("insert into talent_card(talent_id, card_no, card_type, card_level, status, deleted) values (?, ?, 'PERSONAL', ?, 1, 0)", talentId, "TC-PAY-" + suffix, "卓越人才卡");
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
index 681062f..516d400 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
@@ -5,11 +5,14 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import java.util.List;
import java.util.Map;
+import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentConfirmation;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
@@ -61,7 +64,7 @@
void prepareData() {
suffix = String.valueOf(System.nanoTime());
// 共享测试库里可能留着历史遗留的挂起流水,只有本用例自己创建的才能落进对账窗口。
- jdbc.update("update payment_transaction set created_at = now() where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')");
+ jdbc.update("update payment_transaction set created_at = now(), last_reconciled_at = null where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')");
jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
"pending-payment-openid-" + suffix, "Pending payment user", "13900000003");
talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
@@ -112,27 +115,6 @@
}
@Test
- void cancellingIsRefusedAndRecordedWhenClosingRevealsTheTradeWasAlreadyPaid() {
- String orderNo = pendingOrder();
- String paymentNo = pendingPayment(orderNo, 2000L);
- // 查单说「交易不存在」,关单却回「原订单已为终态」且原交易已成功:钱可能真的在,不能当成没收过款。
- when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
- "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
- when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
- "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
- "org_trans_stat", "S", "huifu_id", huifuId)));
-
- // 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但订单也绝不能被取消。
- assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
- .isInstanceOf(BusinessException.class)
- .hasMessageContaining("确认中");
-
- assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
- assertThat(count("select count(*) from payment_transaction where payment_no = ? and status = 'PREPAY_CREATED'", paymentNo)).isEqualTo(1);
- assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L);
- }
-
- @Test
void cancellingProceedsButKeepsThePrepayWatchableWhenTheProviderRefusesToCloseIt() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
@@ -151,32 +133,107 @@
}
@Test
- void cancellingIsRefusedWhileTheProviderIsStillClosingThePrepay() {
+ void cancellingClosesThePrepayEvenWhenTheQueryOnlySaysItIsProcessing() {
String orderNo = pendingOrder();
- pendingPayment(orderNo, 2000L);
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ // 查单只答「处理中」:旧实现把它当成关单的前置门,于是关单一次都不会被调用。
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "trans_amt", "20.00", "huifu_id", huifuId)));
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "F", "resp_code", "20000001", "resp_desc", "不允许关闭一分钟以内的订单", "huifu_id", huifuId)));
+
+ Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
+
+ verify(paymentGateway, times(1)).closeMiniProgramPayment(any());
+ assertThat(((Number) result.get("orderStatus")).intValue())
+ .as("关单回执是明确拒绝,不是「还在处理中」,因此允许取消并继续盯防")
+ .isEqualTo(2);
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .isEqualTo("CANCEL_UNCONFIRMED");
+ }
+
+ @Test
+ void closingReportsTheOriginalTradeSucceededAndTheQueryThenLandsTheOrderAsPaid() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ // 第一次查单说交易不存在(所以会去关单),关单回执说原交易已经成功,再查一次拿到了金额。
+ when(paymentGateway.queryMiniProgramPayment(any()))
+ .thenReturn(providerResponse(Map.of("resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)))
+ .thenReturn(providerResponse(Map.of(
+ "trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
+ "huifu_id", huifuId, "org_hf_seq_id", "HF-CLOSE-PAID-" + suffix)));
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
+ "org_trans_stat", "S", "huifu_id", huifuId)));
+
+ assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("已支付");
+
+ assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+ .as("关单回执说原交易已成功,就必须落成已支付,而不是把一笔真钱留在待支付里")
+ .isEqualTo(1);
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .isEqualTo("SUCCEEDED");
+ }
+
+ @Test
+ void cancellingWaitsForTheCloseConclusionWhenClosingRevealsTheTradeWasAlreadyPaid() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ // 查单说「交易不存在」,关单却回「原订单已为终态」且原交易已成功:钱可能真的在,不能当成没收过款。
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
+ "org_trans_stat", "S", "huifu_id", huifuId)));
+
+ // 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但订单也绝不能被取消。
+ Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
+
+ assertThat(result)
+ .as("关单没有结论时,取消的意图被受理但要等结论,而不是给一个永久报错")
+ .containsEntry("cancellationPending", true);
+ assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .as("关单中:既不能当已关闭(通道还可能收钱),也不该是终点")
+ .isEqualTo("CLOSING");
+ assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
+ .as("关单中期间只能复用,不允许新建第二张可被支付的预支付单")
+ .isEqualTo(1);
+ assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L);
+ }
+
+ @Test
+ void cancellingWaitsForTheCloseConclusionWhileTheProviderIsStillClosingThePrepay() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId)));
- assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
- .isInstanceOf(BusinessException.class)
- .hasMessageContaining("确认中");
+ Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
+ assertThat(result).containsEntry("cancellationPending", true);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .isEqualTo("CLOSING");
+ assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo))
+ .as("关单重试次数要落库,否则重试上限永远到不了")
+ .isEqualTo(1);
}
@Test
- void cancellingIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
+ void cancellingWaitsForTheProviderWhenItCannotSayWhetherTheMoneyWasCollected() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any()))
.thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
- assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
- .isInstanceOf(BusinessException.class)
- .hasMessageContaining("确认中");
+ Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
+ assertThat(result).containsEntry("cancellationPending", true);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.as("an unanswerable payment question must not destroy the only record of the money")
@@ -202,18 +259,200 @@
}
@Test
- void retryingAPaymentIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
+ void retryingAPaymentReportsWaitingWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
String orderNo = pendingOrder();
pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any()))
.thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
- assertThatThrownBy(() -> paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix))
- .isInstanceOf(BusinessException.class)
- .hasMessageContaining("确认中");
+ Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix);
+ assertThat(result)
+ .as("问不出来的不确定只属于这一次支付尝试,不该把用户堵在一句报错上")
+ .containsEntry("paymentStatus", "PAYMENT_PROCESSING");
verify(paymentGateway, never()).createMiniProgramPayment(any());
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
+ }
+
+ @Test
+ void askingToPayAgainReusesTheStoredPrepayWithoutAskingTheProvider() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L, "PREPAY_CREATED", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}");
+
+ Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "reuse-key-" + suffix);
+
+ assertThat(result)
+ .as("本地已经持有可用的支付参数时直接把这张预支付单交回去——查通道只会得到「处理中」这种没有结论的答复")
+ .containsEntry("paymentStatus", "PAYMENT_PENDING")
+ .containsEntry("paymentNo", paymentNo);
+ assertThat(result.get("paymentParams")).isNotNull();
+ verify(paymentGateway, never()).queryMiniProgramPayment(any());
+ verify(paymentGateway, never()).createMiniProgramPayment(any());
+ assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
+ .as("复用不新建支付流水")
+ .isEqualTo(1);
+ }
+
+ @Test
+ void askingToPayAgainIsRefusedWhenAnUndecidedPrepayCannotBeReused() {
+ String orderNo = pendingOrder();
+ // 先取消,通道答「处理中」→ 关单没有结论,这次尝试停在 CLOSING 且订单仍待支付;
+ // 它没有留下可复用的支付参数,因此既不能复用、也不许新建。
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId)));
+ pendingOrderService.cancel(orderNo, talentId);
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("CLOSING");
+ clearInvocations(paymentGateway);
+
+ Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "blocked-key-" + suffix);
+
+ assertThat(result).containsEntry("paymentStatus", "PAYMENT_PROCESSING");
+ verify(paymentGateway, never()).createMiniProgramPayment(any());
+ assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
+ .as("上一张预支付单没有被证明不可能再被支付之前,不允许给同一笔订单再建一张")
+ .isEqualTo(1);
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .isEqualTo("CLOSING");
+ }
+
+ @Test
+ void anExpiredOrderCannotBePaidEvenWhenAPrepayIsStillStoredLocally() {
+ String orderNo = pendingOrder();
+ pendingPayment(orderNo, 2000L, "PREPAY_CREATED", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}");
+ expireNow(orderNo);
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
+
+ assertThatThrownBy(() -> paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "expired-key-" + suffix))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("超时");
+
+ verify(paymentGateway, never()).createMiniProgramPayment(any());
+ }
+
+ @Test
+ void abandoningAPaymentOnlyClosesItAndNeverJudgesWhetherMoneyArrived() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ // 客户端说「我放弃了」,而关单回执说原交易已经成功——即使如此也不能据客户端的话去查单或结算。
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
+ "org_trans_stat", "S", "huifu_id", huifuId)));
+
+ PendingPaymentConfirmation confirmation = reconciliationService.abandonPendingPayments(orderNo, paymentNo);
+
+ verify(paymentGateway, never()).queryMiniProgramPayment(any());
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .as("放弃只触发关单:关单没拿到「关掉」的结论就停在关单中,绝不做任何收款判定")
+ .isEqualTo("CLOSING");
+ assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+ .as("放弃支付不是取消订单")
+ .isZero();
+ assertThat(confirmation.collected()).isFalse();
+ assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L);
+ }
+
+ @Test
+ void abandoningAPaymentClosesThePrepaySoTheOrderCannotBePaidTwice() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "org_trans_stat", "P", "huifu_id", huifuId)));
+
+ reconciliationService.abandonPendingPayments(orderNo, paymentNo);
+
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .isEqualTo("CLOSED");
+ }
+
+ @Test
+ void thePrepayCloseStopsRetryingAtTheCapAndEndsTheOrderWithAWatcher() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
+ // 通道一直答「还在处理中」:关单永远没有结论。
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId)));
+
+ assertThat(pendingOrderService.cancel(orderNo, talentId)).containsEntry("cancellationPending", true);
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .isEqualTo("CLOSING");
+
+ // 快车道每分钟一轮。每轮都把行推回窗口内(共享库里的残留行不影响本用例:断言只按自己的流水号过滤)。
+ for (int round = 0; round < 3; round++) reconcileOnce(paymentNo);
+
+ assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo))
+ .as("关单重试有明确上限")
+ .isEqualTo(3);
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .as("到上限后落成「取消未确认」,预支付单继续被盯着")
+ .isEqualTo("CANCEL_UNCONFIRMED");
+ assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+ .as("关单中不能永远挂着:到上限时订单落成已取消,不留一笔既付不了也退不出的僵尸订单")
+ .isEqualTo(2);
+ assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo))
+ .as("放弃关单要留下问题记录给人工")
+ .isEqualTo(1L);
+
+ // 到上限后不再重试:再跑一轮,关单重试计数不再增长。
+ reconcileOnce(paymentNo);
+ assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo))
+ .isEqualTo(3);
+ }
+
+ @Test
+ void anExpiredOrderWithAClosingOrUnconfirmedAttemptIsNotCancelledByTheDeadline() {
+ for (String status : List.of("CLOSING", "CANCEL_UNCONFIRMED")) {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L, status);
+ expireNow(orderNo);
+
+ pendingOrderService.expireDueOrders(talentId);
+ assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+ .as("%s 的预支付单仍可能被支付,自动取消会把已收下的钱变成没有出口的悬账", status)
+ .isZero();
+
+ pendingOrderService.detail(orderNo, talentId);
+ assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+ .as("单个订单的到期路径同样不能取消它(%s)", status)
+ .isZero();
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .isEqualTo(status);
+ }
+ }
+
+ @Test
+ void theReconciliationRotatesInsteadOfAlwaysTakingTheOldestHundredUndecidedPayments() {
+ String backlogOrderNo = pendingOrder();
+ Long backlogOrderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, backlogOrderNo);
+ // 100 条刚被处理过的历史未决流水:它们都在退避窗口内,不该再被选中。
+ for (int index = 0; index < 100; index++) {
+ jdbc.update("""
+ insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
+ provider_huifu_id, amount, status, idempotency_key, created_at, last_reconciled_at)
+ values (?, 'HUIFU', ?, ?, ?, ?, ?, 2000, 'PREPAY_CREATED', ?, now() - interval 30 minute, now())
+ """, "P-BACKLOG-" + suffix + "-" + index, backlogOrderId, backlogOrderNo, talentId, merchantId, huifuId,
+ "backlog-" + suffix + "-" + index);
+ }
+ assertThat(count("select count(*) from payment_transaction where order_no = ?", backlogOrderNo)).isEqualTo(100);
+
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ jdbc.update("update payment_transaction set created_at = now() - interval 5 minute where payment_no = ?", paymentNo);
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
+
+ reconciliationService.reconcile(5);
+
+ assertThat(openIssueCount("PAYMENT_PENDING_TIMEOUT", paymentNo))
+ .as("刚出现的未决流水不能被历史积压挤出处理窗口——它正是死锁唯一的自愈通道")
+ .isEqualTo(1L);
+ assertThat(count("select count(*) from payment_transaction where payment_no = ? and last_reconciled_at is not null", paymentNo))
+ .isEqualTo(1);
}
@Test
@@ -233,8 +472,9 @@
/** A pending order bound to a collecting sub-merchant, created directly so the payment seam is the only subject. */
@Test
void anExpiredOrderWithAnUndecidedPaymentIsNotCancelledByTheDeadlineSweep() {
- String orderNo = expiredOrder();
+ String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
+ expireNow(orderNo);
pendingOrderService.expireDueOrders(talentId);
@@ -338,16 +578,41 @@
}
private String pendingPayment(String orderNo, long amount, String status) {
+ return pendingPayment(orderNo, amount, status, null);
+ }
+
+ private String pendingPayment(String orderNo, long amount, String status, String clientPaymentPayload) {
String paymentNo = "P-PENDING-" + suffix + "-" + orderSequence;
Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
jdbc.update("""
insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
- provider_huifu_id, amount, status, idempotency_key, created_at)
- values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now())
- """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, amount, status, "pending-payment-" + paymentNo);
+ provider_huifu_id, amount, status, idempotency_key, created_at,
+ client_payment_payload)
+ values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now(), ?)
+ """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, amount, status,
+ "pending-payment-" + paymentNo, clientPaymentPayload);
return paymentNo;
}
+ /** 把这一行推回对账窗口再跑一轮;断言只按本用例自己的流水号过滤,共享库里的残留行不影响结论。 */
+ private void reconcileOnce(String paymentNo) {
+ jdbc.update("""
+ update payment_transaction set created_at = now() - interval 5 minute, last_reconciled_at = null
+ where payment_no = ?
+ """, paymentNo);
+ reconciliationService.reconcile(5);
+ }
+
+ /**
+ * 让订单现在就算过期。
+ *
+ * <p>必须建完订单与支付尝试之后再推时间:到期清理任务({@code PendingOrderExpiryScheduler},测试里
+ * 也开着)会在「订单已过期、但还没有任何支付流水」的那一瞬间把它当成普通超时订单取消掉。
+ */
+ private void expireNow(String orderNo) {
+ jdbc.update("update benefit_order set expires_at = now() - interval 1 minute where order_no = ?", orderNo);
+ }
+
private Map<String, Object> providerResponse(Map<String, Object> data) {
return Map.of("data", data);
}
--
Gitblit v1.7.1