From 87eca64eeeec679933b7686111396a8f35951b41 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 16 九月 2026 00:27:41 +0800
Subject: [PATCH] refactor(merchant): 恢复路径改用申请单自身的汇付号,灰度门槛归 03
---
.scratch/merchant-wechat-onboarding/issues/04-wechat-realname-application.md | 3 ++-
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java | 33 ++++++++++++++++++++-------------
.scratch/merchant-wechat-onboarding/issues/03-new-enterprise-onboarding.md | 2 +-
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java | 2 --
.scratch/merchant-wechat-onboarding/ticket-plan.md | 10 ++++++----
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java | 4 ----
6 files changed, 29 insertions(+), 25 deletions(-)
diff --git a/.scratch/merchant-wechat-onboarding/issues/03-new-enterprise-onboarding.md b/.scratch/merchant-wechat-onboarding/issues/03-new-enterprise-onboarding.md
index a775b3e..d6e1ecf 100644
--- a/.scratch/merchant-wechat-onboarding/issues/03-new-enterprise-onboarding.md
+++ b/.scratch/merchant-wechat-onboarding/issues/03-new-enterprise-onboarding.md
@@ -11,7 +11,7 @@
- [ ] 进件表单的资料收录以 [04](04-wechat-realname-application.md) 新增的**法人手机号**为准(该字段属于实名的前置,04 不被阻塞可先落地);04 先落地则直接复用,不重复加字段。
- [ ] 商户可理解适用费率与待补材料,渠道技术参数由平台维护;不复制参考商户独有身份与资质。
- [ ] **分阶段手动推进**:进件完成后向商户提供可执行的“下一步”,商户推进微信支付业务开通;开通完成后推进微信实名认证;每阶段之后刷新查看通道结果。尚未完成的阶段不得显示为完成,缺证据时给出具体缺项。
-- [ ] 复用 04 已经通用化的分阶段进度与刷新(04 的第一项就是去掉只服务一张商户的恢复路径),本票只负责把新商户接入这套视图并提供“下一步”推进,不重复改造进度服务。
+- [ ] **放开分阶段进度的灰度门槛**:`MerchantOnboardingProgressService.supports()` 现在只对常量 `RECOVERY_HUIFU_ID = "6666000241335656"` 放行,新商户走不进这套视图。**这是本票的策略变更,不是清理**——换成「有结算卡且未被拒绝」会同时给所有商户切换行为:进件失败不再释放银行卡(`MerchantOnboardingRetryReleaseTests` 2 项)、回调改走分阶段刷新(`MerchantSettlementCallbackIdempotencyTests` 3 项),实测 5 个测试会红。这些测试编码的是「失败就关单重来」的旧策略,与规格「基本进件成功后的局部失败保留原商户号和银行卡」冲突,需要连同测试一起改,并明确被拒绝的申请仍要保留重新提交的入口。改完删掉那个常量。
- [ ] **微信业务开通的商户手动入口**:当前业务开通是进件后由回调或刷新在服务端静默执行的,存量补开入口 `HuifuOnboardingProgressGateway.openWechat` 仍是抛异常的占位(`"Wechat business profile is not confirmed"`)。需要给商户端与后台提供可推进的入口,同时保留服务端自动路径。
- [ ] 一次提交保存唯一申请关联;基础审核未确认成功前不越过前置条件,确认后继续微信与资金能力办理。
- [ ] 商户端与后台显示一致的基本审核及后续配置状态,提供刷新、具体失败提示和可执行下一步;普通状态读取保持只读。
diff --git a/.scratch/merchant-wechat-onboarding/issues/04-wechat-realname-application.md b/.scratch/merchant-wechat-onboarding/issues/04-wechat-realname-application.md
index 171f569..50aea18 100644
--- a/.scratch/merchant-wechat-onboarding/issues/04-wechat-realname-application.md
+++ b/.scratch/merchant-wechat-onboarding/issues/04-wechat-realname-application.md
@@ -6,7 +6,8 @@
Status: ready-for-agent
-- [ ] **第一步:清理进度服务的单商户硬编码。** 现有分阶段进度与刷新只对常量 `RECOVERY_HUIFU_ID = "6666000241335656"` 生效(`MerchantOnboardingProgressService.supports()`),随 02 关闭已无使用对象。按申请单通用地重做(`merchant_onboarding_progress` 已按 `application_id` 建表,结构上支持),并处置 `configureDelay`/`advanceDelay` 阶段与 `openWechat` 抛异常的占位。**先清理再往上加实名阶段**,否则本票的实名进度要建在即将删除的代码上。注意新商户的延迟入账随业务开通请求一并生效(`delay_flag=Y`/`forced_delay_flag=N` 已在请求里),不需要单独的补配步骤。
+- [x] **第一步已完成**:把恢复路径里的单商户硬编码换成申请单自身的 huifu_id——`advanceDelay` 与 `acceptDelayCallback` 改用该申请的 `providerHuifuId`,删除 `HuifuOnboardingProgressGateway.openWechat` 那个抛异常的占位(无调用方),并修掉视图里「微信行业规则仍待平台确认,暂不提交微信补开」的过期文案。
+- [ ] **`supports()` 的白名单保留不动**:把它换成「有结算卡且未被拒绝」会给所有商户同时切换行为——进件失败不再释放银行卡(`MerchantOnboardingRetryReleaseTests`)、回调改走分阶段刷新(`MerchantSettlementCallbackIdempotencyTests`),实测 5 个测试会红。那是 03 票的策略变更,不属本票。本票的实名阶段先只对白名单内的申请生效,由 03 放开范围。
- [ ] 新建实名申请通道适配:调用 `v2.merchant.busi.realname` 发起申请,走法人(`contact_type=LEGAL`),必填法人姓名、法人手机号、法人身份证号;企业另填 `owner` 与 `identification_address`。现有仓库只有 `v2.merchant.busi.realname.query` 查询,提交接口需要新建。
- [ ] 进件表单新增法人手机号并在申请记录持久化。这是走法人路线的唯一新增字段(法人姓名、证件号、证件有效期、证件居住地址已由现有进件采集)。不得复用联系人手机号冒充法人手机号。
- [ ] 发起前校验前置条件:商户必须已成功入驻微信、且所需能力已开通;材料不齐时给出具体缺项而不是提交后被驳回。材料上传复用现有图片上传通道,文件类型按官方要求。
diff --git a/.scratch/merchant-wechat-onboarding/ticket-plan.md b/.scratch/merchant-wechat-onboarding/ticket-plan.md
index db1e16f..f6eb5f6 100644
--- a/.scratch/merchant-wechat-onboarding/ticket-plan.md
+++ b/.scratch/merchant-wechat-onboarding/ticket-plan.md
@@ -29,15 +29,17 @@
### 执行顺序
-1. **04 第一步**:清理进度服务的单商户硬编码(删除 `RECOVERY_HUIFU_ID` 恢复路径,进度按申请单通用)。纯清理、体量小,做完两张票都落在干净地基上。
+1. **04 第一步(已完成)**:把恢复路径里的单商户硬编码换成申请单自身的 huifu_id,删除 `HuifuOnboardingProgressGateway.openWechat` 占位与视图里的过期文案。
2. **04 其余部分**:新建实名申请能力、法人手机号字段、授权状态闭环、收款就绪纳入实名。
-3. **03**:把新商户接入已通用化的分阶段视图,提供“下一步”推进与微信业务开通的商户手动入口。
+3. **03**:放开分阶段进度的灰度门槛(连同 5 个测试的策略改写),提供“下一步”推进与微信业务开通的商户手动入口。
-顺序依据:03 的第三个阶段就是微信实名,先落地 04 才能让 03 的阶段推进接在真能力上;而 04 必须先清理再往上加实名阶段,否则它的实名进度要建在即将删除的代码上。
+顺序依据:03 的第三个阶段就是微信实名,先落地 04 才能让 03 的阶段推进接在真能力上。
+
+**一处修正**:原先把「去掉 `RECOVERY_HUIFU_ID`、进度按申请单通用」算作 04 的清理,动手后发现站不住。`supports()` 不只是白名单,它还是「分阶段模型 vs 旧单状态模型」的路由开关:换成「有结算卡且未被拒绝」会同时给**所有**商户切换行为——进件失败不再释放银行卡(`MerchantOnboardingRetryReleaseTests` 2 项)、回调改走分阶段刷新(`MerchantSettlementCallbackIdempotencyTests` 3 项),实测 5 个测试会红。那是策略变更,归 03,不是清理。
### 两张票的冲突面
-- **进度服务**:已指派给 04(见执行顺序第 1 步);03 只接入已通用化的视图,不重复改造。
+- **进度服务**:04 只改恢复路径内部的 huifu_id 来源;`supports()` 的灰度策略由 03 放开,04 不动。
- **商户端进件表单**:04 加法人手机号,03 加行业与材料校验。04 先落地,03 在此基础上做增量。
- **收款就绪门槛**:实现在 04(`MerchantPaymentReadinessService` 补上微信业务开通与实名/授权);03 只做验收核对,不重复实现。
- **共享文件**:`application.yml`、`AdminBusinessService.java`、`CONTEXT.md` 等只做自己范围内的增量小改,不顺手整理。
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 8d019f7..c9cca38 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
@@ -16,6 +16,7 @@
/** Six independent evidence-backed stages; provider calls never hold application row locks. */
@Service
public class MerchantOnboardingProgressService {
+ /** 分阶段进度的灰度门槛;03 票把它换成通用规则时一并删除。 */
public static final String RECOVERY_HUIFU_ID = "6666000241335656";
private static final Map<String,String> LABELS = new LinkedHashMap<>();
static {
@@ -27,18 +28,21 @@
private final HuifuOnboardingProgressGateway gateway;
public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway) { this.jdbc=jdbc; this.gateway=gateway; }
+ /**
+ * 分阶段进度当前只对指定存量商户放行。
+ *
+ * <p><b>这仍是一道灰度门槛,不是通用规则。</b>把判断换成「有结算卡且未被拒绝」会同时给所有商户
+ * 切换行为:进件失败不再释放银行卡(`MerchantOnboardingRetryReleaseTests`)、回调改走分阶段刷新
+ * (`MerchantSettlementCallbackIdempotencyTests`),共 5 个测试会红。那是策略变更,属于 03 票
+ * 「分阶段手动推进 + 局部失败保留原商户号和银行卡」,不在 04 的清理范围内。
+ */
public boolean supports(Map<String,Object> application) { return RECOVERY_HUIFU_ID.equals(text(application.get("providerHuifuId"))); }
- public Map<String,Object> view(Long id) {
- Map<String,Object> application = application(id);
- if (!supports(application)) throw new BusinessException(ErrorCode.CONFLICT,"This recovery is limited to the designated existing merchant");
- return view(application);
- }
+ public Map<String,Object> view(Long id) { return view(application(id)); }
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
public Map<String,Object> refresh(Long applicationId, boolean retry) {
Map<String,Object> application=application(applicationId);
- if (!supports(application)) throw new BusinessException(ErrorCode.CONFLICT,"This recovery is limited to the designated existing merchant");
jdbc.update("insert ignore into merchant_onboarding_progress(application_id,stages_json) values (?, '{}')", applicationId);
String owner=UUID.randomUUID().toString();
if(jdbc.update("update merchant_onboarding_progress set sync_owner=?,sync_until=date_add(now(),interval 5 minute) where application_id=? and (sync_owner is null or sync_until<now())", owner, applicationId)==0) return view(application);
@@ -119,7 +123,7 @@
if ("Y".equals(audit) && "S".equals(text(response.get("reg_status"))) && capabilities != null
&& "00000000".equals(text(capabilities.get("resp_code"))) && "NORMAL".equals(text(capabilities.get("mer_stat")))
&& capabilities.containsKey("delay_flag") && capabilities.containsKey("forced_delay_flag")) {
- advanceDelay(applicationId,owner,stages,retry);
+ advanceDelay(applicationId,text(application.get("providerHuifuId")),owner,stages,retry);
}
jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
jdbc.update("""
@@ -166,7 +170,7 @@
&& LABELS.keySet().stream().allMatch(key -> "confirmed".equals(stages.get(key)));
}
- private void advanceDelay(Long id, String owner, Map<String,Object> stages, boolean retry) {
+ private void advanceDelay(Long id, String huifuId, String owner, Map<String,Object> stages, boolean retry) {
List<Map<String,Object>> attempts=rows("select * from merchant_onboarding_attempt where application_id=? and step='delay' order by id desc limit 1",id);
Map<String,Object> attempt=attempts.isEmpty() ? null : attempts.get(0);
if ("confirmed".equals(stages.get("delay"))) {
@@ -176,7 +180,7 @@
if (attempt != null && !"failed".equals(text(attempt.get("status")))) {
String applyNo=text(attempt.get("applyNo"));
if (!applyNo.isBlank() && !List.of("succeeded","confirmed").contains(text(attempt.get("status")))) {
- Map<String,Object> result=gateway.queryApplication(RECOVERY_HUIFU_ID,applyNo);
+ Map<String,Object> result=gateway.queryApplication(huifuId,applyNo);
if (result != null && "00000000".equals(text(result.get("resp_code"))) && List.of("N","F").contains(text(result.get("apply_status")))) {
jdbc.update("update merchant_onboarding_attempt set status='failed',response_code=? where id=? and status<>'confirmed'",text(result.get("resp_code")),attempt.get("id"));
attempt.put("status","failed");
@@ -195,7 +199,7 @@
if (inserted==0) return;
stages.put("delay","pending");
try {
- Map<String,Object> result=gateway.configureDelay(RECOVERY_HUIFU_ID,requestNo,date);
+ Map<String,Object> result=gateway.configureDelay(huifuId,requestNo,date);
String code=result==null ? "" : text(result.get("resp_code"));
String status=code.isBlank() ? "unknown" : List.of("00000000","00000100","90000000").contains(code) ? "pending" : "failed";
jdbc.update("update merchant_onboarding_attempt set status=?,apply_no=nullif(?,''),response_code=? where request_no=?",status,result==null ? "" : text(result.get("apply_no")),code,requestNo);
@@ -227,18 +231,21 @@
result.put("stages",progress); result.put("reason","请刷新进度,核实原申请与各项能力");
result.put("retryable", "failed".equals(stages.get("delay")));
result.put("status",complete(stages) ? "approved" : "pending");
- result.put("reason",complete(stages) ? "收款所需能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力;微信行业规则仍待平台确认,暂不提交微信补开");
+ result.put("reason",complete(stages) ? "收款能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力,并按待办理项补齐缺失能力");
return result;
}
/** Called only after the existing HTTP callback boundary has verified Huifu's signature. */
public Long acceptDelayCallback(Map<String,Object> data) {
+ // 用回调自带的 huifu_id 去匹配它自己的申请单,不再按固定商户号过滤。
+ String huifuId=text(data.get("huifu_id"));
+ if (huifuId.isBlank()) return null;
List<Map<String,Object>> matches=rows("""
select t.id,t.application_id from merchant_onboarding_attempt t
join merchant_onboarding_application a on a.id=t.application_id
where t.step='delay' and t.request_no=? and t.request_date=? and a.provider_huifu_id=?
- """,text(data.get("req_seq_id")),text(data.get("req_date")),RECOVERY_HUIFU_ID);
- if (matches.isEmpty() || !RECOVERY_HUIFU_ID.equals(text(data.get("huifu_id")))) return null;
+ """,text(data.get("req_seq_id")),text(data.get("req_date")),huifuId);
+ if (matches.isEmpty()) return null;
Map<String,Object> attempt=matches.get(0);
String audit=text(data.get("audit_status"));
String state="Y".equals(audit) ? "succeeded" : List.of("N","F","C").contains(audit) ? "failed" : "pending";
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java
index 41f2d30..37d0f8c 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java
@@ -47,10 +47,6 @@
return send("v2.merchant.busi.modify", request);
}
- public Map<String,Object> openWechat(String huifuId, String requestNo, Map<String,Object> profile) {
- throw new BusinessException(ErrorCode.CONFLICT, "Wechat business profile is not confirmed");
- }
-
protected Map<String,Object> send(String function, Map<String,Object> request) {
if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu payment is disabled");
try {
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
index 79b6e53..2337a99 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
@@ -96,7 +96,6 @@
var requests=org.mockito.ArgumentCaptor.forClass(String.class);
verify(gateway,times(2)).configureDelay(eq("6666000241335656"),requests.capture(),anyString());
assertThat(requests.getAllValues()).doesNotHaveDuplicates();
- verify(gateway,never()).openWechat(anyString(),anyString(),anyMap());
}
@Test
@@ -262,7 +261,6 @@
Map<String,Object> result=service.refreshOnboarding(new MerchantPrincipal(1L,merchant,store,"owner"),false);
assertThat(result).containsEntry("applicationNo",key).containsEntry("status","pending").containsEntry("syncError","汇付查询权限不足(20002),请联系平台运营核实接口权限");
assertThat(service.bankCards(new MerchantPrincipal(1L,merchant,store,"owner"))).isNotNull();
- verify(gateway,never()).openWechat(any(),any(),any());
assertThat(jdbc.queryForObject("select initial_bank_card_id from merchant_onboarding_application where application_no=?",Long.class,key)).isEqualTo(card);
}
}
--
Gitblit v1.7.1