feat(payment): 支付按支付尝试收敛,取消与放弃以关单回执为准
用户报的死锁根因是「支付结果不确定」被当成了整笔订单的停止符:本地明明还持有那张
预支付单的支付参数,服务端却先查通道、拿到「处理中」就抛错,于是订单既付不了也退不出。
判定见 docs/adr/0003。
服务端:
- 支付发起改为**复用优先**:本地有未过期预支付单的支付参数、订单仍待支付时直接交回客户端,
不查通道;新建预支付单的前置不变量改为「上一张已被证明不可能再被支付」(blockingAttempt),
CLOSING / CANCEL_UNCONFIRMED 期间只允许复用。
- 取消与放弃先关单、后用关单回执下结论:已关闭→可取消;原交易已成功→再查一次并落成已支付、
拒绝取消;处理中→支付尝试置新状态 CLOSING(可等待、可重试);其余拒绝→记 CANCEL_UNCONFIRMED
并继续盯防。查单答「处理中」时关单接口现在仍会被调用(旧实现一次都不会调)。
- 关单重试每分钟一次、最多 3 次(close_attempts),到上限落成已取消、保留盯防与问题记录。
- cancel 在未定局时返回 cancellationPending 而不是报错;新增 POST /payment/abandon 只触发关单、
绝不结算;到期与批量两条路径都不再自动取消带 CLOSING / CANCEL_UNCONFIRMED 的订单;超时后
支付接口拒绝补付。
- 对账取行改为按 last_reconciled_at 公平轮转 + 退避,CLOSING / CANCEL_UNCONFIRMED 走 1 分钟快车道,
取代「最早 100 条」的饥饿写法。
迁移 V149:payment_transaction 加 last_reconciled_at(+索引)与 close_attempts;开发库 Flyway 关闭,
另附 scripts/dev-schema-v149-payment-attempt.sql 与通用执行器 scripts/apply-sql-file.ps1,
已在开发库(MySQL 5.7.44)实跑并验证。
人才端:一次点击即支付(建单后立刻发起预支付并拉起收银台,放弃/失败才落到待支付页)、
待支付页三态渲染(支付处理中/取消处理中/已关闭)、退避轮询到约 45 秒 + 手动「刷新支付状态」、
收银台放弃时调「放弃支付」信号。
顺带修一处被测出的缺陷:问题记录的唯一键含 status,同一笔业务被反复「结掉又开出」时,
结掉那一步会撞上已存在的 resolved 行,已改为先清掉被取代的结论行再落新结论。
| | |
| | | <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> |
| | |
| | | |
| | | <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' |
| | | |
| | |
| | | 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> |
| | | |
| | |
| | | .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> |
| | |
| | | <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> |
| | |
| | | <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) |
| | |
| | | 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('无') |
| | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | // #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(() => { |
| | |
| | | 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 = () => { |
| | |
| | | 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 == '') { |
| | |
| | | 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) => { |
| | |
| | | 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 |
| | |
| | | }) |
| | | } |
| | | 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) => { |
| | |
| | | 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 |
| | |
| | | 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 = () => { |
| | |
| | | .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> |
| | |
| | | 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) |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | # 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" } |
| New file |
| | |
| | | -- 开发库(局域网共享的 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); |
| | |
| | | } |
| | | |
| | | 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()); |
| | |
| | | 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) { |
| | |
| | | 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"); |
| | |
| | | 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)); |
| | |
| | | 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"; |
| | | }; |
| | | } |
| | |
| | | package com.talentcard.server.business.payment; |
| | | |
| | | import java.util.List; |
| | | |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | |
| | | """, 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); |
| | | } |
| | | |
| | |
| | | * {@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; |
| | |
| | | */ |
| | | @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)); |
| | | } |
| | | |
| | | /** |
| | |
| | | * 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")); |
| | |
| | | // 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")))); |
| | |
| | | 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, |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 取这一轮要处理的未定局流水。 |
| | | * |
| | | * <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")); |
| | |
| | | |
| | | 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; |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.portal; |
| | | |
| | | import jakarta.validation.constraints.NotNull; |
| | | |
| | | /** |
| | | * 客户端「放弃支付」信号:用户在收银台里点了关闭、或支付发起失败,请服务端立刻尝试关单。 |
| | | * |
| | | * <p>{@code paymentNo} 是客户端手上那张预支付单,可为空(为空时处理该订单所有未定局的尝试)。 |
| | | * 这个信号只用来触发关单,绝不用于判定收款——客户端关于钱的陈述不可信,结论一律以通道回执为准。 |
| | | */ |
| | | public record PendingOrderAbandonRequest(@NotNull Long talentId, String paymentNo) { |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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, "无权操作其他人才用户的订单"); |
| | |
| | | * 到期时间不等于没收到钱。这些订单交给对账任务先向通道问清(已收款就落成已支付,通道说失败就 |
| | | * 把流水置为已失败),下一轮清理自然会收掉它们。到期就自动取消会把已经收下的钱变成一笔没有 |
| | | * 订单、也无法退款的悬账。 |
| | | * |
| | | * <p>{@code CANCEL_UNCONFIRMED} 与 {@code CLOSING} 一并排除:它们是「关不掉的预支付单」, |
| | | * 通道既没说关掉也没说钱收下了,所以在通道侧仍然可能被支付。自动取消这样一笔订单,等于把已经 |
| | | * 收下的钱变成没有出口的悬账。它们由关单补偿给出终止条件(重试上限后落成已取消并保留盯防)。 |
| | | */ |
| | | @Transactional |
| | | public int expireDueOrders(Long talentId) { |
| | |
| | | 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); |
| | |
| | | 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, "支付已发起,不能再修改优惠券"); |
| | |
| | | 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); |
| | |
| | | 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(""" |
| | |
| | | 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); |
| | | } |
| | |
| | | |
| | | @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; |
| | |
| | | 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); |
| | |
| | | @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) { |
| New file |
| | |
| | | -- 支付尝试按「这一次尝试」收敛所需要的两列(判定见 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; |
| | |
| | | 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, "卓越人才卡"); |
| | |
| | | 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; |
| | |
| | | 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); |
| | |
| | | } |
| | | |
| | | @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); |
| | |
| | | } |
| | | |
| | | @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") |
| | |
| | | } |
| | | |
| | | @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 |
| | |
| | | /** 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); |
| | | |
| | |
| | | } |
| | | |
| | | 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); |
| | | } |