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

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

479 lines
13 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 (
"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
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"
}
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", 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()
}