2026-09-23-lsy-onboarding-B.md 90 KB

批次三-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<String> missingOnboardingCredentials()

  • [ ] Step 1: 先写失败的测试

在 LeshouyingPropertiesTest 里加四条。三条是负向、一条是正对照(正对照是第 4 条,它钉住「我们没有把这五项塞进启动校验」这个裁定):

@Test
void missingOnboardingCredentials_listsAllFive_whenAllBlank() {
    LeshouyingProperties p = new LeshouyingProperties();
    List<String> 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 绑空串会抛转换异常,而那正好发生在「凭据还没配」的正常场景):

    /** 进件通道 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):

    public List<String> missingOnboardingCredentials() { ... }

必须一次列全(照 validate() 既有的「一次性列出所有缺失项」先例 —— 逐个抛会让人改一个重启一次,进件当天每轮重启都是分钟级)。返回的是给人看的配置键名(形如 leshouying.channel-id(环境变量 LESHOUYING_CHANNEL_ID)),照 validate() 里 missing.add(...) 的既有写法。

application.yml 的 leshouying: 块下加五个键,默认值一律留空:

  # ── 三-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: 提交

    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<String> LsyMerchantRequiredFields.missingRequiredFields(LsyMerchantCreateDTO dto) —— 返回上游参数名形式的缺失字段清单,空列表=齐备;LsyMerchantCategory 七个常量,每个带 merchantType/accountType/中文标签

★★ 枚举叫 LsyMerchantCategory(七类组合),不叫 LsyMerchantType(三类)。 理由见 §0.3b:必填差异发生在七个组合之间,而 merchant_type 只有三个值 —— 用 Map<merchant_type, Set<字段>> 作键会撞(企业对私法人 与 企业对私非法人 同为 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<LsyMerchantCategory, Set<String>>,键是七个类别,值照 §0.3 附表的 √ 逐格填(附表那一行 account_type 的错误不影响 √ 的落格 —— √ 是按七列读的)。

LABELS:Map<String, String>,上游参数名 → 中文标签,标签逐字取参数表的「说明」列(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<LsyMccItemDTO> 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<String>、一次列全),这是有意的对称。requireByMchNoForTenant(String, Long) 的参数序与既有 requireByEnterprise(Long tenantId, String enterpriseId) 相反 —— 按 requireByMchNo(String) 的主体参数在前,并在 javadoc 里点名这处不一致,免得调用方按前者的顺序写。