yeg
2026-09-14 61e873aada7f8c55ccebb4c5fdf93ec21eb5fffe
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
@@ -21,7 +21,11 @@
    private JdbcTemplate jdbc;
    private TransactionTemplate transactions;
    private MerchantVerificationService service;
    private com.talentcard.server.business.order.OrderVerificationService orderVerification;
    private MerchantPrincipal principal;
    /** 每个用例用独立的订单号与核销码号,避免共享内存库互相踩。 */
    private static final java.util.concurrent.atomic.AtomicLong ORDER_SEQUENCE = new java.util.concurrent.atomic.AtomicLong(1000);
    private static final java.util.concurrent.atomic.AtomicLong ORDER_CODE_SEQUENCE = new java.util.concurrent.atomic.AtomicLong(1000);
    @BeforeEach
    void setUp() {
@@ -62,6 +66,7 @@
                    refund_status tinyint not null default 0,
                    merchant_id bigint not null,
                    store_id bigint,
                    collect_huifu_id varchar(32),
                    deleted tinyint not null default 0
                )
                """);
@@ -89,28 +94,52 @@
                    verify_type varchar(32),
                    verify_amount bigint,
                    subsidy_amount bigint,
                    remark varchar(255)
                    remark varchar(255),
                    idempotency_key varchar(64)
                )
                """);
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null),
                new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc),
                // 这些用例只验证核销本身,资金确认用替身:确认逻辑由 PaymentFundConfirmationTests 覆盖。
                org.mockito.Mockito.mock(com.talentcard.server.business.payment.PaymentFundConfirmationService.class));
        // 资金确认服务会按订单查支付流水,这里给出结构一致的最小表。
        jdbc.execute("""
                create table payment_transaction (
                    id bigint auto_increment primary key,
                    payment_no varchar(64) not null,
                    order_id bigint not null,
                    status varchar(32) not null,
                    amount bigint not null,
                    provider_huifu_id varchar(32),
                    provider_transaction_no varchar(128),
                    funds_confirmation_status varchar(16),
                    created_at timestamp not null default current_timestamp
                )
                """);
        // 用真实的核销实现:错误分支(未支付/已退款)必须由真实逻辑抛出,替身会把这些分支掩盖掉。
        com.talentcard.server.business.order.OrderVerificationService orderVerification =
                new com.talentcard.server.business.order.OrderVerificationService(jdbc,
                        new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc),
                        new com.talentcard.server.business.payment.PaymentFundConfirmationService(jdbc, transactions,
                                org.mockito.Mockito.mock(com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.class)));
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), orderVerification);
        this.orderVerification = orderVerification;
        principal = new MerchantPrincipal(9L, 88L, 7L, "operator");
    }
    /**
     * 订单核销码过期后不再接受直接核销:持码人重新扫码即可(扫码会刷新码)。
     *
     * <p>此前的实现容忍过期,把已经失效的扫码码也当作可核销;现在统一由订单核销入口处理,
     * 过期就明确拒绝并把这次扫码尝试标记为失效,避免"拿着旧码也能核销"的模糊状态。
     */
    @Test
    void confirmsGroupOrderAfterScannedCodeExpiresAndRefreshes() {
    void rejectsGroupOrderWhenTheScannedCodeHasExpired() {
        insertOrder("expired-group-order", 1);
        Long expiredOrderId = jdbc.queryForObject("select max(id) from benefit_order", Long.class);
        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);
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = 20", Integer.class)).isEqualTo(1);
        assertThatThrownBy(() -> confirm("expired-group-order"))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("失效");
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, expiredOrderId)).isZero();
        assertThat(jdbc.queryForObject("select status from merchant_verification_attempt where verification_id = ?", Integer.class,
                "expired-group-order")).isEqualTo(1);
        assertThat(jdbc.queryForObject("select count(*) from verify_record where verify_type = 'ORDER_CODE'", Integer.class)).isEqualTo(1);
                "expired-group-order")).isZero();
    }
    @Test
