Ver Fonte

docs(收款工作台): 设计定稿 - 独立前端工程 + 免鉴权收银台 + 租户级去企业化 + 进件

三批交付:一 去企业化(可独立验收)、二 收款工作台(可独立验收)、
三 商户进件(代码可完成,通电依赖客户经理凭据)。

实测依据:
- code_url 实测为上游可枚举短链(https://test.gdyfsk.com/yfpay/s/s-0000N,39 字符),
  不是图片直链,上游也不另给图片 ⇒ 二维码由我们生成,内容即 code_url
- pay_trace_no 7/7 非空 + 已有唯一索引 ⇒ 收银台令牌零 DDL
- zxing 不在依赖树(依赖树正对照验证过测法有效);pom 无模板引擎;hutool QrCodeUtil
  依赖的 zxing 是 optional 依赖,不传递
- LsyOrderStatus.PENDING 含 PAYING ⇒ 主扫改 3 天有效期后会被 24h 兜底网误杀,
  必须把「QRCODE 且 code_url_expire_time 非空」排除出该网

上游文档缺陷(逐页拉取后对上原文):
- merchant_type 4 处里 1 处把 1/2 对调(行业类目页,该页接口描述系从省市区页整段复制)
- reviewed_status 三套值域,其中「查询商户入网状态」同页两个映射
- 创建/更新商户返回号字段名 shoubei_merchant_no 与示例 merchant_no 不一致
alphaH há 3 semanas atrás
pai
commit
b0149e0a9b
1 ficheiros alterados com 405 adições e 0 exclusões
  1. 405 0
      docs/superpowers/specs/2026-09-22-receive-workbench-design.md

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

@@ -0,0 +1,405 @@
+# 收款工作台 + 商户进件 设计
+
+**日期**:2026-09-22
+**上游**:乐收赢(代称「上游」,所有对用户可见处不得出现其名称、域名、字段名)
+**前序 spec**:`docs/superpowers/specs/2026-09-21-leshouying-payment-channel-design.md`
+
+---
+
+## 0. 需求口径(本轮最终版)
+
+用户在本次会话中逐条给出的口径,按最终版记录:
+
+1. 主扫(生成二维码收款)要返回**渲染好的收银台页面链接**,页面**不鉴权**,商户可直接用,不需要自己开发收银台页面。有效期**默认三天**(上游默认值)。
+2. 生成二维码的入口**不做在小程序端、也不做在现有后台**,而是**一个全新独立的前端工程**。
+3. 该工程包含四页:**登录**(复用现有平台账号)、**二维码收款(主扫)**、**扫描二维码收款(被扫)**、**订单明细**。
+4. **被扫同样需要**(用户自己纠正了早先「被扫全部注释」的说法:被扫也是收款)。
+5. 乐收赢与原有企业逻辑**完全不相干**,一律**租户账号级**;开放接口允许租户账号直接请求,唯一前置是**完成商户进件**。
+6. **原有的开放接口不变**。
+7. **商户进件本轮一并做**,取值依据上游文档。
+8. 常设约束:验签绝不跳过;文案不暴露上游;开发测试期先用文档给的测试信息把支付与收款生命周期走通,线上必须先进件。
+
+---
+
+## 1. 批次划分
+
+一次性做完上面全部内容会把「能立刻验收的东西」和「依赖外部输入才能通电的东西」绑在一起。按**可独立验收**切成三批:
+
+| 批次 | 内容 | 能否独立验收 | 阻塞项 |
+|---|---|---|---|
+| **一** | 乐收赢去企业化(租户账号级) | 能 | 无 |
+| **二** | 收款工作台:免鉴权收银台页 + 管理端收款接口 + 独立前端工程 | 能(端到端跑通一笔主扫、一笔被扫) | 无 |
+| **三** | 商户进件 | **不能** | 客户经理凭据 + 上游配置回调地址,见 §6 |
+
+批次一必须先做:批次二的管理端接口按租户级设计,建立在它之上。
+批次三与批次一、二无代码依赖,但它的联调依赖外部输入,**不应挡住批次二的发布**。
+
+---
+
+## 2. 实测依据
+
+以下读数均为本次会话实测,不是推断。
+
+### 2.1 上游 `code_url` 的真实形状
+
+| 项 | 读数 |
+|---|---|
+| 样例值 | `https://test.gdyfsk.com/yfpay/s/s-00006` |
+| 长度 | **39 字符** |
+| 路径模式 | `/yfpay/s/s-0000N`,**N 连号**(实测见 s-00006…s-00009) |
+| 是不是图片直链 | **不是**。是上游自己的短链收银台页 |
+| 上游是否另给图片 | **不给**。`/v3/qrpay` 应答里只有 `code_url` 一个 URL 字段 |
+
+上游文档 §12(主扫场景)原文:**「商户系统按协议生成支付二维码」**。即二维码由**我们**生成。
+
+**推论(决定了整个收银台方案)**:我们页面上那张二维码里编的内容**就是 `code_url`**。顾客扫码后落到上游域名,这是渠道的既定事实,我们改不了。我们能控制的是**商户拿到手的入口**:一个属于我们、不暴露上游、可保存的链接。
+
+### 2.2 收银台令牌选型
+
+| 项 | 读数 |
+|---|---|
+| `pay_lsy_order` 行数 | 7 |
+| `pay_trace_no` 非空 / 去重 | 7 / 7(0 个 NULL) |
+| 唯一索引 | `uk_lsy_order_pay_trace_no` 已存在 |
+| 生成方式 | `IdUtil.fastSimpleUUID()`,32 位随机十六进制,不可枚举 |
+
+⇒ 用 `pay_trace_no` 当公开页面令牌,**零 DDL**。
+
+### 2.3 其它现状
+
+| 项 | 读数 |
+|---|---|
+| `pay_lsy_order` 按 scene/status | QRCODE:CLOSED 3、QRCODE:PAYING 1、H5:EXPIRED 1、BARCODE:CREATED 1、BARCODE:FAIL 1 |
+| `pay_lsy_merchant` | 1 行,`enterprise_id='2088780645490698'`,`mch_no='Y52211090004'`,`shop_no=NULL` |
+| `pay_lsy_refund` / `pay_lsy_notify_log` | 各 0 行 |
+| `LsyOrderStatus.PENDING` | `{CREATED, PAYING}`,**含 PAYING** |
+| 我们调的上游端点 | `/v3/qrpay`(主扫)、`/v3/pay`(被扫)、`/v3/h5pay`、`/v3/qrclose`、`/v3/cancelorder`、`/v3/queryorder`、`/v3/refundorder`、`/v3/queryrefund` |
+| 上游基础地址 | `application.yml` 的 `leshouying.base-url`,当前指向测试环境 |
+| 我方机构号 | `application.yml` 的 `leshouying.inst-no`(有值,主扫/被扫已跑通) |
+| 二维码库 | **不在依赖树里**(已用依赖树正对照验证过测法有效);Hutool `hutool-all 5.8.34` 在,但 `QrCodeUtil` 依赖的 zxing 是 optional 依赖,不会传递进来 |
+| 后端 HTML 模板引擎 | **无**(pom 里没有 Thymeleaf) |
+| 现有前端栈 | Vue 3.5 / vue-router 4.5 / Pinia 3 / Element Plus 2.11 / axios 1.10 / pnpm 9.15.3 / Vite |
+| 登录接口 | `POST /api/v1/system/auth/login` |
+| 令牌传递 | 请求头 `Authorization: Bearer <accessToken>` |
+
+---
+
+## 3. 批次一:乐收赢去企业化
+
+### 3.1 目标
+
+乐收赢的商户映射、下单、查询全部按**租户**寻址,不再要求、不再校验 `enterprise_id`。原有按企业寻址的能力**保留且行为不变**。
+
+### 3.2 方案
+
+**不变式**:租户级商户 = `pay_lsy_merchant` 中 `enterprise_id IS NULL` 的那一行。
+
+不新增列、不新增表。`pay_lsy_order.enterprise_id` 建表时已是可空,**订单表零 DDL**。
+
+DDL(唯一一处,见 §7):
+
+- `pay_lsy_merchant.enterprise_id` 去掉 `NOT NULL`
+- 新增**租户级唯一索引**:`(tenant_id) WHERE enterprise_id IS NULL`
+
+后者是必须的:现有唯一索引 `uk_lsy_merchant_tenant_ent` 建在 `(tenant_id, enterprise_id)` 上,而 PostgreSQL 唯一索引中 **NULL 互不相等**,不加这条局部索引,同一租户可以插入任意多行「租户级商户」而数据库一声不吭。这与本仓先例 `db/batch-account-level.sql` 的处理方式一致。
+
+代码侧(按名字锚定,不写行号):
+
+- `LsyMerchantService`:**新增** `requireByTenant(Long tenantId)`;`requireByEnterprise` **一字不动**。
+- 三个开放接口 DTO(主扫 / 被扫 / H5 的 create 入参):`enterpriseId` 由 `@NotBlank` 改为可选。
+- `LeshouyingOpenController` 的三个下单方法:`enterpriseId` 为空时走 `requireByTenant`,非空时走 `requireByEnterprise`。**不是替换,是分流**。
+- `LsyMerchantEntity` / `LsyOrderEntity` 的 `enterpriseId` 字段保留。
+- 商户映射的管理端页面(`LeshouyingMerchantController` + 现有前端页):允许企业字段留空。
+
+### 3.3 兼容性
+
+原有按企业下单的调用方**不受影响**:传了 `enterpriseId` 就还是老路径、还是老报错文案。删掉 `enterpriseId` 字段是**方向相反的改动**,不做。
+
+---
+
+## 4. 批次二:收款工作台
+
+### 4.1 组成
+
+```
+独立前端工程 frontend-receive/  ──JWT──▶  管理端收款接口 /payment/collection/receive/**
+        │                                          │
+        │  (展示二维码图片)                        ├─▶ LsyOrderService(既有)
+        ▼                                          │
+   收银台页 /payment/cashier/{token}/qr.png ────────┘
+        ▲
+        │ 免鉴权
+        │
+   商户(或顾客)浏览器  ◀── cashier_url ── 开放接口 /payment/openapi/pay/qrcode/create
+```
+
+### 4.2 免鉴权收银台页
+
+**端点**(`SecurityConfig.WHITE_LIST` 与 `JwtAuthFilter.WHITE_LIST_PATHS` 同步新增 `/payment/cashier/**`):
+
+| 端点 | 产出 |
+|---|---|
+| `GET /payment/cashier/{payTraceNo}` | HTML 页面 |
+| `GET /payment/cashier/{payTraceNo}/qr.png` | 二维码 PNG |
+| `GET /payment/cashier/{payTraceNo}/status` | JSON:金额、状态、剩余有效期 |
+
+**页面内容**:金额、二维码、倒计时、订单状态(支付中 / 已支付 / 已失效)。文案零上游痕迹。
+
+**实现方式**:HTML 一个静态文件放在 `src/main/resources/` 下,控制器读入后**原样**以 `produces = text/html` 返回。**不引入 Thymeleaf**——本服务目前是纯 JSON 服务,为一个页面引入模板引擎不划算。
+
+**服务端不做任何占位符替换。** 页面上每一个变量都从 `/status` 取,由 JS 用 `textContent` 写入 DOM;令牌由 JS 从 `location.pathname` 里读。这样服务端没有任何拼接点,**注入面为零**。
+
+⚠ 如果以后有人往这段 HTML 里加服务端替换,那就必须同时对值做 HTML 转义——金额来自数据库、令牌来自路径,两者都不可信。**当前设计里没有这个位置,加的时候要有意识。**
+
+`/status` 应答:`amount`、`status`、`codeUrlExpireTime`、`paid`。
+
+**令牌校验**:`{payTraceNo}` 不存在 ⇒ 返回一个「链接无效」页,**不是 404 白页**(这是顾客会看到的页面)。令牌本身 32 位随机,不可枚举。
+
+**上游短链可枚举这件事**:`code_url` 是 `s-0000N` 连号,理论上邻单可被猜出。这是上游的设计,我们改不了。**我们这边能做的两件事**:(1) 我们的入口令牌不可枚举;(2) **不把 `code_url` 通过任何接口明文外发**(见 §4.5)。风险等级低(猜到了也只能是「替别人付钱」),记录在案,不作为阻塞。
+
+### 4.3 二维码生成
+
+- 依赖:**新增 `com.google.zxing:core`**。已实测它不在依赖树里,Hutool 的 `QrCodeUtil` 没有它会在运行时炸。
+- 生成点:**只有一处** —— `qr.png` 端点。
+- 二维码内容:`code_url` 原文。
+- 尺寸/容错级别:选一个能印能扫的默认值即可,不作配置项(YAGNI)。
+
+前端不生成二维码:新工程因此**不需要引入任何二维码 npm 包**,`<img src=".../qr.png">` 即可。
+
+### 4.4 管理端收款接口
+
+路径前缀 `/payment/collection/receive`,鉴权走既有 JWT 过滤器。
+
+| 端点 | 用途 | 权限串 |
+|---|---|---|
+| `POST /qrcode` | 主扫下单,输入金额 | `module_payment:receive:qrcode` |
+| `POST /barcode` | 被扫下单,输入付款码 | `module_payment:receive:barcode` |
+| `GET /page` | 订单明细分页 | `module_payment:receive:order:list` |
+| `GET /{outTradeNo}` | 订单详情 | `module_payment:receive:order:list` |
+| `POST /{outTradeNo}/query` | 主动向上游查一次 | `module_payment:receive:order:list` |
+
+**不复用** `module_payment:leshouying:order:list`。理由:能查订单不该自动等于能发起收款。
+
+主扫下单应答:`outTradeNo`、`payTraceNo`、`cashierUrl`、`qrImageUrl`、`amount`、`expireTime`。
+**不回 `codeUrl`**——理由与 §4.5 一致。
+
+被扫下单:入参 `authCode`(付款码)+ `amount`。走既有 `LsyOrderService.createBarcodeOrder`。
+
+### 4.5 开放接口变更
+
+`POST /payment/openapi/pay/qrcode/create` 的应答:`codeUrl` 字段**换成 `cashierUrl`**。
+
+理由有两条,都需要:
+
+1. 商户拿到的应该是**我们的**链接,点开是**我们的**收银台页。直接把 `code_url` 发出去,商户点开看到的是上游域名,等于暴露上游。
+2. 顺带收窄暴露面:`code_url` 是上游可枚举短链(§4.2),不再外发。
+
+这是**破坏性变更**。成立的前提是用户确认过「乐收赢所有代码都没有上过生产」。落地前要再核一次 `LsyPayResultVO.codeUrl` 有没有别的消费方。
+
+### 4.6 独立前端工程 `frontend-receive/`
+
+- 栈与现有 `frontend/` 一致(Vue 3 + Vite + Element Plus + Pinia + vue-router + axios + pnpm),不引入新框架。
+- 登录:调 `POST /api/v1/system/auth/login`,复用现有平台账号,不新建账号体系。
+- 令牌:`Authorization: Bearer`,与现有 `frontend/src/utils/request.ts` 的拦截器同构。
+- 四页:
+  1. **登录**
+  2. **二维码收款(主扫)**:输入金额 → 生成 → 页面展示二维码 + 金额 + 倒计时,支持保存/打印
+  3. **扫描二维码收款(被扫)**:输入框接扫码枪(键盘模拟输入 + 回车触发)→ 提交 → 展示结果
+  4. **订单明细**:分页列表 + 详情
+- 构建:`base: '/receive/'`,产物独立;nginx 新增 `location /receive`。
+- 不作多租户切换 UI:租户从 JWT 里来。
+
+### 4.7 主扫有效期与状态收敛
+
+**改**:`leshouying.default-qr-expire-minutes` 由 `15` 改为 `4320`(三天,上游文档默认值,也是用户要的),`QR_MAX_EXPIRE_MINUTES` 保持 4320。
+
+**随之必须改的**:`LsyReconcileScheduler.expireStaleOrders` 当前按 `absolute-expire-hours = 24` 把所有 `PENDING`(= `{CREATED, PAYING}`,**含 PAYING**)且创建超 24 小时的订单置 `EXPIRED`。主扫单在拿到 `code_url` 后停在 `PAYING`,**正好落在网里**。
+
+今天的 15 分钟有效期让两者永远碰不上;改成三天后,第 24 小时本地记 `EXPIRED`、而上游链接活到第 72 小时。**第 25 到 72 小时之间付的款会变成「钱收了、本地账是过期」**,而现有的两条补救通道都是死的,属于永久性错账。
+
+**规则**:主扫单**自带收敛链**(`code_url_expire_time` → `/v3/qrclose` → `CLOSED`,由 `LsyReconcileScheduler.closeExpiredQrOrders` 负责),因此把它排除出 24 小时绝对兜底网。
+
+排除条件要**精确**,不能一刀切掉整个 `QRCODE`:`pay_scene='QRCODE'` **且 `code_url_expire_time IS NOT NULL`** 才排除。理由:`QRCODE` 卡在 `CREATED`(上游没回 `code_url`,例如下单直接失败)时**没有任何网**,它仍然需要绝对兜底。
+
+H5 与被扫的 24 小时不动。
+
+---
+
+## 5. 批次二的 DDL / 菜单 / 配置
+
+### 5.1 会不会有 DDL
+
+批次一有(§7 一条)。批次二**没有**:收银台复用 `pay_trace_no`,订单表列已够用。
+
+### 5.2 权限与菜单
+
+新增权限串(§4.4 三个)。**菜单与权限的 SQL 由我写好、由用户执行**。
+
+⚠ **SQL 未执行前,收款接口一律 403**。这不是缺陷,是预期;验收顺序上要排在 SQL 之后。
+
+### 5.3 新增配置项
+
+| 键 | 用途 |
+|---|---|
+| `leshouying.cashier-base-url` | 拼 `cashierUrl` 用。默认取现有对外域名,与 `notify-url` 同源 |
+
+不新增:二维码尺寸、颜色、容错级别。
+
+---
+
+## 6. 批次三:商户进件
+
+### 6.1 接口清单(全部来自上游文档,本次逐页拉取)
+
+| # | 接口 | 路径 | 用途 |
+|---|---|---|---|
+| 1 | 创建商户 | `POST /yfpay/api/merchant/create` | 录入资料,成功返回商户号 |
+| 2 | 商户审核异步通知 | **我方提供固定地址**,上游配置 | 审核通过/驳回 |
+| 3 | 更新商户信息 | `POST /yfpay/api/merchant/update` | 增量更新 |
+| 4 | 获取商户信息 | `POST /yfpay/api/merchant/info` | 基本资料 + 各通道审核状态 + 子商户号 |
+| 5 | 创建门店 | `POST /yfpay/api/shop/create` | 返回 `shop_no` |
+| 6 | 门店列表 | `POST /yfpay/api/shop/list` | |
+| 7 | 提交商户入网信息 | `POST /yfpay/api/merchant/commitMerchantInfo` | 资质、门店位置、经纬度 |
+| 8 | 查询商户入网状态 | `POST /yfpay/api/merchant/reportQuery` | |
+| 9 | 安心签个人版开户 | `POST /yfpay/api/merchant/anxinSignPerson` | 通道专用 |
+| 10 | 企业和个体安心签开户 | `POST /yfpay/api/merchant/anxinSignCompany` | 通道专用 |
+| 11 | 商户审核主动查询 | `POST /yfpay/api/merchant/query` | |
+| 12 | 获取省市区 | `POST /yfpay/api/info/area` | 三级联动 |
+| 13 | 获取行业类目 | `POST /yfpay/api/merchant/getTopMccByType` | |
+| 14 | 图片上传 | `POST /yfpay/api/info/upload/img` | multipart,返回 `img_url` |
+
+报文体系与支付类不同:路径是 `/yfpay/api/*` 不是 `/v3/*`,成功判定看 `result_code` + `result_msg`,**没有** `PAY_SUCCESS` 这类业务码。签名规则一致(字典序拼接 + 机构密钥 MD5)。
+
+**创建商户**的必填字段共 **27 个**(含 `inst_no` / `trace_no` / `sign` / `notify_url`),另有 15 个资质图片字段按商户类型分档必填。附表给的是 7 类商户(企业对公 / 企业对私法人 / 企业对私非法人 / 个体对公法人 / 个体对私法人 / 个体对私非法人 / 小微)× 每个字段的必填勾选。
+
+### 6.2 文档缺陷(实测,逐条对上原文)
+
+**(a) `merchant_type` 值域,4 处里 1 处矛盾。**
+
+| 出处 | 值域 |
+|---|---|
+| 创建商户·请求参数表 | `1企业,2个体工商户,3小微商户` |
+| 创建商户·附表(7 类商户) | 1=企业、2=个体、3=小微 |
+| 获取商户信息·返回参数 | `1企业,2个体工商户,3小微个人` |
+| 获取行业类目·请求参数 | `1个体工商户 2企业 3政府组织及事业单位4小微商户5其他机构组织` |
+
+**3 处一致、1 处把 1 和 2 对调**。而且「获取行业类目」那一页的接口描述是从「获取省市区」页**整段复制**过来的(原文写着「通过此接口可获取进件时的省市区列表信息。此接口为三级联动设计…」),是明显的复制粘贴残留。
+
+⇒ **裁定:`1=企业,2=个体工商户,3=小微商户`**,以三处一致者为准,把行业类目页记为文档缺陷。**这不是猜测**:一页有明显复制痕迹的文档,对抗三处互相印证的取值。
+
+**(b) `reviewed_status`:两处真冲突。**
+
+| 出处 | 值域 | 冲突点 |
+|---|---|---|
+| 商户审核异步通知 | 参数表 `1.审核通过,2.审核驳回`,类型 `String(4)` | **同页请求示例写的是 `"reviewed_status":"审核通过"`**(中文串,正好 4 字),且示例里多出参数表没有的 `settlement_status` |
+| 商户审核主动查询 | `0审核通过,1审核中,9审核驳回,10通道报备中,98停用,99关闭` | 示例 `reviewed_status: 0` 配 `reviewed_reason: "审核通过"`——**驳回原因字段里填的是「审核通过」** |
+| 查询商户入网状态 | 返回参数表 `0.审核中,1.审核通过,2.审核驳回,3.预审核通过 4.待提交 5.已提交 6.重新进件` | **同页的接口描述块给的是另一套**:`0已发起进件 / 1返回渠道子商户进件流程成功的终态 / 2重新修改信息进件 / 3银行审核中 / 4需要调用入网接口 / 5银行审核中 / 6需要重新调用创建商户接口`。同一个 0-6 值域、同一页、两个映射 |
+
+前两处是**跨接口**的,只要各按各的解析就不冲突。**第三处是同一页对一个字段给了两个映射**,无法靠「各按各的」绕开。
+
+**(c) 商户号字段名不一致。** 创建商户与更新商户的返回参数表写 `shoubei_merchant_no`,两页的返回示例都写 `merchant_no`。而主动查询、入网状态查询的入参叫 `merchant_no`。⇒ 创建成功后到底从哪个 key 取号,**只有真跑一次才知道**。这是可实测项,不需要外部输入。
+
+**(d) 类型不一致。** `merchant_mcc_code` 在创建商户是 `String(4)`(示例 `204`),行业类目接口返回的 `mcc_code` 是**数字**(`1/7/32/47/86/125`),获取商户信息返回示例是字符串 `"292"`。
+
+### 6.3 阻塞项(拿不到依据就不写那一部分)
+
+| # | 缺什么 | 为什么我拿不到 |
+|---|---|---|
+| 1 | `channel_id`(通道 id) | 文档原文「**由客户经理提供**」,只给了几个举例(19/96/71) |
+| 2 | `bd_id`(业务经理 id) | 文档只给举例 `288/686` |
+| 3 | `rate_code`(费率模板) | 文档只说格式规则(万分之四十五填 `RT0045`),实际填什么要谈 |
+| 4 | `account_bank_code`(结算银行支行联行号) | 要真实联行号,编不出来 |
+| 5 | `channel_type` | 行业类目接口的必填项,文档写「由客户经理提供」 |
+| 6 | 进件审核通知地址的**上游侧配置** | 文档原文「由合作机构提供一个固定的接口地址,**平台配置后方可使用**」。我方地址我能提供,上游侧的配置动作我做不了 |
+| 7 | 测试环境的 `channel_id` 等是否与生产不同 | 文档没写 |
+
+`merchant_mcc_code`(行业类目)也依赖 `channel_type`,一并卡在 #5。
+
+**没有 1–5,创建商户这个接口一步都走不通**——不是「跑不通某一分支」,是第一条请求就会被上游拒。因此批次三拆成两半:
+
+### 6.4 批次三的拆分
+
+**三-A:无歧义、可独立完成并验收**
+
+图片上传、省市区、门店创建、门店列表、获取商户信息、商户审核主动查询。
+
+这六个接口的入参、出参、值域在文档里都干净,不依赖任何未知凭据(只需要已有的 `inst_no` 与机构密钥)。图片上传尤其关键——它是创建商户那些资质图片字段取值的唯一来源。
+
+**三-B:创建商户 + 入网流程**
+
+代码可以写完(必填性、字段名、类型都明确,§6.2 的 (a) 已裁定),但**通电需要 §6.3 的 1–5 与 6**。
+
+做法:字段组装与签名一次做对,值从配置读(不硬编码、不留「测试用假值」)。**在凭据到位前,这一半不部署、不接生产**,与用户「线上版本肯定是要进件再用的」一致。
+
+**三-C:审核状态解析器**
+
+`reviewed_status` 的三套值域(§6.2 (b))全部按各自接口实现,**不做归一化猜测**。同一套语义在不同接口可能对应不同字面值,任何「统一成一个枚举」的冲动都会被文档打脸。入网状态那一页的两个映射冲突**记录在代码注释里指向文档**,等联调时用真实应答确定。
+
+### 6.5 上游 `notify_url` 的能力边界
+
+异步通知要「由合作机构提供一个固定的接口地址」。现有配置里已有支付回调地址(`leshouying.notify-url`),进件审核通知是**另一个**固定地址,需要单独一个配置项和一条白名单路径。上游侧配置动作需要用户去要。
+
+---
+
+## 7. 全部 DDL(一条)
+
+```sql
+-- pay_lsy_merchant 去企业化:允许租户级商户
+ALTER TABLE pay_lsy_merchant ALTER COLUMN enterprise_id DROP NOT NULL;
+
+-- 租户级商户唯一性。现有 uk_lsy_merchant_tenant_ent 建在 (tenant_id, enterprise_id) 上,
+-- 而 PostgreSQL 唯一索引里 NULL 互不相等 ⇒ 没有这条局部索引,同租户可插任意多行租户级商户。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_merchant_tenant_level
+    ON pay_lsy_merchant (tenant_id) WHERE enterprise_id IS NULL;
+```
+
+订单表、门店相关表**无 DDL**。
+
+按本仓规则:SQL 我写,**只有用户执行**。
+
+---
+
+## 8. 安全约束
+
+1. **验签绝不跳过**。进件接口同样是签名请求,一样走既有签名逻辑,不开任何旁路。
+2. **收银台页免鉴权,但不免令牌校验**。无令牌 / 错令牌 → 明确的「链接无效」页。令牌 32 位随机。
+3. **`/payment/cashier/**` 进白名单时逐条精确匹配**,不用 `/**` 粗放放行——本仓既有过滤器注释专门警告过这个坑。
+4. **`code_url` 不外发**。任何面向商户或前端的应答里不出现它。
+5. **文案零上游痕迹**:页面、错误消息、应答字段名里不出现上游名称、域名、其专有字段名。
+6. **凭据不进仓库**。进件新增的 `channel_id` 等一律走配置 + 环境变量,照 `application.yml` 现有的 `${ENV:default}` 写法。顺带记录一条**既存观察**(本轮不改):`leshouying.key` 的默认值目前是明文写在被 git 跟踪的 `application.yml` 里,正式密钥上线前应改为无默认值的环境变量。
+7. 秘密只打印长度,不打印值。
+
+---
+
+## 9. 测试策略
+
+- **批次一**:`requireByTenant` / `requireByEnterprise` 分流各一组;`enterpriseId` 为空与非空两条下单路径。现有 418 个测试必须继续全绿。
+- **批次二**:
+  - 收银台:令牌不存在 → 无效页;令牌存在 → `/status` 金额/状态/过期时间正确;页面 HTML **与令牌无关**(同一段静态内容,挡的是「以后有人加回服务端替换」)。
+  - `qr.png`:返回 PNG 魔数,且内容能被解码回 `code_url`(拿 zxing 自己解一遍,别只断言「非空」)。
+  - 开放接口:`/qrcode/create` 应答**不含** `codeUrl`、**含** `cashierUrl`。
+  - 有效期:`expireStaleOrders` 对 `QRCODE + code_url_expire_time 非空` 的订单**不再**置 EXPIRED,对 `QRCODE + 无 code_url` 与 H5 **仍然**置 EXPIRED。这条要**两向**测:该红的红,不该红的绿。
+  - 免鉴权:`/payment/cashier/**` 无 token 可达;`/payment/collection/receive/**` 无 token **不可达**。
+- **批次三**:三-A 的六个接口按报文契约单测 + 签名单测;三-B 的字段组装与必填校验单测,**其联网行为不做自动化测试**(会被外部环境卡住),改为拿到凭据后手工跑一次并留证据。
+- `mvn test` 需要库隧道在位(`PaymentApplicationContextTest` 的已知依赖)。
+
+---
+
+## 10. 明确不做
+
+1. **当面付(`module/payment/facetoface`)不退役、不改动**。用户口径是「乐收赢与原有功能完全不相干」「原有的开放接口不变」。
+2. **被扫的既有实现不动**:`/v3/pay`、`LsyOrderPollScheduler` 的密集轮询、付款码脱敏层、相关测试全部保留,只是在新的独立前端里多一个入口。
+3. **小程序端不动**。
+4. **不做多商户切换 UI**:租户级一个商户映射,够用。
+5. **不做收银台 SSR 框架**:不引 Thymeleaf。
+6. **不做二维码外观配置**。
+7. **不修 `code_url` 可枚举**:上游的事。
+
+---
+
+## 11. 待用户确认
+
+1. **批次顺序**:一 → 二 → 三 是否认可?三-A 能否单独先做(它不依赖任何外部输入)?
+2. **§6.3 的 1–6 谁来要**:`channel_id` / `bd_id` / `rate_code` / `account_bank_code` / `channel_type` 需要找客户经理;进件审核通知地址需要上游配置。
+3. **§6.2 (a) 我裁定 `1=企业, 2=个体工商户, 3=小微商户`**,是否认可(依据是三处互相印证 vs 一处有复制痕迹)?
+4. **§4.5 开放接口应答 `codeUrl` → `cashierUrl` 是破坏性变更**,确认可以改(前提是乐收赢确实没上过生产)。