原站接入文档
本文给你自己的业务网站看:如何安全地调本收银台完成支付和退款。
微信 / 支付宝的证书、商户号只配在本服务,原站不要直连微信或支付宝。
当前能力:微信 Native 扫码、支付宝电脑网站支付、全额/部分退款。
1. 接入模型
用户在原站下单
│
│ ① 原站服务端 HMAC 签名
│ POST {收银台}/api/v1/orders
▼
收银台返回 cashier_url
│
│ ② 浏览器 302 / 跳转 cashier_url(用户扫码)
▼
支付成功
│
├─ ③ 收银台 POST 原站 notify_url ← 入账、发货只认这个
└─ ④ 浏览器跳回原站 return_url ← 只给人看,不能当成功依据
签名、下单、退款必须在原站服务端完成。禁止把 APP_SECRET 放到前端、小程序、App 包里。
约定:
| 项 | 值 |
|---|---|
| 收银台根地址 | https://easysale.online |
| 共享密钥 | 本服务 .env 的 APP_SECRET,与原站配置同一份 |
| 金额单位 | 分(整数)。1 元 = 100,1 分 = 1 |
| 时间 | timestamp 为 Unix 秒,字符串 |
2. 安全认证(必读)
双方用 HMAC-SHA256 证明请求来自对方,并防篡改。
2.1 待签名串
- 取出参与签名的字段(见各接口表格)。
- 去掉
sign。 - 去掉值为空的字段(空字符串不参与)。
- 按 字段名 ASCII 升序 拼接:
k1=v1&k2=v2&k3=v3。 - 值按原文拼接,不要 URL encode,不要 JSON 序列化后再签。
- 数字必须先转成十进制字符串再签,例如金额
1签"1",不要"1.0"。
2.2 计算签名
sign = hex( HMAC-SHA256( 待签名串, APP_SECRET ) )
十六进制小写。验签时大小写不敏感,但建议统一小写。
比较签名必须用恒定时间比较(Go hmac.Equal、PHP hash_equals),禁止 ==。
2.3 防重放
| 字段 | 规则 |
|---|---|
timestamp | Unix 秒。与本服务时差超过 5 分钟 拒绝(401 timestamp expired) |
nonce | 每次请求换一个随机串,参与签名,避免待签名串雷同 |
out_trade_no | 原站订单号,本服务内唯一。重复创建返回 409 |
out_refund_no | 原站退款单号,同一单号重复请求不会重复退 |
时钟请用 NTP。不要用可预测的 nonce(不要只用时间戳)。
2.4 原站必须做的校验(收到通知时)
- 验签(算法同上)。失败直接丢弃。
- 核对
out_trade_no在自己库里存在。 - 核对
amount与下单金额完全一致(支付通知);退款通知核对本次amount与退款单。 - 幂等:同一
pay_no/out_refund_no成功通知可能来多次,只处理一次。 - 只认通知里的
status:支付成功为SUCCESS,退款成功为REFUND_SUCCESS。 - 用
event区分退款:event=refund走退款逻辑;支付成功通知没有event字段。
伪造通知是真实资损来源。前端跳转、用户改 URL、微信「支付成功」文案都不能当入账依据。
2.5 其它约束
APP_SECRET足够长(建议 ≥ 32 字节随机),只放服务器环境变量,不要进 Git。- 原站
notify_url、return_url必须 HTTPS、公网可访问、不要做登录校验(回调没有用户 Cookie)。 - 建议在本服务
.env设置:
```
MERCHANT_ORIGIN=https://easysale.info
```
设置后,下单接口的 notify_url / return_url 的 scheme+host 必须与此一致,防止密钥泄露后被改去别人的地址。
- 收银台 URL 带
pay_no,知道链接的人能打开支付页。不要把未支付链接发到公开群;pay_no含随机后缀,但仍当半公开资源。 GET /api/v1/orders/{pay_no}给收银台轮询用,无签名。原站不要把它当管理接口暴露给浏览器乱查。对账以异步通知 + 原站自己的订单库为准。- 生产环境本服务
PUBLIC_BASE_URL必须是微信能访问的 HTTPS,不能是127.0.0.1/ 局域网 IP。
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 或不传则不参加签名 |
timestamp | 是 | 是 | Unix 秒,字符串 |
nonce | 是 | 是 | 随机串 |
sign | 是 | 否 | HMAC 结果 |
响应 200
{
"pay_no": "P20260903160559feca330e",
"cashier_url": "https://easysale.online/cashier/P20260903160559feca330e",
"expire_at": "2026-09-03T16:20:59+08:00"
}
原站对浏览器执行 302 到 cashier_url(或 window.location)。
错误
| HTTP | 含义 |
|---|---|
| 400 | 参数不合法 |
| 401 | 签名错误或 timestamp 过期 |
| 409 | out_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": "..."
}
channel 为 wechat 或 alipay。trade_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 次):
- 正文恰好
success(推荐) - 空 body
- JSON 含
"code":"success"或"status":"success"(不区分大小写)
不要在通知接口里做 302、不要输出 HTML。处理应尽快,先落库再发货。
5. 同步跳转(仅展示)
用户付完会跳到 return_url,本服务可能附带:
https://www.your-site.com/pay/result?pay_no=P...&out_trade_no=ORD...&status=SUCCESS
- Query 没有签名,用户可改。
- 结果页只显示「处理中 / 请稍候」,用原站库里的支付状态(由异步通知更新)。
- 未收到通知时,可在原站服务端拿
pay_no查本服务订单(见第 7 节),仍不要在浏览器里信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 # 向微信/支付宝再查一次
有用字段:status(created / paying / success / closed / refunded)、amount、refunded_amount、channel、refunds。
此接口无签名,仅建议原站服务端调用,不要做成对用户公开的 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. 原站推荐落地顺序
- 原站订单表增加:
pay_no、pay_status(unpaid / paid / refunded)、paid_amount。 - 用户点支付 → 服务端创建业务订单(unpaid)→ 签名调收银台 → 跳转
cashier_url。 - 实现
POST /pay/notify:验签、核金额、把 unpaid 改为 paid(或按event=refund记账),返回success。 - 实现
GET /pay/result:只读自己库的状态,文案「支付处理中,请稍后刷新」。 - 后台退款走
POST /api/v1/refunds,同样等退款通知再改库存。
10. 联调检查清单
APP_SECRET与收银台.env一致,且未进前端包- 签名字段排序、空值跳过、金额为整分字符串
timestamp为秒,误差小于 5 分钟- 下单 401 时先打印待签名串两边是否一致
- 通知接口公网 HTTPS、无登录、应答
success - 不在
return_url里发货 - 重复通知不会发两次货、退两次款
- 生产
PUBLIC_BASE_URL、notify_url都不是127.0.0.1
密钥只放各服务端环境变量,不要写进这份公开文档。