线上收银台 · 其他服务请按本文签名接入 · Markdown:INTEGRATION.md

原站接入文档

本文给你自己的业务网站看:如何安全地调本收银台完成支付和退款。

微信 / 支付宝的证书、商户号只配在本服务,原站不要直连微信或支付宝。

当前能力:微信 Native 扫码、支付宝电脑网站支付、全额/部分退款。


1. 接入模型

用户在原站下单
    │
    │  ① 原站服务端 HMAC 签名
    │     POST {收银台}/api/v1/orders
    ▼
收银台返回 cashier_url
    │
    │  ② 浏览器 302 / 跳转 cashier_url(用户扫码)
    ▼
支付成功
    │
    ├─ ③ 收银台 POST 原站 notify_url     ← 入账、发货只认这个
    └─ ④ 浏览器跳回原站 return_url       ← 只给人看,不能当成功依据

签名、下单、退款必须在原站服务端完成。禁止把 APP_SECRET 放到前端、小程序、App 包里。

约定:

收银台根地址https://easysale.online
共享密钥本服务 .envAPP_SECRET,与原站配置同一份
金额单位(整数)。1 元 = 100,1 分 = 1
时间timestamp 为 Unix 秒,字符串

2. 安全认证(必读)

双方用 HMAC-SHA256 证明请求来自对方,并防篡改。

2.1 待签名串

  1. 取出参与签名的字段(见各接口表格)。
  2. 去掉 sign
  3. 去掉值为空的字段(空字符串不参与)。
  4. 字段名 ASCII 升序 拼接:k1=v1&k2=v2&k3=v3
  5. 值按原文拼接,不要 URL encode,不要 JSON 序列化后再签。
  6. 数字必须先转成十进制字符串再签,例如金额 1"1",不要 "1.0"

2.2 计算签名

sign = hex( HMAC-SHA256( 待签名串, APP_SECRET ) )

十六进制小写。验签时大小写不敏感,但建议统一小写。

比较签名必须用恒定时间比较(Go hmac.Equal、PHP hash_equals),禁止 ==

2.3 防重放

