| | |
| | | * |
| | | * <p>查不到就明确拒绝——那说明该商户不在我们**渠道商**的管辖范围内,纳管进行不下去。注意把商户 |
| | | * 改成挂在平台自己名下既解决不了权限失败、还会平白多出材料要求,不是「迁一下归属就能解决」的事。 |
| | | * |
| | | * <p>已经填了入驻申请单号的话,顺带把 `(商户号, 申请单号)` 配一次对(见 {@link #applyNoMismatch}): |
| | | * 错号在提交时才被拒,运营会白填一屏还看不出哪一项有问题。 |
| | | */ |
| | | public Map<String, Object> preview(Long merchantId, String huifuId) { |
| | | Map<String, Object> merchant = requireMerchant(merchantId); |
| | | 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.get("id"))); |
| | | view.put("merchantName", clean(merchant.get("merchantName"))); |
| | | 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("settlementBankName", card == null ? "" : clean(card.get("bank_name"))); |
| | | // 只提示不拦截:连锁各门店共用同一个收款主体是本次有意支持的做法,提示里列出已绑的商家即可。 |
| | | view.put("boundMerchants", boundMerchantNames(providerHuifuId, merchantId)); |
| | | view.put("nameWarning", nameWarning(clean(merchant.get("merchantName")), |
| | | view.put("nameWarning", nameWarning(merchant.name(), |
| | | clean(channel.get("reg_name")), clean(channel.get("short_name")))); |
| | | // 纳管只管收款绑定,不创建商户端登录账号;但商户登不进来要让运营知道。 |
| | | view.put("loginNotice", loginNotice(merchantId, merchant)); |
| | | 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); |
| | |
| | | * 而通道查询本来也不该夹在持有行锁的事务里。 |
| | | */ |
| | | public Map<String, Object> adopt(Long merchantId, String huifuId, String applyNo, boolean confirmed) { |
| | | Map<String, Object> merchant = requireMerchant(merchantId); |
| | | MerchantRef merchant = requireMerchant(merchantId); |
| | | String providerHuifuId = requireHuifuId(huifuId); |
| | | String providerApplyNo = clean(applyNo); |
| | | if (providerApplyNo.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写汇付侧的入驻申请单号:没有它,通道的申请单状态查询(该接口 apply_no 必填)无法推进,进度会永远停在未确认"); |
| | |
| | | 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")); |
| | |
| | | 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) |
| | | + "):两个字段必须属于同一笔进件,请到汇付控台核对后重填"; |
| | | } |
| | | |
| | | /** |
| | |
| | | /** |
| | | * 建结算卡记录,沿用现有的字段级加密与卡号哈希,不新造一套(卡哈希就是进度核实与绑卡去重的依据)。 |
| | | */ |
| | | private Long insertSettlementCard(Map<String, Object> merchant, Long storeId, String huifuId, |
| | | private Long insertSettlementCard(MerchantRef merchant, Long storeId, String huifuId, |
| | | Map<String, Object> card, String cardNumber) { |
| | | Long merchantId = ((Number) merchant.get("id")).longValue(); |
| | | Long merchantId = merchant.id(); |
| | | String cardKey = managedBankCardIdempotencyKey(huifuId); |
| | | // 卡哈希在 (merchant_id, card_hash) 上唯一,幂等键在 (merchant_id, idempotency_key) 上唯一; |
| | | // 同一商家重复纳管会先被进件那一侧的幂等拦掉,走不到这里。 |
| | |
| | | * 建进件记录:非空字段(主体类型、主体名、联系人、联系电话、申请单号、幂等键)全部填满, |
| | | * 通道能反填的用通道的,拿不到的从我们的商家记录取,不留空。 |
| | | */ |
| | | private Long insertApplication(Map<String, Object> merchant, Long storeId, String huifuId, String applyNo, |
| | | private Long insertApplication(MerchantRef merchant, Long storeId, String huifuId, String applyNo, |
| | | String applicationNo, String idempotencyKey, Map<String, Object> channel, Long bankCardId) { |
| | | Long merchantId = ((Number) merchant.get("id")).longValue(); |
| | | String subjectType = "1".equals(clean(channel.get("cust_type"))) ? "enterprise" : "individual"; |
| | | Long merchantId = merchant.id(); |
| | | String subjectType = subjectTypeOf(channel); |
| | | String channelName = clean(channel.get("reg_name")); |
| | | String merchantName = clean(merchant.get("merchantName")); |
| | | 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")), clean(merchant.get("contact_name")), |
| | | 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")), clean(merchant.get("contact_mobile")), |
| | | 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")); |
| | |
| | | ? firstNonBlank(COMPANY_TYPE_BY_ENT_TYPE.getOrDefault(clean(channel.get("ent_type")), ""), "私营企业") |
| | | // 个人主体的 company_type 是经营行业(决定 MCC,而 MCC 在自助进件那边才下发), |
| | | // 纳管不下发,所以这里取我们商家档案上的经营类型,与「行业以系统档案为准」口径一致。 |
| | | : firstNonBlank(clean(merchant.get("merchant_type")), "其它"); |
| | | : 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, |
| | |
| | | /** |
| | | * 纳管不建商户端登录账号(账号由「商家查询」的商家编辑负责),但商户登不进来要让运营知道。 |
| | | */ |
| | | private String loginNotice(Long merchantId, Map<String, Object> merchant) { |
| | | if (!"1".equals(clean(merchant.get("can_login_merchant_app")))) { |
| | | 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, merchantId); |
| | | Integer.class, merchant.id()); |
| | | return accounts == null || accounts == 0 |
| | | ? "该商家还没有商户端登录账号,纳管不会为它创建;请到「商家查询」为该商家编辑并设置登录账号,否则商户登不进来。" |
| | | : ""; |
| | | } |
| | | |
| | | private Map<String, Object> requireMerchant(Long merchantId) { |
| | | 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, "商家不存在"); |
| | | return merchants.get(0); |
| | | 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); |
| | |
| | | return "1".equals(clean(channel.get("cust_type"))) ? "enterprise" : "individual"; |
| | | } |
| | | |
| | | private String cardBankName(Map<String, Object> card, Map<String, Object> merchant) { |
| | | return firstNonBlank(clean(card.get("bank_name")), clean(merchant.get("merchantName"))); |
| | | private String cardBankName(Map<String, Object> card, MerchantRef merchant) { |
| | | return firstNonBlank(clean(card.get("bank_name")), merchant.name()); |
| | | } |
| | | |
| | | /** 对私结算的支行名不一定有值,但该列非空:拿不到就回落总行名,不硬造。 */ |