package com.talentcard.server.business.merchant; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.Map; import com.talentcard.server.business.merchant.auth.MerchantPrincipal; import com.talentcard.server.common.BusinessException; 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() { DriverManagerDataSource dataSource = new DriverManagerDataSource( "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, verification_id varchar(64) not null, credential_type varchar(32) not null, dynamic_code_id bigint, order_verification_code_id bigint, free_stay_verification_code_id bigint, merchant_id bigint not null, store_id bigint not null, operator_id bigint not null, expires_at timestamp, status tinyint not null default 0, verify_no varchar(64), confirmed_at timestamp ) """); jdbc.execute(""" create table benefit_order ( id bigint primary key, talent_id bigint not null, card_id bigint, benefit_id bigint, order_no varchar(64) not null, order_amount bigint not null, subsidy_amount bigint not null, 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 ) """); jdbc.execute(""" create table order_dynamic_verification_code ( id bigint primary key, order_id bigint not null, expires_at timestamp not null, status tinyint not null ) """); jdbc.execute("create table talent_card (id bigint primary key, card_level varchar(64), deleted tinyint not null default 0)"); jdbc.execute("create table benefit (id bigint primary key, benefit_name varchar(128))"); jdbc.execute(""" create table verify_record ( id bigint auto_increment primary key, verify_no varchar(64) not null, order_id bigint, talent_id bigint, card_id bigint, benefit_id bigint, merchant_id bigint, store_id bigint, staff_id bigint, verify_type varchar(32), verify_amount bigint, subsidy_amount bigint, remark varchar(255), idempotency_key varchar(64) ) """); // 资金确认服务会按订单查支付流水,这里给出结构一致的最小表。 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"); } /** * 订单核销码过期后不再接受直接核销:持码人重新扫码即可(扫码会刷新码)。 * *

此前的实现容忍过期,把已经失效的扫码码也当作可核销;现在统一由订单核销入口处理, * 过期就明确拒绝并把这次扫码尝试标记为失效,避免"拿着旧码也能核销"的模糊状态。 */ @Test void rejectsGroupOrderWhenTheScannedCodeHasExpired() { insertOrder("expired-group-order", 1); Long expiredOrderId = jdbc.queryForObject("select max(id) from benefit_order", Long.class); 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")).isZero(); } @Test void stillRejectsUnpaidGroupOrder() { insertOrder("unpaid-group-order", 0); assertThatThrownBy(() -> confirm("unpaid-group-order")) .isInstanceOf(BusinessException.class) .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 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); } /** * 订单核销按订单行锁串行化,所以测试也必须在一个真实事务里调用它。 * *

订单核销不再走 {@code verification/confirm},改为调统一的订单核销实现, * 并把扫码尝试编号带过去标记尝试完成。 */ private Map confirm(String verificationId) { return transactions.execute(status -> { Map 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 (?, 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 (?, ?, ?, 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', ?, 88, 7, 9, ?, 0) """, verificationId, codeId, Timestamp.valueOf(scanExpiresAt)); } }