yeg
2026-09-14 723b7560ecaa16129210591fff3de435bd25f136
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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
package com.talentcard.server.business.merchant;
 
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityQueryCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
 
/**
 * Single decision point for "may this merchant collect money?".
 *
 * <p>A merchant is payable only when the sub-merchant onboarding is approved, its settlement bank
 * card carries a provider token, the provider account is in a normal state, automatic settlement is
 * confirmed off and the encashment (取现) capability is confirmed available. When the provider
 * integration is disabled the network configuration is not required, but the onboarding approval
 * and the bound card token still are.
 */
@Service
public class MerchantPaymentReadinessService {
 
    private static final Logger log = LoggerFactory.getLogger(MerchantPaymentReadinessService.class);
 
    private final JdbcTemplate jdbc;
    private final HuifuMerchantCapabilityGateway capabilityGateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
    private final TransactionTemplate refreshTransaction;
    private final TransactionTemplate providerTransaction;
 
    public MerchantPaymentReadinessService(
            JdbcTemplate jdbc,
            HuifuMerchantCapabilityGateway capabilityGateway,
            HuifuPaymentProperties huifuPaymentProperties,
            TransactionTemplate refreshTransaction
    ) {
        this.jdbc = jdbc;
        this.capabilityGateway = capabilityGateway;
        this.huifuPaymentProperties = huifuPaymentProperties;
        // 注入的 TransactionTemplate 是容器里的单例,支付、退款和自动提现都共用它。这里绝不能改它的
        // 传播行为,否则那些服务会跟着被改成 REQUIRES_NEW,外层事务被静默挂起。两个模板都自己新建。
        // The snapshot must survive a failed provider query in the caller's transaction, so it runs
        // in its own transaction instead of joining the caller's.
        this.refreshTransaction = new TransactionTemplate(refreshTransaction.getTransactionManager());
        this.refreshTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
        // The provider is an HTTP call: never make payment or onboarding hold a database transaction
        // (and its row locks) while waiting for it.
        this.providerTransaction = new TransactionTemplate(refreshTransaction.getTransactionManager());
        this.providerTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED);
    }
 
    /** Readiness of the merchant's newest approved onboarding, refreshing the provider view when stale. */
    @Transactional
    public Readiness readiness(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        if (application == null) {
            return Readiness.blocked("商户进件尚未审核通过,暂时无法收款");
        }
        if (!approvedBankCardExists(merchantId, application.id())) {
            return Readiness.blocked("结算银行卡尚未绑定成功,暂时无法收款");
        }
        return readiness(application);
    }
 
    /**
     * Returns the collecting sub-merchant an order must be bound to, or throws with the first unmet
     * precondition. Callers persist the returned application, store and Huifu id on the order so the
     * payment request cannot drift to another sub-merchant later.
     */
    @Transactional
    public CollectingMerchant requireCollectingMerchant(Long merchantId, Long storeId) {
        Readiness readiness = readiness(merchantId, storeId);
        if (!readiness.ready()) throw new BusinessException(ErrorCode.CONFLICT, readiness.reason());
        return readiness.merchant();
    }
 
    /** Re-reads the provider configuration and stores the resulting snapshot. */
    public Map<String, Object> refresh(Long applicationId) {
        // The snapshot is only updated inside providerTransaction, which needs no row lock.
        queryProviderOutsideTransaction(requireApprovedApplication(applicationId));
        return capabilityView(requireApprovedApplication(applicationId));
    }
 
    /**
     * Capability snapshot for the admin settlement page. It queries the provider when the snapshot
     * has never been confirmed, because the explicit admin request is the moment to find out.
     */
    public Map<String, Object> capability(Long applicationId) {
        Application application = requireApprovedApplication(applicationId);
        if (application.capabilityVerifiedAt().isBlank()) {
            queryProviderOutsideTransaction(application);
            application = requireApprovedApplication(applicationId);
        }
        return capabilityView(application);
    }
 
    /**
     * Attempts to refresh approved applications whose capability snapshot is missing or older than
     * the configured interval. Each application is refreshed in its own transaction, so one failing
     * merchant never affects the others or the snapshots already stored.
     */
    public int refreshStaleCapabilities(int staleHours, int limit) {
        if (!providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return 0;
        List<Map<String, Object>> rows = rows("""
                select id from merchant_onboarding_application
                where status = 'approved' and provider_huifu_id is not null
                  and (capability_verified_at is null or capability_verified_at <= date_sub(now(), interval ? hour))
                order by capability_verified_at asc, id asc
                limit ?
                """, Math.max(1, staleHours), Math.max(1, Math.min(limit, 100)));
        int refreshed = 0;
        for (Map<String, Object> row : rows) {
            if (refreshQuietly(((Number) row.get("id")).longValue())) refreshed++;
        }
        return refreshed;
    }
 
    /**
     * Refreshes the snapshot for one application in its own transaction, so a provider failure never
     * rolls back the caller's work. Returns whether the snapshot was refreshed.
     */
    public boolean refreshQuietly(Long applicationId) {
        if (applicationId == null || !providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return false;
        try {
            return Boolean.TRUE.equals(refreshTransaction.execute(status -> {
                refresh(applicationId);
                return Boolean.TRUE;
            }));
        } catch (RuntimeException exception) {
            log.warn("Merchant capability refresh failed: applicationId={}, type={}", applicationId, exception.getClass().getSimpleName());
            return false;
        }
    }
 
    private Readiness readiness(Application application) {
        boolean unknown = application.autoSettleStatus().isBlank()
                || application.cashSwitchStatus().isBlank()
                || application.merchantStatus().isBlank()
                || application.capabilityVerifiedAt().isBlank();
        if (application.huifuId().isBlank()) return Readiness.blocked("商户进件缺少斗拱商户号,暂时无法收款");
        if (unknown && !providerVerificationRequired()) return localOnly(application);
        // The payment path never calls the provider itself: an unfilled snapshot is filled by the
        // onboarding completion, the scheduler, or an explicit admin refresh.
        if (unknown) return Readiness.blocked("商户收款能力待确认,暂时无法收款");
        // 不再要求「自动结算必须关闭」:未核销的钱由延迟户兜住(不可结算、不可取现),
        // 只有核销确认后才进基本户,因此自动结算开着也不会把钱提前结走。
        if (!isCashAvailable(application.cashSwitchStatus())) return Readiness.blocked("商户取现能力不可用,暂时无法收款");
        if (!isMerchantNormal(application.merchantStatus())) {
            return Readiness.blocked("斗拱子商户状态异常(" + application.merchantStatus() + "),暂时无法收款");
        }
        return Readiness.allowed(new CollectingMerchant(application.id(), application.storeId(), application.huifuId()));
    }
 
    /** The payment integration is switched off, so provider-side network checks cannot be required. */
    private Readiness localOnly(Application application) {
        if (!application.cashSwitchStatus().isBlank() && !isCashAvailable(application.cashSwitchStatus())) {
            return Readiness.blocked("商户取现能力不可用,暂时无法收款");
        }
        return Readiness.allowed(new CollectingMerchant(application.id(), application.storeId(), application.huifuId()));
    }
 
    /**
     * Runs the provider query outside any surrounding transaction so a slow Huifu call never holds
     * the caller's row locks. Returns whether the snapshot was refreshed.
     */
    private boolean queryProviderOutsideTransaction(Application application) {
        return Boolean.TRUE.equals(providerTransaction.execute(status -> queryProvider(application)));
    }
 
    /** Returns false when the provider explicitly rejected the query; the snapshot then stays unknown. */
    private boolean queryProvider(Application application) {
        try {
            HuifuMerchantCapabilityGateway.CapabilityResult result = capabilityGateway.query(
                    new HuifuMerchantCapabilityQueryCommand(BusinessIdGenerator.next("MC"), application.huifuId()));
            if (!result.succeeded()) {
                jdbc.update("""
                        update merchant_onboarding_application
                        set capability_response_code = ?, capability_response_message = ?
                        where id = ?
                        """, clean(result.code()), clean(result.message()), application.id());
                return false;
            }
            jdbc.update("""
                    update merchant_onboarding_application
                    set merchant_stat = nullif(?,''), auto_settle_status = nullif(?,''), cash_switch_status = nullif(?,''),
                        cash_type = nullif(?,''), capability_response_code = ?, capability_response_message = ?,
                        capability_verified_at = now()
                    where id = ?
                    """, clean(result.merchantStatus()), clean(result.settleStatus()), clean(result.cashSwitchStatus()),
                    clean(result.cashType()), clean(result.code()), clean(result.message()), application.id());
            return true;
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("""
                    update merchant_onboarding_application
                    set capability_response_code = 'QUERY_UNKNOWN', capability_response_message = ?
                    where id = ?
                    """, exception.isOutcomeUnknown() ? "商户收款能力查询结果待确认" : "商户收款能力查询失败", application.id());
            return false;
        }
    }
 
    private Application approvedApplication(Long merchantId, Long storeId) {
        String storeFilter = storeId == null ? "" : " and store_id = ?";
        Object[] args = storeId == null ? new Object[] { merchantId } : new Object[] { merchantId, storeId };
        List<Map<String, Object>> rows = rows("""
                select * from merchant_onboarding_application
                where merchant_id = ? and initial_bank_card_id is not null and status = 'approved'
                """ + storeFilter + " order by id desc limit 1", args);
        return rows.isEmpty() ? null : application(rows.get(0));
    }
 
    /**
     * 读取一条已通过的进件。这里刻意不加 {@code for update}:能力快照的 provider 查询和写入都在
     * {@code providerTransaction}({@code PROPAGATION_NOT_SUPPORTED},另一条连接)里完成,锁住这一行
     * 挡不住并发刷新(快照本来就是后写覆盖先写),却会和调用方事务已经持有的行锁撞在一起。进件审核的
     * 微信配置回调和业务开通回调都是在锁着这条进件记录的事务里走到这里的,行锁加在这里会让快照写入一直
     * 等到 {@code innodb_lock_wait_timeout} 后以 {@code CannotAcquireLockException} 失败,商户进件通过
     * 却永远无法收款。
     */
    private Application requireApprovedApplication(Long applicationId) {
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1", applicationId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Application application = application(rows.get(0));
        if (!"approved".equals(application.status())) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过,无法确认收款能力");
        if (application.huifuId().isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "缺少斗拱商户号,无法确认收款能力");
        return application;
    }
 
    private Map<String, Object> capabilityView(Application application) {
        Map<String, Object> view = new LinkedHashMap<>();
        view.put("applicationId", String.valueOf(application.id()));
        view.put("huifuId", application.huifuId());
        view.put("merchantStatus", application.merchantStatus());
        view.put("autoSettleDisabled", !application.autoSettleStatus().isBlank() && isAutoSettleOff(application.autoSettleStatus()));
        view.put("autoSettleStatus", application.autoSettleStatus());
        view.put("cashAvailable", isCashAvailable(application.cashSwitchStatus()));
        view.put("cashSwitchStatus", application.cashSwitchStatus());
        view.put("verifiedAt", application.capabilityVerifiedAt());
        return view;
    }
 
    /** Prefers the onboarding's own card; falls back to any approved card for the store. */
    private boolean approvedBankCardExists(Long merchantId, Long applicationId) {
        List<Map<String, Object>> rows = rows("""
                select id from merchant_bank_card
                where status = 'approved' and provider_token_no is not null
                  and merchant_id = ?
                  and (id = (select initial_bank_card_id from merchant_onboarding_application where id = ?) or store_id = (select store_id from merchant_onboarding_application where id = ?))
                order by id desc limit 1
                """, merchantId, applicationId, applicationId);
        return !rows.isEmpty();
    }
 
    private boolean providerVerificationRequired() {
        return huifuPaymentProperties.isMerchantCapabilityRequired();
    }
 
    private boolean isAutoSettleOff(String value) { return "0".equals(value) || "N".equalsIgnoreCase(value); }
 
    private boolean isCashAvailable(String value) { return "1".equals(value) || "Y".equalsIgnoreCase(value); }
 
    private boolean isMerchantNormal(String value) { return value.isBlank() || "NORMAL".equalsIgnoreCase(value); }
 
    private Application application(Map<String, Object> row) {
        return new Application(
                ((Number) row.get("id")).longValue(),
                ((Number) row.get("merchantId")).longValue(),
                number(row.get("storeId")),
                clean(row.get("status")),
                clean(row.get("providerHuifuId")),
                clean(row.get("merchantStat")),
                clean(row.get("autoSettleStatus")),
                clean(row.get("cashSwitchStatus")),
                clean(row.get("capabilityVerifiedAt"))
        );
    }
 
    /** 进件的 store_id 允许为空(历史数据):这里不能直接拆箱,否则 NullPointerException 会冒到调用方。 */
    private static long number(Object value) {
        return value instanceof Number number ? number.longValue() : 0L;
    }
 
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
 
    private String clean(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
 
    private record Application(
            long id,
            long merchantId,
            long storeId,
            String status,
            String huifuId,
            String merchantStatus,
            String autoSettleStatus,
            String cashSwitchStatus,
            String capabilityVerifiedAt
    ) {}
 
    /**
     * Whether a merchant can take money right now, with the first unmet precondition as the reason.
     * A ready result carries the collecting sub-merchant that orders must be bound to.
     */
    public record Readiness(boolean ready, String reason, CollectingMerchant merchant) {
        public static Readiness allowed(CollectingMerchant merchant) { return new Readiness(true, "", merchant); }
 
        public static Readiness blocked(String reason) { return new Readiness(false, reason, null); }
    }
 
    /**
     * The sub-merchant that collects an order's money: the approved onboarding, its store and the
     * Huifu merchant number. The null-safe accessors exist for the order inserts, where a free order
     * legitimately carries no collecting merchant.
     */
    public record CollectingMerchant(long applicationId, long storeId, String huifuId) {
        public static Long applicationIdOf(CollectingMerchant merchant) { return merchant == null ? null : merchant.applicationId(); }
 
        public static Long storeIdOf(CollectingMerchant merchant) { return merchant == null ? null : merchant.storeId(); }
 
        public static String huifuIdOf(CollectingMerchant merchant) { return merchant == null ? null : merchant.huifuId(); }
    }
}