fix(payment): 退款结果在读订单与列表时收敛,并修掉通道流水号被覆盖

退款提交时通道只回「处理中」,最终结果只有通道知道;退款通知和支付通知一样到不了本地服务,
于是本地只能等对账任务的下一个窗口。开发库实测:14:37:31 提交退款、14:43:41 才对账收敛,中间
六分钟里钱已经退回人才账户,人才端订单却仍显示「待核销」,商户端也当它是「可核销的订单」——
钱到账和订单状态对不上,商户去核销又会被资金边界拒绝。

读路径主动收敛:
- HuifuRefundService 新增 confirmPendingRefunds(按人才)/ confirmPendingRefundsOfMerchant
(按商户),把在途退款各问通道一次。自己的事务、忽略单笔失败,且必须是调用方事务的第一条
读语句——MySQL 的一致性快照是在那条读语句上定下的,排在后面就只能读到收敛前的旧快照。
提交后 20 秒内不查,避免刚提交就打通道;一轮最多两笔,读列表不会被拖长。
- 人才端读订单(待使用/订单状态页)与读订单列表、商户端读订单列表都先收敛一次。

状态词表补上退款维度(原来只看支付/核销,退款中的订单一律落到「待核销」):
- 人才端列表:refund_status 非 0 的订单归入「已取消」,订单状态页区分退款中 / 退款成功 /
退款失败;退款中的订单也能打开这一页(此前报「订单当前未取消」)。未支付就取消的订单语义
不变:仍是「已取消 / 无需退款」,由 CancelledOrderStateTests 守住。
- 商户端:新增 cancelled / refunded 两个状态(文案区分「退款中」「已退款」),「待核销」筛选
排除退款订单,新增「已取消/已退款」筛选;列表徽标走接口文案并给灰色样式,点击已取消/已退款
订单进详情看状态(不再按「已完成」打开),详情页显示对应标题与说明、核销时间显示 —。
- 退款中/已退款的订单不再发核销码:钱要退给人才、商户也核销不了,发了只会让双方白跑一趟。
- 人才端待使用页遇到已退款/已取消订单直接进订单状态页。

顺带修一个数据被覆盖的缺陷:查单确认退款成功时 provider_refund_no 被无条件写成空串——通道退款
流水号只在发起退款那次回执里有,退款查询回执不带它,于是唯一的通道凭证被擦掉(开发库 refund
44 与 43 都是空)。改为保留已有值,与其他路径同样的 coalesce(nullif(?, ''), ...) 写法。

测试:新增 PendingRefundConfirmationTests 8 项、CancelledOrderStateTests 2 项,MerchantWorkServiceTests
增至 7 项。其中「通道流水号不被覆盖」那项退掉修复验证过会红(but was: "")。
全量 273 项,仅 docs/agents/testing.md 所列 3 项基线失败,无新增。
2个文件已添加
9个文件已修改
568 ■■■■■ 已修改文件
rencaika/pages/pending-use/pending-use.uvue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/config/order.uts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/order-detail/order-detail.uvue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/orders/orders.uvue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingUsePortalService.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantWorkServiceTests.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/CancelledOrderStateTests.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java 235 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/pending-use/pending-use.uvue
@@ -78,7 +78,13 @@
    onUnload(() => stopRefresh())
    const goBack = () => safeBack('/pages/order/order')
    const loadOrder = () => getPendingUseOrder(orderId.value, talentId.value, (data:any) => { productName.value=data.productNameSnapshot||productName.value; productCoverUrl.value=resolveApiUrl(data.productCoverUrlSnapshot||'/static/home-assets/top.jpg'); originalAmount.value=Number(data.originalAmount).toFixed(2); payAmount.value=Number(data.payAmount).toFixed(2); discountAmount.value=Number(data.discountAmount).toFixed(2); merchantName.value=data.merchantNameSnapshot||''; merchantAddress.value=data.merchantAddressSnapshot||''; merchantMobile.value=data.merchantMobileSnapshot||''; merchantLatitude.value=Number(data.merchantLatitudeSnapshot); merchantLongitude.value=Number(data.merchantLongitudeSnapshot); createdAt.value=(''+data.createdAt).replace('T',' ').substring(0,19); verifyTime.value=data.verifyTime?(''+data.verifyTime).replace('T',' ').substring(0,19):'-'; canRefund.value=data.canRefund===true }, () => {})
    const loadOrder = () => getPendingUseOrder(orderId.value, talentId.value, (data:any) => {
        // 已退款(含退款中)或已取消的订单不再是「待使用」:这一页不发核销码,直接进订单状态页看退款结果。
        if (Number(data.orderStatus) == 2 || data.refundStatus != null) {
            uni.redirectTo({ url: '/pages/order-cancelled/order-cancelled?recordId=' + encodeURIComponent(recordId.value) + '&orderId=' + encodeURIComponent(orderId.value) + '&talentId=' + encodeURIComponent(talentId.value) })
            return
        }
        productName.value=data.productNameSnapshot||productName.value; productCoverUrl.value=resolveApiUrl(data.productCoverUrlSnapshot||'/static/home-assets/top.jpg'); originalAmount.value=Number(data.originalAmount).toFixed(2); payAmount.value=Number(data.payAmount).toFixed(2); discountAmount.value=Number(data.discountAmount).toFixed(2); merchantName.value=data.merchantNameSnapshot||''; merchantAddress.value=data.merchantAddressSnapshot||''; merchantMobile.value=data.merchantMobileSnapshot||''; merchantLatitude.value=Number(data.merchantLatitudeSnapshot); merchantLongitude.value=Number(data.merchantLongitudeSnapshot); createdAt.value=(''+data.createdAt).replace('T',' ').substring(0,19); verifyTime.value=data.verifyTime?(''+data.verifyTime).replace('T',' ').substring(0,19):'-'; canRefund.value=data.canRefund===true }, () => {})
    const stopRefresh=()=>{if(refreshTimer!=0){clearTimeout(refreshTimer);refreshTimer=0}}
    const refreshCode=()=>{stopRefresh(); if(orderId.value=='')return; codeError.value=false; issueOrderVerificationCode(orderId.value,talentId.value,(data:any)=>{codeImage.value=resolveApiUrl(data.imagePath as string);codeText.value='核销码 '+(data.displayCode as string)+',请向商家出示';refreshTimer=setTimeout(()=>refreshCode(),Math.max(1,Number(data.refreshAfterSeconds)-3)*1000)},()=>{codeImage.value='';codeError.value=true;codeText.value='点击重新生成核销码'})}
    const openNavigation = () => { if(merchantLatitude.value==0||merchantLongitude.value==0){uni.showToast({title:'商户暂未配置导航地址',icon:'none'});return};uni.openLocation({latitude:merchantLatitude.value,longitude:merchantLongitude.value,name:merchantName.value,address:merchantAddress.value,scale:16}) }
