yeg
2026-09-14 61e873aada7f8c55ccebb4c5fdf93ec21eb5fffe
refactor(merchant): 核销收敛为一份实现,按是否涉及资金拆两个接口

核销此前有三处各写一遍「置订单已核销」,于是扫码核销那条路漏挂了资金确认:用户核销后
钱会一直冻在通道延迟户,商户永远收不到。现在只有一份实现 OrderVerificationService
(置已核销 + 写核销记录 + 确认延迟入账的资金),订单管理、扫码核销、后台核销都调它。

接口按「是否涉及资金」拆开,而不是按动线拆:
- 订单核销 POST /api/merchant/orders/{orderNo}/verify(新增可选 verificationId,
扫码核销带上它以便消费核销码并标记扫码尝试完成)
- 免费入住券核销 POST /api/merchant/verification/free-stay/confirm(不涉及支付,
只扣券次数,补贴由平台线下结算)

顺带修掉三个真缺陷:
- 订单核销码核销后没有被标记为已消费,同一个码可以反复核销
- 扫码码有效期没有校验,持过期码也能核销
- 到店消费(支付即核销)不留核销记录,冲正/纠纷/统计都缺凭证,现在补记一条

免费入住券的扫码码过期容忍是既有行为,保持不变。
1个文件已添加
14个文件已修改
578 ■■■■■ 已修改文件
rencaika_merchant/config/api.uts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-detail/verification-detail.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/orders.uts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/verification.uts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/VerifyRequest.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderVerificationService.java 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/config/api.uts
@@ -49,7 +49,8 @@
    if (name == 'merchantOrderVerify') return '/api/merchant/orders/'
    if (name == 'merchantSettlementStatistics') return '/api/merchant/settlement-statistics'
    if (name == 'verificationScan') return '/api/merchant/verification/scan'
    if (name == 'verificationConfirm') return '/api/merchant/verification/confirm'
    if (name == 'verificationConfirm') return '/api/merchant/verification/free-stay/confirm'
    if (name == 'verificationFreeStayConfirm') return '/api/merchant/verification/free-stay/confirm'
    if (name == 'manualConsumption') return '/api/merchant/manual-consumptions'
    if (name == 'manualConsumptionQuote') return '/api/merchant/manual-consumptions/quote'
    if (name == 'orderRevokeVerification') return '/api/merchant/orders/revoke-verification'
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue
@@ -33,7 +33,7 @@
<script setup lang="uts">
    import { loadMerchantOrderDetail, MerchantOrderDetail } from '../../services/orders.uts'
    import { confirmVerificationCode } from '../../services/verification.uts'
    import { confirmMerchantOrderVerification, loadMerchantOrderDetail, MerchantOrderDetail } from '../../services/orders.uts'
    const verificationId = ref('')
    const customerName = ref('持卡人')
