Эх сурвалжийг харах

docs(进件): 三-A 实现计划 + 六页原文重查出的新文档缺陷

计划 .claude/plan/2026-09-22-lsy-onboarding-A.md:7 个 task,只做上游 leg
(客户端两条出站腿 + 一个服务类),不加 HTTP 端点、不加菜单 SQL。

五条裁定,每条带理由与「错了的代价」:
- R-1 三-A 只做上游 leg(前端工程四页里没有进件页,端点零调用方)
- R-2 manager_name 以参数表为准(同页示例写的是错拼的 maneger_name)——可实测,排进联调
- R-3 图片上传的大小上限与类型白名单是我们的护栏,不是上游限制
- R-4 服务方法收 mchNo 不做租户校验,缺口必须在三-B 的端点侧补闸
- R-5 reviewed_status 只做本接口那套值域,三-C 被拆解吸收

重取六页原文,新查出这些缺陷(已进 spec §6.2(e)):
- 创建门店:参数表 manager_name vs 示例 maneger_name
- 获取省市区:area_code 出现 100G/100H,非法十六进制,必须当字符串
- 获取门店列表:返回示例缺一个逗号,是非法 JSON;result_code 的参数示例列写着 PAY_SUCCESS 自相矛盾
- 获取商户信息:wx/ali_channel_rate 各出现两次;多个字段示例是数字而表里写 String
alphaH 3 долоо хоног өмнө
parent
commit
c03d20099d

+ 519 - 0
.claude/plan/2026-09-22-lsy-onboarding-A.md

@@ -0,0 +1,519 @@
+# 商户进件 三-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 必须写明它**不通用** |
+| `java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyMerchantType.java` | `1企业 / 2个体工商户 / 3小微商户`(spec §6.2(a) 的裁定) |
+
+**修改:**
+
+| 文件 | 改什么 |
+|---|---|
+| `.../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<String, Object> postApi(String path, Map<String, Object> 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<String, Object> postMultipart(String path, Map<String, String> 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<String, Object>`:字符串部件用 `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`
+- Create: `.../leshouying/enums/LsyMerchantType.java`
+- Test: `.../service/LsyOnboardingServiceTest.java`
+
+**Interfaces:**
+- Produces: `public List<LsyAreaItemDTO> 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` 既有写法,`@RequiredArgsConstructor` + `@Slf4j`)。
+- 路径字面量就地写:`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<String,Object>` 再手工映射。
+- `LsyMerchantType` 枚举:`ENTERPRISE("1","企业")` / `INDIVIDUAL("2","个体工商户")` / `MICRO("3","小微商户")`,javadoc 写明 spec §6.2(a) 的裁定依据(三处一致 vs 行业类目页有复制痕迹)。本 task **只建枚举不使用** —— 它是 Task 5 的依赖,先建好是为了让 Task 5 不返工。
+
+- [ ] **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<String, Object> 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<LsyShopItemDTO> 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(restTemplate)`)。**这条钉的是本地校验先于出站** |
+| `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 的表。返回 `img_url`。
+
+- [ ] **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(restTemplate)`**(**没有出海**)。**★ 这条钉 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_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(**不是**「清洗后照发」:清洗会让日志里的名字与实际发出去的名字不一致,排障时会骗人)。
+- 复用 Task 2 的 `postMultipart`,`fileFieldName = "img_file"`。
+
+- [ ] **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` | **仍绿** |
+
+- [ ] **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)

+ 16 - 0
docs/superpowers/specs/2026-09-22-receive-workbench-design.md

@@ -438,6 +438,18 @@ GET /payment/cashier/{codeNo}/auth
 
 **(d) 类型不一致。** `merchant_mcc_code` 在创建商户是 `String(4)`(示例 `204`),行业类目接口返回的 `mcc_code` 是**数字**(`1/7/32/47/86/125`),获取商户信息返回示例是字符串 `"292"`。
 
+**(e) 三-A 那六页新查出的缺陷(2026-09-22 逐页重取原文后补)**
+
+| 页 | 缺陷 | 影响 |
+|---|---|---|
+| 创建门店 196236559 | **参数表写 `manager_name` / `manager_phone`,同页请求示例写 `maneger_name` / `maneger_phone`**("maneger" 是错拼) | **签名相关**:发错键名上游可能直接判「参数不合法」。属可实测项,已排进三-A 联调 |
+| 获取省市区 196236568 | `level` 参数表写 `String`,请求示例写**数字** `3` | 轻微,发字符串即可 |
+| 获取省市区 196236568 | **`area_code` 的真实形态不是纯数字**:返回示例里出现 `100A`…`100H`,其中 `100G`/`100H` **不是合法十六进制** | **会炸**:任何 `parseInt` / 数字型 DTO 字段在这里必崩。必须当字符串 |
+| 获取门店列表 196236560 | 返回参数表 `result_code` 一行的「参数示例」列写着 `PAY_SUCCESS`,与同一行的说明「SUCCESS操作成功,FAIL操作失败」**自相矛盾**(支付类页面复制残留) | 无(说明与返回示例一致,取 `SUCCESS`/`FAIL`) |
+| 获取门店列表 196236560 | 返回示例里 `"trace_no": "123456"` 后面**缺一个逗号**,整段是**非法 JSON** | **会误导**:谁照抄这坨当夹具,Hutool 会解析失败,而失败原因看起来像「上游报文坏了」。同时 `shop_list` 第二项**没有 `shop_tel` 键** |
+| 获取商户信息 196236558 | `wx_channel_rate` / `ali_channel_rate` 在返回参数表里**各出现两次**;返回示例里 `merchant_type` / `account_type` / `license_type` / `wx_channel_id` 是**数字**而表里写 `String` | 轻微,读时按 `String.valueOf` 归一 |
+| 商户审核主动查询 196236567 | 已记在 (b) 第 2 行 | — |
+
 ### 6.3 阻塞项(拿不到依据就不写那一部分)
 
 | # | 缺什么 | 为什么我拿不到 |
@@ -462,6 +474,10 @@ GET /payment/cashier/{codeNo}/auth
 
 这六个接口的入参、出参、值域在文档里都干净,不依赖任何未知凭据(只需要已有的 `inst_no` 与机构密钥)。图片上传尤其关键——它是创建商户那些资质图片字段取值的唯一来源。
 
+> **2026-09-22 修订两处**(实现计划 `2026-09-22-lsy-onboarding-A.md` 的 R-1 / R-5):
+> 1. **三-A 只做上游 leg**(`LeshouyingClient` 的 `/api/*` 与 multipart 两条出站腿 + 一个 `LsyOnboardingService`),**不加 HTTP 端点、不加菜单 SQL**。理由:§4.8 的前端工程四页里**没有进件页**,现在加端点等于加一批零调用方的 HTTP 面;端点随 三-B 的进件 UI 一起来,那时租户归属校验才有落点。
+> 2. **三-C 不再单列**,被三-A/三-B 各自吸收:`reviewed_status` 的三套值域里,「商户审核主动查询」这套归三-A,「异步通知」与「商户入网状态」两套归三-B(后者依赖 `reportQuery`)。「不跨接口归一化」这条原则不变。
+
 **三-B:创建商户 + 入网流程**
 
 代码可以写完(必填性、字段名、类型都明确,§6.2 的 (a) 已裁定),但**通电需要 §6.3 的 1–5 与 6**。