# 原站接入文档 本文给**你自己的业务网站**看:如何安全地调本收银台完成支付和退款。 微信 / 支付宝的证书、商户号只配在本服务,原站**不要**直连微信或支付宝。 当前能力:微信 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 待签名串 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 防重放 | 字段 | 规则 | |---|---| | `timestamp` | Unix 秒。与本服务时差超过 **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 其它约束 - `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` ```json { "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` 字段) ```json { "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`) ```json { "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) ```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) ```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) ```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) ```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) ```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_no`、`pay_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. 联调检查清单 - [ ] `APP_SECRET` 与收银台 `.env` 一致,且未进前端包 - [ ] 签名字段排序、空值跳过、金额为整分字符串 - [ ] `timestamp` 为秒,误差小于 5 分钟 - [ ] 下单 401 时先打印待签名串两边是否一致 - [ ] 通知接口公网 HTTPS、无登录、应答 `success` - [ ] 不在 `return_url` 里发货 - [ ] 重复通知不会发两次货、退两次款 - [ ] 生产 `PUBLIC_BASE_URL`、`notify_url` 都不是 `127.0.0.1`