yeg
2026-09-12 ec8e8f1c208abe54042add2124c9759d9b1369d1
feat(order): 核销幂等与退款边界按订单串行化

新增 OrderFundBoundaryService 统一判定并写入订单资金状态:核销、退款和提现锁定都先锁 benefit_order 同一行,因此按订单串行化。四条核销路径(商户订单核销、扫码确认、后台核销、酒店订单核销码)改用 requireVerifiable 守卫,写回改成带 verify_status = 0 条件的更新并校验影响行数;verify_record 增加生成列 active_order_id 与唯一索引,让同一订单只有一条有效核销记录成为数据库层事实。

benefit_order 新增 refund_status / withdrawal_status:退款受理成功即不可核销、不参与提现,本地响应、主动查询和异步回调三条链路统一收敛;回调宣告失败只在本事务真的把该笔退款置失败时归还订单,晚到的失败通知不会重开已成功退款的订单。lockIntoWithdrawal 作为工单 04 日汇总的登记入口,锁定后不能撤销核销或退款,商户端 canRevoke 同步为 false。

顺带修正 revokeVerification 幂等键归属的 Long 引用比较:订单 ID 超过 Long 缓存区间后,重复撤销会被误判为幂等键冲突。

测试:新增 OrderFundBoundaryTests 15 项(含并发核销、核销与退款竞态、退款回调收敛、过期订单),H2 核销用例补退款/提现锁列并放进真实事务;全量 142 项仅剩 3 项既有基线失败。
3个文件已添加
12个文件已修改
889 ■■■■■ 已修改文件
.scratch/automatic-withdrawal/issues/03-verification-refund-boundary.md 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/docs/huifu-payment-integration.md 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderFundBoundaryService.java 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentCallbackService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V132__order_verification_refund_boundary.sql 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/order/OrderFundBoundaryTests.java 414 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/automatic-withdrawal/issues/03-verification-refund-boundary.md
@@ -4,10 +4,64 @@
**Blocked by:** 02 — 支付订单与支付回调闭环
**Status:** ready-for-agent
**Status:** ready-for-human
- [ ] 未支付订单不能核销。
- [ ] 同一订单重复核销只产生一次有效核销结果。
- [ ] 已支付未核销订单可退款,退款后不得核销或提现。
- [ ] 核销、退款和提现锁定按订单串行化。
- [ ] 订单进入提现记录后不能撤销核销。
- [x] 未支付订单不能核销。
- [x] 同一订单重复核销只产生一次有效核销结果。
- [x] 已支付未核销订单可退款,退款后不得核销或提现。
- [x] 核销、退款和提现锁定按订单串行化。
- [x] 订单进入提现记录后不能撤销核销。
## Comments
已实现,主要落点:
- 新增 `OrderFundBoundaryService`:核销、退款、提现锁定共用的唯一资金边界。`lock(orderId)` 用
  `select ... for update` 锁 `benefit_order` 行并返回 `LockedOrder`(支付、核销、退款、提现锁定四个状态),
  带 `requireVerifiable / requireRefundable / requireRevocable` 三个断言;`markRefundInProgress /
  markRefunded / markRefundFailed / lockIntoWithdrawal` 是这四个状态的唯一写入口。`lock` 标注
  `@Transactional(MANDATORY)`:脱离事务直接失败,而不是静默失去串行化能力(其余方法是单条条件更新,
  本身原子)。
- 四条核销路径全部接入守卫:`MerchantWorkService.verify`(商户订单核销,并把幂等键复查移到行锁之后,
  同键并发返回首次结果)、`MerchantVerificationService.confirmOrderCode`(扫码确认)、`OrderService.verify`
  (后台核销)、`BenefitVerificationService.verifyHotelOrderCode`(酒店订单核销码)。写回统一改成
  `update benefit_order set verify_status = 1 where id = ? and verify_status = 0` 并校验影响行数,
  条件更新失败即 409。
- 核销幂等:`verify_record` 增加生成列 `active_order_id`(`status = 1` 时等于 `order_id`,否则 NULL)
  与唯一索引 `uk_verify_record_active_order`,同一订单在数据库层不可能有两条有效核销记录;迁移先把
  历史上同一订单的多条有效记录标记为已撤销(保留最早一条,撤销后仍可重新核销)。
- 退款边界:`HuifuRefundService.prepare` 先 `lock + requireRefundable`(未支付、已退款、退款中、已进入
  提现记录的整单拒退,原有「已核销不可退 + STORE_CONSUME 例外」保留),受理成功即置 `refund_status = 1`,
  查补成功置 2,失败回到 0。退款在途期间订单不可核销、不参与提现汇总;部分退款按人工确认的策略同样
  让整单退出可核销与可提现范围。
- 退款结果的三条链路都收敛到订单级状态:本地同步响应、`HuifuRefundService.query` 主动查询,以及
  `HuifuPaymentCallbackService.handleRefund` 异步回调。回调成功无条件把订单置为已退款;回调宣告失败
  只在本事务真的把该笔退款置为失败时才把订单退回未退款状态,因此晚到的失败通知不会重新打开一笔已成功
  退款的订单。
- 提现锁定:`lockIntoWithdrawal(orderId)` 只接受「已支付 + 已核销 + 未退款 + 未进入其他提现记录」的订单,
  这是工单 04 每日汇总登记订单的唯一入口(04 尚未实现,本工单用真实方法验证了「锁定后不能撤销核销/不能
  退款/不能重复汇总」)。
- 撤销核销:`MerchantWorkService.revokeVerification` 在业务类型与 3 小时时限检查前先 `requireRevocable`;
  订单详情新增 `withdrawal_status`,锁定后 `canRevoke = false`、`revokeReason = 订单已进入提现记录`。
- 现有商户端与后台提现页的可提金额同时排除 `refund_status <> 0` 与 `withdrawal_status = 1` 的订单
  (`MerchantSettlementService.withdrawalSummary`、`MerchantSettlementAdminService`)。
- 迁移 `V132__order_verification_refund_boundary.sql`:新增 `benefit_order.refund_status / withdrawal_status`,
  从 `order_refund` 回填历史退款(成功优先于在途),去重历史重复核销记录并建立上述唯一索引。
- 顺带修掉一个既有缺陷:`revokeVerification` 的幂等键归属用两个 `Long` 的 `!=` 比较,只在 -128..127 的
  Long 缓存区间内碰巧正确;订单 ID 超过该区间后重复撤销会被误判为「幂等键已用于其他订单」(本次全量测试
  在 id=194 的订单上踩到),改为按值比较。
- 文档:`talent-card-server/docs/huifu-payment-integration.md` 新增 "Order fund boundary" 一节,写明四个状态列、
  三条规则、迁移内容和 04 必须调用的锁定入口。
