yeg
2026-09-28 85bfb9351803d97a33329d507f30a2f78bf1fc75
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
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
package com.talentcard.server.business.merchant;
 
import java.math.BigDecimal;
import java.math.RoundingMode;
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.business.order.OrderVerificationService;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.dao.DataAccessException;
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 static final String TICKET_PACKAGE_CODE = "TICKET_PACKAGE_CODE";
 
    /**
     * 6 位数字核销码:顾客屏幕上的数字码,相机不好用或屏幕反光时手输兜底。
     *
     * <p>它就是订单核销码的另一副面孔(同一张 {@code order_dynamic_verification_code} 的
     * {@code display_code}),因此有效期与动态码完全相同;但它只有 100 万空间,必须限次,
     * 见 {@link MerchantNumericCodeAttemptGuard}。
     */
    private static final java.util.regex.Pattern NUMERIC_CODE = java.util.regex.Pattern.compile("\\d{6}");
 
    private final JdbcTemplate jdbc;
    private final ManualConsumptionService manualConsumptionService;
    private final OrderVerificationService orderVerificationService;
    private final MerchantNumericCodeAttemptGuard numericCodeGuard;
 
    public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService,
                                       OrderVerificationService orderVerificationService,
                                       MerchantNumericCodeAttemptGuard numericCodeGuard) {
        this.jdbc = jdbc;
        this.manualConsumptionService = manualConsumptionService;
        this.orderVerificationService = orderVerificationService;
        this.numericCodeGuard = numericCodeGuard;
    }
 
    /**
     * A QR token is opaque to the client. Its source table is the authority for its type and scope.
     *
     * <p>纯 6 位数字的输入走手输核销码那条路:码令牌是 32 位十六进制,不可能是 6 位数字。
     */
    @Transactional
    public Map<String, Object> scan(MerchantPrincipal principal, VerificationScanRequest request) {
        String token = request.scanResult().trim();
        if (NUMERIC_CODE.matcher(token).matches()) {
            return scanNumericCode(principal, token);
        }
        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);
        }
        Map<String, Object> ticketPackageCode = findTicketPackageCode(token, principal);
        if (ticketPackageCode != null) {
            return createTicketPackageAttempt(token, ticketPackageCode);
        }
        throw new BusinessException(ErrorCode.CONFLICT, "二维码已失效、不可核销或不属于当前商户");
    }
 
    /**
     * 手输 6 位数字核销码。
     *
     * <p>{@code display_code} 只有 100 万空间,同一时刻完全可能有两张活着的码撞在一起,所以按
     * {@code limit 2} 取行:拿不到**唯一**一张就拒绝。而「猜错(0 条)」与「撞码(2 条)」必须用同一句话
     * 回答——否则攻击者只要比较这两种答复,就知道「这个号确实有主」,那正是枚举要的探针。
     *
     * <p>答复里**不带订单号**:客户就在眼前,商户不需要靠接口知道这个码属于哪笔订单;带回去却等于给
     * 枚举提供了一张「号码 → 订单」的映射表。确认核销时由服务端从扫码尝试里把订单找回来。
     */
    private Map<String, Object> scanNumericCode(MerchantPrincipal principal, String code) {
        numericCodeGuard.requireUsable(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, '订单权益') 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 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.display_code = ? 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 2
                """, code, principal.merchantId(), principal.storeId());
        if (codes.size() != 1) {
            numericCodeGuard.recordFailure(principal);
            throw new BusinessException(ErrorCode.CONFLICT, "核销码无效或已过期,请核对后重新输入");
        }
        numericCodeGuard.recordSuccess(principal);
        Map<String, Object> found = codes.get(0);
        String verificationId = createAttempt(principal, ORDER_CODE, null,
                number(found.get("orderVerificationCodeId")), null, found.get("expiresAt"));
        Map<String, Object> result = scanResult(verificationId, ORDER_CODE, "核销码有效,请确认后完成核销",
                found.get("talentName"), found.get("talentMobile"), found.get("cardLevel"), null, found.get("expiresAt"), 0L);
        // 连字段都不留:空字符串容易被客户端当成「没查到」,直接不给这个键更清楚。
        result.remove("orderNo");
        result.put("numericEntry", true);
        return result;
    }
 
    /**
     * 免费入住券核销:与支付无关的独立业务——用户不付钱,核销只扣券次数,补贴由平台线下结算。
     *
     * <p>订单核销不走这里:它必须写核销记录并确认延迟入账的资金,统一在
     * {@link OrderVerificationService#verifyByMerchant} 里实现,避免两处各写一遍导致漏改。
     */
    @Transactional
    public Map<String, Object> confirmFreeStay(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"));
        if (!FREE_STAY_CODE.equals(credentialType)) {
            // 订单核销码要确认资金,必须走订单核销接口;人才码要先发单。
            throw new BusinessException(ErrorCode.CONFLICT, "该凭证不能在这里核销,请使用对应的核销入口");
        }
        // 免费入住券不看扫码码的有效期:码过期只影响扫码时机,券本身的 valid_end 才决定能不能核销
        // (confirmFreeStayCode 里会校验)。这是既有行为,不要收紧。
        if (number(attempt.get("attemptStatus")) != 0) {
            jdbc.update("update merchant_verification_attempt set status = 2 where id = ? and status = 0", number(attempt.get("attemptId")));
            throw new BusinessException(ErrorCode.CONFLICT, "核销凭证已失效,请重新扫码");
        }
        return confirmFreeStayCode(principal, attempt);
    }
 
    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"), 0L);
    }
 
    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"), 0L);
    }
 
    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"));
        Map<String, Object> result = scanResult(verificationId, FREE_STAY_CODE, "免费入住券有效,剩余 " + remaining + " 次,请确认后核销",
                code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt"),
                hotelSubsidyAmountCents());
        result.put("freeStayTotalCount", number(code.get("totalCount")));
        result.put("freeStayRemainingCount", Math.max(remaining, 0L));
        result.put("benefitText", freeStayBenefitText(code, principal));
        // 免费天数用完后按门店配置的到店消费折扣计价(如「门市价9折」),免费入住确认页要显示这段。
        result.put("discountText", manualConsumptionService.freeStayExceedDiscountText(
                principal.merchantId(), value(code.get("cardLevel"), ""), value(code.get("sourceAreaType"), "")));
        return result;
    }
 
    /**
     * 观影权益兑换码:票包后台批量生成、绑到影城、用户在小程序领取后出示二维码。
     *
     * <p>只认「属于当前商户 + 已发放未核销 + 未过期」的码;核销本身走
     * {@code POST /api/merchant/benefit-verifications/verify}(与后台同一套记录),
     * 这里只做扫码后的资格确认,让店员先看清是谁的券再确认。
     */
    private Map<String, Object> findTicketPackageCode(String token, MerchantPrincipal principal) {
        List<Map<String, Object>> claims = rows("""
                select cl.id claim_id, cl.exchange_code, cl.talent_name, cl.talent_mobile, cl.card_level,
                       cl.valid_end, cl.status claim_status, p.package_name, m.merchant_name
                from ticket_package_claim cl
                join ticket_package p on p.id = cl.package_id and p.deleted = 0
                left join merchant m on m.id = cl.merchant_id and m.deleted = 0
                where cl.exchange_code = ? and cl.deleted = 0 and cl.merchant_id = ?
                limit 1
                """, token, principal.merchantId());
        if (claims.isEmpty()) return null;
        Map<String, Object> claim = claims.get(0);
        if (!"ISSUED".equalsIgnoreCase(value(claim.get("claimStatus"), "").trim())) {
            throw new BusinessException(ErrorCode.CONFLICT, "该观影权益已核销或已失效");
        }
        if (expiredDate(claim.get("validEnd"))) {
            throw new BusinessException(ErrorCode.CONFLICT, "该观影权益已过期");
        }
        return claim;
    }
 
    private Map<String, Object> createTicketPackageAttempt(String exchangeCode, Map<String, Object> claim) {
        // 这类码没有 attempt 表可用(它只服务动态码/订单码/免费入住券),核销记录由
        // benefit_verify_record 落库,因此这里用「TP+领取记录ID」当核销流水号。
        Map<String, Object> result = scanResult("TP" + number(claim.get("claimId")), TICKET_PACKAGE_CODE,
                "观影权益有效,请确认后核销",
                claim.get("talentName"), claim.get("talentMobile"), claim.get("cardLevel"), null, null, 0L);
        result.put("verifyCode", exchangeCode);
        result.put("packageName", value(claim.get("packageName"), ""));
        result.put("merchantName", value(claim.get("merchantName"), ""));
        result.put("validEnd", claim.get("validEnd") == null ? "" : String.valueOf(claim.get("validEnd")));
        return result;
    }
 
    private String freeStayBenefitText(Map<String, Object> code, MerchantPrincipal principal) {
        try {
            List<Map<String, Object>> benefits = rows("""
                    select benefit_text
                    from merchant_card_benefit
                    where merchant_id = ? and card_level = ? and status = 1
                    limit 1
                    """, principal.merchantId(), value(code.get("cardLevel"), ""));
            return benefits.isEmpty() ? "" : value(benefits.get(0).get("benefitText"), "");
        } catch (DataAccessException ignored) {
            // Older installations may not have the optional display table yet.
            return "";
        }
    }
 
    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> 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, "免费入住券已被使用或已失效");
        }
        Long subsidyAmount = hotelSubsidyAmountCents();
        String verifyNo = insertRecord(principal, null, number(code.get("talentId")), number(code.get("cardId")), null,
                "HOTEL_FREE", 0L, subsidyAmount, "免费入住券(平台线下补贴)");
        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) {
        // source_area_type 必须一起选:酒店按「本地/外地」分等级配置时,确认页要显示的
        // 「超过天数按门市价X折」就是靠它去匹配对应等级的折扣,漏了会拿不到折扣文案。
        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,
                       t.source_area_type
                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, Long subsidyAmount) {
        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("subsidyAmount", subsidyAmount == null ? 0L : subsidyAmount);
        result.put("expiresAt", expiresAt);
        return result;
    }
 
    private Long hotelSubsidyAmountCents() {
        List<Map<String, Object>> settings = rows("""
                select setting_value
                from admin_setting
                where setting_key = 'hotelSubsidy'
                limit 1
                """);
        if (settings.isEmpty()) {
            return 0L;
        }
        String configuredAmount = value(settings.get(0).get("settingValue"), "").trim();
        if (configuredAmount.isEmpty()) {
            return 0L;
        }
        try {
            BigDecimal yuan = new BigDecimal(configuredAmount).setScale(2, RoundingMode.UNNECESSARY);
            if (yuan.signum() < 0) {
                throw new ArithmeticException("negative subsidy");
            }
            return yuan.movePointRight(2).longValueExact();
        } catch (ArithmeticException | NumberFormatException exception) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店免费入住补贴金额配置不正确,请联系平台管理员");
        }
    }
 
    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));
    }
}