package setting import ( "backend/internal/dto" "backend/internal/model" "backend/internal/service/payment" "errors" "fmt" "net/url" "regexp" "strings" "time" "gorm.io/gorm" ) // SettingService 系统设置业务逻辑层 // Logan.Li type SettingService struct { db *gorm.DB } // NewSettingService 创建 service 实例 // Logan.Li func NewSettingService(db *gorm.DB) *SettingService { return &SettingService{db: db} } // Get 获取系统设置(单行) // Logan.Li func (s *SettingService) Get() (*model.Setting, error) { var setting model.Setting if err := s.db.First(&setting).Error; err != nil { return nil, errors.New("系统设置不存在") } return &setting, nil } // Update 更新系统设置 // Logan.Li func (s *SettingService) Update(req dto.SettingUpdate) (*model.Setting, error) { var setting model.Setting if err := s.db.First(&setting).Error; err != nil { return nil, errors.New("系统设置不存在") } updates := map[string]interface{}{} if req.SystemName != nil { updates["system_name"] = *req.SystemName } if req.DefaultDuration != nil { updates["default_duration"] = *req.DefaultDuration } if req.DefaultMaxActivations != nil { updates["default_max_activations"] = *req.DefaultMaxActivations } if req.ExpireReminderDays != nil { updates["expire_reminder_days"] = *req.ExpireReminderDays } if req.APIRateLimit != nil { updates["api_rate_limit"] = *req.APIRateLimit } if req.LogRetentionDays != nil { updates["log_retention_days"] = *req.LogRetentionDays } if len(updates) > 0 { if err := s.db.Model(&setting).Updates(updates).Error; err != nil { return nil, errors.New("更新系统设置失败") } } s.db.First(&setting) return &setting, nil } // GetWaffo 获取 Waffo 支付集成配置(私钥仅返回是否已配置) // Logan.Li func (s *SettingService) GetWaffo() (*dto.WaffoSettingsResponse, error) { cfg := payment.LoadWaffoConfig(s.db) var gw model.PaymentGatewayConfig dbHas := s.db.Where("gateway = ?", "waffo").First(&gw).Error == nil enabled := true if dbHas { enabled = gw.Enabled } hint := "env" if dbHas && strings.TrimSpace(gw.PublicKey) != "" { hint = "db" } else if dbHas { hint = "mixed" } meta := payment.ParseWaffoPublicMeta(gw.PublicKey) env := payment.NormalizeWaffoEnvironment(cfg.Environment) meta.ApplyEnvProfile(env) mappings := buildProductMappings(cfg, meta.ProductMappings) productIDs := cloneProductIDs(cfg.ProductIDs) bag := payment.ParseWaffoSecretBag(gw.SecretKey, env) profiles := map[string]dto.WaffoEnvUI{ "test": buildEnvUI(meta, bag, "test"), "live": buildEnvUI(meta, bag, "live"), } return &dto.WaffoSettingsResponse{ Enabled: enabled, MerchantID: cfg.MerchantID, StoreID: cfg.StoreID, StoreSlug: cfg.StoreSlug, Environment: env, SandboxMode: env == "test", Currency: cfg.Currency, ProductID: firstNonEmpty(meta.ProductID, cfg.ProductID), ProductIDYearly: firstNonEmpty(meta.ProductIDYearly, cfg.ProductIDYearly), ProductIDPerpetual: firstNonEmpty(meta.ProductIDPerpetual, cfg.ProductIDPerpetual), ProductIDs: productIDs, ProductMappings: mappings, SuccessURL: cfg.SuccessURL, WebhookURL: payment.DefaultWebhookURL, PrivateKeyConfigured: bag.SecretForEnv(env) != "", PrivateKeyByEnv: map[string]bool{ "test": bag.SecretForEnv("test") != "", "live": bag.SecretForEnv("live") != "", }, Profiles: profiles, SourceHint: hint, }, nil } // 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, ProductIDPerpetual: cp.ProductIDPerpetual, ProductIDs: cloneProductIDs(cp.ProductIDs), } if len(cp.ProductMappings) > 0 { if cfgLike.ProductIDs == nil { cfgLike.ProductIDs = map[string]string{} } for _, m := range cp.ProductMappings { k := strings.TrimSpace(strings.ToLower(m.PlanKey)) v := strings.TrimSpace(m.ProductID) if k != "" && v != "" { cfgLike.ProductIDs[k] = v } } } return dto.WaffoEnvUI{ ProductMappings: buildProductMappings(cfgLike, cp.ProductMappings), 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) != "", } } // UpdateWaffo 保存 Waffo 配置到 payment_gateway_configs(后台优先于 .env) // Logan.Li func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSettingsResponse, error) { var gw model.PaymentGatewayConfig err := s.db.Where("gateway = ?", "waffo").First(&gw).Error if err != nil { gw = model.PaymentGatewayConfig{ Gateway: "waffo", Enabled: true, SandboxMode: true, Currency: "CNY", Priority: 0, } } oldMeta := payment.ParseWaffoPublicMeta(gw.PublicKey) oldEnv := payment.NormalizeWaffoEnvironment(oldMeta.Environment) if oldEnv == "test" && !gw.SandboxMode { oldEnv = "live" } productIDs, mappings := mergeProductIDInputs(req) newEnv := payment.NormalizeWaffoEnvironment(req.Environment) meta := oldMeta if meta.EnvProfiles == nil { meta.EnvProfiles = map[string]payment.WaffoEnvProfile{} } meta.MerchantID = strings.TrimSpace(req.MerchantID) meta.StoreID = strings.TrimSpace(req.StoreID) meta.StoreSlug = strings.TrimSpace(req.StoreSlug) meta.SuccessURL = strings.TrimSpace(req.SuccessURL) meta.Currency = strings.TrimSpace(req.Currency) if meta.Currency == "" { meta.Currency = "CNY" } 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) meta.ProductIDPerpetual = strings.TrimSpace(req.ProductIDPerpetual) meta.ProductIDs = productIDs meta.ProductMappings = toPaymentMappings(mappings) // 兼容字段与可扩展映射对齐 // Logan.Li if meta.ProductIDYearly == "" { meta.ProductIDYearly = productIDs["yearly"] } if meta.ProductIDPerpetual == "" { meta.ProductIDPerpetual = firstNonEmpty(productIDs["perpetual"], productIDs["lifetime"]) } if meta.ProductID == "" { meta.ProductID = firstNonEmpty(meta.ProductIDPerpetual, meta.ProductIDYearly, productIDs["default"]) } // 当前环境映射写入 Profile,保留另一环境配置 // Logan.Li meta.CaptureEnvProfile(newEnv) if newEnv == "live" { gw.SandboxMode = false } else { gw.SandboxMode = true } encoded, err := payment.EncodeWaffoPublicMeta(meta) if err != nil { return nil, err } gw.PublicKey = encoded gw.Currency = meta.Currency gw.Priority = 0 if req.Enabled != nil { gw.Enabled = *req.Enabled } else { gw.Enabled = true } bag := payment.ParseWaffoSecretBag(gw.SecretKey, oldEnv) if req.ClearPrivateKey { bag.ClearSecretForEnv(newEnv) } else if pk := strings.TrimSpace(req.PrivateKey); pk != "" { if _, err := payment.ParseMerchantPrivateKey(pk); err != nil { return nil, errors.New("私钥无法解析: " + err.Error()) } bag.SetSecretForEnv(newEnv, pk) } encodedSecret, err := payment.EncodeWaffoSecretBag(bag) if err != nil { return nil, errors.New("保存私钥失败") } gw.SecretKey = encodedSecret if err := s.db.Save(&gw).Error; err != nil { return nil, errors.New("保存 Waffo 配置失败") } _ = s.db.Model(&model.PaymentGatewayConfig{}). Where("gateway <> ? AND enabled = ?", "waffo", true). Update("priority", 100).Error return s.GetWaffo() } // 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" } productID := strings.TrimSpace(req.ProductID) if productID == "" { productID = payment.ResolveProductID(cfg, planType) } authMode := "none" if cfg.HasAPIKeyAuth() { authMode = "api_key" } else if strings.TrimSpace(cfg.StoreSlug) != "" { authMode = "store_slug" } out := &dto.WaffoDebugResponse{ AuthMode: authMode, Environment: cfg.Environment, PlanType: planType, ProductIDUsed: productID, PrivateKeyPresent: strings.TrimSpace(cfg.PrivateKey) != "", MerchantIDPresent: strings.TrimSpace(cfg.MerchantID) != "", StoreSlugPresent: strings.TrimSpace(cfg.StoreSlug) != "", } if productID == "" { out.OK = false out.ErrorSummary = "未配置可用的 Waffo Product ID(请在当前环境映射表填写后先保存,或点「填充 OpsXshell 商品」)" return out } if authMode == "none" { out.OK = false out.ErrorSummary = "未配置商户 ID/私钥,且缺少 Store Slug" return out } buyer := strings.TrimSpace(req.BuyerEmail) if buyer == "" { buyer = "debug@opsxshell.local" } orderNo := fmt.Sprintf("debug-%d", time.Now().Unix()) successURL := cfg.SuccessURL 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: currency, BuyerEmail: buyer, SuccessURL: successURL, OrderMerchantExternalID: orderNo, Metadata: map[string]string{ "debug": "true", "planType": planType, "orderNo": orderNo, }, ExpiresInSeconds: 300, StoreSlug: cfg.StoreSlug, Environment: cfg.Environment, }) if err != nil { 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 } out.OK = true out.HTTPStatus = 200 out.SessionID = session.SessionID if u, err := url.Parse(session.CheckoutURL); err == nil && u.Host != "" { out.CheckoutURLHost = u.Host } 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 if len(req.ProductMappings) > 0 { for _, m := range req.ProductMappings { k := strings.TrimSpace(strings.ToLower(m.PlanKey)) v := strings.TrimSpace(m.ProductID) if k == "" { continue } mappings = append(mappings, dto.WaffoProductMapping{ PlanKey: k, Label: strings.TrimSpace(m.Label), ProductID: v, Currency: strings.ToUpper(strings.TrimSpace(m.Currency)), }) if v != "" { ids[k] = v } } } for k, v := range req.ProductIDs { k = strings.TrimSpace(strings.ToLower(k)) v = strings.TrimSpace(v) if k == "" || v == "" { continue } ids[k] = v } if y := strings.TrimSpace(req.ProductIDYearly); y != "" { ids["yearly"] = y } if p := strings.TrimSpace(req.ProductIDPerpetual); p != "" { ids["perpetual"] = p if _, ok := ids["lifetime"]; !ok { ids["lifetime"] = p } } if d := strings.TrimSpace(req.ProductID); d != "" { if _, ok := ids["default"]; !ok { ids["default"] = d } if _, ok := ids["perpetual"]; !ok { ids["perpetual"] = d } } if len(mappings) == 0 { mappings = mappingsFromIDs(ids, nil, nil, payment.WaffoConfig{}) } else { // 用最终 ids 回填表格行 product_id,避免只改了兼容字段 // Logan.Li for i := range mappings { if v := ids[mappings[i].PlanKey]; v != "" { mappings[i].ProductID = v } } } return ids, mappings } 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 == "" { continue } if l := strings.TrimSpace(m.Label); l != "" { labelHint[k] = l } if c := strings.TrimSpace(m.Currency); c != "" { currencyHint[k] = strings.ToUpper(c) } } // 若 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, currencies map[string]string, cfg payment.WaffoConfig) []dto.WaffoProductMapping { if len(ids) == 0 { return []dto.WaffoProductMapping{ {PlanKey: "yearly", Label: "包年", Currency: "CNY"}, {PlanKey: "perpetual", Label: "永久", Currency: "USD"}, } } defaultLabels := map[string]string{ "yearly": "包年", "monthly": "包月", "perpetual": "永久", "lifetime": "终身", "trial": "试用", "default": "默认", } // 稳定顺序:常用 key 优先,其余按字典序 // Logan.Li order := []string{"trial", "yearly", "monthly", "perpetual", "lifetime", "default"} seen := map[string]bool{} out := make([]dto.WaffoProductMapping, 0, len(ids)) appendOne := func(k string) { v := strings.TrimSpace(ids[k]) if v == "" || seen[k] { return } seen[k] = true label := "" if labels != nil { label = labels[k] } if label == "" { label = defaultLabels[k] } 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) } rest := make([]string, 0) for k := range ids { k = strings.TrimSpace(strings.ToLower(k)) if k == "" || seen[k] || strings.TrimSpace(ids[k]) == "" { continue } rest = append(rest, k) } for i := 0; i < len(rest); i++ { for j := i + 1; j < len(rest); j++ { if rest[j] < rest[i] { rest[i], rest[j] = rest[j], rest[i] } } } for _, k := range rest { appendOne(k) } if len(out) == 0 { return []dto.WaffoProductMapping{ {PlanKey: "yearly", Label: "包年", Currency: "CNY"}, {PlanKey: "perpetual", Label: "永久", Currency: "USD"}, } } return out } func toPaymentMappings(in []dto.WaffoProductMapping) []payment.WaffoProductMapping { out := make([]payment.WaffoProductMapping, 0, len(in)) for _, m := range in { k := strings.TrimSpace(strings.ToLower(m.PlanKey)) if k == "" { continue } out = append(out, payment.WaffoProductMapping{ 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{} } 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 } func firstNonEmpty(values ...string) string { for _, v := range values { if s := strings.TrimSpace(v); s != "" { return s } } return "" } var httpStatusRe = regexp.MustCompile(`\((\d{3})\)`) func extractHTTPStatus(msg string) int { m := httpStatusRe.FindStringSubmatch(msg) if len(m) < 2 { return 0 } var n int fmt.Sscanf(m[1], "%d", &n) return n } func sanitizeDebugError(msg string) string { msg = strings.TrimSpace(msg) lower := strings.ToLower(msg) // 防止误回显 PEM / 私钥片段 // Logan.Li if strings.Contains(lower, "private key") || strings.Contains(msg, "BEGIN ") { if idx := strings.Index(lower, "private"); idx >= 0 { return "私钥相关错误(详情已隐藏)" } } if len(msg) > 300 { return msg[:300] } return msg } // ============ 审计日志 ============ // ListAuditLogs 分页查询审计日志 // Logan.Li func (s *SettingService) ListAuditLogs(page, pageSize int, action, targetType string) ([]dto.AuditLogResponse, int64, error) { var logs []model.AuditLog var total int64 query := s.db.Model(&model.AuditLog{}) if action != "" { query = query.Where("action = ?", action) } if targetType != "" { query = query.Where("target_type = ?", targetType) } query.Count(&total) if err := query.Order("created_at DESC"). Offset((page - 1) * pageSize).Limit(pageSize). Find(&logs).Error; err != nil { return nil, 0, err } results := make([]dto.AuditLogResponse, 0, len(logs)) for _, l := range logs { results = append(results, dto.AuditLogResponse{ ID: l.ID, Username: l.Username, Action: l.Action, TargetType: l.TargetType, TargetID: l.TargetID, TargetName: l.TargetName, Detail: l.Detail, Result: l.Result, IP: l.IP, CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"), }) } return results, total, nil } // ClearAuditLogs 清空审计日志 // Logan.Li func (s *SettingService) ClearAuditLogs() error { return s.db.Exec("DELETE FROM audit_logs").Error }