| | |
| | | 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() { |
| | |
| | | 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 |
| | | ) |
| | | """); |
| | |
| | | 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 |
| | |
| | | @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)); |
| | | } |
| | | } |