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.
|
*
|
* <p>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<String, Object> 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<String, Object> 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("无效或已过期");
|
}
|
|
/**
|
* 轮换不再作废上一张码:商户扫到的那一瞬间恰好赶上翻转,两次都应该成功。
|
*
|
* <p>旧的 {@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<String, Object> first = pendingUsePortalService.issueCode(order.orderNo(), talentId);
|
Map<String, Object> 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) {
|
}
|
}
|