From dd22571b369970b35ca6c48f32010e2876b43d25 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 17 九月 2026 15:48:11 +0800
Subject: [PATCH] feat(verification): 核销码轮换放宽与手输 6 位数字码核销

---
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java |  132 +++++++++++++++++++++++++++++++++++++------
 1 files changed, 112 insertions(+), 20 deletions(-)

diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
index 9311959..d40560f 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
@@ -12,13 +12,20 @@
 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 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() {
@@ -26,6 +33,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,8 +63,10 @@
                     pay_amount bigint not null,
                     order_status tinyint not null,
                     verify_status tinyint not null,
+                    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
                 )
                 """);
@@ -84,53 +94,135 @@
                     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));
+        // 资金确认服务会按订单查支付流水,这里给出结构一致的最小表。
+        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, new MerchantNumericCodeAttemptGuard(jdbc));
+        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 = service.confirm(principal,
-                new VerificationConfirmRequest("expired-group-order"), "order-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
     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);
+        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 = ?", 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 -> {
+            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));
     }
 }

--
Gitblit v1.7.1