feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强

支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 20:25:46 +08:00
1 parent 0966375076
commit 8cc2c83f9d
41 files changed
+3177 -332

No files matched your search

+326 -26
View File
@@ -1,35 +1,91 @@
package payment
import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"backend/internal/model"
"gorm.io/gorm"
)
// WaffoConfig hosted checkout settings for pancake.waffo.ai // Logan.Li
type WaffoConfig struct {
CheckoutURL string
StoreSlug string
ProductID string
ProductIDYearly string
ProductIDPerpetual string
Currency string // fallback / default
CurrencyYearly string
CurrencyPerpetual string
Environment string
SuccessURL string
CheckoutURL string
StoreSlug string
StoreID string
MerchantID string
ProductID string
ProductIDYearly string
ProductIDPerpetual string
// ProductIDs 可扩展 plan_type → Product ID;优先于固定 yearly/perpetual 字段 // Logan.Li
ProductIDs map[string]string
Currency string // fallback / default
CurrencyYearly string
CurrencyPerpetual string
Environment string
SuccessURL string
// PrivateKey RSA 私钥原文(PEM 或无头 Base64);仅运行时使用,勿写入 API 响应 // Logan.Li
PrivateKey string
}
// LoadWaffoConfig reads WAFFO_* env vars (same names as OpsXShell legacy license-server). // Logan.Li
func LoadWaffoConfig() WaffoConfig {
// WaffoProductMapping 可扩展计划 key → Product ID(与管理端表格对齐) // Logan.Li
type WaffoProductMapping struct {
PlanKey string `json:"plan_key"`
Label string `json:"label,omitempty"`
ProductID string `json:"product_id"`
}
// WaffoPublicMeta 存入 payment_gateway_configs.public_key 的 JSON(无私钥) // Logan.Li
type WaffoPublicMeta struct {
MerchantID string `json:"merchant_id"`
StoreID string `json:"store_id"`
StoreSlug string `json:"store_slug"`
ProductID string `json:"product_id"`
ProductIDYearly string `json:"product_id_yearly"`
ProductIDPerpetual string `json:"product_id_perpetual"`
ProductIDs map[string]string `json:"product_ids,omitempty"` // 可扩展映射,兼容旧字段 // Logan.Li
ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"` // 前端表格(含 label) // Logan.Li
SuccessURL string `json:"success_url"`
Currency string `json:"currency"`
CurrencyYearly string `json:"currency_yearly"`
CurrencyPerpetual string `json:"currency_perpetual"`
Environment string `json:"environment"`
CheckoutURL string `json:"checkout_url"`
}
// DefaultWebhookURL 对外 Webhook 地址(只读提示) // Logan.Li
const DefaultWebhookURL = "https://l123.91ai.io/api/v1/webhooks/waffo"
// DefaultSuccessURL 支付成功回跳默认地址 // Logan.Li
const DefaultSuccessURL = "https://l123.91ai.io/api/v1/purchase/success"
// HasAPIKeyAuth 是否具备商户 API Key 鉴权(Merchant ID + 私钥) // Logan.Li
func (c WaffoConfig) HasAPIKeyAuth() bool {
return strings.TrimSpace(c.MerchantID) != "" && strings.TrimSpace(c.PrivateKey) != ""
}
// LoadWaffoConfig 读取配置:后台 payment_gateway_configs 优先,.env 兜底 // Logan.Li
func LoadWaffoConfig(db *gorm.DB) WaffoConfig {
cfg := loadWaffoFromEnv()
if db != nil {
mergeWaffoFromDB(db, &cfg)
}
normalizeWaffoConfig(&cfg)
return cfg
}
func loadWaffoFromEnv() WaffoConfig {
env := strings.TrimSpace(os.Getenv("WAFFO_ENVIRONMENT"))
if env == "" {
env = "test"
}
success := strings.TrimSpace(os.Getenv("SUCCESS_URL"))
if success == "" {
success = "http://l123.91ai.io/api/v1/purchase/success"
success = DefaultSuccessURL
}
currency := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY"))
if currency == "" {
@@ -52,9 +108,16 @@ func LoadWaffoConfig() WaffoConfig {
if perpetual == "" {
perpetual = defaultProduct
}
priv := firstNonEmpty(
os.Getenv("WAFFO_PRIVATE_KEY"),
os.Getenv("WAFFO_PRIVATE_KEY_PEM"),
os.Getenv("WAFFO_PRIVATE_KEY_BASE64"),
)
return WaffoConfig{
CheckoutURL: strings.TrimSpace(os.Getenv("WAFFO_CHECKOUT_URL")),
StoreSlug: strings.TrimSpace(os.Getenv("WAFFO_STORE_SLUG")),
StoreID: strings.TrimSpace(os.Getenv("WAFFO_STORE_ID")),
MerchantID: strings.TrimSpace(os.Getenv("WAFFO_MERCHANT_ID")),
ProductID: defaultProduct,
ProductIDYearly: yearly,
ProductIDPerpetual: perpetual,
@@ -63,27 +126,244 @@ func LoadWaffoConfig() WaffoConfig {
CurrencyPerpetual: currencyPerpetual,
Environment: env,
SuccessURL: success,
PrivateKey: strings.TrimSpace(priv),
}
}
// BuildCheckoutURL returns a Waffo hosted checkout URL with order/device/email metadata. // Logan.Li
func mergeWaffoFromDB(db *gorm.DB, cfg *WaffoConfig) {
var gw model.PaymentGatewayConfig
if err := db.Where("gateway = ?", "waffo").First(&gw).Error; err != nil {
return
}
meta := ParseWaffoPublicMeta(gw.PublicKey)
if meta.MerchantID != "" {
cfg.MerchantID = meta.MerchantID
}
if meta.StoreID != "" {
cfg.StoreID = meta.StoreID
}
if meta.StoreSlug != "" {
cfg.StoreSlug = meta.StoreSlug
}
if meta.ProductID != "" {
cfg.ProductID = meta.ProductID
}
if meta.ProductIDYearly != "" {
cfg.ProductIDYearly = meta.ProductIDYearly
}
if meta.ProductIDPerpetual != "" {
cfg.ProductIDPerpetual = meta.ProductIDPerpetual
}
if len(meta.ProductIDs) > 0 {
cfg.ProductIDs = cloneStringMap(meta.ProductIDs)
}
// product_mappings 优先补齐映射(可扩展 key) // Logan.Li
if len(meta.ProductMappings) > 0 {
if cfg.ProductIDs == nil {
cfg.ProductIDs = map[string]string{}
}
for _, m := range meta.ProductMappings {
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
v := strings.TrimSpace(m.ProductID)
if k == "" || v == "" {
continue
}
cfg.ProductIDs[k] = v
}
}
if meta.SuccessURL != "" {
cfg.SuccessURL = meta.SuccessURL
}
if meta.Currency != "" {
cfg.Currency = meta.Currency
}
if meta.CurrencyYearly != "" {
cfg.CurrencyYearly = meta.CurrencyYearly
}
if meta.CurrencyPerpetual != "" {
cfg.CurrencyPerpetual = meta.CurrencyPerpetual
}
if meta.CheckoutURL != "" {
cfg.CheckoutURL = meta.CheckoutURL
}
if meta.Environment != "" {
cfg.Environment = meta.Environment
} else if gw.SandboxMode {
cfg.Environment = "test"
} else {
cfg.Environment = "live"
}
if strings.TrimSpace(gw.Currency) != "" && cfg.Currency == "" {
cfg.Currency = gw.Currency
}
if strings.TrimSpace(gw.SecretKey) != "" {
cfg.PrivateKey = strings.TrimSpace(gw.SecretKey)
}
}
func normalizeWaffoConfig(cfg *WaffoConfig) {
if cfg.Currency == "" {
cfg.Currency = "CNY"
}
if cfg.CurrencyYearly == "" {
cfg.CurrencyYearly = cfg.Currency
}
if cfg.CurrencyPerpetual == "" {
cfg.CurrencyPerpetual = cfg.Currency
}
if cfg.ProductIDs == nil {
cfg.ProductIDs = map[string]string{}
}
// 旧字段回填到可扩展映射,便于统一解析 // Logan.Li
if cfg.ProductIDYearly != "" {
if _, ok := cfg.ProductIDs["yearly"]; !ok {
cfg.ProductIDs["yearly"] = cfg.ProductIDYearly
}
if _, ok := cfg.ProductIDs["monthly"]; !ok {
cfg.ProductIDs["monthly"] = cfg.ProductIDYearly
}
}
if cfg.ProductIDPerpetual != "" {
if _, ok := cfg.ProductIDs["perpetual"]; !ok {
cfg.ProductIDs["perpetual"] = cfg.ProductIDPerpetual
}
if _, ok := cfg.ProductIDs["lifetime"]; !ok {
cfg.ProductIDs["lifetime"] = cfg.ProductIDPerpetual
}
}
if cfg.ProductID != "" {
if _, ok := cfg.ProductIDs["default"]; !ok {
cfg.ProductIDs["default"] = cfg.ProductID
}
}
// 映射回写固定字段,保持 GET 响应兼容 // Logan.Li
if v := cfg.ProductIDs["yearly"]; v != "" {
cfg.ProductIDYearly = v
}
if v := cfg.ProductIDs["perpetual"]; v != "" {
cfg.ProductIDPerpetual = v
}
if cfg.ProductIDYearly == "" {
cfg.ProductIDYearly = cfg.ProductID
}
if cfg.ProductIDPerpetual == "" {
cfg.ProductIDPerpetual = cfg.ProductID
}
if cfg.SuccessURL == "" {
cfg.SuccessURL = DefaultSuccessURL
}
env := strings.ToLower(strings.TrimSpace(cfg.Environment))
switch env {
case "prod", "production", "live":
cfg.Environment = "live"
default:
cfg.Environment = "test"
}
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return map[string]string{}
}
out := make(map[string]string, len(in))
for k, v := range in {
k = strings.TrimSpace(strings.ToLower(k))
v = strings.TrimSpace(v)
if k == "" || v == "" {
continue
}
out[k] = v
}
return out
}
// ParseWaffoPublicMeta 解析 public_key:JSON 或纯 Merchant ID 字符串 // Logan.Li
func ParseWaffoPublicMeta(raw string) WaffoPublicMeta {
raw = strings.TrimSpace(raw)
if raw == "" {
return WaffoPublicMeta{}
}
if strings.HasPrefix(raw, "{") {
var meta WaffoPublicMeta
if err := json.Unmarshal([]byte(raw), &meta); err == nil {
return meta
}
}
// 兼容旧值:public_key 直接存商户 ID // Logan.Li
if strings.HasPrefix(raw, "MER_") {
return WaffoPublicMeta{MerchantID: raw}
}
return WaffoPublicMeta{}
}
// EncodeWaffoPublicMeta 序列化公开配置 JSON // Logan.Li
func EncodeWaffoPublicMeta(meta WaffoPublicMeta) (string, error) {
b, err := json.Marshal(meta)
if err != nil {
return "", fmt.Errorf("序列化 Waffo 配置失败: %w", err)
}
return string(b), nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// BuildCheckoutURL returns a Waffo checkout URL via official create-session API
// (orderMerchantExternalId + metadata). Hosted store page is last-resort fallback only. // Logan.Li
func BuildCheckoutURL(cfg WaffoConfig, orderNo, deviceID, email, returnURL, planType string) (string, error) {
success := cfg.SuccessURL
if strings.TrimSpace(returnURL) != "" {
success = strings.TrimSpace(returnURL)
}
success = appendQuery(success, map[string]string{
"orderNo": orderNo,
"deviceId": deviceID,
"email": email,
"planType": planType,
})
productID := productIDForPlan(cfg, planType)
// Prefer create-session whenever Store Slug or API Key is available. // Logan.Li
if cfg.HasAPIKeyAuth() || strings.TrimSpace(cfg.StoreSlug) != "" {
if productID == "" {
return "", fmt.Errorf("未配置包年/永久 Product ID")
}
session, err := CreateCheckoutSession(cfg, CreateSessionRequest{
ProductID: productID,
ProductType: checkoutTypeForPlan(planType),
Currency: currencyForPlan(cfg, planType),
BuyerEmail: email,
SuccessURL: success,
OrderMerchantExternalID: orderNo,
Metadata: map[string]string{
"orderNo": orderNo,
"deviceId": deviceID,
"email": email,
"planType": planType,
},
StoreSlug: cfg.StoreSlug,
Environment: cfg.Environment,
})
if err != nil {
return "", err
}
return session.CheckoutURL, nil
}
u, err := resolveBaseCheckoutURL(cfg, planType)
if err != nil {
return "", err
}
q := u.Query()
if q.Get("successUrl") == "" {
q.Set("successUrl", appendQuery(success, map[string]string{
"orderNo": orderNo,
"deviceId": deviceID,
"email": email,
"planType": planType,
}))
q.Set("successUrl", success)
}
if deviceID != "" {
q.Set("deviceId", deviceID)
@@ -101,18 +381,39 @@ func BuildCheckoutURL(cfg WaffoConfig, orderNo, deviceID, email, returnURL, plan
return u.String(), nil
}
// ResolveProductID 按 plan_type 解析 Waffo Product ID(可扩展映射优先) // Logan.Li
func ResolveProductID(cfg WaffoConfig, planType string) string {
return productIDForPlan(cfg, planType)
}
func productIDForPlan(cfg WaffoConfig, planType string) string {
switch strings.TrimSpace(planType) {
plan := strings.TrimSpace(strings.ToLower(planType))
if plan == "" {
plan = "default"
}
if id := strings.TrimSpace(cfg.ProductIDs[plan]); id != "" {
return id
}
switch plan {
case "yearly", "monthly":
return cfg.ProductIDYearly
if cfg.ProductIDYearly != "" {
return cfg.ProductIDYearly
}
case "perpetual", "lifetime":
return cfg.ProductIDPerpetual
default:
if cfg.ProductIDPerpetual != "" {
return cfg.ProductIDPerpetual
}
}
if id := strings.TrimSpace(cfg.ProductIDs["default"]); id != "" {
return id
}
if cfg.ProductID != "" {
return cfg.ProductID
}
if cfg.ProductIDPerpetual != "" {
return cfg.ProductIDPerpetual
}
return cfg.ProductIDYearly
}
func currencyForPlan(cfg WaffoConfig, planType string) string {
@@ -139,7 +440,6 @@ func checkoutTypeForPlan(planType string) string {
}
func resolveBaseCheckoutURL(cfg WaffoConfig, planType string) (*url.URL, error) {
// Prefer store + per-plan product IDs so yearly/perpetual map to different Waffo SKUs.
productID := productIDForPlan(cfg, planType)
if cfg.StoreSlug != "" && productID != "" {
raw := fmt.Sprintf("https://pancake.waffo.ai/store/%s/product/%s", cfg.StoreSlug, productID)
@@ -159,7 +459,7 @@ func resolveBaseCheckoutURL(cfg WaffoConfig, planType string) (*url.URL, error)
if cfg.CheckoutURL != "" {
return url.Parse(cfg.CheckoutURL)
}
return nil, fmt.Errorf("未配置 WAFFO_STORE_SLUG 与 WAFFO_PRODUCT_ID_YEARLY / WAFFO_PRODUCT_ID_PERPETUAL(或 WAFFO_CHECKOUT_URL)")
return nil, fmt.Errorf("未配置 StoreSlug 与包年/永久 Product ID(或 CheckoutURL)")
}
func appendQuery(raw string, values map[string]string) string {
@@ -0,0 +1,220 @@
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]
}
+24 -14
View File
@@ -6,7 +6,7 @@ import (
"testing"
)
func TestBuildCheckoutURL_YearlyOnetimeCNY(t *testing.T) {
func TestResolveBaseCheckoutURL_YearlyOnetimeCNY(t *testing.T) {
cfg := WaffoConfig{
StoreSlug: "opsxshell-pwsi6m4n",
ProductIDYearly: "PROD_1BIezCi932IbgCzRIV8hcJ",
@@ -17,16 +17,12 @@ func TestBuildCheckoutURL_YearlyOnetimeCNY(t *testing.T) {
Environment: "test",
SuccessURL: "opsxshell://pro/activated",
}
raw, err := BuildCheckoutURL(cfg, "ORD1", "dev-1", "a@b.c", "", "yearly")
if err != nil {
t.Fatal(err)
}
u, err := url.Parse(raw)
u, err := resolveBaseCheckoutURL(cfg, "yearly")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(u.Path, "PROD_1BIezCi932IbgCzRIV8hcJ") {
t.Fatalf("yearly product missing: %s", raw)
t.Fatalf("yearly product missing: %s", u.String())
}
q := u.Query()
if q.Get("type") != "onetime" {
@@ -37,7 +33,7 @@ func TestBuildCheckoutURL_YearlyOnetimeCNY(t *testing.T) {
}
}
func TestBuildCheckoutURL_PerpetualOnetimeCNY(t *testing.T) {
func TestResolveBaseCheckoutURL_PerpetualOnetimeCNY(t *testing.T) {
cfg := WaffoConfig{
StoreSlug: "opsxshell-pwsi6m4n",
ProductIDYearly: "PROD_1BIezCi932IbgCzRIV8hcJ",
@@ -48,16 +44,12 @@ func TestBuildCheckoutURL_PerpetualOnetimeCNY(t *testing.T) {
Environment: "test",
SuccessURL: "opsxshell://pro/activated",
}
raw, err := BuildCheckoutURL(cfg, "ORD2", "dev-1", "a@b.c", "", "perpetual")
if err != nil {
t.Fatal(err)
}
u, err := url.Parse(raw)
u, err := resolveBaseCheckoutURL(cfg, "perpetual")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(u.Path, "PROD_0pcnzzT4nalEkkcdyIUoD5") {
t.Fatalf("perpetual product missing: %s", raw)
t.Fatalf("perpetual product missing: %s", u.String())
}
q := u.Query()
if q.Get("type") != "onetime" {
@@ -67,3 +59,21 @@ func TestBuildCheckoutURL_PerpetualOnetimeCNY(t *testing.T) {
t.Fatalf("currency=%s want CNY", q.Get("currency"))
}
}
func TestBuildCheckoutURL_HostedFallbackWithoutSlug(t *testing.T) {
cfg := WaffoConfig{
CheckoutURL: "https://pancake.waffo.ai/store/opsxshell-pwsi6m4n/product/PROD_1BIezCi932IbgCzRIV8hcJ?type=onetime&currency=CNY&test=true",
SuccessURL: "http://l123.91ai.io/api/v1/purchase/success",
}
raw, err := BuildCheckoutURL(cfg, "ORD1", "dev-1", "a@b.c", "", "yearly")
if err != nil {
t.Fatal(err)
}
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if u.Query().Get("orderNo") != "ORD1" {
t.Fatalf("orderNo missing: %s", raw)
}
}