# 收款工作台 + 商户进件 设计 **日期**:2026-09-22 **上游**:乐收赢(代称「上游」,所有对用户可见处不得出现其名称、域名、字段名) **前序 spec**:`docs/superpowers/specs/2026-09-21-leshouying-payment-channel-design.md` --- ## 0. 需求口径(本轮最终版) 用户在本次会话中逐条给出的口径,按最终版记录: 1. 主扫(生成二维码收款)要返回**渲染好的收银台页面链接**,页面**不鉴权**,商户可直接用,不需要自己开发收银台页面。有效期**默认三天**(上游默认值)。 2. 生成二维码的入口**不做在小程序端、也不做在现有后台**,而是**一个全新独立的前端工程**。 3. 该工程包含四页:**登录**(复用现有平台账号)、**二维码收款(主扫)**、**扫描二维码收款(被扫)**、**订单明细**。 4. **被扫同样需要**(用户自己纠正了早先「被扫全部注释」的说法:被扫也是收款)。 5. 乐收赢与原有企业逻辑**完全不相干**,一律**租户账号级**;开放接口允许租户账号直接请求,唯一前置是**完成商户进件**。 6. **原有的开放接口不变**。 7. **商户进件本轮一并做**,取值依据上游文档。 8. 常设约束:验签绝不跳过;文案不暴露上游;开发测试期先用文档给的测试信息把支付与收款生命周期走通,线上必须先进件。 9. **(2026-09-22 追加,推翻第 1 条里「一单一码」的隐含前提)群收款场景**:商户拿到**一个长期固定的收款码**发到群里,群里任何人扫这个码进收银台,**顾客自己输入金额**、点确认,**直接唤起对应的支付 App**。用户逐字口径: - 「其实就是提供一个链接或者二维码,指向系统收银台前端,链接或二维码需要**绑定生成的商户**」 - ~~「需要**区分微信和支付宝两种二维码**,商户生成微信二维码则发到微信群,支付宝二维码则发给支付宝用户,输入完金额后**直接要唤起对应的 app 支付**」~~ **(2026-09-22 撤销,见下方第 ④ 条裁定)** - 「**可建多个,带名称**」 - 扫码环境三者都要覆盖:**微信群 / 微信内置浏览器、手机相机 / 普通浏览器、支付宝扫** 本条的实测可行性见 §2.4,落地设计见 §4。**§4 已按本条重写**;第 1 条中「返回收银台页面链接」的结论仍成立,但「一单一码、商户先输金额、收银台只做展示」不成立。 用户就本条追加的裁定(2026-09-22): - **渠道不符不用管**:「微信扫不了支付宝的码,反过来也是一样,不用管」⇒ 不做硬拦、不做提示(落点 §4.2)。**该条 2026-09-22 晚些时候因第 ④ 条失去对象**:码上不再有渠道,「不符」无从谈起。 - **④ 不区分渠道**:用户问「能做到一个码微信和支付宝都能用吗?」⇒ **能,且原设计已经就是这样**(证据链见 §4.2)⇒ 裁定**去掉 `pay_receive_code.channel` 列**,并撤销上面第 ② 条口径。普通浏览器那条腿改由顾客点选渠道(落点 §4.4)。 - **收银台域名与 `notify-url` 同源**(落点 §5.3) - **开放接口不改**:「一般人不会抓包,就算抓包也抓不到什么有用的信息」⇒ 原「`codeUrl` → `cashierUrl`」提案作废(落点 §4.7) - **金额范围**:下限 `0.01` 元、**上限不额外限制**(落点 §5.3) --- ## 1. 批次划分 一次性做完上面全部内容会把「能立刻验收的东西」和「依赖外部输入才能通电的东西」绑在一起。按**可独立验收**切成三批: | 批次 | 内容 | 能否独立验收 | 阻塞项 | |---|---|---|---| | **一** | 乐收赢去企业化(租户账号级) | 能 | 无 | | **二** | 收款工作台:**长期收款码**(§0.9)+ 免鉴权收银台页 + 三通道唤起 + 管理端接口 + 独立前端工程 | 能(端到端跑通一笔群收款、一笔被扫) | 无(三通道的正式环境白名单属上线前事项,见 §2.4) | | **三** | 商户进件 | **不能** | 客户经理凭据 + 上游配置回调地址,见 §6 | **执行顺序:一 → 三 → 二**(用户 2026-09-22 定:「按一、三、二做」)。上表的编号是**内容编号**,不是执行顺序。 批次一必须先做:批次二的管理端接口按租户级设计,建立在它之上。 批次三与批次一、二无代码依赖,但它的**联调**依赖外部输入(三-B 的客户经理凭据),**不应挡住批次二的发布**——三-A 无外部依赖,可先做完。 --- ## 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 ` | ### 2.4 「群收款 + 顾客输金额 + 原生唤起」的实测可行性(§0.9 的依据) **背景**:本设计早先记过一条「JSAPI `v3/jspay` 需 `open_id` + 服务商公众号授权,链路长一个量级,用户已确认本期不做」。用户 2026-09-22 提出 §0.9 后,该条口径被推翻,并对上游文档做了逐页复核 + 打测试环境实测。结论:**链路完全开放,且那个「服务商公众号」不是我们要申请的东西**。 **文档侧读数**(上游文档站,页码可复核): | 项 | 原文口径 | |---|---| | `v3/jspay` 场景 | 「用于支付宝生活号支付,公众号支付,**微信浏览器打开链接支付**,微信扫码打开链接支付」 | | `v3/jspay` 必填 | `inst_no`、`mch_no`、`pay_type`(300支付宝/400微信)、`pay_trace_no`、`pay_time`、`total_amount`、**`open_id`**、`sign`;`appid` 选填且「取值优先于后台配置」 | | `v3/jspay` 返回 | 微信:`appId`/`timeStamp`/`nonceStr`/`packages`/`signType`/`paySign` —— **正是 `WeixinJSBridge.invoke('getBrandWCPayRequest')` 所需**;支付宝:`ali_trade_no`「用于调起支付宝JSAPI」 | | 获取 openid | 「用于**服务商模式(服务商公众号)**授权获取用户信息……使用子商户模式(独立公众号)发起支付的商户请通过相应的直连接口获取」 | | 关单 | `v3/closeorder` 正文:「订单关闭接口,**二维码支付,JSAPI支付可用**」 | **测试环境实测**(我方向上游机构号 + 测试商户号,2026-09-22): | 探针 | 读数 | |---|---| | `GET /jsapi/getauth2`(签名已修正后) | 不再报「签名验证失败」,改报「**请在微信或支付宝中打开!**」⇒ 该接口对我们是**开放的**,只是有 UA 门禁 | | `GET /jsapi/getauth2` + **微信 UA** | `302 → open.weixin.qq.com/connect/oauth2/authorize?appid=wx81ba…&scope=snsapi_base&…` ⇒ 服务商公众号是**上游的**,且 `snsapi_base` 为**静默授权**(无授权弹窗) | | `GET /jsapi/getauth2` + **支付宝 UA** | `302 → openauth.alipay.com`,`scope=auth_base`(同为静默)⇒ **同一个端点按 UA 自动分流两渠道** | | `GET /jsapi/getauth2` + **普通浏览器 UA** | 落回错误页(预期,无对应 JSAPI) | | `redirect_uri` 往返 | 我们传的 `redirect_uri` 被**原样**嵌在上游 `auth_hosted.html?...&redirect_uri=<我们的>` 里 ⇒ 上下文可穿过整个 OAuth 往返 | | `POST /v3/jspay`(假 openid) | `return_code=SUCCESS` / `return_msg='无效的openid'` / `result_code=PAY_FAIL` ⇒ **接口可达、签名通过、参数校验走到了 openid 这一层,只差一个真 openid** | | `POST /v3/h5pay`(对照组,pay_type=300) | `PAY_SUCCESS`,`trade_no=200001070X4326…`,`code_url` 137 字符 ⇒ **普通浏览器有兜底通道** | **由此确定的三条通道**(按 UA 分流,服务端不需问用户): | 扫码环境 | 通道 | 唤起方式 | |---|---|---| | 微信内置浏览器(群里扫码) | `v3/jspay` `pay_type=400` | `WeixinJSBridge` **原生唤起微信支付** | | 支付宝内置浏览器 | `v3/jspay` `pay_type=300` | `ali_trade_no` 调起支付宝 | | 普通浏览器(手机相机扫 / 电脑打开) | `v3/h5pay` | 返回跳转 URL,`location` 过去;**渠道由顾客在页面上点选**(见下) | **仍待上游确认**(外部依赖,不阻塞本地开发,测试环境已证明不校验): 1. `redirect_uri` 在**正式环境**是否需要预先登记白名单(测试环境实测未校验); 2. ~~支付宝回跳时携带用户标识的**字段名**~~ → **文档已写明,不是未知项**。见下方订正。 3. 正式环境参数(域名 / 机构号 / 密钥)。 > **订正(2026-09-22)**:本文档此前把「支付宝侧用户标识字段名未知」列为待确认项,**是我读漏了**。`jsapi/getauth2` 那一页(page 196236540)的返回参数表明写:「`openid` | string | 否 | **微信公众号的openid或支付宝服务窗的userid**」——**两个渠道回跳用的都是同一个参数名 `openid`**,页面按 UA 走哪条腿对它透明。而这个值正是 `v3/jspay` 入参 `open_id` 要的东西:「用户标识(**微信openid/支付宝userid**)」。两页对上,无需再问上游。 > 剩下真正没验的只有一件事:**真机走一遍**(§11.2)。 --- ## 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/** │ │ │ 管理「长期收款码」:建 / 改名 / 停用 ├─▶ 收款码表 pay_receive_code(新增) │ 下载二维码 PNG │ ▼ ▼ 商户把码贴到群里 ──▶ 顾客扫 ──▶ 收银台页 /payment/cashier/{codeNo} │ │ 免鉴权 ├─ 微信内置浏览器 ─▶ 静默授权拿 openid ─▶ JSAPI 原生唤起微信支付 ├─ 支付宝内置浏览器 ─▶ 静默授权拿 userid ─▶ 支付宝 JSAPI 唤起 └─ 普通浏览器 ─▶ H5 支付 ─▶ 跳转唤起 ``` **与旧版 §4 的根本区别**:码**不是一单一码**,而是商户的固定资产(可建多个、可命名、可停用、长期有效);码**不承载金额**,金额由顾客在收银台输入;订单在顾客点「确认支付」时才创建。 ### 4.2 收款码(新增表 `pay_receive_code`) 一个商户可建**多个**收款码,**可命名**、可停用,**长期有效**。码只承载「入口」,不承载金额。 | 列 | 说明 | |---|---| | `id` | 主键,雪花 | | `tenant_id` | 租户,由 MyBatis-Plus 拦截器填(归属只能来自认证上下文,同 `pay_lsy_merchant` 的约束) | | `code_no` | **URL 短码,32 位随机,不可枚举**(沿用旧设计对收银台令牌的要求) | | `name` | 名称,商户自取(「前台」「3 号桌」) | | `merchant_id` | → `pay_lsy_merchant.id`。**码绑定到哪个商户**,用户口径第 ① 条 | | ~~`channel`~~ | **2026-09-22 删除** —— 码不分渠道,理由见下 | | `status` | 启用 / 停用 | | `remark` | 备注 | | `created_time` / `updated_time` | 同全库约定 | 约束:`code_no` 全局唯一;`(tenant_id, name)` 唯一(同名无法管理)。 **为什么不分渠道(2026-09-22 定)** 用户问「能做到一个码微信和支付宝都能用吗?」——**能,而且原设计就已经是这样**,只是被一个多余的列挡住了: 1. 二维码里编的是**我们自己的地址** `https://<收银台域名>/payment/cashier/{codeNo}`(§4.5),**不是**微信或支付宝的专属链接; 2. 两个 App 的「扫一扫」扫到 URL 都用各自内置浏览器打开(**平台既定行为,本项目未实测**); 3. 进了收银台页之后**分流按 UA 做,与码本身无关**(§4.4)。 ⇒ 所谓「微信码」与「支付宝码」的二维码图像**逐像素相同**;`channel` 唯一作用是管理端列表上那个标签。故删列,§0.9 口径第 ② 条同时撤销。 **证据强度要分清**(§11.2 第 1/2 条要验): - **实测** —— 上游 `getauth2` 同一个端点,微信 UA 与支付宝 UA **都会被静默授权**,只有其他 UA 报错(§2.4)。 - **推断(未实测)** —— 因此顾客一定能付成。**没在真机走过完整一单**,不是已证事实。 用户此前「微信扫不了支付宝的码,反过来也是一样,不用管」那条裁定**仍然成立**,且现在自动满足:码上根本没有渠道,「不符」无从发生。 ### 4.3 免鉴权收银台页 **端点**(`SecurityConfig.WHITE_LIST` 与 `JwtAuthFilter.WHITE_LIST_PATHS` 同步新增 `/payment/cashier/**`): | 端点 | 产出 | |---|---| | `GET /payment/cashier/{codeNo}` | HTML 页面 | | `GET /payment/cashier/{codeNo}/info` | JSON:商户名、码名称、渠道、可用状态 | | `GET /payment/cashier/{codeNo}/auth` | 302 到上游静默授权(§4.4) | | `POST /payment/cashier/{codeNo}/pay` | 入参金额 + 授权标识 → 建单 + 下单 → 返回唤起参数 | | `GET /payment/cashier/{codeNo}/status` | JSON:该笔订单状态(前端轮询用) | **页面内容**:商户名、金额输入框、「确认支付」按钮、支付结果。文案零上游痕迹。 **实现方式**:HTML 一个静态文件放在 `src/main/resources/` 下,控制器读入后**原样**以 `produces = text/html` 返回。**不引入 Thymeleaf**——本服务目前是纯 JSON 服务,为一个页面引入模板引擎不划算。 **服务端不做任何占位符替换。** 页面上每一个变量(商户名、码名称)都从 `/info` 取,由 JS 用 `textContent` 写入 DOM;`codeNo` 由 JS 从 `location.pathname` 里读。这样服务端没有任何拼接点,**注入面为零**。 ⚠ 如果以后有人往这段 HTML 里加服务端替换,那就必须同时对值做 HTML 转义——**商户名和码名称是商户自己填的自由文本**、`codeNo` 来自路径,两者都不可信。**当前设计里没有这个位置,加的时候要有意识。** **授权标识的处理**:OAuth 回跳到 `?openid=…` 后,页面捕获它、存进 `sessionStorage`、随即用 `history.replaceState` 把 URL 上的标识抹掉(免得被转发或截图带出去)。它**只用来向上游换唤起参数,不参与我方的任何鉴权**——所以即使被人改成别的值,结果也只是唤起失败,不构成越权。 **`codeNo` 校验**:不存在或已停用 ⇒ 返回一个「链接无效」页,**不是 404 白页**(这是顾客会看到的页面)。`codeNo` 32 位随机,不可枚举。 **上游短链可枚举这件事(旧版遗留,新版已基本消解)**:上游的 `code_url` 是 `s-0000N` 连号,邻单理论上可被猜出。旧设计要发链接给商户,所以必须处理;**新设计里顾客只拿到我们的 `/payment/cashier/{codeNo}`,上游链接只在服务端 302(普通浏览器兜底那条腿)或回调里出现,从不下发到浏览器**。风险等级:低。 ### 4.4 支付唤起(三通道) **微信内置 / 支付宝内置 —— 静默授权 + 原生唤起** ``` 顾客扫长期码 → 收银台页 /payment/cashier/{codeNo} │ 页面加载:sessionStorage 里没有授权标识 ⇒ location.replace('./{codeNo}/auth') ▼ GET /payment/cashier/{codeNo}/auth │ 服务端按既有签名规则签 inst_no / mch_no / redirect_uri │ (redirect_uri 参与签名时不 encodeURI —— 上游文档明写的特殊口径) ▼ 302 → 上游 getauth2 → 上游按 UA 分流(不需要我们判断): 微信 → open.weixin.qq.com scope=snsapi_base(静默,无授权弹窗) 支付宝 → openauth.alipay.com scope=auth_base (静默) ▼ 上游回跳 → 收银台?openid=… │ ⚠ 两个渠道回的都是同名参数 `openid`(文档明写:微信 openid / 支付宝 userid), │ 所以这里不需要按渠道分支。但**拼接成 `?openid=` 还是 `&openid=` 没实测过**, │ `/auth` 侧解析要容错(§11.2 第 3 条) │ 页面捕获 → 存 sessionStorage → history.replaceState 抹掉 ▼ 顾客输入金额 → 点「确认支付」→ POST ./{codeNo}/pay {金额, 授权标识} │ 服务端:查码 → 取绑定商户 → 建单 → 调上游 JSAPI 下单(pay_type 由 UA 决定) ▼ 返回唤起参数 → 前端 WeixinJSBridge.invoke('getBrandWCPayRequest', …) → 收银台直接弹出 ``` **`redirect_uri` 不带任何业务参数。** 金额是顾客回跳之后才输入的,没有东西需要穿过这趟往返——这比「把金额挂在 redirect_uri 上带过去」干净,也少一个可被篡改的入口。 **普通浏览器**(手机相机扫、电脑打开):`WeixinJSBridge` 不存在,两个 JSAPI 都不适用,走 H5 支付。服务端下单后**自己 302** 过去,上游链接不下发到浏览器。 ⚠ **这一条腿的渠道必须顾客选**(2026-09-22 定,因为 `channel` 列已删,§4.2)。理由是两头都堵死了: - `h5pay` 的 `pay_type` 是**必填**(`M`:300 支付宝 / 400 微信 / 600 云闪付,page 196236551)——**不能省**; - 普通浏览器里 UA 只能告诉我们「不是微信也不是支付宝」,**推不出顾客装了哪个 App**,也没有 `channel` 可读。 ⇒ **收银台页在这一条腿上渲染两个按钮(微信支付 / 支付宝支付)**,顾客点哪个就用哪个 `pay_type` 下单。 另两条腿(微信/支付宝内置浏览器)**不出现按钮**——UA 已知,直接唤起。 > 可选优化(**未实测,不作为本期方案**):上游 `/v3/qrpay` 是聚合码接口,描述逐字「创建订单生成**包含微信、支付宝**的二维码链接」,**没有 `pay_type` 入参**。若它在**普通浏览器**里打开也能分流,这条腿就不需要顾客选了。列入 §11.2 第 6 条待验;验通了可以去掉按钮。 **字段名口径**:`pay` 只回微信/支付宝自己的唤起参数名。`appId`/`timeStamp`/`nonceStr`/`packages`/`signType`/`paySign` 是微信 JSAPI 的**既定名,必须原样**(改了 `WeixinJSBridge` 不认),但它们属于微信、不暴露上游。**不回上游订单号、不回上游域名、不回上游任何字段名。** **支付结果的真实性**:`WeixinJSBridge` 的回调**只用来做提示、不作为成功依据**——用户中途退出、网络抖动都会让它丢。真实状态由前端轮询 `GET ./{codeNo}/status` 得到,最终以上游异步回调落库的状态为准(回调链路在批次一之前就有,不在本轮改动范围)。 ### 4.5 二维码生成 - 依赖:**新增 `com.google.zxing:core`**。已实测它不在依赖树里,Hutool 的 `QrCodeUtil` 没有它会在运行时炸。 - 生成点:**只有一处** —— `/code/{id}/qr.png`。 - 二维码内容:`https://<收银台域名>/payment/cashier/{codeNo}`(**我们自己的地址,不是上游链接**)。 - 尺寸/容错级别:选一个能印能扫的默认值即可,不作配置项(YAGNI)。 前端不生成二维码:新工程因此**不需要引入任何二维码 npm 包**,`` 即可。 ### 4.6 管理端收款接口 路径前缀 `/payment/collection/receive`,鉴权走既有 JWT 过滤器。 | 端点 | 用途 | 权限串 | |---|---|---| | `POST /code` | 建收款码 | `module_payment:receive:code` | | `PUT /code/{id}` | 改名 / 停用 / 启用 | `module_payment:receive:code` | | `GET /code/page` | 收款码列表 | `module_payment:receive:code:list` | | `GET /code/{id}/qr.png` | 二维码 PNG | `module_payment:receive:code:list` | | `POST /barcode` | 被扫下单,输入付款码 | `module_payment:receive:barcode` | | `GET /order/page` | 订单明细分页 | `module_payment:receive:order:list` | | `GET /order/{outTradeNo}` | 订单详情 | `module_payment:receive:order:list` | | `POST /order/{outTradeNo}/query` | 主动向上游查一次 | `module_payment:receive:order:list` | **不复用** `module_payment:leshouying:order:list`。理由:能查订单不该自动等于能发起收款。 **旧版的 `POST /qrcode`(管理端先输金额再下单)去掉了**:新模型下订单由顾客在收银台点「确认支付」时触发,管理端不再有「先生成码」这一步。管理端只管理码本身。 被扫下单:入参 `authCode`(付款码)+ `amount`。走既有 `LsyOrderService.createBarcodeOrder`。 ### 4.7 开放接口**不改**(原「codeUrl → cashierUrl」提案,已裁定废弃) 用户 2026-09-22 裁定:**不改**。理由逐字:「一般人不会抓包,就算抓包也抓不到什么有用的信息」。 ⇒ `POST /payment/openapi/pay/qrcode/create` 的应答**保持原样**,`codeUrl` 不动。§0.6「原有的开放接口不变」生效,与本节原先的提议之间的冲突就此消解。 **同时作废的**:原提案里「`code_url` 不出现在任何面向商户或前端的应答里」这条要求(§8 安全约束第 4 条)**随之作废** —— 老开放接口会照常回它。但**新链路(收银台 / 收款码)我们自己的输出里仍然不出现上游域名与字段名**,这条不受影响。 **仍然保留的判断**:`code_url` 是上游可枚举短链。用户已评估为低风险(「抓包也抓不到什么有用的信息」),记录在案,不再作为待办。 ### 4.8 独立前端工程 `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` 的拦截器同构。 - **四页**(旧版第 2 页的形态变了:不再「输金额 → 生成码」,而是「**管理收款码**」): 1. **登录** 2. **收款码管理**:列表(名称 / 渠道 / 绑定商户 / 状态)+ 新建 + 改名 + 停用 + **下载二维码图片** 3. **扫描二维码收款(被扫)**:输入框接扫码枪(键盘模拟输入 + 回车触发)→ 提交 → 展示结果 4. **订单明细**:分页列表 + 详情 - **收银台页不在这个工程里**——它是后端直出的免鉴权静态页(§4.3),不经过前端构建产物。 - 构建:`base: '/receive/'`,产物独立;nginx 新增 `location /receive`。 - 不作多租户切换 UI:租户从 JWT 里来。 ### 4.9 旧「主扫有效期」方案**作废**(原 §4.7) 旧设计要改:`leshouying.default-qr-expire-minutes` 由 `15` 改为 `4320`,并把主扫单精确排除出 24 小时绝对兜底网(排除条件 `pay_scene='QRCODE' AND code_url_expire_time IS NOT NULL`)。 **新模型下这两项改动都不需要了**: - 旧方案要解决的是「码活三天、本地账在第 24 小时被兜底网置 EXPIRED」的错账。新模型里**订单是顾客点确认那一刻才创建的**,不存在「码的生命周期」与「订单的生命周期」错位这回事。 - 顾客确认后下的 JSAPI 单,其未支付窗口以微信侧 `prepay_id` 为准(远短于 24 小时),**落回 24 小时兜底网是合适的**,不需要排除。 **但保留一句**:`/payment/openapi/pay/qrcode/create` 走的老 `qrpay` 链路**保持原样**(§0.6),`default-qr-expire-minutes` 与 `closeExpiredQrOrders` 都不动。若将来老链路的有效期也要拉长到三天,本节的两条改动**必须重新捡起来**——那时的错账风险同旧文所述,不会因为新模型出现而消失。 --- ## 5. 批次二的 DDL / 菜单 / 配置 ### 5.1 会不会有 DDL 批次一有(§7 一条)。批次二**原本没有,现在有了**:§0.9 引入的长期收款码需要一张新表 `pay_receive_code`(§4.2),见 §7。 收银台的 `pay` 端点仍复用既有订单表,不新增订单相关列。 ### 5.2 权限与菜单 新增权限串(§4.6 四个): | 权限串 | 覆盖端点 | |---|---| | `module_payment:receive:code` | `POST /code`、`PUT /code/{id}` | | `module_payment:receive:code:list` | `GET /code/page`、`GET /code/{id}/qr.png` | | `module_payment:receive:barcode` | `POST /barcode` | | `module_payment:receive:order:list` | `GET /order/page`、`GET /order/{outTradeNo}`、`POST /order/{outTradeNo}/query` | **菜单与权限的 SQL 由我写好、由用户执行**。权限串必须与 `sys_menu.permission` 逐字一致(本仓既有教训:差一个字符就是 403)。 ⚠ **SQL 未执行前,收款接口一律 403**。这不是缺陷,是预期;验收顺序上要排在 SQL 之后。 ### 5.3 新增配置项 | 键 | 用途 | |---|---| | `leshouying.cashier-base-url` | 拼收银台地址(二维码内容 + 授权回跳)用。**用户 2026-09-22 裁定:与 `notify-url` 同源**(`https://qcsj88888.com`) | | `receive.amount-min` | 顾客可输的最小金额,**用户 2026-09-22 裁定:`0.01` 元**(`1` 分) | | `receive.amount-max` | 顾客可输的最大金额。**用户 2026-09-22 裁定:不额外限制** ⇒ 默认取上游技术上限 `999999.99` 元(`99999999` 分,`total_amount` 是 `string(8)`)。**这是我们不加闸,不是上游说商户真能收这么多**——真上限由上游按商户资质控制,超了会由上游拒。 | 不新增:二维码尺寸、颜色、容错级别。 --- ## 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"`。 **(e) 三-A 那六页新查出的缺陷(2026-09-22 逐页重取原文后补)** | 页 | 缺陷 | 影响 | |---|---|---| | 创建门店 196236559 | **参数表写 `manager_name` / `manager_phone`,同页请求示例写 `maneger_name` / `maneger_phone`**("maneger" 是错拼) | ~~签名相关:发错键名上游可能直接判「参数不合法」~~ → **2026-09-22 真机定案:按参数表拼写真发成功**(返回 `shop_no`),上游认参数表那版 ⇒ **不构成缺陷,无需改动**。见 §6.6 | | 获取省市区 196236568 | `level` 参数表写 `String`,请求示例写**数字** `3` | 轻微。~~发字符串即可~~ → **实测(2026-09-22):`level` 发字符串 `"1"/"2"/"3"` 全部可用**,返回省 / 市 / 区县三级均非空。见 §6.6 | | 获取省市区 196236568 | 返回**示例**里 `area_code` 出现 `100A`…`100H`(其中 `100G`/`100H` 不是合法十六进制) | ★ **原判「真实形态不是纯数字 / 会炸」不成立 —— 那是文档示例的形状,被当成了服务行为。2026-09-22 真机普查:`area_code` 是 `Integer`,483/483,非数字 0 条**(见 §6.6)。**仍按 `String` 承载**,理由是实测撑得住的:① `area_code` 要被**原样回传**给上游的 `parent_code`(那是 String 参数);② `asText` 的 `String.valueOf` 对 Integer **无损**。**不是**因为「它可能是 `100A`」 | | 获取省市区 196236568 | ★ **每级各有一个未记录的返回字段**:level1 `province_code`、level2 `city_code`、level3 `county_code`(GB 行政码,**String**);文档三级都只记了 `area_name` / `area_code` | 是**缺漏**不是矛盾。三-A 用不到;**三-B 要**(进件要 `address_province` / `address_city` / `address_county`)⇒ 届时给 DTO 加这三个字段。读数见 §6.6.2,缺口见 §6.6.3 | | 获取省市区 196236568 | ★ **`parent_code` 传错不报错**:回 `SUCCESS` + **空 `area_list`**(实测填 GB 码 `110000` 而非上级的 `area_code` 就是这个结果);文档未提 | **排障陷阱**:某级忽然空了会被读成「上游没数据 / 上游挂了」。**静默失败**,故记在此而非只留在 §6.6。见 §6.6.2 与 §6.6 表 #2b | | 获取门店列表 196236560 | 返回参数表 `result_code` 一行的「参数示例」列写着 `PAY_SUCCESS`,与同一行的说明「SUCCESS操作成功,FAIL操作失败」**自相矛盾**(支付类页面复制残留) | 无(说明与返回示例一致,取 `SUCCESS`/`FAIL`) | | 获取门店列表 196236560 | 返回示例里 `"trace_no": "123456"` 后面**缺一个逗号**,整段是**非法 JSON** | **会误导**:谁照抄这坨当夹具,Hutool 会解析失败,而失败原因看起来像「上游报文坏了」。同时 `shop_list` 第二项**没有 `shop_tel` 键** | | 获取商户信息 196236558 | `wx_channel_rate` / `ali_channel_rate` 在返回参数表里**各出现两次**;返回示例里 `merchant_type` / `account_type` / `license_type` / `wx_channel_id` 是**数字**而表里写 `String` | 轻微,读时按 `String.valueOf` 归一 | | 商户审核主动查询 196236567 | 已记在 (b) 第 2 行 | — | ### 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` 与机构密钥)。图片上传尤其关键——它是创建商户那些资质图片字段取值的唯一来源。 > **2026-09-22 修订两处**(实现计划 `2026-09-22-lsy-onboarding-A.md` 的 R-1 / R-5): > 1. **三-A 只做上游 leg**(`LeshouyingClient` 的 `/api/*` 与 multipart 两条出站腿 + 一个 `LsyOnboardingService`),**不加 HTTP 端点、不加菜单 SQL**。理由:§4.8 的前端工程四页里**没有进件页**,现在加端点等于加一批零调用方的 HTTP 面;端点随 三-B 的进件 UI 一起来,那时租户归属校验才有落点。 > 2. **三-C 不再单列**,被三-A/三-B 各自吸收:`reviewed_status` 的三套值域里,「商户审核主动查询」这套归三-A,「异步通知」与「商户入网状态」两套归三-B(后者依赖 `reportQuery`)。「不跨接口归一化」这条原则不变。 **三-B:创建商户 + 入网流程** 代码可以写完(必填性、字段名、类型都明确,§6.2 的 (a) 已裁定),但**通电需要 §6.3 的 1–5 与 6**。 做法:字段组装与签名一次做对,值从配置读(不硬编码、不留「测试用假值」)。**在凭据到位前,这一半不部署、不接生产**,与用户「线上版本肯定是要进件再用的」一致。 **三-C:审核状态解析器** `reviewed_status` 的三套值域(§6.2 (b))全部按各自接口实现,**不做归一化猜测**。同一套语义在不同接口可能对应不同字面值,任何「统一成一个枚举」的冲动都会被文档打脸。入网状态那一页的两个映射冲突**记录在代码注释里指向文档**,等联调时用真实应答确定。 ### 6.5 上游 `notify_url` 的能力边界 异步通知要「由合作机构提供一个固定的接口地址」。现有配置里已有支付回调地址(`leshouying.notify-url`),进件审核通知是**另一个**固定地址,需要单独一个配置项和一条白名单路径。上游侧配置动作需要用户去要。 ### 6.6 三-A 真机读数(2026-09-22) **取证方式**:探针放在仓库外(`D:\lsy-regress\LsyTask7Probe.java`),**调的是生产代码** (`LeshouyingClient` / `LeshouyingSigner` / `LeshouyingResponseParser` / `LsyOnboardingService`), **没有重写一行签名逻辑**。目标是文档给的**测试**环境与**测试**机构。原始应答留在 `D:\lsy-regress\task7\`(UTF-8)。 **凭据只打印长度,不打印值。** ★ **本节全部为真实请求** —— 含**一次真实的门店创建**(下表 #6,在上游测试环境建出了一家门店)。 | # | 接口 | 实测读数 | |---|---|---| | 1 | `/api/info/area` `level=1` | **34** 条省/直辖市(`result_code: SUCCESS`)。`area_code` = **Integer**(`1`/`19`/`37`/`251`…);另有**文档未记录**的 `province_code` = **String**(`"110000"`…) | | 2 | `/api/info/area` `level=2` / `level=3` | **三级联动走得通**(`parent_code` 传上一级的 `area_code`)。level2 另有 `city_code`、level3 另有 `county_code`,**均为 String** | | 2b | 同上,`parent_code` 填 **GB 码** `"110000"` | ★ **`result_code: SUCCESS` + `area_list: []`** —— 上级传错是**静默空列表**,**不报错**。某级忽然空了先怀疑 `parent_code` 传错,**别怀疑上游挂了** | | 3 | `/api/merchant/info` 测试商户 | **SUCCESS** —— **「大概率回商户不存在」的预期被证伪**,拿到完整字段集 | | 4 | `/api/merchant/query` 同 | **SUCCESS**:`reviewed_status` 实测回 `"0"`,而同报文的 `reviewed_reason` 是「审核通过」 | | 5 | `/api/shop/list` 同 | **SUCCESS**,13 家门店。★ **门店电话的键是 `shop_tel`**(创建腿入参是 `shop_phone`)——**同一次真机往返内坐实** | | 6 | `/api/shop/create` | ★ **SUCCESS**:用**参数表拼写** `manager_name` / `manager_phone` 真发成功并返回 `shop_no` ⇒ **R-2 定案**(§6.2 (e) 第 1 行) | | 7 | `/api/info/upload/img` | 不带 `img_name` ⇒ **HTTP 400**(文档的「必填」实测为真);带上 ⇒ **SUCCESS** + `img_url` | #### 6.6.1 由此更正 §6.2 (e) 的两条(原判据写错了) 1. **`area_code` 不是「非纯数字」。** 原写「真实形态不是纯数字 … 会炸」——那是把**文档返回示例的形状** 当成了**服务的行为**。实测 **Integer 483/483、非数字 0 条**。仍按 `String` 承载,但理由换成实测撑得住的 (① 要原样回传给 String 参数 `parent_code`;② `String.valueOf` 对 Integer 无损)。**不是**因为「它可能是 `100A`」。 2. **`manager_name` 不构成缺陷。** 上游认的就是**参数表**那版;请求示例里的 `maneger_*` 是文档自己打错的, 与我们的实现无关。原「签名相关、可能被判参数不合法」的担心**已实测排除**。 #### 6.6.2 文档**缺漏**(是缺漏,不是矛盾) 1. **每级各多一个未记录字段**:level1 `province_code`、level2 `city_code`、level3 `county_code`(GB 行政码,**String**)。 ★ **三级联动的 `parent_code` 不是它们** —— 填 `"110000"` 回 0 条,填上级的 `area_code`(`"1"`)才回数据。 字段**存在性**已由一次 483 条的**穷举普查**覆盖(level1 34 / level2 352 / level3 97,每条都带该级 GB 码)。 2. **`parent_code` 传错是静默空列表而不是报错**(见上表 #2b)。 #### 6.6.3 三-A 用不到、三-B 要用的缺口(**本次仍然不改代码**) `LsyAreaItemDTO` **丢掉了**这三个 GB 码(DTO 只有 `areaName` / `areaCode`)。 `/api/shop/create` 只收自由文本 `shop_address`,所以**三-A 不需要它们**; 但三-B 的商户进件要 `address_province` / `address_city` / `address_county` ⇒ **届时给 DTO 加这三个字段** (加法、可逆,所以不在三-A 做)。 #### 6.6.4 ★ 一条安全读数(影响实现,不只是备注) `/api/merchant/info` 的响应含**身份证号 / 法人证件号 / 真实姓名 / 手机号 / 银行卡照片 URL / 通道费率**。 ⇒ 代码类注释里那条**租户缺口(R-4)警告是真实的高危暴露面**,不是预防性免责声明: 三-B 的端点若不先证明 `mchNo` 属于当前租户,就是一次 IDOR,泄漏的是**真人 PII**。 **本文件不记录这些值**(只记字段名与性质)。 #### 6.6.5 未测项(**留空,不填推断**) - `reviewed_status` 的**完整值域** —— 只有 1 个测试商户、1 个读数(**"0" ⇒ 审核通过** 这一条是单点, 不足以定整个值域,§6.2 (b) 的三套值域冲突**仍未解**)。 - **生产环境**:本节全部读数取自**测试**环境,不得外推到生产。 - §6.3 的 1–7 各项**一个都没被本次读数消解**(本次没碰创建商户那条腿)。 --- ## 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; ``` ### 7.2 批次二:收款码表(§0.9 引入) 列约定照 `pay_lsy_merchant`(`V1.8__create_pay_lsy_tables.sql`):`id BIGINT PRIMARY KEY` 由应用侧雪花生成、`status` 用**字符串枚举**不用 '0'/'1'、时间列 `TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`、每表每列都带 `COMMENT`。 ```sql CREATE TABLE IF NOT EXISTS pay_receive_code ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, code_no VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL, merchant_id BIGINT NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', remark VARCHAR(255), created_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), updated_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS uk_receive_code_no ON pay_receive_code (code_no); CREATE UNIQUE INDEX IF NOT EXISTS uk_receive_code_name ON pay_receive_code (tenant_id, name); CREATE INDEX IF NOT EXISTS idx_receive_code_mch ON pay_receive_code (merchant_id); ``` `code_no` 唯一索引是**全局**的(不带 `tenant_id`)——它出现在公开 URL 上,必须全局唯一,否则两个租户撞码就是串户。 比对旧文:**订单表、门店相关表仍无 DDL**。 按本仓规则:SQL 我写,**只有用户执行**。 --- ## 8. 安全约束 1. **验签绝不跳过**。进件接口同样是签名请求,一样走既有签名逻辑,不开任何旁路。 2. **收银台页免鉴权,但不免令牌校验**。无令牌 / 错令牌 → 明确的「链接无效」页。令牌 32 位随机。 3. **`/payment/cashier/**` 进白名单时逐条精确匹配**,不用 `/**` 粗放放行——本仓既有过滤器注释专门警告过这个坑。 4. ~~**`code_url` 不外发**。任何面向商户或前端的应答里不出现它。~~ **2026-09-22 作废**:用户裁定老开放接口不改(§4.7),该接口照常回 `codeUrl`。**新链路(收银台 / 收款码)自己的输出里仍然不带它**,见 §4.4 的字段名口径。 5. **文案零上游痕迹**:页面、错误消息、应答字段名里不出现上游名称、域名、其专有字段名。 6. **凭据不进仓库**。进件新增的 `channel_id` 等一律走配置 + 环境变量,照 `application.yml` 现有的 `${ENV:default}` 写法。顺带记录一条**既存观察**(本轮不改):`leshouying.key` 的默认值目前是明文写在被 git 跟踪的 `application.yml` 里,正式密钥上线前应改为无默认值的环境变量。 7. 秘密只打印长度,不打印值。 --- ## 9. 测试策略 - **批次一**:`requireByTenant` / `requireByEnterprise` 分流各一组;`enterpriseId` 为空与非空两条下单路径。现有 418 个测试必须继续全绿。 - **批次二**: - 收款码:`code_no` 全局唯一(**跨租户撞码要红**);`(tenant_id, name)` 唯一;停用后收银台返回「链接无效」页。 - 收银台:`codeNo` 不存在 → 无效页(**不是 404 白页**);存在 → `/info` 的商户名/码名称/渠道正确;页面 HTML **与 `codeNo` 无关**(同一段静态内容,挡的是「以后有人加回服务端替换」)。 - `qr.png`:返回 PNG 魔数,且内容能被**解码回** `https://<收银台域名>/payment/cashier/{codeNo}`(拿 zxing 自己解一遍,别只断言「非空」)。 - **唤起分流**(§4.4):微信 UA → JSAPI 分支;支付宝 UA → JSAPI 分支;其它 UA → H5 分支。**两向测**:把分类器改坏,该红的那条必须红、不该红的那条必须仍绿。 - **授权回跳**:`/auth` 出参是带签名的授权地址;回跳带 `openid` 时页面把它存进 `sessionStorage` 并用 `history.replaceState` 抹掉 URL 上的值。 - **不泄露上游**:`/pay` 应答里**不含**上游订单号、上游域名、上游字段名。用**字段名白名单**断言,不要用「没看到就算过」。 - 免鉴权:`/payment/cashier/**` 无 token 可达;`/payment/collection/receive/**` 无 token **不可达**。 - `/qrcode/create` 应答**保持原样**(§4.7 已裁定不改),现有断言**保留不动**。 - **批次三**:三-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. ~~**批次顺序**~~ → 用户已定:**一 → 三 → 二**(原话「按一、三、二做」)。§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.7 开放接口应答 `codeUrl` → `cashierUrl` 是破坏性变更**~~ → **已裁定:不改**(2026-09-22,理由逐字见 §4.7)。与 §0.6 的冲突就此消解。 ### 11.1 §0.9 新需求带来的待确认(2026-09-22 新增) **已裁定:** 5. ~~渠道不符怎么办~~ → **不用管**(用户逐字:「微信扫不了支付宝的码,反过来也是一样,不用管」)。**已落地文档,且 2026-09-22 晚些时候因删掉 `channel` 列而自动满足**:码上不再有渠道,「不符」无从发生。§4.2 有完整理由链。 7. ~~收银台对外域名~~ → **与 `notify-url` 同源**(用户答「可以」)。落点见 §5.3。**已落地文档。** **已裁定:** 6. ~~金额范围~~ → 下限 **`0.01` 元**,上限 **不额外限制**(取上游技术上限 `999999.99` 元)。落点见 §5.3。 **仍待决定(不阻塞本地开发,阻塞上线):** 8. **上游侧要不要现在就去问**:正式环境的 `redirect_uri` 是否需要预先登记白名单。(原先并列的「支付宝回跳字段名」**不是未知项**——`getauth2` 那页明写两个渠道都回 `openid`,见 §2.4 订正。) ### 11.2 联调时必须实测的项(不是待确认,是待取证) 下列各项**目前都只是推断或半证据**,本文档里凡引用它们的地方都标了性质。联调时逐条取得真实读数后再回填: | # | 待验项 | 现状 | 怎么验 | |---|---|---|---| | 1 | **真机走通完整一单**(三通道各一次) | 未做。本文档所有「能付成」都是从实测推出的**推断** | 微信内扫码 → 输金额 → 唤起 → 支付成功 → 回调入账 | | 2 | 支付宝侧的**回跳与唤起**(不是字段名——字段名文档已明写,见 §2.4 订正) | 微信侧实测可达;支付宝侧只验到「UA 会被静默授权」这一步 | 同上,走一单真支付宝:看回跳带没带 `openid`、`jspay` 回没回 `ali_trade_no` | | 3 | **上游回跳时 `?openid=` 是怎么拼上去的** | 文档示例里 redirect_uri 带 query 时拼 `&openid=`,还会插一个 `/`;**我们的 redirect_uri 不带 query**,那到底拼 `?` 还是 `&`、会不会插 `/`——**没实测过**(探针没走完整趟 OAuth) | 真机看回跳 URL 原文;若拼错,`/auth` 侧要能容错解析 | | 4 | 普通浏览器(手机相机扫)走 `v3/h5pay` 的兜底,**含顾客点选渠道那两个按钮** | `h5pay` 本身实测过(对照探针 `PAY_SUCCESS`),但**没串进收银台链路**;两个按钮是 2026-09-22 新增的设计,**零实测** | 用相机扫码走一遍:**两个渠道各点一次**,看 400 和 300 是否都跳到对应 App | | 5 | 上游 `redirect_uri` 正式环境是否要预登记白名单 | 未知(测试环境实测未校验) | 问上游(§11.1 第 8 条) | | 6 | **(可选优化)`/v3/qrpay` 的聚合码 `code_url` 在普通浏览器里能不能两端分流** | 未测。文档只写「在支付宝或微信中打开支付链接完成支付」,**没写普通浏览器**。若成立,第 4 条的按钮可以去掉,一条腿也变成零交互 | 建一笔 `qrpay` 单,把 `code_url` 在 Chrome/Safari 里打开,看是否给出渠道选择或自动分流 |