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;
/**
* 订单核销的唯一实现。
*
*
「订单被核销」这件事要做三件必须一起发生的事:把订单置为已核销、留下核销记录、把延迟入账的钱
* 确认给商户。此前这三件事分散在商户端订单管理、扫码确认核销、后台核销三处各写一遍,结果是漏挂资金确认
* (扫码核销那条路线上钱会一直冻在通道延迟户)。
*
*
因此所有会核销订单的入口都必须经过这里:
*
* - 商户端订单管理、扫码确认核销:{@link #verifyByMerchant};
* - 支付即核销的到店消费订单:{@link #recordAutomaticVerification}(核销由支付完成,
* 这里只补核销记录并确认资金)。
*
*/
@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