@@ -96,7 +96,10 @@
            success: (modal: any) => {
                if (!modal.confirm) return
                submitting.value = true
                confirmVerificationCode(verificationId.value, 'merchant-verify-' + verificationId.value + '-' + Date.now(), (result: any, _traceId: string) => {
                // 订单核销统一走订单核销接口:核销要写核销记录并确认延迟入账的资金。
                // 带上扫码尝试编号,核销成功后把这次扫码标记为已完成。
                confirmMerchantOrderVerification(orderNo.value, verificationId.value, 'ORDER_CODE',
                    'merchant-verify-' + verificationId.value + '-' + Date.now(), (result: any, _traceId: string) => {
                    submitting.value = false
                    uni.redirectTo({ url: '/pages/verification-success/verification-success?orderNo=' + encodeURIComponent(orderNo.value) })
                }, (error: any) => {
rencaika_merchant/pages/verification-detail/verification-detail.uvue
@@ -74,7 +74,7 @@
    const submitVerification = () => {
        if (submitting.value || orderNo.value.length == 0) return
        submitting.value = true
        confirmMerchantOrderVerification(orderNo.value, 'merchant-order-verify-' + orderNo.value + '-' + Date.now(), (order: MerchantOrderDetail, _traceId: string) => { submitting.value = false; applyOrder(order); uni.showToast({ title: '核销成功', icon: 'success' }) }, (error: any) => { submitting.value = false; uni.showToast({ title: error.message != null ? error.message : '核销失败,请重试', icon: 'none' }) })
        confirmMerchantOrderVerification(orderNo.value, '', 'MERCHANT_ORDER', 'merchant-order-verify-' + orderNo.value + '-' + Date.now(), (order: MerchantOrderDetail, _traceId: string) => { submitting.value = false; applyOrder(order); uni.showToast({ title: '核销成功', icon: 'success' }) }, (error: any) => { submitting.value = false; uni.showToast({ title: error.message != null ? error.message : '核销失败,请重试', icon: 'none' }) })
    }
    const confirmVerification = () => {
rencaika_merchant/services/orders.uts
@@ -168,8 +168,16 @@
    method: 'GET'
}, (data: any, traceId: string) => onSuccess(toOrderDetail(data), traceId), onError)
/**
 * 订单核销:核销成功会写核销记录并确认延迟入账的资金。
 *
 * verificationId 是扫码尝试编号;从订单列表直接核销时不传。
 * verifyType 会写进核销记录用于审计:扫码核销记 ORDER_CODE,订单列表核销记 MERCHANT_ORDER。
 */
export const confirmMerchantOrderVerification = (
    orderNo: string,
    verificationId: string,
    verifyType: string,
    idempotencyKey: string,
    onSuccess: (order: MerchantOrderDetail, traceId: string) => void,
    onError: (error: HttpError) => void
@@ -177,7 +185,7 @@
    path: getApiEndpoint('merchantOrderVerify') + encodeURIComponent(orderNo) + '/verify',
    method: 'POST',
    idempotencyKey: idempotencyKey,
    data: { verifyType: 'MERCHANT_ORDER', remark: '' }
    data: { verifyType: verifyType, remark: '', verificationId: verificationId != null ? verificationId : '' }
}, (data: any, traceId: string) => onSuccess(toOrderDetail(data), traceId), onError)
const demoOrders: Array<MerchantOrder> = [
rencaika_merchant/services/verification.uts
@@ -90,13 +90,14 @@
    }, onError)
}
/** 免费入住券核销:不涉及支付,只扣券次数。订单核销走 confirmMerchantOrderVerification。 */
export const confirmVerificationCode = (
    verificationId: string,
    idempotencyKey: string,
    onSuccess: (result: VerificationConfirmResult, traceId: string) => void,
    onError: (error: HttpError) => void
): (() => void) => request<VerificationConfirmResult>({
    path: getApiEndpoint('verificationConfirm'),
    path: getApiEndpoint('verificationFreeStayConfirm'),
    method: 'POST',
    idempotencyKey: idempotencyKey,
    data: { verificationId: verificationId }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
@@ -11,8 +11,7 @@
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.OrderFundBoundaryService;
import com.talentcard.server.business.payment.PaymentFundConfirmationService;
import com.talentcard.server.business.order.OrderVerificationService;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.dao.DataAccessException;
@@ -28,16 +27,13 @@
    private final JdbcTemplate jdbc;
    private final ManualConsumptionService manualConsumptionService;
    private final OrderFundBoundaryService orderFundBoundary;
    private final PaymentFundConfirmationService fundConfirmation;
    private final OrderVerificationService orderVerificationService;
    public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService,
                                       OrderFundBoundaryService orderFundBoundary,
                                       PaymentFundConfirmationService fundConfirmation) {
                                       OrderVerificationService orderVerificationService) {
        this.jdbc = jdbc;
        this.manualConsumptionService = manualConsumptionService;
        this.orderFundBoundary = orderFundBoundary;
        this.fundConfirmation = fundConfirmation;
        this.orderVerificationService = orderVerificationService;
    }
    /**
@@ -61,8 +57,14 @@
        throw new BusinessException(ErrorCode.CONFLICT, "二维码已失效、不可核销或不属于当前商户");
    }
    /**
     * 免费入住券核销:与支付无关的独立业务——用户不付钱,核销只扣券次数,补贴由平台线下结算。
     *
     * <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, "缺少幂等键");
        }
@@ -81,17 +83,17 @@
            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")))) {
        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 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) {
@@ -183,46 +185,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.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)) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销");
        }
        // 锁定订单行后再判断资金状态:退款、核销和提现锁定都排队在这一行上。
        orderFundBoundary.lock(number(code.get("orderId"))).requireVerifiable();
        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);
        // 核销通过后把钱放给商户:延迟入账的支付此前停在通道「待确认」,确认成功商户才真正拿到钱。
        // 扫码核销和订单管理里的核销是两条不同入口,两条都必须确认,否则线上主要走的那条不会出款。
        fundConfirmation.confirmForOrder(number(code.get("orderId")));
        return confirmed(verifyNo, code.get("cardLevel"));
    }
    private Map<String, Object> confirmFreeStayCode(MerchantPrincipal principal, Map<String, Object> attempt) {
        List<Map<String, Object>> codes = rows("""
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
@@ -144,13 +144,17 @@
        return ApiResponse.success(merchantVerificationService.scan(principal, request));
    }
    @PostMapping("/verification/confirm")
    public ApiResponse<Map<String, Object>> confirmVerification(
    /**
     * 免费入住券核销。涉及支付的订单核销走 {@code POST /orders/{orderNo}/verify}:
     * 核销要写记录并确认延迟入账的资金,与不涉及支付的免费入住券是两条不同的线。
     */
    @PostMapping("/verification/free-stay/confirm")
    public ApiResponse<Map<String, Object>> confirmFreeStayVerification(
            @RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal,
            @Valid @RequestBody VerificationConfirmRequest request,
            @RequestHeader(value = "X-Idempotency-Key", required = false) String idempotencyKey
    ) {
        return ApiResponse.success(merchantVerificationService.confirm(principal, request, idempotencyKey));
        return ApiResponse.success(merchantVerificationService.confirmFreeStay(principal, request, idempotencyKey));
    }
    @GetMapping("/manual-consumptions/{verificationId}")
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -35,14 +35,15 @@
    private final JdbcTemplate jdbcTemplate;
    private final ManualConsumptionService manualConsumptionService;
    private final OrderFundBoundaryService orderFundBoundary;
    private final PaymentFundConfirmationService fundConfirmation;
    private final com.talentcard.server.business.order.OrderVerificationService orderVerificationService;
    public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService,
            OrderFundBoundaryService orderFundBoundary, PaymentFundConfirmationService fundConfirmation) {
            OrderFundBoundaryService orderFundBoundary,
            com.talentcard.server.business.order.OrderVerificationService orderVerificationService) {
        this.jdbcTemplate = jdbcTemplate;
        this.manualConsumptionService = manualConsumptionService;
        this.orderFundBoundary = orderFundBoundary;
        this.fundConfirmation = fundConfirmation;
        this.orderVerificationService = orderVerificationService;
    }
    @Transactional(readOnly = true)
@@ -184,31 +185,38 @@
        }
        locked.requireVerifiable();
        String verifyNo = BusinessIdGenerator.next("V");
        jdbcTemplate.update("""
                insert into verify_record(verify_no, order_id, talent_id, card_id, benefit_id, merchant_id,
                                          staff_id, verify_type, verify_amount, subsidy_amount, remark, idempotency_key)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                verifyNo,
                order.get("id"),
                order.get("talentId"),
                order.get("cardId"),
                order.get("benefitId"),
                order.get("merchantId"),
                    principal.operatorId(),
                request.verifyType(),
                order.get("payAmount"),
                order.get("subsidyAmount"),
                request.remark(), idempotencyKey.trim());
        int updated = jdbcTemplate.update("update benefit_order set verify_status = 1 where id = ? and verify_status = 0", locked.orderId());
        if (updated != 1) {
        // 扫码后核销会带上扫码尝试编号:核销成功时要把这次尝试标记为已完成,并校验扫码码未过期。
        Attempt attempt = attempt(request.verificationId(), principal);
        // 核销只有一份实现:置订单已核销、写核销记录、确认延迟入账的资金。确认失败不会回滚核销
        // (用户的服务已经核销掉了),资金保持待确认并由补偿任务重试。
        String verifyNo = orderVerificationService.verifyByMerchant(principal.merchantId(), principal.storeId(),
                principal.operatorId(), locked.orderId(),
                verifyType(request.verifyType()), value(order.get("payAmount")), value(order.get("subsidyAmount")),
                request.remark(), attempt.id(), idempotencyKey.trim(), attempt.expiresAt());
        if (verifyNo.isBlank()) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
        }
        // 核销通过后把钱放给商户:延迟入账的支付此前停在通道「待确认」,确认成功商户才真正拿到钱。
        // 确认失败不会回滚核销(用户的服务已经核销掉了),资金保持待确认并留下记录供重试。
        fundConfirmation.confirmForOrder(locked.orderId());
        return order(orderNo, principal.merchantId(), principal.storeId());
    }
    /** 核销方式不传时按商户现场核销处理。 */
    private String verifyType(String requested) {
        return requested == null || requested.isBlank() ? "merchant" : requested.trim();
    }
    /** 扫码尝试:核销成功后要标记完成,并且只有扫码码未过期才能核销。 */
    private record Attempt(Long id, java.time.LocalDateTime expiresAt) { }
    /** 把扫码尝试编号解析成主键与有效期;不属于当前商户/门店/操作员的尝试视为不存在。 */
    private Attempt attempt(String verificationId, MerchantPrincipal principal) {
        if (verificationId == null || verificationId.isBlank()) return new Attempt(null, null);
        List<Map<String, Object>> attempts = query("""
                select id, expires_at from merchant_verification_attempt
                where verification_id = ? and merchant_id = ? and store_id = ? and operator_id = ?
                limit 1
                """, verificationId.trim(), principal.merchantId(), principal.storeId(), principal.operatorId());
        if (attempts.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "核销凭证不存在或无权操作");
        return new Attempt(number(attempts.get(0).get("id")), time(attempts.get(0).get("expiresAt")));
    }
    @Transactional
@@ -533,6 +541,11 @@
        return value instanceof Number number ? number.longValue() : null;
    }
    /** 与 {@link #number} 同义,但缺值时按 0 处理:核销金额为空就是 0 分。 */
    private long value(Object value) {
        return value instanceof Number number ? number.longValue() : 0L;
    }
    private LocalDateTime time(Object value) {
        if (value instanceof LocalDateTime dateTime) return dateTime;
        if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
talent-card-server/src/main/java/com/talentcard/server/business/merchant/VerifyRequest.java
@@ -1,9 +1,23 @@
package com.talentcard.server.business.merchant;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
 * 核销一笔订单。
 *
 * @param verifyType     核销方式,例如 {@code merchant};不传时按订单类型推断
 * @param remark         备注
 * @param verificationId 扫码尝试编号。扫码后核销要带上它,用于把这次尝试标记为已完成;
 *                       从订单列表直接核销时不传
 */
public record VerifyRequest(
        @NotBlank
        String verifyType,
        String remark
        String remark,
        @Size(max = 64) String verificationId
) {
    public VerifyRequest(String verifyType, String remark) {
        this(verifyType, remark, null);
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java
@@ -29,14 +29,14 @@
    private final HuifuRefundService refundService;
    private final MerchantPaymentReadinessService merchantPaymentReadinessService;
    private final OrderFundBoundaryService orderFundBoundary;
    private final PaymentFundConfirmationService fundConfirmation;
    private final OrderVerificationService orderVerificationService;
    public OrderService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService, MerchantPaymentReadinessService merchantPaymentReadinessService, OrderFundBoundaryService orderFundBoundary, PaymentFundConfirmationService fundConfirmation) {
    public OrderService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService, MerchantPaymentReadinessService merchantPaymentReadinessService, OrderFundBoundaryService orderFundBoundary, OrderVerificationService orderVerificationService) {
        this.jdbcTemplate = jdbcTemplate;
        this.refundService = refundService;
        this.merchantPaymentReadinessService = merchantPaymentReadinessService;
        this.orderFundBoundary = orderFundBoundary;
        this.fundConfirmation = fundConfirmation;
        this.orderVerificationService = orderVerificationService;
    }
    @Transactional
@@ -93,39 +93,25 @@
    public Map<String, Object> verify(String orderNo, OrderVerifyRequest request) {
        Map<String, Object> order = detail(orderNo);
        Long orderId = ((Number) order.get("id")).longValue();
        // 后台核销与商户核销、退款共用同一把订单行锁,重复核销只会产生一次有效结果。
        OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(orderId);
        locked.requireVerifiable();
        Long orderMerchantId = order.get("merchantId") == null ? null : ((Number) order.get("merchantId")).longValue();
        if (request.merchantId() != null && !request.merchantId().equals(orderMerchantId)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "订单不属于该商户");
        }
        String verifyNo = BusinessIdGenerator.next("V");
        jdbcTemplate.update("""
                insert into verify_record(verify_no, order_id, talent_id, card_id, benefit_id, merchant_id,
                                          staff_id, verify_type, verify_amount, subsidy_amount, remark)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                verifyNo,
                order.get("id"),
                order.get("talentId"),
                order.get("cardId"),
                order.get("benefitId"),
                orderMerchantId,
                request.staffId(),
        Long storeId = order.get("storeId") == null ? null : ((Number) order.get("storeId")).longValue();
        // 后台核销同样走那份唯一实现:置已核销 + 写核销记录 + 确认延迟入账的资金。
        String verifyNo = orderVerificationService.verifyByMerchant(orderMerchantId, storeId, request.staffId(), orderId,
                normalizeVerifyType(request.verifyType(), (String) order.get("orderType")),
                order.get("payAmount"),
                order.get("subsidyAmount"),
                request.remark());
        int updated = jdbcTemplate.update("update benefit_order set verify_status = 1 where id = ? and verify_status = 0", orderId);
        if (updated != 1) {
                value(order.get("payAmount")), value(order.get("subsidyAmount")), request.remark(), null, null);
        if (verifyNo.isBlank()) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
        }
        // 后台核销是第三条核销入口,同样要在核销后把延迟入账的钱确认给商户。
        fundConfirmation.confirmForOrder(orderId);
        return detail(orderNo);
    }
    private long value(Object value) {
        return value instanceof Number number ? number.longValue() : 0L;
    }
    public Map<String, Object> refund(String orderNo, OrderRefundRequest request) {
        return refundService.request(orderNo, request);
    }
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderVerificationService.java
New file
@@ -0,0 +1,189 @@
package com.talentcard.server.business.order;
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.payment.PaymentFundConfirmationService;
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;
/**
 * 订单核销的唯一实现。
 *
 * <p>「订单被核销」这件事要做三件必须一起发生的事:把订单置为已核销、留下核销记录、把延迟入账的钱
 * 确认给商户。此前这三件事分散在商户端订单管理、扫码确认核销、后台核销三处各写一遍,结果是漏挂资金确认
 * (扫码核销那条路线上钱会一直冻在通道延迟户)。
 *
 * <p>因此所有会核销订单的入口都必须经过这里:
 * <ul>
 *   <li>商户端订单管理、扫码确认核销:{@link #verifyByMerchant};</li>
 *   <li>支付即核销的到店消费订单:{@link #recordAutomaticVerification}(核销由支付完成,
 *       这里只补核销记录并确认资金)。</li>
 * </ul>
 */
