feat(server): pay v2 出站客户端(HMAC 签名下单/查单/retry/cancel)
This commit is contained in:
@@ -0,0 +1,175 @@
|
|||||||
|
package pay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session 是 pay 下单/retry 返回的渲染会话(render_type 多态,payload 原样透传给客户端)。
|
||||||
|
type Session struct {
|
||||||
|
RenderType string `json:"render_type"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderResult 对应 pay gateway.OrderResult。
|
||||||
|
type OrderResult struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
Session Session `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderStatus 对应 pay gateway.OrderStatusView(查单无 session)。
|
||||||
|
type OrderStatus struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
AmountMinor int64 `json:"amount_minor"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 是 pay 网关的业务错误({code,message} 包络 + HTTP 状态)。
|
||||||
|
type Error struct {
|
||||||
|
HTTPStatus int `json:"-"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
return fmt.Sprintf("pay: HTTP %d %s: %s", e.HTTPStatus, e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client 是 pay v2 出站客户端。仅 CreateOrder 需业务方 HMAC 签名
|
||||||
|
// (pay 只在 create 且 biz_system 非空时验签;查单/retry/cancel 以
|
||||||
|
// order_no 为持有凭据,pay 侧免签 + 改状态端点 per-IP 限流)。
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
system string
|
||||||
|
secret string
|
||||||
|
hc *http.Client
|
||||||
|
now func() time.Time // 测试注入
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL, system, secret string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
system: system,
|
||||||
|
secret: secret,
|
||||||
|
hc: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createOrderReq struct {
|
||||||
|
SKU string `json:"sku"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
BizSystem string `json:"biz_system"`
|
||||||
|
BizRef string `json:"biz_ref"`
|
||||||
|
ReturnURL string `json:"return_url,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrder 签名下单。metadata 只应含 pay 白名单键(is_mobile/render),
|
||||||
|
// 由 handler 层过滤;金额永远不出现在请求里(pay 按 sku+结算币种定价)。
|
||||||
|
func (c *Client) CreateOrder(ctx context.Context, sku, method, bizRef string, metadata map[string]string) (*OrderResult, error) {
|
||||||
|
body, err := json.Marshal(createOrderReq{
|
||||||
|
SKU: sku, Method: method, BizSystem: c.system, BizRef: bizRef, Metadata: metadata,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out OrderResult
|
||||||
|
if err := c.do(ctx, http.MethodPost, "/api/v2/orders", body, true, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetOrder(ctx context.Context, orderNo string) (*OrderStatus, error) {
|
||||||
|
var out OrderStatus
|
||||||
|
if err := c.do(ctx, http.MethodGet, "/api/v2/orders/"+url.PathEscape(orderNo), nil, false, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type retryReq struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry 换支付方式重试。跨结算币种 → pay 回 409 currency_mismatch
|
||||||
|
// (*Error),调用方按「取消旧单 + 新单」处理。
|
||||||
|
func (c *Client) Retry(ctx context.Context, orderNo, method string, metadata map[string]string) (*OrderResult, error) {
|
||||||
|
body, err := json.Marshal(retryReq{Method: method, Metadata: metadata})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out OrderResult
|
||||||
|
if err := c.do(ctx, http.MethodPost, "/api/v2/orders/"+url.PathEscape(orderNo)+"/retry", body, false, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Cancel(ctx context.Context, orderNo string) (bool, error) {
|
||||||
|
var out struct {
|
||||||
|
Canceled bool `json:"canceled"`
|
||||||
|
}
|
||||||
|
if err := c.do(ctx, http.MethodPost, "/api/v2/orders/"+url.PathEscape(orderNo)+"/cancel", nil, false, &out); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return out.Canceled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do 发送请求并解 {"data":...} 成功包络 / {code,message} 错误包络。
|
||||||
|
func (c *Client) do(ctx context.Context, httpMethod, path string, body []byte, signed bool, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, httpMethod, c.baseURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if signed {
|
||||||
|
ts := strconv.FormatInt(c.now().Unix(), 10)
|
||||||
|
nonce := newNonce()
|
||||||
|
req.Header.Set("X-Pay-System", c.system)
|
||||||
|
req.Header.Set("X-Pay-Timestamp", ts)
|
||||||
|
req.Header.Set("X-Pay-Nonce", nonce)
|
||||||
|
req.Header.Set("X-Pay-Sign", hmacSign(c.secret, c.system, ts, nonce, string(body)))
|
||||||
|
}
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rb, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
pe := &Error{HTTPStatus: resp.StatusCode}
|
||||||
|
if json.Unmarshal(rb, pe) != nil || pe.Code == "" {
|
||||||
|
pe.Code = "upstream_error"
|
||||||
|
pe.Message = strings.TrimSpace(string(rb))
|
||||||
|
}
|
||||||
|
return pe
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rb, &env); err != nil {
|
||||||
|
return fmt.Errorf("pay: 解析响应包络: %w", err)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(env.Data, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package pay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testSecret = "test-biz-secret"
|
||||||
|
|
||||||
|
// fakePay 模拟 pay v2:create 按 pay verifyBizSign 语义验签(±300s+HMAC),
|
||||||
|
// 其余端点免签(与 pay 一致:仅 create 且 biz_system 非空时验签)。
|
||||||
|
func fakePay(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyCreateSign(t *testing.T, r *http.Request, body []byte) {
|
||||||
|
t.Helper()
|
||||||
|
system := r.Header.Get("X-Pay-System")
|
||||||
|
ts := r.Header.Get("X-Pay-Timestamp")
|
||||||
|
nonce := r.Header.Get("X-Pay-Nonce")
|
||||||
|
sign := r.Header.Get("X-Pay-Sign")
|
||||||
|
if system != "pangolin" || ts == "" || nonce == "" || sign == "" {
|
||||||
|
t.Fatalf("签名头缺失: system=%q ts=%q nonce=%q sign=%q", system, ts, nonce, sign)
|
||||||
|
}
|
||||||
|
tsi, err := strconv.ParseInt(ts, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ts 非 unix 秒: %v", err)
|
||||||
|
}
|
||||||
|
if d := time.Now().Unix() - tsi; d > 300 || d < -300 {
|
||||||
|
t.Fatalf("ts 超 ±300s 窗口: %d", d)
|
||||||
|
}
|
||||||
|
if !hmacVerify(testSecret, sign, system, ts, nonce, string(body)) {
|
||||||
|
t.Fatal("HMAC 校验失败(须为 std base64 + \\n join)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOrder_SignsAndParses(t *testing.T) {
|
||||||
|
srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v2/orders" {
|
||||||
|
t.Fatalf("意外请求: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
verifyCreateSign(t, r, body)
|
||||||
|
var req map[string]any
|
||||||
|
_ = json.Unmarshal(body, &req)
|
||||||
|
if req["sku"] != "pro_month" || req["method"] != "crypto" ||
|
||||||
|
req["biz_system"] != "pangolin" || req["biz_ref"] != "uuid-1" {
|
||||||
|
t.Fatalf("下单 body 不符: %v", req)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"order_no":"pay123","session":{
|
||||||
|
"render_type":"crypto_address",
|
||||||
|
"payload":{"address":"Txx","amount":"4.201234","amount_minor":4201234,
|
||||||
|
"currency":"USDT","network":"TRC20","contract":"Tcc"},
|
||||||
|
"expires_at":"2026-07-10T12:00:00Z"}}}`))
|
||||||
|
})
|
||||||
|
c := NewClient(srv.URL, "pangolin", testSecret)
|
||||||
|
res, err := c.CreateOrder(t.Context(), "pro_month", "crypto", "uuid-1", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateOrder: %v", err)
|
||||||
|
}
|
||||||
|
if res.OrderNo != "pay123" || res.Session.RenderType != "crypto_address" {
|
||||||
|
t.Fatalf("解析不符: %+v", res)
|
||||||
|
}
|
||||||
|
if got := res.Session.Payload["address"]; got != "Txx" {
|
||||||
|
t.Errorf("payload.address = %v", got)
|
||||||
|
}
|
||||||
|
if res.Session.ExpiresAt == nil {
|
||||||
|
t.Error("expires_at 未解析")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrder_ParsesStatus(t *testing.T) {
|
||||||
|
srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v2/orders/pay123" {
|
||||||
|
t.Fatalf("path = %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"order_no":"pay123","status":"succeeded",
|
||||||
|
"subject":"Pangolin 专业版·月付","amount_minor":4201234,"currency":"USDT",
|
||||||
|
"paid_at":"2026-07-10T11:00:00Z"}}`))
|
||||||
|
})
|
||||||
|
c := NewClient(srv.URL, "pangolin", testSecret)
|
||||||
|
st, err := c.GetOrder(t.Context(), "pay123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrder: %v", err)
|
||||||
|
}
|
||||||
|
if st.Status != "succeeded" || st.AmountMinor != 4201234 || st.PaidAt == nil {
|
||||||
|
t.Fatalf("解析不符: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetry_CurrencyMismatchMapsToError(t *testing.T) {
|
||||||
|
srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v2/orders/pay123/retry" {
|
||||||
|
t.Fatalf("path = %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
_, _ = w.Write([]byte(`{"code":"currency_mismatch","message":"换渠道需新单"}`))
|
||||||
|
})
|
||||||
|
c := NewClient(srv.URL, "pangolin", testSecret)
|
||||||
|
_, err := c.Retry(t.Context(), "pay123", "alipay", nil)
|
||||||
|
var pe *Error
|
||||||
|
if !errors.As(err, &pe) || pe.Code != "currency_mismatch" || pe.HTTPStatus != http.StatusConflict {
|
||||||
|
t.Fatalf("err = %v, want *pay.Error{409 currency_mismatch}", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancel(t *testing.T) {
|
||||||
|
srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v2/orders/pay123/cancel" {
|
||||||
|
t.Fatalf("path = %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"canceled":true}}`))
|
||||||
|
})
|
||||||
|
c := NewClient(srv.URL, "pangolin", testSecret)
|
||||||
|
ok, err := c.Cancel(t.Context(), "pay123")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("Cancel = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Package pay 实现 pangolin 作为业务方接入 pay v2 统一支付网关:出站客户端
|
||||||
|
// (签名下单/查单/换渠道/取消)、购买台账、App 代理端点与 payment.succeeded
|
||||||
|
// webhook 接收器。契约真相源:pay 仓 design/pay-v2 分支(见计划文档头)。
|
||||||
|
package pay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hmacSign 逐字节照抄 pay internal/util/sign.go::HMACSign:
|
||||||
|
// HMAC-SHA256(secret, strings.Join(parts, "\n")) → 标准 base64(非 hex/url-safe)。
|
||||||
|
func hmacSign(secret string, parts ...string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(strings.Join(parts, "\n")))
|
||||||
|
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hmacVerify(secret, sig string, parts ...string) bool {
|
||||||
|
expected := hmacSign(secret, parts...)
|
||||||
|
return hmac.Equal([]byte(expected), []byte(sig))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newNonce 返回 32 字符随机 hex(不新增 uuid 依赖)。
|
||||||
|
func newNonce() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user