# 商户进件 三-A(上游 leg)Implementation Plan > **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/*` 进件类报文这条腿打通并落地六个接口(图片上传、省市区、门店创建、门店列表、获取商户信息、商户审核主动查询),每个接口都有单测钉住报文契约,最后拿测试环境真跑一轮取证。 **Architecture:** 三-A 只做**上游 leg**:`LeshouyingClient` 补两个出站方法(JSON 版 `/api/*` 与 multipart 版),新增一个 `LsyOnboardingService` 按接口一组方法。**不新增 HTTP 端点、不加菜单、不改 DDL**(理由见下方「本计划做出的裁定」R-1)。HTTP 端点与进件 UI 随三-B 一起来 —— 那时才有调用方,也才有东西可点。 **Tech Stack:** Java 21 / Spring Boot 3 / MyBatis-Plus 3.5.5 / Hutool 5.8.34 / JUnit 5 + Mockito / RestTemplate(`SimpleClientHttpRequestFactory`) **Spec:** `docs/superpowers/specs/2026-09-22-receive-workbench-design.md` §6(尤其 §6.1 接口清单、§6.2 文档缺陷、§6.3 阻塞项、§6.4 拆分) ## Global Constraints 以下逐字来自 spec 与本仓既有约定,**每个 task 的要求都隐含包含本节**: 1. **验签绝不跳过。** `/api/*` 与 `/v3/*` 共用同一套签名规则(字典序拼接 + `&key=` + MD5 32 位小写),走既有的 `LeshouyingSigner`,不开任何旁路。 2. **不暴露上游。** 本批次不产出任何面向用户/商户/前端的字符串,因此主要约束在**日志**:出站 trace 日志一律过 `LeshouyingSensitive.maskByKey`(内容由我们构造),入站/异常正文一律过 `LeshouyingSensitive.mask`(内容来自外部)。 3. **凭据只打印长度,不打印值。** 机构密钥不得出现在任何测试断言、日志、计划文本里。 4. **错误信封不要混用。** `/api/*` 只认 `result_code` + `result_msg`,**不得**碰 `parseV3`;`/v3/*` 反之。混用会把「进件失败」读成「解析错误」。 5. **不猜值域。** 文档互相矛盾的地方按 §6.2 的裁定走;裁定覆盖不到的,**原样透传上游值 + 在 javadoc 里指向文档原文**,不做归一化。 6. **构建命令**:`C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f D:\project2\payment-platform\java\pom.xml test`。 - **不要加 `-q`,不要加 `-proc:none`** —— 两者都会悄悄毁掉验证。 - `mvn test` **需要库隧道在位**(`PaymentApplicationContextTest` 的硬依赖)。 - `target/surefire-reports/` 跨运行累积,**不要对整个目录求和**来报测试数。 7. **两向变异验证**:每个新钉子都要证明「该红的会红」,同时**无害改动必须实测仍绿**。两向读数都进报告。 8. **提交粒度**:每个 task 一个 commit,conventional commits,中文正文。 9. **SQL 只写不执行** —— 本批次不产出 SQL(R-1),此条备而不用。 --- ## 本计划做出的裁定 > 以下每条都是我在 spec 未覆盖或自相矛盾处**替用户做的决定**,连理由和「错了的代价」一起记,便于低成本推翻。 ### R-1:三-A 只做上游 leg,不加 HTTP 端点、不加菜单 - **决定**:本批次产出 `LeshouyingClient` 的新方法与 `LsyOnboardingService`,**不加** `@RestController`、不加权限串、不加菜单 SQL。 - **理由**:① spec §4.8 的前端工程只有四页,**没有进件页**;② 现在加端点等于加一批**当下零调用方**的 HTTP 面;③ 本仓有明确先例——`LsyMerchantService.requireByMchNo` 的 javadoc 专门写明「当前无调用方」并解释为什么保留,说明这个仓对「无调用方的面」是有意识的;④ 三-B 的进件 UI 一来,端点自然落地,届时租户归属校验也才有地方长(见 R-4)。 - **错了的代价**:若用户本意是三-A 就要能 curl 到,那么这批端点要补做,但**服务层代码一行不废**,只是多一个薄控制器。 ### R-2:`manager_name` / `manager_phone` 以**参数表**为准,示例里的 `maneger_*` 记为文档缺陷 - **决定**:请求发 `manager_name` / `manager_phone`。 - **依据**:创建门店页(page 196236559)**参数表**写 `manager_name` / `manager_phone`;同一页的**请求示例**写的是 `maneger_name` / `maneger_phone`("maneger" 是拼写错误)。参数表是规范,示例是手打的。 - **★ 但这是可实测项**,Task 7 的真机一轮**必须**用它取证:真发一次,若上游回「参数不合法 / 缺少 manager_name」,说明实际认的是错拼版。 - **实现要求**:把这两个键名提成 `LsyOnboardingService` 里的具名常量并附 javadoc 指向文档页码,**真机确认后只改一处**。不要在两个方法里各写一遍字面量。 ### R-3:图片上传的本地护栏(大小上限 + 扩展名白名单)**是我们的策略,不是上游的限制** - **决定**:`leshouying.upload-max-bytes`,默认 `2097152`(2 MiB);扩展名白名单 `jpg`/`jpeg`/`png`/`bmp`,其余 400。 - **理由**:上传的字节**全程在我们堆里**(要算签名 → 要构造 multipart body),不设上限等于把堆交给调用方。**上游文档没有写任何大小/类型限制**(page 196236572 全文没有),所以这两个数**不得**被表述成「上游要求」。 - **错了的代价**:商户传一张 iPhone 原图(常见 3–8 MB)会被我们拦掉。这是**宁可拦错**的方向;真出现就调大配置,无需改代码。 ### R-4:服务方法收 `mchNo`,不自己解析租户 —— 但这条要在三-B 补闸 - **决定**:`LsyOnboardingService` 的方法签名收 `String mchNo`,**不**接受 `tenantId`、也**不**做 `LsyMerchantService` 查询。 - **理由**:进件阶段商户**还没进 `pay_lsy_merchant`**,拿 `requireByTenant` 查必然落空——正是本轮要防的那种「校验前提不成立」。 - **★ 已知缺口,必须写进 javadoc**:本层的 `mchNo` **不受租户约束**。三-B 加 HTTP 端点时,**必须在端点侧**先证明该 `mchNo` 属于当前租户,否则就是一个 IDOR(A 租户读 B 商户的进件资料与通道费率)。在 三-A 里没有端点 ⇒ 没有可达路径,但**这行 javadoc 是三-B 的前置条件,不许省**。 ### R-5:`reviewed_status` 只做「商户审核主动查询」这一套值域,原样透传 + 附本文档映射 - **决定**:`queryReview` 返回上游原值(字符串)+ 该页自己的映射,**不做跨接口归一化**。 - **依据**:spec §6.4 三-C 要求「三套值域全部按各自接口实现,不做归一化猜测」。三-C 的第三套值域属于**查询商户入网状态**(`reportQuery`),那在三-B。所以三-C 被三-A/三-B 各自吸收一部分,**不再单列**——这条是对 §6.4 的修订。 - **本页值域**(page 196236567,逐字):`0审核通过,1审核中,9审核驳回,10通道报备中,98停用,99关闭`。 - **★ 同页缺陷,必须记进 javadoc**:返回示例是 `"reviewed_status": 0` 配 `"reviewed_reason": "审核通过"` —— **驳回原因字段里填的是「审核通过」**。所以 `reviewed_reason` 只在 `reviewed_status` 为 `9` 时才当作驳回原因。 --- ## File Structure **新增:** | 文件 | 职责 | |---|---| | `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingService.java` | 六个进件接口的全部业务逻辑。**一个类**——它们共享同一套 `mchNo`/`trace_no` 组装与同一条错误通道,拆开只会把同样的注释抄六遍 | | `java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyAreaItemDTO.java` | 省市区一项:`areaName` / `areaCode` | | `java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyShopCreateDTO.java` | 创建门店入参 | | `java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyShopItemDTO.java` | 门店列表一项 | | `java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyReviewStatusDTO.java` | 审核状态:`reviewedStatus`(原值)/ `reviewedReason` / `statusText` | | `java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyReviewStatus.java` | 「商户审核主动查询」**这一套**值域 → 文案。javadoc 必须写明它**不通用** | > ★ **`LsyMerchantType` 不在本批次**(预检时从 Task 3 移除):`merchant_type` 是**创建商户**的必填字段,属三-B。三-A 这六个接口里没有任何一个读它 ⇒ 建出来就是零消费方的投机代码。spec §6.2(a) 那条裁定(`1企业/2个体工商户/3小微商户`)**留在 spec 里**,三-B 用到时照着建。 **修改:** | 文件 | 改什么 | |---|---| | `.../leshouying/client/LeshouyingClient.java` | 加 `postApi(path, params)` 与 `postMultipart(path, parts)`。**`post()` 的行为一个字都不改** | | `.../leshouying/config/LeshouyingProperties.java` | 加 `uploadMaxBytes`(默认 2 MiB)。**不要**加进 `validate()` 的必填列表 | | `.../leshouying/config/LeshouyingPropertiesTest.java` | 补:`uploadMaxBytes` 有默认值、且不在必填校验里 | **测试(新增):** | 文件 | 覆盖 | |---|---| | `.../client/LeshouyingClientApiEnvelopeTest.java` | `postApi` 走 `parseApi` 信封;`post` 仍走 `parseV3` | | `.../client/LeshouyingClientMultipartTest.java` | multipart 组装、签名不含文件、文件名、超限 | | `.../service/LsyOnboardingServiceTest.java` | 六个接口的入参组装、出参解析、错误分支 | **不碰:** 任何 `@RestController`、任何 `db/*.sql`、`SecurityConfig`、`application.yml`、前端。 --- ## Task 1: `LeshouyingClient.postApi` —— `/api/*` 出站腿 **Files:** - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClient.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientApiEnvelopeTest.java` **Interfaces:** - Consumes: 既有 `LeshouyingSigner` / `LeshouyingResponseParser` / `LeshouyingProperties` / `LeshouyingHttpConfig.BEAN_NAME` - Produces: `public Map postApi(String path, Map bizParams)` - [ ] **Step 1: 写失败的测试** 新建 `LeshouyingClientApiEnvelopeTest`(照抄 `LeshouyingClientTest` 的 `@Mock RestTemplate` + 手工 `new LeshouyingClient(...)` 骨架)。三条用例: | 测试名 | 造什么 | 断言什么 | |---|---|---| | `postApi_parsesApiEnvelope_notV3` | 桩返回 `{"result_code":"SUCCESS","result_msg":"操作成功","area_list":[]}` | 返回的 map 里 `result_code` = `SUCCESS`。**这条钉的是信封选择**:若 `postApi` 误走 `parseV3`,会因为缺 `return_code` 抛 `PARSE_ERROR` | | `postApi_failure_throwsWithResultMsg` | 桩返回 `{"result_code":"FAIL","result_msg":"参数不合法"}` | 抛 `LeshouyingApiException`,`getMessage()` 含 `参数不合法` | | `post_stillUsesV3Envelope_apiEnvelopeIsRejected` | 桩返回 `{"result_code":"SUCCESS"}`(**没有** `return_code`),调 `client.post(...)` | 抛 `LeshouyingApiException` 且 code 为 `PARSE_ERROR`。**正对照**:证明两条腿没有共用同一个解析器 | - [ ] **Step 2: 跑测试确认红** Run: `C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f D:\project2\payment-platform\java\pom.xml test -Dtest=LeshouyingClientApiEnvelopeTest` Expected: 编译失败或 `postApi` 不存在 ⇒ **FAIL**。 - [ ] **Step 3: 让它变绿** 在 `LeshouyingClient` 加 `postApi`。实现要点(**契约,不是代码模板**——具体写法由实现者定): - 与 `post()` **共用**同一套:`bizParams` 剔除 null → 补 `inst_no` → 补 `sign` → 同一份出站 trace 日志(`maskByKey`)→ 同一份 `try/catch` 四分支(`LeshouyingApiException` 直抛 / `RestClientResponseException` 脱敏后包 `HTTP_*` / 其它包 `NETWORK_ERROR` + `initCause` / 非 2xx 或空体包 `HTTP_*`)。 - **唯一差别**:末尾 `return parser.parseApi(json)`。 - 抽取公共段时,**`post()` 的公开签名、日志文案、异常 code 一律不得变**——`LeshouyingClientTest` 的五条用例是它的锁。 - javadoc 必须写明:**为什么是两个方法而不是一个带 boolean 的方法**(信封是协议事实,不是开关;布尔参数读作 `post(path, p, true)` 时没人知道 `true` 指哪一边,而传错的那一次会把「进件失败」读成「解析错误」)。 - [ ] **Step 4: 跑测试确认绿** Run: 同上命令 + `-Dtest=LeshouyingClientApiEnvelopeTest,LeshouyingClientTest,LeshouyingResponseParserTest` Expected: **PASS**,且 `LeshouyingClientTest` 五条全绿(证明 `post()` 没被动过)。 - [ ] **Step 5: 两向变异** | 变异 | 期望 | |---|---| | M-1:`postApi` 末尾改成 `parseV3` | `postApi_parsesApiEnvelope_notV3` 必须**红** | | M-2:`post` 末尾改成 `parseApi` | `post_stillUsesV3Envelope_apiEnvelopeIsRejected` 必须**红** | | M-3(无害):给 `postApi` 的 trace 日志文案改个词 | 必须**仍绿** | 三向读数都记进 `task-1-report.md`。 - [ ] **Step 6: Commit** ```bash git add java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClient.java \ java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientApiEnvelopeTest.java git commit -m "feat(进件): LeshouyingClient 补 /api/* 出站腿 - 信封与 /v3/* 分开" ``` --- ## Task 2: `LeshouyingClient.postMultipart` —— 图片上传的出站腿 **Files:** - Modify: `.../leshouying/client/LeshouyingClient.java` - Test: `.../client/LeshouyingClientMultipartTest.java` **Interfaces:** - Consumes: Task 1 的 `LeshouyingClient` - Produces: `public Map postMultipart(String path, Map formFields, String fileFieldName, String fileName, byte[] fileBytes)` **上游契约**(page 196236572,逐字): | 参数 | 类型 | 必填 | 说明 | |---|---|---|---| | `img_file` | File | 是 | 待上传的图片文件,**不参与签名** | | `inst_no` | String | 是 | 机构号 | | `img_name` | String | 是 | 图片的完整名称,带后缀 | | `sign` | String | 是 | 签名字符串 | `Content-Type: multipart/form-data`。返回 `result_code` / `result_msg` / `img_url`。 - [ ] **Step 1: 写失败的测试** 四条用例: | 测试名 | 断言什么 | |---|---| | `postMultipart_signsOnlyFormFields_fileIsNotSigned` | 用 `ArgumentCaptor` 抓 `HttpEntity`,从 `MultiValueMap` 里取出字符串部件重算 `LeshouyingSigner.verify` ⇒ **true**;再断言签名原文的构件里**不含**文件名与文件字节(即:把文件部件加进签名会让这条红) | | `postMultipart_sendsFilePartWithRealFilename` | `ByteArrayResource` 的 `getFilename()` 等于传入的 `fileName`。**默认的 `ByteArrayResource` 返回 null**,不覆写就拿不到文件名 —— 这条钉住那个匿名子类 | | `postMultipart_contentTypeIsMultipartFormData` | 请求头 `Content-Type` 以 `multipart/form-data` 开头 | | `postMultipart_parsesApiEnvelope` | 桩返回 `{"result_code":"SUCCESS","img_url":"http://x/y.jpg"}` ⇒ 能取到 `img_url` | - [ ] **Step 2: 跑测试确认红**(同 Task 1 的写法,`-Dtest=LeshouyingClientMultipartTest`) - [ ] **Step 3: 让它变绿** 实现要点: - 用 `MultiValueMap`:字符串部件用 `HttpHeaders` 设 `Content-Disposition: form-data; name=...`;文件部件用 `ByteArrayResource` 的匿名子类覆写 `getFilename()`。 - 签名:**只**对 `formFields`(不含文件)补 `inst_no` 与 `sign`,签完把 `sign` 也作为**一个字符串部件**放进表单。 - 用**同一个** `lsyRestTemplate` bean(`@Qualifier(BEAN_NAME)`),不要 `new RestTemplate()`。 - 错误处理与 Task 1 的四分支**同一套**,末尾 `parseApi`。 - javadoc 必须写两条: 1. 「文件不参与签名」是上游规定(附 page id),不是我们的选择; 2. **★ 超时残留风险**:`SimpleClientHttpRequestFactory` 的 `readTimeout` 只约束**请求体发完之后等响应**的那一段,`HttpURLConnection` **没有写超时**。大文件在对端只收不读时可以把线程挂住 —— 这正是 `LeshouyingHttpConfig` 那套超时要防的 F-1。当前缓解手段是 Task 6 的大小上限(2 MiB),**不是**超时。这是**已知残留**,不许写成「已解决」。 - [ ] **Step 4: 跑测试确认绿** + `-Dtest=LeshouyingClientMultipartTest,LeshouyingClientTest,LeshouyingClientApiEnvelopeTest` - [ ] **Step 5: 两向变异** | 变异 | 期望 | |---|---| | M-1:把文件部件也塞进签名 map | `postMultipart_signsOnlyFormFields_fileIsNotSigned` **红** | | M-2:去掉 `ByteArrayResource` 的 `getFilename()` 覆写 | `postMultipart_sendsFilePartWithRealFilename` **红** | | M-3(无害):调整字符串部件的加入顺序 | **仍绿** | - [ ] **Step 6: Commit** —— `feat(进件): LeshouyingClient 补 multipart 出站腿 - 文件不参与签名` --- ## Task 3: `LsyOnboardingService` 骨架 + 省市区 **Files:** - Create: `.../leshouying/service/LsyOnboardingService.java` - Create: `.../leshouying/dto/LsyAreaItemDTO.java` - Test: `.../service/LsyOnboardingServiceTest.java` **Interfaces:** - Produces: `public List areaList(String level, String parentCode)` **上游契约**(page 196236568): | 参数 | 必填 | 说明 | |---|---|---| | `inst_no` | 是 | 合作机构号(客户端自动补) | | `level` | 是 | `1`省/直辖市,`2`市/州,`3`区/县 | | `parent_code` | 否 | 当前等级的上级 code,**1 级不填** | 返回 `result_code` / `result_msg` / `area_list[]`,每项 `area_name` / `area_code`。 - [ ] **Step 1: 写失败的测试** | 测试名 | 断言什么 | |---|---| | `areaList_level1_omitsParentCode` | 抓出站 map ⇒ **不含** `parent_code` 键。**这条钉的是「1 级不填」**:若实现统一填 `""`,签名会把空串算进去(本仓签名规则:空串参与、null 不参与),与文档示例对不上 | | `areaList_level3_sendsParentCode` | 抓出站 map ⇒ `parent_code` 等于传入值 | | `areaList_mapsSnakeCaseToCamel` | 桩返回 `area_list:[{"area_name":"东城区","area_code":"1000"}]` ⇒ DTO 的 `areaName`/`areaCode` 对得上 | | `areaList_areaCode_isString_notNumber` | 桩返回 `area_code:"100G"` ⇒ 取回的是**字符串** `"100G"`,且 `equals` 成立。**★ 这条钉上游数据的真实形状**:`area_list` 里出现 `100A`…`100H`(page 196236568 的返回示例),`100G`/`100H` 不是合法十六进制 —— 任何 `Integer.parseInt` / `Long.parseLong` / 数字型 DTO 字段都会在这里炸 | | `areaList_blankList_returnsEmpty` | 桩返回 `{"result_code":"SUCCESS"}`(无 `area_list`)⇒ 返回**空列表**,不是 null | - [ ] **Step 2: 跑测试确认红** - [ ] **Step 3: 让它变绿** 实现要点: - `LsyOnboardingService` 构造器注入 `LeshouyingClient`(照 `Lsy*Service` 既有写法,**`@Slf4j` + `@Service` + `@RequiredArgsConstructor` 三个注解一个不少** —— 同包七个既有服务全是这三个,`LsyQueryService:13-16` 可对照)。 ⚠ **`@Service` 必须写**:漏了它**本批次一条测试都不会红** —— T3–T6 的单测都直接 `new` 这个类, 而 `PaymentApplicationContextTest` 只是「容器能起来」,一个没被任何 bean 注入的类少个注解它照样绿。 代价推迟到**三-B**(controller 注入它)才以 `NoSuchBeanDefinitionException` 暴露,那时 T4/T5/T6 已经全部建在它上面。 - 路径字面量就地写:`client.postApi("/api/info/area", p)`(与 `LsyQueryService` 写 `"/v3/queryorder"` 一致)。 - DTO 用 `@Data`,字段 `areaName` / `areaCode`,**`areaCode` 必须是 `String`**,javadoc 指向 page 196236568 的 `100G` 反例。 - `area_list` 取值要做**防御性读取**:上游没返回该键、返回 null、或返回的不是 List,都归成空列表。不引入 Jackson 反序列化 —— 本模块既有风格是直接读 `Map` 再手工映射。 - **★ 测试骨架的注记**:`LsyOnboardingServiceTest` 里 mock 的是 **`LeshouyingClient`**(不是 `RestTemplate` —— 那个在客户端内部,服务层看不见)。所以本类里一切「没有出海」的断言都写作 `verifyNoInteractions(client)`。Task 5 / Task 6 同理。 - [ ] **Step 4: 跑测试确认绿** Run: `... -Dtest=LsyOnboardingServiceTest` - [ ] **Step 5: 两向变异** | 变异 | 期望 | |---|---| | M-1:`level == 1` 时也填 `parent_code=""` | `areaList_level1_omitsParentCode` **红** | | M-2:`areaCode` 改成 `Integer`(或对 `area_code` 调 `parseInt`) | `areaList_areaCode_isString_notNumber` **红**(编译不过也算红,报告里写明是哪种红) | | M-3:`area_list` 缺失时返回 null 而不是空列表 | `areaList_blankList_returnsEmpty` **红** | | M-4(无害):给 DTO 加一个未被断言的字段 | **仍绿** | - [ ] **Step 6: Commit** —— `feat(进件): 省市区三级联动 - area_code 必须当字符串` --- ## Task 4: 获取商户信息 + 商户审核主动查询 **Files:** - Modify: `.../leshouying/service/LsyOnboardingService.java` - Create: `.../leshouying/dto/LsyReviewStatusDTO.java` - Create: `.../leshouying/enums/LsyReviewStatus.java` - Test: `.../service/LsyOnboardingServiceTest.java`(追加) **Interfaces:** - Produces: - `public Map merchantInfo(String mchNo)` - `public LsyReviewStatusDTO queryReview(String mchNo)` **上游契约**: `POST /api/merchant/info`(page 196236558) - 入参:`inst_no` / `trace_no` / `merchant_no` / `sign` - 出参(**`result_code` 为 SUCCESS 时才返回**):`merchant_no`、`merchant_type`(`1企业,2个体工商户,3小微个人`)、`account_type`(`1对公结算 2法人对私结算 3非法人对私结算`)、`merchant_name`、`merchant_name_short`、`merchant_name_business`、`merchant_mcc_code`、联系人四处、`address_province/city/county/street`、`license_*`、`legal_*`、`account_*` 四处、`picture_*` 十五处、`apply_h5_url`、以及 `{wx,ali,uni}_channel_{id,name,mch_id,sub_mch_id,status,rate}` `POST /api/merchant/query`(page 196236567) - 入参同上 - 出参:`merchant_no`、`merchant_name`、`reviewed_status`、`reviewed_reason` - [ ] **Step 1: 写失败的测试** | 测试名 | 断言什么 | |---|---| | `merchantInfo_sendsMerchantNoAndTraceNo` | 抓出站 map ⇒ `merchant_no` 等于入参、`trace_no` 非空白且长度 32(`IdUtil.fastSimpleUUID()` 的形状) | | `merchantInfo_twoCalls_produceDifferentTraceNo` | 连调两次,两次的 `trace_no` **不相等**。**这条钉的是「流水号唯一」**:写死常量或缓存一次会在上游那边撞流水 | | `merchantInfo_returnsRawUpstreamFields_untouched` | 桩返回含 `wx_channel_rate:"0.006"`、`merchant_type:3` ⇒ 返回值里这两个键**原样**在。**本方法不做字段白名单**(spec §6.2 有四处字段名/类型不一致,现在立白名单就是把错误固化),javadoc 必须写明这条边界 | | `queryReview_mapsKnownStatusCodes` | 桩返回 `reviewed_status:"0"` ⇒ `statusText` = 审核通过;`"1"`⇒审核中;`"9"`⇒审核驳回;`"10"`⇒通道报备中;`"98"`⇒停用;`"99"`⇒关闭 | | `queryReview_unknownStatus_keepsRawAndDoesNotThrow` | 桩返回 `reviewed_status:"7"` ⇒**不抛异常**,`reviewedStatus` = `"7"`,`statusText` = null 或原值(实现者二选一,**测试与实现必须一致**,并在 javadoc 写明选了哪个、为什么)。**这条钉的是「不猜值域」**:将来上游加一个码,正确行为是「页面显示原始码」而不是「整条链路 500」 | | `queryReview_numericStatusFromUpstream_isReadable` | 桩返回 `reviewed_status: 0`(**数字**,与 page 196236567 的返回示例一致)⇒ 仍能映射成「审核通过」。**★ 上游两处示例一个给字符串一个给数字,这条同时钉住两种** | | `queryReview_reviewedReason_isNotTrustedAsReason` | 桩返回 `reviewed_status:0` + `reviewed_reason:"审核通过"` ⇒ `reviewedReason` **照原样返回**,但 javadoc 记录「本字段在上游示例里填的不是驳回原因」。**实现不得**因为 status 是通过就把 reason 清空 —— 那是归一化 | - [ ] **Step 2: 跑测试确认红** - [ ] **Step 3: 让它变绿** 实现要点: - 两个方法都用 `client.postApi("/api/merchant/info" | "/api/merchant/query", p)`,`p` 里放 `merchant_no` + `trace_no`(`IdUtil.fastSimpleUUID()`)。 - **`mchNo` 空白 ⇒ 抛 `BusinessException(400, "商户号不能为空")`**(照 `LsyMerchantService.requireByMchNo` 的口径),不要把空串发给上游。 - `queryReview` 的映射:`LsyReviewStatus` 枚举持 `code` + `text`,用 `of(String)` 做**精确字符串匹配**(不是 `Integer.parseInt`,因为上游可能给 `"10"`,也可能哪天给别的);匹配不到返回 `null`(不抛)。 - `LsyReviewStatusDTO` 三个字段:`reviewedStatus`(String,原值)、`reviewedReason`(String,原值)、`statusText`(String,可为 null)。 - **`LsyReviewStatus` 的 javadoc 必须写明它不通用**:它只对应 page 196236567。另有 page 196236556(异步通知:`1审核通过,2审核驳回`)与 page 196236566(入网状态:`0..6`,同页两个映射)两套值域,**不得**复用本枚举。三套的冲突清单见 spec §6.2(b)。 - [ ] **Step 4: 跑测试确认绿** - [ ] **Step 5: 两向变异** | 变异 | 期望 | |---|---| | M-1:`trace_no` 改成固定字符串 | `merchantInfo_twoCalls_produceDifferentTraceNo` **红** | | M-2:`LsyReviewStatus.of` 对未知码抛异常 | `queryReview_unknownStatus_keepsRawAndDoesNotThrow` **红** | | M-3:把 `merchant_type` 从返回值里剔掉(立白名单) | `merchantInfo_returnsRawUpstreamFields_untouched` **红** | | M-4:`reviewed_status` 用 `Integer.parseInt` 解析 | `queryReview_unknownStatus_keepsRawAndDoesNotThrow` **红**(`"7"` 仍能 parse…)—— ★ **若这条变异实测仍绿,说明该变异无效**,改测 `"0A"` 之类的非数字码,并在报告里写明第一次变异为什么是无效变异 | | M-5(无害):给 `LsyReviewStatus` 加一个未使用的枚举常量 | **仍绿** | - [ ] **Step 6: Commit** —— `feat(进件): 商户信息与审核状态查询 - 值域不跨接口归一化` --- ## Task 5: 门店创建 + 门店列表 **Files:** - Modify: `.../leshouying/service/LsyOnboardingService.java` - Create: `.../leshouying/dto/LsyShopCreateDTO.java` - Create: `.../leshouying/dto/LsyShopItemDTO.java` - Test: `.../service/LsyOnboardingServiceTest.java`(追加) **Interfaces:** - Produces: - `public String createShop(LsyShopCreateDTO dto)` → 返回 `shop_no` - `public List listShops(String mchNo)` **上游契约**: `POST /api/shop/create`(page 196236559) - 入参:`inst_no` / `trace_no` / `merchant_no` / `shop_name` / `shop_phone` / `shop_address` / **`manager_name`** / **`manager_phone`** / `sign`(见 R-2) - 出参:`result_code` / `result_msg` / `inst_no` / `trace_no` / `shop_no`(**操作成功时返回**)/ `sign` `POST /api/shop/list`(page 196236560) - 入参:`inst_no` / `trace_no` / `merchant_no` / `sign` - 出参:`merchant_no` / `merchant_name` / `shop_list[]{shop_no,shop_name,shop_address,shop_tel}` - [ ] **Step 1: 写失败的测试** | 测试名 | 断言什么 | |---|---| | `createShop_sendsManagerNameSpelledAsTable` | 抓出站 map ⇒ 含 `manager_name` 与 `manager_phone`,**不含** `maneger_name`/`maneger_phone`。**这条钉 R-2 的裁定**,且真机确认后若需翻转,改常量即可 | | `createShop_allEightBusinessFieldsAreSent` | 抓出站 map ⇒ 键集合**恰好**是 `{inst_no, trace_no, merchant_no, shop_name, shop_phone, shop_address, manager_name, manager_phone, sign}`。**集合相等**,不是 `contains` —— 多发的字段会进签名,上游可能因此判「参数不合法」 | | `createShop_blankRequiredField_throws400` | 八个业务字段逐个置空白(参数化或循环)⇒ 全部抛 `BusinessException(400, ...)`,且**一次上游调用都没发出**(`verifyNoInteractions(client)`)。**这条钉的是本地校验先于出站** | | `createShop_returnsShopNo` | 桩返回 `{"result_code":"SUCCESS","shop_no":"861907240003"}` ⇒ 返回 `"861907240003"` | | `createShop_missingShopNo_throws` | 桩返回 `{"result_code":"SUCCESS"}`(**无** `shop_no`)⇒ 抛异常(**不要让 null 一路往外走**:上游说成功却没给门店号,调用方拿 null 存库就是一条查不回来的记录)。异常类型由实现者定,测试与实现一致即可,javadoc 写明 | | `listShops_mapsFourFields` | 桩返回 page 196236560 的返回示例(**注意**:该示例里 `"trace_no": "123456"` 后面**缺一个逗号**,是非法 JSON —— 测试里要**补上逗号**再用,不要照抄)⇒ 四项都映射上 | | `listShops_blankShopTel_doesNotBecomeStringNull` | 桩返回第二项**没有** `shop_tel` 键(上游示例里就是这样)⇒ `shopTel` 是 **null**,不是字符串 `"null"`。**★ 这条钉的是 Hutool 的 `JSONNull`**:`LeshouyingResponseParser.toMap` 只归一层顶层,嵌套对象里的 null 仍是 `JSONNull` 单例,`String.valueOf` 会得到 `"null"`(长度 4)。同款坑在 parser 的注释里有实测记录 | - [ ] **Step 2: 跑测试确认红** - [ ] **Step 3: 让它变绿** 实现要点: - 两个键名提成常量:`private static final String KEY_MANAGER_NAME = "manager_name";` + javadoc 指向 page 196236559 与 R-2(含错拼版本 `maneger_name` 的原文,便于真机翻转时一眼改对)。 - `createShop` 的必填校验按 `LsyMerchantService.save` 的口径:空白 ⇒ `BusinessException(400, "...")`,**中文字段名**(「门店名称」而不是 `shop_name`),且**校验在组 map 之前**。 - `shop_no` 缺失:抛 `LeshouyingApiException`?还是 `BusinessException(502, ...)`?—— **由实现者裁定并写进 javadoc**。约束:① 不得返回 null;② 不得静默;③ 异常 message 不得含上游字段名以外的上游痕迹(其实是字段名本身就可以,本批次无用户可见面)。 - `listShops` 的嵌套读取要**显式处理 `JSONNull`**:取到 `JSONNull` 一律当 null。**不要**为了图快把 `LeshouyingResponseParser.toMap` 改成递归 —— 那会动到 Task 1-4 与批次一全部路径,超出本 task 边界(parser 的 javadoc 已声明该边界并注明「谁要读嵌套字段,先回来把这里改成递归」;本 task 选择在**读取点**处理,并在 report 里说明为什么没走另一条)。 - [ ] **Step 4: 跑测试确认绿** - [ ] **Step 5: 两向变异** | 变异 | 期望 | |---|---| | M-1:键名改成 `maneger_name` | `createShop_sendsManagerNameSpelledAsTable` **红** | | M-2:去掉必填校验 | `createShop_blankRequiredField_throws400` **红** | | M-3:多送一个 `shop_no=""` 到出站 map | `createShop_allEightBusinessFieldsAreSent` **红** | | M-4:嵌套读取时不做 `JSONNull` 归 null | `listShops_blankShopTel_doesNotBecomeStringNull` **红** | | M-5(无害):把 `createShop` 内部的 map 从 `LinkedHashMap` 换成 `HashMap` | **仍绿**(签名按 key 排序,与插入顺序无关 —— 这条顺带证明了签名器的排序确实生效) | - [ ] **Step 6: Commit** —— `feat(进件): 门店创建与列表 - manager_name 以参数表为准` --- ## Task 6: 图片上传 **Files:** - Modify: `.../leshouying/service/LsyOnboardingService.java` - Modify: `.../leshouying/config/LeshouyingProperties.java`(加 `uploadMaxBytes`) - Modify: `.../config/LeshouyingPropertiesTest.java` - Test: `.../service/LsyOnboardingServiceTest.java`(追加) **Interfaces:** - Consumes: Task 2 的 `postMultipart` - Produces: `public String uploadImage(byte[] bytes, String fileName)` → 返回 `img_url` **上游契约**(page 196236572):见 Task 2 的表。**接口 URL:`/api/info/upload/img`** —— 文档原写 `https://test.gdyfsk.com/yfpay/api/info/upload/img`,基址由 `props.getBaseUrl()` 提供, 代码里只传路径这一段(与 `post` / `postApi` / `postMultipart` 的既有约定一致)。返回 `img_url`。 > ⚠ **2026-09-22 补漏**:本节原先**没有写这个路径**,全文只出现在 Task 2 的表和联调清单第 7 项里。 > Task 6 的 brief 是从本文件抽取的 ⇒ 实施者会拿不到该传什么 path,只能猜或去翻上游文档。 > 预检扫描把 T6 记为「自洽」是**错的**:那一格只核了「变异表 ↔ 测试表」,没核「本 task 是否自带 > 它要用的常量」。已就地补上。 - [ ] **Step 1: 写失败的测试** | 测试名 | 断言什么 | |---|---| | `uploadImage_sendsImgNameAndFile` | 抓 multipart 部件 ⇒ `img_name` 等于传入文件名、`img_file` 是文件部件 | | `uploadImage_returnsImgUrl` | 桩返回 `{"result_code":"SUCCESS","img_url":"http://www.abc.com/picture/xxx.jpg"}` ⇒ 返回该 URL | | `uploadImage_missingImgUrl_throws` | 桩返回 `{"result_code":"SUCCESS"}`(**无** `img_url`)⇒ 抛异常,不返回 null。同 `createShop_missingShopNo_throws` 的理由 | | `uploadImage_oversize_rejectedLocally` | 传 `uploadMaxBytes + 1` 字节 ⇒ 抛 `BusinessException(400, ...)`,且 **`verifyNoInteractions(client)`**(**没有出海**)。**★ 这条钉 R-3 的护栏真的在出站之前** | | `uploadImage_exactlyAtLimit_accepted` | 传**恰好** `uploadMaxBytes` 字节 ⇒ **通过**。**★ 边界闭区间**:`>` 还是 `>=` 在中文里都可读成「超过上限」,代码只能选一个,这条把它钉死 | | `uploadImage_emptyBytes_rejected` | 传 0 字节 ⇒ `BusinessException(400, ...)` | | `uploadImage_disallowedExtension_rejected` | `evil.exe` / `x.jpg.php` / `x`(无后缀)⇒ 400。`a.JPG`(大写)⇒ **通过**(扩展名匹配不区分大小写,这条钉住那个 `toLowerCase`) | | `uploadImage_fileNameWithPathSeparator_rejected` | `../../etc/passwd.jpg` / `a\\b.jpg` ⇒ 400 | | `uploadImage_nullOrBlankFileName_rejected` | `null` / `""` / `" "` ⇒ `BusinessException(400, ...)`,且 **`verifyNoInteractions(client)`**。**★ 这条现在是硬性的**:Task 2 的 fix round 2 把 `fileName == null` 也纳入了客户端守卫,客户端会抛 `IllegalArgumentException` —— 你若不自己拦,它就以 **500** 漏出去(见实现要点)| | `uploadImage_controlCharInFileName_rejected` | `a\r\nX-Evil: 1.jpg` / `a\nb.jpg` / `a\u0000b.jpg` / `a\tb.jpg` ⇒ 400,且**一次上游调用都没发出**(`verifyNoInteractions(client)`)。**★ 钉的是 2026-09-22 实测出的部件头注入**(见实现要点)| | `uploadImage_ordinaryFilename_accepted` | **正对照**:`工资表 2026-09.jpg`(中文 + 空格)⇒ **通过**。没有这条,一把过宽的控制字符锁会静默拒掉所有真实上传,而上面那条照样绿 | | `uploadImage_fileNameLengthCapped` | 超过上游 `img_name` 能接受的长度(文档**没写**上限 ⇒ **不设**上游长度上限),只断言我们**不**擅自截断:长文件名原样送出去。**★ 这条是「不做」的钉子**,防止后来者顺手加个 `substring(0,50)` | - [ ] **Step 2: 跑测试确认红** - [ ] **Step 3: 让它变绿** 实现要点: - `LeshouyingProperties` 加 `private long uploadMaxBytes = 2L * 1024 * 1024;` + javadoc 写明**这是我们的护栏、不是上游限制**(上游文档未写),并要求改大时同步想清楚堆占用。 - **不要**把它加进 `validate()`:`validate()` 只管「缺了就静默失败」的项(见它自己的 javadoc),一个带合理默认值的上限不属于那类。`LeshouyingPropertiesTest` 补一条断言它不在必填列表里(**正对照**:确证 `validate()` 在有默认值时不炸)。 - 扩展名白名单:`jpg` / `jpeg` / `png` / `bmp`,取自文档示例出现的形态;**常量 + javadoc**说明依据是「上游字段名全是『照片』语义 + 文档示例是 `.jpg`」,这是我们收窄的,不是上游列举的。 - 文件名含 `/`、`\`、`..` ⇒ 400(**不是**「清洗后照发」:清洗会让日志里的名字与实际发出去的名字不一致,排障时会骗人)。 - **★ 文件名含 ISO 控制字符 ⇒ 400**(`Character.isISOControl`:CR / LF / tab / NUL / DEL)。 依据是 **2026-09-22 的实测**,不是推测:multipart 部件头是拼在**报文体**里的, `FormHttpMessageConverter` 把 `fileName` 原样写进 `Content-Disposition` ⇒ 名里的 CRLF 会在部件头块里**造出一个新的头行**(探针读数 `filename="a\r\nX-Evil: 1.jpg"`,真 CRLF; 单 LF 同样注入)。实测**三个**头部绑定入口都会注入:`fileName`、`fileFieldName`、`formFields` 的键。 - **客户端那层已经替你兜住了**(Task 2 的 fix round 在 `postMultipart` 入口对这三处 抛 `IllegalArgumentException`)—— 但**你仍必须自己校验一次**:客户端抛的是 **程序员错误**语义,直接漏出去就是 500;而这里的文件名来自**上传方**,是**用户输入**, 必须变成 `BusinessException(400, 中文名)`。**两层都要,异常类型不同,理由不同。** - **不要**把这条写成「上游要求」—— 上游文档没提这个,这是**我们**的护栏(同 `uploadMaxBytes` 的写法)。 - **★ 文件名 `null` / 空串 / 全空白 ⇒ 400**(**必须先于**扩展名检查,否则 `null` 会先在扩展名那段炸成 NPE)。 为什么现在必须加:Task 2 的 fix round 2 把 `fileName == null` 纳入客户端守卫后, 客户端抛的是 `IllegalArgumentException`(程序员错误语义)—— 你这里不拦,用户就吃 **500**。 与上面那条控制字符同理:**两层都要,异常类型不同**。 `null` 是真实可达的:本方法的 `fileName` 是调用方给的 `String`,而三-B 的 controller 多半从 `MultipartFile.getOriginalFilename()` 取 —— **那个 API 的契约就是可空的** (上游不传 filename 时它为 null)。所以这不是假想路径。 - **正对照(必须有)**:普通文件名含中文与空格(如 `工资表 2026-09.jpg`)**必须通过**。 只测「坏名字被拒」发现不了一把过宽的锁 —— 那会把真实上传全拒掉。 - 复用 Task 2 的 `postMultipart`:`path = "/api/info/upload/img"`、`fileFieldName = "img_file"`。 - `img_name` 这个**字符串部件**与文件部件上的 filename 都从**同一个形参** `fileName` 派生。 依据:文档把 `img_name` 列为**必填字符串部件**、示例值 `8510_pic_idcard.jpg` 与文件名同形。 **文档没有规定二者必须一致** —— 所以这是我们的收窄,理由是不给调用方两个可能写出不一致值的入口; 真不一致时上游的行为我们不知道(未实测)。不要把这条写成「上游要求一致」。 - [ ] **Step 4: 跑测试确认绿** - [ ] **Step 5: 两向变异** | 变异 | 期望 | |---|---| | M-1:把大小校验挪到 `postMultipart` 调用**之后** | `uploadImage_oversize_rejectedLocally` **红**(`verifyNoInteractions` 抓住) | | M-2:边界改成 `>=` | `uploadImage_exactlyAtLimit_accepted` **红** | | M-3:扩展名比较不做 `toLowerCase` | `uploadImage_disallowedExtension_rejected` 里的 `a.JPG` 用例 **红** | | M-4:把路径分隔符改成「清洗后照发」 | `uploadImage_fileNameWithPathSeparator_rejected` **红** | | M-5:给文件名加 `substring(0, 50)` | `uploadImage_fileNameLengthCapped` **红** | | M-6(无害):把白名单常量从 `Set.of` 换成 `List.of` | **仍绿** | | M-7:去掉控制字符校验 | `uploadImage_controlCharInFileName_rejected` **红** | | M-8:把控制字符校验写成「过宽」(连中文/空格也拒) | `uploadImage_ordinaryFilename_accepted` **红**(**★ 这条变异是给锁验宽度的**:只验窄不验宽,过宽的锁照样通过全部拒绝用例)| | M-9:把 `null` / 空串检查**挪到扩展名检查之后** | `uploadImage_nullOrBlankFileName_rejected` **红**(会是 NPE 而不是 400 —— 报告里写明是哪种红)| - [ ] **Step 6: Commit** —— `feat(进件): 图片上传 - 本地大小与类型护栏在出站之前` --- ## Task 7: 真机一轮(测试环境取证) **Files:** 无代码改动(只读探针脚本放 `D:\lsy-regress\`,不进仓库) **这一轮的全部意义**:本计划里凡是「文档说」的地方,都要么变成「实测如此」,要么变成「实测不是这样,待上游解释」。**没有这一步,上面六个 task 全是纸面正确。** - [ ] **Step 1: 起一个探针脚本,用**生产的**出口路径打测试环境** 要求: - **不要**在脚本里重写一份签名逻辑 —— 必须调**生产代码**。做法:写一个 `@SpringBootTest` 之外的 `main`/临时测试,直接 `new LeshouyingClient(restTemplate, new LeshouyingSigner(), new LeshouyingResponseParser(), props)`,`props` 从 `application.yml` 读(**注意 `${ENV:default}` 占位符要剥壳,否则密钥长度 50 而不是 32,全部报「签名验证失败」**)。 - 密钥**只打印长度**。 - [ ] **Step 2: 逐个接口取证,把原始应答写进 UTF-8 文件**(控制台是 GBK) | # | 调什么 | 取什么证据 | |---|---|---| | 1 | `/api/info/area` `level=1` | 省级列表非空;`area_code` 的实际形态(**验证它真的可能不是纯数字**) | | 2 | `/api/info/area` `level=3&parent_code=<上一个结果里的一个>` | 三级联动真的能走通 | | 3 | `/api/merchant/info` `merchant_no=Y52211090004` | ✅/❌ 测试商户能不能查(**很可能回「商户不存在」——这也是有效证据**:证明信封与签名是对的,只是没有进件数据) | | 4 | `/api/merchant/query` 同上 | 同上 | | 5 | `/api/shop/list` 同上 | 同上 | | 6 | `/api/shop/create` | **★ R-2 的判据**:先用 `manager_name` 真发一次。若回「参数不合法/缺少字段」,再用 `maneger_name` 发一次,两次应答**逐字**记下来 | | 7 | `/api/info/upload/img` | 用一张本地小图。先删掉 `img_name` 试一次,确证「必填」;再正常发一次拿 `img_url` | **测试商户 `Y52211090004` 能不能查到数据,是本轮最大的不确定性** —— 它大概率是文档里的示意商户,不在我们机构 `900112` 名下。**这不算失败**:能区分「信封对但没数据」与「信封错」就是有效读数。若全部回「商户不存在」,报告里要**明说这一轮的结论强度**:六个接口的**通信与信封**已实测,**字段语义**未实测。 - [ ] **Step 3: 把读数写进 spec** 在 `docs/superpowers/specs/2026-09-22-receive-workbench-design.md` §6 追加一节「§6.6 三-A 真机读数(yyyy-mm-dd)」,上表逐行填实测值。**没测到的留空并注明「未测」,不要填推断。** - [ ] **Step 4: 按实测结论回填代码** - 若 R-2 翻转(上游认真的是 `maneger_*`):只改那个常量 + 反转 Task 5 的那条测试的断言 + 提交。 - 若发现新的文档缺陷:追加进 §6.2,**不要**默默绕过。 - [ ] **Step 5: Commit** —— `docs(进件): 三-A 真机读数取证` --- ## 完成标准 - [ ] `mvn test` 全绿,且**报告里给出实际的测试条数**(从 `target/surefire-reports/` 里按**本次运行**的 XML 统计,不要对目录求和 —— 那份目录跨运行累积) - [ ] 每个 task 的两向变异读数都在 report 里,**含无效变异**(若某条变异实测无效,写明为什么不成立,而不是删掉不提) - [ ] Task 7 的真机读数落进 spec §6.6,**未测项明确标为未测** - [ ] 没有任何新的 `@RestController`、`@RequestMapping`、`db/*.sql`、`SecurityConfig` 改动(R-1) - [ ] `LsyOnboardingService` 的类 javadoc 含 R-4 的租户缺口警告 ## 明确不做 - 创建商户 / 更新商户 / 提交入网 / 入网状态查询 / 安心签 —— 三-B,卡在凭据(spec §6.3 的 1–5) - 行业类目 —— 卡在 `channel_type`(spec §6.3 #5) - HTTP 端点、权限串、菜单 SQL、进件前端页 —— 随三-B 的 UI 一起(R-1) - 跨接口的 `reviewed_status` 归一化 —— spec §6.4 明令不做(R-5) - 异步审核通知的接收端点 —— 三-B(需要上游配置,spec §6.3 #6) - 改 `LeshouyingResponseParser.toMap` 为递归 —— 超出边界(见 Task 5 Step 3)