测试:新增 `OrderFundBoundaryTests` 15 项(MySQL),覆盖未支付不可核销/不可退款/不可锁定提现、过期取消
订单同样三项都被拒、两个线程并发核销只产生一条有效记录、同幂等键重复核销返回首次结果、数据库层唯一约束、
退款在途阻止核销、部分退款整单排除、退款失败回到可核销、已核销不可退款、已进入提现记录不可撤销核销/
不可退款/不可重复汇总且 `canRevoke = false`、核销与退款并发只能一方成功、退款回调成功/失败/晚到失败
三种收敛。H2 用例 `MerchantVerificationOrderCodeTests` 补上退款与提现锁定列、把 confirm 放进真实事务
(行锁需要事务)并新增「退款后不能核销」;`MerchantVerificationFreeStayTests` 补构造参数。全量 142 项测试
仅剩 `docs/agents/testing.md` 记录的 3 项基线失败。
遗留(不在本 ticket 范围):工单 04 的日汇总查询必须自带
`order_status = 1 and verify_status = 1 and refund_status = 0 and withdrawal_status = 0`,并对每条订单调用
`lockIntoWithdrawal`;工单 05 若需要「失败顺延解锁订单」,要再加一个显式的解锁方法;部分退款后剩余金额
是否可提现按人工确认的策略整单排除,未做金额级拆分。
talent-card-server/docs/huifu-payment-integration.md
@@ -132,6 +132,35 @@
back to the platform merchant exactly as before. Migration `V131` therefore backfills only in-flight
pending orders and never invents a collecting sub-merchant for money that has already moved.
## Order fund boundary (verification, refund, withdrawal lock)
`benefit_order` carries the money state of one order: `order_status` (paid or not), `verify_status`
(consumed by the merchant or not), `refund_status` (`0` none, `1` refund in flight, `2` refunded) and
`withdrawal_status` (`0` not in a withdrawal record, `1` locked into one). `OrderFundBoundaryService`
owns the rules and the writes for the last two: every verification path, the refund path (request,
provider query *and* the asynchronous refund callback) and the daily aggregation lock the same
`benefit_order` row with `SELECT ... FOR UPDATE` first, so verification, refund and withdrawal locking
are serialized per order instead of racing.
The rules it enforces:
- An order can only be verified when it is paid, not yet verified, has no effective refund, and is not
  in a withdrawal record. `verify_record` additionally has a generated column plus unique key
  (`uk_verify_record_active_order`), so one order can never hold two active verification records.
- A paid, unverified order can be refunded. Accepting the refund immediately sets `refund_status = 1`,
  which blocks verification until the provider answers; a successful refund (local response, provider
  query or asynchronous callback) sets it to `2`, which excludes the whole order from withdrawal
  aggregation (a partial refund also excludes the order, by the agreed policy). A refund that the
  provider declares failed returns the order to `refund_status = 0`, but a late failure notification
  never reopens an order whose refund already succeeded.
- `lockIntoWithdrawal` (called by the daily aggregation of ticket 04) only accepts an order that is
  paid, verified, unrefunded and not yet in another withdrawal record. Once locked, the order can
  neither be revoked nor refunded.
