日期:2026-09-22
上游:乐收赢(代称「上游」,所有对用户可见处不得出现其名称、域名、字段名)
前序 spec:docs/superpowers/specs/2026-09-21-leshouying-payment-channel-design.md
用户在本次会话中逐条给出的口径,按最终版记录:
本条的实测可行性见 §2.4,落地设计见 §4。§4 已按本条重写;第 1 条中「返回收银台页面链接」的结论仍成立,但「一单一码、商户先输金额、收银台只做展示」不成立。
用户就本条追加的裁定(2026-09-22):
pay_receive_code.channel 列,并撤销上面第 ② 条口径。普通浏览器那条腿改由顾客点选渠道(落点 §4.4)。notify-url 同源(落点 §5.3)codeUrl → cashierUrl」提案作废(落点 §4.7)0.01 元、上限不额外限制(落点 §5.3)一次性做完上面全部内容会把「能立刻验收的东西」和「依赖外部输入才能通电的东西」绑在一起。按可独立验收切成三批:
| 批次 | 内容 | 能否独立验收 | 阻塞项 |
|---|---|---|---|
| 一 | 乐收赢去企业化(租户账号级) | 能 | 无 |
| 二 | 收款工作台:长期收款码(§0.9)+ 免鉴权收银台页 + 三通道唤起 + 管理端接口 + 独立前端工程 | 能(端到端跑通一笔群收款、一笔被扫) | 无(三通道的正式环境白名单属上线前事项,见 §2.4) |
| 三 | 商户进件 | 不能 | 客户经理凭据 + 上游配置回调地址,见 §6 |
执行顺序:一 → 三 → 二(用户 2026-09-22 定:「按一、三、二做」)。上表的编号是内容编号,不是执行顺序。
批次一必须先做:批次二的管理端接口按租户级设计,建立在它之上。 批次三与批次一、二无代码依赖,但它的联调依赖外部输入(三-B 的客户经理凭据),不应挡住批次二的发布——三-A 无外部依赖,可先做完。
以下读数均为本次会话实测,不是推断。
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。顾客扫码后落到上游域名,这是渠道的既定事实,我们改不了。我们能控制的是商户拿到手的入口:一个属于我们、不暴露上游、可保存的链接。
| 项 | 读数 |
|---|---|
pay_lsy_order 行数 |
7 |
pay_trace_no 非空 / 去重 |
7 / 7(0 个 NULL) |
| 唯一索引 | uk_lsy_order_pay_trace_no 已存在 |
| 生成方式 | IdUtil.fastSimpleUUID(),32 位随机十六进制,不可枚举 |
⇒ 用 pay_trace_no 当公开页面令牌,零 DDL。
| 项 | 读数 |
|---|---|
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> |
背景:本设计早先记过一条「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 过去;渠道由顾客在页面上点选(见下) |
仍待上游确认(外部依赖,不阻塞本地开发,测试环境已证明不校验):
redirect_uri 在正式环境是否需要预先登记白名单(测试环境实测未校验);订正(2026-09-22):本文档此前把「支付宝侧用户标识字段名未知」列为待确认项,是我读漏了。
jsapi/getauth2那一页(page 196236540)的返回参数表明写:「openid| string | 否 | 微信公众号的openid或支付宝服务窗的userid」——两个渠道回跳用的都是同一个参数名openid,页面按 UA 走哪条腿对它透明。而这个值正是v3/jspay入参open_id要的东西:「用户标识(微信openid/支付宝userid)」。两页对上,无需再问上游。 剩下真正没验的只有一件事:真机走一遍(§11.2)。
乐收赢的商户映射、下单、查询全部按租户寻址,不再要求、不再校验 enterprise_id。原有按企业寻址的能力保留且行为不变。
不变式:租户级商户 = 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 一字不动。enterpriseId 由 @NotBlank 改为可选。LeshouyingOpenController 的三个下单方法:enterpriseId 为空时走 requireByTenant,非空时走 requireByEnterprise。不是替换,是分流。LsyMerchantEntity / LsyOrderEntity 的 enterpriseId 字段保留。LeshouyingMerchantController + 现有前端页):允许企业字段留空。原有按企业下单的调用方不受影响:传了 enterpriseId 就还是老路径、还是老报错文案。删掉 enterpriseId 字段是方向相反的改动,不做。
独立前端工程 frontend-receive/ ──JWT──▶ 管理端接口 /payment/collection/receive/**
│ │
│ 管理「长期收款码」:建 / 改名 / 停用 ├─▶ 收款码表 pay_receive_code(新增)
│ 下载二维码 PNG │
▼ ▼
商户把码贴到群里 ──▶ 顾客扫 ──▶ 收银台页 /payment/cashier/{codeNo}
│
│ 免鉴权
├─ 微信内置浏览器 ─▶ 静默授权拿 openid ─▶ JSAPI 原生唤起微信支付
├─ 支付宝内置浏览器 ─▶ 静默授权拿 userid ─▶ 支付宝 JSAPI 唤起
└─ 普通浏览器 ─▶ H5 支付 ─▶ 跳转唤起
与旧版 §4 的根本区别:码不是一单一码,而是商户的固定资产(可建多个、可命名、可停用、长期有效);码不承载金额,金额由顾客在收银台输入;订单在顾客点「确认支付」时才创建。
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 定)
用户问「能做到一个码微信和支付宝都能用吗?」——能,而且原设计就已经是这样,只是被一个多余的列挡住了:
https://<收银台域名>/payment/cashier/{codeNo}(§4.5),不是微信或支付宝的专属链接;⇒ 所谓「微信码」与「支付宝码」的二维码图像逐像素相同;channel 唯一作用是管理端列表上那个标签。故删列,§0.9 口径第 ② 条同时撤销。
证据强度要分清(§11.2 第 1/2 条要验):
getauth2 同一个端点,微信 UA 与支付宝 UA 都会被静默授权,只有其他 UA 报错(§2.4)。用户此前「微信扫不了支付宝的码,反过来也是一样,不用管」那条裁定仍然成立,且现在自动满足:码上根本没有渠道,「不符」无从发生。
端点(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(普通浏览器兜底那条腿)或回调里出现,从不下发到浏览器。风险等级:低。
微信内置 / 支付宝内置 —— 静默授权 + 原生唤起
顾客扫长期码 → 收银台页 /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)——不能省;channel 可读。⇒ 收银台页在这一条腿上渲染两个按钮(微信支付 / 支付宝支付),顾客点哪个就用哪个 pay_type 下单。
另两条腿(微信/支付宝内置浏览器)不出现按钮——UA 已知,直接唤起。
可选优化(未实测,不作为本期方案):上游
/v3/qrpay是聚合码接口,描述逐字「创建订单生成包含微信、支付宝的二维码链接」,没有pay_type入参。若它在普通浏览器里打开也能分流,这条腿就不需要顾客选了。列入 §11.2 第 6 条待验;验通了可以去掉按钮。
字段名口径:pay 只回微信/支付宝自己的唤起参数名。appId/timeStamp/nonceStr/packages/signType/paySign 是微信 JSAPI 的既定名,必须原样(改了 WeixinJSBridge 不认),但它们属于微信、不暴露上游。不回上游订单号、不回上游域名、不回上游任何字段名。
支付结果的真实性:WeixinJSBridge 的回调只用来做提示、不作为成功依据——用户中途退出、网络抖动都会让它丢。真实状态由前端轮询 GET ./{codeNo}/status 得到,最终以上游异步回调落库的状态为准(回调链路在批次一之前就有,不在本轮改动范围)。
com.google.zxing:core。已实测它不在依赖树里,Hutool 的 QrCodeUtil 没有它会在运行时炸。/code/{id}/qr.png。https://<收银台域名>/payment/cashier/{codeNo}(我们自己的地址,不是上游链接)。前端不生成二维码:新工程因此不需要引入任何二维码 npm 包,<img src=".../qr.png"> 即可。
路径前缀 /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。
用户 2026-09-22 裁定:不改。理由逐字:「一般人不会抓包,就算抓包也抓不到什么有用的信息」。
⇒ POST /payment/openapi/pay/qrcode/create 的应答保持原样,codeUrl 不动。§0.6「原有的开放接口不变」生效,与本节原先的提议之间的冲突就此消解。
同时作废的:原提案里「code_url 不出现在任何面向商户或前端的应答里」这条要求(§8 安全约束第 4 条)随之作废 —— 老开放接口会照常回它。但新链路(收银台 / 收款码)我们自己的输出里仍然不出现上游域名与字段名,这条不受影响。
仍然保留的判断:code_url 是上游可枚举短链。用户已评估为低风险(「抓包也抓不到什么有用的信息」),记录在案,不再作为待办。
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 的拦截器同构。base: '/receive/',产物独立;nginx 新增 location /receive。旧设计要改:leshouying.default-qr-expire-minutes 由 15 改为 4320,并把主扫单精确排除出 24 小时绝对兜底网(排除条件 pay_scene='QRCODE' AND code_url_expire_time IS NOT NULL)。
新模型下这两项改动都不需要了:
prepay_id 为准(远短于 24 小时),落回 24 小时兜底网是合适的,不需要排除。但保留一句:/payment/openapi/pay/qrcode/create 走的老 qrpay 链路保持原样(§0.6),default-qr-expire-minutes 与 closeExpiredQrOrders 都不动。若将来老链路的有效期也要拉长到三天,本节的两条改动必须重新捡起来——那时的错账风险同旧文所述,不会因为新模型出现而消失。
批次一有(§7 一条)。批次二原本没有,现在有了:§0.9 引入的长期收款码需要一张新表 pay_receive_code(§4.2),见 §7。
收银台的 pay 端点仍复用既有订单表,不新增订单相关列。
新增权限串(§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 之后。
| 键 | 用途 |
|---|---|
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))。这是我们不加闸,不是上游说商户真能收这么多——真上限由上游按商户资质控制,超了会由上游拒。 |
不新增:二维码尺寸、颜色、容错级别。
| # | 接口 | 路径 | 用途 |
|---|---|---|---|
| 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 类商户(企业对公 / 企业对私法人 / 企业对私非法人 / 个体对公法人 / 个体对私法人 / 个体对私非法人 / 小微)× 每个字段的必填勾选。
(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" 是错拼) |
shop_no),上游认参数表那版 ⇒ 不构成缺陷,无需改动。见 §6.6 |
| 获取省市区 196236568 | level 参数表写 String,请求示例写数字 3 |
轻微。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」 |
| 获取门店列表 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 行 | — |
| # | 缺什么 | 为什么我拿不到 |
|---|---|---|
| 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,创建商户这个接口一步都走不通——不是「跑不通某一分支」,是第一条请求就会被上游拒。因此批次三拆成两半:
三-A:无歧义、可独立完成并验收
图片上传、省市区、门店创建、门店列表、获取商户信息、商户审核主动查询。
这六个接口的入参、出参、值域在文档里都干净,不依赖任何未知凭据(只需要已有的 inst_no 与机构密钥)。图片上传尤其关键——它是创建商户那些资质图片字段取值的唯一来源。
2026-09-22 修订两处(实现计划
2026-09-22-lsy-onboarding-A.md的 R-1 / R-5):
- 三-A 只做上游 leg(
LeshouyingClient的/api/*与 multipart 两条出站腿 + 一个LsyOnboardingService),不加 HTTP 端点、不加菜单 SQL。理由:§4.8 的前端工程四页里没有进件页,现在加端点等于加一批零调用方的 HTTP 面;端点随 三-B 的进件 UI 一起来,那时租户归属校验才有落点。- 三-C 不再单列,被三-A/三-B 各自吸收:
reviewed_status的三套值域里,「商户审核主动查询」这套归三-A,「异步通知」与「商户入网状态」两套归三-B(后者依赖reportQuery)。「不跨接口归一化」这条原则不变。
三-B:创建商户 + 入网流程
代码可以写完(必填性、字段名、类型都明确,§6.2 的 (a) 已裁定),但通电需要 §6.3 的 1–5 与 6。
做法:字段组装与签名一次做对,值从配置读(不硬编码、不留「测试用假值」)。在凭据到位前,这一半不部署、不接生产,与用户「线上版本肯定是要进件再用的」一致。
三-C:审核状态解析器
reviewed_status 的三套值域(§6.2 (b))全部按各自接口实现,不做归一化猜测。同一套语义在不同接口可能对应不同字面值,任何「统一成一个枚举」的冲动都会被文档打脸。入网状态那一页的两个映射冲突记录在代码注释里指向文档,等联调时用真实应答确定。
notify_url 的能力边界异步通知要「由合作机构提供一个固定的接口地址」。现有配置里已有支付回调地址(leshouying.notify-url),进件审核通知是另一个固定地址,需要单独一个配置项和一条白名单路径。上游侧配置动作需要用户去要。
取证方式:探针放在仓库外(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 |
area_code 不是「非纯数字」。 原写「真实形态不是纯数字 … 会炸」——那是把文档返回示例的形状
当成了服务的行为。实测 Integer 483/483、非数字 0 条。仍按 String 承载,但理由换成实测撑得住的
(① 要原样回传给 String 参数 parent_code;② String.valueOf 对 Integer 无损)。不是因为「它可能是 100A」。manager_name 不构成缺陷。 上游认的就是参数表那版;请求示例里的 maneger_* 是文档自己打错的,
与我们的实现无关。原「签名相关、可能被判参数不合法」的担心已实测排除。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 码)。parent_code 传错是静默空列表而不是报错(见上表 #2b)。LsyAreaItemDTO 丢掉了这三个 GB 码(DTO 只有 areaName / areaCode)。
/api/shop/create 只收自由文本 shop_address,所以三-A 不需要它们;
但三-B 的商户进件要 address_province / address_city / address_county ⇒ 届时给 DTO 加这三个字段
(加法、可逆,所以不在三-A 做)。
/api/merchant/info 的响应含身份证号 / 法人证件号 / 真实姓名 / 手机号 / 银行卡照片 URL / 通道费率。
⇒ 代码类注释里那条租户缺口(R-4)警告是真实的高危暴露面,不是预防性免责声明:
三-B 的端点若不先证明 mchNo 属于当前租户,就是一次 IDOR,泄漏的是真人 PII。
本文件不记录这些值(只记字段名与性质)。
reviewed_status 的完整值域 —— 只有 1 个测试商户、1 个读数("0" ⇒ 审核通过 这一条是单点,
不足以定整个值域,§6.2 (b) 的三套值域冲突仍未解)。-- 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;
列约定照 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 我写,只有用户执行。
/payment/cashier/** 进白名单时逐条精确匹配,不用 /** 粗放放行——本仓既有过滤器注释专门警告过这个坑。code_url 不外发。任何面向商户或前端的应答里不出现它。codeUrl。新链路(收银台 / 收款码)自己的输出里仍然不带它,见 §4.4 的字段名口径。channel_id 等一律走配置 + 环境变量,照 application.yml 现有的 ${ENV:default} 写法。顺带记录一条既存观察(本轮不改):leshouying.key 的默认值目前是明文写在被 git 跟踪的 application.yml 里,正式密钥上线前应改为无默认值的环境变量。requireByTenant / requireByEnterprise 分流各一组;enterpriseId 为空与非空两条下单路径。现有 418 个测试必须继续全绿。code_no 全局唯一(跨租户撞码要红);(tenant_id, name) 唯一;停用后收银台返回「链接无效」页。codeNo 不存在 → 无效页(不是 404 白页);存在 → /info 的商户名/码名称/渠道正确;页面 HTML 与 codeNo 无关(同一段静态内容,挡的是「以后有人加回服务端替换」)。qr.png:返回 PNG 魔数,且内容能被解码回 https://<收银台域名>/payment/cashier/{codeNo}(拿 zxing 自己解一遍,别只断言「非空」)。/auth 出参是带签名的授权地址;回跳带 openid 时页面把它存进 sessionStorage 并用 history.replaceState 抹掉 URL 上的值。/pay 应答里不含上游订单号、上游域名、上游字段名。用字段名白名单断言,不要用「没看到就算过」。/payment/cashier/** 无 token 可达;/payment/collection/receive/** 无 token 不可达。/qrcode/create 应答保持原样(§4.7 已裁定不改),现有断言保留不动。mvn test 需要库隧道在位(PaymentApplicationContextTest 的已知依赖)。module/payment/facetoface)不退役、不改动。用户口径是「乐收赢与原有功能完全不相干」「原有的开放接口不变」。/v3/pay、LsyOrderPollScheduler 的密集轮询、付款码脱敏层、相关测试全部保留,只是在新的独立前端里多一个入口。code_url 可枚举:上游的事。channel_id / bd_id / rate_code / account_bank_code / channel_type 需要找客户经理;进件审核通知地址需要上游配置。1=企业, 2=个体工商户, 3=小微商户,是否认可(依据是三处互相印证 vs 一处有复制痕迹)?codeUrl → cashierUrl 是破坏性变更已裁定:
channel 列而自动满足:码上不再有渠道,「不符」无从发生。§4.2 有完整理由链。notify-url 同源(用户答「可以」)。落点见 §5.3。已落地文档。已裁定:
0.01 元,上限 不额外限制(取上游技术上限 999999.99 元)。落点见 §5.3。仍待决定(不阻塞本地开发,阻塞上线):
redirect_uri 是否需要预先登记白名单。(原先并列的「支付宝回跳字段名」不是未知项——getauth2 那页明写两个渠道都回 openid,见 §2.4 订正。)下列各项目前都只是推断或半证据,本文档里凡引用它们的地方都标了性质。联调时逐条取得真实读数后再回填:
| # | 待验项 | 现状 | 怎么验 |
|---|---|---|---|
| 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 里打开,看是否给出渠道选择或自动分流 |