支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
221 lines
6.6 KiB
Go
221 lines
6.6 KiB
Go
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)
|
||
}
|
||
if env == "" {
|
||
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]
|
||
}
|