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;
|
}
|
}
|