# 乐收赢全流程傻瓜化 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: 用 superpowers:executing-plans 或 subagent-driven-development 按任务实现。步骤用 `- [ ]` 复选框。 **Goal:** 一个操作员从零到收款,全程不离开界面、不需要懂任何内部概念(「进件」「映射」「mchNo」这些词他不该看见)。 **Architecture:** 后端补两处缺件(进件「已提交」记号、管理端收款端点),前端把「商户进件」页从"一张 41 字段的大表单"改成"列表打头 + 每行一个下一步按钮",新增「扫码收款」页。 **Tech Stack:** Spring Boot 3 + MyBatis-Plus + PostgreSQL / Vue3 + Element Plus **Spec:** `docs/superpowers/specs/2026-09-22-receive-workbench-design.md`(批次二收款工作台;本计划是它的最小可用子集) ## Global Constraints 1. **面向租户的文案零上游痕迹** —— 不得出现上游名称、域名、专有键名。环境变量名(`LESHOUYING_*`)也算上游名号的罗马字。 2. **绝不跳过验签**;**仓库中不得有凭据**;**只打印凭据的长度而非值**。 3. **不得使用「测试假值」**(spec §6.4 / Global Constraint 4)。 4. **SQL 由我编写,仅由用户执行**(GC6)。 5. **没有凭据之前不部署、不联调**(GC7);**不对网络行为做自动化测试**(GC8)。 6. `mvn` 用绝对路径,**绝不** `-proc:none` / `-q`(GC9);`mvn test` 需要 DB 隧道(GC11)。 7. **不 push**(GC10)。 8. 线上正常运行中的**其它模块**不动。 --- ## 现状实测(2026-09-23,开工前) ### 后端端点 | 控制器 | 路径 | 端点 | |---|---|---| | `LeshouyingOnboardingController` | `/payment/collection/onboarding` | page / create / **commit** / update / upload-image / areas / mcc | | `LeshouyingMerchantController` | `/payment/collection/merchant` | page / save / delete | | `LeshouyingOrderController` | `/payment/collection/order` | page / detail / notify-logs(**只读**) | | `LeshouyingRefundController` | `/payment/collection/refund` | page / detail(**只读**) | | `LeshouyingOpenController` | `/payment/openapi/pay` | barcode/pay、qrcode/create、h5/create、order/query、order/close、refund、refund/query | ### 三个真断点 1. **`POST /onboarding/commit` 前端零调用** —— 正对照:同命令搜 `onboarding/create` 命中 1 处。「提交入网」四字在 `frontend/src/` 零命中。菜单 SQL `lsy-onboarding-menu.sql:187` 配了按钮权限,但没有页面用它。 2. **`GET /onboarding/page` 前端零调用** —— 操作员看不到自己建过哪些商户。 3. **管理端没有任何主动收款入口** —— 平台自己想收一笔钱,只能去调开放 API(要 ApiKey、要签名)。 ### 一处不是漏做(写下来免得被后人当缺陷改掉) 入网状态查询没有 HTTP 端点是有意的:`LeshouyingOnboardingController.java:50-52` 写着「审核结果怎么回到我们这边」走的是**上游主动推**的免登录通知端点,不从管理端发起,回写到 `pay_lsy_merchant.review_status`。 ### 不可分辨的进度(傻瓜化的死结) `createOnboardingRow` 写 `review_status = PENDING`(`LsyMerchantService.java:391`),而回调也写这一列。⇒「建了号还没提交入网」与「提交了在等审核」**在库里长得一模一样**,系统答不出"下一步该干嘛"。 --- ## Task 1:进件「已提交」记号(后端) **Files:** - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyMerchantReviewStatus.java` - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java` - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java` - Test: 对应三个测试类 **Interfaces:** - Produces: `LsyMerchantReviewStatus.SUBMITTED`(值 `"SUBMITTED"`);`LsyMerchantService.markSubmitted(String mchNo, Long tenantId)` - [x] **Step 1: 写失败测试** —— `LsyMerchantReviewStatusTest` 断言四个字面值;`LsyMerchantServiceTest` 断言 `markSubmitted` 定向只写 `review_status` + `updated_time`,且 `WHERE` 带租户谓词。 - [x] **Step 2: 跑测试确认红** —— 7 处「找不到符号」(枚举常量 1 + `markSubmitted` 调用点 6),再一轮加上控制器 3 条(多一处 `when(void)` 编译错,已修)。 - [x] **Step 3: 实现** —— 枚举加 `SUBMITTED`;服务加定向更新方法(**不**复用 `save` 的编辑分支,那条写不了这两列);控制器 `commit` 成功返回前调用。 - [x] **Step 4: 跑测试确认绿** —— `mvn test` **668 绿**(基线 658,+10 = 服务 7 + 控制器 3),BUILD SUCCESS。 - [x] **Step 5: 提交** —— `279aae0`。 **落地时定下的三处(计划里没写、实现时才发现)**: 1. **`markSubmitted` 自己再闸一次**,不收调用方刚定位到的那一行 —— `requireByMchNoForTenant` 的契约就是「闸是本方法自己的事」。代价:`/commit` 上多一次 SELECT。 ★ 副作用:`LeshouyingOnboardingControllerTest.merchantQuery_carriesExplicitTenantPredicate_evenWhenInterceptorWouldSkip` 的 **InOrder 断言表达不了新形状**(两次 `selectList` 在 mapper 调用表里连成一块 ⇒ `times(1)` 报 "Wanted 1 time but was 2",`atLeastOnce` 又整块消费、把游标推过 `commitMerchantInfo`)。改成直接比 Mockito 全局序号,并把两次查询**逐条**断言带租户谓词(比原来只看第一条更强)。 2. **`/commit` 加一条出口护栏**(`markSubmittedOrSayWhy`):记号写在上游调用之后 ⇒ 上游已受理是既成事实 ⇒ 失败不得原样上抛(闸的 404 会说「商户号不存在」;持久层异常落 catch-all 500 = 「请重试」)。照 `/create` 那条 409 先例。 3. **刻意不做幂等短路**(不像 `updateReviewStatusByMchNo` 那样「值已相同就不写」):走到这方法意味着上游刚又收到一次提交,`SUBMITTED` 就是如实状态,短路只会让 `updated_time` 落后于事实。 **两向变异读数**:M-A2(丢租户谓词、查询形状不变)⇒ 4 红,杀它的是那条谓词断言本身(报出 `[(mch_no = …)] expected: <2> but was: <1>`);M-B(记号挪到上游之前)⇒ 新加 3 条控制器用例全红、服务侧不受影响;M-C(SET 两列换序,无害)⇒ 全绿。 **判定规则(前端进度列用)**: - `review_status = PENDING` ⇒ 待提交入网 - `review_status = SUBMITTED` ⇒ 审核中 - `review_status = APPROVED` ⇒ 可以收款了 - `review_status = REJECTED` ⇒ 被驳回(按钮回到"改资料/重新提交") --- ## Task 2:前端 API 层补齐(前端) **Files:** - Modify: `frontend/src/api/module_payment/onboarding.ts` - ~~Modify `frontend/src/api/module_payment/collection.ts`~~ ⇒ **挪给 Task 4**(见下方裁定) **Interfaces:** - Produces: `OnboardingMerchantRow`(接口)、`getOnboardingPage({pageNo, pageSize, keyword?})`、`commitOnboardingMerchant(data)` - [x] **Step 1: 加函数** —— 只落了两个:`getOnboardingPage` → `GET /payment/collection/onboarding/page`;`commitOnboardingMerchant(data)` → `POST /payment/collection/onboarding/commit`。外加 `OnboardingMerchantRow` 接口(Task 3 的列表要用)。 - [x] **Step 2: `pnpm ts:check` 前后差分** —— 基线(改之前)exit 2 / 325 行;改之后 **exit 2 / 325 行、`diff` 逐字节空**。本仓 `ts:check` 有非零历史基线(命令恒 exit 2),**不能说"跑绿了"**,只能说"零新增"。 **裁定:`closeCollectionOrder` 不在 Task 2 落,挪进 Task 4** —— 理由:Task 3 一个字都不用它(唯一调用方是 Task 4 的收款页),而它的 javadoc 要写的东西(应答形状、关单的副作用、失败语义)**此刻一条都没量过** —— 只能照计划表转写,那正是「约束文档自己会失真」的入口。Task 4 端点落地时它有东西可量。代价:Task 4 多改一个文件(它本来就列着要改 `collection.ts`)。 **两向读数(正控)**:`diff` 为空本身也可能是量具没读这个文件 ⇒ 往刚改的文件尾部塞一行真类型错(`const _t2probe: number = getOnboardingPage;`)⇒ 输出**多且只多 1 行**:`onboarding.ts(264,7): error TS2322: Type '(params: { pageNo: number; pageSize: number; keyword?: string | undefined; }) => Promise>' is not assignable to type 'number'.`(325 → 326 行)。撤掉后回到 325 行 / `IDENTICAL`,`grep -c _t2probe` = 0。⇒ 那个「无差异」是真读数。 (★ 探针落点 `(264,7)` 顺带证明 `getOnboardingPage` **真的存在**且形状就是这个——比"编译通过"硬。) --- ## Task 3:进件页改成「我的商户」列表 + 每行一个下一步(前端) **Files:** - Rewrite: `frontend/src/views/module_payment/collection/onboarding/index.vue` **交互契约(傻瓜式)** ``` 我的商户 [+ 新建商户] ────────────────────────────────────────────────────────── 商户号 商户名 进度 操作 8xxxxxx XX店 ① 待提交入网 [继续:提交入网] ← 主按钮 8yyyyyy YY店 ② 审核中 [改资料] 8zzzzzz ZZ店 ③ 可以收款了 [改资料] ``` - 第一屏是**列表**;列表为空时才显示引导 + 「新建商户」。 - 每行**只有一个主按钮**,由进度决定,操作员不需要选。 - 「提交入网」是 6 个字段的小表单(资质 / 资质备注 / 门店位置 / 营业用地 / 经度 / 纬度),标题写人话。 - 现有 41 字段大表单**保留**,但只在点「新建商户」/「改资料」时以抽屉或整页展开。 - **不出现**「进件」「映射」「mchNo」「商户号由服务端产出」这类词。 - [x] **Step 1** 列表 + 空态引导 - [x] **Step 2** 进度列 + 单主按钮逻辑 - [x] **Step 3** 提交审核小表单(6 字段) - [x] **Step 4** 建号/改资料表单接进来(`el-drawer` + `destroy-on-close`) - [x] **Step 5** 验证:见下 **落地时定下的两处(计划里没写、实现时才发现)**: 1. **每行「恰好一个主按钮 + 至多一个文字按钮」**,文字按钮只在**非「可以收款了」**时出现。 规则一句话:**非 APPROVED 的行都必须留着「提交审核」入口** —— 提交审核是把资料送审的**唯一** 一条腿,少了它,「审核未通过」的行改完资料就再也走不到送审那一步,按钮会永远停在「改资料」上。 反面:APPROVED **不给**重新提交(再送一次没意义,而误点一次是不可回滚的上游调用)。 表外的 `review_status` 不猜哪条是下一步,主按钮给保守的「改资料」,但**保留**送审入口免得变死路。 2. **「改资料」入口改成从干净表单起步**(`openUpdate` 先 `prepareForm()`)。原形状是「切模式不清表单」, 于是从建号结果那条链接跳过去时,建号表单里那 41 个值会**原样进更新报文** —— 那正是既有的 X6 **标签**警告的「等于按建号值再覆盖一遍」。旧台子的 X6 标签这么写、 **断言却查相反的事**(`=== "建号时填的"`),本次一并订正为标签的说法。 **读数(2026-09-23,`git status` 干净的工作区上)**: | 量具 | 读数 | |---|---| | `pnpm ts:check` | exit 2 / **325 行**,与改前**逐字节相同**(本仓该命令有非零历史基线,故只能同机差分) | | 同谓词正控 | 往页面尾部塞 `const _t3probe: number = actionsFor;` ⇒ **多且只多 1 行**:`index.vue(1325,7): error TS2322: Type '(row: OnboardingMerchantRow) => RowAction[]' is not assignable to type 'number'`(325→326);撤掉回 325 行 / 逐字节相同 / `grep -c _t3probe` = 0。★ 这条同时证明**这页真在 ts:check 的扫描范围内**(否则「无差异」是量具没读这个文件) | | `pnpm build` | **exit 0**(59.26s) | | 产物串(同谓词正控) | 8 个候选里 **7 个**满足严格谓词(`src` 里只出现在本页 **且** `dist` 里查得到,全在同一个 chunk);第 8 个「审核未通过」被脚本**自己判成「不是本页独有,本读数不成立」**(它在 `AccountOverview.vue` 里也有)—— 这正是该谓词在起作用的样子;对照串 `zzz_t3_absent_control_zzz` **0/0** | | keep-alive 组件名 | `CollectionOnboarding` 在产物里;对照名 `AccountOverview` 命中 3 个文件 ⇒ 量具会区分(★ 丢了它页面不被缓存**且不报错**,所以必须单独量) | | GC1 文案 | 模板段 11 个禁词(含上游名号两种写法与 `LESHOUYING_*` 罗马字)**全 0**;正控:模板段 1395 字符、含主标题「我的商户」 | | 执行级台子 | `task-9-fix1-harness` **25/0**(未改动);`fe-update-harness` **89/0**(原 43 条 → 本轮扩到 89);`fe-update-keycheck` 全绿 | | 两向变异 | `fe-task3-mutate.mjs` **4 轮全绿**(3 个真变异各打中预期用例、1 个无害变异仍绿、4 轮还原 sha 全部逐字节回基线) | | 台子 README | `.claude/plan/2026-09-23-lsy-onboarding-B-harness/README.md` 随之更新:`pass=43` → `89`、登记 `fe-task3-mutate.mjs`(新增 3a 节)、指纹表补上本轮两个文件并**加入 CRLF 侧 sha256**(原先只指去交付说明)。顺手订正三处随本轮改动而失真的读数:stdout 行数 27 → **91**(实测,随用例数走)、「四支」→ 五支、以及第 0 节那句「下面每支都不写生产文件」(对两台变异驱动是假的) | **变异暴露出的两条我自己的问题(都已修,留在台子里当记录)**: - **一条空洞断言**:我写的 S3「409 不置成功提示」在把 409 码值改掉后**照绿**。追下去:`submitCommit` 在调用**之前**就把 `committedMchNo` 置空 ⇒ 一切失败路径上它都是空串 ⇒ 该断言分辨不出 「走了 409 分支」与「根本没走」。它对**任何**变异恒真。**已删**,不留它冒充钉子。 (→ 我的 `expectRed` 也按**实测**改,不是按我当初的预期改。) - **一条会让量具全黑的脆弱写法**:`b.list.value[0].mch_no` 在 L1 一红时抛 `TypeError` ⇒ 整轮崩掉, SUMMARY 与后面所有用例全丢 —— 恰好在最需要读数的时候量具失效。改成 `?.`。 --- ## Task 4:扫码收款页(后端端点 + 前端页面) **这是最大的缺口。** 管理端今天没有任何主动收款入口。 **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierControllerTest.java` - Create: `frontend/src/views/module_payment/collection/cashier/index.vue` - Modify: `frontend/src/api/module_payment/collection.ts` - Create: `java/src/main/resources/db/lsy-cashier-menu.sql` **端点(JWT 认证,带租户闸)** | 方法 | 路径 | 作用 | |---|---|---| | POST | `/payment/collection/cashier/qrcode` | 入参 `{amount, remark?}` → 返回 `{outTradeNo, codeUrl, status}` | | GET | `/payment/collection/cashier/{outTradeNo}` | 轮询用:返回 `{status, receiptAmount}` | | POST | `/payment/collection/cashier/{outTradeNo}/close` | 撤销(关单) | **复用**:`LsyMerchantService.requireByTenant`(找本租户默认商户)、`LsyOrderService`(下单/关单),**不抄** `LeshouyingOpenController` 的落库装配。 - [x] **Step 1** 测试先行:租户闸先于上游调用、金额校验、outTradeNo 生成、状态轮询只回本租户 —— `LeshouyingCashierControllerTest` 20 条,两个方向都钉住(见 Step 2 的变异读数)。 - [x] **Step 2** 实现三个端点 —— `LeshouyingCashierController`(3 个端点)+ `LsyCashierCreateDTO` + `LsyCashierOrderVO`。 **两向变异 7 组全绿**(`logs/_t4-mutate-report.txt`):丢租户谓词 / 关单不加守卫 / 设上 `notify_url` / 去掉查单节流 / 去掉上游失败降级 / 去掉终态短路 —— 六组各自**只**打红它该打红的那条; 外加一组**无害**改动(局部变量改名)实测仍全绿。每组跑完都还原到基线 sha256 `738e01fb17b43bf69cf04664b075730ecff55cdfd1713556fc5a9cb97dce083e`。 ★ 另加**登记完备性**腿⑤(`LeshouyingAdminWiringTest`):反注册本控制器 ⇒ 红并报出 `unbacked = [module_payment:leshouying:cashier:list]`;无害地把 `MENU_SQLS` 换个顺序 ⇒ 仍绿。 - [x] **Step 3** 前端收款页:大金额输入 → 「收款」→ 大二维码 + 等待动画 → 轮询 3 秒 → 成功变绿 + 撤销按钮 —— `views/module_payment/collection/cashier/index.vue`。`pnpm build` rc=0(`✓ built in 1m 11s`); `pnpm ts:check` **前后逐行相同**(287 行,排序集 sha `00a886b38047e0e7`,时序 18:27:45 < 编辑 < 18:33:20) ⇒ 零新增类型错误(本仓该命令恒 exit 2,不能说"跑绿了")。 产物核对(`logs/_t5-dist-probe.py`):本页 6 条独有中文串**全部**落在同一个 chunk `js/index.Cs2OUF6W.js` 里;同谓词正控 2 条命中、反对照 0 命中。 - [x] **Step 4** 菜单 SQL(我写,用户执行)—— `db/lsy-cashier-menu.sql`,**待用户执行**(本仓约定:SQL 只由用户跑)。 `route_name`/`permission` 取自**活库只读实测**(2026-09-23)与源码现取,不是照邻页推的。 --- ## Task 5:接入文档可见性核查(收尾) - [x] 确认 `dist/` 产物含收款文档(前面被打断的检查):`grep -rl collection_qrcode dist/js/` + 同谓词正控 —— 做了,但**换了个更硬的谓词**:不查接口名(`collection_qrcode` 是路径串,接口名改了它不一定变), 改查**本页独有的中文字面量** 6 条(操作员真正看到的那几句)。读数见 Task 4 / Step 3。 - [x] 若产物落后 ⇒ 重新构建 —— 重建过:`pnpm build` rc=0,dist 最新 mtime = 2026-09-23 18:29:18。 - [x] 「商户映射」菜单改名「手工登记商户号」,页面顶部说明改成一句人话 —— 新增 `db/lsy-merchant-menu-rename.sql`(**待用户执行**); 源码里跟着改的是「**引号里指名菜单**」的四处:`LsyMerchantService.java:56` / `:85` 的 404 文案、`LsyOnboardingService.java:44` 的逐字引文(引文失真会让人以为代码没改)、 以及该页弹窗标题与删除确认。 ★ **领域术语一律保留**(javadoc / 表注释 / 类名里的「商户映射」)—— 那里说的是事实 (确实是一条「企业 ↔ 商户号」的映射),只是不适合当菜单名。改与不改的判据是 「**操作员会不会在界面上读到这个旧名字**」,不是「仓库里还有没有这四个字」。 ★ 实测影响面:测试里**零**断言旧菜单名(唯一两处命中是注释与 `contains("未配置商户号")`, 子串不含菜单名)⇒ 改名不需要动任何测试。 --- ## 明确不做(写下来免得被当成漏做) - **不把上游 `reviewed_status` 映射成人话** —— 上游同一页里两套互相冲突的 0–6 码表(`LsyOnboardingService.java` javadoc 逐字抄了),谁映射谁是在猜。等联调实测后一次落下映射 + 改 javadoc。 - **不做租户选择器**(控制器 javadoc `:69-70` 已裁「本批次不实现」)。 - **不加数据库列** —— 「已提交」记号复用现有 `review_status` 列。