| | |
| | | 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")))))); |
| | |
| | | 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"); |
| | | } |
| | | } |
| | |
| | | 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. |
| | |
| | | } |
| | | 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 "请法人或授权联系人完成微信实名及授权,办理后刷新;没有二维码时请联系平台运营"; |
| | |
| | | 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) { |
| | |
| | | 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; |
| | | } |