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)
|
||||
}
|
||||
Reference in New Issue
Block a user