package payment import ( "bytes" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "strings" "time" ) const waffoAPIBase = "https://api.waffo.ai" // CreateSessionRequest API Key / Store Slug checkout session (official create-session). // Logan.Li type CreateSessionRequest struct { ProductID string `json:"productId"` ProductType string `json:"productType"` Currency string `json:"currency"` BuyerEmail string `json:"buyerEmail,omitempty"` SuccessURL string `json:"successUrl,omitempty"` OrderMerchantExternalID string `json:"orderMerchantExternalId,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` ExpiresInSeconds int `json:"expiresInSeconds,omitempty"` // Auth overrides (optional). // Logan.Li StoreSlug string `json:"-"` Environment string `json:"-"` } // CreateSessionResult checkout session response. // Logan.Li type CreateSessionResult struct { SessionID string `json:"sessionId"` CheckoutURL string `json:"checkoutUrl"` ExpiresAt string `json:"expiresAt"` } // CreateCheckoutSession creates a Waffo checkout session with orderMerchantExternalId. // Prefers API Key (Merchant ID + RSA); falls back to Store Slug + X-Environment. // Logan.Li func CreateCheckoutSession(cfg WaffoConfig, req CreateSessionRequest) (*CreateSessionResult, error) { if strings.TrimSpace(req.ProductID) == "" || strings.TrimSpace(req.Currency) == "" { return nil, fmt.Errorf("productId 与 currency 必填") } if req.ProductType == "" { req.ProductType = "onetime" } if req.ExpiresInSeconds <= 0 { req.ExpiresInSeconds = 2700 } bodyBytes, err := json.Marshal(req) if err != nil { return nil, err } path := "/v1/actions/checkout/create-session" httpReq, err := http.NewRequest(http.MethodPost, waffoAPIBase+path, bytes.NewReader(bodyBytes)) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/json") if cfg.HasAPIKeyAuth() { timestamp := fmt.Sprintf("%d", time.Now().Unix()) sig, err := signAPIRequest(cfg.PrivateKey, "POST", path, timestamp, bodyBytes) if err != nil { return nil, err } httpReq.Header.Set("X-Merchant-Id", strings.TrimSpace(cfg.MerchantID)) httpReq.Header.Set("X-Timestamp", timestamp) httpReq.Header.Set("X-Signature", sig) } else { slug := strings.TrimSpace(req.StoreSlug) if slug == "" { slug = strings.TrimSpace(cfg.StoreSlug) } if slug == "" { return nil, fmt.Errorf("未配置商户 ID/私钥,且缺少 Store Slug,无法创建收银台会话") } env := strings.TrimSpace(req.Environment) if env == "" { env = strings.TrimSpace(cfg.Environment) } // Waffo Store Slug 鉴权要求 X-Environment 为 test|prod(不是 live) // Logan.Li switch strings.ToLower(env) { case "live", "prod", "production": env = "prod" default: env = "test" } httpReq.Header.Set("X-Store-Slug", slug) httpReq.Header.Set("X-Environment", env) } client := &http.Client{Timeout: 20 * time.Second} resp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("Waffo create-session 请求失败: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) var envelope struct { Data *CreateSessionResult `json:"data"` Errors []struct { Message string `json:"message"` } `json:"errors"` } if err := json.Unmarshal(respBody, &envelope); err != nil { return nil, fmt.Errorf("Waffo 响应无法解析 (%d): %s", resp.StatusCode, truncate(string(respBody), 200)) } if resp.StatusCode >= 300 || envelope.Data == nil || envelope.Data.CheckoutURL == "" { msg := strings.TrimSpace(string(respBody)) if len(envelope.Errors) > 0 && envelope.Errors[0].Message != "" { msg = envelope.Errors[0].Message } return nil, fmt.Errorf("Waffo create-session 失败 (%d): %s", resp.StatusCode, truncate(msg, 300)) } return envelope.Data, nil } func signAPIRequest(privateKeyMaterial, method, path, timestamp string, body []byte) (string, error) { priv, err := ParseMerchantPrivateKey(privateKeyMaterial) if err != nil { return "", err } sum := sha256.Sum256(body) bodyHash := base64.StdEncoding.EncodeToString(sum[:]) canonical := method + "\n" + path + "\n" + timestamp + "\n" + bodyHash hashed := sha256.Sum256([]byte(canonical)) sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hashed[:]) if err != nil { return "", fmt.Errorf("RSA 签名失败: %w", err) } return base64.StdEncoding.EncodeToString(sig), nil } // ParseMerchantPrivateKey 解析 RSA 私钥:PEM、无头 Base64(PKCS8/PKCS1 DER)均支持 // Logan.Li func ParseMerchantPrivateKey(raw string) (*rsa.PrivateKey, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, fmt.Errorf("私钥为空") } raw = strings.ReplaceAll(raw, `\n`, "\n") if key, err := parseRSAFromPEMOrDER([]byte(raw)); err == nil { return key, nil } // 纯 Base64 体(Dashboard「复制密钥」常见形态) // Logan.Li compact := strings.Map(func(r rune) rune { if r == '\n' || r == '\r' || r == ' ' || r == '\t' { return -1 } return r }, raw) if decoded, err := base64.StdEncoding.DecodeString(compact); err == nil { if key, err2 := parseRSAFromPEMOrDER(decoded); err2 == nil { return key, nil } // 包一层 PKCS8 PEM 再试 // Logan.Li pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: decoded}) if key, err2 := parseRSAFromPEMOrDER(pemBytes); err2 == nil { return key, nil } } // 无头 Base64 拼成 PEM // Logan.Li wrapped := "-----BEGIN PRIVATE KEY-----\n" + chunkBase64(compact, 64) + "\n-----END PRIVATE KEY-----\n" if key, err := parseRSAFromPEMOrDER([]byte(wrapped)); err == nil { return key, nil } return nil, fmt.Errorf("无法解析 RSA 私钥(请粘贴 PEM 或 Dashboard Base64 密钥)") } func parseRSAFromPEMOrDER(raw []byte) (*rsa.PrivateKey, error) { if block, _ := pem.Decode(raw); block != nil { return parseRSAKeyBytes(block.Bytes) } return parseRSAKeyBytes(raw) } func parseRSAKeyBytes(der []byte) (*rsa.PrivateKey, error) { if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { if rsaKey, ok := key.(*rsa.PrivateKey); ok { return rsaKey, nil } return nil, fmt.Errorf("私钥不是 RSA") } if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { return key, nil } return nil, fmt.Errorf("不支持的私钥格式") } func chunkBase64(s string, size int) string { if size <= 0 { return s } var b strings.Builder for i := 0; i < len(s); i += size { end := i + size if end > len(s) { end = len(s) } if i > 0 { b.WriteByte('\n') } b.WriteString(s[i:end]) } return b.String() } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] }