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.List; import java.util.Map; import com.talentcard.server.business.merchant.auth.MerchantPrincipal; import com.talentcard.server.business.portal.PendingUsePortalService; 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.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; /** * Seam: the 6-digit code shown to the customer can be typed in when the camera is unusable, and the * code rotation no longer invalidates the code the merchant has already scanned. * *

The rotation half is exercised through the talent side ({@link PendingUsePortalService#issueCode}): * a new code must not kill the previous one, so a scan that lands on the rotation instant still works. */ @SpringBootTest @ActiveProfiles("test") class MerchantNumericCodeVerificationTests { @Autowired private JdbcTemplate jdbc; @Autowired private MerchantVerificationService verificationService; @Autowired private MerchantWorkService merchantWorkService; @Autowired private PendingUsePortalService pendingUsePortalService; private Long talentId; private Long merchantId; private Long storeId; private Long cardId; private String huifuId; private String suffix; private long sequence; @BeforeEach void prepareData() { suffix = String.valueOf(System.nanoTime()); jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "numeric-code-openid-" + suffix, "Numeric code user", "13900000006"); talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)", "Numeric code merchant " + suffix, "08120000005", "Test address"); merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); storeId = merchantId + 1; huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8)); jdbc.update("insert into talent_card(talent_id, card_no, card_level, card_type, status, deleted) values (?, ?, ?, 'PERSONAL', 1, 0)", talentId, "CARD-" + suffix, "卓越人才卡"); cardId = jdbc.queryForObject("select max(id) from talent_card", Long.class); sequence = 0; } @Test void theSixDigitCodeCanBeTypedInAndConfirmedWithoutDisclosingWhichOrderItBelongsTo() { PaidOrder order = paidOrder(); Map scanned = verificationService.scan(principal(), new VerificationScanRequest(order.displayCode())); assertThat(scanned) .as("手输的 6 位码就是订单核销码的另一副面孔") .containsEntry("credentialType", "ORDER_CODE") .containsEntry("canVerify", true) .containsEntry("numericEntry", true); assertThat(scanned) .as("不返回订单归属:6 位只有 100 万空间,带回去就是一张可以枚举的「号码 → 订单」映射表") .doesNotContainKey("orderNo"); Map confirmed = merchantWorkService.confirmNumericVerification( principal(), (String) scanned.get("verificationId"), "numeric-confirm-" + suffix); assertThat(confirmed).as("确认核销走的是与扫码完全相同的那套订单核销实现").isNotEmpty(); assertThat(jdbc.queryForObject("select verify_status from benefit_order where order_no = ?", Integer.class, order.orderNo())) .isEqualTo(1); assertThat(jdbc.queryForObject("select count(*) from verify_record where order_id = ?", Long.class, order.orderId())) .as("手输核销与扫码核销是同一套核销实现(核销记录只写一条)") .isEqualTo(1L); } @Test void aWrongGuessAndACollidingCodeAnswerExactlyTheSameWay() { PaidOrder first = paidOrder(); PaidOrder second = paidOrder(); String sharedCode = "424242"; jdbc.update("update order_dynamic_verification_code set display_code = ? where order_id in (?, ?)", sharedCode, first.orderId(), second.orderId()); String colliding = errorFrom(() -> verificationService.scan(principal(), new VerificationScanRequest(sharedCode))); String wrong = errorFrom(() -> verificationService.scan(principal(), new VerificationScanRequest("999999"))); assertThat(colliding) .as("撞码与猜错必须用同一句话回答,否则答复本身就是「这个号确实有主」的探针") .isEqualTo(wrong); } @Test void consecutiveWrongCodesLockTheNumericEntryAndLockedMeansItIsNotEvenChecked() { PaidOrder order = paidOrder(); for (int attempt = 0; attempt < MerchantNumericCodeAttemptGuard.MAX_CONSECUTIVE_FAILURES; attempt++) { assertThatThrownBy(() -> verificationService.scan(principal(), new VerificationScanRequest("000000"))) .isInstanceOf(BusinessException.class) .hasMessageContaining("无效或已过期"); } assertThat(errorFrom(() -> verificationService.scan(principal(), new VerificationScanRequest(order.displayCode())))) .as("锁定期间连校验都不做:正确的码也一样被挡在门外,枚举无从继续") .contains("锁定"); assertThat(jdbc.queryForObject(""" select count(*) from merchant_verification_attempt where merchant_id = ? and store_id = ? and operator_id = ? """, Long.class, merchantId, storeId, principal().operatorId())) .as("锁定期间不该产生任何扫码尝试") .isZero(); } @Test void aWrongCodeThatIsFollowedByTheRightOneClearsTheFailureCount() { PaidOrder order = paidOrder(); for (int attempt = 0; attempt < MerchantNumericCodeAttemptGuard.MAX_CONSECUTIVE_FAILURES - 1; attempt++) { assertThatThrownBy(() -> verificationService.scan(principal(), new VerificationScanRequest("000000"))) .isInstanceOf(BusinessException.class); } verificationService.scan(principal(), new VerificationScanRequest(order.displayCode())); assertThat(jdbc.queryForObject(""" select consecutive_failures from merchant_numeric_code_attempt where merchant_id = ? and store_id = ? and operator_id = ? """, Integer.class, merchantId, storeId, principal().operatorId())) .as("输对一次就重新开始数,偶尔手误不该把收银员锁住") .isZero(); } @Test void anExpiredNumericCodeIsRefusedLikeAnyOtherInvalidCode() { PaidOrder order = paidOrder(); jdbc.update("update order_dynamic_verification_code set expires_at = ? where order_id = ?", Timestamp.valueOf(LocalDateTime.now().minusSeconds(1)), order.orderId()); assertThat(errorFrom(() -> verificationService.scan(principal(), new VerificationScanRequest(order.displayCode())))) .as("数字码与动态码同有效期,过了就一样失效") .contains("无效或已过期"); } /** * 轮换不再作废上一张码:商户扫到的那一瞬间恰好赶上翻转,两次都应该成功。 * *

旧的 {@code issueCode} 一生成新码就把旧码置为已失效,于是「刚好翻转」必然失败。 */ @Test void rotatingTheCodeKeepsThePreviousOneUsableDuringTheOverlap() { PaidOrder order = paidOrder(); String seededCodeToken = jdbc.queryForObject("select code_token from order_dynamic_verification_code where order_id = ?", String.class, order.orderId()); Map first = pendingUsePortalService.issueCode(order.orderNo(), talentId); Map second = pendingUsePortalService.issueCode(order.orderNo(), talentId); assertThat(jdbc.queryForObject("select status from order_dynamic_verification_code where code_token = ?", Integer.class, first.get("token"))) .as("轮换不再把上一张码立刻作废(旧实现一生成新码就把旧码置为已失效)") .isZero(); // 新旧两张码都还能被识别:扫到时刚好翻转,不会再报「核销凭证已失效」。 scanSucceeds(seededCodeToken); scanSucceeds((String) first.get("token")); scanSucceeds((String) second.get("token")); } /** 扫这张码会被识别(失败会抛业务异常)。 */ private void scanSucceeds(String codeToken) { verificationService.scan(principal(), new VerificationScanRequest(codeToken)); } private String errorFrom(org.assertj.core.api.ThrowableAssert.ThrowingCallable action) { Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(action); assertThat(thrown).isInstanceOf(BusinessException.class); return thrown.getMessage(); } private MerchantPrincipal principal() { return new MerchantPrincipal(merchantId + 1000, merchantId, storeId, "operator"); } /** 一笔已支付、未核销、可核销的订单,附带一张有效期内的动态核销码(含 6 位数字码)。 */ private PaidOrder paidOrder() { String orderNo = "NUMERIC-" + suffix + "-" + (++sequence); jdbc.update(""" insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, order_amount, base_pay_amount, pay_amount, order_status, verify_status, price_version, collect_huifu_id, product_name_snapshot) values (?, ?, ?, ?, 'GROUP_BUY', 10000, 10000, 10000, 1, 0, 1, ?, '手输核销码测试商品') """, orderNo, talentId, merchantId, storeId, huifuId); Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo); String displayCode = String.format("%06d", 100000 + (System.nanoTime() % 900000)); jdbc.update(""" insert into order_dynamic_verification_code(code_token, display_code, order_id, talent_id, expires_at, status) values (?, ?, ?, ?, ?, 0) """, "numeric-token-" + orderNo, displayCode, orderId, talentId, Timestamp.valueOf(LocalDateTime.now().plusSeconds(60))); jdbc.update(""" insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id, provider_huifu_id, amount, status, idempotency_key, paid_at) values (?, 'HUIFU', ?, ?, ?, ?, ?, 10000, 'SUCCEEDED', ?, now()) """, "P-" + orderNo, orderId, orderNo, talentId, merchantId, huifuId, "numeric-payment-" + orderNo); return new PaidOrder(orderNo, orderId, displayCode); } private record PaidOrder(String orderNo, Long orderId, String displayCode) { } }