shinnosuke
2026-08-28 0b064045b0af20040b6ad976a7ce33cdc03d91f7
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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
package com.talentcard.server.business.merchant;
 
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
 
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
public class MerchantVerificationService {
    private static final String TALENT_CODE = "TALENT_CODE";
    private static final String ORDER_CODE = "ORDER_CODE";
    private static final String FREE_STAY_CODE = "FREE_STAY_CODE";
 
    private final JdbcTemplate jdbc;
    private final ManualConsumptionService manualConsumptionService;
 
    public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService) {
        this.jdbc = jdbc;
        this.manualConsumptionService = manualConsumptionService;
    }
 
    /**
     * A QR token is opaque to the client. Its source table is the authority for its type and scope.
     */
    @Transactional
    public Map<String, Object> scan(MerchantPrincipal principal, VerificationScanRequest request) {
        String token = request.scanResult().trim();
        Map<String, Object> talentCode = findTalentCode(token);
        if (talentCode != null) {
            return createTalentAttempt(principal, talentCode);
        }
        Map<String, Object> orderCode = findOrderCode(token, principal);
        if (orderCode != null) {
            return createOrderAttempt(principal, orderCode);
        }
        Map<String, Object> freeStayCode = findFreeStayCode(token, principal);
        if (freeStayCode != null) {
            return createFreeStayAttempt(principal, freeStayCode);
        }
        throw new BusinessException(ErrorCode.CONFLICT, "二维码已失效、不可核销或不属于当前商户");
    }
 
    @Transactional
    public Map<String, Object> confirm(MerchantPrincipal principal, VerificationConfirmRequest request, String idempotencyKey) {
        if (idempotencyKey == null || idempotencyKey.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键");
        }
        List<Map<String, Object>> attempts = rows("""
                select id attempt_id, credential_type, dynamic_code_id, order_verification_code_id,
                       free_stay_verification_code_id, status attempt_status, verify_no, expires_at
                from merchant_verification_attempt
                where verification_id = ? and merchant_id = ? and store_id = ? and operator_id = ?
                limit 1 for update
                """, request.verificationId().trim(), principal.merchantId(), principal.storeId(), principal.operatorId());
        if (attempts.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "核销凭证不存在或无权操作");
        }
        Map<String, Object> attempt = attempts.get(0);
        if (number(attempt.get("attemptStatus")) == 1) {
            return confirmed(attempt.get("verifyNo"), null);
        }
        String credentialType = String.valueOf(attempt.get("credentialType"));
        boolean codeExpiryRequired = !ORDER_CODE.equals(credentialType) && !FREE_STAY_CODE.equals(credentialType);
        if (number(attempt.get("attemptStatus")) != 0 || (codeExpiryRequired && expired(attempt.get("expiresAt")))) {
            jdbc.update("update merchant_verification_attempt set status = 2 where id = ? and status = 0", number(attempt.get("attemptId")));
            throw new BusinessException(ErrorCode.CONFLICT, "核销凭证已失效,请重新扫码");
        }
        return switch (credentialType) {
            case TALENT_CODE -> throw new BusinessException(ErrorCode.CONFLICT, "人才码需要先录入消费金额并发送订单");
            case ORDER_CODE -> confirmOrderCode(principal, attempt);
            case FREE_STAY_CODE -> confirmFreeStayCode(principal, attempt);
            default -> throw new BusinessException(ErrorCode.CONFLICT, "未知核销凭证类型");
        };
    }
 
    private Map<String, Object> createTalentAttempt(MerchantPrincipal principal, Map<String, Object> code) {
        ensureActiveCard(code);
        Long expectedMerchantId = number(code.get("expectedMerchantId"));
        if (expectedMerchantId != null && !expectedMerchantId.equals(principal.merchantId())) {
            throw new BusinessException(ErrorCode.CONFLICT, "该人才码不可在当前商户核销");
        }
        if (!manualConsumptionService.hasApplicableRule(principal.merchantId(), value(code.get("cardLevel"), ""), value(code.get("sourceAreaType"), ""))) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前商户未配置适用于该人才卡的到店消费权益");
        }
        String verificationId = createAttempt(principal, TALENT_CODE, number(code.get("dynamicCodeId")), null, null, code.get("expiresAt"));
        return scanResult(verificationId, TALENT_CODE, "人才码有效,请录入消费金额并发送待支付订单", code.get("talentName"),
                code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt"));
    }
 
    private Map<String, Object> createOrderAttempt(MerchantPrincipal principal, Map<String, Object> code) {
        String verificationId = createAttempt(principal, ORDER_CODE, null, number(code.get("orderVerificationCodeId")), null, code.get("expiresAt"));
        String title = value(code.get("productName"), "订单权益");
        return scanResult(verificationId, ORDER_CODE, "订单核销码有效,请确认后完成核销:" + title,
                code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), code.get("orderNo"), code.get("expiresAt"));
    }
 
    private Map<String, Object> createFreeStayAttempt(MerchantPrincipal principal, Map<String, Object> code) {
        Long remaining = number(code.get("totalCount")) - number(code.get("usedCount"));
        String verificationId = createAttempt(principal, FREE_STAY_CODE, null, null,
                number(code.get("freeStayVerificationCodeId")), code.get("expiresAt"));
        return scanResult(verificationId, FREE_STAY_CODE, "免费入住券有效,剩余 " + remaining + " 次,请确认后核销",
                code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt"));
    }
 
    private String createAttempt(MerchantPrincipal principal, String type, Long dynamicCodeId, Long orderCodeId, Long freeStayCodeId, Object expiresAt) {
        String verificationId = BusinessIdGenerator.next("MV");
        jdbc.update("""
                insert into merchant_verification_attempt(verification_id, dynamic_code_id, credential_type,
                                                          order_verification_code_id, free_stay_verification_code_id,
                                                          merchant_id, store_id, operator_id, expires_at)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?)
                """, verificationId, dynamicCodeId, type, orderCodeId, freeStayCodeId,
                principal.merchantId(), principal.storeId(), principal.operatorId(), expiresAt);
        return verificationId;
    }
 
    private Map<String, Object> confirmTalentCode(MerchantPrincipal principal, Map<String, Object> attempt) {
        List<Map<String, Object>> codes = rows("""
                select d.id dynamic_code_id, d.card_id, d.talent_id, c.card_level, d.expires_at,
                       c.status card_status, c.valid_end, t.status talent_status
                from talent_dynamic_code d
                join talent_card c on c.id = d.card_id and c.deleted = 0
                join talent_user t on t.id = d.talent_id and t.deleted = 0
                where d.id = ? and d.status = 0
                limit 1 for update
                """, number(attempt.get("dynamicCodeId")));
        if (codes.isEmpty()) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才码已被使用或已失效");
        }
        Map<String, Object> code = codes.get(0);
        ensureActiveCard(code);
        int updated = jdbc.update("""
                update talent_dynamic_code set status = 1, merchant_id = ?, used_at = now()
                where id = ? and status = 0 and expires_at > now()
                """, principal.merchantId(), number(code.get("dynamicCodeId")));
        if (updated == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才码已被使用或已失效");
        }
        String verifyNo = insertRecord(principal, null, number(code.get("talentId")), number(code.get("cardId")), null,
                TALENT_CODE, 0L, 0L, null);
        finishAttempt(attempt, verifyNo);
        return confirmed(verifyNo, code.get("cardLevel"));
    }
 
    private Map<String, Object> confirmOrderCode(MerchantPrincipal principal, Map<String, Object> attempt) {
        List<Map<String, Object>> codes = rows("""
                select c.id order_verification_code_id, c.expires_at, o.id order_id, o.talent_id, o.card_id, o.benefit_id,
                       o.order_no, o.order_amount, o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status,
                       o.merchant_id, o.store_id, tc.card_level
                from order_dynamic_verification_code c
                join benefit_order o on o.id = c.order_id and o.deleted = 0
                left join talent_card tc on tc.id = o.card_id and tc.deleted = 0
                where c.id = ? and c.status in (0, 2)
                limit 1 for update
                """, number(attempt.get("orderVerificationCodeId")));
        if (codes.isEmpty()) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单核销码已被使用或已失效");
        }
        Map<String, Object> code = codes.get(0);
        if (!belongsToPrincipal(code, principal) || number(code.get("orderStatus")) != 1 || number(code.get("verifyStatus")) != 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销");
        }
        int codeUpdated = jdbc.update("""
                update order_dynamic_verification_code set status = 1
                where id = ? and status in (0, 2)
                """, number(code.get("orderVerificationCodeId")));
        int orderUpdated = jdbc.update("""
                update benefit_order set verify_status = 1
                where id = ? and order_status = 1 and verify_status = 0
                """, number(code.get("orderId")));
        if (codeUpdated == 0 || orderUpdated == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已被核销或已失效");
        }
        String verifyNo = insertRecord(principal, number(code.get("orderId")), number(code.get("talentId")),
                number(code.get("cardId")), number(code.get("benefitId")), ORDER_CODE,
                number(code.get("payAmount")), number(code.get("subsidyAmount")), "订单动态核销码");
        finishAttempt(attempt, verifyNo);
        return confirmed(verifyNo, code.get("cardLevel"));
    }
 
    private Map<String, Object> confirmFreeStayCode(MerchantPrincipal principal, Map<String, Object> attempt) {
        List<Map<String, Object>> codes = rows("""
                select c.id free_stay_verification_code_id, c.expires_at, v.id voucher_id, v.talent_id, v.card_id,
                       v.merchant_id, v.total_count, v.used_count, v.valid_end, v.status voucher_status, tc.card_level
                from free_stay_verification_code c
                join talent_free_stay_voucher v on v.id = c.voucher_id
                left join talent_card tc on tc.id = v.card_id and tc.deleted = 0
                where c.id = ? and c.status in (0, 2)
                limit 1 for update
                """, number(attempt.get("freeStayVerificationCodeId")));
        if (codes.isEmpty()) {
            throw new BusinessException(ErrorCode.CONFLICT, "免费入住核销码已被使用或已失效");
        }
        Map<String, Object> code = codes.get(0);
        if (!principal.merchantId().equals(number(code.get("merchantId"))) || number(code.get("voucherStatus")) != 1
                || expiredDate(code.get("validEnd")) || number(code.get("usedCount")) >= number(code.get("totalCount"))) {
            throw new BusinessException(ErrorCode.CONFLICT, "免费入住券当前不可核销");
        }
        int codeUpdated = jdbc.update("""
                update free_stay_verification_code set status = 1
                where id = ? and status in (0, 2)
                """, number(code.get("freeStayVerificationCodeId")));
        int voucherUpdated = jdbc.update("""
                update talent_free_stay_voucher set used_count = used_count + 1
                where id = ? and status = 1 and valid_end >= current_date and used_count < total_count
                """, number(code.get("voucherId")));
        if (codeUpdated == 0 || voucherUpdated == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "免费入住券已被使用或已失效");
        }
        String verifyNo = insertRecord(principal, null, number(code.get("talentId")), number(code.get("cardId")), null,
                "HOTEL_FREE", 0L, 0L, "免费入住券");
        finishAttempt(attempt, verifyNo);
        return confirmed(verifyNo, code.get("cardLevel"));
    }
 
    private String insertRecord(MerchantPrincipal principal, Long orderId, Long talentId, Long cardId, Long benefitId,
                                String verifyType, Long verifyAmount, Long subsidyAmount, String remark) {
        String verifyNo = BusinessIdGenerator.next("V");
        jdbc.update("""
                insert into verify_record(verify_no, order_id, talent_id, card_id, benefit_id, merchant_id, store_id,
                                          staff_id, verify_type, verify_amount, subsidy_amount, remark)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """, verifyNo, orderId, talentId, cardId, benefitId, principal.merchantId(), principal.storeId(),
                principal.operatorId(), verifyType, verifyAmount, subsidyAmount, remark);
        return verifyNo;
    }
 
    private void finishAttempt(Map<String, Object> attempt, String verifyNo) {
        jdbc.update("""
                update merchant_verification_attempt
                set status = 1, verify_no = ?, confirmed_at = now()
                where id = ? and status = 0
                """, verifyNo, number(attempt.get("attemptId")));
    }
 
    private Map<String, Object> findTalentCode(String token) {
        List<Map<String, Object>> codes = rows("""
                select d.id dynamic_code_id, d.expected_merchant_id, d.expires_at,
                       c.id card_id, c.card_level, c.status card_status, c.valid_end,
                       t.id talent_id, t.name talent_name, t.mobile talent_mobile, t.status talent_status, t.source_area_type
                from talent_dynamic_code d
                join talent_card c on c.id = d.card_id and c.deleted = 0
                join talent_user t on t.id = d.talent_id and t.deleted = 0
                where d.code_token = ? and d.status = 0 and d.expires_at > now()
                limit 1
                """, token);
        return codes.isEmpty() ? null : codes.get(0);
    }
 
    private Map<String, Object> findOrderCode(String token, MerchantPrincipal principal) {
        List<Map<String, Object>> codes = rows("""
                select c.id order_verification_code_id, c.expires_at, o.order_no, o.order_status, o.verify_status,
                       o.merchant_id, o.store_id, coalesce(o.product_name_snapshot, b.benefit_name, '订单权益') product_name,
                       t.name talent_name, t.mobile talent_mobile, tc.card_level
                from order_dynamic_verification_code c
                join benefit_order o on o.id = c.order_id and o.deleted = 0
                left join benefit b on b.id = o.benefit_id
                left join talent_user t on t.id = o.talent_id and t.deleted = 0
                left join talent_card tc on tc.id = o.card_id and tc.deleted = 0
                where c.code_token = ? and c.status = 0 and c.expires_at > now()
                  and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)
                  and o.order_status = 1 and o.verify_status = 0
                limit 1
                """, token, principal.merchantId(), principal.storeId());
        return codes.isEmpty() ? null : codes.get(0);
    }
 
    private Map<String, Object> findFreeStayCode(String token, MerchantPrincipal principal) {
        List<Map<String, Object>> codes = rows("""
                select c.id free_stay_verification_code_id, c.expires_at, v.total_count, v.used_count, v.valid_end,
                       v.status voucher_status, v.merchant_id, t.name talent_name, t.mobile talent_mobile, tc.card_level
                from free_stay_verification_code c
                join talent_free_stay_voucher v on v.id = c.voucher_id
                join talent_user t on t.id = v.talent_id and t.deleted = 0
                left join talent_card tc on tc.id = v.card_id and tc.deleted = 0
                where c.code_token = ? and c.status = 0 and c.expires_at > now()
                  and v.merchant_id = ? and v.status = 1 and v.valid_end >= current_date and v.used_count < v.total_count
                limit 1
                """, token, principal.merchantId());
        return codes.isEmpty() ? null : codes.get(0);
    }
 
    private Map<String, Object> scanResult(String verificationId, String credentialType, String message,
                                            Object talentName, Object talentMobile, Object cardLevel, Object orderNo, Object expiresAt) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("verificationId", verificationId);
        result.put("credentialType", credentialType);
        result.put("status", "pending_confirmation");
        result.put("title", "核验通过");
        result.put("message", message);
        result.put("canVerify", true);
        result.put("customerName", maskName(value(talentName, "")));
        result.put("customerMobile", maskMobile(value(talentMobile, "")));
        result.put("cardLevel", cardLevel == null ? "人才卡" : String.valueOf(cardLevel));
        result.put("orderNo", orderNo == null ? "" : String.valueOf(orderNo));
        result.put("expiresAt", expiresAt);
        return result;
    }
 
    private Map<String, Object> confirmed(Object verifyNo, Object cardLevel) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("verified", true);
        result.put("verifyNo", verifyNo == null ? "" : String.valueOf(verifyNo));
        result.put("status", "verified");
        result.put("message", "核销成功");
        result.put("cardLevel", cardLevel == null ? "人才卡" : String.valueOf(cardLevel));
        result.put("verifiedAt", LocalDateTime.now().toString());
        return result;
    }
 
    private boolean belongsToPrincipal(Map<String, Object> order, MerchantPrincipal principal) {
        Long storeId = number(order.get("storeId"));
        return principal.merchantId().equals(number(order.get("merchantId")))
                && (storeId == null || principal.storeId().equals(storeId));
    }
 
    private void ensureActiveCard(Map<String, Object> code) {
        if (!Long.valueOf(1L).equals(number(code.get("talentStatus")))
                || !Long.valueOf(1L).equals(number(code.get("cardStatus")))
                || expiredDate(code.get("validEnd"))) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才卡当前不可核销");
        }
    }
 
    private boolean expired(Object value) {
        return value instanceof LocalDateTime dateTime && !dateTime.isAfter(LocalDateTime.now());
    }
 
    private boolean expiredDate(Object value) {
        return value instanceof LocalDate date && date.isBefore(LocalDate.now());
    }
 
    private Long number(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
 
    private String value(Object value, String fallback) {
        return value == null ? fallback : String.valueOf(value);
    }
 
    private String maskName(String name) {
        if (name == null || name.isBlank()) return "*";
        return name.length() == 1 ? name + "*" : name.substring(0, 1) + "*".repeat(name.length() - 1);
    }
 
    private String maskMobile(String mobile) {
        if (mobile == null || mobile.length() < 7) return "";
        return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4);
    }
 
    private List<Map<String, Object>> rows(String sql, Object... args) {
        return DatabaseRows.normalize(jdbc.queryForList(sql, args));
    }
}