字段规则
timestampUnix 秒。与本服务时差超过 5 分钟 拒绝(401 timestamp expired
nonce每次请求换一个随机串,参与签名,避免待签名串雷同
out_trade_no原站订单号,本服务内唯一。重复创建返回 409
out_refund_no原站退款单号,同一单号重复请求不会重复退

时钟请用 NTP。不要用可预测的 nonce(不要只用时间戳)。

2.4 原站必须做的校验(收到通知时)

  1. 验签(算法同上)。失败直接丢弃。
  2. 核对 out_trade_no 在自己库里存在。
  3. 核对 amount 与下单金额完全一致(支付通知);退款通知核对本次 amount 与退款单。
  4. 幂等:同一 pay_no / out_refund_no 成功通知可能来多次,只处理一次。
  5. 只认通知里的 status:支付成功为 SUCCESS,退款成功为 REFUND_SUCCESS
  6. event 区分退款:event=refund 走退款逻辑;支付成功通知没有 event 字段。

伪造通知是真实资损来源。前端跳转、用户改 URL、微信「支付成功」文案都不能当入账依据。

2.5 其它约束

```

MERCHANT_ORIGIN=https://easysale.info

```

设置后,下单接口的 notify_url / return_url 的 scheme+host 必须与此一致,防止密钥泄露后被改去别人的地址。


3. 下单并跳转收银台

POST {收银台}/api/v1/orders

Content-Type: application/json

请求字段

字段必填签名说明
out_trade_no原站订单号,同一收银台内唯一
subject商品标题,会显示在收银台和微信账单
body是(非空才签)描述
amount整数,单位分,必须 > 0
notify_url原站异步通知,完整 https://...
return_url支付完成后浏览器回去的页面
client_ip是(非空才签)用户 IP
expire_minutes是(非 0 才签)默认 15,最大 120。0 或不传则不参加签名
timestampUnix 秒,字符串
nonce随机串
signHMAC 结果

响应 200

{
  "pay_no": "P20260903160559feca330e",
  "cashier_url": "https://easysale.online/cashier/P20260903160559feca330e",
  "expire_at": "2026-09-03T16:20:59+08:00"
}

原站对浏览器执行 302cashier_url(或 window.location)。

错误

HTTP含义
400参数不合法
401签名错误或 timestamp 过期
409out_trade_no 重复

4. 异步通知(入账)

支付成功后,本服务 POST 你下单时的 notify_url

Content-Type: application/json

支付成功(无 event 字段)

{
  "out_trade_no": "ORD20260903001",
  "pay_no": "P...",
  "channel": "wechat",
  "trade_no": "4200...",
  "amount": "1",
  "status": "SUCCESS",
  "paid_at": "2026-09-03T16:11:00+08:00",
  "timestamp": "1756886400",
  "nonce": "P...",
  "sign": "..."
}

channelwechatalipaytrade_no 为微信/支付宝侧单号。

JSON 里数字会以字符串出现(本服务按 map[string]string 发出),验签时全部当字符串。

退款成功(event=refund

{
  "event": "refund",
  "out_trade_no": "ORD20260903001",
  "pay_no": "P...",
  "out_refund_no": "RFN001",
  "refund_no": "R...",
  "channel": "wechat",
  "trade_no": "4200...",
  "refund_id": "...",
  "amount": "1",
  "refunded_amount": "1",
  "status": "REFUND_SUCCESS",
  "refunded_at": "2026-09-03T16:15:00+08:00",
  "timestamp": "1756886400",
  "nonce": "R...",
  "sign": "..."
}

amount本次退款金额(分),refunded_amount 是该支付单累计已退(分)。

应答

HTTP 2xx,响应体为下面之一即视为成功,否则会重试(约 8 次):

不要在通知接口里做 302、不要输出 HTML。处理应尽快,先落库再发货。


5. 同步跳转(仅展示)

用户付完会跳到 return_url,本服务可能附带:

https://www.your-site.com/pay/result?pay_no=P...&out_trade_no=ORD...&status=SUCCESS

6. 退款

POST {收银台}/api/v1/refunds

字段必填签名说明
pay_no与 out_trade_no 至少一非空才签收银台支付单号
out_trade_no与 pay_no 至少一非空才签原站订单号
out_refund_no非空才签不传则本服务生成;建议原站自己生成以便对账
amount非 0 才签分。不传或 0 表示退剩余全部
reason非空才签退款原因
timestamp
nonce
sign

未支付、已全额退完会 400。同一 out_refund_no 再次请求返回原退款单,不会多退。

查询退款:GET {收银台}/api/v1/refunds/{refund_no或out_refund_no}


7. 查单(可选)

GET {收银台}/api/v1/orders/{pay_no}
GET {收银台}/api/v1/orders/{pay_no}?refresh=1   # 向微信/支付宝再查一次

有用字段:statuscreated / paying / success / closed / refunded)、amountrefunded_amountchannelrefunds

此接口无签名,仅建议原站服务端调用,不要做成对用户公开的 API。


8. 代码示例

以下 APP_SECRET 与本服务 .env 相同。

8.1 签名(Go)

func Sign(params map[string]string, secret string) string {
    keys := make([]string, 0, len(params))
    for k, v := range params {
        if k == "sign" || v == "" {
            continue
        }
        keys = append(keys, k)
    }
    sort.Strings(keys)
    parts := make([]string, 0, len(keys))
    for _, k := range keys {
        parts = append(parts, k+"="+params[k])
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(strings.Join(parts, "&")))
    return hex.EncodeToString(mac.Sum(nil))
}

8.2 下单(Go)

params := map[string]string{
    "out_trade_no": orderNo,
    "subject":      "会员月卡",
    "amount":       strconv.FormatInt(amountFen, 10),
    "notify_url":   "https://easysale.info/pay/notify",
    "return_url":   "https://easysale.info/pay/result",
    "timestamp":    strconv.FormatInt(time.Now().Unix(), 10),
    "nonce":        nonce,
}
params["sign"] = Sign(params, appSecret)

body, _ := json.Marshal(map[string]any{
    "out_trade_no": params["out_trade_no"],
    "subject":      params["subject"],
    "amount":       amountFen, // JSON 数字,签名用上面的十进制字符串
    "notify_url":   params["notify_url"],
    "return_url":   params["return_url"],
    "timestamp":    params["timestamp"],
    "nonce":        params["nonce"],
    "sign":         params["sign"],
})
resp, err := http.Post("https://easysale.online/api/v1/orders", "application/json", bytes.NewReader(body))

注意:amount 在 JSON 里是数字,但签名字符串里是 "amount=1" 这种十进制。不要签成 "amount":1 的 JSON 原文。

8.3 验通知(Go)

var payload map[string]any
json.NewDecoder(r.Body).Decode(&payload)
params := map[string]string{}
for k, v := range payload {
    switch t := v.(type) {
    case string:
        params[k] = t
    case float64:
        params[k] = strconv.FormatInt(int64(t), 10)
    default:
        if v != nil {
            params[k] = fmt.Sprint(t)
        }
    }
}
if Sign(params, appSecret) != strings.ToLower(params["sign"]) {
    http.Error(w, "fail", 400)
    return
}
// 再核对本库订单号、金额,幂等更新
w.Write([]byte("success"))

通知里金额已是字符串,一般走 case string

8.4 签名(PHP)

function pay_sign(array $params, string $secret): string {
    unset($params['sign']);
    $params = array_filter($params, fn($v) => $v !== '' && $v !== null);
    ksort($params, SORT_STRING);
    $parts = [];
    foreach ($params as $k => $v) {
        $parts[] = $k . '=' . $v;
    }
    return hash_hmac('sha256', implode('&', $parts), $secret);
}

8.5 签名(Python)

import hmac, hashlib

def pay_sign(params: dict, secret: str) -> str:
    items = [(k, str(v)) for k, v in params.items() if k != "sign" and v != "" and v is not None]
    items.sort(key=lambda kv: kv[0])
    canonical = "&".join(f"{k}={v}" for k, v in items)
    return hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()

9. 原站推荐落地顺序

  1. 原站订单表增加:pay_nopay_status(unpaid / paid / refunded)、paid_amount
  2. 用户点支付 → 服务端创建业务订单(unpaid)→ 签名调收银台 → 跳转 cashier_url
  3. 实现 POST /pay/notify:验签、核金额、把 unpaid 改为 paid(或按 event=refund 记账),返回 success
  4. 实现 GET /pay/result:只读自己库的状态,文案「支付处理中,请稍后刷新」。
  5. 后台退款走 POST /api/v1/refunds,同样等退款通知再改库存。

10. 联调检查清单

密钥只放各服务端环境变量,不要写进这份公开文档。