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