2026-09-22-lsy-onboarding-A.md 43 KB

商户进件 三-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<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

    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
  • 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 既有写法,@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<String,Object> 再手工映射。
  • ★ 测试骨架的注记: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<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(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_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 的写法)。
  • 正对照(必须有):普通文件名含中文与空格(如 工资表 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 红(★ 这条变异是给锁验宽度的:只验窄不验宽,过宽的锁照样通过全部拒绝用例)
  • 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)