Migration `V132` adds the two columns, backfills `refund_status` from `order_refund`, and marks
duplicate historical verification records as revoked (keeping the earliest one) before creating the
unique key.
## Reconciliation and exception handling
The server records verified callback audit events, then uses the provider query API as a second confirmation path for payments and refunds that remain pending. It never converts an order to paid from a client request or a local timer result alone.
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -52,7 +52,7 @@
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> withdrawals(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        String available = "greatest(0, coalesce((select sum(o.pay_amount) from benefit_order o where o.merchant_id = w.merchant_id and (o.store_id is null or o.store_id = w.store_id) and o.order_status = 1 and o.verify_status = 1 and o.deleted = 0), 0) - coalesce((select sum(w2.amount_cents) from merchant_withdrawal w2 where w2.merchant_id = w.merchant_id and w2.store_id = w.store_id and w2.status in ('pending', 'processing', 'paid')), 0))";
        String available = "greatest(0, coalesce((select sum(o.pay_amount) from benefit_order o where o.merchant_id = w.merchant_id and (o.store_id is null or o.store_id = w.store_id) and o.order_status = 1 and o.verify_status = 1 and o.refund_status = 0 and o.withdrawal_status = 0 and o.deleted = 0), 0) - coalesce((select sum(w2.amount_cents) from merchant_withdrawal w2 where w2.merchant_id = w.merchant_id and w2.store_id = w.store_id and w2.status in ('pending', 'processing', 'paid')), 0))";
        String select = "select w.*, c.bank_name, c.card_masked, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name, " + available + " as available_amount_cents from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id";
        String count = "select count(*) from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id";
        return page(select, count, "w", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -252,7 +252,7 @@
    @Transactional(readOnly = true)
    public Map<String, Object> withdrawalSummary(MerchantPrincipal principal) {
        store(principal);
        Long verified = jdbc.queryForObject("select coalesce(sum(pay_amount), 0) from benefit_order where merchant_id = ? and (store_id is null or store_id = ?) and order_status = 1 and verify_status = 1 and deleted = 0", Long.class, principal.merchantId(), principal.storeId());
        Long verified = jdbc.queryForObject("select coalesce(sum(pay_amount), 0) from benefit_order where merchant_id = ? and (store_id is null or store_id = ?) and order_status = 1 and verify_status = 1 and refund_status = 0 and withdrawal_status = 0 and deleted = 0", Long.class, principal.merchantId(), principal.storeId());
        Long held = jdbc.queryForObject("select coalesce(sum(amount_cents), 0) from merchant_withdrawal where merchant_id = ? and store_id = ? and status in ('pending','processing','paid')", Long.class, principal.merchantId(), principal.storeId());
        long available = Math.max(0, (verified == null ? 0 : verified) - (held == null ? 0 : held));
        return Map.of("availableAmountCents", available, "frozenAmountCents", held == null ? 0 : held);
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
@@ -11,6 +11,7 @@
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.dao.DataAccessException;
@@ -26,10 +27,13 @@
    private final JdbcTemplate jdbc;
    private final ManualConsumptionService manualConsumptionService;
    private final OrderFundBoundaryService orderFundBoundary;
    public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService) {
    public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService,
                                       OrderFundBoundaryService orderFundBoundary) {
        this.jdbc = jdbc;
        this.manualConsumptionService = manualConsumptionService;
        this.orderFundBoundary = orderFundBoundary;
    }
    /**
@@ -178,7 +182,7 @@
    private Map<String, Object> confirmOrderCode(MerchantPrincipal principal, Map<String, Object> attempt) {
        List<Map<String, Object>> codes = rows("""
                select c.id order_verification_code_id, c.expires_at, o.id order_id, o.talent_id, o.card_id, o.benefit_id,
                       o.order_no, o.order_amount, o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status,
                       o.order_no, o.order_amount, o.subsidy_amount, o.pay_amount,
                       o.merchant_id, o.store_id, tc.card_level
                from order_dynamic_verification_code c
                join benefit_order o on o.id = c.order_id and o.deleted = 0
@@ -190,9 +194,11 @@
            throw new BusinessException(ErrorCode.CONFLICT, "订单核销码已被使用或已失效");
        }
        Map<String, Object> code = codes.get(0);
        if (!belongsToPrincipal(code, principal) || number(code.get("orderStatus")) != 1 || number(code.get("verifyStatus")) != 0) {
        if (!belongsToPrincipal(code, principal)) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销");
        }
        // 锁定订单行后再判断资金状态:退款、核销和提现锁定都排队在这一行上。
        orderFundBoundary.lock(number(code.get("orderId"))).requireVerifiable();
        int codeUpdated = jdbc.update("""
                update order_dynamic_verification_code set status = 1
                where id = ? and status in (0, 2)
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -9,11 +9,13 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.business.portal.ProductPriceCalculator;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -31,10 +33,13 @@
    private final JdbcTemplate jdbcTemplate;
    private final ManualConsumptionService manualConsumptionService;
    private final OrderFundBoundaryService orderFundBoundary;
    public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService) {
    public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService,
            OrderFundBoundaryService orderFundBoundary) {
        this.jdbcTemplate = jdbcTemplate;
        this.manualConsumptionService = manualConsumptionService;
        this.orderFundBoundary = orderFundBoundary;
    }
    @Transactional(readOnly = true)
@@ -83,7 +88,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.withdrawal_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
@@ -162,6 +167,9 @@
            throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键");
        }
        Map<String, Object> order = order(orderNo, principal.merchantId(), principal.storeId());
        // 先锁订单行,再复查幂等键和资金状态:并发核销(同键或不同键)都排队在这把锁上,
        // 后来者要么拿到首次结果,要么看到订单已核销。
        OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(number(order.get("id")));
        List<Map<String, Object>> repeated = query("""
                select order_id from verify_record where idempotency_key = ? and merchant_id = ? limit 1
                """, idempotencyKey.trim(), principal.merchantId());
@@ -171,12 +179,7 @@
            }
            return order(orderNo, principal.merchantId(), principal.storeId());
        }
        if (((Number) order.get("orderStatus")).intValue() != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单未支付,不能核销");
        }
        if (((Number) order.get("verifyStatus")).intValue() == 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
        }
        locked.requireVerifiable();
        String verifyNo = BusinessIdGenerator.next("V");
        jdbcTemplate.update("""
@@ -195,7 +198,10 @@
                order.get("payAmount"),
                order.get("subsidyAmount"),
                request.remark(), idempotencyKey.trim());
        jdbcTemplate.update("update benefit_order set verify_status = 1 where order_no = ? and verify_status = 0", orderNo);
        int updated = jdbcTemplate.update("update benefit_order set verify_status = 1 where id = ? and verify_status = 0", locked.orderId());
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
        }
        return order(orderNo, principal.merchantId(), principal.storeId());
    }
@@ -214,7 +220,9 @@
        List<Map<String, Object>> keyOwner = query("""
                select order_id from verify_record where revoke_idempotency_key = ? limit 1
                """, normalizedIdempotencyKey);
        if (!keyOwner.isEmpty() && number(keyOwner.get(0).get("orderId")) != orderId) {
        // 幂等键归属必须按值比较:两个 Long 用 != 只在 -128..127 的缓存区间内碰巧相等,
        // 订单 ID 一旦超过这个区间,重复撤销就会被误判成「幂等键已用于其他订单」。
        if (!keyOwner.isEmpty() && !Objects.equals(number(keyOwner.get(0).get("orderId")), orderId)) {
            throw new BusinessException(ErrorCode.CONFLICT, "幂等键已用于其他订单");
        }
        List<Map<String, Object>> repeated = query("""
@@ -234,10 +242,11 @@
                from benefit_order o
                left join verify_record vr on vr.order_id = o.id and vr.status = 1
                where o.id = ? and o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)
                for update
                """, orderId, principal.merchantId(), principal.storeId());
        if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在");
        Map<String, Object> order = orders.get(0);
        // 撤销核销与核销、退款、提现锁定共用同一把订单行锁;锁内读到的核销记录才是稳定的。
        orderFundBoundary.lock(orderId).requireRevocable();
        if (!"GROUP_BUY".equals(String.valueOf(order.get("orderType")))) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前业务类型不支持撤销核销");
        }
@@ -266,6 +275,11 @@
        result.put("orderStatusText", "待核销");
        result.put("revokedAt", (revokedAt == null ? LocalDateTime.now() : revokedAt).toString());
        return result;
    }
    private String revokeReason(String orderType, boolean withdrawalLocked) {
        if (withdrawalLocked) return "订单已进入提现记录";
        return "GROUP_BUY".equals(orderType) ? "已过可撤销时间" : "当前业务类型不支持撤销核销";
    }
    private Map<String, Object> enrichOrder(Map<String, Object> order) {
@@ -307,10 +321,13 @@
        result.put("couponAmount", couponAmount);
        // 免费入住没有 benefit_order 金额,补贴来自核销记录中的后台配置,不能按原价减实付覆盖为 0。
        result.put("subsidyAmount", "FREE_STAY".equals(orderType) ? configuredSubsidyAmount : totalDiscount);
        boolean canRevoke = "GROUP_BUY".equals(orderType) && verifyTime != null && Duration.between(verifyTime, LocalDateTime.now()).toHours() < REVOCATION_HOURS && verifyStatus != null && verifyStatus == 1;
        // 已进入提现记录的订单不能再撤销核销,商户端不值得展示一个必然失败的按钮。
        Long withdrawalStatus = number(order.get("withdrawalStatus"));
        boolean withdrawalLocked = withdrawalStatus != null && withdrawalStatus == 1;
        boolean canRevoke = "GROUP_BUY".equals(orderType) && verifyTime != null && Duration.between(verifyTime, LocalDateTime.now()).toHours() < REVOCATION_HOURS && verifyStatus != null && verifyStatus == 1 && !withdrawalLocked;
        result.put("canRevoke", canRevoke);
        result.put("revokeDeadline", verifyTime == null ? null : verifyTime.plusHours(REVOCATION_HOURS).toString());
        result.put("revokeReason", canRevoke ? "" : ("GROUP_BUY".equals(orderType) ? "已过可撤销时间" : "当前业务类型不支持撤销核销"));
        result.put("revokeReason", canRevoke ? "" : revokeReason(orderType, withdrawalLocked));
        return result;
    }
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderFundBoundaryService.java
New file
@@ -0,0 +1,169 @@
package com.talentcard.server.business.order;
import java.util.List;
import java.util.Map;
import com.talentcard.server.common.BusinessException;
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;
/**
 * 订单资金边界。
 *
 * <p>一个订单的核销、退款和提现锁定都会先锁定 {@code benefit_order} 的同一行,因此它们按订单串行化:
 * 不会出现「退了款又核销」「核销后被退款」「已经进了提现记录又撤销核销」。
 *
 * <p>{@code refund_status} 由本服务的退款三个方法维护;{@code withdrawal_status} 只有一个写入方
 * {@link #lockIntoWithdrawal(long)}(工单 04 的每日汇总把订单登记进提现记录时调用)。核销、退款、
 * 提现锁定的业务规则都收敛在这里,调用方只负责在拿到 {@link LockedOrder} 后调用对应断言。
 *
 * <p>{@link #lock(long)} 要求已经存在数据库事务:行锁只在事务内有效,脱离事务调用会直接失败,
 * 而不是静默地失去串行化能力。其它方法都是单条条件更新,本身就是原子的。
 */
@Service
public class OrderFundBoundaryService {
    /** 订单退款状态:没有生效中的退款。 */
    public static final long REFUND_NONE = 0;
    /** 订单退款状态:退款已受理、结果未定。 */
    public static final long REFUND_IN_PROGRESS = 1;
    /** 订单退款状态:退款成功(部分退款也算整单退出结算)。 */
    public static final long REFUNDED = 2;
    /** 提现锁定状态:未进入提现记录。 */
    public static final long WITHDRAWAL_NONE = 0;
    /** 提现锁定状态:已进入提现记录,不能再撤销核销或退款。 */
    public static final long WITHDRAWAL_LOCKED = 1;
    private final JdbcTemplate jdbc;
    public OrderFundBoundaryService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }
    /**
     * 锁定订单行并返回它的资金状态。拿到的状态在整个事务内不会被子资金状态变更改动,
     * 因此所有判断都必须基于本方法的返回值。
     */
    @Transactional(propagation = Propagation.MANDATORY)
    public LockedOrder lock(long orderId) {
        List<Map<String, Object>> rows = jdbc.queryForList("""
                select id, order_status, verify_status, refund_status, withdrawal_status
                from benefit_order
                where id = ? and deleted = 0
                limit 1 for update
                """, orderId);
        if (rows.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在");
        }
        return LockedOrder.fromRow(rows.get(0));
    }
    /** 退款受理成功:本笔退款结清前订单不可核销,也不参与提现汇总。 */
    public void markRefundInProgress(long orderId) {
        int updated = jdbc.update("""
                update benefit_order set refund_status = 1
                where id = ? and deleted = 0 and refund_status = 0
                """, orderId);
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已提交退款申请");
        }
    }
    /** 退款成功:整单退出可核销与可提现范围,部分退款同样按整单处理。 */
    public void markRefunded(long orderId) {
        jdbc.update("update benefit_order set refund_status = 2 where id = ? and deleted = 0 and refund_status <> 2", orderId);
    }
    /** 退款失败:没有资金变动,订单回到未退款状态。已有成功退款的订单不会被退回。 */
    public void markRefundFailed(long orderId) {
        jdbc.update("""
                update benefit_order o
                set o.refund_status = 0
                where o.id = ? and o.deleted = 0 and o.refund_status = 1
                  and not exists (select 1 from order_refund r
                                  where r.order_id = o.id and r.deleted = 0 and r.refund_status = 2)
                """, orderId);
    }
    /**
     * 提现锁定:把已核销订单登记进某条提现记录后调用(工单 04 的每日汇总入口)。
     *
     * <p>只有「已支付、已核销、未退款、未进入其他提现记录」的订单才能被锁定,因此同一条订单
     * 不会被两条提现记录重复汇总。锁定后订单不能再撤销核销或退款。
     */
    public void lockIntoWithdrawal(long orderId) {
        int updated = jdbc.update("""
                update benefit_order
                set withdrawal_status = 1
                where id = ? and deleted = 0 and withdrawal_status = 0
                  and order_status = 1 and verify_status = 1 and refund_status = 0
                """, orderId);
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单不满足进入提现记录的条件");
        }
    }
    /** 一条已被行锁固定的订单资金状态。 */
    public record LockedOrder(long orderId, long orderStatus, long verifyStatus, long refundStatus, long withdrawalStatus) {
        static LockedOrder fromRow(Map<String, Object> row) {
            return new LockedOrder(
                    number(row.get("id")),
                    number(row.get("order_status")),
                    number(row.get("verify_status")),
                    number(row.get("refund_status")),
                    number(row.get("withdrawal_status")));
        }
        public boolean withdrawalLocked() {
            return withdrawalStatus == WITHDRAWAL_LOCKED;
        }
        /** 未支付、已核销、已退款或退款中、已进入提现记录的订单都不能再核销。 */
        public void requireVerifiable() {
            if (orderStatus != 1) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单未支付,不能核销");
            }
            if (verifyStatus == 1) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
            }
            if (refundStatus != REFUND_NONE) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单已退款或退款中,不能核销");
            }
            if (withdrawalLocked()) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单已进入提现记录,不能核销");
            }
        }
        /** 未支付、已退款或退款中、已进入提现记录的订单都不能再退款。 */
        public void requireRefundable() {
            if (orderStatus != 1) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单当前状态不可退款");
            }
            if (refundStatus == REFUND_IN_PROGRESS) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单退款处理中,不能重复退款");
            }
            if (refundStatus == REFUNDED) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单已退款");
            }
            if (withdrawalLocked()) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单已进入提现记录,不能退款");
            }
        }
        /** 进入提现记录的订单不能再撤销核销。 */
        public void requireRevocable() {
            if (withdrawalLocked()) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单已进入提现记录,不能撤销核销");
            }
        }
        private static long number(Object value) {
            return value instanceof Number number ? number.longValue() : 0L;
        }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java
