refactor(merchant): 核销收敛为一份实现,按是否涉及资金拆两个接口
核销此前有三处各写一遍「置订单已核销」,于是扫码核销那条路漏挂了资金确认:用户核销后
钱会一直冻在通道延迟户,商户永远收不到。现在只有一份实现 OrderVerificationService
(置已核销 + 写核销记录 + 确认延迟入账的资金),订单管理、扫码核销、后台核销都调它。
接口按「是否涉及资金」拆开,而不是按动线拆:
- 订单核销 POST /api/merchant/orders/{orderNo}/verify(新增可选 verificationId,
扫码核销带上它以便消费核销码并标记扫码尝试完成)
- 免费入住券核销 POST /api/merchant/verification/free-stay/confirm(不涉及支付,
只扣券次数,补贴由平台线下结算)
顺带修掉三个真缺陷:
- 订单核销码核销后没有被标记为已消费,同一个码可以反复核销
- 扫码码有效期没有校验,持过期码也能核销
- 到店消费(支付即核销)不留核销记录,冲正/纠纷/统计都缺凭证,现在补记一条
免费入住券的扫码码过期容忍是既有行为,保持不变。
| | |
| | | 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' |
| | |
| | | |
| | | <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('持卡人') |
| | |
| | | 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) => { |
| | |
| | | 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 = () => { |
| | |
| | | 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 |
| | |
| | | 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> = [ |
| | |
| | | }, 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 } |
| | |
| | | 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; |
| | |
| | | |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | 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, "缺少幂等键"); |
| | | } |
| | |
| | | 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) { |
| | |
| | | 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(""" |
| | |
| | | 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}") |
| | |
| | | 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) |
| | |
| | | } |
| | | 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 |
| | |
| | | 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(); |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | 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 |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | |
| | | 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 |
| | |
| | | } |
| | | // 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; |
| | | } |
| | | |
| | |
| | | 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"); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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"))); |
| | | } |
| | |
| | | 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() { |
| | |
| | | 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 |
| | | ) |
| | | """); |
| | |
| | | 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 |
| | |
| | | @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)); |
| | | } |
| | | } |
| | |
| | | private MerchantVerificationService verificationService; |
| | | |
| | | @Autowired |
| | | private MerchantWorkService merchantWorkService; |
| | | |
| | | @Autowired |
| | | private ManualConsumptionService manualConsumptionService; |
| | | |
| | | @Autowired |
| | |
| | | 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); |
| | | |
| | |
| | | """, 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); |
| | |
| | | """, 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"); |