日期:2026-09-22
上游:乐收赢(代称「上游」,所有对用户可见处不得出现其名称、域名、字段名)
前序 spec:docs/superpowers/specs/2026-09-21-leshouying-payment-channel-design.md
用户在本次会话中逐条给出的口径,按最终版记录:
一次性做完上面全部内容会把「能立刻验收的东西」和「依赖外部输入才能通电的东西」绑在一起。按可独立验收切成三批:
| 批次 | 内容 | 能否独立验收 | 阻塞项 |
|---|---|---|---|
| 一 | 乐收赢去企业化(租户账号级) | 能 | 无 |
| 二 | 收款工作台:免鉴权收银台页 + 管理端收款接口 + 独立前端工程 | 能(端到端跑通一笔主扫、一笔被扫) | 无 |
| 三 | 商户进件 | 不能 | 客户经理凭据 + 上游配置回调地址,见 §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> |
乐收赢的商户映射、下单、查询全部按租户寻址,不再要求、不再校验 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/**
│ │
│ (展示二维码图片) ├─▶ LsyOrderService(既有)
▼ │
收银台页 /payment/cashier/{token}/qr.png ────────┘
▲
│ 免鉴权
│
商户(或顾客)浏览器 ◀── cashier_url ── 开放接口 /payment/openapi/pay/qrcode/create
端点(SecurityConfig.WHITE_LIST 与 JwtAuthFilter.WHITE_LIST_PATHS 同步新增 /payment/cashier/**):
| 端点 | 产出 |
|---|---|
GET /payment/cashier/{payTraceNo} |
HTML 页面 |
GET /payment/cashier/{payTraceNo}/qr.png |
二维码 PNG |
GET /payment/cashier/{payTraceNo}/status |
JSON:金额、状态、剩余有效期 |
页面内容:金额、二维码、倒计时、订单状态(支付中 / 已支付 / 已失效)。文案零上游痕迹。
实现方式:HTML 一个静态文件放在 src/main/resources/ 下,控制器读入后原样以 produces = text/html 返回。不引入 Thymeleaf——本服务目前是纯 JSON 服务,为一个页面引入模板引擎不划算。
服务端不做任何占位符替换。 页面上每一个变量都从 /status 取,由 JS 用 textContent 写入 DOM;令牌由 JS 从 location.pathname 里读。这样服务端没有任何拼接点,注入面为零。
⚠ 如果以后有人往这段 HTML 里加服务端替换,那就必须同时对值做 HTML 转义——金额来自数据库、令牌来自路径,两者都不可信。当前设计里没有这个位置,加的时候要有意识。
/status 应答:amount、status、codeUrlExpireTime、paid。
令牌校验:{payTraceNo} 不存在 ⇒ 返回一个「链接无效」页,不是 404 白页(这是顾客会看到的页面)。令牌本身 32 位随机,不可枚举。
上游短链可枚举这件事:code_url 是 s-0000N 连号,理论上邻单可被猜出。这是上游的设计,我们改不了。我们这边能做的两件事:(1) 我们的入口令牌不可枚举;(2) 不把 code_url 通过任何接口明文外发(见 §4.5)。风险等级低(猜到了也只能是「替别人付钱」),记录在案,不作为阻塞。
com.google.zxing:core。已实测它不在依赖树里,Hutool 的 QrCodeUtil 没有它会在运行时炸。qr.png 端点。code_url 原文。前端不生成二维码:新工程因此不需要引入任何二维码 npm 包,<img src=".../qr.png"> 即可。
路径前缀 /payment/collection/receive,鉴权走既有 JWT 过滤器。
| 端点 | 用途 | 权限串 |
|---|---|---|
POST /qrcode |
主扫下单,输入金额 | module_payment:receive:qrcode |
POST /barcode |
被扫下单,输入付款码 | module_payment:receive:barcode |
GET /page |
订单明细分页 | module_payment:receive:order:list |
GET /{outTradeNo} |
订单详情 | module_payment:receive:order:list |
POST /{outTradeNo}/query |
主动向上游查一次 | module_payment:receive:order:list |
不复用 module_payment:leshouying:order:list。理由:能查订单不该自动等于能发起收款。
主扫下单应答:outTradeNo、payTraceNo、cashierUrl、qrImageUrl、amount、expireTime。
不回 codeUrl——理由与 §4.5 一致。
被扫下单:入参 authCode(付款码)+ amount。走既有 LsyOrderService.createBarcodeOrder。
POST /payment/openapi/pay/qrcode/create 的应答:codeUrl 字段换成 cashierUrl。
理由有两条,都需要:
code_url 发出去,商户点开看到的是上游域名,等于暴露上游。code_url 是上游可枚举短链(§4.2),不再外发。这是破坏性变更。成立的前提是用户确认过「乐收赢所有代码都没有上过生产」。落地前要再核一次 LsyPayResultVO.codeUrl 有没有别的消费方。
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(三天,上游文档默认值,也是用户要的),QR_MAX_EXPIRE_MINUTES 保持 4320。
随之必须改的:LsyReconcileScheduler.expireStaleOrders 当前按 absolute-expire-hours = 24 把所有 PENDING(= {CREATED, PAYING},含 PAYING)且创建超 24 小时的订单置 EXPIRED。主扫单在拿到 code_url 后停在 PAYING,正好落在网里。
今天的 15 分钟有效期让两者永远碰不上;改成三天后,第 24 小时本地记 EXPIRED、而上游链接活到第 72 小时。第 25 到 72 小时之间付的款会变成「钱收了、本地账是过期」,而现有的两条补救通道都是死的,属于永久性错账。
规则:主扫单自带收敛链(code_url_expire_time → /v3/qrclose → CLOSED,由 LsyReconcileScheduler.closeExpiredQrOrders 负责),因此把它排除出 24 小时绝对兜底网。
排除条件要精确,不能一刀切掉整个 QRCODE:pay_scene='QRCODE' 且 code_url_expire_time IS NOT NULL 才排除。理由:QRCODE 卡在 CREATED(上游没回 code_url,例如下单直接失败)时没有任何网,它仍然需要绝对兜底。
H5 与被扫的 24 小时不动。
批次一有(§7 一条)。批次二没有:收银台复用 pay_trace_no,订单表列已够用。
新增权限串(§4.4 三个)。菜单与权限的 SQL 由我写好、由用户执行。
⚠ SQL 未执行前,收款接口一律 403。这不是缺陷,是预期;验收顺序上要排在 SQL 之后。
| 键 | 用途 |
|---|---|
leshouying.cashier-base-url |
拼 cashierUrl 用。默认取现有对外域名,与 notify-url 同源 |
不新增:二维码尺寸、颜色、容错级别。
| # | 接口 | 路径 | 用途 |
|---|---|---|---|
| 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;
订单表、门店相关表无 DDL。
按本仓规则:SQL 我写,只有用户执行。
/payment/cashier/** 进白名单时逐条精确匹配,不用 /** 粗放放行——本仓既有过滤器注释专门警告过这个坑。code_url 不外发。任何面向商户或前端的应答里不出现它。channel_id 等一律走配置 + 环境变量,照 application.yml 现有的 ${ENV:default} 写法。顺带记录一条既存观察(本轮不改):leshouying.key 的默认值目前是明文写在被 git 跟踪的 application.yml 里,正式密钥上线前应改为无默认值的环境变量。requireByTenant / requireByEnterprise 分流各一组;enterpriseId 为空与非空两条下单路径。现有 418 个测试必须继续全绿。/status 金额/状态/过期时间正确;页面 HTML 与令牌无关(同一段静态内容,挡的是「以后有人加回服务端替换」)。qr.png:返回 PNG 魔数,且内容能被解码回 code_url(拿 zxing 自己解一遍,别只断言「非空」)。/qrcode/create 应答不含 codeUrl、含 cashierUrl。expireStaleOrders 对 QRCODE + code_url_expire_time 非空 的订单不再置 EXPIRED,对 QRCODE + 无 code_url 与 H5 仍然置 EXPIRED。这条要两向测:该红的红,不该红的绿。/payment/cashier/** 无 token 可达;/payment/collection/receive/** 无 token 不可达。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 是破坏性变更,确认可以改(前提是乐收赢确实没上过生产)。