| | |
| | | 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; |
| | |
| | | /** Six independent evidence-backed stages; provider calls never hold application row locks. */ |
| | | @Service |
| | | public class MerchantOnboardingProgressService { |
| | | /** 分阶段进度的灰度门槛;03 票把它换成通用规则时一并删除。 */ |
| | | public static final String RECOVERY_HUIFU_ID = "6666000241335656"; |
| | | private static final Map<String,String> LABELS = new LinkedHashMap<>(); |
| | | static { |
| | | LABELS.put("basic", "基础审核"); LABELS.put("wechat", "微信支付"); |
| | |
| | | } |
| | | 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; } |
| | | |
| | | /** |
| | | * 分阶段进度当前只对指定存量商户放行。 |
| | | * 分阶段进度适用于「已关联结算银行卡、且未被整体拒绝」的申请。 |
| | | * |
| | | * <p><b>这仍是一道灰度门槛,不是通用规则。</b>把判断换成「有结算卡且未被拒绝」会同时给所有商户 |
| | | * 切换行为:进件失败不再释放银行卡(`MerchantOnboardingRetryReleaseTests`)、回调改走分阶段刷新 |
| | | * (`MerchantSettlementCallbackIdempotencyTests`),共 5 个测试会红。那是策略变更,属于 03 票 |
| | | * 「分阶段手动推进 + 局部失败保留原商户号和银行卡」,不在 04 的清理范围内。 |
| | | * <p>原先这里按 huifu_id 白名单只放行一张指定存量商户,新商户走不进这套视图。现在按申请自身的 |
| | | * 状态判断:银行卡关联是分阶段视图核实原卡的前提({@code application()} 也用它做条件); |
| | | * 而被拒绝的申请仍留在旧的单状态模型,商户端才有重新提交的入口。 |
| | | * |
| | | * <p>这一条同时决定「进件失败后不重建商户与银行卡」的适用范围:支持的申请走局部刷新与重试, |
| | | * 不支持的才允许关闭重来。旧策略里「通道配置失败就释放银行卡、让商户整单重提」已被规格 |
| | | * 明确取代——基本进件成功后要保留原商户号和银行卡。 |
| | | */ |
| | | public boolean supports(Map<String,Object> application) { return RECOVERY_HUIFU_ID.equals(text(application.get("providerHuifuId"))); } |
| | | 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); |
| | |
| | | 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") |
| | | && zero(item.get("fee_min_amt")) && "1".equals(text(item.get("fee_charge_type"))) |
| | | // 口径必须与业务开通的下发取同一份配置(场景、费率、业务开通类型都来自 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")))))); |
| | |
| | | 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"))); |
| | | } |
| | |
| | | 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"); |
| | | 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 ("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); |
| | | } |
| | | 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=? |
| | | """,complete(stages) ? "approved" : "pending",complete(stages),applicationId,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); |
| | |
| | | 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; |
| | |
| | | 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. |
| | |
| | | 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",complete(stages) ? "approved" : "pending"); |
| | | result.put("reason",complete(stages) ? "收款能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力,并按待办理项补齐缺失能力"); |
| | | 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. */ |
| | |
| | | } |
| | | 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 ("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;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; |
| | | } |