@@ -27,11 +27,13 @@
    private final JdbcTemplate jdbcTemplate;
    private final HuifuRefundService refundService;
    private final MerchantPaymentReadinessService merchantPaymentReadinessService;
    private final OrderFundBoundaryService orderFundBoundary;
    public OrderService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService, MerchantPaymentReadinessService merchantPaymentReadinessService) {
    public OrderService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService, MerchantPaymentReadinessService merchantPaymentReadinessService, OrderFundBoundaryService orderFundBoundary) {
        this.jdbcTemplate = jdbcTemplate;
        this.refundService = refundService;
        this.merchantPaymentReadinessService = merchantPaymentReadinessService;
        this.orderFundBoundary = orderFundBoundary;
    }
    @Transactional
@@ -87,12 +89,10 @@
    @Transactional
    public Map<String, Object> verify(String orderNo, OrderVerifyRequest request) {
        Map<String, Object> order = detail(orderNo);
        if (((Number) order.get("orderStatus")).intValue() != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单未支付,不能核销");
        }
        if (((Number) order.get("verifyStatus")).intValue() == 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
        }
        Long orderId = ((Number) order.get("id")).longValue();
        // 后台核销与商户核销、退款共用同一把订单行锁,重复核销只会产生一次有效结果。
        OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(orderId);
        locked.requireVerifiable();
        Long orderMerchantId = order.get("merchantId") == null ? null : ((Number) order.get("merchantId")).longValue();
        if (request.merchantId() != null && !request.merchantId().equals(orderMerchantId)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "订单不属于该商户");
@@ -114,7 +114,10 @@
                order.get("payAmount"),
                order.get("subsidyAmount"),
                request.remark());
        jdbcTemplate.update("update benefit_order set verify_status = 1 where order_no = ?", orderNo);
        int updated = jdbcTemplate.update("update benefit_order set verify_status = 1 where id = ? and verify_status = 0", orderId);
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
        }
        return detail(orderNo);
    }
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentCallbackService.java
@@ -8,6 +8,7 @@
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.business.payment.HuifuPaymentPayload.TransStatus;
import com.talentcard.server.business.payment.PaymentSettlementService.ProviderPaymentFact;
import com.talentcard.server.business.payment.PaymentSettlementService.SettlementOutcome;
@@ -40,11 +41,13 @@
    private final JdbcTemplate jdbc;
    private final HuifuSignatureService signatureService;
    private final PaymentSettlementService settlementService;
    private final OrderFundBoundaryService orderFundBoundary;
    public HuifuPaymentCallbackService(JdbcTemplate jdbc, HuifuSignatureService signatureService, PaymentSettlementService settlementService) {
    public HuifuPaymentCallbackService(JdbcTemplate jdbc, HuifuSignatureService signatureService, PaymentSettlementService settlementService, OrderFundBoundaryService orderFundBoundary) {
        this.jdbc = jdbc;
        this.signatureService = signatureService;
        this.settlementService = settlementService;
        this.orderFundBoundary = orderFundBoundary;
    }
    @Transactional
@@ -110,6 +113,7 @@
        if (refunds.isEmpty()) return false;
        Map<String, Object> refund = refunds.get(0);
        long orderId = ((Number) refund.get("orderId")).longValue();
        String transStatus = HuifuPaymentPayload.value(data, "trans_stat", "transStat");
        String code = HuifuPaymentPayload.code(data);
        if (HuifuPaymentPayload.paid(transStatus, code)) {
@@ -119,6 +123,8 @@
                        provider_response_code = ?, provider_response_message = ?, refund_time = coalesce(refund_time, now())
                    where id = ? and refund_status = 1
                    """, providerTransactionNo(data), code, HuifuPaymentPayload.message(data), refund.get("id"));
            // 通道确认退款成功是最终事实:订单级退款状态无条件收敛为已退款。
            orderFundBoundary.markRefunded(orderId);
            jdbc.update("""
                    update benefit_order o
                    set o.order_status = 2
@@ -127,11 +133,13 @@
                    """, refund.get("orderId"));
            updateEvent(refundNo, "PROCESSED");
        } else if (HuifuPaymentPayload.terminalFailure(transStatus, code)) {
            jdbc.update("""
            int failed = jdbc.update("""
                    update order_refund
                    set provider_response_code = ?, provider_response_message = ?, refund_status = 3
                    where id = ? and refund_status = 1
                    """, code, HuifuPaymentPayload.message(data), refund.get("id"));
            // 只有本事务真的把退款置为失败,才把订单退回未退款状态;否则会覆盖并发的退款成功。
            if (failed == 1) orderFundBoundary.markRefundFailed(orderId);
            updateEvent(refundNo, "REFUND_FAILED");
        } else {
            jdbc.update("""
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
@@ -3,6 +3,7 @@
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.MoneyUtils;
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.business.order.OrderRefundRequest;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
@@ -32,12 +33,14 @@
    private final TransactionTemplate transactions;
    private final HuifuRefundGateway gateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
    private final OrderFundBoundaryService orderFundBoundary;
    public HuifuRefundService(JdbcTemplate jdbc, TransactionTemplate transactions, HuifuRefundGateway gateway, HuifuPaymentProperties huifuPaymentProperties) {
    public HuifuRefundService(JdbcTemplate jdbc, TransactionTemplate transactions, HuifuRefundGateway gateway, HuifuPaymentProperties huifuPaymentProperties, OrderFundBoundaryService orderFundBoundary) {
        this.jdbc = jdbc;
        this.transactions = transactions;
        this.gateway = gateway;
        this.huifuPaymentProperties = huifuPaymentProperties;
        this.orderFundBoundary = orderFundBoundary;
    }
    public Map<String, Object> request(String orderNo, OrderRefundRequest request) {
@@ -51,12 +54,12 @@
                    prepared.originalRequestDate(), prepared.refundAmount()));
        } catch (HuifuPaymentGatewayException exception) {
            if (!exception.isOutcomeUnknown()) {
                markFailed(prepared.refundNo(), "GATEWAY_REJECTED", exception.getMessage());
                markFailed(prepared.refundNo(), prepared.orderId(), "GATEWAY_REJECTED", exception.getMessage());
                throw new BusinessException(ErrorCode.CONFLICT, "Refund request was rejected");
            }
            return detail(prepared.refundNo());
        } catch (BusinessException exception) {
            markFailed(prepared.refundNo(), "LOCAL_ERROR", exception.getMessage());
            markFailed(prepared.refundNo(), prepared.orderId(), "LOCAL_ERROR", exception.getMessage());
            throw exception;
        } catch (RuntimeException exception) {
            return detail(prepared.refundNo());
@@ -66,7 +69,7 @@
        if (result.success()) {
            markSucceeded(prepared, result);
        } else if (result.failed()) {
            markFailed(prepared.refundNo(), result.code(), result.message());
            markFailed(prepared.refundNo(), prepared.orderId(), result.code(), result.message());
            throw new BusinessException(ErrorCode.CONFLICT, "Refund request was rejected");
        } else {
            updateProviderResult(prepared.refundNo(), result);
@@ -87,7 +90,7 @@
        try {
            ProviderResult result = ProviderResult.from(gateway.queryRefund(command, text(refund.get("providerRefundNo"))));
            if (result.success()) markSucceeded(new PreparedRefund(refundNo, number(refund.get("orderId")), number(refund.get("refundAmount"))), result);
            else if (result.failed()) markFailed(refundNo, result.code(), result.message());
            else if (result.failed()) markFailed(refundNo, number(refund.get("orderId")), result.code(), result.message());
            else updateProviderResult(refundNo, result);
        } catch (HuifuPaymentGatewayException ignored) {
            // Keep the refund pending when the provider outcome is unknown.
@@ -122,8 +125,10 @@
                """, PROVIDER, orderNo);
        if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "Order not found");
        Map<String, Object> order = orders.get(0);
        if (number(order.get("orderStatus")) != 1) throw new BusinessException(ErrorCode.CONFLICT, "Order cannot be refunded in its current state");
        if (number(order.get("verifyStatus")) == 1 && !"STORE_CONSUME".equals(text(order.get("orderType")))) {
        // 支付、核销、退款、提现锁定都按订单串行化:锁住订单行之后才判断能不能退款。
        OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(number(order.get("id")));
        locked.requireRefundable();
        if (locked.verifyStatus() == 1 && !"STORE_CONSUME".equals(text(order.get("orderType")))) {
            throw new BusinessException(ErrorCode.CONFLICT, "Verified orders cannot be refunded");
        }
@@ -153,6 +158,8 @@
                refundNo, order.get("id"), order.get("orderNo"), order.get("talentId"), order.get("cardId"),
                order.get("benefitId"), order.get("merchantId"), order.get("orderType"), paidAmount, refundAmount,
                refundableAmount - refundAmount, PROVIDER, huifuId, providerNo, paymentNo, requestDate, request.reason(), request.operatorName());
        // 退款受理成功即刻生效:在结果确定前订单不再可核销,也不参与提现汇总。
        orderFundBoundary.markRefundInProgress(number(order.get("id")));
        return new PreparedRefund(refundNo, number(order.get("id")), huifuId, paymentNo, providerNo, requestDate, refundAmount);
    }
