yeg
2026-09-16 87eca64eeeec679933b7686111396a8f35951b41
refactor(merchant): 恢复路径改用申请单自身的汇付号,灰度门槛归 03

04 票的第一步。原计划是“删掉只服务一张商户的恢复路径、进度按申请单通用”,
动手后发现后半句不是清理,是策略变更,已退回并改派给 03。

- advanceDelay 与 acceptDelayCallback 改用该申请自己的 providerHuifuId,不再引用
固定商户号。延迟入账的自动补齐因此对每个申请单成立,而不是只对指定商户。
- 删除 HuifuOnboardingProgressGateway.openWechat:从无调用方的占位,只在测试里被
verify(never()) 引用,两处断言一并移除。补开现在由进件后的自动链路承担。
- 修掉分阶段视图里「微信行业规则仍待平台确认,暂不提交微信补开」的过期文案——
CID 已确认为 756、补开也已执行成功。
- supports() 的白名单保留不动。把它换成「有结算卡且未被拒绝」会同时给所有商户切换
行为:进件失败不再释放银行卡、回调改走分阶段刷新,实测 5 个测试会红
(MerchantOnboardingRetryReleaseTests 2 项、MerchantSettlementCallbackIdempotencyTests
3 项)。那些测试编码的是「失败就关单重来」的旧策略,与规格「基本进件成功后的局部
失败保留原商户号和银行卡」冲突,属于 03 的策略变更,需连同测试一起改,并保留
被拒绝申请的重新提交入口。

验证:全量 198 项,仅 docs/agents/testing.md 所列的 3 项基线失败,无新增。
6个文件已修改
54 ■■■■■ 已修改文件
.scratch/merchant-wechat-onboarding/issues/03-new-enterprise-onboarding.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/issues/04-wechat-realname-application.md 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/ticket-plan.md 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.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"`)。需要给商户端与后台提供可推进的入口,同时保留服务端自动路径。
- [ ] 一次提交保存唯一申请关联;基础审核未确认成功前不越过前置条件,确认后继续微信与资金能力办理。
- [ ] 商户端与后台显示一致的基本审核及后续配置状态,提供刷新、具体失败提示和可执行下一步;普通状态读取保持只读。
.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` 查询,提交接口需要新建。
- [ ] 进件表单新增法人手机号并在申请记录持久化。这是走法人路线的唯一新增字段(法人姓名、证件号、证件有效期、证件居住地址已由现有进件采集)。不得复用联系人手机号冒充法人手机号。
- [ ] 发起前校验前置条件:商户必须已成功入驻微信、且所需能力已开通;材料不齐时给出具体缺项而不是提交后被驳回。材料上传复用现有图片上传通道,文件类型按官方要求。
.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` 等只做自己范围内的增量小改,不顺手整理。
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";
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 {
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);
    }
}