Browse Source

feat(收款): 前端补上「我的商户」列表与「提交入网」两条腿的 API

Task 3 要把进件页从「一张 41 字段的大表单」改成「列表打头 + 每行一个下一步按钮」,
而它要用的两个端点此前在前端零调用 —— `GET /onboarding/page`(操作员看不到自己建过
哪些商户)与 `POST /onboarding/commit`(正对照:同命令搜 `onboarding/create` 命中 1 处)。
本提交只补这两条腿的调用层与行类型,页面在 Task 3。

`getOnboardingPage` 的分页参数是 camelCase 而响应里是 snake_case —— 这处不对称在
`list`/`total` 两个单词上看不出来,所以逐字写进 javadoc(读法照已上线的 merchant 页
`res.data.data?.list`)。

`commitOnboardingMerchant` 的报文键名逐条对着 `LsyCommitMerchantInfoDTO` 的字段名
经全局 SNAKE_CASE 得出,javadoc 里列成表。其中一条是踩了就 500 的陷阱:**入站键名与
出站键名逐字不同** —— 服务层发给上游的是 `store_Loca` / `mcht_Landuse`(L 大写,是上游
参数表原文,进签名原文),而本接口收的是 `store_loca` / `mcht_landuse`。把上游那个写法
抄进请求体是未知属性 ⇒ HTTP 500 而不是「静默当没传」:`JacksonConfig` 手搓
`new ObjectMapper()`,Jackson 自己的默认 `FAIL_ON_UNKNOWN_PROPERTIES=true` 因此留了下来
(Spring Boot 自动配置本来会设成 false,而它因为存在 @Primary ObjectMapper 已经退让)。

裁定:计划里的第三个函数 `closeCollectionOrder` **不**在本提交落,挪进 Task 4。
Task 3 一个字都不用它(唯一调用方是 Task 4 的收款页),而它的 javadoc 要写的三件事
(应答形状、关单副作用、失败语义)此刻一条都没量过 —— 只能照计划表转写,那正是本仓
反复出错的「约束文档转写」入口。代价是 Task 4 多改一个文件(它本来就列着要改)。

读数(`pnpm ts:check`,本仓该命令有非零历史基线、恒 exit 2,故只能同机前后差分):
- 改前:exit 2 / 325 行;改后:exit 2 / 325 行、`diff` 逐字节空。
- 正控(否则「无差异」也可能只是量具没读这个文件):往刚改的文件尾部塞一行真类型错
  ⇒ 输出多且只多 1 行,`onboarding.ts(264,7): error TS2322` 并逐字回显了
  `getOnboardingPage` 的完整签名(325 → 326)。撤掉后回到 325 行 / 逐字节相同,
  `grep -c _t2probe` = 0。探针落点顺带证明那个函数真的存在且形状就是它。

只改前端调用层,无生产行为变化(两个函数的调用方在本提交里还不存在)。
alphaH 3 weeks ago
parent
commit
1c9556cceb

+ 177 - 0
.claude/plan/2026-09-23-lsy-simple-flow.md

@@ -0,0 +1,177 @@
+# 乐收赢全流程傻瓜化 实现计划
+
+> **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<AxiosResponse<…>>' 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」「商户号由服务端产出」这类词。
+
+- [ ] **Step 1** 列表 + 空态引导
+- [ ] **Step 2** 进度列 + 单主按钮逻辑
+- [ ] **Step 3** 提交入网小表单
+- [ ] **Step 4** 建号/改资料表单接进来
+- [ ] **Step 5** 验证:`pnpm build` exit 0 + 产物里查得到本页独有字符串(同谓词正控)
+
+---
+
+## 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` 的落库装配。
+
+- [ ] **Step 1** 测试先行:租户闸先于上游调用、金额校验、outTradeNo 生成、状态轮询只回本租户
+- [ ] **Step 2** 实现三个端点
+- [ ] **Step 3** 前端收款页:大金额输入 → 「收款」→ 大二维码 + 等待动画 → 轮询 3 秒 → 成功变绿 + 撤销按钮
+- [ ] **Step 4** 菜单 SQL(我写,用户执行)
+
+---
+
+## Task 5:接入文档可见性核查(收尾)
+
+- [ ] 确认 `dist/` 产物含收款文档(前面被打断的检查):`grep -rl collection_qrcode dist/js/` + 同谓词正控
+- [ ] 若产物落后 ⇒ 重新构建
+- [ ] 「商户映射」菜单改名「手工登记商户号」,页面顶部说明改成一句人话
+
+---
+
+## 明确不做(写下来免得被当成漏做)
+
+- **不把上游 `reviewed_status` 映射成人话** —— 上游同一页里两套互相冲突的 0–6 码表(`LsyOnboardingService.java` javadoc 逐字抄了),谁映射谁是在猜。等联调实测后一次落下映射 + 改 javadoc。
+- **不做租户选择器**(控制器 javadoc `:69-70` 已裁「本批次不实现」)。
+- **不加数据库列** —— 「已提交」记号复用现有 `review_status` 列。

