talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -73,7 +73,12 @@
                        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")))
                                // 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"))))));
@@ -191,7 +196,7 @@
            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=? where id=? and status<>'confirmed'",text(result.get("resp_code")),attempt.get("id"));
                    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");
                }
            }
@@ -211,7 +216,9 @@
            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";
            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);
            // 通道给的描述一起落库。原先只存 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.
@@ -280,6 +287,18 @@
    }
    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 "请法人或授权联系人完成微信实名及授权,办理后刷新;没有二维码时请联系平台运营";
@@ -316,10 +335,23 @@
        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;base64,", "");
        if (value.length() > 200000 || !value.startsWith("iVBORw0KGgo") || !value.matches("[A-Za-z0-9+/=\\r\\n]+")) return "";
        return "data:image/png;base64," + value;
        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) {
@@ -330,6 +362,16 @@
    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;
    }