fix: 修复支付页测试/生产 Product 映射串用与无法保存

切换环境时先暂存再切换;后端 Profile 不再串用另一侧商品;支持一键填充 OpsXshell 与跨环境复制,调试使用当前页映射。
This commit is contained in:
Logan.Li committed 2026-08-12 23:35:27 +08:00
1 parent 9b79eabd34
commit 7c146cb639
5 files changed
+317 -71

No files matched your search

+13 -3
View File
@@ -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
@@ -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 == "" {
@@ -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)
@@ -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{}