Dashboard 收入接口路径对齐;登录页去掉「授权管理平台」;Waffo 测试/生产密钥与 Product 映射分环境保存。 Co-authored-by: Cursor <cursoragent@cursor.com>
509 lines
14 KiB
Go
509 lines
14 KiB
Go
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
|
||
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"`
|
||
}
|
||
|
||
// 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(os.Getenv("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)
|
||
}
|
||
|
||
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
|
||
}
|
||
case "perpetual", "lifetime":
|
||
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 {
|
||
switch strings.TrimSpace(planType) {
|
||
case "yearly", "monthly":
|
||
if cfg.CurrencyYearly != "" {
|
||
return cfg.CurrencyYearly
|
||
}
|
||
case "perpetual", "lifetime":
|
||
if cfg.CurrencyPerpetual != "" {
|
||
return cfg.CurrencyPerpetual
|
||
}
|
||
}
|
||
if cfg.Currency != "" {
|
||
return cfg.Currency
|
||
}
|
||
return "CNY"
|
||
}
|
||
|
||
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()
|
||
}
|