|
|
@@ -0,0 +1,981 @@
|
|
|
+# 汇安融「统一支付平台」接入 设计文档
|
|
|
+
|
|
|
+- 日期: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)、金额/币种
|
|
|
+ → 按 refundOrderId/mchRefundNo 优先分派为退款;否则按 payOrderId/mchOrderNo 分派为订单
|
|
|
+ → 条件 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`:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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/`,不入库。
|
|
|
+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)`,通知需按候选配置逐个验签 |
|
|
|
+
|
|
|
+## 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` 一律不入库。
|