+ 93 - 0
frontend/src/api/module_payment/onboarding.ts

@@ -166,3 +166,96 @@ export function createOnboardingMerchant(data: Record<string, unknown>) {
 export function updateOnboardingMerchant(data: Record<string, unknown>) {
   return request({ url: "/payment/collection/onboarding/update", method: "post", data });
 }
+
+// ==================== 我的商户:列表 + 提交入网 ====================
+
+/**
+ * `/page` 的一行 —— 逐字来自 `LsyMerchantEntity`(经全局 SNAKE_CASE 序列化,见文件头 ★)。
+ *
+ * ★ `id` 是**字符串**不是 number:`LsyMerchantEntity.getId()` 挂了
+ *   `@JsonSerialize(ToStringSerializer)`(19 位雪花超 JS 安全整数)。同实体里其它的 `Long`
+ *   (`tenant_id`)**没有**那个注解 ⇒ 线上是 number。两列写法不一样是既有形状,不是笔误。
+ * ★ `review_status` 就是**进度**那一列(`LsyMerchantReviewStatus` 四个字面值:`PENDING` 待提交入网 /
+ *   `SUBMITTED` 审核中 / `APPROVED` 可以收款 / `REJECTED` 被驳回)。
+ *   新行由 `createOnboardingRow` 写 `PENDING`,**不是**建表语句默认的 `APPROVED`
+ *   (建号的另一条腿 `save` 的新增分支写 `APPROVED`,编辑分支**不碰**这一列)
+ *   ⇒ 应用自己写出来的行都带值。类型仍留 `| null` 是为了兼容 SQL 直插的历史行。
+ *   页面按它决定「下一步该干嘛」,**不要**在页面里另立一套词。
+ */
+export interface OnboardingMerchantRow {
+  id: string;
+  tenant_id: number | null;
+  enterprise_id: string | null;
+  mch_no: string;
+  shop_no: string | null;
+  merchant_name: string | null;
+  review_status: string | null;
+  status: string | null;
+  description: string | null;
+  created_time: string | null;
+  updated_time: string | null;
+}
+
+/**
+ * 我的商户列表(`Result<PageResult<LsyMerchantEntity>>`)。
+ *
+ * ★ 分页参数是 **camelCase** 的 `pageNo` / `pageSize`(query string 不过 Jackson);
+ *   而**响应**里它们是 `page_no` / `page_size` —— `list` / `total` 恰好是单词,两种写法一样,
+ *   所以这处不对称看不出来。`collection.ts` 文件头 ★ 2 逐字解释过同一个坑。
+ * ★ 读法照已上线的 `views/module_payment/collection/merchant/index.vue`:
+ *   `res.data.data?.list` / `res.data.data?.total`(响应拦截器**不解包**,两层 `.data`)。
+ * ★ `keyword` 在后端**同时**模糊匹配 `mch_no` / `merchant_name` / `enterprise_id` 三列
+ *   (`LsyMerchantService.page`)⇒ 输入框占位符要写全三样,别只写「商户号」。
+ * ★ 行级过滤靠 `TenantInnerInterceptor`(`pay_lsy_merchant` 在 `CONDITIONAL_TABLES` 里),
+ *   本端点自己只做「非空租户」校验 —— 见 `LeshouyingOnboardingController#page` 的 ★ 段。
+ * ★ 排序由后端定死 `created_time DESC`,本文件**不**提供排序参数。
+ */
+export function getOnboardingPage(params: {
+  pageNo: number;
+  pageSize: number;
+  keyword?: string;
+}) {
+  return request({ url: "/payment/collection/onboarding/page", method: "get", params });
+}
+
+/**
+ * 提交入网信息(把这一行的资料交给上游审核)。**JSON body,键名 snake_case**。
+ *
+ * ★ 报文里**只能有下面这 7 个键**,逐字对着 `LsyCommitMerchantInfoDTO` 的 Java 字段名
+ *   经全局 SNAKE_CASE 得出(`mchNo` → `mch_no`、`mchtAptitude` → `mcht_aptitude`、
+ *   `storeLoca` → `store_loca`、`mchtLanduse` → `mcht_landuse`):
+ *
+ *   | 线上键 | 中文 | 值 |
+ *   |---|---|---|
+ *   | `mch_no` | 商户号 | 来自列表那一行,**不让操作员手输** |
+ *   | `mcht_aptitude` | 商户资质 | 上游那张 01–12 的码表;本仓**不校验**,原样透传 |
+ *   | `mcht_aptitude_note` | 商户资质备注 | 文本 |
+ *   | `store_loca` | 门店所在位置 | 文本 |
+ *   | `mcht_landuse` | 营业用地 | 文本 |
+ *   | `longitude` | 营业地址经度 | **字符串**(DTO 是 `String`,别送 number) |
+ *   | `latitude` | 营业地址纬度 | **字符串** |
+ *
+ * ★★ **这张表是「前端 → 我们后端」的键,不是「我们后端 → 上游」的键,两者逐字不同**:
+ *   服务层发给上游的是 `store_Loca` / `mcht_Landuse`(`L` 大写,是上游参数表原文,进签名原文),
+ *   而本接口收的是 `store_loca` / `mcht_landuse`(全局 SNAKE_CASE 对**本地** DTO 字段名
+ *   `storeLoca` / `mchtLanduse` 的翻译)。把上游那个大写写法抄进这里的 body ⇒ 未知属性 ⇒
+ *   **HTTP 500**(见下条),不是「静默当没传」。
+ * ★ 除 `mch_no` 外的**六个字段在上游那页全是「必填=是」**,且空白**不发**:
+ *   `LsyOnboardingService.commitMerchantInfo` 在**组报文之前**把空白拒成 400(文案用中文字段名)
+ *   ⇒ 省键与送空串在这里同效 —— 但**不要**因此把这里改成「空串照发」,同一条报文还要过
+ *   `LeshouyingSigner`,它的规则是「null 不参与签名、空串参与」(文件头 ★★)。
+ * ★ **多送一个键 = HTTP 500**(不是 400):`JacksonConfig` 手搓 `new ObjectMapper()`,
+ *   于是 Jackson 自己的默认 `FAIL_ON_UNKNOWN_PROPERTIES=true` 留了下来 ——
+ *   Spring Boot 自动配置本来会把它设成 `false`,而那份自动配置因为存在 `@Primary ObjectMapper`
+ *   已经退让了。⇒ 别把列表那一行**整行摊进** body,也别把上游的键名抄进来。
+ * ★ 应答是 **`Result<Void>`**(`data` 为 `null`);成功提示由响应拦截器统一弹(非 GET)⇒ 页面不重复弹。
+ * ★ **租户闸**:`mch_no` 不属于本租户 ⇒ **404**,且上游一次都不会被调用。
+ * ★ 成功出口上还有一步「已提交」记号(后端 `markSubmitted`)。**它失败时返回的是 409,不是 404/500**
+ *   —— 含义是「上游已经收到这次提交了,本地状态没同步」,文案逐字含「不得当作失败直接重试」。
+ *   页面**必须**把这条 409 当成「已提交」处理(刷新列表),不能停在原地报「提交失败」:
+ *   再点一次就是让上游再收一次,而那一次**无法回滚**。
+ * ★ 权限串 `module_payment:leshouying:onboarding:commit`(与建号那条 `:create` 不是同一个)。
+ */
+export function commitOnboardingMerchant(data: Record<string, unknown>) {
+  return request({ url: "/payment/collection/onboarding/commit", method: "post", data });
+}