diff --git a/server/internal/pay/client.go b/server/internal/pay/client.go new file mode 100644 index 0000000..a981d07 --- /dev/null +++ b/server/internal/pay/client.go @@ -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) +} diff --git a/server/internal/pay/client_test.go b/server/internal/pay/client_test.go new file mode 100644 index 0000000..2109282 --- /dev/null +++ b/server/internal/pay/client_test.go @@ -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) + } +} diff --git a/server/internal/pay/sign.go b/server/internal/pay/sign.go new file mode 100644 index 0000000..184eb63 --- /dev/null +++ b/server/internal/pay/sign.go @@ -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) +}