yeg
2 天以前 ff4525af5b4178a38ca310719e4c7af9af3e742c
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
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
package com.talentcard.server.business.merchant;
 
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
 
/** Six independent evidence-backed stages; provider calls never hold application row locks. */
@Service
public class MerchantOnboardingProgressService {
    private static final Map<String,String> LABELS = new LinkedHashMap<>();
    static {
        LABELS.put("basic", "基础审核"); LABELS.put("wechat", "微信支付");
        LABELS.put("appId", "人才端绑定"); LABELS.put("realname", "实名与授权");
        LABELS.put("delay", "延迟入账"); LABELS.put("settlement", "自动结算");
    }
    private final JdbcTemplate jdbc;
    private final HuifuOnboardingProgressGateway gateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
    public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway, HuifuPaymentProperties huifuPaymentProperties) { this.jdbc=jdbc; this.gateway=gateway; this.huifuPaymentProperties=huifuPaymentProperties; }
 
    /**
     * 分阶段进度适用于「已关联结算银行卡、且未被整体拒绝」的申请。
     *
     * <p>原先这里按 huifu_id 白名单只放行一张指定存量商户,新商户走不进这套视图。现在按申请自身的
     * 状态判断:银行卡关联是分阶段视图核实原卡的前提({@code application()} 也用它做条件);
     * 而被拒绝的申请仍留在旧的单状态模型,商户端才有重新提交的入口。
     *
     * <p>这一条同时决定「进件失败后不重建商户与银行卡」的适用范围:支持的申请走局部刷新与重试,
     * 不支持的才允许关闭重来。旧策略里「通道配置失败就释放银行卡、让商户整单重提」已被规格
     * 明确取代——基本进件成功后要保留原商户号和银行卡。
     */
    public boolean supports(Map<String,Object> application) {
        return application.get("initialBankCardId") != null && !"rejected".equals(text(application.get("status")));
    }
 
    public Map<String,Object> view(Long id) { return view(application(id)); }
 
    /** 进件来源标记:后台纳管进来的外部进件商户。取值与 V158 的 {@code source} 列一致。 */
    public static final String SOURCE_MANAGED = "managed";
 
    /**
     * 这条进件是不是「后台纳管的外部进件」。
     *
     * <p>它决定这条进件能不能**下发**通道改动:纳管全程只查询、不下发(外部商户的口径已由业务要求
     * 保持一致,只需核实;而补开微信、绑人才端、改支付场景、开启延迟入账这些下发操作都不幂等,
     * 连锁多店共用同一个汇付商户号时各自下发还会重复下发)。判定只留这一处定义,刷新链路与纳管/解绑
     * 都读它,避免同一个来源标记在几处各自比较字符串。
     */
    public static boolean isManaged(Map<String,Object> application) {
        return SOURCE_MANAGED.equals(text(application.get("source")).trim());
    }
 
    @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
    public Map<String,Object> refresh(Long applicationId, boolean retry) {
        Map<String,Object> application=application(applicationId);
        // 被拒绝的申请不归分阶段模型管:刷下去会把它从 rejected 翻回 pending,商户端的
        // 「重新提交」入口就没了(supports() 对 rejected 返回 false,正是为了让出那个入口)。
        if (!supports(application)) return view(application);
        jdbc.update("insert ignore into merchant_onboarding_progress(application_id,stages_json) values (?, '{}')", applicationId);
        String owner=UUID.randomUUID().toString();
        if(jdbc.update("update merchant_onboarding_progress set sync_owner=?,sync_until=date_add(now(),interval 5 minute) where application_id=? and (sync_owner is null or sync_until<now())", owner, applicationId)==0) return view(application);
        try {
            Map<String,Object> response=gateway.queryApplication(text(application.get("providerHuifuId")), text(application.get("providerApplyNo")));
            if (!"00000000".equals(text(response.get("resp_code")))) {
                String error=queryError(response);
                jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",error,applicationId,owner);
            } else {
                Map<String,Object> stages = savedStages(applicationId);
                String audit=text(response.get("apply_status"));
                if ("Y".equals(audit) && "S".equals(text(response.get("reg_status")))) stages.put("basic", "confirmed");
                else if (!"confirmed".equals(stages.get("basic"))) stages.put("basic",List.of("N","F").contains(audit) ? "failed" : "pending");
                jdbc.update("update merchant_onboarding_progress set stages_json=?,last_synced_at=now(),sync_error='' where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
                Map<String,Object> capabilities=gateway.queryCapabilities(text(application.get("providerHuifuId")));
                if (capabilities != null && "00000000".equals(text(capabilities.get("resp_code")))) {
                    if (capabilities.containsKey("mer_stat")) {
                        stages.put("merchantNormal", "NORMAL".equals(text(capabilities.get("mer_stat"))));
                        // 原始值一起存下来,只是为了在完成判定里说清「状态异常(CLOSED)」这种话。
                        // 与 realnameQr 一样,它是对应布尔项的**证据**,不是第七个阶段:stages 列表由
                        // LABELS 生成,非 LABELS 的键不会进列表,完成判定也只认 LABELS 的六个键。
                        stages.put("merchantStat", text(capabilities.get("mer_stat")));
                    }
                    if ("S".equals(text(response.get("bind_card_status"))) && capabilities.containsKey("qry_cash_card_info_list") && capabilities.containsKey("qry_settle_config_list")) {
                        stages.put("bank", confirmOriginalCard(application,capabilities,owner));
                        // 进件通过后新增的结算卡同样只有绑定回调能推进,而回调会丢(开发/内网环境更是收不到)。
                        // 把上面那条查询路径推广到该门店所有待处理的卡,挂在同一次人工刷新上——不新增定时任务。
                        confirmAddedCards(application,capabilities,owner);
                    }
                    if (capabilities.containsKey("qry_wx_conf_list")) {
                        List<Map<String,Object>> wechat = configurations(capabilities.get("qry_wx_conf_list"));
                        // 口径必须与业务开通的下发取同一份配置(场景、费率、业务开通类型都来自 properties):
                        // 判定跟着下发走,否则会出现「通道已开通、阶段永远 missing」,而它下游的收款闸门
                        // 会把商户挡在下单之外。2026-09-24 前这里是硬编码的线上 4/5/12 + 0.70。
                        List<String> expectedScenes = huifuPaymentProperties.merchantWechatSceneList();
                        String expectedFeeRate = huifuPaymentProperties.getMerchantWechatFeeRate().trim();
                        boolean valid = !expectedScenes.isEmpty() && expectedScenes.stream().allMatch(scene -> wechat.stream().anyMatch(item ->
                                scene.equals(text(item.get("pay_scene"))) && decimal(item.get("fee_rate"), expectedFeeRate)
                                // fee_min_amt 是可选字段,且文档写明「大于 0」——不设最低收费时我们**不下发**它,
                                // 通道也就不返回它。缺失必须按 0 处理,否则这条判定永远不成立:商户微信业务
                                // 明明已经开好(三场景、费率、内扣、子商户号都对),阶段却永远停在未确认
                                // (2026-09-16 拿真实商户的通道回执核对出来的)。
                                && zeroUnlessAbsent(item.get("fee_min_amt"))
                                && "1".equals(text(item.get("fee_charge_type")))
                                && "2".equals(text(item.get("out_fee_flag"))) && item.containsKey("wx_mer_infos")
                                && configurations(item.get("wx_mer_infos")).stream().anyMatch(merchant ->
                                        !text(merchant.get("bank_mer_code")).isBlank() && "1".equals(text(merchant.get("is_default"))))));
                        stages.put("wechat", valid ? "confirmed" : "missing");
                    }
                    if (capabilities.containsKey("qry_settle_config_list")) {
                        List<Map<String,Object>> settlements = configurations(capabilities.get("qry_settle_config_list"));
                        boolean valid = settlements.size() == 1 && settlements.stream().allMatch(item ->
                                "1".equals(text(item.get("settle_status"))) && "D1".equals(text(item.get("settle_cycle")))
                                && "P0".equals(text(item.get("settle_pattern")))
                                && zero(item.get("min_amt")) && zero(item.get("remained_amt")));
                        stages.put("settlement", valid ? "confirmed" : "missing");
                    }
                    if (capabilities.containsKey("delay_flag") && capabilities.containsKey("forced_delay_flag")) {
                        stages.put("delay", "Y".equals(capabilities.get("delay_flag")) && "N".equals(capabilities.get("forced_delay_flag")) ? "confirmed" : "missing");
                    } else {
                        jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?","汇付返回缺少延迟配置字段,已保留最后有效状态",applicationId,owner);
                    }
                    jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
                } else {
                    jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",queryError(capabilities),applicationId,owner);
                }
                jdbc.update("update merchant_onboarding_progress set last_synced_at=now() where application_id=? and sync_owner=?",applicationId,owner);
                Map<String,Object> appConfig = gateway.queryWechatConfig(text(application.get("providerHuifuId")));
                if (appConfig != null && "00000000".equals(text(appConfig.get("resp_code")))) {
                    boolean bound = false, controlled = false;
                    // 业务开通类型也取配置:绑定写在哪个 fee_type 下就得在哪个下面找,两边必须同一个值。
                    String expectedFeeType = huifuPaymentProperties.getMerchantWechatFeeType();
                    String expectedAppId = huifuPaymentProperties.getMiniProgramAppId().trim();
                    for (Map<String,Object> config : configurations(appConfig.getOrDefault("atu_config_bo_list", List.of()))) {
                        if (!expectedFeeType.equals(text(config.get("fee_type")))) continue;
                        for (Map<String,Object> entry : configurations(config.getOrDefault("wx_config_list", List.of()))) {
                            if ("1".equals(text(entry.get("config_type"))) && expectedAppId.equals(text(entry.get("config_value")))) {
                                bound = true;
                                controlled |= "Y".equals(text(entry.get("control_flag")));
                            }
                        }
                    }
                    stages.put("appId", controlled ? "action_required" : bound ? "confirmed" : "missing");
                } else if (appConfig != null && !appConfig.isEmpty()) {
                    jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",queryError(appConfig),applicationId,owner);
                }
                Map<String,Object> realname = gateway.queryRealname(text(application.get("providerHuifuId")));
                if (realname != null && "00000000".equals(text(realname.get("resp_code")))) {
                    String status = text(realname.get("applyment_stat"));
                    if (!status.isBlank()) stages.put("realname", realnameStage(status, text(realname.get("authorize_stat"))));
                    stages.put("realnameQr", qrCode(text(realname.get("qrcode_data"))));
                } else if (realname != null && !realname.isEmpty()) {
                    if ("00000001".equals(text(realname.get("resp_code"))) && "AT入驻信息池信息不存在".equals(text(realname.get("resp_desc")))) {
                        if (!"confirmed".equals(stages.get("realname"))) stages.put("realname", "missing");
                    } else jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",queryError(realname),applicationId,owner);
                }
                if (!isManaged(application) && "Y".equals(audit) && "S".equals(text(response.get("reg_status"))) && capabilities != null
                        && "00000000".equals(text(capabilities.get("resp_code"))) && "NORMAL".equals(text(capabilities.get("mer_stat")))
                        && capabilities.containsKey("delay_flag") && capabilities.containsKey("forced_delay_flag")) {
                    advanceDelay(applicationId,text(application.get("providerHuifuId")),owner,stages,retry);
                }
                jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
                // 基本资料被通道驳回时要落到 rejected:一是让商户端出现「重新提交」入口(supports() 对
                // rejected 返回 false,交回旧的单状态模型),二是别把一次明确拒绝一直显示成「审核中」。
                // 后续配置失败不算拒绝——那种情况保留原商户号和银行卡,局部重试即可。
                boolean basicRejected = "failed".equals(text(stages.get("basic")));
                String applicationStatus = basicRejected ? "rejected" : complete(stages) ? "approved" : "pending";
                jdbc.update("""
                        update merchant_onboarding_application a join merchant_onboarding_progress p on p.application_id=a.id
                        set a.status=?,a.reviewed_at=case when ? then now() else null end
                        where a.id=? and p.sync_owner=?
                        """,applicationStatus,!"pending".equals(applicationStatus),applicationId,owner);
                if (basicRejected) jdbc.update("update merchant_onboarding_application set reject_reason=? where id=?","基本资料审核未通过,请核对资料后重新提交",applicationId);
            }
        } catch (RuntimeException exception) {
            jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?","查询结果暂未确认,已保留最后有效进度,请稍后刷新",applicationId,owner);
        } finally {
            jdbc.update("update merchant_onboarding_progress set sync_owner=null,sync_until=null where application_id=? and sync_owner=?",applicationId,owner);
        }
        return view(application(applicationId));
    }
 
    private boolean confirmOriginalCard(Map<String,Object> application, Map<String,Object> capabilities, String owner) {
        List<Map<String,Object>> original=rows("select * from merchant_bank_card where id=? and merchant_id=?",application.get("initialBankCardId"),application.get("merchantId"));
        if (original.size()!=1) return false;
        return approveIfLinked(original.get(0),application,capabilities,owner);
    }
 
    /**
     * 进件通过后新增的结算卡:把 {@link #confirmOriginalCard} 的那条查询路径推广到该门店所有待处理的卡。
     *
     * <p>初次进件那张卡有查询对等物(只认 {@code initial_bank_card_id}),后加的卡却只有绑定回调能推进——
     * 回调一丢(内网或开发环境必然丢),卡就永远停在「处理中」。补法是在**已有的人工刷新**路径上多走一遍,
     * 不是新增定时任务(ADR 0001 / ADR 0002 的边界)。
     */
    private int confirmAddedCards(Map<String,Object> application, Map<String,Object> capabilities, String owner) {
        List<Map<String,Object>> added=rows("""
                select * from merchant_bank_card
                where merchant_id=? and status='pending' and (store_id=? or store_id is null) and id<>?
                """,application.get("merchantId"),application.get("storeId"),application.get("initialBankCardId"));
        int confirmed=0;
        for (Map<String,Object> card:added) {
            if (approveIfLinked(card,application,capabilities,owner)) confirmed++;
        }
        return confirmed;
    }
 
    /**
     * 通道的结算卡列表里有没有这张卡,且它已经挂到结算配置上——两件都成立才算绑定成功。
     *
     * <p>匹配用卡号哈希:通道回执里的卡号是明文,本地只存哈希与后四位。
     */
    private boolean approveIfLinked(Map<String,Object> card, Map<String,Object> application,
                                    Map<String,Object> capabilities, String owner) {
        for (Map<String,Object> remote:configurations(capabilities.get("qry_cash_card_info_list"))) {
            String number=text(remote.get("card_no")), token=text(remote.get("token_no"));
            if (!number.matches("[0-9]{8,32}") || token.isBlank() || !sha256(number).equals(text(card.get("cardHash")))) continue;
            String previous=text(card.get("providerTokenNo"));
            if (!previous.isBlank() && !previous.equals(token)) continue;
            boolean linked=configurations(capabilities.get("qry_settle_config_list")).stream().anyMatch(settle -> token.equals(text(settle.get("token_no"))));
            if (!linked) continue;
            return jdbc.update("""
                    update merchant_bank_card b join merchant_onboarding_progress p on p.application_id=?
                    set b.status='approved',b.provider_token_no=?,b.reviewed_at=coalesce(b.reviewed_at,now())
                    where b.id=? and p.sync_owner=?
                    """,application.get("id"),token,card.get("id"),owner)>0;
        }
        return false;
    }
 
    private static String sha256(String value) {
        try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
        catch (java.security.NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
    }
 
    private static boolean complete(Map<String,Object> stages) {
        return Boolean.TRUE.equals(stages.get("bank")) && Boolean.TRUE.equals(stages.get("merchantNormal"))
                && LABELS.keySet().stream().allMatch(key -> "confirmed".equals(stages.get(key)));
    }
 
    private void advanceDelay(Long id, String huifuId, String owner, Map<String,Object> stages, boolean retry) {
        List<Map<String,Object>> attempts=rows("select * from merchant_onboarding_attempt where application_id=? and step='delay' order by id desc limit 1",id);
        Map<String,Object> attempt=attempts.isEmpty() ? null : attempts.get(0);
        if ("confirmed".equals(stages.get("delay"))) {
            if (attempt != null) jdbc.update("update merchant_onboarding_attempt set status='confirmed' where id=?",attempt.get("id"));
            return;
        }
        if (attempt != null && !"failed".equals(text(attempt.get("status")))) {
            String applyNo=text(attempt.get("applyNo"));
            if (!applyNo.isBlank() && !List.of("succeeded","confirmed").contains(text(attempt.get("status")))) {
                Map<String,Object> result=gateway.queryApplication(huifuId,applyNo);
                if (result != null && "00000000".equals(text(result.get("resp_code"))) && List.of("N","F").contains(text(result.get("apply_status")))) {
                    jdbc.update("update merchant_onboarding_attempt set status='failed',response_code=?,response_message=nullif(?,'') where id=? and status<>'confirmed'",text(result.get("resp_code")),channelMessage(result),attempt.get("id"));
                    attempt.put("status","failed");
                }
            }
            if (!"failed".equals(text(attempt.get("status")))) { stages.put("delay","pending"); return; }
        }
        if (attempt != null && !retry) { stages.put("delay","failed"); return; }
        String requestNo=UUID.randomUUID().toString().replace("-", "");
        String date=java.time.LocalDate.now(java.time.ZoneId.of("Asia/Shanghai")).format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
        int inserted=jdbc.update("""
                insert into merchant_onboarding_attempt(application_id,step,request_no,request_date,status)
                select ?, 'delay', ?, ?, 'unknown' from merchant_onboarding_progress
                where application_id=? and sync_owner=? and sync_until>now()
                """,id,requestNo,date,id,owner);
        if (inserted==0) return;
        stages.put("delay","pending");
        try {
            Map<String,Object> result=gateway.configureDelay(huifuId,requestNo,date);
            String code=result==null ? "" : text(result.get("resp_code"));
            String status=code.isBlank() ? "unknown" : List.of("00000000","00000100","90000000").contains(code) ? "pending" : "failed";
            // 通道给的描述一起落库。原先只存 response_code,而延迟入账这一条失败回的是 99999999
            // (通道的「失败:<原因>」通用码),描述一丢就完全看不出为什么失败,排查只能靠猜。
            jdbc.update("update merchant_onboarding_attempt set status=?,apply_no=nullif(?,''),response_code=?,response_message=nullif(?,'') where request_no=?",status,result==null ? "" : text(result.get("apply_no")),code,channelMessage(result),requestNo);
            if ("failed".equals(status)) stages.put("delay","failed");
        } catch (RuntimeException ignored) {
            // The durable unknown attempt blocks resubmission after a crash, timeout or invalid response.
        }
    }
 
    public Map<String,Object> view(Map<String,Object> application) {
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("id",text(application.get("id"))); result.put("applicationNo",text(application.get("applicationNo")));
        result.put("status","pending"); result.put("subjectType",text(application.get("subjectType")));
        result.put("submittedAt",text(application.get("createdAt"))); result.put("retryable",false);
        result.put("refreshable",true); result.put("lastSyncedAt",""); result.put("syncError","");
        List<Map<String,Object>> saved=rows("select * from merchant_onboarding_progress where application_id=?",application.get("id"));
        Map<String,Object> stages=new LinkedHashMap<>();
        if (!saved.isEmpty()) {
            stages=JSON.parseObject(text(saved.get(0).get("stagesJson")), Map.class);
            result.put("lastSyncedAt",text(saved.get(0).get("lastSyncedAt")));
            result.put("syncError",text(saved.get(0).get("syncError")));
        }
        List<Map<String,Object>> progress=new ArrayList<>();
        for (var entry:LABELS.entrySet()) {
            String status = text(stages.getOrDefault(entry.getKey(),"unknown"));
            progress.add(Map.of("key",entry.getKey(),"label",entry.getValue(),"status",status,"reason",stageReason(entry.getKey(),status),
                    "qrCode", "realname".equals(entry.getKey()) ? text(stages.get("realnameQr")) : ""));
        }
        // 被通道明确驳回基本资料的申请仍显示 rejected(并带通道给的驳回原因),不能被阶段计算
        // 盖成 pending——那样商户端看不到「重新提交」。
        boolean rejected = "rejected".equals(text(application.get("status")));
        boolean completed = complete(stages);
        List<Map<String,Object>> checks = completionChecks(stages, application);
        result.put("stages",progress); result.put("reason","请刷新进度,核实原申请与各项能力");
        result.put("completionChecks",checks);
        result.put("retryable", "failed".equals(stages.get("delay")));
        result.put("status",rejected ? "rejected" : completed ? "approved" : "pending");
        result.put("reason",rejected ? text(application.get("rejectReason"))
                : completed ? "收款能力已核实,沿用原商户号和结算银行卡" : incompleteReason(progress, checks));
        return result;
    }
 
    /**
     * 完成判定里不在六项阶段中的另外两项核实结果。
     *
     * <p>{@code complete()} 还要求结算银行卡核实({@code bank})与通道商户状态
     * ({@code merchantNormal}),但这两项原先既不进 {@code LABELS}、{@code view()} 也不返回:
     * 一旦某次刷新发现通道把商户停用了,申请会掉回 {@code pending},页面于是出现
     * 「标题说待办理、六项却全绿」——商户看不到究竟缺什么,只会以为页面出错。
     *
     * <p>它们**不能**加进 {@code LABELS}:那里同时是完成判定的键集,而且这两项是布尔值、
     * 其余六项必须是字符串 {@code "confirmed"},加进去会让进件永远无法 approved。
     */
    private static List<Map<String,Object>> completionChecks(Map<String,Object> stages, Map<String,Object> application) {
        // 证据优先取刷新那一刻存下来的通道原值;老记录里没有这个键时回落到申请行上的快照列。
        String merchantStatus = text(stages.get("merchantStat"));
        if (merchantStatus.isBlank()) merchantStatus = text(application.get("merchantStat"));
        List<Map<String,Object>> checks=new ArrayList<>();
        checks.add(completionCheck("bank","结算银行卡核实",stages.get("bank"),
                "结算银行卡尚未核实:通道没有这张原卡的可用记录,请确认结算卡后刷新"));
        checks.add(completionCheck("merchantNormal","通道商户状态",stages.get("merchantNormal"),
                merchantStatus.isBlank()
                        ? "斗拱子商户状态异常,请联系汇付或后台处理"
                        : "斗拱子商户状态异常(" + merchantStatus + "),请联系汇付或后台处理"));
        return checks;
    }
 
    private static Map<String,Object> completionCheck(String key, String label, Object value, String failedReason) {
        String status="unknown", reason="尚未核实,请刷新进度";
        if (value instanceof Boolean confirmed) {
            status = confirmed ? "confirmed" : "failed";
            reason = confirmed ? "已核实通过" : failedReason;
        }
        return Map.of("key",key,"label",label,"status",status,"reason",reason);
    }
 
    /**
     * pending 的原因。六项阶段全绿却还没完成时,必须点出另外两项完成条件——否则那句话会变成
     * 「请按待办理项补齐缺失能力」,而页面上一个待办理项都没有。
     */
    private static String incompleteReason(List<Map<String,Object>> progress, List<Map<String,Object>> checks) {
        boolean stagesAllConfirmed = progress.stream().allMatch(stage -> "confirmed".equals(stage.get("status")));
        if (!stagesAllConfirmed) return "请刷新进度核实各项能力,并按待办理项补齐缺失能力";
        List<String> failed = checks.stream().filter(check -> !"confirmed".equals(check.get("status")))
                .map(check -> String.valueOf(check.get("label"))).toList();
        return failed.isEmpty() ? "请刷新进度核实各项能力,并按待办理项补齐缺失能力"
                : "进件阶段已全部确认,但" + String.join("、", failed) + "未通过核实,见下方核实结果";
    }
 
    /** Called only after the existing HTTP callback boundary has verified Huifu's signature. */
    public Long acceptDelayCallback(Map<String,Object> data) {
        // 用回调自带的 huifu_id 去匹配它自己的申请单,不再按固定商户号过滤。
        String huifuId=text(data.get("huifu_id"));
        if (huifuId.isBlank()) return null;
        List<Map<String,Object>> matches=rows("""
                select t.id,t.application_id from merchant_onboarding_attempt t
                join merchant_onboarding_application a on a.id=t.application_id
                where t.step='delay' and t.request_no=? and t.request_date=? and a.provider_huifu_id=?
                """,text(data.get("req_seq_id")),text(data.get("req_date")),huifuId);
        if (matches.isEmpty()) return null;
        Map<String,Object> attempt=matches.get(0);
        String audit=text(data.get("audit_status"));
        String state="Y".equals(audit) ? "succeeded" : List.of("N","F","C").contains(audit) ? "failed" : "pending";
        jdbc.update("""
                update merchant_onboarding_attempt set status=?,apply_no=coalesce(apply_no,nullif(?,'')),response_code=?
                where id=? and status not in ('succeeded','confirmed')
                """,state,text(data.get("apply_no")),text(data.getOrDefault("sub_resp_code",data.get("resp_code"))),attempt.get("id"));
        return ((Number)attempt.get("applicationId")).longValue();
    }
 
    private Map<String,Object> application(Long id) {
        List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where id=? and initial_bank_card_id is not null",id);
        if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found");
        return records.get(0);
    }
    @SuppressWarnings("unchecked")
    private Map<String,Object> savedStages(Long id) {
        return JSON.parseObject(jdbc.queryForObject("select stages_json from merchant_onboarding_progress where application_id=?",String.class,id),Map.class);
    }
    private List<Map<String,Object>> rows(String sql,Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql,args)); }
    private static String text(Object value) { return value==null ? "" : String.valueOf(value); }
 
    /**
     * 通道返回的描述。业务描述可能落在 {@code sub_resp_desc},网关层描述落在 {@code resp_desc},
     * 两者都取不到就留空。列定长 512,超长按列宽截断,避免整条 update 报「Data too long」。
     */
    private static String channelMessage(Map<String,Object> result) {
        if (result == null) return "";
        String message = text(result.get("sub_resp_desc"));
        if (message.isBlank()) message = text(result.get("resp_desc"));
        if (message.isBlank()) message = text(result.get("resp_msg"));
        return message.length() <= 512 ? message : message.substring(0, 512);
    }
    private static String stageReason(String key, String status) {
        if ("confirmed".equals(status)) return "已取得通道生效证据";
        if ("realname".equals(key) && "action_required".equals(status)) return "请法人或授权联系人完成微信实名及授权,办理后刷新;没有二维码时请联系平台运营";
        if ("realname".equals(key) && "missing".equals(status)) return "尚未发起微信实名认证,请先发起;实名走法人,需要法人本人的手机号";
        // 微信业务开通与人才端绑定都由刷新推进会继续办理,所以要明确告诉用户「点刷新就能继续」。
        if ("wechat".equals(key) && "missing".equals(status)) return "微信支付业务开通尚未完成,点「刷新进度」会继续办理这一步";
        if ("appId".equals(key) && "missing".equals(status)) return "人才端小程序尚未绑定,点「刷新进度」会继续办理这一步";
        if ("appId".equals(key) && "action_required".equals(status)) return "人才端绑定被微信管控,请联系平台运营解除后刷新";
        if ("delay".equals(key) && "missing".equals(status)) return "需开启延迟入账并关闭强制延迟";
        if ("settlement".equals(key) && "missing".equals(status)) return "需开启 D1 批次结算,起结金额与留存金额均为 0";
        if ("pending".equals(status)) return "通道正在审核,请稍后刷新";
        if ("failed".equals(status)) return "该步骤未通过,请联系平台运营核对资料后局部重试";
        return "尚未取得通道生效证据";
    }
    /**
     * 实名阶段的状态映射。微信实名分「实名认证申请」与「商户授权」两个阶段,各有各的状态,
     * 必须分别看:
     *
     * <ul>
     *   <li>{@code NOAPPLY}/{@code CANCELED} 是没申请或已作废,要提示去发起,不能和「需要本人办理」
     *       混为一谈;</li>
     *   <li>{@code REJECTED} 是驳回,要按原因补资料并更换业务申请编号重来;</li>
     *   <li>授权是否完成**只看 {@code authorize_stat}**,不看申请单状态。{@code PASSED} 单独不等于
     *       授权完成;反过来 {@code FREEZED}(「该主体已完成过入驻」)也可能已经授权成功——真实样本
     *       里就是 {@code APPLYMENT_STATE_FREEZED} + {@code authorize_stat=1}。按申请单状态判会把
     *       已经授权好的商户显示成待办理。</li>
     * </ul>
     */
    private static String realnameStage(String applymentStat, String authorizeStat) {
        if ("APPLYMENT_STATE_REJECTED".equals(applymentStat)) return "failed";
        if ("APPLYMENT_STATE_NOAPPLY".equals(applymentStat) || "APPLYMENT_STATE_CANCELED".equals(applymentStat)) return "missing";
        if ("1".equals(authorizeStat)) return "confirmed";
        if ("APPLYMENT_STATE_EDITTING".equals(applymentStat) || "APPLYMENT_STATE_WAITTING_FOR_AUDIT".equals(applymentStat)) return "pending";
        return "action_required";
    }
 
    /**
     * 把通道返回的 base64 图片转成能直接放进 {@code <img src>} 的 data URL。
     *
     * <p>通道回的是**裸 base64**,而且是 JPEG 还是 PNG 并不固定:实测小微实名的授权二维码就是
     * **JPEG**(`/9j/4AAQSkZJRg…`)。这里原先写死了 PNG 的 magic(`iVBORw0KGgo`)并统一标成
     * `image/png`,于是 JPEG 的码被判成非法、返回空串——两端都拿不到二维码,而"本人扫码授权"
     * 是完成实名的唯一途径(2026-09-16 实测:申请单已受理、通道已回码,页面却是空的)。
     *
     * <p>仍然只接受图片 base64(按 magic 判定),不把任意内容拼进 img src。
     */
    private static String qrCode(String raw) {
        String value = raw.replaceFirst("^data:image/(png|jpe?g);base64,", "");
        if (value.length() > 200000 || !value.matches("[A-Za-z0-9+/=\\r\\n]+")) return "";
        String mime = value.startsWith("iVBORw0KGgo") ? "image/png"
                : value.startsWith("/9j/") ? "image/jpeg"
                : "";
        return mime.isBlank() ? "" : "data:" + mime + ";base64," + value;
    }
    @SuppressWarnings("unchecked")
    private static List<Map<String,Object>> configurations(Object raw) {
        Object parsed = raw instanceof String value ? JSON.parse(value) : raw;
        if (!(parsed instanceof List<?> list)) throw new IllegalArgumentException("Invalid provider configuration list");
        return list.stream().map(item -> (Map<String,Object>)item).toList();
    }
    private static boolean zero(Object value) {
        return decimal(value, "0");
    }
 
    /**
     * 给「字段缺失就等于没设」的可选金额字段用。
     *
     * <p>与 {@link #zero} 的区别只在 null:{@code fee_min_amt} 这类字段不设时通道不返回,
     * 而"不返回"的语义正是"没设最低收费"(即 0),不能按"取不到就不算数"处理。
     */
    private static boolean zeroUnlessAbsent(Object value) {
        return value == null || zero(value);
    }
    private static boolean decimal(Object value, String expected) {
        return value != null && new java.math.BigDecimal(text(value)).compareTo(new java.math.BigDecimal(expected)) == 0;
    }
    private static String queryError(Map<String,Object> response) {
        return response != null && "20002".equals(text(response.get("resp_code")))
                ? "汇付查询权限不足(20002),请联系平台运营核实接口权限"
                : "汇付查询未成功,请稍后刷新或联系平台运营";
    }
}