聚合支付 商户接入文档
适用对象:接入本平台收款的商户系统开发者(服务端)。 版本:v1(2026-11-07),基于当前代码整理。平台内部实现见 aggpay-developer-guide.md。
1. 接入概览
商户服务端 ──签名请求──> 网关 /api/aggpay/v1/* 下单 / 查单 / 关单 / 退款 / 退款查询 / 投诉处理
商户客户端 <──pay_action── 商户服务端 用返回的支付参数拉起微信 / 支付宝 / 小程序收银台
网关 ──签名通知──> 商户 notify_url order.paid / refund.succeeded / refund.failed
接入步骤:
- 平台运营在后台为你开通:商户 → 产品(如
wechat_native)与费率 → 商户应用。 - 拿到
app_id和app_secret。密钥只在创建或轮换时显示一次,请立即保存在服务端,不要放进客户端。 - 向运营提供通知回调域名(加入应用的
notify_hosts白名单),如需限制来源 IP 也一并提供(ip_allowlist)。 - 按第 2 节实现签名,按第 4 节调用接口,按第 6 节处理异步通知。
- 先用沙箱产品(
mock_qr)跑通全流程,再切换到真实产品。
1.1 基本约定
| 项 | 约定 |
|---|---|
| 协议 | HTTPS,只接受 POST,Content-Type: application/json,不能带 query string |
| 字符集 | UTF-8 |
| 请求体大小 | ≤ 16 KB |
| 金额 | 整数,单位分。不接受小数、字符串数字、布尔值 |
| 币种 | 固定 CNY |
| 时间 | 响应中均为 ISO-8601 UTC,例如 2026-11-07T08:15:36Z |
| 商户单号 | out_trade_no / out_refund_no:6–64 位 [A-Za-z0-9_-],同一应用内唯一,同时是幂等键 |
2. 签名
所有接口请求、所有接口响应、所有异步通知都使用 HMAC-SHA256 签名,密钥为 app_secret。
2.1 请求签名
请求头:
| 头 | 规则 |
|---|---|
X-Agg-App-Id |
你的 app_id(形如 app_ + 24 位小写十六进制) |
X-Agg-Timestamp |
当前 Unix 时间戳(秒,10 位),与服务器时间偏差不得超过 300 秒 |
X-Agg-Nonce |
16–64 位 [A-Za-z0-9_-] 随机串,每个请求都不同,重复使用会被拒绝 |
X-Agg-Signature |
64 位小写十六进制签名 |
签名串(各段以 \n 连接,末尾没有换行):
canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(rawBody))
signature = hex(HMAC-SHA256(key = app_secret, data = canonical))
METHOD:固定POST。PATH:完整路径,例如/api/aggpay/v1/orders(不含域名)。rawBody:实际发送的请求体字节。先序列化 JSON,再用同一份字符串计算签名并发送,不要签名后再重新序列化。app_secret按字符串原样作为 HMAC 密钥(它是 64 个十六进制字符,不要做 hex 解码)。
2.2 响应与通知验签
认证通过后,网关的每个响应(包括业务错误)和每条异步通知都带有同样四个头,签名串为:
canonical = TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(rawBody))
商户必须:
- 用
app_secret重新计算并常量时间比较X-Agg-Signature; - 校验
X-Agg-Timestamp与本地时间偏差在 300 秒内; - 验签失败的响应或通知一律不信任。
认证失败(401/403)、请求格式错误(405/413/415)时网关还不知道你是谁,这类响应不带签名。把“无签名的响应”当作失败处理即可。
密钥轮换:运营轮换密钥后,旧密钥在 24 小时内仍可用于签名请求,网关会用你请求时使用的那把密钥签名响应。请在宽限期内换上新密钥。
2.3 示例代码
PHP
function aggpay_post(string $base, string $path, array $payload, string $appId, string $secret): array
{
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$sign = hash_hmac('sha256', "POST\n{$path}\n{$ts}\n{$nonce}\n" . hash('sha256', $body), $secret);
$respHeaders = [];
$ch = curl_init($base . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"X-Agg-App-Id: {$appId}",
"X-Agg-Timestamp: {$ts}",
"X-Agg-Nonce: {$nonce}",
"X-Agg-Signature: {$sign}",
],
CURLOPT_HEADERFUNCTION => function ($c, $line) use (&$respHeaders) {
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$respHeaders[strtolower(trim($k))] = trim($v);
}
return strlen($line);
},
]);
$raw = (string) curl_exec($ch);
curl_close($ch);
// 验证响应签名
$ok = isset($respHeaders['x-agg-signature'], $respHeaders['x-agg-timestamp'], $respHeaders['x-agg-nonce'])
&& abs(time() - (int) $respHeaders['x-agg-timestamp']) <= 300
&& hash_equals(
hash_hmac('sha256', $respHeaders['x-agg-timestamp'] . "\n" . $respHeaders['x-agg-nonce'] . "\n" . hash('sha256', $raw), $secret),
$respHeaders['x-agg-signature']
);
if (!$ok) {
throw new RuntimeException('Unsigned or invalid response: ' . substr($raw, 0, 200));
}
return json_decode($raw, true);
}
Java
static String sha256Hex(byte[] data) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data));
}
static String hmacHex(String secret, String data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}
// 请求
byte[] body = json.getBytes(StandardCharsets.UTF_8);
String ts = String.valueOf(Instant.now().getEpochSecond());
String nonce = UUID.randomUUID().toString().replace("-", "");
String sign = hmacHex(appSecret, "POST\n" + path + "\n" + ts + "\n" + nonce + "\n" + sha256Hex(body));
// 发送 body 原字节,并带上 X-Agg-App-Id / X-Agg-Timestamp / X-Agg-Nonce / X-Agg-Signature
// 验签(响应或通知)
String expected = hmacHex(appSecret, respTs + "\n" + respNonce + "\n" + sha256Hex(respBodyBytes));
boolean ok = MessageDigest.isEqual(expected.getBytes(), respSign.getBytes())
&& Math.abs(Instant.now().getEpochSecond() - Long.parseLong(respTs)) <= 300;
Node.js
const crypto = require('crypto');
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
const hmac = (key, s) => crypto.createHmac('sha256', key).update(s).digest('hex');
async function aggpayPost(base, path, payload, appId, secret) {
const body = JSON.stringify(payload);
const ts = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(16).toString('hex');
const res = await fetch(base + path, {
method: 'POST',
body,
headers: {
'Content-Type': 'application/json',
'X-Agg-App-Id': appId,
'X-Agg-Timestamp': ts,
'X-Agg-Nonce': nonce,
'X-Agg-Signature': hmac(secret, `POST\n${path}\n${ts}\n${nonce}\n${sha256(body)}`),
},
});
const raw = await res.text();
const [rts, rnonce, rsig] = ['x-agg-timestamp', 'x-agg-nonce', 'x-agg-signature'].map((h) => res.headers.get(h));
const expected = rts && rnonce ? hmac(secret, `${rts}\n${rnonce}\n${sha256(raw)}`) : '';
if (!rsig || rsig.length !== 64 || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(rsig))
|| Math.abs(Date.now() / 1000 - Number(rts)) > 300) {
throw new Error('Unsigned or invalid response');
}
return JSON.parse(raw);
}
Python
import hashlib, hmac, json, os, time, requests
def aggpay_post(base, path, payload, app_id, secret):
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
ts, nonce = str(int(time.time())), os.urandom(16).hex()
canonical = f"POST\n{path}\n{ts}\n{nonce}\n{hashlib.sha256(body).hexdigest()}"
sign = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
r = requests.post(base + path, data=body, timeout=20, headers={
'Content-Type': 'application/json', 'X-Agg-App-Id': app_id,
'X-Agg-Timestamp': ts, 'X-Agg-Nonce': nonce, 'X-Agg-Signature': sign})
rts, rnonce, rsig = (r.headers.get(h, '') for h in ('X-Agg-Timestamp', 'X-Agg-Nonce', 'X-Agg-Signature'))
expected = hmac.new(secret.encode(), f"{rts}\n{rnonce}\n{hashlib.sha256(r.content).hexdigest()}".encode(), hashlib.sha256).hexdigest()
if not rsig or not hmac.compare_digest(expected, rsig) or abs(time.time() - int(rts or 0)) > 300:
raise RuntimeError('Unsigned or invalid response')
return r.json()
3. 通用响应与错误码
成功:
{"code": 0, "message": "ok", "data": { ... }}
失败:data 为 null,HTTP 状态码与 code 对应:
{"code": 42220, "message": "Product is not available for this merchant.", "data": null}
| HTTP | code | 含义 | 建议处理 |
|---|---|---|---|
| 401 | 40100 | 签名缺失或错误、时间戳超窗、nonce 重放、应用不存在 | 检查签名实现与服务器时间;不要原样重放同一 nonce |
| 403 | 40300 | 应用或商户已停用 / 冻结;来源 IP 不在白名单 | 联系平台运营 |
| 404 | 40400 | 订单或退款不存在(只能查到本应用的数据) | 检查单号 |
| 405 | 40500 | 不是 POST,或 URL 带了 query string | — |
| 413 | 41300 | 请求体超过 16 KB | — |
| 415 | 41500 | Content-Type 不是 application/json | — |
| 409 | 40900 | 通用冲突 | — |
| 409 | 40901 | 幂等冲突:同一 out_trade_no / out_refund_no 用了不同参数 |
换新单号,或用原参数重试 |
| 409 | 40902 | 状态不允许:如关闭已支付订单、退款未支付订单 | 先查单确认状态 |
| 409 | 40903 | 渠道明确拒绝(投诉相关操作,见第 8 节) | 按 message 中的渠道原因处理 |
| 422 | 42200 | 参数校验失败(message 说明具体字段) |
修正参数 |
| 422 | 42210 | 超过单笔或当日限额 | 联系运营调整限额 |
| 422 | 42220 | 该产品未开通,或渠道当前不可用 | 联系运营 |
| 422 | 42230 | 退款金额超过可退余额 | 先查单看 amount - refunded_amount |
| 502 | 50200 | 渠道暂时不可用,结果未知 | 用完全相同的参数重试(幂等),或稍后查单 |
| 502 | 50300 | 渠道已关闭(开关或账号停用) | 联系运营 |
遇到 502 或网络超时,订单可能已经在渠道侧创建。不要换新单号重下,用相同参数重试即可得到同一笔订单。
4. 接口
基础地址:https://<网关域名>/api/aggpay/v1。
4.1 统一下单 POST /orders
请求:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
out_trade_no |
string | 是 | 商户订单号,6–64 位 [A-Za-z0-9_-],幂等键 |
product |
string | 是 | 产品代码,见 4.1.1 |
amount |
int | 是 | 金额(分),1 – 10,000,000,000,不能超过商户单笔限额 |
subject |
string | 是 | 商品描述,≤ 128 字符,不能含控制字符 |
notify_url |
string | 否 | 异步通知地址,≤ 512 字符,必须 https,域名必须在应用 notify_hosts 白名单内(支持 *.example.com)。不填则不发异步通知,只能主动查单 |
expire_minutes |
int | 否 | 订单有效期 1–1440 分钟,默认 30 |
client_ip |
string | 视产品 | 付款人 IP。wechat_h5 必填 |
openid |
string | 视产品 | 付款人 openid,≤ 128。wechat_jsapi 必填 |
goods_id |
string | 视产品 | 道具 ID(虚拟支付后台配置的 productId),≤ 64。wechat_xpay_goods 必填 |
示例:
{"out_trade_no": "T202611070001", "product": "wechat_native", "amount": 100, "subject": "测试商品",
"notify_url": "https://shop.example.com/pay/notify", "expire_minutes": 15}
响应 data(订单对象,查单、关单、通知中格式相同):
{
"order_no": "P261107081506142318313195",
"out_trade_no": "T202611070001",
"product": "wechat_native",
"subject": "测试商品",
"amount": 100,
"currency": "CNY",
"fee_amount": 1,
"refunded_amount": 0,
"status": "created",
"channel_trade_no": null,
"pay_action": {"type": "qr_code", "value": "weixin://wxpay/bizpayurl?pr=abc123"},
"expire_at": "2026-11-07T08:30:06Z",
"paid_at": null,
"closed_at": null,
"created_at": "2026-11-07T08:15:06Z"
}
| 字段 | 说明 |
|---|---|
order_no |
平台订单号(25 位),也是发给渠道的商户订单号 |
fee_amount |
平台手续费(分)= ⌈amount × 费率 ÷ 10000⌉,下单时按当时费率固定 |
status |
created 待支付 / succeeded 已支付 / closed 已关闭 / failed 渠道拒绝下单 |
channel_trade_no |
渠道交易号,付款后才有 |
pay_action |
支付参数,见 4.1.2。只在 status = created 时返回,其他状态为 null |
幂等规则:
- 同一
out_trade_no再次下单,且以下字段完全一致:product、amount、subject、notify_url、expire_minutes、openid、goods_id,返回同一笔订单的当前状态;任一字段不同返回 409 / 40901。client_ip不参与比较。 - 上次下单因渠道问题没拿到
pay_action(返回 502)时,用相同参数重试会重新向渠道获取,渠道侧也是同一笔订单。 - 下单返回
status = failed表示渠道明确拒绝,该单号不能再用。
4.1.1 产品列表
| 产品 | 渠道 | 场景 | 额外必填 | pay_action.type |
|---|---|---|---|---|
mock_qr |
沙箱 | 联调用,模拟扫码 | — | qr_code |
mock_h5 |
沙箱 | 联调用,模拟跳转 | — | redirect |
wechat_native |
微信支付 | PC / 线下扫码 | — | qr_code |
wechat_h5 |
微信支付 | 手机浏览器(非微信内) | client_ip |
redirect |
wechat_jsapi |
微信支付 | 公众号 / 小程序内 | openid |
jsapi |
wechat_app |
微信支付 | App | — | app |
alipay_qr |
支付宝 | 当面付扫码 | — | qr_code |
alipay_wap |
支付宝 | 手机网站 | — | redirect |
alipay_page |
支付宝 | 电脑网站 | — | redirect |
alipay_app |
支付宝 | App | — | app |
wechat_xpay_goods |
微信小程序虚拟支付 | App 拉起收银台小程序买道具(第 7 节) | goods_id |
mini_program |
duolabao_qr |
京东收银(哆啦宝) | 哆啦宝收银页二维码,用户用微信 / 支付宝 / 京东扫码付款 | — | qr_code |
duolabao_wx_jsapi |
京东收银(哆啦宝) | 公众号 / 小程序内微信支付 | openid |
jsapi |
实际能用哪些产品,取决于运营为你开通的产品路由,未开通返回 422 / 42220。
4.1.2 pay_action 用法
| type | value |
客户端用法 |
|---|---|---|
qr_code |
字符串(weixin://…、https://qr.alipay.com/…、https://order.duolabao.com/…) |
把字符串生成二维码给用户扫 |
redirect |
完整 URL | 浏览器跳转到该地址(微信 H5 需在微信支付后台配置 H5 域名) |
jsapi |
对象 {appId, timeStamp, nonceStr, package, signType, paySign} |
原样传给 wx.requestPayment / WeixinJSBridge |
app(微信) |
对象 {appid, partnerid, prepayid, package, noncestr, timestamp, sign} |
原样填入微信 OpenSDK PayReq |
app(支付宝) |
字符串(已签名的 order string) | 原样传给支付宝 App SDK payOrder |
mini_program |
对象 {appid, username, path, mini_program_type} |
用微信 OpenSDK WXLaunchMiniProgram 拉起收银台小程序,见第 7 节 |
支付参数都由网关签好名,客户端不要修改。客户端回调的“支付成功”不能作为发货依据,以第 6 节的异步通知或查单结果为准。
4.2 查询订单 POST /orders/query
请求:order_no 或 out_trade_no,二选一。
{"out_trade_no": "T202611070001"}
响应:订单对象。查单只读本地状态,不会实时访问渠道;如果长时间收不到通知,平台后台任务会主动向渠道补单(约每 30 秒一轮)。
4.3 关闭订单 POST /orders/close
请求:order_no 或 out_trade_no,二选一。网关会先向渠道查单:
| 情况 | 结果 |
|---|---|
| 渠道确认未支付并已关闭 | 返回订单对象,status = closed |
| 已经关闭 | 直接返回订单对象 |
| 渠道显示已支付 | 409 / 40902,订单会被记为 succeeded |
| 渠道暂时无法确认 | 502,订单保持 created |
以下几类订单在过期前可能无法关闭(返回 502),过期后由系统自动关闭:
alipay_qr:用户扫码前支付宝侧没有交易,而二维码在有效期内仍可付款;wechat_xpay_goods:已打开收银台的订单,虚拟支付没有关单接口。duolabao_qr:用户还没打开的收银页链接。哆啦宝的支付链接没有有效期参数,过期后平台在本地关闭;若用户之后仍用旧链接付款,平台会记录为“迟到付款”,需人工退款。
duolabao_* 的退款:同一订单最多两次部分退款(哆啦宝规则),第三次会被拒绝(failed);首次就退全额时走整单退款。
4.4 申请退款 POST /refunds
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
order_no / out_trade_no |
string | 二选一 | 原订单 |
out_refund_no |
string | 是 | 商户退款单号,6–64 位,幂等键 |
amount |
int | 是 | 退款金额(分),≤ 可退余额 |
reason |
string | 否 | 退款原因,≤ 128 字符 |
complaint_no |
string | 否 | 为处理某笔投诉而退款时填写(见第 8 节)。必须是本订单的投诉;退款成功后平台自动把退款结果回报给渠道的投诉系统 |
规则:
- 只有
succeeded的订单可退(否则 409 / 40902)。支持多次部分退款,累计不超过订单金额(超过返回 422 / 42230)。 - 并发退款不会超退:申请时就会预留额度。
- 同一
out_refund_no用相同的(订单、金额、原因、complaint_no)重试返回原退款单;参数不同返回 409 / 40901。 - 手续费按退款比例退回(
fee_refund),使订单全部退完的那笔退款会补齐剩余手续费。
响应 data(退款对象):
{
"refund_no": "R261107081528460656441059",
"out_refund_no": "RF202611070001",
"order_no": "P261107081506142318313195",
"out_trade_no": "T202611070001",
"amount": 50,
"fee_refund": 0,
"status": "processing",
"fail_reason": null,
"succeeded_at": null,
"created_at": "2026-11-07T08:15:28Z"
}
status |
含义 |
|---|---|
pending |
已受理,尚未得到渠道确认(如渠道暂时不可用),系统会自动重试 |
processing |
渠道已受理,处理中 |
succeeded |
退款成功(终态) |
failed |
退款失败(终态),fail_reason 为原因,预留额度已释放,可重新申请 |
退款最终结果以 refund.succeeded / refund.failed 通知或退款查询为准。
wechat_xpay_goods的退款接口字段尚未与微信官方文档核实,联调确认前请勿依赖。
4.5 查询退款 POST /refunds/query
请求:refund_no 或 out_refund_no,二选一。响应:退款对象。
5. 沙箱联调
- 运营为你开通
mock_qr(沙箱渠道)。 - 正常调用下单,拿到
pay_action.value = mockpay://pay/MT…。 - 运营在后台“订单”页对该订单点击 沙箱模拟付款,网关会走完整的回调、入账、通知流程,你会收到
order.paid通知。 - 再调用退款、查单、关单接口验证你的实现。
沙箱与真实渠道的签名、响应、通知格式完全一致,切换时只需换 product。
6. 异步通知
订单支付成功、退款成功、退款失败时,网关向下单时的 notify_url 发送 POST 请求。
6.1 格式
请求头:Content-Type: application/json,以及签名头 X-Agg-App-Id、X-Agg-Timestamp、X-Agg-Nonce、X-Agg-Signature(验签方法见 2.2)。
请求体:
{
"event": "order.paid",
"notification_id": "28",
"data": { "...": "订单对象(order.paid)或退款对象(refund.*)" }
}
event |
data |
|---|---|
order.paid |
订单对象,status = succeeded |
refund.succeeded |
退款对象,status = succeeded |
refund.failed |
退款对象,status = failed,含 fail_reason |
complaint.created |
投诉对象(第 8 节),用户刚发起投诉 |
complaint.updated |
投诉对象,投诉状态或内容有变化(如已回复、已完结、用户补充投诉) |
投诉通知发往该订单下单时的 notify_url;下单没填 notify_url 的订单,只能通过投诉列表接口拉取。
6.2 应答
在 10 秒内返回 HTTP 2xx,且响应体为纯文本 SUCCESS(大小写一致,可带首尾空白),才算送达。其他任何响应(包括重定向)都视为失败。
6.3 重试与去重
- 失败后按以下间隔重试:15 秒、1 分、5 分、15 分、30 分、1 小时、2 小时、6 小时、12 小时、24 小时。连续 10 次失败后停止,可请运营在后台手动重投。
- 同一事件可能送达多次(例如你已处理但应答超时)。请按
notification_id去重,并让处理逻辑幂等。 - 不保证顺序:例如退款通知可能先于你处理完支付通知到达。处理时以
data.status为准。
6.4 处理建议
1. 验签(2.2),失败返回非 2xx
2. 按 notification_id 去重;已处理过直接返回 SUCCESS
3. 用 data.out_trade_no 找到你的订单,核对 data.amount 与你的订单金额
4. 在一个事务里更新你的订单状态 / 发货,并记录 notification_id
5. 返回 SUCCESS
通知只会从网关发出,且只会发往应用白名单内的域名;回调地址请使用 https 并部署在公网。
7. 收银台小程序(App 拉起小程序付款)
用于“App 内购买虚拟道具,跳到收银台小程序完成付款”(产品 wechat_xpay_goods,微信小程序虚拟支付·道具直购)。
7.1 流程
App 后端 ──POST /orders (product=wechat_xpay_goods, goods_id)──> 网关
App 后端 <── pay_action {type: mini_program, value: {appid, username, path, mini_program_type}}
App ──WXLaunchMiniProgram(username, path)──> 收银台小程序
小程序 ──wx.login 拿 code──> POST /api/aggpay/cashier/prepare {t, code} ──> 网关
小程序 <── invoke (signData / paySig / signature)
小程序 ──wx.requestVirtualPayment──> 微信
网关 <── 微信发货推送 + 主动查单确认 ──> App 后端收到 order.paid 通知
7.2 下单
在 4.1 的基础上:
product = "wechat_xpay_goods";goods_id= 虚拟支付后台配置的道具 ID;amount必须等于该道具配置的价格(分),一单一件。
返回的 pay_action.value:
| 字段 | 说明 |
|---|---|
appid |
收银台小程序 AppID |
username |
小程序原始 ID(gh_…),WXLaunchMiniProgram 的 userName |
path |
页面路径,含一次性令牌 t,原样使用 |
mini_program_type |
0 正式版 / 1 开发版 / 2 体验版 |
下单时网关不会调用微信,订单在付款人打开收银台后才会在微信侧创建。
7.3 App 拉起
App 和收银台小程序必须绑定在同一个微信开放平台账号下。
// Android(微信 OpenSDK)
WXLaunchMiniProgram.Req req = new WXLaunchMiniProgram.Req();
req.userName = value.username;
req.path = value.path;
req.miniprogramType = value.mini_program_type;
api.sendReq(req);
// iOS
WXLaunchMiniProgramReq *req = [WXLaunchMiniProgramReq object];
req.userName = value[@"username"];
req.path = value[@"path"];
req.miniProgramType = (WXMiniProgramType)[value[@"mini_program_type"] integerValue];
[WXApi sendReq:req completion:nil];
7.4 收银台接口(小程序调用,无需商户签名)
基础地址:https://<网关域名>/api/aggpay/cashier。凭证只有 path 里的一次性令牌 t(64 位十六进制),它只能用来支付对应的那一笔订单。
POST /prepare:换取调起参数。
{"t": "<令牌>", "code": "<wx.login 返回的 code>"}
{"code": 0, "message": "ok", "data": {
"order": {"order_no": "P…", "subject": "600钻石", "amount": 600, "currency": "CNY", "status": "created", "expire_at": "…", "paid_at": null},
"invoke": {"type": "xpay", "value": {"mode": "short_series_goods", "signData": "{…}", "paySig": "…", "signature": "…"}}
}}
把 invoke.value 的四个字段原样传给 wx.requestVirtualPayment,signData 是字符串,不要重新序列化。
- 每次调用都要用新的
wx.logincode;code 无效返回 422。 - 第一个调用 prepare 的微信用户会绑定到订单,其他用户拿同一令牌返回 409。
- 订单已支付、已关闭或已过期返回 409;每单最多 20 次 prepare。
POST /status:{"t": "<令牌>"},返回 order 对象,用于付款后在小程序内展示结果。
示例收银台页面:aggpay-cashier-miniprogram/。
发货以 App 后端收到的
order.paid通知(或查单)为准,不要以小程序页面状态或launchApp返回参数为准。
8. 投诉与自动退款
用户在微信、支付宝里对订单发起的投诉,平台会核实后推送给你,并提供查询、回复、处理完成接口。你可以据此实现“收到投诉自动退款”。
8.1 流程
用户在微信/支付宝投诉 ──> 平台向渠道核实后入库
平台 ──complaint.created 通知──> 商户 notify_url
商户 ──POST /refunds(带 complaint_no)──> 平台退款;成功后自动回报渠道投诉系统
商户 ──POST /complaints/respond──> 回复用户(用户在投诉详情里可见)
商户 ──POST /complaints/complete──> 告知渠道处理完成
平台 ──complaint.updated 通知──> 商户(状态变为 processing / resolved)
平台只把能对应到你的订单的投诉推送给你。若渠道通知丢失,平台约每 5 分钟会向渠道补拉近 3 天的投诉。
8.2 投诉对象
{
"complaint_no": "C261107162223893617654295",
"order_no": "P261107081506142318313195",
"out_trade_no": "T202611070001",
"channel": "wechat",
"status": "pending",
"channel_state": "PENDING",
"problem_type": "refund",
"description": "没收到货,要求退款",
"requested_refund_amount": 6000,
"order_amount": 10000,
"refunded_amount": 0,
"fully_refunded": false,
"user_complaint_times": 1,
"refund_report_status": null,
"complained_at": "2026-11-07T16:22:23Z",
"responded_at": null,
"completed_at": null,
"updated_at": "2026-11-07T16:22:23Z"
}
| 字段 | 说明 |
|---|---|
complaint_no |
平台投诉单号,后续接口都用它 |
status |
pending 待处理 / processing 处理中(已回复)/ resolved 已完结 |
channel_state |
渠道原始状态,仅供参考 |
problem_type |
refund 用户申请退款 / service 服务问题 / other 其他 |
requested_refund_amount |
用户申请的退款金额(分),只在 refund 类型时有值 |
order_amount / refunded_amount |
原订单金额和已退金额,判断还能退多少 |
fully_refunded |
渠道认为该订单是否已全额退款 |
user_complaint_times |
该用户对这笔交易的投诉次数 |
refund_report_status |
退款结果回报渠道的进度:pending 待回报 / reported 已回报 / skipped 无需回报(非退款类投诉,或渠道自己能看到退款,如小程序虚拟支付) / failed 渠道拒绝 |
payer_phone |
投诉人手机号,只在 /complaints/query 返回,通知和列表里没有 |
8.3 接口
都在 /api/aggpay/v1 下,签名规则同第 2 节;只能操作你自己应用订单的投诉,否则返回 404。
| 接口 | 请求 | 说明 |
|---|---|---|
POST /complaints/query |
{"complaint_no": "C…"} |
查询单个投诉(含 payer_phone) |
POST /complaints/list |
{"status"?: "pending", "after_id"?: 0, "limit"?: 20} |
按创建顺序分页,返回 {items, next_after_id};next_after_id 为 null 表示没有更多 |
POST /complaints/respond |
{"complaint_no": "C…", "content": "已为您退款"} |
回复用户,content ≤ 200 字;已完结的投诉返回 409 |
POST /complaints/complete |
{"complaint_no": "C…"} |
处理完成;已完结时重复调用直接返回。渠道一般要求先回复用户,未回复时渠道会拒绝(409 / 40903) |
为投诉退款:调用 POST /refunds 并带上 complaint_no(4.4 节)。退款本身的规则不变,可以部分退款;complaint_no 必须属于同一订单,否则返回 422。
错误码补充:
| HTTP | code | 含义 |
|---|---|---|
| 409 | 40903 | 渠道明确拒绝了这次操作(如投诉已关闭、未回复就标记完成),message 中带渠道原因 |
| 502 | 50200 | 渠道暂时不可用,结果未知,可稍后重试(回复、完成都可安全重试) |
8.4 自动退款示例
// 收到通知并验签(第 6 节)后:
if ($event === 'complaint.created' && $data['problem_type'] === 'refund') {
$refundable = $data['order_amount'] - $data['refunded_amount'];
$amount = min($data['requested_refund_amount'] ?? $refundable, $refundable);
if ($amount > 0 && my_policy_allows_auto_refund($data)) { // 你的业务规则,例如金额上限、商品类型
aggpay_post($base, '/api/aggpay/v1/refunds', [
'order_no' => $data['order_no'],
'out_refund_no' => 'CR' . $data['complaint_no'], // 用投诉单号派生,重复通知不会重复退款
'amount' => $amount,
'reason' => '投诉退款',
'complaint_no' => $data['complaint_no'],
], $appId, $secret);
aggpay_post($base, '/api/aggpay/v1/complaints/respond',
['complaint_no' => $data['complaint_no'], 'content' => '已为您退款,原路返回,请注意查收。'], $appId, $secret);
aggpay_post($base, '/api/aggpay/v1/complaints/complete',
['complaint_no' => $data['complaint_no']], $appId, $secret);
}
}
- 用
complaint_no派生out_refund_no,通知重复到达时退款接口会返回原退款单,不会重复退。 - 退款接口返回
processing时,回复和完成可以先做,也可以等收到refund.succeeded再做。 - 自动退款前请自行设定规则(金额上限、黑名单等),避免被恶意投诉套现。
8.5 沙箱联调
在 mock_qr 沙箱订单付款后,请运营在后台订单页点击 模拟投诉(可选类型与申请退款金额),你会收到 complaint.created 通知,可用上面的接口走完整个流程。
当前支持投诉的渠道:沙箱、微信支付、小程序虚拟支付(京东收银/哆啦宝没有投诉接口)。虚拟支付投诉按官方文档接入,尚未实盘验证。微信投诉接口的路径来自所用 SDK;返回字段与“更新退款进度”的请求内容尚未与微信官方文档逐项核实,真实联调前请勿完全依赖。支付宝的投诉暂未接入。
9. 上线前自查
- 签名使用实际发送的 body 字节;服务器时间已同步(NTP)
- 每个请求的 nonce 都是新的随机值
- 所有响应和通知都先验签再使用;无签名的响应当失败处理
- 502 和超时用相同参数重试,不换单号
- 通知按
notification_id去重,返回纯文本SUCCESS - 发货只依据通知或查单,不依据客户端回调
-
app_secret只存服务端,并能在轮换宽限期(24 小时)内更换 - 回调地址为公网 https,域名已加入应用白名单
- 处理
complaint.created/complaint.updated;自动退款用投诉单号派生out_refund_no,并有金额等风控规则