Files
opsx-licence/backend/internal/service/payment/waffo_api.go
T
Logan.LiandCursor 8cc2c83f9d feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 20:25:46 +08:00

221 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]
}