2026-10-20-unified-pay-channel-design.md 80 KB

汇安融「统一支付平台」接入 设计文档

  • 日期:2026-10-20
  • 状态:设计待评审(本文不含产品代码)
  • 上游文档:统一支付平台接口文档_V1.0.5.docx(用户提供;抽取文本见 logs/_harpay-recon/doc.txt)
  • 上游 SDK:harpay-sdk-java-1.0.1-snapshot.jar(仅作字段与行为参考,不引入依赖)
  • 上游测试页:pay-h5-test.html(含测试凭据,任何内容不得入库或提交)

1. 背景与定位

汇安融「统一支付平台」(下称「统一支付」)是汇安融的另一条收单产品线,与现有的「汇安融通道」(支付宝收单,/payment/har)并列而非替代:

  • 同一家上游,但接口体系不同:MD5 签名、t-cube.huianrong.com/gateway、16 种 wayCode、覆盖微信/支付宝/云闪付。
  • 现有 HAR 通道是支付宝单产品;统一支付是聚合收单,商户配置、订单、退款、回调都是独立一套。
  • 用户明确要求:新开一个菜单,这是另外的产品。

    企业商户/租户 ──①──> 平台(后台收银台 / 开放 API) ──②──> 统一支付网关 ──> 微信/支付宝/云闪付
     ↑                                  │
     └────────④ 下游回调 / ⑤ 查询────────┘(③ 上游通知回流)
    
    ① 平台后台(JWT)或开放 API(API Key + HMAC-SHA256)        [平台已有底座]
    ② 统一支付 API(JSON + MD5(appSecret))                      [本模块新建]
    ③ 统一支付通知(form/JSON,MD5 验签)                         [本模块新建]
    ④ 回调企业商户(form: notify_id/timestamp/content)           [复用平台既有回调格式]
    ⑤ 企业查单/退款查询(开放 API)                                [本模块新建]
    

资金流:平台不碰钱。款项进入企业商户在汇安融侧的商户号,平台只记录订单与退款生命周期,不做余额、提现、对账、资金记账。

2. 范围

2.1 本期做

项 说明
商户配置 每个租户可配一条或多条上游商户(mchNo + appId + appSecret),超管写入、租户只读
签名与验签 UnifiedSigner:请求签名、响应 data 验签、通知全参数验签,纯函数可单测
上游客户端 UnifiedClient:JSON 报文、超时、响应解析、错误分类
下单 开放 API + 后台收银台;16 种 wayCode 全部可透传
查单 开放 API、后台详情、收银台轮询、定时补偿
退款 开放 API + 后台订单页;支持全额与部分退款;退款查询(SDK 证据)
获取渠道用户 ID 服务端生成带签名的跳转 URL,调用方把浏览器 302 过去
上游通知 两个免登录入口(订单/退款),验签、幂等、状态机、转下游回调
下游回调 沿用平台现有 form 格式;地址三级解析;失败最多投递 3 次(含首次)
后台页面 商户配置、订单管理、收银台(主扫/被扫/H5)
开放文档 接入文档新增「统一支付收单」组;左侧分组可收起/展开,默认全部收起
菜单权限 db/unified-menu.sql 幂等种子,role 1 与 role 2 均授权(明细见 §9.5)
数据库迁移 V1.9__create_pay_unified_tables.sql,三张新表

2.2 本期不做

项 原因
SDK 的分账、转账、提现、码牌解析 用户已裁定不在本产品范围
商户进件 上游是人工流程,平台只做配置录入
对账任务、资金记账、余额 平台不碰钱,与 HAR 通道同一口径
通用渠道抽象层 当前只有一个上游,抽象形状无依据
16 种方式的后台收银台 UI 收银台只做「主扫 / 被扫 / H5」;JSAPI、APP、小程序等由开放 API 调用方自行拉起
appSecret 加密存储 平台无 KMS;本仓既有 open_conf.encrypt_key 也是明文。见 §11 风险
下游回调签名 沿用平台现有 notify_id/timestamp/content 格式,不新增签名。见 §11 风险

3. 命名与目录(跨任务契约)

项 取值
顶层菜单 标题「统一支付平台」,route_name='payment-unified',route_path='/payment/unified',icon='el-icon-Wallet'
页面 商户配置 /payment/unified/merchant;订单管理 /payment/unified/order;收银台 /payment/unified/cashier
页面 route_name payment-unified-merchant / payment-unified-order / payment-unified-cashier(与 SFC defineOptions({ name }) 逐字一致)
后端包 com.payment.platform.module.payment.unified
表 pay_unified_merchant / pay_unified_order / pay_unified_refund
权限串前缀 module_payment:unified:
开放 API 前缀 /payment/openapi/unified(由 TenantApiKeyAuthFilter 认证)
上游通知 POST /payment/notify/unified/order、POST /payment/notify/unified/refund(免登录)
订单号 UP + 雪花 ID(雪花 ID 全局唯一,作为上游 mchOrderNo)
退款号 UR + 雪花 ID(作为上游 mchRefundNo)
事件通道 unified_order / unified_refund
开放文档组 侧边栏「统一支付收单」,正文 docs/unified-openapi.md,切片 unifiedDoc.ts
SQL db/migration/V1.9__create_pay_unified_tables.sql、db/unified-menu.sql

菜单 icon 必须写成 el-icon-Wallet。MenuItemContent.vue 只在 icon.startsWith("el-icon") 时渲染 Element Plus 图标,否则按 i-svg:<name> 找 SVG;Wallet 是 @element-plus/icons-vue 的实际导出(本机 node_modules 已核)。

4. 上游协议与判读

4.1 接口清单

接口 方法 路径(相对 base-url) 证据
统一下单 POST /har-pay/api/pay/unifiedOrder 文档 §二 + SDK PayOrderCreateRequest
查询订单 POST /har-pay/api/pay/query 文档 §三 + SDK PayOrderQueryRequest
统一退款 POST /har-pay/api/refund/refundOrder 文档 §四 + SDK RefundOrderCreateRequest
退款查询 POST /har-pay/api/refund/query 仅 SDK RefundOrderQueryRequest(文档未写)
获取渠道用户 ID GET /har-pay/api/channelUserId/jump 文档 §五(浏览器 302)

base-url 默认 https://t-cube.huianrong.com/gateway。

4.2 请求与签名

  • 报文:JSON(文档允许 JSON 或 form;测试页与 SDK 均按 JSON 使用)。UTF-8。
  • 公共字段:mchNo、appId、reqTime、version='1.0'、signType='MD5'、sign。
  • 签名算法(文档 §一,与 SDK SignUtil.getSign 逐行一致):
    1. 取所有非空参数(sign 不参与);
    2. 按参数名排序,拼成 k1=v1&k2=v2&...;
    3. 末尾追加 &key=<appSecret>;
    4. MD5 后转大写。
  • 排序规则冲突:文档写「ASCII 码从小到大」,SDK 与测试页用 String.CASE_INSENSITIVE_ORDER。对真实字段集(amount/appId/body/channelExtra/clientIp/currency/extParam/mchNo/mchOrderNo/notifyUrl/reqTime/returnUrl/signType/subject/version/wayCode)两种排序结果相同;实现按文档 ASCII,并加一条「两序一致」单测。出现新字段时该测试会先红。
  • reqTime 三种写法:
    • 文档字段表:13 位毫秒时间戳(1622016572190);
    • 文档请求示例:10 位秒时间戳(1624005107);
    • 测试页(上游提供、可运行):14 位 yyyyMMddHHmmss(pay-h5-test.html:124/164/206,格式化函数在 293-296)。 ⇒ 默认按测试页发 14 位紧凑格式,用 unified.pay.req-time-format 可在 compact14 | epoch13 | epoch10 间切换,联调时用读数定案。
  • 金额:上游 amount / refundAmount 单位是分(int)。本模块全链路以「分」存储与传输,前端负责元↔分换算。
  • channelExtra:是 JSON 字符串,不是对象。按 wayCode 组装,见 §4.6。

4.3 响应

