统一支付平台接口文档_V1.0.5.docx(用户提供;抽取文本见 logs/_harpay-recon/doc.txt)harpay-sdk-java-1.0.1-snapshot.jar(仅作字段与行为参考,不引入依赖)pay-h5-test.html(含测试凭据,任何内容不得入库或提交)汇安融「统一支付平台」(下称「统一支付」)是汇安融的另一条收单产品线,与现有的「汇安融通道」(支付宝收单,/payment/har)并列而非替代:
t-cube.huianrong.com/gateway、16 种 wayCode、覆盖微信/支付宝/云闪付。用户明确要求:新开一个菜单,这是另外的产品。
企业商户/租户 ──①──> 平台(后台收银台 / 开放 API) ──②──> 统一支付网关 ──> 微信/支付宝/云闪付
↑ │
└────────④ 下游回调 / ⑤ 查询────────┘(③ 上游通知回流)
① 平台后台(JWT)或开放 API(API Key + HMAC-SHA256) [平台已有底座]
② 统一支付 API(JSON + MD5(appSecret)) [本模块新建]
③ 统一支付通知(form/JSON,MD5 验签) [本模块新建]
④ 回调企业商户(form: notify_id/timestamp/content) [复用平台既有回调格式]
⑤ 企业查单/退款查询(开放 API) [本模块新建]
资金流:平台不碰钱。款项进入企业商户在汇安融侧的商户号,平台只记录订单与退款生命周期,不做余额、提现、对账、资金记账。
| 项 | 说明 |
|---|---|
| 商户配置 | 每个租户可配一条或多条上游商户(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,三张新表 |
| 项 | 原因 |
|---|---|
| SDK 的分账、转账、提现、码牌解析 | 用户已裁定不在本产品范围 |
| 商户进件 | 上游是人工流程,平台只做配置录入 |
| 对账任务、资金记账、余额 | 平台不碰钱,与 HAR 通道同一口径 |
| 通用渠道抽象层 | 当前只有一个上游,抽象形状无依据 |
| 16 种方式的后台收银台 UI | 收银台只做「主扫 / 被扫 / H5」;JSAPI、APP、小程序等由开放 API 调用方自行拉起 |
appSecret 加密存储 |
平台无 KMS;本仓既有 open_conf.encrypt_key 也是明文。见 §11 风险 |
| 下游回调签名 | 沿用平台现有 notify_id/timestamp/content 格式,不新增签名。见 §11 风险 |
| 项 | 取值 |
|---|---|
| 顶层菜单 | 标题「统一支付平台」,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已核)。
| 接口 | 方法 | 路径(相对 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。
mchNo、appId、reqTime、version='1.0'、signType='MD5'、sign。SignUtil.getSign 逐行一致):
sign 不参与);k1=v1&k2=v2&...;&key=<appSecret>;String.CASE_INSENSITIVE_ORDER。对真实字段集(amount/appId/body/channelExtra/clientIp/currency/extParam/mchNo/mchOrderNo/notifyUrl/reqTime/returnUrl/signType/subject/version/wayCode)两种排序结果相同;实现按文档 ASCII,并加一条「两序一致」单测。出现新字段时该测试会先红。reqTime 三种写法:
1622016572190);1624005107);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。外层:code(int)、msg、sign、data(文档写 String,示例是对象;两者都接受)。
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。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。订单(orderState / state):
| 值 | 含义 | 本地是否终态 |
|---|---|---|
| 0 | 订单生成 | 否 |
| 1 | 支付中 | 否 |
| 2 | 支付成功 | 否(可转 5) |
| 3 | 支付失败 | 是 |
| 4 | 已撤销 | 是 |
| 5 | 已退款 | 是 |
| 6 | 订单关闭 | 是 |
退款(state):
| 值 | 含义 | 本地是否终态 |
|---|---|---|
| 0 | 退款生成 | 否 |
| 1 | 退款中 | 否 |
| 2 | 退款成功 | 是 |
| 3 | 退款失败 | 是 |
| 4 | 退款关闭 | 是 |
状态直接采用上游枚举,不做本地转义;后台页面与文档只做展示映射。
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)。
appSecret 绝不能进浏览器。POST /payment/openapi/unified/channel-user/jump-url:调用方用自己的 API Key 换一条已签名的上游 URL,再把用户浏览器 302 过去。ifCode:fuioupay(服务商模式,商户无需自有公众号)/ wxpay(自有公众号)/ AUTO。默认取商户配置的 channel_user_if_code(默认 fuioupay)。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。| # | 冲突 | 处理 |
|---|---|---|
| 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 文档没写,测试页在用 |
宽容解析,不做业务依赖 |
结论:这份文档不能逐字相信。以联调读数为准,所有不确定点都做成可配置或双路兼容,并在日志里留证据。
三张表,Postgres,主键为雪花 ID(bigint),公共列沿用 PaymentBaseEntity / PaymentTenantBaseEntity 约定。status 是行状态('0' 正常),不是业务状态;业务状态在 trade_status / state。
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)(列表主路径)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'(回调重试热路径)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 NULLUNIQUE (upstream_refund_order_id) WHERE upstream_refund_order_id IS NOT NULLINDEX (order_no)(订单详情)INDEX (state, next_query_time)(轮询)INDEX (notify_status, last_notify_time) WHERE notify_status = 'FAILED'三张表都要加入 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」。本模块统一用「分」(上游契约)。后台输入框以「元」展示,提交前 Math.round(yuan * 100);展示时 fen / 100 保留两位。开放 API 文档明确写「单位分」,不与 HAR 通道的「元」混用。
时间:上游 successTime/createdAt 是 epoch 毫秒,转 OffsetDateTime 一律用 Asia/Shanghai(平台既有口径),空值/0 不写库。不要用 JVM 默认时区。
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(后台端点共用)
@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(不含密钥),便于部署核对公网可达性。
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 的请求跑一遍,断言这些值不出现在日志里。reqTime/sign,无法直接完成签名。SDK 只用于核对字段名与路径。UnifiedSigner 与请求体构造函数做成包内可见的纯函数,单测不启动 Spring。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 新增必填;修改留空 = 不修改。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/全局唯一键定位配置。开放 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
要点:
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 顶上(上游要求非空)。queryOrder:用我方 order_no 作为 mchOrderNo 查上游;响应 data.state 回写。state=2/5 时按 §7.5 同一规则比对 amount/currency;不符 → 不推进状态、写 err_code='AMOUNT_MISMATCH'、记 ERROR。通知和查单两条腿的钱安全规则必须一致。WHERE order_no = ? AND trade_status IN (允许的前态)),检查影响行数;0 行说明状态已变,不覆盖、不发事件。禁止 updateById + 乐观锁的静默丢写(HAR 已踩过)。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;next_query_time = NULL,页面显示「轮询已停止,可手动同步」,不擅自改状态;POST /payment/unified/order/{id}/sync:同步该订单 + 该订单所有非终态退款;权限 :sync。开放 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),创建响应、上游通知、退款轮询、后台手动同步四条腿都只调它:
SELECT 订单 FOR UPDATE;payAmount,先核对订单 amount,不符则记 ERROR、不改订单;若订单是 3/4/6:不改订单,记 ERROR 留人工对账;若订单是 2/5:继续;refunded_amount;>= amount 则订单置 5;refunded_amount=0。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 + 时间戳 + 随机数),提交期间禁用按钮,失败重试复用同一个号 —— 不能只靠按钮禁用,双击/重放仍要落到幂等键上。notifyUrl 固定为我们自己的 {notify-base-url}/payment/notify/unified/refund;调用方的 notify_url 只进 callback_url,不透传上游。UnifiedRefundPollScheduler:30 秒,同样的退避;同样以「创建后 30 分钟或 60 次」为上限,到上限置 next_query_time = NULL。POST /payment/notify/unified/{order|refund} (免登录)
→ 读参数(form 优先,JSON 兼容;上限 64KB)
→ 取 mchNo,按全局唯一键查商户配置(无认证,条件表跳过租户过滤)
→ 用该配置的 appSecret 验签(全部参数,sign 除外)
→ 校验 appId、单号归属(订单/退款行的 mch_no 与 tenant)、金额/币种
→ 端点即分派:`/order` 只走订单(payOrderId/mchOrderNo),`/refund` 只走退款(refundOrderId/mchRefundNo)
→ 条件 UPDATE(状态机守卫)
→ 状态真的变了才发事件 / 推下游
→ 返回 "SUCCESS"
FAIL。日志字段:收到的 sign 前 8 位(绝不打印期望签名 —— 那等于把一条合法请求写给看日志的人)、mchNo 的 SHA-256 前 12 位或过 UnifiedLogSanitizer.flatten 的值、参数键名(键名也过 sanitizer,防 CWE-117 换行注入)。不打任何参数值(含 channelExtra)。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)。沿用平台现有 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 精确查(该列全局唯一)。FAILED。不阻塞上游通知响应。UnifiedCallbackRetryScheduler 60 秒扫 notify_status='FAILED' AND notify_count < 3,按 last_notify_time 升序,LIMIT 50。notify_count 含首次,即最多投递 3 次(首次 + 2 次重试)。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()(那个故意没有超时)。UnifiedLogSanitizer.flatten(CWE-117)。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。
OrderEventPublisher 新增 CHANNEL_UNIFIED_ORDER = "unified_order"、CHANNEL_UNIFIED_REFUND = "unified_refund"。OrderEventPublisherTest 与 frontend/scripts/verify-order-stream.mjs 的通道清单(两处各钉了一遍,改名/漏加即断链)。Authorization: ApiKey <key> + Signature(HMAC-SHA256,按请求 JSON body 计算),由 TenantApiKeyAuthFilter 处理。租户只从 token 取,请求体里没有 tenant_id。SNAKE_CASE)。{ code, msg, data };code=0 成功。业务错误按平台约定返回 HTTP 200 + body code(校验错误 400、认证 401)。mch_no:租户只有一条配置时可省;多条时必须指定,否则 400「当前租户配置了多个商户号,请在请求中指定 mch_no」——不替调用方挑。| # | 方法 | 路径 | 说明 |
|---|---|---|---|
| 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 |
下单:
| 字段 | 必填 | 说明 |
|---|---|---|
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 二选一。
订单 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 安全整数)。
下表的状态是响应体 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 只能放进受信页面。
views/module_payment/unified/merchant/index.vuetenant_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,也没有「查看密钥」按钮。views/module_payment/unified/order/index.vueorder_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 订单页一致)。views/module_payment/unified/cashier/index.vueGET /payment/unified/merchant;只有一条时默认选中并隐藏下拉,多条时必选(mch_no 随创建请求提交);一条都没有时页面提示「请联系管理员配置商户」,禁用收款表单。租户只能看到自己租户的配置(后端过滤)。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、不回显。way_code=WX_H5;展示 pay_data 链接,提供「打开支付页」按钮(window.open)。只允许 http:/https:,其他协议(javascript:、data:、file:)不打开、按文本展示并记 WARN。subject 必填,body 可空。out_trade_no = "C" + yyyyMMddHHmmss + 6 位随机,双击复用同一个号。GET /payment/unified/cashier/order/{order_no}(后端节流:3 秒内直接返回本地快照);终态停止;5 分钟仍未终态停止并提示「请到订单页手动同步」。tenant_id → 后端 403「当前登录身份没有归属租户」。defineOptions({ name: "payment-unified-..." }),与 route_name 逐字一致;否则 keep_alive=true 静默失效(乐收赢踩过,bf09016)。userStore.basicInfo.is_superuser === true 显隐(与后端 @perm.hasRole('SUPERUSER') 同一个来源);没有 merchant:save/delete 权限点,不要伪造一个。frontend/src/api/module_payment/unified.ts,路径常量 /payment/unified。id 一律当字符串处理。*100。qrcode ^1.5.4 + @types/qrcode,不新增依赖。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'),不是租户权限点;给它们建行会让「有权限但被超管身份挡住」的假象。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 的查询核对行数。| 方法 | 路径 | 权限 |
|---|---|---|
| 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')。TenantInnerInterceptor 在已登录但 LoginUser.tenantId == null 时对条件表跳过租户过滤(租户列可空,角色 ADMIN 的用户也可能没绑租户)。因此上表所有后台端点(不含开放 API 与免登录通知)在业务前统一过一个共享校验:is_superuser == false && tenantId == null → 403「当前登录身份没有归属租户」。不要只给收银台加:商户列表、订单列表/详情/同步/退款、退款列表任何一条漏了,都会让零租户用户看到全平台数据。开放 API 的租户来自 API Key(必非空),超管走全量,不受此限。out_refund_no(前端弹窗生成、重试复用),后端 @NotBlank;缺了直接 400,不能落一笔没有幂等键的退款。当前 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 样式。v-for="(section, key) in unifiedSections",与 har/collection 两组同形。mdSections 改为 { ...collectionSections, ...harSections, ...unifiedSections };getSectionTitle() 自动覆盖新 key。activeSection='auth';组收起时内容照常显示,符合「默认收起」的要求。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两处要一起改。
正文至少覆盖:
wayCode 与 channelExtra 规则;notify_id/timestamp/content 格式、事件类型、最多投递 3 次(含首次)、notify_id 可去重;pay_data 的渲染安全提醒;buildSections 在标题对不上时整条不显示(docSlicer.ts 的 buildSections:空切片整条丢弃)。加一条测试(Java 或 Node):
unifiedDoc.ts 里的 parts 字面量;unified-openapi.md;| 风险 | 处置 |
|---|---|
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) |
新增 core/log/SensitivePayloadRedactor(或放在 common/utils):
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。key=value 正则替换;channel_extra 整段替换(不再尝试解析嵌套)。MAX_PAYLOAD 再脱敏,避免大报文递归开销。OperationLogInterceptor#getPayload 与 TenantApiKeyAuthFilter 验签失败日志共用。必须同步改的五处:
SecurityConfig.WHITE_LIST 追加 /payment/openapi/unified/**、/payment/notify/unified/**。TenantApiKeyAuthFilter.OPENAPI_PREFIXES 追加 /payment/openapi/unified(认证判定读这个数组)。TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS 追加 5 条精确路径(只供核对)。TenantInnerInterceptor.CONDITIONAL_TABLES 追加三张表。TenantApiKeyAuthFilter 验签失败日志(约 244–247 行):删掉期望签名与完整 apiKey,请求体改走 SensitivePayloadRedactor。这是既有缺陷,修一次全通道受益;不改的话,一条日志就是一张可重放的合法请求。
OpenapiAuthWiringTest会逐条核对白名单里每个/payment/openapi/前缀都被 filter 认领(单向:白名单漏加它查不出来)。反过来,白名单放行而OPENAPI_PREFIXES没加就是「被放行 + 无人认证」的零鉴权(2026-09-24 踩过),所以两处必须同名改。
| 层 | 测试 | 现在可验证 |
|---|---|---|
| 签名 | 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。
db/migration/V1.9__create_pay_unified_tables.sql(CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS,可重复执行)。db/unified-menu.sql。执行前先跑脚本头的只读核对:确认 route_name='payment-unified' 不存在、role 1/2 存在。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 元是真金。mvn 用绝对路径,不加 -q)。mch_no、app_id、app_secret、channel_user_if_code。/payment/notify/unified/order(看日志 + upstream_data_json);open_conf.return_url(或订单快照地址);status='1' 或删 sys_role_menus 授权(不要删目录行,父不在集合里子节点会被静默丢弃)。| # | 项 | 当前处理 | 怎么定案 |
|---|---|---|---|
| 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 元是真金,回调地址必须指到联调实例 |
reqTime/sign,直接依赖的收益小于风险。SDK 只作字段参考。mch_no 作为租户侧选择键:不另造 merchant_code;上游商户号本身就是调用方认识的编号。refundAmount,后台与开放 API 都开放;全额/部分由金额决定。notify_id 确定性:同一对象同一状态的重试保持同一个 id,便于接收方去重(HAR 是每次新生成,这里刻意改进)。tenant_id;用租户账号或 API Key 测试。mch_no 全局唯一,租户自助会抢注。IF NOT EXISTS 保证可重复。UNIQUE (tenant_id, out_trade_no) / UNIQUE (tenant_id, out_refund_no),不把 mch_no 放进键里。同一租户多商户复用同一调用方单号会 409;调用方应把商户维度放进自己的单号。这样单号不跨租户撞车,也不让上游商户号成为幂等键的一部分。out_trade_no(下单)与 out_refund_no(退款)都是必填;缺失 400。没有幂等键,读超时重试就会变成两笔资金动作。trade_status='2' 且可退额度 > 0;上游同步明确拒绝直接置 3 并释放额度,只有超时才留在 0 等查单。notify_id 因此每事件唯一。tenant_id,403 兜底;不能依赖拦截器(它对 null 租户跳过过滤)。status='0';通知与轮询不受影响。app_secret 明文存库(无 KMS);数据库被读即泄露。builder-check)+ 安全评审(支付/认证/敏感数据)。superpowers:test-driven-development 先写测试再写实现。git add -A;application.yml、测试页、appSecret 一律不入库。