From 7c146cb639983cf12374716ec26c3d034e707e37 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Wed, 12 Aug 2026 23:35:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E9=A1=B5=E6=B5=8B=E8=AF=95/=E7=94=9F=E4=BA=A7=20Product=20?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E4=B8=B2=E7=94=A8=E4=B8=8E=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切换环境时先暂存再切换;后端 Profile 不再串用另一侧商品;支持一键填充 OpsXshell 与跨环境复制,调试使用当前页映射。 --- backend/internal/dto/analytics.go | 16 +- backend/internal/service/payment/waffo.go | 6 + backend/internal/service/payment/waffo_api.go | 6 +- .../service/setting/setting_service.go | 180 ++++++++++++++++-- frontend/src/views/Payments.vue | 180 +++++++++++++----- 5 files changed, 317 insertions(+), 71 deletions(-) diff --git a/backend/internal/dto/analytics.go b/backend/internal/dto/analytics.go index 19211e5..a8a3a23 100644 --- a/backend/internal/dto/analytics.go +++ b/backend/internal/dto/analytics.go @@ -89,6 +89,7 @@ type WaffoProductMapping struct { PlanKey string `json:"plan_key"` Label string `json:"label,omitempty"` ProductID string `json:"product_id"` + Currency string `json:"currency,omitempty"` // 如 CNY / USD,空则用全局默认 // Logan.Li } // WaffoSettingsResponse 管理端 Waffo 配置(私钥不回显) // Logan.Li @@ -130,6 +131,8 @@ type WaffoSettingsUpdate struct { StoreSlug string `json:"store_slug"` Environment string `json:"environment"` // test | live | prod Currency string `json:"currency"` + CurrencyYearly string `json:"currency_yearly"` + CurrencyPerpetual string `json:"currency_perpetual"` ProductID string `json:"product_id"` ProductIDYearly string `json:"product_id_yearly"` ProductIDPerpetual string `json:"product_id_perpetual"` @@ -142,9 +145,16 @@ type WaffoSettingsUpdate struct { // WaffoDebugRequest 用当前配置探测 create-session(可覆盖 plan/product) // Logan.Li type WaffoDebugRequest struct { - PlanType string `json:"plan_type"` // yearly | perpetual | …,默认 yearly - ProductID string `json:"product_id"` // 可选覆盖 - BuyerEmail string `json:"buyer_email"` // 可选 + PlanType string `json:"plan_type"` // yearly | perpetual | …,默认 yearly + ProductID string `json:"product_id"` // 可选覆盖 + BuyerEmail string `json:"buyer_email"` // 可选 + Environment string `json:"environment"` // 可选:用页面当前环境覆盖 DB + ProductIDYearly string `json:"product_id_yearly"` + ProductIDPerpetual string `json:"product_id_perpetual"` + ProductMappings []WaffoProductMapping `json:"product_mappings"` + Currency string `json:"currency"` + CurrencyYearly string `json:"currency_yearly"` + CurrencyPerpetual string `json:"currency_perpetual"` } // WaffoDebugResponse 调试结果摘要(绝不含私钥) // Logan.Li diff --git a/backend/internal/service/payment/waffo.go b/backend/internal/service/payment/waffo.go index 9583069..c8bb58d 100644 --- a/backend/internal/service/payment/waffo.go +++ b/backend/internal/service/payment/waffo.go @@ -37,6 +37,7 @@ 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 @@ -416,6 +417,11 @@ 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 == "" { diff --git a/backend/internal/service/payment/waffo_api.go b/backend/internal/service/payment/waffo_api.go index e57922e..cd2902c 100644 --- a/backend/internal/service/payment/waffo_api.go +++ b/backend/internal/service/payment/waffo_api.go @@ -86,7 +86,11 @@ func CreateCheckoutSession(cfg WaffoConfig, req CreateSessionRequest) (*CreateSe if env == "" { env = strings.TrimSpace(cfg.Environment) } - if env == "" { + // Waffo Store Slug 鉴权要求 X-Environment 为 test|prod(不是 live) // Logan.Li + switch strings.ToLower(env) { + case "live", "prod", "production": + env = "prod" + default: env = "test" } httpReq.Header.Set("X-Store-Slug", slug) diff --git a/backend/internal/service/setting/setting_service.go b/backend/internal/service/setting/setting_service.go index 02d3698..543d065 100644 --- a/backend/internal/service/setting/setting_service.go +++ b/backend/internal/service/setting/setting_service.go @@ -122,10 +122,17 @@ func (s *SettingService) GetWaffo() (*dto.WaffoSettingsResponse, error) { }, nil } -// buildEnvUI 组装单环境预览 // Logan.Li +// buildEnvUI 组装单环境预览(不得串用另一环境顶层映射) // Logan.Li func buildEnvUI(meta payment.WaffoPublicMeta, bag payment.WaffoSecretBag, env string) dto.WaffoEnvUI { cp := meta + // 先清空商品字段,再叠该环境 Profile,避免未配置环境显示成另一侧数据 // Logan.Li + cp.ProductID = "" + cp.ProductIDYearly = "" + cp.ProductIDPerpetual = "" + cp.ProductIDs = nil + cp.ProductMappings = nil cp.ApplyEnvProfile(env) + cfgLike := payment.WaffoConfig{ ProductID: cp.ProductID, ProductIDYearly: cp.ProductIDYearly, @@ -146,9 +153,9 @@ func buildEnvUI(meta payment.WaffoPublicMeta, bag payment.WaffoSecretBag, env st } return dto.WaffoEnvUI{ ProductMappings: buildProductMappings(cfgLike, cp.ProductMappings), - ProductID: cp.ProductID, - ProductIDYearly: cp.ProductIDYearly, - ProductIDPerpetual: cp.ProductIDPerpetual, + ProductID: firstNonEmpty(cp.ProductID, cfgLike.ProductIDs["perpetual"], cfgLike.ProductIDs["lifetime"], cfgLike.ProductIDs["yearly"]), + ProductIDYearly: firstNonEmpty(cp.ProductIDYearly, cfgLike.ProductIDs["yearly"]), + ProductIDPerpetual: firstNonEmpty(cp.ProductIDPerpetual, cfgLike.ProductIDs["perpetual"], cfgLike.ProductIDs["lifetime"]), PrivateKeyConfigured: bag.SecretForEnv(env) != "", } } @@ -188,8 +195,8 @@ func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSet if meta.Currency == "" { meta.Currency = "CNY" } - meta.CurrencyYearly = meta.Currency - meta.CurrencyPerpetual = meta.Currency + meta.CurrencyYearly = firstNonEmpty(strings.TrimSpace(req.CurrencyYearly), currencyFromMappings(mappings, "yearly"), meta.Currency) + meta.CurrencyPerpetual = firstNonEmpty(strings.TrimSpace(req.CurrencyPerpetual), currencyFromMappings(mappings, "perpetual"), currencyFromMappings(mappings, "lifetime"), meta.Currency) meta.Environment = newEnv meta.ProductID = strings.TrimSpace(req.ProductID) meta.ProductIDYearly = strings.TrimSpace(req.ProductIDYearly) @@ -258,6 +265,18 @@ func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSet // DebugWaffo 用当前 DB/env 配置轻量探测 create-session(不回显私钥) // Logan.Li func (s *SettingService) DebugWaffo(req dto.WaffoDebugRequest) *dto.WaffoDebugResponse { cfg := payment.LoadWaffoConfig(s.db) + // 页面未保存的环境 / 映射优先覆盖,避免「填了映射却仍用旧 Product」 // Logan.Li + if strings.TrimSpace(req.Environment) != "" { + env := payment.NormalizeWaffoEnvironment(req.Environment) + cfg.Environment = env + var gw model.PaymentGatewayConfig + if err := s.db.Where("gateway = ?", "waffo").First(&gw).Error; err == nil { + bag := payment.ParseWaffoSecretBag(gw.SecretKey, env) + cfg.PrivateKey = bag.SecretForEnv(env) + } + } + applyDebugProductOverrides(&cfg, req) + planType := strings.TrimSpace(strings.ToLower(req.PlanType)) if planType == "" { planType = "yearly" @@ -286,7 +305,7 @@ func (s *SettingService) DebugWaffo(req dto.WaffoDebugRequest) *dto.WaffoDebugRe if productID == "" { out.OK = false - out.ErrorSummary = "未配置可用的 Waffo Product ID" + out.ErrorSummary = "未配置可用的 Waffo Product ID(请在当前环境映射表填写后先保存,或点「填充 OpsXshell 商品」)" return out } if authMode == "none" { @@ -304,11 +323,15 @@ func (s *SettingService) DebugWaffo(req dto.WaffoDebugRequest) *dto.WaffoDebugRe if successURL == "" { successURL = payment.DefaultSuccessURL } + currency := payment.ResolveCurrency(cfg, planType) + if c := strings.TrimSpace(req.Currency); c != "" && planType != "yearly" && planType != "perpetual" && planType != "lifetime" { + currency = c + } session, err := payment.CreateCheckoutSession(cfg, payment.CreateSessionRequest{ ProductID: productID, ProductType: "onetime", - Currency: firstNonEmpty(cfg.Currency, "CNY"), + Currency: currency, BuyerEmail: buyer, SuccessURL: successURL, OrderMerchantExternalID: orderNo, @@ -325,6 +348,9 @@ func (s *SettingService) DebugWaffo(req dto.WaffoDebugRequest) *dto.WaffoDebugRe out.OK = false out.ErrorSummary = sanitizeDebugError(err.Error()) out.HTTPStatus = extractHTTPStatus(err.Error()) + if strings.Contains(out.ErrorSummary, "not approved for production") { + out.ErrorSummary += "(Waffo 门店尚未开通生产收款:请先在测试环境验证;生产需在 Waffo Dashboard 完成 Go-Live)" + } return out } @@ -337,6 +363,55 @@ func (s *SettingService) DebugWaffo(req dto.WaffoDebugRequest) *dto.WaffoDebugRe return out } +// applyDebugProductOverrides 把调试请求中的映射叠到运行时配置 // Logan.Li +func applyDebugProductOverrides(cfg *payment.WaffoConfig, req dto.WaffoDebugRequest) { + if cfg == nil { + return + } + if cfg.ProductIDs == nil { + cfg.ProductIDs = map[string]string{} + } + for _, m := range req.ProductMappings { + k := strings.TrimSpace(strings.ToLower(m.PlanKey)) + v := strings.TrimSpace(m.ProductID) + if k == "" || v == "" { + continue + } + cfg.ProductIDs[k] = v + switch k { + case "yearly", "monthly": + cfg.ProductIDYearly = v + if c := strings.TrimSpace(m.Currency); c != "" { + cfg.CurrencyYearly = c + } + case "perpetual", "lifetime": + cfg.ProductIDPerpetual = v + cfg.ProductID = v + if c := strings.TrimSpace(m.Currency); c != "" { + cfg.CurrencyPerpetual = c + } + } + } + if y := strings.TrimSpace(req.ProductIDYearly); y != "" { + cfg.ProductIDYearly = y + cfg.ProductIDs["yearly"] = y + } + if p := strings.TrimSpace(req.ProductIDPerpetual); p != "" { + cfg.ProductIDPerpetual = p + cfg.ProductID = p + cfg.ProductIDs["perpetual"] = p + } + if c := strings.TrimSpace(req.Currency); c != "" { + cfg.Currency = c + } + if c := strings.TrimSpace(req.CurrencyYearly); c != "" { + cfg.CurrencyYearly = c + } + if c := strings.TrimSpace(req.CurrencyPerpetual); c != "" { + cfg.CurrencyPerpetual = c + } +} + func mergeProductIDInputs(req dto.WaffoSettingsUpdate) (map[string]string, []dto.WaffoProductMapping) { ids := map[string]string{} var mappings []dto.WaffoProductMapping @@ -352,6 +427,7 @@ func mergeProductIDInputs(req dto.WaffoSettingsUpdate) (map[string]string, []dto PlanKey: k, Label: strings.TrimSpace(m.Label), ProductID: v, + Currency: strings.ToUpper(strings.TrimSpace(m.Currency)), }) if v != "" { ids[k] = v @@ -387,7 +463,7 @@ func mergeProductIDInputs(req dto.WaffoSettingsUpdate) (map[string]string, []dto } if len(mappings) == 0 { - mappings = mappingsFromIDs(ids, nil) + mappings = mappingsFromIDs(ids, nil, nil, payment.WaffoConfig{}) } else { // 用最终 ids 回填表格行 product_id,避免只改了兼容字段 // Logan.Li for i := range mappings { @@ -401,6 +477,7 @@ func mergeProductIDInputs(req dto.WaffoSettingsUpdate) (map[string]string, []dto func buildProductMappings(cfg payment.WaffoConfig, stored []payment.WaffoProductMapping) []dto.WaffoProductMapping { labelHint := map[string]string{} + currencyHint := map[string]string{} for _, m := range stored { k := strings.TrimSpace(strings.ToLower(m.PlanKey)) if k == "" { @@ -409,15 +486,55 @@ func buildProductMappings(cfg payment.WaffoConfig, stored []payment.WaffoProduct if l := strings.TrimSpace(m.Label); l != "" { labelHint[k] = l } + if c := strings.TrimSpace(m.Currency); c != "" { + currencyHint[k] = strings.ToUpper(c) + } } - return mappingsFromIDs(cfg.ProductIDs, labelHint) + // 若 stored 有完整行(含空 product 也应展示),优先按 stored 顺序回显 // Logan.Li + if len(stored) > 0 { + out := make([]dto.WaffoProductMapping, 0, len(stored)) + seen := map[string]bool{} + for _, m := range stored { + k := strings.TrimSpace(strings.ToLower(m.PlanKey)) + if k == "" || seen[k] { + continue + } + seen[k] = true + pid := strings.TrimSpace(m.ProductID) + if pid == "" { + pid = strings.TrimSpace(cfg.ProductIDs[k]) + } + cur := strings.TrimSpace(m.Currency) + if cur == "" { + cur = currencyHint[k] + } + if cur == "" { + switch k { + case "yearly", "monthly": + cur = cfg.CurrencyYearly + case "perpetual", "lifetime": + cur = cfg.CurrencyPerpetual + } + } + out = append(out, dto.WaffoProductMapping{ + PlanKey: k, + Label: firstNonEmpty(strings.TrimSpace(m.Label), labelHint[k]), + ProductID: pid, + Currency: strings.ToUpper(strings.TrimSpace(cur)), + }) + } + if len(out) > 0 { + return out + } + } + return mappingsFromIDs(cfg.ProductIDs, labelHint, currencyHint, cfg) } -func mappingsFromIDs(ids map[string]string, labels map[string]string) []dto.WaffoProductMapping { +func mappingsFromIDs(ids map[string]string, labels, currencies map[string]string, cfg payment.WaffoConfig) []dto.WaffoProductMapping { if len(ids) == 0 { return []dto.WaffoProductMapping{ - {PlanKey: "yearly", Label: "包年"}, - {PlanKey: "perpetual", Label: "永久"}, + {PlanKey: "yearly", Label: "包年", Currency: "CNY"}, + {PlanKey: "perpetual", Label: "永久", Currency: "USD"}, } } defaultLabels := map[string]string{ @@ -445,7 +562,26 @@ func mappingsFromIDs(ids map[string]string, labels map[string]string) []dto.Waff if label == "" { label = defaultLabels[k] } - out = append(out, dto.WaffoProductMapping{PlanKey: k, Label: label, ProductID: v}) + cur := "" + if currencies != nil { + cur = currencies[k] + } + if cur == "" { + switch k { + case "yearly", "monthly": + cur = cfg.CurrencyYearly + case "perpetual", "lifetime": + cur = cfg.CurrencyPerpetual + default: + cur = cfg.Currency + } + } + out = append(out, dto.WaffoProductMapping{ + PlanKey: k, + Label: label, + ProductID: v, + Currency: strings.ToUpper(strings.TrimSpace(cur)), + }) } for _, k := range order { appendOne(k) @@ -470,8 +606,8 @@ func mappingsFromIDs(ids map[string]string, labels map[string]string) []dto.Waff } if len(out) == 0 { return []dto.WaffoProductMapping{ - {PlanKey: "yearly", Label: "包年"}, - {PlanKey: "perpetual", Label: "永久"}, + {PlanKey: "yearly", Label: "包年", Currency: "CNY"}, + {PlanKey: "perpetual", Label: "永久", Currency: "USD"}, } } return out @@ -488,11 +624,23 @@ func toPaymentMappings(in []dto.WaffoProductMapping) []payment.WaffoProductMappi PlanKey: k, Label: strings.TrimSpace(m.Label), ProductID: strings.TrimSpace(m.ProductID), + Currency: strings.ToUpper(strings.TrimSpace(m.Currency)), }) } return out } +// currencyFromMappings 从映射表取指定 plan 的货币 // Logan.Li +func currencyFromMappings(mappings []dto.WaffoProductMapping, plan string) string { + plan = strings.TrimSpace(strings.ToLower(plan)) + for _, m := range mappings { + if strings.TrimSpace(strings.ToLower(m.PlanKey)) == plan { + return strings.ToUpper(strings.TrimSpace(m.Currency)) + } + } + return "" +} + func cloneProductIDs(in map[string]string) map[string]string { if len(in) == 0 { return map[string]string{} diff --git a/frontend/src/views/Payments.vue b/frontend/src/views/Payments.vue index 62b3e23..f9fd638 100644 --- a/frontend/src/views/Payments.vue +++ b/frontend/src/views/Payments.vue @@ -8,6 +8,10 @@ 后台配置优先于 .env
+ + + + 测试连接 / 调试 保存配置
@@ -18,7 +22,7 @@ type="info" :closable="false" show-icon - title="测试 / 生产环境的私钥与 Product ID 映射相互隔离。切换到生产后需单独填写生产密钥;保存后才会生效。" + title="测试 / 生产的私钥与 Product 映射相互隔离。改映射后需点「保存」;调试不会自动写库。生产 create-session 需 Waffo 门店已 Go-Live。" style="margin-bottom: 16px" /> @@ -30,7 +34,7 @@
测试
-
切换环境会载入该环境的 Product 映射;若该环境尚未配置私钥,需重新粘贴保存。
+
切换前会暂存当前映射到对应环境;未配置私钥的环境需单独粘贴保存。
@@ -72,8 +76,9 @@ style="margin-top: 8px" /> - - + + +
映射表可按计划覆盖(包年 CNY / 永久 USD)
@@ -93,41 +98,55 @@ - + - + - + + + +