外层:code(int)、msg、sign、data(文档写 String,示例是对象;两者都接受)。

  • 成功码:文档与 SDK 要求 code == 0;测试页判 res.code !== 200 为失败(pay-h5-test.html:246)。⇒ 客户端同时接受 0 与 200,联调记录实际值;其他值按失败抛 UnifiedApiException(code, msg)。
  • 响应验签:sign 只覆盖 data。按同一算法对 data 的顶层键值计算;值为嵌套对象/数组时按紧凑 JSON 序列化后参与签名(SDK 的 SignUtil 对 fastjson.JSONObject 取 toString(),即 JSON 文本)。data 为空且 sign 为空时跳过验签(SDK HarpayResponse#checkSign 实测:这种形态直接返回 true —— 典型是错误响应);其余情况验签失败抛异常,不静默接受。
  • data 形态:下单用 orderState,查单用 state,回调可能两者都带。解析时 state 优先、orderState 兜底;只有 0/1/2/3/4/5/6 视为有效状态。
  • channelRetMsg:测试页渲染了 data.channelRetMsg.channelErrCode/channelErrMsg(pay-h5-test.html:254),但文档 data 表没有这个字段。按可选嵌套对象解析,存入 upstream_data_json 供排障,业务逻辑不依赖它。
  • payDataType:文档写 payUrl,规则 7 与测试页用 payurl。比较一律忽略大小写;已知值:payUrl/payurl、form、wxapp、aliapp、ysfapp、codeUrl、codeImgUrl、none。

4.4 通知

  • 入口:上游把通知推到我们下单/退款时传的 notifyUrl(我们自己的地址,见 §6.1)。
  • 报文体:参考实现 NotifyController 用 request.getParameterMap(),即 form-urlencoded;本模块同时接受 JSON(按 Content-Type 分流)。
  • 参数构成(用户原话):我们上送的内容 + 查单接口返回的 data 内容。因此通知里会同时出现 mchOrderNo、payOrderId、state,也可能有 orderState、channelExtra、notifyUrl 等我们上送的字段。
  • 验签:所有参数(sign 除外)按同一算法计算;空值不参与。参考实现 NotifyController.checkNotifySign 正是这样做的。
  • 多值参数:参考实现 SignUtil.convert 对多值会放数组,String.valueOf 得到 JVM 身份串,无法复现。真实通知是单值;本模块对多值参数取第一个值并打警告日志。
  • 应答:参考实现返回字符串 "SUCCESS"。本模块:成功/重复 → HTTP 200 + SUCCESS;验签失败 → HTTP 401 + FAIL;处理异常 → HTTP 500 + FAIL。上游对非 SUCCESS 的重试窗口未知,按「会重试」设计(主动轮询是主路径,通知只是快路径,与 HAR 通道同一原则)。
  • 安全:通知参数里的 channelExtra 可能含付款码/openid;不得写入日志、不得落库、不得转发下游。见 §11。

4.5 状态枚举

订单(orderState / state):

值 含义 本地是否终态
0 订单生成 否
1 支付中 否
2 支付成功 否(可转 5)
3 支付失败 是
4 已撤销 是
5 已退款 是
6 订单关闭 是

退款(state):

值 含义 本地是否终态
0 退款生成 否
1 退款中 否
2 退款成功 是
3 退款失败 是
4 退款关闭 是

状态直接采用上游枚举,不做本地转义;后台页面与文档只做展示映射。

4.6 wayCode 与 channelExtra

wayCode 支付方式 channelExtra 规则
QR_CASHIER 聚合扫码(用户扫商家) 可选 payDataType=codeUrl/codeImgUrl(默认 codeUrl)
AUTO_BAR 聚合条码(商家扫用户) 必传付款码,键名见下方冲突
ALI_BAR 支付宝条码 同上
ALI_JSAPI 支付宝生活号 必传 buyerUserId
ALI_APP 支付宝 APP 无额外参数
ALI_WAP 支付宝 WAP 可选 payDataType=form/codeImgUrl/payUrl(默认 payUrl)
ALI_PC 支付宝 PC 网站 可选 payDataType=form/payUrl(默认 payUrl)
ALI_QR 支付宝二维码 可选 payDataType=codeUrl/codeImgUrl
WX_BAR 微信条码 必传付款码
WX_JSAPI 微信公众号 必传 openid;服务商模式另传 wxAppId
WX_LITE 微信小程序 同上
WX_APP 微信 APP 无额外参数
WX_H5 微信 H5 无必传;测试页在已取得渠道用户 ID 时可选传 openid。返回 orderState=0、payDataType=payurl,payData 为承接页地址
WX_NATIVE 微信扫码 可选 payDataType=codeUrl/codeImgUrl
YSF_BAR 云闪付条码 必传付款码
YSF_JSAPI 云闪付 JSAPI 文档未展开,按通用 JSAPI 透传

付款码键名冲突:文档「channelExtra 参数说明」写 auth_code,文档「请求示例」写 authCode。本模块对外(开放 API)两个都接受,转上游时按 unified.pay.auth-code-key 发一个:默认 auth_code,可切 authCode 或 both。联调实测后定案(§14 #4)。

4.7 获取渠道用户 ID

  • 上游是 GET + 浏览器 302,参数要拼在 URL 上并签名。appSecret 绝不能进浏览器。
  • 因此平台不把上游 URL 直接交给浏览器,而是提供一个已认证的服务端端点 POST /payment/openapi/unified/channel-user/jump-url:调用方用自己的 API Key 换一条已签名的上游 URL,再把用户浏览器 302 过去。
  • ifCode:fuioupay(服务商模式,商户无需自有公众号)/ wxpay(自有公众号)/ AUTO。默认取商户配置的 channel_user_if_code(默认 fuioupay)。
  • 上游请求字段(camelCase):version、signType、reqTime、mchNo、appId、ifCode、redirectUrl、extParam、sign。回跳参数:channelUserId、channelAppId、appId、extParam。下单时 WX_JSAPI/WX_LITE 要把 channelUserId 与 channelAppId 分别放进 channelExtra.openid 与 channelExtra.wxAppId;ALI_JSAPI 放进 buyerUserId。

4.8 文档自身的不一致(逐条记录)

# 冲突 处理
1 签名示例结果算不出来:按文档给的待签名串 MD5 大写应为 84E1CA56F984502BBAC06EA6707157F5,文档写 4A5078DABBCE0D9C4E7668DACB96FF7A 不得把文档值当测试向量;用自造向量单测,联调用真实响应验签
2 成功码 0(文档/SDK)vs 200(测试页) 客户端同时接受,记录实测值
3 reqTime 13 位毫秒 / 10 位秒 / 14 位紧凑三种 默认 14 位,配置可切
4 付款码键 auth_code vs authCode 对外都收,转上游按键名配置,默认 auth_code
5 payDataType payUrl vs payurl 忽略大小写比较
6 下单 orderState vs 查单 state 两个都解析,state 优先
7 退款查询接口文档没写,SDK 有 按 SDK 模型实现,联调验证
8 data 字段表写 String,示例是对象 两种都接受
9 channelRetMsg 文档没写,测试页在用 宽容解析,不做业务依赖

结论:这份文档不能逐字相信。以联调读数为准,所有不确定点都做成可配置或双路兼容,并在日志里留证据。

5. 数据模型

三张表,Postgres,主键为雪花 ID(bigint),公共列沿用 PaymentBaseEntity / PaymentTenantBaseEntity 约定。status 是行状态('0' 正常),不是业务状态;业务状态在 trade_status / state。

5.1 pay_unified_merchant — 租户 ↔ 上游商户配置

列 类型 说明
id bigint PK 雪花 ID
tenant_id bigint NOT NULL 归属租户
mch_no varchar(64) NOT NULL 上游商户号,全局唯一(通知只带 mchNo,必须能唯一定位租户)
app_id varchar(64) NOT NULL 上游应用 ID
app_secret varchar(128) NOT NULL MD5 私钥。永不返回前端、永不进日志
merchant_name varchar(128) 显示名
channel_user_if_code varchar(16) NOT NULL DEFAULT 'fuioupay' 渠道用户 ID 跳转的 ifCode
status varchar(10) NOT NULL DEFAULT '0' 0 启用 / 1 停用
description text 备注
created_time / updated_time timestamp
version int NOT NULL DEFAULT 0 乐观锁

索引/约束:

  • UNIQUE (mch_no)(uk_unified_merchant_mch_no)
  • INDEX (tenant_id)(列表主路径)

5.2 pay_unified_order — 支付订单

列 类型 说明
id bigint PK 雪花 ID
tenant_id bigint NOT NULL 归属租户
merchant_id bigint NOT NULL pay_unified_merchant.id
mch_no / app_id varchar(64) NOT NULL 下单时快照(配置变更不影响历史单)
order_no varchar(64) NOT NULL 我方单号,上游 mchOrderNo,全局唯一
out_trade_no varchar(64) 调用方单号;开放 API 幂等键
api_key varchar(128) 开放 API 调用方 Key(回调取址用);收银台为空
source varchar(16) NOT NULL DEFAULT 'OPENAPI' OPENAPI / CASHIER(后台只有收银台能建单,不设 ADMIN)
way_code varchar(16) NOT NULL 见 §4.6
amount bigint NOT NULL 金额,分
refunded_amount bigint NOT NULL DEFAULT 0 已成功退款金额(分),由退款表重算
currency varchar(3) NOT NULL DEFAULT 'cny' 本期只支持 cny
client_ip varchar(64) 上游 clientIp
subject varchar(128) NOT NULL 商品标题
body varchar(512) 商品描述
callback_url varchar(512) 下游回调地址快照(不是上游 notifyUrl)
ext_param varchar(512) 上游扩展参数,原样返回
channel_extra_keys varchar(128) channelExtra 的键名,值一律不落库(如 auth_code,payDataType)
upstream_pay_order_id varchar(64) 上游 payOrderId
upstream_channel_order_no varchar(64) 上游渠道订单号 channelOrderNo
pay_data_type varchar(32) payDataType 原值
pay_data text payData 原值(二维码 URL / 表单 / 跳转链接)
trade_status varchar(8) NOT NULL DEFAULT '0' 上游订单状态 0–6
err_code / err_msg varchar(64) / varchar(512) 上游错误码 / 描述
pay_time timestamp 上游 successTime
upstream_created_at timestamp 上游 createdAt
upstream_data_json text 最近一次上游 data/通知参数的脱敏快照(仅排障,字段白名单见 §7.5)
last_query_time / next_query_time timestamp 轮询节流
query_count int NOT NULL DEFAULT 0 轮询次数
notify_status varchar(16) NOT NULL DEFAULT 'PENDING' PENDING / SUCCESS / FAILED
notify_count int NOT NULL DEFAULT 0 下游回调尝试次数(含首次)
last_notify_time timestamp
status / description / created_time / updated_time 基类公共列(PaymentBaseEntity);version 是本模块实体显式加,不在基类

索引/约束:

  • UNIQUE (order_no)(uk_unified_order_order_no)
  • UNIQUE (tenant_id, out_trade_no) WHERE out_trade_no IS NOT NULL(幂等;收银台单可为空)
  • UNIQUE (upstream_pay_order_id) WHERE upstream_pay_order_id IS NOT NULL(防两笔本地单抢同一上游单)
  • INDEX (mch_no, created_time DESC)(后台列表)
  • INDEX (trade_status, next_query_time)(轮询热路径)
  • INDEX (notify_status, last_notify_time) WHERE notify_status = 'FAILED'(回调重试热路径)

5.3 pay_unified_refund — 退款

列 类型 说明
id bigint PK 雪花 ID
tenant_id bigint NOT NULL 归属租户
order_id bigint NOT NULL pay_unified_order.id
order_no varchar(64) NOT NULL 冗余,便于查询
refund_no varchar(64) NOT NULL 我方退款号,上游 mchRefundNo,全局唯一
out_refund_no varchar(64) 调用方退款号;幂等键
mch_no / app_id varchar(64) NOT NULL 下单快照的同一份
api_key varchar(128) 开放 API 调用方 Key
source varchar(16) NOT NULL DEFAULT 'OPENAPI' OPENAPI / ADMIN(后台订单页发起退款);收银台没有退款入口
amount bigint NOT NULL 退款金额,分
currency varchar(3) NOT NULL DEFAULT 'cny'
refund_reason varchar(64) NOT NULL 上游要求非空
callback_url varchar(512) 本笔退款专属下游回调地址快照
ext_param varchar(512)
upstream_refund_order_id varchar(64) 上游 refundOrderId
upstream_channel_order_no varchar(64) 上游 channelOrderNo
state varchar(8) NOT NULL DEFAULT '0' 0–4
err_code / err_msg varchar(64) / varchar(512)
success_time timestamp 上游 successTime
upstream_created_at timestamp 上游 createdAt
upstream_data_json text 脱敏快照
last_query_time / next_query_time / query_count 轮询
notify_status / notify_count / last_notify_time 下游回调
status / description / created_time / updated_time 基类公共列(PaymentBaseEntity);version 是本模块实体显式加,不在基类

索引/约束:

  • UNIQUE (refund_no)(uk_unified_refund_refund_no)
  • UNIQUE (tenant_id, out_refund_no) WHERE out_refund_no IS NOT NULL
  • UNIQUE (upstream_refund_order_id) WHERE upstream_refund_order_id IS NOT NULL
  • INDEX (order_no)(订单详情)
  • INDEX (state, next_query_time)(轮询)
  • INDEX (notify_status, last_notify_time) WHERE notify_status = 'FAILED'

5.4 租户隔离登记(必须做)

三张表都要加入 TenantInnerInterceptor.CONDITIONAL_TABLES。原因:

  • 上游通知、定时轮询、下游回调重试都在无认证上下文里跑;漏登记 ⇒ 被追加 AND tenant_id = 0 ⇒ 按 mch_no/order_no 查恒为空(pay_batch_authorize 踩过)。
  • 条件表在无认证时跳过租户过滤,所以这些路径的点查必须只用全局唯一键读:mch_no、order_no、refund_no、upstream_pay_order_id、upstream_refund_order_id;禁止用 selectOne / LIMIT 1 按非全局唯一键静默挑一行(无认证查询不带租户条件,静默挑中的那一行可能是别的租户的)。
  • 批量扫描是另一类:轮询按 trade_status IN ...、回调重试按 notify_status='FAILED' 扫一批,允许 selectList —— 它的语义就是「扫一批」,不是「按外来键定位一行」,后续更新按行 id 或全局唯一键执行。不要把上面那条点查禁令误读成「未认证路径不许有 selectList」。

5.5 金额单位

本模块统一用「分」(上游契约)。后台输入框以「元」展示,提交前 Math.round(yuan * 100);展示时 fen / 100 保留两位。开放 API 文档明确写「单位分」,不与 HAR 通道的「元」混用。

时间:上游 successTime/createdAt 是 epoch 毫秒,转 OffsetDateTime 一律用 Asia/Shanghai(平台既有口径),空值/0 不写库。不要用 JVM 默认时区。

6. 后端模块结构

module/payment/unified/
├── config/       UnifiedPayProperties           baseUrl / notifyBaseUrl / 超时 / reqTime 格式 / cb 上限
├── client/       UnifiedSigner                  请求签名、响应验签、通知验签(纯函数)
│                 UnifiedClient                  JSON 组装 + 发送 + 解析 + 错误分类
│                 UnifiedApiException            上游错误(code/msg)
│                 UnifiedResponseParser          data 对象/字符串双路解析
├── controller/   UnifiedMerchantController      商户配置(JWT)
│                 UnifiedOrderController         订单管理(JWT)
│                 UnifiedCashierController       收银台(JWT)
│                 UnifiedNotifyController        上游通知(免登录)
│                 open/UnifiedOpenController     开放 API(API Key)
├── dto/          req/ resp/ query/ vo/
├── entity/       UnifiedMerchantEntity / UnifiedOrderEntity / UnifiedRefundEntity
├── enums/        UnifiedOrderStatus / UnifiedRefundStatus / UnifiedNotifyStatus / UnifiedSource
├── mapper/       UnifiedMerchantMapper / UnifiedOrderMapper / UnifiedRefundMapper
├── service/      UnifiedMerchantService
│                 UnifiedOrderService
│                 UnifiedRefundService
│                 UnifiedCashierService
│                 UnifiedNotifyService
│                 UnifiedCallbackService
│                 UnifiedChannelUserService
├── scheduler/    UnifiedOrderPollScheduler
│                 UnifiedRefundPollScheduler
│                 UnifiedCallbackRetryScheduler
└── util/         UnifiedLogSanitizer
                  UnifiedTenantGuard            非超管必须有 tenant_id(后台端点共用)

6.1 配置

@ConfigurationProperties(prefix = "unified.pay"),全部带代码默认值,不改 application.yml(含凭据,禁止提交):

属性 默认值 说明
base-url https://t-cube.huianrong.com/gateway 上游网关
notify-base-url https://qcsj88888.com/api/v1 我们给上游的回调基址(含 context-path)。与 HAR 通道通知基址一致(HarOrderService 固定拼 https://qcsj88888.com/api/v1/payment/notify/har)
connect-timeout-ms 3000
read-timeout-ms 10000
callback-timeout-ms 5000 下游回调
req-time-format compact14 compact14 / epoch13 / epoch10
auth-code-key auth_code auth_code / authCode / both
callback-max-retries 3 含首次
callback-pool-size 4 下游回调线程池
poll-batch 50 每轮扫描上限
order-poll-interval-ms 10000
refund-poll-interval-ms 30000
callback-retry-interval-ms 60000

启动时 INFO 打印 notify-base-url(不含密钥),便于部署核对公网可达性。

6.2 客户端

  • 使用 JDK java.net.http.HttpClient,显式 connect/read 超时;不复用共享 RestTemplate(WebMvcConfig 里那个故意没有超时)。
  • 上游报文用独立的普通 ObjectMapper(camelCase)。全局 JacksonConfig 是 SNAKE_CASE,绝不能用于上游。
  • 请求体用 LinkedHashMap 组装;amount 是数字,channelExtra 是字符串。
  • 只发已知字段:测试页注释实测「顶层传未知字段会导致验签失败」(pay-h5-test.html:216)。调用方请求里多出来的字段(如 notify_url、out_trade_no、自定义字段)一律不透传上游;上游请求模型字段逐字见 §4.1 各接口的 SDK 模型(PayOrderCreateReqModel / RefundOrderCreateReqModel 等)。
  • 不打印请求/响应报文:UnifiedClient 绝不整包 log.info/log.debug(报文含 channelExtra、sign);错误日志只打接口名、上游 code/msg、mchOrderNo/mchRefundNo。确需排障时先过 SensitivePayloadRedactor。加一条日志捕获测试:用含 auth_code/openid 的请求跑一遍,断言这些值不出现在日志里。
  • 不引入 SDK:SDK 是 SNAPSHOT、依赖 fastjson/logback、请求模型不含 reqTime/sign,无法直接完成签名。SDK 只用于核对字段名与路径。
  • UnifiedSigner 与请求体构造函数做成包内可见的纯函数,单测不启动 Spring。

7. 关键链路

7.1 商户配置

  • 列表 GET /payment/unified/merchant:权限串 module_payment:unified:merchant:list。租户看到自己那几行(TenantInnerInterceptor 过滤);超管看到全部。列表 VO 带 tenant_id(前端据此显示租户名),但不带 app_secret。
  • 保存 POST /payment/unified/merchant:仅超管 @perm.hasRole('SUPERUSER')。
    • 新增必须带 tenant_id(替哪个租户建号);修改忽略 tenant_id(归属不可改)。
    • mch_no 全局唯一;重复返回 400。
    • app_secret 新增必填;修改留空 = 不修改。
    • 返回 VO 不含 app_secret,只给 app_secret_set: true/false。
  • 删除 DELETE /payment/unified/merchant/{id}:仅超管;该商户已有订单/退款时返回 400「已有交易,不能删除,请停用」,避免历史单失去配置。
  • 租户不能自助建号:mch_no 全局唯一,放开等于允许抢注别人已报备的商户号(与 HAR 同一理由)。
  • 停用(status='1')必须有强制点:新建订单(开放 API + 收银台)、发起退款、生成渠道用户跳转 URL 都要求配置 status='0',否则 400「商户已停用」。上游通知、轮询、回调重试不看 status —— 历史单还要继续收敛,它们按订单快照里的 mch_no/全局唯一键定位配置。

7.2 下单

开放 API / 收银台
  → 解析租户(API Key 或 JWT)+ 选配置(mch_no;只有一条时可省)
  → 校验 wayCode / channelExtra / 金额
  → 按 (tenant_id, out_trade_no) 幂等检查
  → 生成 order_no = "UP" + 雪花
  → 【事务 A】落本地订单(trade_status='0')并提交   ← 必须先提交,通知才找得到
  → 组装上游报文(JSON + MD5)
  → 调上游 unifiedOrder(无事务)
  → 【事务 B】按响应回填 payOrderId/orderState/payData/errCode
  → 发布 unified_order 事件(仅状态真的变了)
  → 返回 VO

要点:

  • 先落库再调上游:上游通知可能在 HTTP 响应返回前就到;订单必须已提交。
  • 上游 notifyUrl 永远是我们自己的:{notify-base-url}/payment/notify/unified/order。调用方的 notify_url 只存 callback_url,绝不透传给上游。
  • 幂等:同一 out_trade_no 重复提交且关键字段(金额、wayCode、标题)一致 → 返回已有订单;不一致 → 409「out_trade_no 已存在且参数不一致」。开放 API 强制要求 out_trade_no(缺失 400):没有幂等键,一次读超时重试就会变成两笔单、两条支付链接。收银台由前端每次生成 C + 时间戳 + 随机数的 out_trade_no,双击不会产生两笔。
  • 上游失败/超时:本地订单保留在 trade_status='0',next_query_time 设为 15 秒后,返回业务错误码 500 + 一句人话(平台惯例 BusinessException ⇒ HTTP 200 + body code=500,不是真 HTTP 500),并明确「本地订单已保留,可能仍会成功,请用 out_trade_no 查询」。不要删单或当作未发生(HAR 通道同口径)。
  • 创建响应即终态:被扫可能直接返回 orderState=2/3;按状态机落库,2 则安排下游回调。
  • channelExtra 不落库:只把键名写 channel_extra_keys,值丢弃。付款码/openid 只在内存里活一次请求。
  • card 金额:amount > 0;currency 只接受 cny(大小写不敏感)。
  • expiredTime:开放 API 默认 7200 秒;收银台固定 300 秒(避免过期码)。上游允许范围未写,超范围由上游报错。
  • body:开放 API 可选,空则用 subject 顶上(上游要求非空)。

7.3 查询与轮询

  • queryOrder:用我方 order_no 作为 mchOrderNo 查上游;响应 data.state 回写。
  • 查单落成功同样核对金额/币种:state=2/5 时按 §7.5 同一规则比对 amount/currency;不符 → 不推进状态、写 err_code='AMOUNT_MISMATCH'、记 ERROR。通知和查单两条腿的钱安全规则必须一致。
  • 回写一律用条件 UPDATE(WHERE order_no = ? AND trade_status IN (允许的前态)),检查影响行数;0 行说明状态已变,不覆盖、不发事件。禁止 updateById + 乐观锁的静默丢写(HAR 已踩过)。
  • 终态不可回退:3/4/6 不再接受 0/1/2;5 不再接受 2;2 只能转 5。
  • 定时器 UnifiedOrderPollScheduler:@Scheduled(fixedDelayString = "${unified.pay.order-poll-interval-ms:10000}")
    • 扫描 trade_status IN ('0','1') AND next_query_time <= now() AND created_time > now() - interval '30 minutes' AND query_count < 60,按 next_query_time 升序,LIMIT 50;
    • 退避:5s → 10s → 20s → 30s → 60s → 120s → 300s 封顶;
    • 到 30 分钟或 60 次上限仍非终态:next_query_time = NULL,页面显示「轮询已停止,可手动同步」,不擅自改状态;
    • 单实例假设(平台现有部署形态);多实例时需分布式锁,本期不做。
  • 后台「同步」POST /payment/unified/order/{id}/sync:同步该订单 + 该订单所有非终态退款;权限 :sync。

7.4 退款

开放 API / 后台订单页
  → 解析订单(租户内)
  → 【事务 A】SELECT 订单 FOR UPDATE
       trade_status != '2' → 400(未支付成功不能退)
       可退 = amount - SUM(refund.amount WHERE state IN ('0','1','2'))
       refund_amount > 可退 → 400
       INSERT refund(state='0')并提交
  → 组装上游 refundOrder(mchRefundNo=refund_no)
  → 调上游(无事务)
  → 明确业务失败(code != 0)→ refund 置 3、释放额度
  → 超时/网络异常 → 保持 0,等退款查询收敛
  → 【事务 B】按响应回填 refundOrderId/state
  → state='2' 时走统一的 applyRefundState(重算订单、可能置 5、发事件)

要点:

  • 退款前置条件:只有 trade_status='2' 且 可退 = amount - SUM(refund.amount WHERE state IN ('0','1','2')) > 0 才能发起。0/1(未支付/支付中)、3/4/6(失败/撤销/关闭)一律 400「订单未支付成功,不能退款」;5(已全退)自然没有可退额度 → 400「已无可退金额」。不许对没收到钱的单发退款。
  • 在途退款占额度:state IN ('0','1','2') 都算已占用。这样并发两笔退款不会把总额退超;代价是「上游没受理」的在途单会短暂锁住额度。
  • 部分退款:refund_amount 可小于订单金额;成功后订单保持 2,直到 refunded_amount >= amount 才置 5。
  • 全额退款:refunded_amount >= amount ⇒ 订单 trade_status='5'。
  • 上游同步业务失败(code != 0 且不是超时/网络异常):这是「上游明确拒绝」,退款行直接置 state='3' 并记 err_code/err_msg,额度立即释放;不要留在 0 等轮询 —— 让一个已确定失败的单锁 30 分钟额度没有意义。超时/网络异常才留在 state='0' 等查单收敛。
  • 退款成功的统一收敛:新增 applyRefundState(refund),创建响应、上游通知、退款轮询、后台手动同步四条腿都只调它:
    1. 条件 UPDATE 退款行(状态机守卫);
    2. SELECT 订单 FOR UPDATE;
    3. 若订单是 0/1:说明先前丢了支付成功通知,先把订单置 2(钱确实到账了)再继续 —— 若本次报文带 payAmount,先核对订单 amount,不符则记 ERROR、不改订单;若订单是 3/4/6:不改订单,记 ERROR 留人工对账;若订单是 2/5:继续;
    4. 用退款表重算 refunded_amount;>= amount 则订单置 5;
    5. 按 §7.6 矩阵发事件。 只在创建路径重算是错的:异步退款(绝大多数)会让订单永远停在 2、refunded_amount=0。
  • 状态机:退款 2/3/4 是终态;重复通知不改状态、不再推下游。
  • 在途单收敛:退款轮询 state IN ('0','1')。若上游明确返回「退款单不存在」的错误码(联调确认后)且 query_count 达到阈值,置 3 并释放额度;在确认之前保持 0,宁可锁额度也不冒超退风险。
  • 退款行的租户:tenant_id 取订单行的 tenant_id,显式 setTenantId(order.getTenantId())。不能依赖 AutoFillMetaObjectHandler:超管在订单页发起退款时当前用户没有租户,AutoFill 会填 null(或填错),而退款必须跟订单同租户。
  • 退款号:refund_no = "UR" + 雪花,作为 mchRefundNo。out_refund_no 必填(开放 API 缺失 400;后台由退款弹窗生成)。同一 out_refund_no 重复提交且订单/金额一致 → 返回已有退款;不一致 → 409。后台退款也由前端在打开弹窗时生成一次 out_refund_no(R + 时间戳 + 随机数),提交期间禁用按钮,失败重试复用同一个号 —— 不能只靠按钮禁用,双击/重放仍要落到幂等键上。
  • 退款原因:上游必填,后台/开放 API 都要求非空;默认「商户申请退款」仅用于后台未填时的兜底(与 HAR 同口径)。
  • 上游 notifyUrl 固定为我们自己的 {notify-base-url}/payment/notify/unified/refund;调用方的 notify_url 只进 callback_url,不透传上游。
  • 退款轮询 UnifiedRefundPollScheduler:30 秒,同样的退避;同样以「创建后 30 分钟或 60 次」为上限,到上限置 next_query_time = NULL。

7.5 上游通知

POST /payment/notify/unified/{order|refund}   (免登录)
  → 读参数(form 优先,JSON 兼容;上限 64KB)
  → 取 mchNo,按全局唯一键查商户配置(无认证,条件表跳过租户过滤)
  → 用该配置的 appSecret 验签(全部参数,sign 除外)
  → 校验 appId、单号归属(订单/退款行的 mch_no 与 tenant)、金额/币种
  → 端点即分派:`/order` 只走订单(payOrderId/mchOrderNo),`/refund` 只走退款(refundOrderId/mchRefundNo)
  → 条件 UPDATE(状态机守卫)
  → 状态真的变了才发事件 / 推下游
  → 返回 "SUCCESS"
  • 验签失败:HTTP 401 + FAIL。日志字段:收到的 sign 前 8 位(绝不打印期望签名 —— 那等于把一条合法请求写给看日志的人)、mchNo 的 SHA-256 前 12 位或过 UnifiedLogSanitizer.flatten 的值、参数键名(键名也过 sanitizer,防 CWE-117 换行注入)。不打任何参数值(含 channelExtra)。
  • 未知单号:验签已过,但本地没有对应订单/退款 → 记 ERROR(含单号),返回 SUCCESS。理由:没有本地行可更新,让上游重试也永远不会成功;用主动查单或人工核对兜底。这是设计裁量。
  • mchNo 不匹配:视为伪造/配置错,401 + FAIL。appId 若通知里带了则必须匹配;未带只记 WARN(通知参数构成尚未完全证实,见 §14 #15),不因此拒收。
  • 关联绑定(防跨商户伪造):验签只证明「某条配置的持有者签了这份报文」,不证明报文里的单号属于他。更新前必须再校验:订单行的 mch_no == 报文 mchNo,且订单行 tenant_id == 该商户配置的 tenant_id;退款同样校验退款行 mch_no/tenant_id(并可回查订单行)。任一条不符 → 401 + FAIL,不更新。
  • 金额与币种核对(钱安全):state/orderState=2/5 或退款 state=2 时,必须比对报文 amount(退款比 payAmount/refundAmount)与本地订单 amount、currency 与本地 currency(大小写不敏感)。不一致 → 不落成功:保持原状态、写 err_code='AMOUNT_MISMATCH'、记 ERROR(含单号与两个金额)、返回 FAIL。绝不把一笔少付的单按本地金额标成已支付。
  • 幂等:同一状态重复通知 → 返回 SUCCESS,不更新、不推下游。
  • 脱敏快照:upstream_data_json 只存白名单字段(payOrderId/mchOrderNo/state/orderState/amount/currency/wayCode/ifCode/channelOrderNo/successTime/createdAt/refundOrderId/mchRefundNo/refundAmount/payAmount/extParam/errCode/errMsg),永不存 channelExtra、openid、buyerUserId、authCode、sign。快照截断到 8KB,避免异常大报文把行撑爆。
  • 回调参数里的 notifyUrl 是我们自己的地址,不落库、不转发。
  • 通知入口不进 sys_log:OperationLogInterceptor 跳过 /payment/notify/**(见 §11)。

7.6 下游回调

沿用平台现有 form 字段(notify_id / timestamp / content,与 HarCallbackService 一致);content 的结构按本产品定义(HAR 是扁平业务字段,这里用 event/order/refund/upstream 分层,便于区分订单与退款)。form POST:

字段 值
notify_id 确定性:uo_<order_no>_<trade_status> 或 ur_<refund_no>_<state>。同一事件重试保持同一个 id,接收方可直接去重
timestamp 当前毫秒时间戳
content JSON 字符串,见下

content:

{
  "event": "ORDER",
  "order": {
    "order_no": "UP...",
    "out_trade_no": "C...",
    "mch_no": "M...",
    "way_code": "QR_CASHIER",
    "amount": 100,
    "refunded_amount": 0,
    "refundable_amount": 100,
    "trade_status": "2",
    "upstream_pay_order_id": "P...",
    "upstream_channel_order_no": "...",
    "pay_time": "2026-10-20T12:00:00+08:00",
    "err_code": null,
    "err_msg": null
  },
  "upstream": { "state": "2", "successTime": 1760000000000 }
}

退款事件把 event 设为 REFUND,带 refund 对象(refund_no/out_refund_no/order_no/amount/state/upstream_refund_order_id/success_time/err_code/err_msg),并同时带 order 的当前快照(trade_status/refunded_amount/refundable_amount)。

事件矩阵(唯一定义,实现与文档都按这张表):

本地变化 推 ORDER 推 REFUND 说明
trade_status 0→1 否 — 支付中是过程态,不打扰商户
trade_status → 2 是 — 支付成功
trade_status → 3/4/6 是 — 失败/撤销/关闭
trade_status 2→5(全额退款导致) 是 见下行 订单终态 5
退款 state 0→1 — 否 过程态
退款 state → 2/3/4 — 是 退款成功/失败/关闭
部分退款成功(订单仍为 2) 否 是 订单状态没变,发了会撞 uo_<order_no>_2 的旧 id;退款事件里带订单快照,商户能拿到新的 refunded_amount

notify_id 因此对每个事件全局唯一:ORDER 只在 2/3/4/5/6 发且终态不再变;REFUND 只在 2/3/4 发且终态不再变。部分退款不重发 ORDER,正是为了守住这条。

  • 地址优先级:订单/退款快照 callback_url → sys_tenant_api_key.return_url(按 api_key 精确)→ open_conf.return_url(按租户)。退款额外先看退款快照,再看订单快照。
  • 地址为空:记 FAILED,不静默跳过;商户事后补配地址,重试调度器还能捞起(与 HAR 通道同一处置)。
  • open_conf 兜底不要照抄 selectOne(... LIMIT 1):open_conf.tenant_id 没有全局唯一约束,无认证路径的 LIMIT 1 是静默挑行。用 selectList 按 updated_time DESC 取第一行;多行记 WARN(保留现有「取最新配置」语义)。sys_tenant_api_key 按 api_key 精确查(该列全局唯一)。
  • 推送:单次、异步、独立线程池;2xx 即成功;非 2xx/异常记 FAILED。不阻塞上游通知响应。
  • 重试:UnifiedCallbackRetryScheduler 60 秒扫 notify_status='FAILED' AND notify_count < 3,按 last_notify_time 升序,LIMIT 50。notify_count 含首次,即最多投递 3 次(首次 + 2 次重试)。
  • SSRF 最小防护:只允许 http:// / https://,host 非空;额外拒绝 localhost、127.0.0.0/8、::1、0.0.0.0、169.254.169.254(云元数据地址)。不解析私网 IP(企业内网回调是合法用法,全禁会误伤);残余风险见 §11。
  • 下游回调客户端:独立 RestTemplate(连接/读超时),不跟随 30x(setInstanceFollowRedirects(false)),不复用 WebMvcConfig.restTemplate()(那个故意没有超时)。
  • 日志:url 与异常 message 先过 UnifiedLogSanitizer.flatten(CWE-117)。
  • 无签名:与平台现有回调一致;接收方应以查询接口复核。列为残余风险(§11),不在本期加签。

7.7 获取渠道用户 ID

POST /payment/openapi/unified/channel-user/jump-url:

请求(JSON,snake_case):

字段 必填 说明
mch_no 否 租户有多条配置时必填
redirect_url 是 授权完成后回跳的公网页面;只允许 http/https
if_code 否 默认取商户配置的 channel_user_if_code
ext_param 否 原样回跳

响应 data.url:上游 channelUserId/jump 的完整 URL,参数已签名、已 URL 编码。调用方服务端拿到后把用户浏览器 302 过去。浏览器里没有 appSecret。

7.8 实时推送

  • OrderEventPublisher 新增 CHANNEL_UNIFIED_ORDER = "unified_order"、CHANNEL_UNIFIED_REFUND = "unified_refund"。
  • 只在确实写库成功后发(条件 UPDATE 影响行数 > 0);无变化的分支不发。具体发哪些事件按 §7.6 的事件矩阵(0→1 不发、部分退款不发 ORDER、全额退款既发 REFUND 又发 ORDER→5)。
  • 订单页订阅两个通道;退款变化也刷订单列表。
  • 同步更新 OrderEventPublisherTest 与 frontend/scripts/verify-order-stream.mjs 的通道清单(两处各钉了一遍,改名/漏加即断链)。

8. 开放 API 契约

8.1 认证与通用约定

  • 认证:Authorization: ApiKey <key> + Signature(HMAC-SHA256,按请求 JSON body 计算),由 TenantApiKeyAuthFilter 处理。租户只从 token 取,请求体里没有 tenant_id。
  • 报文体:JSON,字段 snake_case(全局 Jackson SNAKE_CASE)。
  • 金额:分(int)。
  • 响应:{ code, msg, data };code=0 成功。业务错误按平台约定返回 HTTP 200 + body code(校验错误 400、认证 401)。
  • mch_no:租户只有一条配置时可省;多条时必须指定,否则 400「当前租户配置了多个商户号,请在请求中指定 mch_no」——不替调用方挑。

8.2 端点

# 方法 路径 说明
1 POST /payment/openapi/unified/order/create 统一下单
2 POST /payment/openapi/unified/order/query 查单
3 POST /payment/openapi/unified/refund 退款
4 POST /payment/openapi/unified/refund/query 退款查询
5 POST /payment/openapi/unified/channel-user/jump-url 生成渠道用户授权跳转 URL

8.3 请求字段

下单:

字段 必填 说明
mch_no 否 多配置时必填
amount 是 分,> 0
way_code 是 16 种之一
subject 是 ≤128
body 否 ≤512;空则用 subject
out_trade_no 是 调用方单号,≤64;必填,幂等键(缺失 400)
client_ip 否 不传取请求来源 IP
notify_url 否 下游回调地址,≤512;只有传了才推这个地址
return_url 否 上游同步跳转地址(浏览器),≤512
expired_time 否 秒,默认 7200
channel_extra 否 JSON 对象(调用方传对象);按 wayCode 传 auth_code/openid/wxAppId/buyerUserId/payDataType 等。平台收到后序列化成紧凑 JSON 字符串再发上游(上游要求字符串,见 §4.2)
ext_param 否 上游原样返回

不透传 divisionMode:分账不在本期范围(§2.2),请求里不出现该字段,按上游默认 0(不分账)。调用方传入 division_mode 视为未知字段忽略,不报错也不转发。

查单:mch_no 否;order_no / out_trade_no 二选一(都传以 order_no 为准)。

退款:mch_no 否;order_no / out_trade_no 二选一;refund_amount 是(分);refund_reason 是(≤64);out_refund_no 是(≤64,幂等键,缺失 400);notify_url 否;ext_param 否。

退款查询:mch_no 否;refund_no / out_refund_no 二选一。

8.4 响应字段

订单 VO:

order_no、out_trade_no、mch_no、way_code、amount、refunded_amount、refundable_amount、trade_status、upstream_pay_order_id、upstream_channel_order_no、pay_data_type、pay_data、err_code、err_msg、pay_time、created_time、updated_time、notify_status。

退款 VO:

refund_no、out_refund_no、order_no、mch_no、amount、state、upstream_refund_order_id、upstream_channel_order_no、err_code、err_msg、success_time、created_time、updated_time、notify_status。

refundable_amount = amount - SUM(refund.amount WHERE state IN ('0','1','2')) —— 与退款闸门同一公式,在途退款也扣。只减成功退款会让订单页预填一个接口必然拒绝的金额。

内部 id 不返回给开放 API;后台 VO 的 id 必须挂 @JsonSerialize(using = ToStringSerializer.class)(19 位雪花 ID 超过 JS 安全整数)。

8.5 幂等与错误

下表的状态是响应体 Result.code,不是 HTTP 状态:平台全局 BusinessException 处理器返回 HTTP 200 + body code(GlobalExceptionHandler#handleBusinessException);鉴权失败才是真正的 HTTP 401/403,参数校验是 HTTP 400。调用方以 code == 0 判成功,不要只看 HTTP 状态。

场景 行为(均为 body code)
缺少 out_trade_no(下单)或 out_refund_no(退款) 400
同 out_trade_no、同参数重复下单 返回已有订单(不再打上游)
同 out_trade_no、参数不同 409
同 out_refund_no、同参数重复退款 返回已有退款
同 out_refund_no、参数不同 409
租户多条配置但未传 mch_no 400
订单 trade_status != 2 时发起退款 400「订单未支付成功,不能退款」
配置/订单不存在或不属于本租户 404(不区分,避免探测)
上游调用失败/超时 500 + 本地订单/退款已保留的提示;用幂等键查询确认
上游成功但金额/币种与本地不符 不落成功;开放 API 返回 500 + AMOUNT_MISMATCH,通知返 FAIL
超额退款 400

pay_data 是上游原样返回(二维码 URL / 表单 HTML / 跳转链接)。调用方渲染前必须校验协议(只允许 http/https);表单 HTML 只能放进受信页面。

9. 后台页面

9.1 商户配置 views/module_payment/unified/merchant/index.vue

  • 搜索:tenant_id(仅超管显示,下拉来自 TenantAPI)、mch_no、merchant_name、status。TenantAPI 只在 is_superuser === true 时加载;租户身份不调这个接口(它没有租户列表权限)。
  • 列表:租户(超管视角;列显示 tenantName(tenant_id),取不到时退回 id)、mch_no、merchant_name、app_id、channel_user_if_code、status、更新时间、操作。
  • 新增/编辑弹窗(仅 is_superuser 显示):
    • tenant_id(仅新增时显示且必填,下拉来自 TenantAPI;编辑时忽略,归属不可改 —— 与 HAR 商户页同一处理,旧数据无租户也能改备注);
    • mch_no、app_id、app_secret(type=password,autocomplete=new-password;编辑时留空 = 不修改,旁边一句「已配置,留空不修改」);
    • merchant_name、channel_user_if_code(下拉 fuioupay/wxpay/AUTO)、status、description;
    • 绝不回显 app_secret,也没有「查看密钥」按钮。
  • 删除(仅超管):已有交易时后端 400,前端提示「请停用而不是删除」。

9.2 订单管理 views/module_payment/unified/order/index.vue

  • 搜索:order_no、out_trade_no、mch_no、way_code、trade_status、日期范围。
  • 表格列:订单号、商户号、方式、金额(元)、状态、上游单号、支付时间、回调状态、创建时间。
  • 详情抽屉:订单全字段、上游状态原值、pay_data(二维码/链接只读展示)、退款列表、upstream_data_json 折叠查看(脱敏)。
    • pay_data 一律按文本展示(<pre>/复制按钮),禁止 v-html:ALI_WAP/ALI_PC 的 form 是上游返回的 HTML,直接插进后台页面就是存储型 XSS。
    • codeUrl 用 qrcode 渲染;codeImgUrl 用 <img> 前只接受 http:/https:,其他协议(data:、javascript:、file:)显示为文本并记 WARN。
  • 操作:同步(:sync)、退款(:refund)。
  • 退款弹窗:默认可退金额,输入退款金额(元→分)、原因;打开弹窗时生成一次 out_refund_no,提交期间禁用按钮,失败重试复用;提交后刷新。
  • 订阅 unified_order + unified_refund,用现有 useLiveRefetch 模式;操作进行中降级为手动刷新(与 HAR 订单页一致)。

9.3 收银台 views/module_payment/unified/cashier/index.vue

  • 商户:进页面调 GET /payment/unified/merchant;只有一条时默认选中并隐藏下拉,多条时必选(mch_no 随创建请求提交);一条都没有时页面提示「请联系管理员配置商户」,禁用收款表单。租户只能看到自己租户的配置(后端过滤)。
  • 模式:主扫 / 被扫 / H5。
  • 主扫:way_code 下拉 QR_CASHIER(默认)/ ALI_QR / WX_NATIVE;请求 channel_extra={"payDataType":"codeUrl"};用 qrcode 把 pay_data 渲染成二维码;pay_data_type=codeImgUrl 时直接用 <img>。
  • 被扫:way_code 下拉 AUTO_BAR(默认)/ ALI_BAR / WX_BAR / YSF_BAR;付款码输入框 type=password,提交后立即清空,不写 localStorage、不回显。
  • H5:way_code=WX_H5;展示 pay_data 链接,提供「打开支付页」按钮(window.open)。只允许 http:/https:,其他协议(javascript:、data:、file:)不打开、按文本展示并记 WARN。
  • 金额输入以元为单位,提交前转分;subject 必填,body 可空。
  • 每次提交生成 out_trade_no = "C" + yyyyMMddHHmmss + 6 位随机,双击复用同一个号。
  • 创建成功后每 3 秒调 GET /payment/unified/cashier/order/{order_no}(后端节流:3 秒内直接返回本地快照);终态停止;5 分钟仍未终态停止并提示「请到订单页手动同步」。
  • 页面只服务租户登录身份;超管无 tenant_id → 后端 403「当前登录身份没有归属租户」。

9.4 前端契约

  • 三个页面都写 defineOptions({ name: "payment-unified-..." }),与 route_name 逐字一致;否则 keep_alive=true 静默失效(乐收赢踩过,bf09016)。
  • 商户页的「新增/编辑/删除」按钮用 userStore.basicInfo.is_superuser === true 显隐(与后端 @perm.hasRole('SUPERUSER') 同一个来源);没有 merchant:save/delete 权限点,不要伪造一个。
  • API 模块 frontend/src/api/module_payment/unified.ts,路径常量 /payment/unified。
  • 雪花 id 一律当字符串处理。
  • 金额换算集中在一个 helper,避免各页面各写一份 *100。
  • 二维码库已有 qrcode ^1.5.4 + @types/qrcode,不新增依赖。

9.5 菜单与权限 SQL(db/unified-menu.sql)

结构:一个 type=1 顶层目录 → 三个 type=2 页面 → type=3 按钮(页面行自带 :list 权限,按钮行放其余权限点)。

层级 name / title route_name route_path component_path permission order
目录 统一支付平台 payment-unified /payment/unified NULL NULL 顶层 MAX+1
页面 商户配置 payment-unified-merchant /payment/unified/merchant module_payment/unified/merchant/index module_payment:unified:merchant:list 1
页面 订单管理 payment-unified-order /payment/unified/order module_payment/unified/order/index module_payment:unified:order:list 2
页面 收银台 payment-unified-cashier /payment/unified/cashier module_payment/unified/cashier/index module_payment:unified:cashier:list 3
按钮 订单详情 NULL NULL NULL module_payment:unified:order:detail 1(父=订单页)
按钮 同步上游 NULL NULL NULL module_payment:unified:order:sync 2
按钮 发起退款 NULL NULL NULL module_payment:unified:order:refund 3
按钮 发起收款 NULL NULL NULL module_payment:unified:cashier:create 1(父=收银台页)
  • 不建 merchant:save / merchant:delete 权限行:这两个动作只认 @perm.hasRole('SUPERUSER'),不是租户权限点;给它们建行会让「有权限但被超管身份挡住」的假象。
  • role 1(ADMIN):目录 + 三个页面 + 四个按钮全授。
  • role 2(租户):同样全授(含退款)。这与 HAR 通道不同(HAR 的商户配置不对租户开放),但统一支付的商户配置是「租户自己的上游商户号」,且本设计把写操作收在超管身份上;租户只读列表 + 做自己的订单/退款/收银台。若日后要收紧,删 sys_role_menus 里 role 2 对 :refund 的授权即可。
  • 目录行必须授给角色:UserService.buildMenuTree 只把 parent 同在集合内的节点挂上树,父不在集合里的子节点被直接丢弃、不报错。
  • 幂等形状:目录锚 route_name='payment-unified'、页面锚 route_path、按钮锚 (parent_id, permission)、授权锚 (role_id, menu_id);每段 INSERT ... SELECT ... WHERE NOT EXISTS。uuid 必须显式 gen_random_uuid():活库实测该列可空、无默认值(lsy-onboarding-menu.sql 头部,2026-09-23 查 information_schema.columns),不给值不报错、静默插 NULL(batch-menu.sql 已留下 5 行 NULL uuid);唯一索引允许多个 NULL,所以也不会撞唯一性。id 省略(序列默认);created_time/updated_time 用 CURRENT_TIMESTAMP;status='0'。
  • 页面行:keep_alive=true、hidden=false、always_show=false、affix=false;目录 component_path 为 NULL。
  • component_path 相对 src/views/:不带 views/ 前缀、不带 .vue 后缀;写错会静默落到 404,不报错。
  • route_name 与 SFC defineOptions({ name }) 逐字一致:否则 keep-alive 静默失效(乐收赢 bf09016 先例)。
  • 权限串与 @PreAuthorize 逐字一致:差一个字符就是 403,且超管测不出来(PermissionCheck 对 ROLE_SUPERUSER 直接放行)。用非超管账号验一遍。
  • 执行前只读核对:route_name='payment-unified' 是否已存在、role 1/2 是否存在、顶层 MAX("order") 当前值;执行后用 §13 的查询核对行数。

9.6 后台 API 端点(JWT)

方法 路径 权限
GET /payment/unified/merchant module_payment:unified:merchant:list
POST /payment/unified/merchant @perm.hasRole('SUPERUSER')
DELETE /payment/unified/merchant/{id} @perm.hasRole('SUPERUSER')
GET /payment/unified/order module_payment:unified:order:list
GET /payment/unified/order/{id} module_payment:unified:order:detail
POST /payment/unified/order/{id}/sync module_payment:unified:order:sync
POST /payment/unified/order/{id}/refund module_payment:unified:order:refund
GET /payment/unified/order/{id}/refunds module_payment:unified:order:detail
POST /payment/unified/cashier/order module_payment:unified:cashier:create
GET /payment/unified/cashier/order/{order_no} module_payment:unified:cashier:list
  • 路径变量 {id} 是雪花 ID,后端按 String 接收后转 Long;前端按字符串处理,不经过 JS number。
  • GET /order/{id}/refunds 与详情抽屉共用 :detail,不单独造权限点。
  • 商户创建/删除没有权限点,只有超管身份 —— 注解逐字为 @perm.hasRole('SUPERUSER')。
  • 非超管必须有租户(fail-closed):TenantInnerInterceptor 在已登录但 LoginUser.tenantId == null 时对条件表跳过租户过滤(租户列可空,角色 ADMIN 的用户也可能没绑租户)。因此上表所有后台端点(不含开放 API 与免登录通知)在业务前统一过一个共享校验:is_superuser == false && tenantId == null → 403「当前登录身份没有归属租户」。不要只给收银台加:商户列表、订单列表/详情/同步/退款、退款列表任何一条漏了,都会让零租户用户看到全平台数据。开放 API 的租户来自 API Key(必非空),超管走全量,不受此限。
  • 后台退款请求体必须带 out_refund_no(前端弹窗生成、重试复用),后端 @NotBlank;缺了直接 400,不能落一笔没有幂等键的退款。

10. 开放文档改造

10.1 侧边栏可收起

当前 views/module_payment/apikey/index.vue 的「接入文档」左侧是 6 个 .sidebar-section(认证/账户接口/回调通知/支付宝收单/银联收单/其他),全部平铺。改为:

  • 用一个 <el-collapse v-model="openedDocGroups" class="docs-collapse"> 包住所有组,每组一个 <el-collapse-item :title="..." name="...">,里面保持现有 <ul><li> 结构。
  • const openedDocGroups = ref<string[]>([]) —— 默认全部收起。
  • 组名:auth / account / callback / har / collection / unified / other。
  • 样式只作用于 .docs-collapse:去掉 el-collapse 默认边框、压缩 header 高度、保留 .sidebar-section 的间距与激活色。不要动全局 collapse 样式。
  • 新增第 7 组「统一支付收单」:v-for="(section, key) in unifiedSections",与 har/collection 两组同形。
  • mdSections 改为 { ...collectionSections, ...harSections, ...unifiedSections };getSectionTitle() 自动覆盖新 key。
  • 默认内容仍是 activeSection='auth';组收起时内容照常显示,符合「默认收起」的要求。

10.2 新文档

  • 文件:views/module_payment/apikey/docs/unified-openapi.md。
  • 切片:views/module_payment/apikey/unifiedDoc.ts,复用 buildSections。
  • 标题与条目一一对应(标题必须逐字一致,否则 buildSections 静默丢条目):
key 侧边栏标题 md 标题
unified_overview 概述 ## 1. 概述
unified_create 统一下单 ## 2. 统一下单
unified_query 订单查询 ## 3. 订单查询
unified_refund 统一退款 ## 4. 统一退款
unified_refund_query 退款查询 ## 5. 退款查询
unified_channel_user 获取渠道用户 ID ## 6. 获取渠道用户 ID
unified_notify 异步通知 ## 7. 异步通知
unified_checklist 联调检查清单 ## 8. 联调检查清单

buildSections 的 parts 填整行标题原文(含 ## 前缀,如 "## 1. 概述"),与 md 逐字一致;标题里的序号改了,md 与 unifiedDoc.ts 两处要一起改。

正文至少覆盖:

  • 这是另一条产品线,与「支付宝收单」的接口/金额单位/状态枚举不同,不要混用;
  • 认证是平台 API Key,不是上游 MD5;
  • 金额单位分;
  • 16 种 wayCode 与 channelExtra 规则;
  • 幂等键与重复提交行为;
  • 回调 notify_id/timestamp/content 格式、事件类型、最多投递 3 次(含首次)、notify_id 可去重;
  • 渠道用户 ID 必须由服务端换 URL,浏览器不接触密钥;
  • pay_data 的渲染安全提醒;
  • 联调检查清单(含小额实弹、回调地址公网可达、退款查询)。

10.3 静默失效防线

buildSections 在标题对不上时整条不显示(docSlicer.ts 的 buildSections:空切片整条丢弃)。加一条测试(Java 或 Node):

  • 读 unifiedDoc.ts 里的 parts 字面量;
  • 读 unified-openapi.md;
  • 断言每个标题在 md 中出现恰好一次,且 8 个 key 都能切出非空内容。

11. 安全

11.1 已识别风险与处置

风险 处置
appSecret 明文落库 仅超管可写、永不返回、永不进日志;实体字段加 @JsonIgnore 且 toString 排除;列为残余风险(无 KMS)
付款码 / openid / buyerUserId 落库 不落库:只存 channel_extra_keys 键名;值只存在于请求内存
上游通知参数含 channelExtra 被写进 sys_log OperationLogInterceptor 跳过 /payment/notify/**;其余路径加脱敏
开放 API 验签失败日志打印请求体(含 channel_extra) TenantApiKeyAuthFilter 的失败日志改用同一个脱敏器
商户保存请求体含 app_secret 被写进 sys_log 脱敏器覆盖 app_secret/appSecret/api_secret/apiSecret/password
租户越权读别人的商户/订单/退款 TenantInnerInterceptor + 条件表;未认证路径只用全局唯一键
超管与角色 ADMIN 混淆 商户写操作走 @perm.hasRole('SUPERUSER'),与租户拦截器同一个 is_superuser 开关
超退 / 并发退款 订单行 FOR UPDATE + 在途退款占用额度
终态被迟到通知改写 条件 UPDATE + 状态机守卫
雪花 ID 精度丢失 后台 VO id 挂 ToStringSerializer
回调 URL 日志注入 UnifiedLogSanitizer.flatten(CWE-117)
回调 URL SSRF 只允许 http/https + host 非空;拒绝 localhost/127.0.0.0/8/::1/0.0.0.0/169.254.169.254;不跟随 30x;不做私网 IP 解析(残余风险)
密钥进浏览器 渠道用户跳转由服务端签名后返回 URL;收银台不接触 appSecret
未签名下游回调可伪造 沿用平台既有格式,本期不加签;接收方应以查询接口复核。列为残余风险
免登录通知被刷 验签 + 64KB 上限;限流未做(残余风险)
零租户的已登录用户绕过租户过滤(既有拦截器行为) 统一支付的所有后台端点过共享守卫:非超管且 tenant_id == null → 403(见 §9.6)
开放 API 验签失败日志含「期望签名」(既有代码) 期望签名是一条合法请求,日志可读者能直接重放。改为只记收到的签名前 8 位 + apiKey 前缀/哈希,永不记期望值;请求体过脱敏器(见 §11.3 #5)
退款/下单重放导致重复资金动作 开放 API 强制 out_trade_no/out_refund_no;后台退款弹窗一次生成、重试复用。平台现有签名不覆盖 timestamp/nonce/path(全局既有设计,本模块不改),列为残余风险
上游成功金额与本地不符 通知/查单落成功前比对 amount/currency;不符不落成功、记 ERROR、返 FAIL(见 §7.5)
异步退款成功后订单停留在 2 四条腿统一 applyRefundState(创建/通知/轮询/手动同步),锁订单重算并入 5(见 §7.4)
商户停用后仍能下单/退款 新交易路径校验 status='0';通知/轮询不看 status(见 §7.1)
未认证通知日志被换行注入(CWE-117) mchNo 与参数键名过 UnifiedLogSanitizer.flatten 或记哈希(见 §7.5)
上游客户端报文进日志(含付款码/openid/sign) UnifiedClient 禁止整包日志;错误只记 code/msg/单号;日志捕获测试断言敏感值不出现(见 §6.2)

11.2 脱敏器

新增 core/log/SensitivePayloadRedactor(或放在 common/utils):

  • JSON:递归遍历 JsonNode,命中键名(大小写不敏感)替换为 "***": app_secret、appSecret、api_secret、apiSecret、api_key、apiKey、password、sign、signature、auth_code、authCode、openid、buyerUserId、buyer_user_id、channelUserId、channel_user_id、channelUser、channel_user、channelExtra、channel_extra。
  • 非 JSON(form/纯文本):按 key=value 正则替换;channel_extra 整段替换(不再尝试解析嵌套)。
  • 先截断到 MAX_PAYLOAD 再脱敏,避免大报文递归开销。
  • OperationLogInterceptor#getPayload 与 TenantApiKeyAuthFilter 验签失败日志共用。
  • 单测覆盖:嵌套 JSON、JSON 字符串里再嵌 JSON、form、混合大小写、非敏感字段保持原样。

11.3 认证面清单(改既有文件)

必须同步改的五处:

  1. SecurityConfig.WHITE_LIST 追加 /payment/openapi/unified/**、/payment/notify/unified/**。
  2. TenantApiKeyAuthFilter.OPENAPI_PREFIXES 追加 /payment/openapi/unified(认证判定读这个数组)。
  3. TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS 追加 5 条精确路径(只供核对)。
  4. TenantInnerInterceptor.CONDITIONAL_TABLES 追加三张表。
  5. TenantApiKeyAuthFilter 验签失败日志(约 244–247 行):删掉期望签名与完整 apiKey,请求体改走 SensitivePayloadRedactor。这是既有缺陷,修一次全通道受益;不改的话,一条日志就是一张可重放的合法请求。

OpenapiAuthWiringTest 会逐条核对白名单里每个 /payment/openapi/ 前缀都被 filter 认领(单向:白名单漏加它查不出来)。反过来,白名单放行而 OPENAPI_PREFIXES 没加就是「被放行 + 无人认证」的零鉴权(2026-09-24 踩过),所以两处必须同名改。

12. 测试策略

层 测试 现在可验证
签名 UnifiedSignerTest:自造向量、空值跳过、大写、ASCII vs 大小写不敏感两序一致、响应 data 嵌套对象、通知全参数、多值参数取首值 ✅
客户端 UnifiedClientTest:请求体 camelCase、reqTime 三种格式、code 0/200、错误码抛异常、超时配置、channelExtra 序列化 ✅(纯函数/桩传输)
订单 UnifiedOrderServiceTest:out_trade_no 必填、幂等、参数不一致 409、先落库后调上游、超时保留本地单、状态机、终态不可回退、事件只在变化时发(按 §7.6 矩阵) ✅
退款 UnifiedRefundServiceTest:未支付单 400、在途额度、并发两笔不超退、上游同步拒绝置 3 并释放、全额置 5、部分保持 2、重复 out_refund_no、applyRefundState 从创建/通知/轮询/同步四条腿都收敛订单、可退金额含在途 ✅
通知 UnifiedNotifyServiceTest:验签、mchNo/appId 不匹配、退款行与签名商户不绑定、金额/币种不符不落成功、未知单号、重复通知不推下游、upstream_data_json 白名单、channelExtra 不落库 ✅
下游回调 UnifiedCallbackServiceTest:事件矩阵(0→1 不发、部分退款不发 ORDER、全额发两条)、确定性 notify_id、三级取址、无地址记 FAILED、重试上限、form 字段 ✅
日志安全 捕获 logback:TenantApiKeyAuthFilter 失败日志无期望签名/完整 apiKey;UnifiedClient 无报文;通知日志无换行注入 ✅
租户守卫 每个后台端点:非超管 + tenant_id=null → 403;超管不受限 ✅
迁移 UnifiedMigrationTest:实体字段 ↔ V1.9 列、索引、三表进 CONDITIONAL_TABLES ✅
菜单/权限 UnifiedAdminWiringTest:SQL 权限串 ↔ 控制器注解、角色授权、目录必须授权 ✅
开放 API 接线 扩展现有 OpenapiAuthWiringTest ✅
文档切片 UnifiedDocSectionsTest:unifiedDoc.ts 的 8 个标题在 md 中各出现一次 ✅
前端菜单契约 frontend/scripts/verify-unified-menu.mjs:SQL route_name ↔ defineOptions.name、component_path ↔ 文件存在 ✅
前端页面 现有 SSR/执行级台子差分 + 两向变异;ts:check 基线非零,只能同机差分(在 frontend/ 里跑) ✅
实时通道 更新 OrderEventPublisherTest 与 verify-order-stream.mjs,加两个通道 ✅
真实网络 签名向量、成功码、reqTime、付款码键名、退款查询、通知/回调、跳转 URL、16 种通道可用性 ❌ 只能联调

不得用作测试向量:文档 §一的签名结果(复现不出来,见 §4.8 #1)。用我们自己构造的输入 + 独立计算的 MD5。

13. 部署

  1. 先跑迁移:db/migration/V1.9__create_pay_unified_tables.sql(CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS,可重复执行)。
  2. 再跑菜单:db/unified-menu.sql。执行前先跑脚本头的只读核对:确认 route_name='payment-unified' 不存在、role 1/2 存在。
  3. 配置(按需,改环境变量,不改 application.yml):UNIFIED_PAY_BASE_URL、UNIFIED_PAY_NOTIFY_BASE_URL 等;默认值已写在代码里。
    • 联调边界:t-cube.huianrong.com/gateway 是用户提供的上游(测试页带测试参数)。凭据只在用户手里,仓库不保存;pay-h5-test.html 含 APP_SECRET,不得复制进仓库。builder 不需要上游访问权限:单测用自造向量 + 桩传输;真实联调由用户按第 8 步执行。联调脚本(若写)放 logs/,不入库。
    • 联调时的回调地址:UNIFIED_PAY_NOTIFY_BASE_URL 必须指向本次联调实例自己可达的地址,不要指向生产域名(qcsj88888.com)。否则上游会把测试单的通知打到生产实例,生产侧只能按「未知单号」记 ERROR + 返回 SUCCESS。上游网关是测试还是生产环境尚未确认(§14 #17);若是生产,验收的 1 元是真金。
  4. 构建:后端 jar + 前端 dist(命令见项目既有构建说明;mvn 用绝对路径,不加 -q)。
  5. 换 jar/dist。
  6. 超管录入商户配置:mch_no、app_id、app_secret、channel_user_if_code。
  7. 重新登录(菜单与权限集合在登录时进 token / permission store)。
  8. 验收:
    • 侧边栏出现「统一支付平台」,三个页面可进;
    • 收银台主扫 1 元实弹(上游最低金额未证实,别用 1 分),顾客扫码后状态变 2;
    • 被扫用真实付款码实弹;
    • 开放 API 用 API Key 下单/查单/退款/退款查询;
    • 上游通知能到 /payment/notify/unified/order(看日志 + upstream_data_json);
    • 下游回调能到 open_conf.return_url(或订单快照地址);
    • 开放文档「统一支付收单」组默认收起,展开后 8 条都在。
  9. 回滚:换回旧 jar/dist 即可;迁移是纯加法,不删不改既有表。菜单行如要隐藏,置 status='1' 或删 sys_role_menus 授权(不要删目录行,父不在集合里子节点会被静默丢弃)。

14. 未决项(联调实测,按当前默认跑)

# 项 当前处理 怎么定案
1 文档签名示例复现不出 不用它做向量;自造向量 + 真实响应验签 联调第一笔响应 sign
2 成功码 0 还是 200 两个都接受 记录真实响应 code
3 reqTime 13/10/14 位 默认 14 位,配置可切 用 14 位打第一笔;失败切 13 位
4 付款码键 auth_code vs authCode 对外都收;默认发 auth_code 被扫实弹;失败切 authCode / both
5 响应 data 含嵌套 channelRetMsg 时签名的序列化方式 嵌套值按紧凑 JSON 第一笔下单响应的 sign
6 通知返回 "SUCCESS" 之外的行为/重试窗口 验签失败 401+FAIL;成功 200+SUCCESS;主动轮询兜底 人为让一次验签失败看上游是否重试
7 通知是 form 还是 JSON 两者都收 看首个通知的 Content-Type
8 退款查询的请求/响应字段 按 SDK RefundOrderQueryReq/ResModel 第一笔退款查询
9 退款单不存在时上游的错误码 在途保持 0,锁额度 查一笔不存在的 mchRefundNo
10 expiredTime 允许范围与过期后的状态 开放 API 7200s、收银台 300s;过期由轮询收敛 观察过期单的 state
11 全额退款后订单是否也推 state=5 本地按退款成功重算;通知 state=5 也接受 退款实弹
12 channelUserId/jump 的 ifCode=fuioupay 是否对本商户开通 默认 fuioupay 微信内打开跳转 URL
13 16 种 wayCode 是否全部开通 全部透传;收银台只做 3 类 逐个用测试商户试
14 上游是否要求 notifyUrl 公网 HTTPS 默认生产域名 部署后看上游是否回调
15 通知参数是否带 appId 带则必须匹配;未带只记 WARN,不拒收 看首个通知的参数键名
16 一个 mch_no 是否对应多个 app_id 当前 UNIQUE (mch_no)、一配置一个 app_id;通知按 mch_no 唯一定位 联调确认;若上游确实多 app,改为 UNIQUE (mch_no, app_id),通知需按候选配置逐个验签
17 上游网关是测试环境还是生产环境 按「测试页 + 测试参数」当测试环境用;未证实 用户确认域名归属;若是生产,验收的 1 元是真金,回调地址必须指到联调实例

15. 设计裁量与残余风险

15.1 本设计做出的裁量(builder 不必再问)

  1. 不引入 SDK:SNAPSHOT + fastjson/logback + 请求模型不含 reqTime/sign,直接依赖的收益小于风险。SDK 只作字段参考。
  2. 金额全链路用分:与上游契约一致,避免 HAR 通道「元/分」混淆;前端负责换算。
  3. mch_no 作为租户侧选择键:不另造 merchant_code;上游商户号本身就是调用方认识的编号。
  4. 先落库再调上游:为了通知能命中;超时保留本地单。
  5. 退款在途占额度:宁可短暂锁额度,也不冒超退风险。
  6. 部分退款支持:上游支持 refundAmount,后台与开放 API 都开放;全额/部分由金额决定。
  7. 下游回调沿用平台格式、不加签:与 HAR/LSY 一致;接收方以查询接口复核。
  8. notify_id 确定性:同一对象同一状态的重试保持同一个 id,便于接收方去重(HAR 是每次新生成,这里刻意改进)。
  9. 未知单号通知返回 SUCCESS:无本地行可更新,重试无意义;记 ERROR 留给人工/查单。
  10. 收银台只做三类:主扫/被扫/H5;JSAPI/APP/小程序走开放 API,由调用方拉起。
  11. 超管不能直接开收银台:没有 tenant_id;用租户账号或 API Key 测试。
  12. 商户写操作仅超管:mch_no 全局唯一,租户自助会抢注。
  13. 迁移不用 Flyway:本仓没有 Flyway,SQL 由人执行;用 IF NOT EXISTS 保证可重复。
  14. 幂等键按租户唯一:UNIQUE (tenant_id, out_trade_no) / UNIQUE (tenant_id, out_refund_no),不把 mch_no 放进键里。同一租户多商户复用同一调用方单号会 409;调用方应把商户维度放进自己的单号。这样单号不跨租户撞车,也不让上游商户号成为幂等键的一部分。
  15. 开放 API 强制幂等键:out_trade_no(下单)与 out_refund_no(退款)都是必填;缺失 400。没有幂等键,读超时重试就会变成两笔资金动作。
  16. 退款只对已支付成功的单:trade_status='2' 且可退额度 > 0;上游同步明确拒绝直接置 3 并释放额度,只有超时才留在 0 等查单。
  17. 下游事件矩阵以 §7.6 为准:0→1 不推、部分退款只推 REFUND、全额退款推 REFUND + ORDER(5);notify_id 因此每事件唯一。
  18. 零租户 fail-closed:所有统一支付后台端点要求非超管必须有 tenant_id,403 兜底;不能依赖拦截器(它对 null 租户跳过过滤)。
  19. 商户停用有强制点:新下单/退款/取跳转 URL 校验 status='0';通知与轮询不受影响。
  20. 不改平台现有 API Key 签名协议:不新增 timestamp/nonce/path 绑定(会影响全部开放通道与既有调用方);重放风险用强制幂等键 + 不泄露签名缓解,列为残余风险。
  21. builder 不依赖上游访问:单测全部用自造向量/桩传输;真实联调是用户按 §13.8 的验收步骤,凭据与测试页都不入库。

15.2 残余风险(如实记录)

  • app_secret 明文存库(无 KMS);数据库被读即泄露。
  • 下游回调无签名,接收方可能被伪造回调;只能靠查询接口复核。
  • 免登录通知入口无频率限制,可能被刷;验签挡住伪造,但挡不住流量。
  • 调度器按单实例设计;多实例会重复轮询/重复回调。
  • 平台现有 API Key 签名只覆盖请求 body,不含时间戳/nonce/路径绑定(全局既有设计);被截获的合法请求可重放。本模块只能靠强制幂等键把资金影响降到「返回已有单」,不能识别重放本身。
  • 非超管零租户用户由应用层守卫拦截;若将来有新端点忘记接守卫,拦截器不会兜底(它对 null 租户跳过过滤)。
  • 上游文档多处自相矛盾(§4.8),联调前无法证明协议理解 100% 正确。
  • 收银台/被扫依赖真实付款码,无法自动化端到端。

15.3 合并前必须做

  • 独立评审(builder-check)+ 安全评审(支付/认证/敏感数据)。
  • superpowers:test-driven-development 先写测试再写实现。
  • 所有 SQL 先由用户执行,再换 jar/dist;未执行 SQL 就换 jar 会导致表不存在。
  • 提交只走显式路径,永不 git add -A;application.yml、测试页、appSecret 一律不入库。