Browse Source

docs(聚合支付): 新增离线对接 PDF,订正 if_code 说明

- docs/聚合支付开放接口对接指南.pdf:6 页商户离线对接文档,覆盖请求约定、签名、下单、支付方式、幂等、查单、退款、渠道用户 ID、通知、错误码、联调清单
- if_code 注释改为「传了也会被忽略,服务端固定 fuioupay」,与代码一致
alphaH 1 week ago
parent
commit
d95369c8f1

+ 510 - 0
docs/聚合支付开放接口对接指南.html

@@ -0,0 +1,510 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<title>聚合支付开放接口对接指南</title>
+<style>
+  @page { size: A4; margin: 12mm 12.5mm 11mm 12.5mm; }
+  :root {
+    --ink: #1a1d21;
+    --muted: #5b6570;
+    --accent: #14508c;
+    --line: #d7dde5;
+    --soft: #f3f6fa;
+    --warn: #b8791a;
+    --warn-bg: #fdf7ea;
+  }
+  * { box-sizing: border-box; }
+  html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+  body {
+    margin: 0;
+    font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
+    font-size: 8.8pt;
+    line-height: 1.42;
+    color: var(--ink);
+  }
+  .head { border-top: 4px solid var(--accent); padding-top: 8px; margin-bottom: 8px; }
+  .kicker { font-size: 8pt; letter-spacing: 1.5px; color: var(--accent); text-transform: uppercase; }
+  h1 { font-size: 20pt; margin: 2px 0 2px; letter-spacing: 0.5px; }
+  .meta { font-size: 8.6pt; color: var(--muted); }
+
+  .quick {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 3px 10px;
+    background: var(--soft);
+    border: 1px solid var(--line);
+    border-radius: 4px;
+    padding: 6px 10px;
+    margin: 8px 0 4px;
+    font-size: 8.8pt;
+  }
+  .quick div { white-space: nowrap; }
+  .quick span { color: var(--muted); display: inline-block; min-width: 62px; }
+
+  h2 {
+    font-size: 11.6pt;
+    margin: 11px 0 4px;
+    padding-left: 7px;
+    border-left: 3.5px solid var(--accent);
+    break-after: avoid;
+  }
+  h3 { font-size: 9.6pt; margin: 8px 0 3px; color: #24343f; break-after: avoid; }
+  p { margin: 3px 0 5px; }
+  ul, ol { margin: 3px 0 6px; padding-left: 18px; }
+  li { margin: 1.5px 0; }
+
+  code {
+    font-family: Consolas, "Cascadia Mono", "Courier New", monospace;
+    font-size: 8.4pt;
+    background: #eef1f5;
+    padding: 0 2px;
+    border-radius: 2px;
+    word-break: break-word;
+  }
+  pre {
+    background: var(--soft);
+    border: 1px solid #e2e7ee;
+    border-radius: 3px;
+    padding: 5px 8px;
+    margin: 4px 0 7px;
+    font-family: Consolas, "Cascadia Mono", "Courier New", monospace;
+    font-size: 7.4pt;
+    line-height: 1.36;
+    white-space: pre-wrap;
+    word-break: break-word;
+    break-inside: avoid;
+  }
+  pre code { background: none; padding: 0; font-size: inherit; }
+  pre.long { break-inside: auto; }
+
+  table {
+    width: 100%;
+    border-collapse: collapse;
+    margin: 4px 0 8px;
+    font-size: 8.1pt;
+  }
+  th, td { border: 1px solid var(--line); padding: 2.2px 5px; vertical-align: top; text-align: left; }
+  th { background: #eaf0f7; font-weight: 600; white-space: nowrap; }
+  tr { break-inside: avoid; }
+  thead { display: table-header-group; }
+  td code { font-size: 7.9pt; }
+  .nowrap { white-space: nowrap; }
+
+  .note {
+    border-left: 3px solid var(--warn);
+    background: var(--warn-bg);
+    padding: 4px 9px;
+    margin: 5px 0 7px;
+    font-size: 8.9pt;
+    break-inside: avoid;
+  }
+  .note b { color: var(--warn); }
+  .cols2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+  .check { list-style: none; padding-left: 0; }
+  .check li { padding-left: 16px; text-indent: -16px; margin: 2px 0; }
+  .check li::before { content: "\2610\00a0"; color: var(--muted); }
+  .small { font-size: 8.2pt; color: var(--muted); }
+  .pdf-out { break-inside: avoid; }
+</style>
+</head>
+<body>
+
+<header class="head">
+  <div class="kicker">商户服务端对接文档</div>
+  <h1>聚合支付 开放接口</h1>
+  <div class="meta">版本 1.0 · 2026-10-21 · 5 个接口 · 请求签名 HMAC-SHA256</div>
+</header>
+
+<div class="quick">
+  <div><span>接口根地址</span><code>https://qcsj88888.com/api/v1</code></div>
+  <div><span>请求方式</span><code>POST</code>,<code>application/json</code></div>
+  <div><span>金额单位</span><code>分</code>,整数(<code>100</code> = 1.00 元)</div>
+  <div><span>成功标志</span>响应体 <code>code = 0</code></div>
+</div>
+
+<h2>1 请求约定</h2>
+
+<p>所有接口用 POST 提交 JSON,响应也是 JSON。每个请求带三个请求头:</p>
+
+<table>
+  <thead><tr><th style="width:30%">请求头</th><th>值</th></tr></thead>
+  <tr><td><code>Content-Type</code></td><td><code>application/json</code></td></tr>
+  <tr><td><code>Authorization</code></td><td><code>ApiKey &lt;你的 api_key&gt;</code>,注意 <code>ApiKey</code> 后面有一个空格</td></tr>
+  <tr><td><code>Signature</code></td><td>对请求体算出的 HMAC-SHA256,64 位小写十六进制,算法见 §2</td></tr>
+</table>
+
+<p>响应包为 <code>{"code":0,"msg":"成功","data":{...},"status_code":200,"success":true}</code>。只看 <code>code</code> 和 <code>data</code>:<code>code=0</code> 成功,业务数据在 <code>data</code>;<code>code≠0</code> 失败,原因看 <code>msg</code>。<code>status_code</code>、<code>success</code> 与 <code>code</code> 重复,可忽略,下文示例省略这两个字段。<b>HTTP 200 不代表成功。</b></p>
+
+<table>
+  <thead><tr><th style="width:34%">用途</th><th>方法 + 路径(拼在根地址后面)</th></tr></thead>
+  <tr><td>统一下单</td><td><code>POST /payment/openapi/unified/order/create</code></td></tr>
+  <tr><td>查询订单</td><td><code>POST /payment/openapi/unified/order/query</code></td></tr>
+  <tr><td>发起退款</td><td><code>POST /payment/openapi/unified/refund</code></td></tr>
+  <tr><td>查询退款</td><td><code>POST /payment/openapi/unified/refund/query</code></td></tr>
+  <tr><td>获取渠道用户 ID(仅 JSAPI / 小程序)</td><td><code>POST /payment/openapi/unified/channel-user/jump-url</code></td></tr>
+</table>
+
+<p><b>商户号 <code>mch_no</code>:</b>租户只有一个商户号时不传。有多个商户号时,<b>下单</b>和<b>获取渠道用户 ID</b> 必须传;查单、退款、查退款按单号寻址,不必传。</p>
+
+<h2>2 签名</h2>
+
+<p>签名只算请求体的顶层字段,与 URL、请求头、时间戳无关。</p>
+
+<ol>
+  <li>取请求体的顶层字段。</li>
+  <li>去掉 <code>sign</code>、<code>null</code>、空字符串、空对象 <code>{}</code>、空数组 <code>[]</code>。</li>
+  <li>按字段名 ASCII 升序排列。</li>
+  <li>每个值先 URL 编码(RFC 3986:空格 <code>%20</code>、星号 <code>%2A</code>、波浪号保留)。对象和数组先按键排序输出紧凑 JSON(无空格、中文不转义),再整体编码。</li>
+  <li>用 <code>&amp;</code> 拼成 <code>k=v&amp;k=v</code>。</li>
+  <li>以 <code>api_secret</code> 为密钥做 HMAC-SHA256,取小写十六进制,放入 <code>Signature</code> 头。</li>
+</ol>
+
+<pre><code>from urllib.parse import quote
+import hashlib, hmac, json
+
+
+def sign(body: dict, api_secret: str) -&gt; str:
+    parts = []
+    for key in sorted(body):
+        value = body[key]
+        if key == "sign" or value is None or value == "" or value == {} or value == []:
+            continue
+        if isinstance(value, (dict, list)):
+            text = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+        elif isinstance(value, bool):
+            text = "true" if value else "false"
+        else:
+            text = str(value)
+        parts.append(key + "=" + quote(text, safe=""))
+    raw = "&amp;".join(parts)
+    return hmac.new(api_secret.encode(), raw.encode(), hashlib.sha256).hexdigest()
+</code></pre>
+
+<div class="note"><b>密钥只在服务端使用。</b>不要把 <code>api_secret</code> 写进浏览器、App 或前端仓库。签名算的是你实际发出的那个请求体,改了任何字段都要重算。</div>
+
+<h2>3 下单</h2>
+
+<h3>3.1 必填参数</h3>
+<table>
+  <thead><tr><th style="width:18%">字段</th><th style="width:11%">类型</th><th>说明</th></tr></thead>
+  <tr><td><code>out_trade_no</code></td><td>string(64)</td><td>你的订单号,<b>幂等键</b>。同一租户内唯一。</td></tr>
+  <tr><td><code>amount</code></td><td>integer</td><td>金额,单位<b>分</b>,必须大于 0。</td></tr>
+  <tr><td><code>way_code</code></td><td>string(16)</td><td>支付方式,见 §4。</td></tr>
+  <tr><td><code>subject</code></td><td>string(128)</td><td>商品标题。</td></tr>
+  <tr><td><code>mch_no</code></td><td>string(64)</td><td>多商户时必传;单商户可空。</td></tr>
+</table>
+
+<h3>3.2 可选参数</h3>
+<table>
+  <thead><tr><th style="width:18%">字段</th><th style="width:11%">类型</th><th>说明</th></tr></thead>
+  <tr><td><code>body</code></td><td>string(512)</td><td>商品描述。不传时用 <code>subject</code>。</td></tr>
+  <tr><td><code>notify_url</code></td><td>string(512)</td><td>异步通知地址,必须是公网可访问的 http/https 地址。见 §10。</td></tr>
+  <tr><td><code>return_url</code></td><td>string(512)</td><td>支付完成后浏览器跳回的地址(部分支付方式使用)。</td></tr>
+  <tr><td><code>expired_time</code></td><td>integer</td><td>订单有效期,单位秒,默认 7200(2 小时)。</td></tr>
+  <tr><td><code>client_ip</code></td><td>string(64)</td><td>用户终端 IP。不传时取请求来源 IP。</td></tr>
+  <tr><td><code>ext_param</code></td><td>string(512)</td><td>透传参数,上游原样返回。</td></tr>
+  <tr><td><code>channel_extra</code></td><td>object</td><td>支付方式附加参数,见 §4。</td></tr>
+</table>
+
+<h3>3.3 完整调用示例</h3>
+<pre><code>import requests, uuid
+
+BASE = "https://qcsj88888.com/api/v1"
+API_KEY = "你的 api_key"
+API_SECRET = "你的 api_secret"
+
+
+def post(path: str, body: dict) -&gt; dict:
+    headers = {
+        "Content-Type": "application/json",
+        "Authorization": "ApiKey " + API_KEY,
+        "Signature": sign(body, API_SECRET),   # sign() 见 §2
+    }
+    resp = requests.post(BASE + path, json=body, headers=headers, timeout=15)
+    return resp.json()
+
+
+body = {
+    "out_trade_no": "T" + uuid.uuid4().hex[:16],
+    "amount": 100,                     # 分
+    "way_code": "QR_CASHIER",
+    "subject": "测试商品",
+    "notify_url": "https://example.com/pay/notify",
+}
+print(post("/payment/openapi/unified/order/create", body))
+</code></pre>
+
+<h3>3.4 成功响应</h3>
+<pre><code>{
+  "code": 0,
+  "msg": "成功",
+  "data": {
+    "order_no": "UP1960000000000000001",
+    "out_trade_no": "T20261020001",
+    "way_code": "QR_CASHIER",
+    "amount": 100,
+    "refundable_amount": 100,
+    "trade_status": "0",
+    "pay_data_type": "codeUrl",
+    "pay_data": "https://qr.example.com/xxx",
+    "created_time": "2026-10-20T12:00:00+08:00"
+  }
+}
+</code></pre>
+
+<p><code>order_no</code> 是平台订单号。<code>out_trade_no</code> 是你的单号,后续查单、退款都用它。把 <code>pay_data</code> 交给用户完成支付,渲染方式见 §4.2。</p>
+
+<h2>4 支付方式与 pay_data</h2>
+
+<h3>4.1 支持的 way_code</h3>
+<table>
+  <thead><tr><th style="width:15%">way_code</th><th style="width:34%">场景</th><th>channel_extra</th></tr></thead>
+  <tr><td><code>QR_CASHIER</code></td><td>聚合扫码,用户扫你</td><td>可选 <code>payDataType</code>:<code>codeUrl</code> / <code>codeImgUrl</code></td></tr>
+  <tr><td><code>AUTO_BAR</code> <code>ALI_BAR</code> <code>WX_BAR</code> <code>YSF_BAR</code></td><td>条码支付,你扫用户(聚合 / 支付宝 / 微信 / 云闪付)</td><td>必传 <code>{"auth_code":"付款码"}</code></td></tr>
+  <tr><td><code>ALI_JSAPI</code></td><td>支付宝生活号</td><td>必传 <code>{"buyerUserId":"..."}</code></td></tr>
+  <tr><td><code>WX_JSAPI</code></td><td>微信公众号</td><td>必传 <code>{"openid":"..."}</code>;服务商模式加 <code>wxAppId</code></td></tr>
+  <tr><td><code>WX_LITE</code></td><td>微信小程序</td><td>必传 <code>{"openid":"..."}</code>;服务商模式加 <code>wxAppId</code></td></tr>
+  <tr><td><code>YSF_JSAPI</code></td><td>云闪付 JSAPI</td><td>可选 <code>{"openid":"..."}</code></td></tr>
+  <tr><td><code>ALI_APP</code> <code>WX_APP</code></td><td>原生 APP 支付(支付宝 / 微信)</td><td>不用传</td></tr>
+  <tr><td><code>ALI_WAP</code></td><td>支付宝手机网站</td><td>可选 <code>payDataType</code>:<code>form</code> / <code>codeImgUrl</code> / <code>payUrl</code></td></tr>
+  <tr><td><code>ALI_PC</code></td><td>支付宝电脑网站</td><td>可选 <code>payDataType</code>:<code>form</code> / <code>payUrl</code></td></tr>
+  <tr><td><code>ALI_QR</code></td><td>支付宝二维码</td><td>可选 <code>payDataType</code>:<code>codeUrl</code> / <code>codeImgUrl</code></td></tr>
+  <tr><td><code>WX_NATIVE</code></td><td>微信扫码</td><td>可选 <code>payDataType</code>:<code>codeUrl</code> / <code>codeImgUrl</code></td></tr>
+  <tr><td><code>WX_H5</code></td><td>微信 H5</td><td>可不传;已拿到 <code>openid</code> 时可传</td></tr>
+</table>
+
+<p><code>way_code</code> 大小写敏感。条码类的付款码可写 <code>auth_code</code>,也可写 <code>authCode</code>。付款码、<code>openid</code>、<code>buyerUserId</code> 只用于本次请求:平台不落库、不写日志。</p>
+
+<h3>4.2 pay_data 怎么用</h3>
+<table>
+  <thead><tr><th style="width:16%">pay_data_type</th><th style="width:30%">pay_data 内容</th><th>用法</th></tr></thead>
+  <tr><td><code>codeUrl</code></td><td>支付链接</td><td>生成二维码给用户扫</td></tr>
+  <tr><td><code>codeImgUrl</code></td><td>二维码图片 URL</td><td>放进 <code>&lt;img src&gt;</code></td></tr>
+  <tr><td><code>payUrl</code></td><td>支付页链接</td><td>浏览器跳转</td></tr>
+  <tr><td><code>form</code></td><td>HTML 表单字符串</td><td>作为独立页面提交;不要插进现有页面</td></tr>
+  <tr><td><code>wxapp</code></td><td>JSON 字符串,微信支付参数</td><td><code>JSON.parse</code> 后传给 <code>WeixinJSBridge.invoke('getBrandWCPayRequest', ...)</code></td></tr>
+</table>
+
+<div class="note"><b>只渲染 http/https 地址。</b>把 <code>pay_data</code> 当数据,不要当代码执行。<code>form</code> 是完整表单 HTML,插入页面会破坏现有 DOM。</div>
+
+<h2>5 幂等与失败重试</h2>
+
+<ul>
+  <li><b>同一 <code>out_trade_no</code></b>,金额、<code>way_code</code>、<code>subject</code> 全部相同:返回原订单,<code>code=0</code>,不会重复扣款。</li>
+  <li><b>同一 <code>out_trade_no</code></b>,以上任一项不同:返回 <code>code=409</code>「out_trade_no 已存在且参数不一致」。换单号再下。</li>
+  <li><b><code>code=500</code> 且 msg 以「本地订单已保留」开头</b>:上游调用失败或超时,这笔<b>可能已经成功</b>。不要换单号重下,先查单。确认未支付且没有可用的 <code>pay_data</code> 后,用<b>新的</b> <code>out_trade_no</code> 重新下单。</li>
+  <li>退款同理:<code>out_refund_no</code> 是幂等键,同一单号参数不同返回 <code>code=409</code>。</li>
+  <li>调用前先把 <code>out_trade_no</code> 持久化。所有网络超时、连接中断,一律先查单再决定。</li>
+</ul>
+
+<h2>6 查询订单</h2>
+
+<pre><code>{"out_trade_no": "T20261020001"}</code></pre>
+
+<p>也可以传 <code>{"order_no": "UP1960000000000000001"}</code>。两个都传时以 <code>order_no</code> 为准。响应的 <code>data</code> 与下单相同。</p>
+
+<table>
+  <thead><tr><th style="width:14%">trade_status</th><th>含义</th></tr></thead>
+  <tr><td><code>0</code></td><td>待支付</td></tr>
+  <tr><td><code>1</code></td><td>支付中</td></tr>
+  <tr><td><code>2</code></td><td>支付成功(之后可能变成 <code>5</code> 已退款)</td></tr>
+  <tr><td><code>3</code></td><td>支付失败</td></tr>
+  <tr><td><code>4</code></td><td>已撤销</td></tr>
+  <tr><td><code>5</code></td><td>已退款</td></tr>
+  <tr><td><code>6</code></td><td>已关闭</td></tr>
+</table>
+
+<p><code>3</code> / <code>4</code> / <code>5</code> / <code>6</code> 是终态。建议每 3 秒查一次,到终态停止。支付结果以查单或异步通知中的 <code>trade_status</code> 为准。</p>
+
+<h2>7 发起退款</h2>
+
+<pre><code>{
+  "out_trade_no": "T20261020001",
+  "out_refund_no": "R20261020001",
+  "refund_amount": 100,
+  "refund_reason": "测试退款",
+  "notify_url": "https://example.com/refund/notify"
+}</code></pre>
+
+<p>定位订单用 <code>order_no</code> 或 <code>out_trade_no</code>,至少传一个,都传时以 <code>order_no</code> 为准。<code>mch_no</code> 可选,传了必须与订单一致。</p>
+
+<table>
+  <thead><tr><th style="width:18%">字段</th><th style="width:11%">类型</th><th>说明</th></tr></thead>
+  <tr><td><code>out_refund_no</code></td><td>string(64)</td><td>你的退款单号,<b>幂等键</b>。</td></tr>
+  <tr><td><code>refund_amount</code></td><td>integer</td><td>退款金额,分。</td></tr>
+  <tr><td><code>refund_reason</code></td><td>string(64)</td><td>退款原因,必填。</td></tr>
+  <tr><td><code>notify_url</code></td><td>string(512)</td><td>本笔退款的通知地址,可选。</td></tr>
+</table>
+
+<ul>
+  <li>只有 <code>trade_status=2</code> 的订单能退款。</li>
+  <li><code>refund_amount</code> 不能超过 <code>refundable_amount</code>。在途退款也占用额度。</li>
+  <li>同一订单可以多次部分退款,每次换一个 <code>out_refund_no</code>。</li>
+</ul>
+
+<pre><code>{
+  "code": 0,
+  "msg": "成功",
+  "data": {
+    "refund_no": "UR1960000000000000001",
+    "out_refund_no": "R20261020001",
+    "order_no": "UP1960000000000000001",
+    "mch_no": "M202610200001",
+    "amount": 100,
+    "state": "1",
+    "created_time": "2026-10-20T12:05:00+08:00"
+  }
+}
+</code></pre>
+
+<table>
+  <thead><tr><th style="width:12%">state</th><th>含义</th></tr></thead>
+  <tr><td><code>0</code></td><td>退款生成</td></tr>
+  <tr><td><code>1</code></td><td>退款中</td></tr>
+  <tr><td><code>2</code></td><td>退款成功</td></tr>
+  <tr><td><code>3</code></td><td>退款失败</td></tr>
+  <tr><td><code>4</code></td><td>退款关闭</td></tr>
+</table>
+
+<p><code>2</code> / <code>3</code> / <code>4</code> 是终态。</p>
+
+<h2>8 查询退款</h2>
+
+<pre><code>{"out_refund_no": "R20261020001"}</code></pre>
+
+<p>也可以传 <code>{"refund_no": "UR1960000000000000001"}</code>。两个都传时以 <code>refund_no</code> 为准。响应与退款相同。不要只等通知:主动查到 <code>state=2</code> 再确认退款成功。</p>
+
+<h2>9 获取渠道用户 ID(JSAPI / 小程序)</h2>
+
+<p><code>WX_JSAPI</code>、<code>WX_LITE</code>、<code>ALI_JSAPI</code> 需要先拿渠道用户 ID。<code>YSF_JSAPI</code> 可选。上游签名要用 <code>api_secret</code>,所以这一步必须由服务端换取 URL,浏览器不接触密钥。</p>
+
+<pre><code>{
+  "mch_no": "M202610200001",
+  "redirect_url": "https://example.com/channel/callback",
+  "ext_param": "order=1"
+}</code></pre>
+
+<pre><code>{"code": 0, "msg": "成功", "data": {"url": "https://...签名后的上游地址"}}</code></pre>
+
+<p>把用户浏览器跳到返回的 <code>url</code>。授权完成后,<code>redirect_url</code> 会带上 <code>channelUserId</code>(微信还会带 <code>channelAppId</code>):</p>
+
+<ul>
+  <li>微信:把 <code>channelUserId</code> 作为 <code>openid</code> 放进下单的 <code>channel_extra</code>;服务商模式再把 <code>channelAppId</code> 作为 <code>wxAppId</code>。</li>
+  <li>支付宝:把 <code>channelUserId</code> 作为 <code>buyerUserId</code> 放进下单的 <code>channel_extra</code>。</li>
+</ul>
+
+<p><code>if_code</code> 不用传,平台固定使用 <code>fuioupay</code>。</p>
+
+<h2>10 异步通知</h2>
+
+<p>平台用 <b>form POST</b>(<code>application/x-www-form-urlencoded</code>)推送,固定三个字段:</p>
+
+<table>
+  <thead><tr><th style="width:18%">字段</th><th>说明</th></tr></thead>
+  <tr><td><code>notify_id</code></td><td>事件 ID。同一事件重试时不变,用它去重。</td></tr>
+  <tr><td><code>timestamp</code></td><td>毫秒时间戳。</td></tr>
+  <tr><td><code>content</code></td><td>JSON 字符串,结构见下。</td></tr>
+</table>
+
+<p>支付事件(<code>event=ORDER</code>):</p>
+<pre class="long"><code>{
+  "event": "ORDER",
+  "order": {
+    "order_no": "UP1960000000000000001",
+    "out_trade_no": "T20261020001",
+    "mch_no": "M202610200001",
+    "way_code": "QR_CASHIER",
+    "amount": 100,
+    "refunded_amount": 0,
+    "refundable_amount": 100,
+    "trade_status": "2",
+    "upstream_pay_order_id": "19522112587029827620865",
+    "upstream_channel_order_no": "4200001234202610200000000000",
+    "pay_time": "2026-10-20T12:00:00+08:00",
+    "err_code": null,
+    "err_msg": null
+  },
+  "upstream": {}
+}
+</code></pre>
+
+<p>退款事件(<code>event=REFUND</code>):</p>
+<pre class="long"><code>{
+  "event": "REFUND",
+  "refund": {
+    "refund_no": "UR1960000000000000001",
+    "out_refund_no": "R20261020001",
+    "order_no": "UP1960000000000000001",
+    "amount": 100,
+    "state": "2",
+    "upstream_refund_order_id": "...",
+    "success_time": "2026-10-20T12:06:00+08:00",
+    "err_code": null,
+    "err_msg": null
+  },
+  "order": { "...": "订单当前快照,字段同 ORDER 事件" },
+  "upstream": {}
+}
+</code></pre>
+
+<p><code>upstream</code> 是上游回调字段的白名单投影(<code>payOrderId</code>、<code>state</code>、<code>channelOrderNo</code> 等),没有快照时是空对象。</p>
+
+<h3>10.1 推送时机</h3>
+<table>
+  <thead><tr><th style="width:42%">事件</th><th>什么时候推</th></tr></thead>
+  <tr><td><code>ORDER</code></td><td>订单状态变成 <code>2</code> / <code>3</code> / <code>4</code> / <code>6</code></td></tr>
+  <tr><td><code>ORDER</code> + <code>REFUND</code></td><td>全额退款导致订单变成 <code>5</code>,两条都推</td></tr>
+  <tr><td><code>REFUND</code></td><td>退款状态变成 <code>2</code> / <code>3</code> / <code>4</code></td></tr>
+  <tr><td>不推</td><td>订单 <code>0</code> / <code>1</code>、退款 <code>0</code> / <code>1</code></td></tr>
+  <tr><td>只推 <code>REFUND</code></td><td>部分退款成功、订单仍是 <code>2</code>;新的 <code>refunded_amount</code> 从订单快照读</td></tr>
+</table>
+
+<h3>10.2 接收规则</h3>
+<ul>
+  <li>返回任意 <b>2xx</b> 即视为接收成功,响应体不限。</li>
+  <li>失败后每分钟重试一次,<b>最多投递 3 次</b>(含首次)。</li>
+  <li>用 <code>notify_id</code> 去重。重复通知直接返回 2xx。</li>
+  <li>回调<b>不带签名</b>。不放心时以查单或查退款的结果为准,不要只凭回调发货。</li>
+  <li><code>notify_url</code> 必须公网可达,平台会拒绝 <code>localhost</code>、<code>127.0.0.1</code>、内网地址。</li>
+</ul>
+
+<h2>11 错误码与排查</h2>
+
+<table>
+  <thead><tr><th style="width:12%">HTTP</th><th style="width:12%">code</th><th style="width:30%">含义</th><th>处理</th></tr></thead>
+  <tr><td>200</td><td><code>0</code></td><td>成功</td><td>处理 <code>data</code></td></tr>
+  <tr><td>200</td><td><code>400</code></td><td>业务参数不合法</td><td>看 <code>msg</code></td></tr>
+  <tr><td>200</td><td><code>404</code></td><td>订单或退款单不存在</td><td>核对单号;确认是同一租户</td></tr>
+  <tr><td>200</td><td><code>409</code></td><td>幂等键已存在且参数不一致</td><td>换单号,或先查询确认原单</td></tr>
+  <tr><td>200</td><td><code>500</code></td><td>上游失败或超时</td><td>不要换单号,先查单</td></tr>
+  <tr><td>400</td><td><code>400</code></td><td>JSON 格式错误或字段校验失败</td><td>看 <code>msg</code> 里的字段名</td></tr>
+  <tr><td>401</td><td><code>10401</code></td><td>认证失败</td><td>检查 <code>ApiKey </code> 前缀、key、签名</td></tr>
+  <tr><td>404</td><td><code>404</code></td><td>路径不存在</td><td>检查 URL 拼写</td></tr>
+  <tr><td>500</td><td><code>500</code></td><td>服务端异常</td><td>稍后重试;持续出现联系平台</td></tr>
+</table>
+
+<h3>11.1 常见问题</h3>
+<ul>
+  <li><b>一直 401:</b>最常见是签名对不上。确认 <code>Signature</code> 是对<b>实际发送的 body</b> 算的;<code>Authorization</code> 是 <code>ApiKey</code> + 一个空格 + key;<code>api_key</code> 与 <code>api_secret</code> 是同一对。</li>
+  <li><b>金额差 100 倍:</b>金额单位是分。<code>1.00 元</code> 传 <code>100</code>。请求体字段全部用 snake_case。</li>
+  <li><b>收不到通知:</b><code>notify_url</code> 要公网可达,不能是 <code>localhost</code> 或内网 IP;返回非 2xx 会重试,3 次后停止。</li>
+</ul>
+
+<h2>12 联调清单</h2>
+<ul class="check">
+  <li>用 1 元(<code>100</code> 分)跑通「下单 → 支付 → 查单 → 全额退款 → 查退款」,确认所有金额都是<b>分</b>。</li>
+  <li><code>notify_url</code> 公网可达,返回 2xx;重复通知按 <code>notify_id</code> 去重。</li>
+  <li>同一 <code>out_trade_no</code> 原样重放返回同一笔订单;改金额重放返回 <code>code=409</code>。</li>
+  <li>按业务选对 <code>way_code</code>;条码类传 <code>channel_extra.auth_code</code>;JSAPI 类先拿 <code>openid</code>。</li>
+  <li><code>pay_data</code> 只按 http/https 渲染;<code>form</code> 不插入现有页面。</li>
+  <li>退款后主动查 <code>refund/query</code>,确认 <code>state=2</code>。</li>
+  <li>把 <code>out_trade_no</code>、<code>out_refund_no</code> 持久化,接口超时不换号,先查单。</li>
+</ul>
+
+<p class="small" style="margin-top:8px; border-top:1px solid var(--line); padding-top:5px;">
+  聚合支付开放接口 v1 · 2026-10-21 · 接口如有变更以平台通知为准
+</p>
+
+</body>
+</html>

BIN
docs/聚合支付开放接口对接指南.pdf


+ 1 - 1
frontend/src/views/module_payment/apikey/docs/unified-openapi.md

@@ -204,7 +204,7 @@ body = {"out_refund_no": "R20261020001"}
 body = {
     "mch_no": "你的商户号",                    # 多商户时必填
     "redirect_url": "https://你的域名/callback",  # 授权后浏览器跳回这里
-    "if_code": "fuioupay",                    # 可不传,不传时用商户配置(默认 fuioupay)
+    "if_code": "fuioupay",                    # 可不传;传了也会被忽略,服务端固定用 fuioupay
 }
 # POST /payment/openapi/unified/channel-user/jump-url
 # 返回 {"code":0,"data":{"url":"https://...签名后的上游地址"}}