更新于 2026-11-10 03:54 · 源文件 docs/aggpay-merchant-api.md

聚合支付 商户接入文档

适用对象:接入本平台收款的商户系统开发者(服务端)。 版本:v1(2026-11-07),基于当前代码整理。平台内部实现见 aggpay-developer-guide.md。


1. 接入概览

商户服务端 ──签名请求──> 网关 /api/aggpay/v1/*        下单 / 查单 / 关单 / 退款 / 退款查询 / 投诉处理
商户客户端 <──pay_action── 商户服务端                    用返回的支付参数拉起微信 / 支付宝 / 小程序收银台
网关 ──签名通知──> 商户 notify_url                       order.paid / refund.succeeded / refund.failed

接入步骤:

  1. 平台运营在后台为你开通:商户 → 产品(如 wechat_native)与费率 → 商户应用。
  2. 拿到 app_id 和 app_secret。密钥只在创建或轮换时显示一次,请立即保存在服务端,不要放进客户端。
  3. 向运营提供通知回调域名(加入应用的 notify_hosts 白名单),如需限制来源 IP 也一并提供(ip_allowlist)。
  4. 按第 2 节实现签名,按第 4 节调用接口,按第 6 节处理异步通知。
  5. 先用沙箱产品(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))

商户必须:

  1. 用 app_secret 重新计算并常量时间比较 X-Agg-Signature;
  2. 校验 X-Agg-Timestamp 与本地时间偏差在 300 秒内;
  3. 验签失败的响应或通知一律不信任。

认证失败(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. 沙箱联调

  1. 运营为你开通 mock_qr(沙箱渠道)。
  2. 正常调用下单,拿到 pay_action.value = mockpay://pay/MT…。
  3. 运营在后台“订单”页对该订单点击 沙箱模拟付款,网关会走完整的回调、入账、通知流程,你会收到 order.paid 通知。
  4. 再调用退款、查单、关单接口验证你的实现。

沙箱与真实渠道的签名、响应、通知格式完全一致,切换时只需换 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.login code;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,并有金额等风控规则