Files
opsx-licence/backend/internal/service/payment/waffo.go
T
Logan.Li 83a3630404
CI / backend (push) Canceled after 0s
feat(settings): 运行配置中心——.env 业务参数统一到后台页面维护
- 新增 app_configs 表与 appconfig 注册表:读取优先级 页面 → .env → 默认,删除即回退
- 页面可配 7 项:支付回调基址、成功回跳、Webhook 密钥(加密入库)、未验签放行开关、CORS、可信代理、GeoIP 路径
- 引导密钥(JWT_SECRET / OPSLIC_MASTER_KEY / OFFLINE_LICENSE_SEED)与部署参数只读展示掩码+指纹,永不入库
- 校验:URL 格式、密钥长度≥16、CIDR、release 下禁止开启未验签回调;变更写审计日志
- 消费点改造:支付回调地址、Waffo 回跳、webhook 验签、可信代理、CORS、GeoIP 统一走配置中心
- 前端新增「审计与设置 → 运行配置」页(分组表格 + 来源标记 + 编辑弹窗 + 恢复默认)
- .env.example 补注释:哪些必须留在 .env、哪些已收敛到页面
2026-11-01 06:17:58 +08:00

535 lines
15 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"
"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()
}