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 @@