yeg
2 天以前 ff4525af5b4178a38ca310719e4c7af9af3e742c
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -3,6 +3,7 @@
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;
@@ -24,7 +25,8 @@
    }
    private final JdbcTemplate jdbc;
    private final HuifuOnboardingProgressGateway gateway;
    public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway) { this.jdbc=jdbc; this.gateway=gateway; }
    private final HuifuPaymentProperties huifuPaymentProperties;
    public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway, HuifuPaymentProperties huifuPaymentProperties) { this.jdbc=jdbc; this.gateway=gateway; this.huifuPaymentProperties=huifuPaymentProperties; }
    /**
     * 分阶段进度适用于「已关联结算银行卡、且未被整体拒绝」的申请。
@@ -42,6 +44,21 @@
    }
    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) {
@@ -65,14 +82,28 @@
                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"))));
                    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"));
                        boolean valid = List.of("4", "5", "12").stream().allMatch(scene -> wechat.stream().anyMatch(item ->
                                scene.equals(text(item.get("pay_scene"))) && decimal(item.get("fee_rate"), "0.70")
                        // 口径必须与业务开通的下发取同一份配置(场景、费率、业务开通类型都来自 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 处理,否则这条判定永远不成立:商户微信业务
                                // 明明已经开好(三场景、费率、内扣、子商户号都对),阶段却永远停在未确认
@@ -105,10 +136,13 @@
                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 (!"01".equals(text(config.get("fee_type")))) continue;
                        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"))) && "wxcfee477acb54d179".equals(text(entry.get("config_value")))) {
                            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")));
                            }
@@ -128,7 +162,7 @@
                        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 ("Y".equals(audit) && "S".equals(text(response.get("reg_status"))) && capabilities != null
                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);
@@ -157,7 +191,35 @@
    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;
        Map<String,Object> card=original.get(0);
        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;
@@ -247,14 +309,64 @@
        // 被通道明确驳回基本资料的申请仍显示 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" : complete(stages) ? "approved" : "pending");
        result.put("status",rejected ? "rejected" : completed ? "approved" : "pending");
        result.put("reason",rejected ? text(application.get("rejectReason"))
                : complete(stages) ? "收款能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力,并按待办理项补齐缺失能力");
                : 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 去匹配它自己的申请单,不再按固定商户号过滤。