From 61e873aada7f8c55ccebb4c5fdf93ec21eb5fffe Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 14 九月 2026 14:41:13 +0800
Subject: [PATCH] refactor(merchant): 核销收敛为一份实现,按是否涉及资金拆两个接口

---
 rencaika_merchant/pages/verification-confirm/verification-confirm.uvue                                           |    7 
 talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java                          |   38 +--
 rencaika_merchant/pages/verification-detail/verification-detail.uvue                                             |    2 
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java |  117 +++++++++--
 rencaika_merchant/config/api.uts                                                                                 |    3 
 talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java            |   12 +
 rencaika_merchant/services/orders.uts                                                                            |   10 
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java             |   10 
 talent-card-server/src/main/java/com/talentcard/server/business/order/OrderVerificationService.java              |  189 ++++++++++++++++++
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java   |   22 +
 rencaika_merchant/services/verification.uts                                                                      |    3 
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java                |   63 +++--
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/VerifyRequest.java                      |   18 +
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java  |    8 
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java        |   76 +-----
 15 files changed, 422 insertions(+), 156 deletions(-)

diff --git a/rencaika_merchant/config/api.uts b/rencaika_merchant/config/api.uts
index d4582ef..bc8a7a3 100644
--- a/rencaika_merchant/config/api.uts
+++ b/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'
diff --git a/rencaika_merchant/pages/verification-confirm/verification-confirm.uvue b/rencaika_merchant/pages/verification-confirm/verification-confirm.uvue
index 61cae9b..c329093 100644
--- a/rencaika_merchant/pages/verification-confirm/verification-confirm.uvue
+++ b/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) => {
diff --git a/rencaika_merchant/pages/verification-detail/verification-detail.uvue b/rencaika_merchant/pages/verification-detail/verification-detail.uvue
index a1bda9b..d135a8d 100644
--- a/rencaika_merchant/pages/verification-detail/verification-detail.uvue
+++ b/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 = () => {
diff --git a/rencaika_merchant/services/orders.uts b/rencaika_merchant/services/orders.uts
index 3cb207a..14129a9 100644
--- a/rencaika_merchant/services/orders.uts
+++ b/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> = [
diff --git a/rencaika_merchant/services/verification.uts b/rencaika_merchant/services/verification.uts
index 2444da5..99b454e 100644
--- a/rencaika_merchant/services/verification.uts
+++ b/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 }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
index 94e45fa..52f3e8e 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
+++ b/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("""
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
index 724ea25..586084b 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
+++ b/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}")
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
index d2383f6..0e307a3 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
+++ b/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();
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/VerifyRequest.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/VerifyRequest.java
index 51bb73d..c4c1f85 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/VerifyRequest.java
+++ b/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);
+	}
 }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java b/talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java
index 1334a9a..4a2dab2 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java
+++ b/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);
 	}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/order/OrderVerificationService.java b/talent-card-server/src/main/java/com/talentcard/server/business/order/OrderVerificationService.java
new file mode 100644
index 0000000..21b0b80
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/order/OrderVerificationService.java
@@ -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;
+    }
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java
index 973700c..b9ecbb8 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java
+++ b/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;
     }
 
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java
index 87088d2..1de7d96 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java
+++ b/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")));
     }
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
index 37aefbb..f5bc8fa 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
+++ b/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));
     }
 }
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java
index 230dbdb..fcfcb9e 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java
+++ b/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");

--
Gitblit v1.7.1