2026-09-22-receive-workbench-design.md 49 KB

收款工作台 + 商户进件 设计

日期: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 <accessToken>

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 包,<img src=".../qr.png"> 即可。

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" 是错拼) 签名相关:发错键名上游可能直接判「参数不合法」。属可实测项,已排进三-A 联调
获取省市区 196236568 level 参数表写 String,请求示例写数字 3 轻微,发字符串即可
获取省市区 196236568 area_code 的真实形态不是纯数字:返回示例里出现 100A…100H,其中 100G/100H 不是合法十六进制 会炸:任何 parseInt / 数字型 DTO 字段在这里必崩。必须当字符串
获取门店列表 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),进件审核通知是另一个固定地址,需要单独一个配置项和一条白名单路径。上游侧配置动作需要用户去要。


7. 全部 DDL(一条)

-- 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。

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 新增)

已裁定:

  1. 渠道不符怎么办 → 不用管(用户逐字:「微信扫不了支付宝的码,反过来也是一样,不用管」)。已落地文档,且 2026-09-22 晚些时候因删掉 channel 列而自动满足:码上不再有渠道,「不符」无从发生。§4.2 有完整理由链。
  2. 收银台对外域名 → 与 notify-url 同源(用户答「可以」)。落点见 §5.3。已落地文档。

已裁定:

  1. 金额范围 → 下限 0.01 元,上限 不额外限制(取上游技术上限 999999.99 元)。落点见 §5.3。

仍待决定(不阻塞本地开发,阻塞上线):

  1. 上游侧要不要现在就去问:正式环境的 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 里打开,看是否给出渠道选择或自动分流