yeg
2026-09-17 dd22571b369970b35ca6c48f32010e2876b43d25
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
@@ -1,5 +1,7 @@
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;
@@ -9,8 +11,10 @@
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;
@@ -21,20 +25,40 @@
    private static final String ORDER_CODE = "ORDER_CODE";
    private static final String FREE_STAY_CODE = "FREE_STAY_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) {
    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);
@@ -50,8 +74,55 @@
        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> confirm(MerchantPrincipal principal, VerificationConfirmRequest request, String idempotencyKey) {
    public Map<String, Object> confirmFreeStay(MerchantPrincipal principal, VerificationConfirmRequest request, String idempotencyKey) {
        if (idempotencyKey == null || idempotencyKey.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键");
        }
@@ -69,17 +140,18 @@
        if (number(attempt.get("attemptStatus")) == 1) {
            return confirmed(attempt.get("verifyNo"), null);
        }
        if (number(attempt.get("attemptStatus")) != 0 || expired(attempt.get("expiresAt"))) {
        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, "核销凭证已失效,请重新扫码");
        }
        String credentialType = String.valueOf(attempt.get("credentialType"));
        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, "未知核销凭证类型");
        };
        return confirmFreeStayCode(principal, attempt);
    }
    private Map<String, Object> createTalentAttempt(MerchantPrincipal principal, Map<String, Object> code) {
@@ -93,22 +165,42 @@
        }
        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"));
                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"));
                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"));
        return scanResult(verificationId, FREE_STAY_CODE, "免费入住券有效,剩余 " + remaining + " 次,请确认后核销",
                code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), null, 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));
        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) {
@@ -151,41 +243,6 @@
        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 = 0
                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 || expired(code.get("expiresAt"))) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销");
        }
        int codeUpdated = jdbc.update("""
                update order_dynamic_verification_code set status = 1
                where id = ? and status = 0 and expires_at > now()
                """, 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("""
@@ -194,7 +251,7 @@
                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 = 0
                where c.id = ? and c.status in (0, 2)
                limit 1 for update
                """, number(attempt.get("freeStayVerificationCodeId")));
        if (codes.isEmpty()) {
@@ -202,12 +259,12 @@
        }
        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")) || expired(code.get("expiresAt"))) {
                || 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 = 0 and expires_at > now()
                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
@@ -216,8 +273,9 @@
        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, 0L, "免费入住券");
                "HOTEL_FREE", 0L, subsidyAmount, "免费入住券(平台线下补贴)");
        finishAttempt(attempt, verifyNo);
        return confirmed(verifyNo, code.get("cardLevel"));
    }
@@ -290,7 +348,8 @@
    }
    private Map<String, Object> scanResult(String verificationId, String credentialType, String message,
                                            Object talentName, Object talentMobile, Object cardLevel, Object orderNo, Object expiresAt) {
                                            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);
@@ -302,10 +361,36 @@
        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);