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) mappings := buildProductMappings(cfg, meta.ProductMappings) productIDs := cloneProductIDs(cfg.ProductIDs) return &dto.WaffoSettingsResponse{ Enabled: enabled, MerchantID: cfg.MerchantID, StoreID: cfg.StoreID, StoreSlug: cfg.StoreSlug, Environment: cfg.Environment, SandboxMode: cfg.Environment == "test", Currency: cfg.Currency, ProductID: cfg.ProductID, ProductIDYearly: cfg.ProductIDYearly, ProductIDPerpetual: cfg.ProductIDPerpetual, ProductIDs: productIDs, ProductMappings: mappings, SuccessURL: cfg.SuccessURL, WebhookURL: payment.DefaultWebhookURL, PrivateKeyConfigured: strings.TrimSpace(cfg.PrivateKey) != "", SourceHint: hint, }, nil } // 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, } } productIDs, mappings := mergeProductIDInputs(req) meta := payment.WaffoPublicMeta{ MerchantID: strings.TrimSpace(req.MerchantID), StoreID: strings.TrimSpace(req.StoreID), StoreSlug: strings.TrimSpace(req.StoreSlug), ProductID: strings.TrimSpace(req.ProductID), ProductIDYearly: strings.TrimSpace(req.ProductIDYearly), ProductIDPerpetual: strings.TrimSpace(req.ProductIDPerpetual), ProductIDs: productIDs, ProductMappings: toPaymentMappings(mappings), SuccessURL: strings.TrimSpace(req.SuccessURL), Currency: strings.TrimSpace(req.Currency), } if meta.Currency == "" { meta.Currency = "CNY" } meta.CurrencyYearly = meta.Currency meta.CurrencyPerpetual = meta.Currency // 兼容字段与可扩展映射对齐 // 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"]) } env := strings.ToLower(strings.TrimSpace(req.Environment)) switch env { case "live", "prod", "production": meta.Environment = "live" gw.SandboxMode = false default: meta.Environment = "test" 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 } if req.ClearPrivateKey { gw.SecretKey = "" } else if pk := strings.TrimSpace(req.PrivateKey); pk != "" { if _, err := payment.ParseMerchantPrivateKey(pk); err != nil { return nil, errors.New("私钥无法解析: " + err.Error()) } gw.SecretKey = pk } 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) 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" 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 } session, err := payment.CreateCheckoutSession(cfg, payment.CreateSessionRequest{ ProductID: productID, ProductType: "onetime", Currency: firstNonEmpty(cfg.Currency, "CNY"), 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()) 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 } 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, }) 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) } 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{} for _, m := range stored { k := strings.TrimSpace(strings.ToLower(m.PlanKey)) if k == "" { continue } if l := strings.TrimSpace(m.Label); l != "" { labelHint[k] = l } } return mappingsFromIDs(cfg.ProductIDs, labelHint) } func mappingsFromIDs(ids map[string]string, labels map[string]string) []dto.WaffoProductMapping { if len(ids) == 0 { return []dto.WaffoProductMapping{ {PlanKey: "yearly", Label: "包年"}, {PlanKey: "perpetual", Label: "永久"}, } } 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] } out = append(out, dto.WaffoProductMapping{PlanKey: k, Label: label, ProductID: v}) } 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: "包年"}, {PlanKey: "perpetual", Label: "永久"}, } } 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), }) } return out } 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 } // ============ License 模板 ============ // ListLicenseTemplates 查询 License 模板 // Logan.Li func (s *SettingService) ListLicenseTemplates() ([]model.LicenseTemplate, error) { var templates []model.LicenseTemplate s.db.Order("created_at DESC").Find(&templates) return templates, nil } // SaveLicenseTemplate 创建/更新 License 模板 // Logan.Li func (s *SettingService) SaveLicenseTemplate(tmpl model.LicenseTemplate) (*model.LicenseTemplate, error) { if tmpl.Name == "" { return nil, errors.New("模板名称不能为空") } if tmpl.ID > 0 { if err := s.db.Save(&tmpl).Error; err != nil { return nil, errors.New("更新模板失败") } } else { if err := s.db.Create(&tmpl).Error; err != nil { return nil, errors.New("创建模板失败") } } return &tmpl, nil } // DeleteLicenseTemplate 删除 License 模板 // Logan.Li func (s *SettingService) DeleteLicenseTemplate(id uint) error { return s.db.Delete(&model.LicenseTemplate{}, id).Error }