yeg
2 天以前 65085442d5e6dfe2eaa0c7794a2fbc09d801a0dd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
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) {
    }
}