rencaika_merchant/config/order.uts
@@ -1,5 +1,5 @@
export type OrderCategory = 'group' | 'store' | 'stay'
export type OrderStatus = 'all' | 'pending_verification' | 'pending_payment' | 'completed'
export type OrderStatus = 'all' | 'pending_verification' | 'pending_payment' | 'completed' | 'closed'
export const getPendingStatusByCategory = (category: string): OrderStatus => {
    if (category == 'store') return 'pending_payment'
@@ -10,5 +10,6 @@
    if (status == 'pending_verification') return '待核销'
    if (status == 'pending_payment') return '待支付'
    if (status == 'completed') return '已完成'
    if (status == 'closed') return '已取消/已退款'
    return '全部'
}
rencaika_merchant/pages/order-detail/order-detail.uvue
@@ -72,7 +72,9 @@
        paidAmount.value = formatCents(detail.payAmount)
        subsidyAmount.value = formatCents(detail.subsidyAmount)
        createdAt.value = detail.createdAt.replace('T', ' ')
        verificationAt.value = detail.verifyTime.length > 0 ? detail.verifyTime.replace('T', ' ') : '待核销'
        // 已取消/已退款:订单已经离开可核销范围,不能再说成「待核销」,也没有核销时间。
        const closed = detail.status == 'cancelled' || detail.status == 'refunded'
        verificationAt.value = detail.verifyTime.length > 0 ? detail.verifyTime.replace('T', ' ') : (closed ? '—' : '待核销')
        customerName.value = detail.customerName
        customerPhone.value = detail.customerMobile
        cardLevel.value = detail.cardLevel.length > 0 ? detail.cardLevel : '人才卡'
