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