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; /** * 订单核销的唯一实现。 * *

「订单被核销」这件事要做三件必须一起发生的事:把订单置为已核销、留下核销记录、把延迟入账的钱 * 确认给商户。此前这三件事分散在商户端订单管理、扫码确认核销、后台核销三处各写一遍,结果是漏挂资金确认 * (扫码核销那条路线上钱会一直冻在通道延迟户)。 * *

因此所有会核销订单的入口都必须经过这里: *

*/ @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; } /** * 核销一笔已支付订单:写入核销记录、置为已核销、确认资金、结束扫码尝试。 * *

幂等:订单已被核销时不再写第二条记录,返回已有的核销记录号。调用方负责前置校验 * (订单属于该商户等),行锁与资金状态复查在方法内完成。 * *

参数用商户/门店/操作员三个 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 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; } /** * 支付即核销:到店消费订单在支付成功那一刻就已经核销,这里补上缺失的核销记录并确认资金。 * *

不重复置位订单状态(支付流程已经置过),只做幂等的补记;没有核销记录的订单才会补, * 因此重复调用不会产生第二条记录。没有这一步,到店消费的核销在数据里就没有任何凭证。 */ @Transactional public void recordAutomaticVerification(long orderId, java.time.LocalDateTime verifiedAt) { List> 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 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> 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 orderRow(long orderId) { List> 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; } }