package payment import ( "encoding/json" "fmt" "net/url" "os" "strings" "backend/internal/model" "backend/internal/service/appconfig" "gorm.io/gorm" ) // WaffoConfig hosted checkout settings for pancake.waffo.ai // Logan.Li type WaffoConfig struct { 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 } // WaffoProductMapping 可扩展计划 key → Product ID(与管理端表格对齐) // Logan.Li type WaffoProductMapping struct { PlanKey string `json:"plan_key"` Label string `json:"label,omitempty"` ProductID string `json:"product_id"` Currency string `json:"currency,omitempty"` } // 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 EnvProfiles map[string]WaffoEnvProfile `json:"env_profiles,omitempty"` // test/live 隔离映射 // 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(appconfig.String("SUCCESS_URL")) if success == "" { success = DefaultSuccessURL } currency := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY")) if currency == "" { currency = "CNY" } currencyYearly := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY_YEARLY")) if currencyYearly == "" { currencyYearly = currency } currencyPerpetual := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY_PERPETUAL")) if currencyPerpetual == "" { currencyPerpetual = currency } defaultProduct := strings.TrimSpace(os.Getenv("WAFFO_PRODUCT_ID")) yearly := strings.TrimSpace(os.Getenv("WAFFO_PRODUCT_ID_YEARLY")) perpetual := strings.TrimSpace(os.Getenv("WAFFO_PRODUCT_ID_PERPETUAL")) if yearly == "" { yearly = defaultProduct } 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, Currency: currency, CurrencyYearly: currencyYearly, CurrencyPerpetual: currencyPerpetual, Environment: env, SuccessURL: success, PrivateKey: strings.TrimSpace(priv), } } 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" } cfg.Environment = NormalizeWaffoEnvironment(cfg.Environment) meta.ApplyEnvProfile(cfg.Environment) // ApplyEnvProfile 后同步产品字段到 cfg // Logan.Li 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) } 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 strings.TrimSpace(gw.Currency) != "" && cfg.Currency == "" { cfg.Currency = gw.Currency } bag := ParseWaffoSecretBag(gw.SecretKey, cfg.Environment) if pk := bag.SecretForEnv(cfg.Environment); pk != "" { cfg.PrivateKey = pk } } 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", success) } if deviceID != "" { q.Set("deviceId", deviceID) } if orderNo != "" { q.Set("orderNo", orderNo) } if email != "" { q.Set("email", email) } if planType != "" { q.Set("planType", planType) } u.RawQuery = q.Encode() return u.String(), nil } // ResolveProductID 按 plan_type 解析 Waffo Product ID(可扩展映射优先) // Logan.Li func ResolveProductID(cfg WaffoConfig, planType string) string { return productIDForPlan(cfg, planType) } // ResolveCurrency 按 plan_type 解析结算货币 // Logan.Li func ResolveCurrency(cfg WaffoConfig, planType string) string { return currencyForPlan(cfg, planType) } func productIDForPlan(cfg WaffoConfig, planType string) string { 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": if cfg.ProductIDYearly != "" { return cfg.ProductIDYearly } // 禁止回落到永久 Product,避免测包年误用永久价/币种 // Logan.Li return "" case "perpetual", "lifetime": if cfg.ProductIDPerpetual != "" { return cfg.ProductIDPerpetual } if cfg.ProductID != "" { return cfg.ProductID } return "" } if id := strings.TrimSpace(cfg.ProductIDs["default"]); id != "" { return id } if cfg.ProductID != "" { return cfg.ProductID } return firstNonEmpty(cfg.ProductIDPerpetual, cfg.ProductIDYearly) } func currencyForPlan(cfg WaffoConfig, planType string) string { // 已知商品 ID 优先(测试包年不支持 CNY) // Logan.Li if pid := productIDForPlan(cfg, planType); pid != "" { switch pid { case "PROD_1BIezCi932IbgCzRIV8hcJ", // test yearly "PROD_0pcnzzT4nalEkkcdyIUoD5", // test perpetual "PROD_3BSJqDRkVBoYNgO78NsTAU": // live perpetual return "USD" case "PROD_5PqVJyEiCM4nDgVoA0RKjN": // live yearly return "CNY" } } switch strings.TrimSpace(strings.ToLower(planType)) { case "yearly", "monthly": if cfg.CurrencyYearly != "" { return cfg.CurrencyYearly } if NormalizeWaffoEnvironment(cfg.Environment) == "live" { return "CNY" } return "USD" case "perpetual", "lifetime": if cfg.CurrencyPerpetual != "" { return cfg.CurrencyPerpetual } return "USD" } if cfg.Currency != "" { return cfg.Currency } return "USD" } func checkoutTypeForPlan(planType string) string { // OpsXShell 包年 / 永久均为 Waffo onetime 商品(包年不再走 subscription)。 // Logan.Li _ = planType return "onetime" } func resolveBaseCheckoutURL(cfg WaffoConfig, planType string) (*url.URL, error) { productID := productIDForPlan(cfg, planType) if cfg.StoreSlug != "" && productID != "" { raw := fmt.Sprintf("https://pancake.waffo.ai/store/%s/product/%s", cfg.StoreSlug, productID) u, err := url.Parse(raw) if err != nil { return nil, err } q := u.Query() q.Set("type", checkoutTypeForPlan(planType)) q.Set("currency", currencyForPlan(cfg, planType)) if cfg.Environment == "test" { q.Set("test", "true") } u.RawQuery = q.Encode() return u, nil } if cfg.CheckoutURL != "" { return url.Parse(cfg.CheckoutURL) } return nil, fmt.Errorf("未配置 StoreSlug 与包年/永久 Product ID(或 CheckoutURL)") } func appendQuery(raw string, values map[string]string) string { u, err := url.Parse(raw) if err != nil { return raw } q := u.Query() for k, v := range values { if v != "" { q.Set(k, v) } } u.RawQuery = q.Encode() return u.String() }