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