@@ -125,35 +154,75 @@
    @Test
    void rejectsGroupOrderThatWasRefundedWhileTheCodeWasOpen() {
        insertOrder("refunded-group-order", 1);
        jdbc.update("update benefit_order set refund_status = 2 where id = 20");
        Long refundedOrderId = jdbc.queryForObject("select max(id) from benefit_order", Long.class);
        jdbc.update("update benefit_order set refund_status = 2 where id = ?", refundedOrderId);
        assertThatThrownBy(() -> confirm("refunded-group-order"))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("退款");
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = 20", Integer.class)).isZero();
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, refundedOrderId)).isZero();
    }
    /** 核销确认按订单行锁串行化,所以测试也必须在一个真实事务里调用它。 */
    /** 未过期的订单核销码可以正常核销:置订单已核销、写核销记录、标记扫码尝试完成。 */
    @Test
    void confirmsGroupOrderWithAFreshScannedCode() {
        insertOrder("fresh-group-order", 1, LocalDateTime.now().plusMinutes(5));
        Map<String, Object> confirmed = confirm("fresh-group-order");
        long orderId = ((Number) confirmed.get("orderId")).longValue();
        assertThat(confirmed.get("verified")).isEqualTo(true);
        assertThat(jdbc.queryForObject("select status from order_dynamic_verification_code where order_id = ?", Integer.class, orderId)).isEqualTo(1);
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1);
        assertThat(jdbc.queryForObject("select status from merchant_verification_attempt where verification_id = ?", Integer.class,
                "fresh-group-order")).isEqualTo(1);
        assertThat(jdbc.queryForObject("select count(*) from verify_record where verify_type = 'ORDER_CODE' and order_id = ?", Integer.class, orderId)).isEqualTo(1);
    }
    /**
     * 订单核销按订单行锁串行化,所以测试也必须在一个真实事务里调用它。
     *
     * <p>订单核销不再走 {@code verification/confirm},改为调统一的订单核销实现,
     * 并把扫码尝试编号带过去标记尝试完成。
     */
    private Map<String, Object> confirm(String verificationId) {
        return transactions.execute(status -> service.confirm(principal,
                new VerificationConfirmRequest(verificationId), "order-confirm-" + verificationId));
        return transactions.execute(status -> {
            Map<String, Object> attempt = jdbc.queryForMap(
                    "select a.id, a.expires_at, c.order_id from merchant_verification_attempt a"
                            + " join order_dynamic_verification_code c on c.id = a.order_verification_code_id"
                            + " where a.verification_id = ?", verificationId);
            long attemptId = ((Number) attempt.get("id")).longValue();
            long orderId = ((Number) attempt.get("order_id")).longValue();
            java.time.LocalDateTime expiresAt = ((Timestamp) attempt.get("expires_at")).toLocalDateTime();
            String verifyNo = orderVerification.verifyByMerchant(principal.merchantId(), principal.storeId(),
                    principal.operatorId(), orderId, "ORDER_CODE", 8000L, 2000L, "订单动态核销码",
                    attemptId, "order-confirm-" + verificationId, expiresAt);
            return Map.of("verified", !verifyNo.isBlank(), "verifyNo", verifyNo, "orderId", orderId);
        });
    }
    private void insertOrder(String verificationId, int orderStatus) {
        insertOrder(verificationId, orderStatus, LocalDateTime.now().minusMinutes(1));
    }
    private void insertOrder(String verificationId, int orderStatus, LocalDateTime scanExpiresAt) {
        // 每个用例用独立订单号:共用同一个 id 时,前一个用例核销过的订单会让后一个用例直接失败。
        long orderId = ORDER_SEQUENCE.incrementAndGet();
        jdbc.update("insert into talent_card(id, card_level, deleted) values (10, '卓越人才卡', 0)");
        jdbc.update("""
                insert into benefit_order(id, talent_id, card_id, benefit_id, order_no, order_amount, subsidy_amount,
                                          pay_amount, order_status, verify_status, merchant_id, store_id, deleted)
                values (20, 1015, 10, null, ?, 10000, 3000, 7000, ?, 0, 88, 7, 0)
                """, "GROUP-" + verificationId, orderStatus);
                values (?, 1015, 10, null, ?, 10000, 3000, 7000, ?, 0, 88, 7, 0)
                """, orderId, "GROUP-" + verificationId, orderStatus);
        jdbc.update("""
                insert into order_dynamic_verification_code(id, order_id, expires_at, status)
                values (30, 20, ?, 2)
                """, Timestamp.valueOf(LocalDateTime.now().minusMinutes(1)));
                values (?, ?, ?, 2)
                """, ORDER_CODE_SEQUENCE.incrementAndGet(), orderId, Timestamp.valueOf(scanExpiresAt));
        Long codeId = jdbc.queryForObject("select max(id) from order_dynamic_verification_code", Long.class);
        jdbc.update("""
                insert into merchant_verification_attempt(verification_id, credential_type, order_verification_code_id,
                                                          merchant_id, store_id, operator_id, expires_at, status)
                values (?, 'ORDER_CODE', 30, 88, 7, 9, ?, 0)
                """, verificationId, Timestamp.valueOf(LocalDateTime.now().minusMinutes(1)));
                values (?, 'ORDER_CODE', ?, 88, 7, 9, ?, 0)
                """, verificationId, codeId, Timestamp.valueOf(scanExpiresAt));
    }
}