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 {
|
public static final String RECOVERY_HUIFU_ID = "6666000241335656";
|
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; }
|
|
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");
|
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"))));
|
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 {
|
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"));
|
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);
|
} 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 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) {
|
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")) : ""));
|
}
|
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) {
|
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); }
|
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),请联系平台运营核实接口权限"
|
: "汇付查询未成功,请稍后刷新或联系平台运营";
|
}
|
}
|