package com.talentcard.server.business.merchant;
|
|
import com.alibaba.fastjson.JSON;
|
import com.talentcard.server.business.DatabaseRows;
|
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
|
import com.talentcard.server.common.BusinessException;
|
import com.talentcard.server.common.ErrorCode;
|
import java.util.ArrayList;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.UUID;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.stereotype.Service;
|
|
/** Six independent evidence-backed stages; provider calls never hold application row locks. */
|
@Service
|
public class MerchantOnboardingProgressService {
|
private static final Map<String,String> LABELS = new LinkedHashMap<>();
|
static {
|
LABELS.put("basic", "基础审核"); LABELS.put("wechat", "微信支付");
|
LABELS.put("appId", "人才端绑定"); LABELS.put("realname", "实名与授权");
|
LABELS.put("delay", "延迟入账"); LABELS.put("settlement", "自动结算");
|
}
|
private final JdbcTemplate jdbc;
|
private final HuifuOnboardingProgressGateway gateway;
|
public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway) { this.jdbc=jdbc; this.gateway=gateway; }
|
|
/**
|
* 分阶段进度适用于「已关联结算银行卡、且未被整体拒绝」的申请。
|
*
|
* <p>原先这里按 huifu_id 白名单只放行一张指定存量商户,新商户走不进这套视图。现在按申请自身的
|
* 状态判断:银行卡关联是分阶段视图核实原卡的前提({@code application()} 也用它做条件);
|
* 而被拒绝的申请仍留在旧的单状态模型,商户端才有重新提交的入口。
|
*
|
* <p>这一条同时决定「进件失败后不重建商户与银行卡」的适用范围:支持的申请走局部刷新与重试,
|
* 不支持的才允许关闭重来。旧策略里「通道配置失败就释放银行卡、让商户整单重提」已被规格
|
* 明确取代——基本进件成功后要保留原商户号和银行卡。
|
*/
|
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)); }
|
|
@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);
|
try {
|
Map<String,Object> response=gateway.queryApplication(text(application.get("providerHuifuId")), text(application.get("providerApplyNo")));
|
if (!"00000000".equals(text(response.get("resp_code")))) {
|
String error=queryError(response);
|
jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",error,applicationId,owner);
|
} else {
|
Map<String,Object> stages = savedStages(applicationId);
|
String audit=text(response.get("apply_status"));
|
if ("Y".equals(audit) && "S".equals(text(response.get("reg_status")))) stages.put("basic", "confirmed");
|
else if (!"confirmed".equals(stages.get("basic"))) stages.put("basic",List.of("N","F").contains(audit) ? "failed" : "pending");
|
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"))));
|
// 原始值一起存下来,只是为了在完成判定里说清「状态异常(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));
|
}
|
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")
|
// 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"))))));
|
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 {
|
jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?","汇付返回缺少延迟配置字段,已保留最后有效状态",applicationId,owner);
|
}
|
jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
|
} else {
|
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"));
|
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
|
&& "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=?
|
""",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);
|
} finally {
|
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 huifuId, 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(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=?,response_message=nullif(?,'') where id=? and status<>'confirmed'",text(result.get("resp_code")),channelMessage(result),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(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";
|
// 通道给的描述一起落库。原先只存 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.
|
}
|
}
|
|
public Map<String,Object> view(Map<String,Object> application) {
|
Map<String,Object> result=new LinkedHashMap<>();
|
result.put("id",text(application.get("id"))); result.put("applicationNo",text(application.get("applicationNo")));
|
result.put("status","pending"); result.put("subjectType",text(application.get("subjectType")));
|
result.put("submittedAt",text(application.get("createdAt"))); result.put("retryable",false);
|
result.put("refreshable",true); result.put("lastSyncedAt",""); result.put("syncError","");
|
List<Map<String,Object>> saved=rows("select * from merchant_onboarding_progress where application_id=?",application.get("id"));
|
Map<String,Object> stages=new LinkedHashMap<>();
|
if (!saved.isEmpty()) {
|
stages=JSON.parseObject(text(saved.get(0).get("stagesJson")), Map.class);
|
result.put("lastSyncedAt",text(saved.get(0).get("lastSyncedAt")));
|
result.put("syncError",text(saved.get(0).get("syncError")));
|
}
|
List<Map<String,Object>> progress=new ArrayList<>();
|
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")) : ""));
|
}
|
// 被通道明确驳回基本资料的申请仍显示 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" : 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. */
|
public Long acceptDelayCallback(Map<String,Object> data) {
|
// 用回调自带的 huifu_id 去匹配它自己的申请单,不再按固定商户号过滤。
|
String huifuId=text(data.get("huifu_id"));
|
if (huifuId.isBlank()) return null;
|
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")),huifuId);
|
if (matches.isEmpty()) 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) {
|
List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where id=? and initial_bank_card_id is not null",id);
|
if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found");
|
return records.get(0);
|
}
|
@SuppressWarnings("unchecked")
|
private Map<String,Object> savedStages(Long id) {
|
return JSON.parseObject(jdbc.queryForObject("select stages_json from merchant_onboarding_progress where application_id=?",String.class,id),Map.class);
|
}
|
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 ("pending".equals(status)) return "通道正在审核,请稍后刷新";
|
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|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) {
|
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");
|
}
|
|
/**
|
* 给「字段缺失就等于没设」的可选金额字段用。
|
*
|
* <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;
|
}
|
private static String queryError(Map<String,Object> response) {
|
return response != null && "20002".equals(text(response.get("resp_code")))
|
? "汇付查询权限不足(20002),请联系平台运营核实接口权限"
|
: "汇付查询未成功,请稍后刷新或联系平台运营";
|
}
|
}
|