From 9d994ea0eee51dbc87fe83ead985580545926b8a Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期二, 15 九月 2026 21:41:58 +0800
Subject: [PATCH] feat(merchant): 完善存量商户微信开通进度闭环

---
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java |  192 +++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 191 insertions(+), 1 deletions(-)

diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
index c22a1e7..8d019f7 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -29,6 +29,13 @@
 
     public boolean supports(Map<String,Object> application) { return RECOVERY_HUIFU_ID.equals(text(application.get("providerHuifuId"))); }
 
+    public Map<String,Object> view(Long id) {
+        Map<String,Object> application = application(id);
+        if (!supports(application)) throw new BusinessException(ErrorCode.CONFLICT,"This recovery is limited to the designated existing merchant");
+        return view(application);
+    }
+
+    @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);
         if (!supports(application)) throw new BusinessException(ErrorCode.CONFLICT,"This recovery is limited to the designated existing merchant");
@@ -48,6 +55,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 ("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));
+                    }
+                    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")
+                                && zero(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 {
@@ -58,6 +87,46 @@
                     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;
+                    for (Map<String,Object> config : configurations(appConfig.getOrDefault("atu_config_bo_list", List.of()))) {
+                        if (!"01".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")))) {
+                                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"));
+                    boolean authorized = "APPLYMENT_STATE_PASSED".equals(status) && "1".equals(text(realname.get("authorize_stat")));
+                    if (!status.isBlank()) stages.put("realname", authorized ? "confirmed" :
+                            "APPLYMENT_STATE_REJECTED".equals(status) ? "failed" :
+                            "APPLYMENT_STATE_WAITTING_FOR_AUDIT".equals(status) ? "pending" : "action_required");
+                    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 ("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,owner,stages,retry);
+                }
+                jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
+                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=?
+                        """,complete(stages) ? "approved" : "pending",complete(stages),applicationId,owner);
             }
         } catch (RuntimeException exception) {
             jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?","查询结果暂未确认,已保留最后有效进度,请稍后刷新",applicationId,owner);
@@ -65,6 +134,75 @@
             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;
+        Map<String,Object> card=original.get(0);
+        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 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(RECOVERY_HUIFU_ID,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=? where id=? and status<>'confirmed'",text(result.get("resp_code")),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(RECOVERY_HUIFU_ID,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";
+            jdbc.update("update merchant_onboarding_attempt set status=?,apply_no=nullif(?,''),response_code=? where request_no=?",status,result==null ? "" : text(result.get("apply_no")),code,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) {
@@ -81,9 +219,34 @@
             result.put("syncError",text(saved.get(0).get("syncError")));
         }
         List<Map<String,Object>> progress=new ArrayList<>();
-        for (var entry:LABELS.entrySet()) progress.add(Map.of("key",entry.getKey(),"label",entry.getValue(),"status",text(stages.getOrDefault(entry.getKey(),"unknown")),"reason","尚未取得通道生效证据"));
+        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")) : ""));
+        }
         result.put("stages",progress); result.put("reason","请刷新进度,核实原申请与各项能力");
+        result.put("retryable", "failed".equals(stages.get("delay")));
+        result.put("status",complete(stages) ? "approved" : "pending");
+        result.put("reason",complete(stages) ? "收款所需能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力;微信行业规则仍待平台确认,暂不提交微信补开");
         return result;
+    }
+
+    /** Called only after the existing HTTP callback boundary has verified Huifu's signature. */
+    public Long acceptDelayCallback(Map<String,Object> data) {
+        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")),RECOVERY_HUIFU_ID);
+        if (matches.isEmpty() || !RECOVERY_HUIFU_ID.equals(text(data.get("huifu_id")))) 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) {
@@ -97,6 +260,33 @@
     }
     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); }
+    private static String stageReason(String key, String status) {
+        if ("confirmed".equals(status)) return "已取得通道生效证据";
+        if ("realname".equals(key) && "action_required".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 "尚未取得通道生效证据";
+    }
+    private static String qrCode(String raw) {
+        String value = raw.replaceFirst("^data:image/png;base64,", "");
+        if (value.length() > 200000 || !value.startsWith("iVBORw0KGgo") || !value.matches("[A-Za-z0-9+/=\\r\\n]+")) return "";
+        return "data:image/png;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");
+    }
+    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),请联系平台运营核实接口权限"

--
Gitblit v1.7.1