yeg
2026-09-14 61e873aada7f8c55ccebb4c5fdf93ec21eb5fffe
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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;
    }
}