日期:2026-09-22
上游:乐收赢(代称「上游」,所有对用户可见处不得出现其名称、域名、字段名)
前序 spec:docs/superpowers/specs/2026-09-21-leshouying-payment-channel-design.md
用户在本次会话中逐条给出的口径,按最终版记录:
本条的实测可行性见 §2.4,落地设计见 §4。§4 已按本条重写;第 1 条中「返回收银台页面链接」的结论仍成立,但「一单一码、商户先输金额、收银台只做展示」不成立。
一次性做完上面全部内容会把「能立刻验收的东西」和「依赖外部输入才能通电的东西」绑在一起。按可独立验收切成三批:
| 批次 | 内容 | 能否独立验收 | 阻塞项 |
|---|---|---|---|
| 一 | 乐收赢去企业化(租户账号级) | 能 | 无 |
| 二 | 收款工作台:长期收款码(§0.9)+ 免鉴权收银台页 + 三通道唤起 + 管理端接口 + 独立前端工程 | 能(端到端跑通一笔群收款、一笔被扫) | 无(三通道的正式环境白名单属上线前事项,见 §2.4) |
| 三 | 商户进件 | 不能 | 客户经理凭据 + 上游配置回调地址,见 §6 |
批次一必须先做:批次二的管理端接口按租户级设计,建立在它之上。 批次三与批次一、二无代码依赖,但它的联调依赖外部输入,不应挡住批次二的发布。
以下读数均为本次会话实测,不是推断。
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 在正式环境是否需要预先登记白名单(测试环境实测未校验);openid,支付宝侧未知,联调时实测);乐收赢的商户映射、下单、查询全部按租户寻址,不再要求、不再校验 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 |
复用既有枚举 LsyPayType:WECHAT/ALIPAY(上游值 400/300)。用户口径第 ② 条 |
status |
启用 / 停用 |
remark |
备注 |
created_time / updated_time |
同全库约定 |
约束:code_no 全局唯一;(tenant_id, name) 唯一(同名无法管理)。
⚠ channel 是「这个码发给谁」的标记,不是服务端的硬闸。 真实走哪条通道以 UA 为准(§4.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=…
│ 页面捕获 → 存 sessionStorage → history.replaceState 抹掉
▼
顾客输入金额 → 点「确认支付」→ POST ./{codeNo}/pay {金额, 授权标识}
│ 服务端:查码 → 取绑定商户 → 建单 → 调上游 JSAPI 下单(pay_type 由 UA 决定)
▼
返回唤起参数 → 前端 WeixinJSBridge.invoke('getBrandWCPayRequest', …) → 收银台直接弹出
redirect_uri 不带任何业务参数。 金额是顾客回跳之后才输入的,没有东西需要穿过这趟往返——这比「把金额挂在 redirect_uri 上带过去」干净,也少一个可被篡改的入口。
普通浏览器(手机相机扫、电脑打开):WeixinJSBridge 不存在,两个 JSAPI 都不适用,走 H5 支付。服务端下单后自己 302 过去,上游链接不下发到浏览器。
字段名口径: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。
POST /payment/openapi/pay/qrcode/create 的应答:codeUrl 字段换成 cashierUrl。
理由有两条,都需要:
code_url 发出去,商户点开看到的是上游域名,等于暴露上游。code_url 是上游可枚举短链,不再外发。这是破坏性变更。成立的前提是用户确认过「乐收赢所有代码都没有上过生产」。落地前要再核一次 LsyPayResultVO.codeUrl 有没有别的消费方。
⚠ 冲突:用户口径 §0.6 是「原有的开放接口不变」,本节的提议是改它。两条都是用户说的,我不能替用户选。默认取值:不动(遵守 §0.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 的拦截器同构。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 |
拼收银台地址(二维码内容 + 授权回跳)用。默认取现有对外域名,与 notify-url 同源 |
receive.amount-min |
顾客可输的最小金额,默认 1(分,即 0.01 元) |
receive.amount-max |
顾客可输的最大金额,默认待用户给数(见 §11.1 第 6 条) |
不新增:二维码尺寸、颜色、容错级别。
| # | 接口 | 路径 | 用途 |
|---|---|---|---|
| 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"。
| # | 缺什么 | 为什么我拿不到 |
|---|---|---|
| 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 与机构密钥)。图片上传尤其关键——它是创建商户那些资质图片字段取值的唯一来源。
三-B:创建商户 + 入网流程
代码可以写完(必填性、字段名、类型都明确,§6.2 的 (a) 已裁定),但通电需要 §6.3 的 1–5 与 6。
做法:字段组装与签名一次做对,值从配置读(不硬编码、不留「测试用假值」)。在凭据到位前,这一半不部署、不接生产,与用户「线上版本肯定是要进件再用的」一致。
三-C:审核状态解析器
reviewed_status 的三套值域(§6.2 (b))全部按各自接口实现,不做归一化猜测。同一套语义在不同接口可能对应不同字面值,任何「统一成一个枚举」的冲动都会被文档打脸。入网状态那一页的两个映射冲突记录在代码注释里指向文档,等联调时用真实应答确定。
notify_url 的能力边界异步通知要「由合作机构提供一个固定的接口地址」。现有配置里已有支付回调地址(leshouying.notify-url),进件审核通知是另一个固定地址,需要单独一个配置项和一条白名单路径。上游侧配置动作需要用户去要。
-- 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,
channel VARCHAR(16) 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 不外发。任何面向商户或前端的应答里不出现它。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 应答不含 codeUrl、含 cashierUrl;裁决为「不改」则保留现有断言。别按未裁决的版本写测试。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 是破坏性变更,确认可以改(前提是乐收赢确实没上过生产)。这条与 §0.6「原有的开放接口不变」直接冲突,默认按 §0.6 不动,请裁决。total_amount 是 string(8),理论上限 999,999.99 元)。leshouying.cashier-base-url 要指向哪个域名?现有 notify-url 用的是 https://qcsj88888.com/api/v1/...,收银台是否用同一个域名。redirect_uri 是否需要预先登记白名单;② 支付宝回跳时携带用户标识的字段名(文档只写了微信侧示例)。