yeg
2026-09-14 61e873aada7f8c55ccebb4c5fdf93ec21eb5fffe
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
223
224
225
226
227
228
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.Map;
 
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
 
class MerchantVerificationOrderCodeTests {
 
    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() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(
                "jdbc:h2:mem:order-code-verification-" + System.nanoTime()
                        + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", "");
        jdbc = new JdbcTemplate(dataSource);
        transactions = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
        jdbc.execute("""
                create table merchant_verification_attempt (
                    id bigint auto_increment primary key,
                    verification_id varchar(64) not null,
                    credential_type varchar(32) not null,
                    dynamic_code_id bigint,
                    order_verification_code_id bigint,
                    free_stay_verification_code_id bigint,
                    merchant_id bigint not null,
                    store_id bigint not null,
                    operator_id bigint not null,
                    expires_at timestamp,
                    status tinyint not null default 0,
                    verify_no varchar(64),
                    confirmed_at timestamp
                )
                """);
        jdbc.execute("""
                create table benefit_order (
                    id bigint primary key,
                    talent_id bigint not null,
                    card_id bigint,
                    benefit_id bigint,
                    order_no varchar(64) not null,
                    order_amount bigint not null,
                    subsidy_amount bigint not null,
                    pay_amount bigint not null,
                    order_status tinyint not null,
                    verify_status tinyint not null,
                    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
                )
                """);
        jdbc.execute("""
                create table order_dynamic_verification_code (
                    id bigint primary key,
                    order_id bigint not null,
                    expires_at timestamp not null,
                    status tinyint not null
                )
                """);
        jdbc.execute("create table talent_card (id bigint primary key, card_level varchar(64), deleted tinyint not null default 0)");
        jdbc.execute("create table benefit (id bigint primary key, benefit_name varchar(128))");
        jdbc.execute("""
                create table verify_record (
                    id bigint auto_increment primary key,
                    verify_no varchar(64) not null,
                    order_id bigint,
                    talent_id bigint,
                    card_id bigint,
                    benefit_id bigint,
                    merchant_id bigint,
                    store_id bigint,
                    staff_id bigint,
                    verify_type varchar(32),
                    verify_amount bigint,
                    subsidy_amount bigint,
                    remark varchar(255),
                    idempotency_key varchar(64)
                )
                """);
        // 资金确认服务会按订单查支付流水,这里给出结构一致的最小表。
        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 rejectsGroupOrderWhenTheScannedCodeHasExpired() {
        insertOrder("expired-group-order", 1);
        Long expiredOrderId = jdbc.queryForObject("select max(id) from benefit_order", Long.class);
 
        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")).isZero();
    }
 
    @Test
    void stillRejectsUnpaidGroupOrder() {
        insertOrder("unpaid-group-order", 0);
 
        assertThatThrownBy(() -> confirm("unpaid-group-order"))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("未支付");
    }
 
    @Test
    void rejectsGroupOrderThatWasRefundedWhileTheCodeWasOpen() {
        insertOrder("refunded-group-order", 1);
        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 = ?", 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 -> {
            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 (?, 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 (?, ?, ?, 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', ?, 88, 7, 9, ?, 0)
                """, verificationId, codeId, Timestamp.valueOf(scanExpiresAt));
    }
}