2026-09-22-receive-workbench-design.md 25 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. 常设约束:验签绝不跳过;文案不暴露上游;开发测试期先用文档给的测试信息把支付与收款生命周期走通,线上必须先进件。

1. 批次划分

一次性做完上面全部内容会把「能立刻验收的东西」和「依赖外部输入才能通电的东西」绑在一起。按可独立验收切成三批:

批次 内容 能否独立验收 阻塞项
一 乐收赢去企业化(租户账号级) 能 无
二 收款工作台:免鉴权收银台页 + 管理端收款接口 + 独立前端工程 能(端到端跑通一笔主扫、一笔被扫) 无
三 商户进件 不能 客户经理凭据 + 上游配置回调地址,见 §6

批次一必须先做:批次二的管理端接口按租户级设计,建立在它之上。 批次三与批次一、二无代码依赖,但它的联调依赖外部输入,不应挡住批次二的发布。


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>

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/**
        │                                          │
        │  (展示二维码图片)                        ├─▶ LsyOrderService(既有)
        ▼                                          │
   收银台页 /payment/cashier/{token}/qr.png ────────┘
        ▲
        │ 免鉴权
        │
   商户(或顾客)浏览器  ◀── cashier_url ── 开放接口 /payment/openapi/pay/qrcode/create

4.2 免鉴权收银台页

端点(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)。风险等级低(猜到了也只能是「替别人付钱」),记录在案,不作为阻塞。

4.3 二维码生成

  • 依赖:新增 com.google.zxing:core。已实测它不在依赖树里,Hutool 的 QrCodeUtil 没有它会在运行时炸。
  • 生成点:只有一处 —— qr.png 端点。
  • 二维码内容:code_url 原文。
  • 尺寸/容错级别:选一个能印能扫的默认值即可,不作配置项(YAGNI)。

前端不生成二维码:新工程因此不需要引入任何二维码 npm 包,<img src=".../qr.png"> 即可。

4.4 管理端收款接口

路径前缀 /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。

4.5 开放接口变更

POST /payment/openapi/pay/qrcode/create 的应答:codeUrl 字段换成 cashierUrl。

理由有两条,都需要:

  1. 商户拿到的应该是我们的链接,点开是我们的收银台页。直接把 code_url 发出去,商户点开看到的是上游域名,等于暴露上游。
  2. 顺带收窄暴露面:code_url 是上游可枚举短链(§4.2),不再外发。

这是破坏性变更。成立的前提是用户确认过「乐收赢所有代码都没有上过生产」。落地前要再核一次 LsyPayResultVO.codeUrl 有没有别的消费方。

4.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 的拦截器同构。
  • 四页:
    1. 登录
    2. 二维码收款(主扫):输入金额 → 生成 → 页面展示二维码 + 金额 + 倒计时,支持保存/打印
    3. 扫描二维码收款(被扫):输入框接扫码枪(键盘模拟输入 + 回车触发)→ 提交 → 展示结果
    4. 订单明细:分页列表 + 详情
  • 构建:base: '/receive/',产物独立;nginx 新增 location /receive。
  • 不作多租户切换 UI:租户从 JWT 里来。

4.7 主扫有效期与状态收敛

改: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 小时不动。


5. 批次二的 DDL / 菜单 / 配置

5.1 会不会有 DDL

批次一有(§7 一条)。批次二没有:收银台复用 pay_trace_no,订单表列已够用。

5.2 权限与菜单

新增权限串(§4.4 三个)。菜单与权限的 SQL 由我写好、由用户执行。

⚠ SQL 未执行前,收款接口一律 403。这不是缺陷,是预期;验收顺序上要排在 SQL 之后。

5.3 新增配置项

键 用途
leshouying.cashier-base-url 拼 cashierUrl 用。默认取现有对外域名,与 notify-url 同源

不新增:二维码尺寸、颜色、容错级别。


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

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 与机构密钥)。图片上传尤其关键——它是创建商户那些资质图片字段取值的唯一来源。

三-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;

订单表、门店相关表无 DDL。

按本仓规则:SQL 我写,只有用户执行。


8. 安全约束

  1. 验签绝不跳过。进件接口同样是签名请求,一样走既有签名逻辑,不开任何旁路。
  2. 收银台页免鉴权,但不免令牌校验。无令牌 / 错令牌 → 明确的「链接无效」页。令牌 32 位随机。
  3. /payment/cashier/** 进白名单时逐条精确匹配,不用 /** 粗放放行——本仓既有过滤器注释专门警告过这个坑。
  4. code_url 不外发。任何面向商户或前端的应答里不出现它。
  5. 文案零上游痕迹:页面、错误消息、应答字段名里不出现上游名称、域名、其专有字段名。
  6. 凭据不进仓库。进件新增的 channel_id 等一律走配置 + 环境变量,照 application.yml 现有的 ${ENV:default} 写法。顺带记录一条既存观察(本轮不改):leshouying.key 的默认值目前是明文写在被 git 跟踪的 application.yml 里,正式密钥上线前应改为无默认值的环境变量。
  7. 秘密只打印长度,不打印值。

9. 测试策略

  • 批次一: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 不可达。
  • 批次三:三-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. 批次顺序:一 → 二 → 三 是否认可?三-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.5 开放接口应答 codeUrl → cashierUrl 是破坏性变更,确认可以改(前提是乐收赢确实没上过生产)。