# 批次三-B:创建商户 + 入网流程 —— 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 把上游「商户进件」这条腿打通到「代码可交付、凭据到位即可通电」—— 上游 `/api/merchant/*` 的七条调用腿、一个审核通知接收端、租户归属闸、以及配套的管理端进件页。 **Architecture:** 沿用三-A 已建立的形状:出站全部经 `LeshouyingClient.postApi`(自动签名、自动剥信封),服务层集中在 `LsyOnboardingService`,纯规则(必填矩阵)单独成类以便零网络单测。新增一个 `LeshouyingOnboardingController` 承载进件端点,**每个带 `mchNo` 的端点必须先证明该商户属于调用方租户**(§6.6.4 的 IDOR 面)。审核通知走既有的免登录回调前缀。 **Tech Stack:** Java 17 / Spring Boot 3 / MyBatis-Plus / Hutool / JUnit 5 + Mockito;前端 Vue 3 + Element Plus(现有 admin 工程)。 **Spec:** `docs/superpowers/specs/2026-09-22-receive-workbench-design.md`(绑定文件;本计划从它论证) **上游原文(本次逐页取回,逐字转写):** `.superpowers/sdd/2026-09-23-lsy-onboarding-B/upstream-docs.md`,分页原始件同目录 `_txt-*.md`。**本计划里每一处字段名、值域、必填判定都以这些转写件为准**;与 spec §6 冲突时以转写件为准(spec 是概述,转写件是原文)。 --- ## Global Constraints 1. **文案零上游痕迹**(spec §8.5)。任何到达租户眼前的字符串 —— 异常 message、页面文案、应答字段名 —— 不得出现上游名称、域名、其专有字段名。`GlobalExceptionHandler` 把 `BusinessException` 的 message **原样**回给调用方,所以这条对每一条新 message 都成立。★★ **订正(2026-09-23)—— 本行订正过两次,第二版已被推翻,现为第三版。** 第一版写「既有反例已被清除(`663156f`)」;第二版改成「`LsyOnboardingService` 的 `createShop` 502 文案里含 `shop_no`(**上游专有字段名**)⇒ 越线,归 Task 3 一并处理」。**第二版是错的,据以派活就是派错活**:`shop_no` **是我们自己的名字** —— ① 本计划 Task 7 节自己列出的 `pay_lsy_merchant` 列清单里就有 `shop_no`(★ 2026-09-23 订正:原写「Task 7 节」—— 实测该清单在 **Task 6 节**,即「V1.8 的列序**逐字**是…= **11 列**」那一处;Task 7 节里 `shop_no` 零命中),与建表语句(`java/src/main/resources/db/migration/V1.8__create_pay_lsy_tables.sql` 的 `shop_no VARCHAR(32)`)逐字一致;② `LeshouyingMerchantController` 直接回 `LsyMerchantEntity` 实体,而 Jackson 是全局 `PropertyNamingStrategies.SNAKE_CASE` ⇒ `shop_no` 就是我们**发布给运维**的应答字段名;`out_trade_no`/`trade_no`/`mch_no`/`enterprise_id` 同理。**我们对外发布的名字,不可能是「上游专有」的名字** ⇒ 该文案**不构成 §8.5 违规,Task 3 不改它、也不扩它的用例断言**(裁定 R32;本案 `LsyOnboardingService` 全主源码零注入点,那句 502 今天还到不了任何租户眼前)。**判据(防再凭语感判)**:判「上游专有键名」必须两查 —— 查建表语句有没有这一列 + 查有没有对外 DTO/实体把它回给租户。**这是一条持续约束,不是一次性修复** —— 对**新写**的 message 依然成立。 2. **验签绝不跳过**(spec §8.1)。进件接口同样走既有签名逻辑,不开任何旁路;审核通知入口无认证,签名是唯一身份证明。 3. **凭据不进仓库**(spec §8.6)。`channel_id` / `bd_id` / `channel_type` / `rate_code` 一律走配置 + 环境变量,照现有 `${ENV:}` 写法。 4. **不留「测试用假值」**(spec §6.4 逐字)。配置项默认值一律留空;**不得**为了跑通而往 `application.yml` 填一个看似合理的数。 5. **秘密只打印长度,不打印值**(spec §8.7)。 6. **SQL 由我写、只有用户执行**。本计划产出 SQL 文件并给出只读核对语句,执行动作留给用户。 7. **在凭据到位前,三-B 不部署、不接生产**(spec §6.4)。代码可以写完。 8. **联网行为不做自动化测试**(spec §9)。三-B 只有「字段组装 / 必填校验 / 应答解析」的单测;拿到凭据后手工跑一次并留证据。 9. **`mvn` 一律用绝对路径**;**不得**加 `-proc:none`,**不得**加 `-q` —— 两者都会悄悄毁掉验证。 10. **不 push**。本批次所有提交留在本地 `java` 分支。 11. **`mvn test` 需要库隧道在位**(`PaymentApplicationContextTest` 的已知硬依赖,spec §9 末尾)。 12. **同一批文件的多次改动若按文件拆不开**,按 memory `split-commit-without-interactive-add` 的「时序重放」法拆提交(本环境禁 `git add -p` / `add -i`)。 --- ## §0 本次取证得到的、实现前必须知道的事实 > 全部来自 `.superpowers/sdd/2026-09-23-lsy-onboarding-B/_txt-*.md`(逐字原文转写)与站点菜单树(142 页全量,映射见同目录 `_pagemap.tsv`)。 ### 0.1 八条接口的准确坐标 | # | 页面 | page_id | 路径 | 本批次任务 | |---|---|---|---|---| | 1 | 创建商户 | 196236555 | `/api/merchant/create` | Task 3 | | 2 | 更新商户信息 | 196236557 | `/api/merchant/update` | Task 4 | | 3 | 提交商户入网信息 | 196236563 | `/api/merchant/commitMerchantInfo` | Task 4 | | 4 | 查询商户入网状态 | 196236564 | `/api/merchant/reportQuery` | Task 4 | | 5 | 获取行业类目(根据通道) | 196236569 | `/api/merchant/getTopMccByType` | Task 5 | | 6 | 安心签个人版开户发送验证码 | 196236565 | `/api/merchant/anxinSignPerson` | Task 5 | | 7 | 企业和个体安心签开户发送验证码 | 196236566 | `/api/merchant/anxinSignCompany` | Task 5 | | 8 | 商户审核异步通知 | 196236556 | **我方提供** | Task 6 | ★ 第 6、7 项的**菜单标题带「发送验证码」后缀**,spec §6.1 转写时丢了这个后缀。两页的 `接口描述` 是**空的**,参数表却是完整开户载荷(含身份证、结算卡号)。⇒ 无法从文档判定它到底是「只发短信」还是「开户并触发短信」。**按参数表实现(请求发全量字段)**,把标题歧义记进方法 javadoc。这是可实测项。 ### 0.2 创建商户:必填 **27** 个(独立复核过两次) `inst_no`、`trace_no`、`sign`、`notify_url`、`merchant_name`、`merchant_name_short`、`merchant_name_business`、`merchant_mcc_code`、`channel_id`、`bd_id`、`contact_name`、`contact_phone`、`contact_email`、`contact_service`、`address_province`、`address_city`、`address_county`、`address_street`、`merchant_type`、`account_type`、`account_idcard_num`、`account_idcard_expire`、`account_card_name`、`account_card_num`、`account_card_phone`、`account_bank_code`、`rate_code` 另有 5 个 `必填=C` 的条件必填:`reg_capital`、`fSettle_account_type`、`actual_addr`、`business_scope`(四者说明均写「平安收银宝必填」)、`check_code`(写「星驿付必填」)。 ### 0.3 ★ 附表(7 类商户 × 必填):与参数表**冲突**,且 spec §6.2 未记录 参数表的「必填」列把 `license_type` / `license_no` / `license_expire` / `legal_name` / `legal_cert_no` / `legal_cert_expire` **全部留空**;而页末附表把前三个对**除小微外全部 6 类**标 √,把后三个对**企业对公 / 企业对私法人 / 企业对私非法人 / 个体工商户对私非法人**标 √。 **裁定:以附表为准。** 依据有二:① 创建商户页 `接口描述` 自己写着「各类型商户进件所需的资料,详见[商户进件所需资料]」,即该页**指名**附表为分档必填的权威;② 参数表的「必填」列只有「是 / 空 / C」三态,没有「按类型」这一态,其「空」只能读成「不无条件必填」,读不出「永远不必填」。 **代价若判错**:某些类型的商户在进件时少送 `license_no` 等字段 ⇒ 上游拒,报错点在上游不在我们 —— 但请求体在日志里,可当场比对附表改回。 附表逐行(照原文转写,`√`=必填、空=不必填、`-`=当且仅当 `license_type=0` 时必填): | 字段 | 企业对公 | 企业对私法人 | 企业对私非法人 | 个体对公法人 | 个体对私法人 | 个体对私非法人 | 小微 | |---|---|---|---|---|---|---|---| | merchant_type(取值) | 1 | 1 | 1 | 2 | 2 | 2 | 3 | | account_type(**原文错误值**) | 1 | 2 | 2 | 1 | 2 | 2 | 2 | | license_type | √ | √ | √ | √ | √ | √ | | | license_no | √ | √ | √ | √ | √ | √ | | | license_expire | √ | √ | √ | √ | √ | √ | | | legal_name | √ | √ | √ | | | √ | | | legal_cert_no | √ | √ | √ | | | √ | | | legal_cert_expire | √ | √ | √ | | | √ | | | account_idcard_num | √ | √ | √ | √ | √ | √ | √ | | account_idcard_expire | √ | √ | √ | √ | √ | √ | √ | | account_card_name / num / phone | √ | √ | √ | √ | √ | √ | √ | | account_bank_code | √ | √ | √ | √ | √ | √ | √ | | picture_license | √ | √ | √ | √ | √ | √ | | | picture_idcard_a | √ | | √ | √ | | √ | √ | | picture_idcard_b | √ | | √ | √ | | √ | | | picture_bankcard_a | | √ | √ | | √ | √ | √ | | picture_bankcard_b | | √ | √ | | √ | √ | √ | | picture_outside | √ | √ | √ | √ | √ | √ | √ | | picture_inside | √ | √ | √ | √ | √ | √ | √ | | picture_tripartite | | | | | | | | | picture_idcard_hander | | | | | | | √ | | picture_open_account | √ | √ | √ | √ | √ | √ | | | picture_organization | - | - | - | - | - | - | | | picture_tax_registration | - | - | - | - | - | - | | | picture_unincorporated | | | √ | | | √ | | | picture_bank_idcard_a | | √ | √ | | √ | √ | √ | | picture_bank_idcard_b | | √ | √ | | √ | √ | √ | | picture_other | | | | | | | | ★★ **附表自己有两处不对称,照抄不修**:`picture_idcard_a` 对小微标 √ 而 `picture_idcard_b` 对小微留空;`picture_license` 对小微留空。**不要**「顺手补全」—— 补全就是发明原文没有的规则。实现按表逐格。 ★ 附表里 `~~settlement_type~~` 被**划掉**(作废),且参数表里根本没有这个字段 ⇒ **不实现**。 ### 0.3b ★★★ 附表那一行 `account_type` 是**错的** —— 七类必须按修正后的键重排 附表把 `account_type` 写成 `1|2|2|1|2|2|2`,于是**七列塌成五个键**:`企业对私法人` 与 `企业对私非法人` 都成了 `(1,2)`,`个体工商户对私法人` 与 `个体工商户对私非法人` 都成了 `(2,2)`。而这两列在附表里**要求不同**(`picture_unincorporated` 只有非法人那列是 √)⇒ 若照抄附表,这个必填差异**在代码里无法表达**。 **决定性依据在参数表第 49 行**(`account_type` 的说明栏,逐字): > `1对公结算(个体不支持对公) 2法人对私结算 3非法人对私结算 4 小微对私` 值域有 **4** 个值,不是 2 个。而附表那七个列名与这四个值是**逐字同名**的: | 附表列名 | `account_type` 说明栏里的名字 | ⇒ `account_type` | |---|---|---| | 企业对公 | `1对公结算` | 1 | | 企业对私法人 | `2法人对私结算` | 2 | | 企业对私非法人 | `3非法人对私结算` | 3 | | 个体工商户对公法人 | `1对公结算` | 1 | | 个体工商户对私法人 | `2法人对私结算` | 2 | | 个体工商户对私非法人 | `3非法人对私结算` | 3 | | 小微商户 | `4 小微对私` | 4 | ⇒ **裁定:`account_type` 取值按本表(`1|2|3|1|2|3|4`),附表的 `1|2|2|1|2|2|2` 是文档错误。** 这是**两表同名项逐字对照**读出来的,不是推断、不是补全;七个键重排后**唯一且不撞**(照抄附表则撞两对)。 ⇒ 连带结论:**七类是 `(merchant_type, account_type)` 的组合**,不是「`merchant_type` 三选一」。附表那七个列名就是这七个组合的名字,也是**唯一合法的七种组合**(其余组合文档未列 ⇒ 不支持)。 ### 0.3c ★ 参数表里还有 8 个**附表没列**的字段 逐行比对过两张表(参数表 24-88 行 vs 附表 156-203 行): | 字段 | 参数表说明栏 | |---|---| | `license_stt` | 营业执照开始 | | `legal_cert_stt` | 法人证件开始时间 | | `account_idcard_stt` | 持卡人身份证开始时间 | | `lease_contract` | 租赁合同 —— 平安收银宝必填 | | `p_cashier` | 店铺收银台 —— 平安收银宝必填,星驿付必填 | | `share_scene` | 分账场景(选填)需要开通资金分账必传 | | `share_certificate` | 分账证明(选填)需要开通资金分账必传 | | `tripartite_flow_certificate_url` | 三方流量证明(选填)需要开通电商平台分账必传 | **反向不存在**:附表里没有任何字段是参数表没有的。 ⇒ 这 8 个都**不是**无条件必填(附表没有它们,且说明栏里要么是「开始时间」要么带通道/分账条件)⇒ **不进必填矩阵**,但**进 DTO**(要能上送)。 ### 0.3d ★★ 参数表说明栏里的**通道条件必填**,本批次一律不收 `reg_capital` / `fSettle_account_type` / `actual_addr` / `business_scope`(平安收银宝)、`check_code`(星驿付、安心签验证码)、`lease_contract` / `p_cashier`(平安收银宝 / 星驿付)、以及 `picture_*` 那一串说明栏里的通道名 —— 这些**全部是「按通道」的条件**,而**通道是配置项、此刻未知**(§0.8 同源)。 ⇒ **裁定:必填矩阵只收附表那套「按商户类型」的 √,通道条件一概不收。** 字段照常进 DTO、照常上送(`不收 ≠ 放行`)。 **代价若判错**:通道落定为平安收银宝/星驿付后,操作者漏填 `reg_capital`、`p_cashier` 之类,上游拒 —— 报错点在上游,请求体在日志里,可当场比对说明栏补。**这条要进最终验收的待确认清单。** ### 0.4 `reviewed_status` 三套值域,各自实现、不归一化(spec §6.2(b) / §6.4 三-C) | 归属 | 值域 | 本批次怎么落 | |---|---|---| | 主动查询(page 196236567) | `0/1/9/10/98/99` | **三-A 已实现**(`LsyReviewStatus`),不动 | | 异步通知(page 196236556) | 参数表 `1.审核通过,2.审核驳回`(类型标称 `String(4)`);**同页示例写的是中文字符串 `"审核通过"`** | Task 6 **另建**一个枚举 | | 入网状态(page 196236564) | 返回参数表 `0审核中 1审核通过 2审核驳回 3预审核通过 4待提交 5已提交 6重新进件`;**同页接口描述块是另一套** `0已发起进件 1返回渠道子商户进件流程成功的终态 2重新修改信息进件 3银行审核中 4需要调用入网接口 5银行审核中 6需要重新调用创建商户接口` | Task 4:**不建枚举**,原样透传字符串 + 两个映射都抄进 javadoc 指向文档 | ★ `LsyReviewStatus` 的类注释已明写「本枚举不通用 —— 不得拿它去解别的接口的 `reviewed_status`」「入网状态那一套属三-B,用到时另建,不要扩本枚举」。**遵守它。** ### 0.5 文档缺陷清单(实测,逐条对上原文) | 页 | 缺陷 | 对实现的影响 | |---|---|---| | 196236556 | 参数表 10 个字段,请求示例只给 6 个;`reviewed_status` 表里是数字、示例是中文;示例多出表里没有的 `settlement_status`;**`wx_channel_sub_mch_id` 在表里出现两次**(第二行说明写「银联子商户号」) | Task 6 只认参数表里**有意义的**字段;重复键按「第二个是银联子商户号、键名是文档打错」处理 —— **不猜正确键名**,需要时不读它 | | 196236563 / 564 / 565 / 566 | **返回示例全部为空**(只有空代码块);请求示例**全是「获取省市区」页原样复制**(`{"inst_no":"900075","level":"1","sign":"..."}`) | 这四条的**应答结构只能等实测**。实现按各自的「返回参数表」解析,缺键一律返回 null / 空,**不抛** | | 196236569 / 570 | 两页 `接口描述` 逐字相同且都是「获取省市区」的文案(复制残留);**返回参数表写的是 `area_list`/`area_name`/`area_code`,而返回示例给的是 `mcc_list`/`mcc_name`/`mcc_code`** | Task 5 按**示例**取 `mcc_list`;键缺失回空列表(照三-A `toAreaItems` 先例),**不抛** | | 196236569 | `merchant_type` 的说明是 `1个体工商户 2企业 3政府组织及事业单位 4小微商户 5其他机构组织`,**与创建商户的三处一致值域把 1 和 2 对调** | 该参数是 `否`(非必填)且「星驿付必传」⇒ Task 5 原样透传调用方给的值,**不做本地校验**(校验就是猜) | | 196236557 | ~~`license_stt` 与 `license_expire` 的说明都是「营业执照有效期」(重复文案)~~ **★ 2026-09-23 主控订正(R52):这句是转述,与逐字转写不符** —— 逐字是 `:42` `license_stt` =「营业执照有效期(格式YYYYMMDD)」、`:43` `license_expire` =「营业执照有效期(格式YYYYMMDD)**长期传20991231**」⇒ 两行**不是**同一句;且本页说明虽通用,**创建页 `_txt-196236555.md:46-47` 对同名两字段写的是「营业执照开始」/「营业执照结束」**,§0.3c(本文件 `:131`)也已据此记作「营业执照开始」⇒ **本行原写的「语义不明」不成立**。`account_idcard_stt` 在此页 `String(8)`、在创建页 `String(30)`(这条未变) | **裁定不变:按各自页的类型发;`license_stt` 原样透传、不加校验。** —— ★ 只推翻本行原句的**前提**(不实),**不推翻结论**:不加校验的理由是「本模块 50 多个字段一个都不校验」,**不是**「语义不明」(校验就是猜那半句仍适用于 §0.5 其余各行)。Task 4 修复轮 1 据此订正代码里那句 javadoc | ### 0.6 ★ 菜单树里 spec §6.1 **未列**的页(本批次不做,但要记账) 站点「商户接口」下共 20 页,spec §6.1 的清单漏了 5 页。逐条判过,**没有一页是本批次必须做的**,但其中两条推翻了 spec §6.3 的阻塞判断: | page_id | 标题 | 路径 | 判断 | |---|---|---|---| | 196236571 | 获取银行信息 | `/api/info/bank` | ★★ **推翻 §6.3 #4**。见 §0.7 | | 196236570 | 获取行业类目信息 | `/api/info/mcc` | ★ **与 196236569 是两个不同接口**(同级兄弟页)。入参只有 `inst_no`/`level`/`parent_code`/`sign` —— **不需要 `channel_type`**,即 §6.3 #5 对它不成立。**本批次不实现**(spec §6.1 只列了 569),只记账 | | 196236561 | 根据授权token换取平安签约地址 | — | 平安收银宝**通道专属**流程步。通道未定 ⇒ 不做 | | 196236562 | 平安查询签约状态 | — | 同上 | | 196236909 / 196236911 | 资料收集 / 根据服务商查询下属商户 | — | 与本批次目标无关 | **裁定:本批次严格实现 spec §6.1 的集合,上面 5 页一律不实现。** 理由:spec 是绑定文件;570/571 都要等通道与凭据落定后才知道要不要用;而整条腿在凭据到位前本就不通电,多实现一层只是多一层没通电的代码。 **代价若判错**:将来要加 570 或 571,各是一个薄服务方法 + 一个 DTO(约 40 行)—— 加法、可逆。 ### 0.7 ★ `account_bank_code` 的定性被改正(推翻 spec §6.3 #4) spec §6.3 #4 写「`account_bank_code`(结算银行支行联行号)—— 要真实联行号,编不出来」,把它列为**平台级凭据**。**这条定性错了**:它是**商户自己的**结算账户开户支行,属结算信息,逐商户填 —— 不是我们从客户经理那里领的凭据。 而且 `/api/info/bank`(page 196236571)**提供了它的取值来源**:`level=1` 取总行列表,`level=2` 配合 `parent_code`(总行 code)与 `city_code`(由省市区接口 `level=2` 取)拿支行列表,**`level=2` 时返回的 `bank_code` 就是统一联行号**(示例 `103673767189`,与创建商户参数表示例 `103651081044` 同形)。该接口入参只有 `inst_no`/`level`/`sign`,**零凭据依赖**。 ⇒ **§6.3 的实际剩余阻塞项从 5 条收敛为 4 条**:`channel_id`、`bd_id`、`rate_code`、`channel_type`(外加 #6 上游侧通知地址配置、#7 测试环境差异)。`account_bank_code` 改为**表单字段**(Task 2/3 的 DTO 字段 + Task 9 的输入项)。 ⇒ `/api/info/bank` 本批次**不实现**(不在 spec §6.1 集合内,且联行号可手工录入)。记账,供用户在最终验收时决定要不要补一个选择器。 ### 0.8 `rate_code` 的归属是未定的 `rate_code`(费率模板)在创建商户里是必填,在更新商户里**不是**必填。它「实际填什么要谈」(spec §6.3 #3)。它可能是**平台级**(我们谈下来的一个费率),也可能是**逐商户**的。 **裁定:先按平台级配置项实现**(Task 1 的 `rate-code`),DTO 里同时留一个可覆盖字段。**代价若判错**:若其实逐商户,操作者会用配置里的同一个值给所有商户进件 ⇒ 费率错。这条**必须进最终验收的「待用户确认」清单**,不能只在代码里。 ### 0.9 实现前必须知道的三条既有代码事实 1. **`pay_lsy_merchant.review_status` 的本地词表未定义。** DDL 是 `VARCHAR(32) NOT NULL DEFAULT 'APPROVED'`,注释只写「A 阶段默认 APPROVED;B 阶段由进件写入口与上游审核通知回写」。**没有**任何地方枚举过 B 阶段该写什么值。 ★ **裁定:定义本地词表 `PENDING` / `APPROVED` / `REJECTED`。** 进件写入口写 `PENDING`(**不得**落到 DDL 默认的 `APPROVED` —— 那会让「刚进件、还没审核」在管理端读成「已通过」);审核通知按 556 自己的值域映射到 `APPROVED` / `REJECTED`。 这**不是** spec §6.4 禁的那种「归一化」:那条禁的是把上游**三套** `reviewed_status` 值域互相猜着合并;这里是给**本地**一列定词表,且只从 556 这一套推导。 **代价若判错**:词表值取错 ⇒ 管理端显示错、且 `LsyMerchantService.save` 新增分支的默认值(`APPROVED`)与它并存时语义要重看。 2. **`LsyMerchantService.requireByMchNo` 的 wrapper 里不带租户条件,且 `pay_lsy_merchant` 在 `mch_no` 上没有唯一索引**(唯一约束是 `(tenant_id, enterprise_id)`)。其 javadoc 自己写着「查询为 `LIMIT 1` 且无 `ORDER BY`,一旦存在重复 `mch_no`,返回的是**任意一行**。调用方须自行确认 `mch_no` 的唯一性」。 ⇒ **认证端点绝不先调它再在 Java 里比对 tenantId**(那样已经把别人的 PII 读进内存了 —— §6.6.4 的整改要害)。Task 7 新增带租户条件的查询。 ⇒ **免登录的通知入口没有租户上下文**,它才是 `requireByMchNo` 的正当调用方。 ★★ **但该入口的安全性不来自唯一性** —— 可不可达由**验签**决定(`onboardingNotify_rejectsBadSign`),读到哪一行由**查询形状**决定(不得 `LIMIT 1` / `selectOne`,命中 ≠1 行必须响亮失败 —— 见 Task 6 Step 0 第 1 道闸);`uk_lsy_merchant_mch_no` 是**第二道闸**,且**今天不存在**(库里有重复行挡着)。★ 订正留痕:本行原写「那里签名 + 唯一索引即全部依据(Task 6 Step 0)」,那句在两种读法下都不成立(索引今天不存在 ⇒ 不可能是「全部依据」)。 ★★ **但「无租户条件」不等于「实际没过滤」** —— 本仓 `TenantInnerInterceptor` 会对已认证的非超管主体**自动追加** `AND tenant_id = ?`。真实机制、以及为什么仍要显式闸,见 **Task 7 的 Step 0**。不要把本条读成「拦截器没在做事」。 3. **`LsyMerchantService.save` 的新增分支会把 `review_status` 默认成 `APPROVED`。** 实体与服务两处 javadoc 都明写三-B 要**另起写入口、不要复用 `save`**。Task 3 与 Task 6 都必须遵守。 --- ## 文件结构(先定边界) **新建** | 文件 | 职责 | |---|---| | `.../leshouying/enums/LsyMerchantCategory.java` | **七类商户组合**(各带 `merchant_type` + `account_type`),承载 §0.3b 的值域修正与 §6.2(a) 的裁定 | | `.../leshouying/enums/LsyOnboardingReviewStatus.java` | **仅** page 196236556 的值域(含数字与中文两种形态) | | `.../leshouying/service/LsyMerchantRequiredFields.java` | **纯规则类**:§0.2 的 27 个无条件必填 + §0.3 的 7 类分档矩阵。零依赖、零网络,是本批次测试最密的地方 | | `.../leshouying/dto/LsyMerchantCreateDTO.java` | 创建商户的字段载体 | | `.../leshouying/dto/LsyMerchantUpdateDTO.java` | 更新商户的字段载体(增量语义) | | `.../leshouying/dto/LsyCommitMerchantInfoDTO.java` | 提交入网信息的字段载体 | | `.../leshouying/dto/LsyMccItemDTO.java` | 行业类目条目(`mcc_name` / `mcc_code`) | | `.../leshouying/controller/LeshouyingOnboardingController.java` | 进件端点 + **租户归属闸** | | `java/src/main/resources/db/lsy-onboarding-menu.sql` | 菜单与权限(**我写,用户执行**) | | `java/src/main/resources/db/lsy-review-status-comment.sql` | 把 §0.9-1 的本地词表写进列注释(**我写,用户执行**) | | `frontend/src/views/module_payment/collection/onboarding/index.vue` | 进件页 | | `frontend/src/api/module_payment/onboarding.ts` | 进件页的 API 层 | **修改** | 文件 | 改什么 | |---|---| | `.../leshouying/config/LeshouyingProperties.java` | +5 个配置项与凭据缺失清单(**不进 `validate()`**) | | `java/src/main/resources/application.yml` | +5 个键,默认值全空 | | `.../leshouying/dto/LsyAreaItemDTO.java` | +`provinceCode`/`cityCode`/`countyCode`(spec §6.6.3) | | `.../leshouying/service/LsyOnboardingService.java` | +7 条上游腿 + `toMccItems` | | `.../leshouying/service/LsyMerchantService.java` | +带租户条件的 `requireByMchNoForTenant` + 审核状态写入口 | | `.../leshouying/controller/LeshouyingNotifyController.java` | +进件审核通知映射 | | `frontend/src/router` 与菜单 | 进件页路由 | --- ## Task 1: 进件配置项与凭据闸 **Files:** - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java` - Modify: `java/src/main/resources/application.yml` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingPropertiesTest.java` **Interfaces:** - Consumes: 无 - Produces: `String getChannelId()`、`String getBdId()`、`String getChannelType()`、`String getRateCode()`、`String getOnboardingNotifyUrl()`、`List missingOnboardingCredentials()` - [ ] **Step 1: 先写失败的测试** 在 `LeshouyingPropertiesTest` 里加四条。三条是负向、一条是**正对照**(正对照是第 4 条,它钉住「我们**没有**把这五项塞进启动校验」这个裁定): ```java @Test void missingOnboardingCredentials_listsAllFive_whenAllBlank() { LeshouyingProperties p = new LeshouyingProperties(); List missing = p.missingOnboardingCredentials(); assertEquals(5, missing.size(), "五项全缺时必须一次列全,不能只报第一个"); } @Test void missingOnboardingCredentials_isEmpty_whenAllSet() { LeshouyingProperties p = new LeshouyingProperties(); p.setChannelId("96"); p.setBdId("288"); p.setChannelType("1"); p.setRateCode("RT0045"); p.setOnboardingNotifyUrl("https://example.invalid/notify"); assertTrue(p.missingOnboardingCredentials().isEmpty()); } @Test void missingOnboardingCredentials_listsOnlyTheMissingOne() { LeshouyingProperties p = new LeshouyingProperties(); p.setChannelId("96"); p.setBdId("288"); p.setChannelType("1"); p.setRateCode("RT0045"); // onboardingNotifyUrl 留空 assertEquals(1, p.missingOnboardingCredentials().size()); } @Test void validate_passesWhenOnboardingCredentialsAreAbsent() { // ★ 正对照:本用例钉住 —— 进件凭据**不得**进 validate()(依据:Global Constraint 3/4 + spec §6.4)。 // 若是进了,本仓的 PaymentApplicationContextTest(@SpringBootTest,吃 src/main/resources/ // application.yml)会连同整个应用启动一起失败,而 §6.4 又禁止我们往那份 yml 填假值。 LeshouyingProperties p = new LeshouyingProperties(); p.setInstNo("real-inst"); p.setKey("real-key"); p.setBaseUrl("https://real.example.invalid/yfpay"); p.setNotifyUrl("https://real.example.invalid/notify"); p.setActiveProfiles("dev"); assertDoesNotThrow(p::validate); } ``` - [ ] **Step 2: 跑测试确认失败** Run: `D:\project2\payment-platform\java\mvnw` 不可用(见 memory `java-build-commands`),用绝对路径 mvn 只跑这一个类。 Expected: 编译失败 —— `missingOnboardingCredentials` 不存在。 - [ ] **Step 3: 实现** 在 `LeshouyingProperties` 里加五个 `String` 字段(**全部 String,不要 Integer** —— 五个配置项都要能吃 `${ENV:}` 的空默认值,`Integer` 绑空串会抛转换异常,而那正好发生在「凭据还没配」的正常场景): ```java /** 进件通道 id(§6.3 #1,由客户经理提供)。**不给默认值。** */ private String channelId; /** 进件业务经理 id(§6.3 #2,由客户经理提供)。**不给默认值。** */ private String bdId; /** 行业类目接口的通道类型(§6.3 #5,由客户经理提供)。**不给默认值。** */ private String channelType; /** 费率模板(§6.3 #3)。格式规则见文档:万分之四十五填 RT0045。归属见计划 §0.8。 */ private String rateCode; /** 进件审核通知地址(我方固定地址,spec §6.5)。**与 notifyUrl 是两个不同的地址。** */ private String onboardingNotifyUrl; ``` 再加方法(**不进 `validate()`**,理由写在 javadoc 里,逐字落 Global Constraint 3/4 与 spec §6.4): ```java public List missingOnboardingCredentials() { ... } ``` **必须一次列全**(照 `validate()` 既有的「一次性列出所有缺失项」先例 —— 逐个抛会让人改一个重启一次,进件当天每轮重启都是分钟级)。返回的是**给人看的配置键名**(形如 `leshouying.channel-id(环境变量 LESHOUYING_CHANNEL_ID)`),照 `validate()` 里 `missing.add(...)` 的既有写法。 `application.yml` 的 `leshouying:` 块下加五个键,**默认值一律留空**: ```yaml # ── 三-B 进件凭据(spec §6.3 的 1-5,由客户经理提供)── # ★ 默认值必须留空:§6.4 逐字禁止「测试用假值」。缺失由 missingOnboardingCredentials() # 在进件入口挡下并一次列全,**不进 validate()**(理由见该方法 javadoc)。 channel-id: ${LESHOUYING_CHANNEL_ID:} bd-id: ${LESHOUYING_BD_ID:} channel-type: ${LESHOUYING_CHANNEL_TYPE:} rate-code: ${LESHOUYING_RATE_CODE:} # 进件审核通知地址(我方固定地址)。★ 与上面 notify-url 不是同一个。 onboarding-notify-url: ${LESHOUYING_ONBOARDING_NOTIFY_URL:} ``` - [ ] **Step 4: 跑测试确认通过 + 全量回归** Run: 先单类,再全量。 Expected: 新增 4 条绿;全量 **503** 绿(本批次起点 498 + 本任务 4 = 502… **以实测为准**,不要照抄这个数)。 > ★ 不要照抄计划里的期望计数。起点数用你自己跑出来的当次读数。 - [ ] **Step 5: 提交** ```bash git add java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java java/src/main/resources/application.yml java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingPropertiesTest.java git commit -m "feat(进件): 三-B 配置项与凭据闸(不进 validate,避免假值进仓库)" ``` > ★ `application.yml` 当前工作区**有一处未提交的改动**(`spring.data.redis.host: redis → localhost`),是既有的提交隐患。**`git add` 必须用显式路径**(如上),提交完用 `git diff --cached --stat` 确认只进去了该进的东西;**绝不** `git add -A`。 --- ## Task 2: 创建商户的字段载体与 7 类必填矩阵 **Files:** - Create: `.../leshouying/enums/LsyMerchantCategory.java` ★ 七类组合枚举,见下 - Create: `.../leshouying/service/LsyMerchantRequiredFields.java` - Create: `.../leshouying/dto/LsyMerchantCreateDTO.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantRequiredFieldsTest.java` **Interfaces:** - Consumes: 无(本任务零依赖、零网络) - Produces: `List LsyMerchantRequiredFields.missingRequiredFields(LsyMerchantCreateDTO dto)` —— 返回**上游参数名**形式的缺失字段清单,空列表=齐备;`LsyMerchantCategory` 七个常量,每个带 `merchantType`/`accountType`/中文标签 ★★ **枚举叫 `LsyMerchantCategory`(七类组合),不叫 `LsyMerchantType`(三类)。** 理由见 §0.3b:必填差异发生在**七个组合**之间,而 `merchant_type` 只有三个值 —— 用 `Map>` 作键会撞(`企业对私法人` 与 `企业对私非法人` 同为 `merchant_type=1`)。 ⇒ 七个常量**各自携带** `merchantType` 与 `accountType` 两个码值,取值照 §0.3b 的修正表(`1|2|3|1|2|3|4`)。`merchant_type`/`account_type` 由类别**派生**,DTO 上不再各存一个可自由组合的字段 —— 于是「文档未列的组合」在类型层面就不可表达。 ⇒ ★ **但「类别」本身必须在 DTO 上。** 裁定(2026-09-23):`LsyMerchantCreateDTO` 带一个 `LsyMerchantCategory category` 字段(驼峰,命名见 Step 3)。依据是**两条签名同时只认 DTO**:本任务的 `missingRequiredFields(LsyMerchantCreateDTO dto)` 与 Task 3 的 `createMerchant(LsyMerchantCreateDTO dto)`(两条都写在各自任务的 **Produces** 行里)都要按类别分支,而本任务那六条按类别断言的用例(用例名以 `missingRequiredFields_doesNotRequire` / `missingRequiredFields_requires` 开头)与 Task 3 的 `createMerchant_sendsExactlyTheExpectedParams`(**集合相等**断言键集,**必含** `merchant_type`/`account_type`)都要求这件事。**若判错**:两处签名都要改签名并回改调用方 —— 改动面小,且会在评审或 Task 3 编译时立刻暴露。 ★ **本条原写的三个行号(`:456` / `:401`–`:406` / `:468`)在 2026-09-23 插入本段之后全部失效**(实测真值 `:458` / **`:404`–`:409`** / `:470` —— ★ **「齐差 2」不成立**:前后两个各 +2,中间那个 **+3**。`:403` 是 `requiredFieldAccounting_isExactly27`,六条按类别用例从 `:404` 起;原写的 `:401`–`:406` 当时就已偏 1)—— 插行把后面的行号整体推下去了。**改引可搜锚点(签名原文、用例名),行号会随编辑漂。** ⇒ DTO **仍不**带 `merchant_type`/`account_type` —— 那两个是**派生结果**,不是入参;调用方给的是**类别**。 ⇒ `LsyMerchantCategory.of(...)` 的入参:调用方给的是**类别名**(前端七选一),不是两个码值。这与附表「七列就是七个类别」的结构一致。 ⇒ 类 javadoc 必须落 §0.3b 的完整依据:附表那一行是错的、参数表第 49 行是权威、七个名字逐字同名 —— 否则下一个读代码的人会以为我们在发明值域。 这是本批次测试最密、最该独立评审的一块:它把 §0.2 与 §0.3 两张表变成可执行判据,而且**零网络**。 - [ ] **Step 1: 先写失败的测试** 必测用例(每条都是一个独立钉子): | 用例名 | 钉什么 | |---|---| | `category_sevenConstants_allPairsUnique` | ★★ **本任务最该有的那条**:七个常量的 `(merchantType, accountType)` 两两不重复,且 `accountType` 取值集合恰为 `{1,2,3,4}`(§0.3b 的修正表)。**照抄附表那一行会让本用例变红** —— 这就是钉住「我们没照抄错误表格」的钉子 | | `category_neverProducesUnlistedCombination` | 七个类别派生出的组合与 §0.3b 表逐条相等(`1-1 / 1-2 / 1-3 / 2-1 / 2-2 / 2-3 / 3-4`) | | `missingRequiredFields_listsExactSet_whenAllBlank` | **集合相等**断言(别只断 size —— 集合相等才能防「漏一个、多一个」互相抵消)。期望集合 = **`UNCONDITIONAL ∪ BY_CATEGORY[该类别]`**(两个常量的定义见 Step 3)—— ★ **不要在用例里写死个数**:这个集合**随类别变**,写死个数就会与下面那六条按类别断言的用例互相打架。逐类别各断一条,或参数化 | | `missingRequiredFields_isEmpty_whenAllRequiredSet` | 齐备时为空 | | `requiredFieldAccounting_isExactly27` | ★ 把 §0.2 的 27 个名字在测试里写成一个常量表,断言 `信封4 ∪ 配置3 ∪ 类别派生2 ∪ UNCONDITIONAL` **恰好等于**那 27 个(集合相等)。这条钉的是**四层分工会不会随着后续任务漂移** —— 有人在 Task 3 里多查一个、少查一个,本用例变红 | | `missingRequiredFields_doesNotRequireLicenseFields_forXiaowei` | 小微不要求 `license_type`/`license_no`/`license_expire`/`legal_*`(§0.3 附表留空) | | `missingRequiredFields_requiresLicenseFields_forEnterprise` | 企业对公要求 `license_type`/`license_no`/`license_expire`(参数表留空、**附表要求** —— 这条钉的就是 §0.3 的裁定) | | `missingRequiredFields_requiresLegalFields_forLegalPersonCategoriesOnly` | 要 `legal_name`/`legal_cert_no`/`legal_cert_expire` 的恰是 `(1,1)/(1,2)/(1,3)` **与 `(2,3)`**;`(2,1)/(2,2)` 与小微不要。★ 附表 178-180 行对个体对私法人留空、对个体对私非法人打 √ —— 按表逐格 | | `missingRequiredFields_requiresPictureIdcardAForXiaowei_butNotB` | ★ 附表的不对称照抄不修(小微要 `picture_idcard_a`、不要 `picture_idcard_b`) | | `missingRequiredFields_requiresPictureOutsideAndInsideForAllSeven` | 七类都要 `picture_outside`/`picture_inside` | | `missingRequiredFields_requiresPictureUnincorporated_forAccountType3Only` | 恰是 `accountType == 3` 那两类(`(1,3)`、`(2,3)`) | | `missingRequiredFields_ignoresSettlementType` | 附表里被划掉的 `settlement_type` **不在**必填集合里(钉「不实现作废字段」) | | `missingRequiredFields_ignoresSttFields` | `license_stt`/`legal_cert_stt`/`account_idcard_stt`:参数表`必填`列留空、**附表完全没有**、说明栏也没写通道条件 ⇒ 不必填(§0.3c) | | `missingRequiredFields_ignoresTheEightAppendixMissingFields` | ★ `lease_contract`/`p_cashier`/`share_scene`/`share_certificate`/`tripartite_flow_certificate_url` 五个(另三个是上面的 `*_stt`)**全部不在**必填集合里(§0.3c) | | `missingRequiredFields_ignoresChannelConditionalFields` | ★ **§0.3d**:`reg_capital`/`fSettle_account_type`/`actual_addr`/`business_scope`/`check_code` 五个 `C` 字段 + 说明栏带通道名的 `picture_*` **都不进**必填集合 —— 条件是「按通道」,而通道是配置项、此刻未知 | ★ 最后一条需要**显式裁定**并写进 javadoc:`必填=C` 的五个字段,「平安收银宝必填」「星驿付必填」这个条件**在 `channel_id` 未知时无法求值**。本批次**不收**它们(不收 ≠ 放行:字段照常随 DTO 上送)。**代价若判错**:若最终通道是平安收银宝而操作者没填 `reg_capital`,上游拒 —— 报错在上游。**这条要进最终验收的待确认清单。** - [ ] **Step 2: 跑测试确认失败** → 编译失败(三个类不存在) - [ ] **Step 3: 实现** `LsyMerchantCategory`:**七个**枚举常量,每个带 `merchantType`/`accountType`/中文标签,码值照 §0.3b 修正表。`of(String)` 照同包 `LsyReviewStatus.of` 的**精确匹配、匹配不到返回 `null`、不抛**先例(**不要**照 `LsyPayType.of` —— 那个是反语义,本包两处 `of` 有意不同,`LsyReviewStatus` 的 javadoc 已就此写过警告)。 类 javadoc 要落三件事:① §0.3b 的完整依据(附表那行是错的、参数表第 49 行是权威、七个名字逐字同名);② **spec** §6.2(a) 的裁定与依据(★ **本计划没有 `§6.2` 这一节** —— 裸写的 `§6.2(a)` 一律指 spec 那个文件;原文已切进 `task-2-refs.md` 末节);③ **行业类目页那一套 `1个体工商户 2企业 …` 不是本枚举的值域**(§0.5 已记该页 `merchant_type` 与创建页对调,本地不校验、原样透传)。 `LsyMerchantRequiredFields`:一个**纯静态**类,表用不可变集合表达。**不要**写成 if/else 长链 —— 表驱动才能让「附表这一格」在代码里可逐格核对。 ★★ **§0.2 那 27 个里,本类只管 18 个**(下表最后一行;另 9 个归别处,本类**不管**它们,也不会因为不管而「漏」)。★ **别把这句读成「本类只判这 18 个字段的必填」** —— 本类**还要**判 §0.3 附表里那些**根本不在 §0.2 的 27 个之内**的字段(`license_*`/`legal_*`/`picture_*`),它们进 `BY_CATEGORY`(见下)。§0.2 的 27 个这样分完(**27 = 4 + 3 + 2 + 18**,无重叠无遗漏): | 归属 | 个数 | 字段 | 谁负责 | |---|---|---|---| | 信封 | 4 | `inst_no`、`trace_no`、`sign`、`notify_url` | `LeshouyingClient` + 配置(Task 1 的 `onboardingNotifyUrl`) | | 配置 | 3 | `channel_id`、`bd_id`、`rate_code` | Task 1 的凭据闸 `missingOnboardingCredentials()` | | 类别派生 | 2 | `merchant_type`、`account_type` | `LsyMerchantCategory`(由类别推出,不可自由组合) | | **DTO 承担** | **18** | `merchant_name`、`merchant_name_short`、`merchant_name_business`、`merchant_mcc_code`、`contact_name`、`contact_phone`、`contact_email`、`contact_service`、`address_province`、`address_city`、`address_county`、`address_street`、`account_idcard_num`、`account_idcard_expire`、`account_card_name`、`account_card_num`、`account_card_phone`、`account_bank_code` | **本类** | ⇒ 所以 `UNCONDITIONAL` 是**上面那 18 个**(不是 23、不是 27)。★ **但 `missingRequiredFields` 返回的不是只有 `UNCONDITIONAL`** —— 它返回 `UNCONDITIONAL ∪ BY_CATEGORY[该类别]`。依据:下面那六条按类别断言 `license_*`/`legal_*`/`picture_*` 的用例,**那些字段一个都不在这 18 个里**,只查 18 个它们不可能通过。这条关系要写进类 javadoc。每层各查各的,四层加起来正好 27 —— 这个加法本身就该有一条测试(见下)。 `BY_CATEGORY`:`Map>`,键是**七个类别**,值照 §0.3 附表的 √ 逐格填(附表那一行 `account_type` 的错误不影响 √ 的落格 —— √ 是按七列读的)。 **`LABELS`:`Map`,`上游参数名 → 中文标签`,标签逐字取参数表的「说明」列**(`merchant_name` → 「商户名称」、`account_bank_code` → 「结算银行支行联行号」……)。**三个用途共用这一份**:① Task 3 拼缺失清单的人话文案;② Task 9 前端表单的字段标签;③ Task 4/5 的 `requireText(value, label)` 参数。 ★ **为什么必须有它**:本模块既有约定就是**报中文标签、不报键名** —— `LsyOnboardingService.requireText` 的 javadoc 逐字写着「`label` 用**上游参数表里的中文字段名**(「门店名称」而不是 `shop_name`):报键名的话,操作者得在文档里做一次心算才能对上是界面上的哪个输入框」。所以 `missingRequiredFields` **返回参数名**(机器可读,让 `requiredFieldAccounting_isExactly27` 能在同一套词汇里断言),**渲染成文案时**过 `LABELS`。两件事分开,各取所需。 `LsyMerchantCreateDTO` 的字段命名 —— ★ **裁定:Java 侧一律驼峰,上游下划线名由组装层的常量映射承担**,照三-A `LsyOnboardingService` 里 `KEY_MANAGER_NAME` 那一族常量的既有写法。理由:① 本模块既有代码是驼峰字段 + 常量映射,下划线字段名会成为这个包里唯一的例外;② 上游键名里有两处**大小写敏感**的怪形(`store_Loca`、`mcht_Landuse`,见 Task 4),把它们写成 Java 字段名迟早被人「顺手规整」成 `storeLoca` —— 而改一个字母就换签名。常量集中在一处才好加注释钉住。 **代价若判错**:无。两种命名都能过,只是前者与本包其余部分一致。 - [ ] **Step 4: 跑测试确认通过**(单类 + 全量) - [ ] **Step 5: 提交** --- ## Task 3: `createMerchant` —— 组装、上送、取号 **Files:** - Create: `.../leshouying/enums/LsyMerchantReviewStatus.java` ★ 见下方 Interfaces 的说明 - Modify: `.../leshouying/service/LsyOnboardingService.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingServiceTest.java` **Interfaces:** - Consumes: `LsyMerchantCreateDTO`、`LsyMerchantRequiredFields.missingRequiredFields`、`LeshouyingProperties.missingOnboardingCredentials()` - Produces: `String createMerchant(LsyMerchantCreateDTO dto)` —— 返回**保证非空白**的商户号;`LsyMerchantReviewStatus.{PENDING, APPROVED, REJECTED}` ★★ **`LsyMerchantReviewStatus` 由本任务创建,不由 Task 6 创建** —— 词表常量必须落在**第一个写它的人**手里。Task 3 与 Task 6 都要写这一列(一个写 `PENDING`,一个写 `APPROVED`/`REJECTED`),若常量建在 Task 6,Task 3 就得依赖一个尚不存在的类。这是跨任务接口,**不是**可选的组织问题。 三个常量的**字面值就是** `"PENDING"` / `"APPROVED"` / `"REJECTED"`(`review_status` 是 `VARCHAR(32)`):`PENDING` 是本批次新引入的,`APPROVED` 沿用 DDL 默认值那个词,`REJECTED` 沿用同模块 `pay_lsy_notify_log.process_status` 已在用的那个词(不是新造词)。类 javadoc 必须写明这三条的来源,并写清**这不是** spec §6.4 禁的上游值域归一化(§0.9-1)。 - [ ] **Step 1: 先写失败的测试** | 用例名 | 钉什么 | |---|---| | `createMerchant_missingCredentials_throws400ListingAll` | 凭据缺失时 **400**,且 `verifyNoInteractions(client)` —— **不得发上游**。★ message **不含缺失项明细**(见 Step 3 的对外文案裁定):只含**中文固定文案 + 缺失项数**,明细走日志。另加一条断言:message 不含 `LESHOUYING`(照 `LsyOnboardingServiceTest` 里 `663156f` 加的那条"不含上游名号"断言的解法) | | `createMerchant_missingRequiredFields_throws400ListingAll` | 必填缺失时 400,message 含全部缺失字段的**中文标签**(不是上游参数名 —— 见 Step 3 的用词要求),`verifyNoInteractions(client)` | | `createMerchant_sendsExactlyTheExpectedParams` | 打桩捕获 `postApi` 的 path 与参数 Map,**集合相等**断言键集(防漏发/多发) | | `createMerchant_sendsConfiguredCredentials` | `channel_id`/`bd_id`/`rate_code` 取自配置 | | `createMerchant_sendsNotifyUrlFromConfig` | `notify_url` 取 `onboardingNotifyUrl`(**不是** `notifyUrl` —— 这条钉的就是 spec §6.5 的两个地址之分) | | `createMerchant_readsMerchantNoFromEitherKey` | 上游回 `merchant_no` 或 `shoubei_merchant_no` 都能取到号(§6.2(c):**返回参数表写 `shoubei_merchant_no`、返回示例写 `merchant_no`**,**只有真跑一次才知道** ⇒ 两个都读)。★ **本行原写「返回参数表写前者、示例写后者」—— 括号写反了**(本行先枚举的是 `merchant_no`,所以「前者」指它;而真值相反)。实测出处:`_txt-196236555.md:139`(参数表 = `shoubei_merchant_no`)与 `:149`(示例 = `merchant_no`);spec `:457` 逐字同。**之所以没人踩坑:这条要求是「两个都读」,它在前后调换下不变** —— 写反的只有归因,不是动作 | | `createMerchant_noMerchantNoInResponse_throws502` | 信封 SUCCESS 但两个键都没有 ⇒ 502,message **既不含上游名号、也不含上游专有字段名**(Global Constraint 1 / spec §8.5 第 5 条 —— **两个都禁**)且**含「不得当作失败直接重试」**。★ 「照 `createShop` 的既有文案形状」只指**结构**(成功但缺号 ⇒ 502 + 行为指令),**不照它的字段名** —— 那一行**没有**「字段名」可取(`shop_no` 是我们自己的列名,见 GC 1 的 2026-09-23 订正),所以这条「不照字段名」是**空的**,本用例只需钉住「两个键都没有 ⇒ 502 + 行为指令」。 | | `createMerchant_message_neverNamesUpstream` | 对上面两条 message 断言不含「乐收赢」(照 `createShop_missingShopNo_throws` 的解法) | - [ ] **Step 2: 跑测试确认失败** - [ ] **Step 3: 实现** 形状照 `createShop`(`LsyOnboardingService:270`):先校验、再组装、再 `client.postApi("/api/merchant/create", p)`、再取号。 顺序**必须**是:① 凭据闸 → ② 必填矩阵 → ③ 组装 → ④ 上送。①在②之前,因为凭据缺失时连字段都补不全。 组装时 `channel_id`/`bd_id` 按 **page 196236555 的 `Integer(11)`** 形态放(该页参数表如此声明);`rate_code` 用配置值,DTO 有值时以 DTO 为准(§0.8)。 ★ **400 文案的用词**:`missingRequiredFields` 返回的是**上游参数名**(机器可读,供集合断言),**不得**直接拼进 message;拼之前逐名过 `LsyMerchantRequiredFields.LABELS`(Task 2 产)取中文标签。两条依据:① 本模块既有 `LsyOnboardingService.requireText(String, String)` 的 javadoc **逐字**要求报中文标签而不是键名(`LsyOnboardingService.java:501-502`,逐字:「{@code label} 用上游参数表里的中文字段名(「门店名称」而不是 {@code shop_name}):报键名的话,操作者得在文档里做一次心算才能对上是界面上的哪个输入框」);② **Global Constraint 1** —— `fSettle_account_type`、`mcht_Landuse` 这种上游键名出现在对外 400 文案里就是暴露上游词汇。`LABELS` 是**唯一**的翻译处,不要在 Task 3 里另写一张表。 ★★ **对外文案的第三处落点:凭据缺失的报错(2026-09-23 裁定)。** `LeshouyingProperties.missingOnboardingCredentials()` 返回的字符串**逐条含环境变量名**(`LESHOUYING_CHANNEL_ID` 这种),Task 1 的用例 `missingOnboardingCredentials_namesEachConfigKeyWithItsEnvVar` 钉着这个格式。那串里含**上游名号的罗马字**,直接拼进 `BusinessException` 的 message 就是越线(GC 1 / spec §8.5 第 5 条:不得出现上游**名称**)。裁定:**明细只进日志**(这里打的是**键名**不是值,不触 GC 5),对外 message 给**中文固定文案 + 缺失项数**(形如「服务端进件配置缺失(共 N 项),明细见服务端日志」)。**不要**把环境变量名换成中文标签再发出去 —— 那会造出第二张翻译表,且仍在教调用方猜服务端配置。依据:`663156f` 处理的是同一个 `GlobalExceptionHandler` 通道;`LsyOnboardingServiceTest` 里 `663156f` 加的那条断言是现成的解法。 ★ **订正(2026-09-23):本行上一版写的「同时收掉一处既存越线:`createShop` 的 502 文案含 `shop_no` ⇒ 顺带改掉、并把 `createShop_missingShopNo_throws` 的断言扩成『也不含上游字段名』」作废 —— 那是照一条**假前提**派活,`createShop` 的 502 文案与它那条用例**都不动**(理由见 GC 1 的 2026-09-23 订正:`shop_no` 是我们自己的列名与对外应答字段名,不是上游专有)。**本任务只对本任务新写的文案负责。**(紧接下一行的「代价若判错」是上面**凭据缺失**那条裁定的代价,与本行无关。) **代价若判错**:调用方少一条自助定位信息(改看日志)。这是**故意**的取舍:GC 1 是**持续约束**。 - [ ] **Step 4: 跑测试确认通过**(单类 + 全量) - [ ] **Step 5: 提交** --- ## Task 4: 更新商户 / 提交入网信息 / 查询入网状态 三条都是「组装 + 解析」的薄腿,**合成一个批次**(同一形状、同一个评审面)。 **Files:** - Create: `.../leshouying/dto/LsyMerchantUpdateDTO.java`、`.../leshouying/dto/LsyCommitMerchantInfoDTO.java` - Modify: `.../leshouying/service/LsyOnboardingService.java` - Test: `LsyOnboardingServiceTest.java` **Interfaces:** - Produces: `void updateMerchant(LsyMerchantUpdateDTO dto)`、`void commitMerchantInfo(LsyCommitMerchantInfoDTO dto)`、`String reportQuery(String mchNo, String channelId)`(返回**原始** `reviewed_status`,未识别时为原字符串) - [ ] **Step 1: 先写失败的测试** | 用例名 | 钉什么 | |---|---| | `updateMerchant_sendsOnlyNonBlankFields` | ★ **增量语义**:页里明写「请求参数有值则更新,请求参数无值则保持原数据」⇒ 空白字段**不得**出现在参数 Map 里(键集断言) | | `updateMerchant_alwaysSendsMchNoAndChannelId` | 这两个是该页的必填项,即使 DTO 里为空也要有校验(400) | | `commitMerchantInfo_sendsAllNineParams` | 键集相等:`mcht_aptitude`/`mcht_aptitude_note`/`store_Loca`/`mcht_Landuse`/`longitude`/`latitude` + `channel_id` | | `commitMerchantInfo_preservesUpstreamKeyCasing` | ★ `store_Loca` 与 `mcht_Landuse` 的**大小写照原文**(签名对键名敏感,改大小写就换签名) | | `reportQuery_returnsRawStatus_forAllSevenKnownCodes` | 0..6 逐个原样回,**不映射**、不建枚举(§0.4) | | `reportQuery_returnsRawStatus_forUnknownCode` | 未知码原样回,不抛 | | `reportQuery_missingStatus_returnsNull` | 缺键回 null(§0.5:该页返回示例是空的,键可能真的不在) | - [ ] **Step 2: 跑测试确认失败** - [ ] **Step 3: 实现** `reportQuery` 的 **javadoc 必须把 §0.4 表里那两套 0–6 映射逐字抄进去**并指向 page 196236564 —— 这是 spec §6.4 三-C 的逐字要求(「记录在代码注释里指向文档,等联调时用真实应答确定」)。**不要**在代码里挑一套。 ★ **三条已有的代码事实,直接决定这三条腿怎么写**(都实测过,实施者不必再找): 1. **`postApi` 对非 `SUCCESS` 一律抛 `LeshouyingApiException`**(`LeshouyingClient.java:103-106` 的 javadoc 逐字:「进件类的失败语义与 v3 不同:非 SUCCESS **一律抛**」)⇒ 这三条腿**不需要**自己判 `result_code`,能被 `catch` 接住的失败与「上游拒绝」是同一件事。 2. **`trace_no` 不在客户端自动补** —— `LeshouyingClient` 只自动补 `inst_no` 与 `sign`。而 `merchant_no` + 一个**每次新生成**的 `trace_no` 正好有一个现成的私有方法:**`LsyOnboardingService.mchNoParams(String mchNo)`**(`LsyOnboardingService.java:488`,已含空白校验 + 400 + `IdUtil.fastSimpleUUID()`)。**三条腿都用它,不要各写各的** —— 该方法自己的 javadoc 就是为「两个收 `mchNo` 的接口共用一份」写的,而它现在面对的是**四个**调用方,改它时要连带看新调用方。 ★ 该方法 javadoc 里还有一条警告照旧成立:`trace_no` **每次调用重新生成**,写成常量或缓存在字段里会撞流水,而失败形态是笼统的「重复请求」。 3. **必填/空白校验用既有的 `LsyOnboardingService.requireText(String value, String label)`**(`LsyOnboardingService.java:508` —— ★ **行号必须带文件名**),`label` 取自 `LsyMerchantRequiredFields.LABELS`(Task 2 产)。 - [ ] **Step 4: 跑测试确认通过** - [ ] **Step 5: 提交** --- ## Task 5: 省市区 DTO 扩展 + 行业类目 + 安心签两条 **Files:** - Modify: `.../leshouying/dto/LsyAreaItemDTO.java`、`.../leshouying/service/LsyOnboardingService.java` - Create: `.../leshouying/dto/LsyMccItemDTO.java`、`.../leshouying/dto/LsyAnxinSignPersonDTO.java`、`.../leshouying/dto/LsyAnxinSignCompanyDTO.java` - Test: `LsyOnboardingServiceTest.java` **Interfaces:** - Produces: `List mccList(String level, String parentId, String merchantType)`、`void anxinSignPerson(LsyAnxinSignPersonDTO)`、`void anxinSignCompany(LsyAnxinSignCompanyDTO)`;`LsyAreaItemDTO` 新增 `getProvinceCode()`/`getCityCode()`/`getCountyCode()` - Consumes: `props.getChannelType()` - [ ] **Step 1: 先写失败的测试** | 用例名 | 钉什么 | |---|---| | `areaList_carriesProvinceCode_atLevel1` | §6.6.3 的缺口:level1 的 `province_code` 进 DTO | | `areaList_carriesCityCode_atLevel2` | level2 的 `city_code` | | `areaList_carriesCountyCode_atLevel3` | level3 的 `county_code` | | `areaList_missingGbCode_leavesFieldNull` | 上游没给就是 null(不抛、不填 "") | | `mccList_readsMccListKey` | ★ 按**返回示例**取 `mcc_list`(§0.5:该页返回**参数表**写的是 `area_list`,是复制残留) | | `mccList_missingKey_returnsEmptyList` | 键缺失回**空列表**,不返回 null、不抛(照 `toAreaItems` 先例) | | `mccList_sendsChannelTypeFromConfig` | `channel_type` 取配置 | | `mccList_doesNotValidateMerchantType` | §0.5 第 4 行:该页的 `merchant_type` 值域与创建页**对调**,本地不校验,原样透传 | | `anxinSignPerson_sendsAllSevenParams` | 键集相等 | | `anxinSignCompany_sendsAllElevenParams` | 键集相等(比个人版多 `business_licence`/`merchant_address`/`legal_cert_no`/`legal_name`) | | `anxinSign_doesNotLogPii` | ★ 打桩 log 或用一个捕获 appender,断言**身份证号与结算卡号不出现在任何日志里**(Global Constraint 5)—— 两个接口的入参都是真人 PII | - [ ] **Step 2: 跑测试确认失败** - [ ] **Step 3: 实现** `LsyAreaItemDTO` 加三个字段,javadoc 照该文件既有风格落「依据是实测(483 条普查,每级各带该级 GB 码)」与「三-A 用不到、三-B 要」。 ★ **`parent_code` 不是它们** —— 三级联动的 `parent_code` 填的是上级的 `area_code`(填 GB 码回空列表,实测)。三个 GB 码是**进件的 `address_province`/`address_city`/`address_county` 的取值来源**,用途完全不同。这句必须进 javadoc,否则下一个人会把它们当 `parent_code` 用。 安心签两个方法的 javadoc 要落 §0.1 的标题歧义(菜单标题带「发送验证码」,接口描述为空,参数表是完整开户载荷 ⇒ 按参数表发全量)。 - [ ] **Step 4: 跑测试确认通过** - [ ] **Step 5: 提交** --- ## Task 6: 审核通知接收端 **安全敏感**:这是**免登录**入口。完成后需走 `security-review` 技能。 **Files:** - Create: `.../leshouying/enums/LsyOnboardingReviewStatus.java` - ~~Create~~ ★ **已存在,不得重写**:`java/src/main/resources/db/lsy-merchant-mch-no-uk.sql` —— **前置**,见 Step 0(**主控已写,用户执行**)。它已在工作区(未跟踪),实施者**不要创建、不要覆盖**,只读它。 - Modify: `.../leshouying/controller/LeshouyingNotifyController.java` - Create: `.../leshouying/service/LsyOnboardingNotifyService.java` ★ 见下,**不并进 `LsyNotifyService`** - Modify: `.../leshouying/service/LsyMerchantService.java`(**新增**审核状态写入口) - Test: 对应测试类 **Interfaces:** - Consumes: `LsyMerchantReviewStatus.{APPROVED, REJECTED}`(**Task 3 已建**,本任务不得另起一个词表) - Produces: `POST /payment/collection/notify/onboarding` → `{"result_code":"SUCCESS","result_msg":"..."}`;`LsyMerchantService.updateReviewStatusByMchNo(String mchNo, String localStatus, String reviewedReason)` ★★ **入站键名与本地列名不同名 —— 这是本任务最容易被静默写错的一处。** page 196236556 的请求参数表(`_txt-196236556.md:21`)逐字:`merchant_no | String(15) | 是 | **平台商户号** | 851901070001`。 **入站取的键是 `merchant_no`;要写的本地列是 `pay_lsy_merchant.mch_no`。** 两者的值是同一个(本仓既有先例逐字:`LsyShopCreateDTO.java:32-33` 「乐收赢商户号(上游 `merchant_no`,必填)」+ `private String mchNo;`)。 ⇒ 写成 `body.get("mch_no")` 会**恒为 null**,失败形态是**每一条通知都走「行不存在」分支**(响亮 FAIL,不是静默), 但仍要先知道该读哪个键才看得出红在哪。**别按列名猜入站键名。** ★★ **应答信封与本控制器的类 javadoc 逐字不同 —— 别抄那句。** page 196236556 的返回参数表(`:50-51`)与返回示例(`:57-58`)都是 **`result_code` / `result_msg`**。 而 `LeshouyingNotifyController` 的类 javadoc `:19` 写的是 **`return_code` / `return_msg`** —— 那是它的**另外两条腿**(`/pay`、`/refund`,各自的上游页)的口径,**不是本条的**。 ⇒ 本端点必须回 `result_code` / `result_msg`;同时把 `:19` 那句**限定到已有的两条腿**, 否则加了第三条腿之后,那句类 javadoc 变成一句对整类都成立的**假话**。 ★ **`LsyOnboardingReviewStatus`(本任务的枚举,解上游 556 那套值域)与 `LsyMerchantReviewStatus`(Task 3 的常量,是本地列的词表)是两个不同的东西,不得合并。** 前者解**上游报文**,后者写**本地库列**;中间那层映射(上游 `1`/`"审核通过"` → 本地 `APPROVED`)正是本任务的产出,也正是它该集中的地方。 ★ **裁定:新建 `LsyOnboardingNotifyService`,不把第三个方法塞进 `LsyNotifyService`。** 依据(实测,非审美): 1. `LsyNotifyService` 已有 **446 行**,两个公开方法 `handlePayNotify` / `handleRefundNotify` **都是交易回调**,且都按 **`pay_trace_no` / `refund_no`** 定位。本任务按 **`mch_no`** 定位,且完全没有订单/退款语义。 2. 那个类背着 **R41 的历史**:一条因为「定位键根本不在报文里」而**从未通电**的分支。往一个正在讲「哪个键定位什么」的类里再塞**第三个定位键**,只会让那段叙事更难读 —— 而那段叙事正是 R41 的教训本身。 3. 两者是不同的限界上下文(商户进件 vs 交易处理)。 - [ ] **Step 0: ★★ 前置 —— 本任务触发了一条本仓自己写下的义务** `TenantInnerInterceptor` 的 `CONDITIONAL_TABLES` 注释里,紧挨着 `pay_lsy_merchant` 那段逐字写着: > ★ `pay_lsy_merchant` 的 `mch_no` **没有唯一索引**(见 `LsyMerchantService.requireByMchNo` 的 javadoc)。当前**没有任何**未认证路径查询它(两条回调实测按 `pay_trace_no` / `refund_no` 定位),所以本次加入是**惰性**的,不改变现有行为。 > **义务**:将来一旦有人写按 `mch_no` 的**未认证**查询,必须先把 `uk_lsy_merchant_mch_no` 补上 —— 唯一性是无认证查询不串租户的前提(通则的实例)。 **本任务正是那个「将来」**:审核通知入口**免登录**(无认证上下文 ⇒ `ignoreTable` 返回 `true` ⇒ **不追加任何租户谓词**),而要定位本地行**只能**按 `mch_no`。 ⇒ **裁定(2026-09-23 更正,原句「必须先补唯一约束,再实现查询」不是可执行的顺序)**: 原文那样写读起来像「索引是查询的前提」。**它不是**,而且**今天也做不到**: `pay_lsy_merchant` 里**现在就有重复**(实测 `mch_no='Y52211090004'`,n=2,`ids={1, 2102313081860202497}`), `CREATE UNIQUE INDEX` **必然失败**;而 SQL 一律**由用户执行**,实施者既不能建也不能删行。 ⇒ 拆成两件互不阻塞的事: 1. **第一道闸(本任务必须落地,且不依赖索引)**:定位查询**不得用 `LIMIT 1`、不得用 `selectOne`**。 要 `selectList` 后自己判:**0 行 ⇒ ERROR + 应答 FAIL**;**>1 行 ⇒ ERROR + 应答 FAIL**;**恰好 1 行 ⇒ 更新**。 依据不只是本条,还有 `TenantInnerInterceptor.java:156` 那条**通则**逐字:「任何**未认证**的读都**不得**用 `selectOne` —— 无认证查询不带租户条件,`LIMIT 1` 会返回**任意租户**的行」。本条正是它的**实例**(`:162-163` 自己这么说)。 ⇒ 免登录入口在**没有索引**时也是安全的;`onboardingNotify_duplicateMchNo_returnsFail` 钉的就是这道闸。 2. **第二道闸(本任务只交付 SQL,执行归用户)**:`uk_lsy_merchant_mch_no` 是纵深防御 + 为将来的调用方立的约束。 它的建立被「库里那两行重复」**阻塞**,处置是**用户的数据决策**。 `lsy-merchant-mch-no-uk.sql` 必须**自带只读预检**并写死预期读数,照 `receive-code.sql` 的自带核对先例: 1. 查现有重复:`SELECT mch_no, count(*) FROM pay_lsy_merchant GROUP BY mch_no HAVING count(*) > 1;` ★★ **本行原写「预期 0 行」,那句是错的** —— 2026-09-23 实测 **1 行**(`mch_no='Y52211090004'`,n=2,`ids={1, 2102313081860202497}`, 两行都是测试夹具:一行的 `description` 自述「联调临时数据…应删除」,另一行自述「租户级映射(Task5 Step1 端到端验证用)」)。 ⇒ **预期读数就是 1 行**,不是 0。不要照那句「预期 0 行」判「脚本跑对了」。 2. 第 1 步非 0 行(**即当前的真实情况**)⇒ **不要**建索引,把读数交给用户定夺 (重复的 `mch_no` 是真实数据问题,不是 SQL 能掩盖的;删哪一行是用户的数据决策,脚本不替你删)。 3. 建索引:`CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_merchant_mch_no ON pay_lsy_merchant (mch_no);` —— **不带** `tenant_id`:无认证查询没有租户可带,跨租户唯一才是它要的性质。 4. 建后复核:`SELECT indexname FROM pg_indexes WHERE tablename='pay_lsy_merchant';` 应出现该索引名,且 `idx_lsy_merchant_mch_no`(非唯一)**仍在** —— 两个都留,查询计划可以选旧的。 ★★ **`requireByMchNo` 的 javadoc(`LsyMerchantService.java:99-102`)要改,但不得改成「唯一性由 `uk_lsy_merchant_mch_no` 保证(2026-09-23 加入)」。** 本行原先这么要求,**那句会写成一句假话**:索引**今天不存在**(重复行挡着,且执行归用户), 写「唯一性由它保证」= 在一个**没有**保证的地方声明有保证,而读者会因此停止核对。 ⇒ 改成**实测的样子**:索引 `uk_lsy_merchant_mch_no` **尚未建立**(库里 `mch_no='Y52211090004'` 有重复,见 `db/lsy-merchant-mch-no-uk.sql` 第 3 步待用户裁定);**在那之前**,未认证路径不得依赖 `mch_no` 的唯一定位, 且不得用 `LIMIT 1` / `selectOne`(`TenantInnerInterceptor:156` 通则)。索引建好后,把这段再改成「由它保证」。 ★ 规矩:**警告只在它仍然成立时才删**;「因为打算修好所以先把警告删掉」正是让真警告失效的路径。 - [ ] **Step 1: 先写失败的测试** | 用例名 | 钉什么 | |---|---| | `onboardingNotify_rejectsBadSign` | ★ 验签失败 ⇒ **不写任何业务状态** + 应答 FAIL(Global Constraint 2) | | `onboardingNotify_acceptsBothStatusShapes` | 556 值域两种形态都识别:数字 `"1"`/`"2"` **与**中文 `"审核通过"`/`"审核驳回"`(§0.4) | | `onboardingNotify_mapsApprovedToApproved` | 本地词表写 `APPROVED`(§0.9-1) | | `onboardingNotify_mapsRejectedToRejected` | 写 `REJECTED` | | `onboardingNotify_unknownStatus_stillSucceedsAndLogsRaw` | 未知值 ⇒ 不抛、不瞎映射,**原样记日志**、应答 SUCCESS(防上游无限重推) | | `onboardingNotify_isIdempotent` | 同一通知重放两次,第二次是 no-op(值与库里相同则不写) | | `onboardingNotify_missingMerchantRow_returnsFail` | 行不存在 ⇒ 记 ERROR + 应答 FAIL,让上游重推(§0.9-2 的裁定) | | `onboardingNotify_duplicateMchNo_returnsFail` | `mch_no` 命中多行 ⇒ 记 ERROR + 应答 FAIL(该列无唯一索引,`LIMIT 1` 会静默取任意一行) | | `onboardingNotify_doesNotWriteThroughSave` | `verify(merchantService, never()).save(any())` —— 钉「另起写入口」(§0.9-3) | | `onboardingNotify_sanitizesLogInput` | 报文里的 `\r\n` 不能伪造日志行(照 `LeshouyingNotifyController` 既有的 `LsyLogSanitizer.flatten` 先例) | - [ ] **Step 2: 跑测试确认失败** - [ ] **Step 3: 实现** 控制器加一个映射。**路径放在既有的 `/payment/collection/notify` 前缀下**: > ★ **裁定:复用既有白名单前缀,不新增白名单条目。** 依据:`SecurityConfig.WHITE_LIST` 现有 `/payment/collection/notify/**`(`SecurityConfig.java:69`),把进件通知挂在同一前缀下即已受该条覆盖;新增一条 `/**` 会让**免认证面**变大,而 spec §8.3 对 `/**` 粗放放行的告诫正是这个方向。spec §6.5 要的是「单独一个**配置项**和一条**白名单路径**」—— 配置项是新的(`onboardingNotifyUrl`),路径是被既有条目覆盖的。 > **代价若判错**:若要求两者物理隔离,把映射改到新前缀并在 `WHITE_LIST` 加**一条精确路径**(不是 `/**`)即可,两行改动。 **路径最后一段是 `/onboarding` 而不是 `/pay` 或 `/refund`** —— 上游侧的配置动作(§6.3 #6)要用户去要,我们给出的固定地址就是 `onboardingNotifyUrl` 的取值。 写入口放在 `LsyMerchantService`,用 `LambdaUpdateWrapper` 定向更新 `review_status` + `updated_time`,**不用 `save`**(§0.9-3)。 ★★ **不得复用现成的 `LsyMerchantService.requireByMchNo(String)` —— 它的形状与本路径要的正好相反。** 它就在同一个类里、名字正好、参数正好,是最可能被顺手调用的一个。实测(`LsyMerchantService.java:104-115`): ① 用 `.last("LIMIT 1")`(`:110`)⇒ **它构造上无法发现重复行**,而「多行 ⇒ FAIL」正是本任务第一道闸; ② 行不存在时抛 `BusinessException(404, ...)`(`:112`)⇒ 会把「找不到」变成异常路径,而本条要的是 「记 ERROR + 应答 FAIL」。两条都反 ⇒ 新写入口**自己 `selectList`**,不复用它、也不改它 (它的既有调用方在已认证路径上,改它=动无关行为)。 ★ **已实测**(`db/migration/V1.8__create_pay_lsy_tables.sql` 的建表语句**逐列数过**):`pay_lsy_merchant` **没有** `reviewed_reason` 列。 V1.8 的列序**逐字**是:`id / tenant_id / enterprise_id / mch_no / shop_no / merchant_name / review_status / status / description / created_time / updated_time` = **11 列**。 (★ 本行原写「全表 8 列」并逐个列举,**是错的** —— 漏了 `description`(V1.8 里逐字有)与两个时间戳。按「业务列」数、而不是按建表语句数,就会得到这个 8。) ★★ **但 V1.8 单独一份不是这张表的真相。** 读这张表的形状必须 **V1.8 + `db/lsy-tenant-level.sql`** 一起读: V1.8 声明 `enterprise_id ... NOT NULL`,而**实库该列可为 NULL**(活库有一行 `enterprise_id=NULL`,且存在偏索引 `uk_lsy_merchant_tenant_level (tenant_id) UNIQUE WHERE enterprise_id IS NULL` —— 一个 `WHERE … IS NULL` 的偏索引 **只可能**建在可空列上)。承载这次变更的是 `db/lsy-tenant-level.sql:17` 的 `ALTER TABLE pay_lsy_merchant ALTER COLUMN enterprise_id DROP NOT NULL;`(**用户已执行**)。 ⇒ `db/*.sql` 那一族是**已执行过的手工脚本**,不是待执行的迁移;`migration/V*.sql` 只是其中**一部分**真相。 Task 6 的定位查询要能容忍 `enterprise_id` 为 NULL。 ⇒ **裁定:本批次不新增列。** 驳回原因**不落库**,只随日志走(`updateReviewStatusByMchNo` 的第三个参数就为此存在)。理由:为一个只在管理端「看一眼」的字符串加列 + 加迁移 + 加回显,是给一条**凭据到位前根本不通电**的腿加表面积;而原因在日志里已经完整可查。 **代价若判错**:将来要做「驳回原因回显」时,加一列 + 一条 `ALTER TABLE` 即可;历史通知的原因只在日志里 —— 而日志本就在。**这条要进最终验收的待确认清单。** - [ ] **Step 4: 跑测试确认通过** - [ ] **Step 5: 提交** - [ ] **Step 6: 派 `security-review` 技能审这个入口**(本仓 CLAUDE.md 的自动触发规则:涉及认证、敏感数据) --- ## Task 7: 进件 HTTP 端点与租户归属闸(IDOR) **安全敏感**:spec §6.6.4 逐字判定这是**真实的高危暴露面**(`/api/merchant/info` 的响应含身份证号、法人证件号、真实姓名、手机号、银行卡照片 URL、通道费率)。**不是**预防性免责声明。完成后走 `security-review`。 **Files:** - Create: `.../leshouying/controller/LeshouyingOnboardingController.java` - Modify: `.../leshouying/service/LsyMerchantService.java`(**新增**带租户条件的查询 + 进件建行入口) - Test: 控制器测试 + `LsyMerchantServiceTest` **Interfaces:** - Produces: `LsyMerchantService.requireByMchNoForTenant(String mchNo, Long tenantId)`、`LsyMerchantService.createOnboardingRow(Long tenantId, String enterpriseId, String mchNo, String merchantName)`;权限串 `module_payment:leshouying:onboarding:list` / `:create` / `:commit` - Consumes: Task 3/4/5/6 的服务方法与 `LsyMerchantRequiredFields`、`LsyMerchantReviewStatus.PENDING` ★★ **本任务还负责一件计划原先漏掉的、载荷级的活:把新商户落成本地行。** `createMerchant` 只从上游换回一个商户号,**它不写库**;在此之前,`pay_lsy_merchant` 里没有任何一行带这个 `mch_no`。而 Task 6 的审核通知**正是靠 `mch_no` 定位本地行**的 —— 不落库,`onboardingNotify_missingMerchantRow_returnsFail` 这条分支就是**每次都会走到的死路**,通知永远写不进任何状态。 ⇒ 落在本任务,因为这是**唯一同时握有新 `mch_no` 与认证租户**的地方:`tenant_id` 只能来自认证上下文,而 `createMerchant` 在服务层看不到它。 ⇒ 新行写 `review_status = LsyMerchantReviewStatus.PENDING`(**不得**落到 DDL 默认的 `APPROVED`,§0.9-1)、`tenant_id` 取自认证上下文、`enterprise_id` 取自请求体(那是业务字段,不是归属字段)。 ⇒ **不得复用 `LsyMerchantService.save`**(§0.9-3:它的新增分支会把 `review_status` 钉成 `APPROVED`)。 - [ ] **Step 0: ★★★ 先量清楚租户隔离**已经**由谁在做** —— 不要假设** 本仓有 `TenantInnerInterceptor`(`core/tenant/TenantInnerInterceptor.java:190-216`)。它对 `pay_lsy_merchant`(在 `CONDITIONAL_TABLES` 里,`:166`)逐字是: | 主体 | `ignoreTable` 返回 | 效果 | |---|---|---| | 超管(`LoginUser.isSuperuser == true`) | `true` | **不追加**租户谓词(`:193-197`) | | 已登录 `LoginUser` 且 `tenantId != null` | `false` | **自动追加** `AND tenant_id = ?`(`:209-211`) | | 已登录 `LoginUser` 但 `tenantId == null` | `true` | **不追加**(`:211`) | | 无认证上下文(无 auth,或 principal 不是 `LoginUser`) | `true` | **不追加**(`:213`) | ⇒ **对本任务的端点(已认证、非超管、`tenantId != null` 的正常租户用户),`requireByMchNo` 其实已经被拦截器加了租户谓词** —— 也就是说 §0.9-2 描述的「返回任意一行」在那个主体下**不成立**。计划原先把 IDOR 说成「`requireByMchNo` 无租户条件所致」,那个说法**是错的**(结论仍对,理由要换)。 **换后的理由(才是真正的暴露面)**: 1. **`tenantId == null` 的已登录主体**(第 3 行)—— 没有租户谓词。这个主体是否可被创建出来,本任务要**实测**(查用户/角色表或登录链路),不能假设不会。 2. **超管**(第 1 行)—— 按设计跨租户可见。 3. **隐性依赖**:把安全性质挂在一个**配置在别处的拦截器**上,等于把「这个查询是否越权」的答案交给另一个文件。拦截器的 `CONDITIONAL_TABLES` 名单改一次、`ignoreTable` 的分支调一次,本端点就静默越权,**而本端点的测试不会变红**。 **Step 0 的产出**(写进报告,不写进代码):上面四行逐行**实测**一遍(可用既有测试基建或直连库读 SQL 日志),确认哪一行是真的。**没量过不许照抄本表。** - [ ] **Step 1: 先写失败的测试** | 用例名 | 钉什么 | |---|---| | `requireByMchNoForTenant_queriesWithTenantCondition` | 查询条件里**有** `tenant_id`(照 `requireByTenant_queriesNullEnterpriseId` 的既有钉子形状) | | `requireByMchNoForTenant_otherTenantsMchNo_throws404` | ★ **IDOR 负向**:租户 B 拿租户 A 的 `mch_no` ⇒ 404,且**不得**回任何 A 的数据 | | `requireByMchNoForTenant_duplicateMchNoSameTenant_failsLoudly` | ★★ **本行原写「断言 `uk_lsy_merchant_mch_no` 确实在(读 `pg_indexes`)」—— 那句已作废**(2026-09-23 订正,理由见下)。本用例钉的是:**同租户内 `mch_no` 命中多行时,必须响亮失败**(0 行 ⇒ 404;>1 行 ⇒ 抛/记 ERROR,**不得静默取一行**)。**用 mock 构造,不依赖库里的数据形状** ⇒ 无论唯一索引建没建、库里有没有重复,本用例的强度不变 | | `createEndpoint_usesTenantFromSecurityContext` | 端点的 `tenantId` **只**来自认证上下文,**不接受请求体指定**(照 `LeshouyingMerchantController.save` 里 `entity.setTenantId(null)` 的既有教训) | | `createEndpoint_persistsRowWithPendingStatus` | ★ 建号成功后**真的写了一行**,且 `review_status` 是 `PENDING` 不是 `APPROVED`(钉上面那条载荷级裁定) | | `createEndpoint_doesNotPersistRow_whenUpstreamThrows` | ★ 反向:上游抛错时**不得**留下半个行(没有号的行会让通知那条腿误命中) | | `createEndpoint_rejectsBodySuppliedTenantId` | 请求体里塞 `tenantId` 被忽略/拒绝(同 `save` 的既有教训) | | `endpointPermissionStrings_matchTheDeclaredConstants` | ★ 本用例**只钉住三串权限常量本身**(对测试内写死的三个字符串字面量做断言),**不**去读 SQL 文件 —— 单测读 SQL 是把文件当接口,脆且假。真正的「SQL 与代码逐字一致」由 **Task 8 Step 2 的只读核对语句**在库上验(既有教训:差一个字符即 403,且**超管测不出来**,所以必须有一处独立核对) | | `noEndpointReturnsUpstreamOnlyFields` | 应答字段名白名单断言(Global Constraint 1) | ★★ **为什么原写法作废**(Task 6 的裁定回头改了本任务的前提): Task 6 原写「必须先补 `uk_lsy_merchant_mch_no` 再实现查询」。**实测该索引今天建不成** —— `pay_lsy_merchant` 里**已有重复**:`mch_no='Y52211090004'` 出现 **2 次**(`ids={1, 2102313081860202497}`,**同 `tenant_id=2`**), `CREATE UNIQUE INDEX` 必然失败;而 SQL **一律由用户执行**,删哪一行是**用户的数据决策**。 ⇒ 那条索引**可能长期不存在**。于是一个「断言索引在」的 Java 用例会**因为一条数据决策而红**, 且红的原因与 Task 7 的代码**毫无关系** —— 那是把测试套件挂到别人的待办上。 ★ 索引的「在不在」由 **SQL 自带的两条只读核对语句**验(`db/lsy-merchant-mch-no-uk.sql` §第 2 步 / §只读核对), **不要**在 Java 单测里读 `pg_indexes`。 ★ **而且原用例的目的本来就不靠索引**:本任务走的是**已认证**路径,`requireByMchNoForTenant(mchNo, tenantId)` 的 `WHERE mch_no=? AND tenant_id=?` **本身就是闸** —— 跨租户重号在这个 WHERE 下**不可能串**。 索引是给 **Task 6 那条免登录路径**用的纵深防御(那里没有租户谓词可带),**不是**本任务的安全前提。 ★★ **顺带一处必须一起落地的形状要求**:既然库里**现在真的**有同 `tenant_id` + 同 `mch_no` 的两行, `requireByMchNoForTenant` **不得**用 `selectOne`(MP 会抛 `TooManyResultsException`,形态笼统) **更不得**用 `.last("LIMIT 1")`(**静默**取任意一行 —— 这正是 `LsyMerchantService.requireByMchNo` 的既有形状(订正 2026-09-23:行号原写 `:110`,**现量**声明在 `:125`、`.last("LIMIT 1")` 在 `:131`。★ **本节所有行号都只是当日参看值** —— Task 6 修复轮与后续任务改注释都会让它们漂,**一律按符号名定位,不要按行号**), **照抄它就等于把那个静默行为搬进新方法**)。要 `selectList` + 显式判 0 / 1 / >1。 依据与 Task 6 第一道闸**同源**(`TenantInnerInterceptor.java:156` 的通则),但本条**不依赖**那节 —— 同租户重号已经足够让「静默取一行」成为真问题。 - [ ] **Step 2: 跑测试确认失败** - [ ] **Step 3: 实现** 控制器前缀 `/payment/collection/onboarding`,`@PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:list')")` 形态照 `LeshouyingMerchantController`。 ★★ **每个带 `mchNo` 的端点,第一行就是租户闸。** 顺序是:取认证上下文 → `requireByMchNoForTenant(mchNo, tenantId)` → 才调服务。**绝不允许**先 `requireByMchNo(mchNo)` 再在 Java 里比对 tenantId —— 那已经把别人的 PII 读进内存了。 ★★★ **裁定:`tenantId` 只来自认证上下文,取不到就「拒绝」,不得「不带谓词继续查」。** 取当前主体的既有惯用法是 `EnterpriseController.java:29-33` 那个私有 `currentUser()`(读 `SecurityContextHolder`, `principal instanceof LoginUser` 才返回,否则 **null**)。据此: - `currentUser() == null`,或 `getTenantId() == null` ⇒ **必须显式拒绝**(403 或 400 + 一句人话), **不得**带着 `null` 继续往下走。 - **不接受请求体 / query 参数指定 `tenantId`**(与 `LeshouyingMerchantController.save:47` 的既有教训同族)。 **为什么必须显式拒绝,而不是「反正 `eq(列, null)` 在 SQL 里恒不成立、自然查不到」**: 那是把安全性质挂在一个**未经本任务实测的 MyBatis-Plus 实现细节**上 —— 本次没量过 `eq` 传 null 的真实行为, 而「恰好失败」与「设计上失败」在下次升级或换写法时分道扬镳,且**本端点的测试不会因此变红**。 ★ 结论:**要么显式拒绝,要么实测 `eq(null)` 的行为并写进报告** —— 前者是本任务的默认落法。 **关于超管(Step 0 表格第一行)**:拦截器按设计对超管不追加谓词,但**本端点不继承那个设计** —— `requireByMchNoForTenant` 一律要求非空 `tenantId`。**代价与出口**:若本部署的超管账号 `tenant_id` 恰为 `null`,它会**拿不到本页**(响亮 403,不是静默越权)。 出口是照 `EnterpriseController.all`(`:35-42`)的既有先例,给超管加一个**显式的** `tenant_id` 参数 —— **这是一处小改动,不要提前实现**(Task 9 的进件页**没有租户选择器**,当前没有任何调用方会传它)。 ⇒ **Step 0 必须把「本部署超管的 `tenant_id` 是不是 null」**当作**要实测的一格**,别推断。 ★★★ **`requireByMchNoForTenant` 的 javadoc 必须写明它与 `TenantInnerInterceptor` 的关系**,逐字落 Step 0 那张表,并说明**为什么有了拦截器还要这个方法**: - 拦截器是**在别处配置的**(`CONDITIONAL_TABLES` 名单 + `ignoreTable` 分支)。把「这个查询是否会越权」的答案交给另一个文件,等于让本端点的安全性质**可以被一次无关改动静默改掉,而本端点的测试不会变红**。显式谓词把这条性质钉在本方法自己的 SET 里。 - 拦截器对 `tenantId == null` 的已登录主体与超管**不追加**谓词。显式谓词让「不追加」不再等于「越权」。 ★ 同时加一条**钉住依赖**的测试:`merchantQuery_carriesExplicitTenantPredicate_evenWhenInterceptorWouldSkip` —— 用一个**超管**主体(拦截器必定跳过的那一类)跑一次。**这条是本任务唯一能证明「闸不依赖拦截器」的用例**; 没有它,把显式谓词删掉全库不会变红。 ★★ **它的断言必须写死成「显式拒绝」,不要写成「查不到」** —— 这两种写法在本用例里**都会绿**, 但强度差一个数量级:把谓词删掉之后,「查不到」仍可能因为别的偶然原因绿,而「抛了 403」不会。 ⇒ 钉:**超管 + 无显式租户 ⇒ 抛 403/400**,且**断言异常类型/码**,不是断言「返回空」。 (本计划的旧措辞是「断言拿不到别租户的行」,那句**歧义**:它同时容得下 「超管可以带一个显式租户去查那个租户」与「超管一律被拒」两种实现,而按上一段的裁定是**后者**。 ⇒ 按本段钉死,别照旧措辞猜。) - [ ] **Step 4: 跑测试确认通过** - [ ] **Step 5: 提交** - [ ] **Step 6: 派 `security-review` 技能审这一批端点** --- ## Task 8: 菜单与权限 SQL(**我写,用户执行**) **Files:** - Create: `java/src/main/resources/db/lsy-onboarding-menu.sql` - Create: `java/src/main/resources/db/lsy-review-status-comment.sql` - [ ] **Step 1: 写 SQL** `lsy-onboarding-menu.sql` **照 `lsy-menu.sql` 的 20 列写法逐列照抄**,并把它文件头那几段警告原样带上(`sys_menu` 的真实列名与 RuoYi 那套完全不同;`uuid` NOT NULL 且无兜底;`id` 不要写死;`component_path` 是相对 `src/views/` 的路径;目录已存在则三个页面**都不会插且不报错**)。新增一行菜单「商户进件」,`permission` 逐字取自 Task 7 的三个 `@PreAuthorize`。 ★ **取值来自源码现取,不抄文档**(照 `lsy-menu.sql` 的做法)。 `lsy-review-status-comment.sql`:把 §0.9-1 的本地词表写进 `pay_lsy_merchant.review_status` 的列注释。 - [ ] **Step 2: 给出只读核对语句**(照 `receive-code.sql` 的自带核对先例),并把「预期读数」写死在文件里 - [ ] **Step 3: 提交 SQL 文件**(**执行由用户做**) - [ ] **Step 4: 在最终报告里提示用户执行** --- ## Task 9: 进件页(现有 admin 前端) **Files:** - Create: `frontend/src/views/module_payment/collection/onboarding/index.vue` - Create: `frontend/src/api/module_payment/onboarding.ts` - Modify: 路由与菜单(与 Task 8 的 `component_path` 对齐) > ★ **裁定:进件页放在既有 admin 工程**(挨着 `frontend/src/views/module_payment/collection/merchant`),**不**放进 spec §4.8 的 `frontend-receive/`。依据:spec §4.8 那个「四页、无进件页」的工程是**收款工作台**(批次二),§0.2 的「不放进既有 admin」这条约束的射程是**收款码生成入口**;而 §3.2 明写乐收赢的商户映射管理端就在「现有前端页」,`f790a09` 已落地三页 + `lsy-menu.sql`。且三-B 在批次二之前,`frontend-receive/` 那时还不存在。 > **代价若判错**:搬家是一次性文件移动 + 一条菜单 SQL 改 `component_path`,很廉价。 - [ ] **Step 1: 先写 API 层**(照 `frontend/src/api/module_payment/collection.ts` 的既有形状) - [ ] **Step 2: 写页面** 必须覆盖:商户类型选择(1/2/3)、省市区三级联动(★ 用 `provinceCode`/`cityCode`/`countyCode` 填 `address_province`/`address_city`/`address_county`,**不是** `areaCode`)、结算信息、资质图片上传(走 §三-A 已实现的 `/api/info/upload/img` 腿,前端先做大小与类型护栏)、提交后回显商户号。 ★ **前端文案零上游痕迹**(Global Constraint 1)。★ 图片上传的护栏要**在出站之前**(照 `b229688` 的既有做法)。 - [ ] **Step 3: 起 dev server 用 Playwright 走一遍关键路径**(本仓 CLAUDE.md 规则:前端改动完成后交互验证) - [ ] **Step 4: 提交** --- ## Task 10: 收尾 - [ ] **Step 1: 全量回归**(含 `PaymentApplicationContextTest`,需库隧道在位) - [ ] **Step 2: 逐条核对本计划的 Global Constraints**,把实测读数写进台账 - [ ] **Step 3: 写交付说明**:列出**用户需要执行的两个 SQL**、**需要向客户经理索取的 4 项 + 上游需配置的 1 项**、以及 §0.3 / §0.7 / §0.8 三条**需要用户裁定的待确认项** - [ ] **Step 4: 派最终整支评审**(全批次 diff,最强模型) --- ## Self-Review **1. Spec 覆盖** | spec 条款 | 落点 | |---|---| | §6.1 的七条进件腿 | Task 3/4/5(+ 三-A 已完成的六条) | | §6.1 #2 审核通知 | Task 6 | | §6.2 (a) merchant_type 裁定 | Task 2(`LsyMerchantCategory` javadoc) | | §6.6.4 IDOR(**理由已改写**) | Task 7 Step 0 + 显式租户谓词 + 超管主体的钉子用例(R13) | | `mch_no` 唯一性义务(拦截器自述) | Task 6 Step 0(R12) | | §6.2 (b) 三套值域 | §0.4 + Task 4(不建枚举)+ Task 6(另建枚举) | | §6.2 (c) 商户号双键 | Task 3 | | §6.4 值从配置读、不留假值 | Task 1 | | §6.4 凭据到位前不部署 | Global Constraint 7 | | §6.5 单独配置项 + 白名单路径 | Task 1 + Task 6 Step 3 的裁定 | | §6.6.3 省市区 DTO 加三个 GB 码 | Task 5 | | §6.6.4 IDOR | Task 7 | | §8.1 验签不跳过 | Task 6 | | §8.5 文案零痕迹 | Global Constraint 1,Task 3/7 各有用例 | | §8.6 凭据不进仓库 | Task 1 | | §8.7 秘密只打印长度 | Task 5(PII 用例) | | §9 三-B 只做组装/校验单测 | Global Constraint 8 | | spec §6.4 修订:端点+菜单+前端页随三-B | Task 7/8/9 | **缺口(本计划**有意**不做,已记账)**:`/api/info/bank`(§0.7)、`/api/info/mcc`(§0.6)、平安收银宝两条签约腿(§0.6)。 **2. 占位符扫描**:无 TBD / TODO / 「类似 Task N」。每个 Task 的用例名与断言对象都是具体的。 ★ 但**没有给生产代码模板** —— 这是有意的:本仓有「计划里的代码模板被执行成生产缺陷」的前科(memory `constraint-docs-drift-anchor-by-name`)。凡**字面值**(字段名、键名、message、权限串、词表)都在计划里给死;凡**代码结构**一律描述到「照哪个既有方法」为止,由实施者现读现写。 **3. 类型一致性**:`missingRequiredFields` / `missingOnboardingCredentials` 两个「列全缺失项」的方法**同名同形**(返回 `List`、一次列全),这是有意的对称。`requireByMchNoForTenant(String, Long)` 的参数序与既有 `requireByEnterprise(Long tenantId, String enterpriseId)` **相反** —— 按 `requireByMchNo(String)` 的主体参数在前,并在 javadoc 里点名这处不一致,免得调用方按前者的顺序写。