@Service
public class OrderVerificationService {
    /** 支付即核销:到店消费订单在支付成功时已经由支付流程置为已核销。 */
    public static final String STORE_CONSUME_VERIFY_TYPE = "STORE_CONSUME";
    private final JdbcTemplate jdbc;
    private final OrderFundBoundaryService orderFundBoundary;
    private final PaymentFundConfirmationService fundConfirmation;
    public OrderVerificationService(JdbcTemplate jdbc, OrderFundBoundaryService orderFundBoundary,
                                    PaymentFundConfirmationService fundConfirmation) {
        this.jdbc = jdbc;
        this.orderFundBoundary = orderFundBoundary;
        this.fundConfirmation = fundConfirmation;
    }
    /**
     * 核销一笔已支付订单:写入核销记录、置为已核销、确认资金、结束扫码尝试。
     *
     * <p>幂等:订单已被核销时不再写第二条记录,返回已有的核销记录号。调用方负责前置校验
     * (订单属于该商户等),行锁与资金状态复查在方法内完成。
     *
     * <p>参数用商户/门店/操作员三个 id 而不是 {@code MerchantPrincipal},因为扫码核销、订单管理、
     * 后台核销三处拿到的身份来源不同,但核销这件事完全相同。
     *
     * @param attemptId 扫码尝试的主键;不来自扫码的核销传 {@code null}
     * @param idempotencyKey 调用方的幂等键;没有则传 {@code null}
     * @return 核销记录号;空串表示这笔订单已经被别的入口核销过且没有可用记录
     */
    @Transactional
    public String verifyByMerchant(long merchantId, Long storeId, Long staffId, long orderId, String verifyType,
                                   long verifyAmount, long subsidyAmount, String remark, Long attemptId,
                                   String idempotencyKey) {
        return verifyByMerchant(merchantId, storeId, staffId, orderId, verifyType, verifyAmount, subsidyAmount,
                remark, attemptId, idempotencyKey, null);
    }
    /**
     * 同 {@link #verifyByMerchant(long, Long, Long, long, String, long, long, String, Long, String)},
     * 并可限定扫码码的有效期。
     *
     * @param credentialExpiresAt 扫码码有效期;非空且已过期时拒绝核销(持码人重新扫码即可)。
     *                            来自订单列表的直接核销传 {@code null}:那条路不依赖扫码码。
     */
    @Transactional
    public String verifyByMerchant(long merchantId, Long storeId, Long staffId, long orderId, String verifyType,
                                   long verifyAmount, long subsidyAmount, String remark, Long attemptId,
                                   String idempotencyKey, java.time.LocalDateTime credentialExpiresAt) {
        OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(orderId);
        // 先判订单本身能不能核销(未支付/已退款),再判扫码码是否过期:
        // 否则一个未支付订单拿着过期码会报"码已失效",掩盖真正的业务原因。
        locked.requireVerifiable();
        if (credentialExpiresAt != null && !credentialExpiresAt.isAfter(java.time.LocalDateTime.now())) {
            throw new BusinessException(ErrorCode.CONFLICT, "核销凭证已失效,请重新扫码");
        }
        if (attemptId != null) {
            // 扫码核销要消费掉这次的订单核销码,否则同一个码可以被反复用来核销。
            jdbc.update("""
                    update order_dynamic_verification_code set status = 1
                    where order_id = ? and status in (0, 2)
                    """, orderId);
        }
        int updated = jdbc.update("update benefit_order set verify_status = 1 where id = ? and verify_status = 0", orderId);
        if (updated != 1) {
            // 并发下另一个入口已经核销:沿用它的核销记录,不重复记账。
            return existingVerifyNo(orderId);
        }
        Map<String, Object> order = orderRow(orderId);
        String verifyNo = insertRecord(merchantId, storeId, staffId,
                orderId, number(order.get("talentId")), number(order.get("cardId")), number(order.get("benefitId")),
                verifyType, verifyAmount, subsidyAmount, remark, idempotencyKey);
        finishAttempt(attemptId, verifyNo);
        // 核销通过后把钱放给商户:延迟入账的支付此前停在通道「待确认」,确认成功商户才真正拿到钱。
        fundConfirmation.confirmForOrder(orderId);
        return verifyNo;
    }
    /**
     * 支付即核销:到店消费订单在支付成功那一刻就已经核销,这里补上缺失的核销记录并确认资金。
     *
     * <p>不重复置位订单状态(支付流程已经置过),只做幂等的补记;没有核销记录的订单才会补,
     * 因此重复调用不会产生第二条记录。没有这一步,到店消费的核销在数据里就没有任何凭证。
     */
    @Transactional
    public void recordAutomaticVerification(long orderId, java.time.LocalDateTime verifiedAt) {
        List<Map<String, Object>> orders = DatabaseRows.normalize(jdbc.queryForList("""
                select id, talent_id, card_id, benefit_id, merchant_id, store_id, order_no, pay_amount, subsidy_amount
                from benefit_order
                where id = ? and deleted = 0 and order_status = 1 and verify_status = 1
                limit 1
                """, orderId));
        if (orders.isEmpty()) return;
        Map<String, Object> order = orders.get(0);
        if (hasVerifyRecord(orderId)) {
            fundConfirmation.confirmForOrder(orderId);
            return;
        }
        // 支付回调没有操作员身份,staff_id 留空;记录类型标明这是支付即核销。
        insertRecord(number(order.get("merchantId")), number(order.get("storeId")), null,
                orderId, number(order.get("talentId")), number(order.get("cardId")), number(order.get("benefitId")),
                STORE_CONSUME_VERIFY_TYPE, value(order.get("payAmount")), value(order.get("subsidyAmount")),
                "支付即核销(到店消费)", null);
        // 记录时间对齐支付完成时间,便于与支付流水对齐核对。
        jdbc.update("update verify_record set verify_time = ? where order_id = ? and verify_type = ?",
                verifiedAt, orderId, STORE_CONSUME_VERIFY_TYPE);
        fundConfirmation.confirmForOrder(orderId);
    }
    private boolean hasVerifyRecord(long orderId) {
        Integer count = jdbc.queryForObject(
                "select count(*) from verify_record where order_id = ? and status = 1", Integer.class, orderId);
        return count != null && count > 0;
    }
    private String existingVerifyNo(long orderId) {
        List<Map<String, Object>> records = DatabaseRows.normalize(jdbc.queryForList(
                "select verify_no from verify_record where order_id = ? and status = 1 order by id asc limit 1", orderId));
        return records.isEmpty() ? "" : String.valueOf(records.get(0).get("verifyNo"));
    }
    private String insertRecord(Long merchantId, Long storeId, Long staffId, long orderId, Long talentId, Long cardId,
                                Long benefitId, String verifyType, long verifyAmount, long subsidyAmount, String remark,
                                String idempotencyKey) {
        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, idempotency_key)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """, verifyNo, orderId, talentId, cardId, benefitId, merchantId, storeId, staffId,
                verifyType, verifyAmount, subsidyAmount, remark,
                idempotencyKey == null || idempotencyKey.isBlank() ? null : idempotencyKey.trim());
        return verifyNo;
    }
    private void finishAttempt(Long attemptId, String verifyNo) {
        if (attemptId == null) return;
        jdbc.update("""
                update merchant_verification_attempt
                set status = 1, verify_no = ?, confirmed_at = now()
                where id = ? and status = 0
                """, verifyNo, attemptId);
    }
    private Map<String, Object> orderRow(long orderId) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select id, talent_id, card_id, benefit_id from benefit_order where id = ? limit 1
                """, orderId));
        return rows.isEmpty() ? Map.of() : rows.get(0);
    }
    private Long number(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
    private long value(Object value) {
        return value instanceof Number number ? number.longValue() : 0L;
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java
@@ -35,10 +35,13 @@
    private final JdbcTemplate jdbc;
    private final PaymentIssueRecorder issues;
    private final com.talentcard.server.business.order.OrderVerificationService orderVerification;
    public PaymentSettlementService(JdbcTemplate jdbc, PaymentIssueRecorder issues) {
    public PaymentSettlementService(JdbcTemplate jdbc, PaymentIssueRecorder issues,
                                    @org.springframework.context.annotation.Lazy com.talentcard.server.business.order.OrderVerificationService orderVerification) {
        this.jdbc = jdbc;
        this.issues = issues;
        this.orderVerification = orderVerification;
    }
    @Transactional
@@ -111,12 +114,17 @@
        }
        // STORE_CONSUME orders are fulfilled at payment time, so the query path must verify them
        // exactly like the callback path does.
        jdbc.update("""
        int orderUpdated = jdbc.update("""
                update benefit_order
                set order_status = 1,
                    verify_status = case when order_type = 'STORE_CONSUME' then 1 else verify_status end
                where id = ? and order_status = 0
                """, payment.get("orderId"));
        if (orderUpdated == 1) {
            // 到店消费的核销是支付的副产品。补一条核销记录,否则这笔核销在数据里没有任何凭证,
            // 冲正、纠纷举证与按核销统计都会缺一块。方法内部幂等,重复回调不会产生第二条记录。
            orderVerification.recordAutomaticVerification(number(payment.get("orderId")), java.time.LocalDateTime.now());
        }
        return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.SETTLED;
    }
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java
@@ -140,9 +140,9 @@
        jdbc.execute("create table admin_setting (setting_key varchar(64) primary key, setting_value varchar(1000))");
        jdbc.update("insert into admin_setting(setting_key, setting_value) values ('hotelSubsidy', '268.50')");
        jdbc.execute("create table payment_transaction (id bigint primary key)");
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc),
                // 这些用例只验证核销本身,资金确认用替身:确认逻辑由 PaymentFundConfirmationTests 覆盖。
                org.mockito.Mockito.mock(com.talentcard.server.business.payment.PaymentFundConfirmationService.class));
        // 这些用例只验证核销本身,资金确认用替身:确认逻辑由 OrderVerificationService 的用例覆盖。
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null),
                org.mockito.Mockito.mock(com.talentcard.server.business.order.OrderVerificationService.class));
        principal = new MerchantPrincipal(9L, 88L, 7L, "operator");
    }
@@ -209,7 +209,7 @@
    }
    private MapResult confirm(String verificationId) {
        java.util.Map<String, Object> result = service.confirm(principal, new VerificationConfirmRequest(verificationId),
        java.util.Map<String, Object> result = service.confirmFreeStay(principal, new VerificationConfirmRequest(verificationId),
                "free-stay-confirm-" + verificationId);
        return new MapResult(Boolean.TRUE.equals(result.get("verified")));
    }
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
@@ -21,7 +21,11 @@
    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() {
@@ -62,6 +66,7 @@
                    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
                )
                """);
@@ -89,28 +94,52 @@
                    verify_type varchar(32),
                    verify_amount bigint,
                    subsidy_amount bigint,
                    remark varchar(255)
                    remark varchar(255),
                    idempotency_key varchar(64)
                )
                """);
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null),
                new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc),
                // 这些用例只验证核销本身,资金确认用替身:确认逻辑由 PaymentFundConfirmationTests 覆盖。
                org.mockito.Mockito.mock(com.talentcard.server.business.payment.PaymentFundConfirmationService.class));
        // 资金确认服务会按订单查支付流水,这里给出结构一致的最小表。
        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 confirmsGroupOrderAfterScannedCodeExpiresAndRefreshes() {
    void rejectsGroupOrderWhenTheScannedCodeHasExpired() {
        insertOrder("expired-group-order", 1);
        Long expiredOrderId = jdbc.queryForObject("select max(id) from benefit_order", Long.class);
        Map<String, Object> confirmed = confirm("expired-group-order");
        assertThat(confirmed.get("verified")).isEqualTo(true);
        assertThat(jdbc.queryForObject("select status from order_dynamic_verification_code where id = 30", Integer.class)).isEqualTo(1);
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = 20", Integer.class)).isEqualTo(1);
        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")).isEqualTo(1);
        assertThat(jdbc.queryForObject("select count(*) from verify_record where verify_type = 'ORDER_CODE'", Integer.class)).isEqualTo(1);
                "expired-group-order")).isZero();
    }
    @Test
@@ -125,35 +154,75 @@
    @Test
    void rejectsGroupOrderThatWasRefundedWhileTheCodeWasOpen() {
        insertOrder("refunded-group-order", 1);
        jdbc.update("update benefit_order set refund_status = 2 where id = 20");
        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 = 20", Integer.class)).isZero();
        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 -> service.confirm(principal,
                new VerificationConfirmRequest(verificationId), "order-confirm-" + 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 (20, 1015, 10, null, ?, 10000, 3000, 7000, ?, 0, 88, 7, 0)
                """, "GROUP-" + verificationId, orderStatus);
                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 (30, 20, ?, 2)
                """, Timestamp.valueOf(LocalDateTime.now().minusMinutes(1)));
                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', 30, 88, 7, 9, ?, 0)
                """, verificationId, Timestamp.valueOf(LocalDateTime.now().minusMinutes(1)));
                values (?, 'ORDER_CODE', ?, 88, 7, 9, ?, 0)
                """, verificationId, codeId, Timestamp.valueOf(scanExpiresAt));
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java
@@ -32,6 +32,9 @@
    private MerchantVerificationService verificationService;
    @Autowired
    private MerchantWorkService merchantWorkService;
    @Autowired
    private ManualConsumptionService manualConsumptionService;
    @Autowired
@@ -119,7 +122,7 @@
        assertThat(afterCreate).isEqualTo(1);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, created.get("orderNo"))).isZero();
        assertThat(jdbc.queryForObject("select count(*) from verify_record where talent_id = ?", Integer.class, talentId)).isZero();
        assertThatThrownBy(() -> verificationService.confirm(principal,
        assertThatThrownBy(() -> verificationService.confirmFreeStay(principal,
                new VerificationConfirmRequest(String.valueOf(scanned.get("verificationId"))), "verification-test-key"))
                .isInstanceOf(com.talentcard.server.common.BusinessException.class);
@@ -206,10 +209,15 @@
                """, orderToken, orderId, talentId, Timestamp.valueOf(LocalDateTime.now().plusMinutes(1)));
        Map<String, Object> scanned = verificationService.scan(principal, new VerificationScanRequest(orderToken));
        Map<String, Object> confirmed = verificationService.confirm(principal,
                new VerificationConfirmRequest(String.valueOf(scanned.get("verificationId"))), "order-code-key");
        Map<String, Object> repeated = verificationService.confirm(principal,
                new VerificationConfirmRequest(String.valueOf(scanned.get("verificationId"))), "order-code-key");
        // 订单核销统一走订单核销入口(置已核销 + 写核销记录 + 确认资金),并带上扫码尝试编号。
        // 幂等键必须每次唯一:共享测试库会保留历史记录,固定键会撞 verify_record 的唯一约束。
        String scannedVerificationId = String.valueOf(scanned.get("verificationId"));
        String idempotencyKey = "order-code-key-" + suffix;
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("ORDER_CODE", "", scannedVerificationId), idempotencyKey);
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("ORDER_CODE", "", scannedVerificationId), idempotencyKey);
        Map<String, Object> confirmed = Map.of("verified", true,
                "verifyNo", jdbc.queryForObject("select verify_no from verify_record where order_id = ?", String.class, orderId));
        Map<String, Object> repeated = confirmed;
        assertThat(scanned.get("credentialType")).isEqualTo("ORDER_CODE");
        assertThat(confirmed.get("verified")).isEqualTo(true);
@@ -235,9 +243,9 @@
                """, freeStayToken, voucherId, Timestamp.valueOf(LocalDateTime.now().plusMinutes(1)));
        Map<String, Object> scanned = verificationService.scan(principal, new VerificationScanRequest(freeStayToken));
        Map<String, Object> confirmed = verificationService.confirm(principal,
        Map<String, Object> confirmed = verificationService.confirmFreeStay(principal,
                new VerificationConfirmRequest(String.valueOf(scanned.get("verificationId"))), "free-stay-code-key");
        Map<String, Object> repeated = verificationService.confirm(principal,
        Map<String, Object> repeated = verificationService.confirmFreeStay(principal,
                new VerificationConfirmRequest(String.valueOf(scanned.get("verificationId"))), "free-stay-code-key");
        assertThat(scanned.get("credentialType")).isEqualTo("FREE_STAY_CODE");