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) }