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) {
}
}