@@ -82,9 +84,15 @@
        isStoreCompleted.value = detail.category == 'store' && detail.status == 'completed'
        canRevokeVerification.value = detail.canRevoke
        revokeExpiredMessage.value = detail.revokeReason.length > 0 ? detail.revokeReason : '已过可撤销时间'
        statusTitle.value = isCompleted.value ? '已完成' : '待核销'
        statusDesc.value = isCompleted.value ? '订单已完成核销,相关权益已结算。' : '订单已完成支付,请确认后即可核销。'
        statusIcon.value = isCompleted.value ? '/static/icons/lucide/badge-check.svg' : '/static/icons/lucide/clock-3.svg'
        if (closed) {
            statusTitle.value = detail.statusText.length > 0 ? detail.statusText : (detail.status == 'cancelled' ? '已取消' : '已退款')
            statusDesc.value = detail.status == 'cancelled' ? '订单在支付前已取消,无需核销。' : '退款已受理或完成,款项退回人才原支付账户,订单不再核销。'
            statusIcon.value = '/static/icons/lucide/x.svg'
        } else {
            statusTitle.value = isCompleted.value ? '已完成' : '待核销'
            statusDesc.value = isCompleted.value ? '订单已完成核销,相关权益已结算。' : '订单已完成支付,请确认后即可核销。'
            statusIcon.value = isCompleted.value ? '/static/icons/lucide/badge-check.svg' : '/static/icons/lucide/clock-3.svg'
        }
    }
    onLoad((options: any) => {
rencaika_merchant/pages/orders/orders.uvue
@@ -19,7 +19,7 @@
            <view v-if="listLoadState == 'success'" class="order-list">
                <view v-for="order in orders" :key="order.id" class="order-card" :class="order.category == 'stay' ? 'stay-order-card' : (order.category == 'store' ? 'store-order-card' : '')" @tap="openOrderItem(order)">
                    <view class="customer-row"><view class="customer-info"><image class="customer-icon" src="/static/icons/lucide/circle-user-round.svg" mode="aspectFit"></image><text class="customer-name">{{order.customerName}}</text><text class="customer-phone">{{order.customerMobile}}</text></view><text class="status" :class="order.status == 'completed' ? 'verified' : 'pending'">{{order.statusText}}</text></view>
                    <view class="customer-row"><view class="customer-info"><image class="customer-icon" src="/static/icons/lucide/circle-user-round.svg" mode="aspectFit"></image><text class="customer-name">{{order.customerName}}</text><text class="customer-phone">{{order.customerMobile}}</text></view><text class="status" :class="order.status == 'completed' ? 'verified' : ((order.status == 'cancelled' || order.status == 'refunded') ? 'closed' : 'pending')">{{order.statusText}}</text></view>
                    <text v-if="order.category != 'stay'" class="order-name">{{order.title}}</text>
                    <view v-if="order.category == 'stay'" class="stay-title-row"><text class="stay-order-name">{{order.title}}</text><text class="card-tag">{{order.cardLevel}}</text></view>
                    <view v-else class="price-row" :class="order.category == 'store' ? 'store-price-row' : ''"><view class="price-group"><text class="price">¥{{formatAmount(order.payAmount)}}</text><text v-if="order.orderAmount > order.payAmount" class="original-price">¥{{formatAmount(order.orderAmount)}}</text></view><text class="card-tag">{{order.cardLevel}}</text></view>
@@ -123,6 +123,7 @@
                <view class="status-option" :class="statusFilter == 'all' ? 'selected' : ''" @tap="selectStatus('all')"><text class="status-option-text">全部</text><text v-if="statusFilter == 'all'" class="status-selected-mark">已选</text></view>
                <view class="status-option" :class="statusFilter == pendingStatus ? 'selected' : ''" @tap="selectStatus(pendingStatus)"><text class="status-option-text">{{pendingStatusText}}</text><text v-if="statusFilter == pendingStatus" class="status-selected-mark">已选</text></view>
                <view class="status-option" :class="statusFilter == 'completed' ? 'selected' : ''" @tap="selectStatus('completed')"><text class="status-option-text">已完成</text><text v-if="statusFilter == 'completed'" class="status-selected-mark">已选</text></view>
                <view class="status-option" :class="statusFilter == 'closed' ? 'selected' : ''" @tap="selectStatus('closed')"><text class="status-option-text">已取消/已退款</text><text v-if="statusFilter == 'closed'" class="status-selected-mark">已选</text></view>
            </view>
            <view class="status-cancel" @tap="closeStatusFilter"><text class="status-cancel-text">取消</text></view>
        </view>
@@ -286,6 +287,11 @@
        const query = 'id=' + encodeURIComponent(order.id) + '&orderNo=' + encodeURIComponent(order.orderNo)
        if (order.status == 'pending_verification') {
            uni.navigateTo({ url: '/pages/verification-detail/verification-detail?' + query })
            return
        }
        // 已取消/已退款的订单进详情看状态,不能按「已完成」打开,否则会把取消说成完成。
        if (order.status == 'cancelled' || order.status == 'refunded') {
            uni.navigateTo({ url: '/pages/order-detail/order-detail?status=' + order.status + '&' + query })
            return
        }
        uni.navigateTo({ url: '/pages/order-detail/order-detail?status=completed&' + query })
@@ -529,6 +535,7 @@
    .status { font-size: 24rpx; line-height: 34rpx; font-weight: 600; }
    .pending { color: #FF5A42; }
    .verified { color: #98A3B4; }
    .closed { color: #98A3B4; }
    .order-name { color: #182236; font-size: 31rpx; line-height: 44rpx; font-weight: 700; margin-top: 26rpx; }
    .price-row { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-top: 18rpx; }
    .store-price-row { margin-top: 24rpx; }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -17,6 +17,7 @@
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.business.payment.PaymentFundConfirmationService;
import com.talentcard.server.business.payment.HuifuRefundService;
import com.talentcard.server.business.portal.ProductPriceCalculator;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -36,14 +37,17 @@
    private final ManualConsumptionService manualConsumptionService;
    private final OrderFundBoundaryService orderFundBoundary;
    private final com.talentcard.server.business.order.OrderVerificationService orderVerificationService;
    private final HuifuRefundService refundService;
    public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService,
            OrderFundBoundaryService orderFundBoundary,
            com.talentcard.server.business.order.OrderVerificationService orderVerificationService) {
            com.talentcard.server.business.order.OrderVerificationService orderVerificationService,
            HuifuRefundService refundService) {
        this.jdbcTemplate = jdbcTemplate;
        this.manualConsumptionService = manualConsumptionService;
        this.orderFundBoundary = orderFundBoundary;
        this.orderVerificationService = orderVerificationService;
        this.refundService = refundService;
    }
    @Transactional(readOnly = true)
@@ -51,6 +55,10 @@
        if (query != null && "stay".equals(normalize(query.category()))) {
            return freeStayOrders(merchantId, storeId, page, size, query);
        }
        // 退款到不到最终结果只有通道知道,退款通知又会遇到和支付通知一样的问题(本地/内网环境收不到)。
        // 读列表之前先把这家商户在途的退款收敛一次,免得商户看到的退款状态比人才端晚几分钟。这一步必须
        // 排在本事务的第一条读语句之前,否则列表读到的是收敛前的旧快照。免费入住没有退款,走上面的分支。
        refundService.confirmPendingRefundsOfMerchant(merchantId, HuifuRefundService.PENDING_CONFIRM_LIMIT);
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        OrderFilters filters = filters(query);
@@ -64,7 +72,7 @@
                select o.id, o.order_no, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
                       coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level,
                       b.benefit_name, o.product_name_snapshot, o.order_type, o.order_amount, o.subsidy_amount, o.pay_amount,
                       o.order_status, o.verify_status, o.created_at, verified.verify_time
                       o.order_status, o.verify_status, o.refund_status, o.created_at, verified.verify_time
                from benefit_order o
                left join talent_user tu on tu.id = o.talent_id
                left join talent_card tc on tc.id = o.card_id
@@ -92,7 +100,7 @@
                       o.merchant_id, m.merchant_name, m.merchant_rights_config,
                       o.product_id, o.product_name_snapshot, p.rights_config,
                       o.order_type, o.order_amount, o.base_pay_amount, o.coupon_amount,
                       o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.created_at,
                       o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.refund_status, o.created_at,
                       vr.verify_time, vr.status as verify_record_status
                from benefit_order o
                left join talent_user tu on tu.id = o.talent_id
@@ -109,7 +117,7 @@
                           r.card_id, tc.card_no, tc.card_level, r.merchant_id, m.merchant_name, m.merchant_rights_config,
                           '免费入住' as benefit_name, '1天免费入住' as product_name_snapshot,
                           'FREE_STAY' as order_type, 0 as order_amount, 0 as base_pay_amount, 0 as coupon_amount,
                           r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status,
                           r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status, 0 as refund_status,
                           r.verify_time as created_at, r.verify_time, r.status as verify_record_status
                    from verify_record r
                    left join talent_user tu on tu.id = r.talent_id
@@ -150,7 +158,7 @@
                       r.card_id, tc.card_level, r.merchant_id, m.merchant_name,
                       '免费入住' as benefit_name, '1天免费入住' as product_name_snapshot,
                       'FREE_STAY' as order_type, 0 as order_amount, 0 as base_pay_amount, 0 as coupon_amount,
                       r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status,
                       r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status, 0 as refund_status,
                       r.verify_time as created_at, r.verify_time
                from verify_record r
                left join talent_user tu on tu.id = r.talent_id
@@ -301,14 +309,20 @@
        String category = "GROUP_BUY".equals(orderType) ? "group" : ("STORE_CONSUME".equals(orderType) ? "store" : "stay");
        Long orderStatus = number(order.get("orderStatus"));
        Long verifyStatus = number(order.get("verifyStatus"));
        Long refundStatus = number(order.get("refundStatus"));
        LocalDateTime verifyTime = time(order.get("verifyTime"));
        // 核销状态必须同时有有效核销记录和核销时间。历史数据可能只更新了
        // benefit_order.verify_status,不能据此把订单展示为已完成。
        boolean verified = verifyStatus != null && verifyStatus == 1 && verifyTime != null;
        String status = orderStatus != null && orderStatus == 0 ? "pending_payment" : (verified ? "completed" : "pending_verification");
        // 与列表同一口径:待支付 → 退款中/已退款 → 已取消 → 已完成 → 待核销。
        String status;
        if (orderStatus != null && orderStatus == 0) status = "pending_payment";
        else if (refundStatus != null && refundStatus != 0) status = "refunded";
        else if (orderStatus != null && orderStatus == 2) status = "cancelled";
        else status = verified ? "completed" : "pending_verification";
        result.put("category", category);
        result.put("status", status);
        result.put("statusText", "pending_payment".equals(status) ? "待支付" : ("completed".equals(status) ? "已完成" : "待核销"));
        result.put("statusText", statusText(status, refundStatus));
        result.put("customerName", displayName(order.get("talentName"), number(order.get("gender"))));
        result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile"))));
        result.put("title", orderTitle(order, category));
@@ -363,9 +377,12 @@
        String status = normalize(value.status());
        if (!status.isEmpty() && !"all".equals(status)) {
            switch (status) {
                case "pending_verification" -> conditions.append(" and o.order_status = 1 and o.verify_status = 0");
                // 待核销要排除退款中/已退款的订单:它们已经离开可核销范围,点核销只会被资金边界拒绝。
                case "pending_verification" -> conditions.append(" and o.order_status = 1 and o.verify_status = 0 and o.refund_status = 0");
                case "pending_payment" -> conditions.append(" and o.order_status = 0");
                case "completed" -> conditions.append(" and o.order_status = 1 and o.verify_status = 1");
                // 离开支付流程的订单:未支付被取消,或已经退款(含退款中)。
                case "closed" -> conditions.append(" and (o.order_status = 2 or o.refund_status <> 0)");
                default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "订单状态无效");
            }
        }
@@ -388,10 +405,11 @@
    private Map<String, Object> toListItem(Map<String, Object> order) {
        Map<String, Object> result = new LinkedHashMap<>(order);
        String category = category(String.valueOf(order.get("orderType")));
        String status = status(number(order.get("orderStatus")), number(order.get("verifyStatus")));
        Long refundStatus = number(order.get("refundStatus"));
        String status = status(number(order.get("orderStatus")), number(order.get("verifyStatus")), refundStatus);
        result.put("category", category);
        result.put("status", status);
        result.put("statusText", statusText(status));
        result.put("statusText", statusText(status, refundStatus));
        result.put("customerName", displayName(order.get("talentName"), number(order.get("gender"))));
        result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile"))));
        result.put("title", orderTitle(order, category));
@@ -484,15 +502,21 @@
        return "group";
    }
    private String status(Long orderStatus, Long verifyStatus) {
    private String status(Long orderStatus, Long verifyStatus, Long refundStatus) {
        if (orderStatus != null && orderStatus == 0) return "pending_payment";
        // 退款中/已退款、已取消的订单都已经离开「待核销」:钱退给了人才,或者订单根本没付过钱。
        // 把它们兜底成「待核销」会让商户以为还有一单要核销,点核销又会被资金边界拒绝。
        if (refundStatus != null && refundStatus != 0) return "refunded";
        if (orderStatus != null && orderStatus == 2) return "cancelled";
        if (verifyStatus != null && verifyStatus == 1) return "completed";
        return "pending_verification";
    }
    private String statusText(String status) {
    private String statusText(String status, Long refundStatus) {
        if ("pending_payment".equals(status)) return "待支付";
        if ("completed".equals(status)) return "已完成";
        if ("cancelled".equals(status)) return "已取消";
        if ("refunded".equals(status)) return refundStatus != null && refundStatus == 1 ? "退款中" : "已退款";
        return "待核销";
    }
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
@@ -13,6 +13,8 @@
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
@@ -98,6 +100,55 @@
        return detail(refundNo);
    }
    /** 刚提交的退款通道必然还在处理,留一点时间再去问,避免用户一打开页面就打通道。 */
    private static final int SUBMISSION_GRACE_SECONDS = 20;
    /** 一轮最多收敛几笔在途退款:正常用户同一时刻只有一笔,列表读路径不该被它拖长。 */
    public static final int PENDING_CONFIRM_LIMIT = 2;
    /**
     * 把这位人才名下还在途的退款各自向通道确认一次。
     *
     * <p>退款提交时通道多半只回「处理中」,最终结果只有通道知道;退款通知又会遇到和支付通知一样的
     * 问题——本地或内网环境收不到。于是在读订单/读订单列表之前问一次,把「退款中」尽快收敛成通道的
     * 最终答案,而不是等对账任务的下一个窗口(最长十分钟)。已经落定的退款连查都不查。
     *
     * <p>在自己的事务里执行,且必须是调用方事务里的**第一条**语句:调用方的事务要能读到收敛后的状态,
     * 而 MySQL 的一致性快照是在该事务的第一条读语句上定下的。单笔查询失败只跳过这一笔——读路径上的
     * 尽力收敛,查不到结果不该让订单列表打不开,剩下的交给对账任务和下一次读。
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public int confirmPendingRefunds(Long talentId, int limit) {
        return confirmPendingRefunds("o.talent_id", talentId, limit);
    }
    /** 同上,按商户归属收敛:商户端订单列表读之前调用,免得商户看到的退款状态比人才端晚几分钟。 */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public int confirmPendingRefundsOfMerchant(Long merchantId, int limit) {
        return confirmPendingRefunds("o.merchant_id", merchantId, limit);
    }
    private int confirmPendingRefunds(String ownerColumn, Long ownerId, int limit) {
        if (ownerId == null) return 0;
        List<Map<String, Object>> pending = rows("""
                select r.refund_no
                from order_refund r
                join benefit_order o on o.id = r.order_id
                where r.deleted = 0 and r.provider = ? and r.refund_status = 1
                  and o.deleted = 0 and %s = ?
                  and r.created_at <= date_sub(now(), interval ? second)
                order by r.id desc
                limit ?
                """.formatted(ownerColumn), PROVIDER, ownerId, SUBMISSION_GRACE_SECONDS, Math.max(1, limit));
        for (Map<String, Object> row : pending) {
            try {
                query(text(row.get("refundNo")));
            } catch (RuntimeException ignored) {
                // 这一笔这次问不到,保持在途;对账任务与下一次读会再来。
            }
        }
        return pending.size();
    }
    public Map<String, Object> detail(String refundNo) {
        List<Map<String, Object>> rows = rows("""
                select r.id, r.order_id, r.refund_no, r.order_no, r.order_type, r.paid_amount, r.refund_amount,
@@ -165,9 +216,12 @@
    private void markSucceeded(PreparedRefund prepared, ProviderResult result) {
        transactions.executeWithoutResult(status -> {
            // 通道退款流水号只在发起退款的那次回执里有;退款查询的回执不带它。这里必须保留已有的号,
            // 否则查单确认会把唯一的通道凭证覆盖成空,退款排查和对账就失去定位依据。
            jdbc.update("""
                    update order_refund
                    set refund_status = 2, provider_refund_no = ?, provider_response_code = ?, provider_response_message = ?, refund_time = now()
                    set refund_status = 2, provider_refund_no = coalesce(nullif(?, ''), provider_refund_no),
                        provider_response_code = ?, provider_response_message = ?, refund_time = now()
                    where refund_no = ? and refund_status = 1
                    """, result.providerNo(), result.code(), result.message(), prepared.refundNo());
            jdbc.update("""
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingUsePortalService.java
@@ -15,6 +15,7 @@
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.order.OrderRefundRequest;
import com.talentcard.server.business.order.OrderService;
import com.talentcard.server.business.payment.HuifuRefundService;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -23,9 +24,16 @@
@Service
public class PendingUsePortalService {
    private final JdbcTemplate jdbc; private final OrderService orderService;
    public PendingUsePortalService(JdbcTemplate jdbc, OrderService orderService) { this.jdbc = jdbc; this.orderService = orderService; }
    @Transactional(readOnly = true) public Map<String,Object> detail(String orderNo, Long talentId) { return view(find(orderNo, talentId)); }
    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;
    }
    @Transactional(readOnly = true) public Map<String,Object> detail(String orderNo, Long talentId) {
        // 退款通知和支付通知一样可能到不了,而退款到不到最终结果只有通道知道。先收敛在途的退款,再读订单:
        // 这一步必须排在本事务的第一条读语句之前,否则读到的是收敛前的旧快照。
        refundService.confirmPendingRefunds(talentId, HuifuRefundService.PENDING_CONFIRM_LIMIT);
        return view(find(orderNo, talentId));
    }
    @Transactional(readOnly = true) public Map<String,Object> completedDetail(String orderNo, Long talentId) {
        Map<String,Object> result = detail(orderNo, talentId);
        if (!Boolean.TRUE.equals(result.get("verified"))) throw new BusinessException(ErrorCode.CONFLICT, "订单尚未完成核销");
@@ -33,7 +41,10 @@
    }
    @Transactional(readOnly = true) public Map<String,Object> cancelledDetail(String orderNo, Long talentId) {
        Map<String,Object> result = detail(orderNo, talentId);
        if (number(result.get("orderStatus")) != 2) throw new BusinessException(ErrorCode.CONFLICT, "订单当前未取消");
        // 退款中的订单也要能打开这一页:退款已经受理就不能再核销,「退款中」是它此刻的真实状态。
        if (number(result.get("orderStatus")) != 2 && number(result.get("refundStatus")) == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单当前未取消");
        }
        long refundStatus = number(result.get("refundStatus"));
        result.put("cancelTime", result.get("refundTime") == null ? result.get("updatedAt") : result.get("refundTime"));
        result.put("cancelSource", refundStatus == 0 ? "订单取消" : "退款处理");
@@ -44,7 +55,9 @@
        return result;
    }
    @Transactional public Map<String,Object> issueCode(String orderNo, Long talentId) {
        Map<String,Object> order = find(orderNo, 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, "订单当前不可核销");
        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);
        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));
talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java
@@ -7,6 +7,7 @@
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.payment.HuifuRefundService;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -21,9 +22,11 @@
    );
    private final JdbcTemplate jdbcTemplate;
    private final HuifuRefundService refundService;
    public UsageRecordPortalService(JdbcTemplate jdbcTemplate) {
    public UsageRecordPortalService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService) {
        this.jdbcTemplate = jdbcTemplate;
        this.refundService = refundService;
    }
    @Transactional(readOnly = true)
@@ -37,6 +40,10 @@
        if (status != null && !status.isBlank() && !SUPPORTED_STATUSES.contains(status)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的记录状态");
        }
        // 退款到不到最终结果只有通道知道,退款通知又会遇到和支付通知一样的问题(本地/内网环境收不到)。
        // 所以在读列表之前先把在途退款收敛一次——否则刚退完款的订单会一直显示成「待核销」。这一步必须排在
        // 本事务的第一条读语句之前,否则列表读到的是收敛前的旧快照。
        refundService.confirmPendingRefunds(talentId, HuifuRefundService.PENDING_CONFIRM_LIMIT);
        Integer talentCount = jdbcTemplate.queryForObject(
                "select count(*) from talent_user where id = ? and deleted = 0", Integer.class, talentId);
        if (talentCount == null || talentCount == 0) {
@@ -81,7 +88,7 @@
                       o.created_at as occurred_at,
                       case
                         when o.order_status = 0 then 'pending_payment'
                         when o.order_status = 2 then 'cancelled'
                         when o.order_status = 2 or o.refund_status <> 0 then 'cancelled'
                         when o.verify_status = 1 then 'completed'
                         else 'pending_verification'
                       end as status,
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantWorkServiceTests.java
@@ -107,6 +107,67 @@
                .containsEntry("subsidyAmount", 14799L);
    }
    @Test
    void aCancelledOrderIsShownAsCancelledInsteadOfWaitingToBeVerified() {
        String orderNo = insertOrder("GROUP_BUY", 2, 0, LocalDateTime.now());
        assertThat(recordOf(list("group", "all"), orderNo))
                .containsEntry("status", "cancelled")
                .containsEntry("statusText", "已取消");
        assertThat(orderNos(list("group", "pending_verification")))
                .as("已取消的订单不是待核销:商户照它去核销只会被资金边界拒绝")
                .doesNotContain(orderNo);
        assertThat(orderNos(list("group", "closed"))).contains(orderNo);
    }
    @Test
    void aRefundedOrderIsShownAsRefundedAndStopsWaitingToBeVerified() {
        String orderNo = insertOrder("GROUP_BUY", 1, 0, LocalDateTime.now());
        jdbc.update("update benefit_order set refund_status = 2 where order_no = ?", orderNo);
        assertThat(recordOf(list("group", "all"), orderNo))
                .containsEntry("status", "refunded")
                .containsEntry("statusText", "已退款");
        assertThat(orderNos(list("group", "pending_verification"))).doesNotContain(orderNo);
        assertThat(orderNos(list("group", "closed"))).contains(orderNo);
    }
    @Test
    void aRefundInFlightIsShownAsRefunding() {
        String orderNo = insertOrder("GROUP_BUY", 1, 0, LocalDateTime.now());
        jdbc.update("update benefit_order set refund_status = 1 where order_no = ?", orderNo);
        assertThat(recordOf(list("group", "all"), orderNo)).containsEntry("statusText", "退款中");
        assertThat(orderNos(list("group", "pending_verification"))).doesNotContain(orderNo);
    }
    @Test
    void theOrderDetailCarriesTheSameClosedState() {
        String orderNo = insertOrder("GROUP_BUY", 1, 0, LocalDateTime.now());
        jdbc.update("update benefit_order set refund_status = 1 where order_no = ?", orderNo);
        assertThat(merchantWorkService.order(orderNo, merchantId, storeId))
                .containsEntry("status", "refunded")
                .containsEntry("statusText", "退款中");
        assertThat(merchantWorkService.order(insertOrder("GROUP_BUY", 2, 0, LocalDateTime.now()), merchantId, storeId))
                .containsEntry("status", "cancelled");
    }
    private PageResult<Map<String, Object>> list(String category, String status) {
        return merchantWorkService.orders(merchantId, storeId, 1, 50, new MerchantOrderQuery(category, status, null, null));
    }
    private Map<String, Object> recordOf(PageResult<Map<String, Object>> page, String orderNo) {
        return page.records().stream()
                .filter(record -> orderNo.equals(String.valueOf(record.get("orderNo"))))
                .findFirst()
                .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the merchant order list"));
    }
    private java.util.List<String> orderNos(PageResult<Map<String, Object>> page) {
        return page.records().stream().map(record -> String.valueOf(record.get("orderNo"))).toList();
    }
    private String insertOrder(String type, int orderStatus, int verifyStatus, LocalDateTime createdAt) {
        String orderNo = "ORDER-" + System.nanoTime();
        jdbc.update("""
talent-card-server/src/test/java/com/talentcard/server/business/payment/CancelledOrderStateTests.java
New file
@@ -0,0 +1,98 @@
package com.talentcard.server.business.payment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.util.Map;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
import com.talentcard.server.business.portal.PendingUsePortalService;
import com.talentcard.server.business.portal.UsageRecordPortalService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
 * Seam: how the talent app reads an order that was cancelled before it was ever paid.
 *
 * <p>「已取消」covers two different stories and they must not be merged: an order the talent
 * abandoned while it was still unpaid(订单取消,无需退款)and an order whose money was given back
 * (退款处理,退款中 / 退款成功 / 退款失败). The first one never involved money, so it asks the
 * provider nothing and reads as a plain cancellation; the refund dimension only adds a reason to the
 * same state, it does not take the state over.
 */
@SpringBootTest
@ActiveProfiles("test")
class CancelledOrderStateTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private UsageRecordPortalService usageRecordPortalService;
    @Autowired
    private PendingUsePortalService pendingUsePortalService;
    @MockitoBean
    private HuifuRefundGateway refundGateway;
    private Long talentId;
    private String suffix;
    @BeforeEach
    void prepareData() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "cancelled-order-openid-" + suffix, "Cancelled order user", "13900000005");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
    }
    @Test
    void anOrderCancelledBeforePaymentIsStillListedAsCancelled() {
        String orderNo = cancelledUnpaidOrder();
        assertThat(recordOf(orderNo)).containsEntry("status", "cancelled");
    }
    @Test
    void anOrderCancelledBeforePaymentReadsAsACancellationThatNeedsNoRefund() {
        String orderNo = cancelledUnpaidOrder();
        Map<String, Object> detail = pendingUsePortalService.cancelledDetail(orderNo, talentId);
        assertThat(detail)
                .containsEntry("cancelSource", "订单取消")
                .containsEntry("refundStatusText", "无需退款");
        assertThat(((Number) detail.get("actualPaidAmount")).longValue())
                .as("没付过钱就不该显示退过钱")
                .isZero();
        verify(refundGateway, never()).queryRefund(any(), any());
    }
    /** An order the talent cancelled while it was still unpaid: no payment, no refund. */
    private String cancelledUnpaidOrder() {
        String orderNo = "CANCELLED-UNPAID-" + suffix;
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, order_type, order_amount, base_pay_amount, pay_amount,
                                          order_status, verify_status, product_name_snapshot)
                values (?, ?, 'GROUP_BUY', 2000, 2000, 2000, 2, 0, '取消测试订单')
                """, orderNo, talentId);
        return orderNo;
    }
    private Map<String, Object> recordOf(String orderNo) {
        PageResult<Map<String, Object>> page = usageRecordPortalService.list(talentId, null, 1, 50);
        return page.records().stream()
                .filter(record -> orderNo.equals(String.valueOf(record.get("businessId"))))
                .findFirst()
                .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the talent order list"));
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java
New file
@@ -0,0 +1,235 @@
package com.talentcard.server.business.payment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.MerchantOrderQuery;
import com.talentcard.server.business.merchant.MerchantWorkService;
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
import com.talentcard.server.business.portal.PendingUsePortalService;
import com.talentcard.server.business.portal.UsageRecordPortalService;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
 * Seam: a submitted refund is not finished until the provider says so, and the provider's
 * notification cannot be relied on. Reading the order must therefore ask the provider about a refund
 * that is still in flight, so the order stops looking verifiable the moment money goes back.
 *
 * <p>A refund in flight is never shown as "waiting to be verified": either the provider confirms it
 * (the order leaves the verifiable set for good) or it is still in flight (it stays unverifiable
 * until the provider decides). Only the provider's answer may change that.
 */
@SpringBootTest
@ActiveProfiles("test")
class PendingRefundConfirmationTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private PendingUsePortalService pendingUsePortalService;
    @Autowired
    private UsageRecordPortalService usageRecordPortalService;
    @Autowired
    private MerchantWorkService merchantWorkService;
    @MockitoBean
    private HuifuRefundGateway refundGateway;
    private Long talentId;
    private Long merchantId;
    private String huifuId;
    private String suffix;
    private long sequence;
    @BeforeEach
    void prepareData() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "pending-refund-openid-" + suffix, "Pending refund user", "13900000004");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)",
                "Pending refund merchant " + suffix, "08120000003", "Test address");
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8));
        sequence = 0;
    }
    @Test
    void openingAnOrderConvergesTheRefundThatIsStillInFlight() {
        RefundingOrder order = refundingOrder(1);
        providerSettlesTheRefund();
        Map<String, Object> detail = pendingUsePortalService.detail(order.orderNo(), talentId);
        verify(refundGateway).queryRefund(any(), any());
        assertThat(((Number) detail.get("refundStatus")).intValue()).isEqualTo(2);
        assertThat(jdbc.queryForMap("select order_status, refund_status from benefit_order where order_no = ?", order.orderNo()))
                .containsEntry("order_status", 2)
                .containsEntry("refund_status", 2);
        assertThat(jdbc.queryForObject("select refund_status from order_refund where refund_no = ?", Integer.class, order.refundNo()))
                .isEqualTo(2);
    }
    @Test
    void theRecordListConvergesTheRefundInsteadOfLeavingTheOrderWaitingToBeVerified() {
        RefundingOrder order = refundingOrder(1);
        providerSettlesTheRefund();
        Map<String, Object> record = recordOf(order.orderNo());
        verify(refundGateway).queryRefund(any(), any());
        assertThat(record).containsEntry("status", "cancelled");
    }
    @Test
    void anOrderWhoseRefundIsStillInFlightIsNotWaitingToBeVerifiedEither() {
        RefundingOrder order = refundingOrder(1);
        when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix)));
        assertThat(recordOf(order.orderNo())).containsEntry("status", "cancelled");
        assertThat(jdbc.queryForObject("select refund_status from order_refund where refund_no = ?", Integer.class, order.refundNo()))
                .as("通道还在处理就不能自行判定退款成功")
                .isEqualTo(1);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, order.orderNo()))
                .isEqualTo(1);
    }
    @Test
    void anAlreadyRefundedOrderIsNotWaitingToBeVerifiedAndAsksTheProviderNothing() {
        RefundingOrder order = refundingOrder(2);
        assertThat(recordOf(order.orderNo())).containsEntry("status", "cancelled");
        verify(refundGateway, never()).queryRefund(any(), any());
    }
    @Test
    void anOrderWithARefundInFlightOpensOnTheCancelledDetail() {
        RefundingOrder order = refundingOrder(1);
        when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix)));
        Map<String, Object> detail = pendingUsePortalService.cancelledDetail(order.orderNo(), talentId);
        assertThat(detail).containsEntry("refundStatusText", "退款中");
        assertThat(detail).containsEntry("cancelSource", "退款处理");
    }
    @Test
    void anOrderWithARefundStillInFlightDoesNotIssueAVerificationCode() {
        RefundingOrder order = refundingOrder(1);
        when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix)));
        assertThatThrownBy(() -> pendingUsePortalService.issueCode(order.orderNo(), talentId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("不可核销");
    }
    @Test
    void aRefundConfirmedByAQueryKeepsTheProviderReferenceItWasGiven() {
        RefundingOrder order = refundingOrder(1);
        // 退款查询回执里没有通道退款流水号(只有发起退款时那次回执有)。
        when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "sub_resp_desc", "查询成功")));
        pendingUsePortalService.detail(order.orderNo(), talentId);
        assertThat(jdbc.queryForObject("select provider_refund_no from order_refund where refund_no = ?", String.class, order.refundNo()))
                .as("查单确认不能把发起退款时拿到的通道退款流水号覆盖成空")
                .isEqualTo(order.providerRefundNo());
    }
    @Test
    void theMerchantOrderListConvergesTheRefundTooInsteadOfShowingItAsRefunding() {
        RefundingOrder order = refundingOrder(1);
        providerSettlesTheRefund();
        Map<String, Object> record = merchantRecordOf(order.orderNo());
        verify(refundGateway).queryRefund(any(), any());
        assertThat(record)
                .as("商户看到的退款状态不该比人才端晚几分钟")
                .containsEntry("status", "refunded")
                .containsEntry("statusText", "已退款");
    }
    /** A paid order whose refund was submitted and has no final provider answer yet. */
    private RefundingOrder refundingOrder(int refundStatus) {
        String orderNo = "REFUND-" + suffix + "-" + (++sequence);
        String refundNo = "R-REFUND-" + suffix + "-" + sequence;
        String paymentNo = "P-REFUND-" + suffix + "-" + sequence;
        String providerRefundNo = "HF-REFUND-SUBMIT-" + sequence;
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, merchant_id, order_type, order_amount, base_pay_amount,
                                          pay_amount, order_status, verify_status, refund_status, price_version,
                                          collect_huifu_id, product_name_snapshot)
                values (?, ?, ?, 'GROUP_BUY', 2000, 2000, 2000, 1, 0, ?, 1, ?, '退款测试订单')
                """, orderNo, talentId, merchantId, refundStatus, huifuId);
        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, paid_at)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, 2000, 'SUCCEEDED', ?, now())
                """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, "refund-payment-" + paymentNo);
        jdbc.update("""
                insert into order_refund(refund_no, order_id, order_no, talent_id, merchant_id, order_type,
                                         paid_amount, refund_amount, refundable_amount, refund_status,
                                         provider, provider_huifu_id, provider_refund_no,
                                         provider_original_req_seq_id, provider_original_req_date,
                                         reason, operator_name, created_at)
                values (?, ?, ?, ?, ?, 'GROUP_BUY', 2000, 2000, 0, ?, 'HUIFU', ?, ?, ?, ?, '用户申请退款', '人才端用户',
                        now() - interval 2 minute)
                """, refundNo, orderId, orderNo, talentId, merchantId, refundStatus, huifuId, providerRefundNo, paymentNo,
                java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd").format(java.time.LocalDate.now()));
        return new RefundingOrder(orderNo, refundNo, providerRefundNo);
    }
    private void providerSettlesTheRefund() {
        when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "sub_resp_desc", "交易成功", "hf_seq_id", "HF-REFUND-" + suffix)));
    }
    /** The record the talent's order list serves for this order. */
    private Map<String, Object> recordOf(String orderNo) {
        PageResult<Map<String, Object>> page = usageRecordPortalService.list(talentId, null, 1, 50);
        return page.records().stream()
                .filter(record -> orderNo.equals(String.valueOf(record.get("businessId"))))
                .findFirst()
                .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the talent order list"));
    }
    /** The record the merchant's order list serves for this order. */
    private Map<String, Object> merchantRecordOf(String orderNo) {
        PageResult<Map<String, Object>> page = merchantWorkService.orders(merchantId, null, 1, 50,
                new MerchantOrderQuery("group", "all", null, null));
        return page.records().stream()
                .filter(record -> orderNo.equals(String.valueOf(record.get("orderNo"))))
                .findFirst()
                .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the merchant order list"));
    }
    private Map<String, Object> providerResponse(Map<String, Object> data) {
        return Map.of("data", data);
    }
    private record RefundingOrder(String orderNo, String refundNo, String providerRefundNo) {
    }
}