package com.talentcard.server.business.admin;
|
|
import com.talentcard.server.business.DatabaseRows;
|
import com.talentcard.server.business.merchant.MerchantOnboardingProgressService;
|
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
|
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
|
import com.talentcard.server.common.BusinessException;
|
import com.talentcard.server.common.ErrorCode;
|
import com.talentcard.server.common.SensitiveDataCrypto;
|
import java.nio.charset.StandardCharsets;
|
import java.security.MessageDigest;
|
import java.util.ArrayList;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.support.TransactionTemplate;
|
|
/**
|
* 外部进件商户的纳管与解绑:把「已在汇付侧完成进件」的商户接到我方某个商家上,使它能直接用我们这套
|
* 进件记录、分阶段进度与收款闸门收款;绑错了能解绑重来。
|
*
|
* <p>为什么不新造链路:纳管产出的就是一条进件记录。它复用既有的进件记录、六阶段进度、收款能力快照
|
* 与收款就绪判定,只在「写入口」和「来源标记」两处新增——{@code provider_huifu_id} 原先三个写入口
|
* 全部来自汇付回执,纳管是第四个(见 V158 的 source 列)。
|
*
|
* <p><b>全程只查询、不下发。</b> 外部进件商户的口径已由业务要求与自助进件保持一致,纳管只需核实;
|
* 而补开微信、绑人才端 AppID、改支付场景、开启延迟入账这些下发操作都不幂等(改支付场景会重建微信
|
* 子商户号),且连锁多店共用同一个汇付商户号时各自下发会造成重复下发。所以:
|
* <ul>
|
* <li>进度刷新走 {@link MerchantOnboardingProgressService#refresh}(纯查询);</li>
|
* <li>{@code source=managed} 的记录在刷新链路里一律跳过下发步骤(延迟入账、微信配置、业务开通)。</li>
|
* </ul>
|
*
|
* <p>防绑错靠**人工核对**,不靠程序判定:我们这边 {@code merchant} 只有一个商家名(往往是服务点名或
|
* 品牌简称),通道那边是工商全称加简称,形态不对应,字符串比较必然误判——误拒会把运营卡死且无路可绕,
|
* 误放等于没设防。所以程序只判确定性的事(用我们的签名身份查得到该商户),名称改成界面展示 + 运营
|
* 显式勾选确认,并在两个名称与商家名一个字符都不重叠时给出醒目警示(不硬拒,可能是简称、别名或服务点名)。
|
*/
|
@Service
|
public class MerchantManagedOnboardingService {
|
/**
|
* 通道的企业种类取值 → 我们进件记录里的公司类型。
|
*
|
* <p>与 {@code MerchantSettlementService.entType} 是同一张表的两面:那里是「我们的取值 → 通道代码」,
|
* 这里是通道代码 → 我们的取值。取值必须一致,否则对私/对公结算凭证的判定会跟着漂。
|
*/
|
private static final Map<String, String> COMPANY_TYPE_BY_ENT_TYPE = Map.ofEntries(
|
Map.entry("1", "政府机构"), Map.entry("2", "国营企业"), Map.entry("3", "私营企业"),
|
Map.entry("4", "外资企业"), Map.entry("5", "个体工商户"), Map.entry("6", "其它组织"),
|
Map.entry("7", "事业单位"), Map.entry("9", "业主委员会"));
|
|
private final JdbcTemplate jdbc;
|
private final HuifuOnboardingProgressGateway providerGateway;
|
private final SensitiveDataCrypto sensitiveDataCrypto;
|
private final MerchantOnboardingProgressService progressService;
|
private final MerchantPaymentReadinessService readinessService;
|
private final TransactionTemplate transactions;
|
|
public MerchantManagedOnboardingService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway providerGateway,
|
SensitiveDataCrypto sensitiveDataCrypto,
|
MerchantOnboardingProgressService progressService,
|
MerchantPaymentReadinessService readinessService,
|
TransactionTemplate transactions) {
|
this.jdbc = jdbc;
|
this.providerGateway = providerGateway;
|
this.sensitiveDataCrypto = sensitiveDataCrypto;
|
this.progressService = progressService;
|
this.readinessService = readinessService;
|
this.transactions = transactions;
|
}
|
|
/**
|
* 纳管前置核验:用我们自己的签名身份只读查一次该商户,把要人工核对的材料取回来。
|
*
|
* <p>查不到就明确拒绝——那说明该商户不在我们**渠道商**的管辖范围内,纳管进行不下去。注意把商户
|
* 改成挂在平台自己名下既解决不了权限失败、还会平白多出材料要求,不是「迁一下归属就能解决」的事。
|
*
|
* <p>已经填了入驻申请单号的话,顺带把 `(商户号, 申请单号)` 配一次对(见 {@link #applyNoMismatch}):
|
* 错号在提交时才被拒,运营会白填一屏还看不出哪一项有问题。
|
*/
|
public Map<String, Object> preview(Long merchantId, String huifuId, String applyNo) {
|
MerchantRef merchant = requireMerchant(merchantId);
|
String providerHuifuId = requireHuifuId(huifuId);
|
Map<String, Object> channel = channelMerchant(providerHuifuId);
|
Long storeId = primaryStore(merchantId);
|
Map<String, Object> card = pickSettlementCard(channel);
|
String providerApplyNo = clean(applyNo);
|
String mismatched = providerApplyNo.isBlank() ? "" : applyNoMismatch(providerHuifuId, providerApplyNo);
|
|
Map<String, Object> view = new LinkedHashMap<>();
|
view.put("merchantId", String.valueOf(merchant.id()));
|
view.put("merchantName", merchant.name());
|
view.put("storeId", String.valueOf(storeId));
|
view.put("storeName", storeName(storeId));
|
view.put("providerHuifuId", providerHuifuId);
|
// 通道返回的两个名字与主体类型:运营拿它们与所选商家核对,勾选确认后才能提交。
|
view.put("channelMerchantName", clean(channel.get("reg_name")));
|
view.put("channelShortName", clean(channel.get("short_name")));
|
view.put("channelSubjectType", subjectTypeOf(channel));
|
view.put("channelCompanyType", COMPANY_TYPE_BY_ENT_TYPE.getOrDefault(clean(channel.get("ent_type")), ""));
|
view.put("settlementCardMasked", card == null ? "" : maskedCardNumber(clean(card.get("card_no"))));
|
view.put("settlementBankName", card == null ? "" : clean(card.get("bank_name")));
|
// 只提示不拦截:连锁各门店共用同一个收款主体是本次有意支持的做法,提示里列出已绑的商家即可。
|
view.put("boundMerchants", boundMerchantNames(providerHuifuId, merchantId));
|
view.put("nameWarning", nameWarning(merchant.name(),
|
clean(channel.get("reg_name")), clean(channel.get("short_name"))));
|
// 纳管只管收款绑定,不创建商户端登录账号;但商户登不进来要让运营知道。
|
view.put("loginNotice", loginNotice(merchant));
|
// 已经纳管过这个商户号时不算冲突:重复纳管是幂等的,提交会直接返回原进件,别让运营以为要先解绑。
|
Map<String, Object> active = activeApplication(merchantId, storeId);
|
boolean alreadyManaged = active != null && providerHuifuId.equals(clean(active.get("providerHuifuId")));
|
view.put("alreadyManaged", alreadyManaged);
|
// 硬拦截项在这里先告诉运营,免得填完一屏才被拒。申请单号是刚填的那一项,排在第一个。
|
List<String> blockers = new ArrayList<>();
|
if (!mismatched.isBlank()) blockers.add(mismatched);
|
if (card == null) blockers.add("通道未返回可用的结算银行卡,无法纳管(分阶段进度与收款闸门都要求先绑定结算卡)");
|
if (active != null && !alreadyManaged) blockers.add(conflictMessage(active));
|
view.put("blockers", blockers);
|
return view;
|
}
|
|
/**
|
* 纳管:为该商家建立结算卡记录与进件记录,并触发一次进度刷新,使分阶段进度与收款能力快照在既有
|
* 模型内收敛。
|
*
|
* <p>幂等由「由汇付商户号派生的键」保证:{@code application_no} 与 {@code idempotency_key} 都是
|
* 唯一键且非空(见 V80),两个键都只从「商户号 + 门店」派生,重复点击不会产生第二条进件记录。
|
*
|
* <p>{@code confirmed} 是运营在对话框里勾的那个确认框:通道的商户注册名称与简称是运营肉眼核对过的,
|
* 程序不为它做字符串判定(见类注释)。
|
*
|
* <p>两条写入放在同一个事务里(半途失败不留孤儿结算卡),但**进度刷新与能力快照在事务外**:
|
* 刷新是 {@code NOT_SUPPORTED} 的独立连接,在未提交的事务里它查不到这条刚建的进件;
|
* 而通道查询本来也不该夹在持有行锁的事务里。
|
*/
|
public Map<String, Object> adopt(Long merchantId, String huifuId, String applyNo, boolean confirmed) {
|
MerchantRef merchant = requireMerchant(merchantId);
|
String providerHuifuId = requireHuifuId(huifuId);
|
String providerApplyNo = clean(applyNo);
|
if (providerApplyNo.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写汇付侧的入驻申请单号:没有它,通道的申请单状态查询(该接口 apply_no 必填)无法推进,进度会永远停在未确认");
|
if (!confirmed) throw new BusinessException(ErrorCode.BAD_REQUEST, "请先核对通道返回的商户注册名称与简称,并勾选确认后再提交");
|
Long storeId = primaryStore(merchantId);
|
String idempotencyKey = managedIdempotencyKey(providerHuifuId);
|
// 先看幂等:这一条查的就是我们自己上一次纳管留下的记录,别把它当成「门店已有进件」的冲突。
|
List<Map<String, Object>> repeated = rows(
|
"select * from merchant_onboarding_application where merchant_id = ? and idempotency_key = ? limit 1",
|
merchantId, idempotencyKey);
|
if (!repeated.isEmpty()) return reapplyExisting(repeated.get(0));
|
String conflict = activeConflict(merchantId, storeId);
|
if (!conflict.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, conflict);
|
|
Map<String, Object> channel = channelMerchant(providerHuifuId);
|
// 商户号核得住不代表申请单号核得住:这两个字段是分开填的,必须配一次对才能建记录。
|
String mismatched = applyNoMismatch(providerHuifuId, providerApplyNo);
|
if (!mismatched.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, mismatched);
|
Map<String, Object> card = pickSettlementCard(channel);
|
if (card == null) throw new BusinessException(ErrorCode.CONFLICT, "通道未返回可用的结算银行卡,无法纳管");
|
String cardNumber = clean(card.get("card_no"));
|
String applicationNo = managedApplicationNo(providerHuifuId, storeId);
|
Long applicationId = transactions.execute(status -> {
|
Long bankCardId = insertSettlementCard(merchant, storeId, providerHuifuId, card, cardNumber);
|
return insertApplication(merchant, storeId, providerHuifuId, providerApplyNo,
|
applicationNo, idempotencyKey, channel, bankCardId);
|
});
|
// 纳管之后收敛:只查询,不下发。进度刷新负责六个阶段,能力快照负责收款闸门读的那几个字段。
|
progressService.refresh(applicationId, false);
|
refreshCapabilityWhenApproved(applicationId);
|
return progressService.view(applicationId);
|
}
|
|
/**
|
* 把 `(商户号, 申请单号)` 配一次对:**两个字段是一起送给通道的,通道会交叉校验**,所以填错的号
|
* ——包括"真实存在、但属于别的商户号"的号——都会回 `00000001 申请单不存在`(2026-10-12 实测过这两种)。
|
*
|
* <p>为什么必须在建记录之前拦:错号**不会误放**(基础审核确认不了、进件永远 pending、闸门一直拒),
|
* 但会留下一条收不了款的记录,而刷新给出的却是「汇付查询未成功,请稍后刷新或联系平台运营」——
|
* 那句话看起来像通道抖动,实际是号填错了;更麻烦的是重复纳管是幂等的、不会用新号覆盖旧号,只能解绑重来。
|
* 拦在这里之后,运营当场就知道是哪一栏错了。
|
*
|
* <p>返回空串表示配得上;否则返回给运营看的原话。只查询,不下发。
|
*/
|
private String applyNoMismatch(String huifuId, String applyNo) {
|
Map<String, Object> response;
|
try {
|
response = providerGateway.queryApplication(huifuId, applyNo);
|
} catch (RuntimeException exception) {
|
return "申请单号核验失败:通道查询暂时不通,请稍后重试";
|
}
|
if (response != null && "00000000".equals(clean(response.get("resp_code")))) return "";
|
String description = response == null ? "" : clean(response.get("resp_desc"));
|
return "申请单号与该商户号不匹配(" + (description.isBlank() ? "通道未返回原因" : description)
|
+ "):两个字段必须属于同一笔进件,请到汇付控台核对后重填";
|
}
|
|
/**
|
* 重复纳管命中的是上一次留下的那条记录:不新建,但也不能什么都不做。
|
*
|
* <p>已通过的记录直接回它(再刷一次只可能把通过翻回待办,没有好处);还在办理中的**再跑一次进度
|
* 刷新**,让重复点击真的是一次重试。被通道驳回的记录刷新救不回来({@code supports()} 对 rejected
|
* 返回 false,刷新原样返回),必须明确告诉运营去解绑后重来,而不是报一句「纳管成功」却什么都没发生。
|
*/
|
private Map<String, Object> reapplyExisting(Map<String, Object> existing) {
|
Long applicationId = ((Number) existing.get("id")).longValue();
|
if ("rejected".equals(clean(existing.get("status")))) {
|
throw new BusinessException(ErrorCode.CONFLICT, "该商家纳管的这条进件已被通道驳回("
|
+ clean(existing.get("applicationNo")) + "),不能重复纳管;请先在进件详情里解绑,再重新纳管");
|
}
|
if (!"approved".equals(clean(existing.get("status")))) progressService.refresh(applicationId, false);
|
return progressService.view(applicationId);
|
}
|
|
/**
|
* 解绑:解除我方绑定,清理这条纳管产生的进件记录与结算卡记录(结算卡同时从「银行卡申请」页签消失)。
|
*
|
* <p>**不回退通道侧的任何改动**:微信口径、人才端 AppID 绑定、延迟入账、实名都不是这次纳管改的,
|
* 解绑也不会把它们撤销。这一点界面上必须写明,否则运营会误以为商户已恢复原样。
|
*
|
* <p>只允许解绑纳管产生的记录:自助进件那条记录里是商户自己提交的资料,删掉它是另一个性质的操作,
|
* 不在这张票的范围里。
|
*
|
* <p>「回收商户端收款相关权限」就是这条删除本身:商户端的收款权限没有单独的授权行,它是从进件记录
|
* 派生出来的——{@link MerchantPaymentReadinessService} 找不到已通过的进件就直接判「商户进件尚未审核
|
* 通过,暂时无法收款」。所以记录没了,权限也就没了,不需要再动商户端登录账号(那是登录,不是收款)。
|
*/
|
@Transactional
|
public Map<String, Object> unbind(Long applicationId) {
|
List<Map<String, Object>> applications = rows(
|
"select * from merchant_onboarding_application where id = ? limit 1 for update", applicationId);
|
if (applications.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
|
Map<String, Object> application = applications.get(0);
|
if (!MerchantOnboardingProgressService.isManaged(application)) {
|
throw new BusinessException(ErrorCode.CONFLICT, "这条进件来自商户端自助进件,不支持解绑(解绑只针对后台纳管的外部进件商户)");
|
}
|
String blocker = unbindBlocker(application);
|
if (!blocker.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, blocker);
|
|
Object id = application.get("id");
|
// 进度、尝试记录都是这条进件的从属行,一起清掉;否则重新纳管时旧证据会被新记录误读。
|
jdbc.update("delete from merchant_onboarding_attempt where application_id = ?", id);
|
jdbc.update("delete from merchant_onboarding_progress where application_id = ?", id);
|
// 结算卡按进件上绑的那一张删:纳管只建这一张,其余卡(商户后来自己加过的话)不属于这次绑定。
|
jdbc.update("delete from merchant_bank_card where id = ? and merchant_id = ?",
|
application.get("initialBankCardId"), application.get("merchantId"));
|
jdbc.update("delete from merchant_onboarding_application where id = ?", id);
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
result.put("unbound", true);
|
result.put("merchantId", String.valueOf(application.get("merchantId")));
|
result.put("providerHuifuId", clean(application.get("providerHuifuId")));
|
// 解绑后可重新纳管:商户端登录账号、门店与资料都没动。
|
result.put("message", "已解除我方绑定。通道侧的配置(微信口径、人才端绑定、延迟入账、实名)不会被回退,商户可重新纳管。");
|
return result;
|
}
|
|
/** 纳管完成后跑一次进度刷新;只有它把申请收口成通过时,能力快照才有东西可核实。 */
|
private void refreshCapabilityWhenApproved(Long applicationId) {
|
List<Map<String, Object>> rows = rows("select status from merchant_onboarding_application where id = ? limit 1", applicationId);
|
if (rows.isEmpty() || !"approved".equals(clean(rows.get(0).get("status")))) return;
|
// 能力快照是纯查询(商户详细信息查询),与「只查询不下发」不冲突;它是收款闸门读的那几个字段。
|
readinessService.refreshQuietly(applicationId);
|
}
|
|
/**
|
* 只读核验通道商户:用我们自己的签名身份查一次商户详细信息。
|
*
|
* <p>这个接口同时是「商户注册名称 / 商户简称 / 主体类型」与「结算卡列表 + 绑卡序列号」的来源,
|
* 所以纳管表单一共只有三个字段,卡号从通道查回来、不需要运营填(回执里的卡号是明文,见
|
* {@link MerchantOnboardingProgressService} 里按卡号哈希匹配绑卡的原注释)。
|
*/
|
private Map<String, Object> channelMerchant(String huifuId) {
|
Map<String, Object> detail;
|
try {
|
detail = providerGateway.queryCapabilities(huifuId);
|
} catch (RuntimeException exception) {
|
throw new BusinessException(ErrorCode.CONFLICT, "通道查询失败,暂时无法核验该商户,请稍后重试");
|
}
|
if (detail == null || !"00000000".equals(clean(detail.get("resp_code")))) {
|
String description = detail == null ? "" : clean(detail.get("resp_desc"));
|
throw new BusinessException(ErrorCode.CONFLICT, "通道查不到该商户("
|
+ (description.isBlank() ? "无返回描述" : description)
|
+ "):它不在我们渠道商的管辖范围内,纳管无法进行");
|
}
|
if (clean(detail.get("reg_name")).isBlank()) {
|
throw new BusinessException(ErrorCode.CONFLICT, "通道未返回该商户的注册名称,无法人工核对主体,纳管无法进行");
|
}
|
return detail;
|
}
|
|
/**
|
* 通道的结算卡列表里挑一张能用的卡。
|
*
|
* <p>优先级:挂在结算配置上的默认卡 → 挂在结算配置上的卡 → 任意一张有明文的卡。必须挑到与结算
|
* 配置对得上的那张:分阶段进度的银行卡阶段就是按「卡号哈希 + 绑卡序列号 + 已挂结算配置」核实的,
|
* 挑错了这张卡永远核实不过,进件也就永远收不了口。
|
*/
|
private Map<String, Object> pickSettlementCard(Map<String, Object> channel) {
|
List<String> settleTokens = providerList(channel.get("qry_settle_config_list")).stream()
|
.map(item -> clean(item.get("token_no"))).filter(token -> !token.isBlank()).toList();
|
Map<String, Object> linkedDefault = null;
|
Map<String, Object> linked = null;
|
Map<String, Object> fallback = null;
|
for (Map<String, Object> card : providerList(channel.get("qry_cash_card_info_list"))) {
|
// 通道回执里的卡号是明文;掩码(含 *)不算,否则建出来的卡哈希永远对不上。
|
if (!clean(card.get("card_no")).matches("[0-9]{8,32}")) continue;
|
// N 正常 / C 关闭:已关闭的卡建出来只会让银行卡阶段永远核实不过。
|
if ("C".equalsIgnoreCase(clean(card.get("status")))) continue;
|
if (settleTokens.contains(clean(card.get("token_no")))) {
|
if ("Y".equalsIgnoreCase(clean(card.get("is_settle_default")))) linkedDefault = card;
|
else if (linked == null) linked = card;
|
} else if (fallback == null) fallback = card;
|
}
|
if (linkedDefault != null) return linkedDefault;
|
return linked != null ? linked : fallback;
|
}
|
|
/**
|
* 建结算卡记录,沿用现有的字段级加密与卡号哈希,不新造一套(卡哈希就是进度核实与绑卡去重的依据)。
|
*/
|
private Long insertSettlementCard(MerchantRef merchant, Long storeId, String huifuId,
|
Map<String, Object> card, String cardNumber) {
|
Long merchantId = merchant.id();
|
String cardKey = managedBankCardIdempotencyKey(huifuId);
|
// 卡哈希在 (merchant_id, card_hash) 上唯一,幂等键在 (merchant_id, idempotency_key) 上唯一;
|
// 同一商家重复纳管会先被进件那一侧的幂等拦掉,走不到这里。
|
jdbc.update("""
|
insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, branch_code, city_code,
|
card_tail, card_masked, card_hash, card_no_encrypted, provider_token_no,
|
provider_response_message, idempotency_key, status, reviewed_at)
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', now())
|
""", merchantId, storeId, cardBankName(card, merchant), cardBranchName(card), clean(card.get("branch_code")),
|
clean(card.get("area_id")), cardNumber.substring(cardNumber.length() - 4), maskedCardNumber(cardNumber),
|
sha256(cardNumber), sensitiveDataCrypto.encrypt(cardNumber), clean(card.get("token_no")),
|
"纳管自汇付侧已进件商户,结算卡由通道查询反填", cardKey);
|
return jdbc.queryForObject("select id from merchant_bank_card where merchant_id = ? and idempotency_key = ?",
|
Long.class, merchantId, cardKey);
|
}
|
|
/**
|
* 建进件记录:非空字段(主体类型、主体名、联系人、联系电话、申请单号、幂等键)全部填满,
|
* 通道能反填的用通道的,拿不到的从我们的商家记录取,不留空。
|
*/
|
private Long insertApplication(MerchantRef merchant, Long storeId, String huifuId, String applyNo,
|
String applicationNo, String idempotencyKey, Map<String, Object> channel, Long bankCardId) {
|
Long merchantId = merchant.id();
|
String subjectType = subjectTypeOf(channel);
|
String channelName = clean(channel.get("reg_name"));
|
String merchantName = merchant.name();
|
// 主体名取通道的**商户注册名称**(工商全称):进件列表的「进件主体」列就是它,运营要能一眼
|
// 比对我方商家名与通道主体名,才看得出有没有绑错主体。通道没给才回落到法人姓名/我们的商家名。
|
// 同一份名称也写进 merchant_name / merchant_short_name(详情页的「商户名称 / 商户简称」)——
|
// 对纳管记录来说,「通道主体名」与「通道商户名称」本来就是同一个工商全称。
|
String subjectName = firstNonBlank(channelName, clean(channel.get("legal_name")), merchantName);
|
String contactName = firstNonBlank(clean(channel.get("contact_name")), merchant.contactName(),
|
clean(channel.get("legal_name")), merchantName);
|
String contactMobile = firstNonBlank(clean(channel.get("contact_mobile_no")), merchant.contactMobile(),
|
clean(channel.get("legal_mobile_no")));
|
String legalMobile = firstNonBlank(clean(channel.get("legal_mobile_no")), contactMobile);
|
String idNumber = clean(channel.get("legal_cert_no"));
|
String companyType = "enterprise".equals(subjectType)
|
? firstNonBlank(COMPANY_TYPE_BY_ENT_TYPE.getOrDefault(clean(channel.get("ent_type")), ""), "私营企业")
|
// 个人主体的 company_type 是经营行业(决定 MCC,而 MCC 在自助进件那边才下发),
|
// 纳管不下发,所以这里取我们商家档案上的经营类型,与「行业以系统档案为准」口径一致。
|
: firstNonBlank(merchant.type(), "其它");
|
jdbc.update("""
|
insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type,
|
subject_name, merchant_name, merchant_short_name, company_type, credit_code,
|
subject_id_no_masked, subject_id_no_encrypted, contact_name, contact_mobile, legal_mobile,
|
administrator_email, legal_certificate_address, registered_province_code, registered_city_code,
|
registered_district_code, registered_address, business_address, provider_huifu_id,
|
provider_apply_no, initial_bank_card_id, idempotency_key, source, status)
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
""", merchantId, storeId, applicationNo, subjectType, subjectName,
|
firstNonBlank(channelName, merchantName), clean(channel.get("short_name")), companyType,
|
clean(channel.get("license_code")), maskIdNumber(idNumber),
|
idNumber.isBlank() ? null : sensitiveDataCrypto.encrypt(idNumber), contactName, contactMobile,
|
legalMobile, clean(channel.get("contact_email")), clean(channel.get("legal_addr")),
|
clean(channel.get("reg_prov_id")), clean(channel.get("reg_city_id")), clean(channel.get("reg_district_id")),
|
firstNonBlank(clean(channel.get("reg_addr")), clean(channel.get("reg_detail"))),
|
clean(channel.get("detail_addr")), huifuId, applyNo, bankCardId, idempotencyKey,
|
MerchantOnboardingProgressService.SOURCE_MANAGED);
|
return jdbc.queryForObject("select id from merchant_onboarding_application where application_no = ?", Long.class, applicationNo);
|
}
|
|
/**
|
* 解绑的硬拦截:该绑定上还有在途订单或待结算资金时拒绝并说明。
|
*
|
* <p>平台本地**没有结算账本**(V140 已把提现/结算记录整体退役,钱怎么结是斗拱按周期自动做的),
|
* 所以「待结算资金」只能用订单状态推:已收款且未终态退款的订单,钱还在通道侧(延迟户或基本户)
|
* 等结算,那就是待结算资金。已取消、已退款的订单是终态,不拦。
|
*
|
* <p>范围按订单上记录的收款绑定(V131 的 {@code collect_onboarding_id})划,不按汇付商户号划:
|
* 连锁多店共用同一个商户号,按商户号划会让 A 店的订单把 B 店的解绑也拦死。历史行没有这个字段时
|
* 回落到「同商家同门店」。
|
*/
|
private String unbindBlocker(Map<String, Object> application) {
|
List<Map<String, Object>> counts = rows("""
|
select
|
sum(case when o.order_status = 0 then 1 else 0 end) as unpaid,
|
sum(case when o.order_status = 1 then 1 else 0 end) as unsettled
|
from benefit_order o
|
where o.deleted = 0 and o.pay_amount > 0 and o.order_status in (0, 1) and o.refund_status <> 2
|
and (o.collect_onboarding_id = ?
|
or (o.collect_onboarding_id is null and o.merchant_id = ? and o.store_id = ?))
|
""", application.get("id"), application.get("merchantId"), application.get("storeId"));
|
long unpaid = count(counts, "unpaid");
|
long unsettled = count(counts, "unsettled");
|
if (unsettled > 0) return "该商家名下还有 " + unsettled + " 笔已收款未结算的订单,解绑会切断正在进行的对账,请等资金结算完成后再解绑";
|
if (unpaid > 0) return "该商家名下还有 " + unpaid + " 笔在途订单(待支付),请等这些订单关闭后再解绑";
|
return "";
|
}
|
|
/**
|
* 该门店是否已经有一条进行中或已通过的进件。
|
*
|
* <p>这与「重复纳管同一商家幂等」是两件事:幂等防的是重复点击,这里防的是同一门店出现两个收款
|
* 主体——两条都会在下单时打架(下单闸门取的是最新那条已通过的进件)。
|
*/
|
private String activeConflict(Long merchantId, Long storeId) {
|
Map<String, Object> active = activeApplication(merchantId, storeId);
|
return active == null ? "" : conflictMessage(active);
|
}
|
|
/** 该门店当前进行中或已通过的进件;没有则返回 null。 */
|
private Map<String, Object> activeApplication(Long merchantId, Long storeId) {
|
// 只看「有结算卡」的那类进件:进度只接管已关联结算卡的申请,没有卡就永远收不了口、也永远过不了
|
// 下单闸门,因此它不构成第二个收款主体。把它当成硬拦只会把运营卡死——那种记录连解绑都做不了
|
// (解绑只面向纳管记录)。判定与收款闸门、进度刷新读的是同一个条件。
|
List<Map<String, Object>> active = rows("""
|
select application_no, status, source, provider_huifu_id from merchant_onboarding_application
|
where merchant_id = ? and store_id = ? and initial_bank_card_id is not null
|
and status in ('pending', 'approved')
|
order by id desc limit 1
|
""", merchantId, storeId);
|
return active.isEmpty() ? null : active.get(0);
|
}
|
|
private String conflictMessage(Map<String, Object> active) {
|
String origin = MerchantOnboardingProgressService.isManaged(active) ? "纳管的" : "自助进件的";
|
return "该门店已有一条" + ("approved".equals(clean(active.get("status"))) ? "已通过" : "进行中")
|
+ "的" + origin + "进件(" + clean(active.get("applicationNo")) + "),请先处理它或解绑,避免同一门店出现两个收款主体";
|
}
|
|
/** 该汇付商户号已经绑在哪些其他商家上。只提示不拦截,所以要连商家名带门店名一起列出来。 */
|
private List<String> boundMerchantNames(String huifuId, Long exceptMerchantId) {
|
return rows("""
|
select m.merchant_name, s.store_name from merchant_onboarding_application a
|
join merchant m on m.id = a.merchant_id
|
left join merchant_store s on s.id = a.store_id
|
where a.provider_huifu_id = ? and a.merchant_id <> ? and a.status in ('pending', 'approved')
|
order by a.id
|
""", huifuId, exceptMerchantId).stream()
|
.map(row -> displayName(clean(row.get("merchantName")), clean(row.get("storeName"))))
|
.distinct().toList();
|
}
|
|
/**
|
* 「商家名(门店名)」的展示形式。门店名与商家名一样时不再重复一遍——多数门店名就是商家名,
|
* 加上去只是把同一句话印两遍(列表里看起来像「某某商户(某某商户)」)。
|
*/
|
private static String displayName(String merchantName, String storeName) {
|
return storeName.isBlank() || storeName.equals(merchantName)
|
? merchantName : merchantName + "(" + storeName + ")";
|
}
|
|
/**
|
* 通道侧两个名称与所选商家名**连一个字符都不重叠**时的警示。
|
*
|
* <p>刻意不做「名称是否对得上」的判定:形态不对应(工商全称 vs 品牌简称),规则化匹配必然误判。
|
* 只把「完全不像同一个主体」这个确定性事实说出来,既不硬拒也不错放。
|
*/
|
static String nameWarning(String ourName, String channelName, String channelShortName) {
|
if (ourName.isBlank()) return "";
|
if (!sharesNoCharacter(ourName, channelName) || !sharesNoCharacter(ourName, channelShortName)) return "";
|
return "通道侧名称(" + channelName + " / " + channelShortName + ")与所选商家名(" + ourName
|
+ ")没有任何相同字符,请确认选对了商家;确实可能是简称、别名或服务点名。";
|
}
|
|
/** 商家名里每个非空白字符都不在通道名称里出现,即「连一个字符都不重叠」。 */
|
private static boolean sharesNoCharacter(String ourName, String channelName) {
|
if (channelName.isBlank()) return false;
|
for (char character : ourName.trim().toCharArray()) {
|
if (!Character.isWhitespace(character) && channelName.indexOf(character) >= 0) return false;
|
}
|
return true;
|
}
|
|
/**
|
* 纳管不建商户端登录账号(账号由「商家查询」的商家编辑负责),但商户登不进来要让运营知道。
|
*/
|
private String loginNotice(MerchantRef merchant) {
|
if (!merchant.loginEnabled()) {
|
return "该商家未开启商户端登录,纳管不会为它创建登录账号;商户要自己看收款状态,请先到「商家查询」开启商户端登录并设置账号。";
|
}
|
Integer accounts = jdbc.queryForObject(
|
"select count(*) from merchant_operator where merchant_id = ? and status = 1 and deleted = 0",
|
Integer.class, merchant.id());
|
return accounts == null || accounts == 0
|
? "该商家还没有商户端登录账号,纳管不会为它创建;请到「商家查询」为该商家编辑并设置登录账号,否则商户登不进来。"
|
: "";
|
}
|
|
private MerchantRef requireMerchant(Long merchantId) {
|
if (merchantId == null) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择我方商家");
|
List<Map<String, Object>> merchants = rows(
|
"select id, merchant_name, merchant_type, contact_name, contact_mobile, can_login_merchant_app from merchant where id = ? and deleted = 0 limit 1",
|
merchantId);
|
if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商家不存在");
|
Map<String, Object> row = merchants.get(0);
|
return new MerchantRef(((Number) row.get("id")).longValue(), clean(row.get("merchantName")),
|
clean(row.get("merchantType")), clean(row.get("contactName")), clean(row.get("contactMobile")),
|
"1".equals(clean(row.get("canLoginMerchantApp"))));
|
}
|
|
/**
|
* 我方商家在纳管里用到的字段。
|
*
|
* <p>存在的理由不只是省几个参数:库行经 {@link DatabaseRows#normalize} 之后键是 **camelCase**,而通道
|
* 回执的键是 snake_case,两种 map 用同一个 {@code get()} 取,很容易把列名写成 snake_case 去读——那会
|
* 静默取到 null(`clean(null)` 是空串),表现为「登录明明开着却说没开」「联系人莫名回落成别的字段」。
|
* 列名只在这一处读一次,其余地方一律走这个 record,就没有再写错键的机会。
|
*/
|
private record MerchantRef(long id, String name, String type, String contactName, String contactMobile,
|
boolean loginEnabled) { }
|
|
private String requireHuifuId(String huifuId) {
|
String value = clean(huifuId);
|
if (value.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写汇付商户号");
|
return value;
|
}
|
|
/**
|
* 纳管的门店:进件记录的 store_id 非空,而纳管表单只选商家;取该商家最早的那个可用门店。
|
*
|
* <p>连锁按「每个门店建成一个独立商家」实现(不做「一个商家挂多个门店」),所以一个商家对应一个
|
* 收款主体,取哪个门店都不改变业务含义;取最早那个是为了让重复纳管派生出的键稳定。
|
*/
|
private Long primaryStore(Long merchantId) {
|
List<Map<String, Object>> stores = rows(
|
"select id from merchant_store where merchant_id = ? and status = 1 and deleted = 0 order by id asc limit 1",
|
merchantId);
|
if (stores.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "该商家没有可用门店,无法纳管");
|
return ((Number) stores.get(0).get("id")).longValue();
|
}
|
|
private String storeName(Long storeId) {
|
List<Map<String, Object>> stores = rows("select store_name from merchant_store where id = ? limit 1", storeId);
|
return stores.isEmpty() ? "" : clean(stores.get(0).get("storeName"));
|
}
|
|
/**
|
* 由汇付商户号派生进件键,使重复纳管稳定幂等。
|
*
|
* <p>{@code application_no} 是全局唯一键、{@code idempotency_key} 在 (merchant_id, key) 上唯一
|
* (见 V80)。商户号可以合法地被多个商家共用,所以门店号必须进 application_no,否则第二个商家
|
* 纳管同一个商户号时会撞唯一键。
|
*/
|
static String managedApplicationNo(String huifuId, Long storeId) {
|
return "MG" + huifuId + "-" + storeId;
|
}
|
|
static String managedIdempotencyKey(String huifuId) {
|
return "managed:" + huifuId;
|
}
|
|
static String managedBankCardIdempotencyKey(String huifuId) {
|
return "managed-card:" + huifuId;
|
}
|
|
/** 通道的 {@code cust_type}:1 企业 / 2 个人。只认 1 是企业,取值缺失按个人处理只会造出错误的主体类型。 */
|
private static String subjectTypeOf(Map<String, Object> channel) {
|
return "1".equals(clean(channel.get("cust_type"))) ? "enterprise" : "individual";
|
}
|
|
private String cardBankName(Map<String, Object> card, MerchantRef merchant) {
|
return firstNonBlank(clean(card.get("bank_name")), merchant.name());
|
}
|
|
/** 对私结算的支行名不一定有值,但该列非空:拿不到就回落总行名,不硬造。 */
|
private String cardBranchName(Map<String, Object> card) {
|
return firstNonBlank(clean(card.get("branch_name")), clean(card.get("bank_name")));
|
}
|
|
private static String maskedCardNumber(String cardNumber) {
|
return "**** **** **** " + cardNumber.substring(cardNumber.length() - 4);
|
}
|
|
private static String maskIdNumber(String idNumber) {
|
return idNumber.length() < 8 ? "" : idNumber.substring(0, 3) + "********" + idNumber.substring(idNumber.length() - 4);
|
}
|
|
private static long count(List<Map<String, Object>> rows, String column) {
|
if (rows.isEmpty()) return 0L;
|
Object value = rows.get(0).get(column);
|
return value instanceof Number number ? number.longValue() : 0L;
|
}
|
|
private static String firstNonBlank(String... values) {
|
for (String value : values) if (value != null && !value.isBlank()) return value.trim();
|
return "";
|
}
|
|
private static String sha256(String value) {
|
try {
|
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
} catch (java.security.NoSuchAlgorithmException exception) {
|
throw new IllegalStateException("Unable to hash settlement card", exception);
|
}
|
}
|
|
/** 通道返回的列表字段可能是 JSON 字符串也可能是已解析的数组,两种都要能吃。 */
|
@SuppressWarnings("unchecked")
|
private static List<Map<String, Object>> providerList(Object raw) {
|
if (raw == null) return List.of();
|
Object parsed = raw instanceof String value ? (value.isBlank() ? null : com.alibaba.fastjson.JSON.parse(value)) : raw;
|
if (!(parsed instanceof List<?> list)) return List.of();
|
return list.stream().filter(Map.class::isInstance).map(item -> (Map<String, Object>) item).toList();
|
}
|
|
private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
|
|
private static String clean(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
|
}
|