From d8eaa16682cf180e9563d6be05fb43057daf6c07 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 12 十月 2026 10:34:14 +0800
Subject: [PATCH] feat(merchant): 后台纳管外部进件商户并支持解绑
---
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java | 14
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java | 5
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java | 3
talent-card-server/src/main/resources/db/migration/V157__merchant_onboarding_source.sql | 25
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java | 17
docs/adr/0006-managed-onboarding-is-query-only.md | 50 +
CONTEXT.md | 13
talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java | 500 +++++++++++++++
talent-card-admin/src/views/MerchantOnboardingDetailView.vue | 57 +
.scratch/merchant-external-onboarding/issues/01-external-merchant-onboarding-adoption.md | 65 ++
.scratch/merchant-external-onboarding/spec.md | 216 ++++++
talent-card-admin/src/api/admin.ts | 28
docs/agents/testing.md | 19
talent-card-server/src/test/resources/application-test.yml | 9
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingRequest.java | 17
talent-card-admin/src/views/MerchantSettlementView.vue | 258 ++++++++
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java | 569 +++++++++++++++++
17 files changed, 1,857 insertions(+), 8 deletions(-)
diff --git a/.scratch/merchant-external-onboarding/issues/01-external-merchant-onboarding-adoption.md b/.scratch/merchant-external-onboarding/issues/01-external-merchant-onboarding-adoption.md
new file mode 100644
index 0000000..3f064dd
--- /dev/null
+++ b/.scratch/merchant-external-onboarding/issues/01-external-merchant-onboarding-adoption.md
@@ -0,0 +1,65 @@
+# 01 — 外部进件商户的纳管(含解绑)
+
+**What to build:** 平台运营在后台把一个**已在汇付侧完成进件**的商户接到我方某个商家上,使它能在我们商户端直接使用并收款;绑定错了能解绑重来。
+
+后台入口:在「财务管理 → 结算记录 → 商户进件」页签的筛选栏放一个纳管按钮,点开是对话框——选我方商家 + 填入汇付商户号 + 入驻申请单号(结算卡号不用填,从通道查回来)。提交后该商家立刻出现在进件列表里,进度收敛后即可收款。
+
+同一个汇付商户号可以被多个商家分别纳管(连锁各门店共用一个收款主体),入口只提示已绑的商家、不拦截。
+
+解绑入口在「商户进件详情」页,与既有的刷新进度 / 重试失败步骤 / 查询通道状态 / 发起微信实名并列,作为危险操作。
+
+**Blocked by:** None — can start immediately
+
+**Status:** ready-for-agent
+
+## 交付内容
+
+**纳管入口(后台)**
+- [ ] 「商户进件」页签出现纳管按钮,点开对话框可选我方商家、填汇付商户号与入驻申请单号;提交后列表可见该进件记录,并可直接进入详情。
+- [ ] 纳管前用我们自己的签名身份只读查询一次;**查不到该商户时明确拒绝**并给出原因。
+- [ ] 对话框展示通道返回的**商户注册名称**与**商户简称**(以及主体类型:企业/个人),由运营与所选商家核对并**显式勾选确认**后才能提交。
+- [ ] **不用字符串比较判定名称是否「对得上」**:我们这边只有一个商家名,通道那边是工商全称加简称,形态不对应,规则化匹配必然误判——误拒会把运营卡死且无路可绕,误放等于没设防。
+- [ ] 通道侧的两个名称与所选商家名**连一个字符都不重叠**时给出醒目警示,但**不硬拒**(可能是简称、别名或服务点名)。
+- [ ] 把通道侧的主体名写进件记录,使进件列表能同时看到我方商家名与通道主体名,便于事后核对与追责。
+- [ ] 该汇付商户号已经被其他商家绑定时**只提示不拦截**,提示里列出已绑的商家。
+- [ ] 该商家/门店**已经有一条进行中或已通过的进件**时拒绝并说明(避免同一门店出现两个收款主体)。
+- [ ] 重复纳管同一个商家是幂等的,不会产生第二条进件记录。
+- [ ] 纳管同时为该商家建立结算卡记录;卡号与绑卡序列号从通道的商户详细信息查询取回,沿用现有的字段级加密与卡号哈希,不新造一套。
+- [ ] 进件记录的非空字段(主体类型、主体名、联系人、联系电话等)由通道查询反填,拿不到的从我们的商家记录取,不留空。
+- [ ] 进件记录上标明来源为「纳管」,与自助进件区分。
+- [ ] 纳管**全程只查询、不下发**:不调用任何补开、绑定、改口径类的下发接口。
+- [ ] 纳管入口不创建商户端登录账号;若该商家还没有登录账号或未开启商户端登录,给出提示。
+
+**纳管之后的收敛**
+- [ ] 纳管触发一次进度刷新,分阶段进度与收款能力快照在**既有模型**内落库,不新造并行链路。
+- [ ] 进度刷新在只有汇付侧入驻申请单号(没有我方发起的申请单号)时仍能推进,不会永远停在未确认。
+- [ ] 收敛后该商家能通过下单闸门收款(与自助进件走完全相同的收款就绪口径)。
+- [ ] 不新增任何进件定时任务或页面自动轮询。
+- [ ] 商户端登录后能看到与下单闸门一致的「能否收款 + 原因 + 最近核实时间」,以及待办理项(含实名二维码)。
+- [ ] 通道查询失败时保留上一次的进度,不显示为失败。
+
+**解绑**
+- [ ] 进件详情页出现解绑(危险操作,二次确认)。
+- [ ] 该商户号上还有在途订单或待结算资金时拒绝解绑并说明原因。
+- [ ] 解绑清理进件记录与结算卡记录,结算卡同时从「银行卡申请」页签消失;解绑后页面回到列表。
+- [ ] 解绑只解除我方绑定,界面明确说明通道侧的改动不会被回退。
+- [ ] 解绑不动商户端登录账号;解绑后可以重新纳管。
+
+**边界与兼容**
+- [ ] 迁移只新增一列(来源标记),编号取 `V157`,可重复执行。
+- [ ] 纳管与解绑的接口放在既有的进件查改前缀之下,沿用现有的商户结算权限码,不新增权限码、不改共享的权限映射。
+- [ ] 不新增前端路由。
+- [ ] 自助进件的既有流程与存量商户行为不受影响。
+
+**测试与验收**
+- [ ] 接口级测试覆盖:纳管成功、通道查不到、主体名不符、已被其他商家绑定(提示但放行)、重复纳管幂等、该门店已有进行中/已通过进件的冲突、解绑、解绑的在途资金拦截;使用真实测试库与模拟汇付响应,**不创建真实汇付商户**。
+- [ ] 支付链路全量测试通过(仓库既有基线失败除外)。
+- [ ] 拿**一个**真实的外部进件商户端到端走通:后台纳管 → 进度收敛到收款就绪 → 商户端能登录并看到已就绪 → 真实收一笔。
+
+## Further Notes
+
+- 本票对应规格 [spec.md](../spec.md)。规格里 31 条用户故事与本票的交付内容一一对应,实现细节以规格的 Implementation Decisions 为准。
+- 接口级测试沿用现有进件链路的同一条接缝(接口 + 真实测试库 + 模拟汇付响应),不需要新接缝。
+- 关于二清:能共用一个汇付商户号本身就意味着只有一个主体,共用不引入资金二次清算;真正的风险是**绑错主体**,靠纳管时的**主体名人工核对 + 显式确认 + 留痕 + 可解绑**拦截,而不是一个字符串比较。
+- 纳管的第一步是只读核验通道权限:查不到说明该商户不在我们**渠道商**的管辖范围内。按领域上下文,数据权限由签名身份决定,**把商户改成挂在平台自己名下既解决不了权限失败、还会平白多出材料要求**,不要把它当成「迁一下归属就能解决」的事。
+- 交付顺序上建议先做纳管、再做解绑(解绑依赖纳管产生的记录),但同属一张票。
diff --git a/.scratch/merchant-external-onboarding/spec.md b/.scratch/merchant-external-onboarding/spec.md
new file mode 100644
index 0000000..e473d79
--- /dev/null
+++ b/.scratch/merchant-external-onboarding/spec.md
@@ -0,0 +1,216 @@
+# 汇付侧已进件商户的纳管(两条进件路径并存)
+
+Status: ready-for-agent
+更新日期:2026-10-09
+规格来源:本会话已确认的业务决策,以及对进件、分阶段进度、收款闸门与汇付接口文档的只读核查。
+测试边界:以接口为主入口、真实测试库与模拟汇付响应;自动化测试不得创建真实汇付商户。
+
+**术语**:本规格把「商户在我们商户端自助进件」称为**自助进件**,把「商户已在汇付侧完成进件、
+需要接进我们系统」称为**外部进件**。两者是同一件事的两条入口,最终收敛到同一套进件记录、
+分阶段进度与**收款就绪**判定。
+
+## Problem Statement
+
+项目上线后商户有两条进件路径:一条是在我们商户端自助进件(已有能力),另一条是商户直接在汇付侧
+完成进件。第二条路径目前没有任何纳管手段——这些商户在汇付侧已经进件成功、微信支付业务也已开通,
+但我们系统里没有它的进件记录,于是:
+
+- **收款被整体拦住。** 下单闸门要求该商家有一条已通过的进件,外部进件商户永远没有,报「商户进件
+ 尚未审核通过,暂时无法收款」。
+- **商户端看不到任何有意义的收款状态。** 进件页、收款能力页依赖同一条记录,没有记录就只能显示未提交。
+- **运营只能靠手工操作**,且没有任何入口把汇付侧的商户号接到我们的商家上。
+
+同时存在连锁场景:一个商家主体下有几个门店,希望共用一个收款主体(同一个汇付商户号),
+而不是每个门店各自进一次件。本次确认的做法是**每个门店建成一个独立商家**,共用同一个汇付商户号,
+不做「一个商家挂多个门店」的模型。
+
+## Solution
+
+后台提供一个**纳管**入口:运营选定我方商家、填入汇付商户号与入驻申请单号,平台先做只读核验,
+通过后为该商家建立结算卡记录与进件记录,并触发一次进度刷新,使分阶段进度与收款就绪在**现有模型内**
+收敛。连锁的其余门店用同一入口各纳管一次、指向同一个汇付商户号即可,不需要额外机制。
+
+同时提供**解绑**,作为挂错商户时的补救。解绑只解除我们这边的绑定,不回退通道侧的改动
+(微信口径、人才端 AppID 绑定、延迟入账、实名都不是我们这次改的,也不会被解绑撤销)。
+
+纳管全程**只查询、不下发**:不在纳管里补开微信、绑 AppID 或改口径。因为下发类操作不幂等——
+改支付场景会重建微信子商户号——而共用同一个汇付商户号的多个商家各自下发会造成重复下发。
+外部进件商户的口径已由业务要求保持一致,纳管只需要核实,不需要改动。
+
+## User Stories
+
+1. 作为平台运营人员,我希望在后台用一个入口把一个汇付侧已进件的商户接到我们的商家上,以便外部进件商户能在我方系统里收款。
+2. 作为平台运营人员,我希望纳管时只需选定我方商家并填入汇付商户号与入驻申请单号,以便不必理解通道内部的字段结构。
+3. 作为平台运营人员,我希望填错或不属于我们管辖的商户号在纳管时就被明确拒绝,以便不会挂到一个我们无权操作的商户上。
+4. 作为平台运营人员,我希望纳管前看到通道侧返回的商户注册名称与商户简称,并能显式确认后才提交,以便与我们的商家名称核对,避免挂错主体。
+5. 作为平台运营人员,我希望这个商户号已经绑给其他商家时得到提示而不是被拒绝,以便支持连锁多店共用同一个收款主体。
+6. 作为平台运营人员,我希望同一入口对连锁的每个门店各执行一次就能共用同一个收款主体,以便不需要新机制。
+7. 作为平台运营人员,我希望纳管完成后能立刻看到分阶段进度和收款就绪,以便知道还缺什么。
+8. 作为平台运营人员,我希望纳管失败(通道查不到、核验不通过、写入冲突)时得到具体原因,以便判断是资料问题还是通道权限问题。
+9. 作为平台运营人员,我希望重复纳管同一个商家得到幂等结果而不是产生第二条进件记录,以便重复点击不会制造重复数据。
+10. 作为平台运营人员,我希望把挂错的商户解绑,以便纠正错误绑定。
+11. 作为平台运营人员,我希望解绑前被告知该商户上是否还有在途订单或待结算的资金,以便不会切断正在进行的对账。
+12. 作为平台运营人员,我希望解绑只解除我方绑定并明确告知通道侧不会被回退,以便不会误以为商户已恢复原样。
+13. 作为平台运营人员,我希望解绑是可逆的(可以重新纳管),以便误操作后能恢复。
+14. 作为平台运营人员,我希望外部进件商户的进件记录上标明它来自纳管,以便区分两条进件路径。
+15. 作为平台运营人员,我希望纳管不自动下发任何通道改动,以便不会因重复下发重建微信子商户号。
+16. 作为平台运营人员,我希望外部进件商户没有我方发起的申请单号时进度刷新仍能推进,以便不会永远停在未确认。
+17. 作为外部进件商户,我希望登录商户端后能看到自己的收款状态和待办,以便知道现在能不能收款。
+18. 作为外部进件商户,我希望首次纳管时若还没有商户端账号则被同时建好,以便能直接登录。
+19. 作为外部进件商户,我希望已有商户端账号时纳管不覆盖我的密码,以便不会忽然登不上去。
+20. 作为外部进件商户,我希望看到与下单闸门一致的「能否收款 + 原因 + 最近核实时间」,以便判断问题在自己还是在平台。
+21. 作为外部进件商户,我希望点击「刷新进度」和「立即核实」推进状态,以便本地收不到回调时也能收敛。
+22. 作为外部进件商户,我希望缺少实名或授权时看到办理入口(二维码),以便本人扫码完成。
+23. 作为外部进件商户,我希望通道查询失败时保留上一次的进度而不是显示成失败,以便不会误以为进件出问题。
+24. 作为连锁商户,我希望几个门店各自是一个独立店铺、共用同一个收款主体,以便门店各自经营但资金进一个主体。
+25. 作为连锁商户的门店,我希望自己的订单、核销与门店维度都对得上,以便和共用同一收款主体的其他门店区分开。
+26. 作为平台,我希望纳管前置校验商户是否落在我们**渠道商**的管辖范围内,以便不会对一个我们签名身份查不到的商户建记录。
+27. 作为平台,我希望纳管复用既有的分阶段进度、能力快照与收款闸门判定,以便外部进件商户和自助进件商户走完全相同的收款就绪口径。
+28. 作为平台,我希望纳管不新增任何定时任务,以便维持「进件进度由人工刷新推进」的既有取舍。
+29. 作为平台,我希望纳管只在后台提供,不开放商户端自助认领,以便不会出现「填上别人的商户号就能挂靠」的冒领。
+30. 作为平台,我希望纳管不影响自助进件的既有流程与存量商户,以便两条路径并存而不互相干扰。
+31. 作为平台,我希望共用一个收款主体时,对账仍能按订单上的门店归属分摊到各商家,以便资金能说清是哪家门店的。
+
+## Implementation Decisions
+
+**范围与形状**
+
+- 只做**后台纳管入口 + 解绑**,外加支撑它们所需的最小改动。共用同一个汇付商户号本身不需要新机制:
+ 现有表对 `provider_huifu_id` 没有唯一约束,也没有任何查重,多个商家指向同一个号本来就被允许。
+- **连锁按「每个门店建成一个独立商家」实现**,不做「一个商家挂多个门店」。后者需要新做「给商家加门店」
+ 的入口与商户端按门店进件,本次不做。
+- 复用既有的进件记录、分阶段进度(六阶段)、**收款能力**快照与**收款就绪**判定,不新造状态、不新造并行链路。
+
+**新写入口与来源标记**
+
+- `provider_huifu_id` 目前的三个写入口全部来自汇付回执(进件结果与绑卡回调)。纳管必须**新增一个写入口**。
+- 进件记录新增一列标明来源(自助进件 / 纳管),用于进度刷新区分判定依据。迁移编号取 **V157**
+ (当前已提交最大为 V156,动手前确认无人占号)。
+- 进件记录的 `application_no` 与 `idempotency_key` 都是唯一键且非空,纳管生成的键必须可稳定复现
+ (由汇付商户号派生),使重复纳管幂等。
+
+**结算卡是硬前提**
+
+- 分阶段进度只接管「已关联结算银行卡且未被整体拒绝」的申请;下单闸门另行要求该商家存在一张已通过的
+ 结算卡。因此纳管**必须同时建立结算卡记录**,否则进度不会推进、闸门也不会放行。
+- 卡号从通道的商户详细信息查询取回即可(该接口按 `huifu_id` 查询,返回结算卡列表与绑卡序列号)。
+ **已确认能拿到明文卡号**:现有的结算卡核实就是拿该查询返回的 `qry_cash_card_info_list[].card_no`
+ 与本地卡号哈希做匹配的,且校验为 8-32 位纯数字(掩码含 `*` 会直接不通过),代码注释亦写明
+ 「通道回执里的卡号是明文」。因此纳管表单**不需要**增加卡号输入。
+- 结算卡记录沿用现有的字段级加密与卡号哈希算法,不得为此新造一套。
+
+**进度刷新要能吃下「没有我方申请单号」的进件**
+
+- 现有刷新流程的第一步是按 `huifu_id` + `apply_no` 调申请单状态查询,且要求受理成功才继续。而
+ `apply_no` 在该接口的请求中是必填项,外部进件商户没有我方发起过的申请单号,这一步会直接失败,
+ 整段刷新只写一条同步错误就跳过——基础审核阶段永远无法确认,进件状态永远进不了通过。
+- 做法:**纳管时把汇付侧那个入驻申请单号一起录入**(控台的入驻申请列表里可见,运营能取到)。该字段
+ **必填**,刷新流程因此无需改动即可跑通。
+- **不做**不依赖申请单号的备用判定分支:取不到申请单号的商户不予纳管,提示运营去控台取,而不是为它
+ 另开一条并行的判定路径。
+
+**防绑错:主体名只做人工核对,不做程序判定**
+
+- 通道的商户详细信息查询返回**商户注册名称**(要求与证照一致,即工商全称)与**商户简称**两个名字;
+ 而我们这边 `merchant` 只有 `merchant_name` 一个名字,且往往是服务点名或品牌简称,与通道的全称形态
+ 不对应。因此**不能用字符串比较判定「对得上」**——误拒会把运营卡死且无路可绕,误放等于没设防。
+- 程序只判确定性的事:用我们的签名身份查得到该商户(查不到即拒)。
+- 名称改为**展示 + 显式确认**:纳管对话框列出通道返回的商户注册名称、商户简称与主体类型(企业/个人),
+ 由运营与所选商家核对并显式勾选确认后才能提交。
+- 两个名称与所选商家名**连一个字符都不重叠**时给出醒目警示,但不硬拒(可能是简称、别名或服务点名)。
+- 通道侧的主体名写入进件记录(进件记录本来就有主体名字段),使进件列表能同时看到我方商家名与通道主体名。
+
+**纳管记录本身要填什么、以及冲突处理**
+
+- 进件记录里有几个非空字段(主体类型、主体名、联系人、联系电话、申请单号、幂等键)。纳管时这些
+ **从通道的商户详细信息查询反填**(该接口按 `huifu_id` 返回客户类型与主体资料),拿不到的(如联系人)
+ 再从我们的商家记录取。不得留空导致后续读取失败。
+- 纳管前要检查**该商家、该门店**是否已经有一条进行中或已通过的进件。已有则拒绝并说明(同一门店出现两个
+ 收款主体会在下单时打架)。这与「重复纳管同一商家幂等」是两件事:前者是防冲突,后者是防重复点击。
+- 纳管成功后跑一次进度刷新,由刷新决定这条记录最终是通过还是待办。
+
+**商户端账号不在纳管里处理**
+
+- 纳管只管收款绑定,**不创建商户端登录账号**。账号由「商家查询」的商家编辑负责(该处已经有创建/更新
+ 商户端账号的能力)。
+- 但纳管时若该商家还没有登录账号、或未开启商户端登录,应给出提示,让运营知道商户还登不进来。
+- 解绑**不动**商户端账号:解绑只是解除收款绑定,商家的登录与资料照旧。
+
+**共用同一个汇付商户号**
+
+- 入口对「该商户号已被其他商家绑定」**只提示不拦截**,提示文案列出已绑的商家,由运营确认。
+- 每个商家各自有一条进件记录、各自一份进度与能力快照。这是本次有意接受的代价:体量下并发副本很少,
+ 不一致是瞬时的,会在下一次成功刷新后自动收敛。**不做挂靠表**。
+
+**解绑**
+
+- 解绑清理该商家的进件记录与结算卡记录,并回收商户端收款相关权限。
+- 解绑前必须检查该商户号上是否还有在途订单或待结算资金,有则拒绝并说明原因。
+- 解绑只解除我方绑定:不上报通道、不回退通道侧的任何改动。这一点必须在界面上写明。
+
+**后台页面:入口放哪**
+
+- **纳管入口**放在「财务管理 → 结算记录 → 商户进件」页签的筛选栏里,作为该页签的主按钮(位置沿用「商家查询」
+ 页「新建」按钮的既有习惯)。理由是纳管产出的就是一条进件记录,这个页签正是进件记录的列表,纳管结果当场可见。
+ 点开后是一个**对话框**(选我方商家 + 汇付商户号 + 入驻申请单号;卡号不用填,从通道查回来),
+ 对话框里同时列出通道返回的商户注册名称、商户简称与主体类型供运营核对并显式确认;
+ 提交后刷新列表,并可直接进入该进件详情。
+- **解绑入口**放在「商户进件详情」页,与既有的「刷新进度 / 重试失败步骤 / 查询通道状态 / 发起微信实名」并列,
+ 作为**危险操作**(二次确认,并在确认前说明在途订单与待结算资金的拦截条件)。解绑成功后该进件记录消失,
+ 页面需回到列表;该商家的结算卡也会同时从「银行卡申请」页签消失。
+- 不新增路由:对话框沿用现有页面,避免改动共享的前端路由文件。若团队更看重与「新建商家」(独立路由页)
+ 的一致,也可以做页面,但那会新增一条路由。
+- 只在后台提供,**不开放商户端自助认领**(避免「填上别人的商户号就能挂靠」的冒领)。
+- **权限不新增**:纳管与解绑的端点放在既有的 `/merchant-settlement/onboardings/...` 之下,即可沿用现有的
+ `admin.merchant-settlement.edit`(该前缀已按「GET 为 view、其余为 edit」统一判定),前端按钮用同一个
+ 权限码控制。这样不需要新增权限码,也不需要改共享的权限映射。
+- 商家查询页**不放**这个入口:它是商家资料域,且每行已有禁用/编辑/团购管理三个操作,再加会挤。若运营的心智
+ 是「这家商家要收款」而希望从商家列表进入,可另议,但同一功能不要两处都放。
+
+**既有取舍的继承(不得违背)**
+
+- 不新增进件定时任务或页面自动轮询;进度由人工刷新与回调推进(既有 ADR)。
+- 商户端只展示派生结果(能否收款 + 原因 + 最近核实时间),不回传通道原始值(既有 ADR)。
+- 需要通道推进的状态必须有查询对等物;纳管触发的刷新就是这条查询路径。
+- 不得为了开发便利提供「跳过真实通道」的捷径。
+
+## Testing Decisions
+
+- **测什么**:只测对外可见的行为——纳管与解绑接口的输入校验与结果、纳管后该商家能通过下单闸门、
+ 共用同一商户号的多个商家各自可用、重复纳管幂等、解绑的在途资金拦截、进度刷新在无申请单号时
+ 仍能收敛到收款就绪。
+- **不测什么**:不测内部实现细节(具体 SQL、私有方法调用顺序、列名)。不测通道返回的原始字段如何
+ 被解析——那属于进件解析已有测试的范围。
+- **在哪测(接缝)**:沿用现有的**接口层 + 真实测试库 + 模拟汇付响应**这一条最高接缝,与
+ `merchant-wechat-onboarding` 的既有测试同源(该目录下已有进件、补开、能力、绑卡等接口级测试)。
+ 本功能**不需要新接缝**;纳管只是进件这条链路上新增的两个入口,复用同一条接缝即可。
+- **先验一项**:动手前先用一次只读的通道查询确认两件事——我们用现有签名身份能否查到目标商户、
+ 详情查询是否返回明文卡号。这两个答案决定入口字段是三个还是四个。
+- **回归面**:改动落在进件记录写入、进度刷新判定与收款闸门读取上,属于支付链路核心,必须跑通全量
+ 支付相关测试(仓库既有基线失败除外)。
+
+## Out of Scope
+
+- **通道侧能力补齐**:纳管只查询不下发,不做补开微信、绑定人才端 AppID、改支付场景/费率、开启延迟
+ 入账。这些如需处理,另行走自助进件的既有路径。
+- **「一个商家挂多个门店」**:不做给商家加门店的入口,不做商户端按门店进件。
+- **分账**:通道的收款方 + 分账接收方能力本身不在本次范围。
+- **不依赖申请单号的备用判定分支**:纳管时入驻申请单号必填,取不到就不纳管。
+- **商户端自助认领**:不做,纳管只放后台。
+- **批量/自动纳管**:不做一个「扫一遍控台把商户批量接进来」的能力;本次是逐个纳管。
+- **对账分摊逻辑**:共用同一个收款主体时,靠订单上已记录的门店归属分摊到各商家;本次不新增分摊逻辑。
+- **挂靠表**:不做「一份收款主体 + N 个引用」的模型。
+- **存量/自助进件商户的行为**:不改变它们的任何现有流程。
+
+## Further Notes
+
+- **关于二清**:能共用一个汇付商户号,这件事本身就意味着只有一个主体,共用不引入资金二次清算,
+ 不需要再单独确认「是不是同一法人」。真正的风险是**绑错主体**——把别家的商家接到这个商户号上——
+ 由纳管时的主体名核对拦截。
+- **前提(通道权限)**:纳管的第一步是用我们自己的签名身份查一次;查不到说明该商户不在我们
+ **渠道商**的管辖范围内,纳管无法进行。注意按领域上下文,数据权限由签名身份决定,**把商户改成挂在
+ 平台自己名下既解决不了权限失败、还会平白多出材料要求**,所以不要把它当成「迁一下归属就能解决」的事。
+- **待确认**:纳管入口的商户端账号处理——若该商家已有登录账号,只做启用、不重置密码。
+- **不接受的做法**:为了快速看到效果而伪造通道成功状态、或在纳管里自动下发通道改动。
+- **建议的落地节奏**:先拿**一个**外部进件商户端到端走通(纳管 → 刷新 → 商户端可登录且显示收款就绪
+ → 真实收一笔),再铺开给其余商户,不要一次性做完再验。
diff --git a/CONTEXT.md b/CONTEXT.md
index f3f5bb8..b3b748b 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -79,6 +79,19 @@
**企业与个人主体都要交**:通道在进件这一步就按 F-code 逐张核对,缺一张整笔驳回。
基本资料审核通过后,微信支付仍可能处于开通中或开通失败。
+**自助进件**:商户在我们商户端自己提交上一条**商户进件**的路径。相对的是**外部进件**。
+_Avoid_: 线上进件(那是「进件场景」,与谁发起无关)
+
+**外部进件**:商户**已在汇付侧完成进件**,我们把它接进某个商家。两条路径收敛到同一套进件记录、
+分阶段进度与**收款就绪**判定,只有来源不同(进件记录的 `source`:`self` 自助进件 / `managed` 纳管)。
+接进来的动作叫**纳管**:后台选定我方商家 + 填汇付商户号与入驻申请单号,先只读核验通道,再建结算卡与
+进件记录并跑一次进度刷新。**纳管全程只查询、不下发**——外部商户的口径已由业务要求与自助进件保持一致,
+只需核实;而补开微信、绑人才端 AppID、改支付场景、开启延迟入账都不幂等(改支付场景会重建微信子商户号),
+连锁多店共用一个汇付商户号时各自下发还会重复下发。防绑错靠**主体名人工核对 + 显式确认 + 留痕 + 可解绑**,
+不做「名称对不对得上」的字符串判定(我们只有一个商家名,通道那边是工商全称加简称,形态不对应)。
+一个汇付商户号可以被多个商家分别纳管(连锁各门店共用一个收款主体)。
+_Avoid_: 迁管、挂靠(会让人以为是一份收款主体 + N 个引用的模型)
+
**渠道商**:汇付里位于我们平台之上、**直属**管辖我们名下商户的那一层主体。平台的每一次通道调用都以它(或它的下级身份)
签名,因此「谁能查到哪个商户」由**签名身份本身的数据权限**决定,与商户在通道里挂在谁下面无关——
把商户改成挂在平台自己名下既解除不了数据权限失败,还会平白多出材料要求。
diff --git a/docs/adr/0006-managed-onboarding-is-query-only.md b/docs/adr/0006-managed-onboarding-is-query-only.md
new file mode 100644
index 0000000..0432279
--- /dev/null
+++ b/docs/adr/0006-managed-onboarding-is-query-only.md
@@ -0,0 +1,50 @@
+# 纳管进来的外部进件只查询、不下发
+
+商户有两条进件路径:一条是商户在我们**商户端自助进件**(已有能力),另一条是商户**已在汇付侧完成进件**、
+需要被接进我们的商家。第二条路径上的接入动作叫**纳管**:后台选定我方商家 + 填汇付商户号与入驻申请单号,
+先只读核验通道,再建结算卡与进件记录,并跑一次进度刷新。
+
+两条路径**收敛到同一套进件记录、同一套六阶段进度、同一套收款就绪判定**,只有来源不同(进件记录的
+`source`:`self` 自助进件 / `managed` 纳管,见 `V157`)。也就是说,纳管不新造链路、不新造状态,
+只在「写入口」与「来源标记」两处新增——`provider_huifu_id` 原先三个写入口全部来自汇付回执,纳管是第四个。
+
+**纳管全程只查询、不下发。** 进度刷新是纯查询;`source=managed` 的记录在刷新链路里跳过全部下发步骤
+(延迟入账、微信业务开通、微信小程序配置)。理由三条,缺一条都不足以推翻它:
+
+- **下发不幂等。** 改支付场景会**重建微信子商户号**,业务开通与微信配置都是「添加」型接口,
+ 重复提交会在通道侧留下重复配置或第二份申请。
+- **共用同一个汇付商户号会让重复下发成真。** 连锁按「每个门店建成一个独立商家」实现,几家门店共用一个
+ 收款主体(同一个 `provider_huifu_id`);各自下发就是对一个主体重复下发。
+- **不需要下发。** 外部进件商户的口径已由业务要求与自助进件保持一致,纳管只需**核实**。
+ 核实不通过时界面按阶段如实显示缺什么,处理另走自助进件的既有路径。
+
+**防绑错靠人工核对,不靠程序判定。** 通道返回**商户注册名称**(工商全称)与**商户简称**,而我们这边
+`merchant` 只有一个商家名(往往是服务点名或品牌简称),形态不对应——规则化匹配必然误判,而误拒会把运营
+卡死且无路可绕,误放等于没设防。所以程序只判确定性的事(用我们的签名身份查得到该商户,查不到即拒),
+名称改成「展示 + 运营显式勾选确认 + 写进进件记录留痕 + 可解绑」,并在两个名称与商家名一个字符都不重叠时
+给出醒目警示但**不硬拒**。
+
+**解绑只解除我方绑定。** 它清理这条纳管产生的进件记录与结算卡记录(商户端的收款权限是从进件记录派生的,
+记录没了权限也就没了),但**不回退通道侧的任何改动**——微信口径、人才端 AppID 绑定、延迟入账、实名都不是
+纳管改的,解绑也不会把它们撤销。该商家名下还有在途订单或待结算资金时拒绝解绑,避免切断正在进行的对账。
+
+## 与 ADR-0001 的关系
+
+ADR-0001 定的执行方式是「刷新进度会主动查询审核及业务结果,并**继续已满足前置条件的开通步骤**」。本条是
+这条规则的一个明确**例外**,且例外只按 `source=managed` 判定:自助进件的刷新一字未动,仍然会推进开通步骤
+(既有用例 `MerchantExistingOnboardingTests` 继续覆盖那条路径)。这里写下例外,后来者才不会以为纳管绕开了
+ADR-0001 的取舍。
+
+ADR-0001 其余部分照旧继承:不新增任何进件定时任务、不新增页面自动轮询,进度仍只由回调与人工刷新推进;
+需要通道推进的状态必须有查询对等物(纳管触发的刷新就是这条查询路径);不得为了开发便利提供「跳过真实通道」
+的捷径。
+
+## 考虑过但没选的
+
+- **不依赖申请单号的备用判定分支。** 通道的申请单状态查询把 `apply_no` 列为必填,外部商户没有我方发起过的
+ 申请单号;纳管时把汇付侧那个号一起录入即可跑通。取不到申请单号的商户不予纳管,提示运营去控台取,
+ 而不是为它另开一条并行的判定路径。
+- **挂靠表(一份收款主体 + N 个引用)。** 各商家各自有一条进件记录、各自一份进度与能力快照,是本次有意
+ 接受的代价:体量下并发副本很少,不一致是瞬时的,下次成功刷新就收敛。
+- **商户端自助认领。** 不做——那等于「填上别人的商户号就能挂靠」,冒领风险。
+- **在纳管里补开微信/绑 AppID/开延迟入账。** 见上,下发不幂等且会重复下发。
diff --git a/docs/agents/testing.md b/docs/agents/testing.md
index 681989f..54cabe9 100644
--- a/docs/agents/testing.md
+++ b/docs/agents/testing.md
@@ -125,6 +125,25 @@
的回退在途)状态。以后新写这类「后台任务只处理了我这条」的用例,照它的做法先腾窗口,不要再依赖测试库的
累积状况。
+## 「Too many connections」:也是环境相关,已修(2026-10-12)
+
+全量跑着跑着,**后加载**的那个用例类会整类报错,根因是:
+
+```
+Unable to obtain connection from database: ...
+message from server: "Too many connections" (SQL State 08004)
+```
+
+每个 `@SpringBootTest` 的上下文都缓存到整个 JVM 结束、各自持有一个 Hikari 连接池,而 Hikari 的
+`minimum-idle` 默认等于 `maximum-pool-size`(10),**启动时就把 10 条连接建满**。一次全量会累积十几个
+上下文(2026-10-12 实测 15 个),150 条空闲连接正好把测试库那个 8.0 实例的 `max_connections=151`
+吃光。测试 profile 现在把 `spring.datasource.hikari.minimum-idle` 钉成 `1`(见
+`src/test/resources/application-test.yml`),空闲上下文只留一条、按需再涨,连接池语义不变。
+**别把它改回去**:症状是「明明只加了几个测试,却有整整一个类的用例全挂」,很容易被误判成自己引入的回归。
+
+判断依据:报错里出现 `Too many connections` 就是它,与代码无关(同一次全量里,挂的总是**靠后**加载的
+上下文,而不是某个具体用例的逻辑)。
+
## 测试库跑不起来时,先看 WSL(2026-09-17 实测)
测试库那个 8.0.36 实例跑在 **WSL 的 docker 里**(发行版 `Ubuntu-26.04`,容器 `talent-card-mysql-1`,
diff --git a/talent-card-admin/src/api/admin.ts b/talent-card-admin/src/api/admin.ts
index 7ef20a4..6562e49 100644
--- a/talent-card-admin/src/api/admin.ts
+++ b/talent-card-admin/src/api/admin.ts
@@ -122,6 +122,34 @@
// 微信实名认证:平台运营代为发起。未决申请不会重复提交,retry 只对通道已明确驳回时有意义。
export function submitMerchantRealname(id: number | string, retry = false) { return http.post<Record<string, any>, Record<string, any>>(`/api/admin/management/merchant-settlement/onboardings/${id}/realname`, null, { params: { retry } }) }
+export interface ManagedOnboardingPayload {
+ merchantId: number | string
+ providerHuifuId: string
+ providerApplyNo?: string
+ confirmed?: boolean
+}
+/**
+ * 纳管前置核验:用我们自己的签名身份只读查一次该商户,把运营要人工核对的商户注册名称、商户简称、
+ * 主体类型与结算卡取回来。通道查不到会明确报错(该商户不在我们渠道商的管辖范围内)。
+ */
+export function previewManagedOnboarding(payload: ManagedOnboardingPayload) {
+ return http.post<Record<string, any>, Record<string, any>>('/api/admin/management/merchant-settlement/onboardings/managed/preview', payload)
+}
+/**
+ * 纳管:把已在汇付侧完成进件的外部商户接到我方某个商家上。confirmed 是运营肉眼核对通道名称后的
+ * 显式确认,后端要求它为真才受理——名称不做字符串判定,那个确认框就是防绑错的人工闸门。
+ */
+export function adoptManagedOnboarding(payload: ManagedOnboardingPayload & { providerApplyNo: string; confirmed: boolean }) {
+ return http.post<Record<string, any>, Record<string, any>>('/api/admin/management/merchant-settlement/onboardings/managed', payload)
+}
+/**
+ * 解绑:只解除我方绑定,清理这条纳管产生的进件与结算卡记录,**不回退通道侧的任何改动**。
+ * 该商户号上还有在途订单或待结算资金时后端会拒绝并给出原因。
+ */
+export function unbindManagedOnboarding(id: number | string) {
+ return http.post<Record<string, any>, Record<string, any>>(`/api/admin/management/merchant-settlement/onboardings/${id}/unbind`)
+}
+
export function getAdminNews(params: NewsArticleQuery = {}) {
return http.get<PageResult<NewsArticle>, PageResult<NewsArticle>>('/api/admin/business/news', { params })
}
diff --git a/talent-card-admin/src/views/MerchantOnboardingDetailView.vue b/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
index 781326a..4f4ad85 100644
--- a/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
+++ b/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
-import { useRoute } from 'vue-router'
-import { getMerchantOnboardingDetail, refreshMerchantOnboarding, refreshMerchantPaymentCapability, submitMerchantRealname } from '../api/admin'
+import { useRoute, useRouter } from 'vue-router'
+import { getMerchantOnboardingDetail, refreshMerchantOnboarding, refreshMerchantPaymentCapability, submitMerchantRealname, unbindManagedOnboarding } from '../api/admin'
import { hasAdminPermission } from '../utils/permissions'
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
+const router = useRouter()
const loading = ref(false)
const detail = ref<Record<string, any>>()
const syncing = ref(false)
@@ -101,6 +102,37 @@
}
}
+/**
+ * 解绑(危险操作,二次确认)。只解除我方绑定,清理这条纳管产生的进件与结算卡记录,页面回到列表。
+ *
+ * 确认框里必须写清两件事:一是该商户号上还有在途订单或待结算资金时会**拒绝**(那是正在进行的对账,
+ * 不能切断),二是解绑**不回退**通道侧的改动——微信口径、人才端绑定、延迟入账、实名都不是纳管改的。
+ */
+async function unbind() {
+ if (syncing.value) return
+ try {
+ await ElMessageBox.confirm(
+ '解绑只解除我方绑定,清理这条进件记录与它的结算卡记录,页面将回到列表。'
+ + '通道侧的改动(微信口径、人才端绑定、延迟入账、实名)不会被回退,商户可重新纳管。'
+ + '该商家名下还有在途订单或待结算资金时不能解绑。确认解绑?',
+ '解绑(危险操作)',
+ { type: 'warning', confirmButtonText: '确认解绑', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' },
+ )
+ } catch {
+ return
+ }
+ syncing.value = true
+ try {
+ await unbindManagedOnboarding(String(route.params.id))
+ ElMessage.success('已解除绑定')
+ router.push({ name: 'merchant-settlement', query: { kind: 'onboardings' } })
+ } catch (error: any) {
+ ElMessage.error(error?.message || '解绑失败,请稍后重试')
+ } finally {
+ syncing.value = false
+ }
+}
+
onMounted(load)
</script>
@@ -121,6 +153,14 @@
<h3 class="onboarding-heading">进件信息</h3>
<div class="detail-grid">
+ <div class="detail-field">
+ <span>进件来源</span>
+ <strong>
+ <el-tag :type="detail.source === 'managed' ? 'success' : 'info'" effect="plain" size="small">
+ {{ detail.source === 'managed' ? '纳管(外部进件商户)' : '自助进件' }}
+ </el-tag>
+ </strong>
+ </div>
<div class="detail-field"><span>进件编号</span><strong>{{ text(detail.applicationNo) }}</strong></div>
<div class="detail-field"><span>申请时间</span><strong>{{ dateTimeText(detail.createdAt) }}</strong></div>
<div class="detail-field"><span>商户编号</span><strong>{{ text(detail.merchantId) }}</strong></div>
@@ -149,6 +189,17 @@
<el-button v-if="hasAdminPermission('admin.merchant-settlement.edit') && detail.progress.retryable" :disabled="syncing" @click="refreshProgress(true)">重试失败步骤</el-button>
<el-button v-if="hasAdminPermission('admin.merchant-settlement.edit')" :disabled="syncing" @click="refreshChannelStatus()">查询通道状态</el-button>
<el-button v-if="hasAdminPermission('admin.merchant-settlement.edit') && realnameActionable()" :disabled="syncing" @click="startRealname()">{{ realnameStage()?.status === 'failed' ? '重新发起微信实名' : '发起微信实名' }}</el-button>
+ <!-- 解绑只对纳管产生的记录开放:自助进件那条记录里是商户自己提交的资料,删掉它是另一个性质的操作。
+ 危险操作,二次确认,确认前说明在途订单与待结算资金的拦截条件。 -->
+ <el-button
+ v-if="hasAdminPermission('admin.merchant-settlement.edit') && detail.source === 'managed'"
+ type="danger"
+ plain
+ :disabled="syncing"
+ @click="unbind()"
+ >
+ 解绑
+ </el-button>
</section>
<h3 class="onboarding-heading">商户进件</h3>
diff --git a/talent-card-admin/src/views/MerchantSettlementView.vue b/talent-card-admin/src/views/MerchantSettlementView.vue
index 4ba1a12..8705464 100644
--- a/talent-card-admin/src/views/MerchantSettlementView.vue
+++ b/talent-card-admin/src/views/MerchantSettlementView.vue
@@ -1,8 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
-import { Refresh, Search } from '@element-plus/icons-vue'
-import { getMerchantSettlement, getMerchantSettlementMerchantTypes, getMerchantSettlements } from '../api/admin'
+import { Plus, Refresh, Search } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import {
+ adoptManagedOnboarding,
+ getAdminMerchants,
+ getMerchantSettlement,
+ getMerchantSettlementMerchantTypes,
+ getMerchantSettlements,
+ previewManagedOnboarding,
+} from '../api/admin'
+import { hasAdminPermission } from '../utils/permissions'
type Kind = 'onboardings' | 'bank-cards' | 'settlements'
@@ -202,6 +211,108 @@
}
}
+/**
+ * 纳管外部进件商户。
+ *
+ * 流程刻意分两步:先「核验通道商户」做一次只读查询,把通道侧的商户注册名称、商户简称与主体类型
+ * 展示出来,运营与所选商家核对后**显式勾选确认**才能提交。不做「名称是否对得上」的程序判定——
+ * 我们这边只有一个商家名(常是简称或服务点名),通道那边是工商全称,形态不对应,规则化匹配必然误判。
+ */
+const adoptVisible = ref(false)
+const adoptPreviewing = ref(false)
+const adoptSubmitting = ref(false)
+const adoptConfirmed = ref(false)
+const adoptPreview = ref<Record<string, any>>()
+const adoptForm = reactive({ merchantId: undefined as number | string | undefined, providerHuifuId: '', providerApplyNo: '' })
+const merchantOptions = ref<Array<{ id: number | string; merchantName: string }>>([])
+const merchantLoading = ref(false)
+
+async function searchMerchants(keyword: string) {
+ merchantLoading.value = true
+ try {
+ const result = await getAdminMerchants({ page: 1, size: 20, keyword: keyword?.trim() || undefined })
+ merchantOptions.value = result.records || []
+ } catch {
+ merchantOptions.value = []
+ } finally {
+ merchantLoading.value = false
+ }
+}
+
+function openAdopt() {
+ adoptForm.merchantId = undefined
+ adoptForm.providerHuifuId = ''
+ adoptForm.providerApplyNo = ''
+ adoptPreview.value = undefined
+ adoptConfirmed.value = false
+ adoptVisible.value = true
+ if (merchantOptions.value.length === 0) searchMerchants('')
+}
+
+/** 换了商家或商户号,之前那次核验的对象就不是现在填的了,确认必须重新勾。 */
+function invalidateAdoptPreview() {
+ adoptPreview.value = undefined
+ adoptConfirmed.value = false
+}
+
+async function runAdoptPreview() {
+ if (!adoptForm.merchantId) {
+ ElMessage.warning('请先选择我方商家')
+ return
+ }
+ if (!adoptForm.providerHuifuId.trim()) {
+ ElMessage.warning('请先填写汇付商户号')
+ return
+ }
+ adoptPreviewing.value = true
+ try {
+ adoptPreview.value = await previewManagedOnboarding({
+ merchantId: adoptForm.merchantId,
+ providerHuifuId: adoptForm.providerHuifuId.trim(),
+ })
+ adoptConfirmed.value = false
+ } catch (error: any) {
+ adoptPreview.value = undefined
+ adoptConfirmed.value = false
+ ElMessage.error(error?.message || '核验通道商户失败,请确认商户号后重试')
+ } finally {
+ adoptPreviewing.value = false
+ }
+}
+
+async function submitAdopt() {
+ if (!adoptPreview.value) {
+ ElMessage.warning('请先核验通道商户')
+ return
+ }
+ if (!adoptForm.providerApplyNo.trim()) {
+ ElMessage.warning('请填写汇付侧的入驻申请单号')
+ return
+ }
+ if (!adoptConfirmed.value) {
+ ElMessage.warning('请先核对通道商户名称并勾选确认')
+ return
+ }
+ adoptSubmitting.value = true
+ try {
+ const result = await adoptManagedOnboarding({
+ merchantId: adoptForm.merchantId as number | string,
+ providerHuifuId: adoptForm.providerHuifuId.trim(),
+ providerApplyNo: adoptForm.providerApplyNo.trim(),
+ confirmed: true,
+ })
+ adoptVisible.value = false
+ ElMessage.success('纳管成功,已触发一次进度刷新')
+ await load()
+ // 纳管产出的就是一条进件记录,直接进详情看分阶段进度与待办理项。
+ if (result?.id) router.push({ name: 'merchant-onboarding-detail', params: { id: String(result.id) } })
+ } catch (error: any) {
+ ElMessage.error(error?.message || '纳管失败,请稍后重试')
+ } finally {
+ adoptSubmitting.value = false
+ }
+}
+
watch(() => route.query.kind, () => {
const next = routeKind()
// changeKind 自己写回地址栏也会走到这里,那一次已经查过了,别重复请求。
@@ -265,6 +376,18 @@
<el-button :icon="Refresh" @click="reset">重置</el-button>
</el-form-item>
</el-form>
+ <!-- 纳管入口只放在「商户进件」页签:纳管产出的就是一条进件记录,这个页签正是进件记录的列表,
+ 纳管结果当场可见。位置沿用「商家查询」页「新建」按钮的既有习惯。 -->
+ <div v-if="kind === 'onboardings'" class="settlement-table-tools">
+ <el-button
+ v-if="hasAdminPermission('admin.merchant-settlement.edit')"
+ type="primary"
+ :icon="Plus"
+ @click="openAdopt"
+ >
+ 纳管外部进件商户
+ </el-button>
+ </div>
</section>
<section class="table-panel">
@@ -289,6 +412,12 @@
<el-table-column prop="merchantType" label="商家类型" min-width="110" />
<el-table-column prop="subjectName" label="进件主体" min-width="150" show-overflow-tooltip />
<el-table-column label="主体类型" min-width="105"><template #default="{ row }">{{ subjectTypeLabel(row.subjectType) }}</template></el-table-column>
+ <!-- 来源标记:区分两条进件路径,纳管进来的要一眼看出来。 -->
+ <el-table-column label="来源" min-width="100" align="center">
+ <template #default="{ row }">
+ <el-tag :type="row.source === 'managed' ? 'success' : 'info'" effect="plain">{{ row.source === 'managed' ? '纳管' : '自助进件' }}</el-tag>
+ </template>
+ </el-table-column>
</template>
<template v-else>
<el-table-column prop="id" label="申请id" min-width="100" />
@@ -336,6 +465,125 @@
/>
</div>
</section>
+
+ <el-dialog
+ v-model="adoptVisible"
+ title="纳管外部进件商户"
+ width="640px"
+ :close-on-click-modal="false"
+ destroy-on-close
+ >
+ <p class="adopt-hint">
+ 用于把<b>已在汇付侧完成进件</b>的商户接到我方某个商家上。纳管全程只查询、不下发:不会补开微信、
+ 不会绑定人才端、不会改动支付场景与延迟入账。
+ </p>
+ <el-form label-width="120px" class="adopt-form">
+ <el-form-item label="我方商家" required>
+ <el-select
+ v-model="adoptForm.merchantId"
+ filterable
+ remote
+ reserve-keyword
+ clearable
+ placeholder="输入商家名称搜索"
+ :remote-method="searchMerchants"
+ :loading="merchantLoading"
+ @change="invalidateAdoptPreview"
+ >
+ <el-option
+ v-for="item in merchantOptions"
+ :key="item.id"
+ :label="item.merchantName"
+ :value="item.id"
+ />
+ </el-select>
+ </el-form-item>
+ <el-form-item label="汇付商户号" required>
+ <el-input v-model="adoptForm.providerHuifuId" clearable placeholder="斗拱侧该商户的 huifu_id" @input="invalidateAdoptPreview" />
+ </el-form-item>
+ <el-form-item label="入驻申请单号" required>
+ <el-input v-model="adoptForm.providerApplyNo" clearable placeholder="汇付控台入驻申请列表里的申请单号" />
+ </el-form-item>
+ <el-form-item label=" ">
+ <el-button type="primary" plain :loading="adoptPreviewing" @click="runAdoptPreview">核验通道商户</el-button>
+ <span class="adopt-tip">卡号不用填,从通道查回来。</span>
+ </el-form-item>
+ </el-form>
+
+ <template v-if="adoptPreview">
+ <el-descriptions :column="1" border size="small" class="adopt-preview">
+ <el-descriptions-item label="我方商家">
+ {{ adoptPreview.merchantName }}<span class="muted">(门店:{{ adoptPreview.storeName }})</span>
+ </el-descriptions-item>
+ <el-descriptions-item label="通道商户注册名称">{{ adoptPreview.channelMerchantName }}</el-descriptions-item>
+ <el-descriptions-item label="通道商户简称">{{ adoptPreview.channelShortName }}</el-descriptions-item>
+ <el-descriptions-item label="通道主体类型">
+ {{ adoptPreview.channelSubjectType === 'enterprise' ? '企业' : '个人' }}
+ <span v-if="adoptPreview.channelCompanyType" class="muted">({{ adoptPreview.channelCompanyType }})</span>
+ </el-descriptions-item>
+ <el-descriptions-item label="结算银行卡">
+ {{ adoptPreview.settlementCardMasked || '-' }}
+ <span v-if="adoptPreview.settlementBankName" class="muted">({{ adoptPreview.settlementBankName }})</span>
+ </el-descriptions-item>
+ </el-descriptions>
+ <!-- 只提示不拦截:连锁各门店共用一个收款主体是允许的,这里列出已绑的商家让运营自己确认。 -->
+ <el-alert
+ v-if="adoptPreview.alreadyManaged"
+ class="adopt-alert"
+ type="success"
+ :closable="false"
+ title="该商家已纳管过这个汇付商户号"
+ description="重复纳管是幂等的,提交会直接返回原来那条进件记录,不会产生第二条。"
+ />
+ <el-alert
+ v-if="adoptPreview.boundMerchants?.length"
+ class="adopt-alert"
+ type="info"
+ :closable="false"
+ :title="`该汇付商户号已绑给:${adoptPreview.boundMerchants.join('、')}`"
+ description="可以继续纳管(连锁各门店共用一个收款主体)。"
+ />
+ <el-alert
+ v-if="adoptPreview.nameWarning"
+ class="adopt-alert"
+ type="warning"
+ :closable="false"
+ title="通道名称与所选商家名完全没有重合字符"
+ :description="adoptPreview.nameWarning"
+ />
+ <el-alert
+ v-if="adoptPreview.loginNotice"
+ class="adopt-alert"
+ type="warning"
+ :closable="false"
+ title="商户端登录"
+ :description="adoptPreview.loginNotice"
+ />
+ <el-alert
+ v-for="blocker in adoptPreview.blockers || []"
+ :key="blocker"
+ class="adopt-alert"
+ type="error"
+ :closable="false"
+ :title="blocker"
+ />
+ <el-checkbox v-model="adoptConfirmed" :disabled="(adoptPreview.blockers || []).length > 0">
+ 我已把通道侧的商户注册名称与简称与所选商家核对一致(纳管后该商家的收款将进入这个收款主体)
+ </el-checkbox>
+ </template>
+
+ <template #footer>
+ <el-button @click="adoptVisible = false">取消</el-button>
+ <el-button
+ type="primary"
+ :loading="adoptSubmitting"
+ :disabled="!adoptPreview || !adoptConfirmed || (adoptPreview.blockers || []).length > 0"
+ @click="submitAdopt"
+ >
+ 提交纳管
+ </el-button>
+ </template>
+ </el-dialog>
</div>
</template>
@@ -352,6 +600,12 @@
.query-form :deep(.el-input), .query-form :deep(.el-select), .query-form :deep(.el-date-editor) { width: 100%; }
.query-actions { justify-content: flex-end; }
.query-actions :deep(.el-form-item__content) { justify-content: flex-end; }
+.settlement-table-tools { margin: 0 0 12px; }
+.adopt-hint { margin: 0 0 16px; color: #606266; font-size: 13px; line-height: 20px; }
+.adopt-form :deep(.el-select) { width: 100%; }
+.adopt-tip { margin-left: 12px; color: #909399; font-size: 12px; }
+.adopt-preview { margin-bottom: 14px; }
+.adopt-alert { margin-bottom: 12px; }
.table-panel { margin-top: 16px; padding: 0 20px 12px; }
.settlement-table { width: 100%; }
.settlement-table :deep(th.el-table__cell) { color: #303133; font-weight: 600; background: #f7f8fa; }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
index 6ae2a4f..43c14d6 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -11,9 +11,10 @@
public class AdminManagementController {
private final AdminManagementService service;
private final MerchantSettlementAdminService settlementService;
+ private final MerchantManagedOnboardingService managedOnboardingService;
private final PaymentReconciliationAdminService reconciliationService;
private final ProtocolSettingAdminService protocolSettingService;
- public AdminManagementController(AdminManagementService service, MerchantSettlementAdminService settlementService, PaymentReconciliationAdminService reconciliationService, ProtocolSettingAdminService protocolSettingService) { this.service = service; this.settlementService = settlementService; this.reconciliationService = reconciliationService; this.protocolSettingService = protocolSettingService; }
+ public AdminManagementController(AdminManagementService service, MerchantSettlementAdminService settlementService, MerchantManagedOnboardingService managedOnboardingService, PaymentReconciliationAdminService reconciliationService, ProtocolSettingAdminService protocolSettingService) { this.service = service; this.settlementService = settlementService; this.managedOnboardingService = managedOnboardingService; this.reconciliationService = reconciliationService; this.protocolSettingService = protocolSettingService; }
@GetMapping("/settings") public ApiResponse<Map<String,Object>> settings() { return ApiResponse.success(service.settings()); }
@PutMapping("/settings") public ApiResponse<Map<String,Object>> saveSettings(@RequestBody Map<String,String> body) { return ApiResponse.success(service.saveSettings(body)); }
@GetMapping("/protocols/{key}") public ApiResponse<Map<String,Object>> protocol(@PathVariable String key) { return ApiResponse.success(protocolSettingService.detail(key)); }
@@ -36,6 +37,17 @@
@PostMapping("/merchant-settlement/onboardings/{id}/realname") public ApiResponse<Map<String,Object>> submitRealname(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean retry) { return ApiResponse.success(settlementService.submitRealname(id, retry)); }
@GetMapping("/merchant-settlement/onboardings/{id}/payment-capability") public ApiResponse<Map<String,Object>> paymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.paymentCapability(id)); }
@PostMapping("/merchant-settlement/onboardings/{id}/payment-capability/refresh") public ApiResponse<Map<String,Object>> refreshPaymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.refreshPaymentCapability(id)); }
+ /**
+ * 纳管:把已在汇付侧完成进件的外部商户接到我方某个商家上。
+ *
+ * <p>前置核验是**只读**的通道查询,把要人工核对的商户注册名称、商户简称、主体类型与结算卡取回来;
+ * 运营核对并显式确认后走 {@code managed} 提交。两者都挂在既有的 {@code onboardings} 前缀下,
+ * 权限沿用 {@code admin.merchant-settlement.edit}(GET 为 view、其余为 edit),不新增权限码。
+ */
+ @PostMapping("/merchant-settlement/onboardings/managed/preview") public ApiResponse<Map<String,Object>> previewManagedOnboarding(@RequestBody MerchantManagedOnboardingRequest request) { return ApiResponse.success(managedOnboardingService.preview(request.merchantId(), request.providerHuifuId())); }
+ @PostMapping("/merchant-settlement/onboardings/managed") public ApiResponse<Map<String,Object>> adoptManagedOnboarding(@RequestBody MerchantManagedOnboardingRequest request) { return ApiResponse.success(managedOnboardingService.adopt(request.merchantId(), request.providerHuifuId(), request.providerApplyNo(), Boolean.TRUE.equals(request.confirmed()))); }
+ /** 解绑:只解除我方绑定,只清理这条纳管产生的进件与结算卡记录,不回退通道侧的任何改动。 */
+ @PostMapping("/merchant-settlement/onboardings/{id}/unbind") public ApiResponse<Map<String,Object>> unbindManagedOnboarding(@PathVariable Long id) { return ApiResponse.success(managedOnboardingService.unbind(id)); }
@GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.bankCards(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
/**
* 结算记录:向斗拱查询指定区间的真实结算流水,用于监控每天自动结算了多少到银行卡。
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingRequest.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingRequest.java
new file mode 100644
index 0000000..7f3365f
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingRequest.java
@@ -0,0 +1,17 @@
+package com.talentcard.server.business.admin;
+
+/**
+ * 纳管的表单:我方商家 + 汇付商户号 + 入驻申请单号(+ 运营的显式确认)。
+ *
+ * <p>只有三个输入项,卡号不在其中——它连同绑卡序列号一起从通道的商户详细信息查询取回(见
+ * {@link MerchantManagedOnboardingService})。
+ *
+ * <p>{@code confirmed} 是运营在对话框里勾的确认框:通道返回的商户注册名称与简称要他肉眼与所选商家
+ * 核对过。程序不为名称做字符串判定,那个确认框就是防绑错的那道人工闸门,所以它**必须**为真才能提交。
+ */
+public record MerchantManagedOnboardingRequest(
+ Long merchantId,
+ String providerHuifuId,
+ String providerApplyNo,
+ Boolean confirmed
+) { }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java
new file mode 100644
index 0000000..6658be1
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantManagedOnboardingService.java
@@ -0,0 +1,569 @@
+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} 原先三个写入口
+ * 全部来自汇付回执,纳管是第四个(见 V157 的 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>查不到就明确拒绝——那说明该商户不在我们**渠道商**的管辖范围内,纳管进行不下去。注意把商户
+ * 改成挂在平台自己名下既解决不了权限失败、还会平白多出材料要求,不是「迁一下归属就能解决」的事。
+ */
+ public Map<String, Object> preview(Long merchantId, String huifuId) {
+ Map<String, Object> merchant = requireMerchant(merchantId);
+ String providerHuifuId = requireHuifuId(huifuId);
+ Map<String, Object> channel = channelMerchant(providerHuifuId);
+ Long storeId = primaryStore(merchantId);
+ Map<String, Object> card = pickSettlementCard(channel);
+
+ Map<String, Object> view = new LinkedHashMap<>();
+ view.put("merchantId", String.valueOf(merchant.get("id")));
+ view.put("merchantName", clean(merchant.get("merchantName")));
+ 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(clean(merchant.get("merchantName")),
+ clean(channel.get("reg_name")), clean(channel.get("short_name"))));
+ // 纳管只管收款绑定,不创建商户端登录账号;但商户登不进来要让运营知道。
+ view.put("loginNotice", loginNotice(merchantId, 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 (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) {
+ Map<String, Object> 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);
+ 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);
+ }
+
+ /**
+ * 重复纳管命中的是上一次留下的那条记录:不新建,但也不能什么都不做。
+ *
+ * <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(Map<String, Object> merchant, Long storeId, String huifuId,
+ Map<String, Object> card, String cardNumber) {
+ Long merchantId = ((Number) merchant.get("id")).longValue();
+ 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(Map<String, Object> 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";
+ String channelName = clean(channel.get("reg_name"));
+ String merchantName = clean(merchant.get("merchantName"));
+ // 主体名取通道的**商户注册名称**(工商全称):进件列表的「进件主体」列就是它,运营要能一眼
+ // 比对我方商家名与通道主体名,才看得出有没有绑错主体。通道没给才回落到法人姓名/我们的商家名。
+ // 同一份名称也写进 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")),
+ clean(channel.get("legal_name")), merchantName);
+ String contactMobile = firstNonBlank(clean(channel.get("contact_mobile_no")), clean(merchant.get("contact_mobile")),
+ 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(clean(merchant.get("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 -> clean(row.get("merchantName")) + (clean(row.get("storeName")).isBlank() ? "" : "(" + clean(row.get("storeName")) + ")"))
+ .distinct().toList();
+ }
+
+ /**
+ * 通道侧两个名称与所选商家名**连一个字符都不重叠**时的警示。
+ *
+ * <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(Long merchantId, Map<String, Object> merchant) {
+ if (!"1".equals(clean(merchant.get("can_login_merchant_app")))) {
+ return "该商家未开启商户端登录,纳管不会为它创建登录账号;商户要自己看收款状态,请先到「商家查询」开启商户端登录并设置账号。";
+ }
+ Integer accounts = jdbc.queryForObject(
+ "select count(*) from merchant_operator where merchant_id = ? and status = 1 and deleted = 0",
+ Integer.class, merchantId);
+ return accounts == null || accounts == 0
+ ? "该商家还没有商户端登录账号,纳管不会为它创建;请到「商家查询」为该商家编辑并设置登录账号,否则商户登不进来。"
+ : "";
+ }
+
+ private Map<String, Object> 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);
+ }
+
+ 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, Map<String, Object> merchant) {
+ return firstNonBlank(clean(card.get("bank_name")), clean(merchant.get("merchantName")));
+ }
+
+ /** 对私结算的支行名不一定有值,但该列非空:拿不到就回落总行名,不硬造。 */
+ 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(); }
+}
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 132b063..bfa7767 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
@@ -45,6 +45,21 @@
public Map<String,Object> view(Long id) { return view(application(id)); }
+ /** 进件来源标记:后台纳管进来的外部进件商户。取值与 V157 的 {@code source} 列一致。 */
+ public static final String SOURCE_MANAGED = "managed";
+
+ /**
+ * 这条进件是不是「后台纳管的外部进件」。
+ *
+ * <p>它决定这条进件能不能**下发**通道改动:纳管全程只查询、不下发(外部商户的口径已由业务要求
+ * 保持一致,只需核实;而补开微信、绑人才端、改支付场景、开启延迟入账这些下发操作都不幂等,
+ * 连锁多店共用同一个汇付商户号时各自下发还会重复下发)。判定只留这一处定义,刷新链路与纳管/解绑
+ * 都读它,避免同一个来源标记在几处各自比较字符串。
+ */
+ public static boolean isManaged(Map<String,Object> application) {
+ return SOURCE_MANAGED.equals(text(application.get("source")).trim());
+ }
+
@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);
@@ -147,7 +162,7 @@
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
+ if (!isManaged(application) && "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);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
index 148f042..c374cf8 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
@@ -131,6 +131,9 @@
columns.put("store_cashier_url", "varchar(512) default null");
// 经营详细地址:线下进件的 detail_addr 是通道必填项,与证件地址分开存。见 V155 的说明。
columns.put("business_address", "varchar(255) default null");
+ // 进件来源(自助进件 / 后台纳管):决定进度刷新是否允许下发通道改动。见 V157 的说明。
+ // 开发库(flyway 关闭)靠这里补列,否则纳管的 INSERT 会报 Unknown column。
+ columns.put("source", "varchar(16) not null default 'self'");
for (Map.Entry<String, String> column : columns.entrySet()) {
if (!hasColumn("merchant_onboarding_application", column.getKey())) addColumn("merchant_onboarding_application", column.getKey(), column.getValue());
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
index cfc209b..2e7c25e 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -82,7 +82,10 @@
List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where id=? and initial_bank_card_id is not null",applicationId);
if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found");
String applicationNo=clean(records.get(0).get("applicationNo"));
- if (!applicationNo.isBlank()) continuePostOnboardingProvisioning(applicationNo);
+ // 后台纳管的进件(source=managed)只查询不下发:业务开通与微信配置都是下发类操作,而外部商户的
+ // 口径已由业务要求保持一致,纳管只需核实;连锁多店共用同一个汇付商户号时各自下发还会重复下发。
+ // 判定只认 MerchantOnboardingProgressService.isManaged 那一处定义。
+ if (!applicationNo.isBlank() && !MerchantOnboardingProgressService.isManaged(records.get(0))) continuePostOnboardingProvisioning(applicationNo);
return onboardingProgressService.refresh(applicationId,retry);
}
diff --git a/talent-card-server/src/main/resources/db/migration/V157__merchant_onboarding_source.sql b/talent-card-server/src/main/resources/db/migration/V157__merchant_onboarding_source.sql
new file mode 100644
index 0000000..3a3a4f9
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V157__merchant_onboarding_source.sql
@@ -0,0 +1,25 @@
+-- 进件来源标记:区分「商户端自助进件」与「运营在后台纳管的外部进件商户」。
+--
+-- 背景:商户有两条进件路径。一条是商户在我们商户端自助进件(既有能力),另一条是商户直接在汇付侧
+-- 完成进件、需要被接进我们的商家(见 .scratch/merchant-external-onboarding/spec.md)。两条路径收敛到
+-- 同一套进件记录、分阶段进度与收款就绪判定,只有「来源」不同,所以只加这一列。
+--
+-- 它决定一件业务规则:纳管进来的进件**全程只查询、不下发**。外部进件商户的口径已由业务要求保持一致,
+-- 纳管只需核实;而补开微信、绑人才端 AppID、开启延迟入账这些下发操作都不幂等(改支付场景会重建微信
+-- 子商户号),且连锁多店共用同一个汇付商户号时各自下发会造成重复下发。所以进度刷新与后续配置推进
+-- 都要按这一列让路(见 MerchantManagedOnboardingService 与 MerchantOnboardingProgressService)。
+--
+-- 默认 'self'(自助进件):存量记录本来就是自助进件,不需要回填。
+-- 幂等:先查 INFORMATION_SCHEMA 再决定是否加列,MySQL 5.7 与 8.0 都能直接重复执行,
+-- 所以这份迁移也可以用 scripts/apply-sql-file.ps1 直接落到开发库(开发库 Flyway 关闭、结构不由迁移维护)。
+DELIMITER $$
+DROP PROCEDURE IF EXISTS add_merchant_onboarding_source$$
+CREATE PROCEDURE add_merchant_onboarding_source()
+BEGIN
+ DECLARE col_exists INT DEFAULT 0;
+ SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'source';
+ IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN source VARCHAR(16) NOT NULL DEFAULT 'self' COMMENT '进件来源:self 商户端自助进件,managed 后台纳管的外部进件' AFTER status; END IF;
+END$$
+CALL add_merchant_onboarding_source()$$
+DROP PROCEDURE IF EXISTS add_merchant_onboarding_source$$
+DELIMITER ;
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java
new file mode 100644
index 0000000..291be6d
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantManagedOnboardingTests.java
@@ -0,0 +1,500 @@
+package com.talentcard.server.business.admin;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
+import com.talentcard.server.business.merchant.MerchantSettlementService;
+import com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway;
+import com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway;
+import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
+import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway;
+import com.talentcard.server.common.BusinessException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+/**
+ * 外部进件商户的纳管与解绑(接口级,真实测试库 + 模拟汇付响应)。
+ *
+ * <p>断言只落在对外可见的行为上:纳管与解绑的输入校验与结果、纳管后该商家能通过下单闸门、
+ * 共用同一商户号的多个商家各自可用、重复纳管幂等、解绑的在途资金拦截,以及「纳管全程只查询不下发」。
+ * 不测内部 SQL 与私有方法调用顺序。
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+class MerchantManagedOnboardingTests {
+ private static final String APPLY_NO = "HF-EXTERNAL-APPLY-001";
+
+ @Autowired JdbcTemplate jdbc;
+ @Autowired MerchantManagedOnboardingService service;
+ @Autowired MerchantSettlementService settlementService;
+ @Autowired MerchantPaymentReadinessService readinessService;
+ @Autowired MerchantSettlementAdminService adminSettlementService;
+ @MockitoBean HuifuOnboardingProgressGateway gateway;
+ // 纳管全程只查询不下发:这三个下发网关全部挡在这里,任何一次调用都会被断言抓住。
+ @MockitoBean HuifuMerchantWechatConfigGateway wechatConfigGateway;
+ @MockitoBean HuifuMerchantBusinessOpenGateway businessOpenGateway;
+ @MockitoBean HuifuPictureUploadGateway pictureUploadGateway;
+
+ @Test
+ void adoptingAChannelMerchantLetsTheStoreCollectThroughTheOrderGate() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("纳管成功");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+
+ Map<String, Object> adopted = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
+
+ assertThat(adopted).containsEntry("status", "approved");
+ assertThat(jdbc.queryForObject("""
+ select source from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?
+ """, String.class, fixture.merchantId(), huifuId)).isEqualTo("managed");
+ assertThat(jdbc.queryForObject("""
+ select provider_apply_no from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?
+ """, String.class, fixture.merchantId(), huifuId)).isEqualTo(APPLY_NO);
+ // 结算卡是硬前提:卡与绑卡序列号都从通道查回来,闸门才放行。
+ assertThat(jdbc.queryForObject("""
+ select provider_token_no from merchant_bank_card where merchant_id = ? and status = 'approved'
+ """, String.class, fixture.merchantId())).isEqualTo("external-card-token");
+ // 与自助进件走完全相同的收款就绪口径。
+ assertThat(readinessService.requireCollectingMerchant(fixture.merchantId(), fixture.storeId()).huifuId())
+ .isEqualTo(huifuId);
+ }
+
+ /**
+ * 进度刷新要能吃下「只有汇付侧入驻申请单号」的进件。
+ *
+ * <p>通道的申请单状态查询把 {@code apply_no} 列为必填,外部进件商户没有我方发起过的申请单号;
+ * 纳管时把汇付侧那个号一起录入,刷新流程无需改动即可跑通,不会永远停在未确认。
+ */
+ @Test
+ void progressConvergesFromTheChannelApplyNumberAlone() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("通道申请单号");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+
+ service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
+
+ verify(gateway).queryApplication(eq(huifuId), eq(APPLY_NO));
+ assertThat(adoptedApplicationId(fixture.merchantId(), huifuId)).isNotNull();
+ }
+
+ /** 通道查不到 = 该商户不在我们渠道商的管辖范围内,纳管必须明确拒绝并给出原因。 */
+ @Test
+ void aMerchantTheChannelCannotQueryIsRejectedWithItsReason() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("通道查不到");
+ when(gateway.queryCapabilities(huifuId)).thenReturn(Map.of("resp_code", "20002", "resp_desc", "数据权限认证失败"));
+
+ assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("通道查不到该商户")
+ .hasMessageContaining("数据权限认证失败");
+ assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
+ Integer.class, fixture.merchantId())).isZero();
+ }
+
+ /** 没勾确认框不受理:名称核对是唯一防绑错的那道人工闸门。 */
+ @Test
+ void adoptingWithoutTheManualConfirmationIsRejected() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("未确认");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+
+ assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, false))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("勾选确认");
+ }
+
+ /** 入驻申请单号必填:取不到就不纳管(不做不依赖申请单号的备用判定分支)。 */
+ @Test
+ void adoptingWithoutTheChannelApplyNumberIsRejected() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("缺申请单号");
+
+ assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, " ", true))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("入驻申请单号");
+ }
+
+ /**
+ * 主体名不符**只警示不硬拒**:我们这边只有一个商家名(常是简称或服务点名),通道那边是工商全称,
+ * 形态不对应,规则化匹配必然误判——误拒会把运营卡死且无路可绕。
+ */
+ @Test
+ void aMerchantNameSharingNotASingleCharacterWarnsButStillAdopts() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("A甲乙丙丁戊己");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token", "上海汇付支付服务有限公司", "汇付支付");
+
+ Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId);
+
+ assertThat(String.valueOf(preview.get("nameWarning"))).contains("没有任何相同字符");
+ assertThat(preview).containsEntry("channelMerchantName", "上海汇付支付服务有限公司");
+ // 警示不阻挡提交。
+ assertThat(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved");
+ }
+
+ /** 名称有重合字符时不提示,避免天天喊狼来了。 */
+ @Test
+ void overlappingNamesProduceNoWarning() {
+ assertThat(MerchantManagedOnboardingService.nameWarning("汇付餐饮店", "上海汇付支付服务有限公司", "汇付支付")).isEmpty();
+ assertThat(MerchantManagedOnboardingService.nameWarning("测试商家", "上海汇付支付服务有限公司", "汇付支付")).isNotEmpty();
+ }
+
+ /** 同一个汇付商户号可以被多个商家分别纳管(连锁各门店共用一个收款主体),只提示不拦截。 */
+ @Test
+ void aHuifuIdAlreadyBoundToAnotherMerchantIsReportedButNotBlocked() {
+ String huifuId = uniqueHuifuId();
+ Fixture first = merchantFixture("连锁A店");
+ Fixture second = merchantFixture("连锁B店");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+
+ service.adopt(first.merchantId(), huifuId, APPLY_NO, true);
+ Map<String, Object> preview = service.preview(second.merchantId(), huifuId);
+
+ assertThat((List<?>) preview.get("boundMerchants")).anySatisfy(name -> assertThat(String.valueOf(name)).contains(first.key()));
+ assertThat((List<?>) preview.get("blockers")).as("已被其他商家绑定只是提示,不能拦").isEmpty();
+ assertThat(service.adopt(second.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved");
+ assertThat(readinessService.requireCollectingMerchant(second.merchantId(), second.storeId()).huifuId()).isEqualTo(huifuId);
+ }
+
+ /** 重复纳管同一商家是幂等的,不会产生第二条进件记录。 */
+ @Test
+ void adoptingTheSameMerchantTwiceIsIdempotent() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("幂等");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+
+ Map<String, Object> first = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
+ Map<String, Object> second = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
+
+ assertThat(second.get("id")).isEqualTo(first.get("id"));
+ assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
+ Integer.class, fixture.merchantId())).isEqualTo(1);
+ assertThat(jdbc.queryForObject("select count(*) from merchant_bank_card where merchant_id = ?",
+ Integer.class, fixture.merchantId())).isEqualTo(1);
+ // 已经纳管过的商户号在核验里只说明会复用原记录,不能报成「门店已有进件」这种硬拦截。
+ Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId);
+ assertThat(preview).containsEntry("alreadyManaged", true);
+ assertThat((List<?>) preview.get("blockers")).isEmpty();
+ }
+
+ /**
+ * 命中幂等时不能报一句「纳管成功」却什么都没发生。
+ *
+ * <p>被通道驳回的记录刷新救不回来({@code supports()} 对 rejected 返回 false),所以必须明确让运营
+ * 去解绑后重来;否则运营会以为纳管生效了,而这条记录永远收不了款。
+ */
+ @Test
+ void readoptingAChannelRejectedApplicationAsksTheOperatorToUnbindFirst() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("已驳回重纳管");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+ service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
+ jdbc.update("update merchant_onboarding_application set status = 'rejected' where merchant_id = ?", fixture.merchantId());
+
+ assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("已被通道驳回")
+ .hasMessageContaining("解绑");
+ assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
+ Integer.class, fixture.merchantId())).isEqualTo(1);
+ }
+
+ /** 同一门店已经有进行中或已通过的进件时拒绝:两条收款主体会在下单时打架。 */
+ @Test
+ void adoptingASecondSubMerchantForTheSameStoreIsRejected() {
+ String firstHuifuId = uniqueHuifuId();
+ String secondHuifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("门店冲突");
+ stubConvergedChannel(firstHuifuId, "622200001234", "external-card-token");
+ stubConvergedChannel(secondHuifuId, "622200001235", "second-card-token");
+
+ service.adopt(fixture.merchantId(), firstHuifuId, APPLY_NO, true);
+
+ assertThat(service.preview(fixture.merchantId(), secondHuifuId).get("blockers"))
+ .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
+ .anySatisfy(blocker -> assertThat(blocker).contains("同一门店出现两个收款主体"));
+ assertThatThrownBy(() -> service.adopt(fixture.merchantId(), secondHuifuId, APPLY_NO, true))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("同一门店出现两个收款主体");
+ }
+
+ /**
+ * 纳管全程只查询、不下发:通道侧延迟入账没配好时,纳管**不会**替它配一次。
+ *
+ * <p>下发类操作不幂等(改支付场景会重建微信子商户号),且连锁多店共用同一个汇付商户号时各自
+ * 下发会重复下发。缺什么由界面按阶段如实显示,处理另走自助进件的既有路径。
+ */
+ @Test
+ void adoptionNeverSendsAnyProviderChange() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("只查询不下发");
+ // 通道侧其他能力都正常,只有延迟入账没开——自助进件在这一点上会下发一次 delay 配置。
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+ when(gateway.queryCapabilities(huifuId)).thenReturn(channelDetail(huifuId, "622200001234", "external-card-token", "N", false));
+
+ Map<String, Object> adopted = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
+
+ verify(gateway, never()).configureDelay(anyString(), anyString(), anyString());
+ verify(wechatConfigGateway, never()).configure(anyString(), anyString());
+ verify(businessOpenGateway, never()).open(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyBoolean(), any(), any(), any());
+ verifyNoInteractions(pictureUploadGateway);
+ // 如实反映「这一步还没配好」,而不是伪造成功状态。
+ assertThat(adopted).containsEntry("status", "pending");
+ assertThat((List<Map<String, Object>>) adopted.get("stages"))
+ .anySatisfy(stage -> assertThat(stage).containsEntry("key", "delay").containsEntry("status", "missing"));
+ }
+
+ /** 后台/商户端点「刷新进度」对纳管记录同样是只查询:不会走开通步骤的那条下发链路。 */
+ @Test
+ void refreshingAManagedApplicationStaysQueryOnly() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("刷新只查询");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+ Map<String, Object> adopted = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
+
+ settlementService.refreshProgress(Long.valueOf(String.valueOf(adopted.get("id"))), false);
+
+ verify(wechatConfigGateway, never()).configure(anyString(), anyString());
+ verify(businessOpenGateway, never()).open(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyBoolean(), any(), any(), any());
+ verify(gateway, never()).configureDelay(anyString(), anyString(), anyString());
+ // 通道返回的一切都按原样落库,进度仍是已通过。
+ assertThat(settlementService.refreshProgress(Long.valueOf(String.valueOf(adopted.get("id"))), false))
+ .containsEntry("status", "approved");
+ }
+
+ /** 解绑清理进件记录与结算卡记录,两者都从后台列表消失。 */
+ @Test
+ void unbindingRemovesTheOnboardingAndItsSettlementCard() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("解绑");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+ Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
+ assertThat(adminSettlementService.bankCards(1, 20, fixture.key(), null, null, null, null, null).records()).hasSize(1);
+
+ Map<String, Object> result = service.unbind(applicationId);
+
+ assertThat(result).containsEntry("unbound", true);
+ assertThat(adminSettlementService.onboardings(1, 20, fixture.key(), null, null, null, null, null).records()).isEmpty();
+ assertThat(adminSettlementService.bankCards(1, 20, fixture.key(), null, null, null, null, null).records()).isEmpty();
+ assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_progress where application_id = ?",
+ Integer.class, applicationId)).isZero();
+ }
+
+ /** 该商户号上还有已收款未结算的订单时拒绝解绑:那是正在进行的对账,不能切断。 */
+ @Test
+ void unbindingIsRefusedWhileMoneyIsStillUnsettled() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("待结算");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+ Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
+ insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 1, /*refundStatus*/ 0);
+
+ assertThatThrownBy(() -> service.unbind(applicationId))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("已收款未结算");
+ assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where id = ?",
+ Integer.class, applicationId)).isEqualTo(1);
+ }
+
+ @Test
+ void unbindingIsRefusedWhileOrdersAreStillInFlight() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("在途订单");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+ Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
+ insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 0, /*refundStatus*/ 0);
+
+ assertThatThrownBy(() -> service.unbind(applicationId))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("在途订单");
+ }
+
+ /** 已取消/已退款的终态订单不拦解绑:那不是在途资金。 */
+ @Test
+ void settledOrRefundedOrdersDoNotBlockUnbinding() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("终态订单");
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+ Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
+ insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 2, /*refundStatus*/ 0);
+ insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 1, /*refundStatus*/ 2);
+
+ assertThat(service.unbind(applicationId)).containsEntry("unbound", true);
+ }
+
+ /** 自助进件的记录不支持解绑:那是商户自己提交的资料,删掉它是另一个性质的操作。 */
+ @Test
+ void aSelfOnboardedApplicationCannotBeUnbound() {
+ Fixture fixture = merchantFixture("自助进件");
+ jdbc.update("""
+ insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type,
+ subject_name, contact_name, contact_mobile, idempotency_key, status, source)
+ values (?, ?, ?, 'enterprise', '自助主体', '自助联系人', '13700000000', ?, 'pending', 'self')
+ """, fixture.merchantId(), fixture.storeId(), "MO-SELF-" + fixture.key(), fixture.key());
+ Long applicationId = jdbc.queryForObject("select id from merchant_onboarding_application where merchant_id = ?",
+ Long.class, fixture.merchantId());
+
+ assertThatThrownBy(() -> service.unbind(applicationId))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("不支持解绑");
+ }
+
+ /** 解绑只解除我方绑定:商户端登录账号与门店资料都不动,之后可以重新纳管。 */
+ @Test
+ void unbindingKeepsTheMerchantAccountAndAllowsAdoptingAgain() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("可重新纳管");
+ jdbc.update("insert into merchant_operator(merchant_id, account, password_hash, display_name, role_code, status, deleted) values (?, ?, 'x', '店主', 'owner', 1, 0)",
+ fixture.merchantId(), "t01op" + Math.abs(System.nanoTime()));
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+ Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
+
+ service.unbind(applicationId);
+ Map<String, Object> again = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
+
+ assertThat(again).containsEntry("status", "approved");
+ assertThat(again.get("id")).isNotEqualTo(String.valueOf(applicationId));
+ assertThat(jdbc.queryForObject("select count(*) from merchant_operator where merchant_id = ? and status = 1 and deleted = 0",
+ Integer.class, fixture.merchantId())).isEqualTo(1);
+ }
+
+ /** 该商家还没有商户端登录账号时纳管照做,但要提示运营,否则商户登不进来。 */
+ @Test
+ void adoptionWarnsWhenTheMerchantCannotSignInYet() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("未开登录");
+ jdbc.update("update merchant set can_login_merchant_app = 0 where id = ?", fixture.merchantId());
+ stubConvergedChannel(huifuId, "622200001234", "external-card-token");
+
+ Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId);
+
+ assertThat(String.valueOf(preview.get("loginNotice"))).contains("未开启商户端登录");
+ // 提示不阻挡纳管:纳管只管收款绑定,账号由「商家查询」负责。
+ assertThat(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved");
+ }
+
+ /** 通道没给出可用结算卡时不纳管:没有结算卡,进度不会推进、闸门也不会放行。 */
+ @Test
+ void adoptingIsRejectedWhenTheChannelReturnsNoUsableCard() {
+ String huifuId = uniqueHuifuId();
+ Fixture fixture = merchantFixture("无结算卡");
+ Map<String, Object> detail = channelDetail(huifuId, "622200001234", "token", "Y", true);
+ detail.put("qry_cash_card_info_list", List.of(Map.of("card_no", "6222****1234", "token_no", "masked-token")));
+ when(gateway.queryCapabilities(huifuId)).thenReturn(detail);
+
+ assertThat(service.preview(fixture.merchantId(), huifuId).get("blockers"))
+ .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
+ .anySatisfy(blocker -> assertThat(blocker).contains("结算银行卡"));
+ assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("结算银行卡");
+ }
+
+ private Long adoptedApplicationId(Long merchantId, String huifuId) {
+ return jdbc.queryForObject("select id from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?",
+ Long.class, merchantId, huifuId);
+ }
+
+ /** 通道侧一切就绪(六阶段 + 结算卡 + 商户状态正常)的商户详情回执。 */
+ private void stubConvergedChannel(String huifuId, String cardNumber, String token) {
+ stubConvergedChannel(huifuId, cardNumber, token, "攀枝花测试餐饮有限公司", "测试餐饮");
+ }
+
+ private void stubConvergedChannel(String huifuId, String cardNumber, String token, String registeredName, String shortName) {
+ when(gateway.queryCapabilities(huifuId)).thenReturn(channelDetail(huifuId, cardNumber, token, "Y", true, registeredName, shortName));
+ when(gateway.queryApplication(anyString(), anyString())).thenReturn(Map.of(
+ "resp_code", "00000000", "apply_status", "Y", "reg_status", "S", "bind_card_status", "S"));
+ when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code", "00000000", "atu_config_bo_list",
+ List.of(Map.of("fee_type", "02", "wx_config_list",
+ List.of(Map.of("config_type", "1", "config_value", "wxcfee477acb54d179"))))));
+ when(gateway.queryRealname(anyString())).thenReturn(Map.of(
+ "resp_code", "00000000", "applyment_stat", "APPLYMENT_STATE_PASSED", "authorize_stat", "1"));
+ }
+
+ /**
+ * 商户详细信息查询的回执:名称、主体类型、结算卡、微信场景、结算配置与延迟配置。
+ *
+ * <p>{@code delayFlag} 传 {@code N} 就是「通道侧还没开延迟入账」——自助进件在这一点上会下发一次
+ * 配置,纳管不允许下发。
+ */
+ private Map<String, Object> channelDetail(String huifuId, String cardNumber, String token, String delayFlag, boolean settlementConfigured) {
+ return channelDetail(huifuId, cardNumber, token, delayFlag, settlementConfigured, "攀枝花测试餐饮有限公司", "测试餐饮");
+ }
+
+ private Map<String, Object> channelDetail(String huifuId, String cardNumber, String token, String delayFlag,
+ boolean settlementConfigured, String registeredName, String shortName) {
+ Map<String, Object> detail = new LinkedHashMap<>();
+ detail.put("resp_code", "00000000");
+ detail.put("huifu_id", huifuId);
+ detail.put("reg_name", registeredName);
+ detail.put("short_name", shortName);
+ detail.put("cust_type", "1");
+ detail.put("ent_type", "3");
+ detail.put("legal_name", "测试法人");
+ detail.put("legal_mobile_no", "13700000009");
+ detail.put("contact_name", "测试联系人");
+ detail.put("contact_mobile_no", "13700000008");
+ detail.put("mer_stat", "NORMAL");
+ detail.put("delay_flag", delayFlag);
+ detail.put("forced_delay_flag", "N");
+ detail.put("qry_cash_card_info_list", List.of(Map.of(
+ "card_no", cardNumber, "token_no", token, "status", "N", "is_settle_default", "Y",
+ "bank_name", "中国工商银行", "branch_name", "中国工商银行攀枝花东区支行", "branch_code", "102290026507",
+ "area_id", "510400", "card_name", registeredName)));
+ List<Map<String, Object>> settleConfigs = settlementConfigured
+ ? List.of(Map.of("settle_status", "1", "settle_cycle", "D1", "settle_pattern", "P0",
+ "min_amt", "0", "remained_amt", "0", "token_no", token))
+ : List.of();
+ detail.put("qry_settle_config_list", settleConfigs);
+ detail.put("qry_wx_conf_list", wechatScenes());
+ return detail;
+ }
+
+ private static List<Map<String, Object>> wechatScenes() {
+ return List.of("1", "2", "3").stream().map(scene -> Map.of(
+ "pay_scene", scene, "fee_rate", "0.30", "fee_min_amt", "0.00",
+ "fee_charge_type", "1", "out_fee_flag", "2",
+ "wx_mer_infos", List.of(Map.of("bank_mer_code", "922609119", "pay_channel_id", "00016904", "is_default", "1"))
+ )).toList();
+ }
+
+ /** 一笔处于某种状态的订单;纳管记录会被订单上的收款绑定指回来。 */
+ private void insertOrder(Fixture fixture, Long applicationId, String huifuId, long payAmount, int orderStatus, int refundStatus) {
+ jdbc.update("""
+ insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, pay_amount,
+ order_status, verify_status, refund_status, collect_onboarding_id, collect_huifu_id)
+ values (?, 1, ?, ?, 'STORE_CONSUME', ?, ?, 0, ?, ?, ?)
+ """, "T01O-" + fixture.key() + "-" + payAmount + orderStatus + refundStatus, fixture.merchantId(),
+ fixture.storeId(), payAmount, orderStatus, refundStatus, applicationId, huifuId);
+ }
+
+ private Fixture merchantFixture(String label) {
+ String key = "T01" + label + System.nanoTime();
+ jdbc.update("""
+ insert into merchant(merchant_name, merchant_type, contact_name, contact_mobile, can_login_merchant_app, status, deleted)
+ values (?, '餐饮服务', '联系人', '13700000001', 1, 1, 0)
+ """, key);
+ Long merchantId = jdbc.queryForObject("select id from merchant where merchant_name = ?", Long.class, key);
+ jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, key + "门店");
+ Long storeId = jdbc.queryForObject("select id from merchant_store where merchant_id = ?", Long.class, merchantId);
+ return new Fixture(key, merchantId, storeId);
+ }
+
+ /** 每个用例用自己的商户号,避免共享测试库上互相串。 */
+ private static String uniqueHuifuId() {
+ return "6666" + String.format("%014d", Math.abs(System.nanoTime() % 100_000_000_000_000L));
+ }
+
+ private record Fixture(String key, Long merchantId, Long storeId) { }
+}
diff --git a/talent-card-server/src/test/resources/application-test.yml b/talent-card-server/src/test/resources/application-test.yml
index 9cb75d3..2b3d00e 100644
--- a/talent-card-server/src/test/resources/application-test.yml
+++ b/talent-card-server/src/test/resources/application-test.yml
@@ -6,6 +6,15 @@
username: ${TEST_DATASOURCE_USERNAME:sa}
password: ${TEST_DATASOURCE_PASSWORD:}
driver-class-name: ${TEST_DATASOURCE_DRIVER:org.h2.Driver}
+ hikari:
+ # 空闲上下文只留 1 条连接,按需再涨到 maximum-pool-size(默认 10)。
+ #
+ # 每个 @SpringBootTest 的上下文都缓存到整个 JVM 结束、各自持有一个连接池,而 Hikari 的
+ # minimum-idle 默认等于 maximum-pool-size(10),启动时就把 10 条建满——一次全量跑下来会累积
+ # 十几个上下文,测试库那个 8.0 实例的 max_connections=151 就被吃光,后加载的上下文直接以
+ # 「Too many connections / SQL State 08004」失败(2026-10-12 实测:一次全量里建了 15 个上下文,
+ # 150 条空闲连接)。连接池语义本身不变,只是不让**空闲**的上下文各占 10 条。
+ minimum-idle: 1
flyway:
enabled: true
jpa:
--
Gitblit v1.7.1