@@ -169,11 +176,17 @@
                    where o.id = ? and o.order_status = 1
                      and o.pay_amount <= (select coalesce(sum(r.refund_amount), 0) from order_refund r where r.order_id = o.id and r.refund_status = 2 and r.deleted = 0)
                    """, prepared.orderId());
            orderFundBoundary.markRefunded(prepared.orderId());
        });
    }
    private void markFailed(String refundNo, String code, String message) {
        jdbc.update("update order_refund set refund_status = 3, provider_response_code = ?, provider_response_message = ? where refund_no = ? and refund_status = 1", code, message, refundNo);
    /** 退款失败没有资金变动,订单回到未退款状态(可再次核销或再次退款)。 */
    private void markFailed(String refundNo, long orderId, String code, String message) {
        transactions.executeWithoutResult(status -> {
            int failed = jdbc.update("update order_refund set refund_status = 3, provider_response_code = ?, provider_response_message = ? where refund_no = ? and refund_status = 1", code, message, refundNo);
            // 只有本事务真的把这笔退款置为失败,才把订单退回未退款状态;否则会覆盖并发的退款成功。
            if (failed == 1) orderFundBoundary.markRefundFailed(orderId);
        });
    }
    private void updateProviderResult(String refundNo, ProviderResult result) {
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java
@@ -9,6 +9,7 @@
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.request.BenefitVerificationRequest;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.common.BusinessException;
@@ -53,6 +54,7 @@
    private final TicketPackageClaimMapper ticketPackageClaimMapper;
    private final TicketPackageCodeMapper ticketPackageCodeMapper;
    private final TicketPackagePortalService ticketPackagePortalService;
    private final OrderFundBoundaryService orderFundBoundary;
    public BenefitVerificationService(
            BenefitQuotaService quotaService,
@@ -67,7 +69,8 @@
            BenefitMerchantProductMapper productMapper,
            TicketPackageClaimMapper ticketPackageClaimMapper,
            TicketPackageCodeMapper ticketPackageCodeMapper,
            TicketPackagePortalService ticketPackagePortalService
            TicketPackagePortalService ticketPackagePortalService,
            OrderFundBoundaryService orderFundBoundary
    ) {
        this.quotaService = quotaService;
        this.verifyRecordMapper = verifyRecordMapper;
@@ -82,6 +85,7 @@
        this.ticketPackageClaimMapper = ticketPackageClaimMapper;
        this.ticketPackageCodeMapper = ticketPackageCodeMapper;
        this.ticketPackagePortalService = ticketPackagePortalService;
        this.orderFundBoundary = orderFundBoundary;
    }
    @Transactional
@@ -198,10 +202,11 @@
        }
        BenefitOrderDynamicVerificationCode code = context.code();
        BenefitOrder order = context.order();
        if (intValue(code.getStatus()) != 0 || expiredDateTime(code.getExpiresAt())
                || intValue(order.getOrderStatus()) != 1 || intValue(order.getVerifyStatus()) != 0) {
        if (intValue(code.getStatus()) != 0 || expiredDateTime(code.getExpiresAt())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店订单当前不可核销");
        }
        // 与商户端核销、退款共用同一把订单行锁:未支付、已退款或已进入提现记录的订单都不能核销。
        orderFundBoundary.lock(order.getId()).requireVerifiable();
        int codeUpdated = orderCodeMapper.update(null, new UpdateWrapper<BenefitOrderDynamicVerificationCode>()
                .set("status", 1)
                .eq("id", code.getId())
talent-card-server/src/main/resources/db/migration/V132__order_verification_refund_boundary.sql
New file
@@ -0,0 +1,43 @@
-- 核销幂等与退款边界:把「能不能核销 / 能不能退款 / 能不能进提现」三个判断收敛到 benefit_order 的同一行,
-- 让核销、退款和提现锁定在数据库行锁上按订单串行化。
-- 订单级退款状态。任何一笔退款在途或成功,整单都退出可核销与可提现范围;
-- 退款失败会回到 0,因此这一列只表示「当前生效的退款」。
alter table benefit_order
    add column refund_status tinyint not null default 0 comment '订单退款状态:0未退款 1退款中 2已退款';
-- 提现锁定。订单被每日提现汇总登记进提现记录后置 1,之后不能再撤销核销或退款,
-- 也不会再被其它提现记录汇总。
alter table benefit_order
    add column withdrawal_status tinyint not null default 0 comment '提现锁定状态:0未进入提现记录 1已进入提现记录';
-- 回填历史退款:order_refund 是既有事实来源,成功优先于在途。
update benefit_order o
    join (select order_id, max(case when refund_status = 2 then 1 else 0 end) refunded
          from order_refund
          where deleted = 0 and refund_status in (1, 2)
          group by order_id) r on r.order_id = o.id
set o.refund_status = case when r.refunded = 1 then 2 else 1 end
where o.refund_status = 0;
-- 同一订单只允许一条有效核销记录。并发核销在旧实现里可能留下多条 status = 1 的记录,
-- 迁移只保留最早一条,其余标记为已撤销(不是删除,核销历史仍然可查)。
update verify_record v
    join (select order_id, min(id) active_id
          from verify_record
          where status = 1 and order_id is not null
          group by order_id
          having count(*) > 1) duplicated on duplicated.order_id = v.order_id
set v.status = 2,
    v.revoked_at = coalesce(v.revoked_at, now()),
    v.revoke_reason = coalesce(v.revoke_reason, 'V132 迁移:同一订单存在多条有效核销记录,仅保留最早一条')
where v.status = 1 and v.id <> duplicated.active_id;
-- 生成列让「有效核销记录」在数据库层唯一:status = 1 时等于 order_id,撤销后为 NULL
-- (唯一索引允许多个 NULL),因此撤销后重新核销仍然合法,但同一订单不可能同时有两条有效核销。
alter table verify_record
    add column active_order_id bigint generated always as (case when status = 1 then order_id else null end) virtual
        comment '有效核销记录对应的订单ID,status <> 1 时为 NULL,用于订单级唯一约束';
alter table verify_record
    add unique key uk_verify_record_active_order (active_order_id);
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java
@@ -140,7 +140,7 @@
        jdbc.execute("create table admin_setting (setting_key varchar(64) primary key, setting_value varchar(1000))");
        jdbc.update("insert into admin_setting(setting_key, setting_value) values ('hotelSubsidy', '268.50')");
        jdbc.execute("create table payment_transaction (id bigint primary key)");
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null));
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc));
        principal = new MerchantPrincipal(9L, 88L, 7L, "operator");
    }
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
@@ -12,11 +12,14 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
class MerchantVerificationOrderCodeTests {
    private JdbcTemplate jdbc;
    private TransactionTemplate transactions;
    private MerchantVerificationService service;
    private MerchantPrincipal principal;
@@ -26,6 +29,7 @@
                "jdbc:h2:mem:order-code-verification-" + System.nanoTime()
                        + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", "");
        jdbc = new JdbcTemplate(dataSource);
        transactions = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
        jdbc.execute("""
                create table merchant_verification_attempt (
                    id bigint auto_increment primary key,
@@ -55,6 +59,8 @@
                    pay_amount bigint not null,
                    order_status tinyint not null,
                    verify_status tinyint not null,
                    refund_status tinyint not null default 0,
                    withdrawal_status tinyint not null default 0,
                    merchant_id bigint not null,
                    store_id bigint,
                    deleted tinyint not null default 0
@@ -87,7 +93,8 @@
                    remark varchar(255)
                )
                """);
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null));
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null),
                new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc));
        principal = new MerchantPrincipal(9L, 88L, 7L, "operator");
    }
@@ -95,8 +102,7 @@
    void confirmsGroupOrderAfterScannedCodeExpiresAndRefreshes() {
        insertOrder("expired-group-order", 1);
        Map<String, Object> confirmed = service.confirm(principal,
                new VerificationConfirmRequest("expired-group-order"), "order-confirm-expired-group-order");
        Map<String, Object> confirmed = confirm("expired-group-order");
        assertThat(confirmed.get("verified")).isEqualTo(true);
        assertThat(jdbc.queryForObject("select status from order_dynamic_verification_code where id = 30", Integer.class)).isEqualTo(1);
@@ -110,10 +116,26 @@
    void stillRejectsUnpaidGroupOrder() {
        insertOrder("unpaid-group-order", 0);
        assertThatThrownBy(() -> service.confirm(principal, new VerificationConfirmRequest("unpaid-group-order"),
                "order-confirm-unpaid-group-order"))
        assertThatThrownBy(() -> confirm("unpaid-group-order"))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("当前不可核销");
                .hasMessageContaining("未支付");
    }
    @Test
    void rejectsGroupOrderThatWasRefundedWhileTheCodeWasOpen() {
        insertOrder("refunded-group-order", 1);
        jdbc.update("update benefit_order set refund_status = 2 where id = 20");
        assertThatThrownBy(() -> confirm("refunded-group-order"))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("退款");
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = 20", Integer.class)).isZero();
    }
    /** 核销确认按订单行锁串行化,所以测试也必须在一个真实事务里调用它。 */
    private Map<String, Object> confirm(String verificationId) {
        return transactions.execute(status -> service.confirm(principal,
                new VerificationConfirmRequest(verificationId), "order-confirm-" + verificationId));
    }
    private void insertOrder(String verificationId, int orderStatus) {
talent-card-server/src/test/java/com/talentcard/server/business/order/OrderFundBoundaryTests.java
New file
@@ -0,0 +1,414 @@
package com.talentcard.server.business.order;
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.when;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import com.talentcard.server.business.merchant.MerchantRevokeVerificationRequest;
import com.talentcard.server.business.merchant.MerchantWorkService;
import com.talentcard.server.business.merchant.VerifyRequest;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.payment.HuifuPaymentCallbackService;
import com.talentcard.server.business.payment.HuifuRefundService;
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
import com.talentcard.server.business.payment.huifu.HuifuSignatureService;
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.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
 * 核销幂等与退款边界 seam:未支付订单不能核销;同一订单重复核销只产生一次有效结果;
 * 已支付未核销订单可退款,退款后不得核销或提现;核销、退款和提现锁定按订单串行化;
 * 订单进入提现记录后不能撤销核销。
 */
@SpringBootTest
@ActiveProfiles("test")
class OrderFundBoundaryTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantWorkService merchantWorkService;
    @Autowired
    private OrderService orderService;
    @Autowired
    private OrderFundBoundaryService orderFundBoundaryService;
    @Autowired
    private HuifuRefundService refundService;
    @Autowired
    private HuifuPaymentCallbackService callbackService;
    @MockitoBean
    private HuifuRefundGateway refundGateway;
    @MockitoBean
    private HuifuSignatureService signatureService;
    private Long merchantId;
    private Long storeId;
    private Long talentId;
    private MerchantPrincipal principal;
    private String suffix;
    @BeforeEach
    void prepareMerchant() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into merchant(merchant_name, status, can_login_merchant_app, deleted) values (?, 1, 1, 0)", "Fund boundary merchant " + suffix);
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Fund boundary store " + suffix);
        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "fund-boundary-" + suffix, "资金边界用户", "13900000002");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        principal = new MerchantPrincipal(9601L, merchantId, storeId, "operator");
        when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
    }
    @Test
    void anUnpaidOrderCannotBeVerified() {
        String orderNo = insertOrder("GROUP_BUY", 0, 0);
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "unpaid"), "unpaid-" + suffix))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("未支付");
        assertThatThrownBy(() -> orderService.verify(orderNo, new OrderVerifyRequest(merchantId, 1L, "GROUP_BUY", "unpaid")))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("未支付");
        assertThat(activeVerifyRecords(orderId(orderNo))).isZero();
    }
    @Test
    void anUnpaidOrderCannotBeRefundedOrLockedForWithdrawal() {
        String orderNo = insertOrder("GROUP_BUY", 0, 0);
        Long orderId = orderId(orderNo);
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "unpaid", "tester")))
                .isInstanceOf(BusinessException.class);
        assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("提现记录");
    }
    @Test
    void anExpiredOrderCannotBeVerifiedRefundedOrLockedForWithdrawal() {
        // 超时未支付订单会被取消为 order_status = 2(与已退款共用同一个终态)。
        String orderNo = insertOrder("GROUP_BUY", 2, 0);
        Long orderId = orderId(orderNo);
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "expired"), "expired-" + suffix))
                .isInstanceOf(BusinessException.class);
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "expired", "tester")))
                .isInstanceOf(BusinessException.class);
        assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId))
                .isInstanceOf(BusinessException.class);
        assertThat(activeVerifyRecords(orderId)).isZero();
    }
    @Test
    void aRefundFailureCallbackReturnsTheOrderToTheVerifiableState() {
        String orderNo = insertPaidOrderWithSucceededPayment();
        Long orderId = orderId(orderNo);
        String refundNo = pendingRefund(orderNo).get("refundNo").toString();
        callbackService.handle(refundCallback(refundNo, "F", "20000000"), "signature");
        assertThat(jdbc.queryForMap("select refund_status from order_refund where refund_no = ?", refundNo)).containsEntry("refund_status", 3);
        assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isZero();
        assertThat(merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after failed callback"), "callback-failed-" + suffix))
                .containsEntry("status", "completed");
    }
    @Test
    void aRefundSuccessCallbackExcludesTheOrderFromVerificationAndPayout() {
        String orderNo = insertPaidOrderWithSucceededPayment();
        Long orderId = orderId(orderNo);
        String refundNo = pendingRefund(orderNo).get("refundNo").toString();
        callbackService.handle(refundCallback(refundNo, "S", "00000000"), "signature");
        assertThat(jdbc.queryForMap("select refund_status from order_refund where refund_no = ?", refundNo)).containsEntry("refund_status", 2);
        assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(2);
        // 全额退款同时把订单置为已退款状态,核销被拒;无论命中哪条规则,都不允许核销。
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after callback"), "callback-succeeded-" + suffix))
                .isInstanceOf(BusinessException.class);
        assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId))
                .isInstanceOf(BusinessException.class);
    }
    @Test
    void aLateFailureCallbackNeverReopensARefundedOrder() {
        String orderNo = insertPaidOrderWithSucceededPayment();
        Long orderId = orderId(orderNo);
        String refundNo = pendingRefund(orderNo).get("refundNo").toString();
        callbackService.handle(refundCallback(refundNo, "S", "00000000"), "signature");
        callbackService.handle(refundCallback(refundNo, "F", "20000000"), "signature");
        assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(2);
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "late failure"), "late-failure-" + suffix))
                .isInstanceOf(BusinessException.class);
    }
    @Test
    void concurrentVerificationOfOneOrderProducesOneEffectiveResult() throws Exception {
        String orderNo = insertOrder("GROUP_BUY", 1, 0);
        Long orderId = orderId(orderNo);
        CountDownLatch start = new CountDownLatch(1);
        ExecutorService pool = Executors.newFixedThreadPool(2);
        try {
            Future<String> first = pool.submit(verificationAttempt(orderNo, start, "concurrent-verify-a-" + suffix));
            Future<String> second = pool.submit(verificationAttempt(orderNo, start, "concurrent-verify-b-" + suffix));
            start.countDown();
            List<String> outcomes = List.of(first.get(30, TimeUnit.SECONDS), second.get(30, TimeUnit.SECONDS));
            assertThat(outcomes).containsExactlyInAnyOrder("verified", "conflict");
        } finally {
            pool.shutdownNow();
        }
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1);
    }
    @Test
    void repeatingOneVerificationWithTheSameIdempotencyKeyReturnsTheFirstResult() {
        String orderNo = insertOrder("GROUP_BUY", 1, 0);
        Long orderId = orderId(orderNo);
        String key = "repeat-verify-" + suffix;
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "first"), key);
        Map<String, Object> repeated = merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "first"), key);
        assertThat(repeated).containsEntry("status", "completed");
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
    }
    @Test
    void theDatabaseAllowsOnlyOneEffectiveVerificationRecordPerOrder() {
        String orderNo = insertOrder("GROUP_BUY", 1, 0);
        Long orderId = orderId(orderNo);
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "first"), "db-guard-" + suffix);
        assertThatThrownBy(() -> jdbc.update("""
                insert into verify_record(verify_no, order_id, talent_id, merchant_id, verify_type, status)
                values (?, ?, ?, ?, 'ORDER_CODE', 1)
                """, "V-DUP-" + suffix, orderId, talentId, merchantId))
                .isInstanceOf(DataIntegrityViolationException.class);
    }
    @Test
    void aRefundInProgressBlocksVerification() {
        String orderNo = insertPaidOrderWithSucceededPayment();
        Long orderId = orderId(orderNo);
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000000", "hf_seq_id", "HF-FUND-P-" + suffix)));
        Map<String, Object> refund = refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "processing", "tester"));
        assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1);
        assertThat(refund).containsEntry("refundStatus", 1);
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after refund"), "in-progress-" + suffix))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("退款");
        assertThat(activeVerifyRecords(orderId)).isZero();
    }
    @Test
    void aPartialRefundStillExcludesTheWholeOrderFromVerificationAndWithdrawal() {
        String orderNo = insertPaidOrderWithSucceededPayment();
        Long orderId = orderId(orderNo);
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "hf_seq_id", "HF-FUND-S-" + suffix)));
        refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("30.00"), "partial", "tester"));
        assertThat(jdbc.queryForMap("select order_status, refund_status from benefit_order where id = ?", orderId))
                .as("部分退款不改变订单支付状态,但整单退出可核销与可提现范围")
                .containsEntry("order_status", 1)
                .containsEntry("refund_status", 2);
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after partial refund"), "partial-" + suffix))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("退款");
        assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("提现记录");
    }
    @Test
    void aFailedRefundReturnsTheOrderToTheVerifiableState() {
        String orderNo = insertPaidOrderWithSucceededPayment();
        Long orderId = orderId(orderNo);
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "resp_code", "20000000", "sub_resp_desc", "refund rejected")));
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "rejected", "tester")))
                .isInstanceOf(BusinessException.class);
        assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isZero();
        Map<String, Object> verified = merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after failed refund"), "failed-refund-" + suffix);
        assertThat(verified).containsEntry("status", "completed");
    }
    @Test
    void aVerifiedOrderCannotBeRefunded() {
        String orderNo = insertPaidOrderWithSucceededPayment();
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "verified"), "verified-refund-" + suffix);
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "too late", "tester")))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("refunded");
    }
    @Test
    void anOrderInAWithdrawalRecordCannotBeRevokedOrRefunded() {
        String orderNo = insertPaidOrderWithSucceededPayment();
        Long orderId = orderId(orderNo);
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "verified"), "locked-" + suffix);
        orderFundBoundaryService.lockIntoWithdrawal(orderId);
        assertThat(jdbc.queryForObject("select withdrawal_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1);
        assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal,
                new MerchantRevokeVerificationRequest(String.valueOf(orderId), "too late"), "locked-revoke-" + suffix))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("提现记录");
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "too late", "tester")))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("提现记录");
        assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId))
                .as("同一订单不能被两条提现记录重复汇总")
                .isInstanceOf(BusinessException.class);
        assertThat(merchantWorkService.order(orderNo, merchantId, storeId))
                .containsEntry("canRevoke", false)
                .containsEntry("revokeReason", "订单已进入提现记录");
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1);
    }
    @Test
    void aVerificationAndARefundOfTheSameOrderCannotBothWin() throws Exception {
        String orderNo = insertPaidOrderWithSucceededPayment();
        Long orderId = orderId(orderNo);
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "hf_seq_id", "HF-RACE-" + suffix)));
        CountDownLatch start = new CountDownLatch(1);
        ExecutorService pool = Executors.newFixedThreadPool(2);
        List<String> outcomes;
        try {
            Future<String> verification = pool.submit(() -> {
                start.await(10, TimeUnit.SECONDS);
                try {
                    merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "race"), "race-verify-" + suffix);
                    return "verified";
                } catch (BusinessException exception) {
                    return "verification-rejected";
                }
            });
            Future<String> refund = pool.submit(() -> {
                start.await(10, TimeUnit.SECONDS);
                try {
                    refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "race", "tester"));
                    return "refunded";
                } catch (BusinessException exception) {
                    return "refund-rejected";
                }
            });
            start.countDown();
            outcomes = List.of(verification.get(30, TimeUnit.SECONDS), refund.get(30, TimeUnit.SECONDS));
        } finally {
            pool.shutdownNow();
        }
        Map<String, Object> order = jdbc.queryForMap("select order_status, verify_status, refund_status from benefit_order where id = ?", orderId);
        if (outcomes.get(1).equals("refunded")) {
            assertThat(outcomes).containsExactly("verification-rejected", "refunded");
            assertThat(order).containsEntry("verify_status", 0).containsEntry("refund_status", 2);
            assertThat(activeVerifyRecords(orderId)).isZero();
        } else {
            assertThat(outcomes).containsExactly("verified", "refund-rejected");
            assertThat(order).containsEntry("verify_status", 1).containsEntry("refund_status", 0);
            assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
        }
    }
    private Callable<String> verificationAttempt(String orderNo, CountDownLatch start, String key) {
        return () -> {
            start.await(10, TimeUnit.SECONDS);
            try {
                merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "concurrent"), key);
                return "verified";
            } catch (BusinessException exception) {
                return "conflict";
            }
        };
    }
    private String insertOrder(String type, int orderStatus, int verifyStatus) {
        String orderNo = "FUND-" + System.nanoTime();
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, order_amount,
                                          subsidy_amount, pay_amount, order_status, verify_status, deleted)
                values (?, ?, ?, ?, ?, 10000, 1000, 9000, ?, ?, 0)
                """, orderNo, talentId, merchantId, storeId, type, orderStatus, verifyStatus);
        return orderNo;
    }
    /** 已支付订单加上一笔成功支付流水,退款才能找到收款方和原交易号。 */
    private String insertPaidOrderWithSucceededPayment() {
        String orderNo = insertOrder("GROUP_BUY", 1, 0);
        Long orderId = orderId(orderNo);
        jdbc.update("""
                insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
                                                provider_huifu_id, provider_transaction_no, amount, status, idempotency_key, paid_at)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, 9000, 'SUCCEEDED', ?, now())
                """, "P-FUND-" + System.nanoTime(), orderId, orderNo, talentId, merchantId,
                "H" + suffix.substring(Math.max(0, suffix.length() - 8)), "HF-TX-" + suffix, "payment-fund-" + suffix + "-" + orderId);
        return orderNo;
    }
    private Long orderId(String orderNo) {
        return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
    }
    /** 提交一笔结果未定的退款,返回退款记录;通道回调留给测试自己发送。 */
    private Map<String, Object> pendingRefund(String orderNo) {
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000000", "hf_seq_id", "HF-PENDING-" + System.nanoTime())));
        return refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "pending", "tester"));
    }
    private String refundCallback(String refundNo, String transStatus, String code) {
        return com.alibaba.fastjson.JSON.toJSONString(Map.of(
                "resp_code", code,
                "resp_desc", "退款回调",
                "trans_stat", transStatus,
                "req_seq_id", refundNo));
    }
    private Integer activeVerifyRecords(Long orderId) {
        return jdbc.queryForObject("select count(*) from verify_record where order_id = ? and status = 1", Integer.class, orderId);
    }
    private Map<String, Object> providerResponse(Map<String, Object> data) {
        return Map.of("data", data);
    }
}