package appconfig import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "net" "net/netip" "net/url" "os" "strings" "sync" "sync/atomic" "backend/internal/dto" "backend/internal/model" "backend/internal/secretbox" "gorm.io/gorm" "gorm.io/gorm/clause" ) // 配置来源标记 // Logan.Li const ( SourcePage = "page" // 后台页面(app_configs 表) SourceEnv = "env" // 环境变量 / .env SourceDefault = "default" // 注册表默认值 SourceMissing = "missing" // 未配置 ) // Service 运行配置服务:负责读写 app_configs、解密缓存与生效值解析。 // Logan.Li type Service struct { db *gorm.DB mu sync.RWMutex rows map[string]model.AppConfig // 原始行(密文) plain map[string]string // 解密后的明文缓存(仅内存) } // std 全局单例,供各消费点直接读取生效值(未初始化时退化为纯环境变量)。 // Logan.Li var std atomic.Pointer[Service] // Standard 返回全局已装载的服务;未初始化时用给定 db 兜底构建(不写全局) // Logan.Li func Standard(db *gorm.DB) *Service { if s := std.Load(); s != nil { return s } s := &Service{db: db, rows: map[string]model.AppConfig{}, plain: map[string]string{}} if db != nil { _ = s.reload() } return s } // Init 装载配置中心(须在 database.Init 之后、路由注册之前调用) // Logan.Li func Init(db *gorm.DB) error { s := &Service{db: db} if err := s.reload(); err != nil { return err } std.Store(s) return nil } // reload 从数据库重载全部配置(解密失败只告警不阻断启动) // Logan.Li func (s *Service) reload() error { var rows []model.AppConfig if err := s.db.Find(&rows).Error; err != nil { return fmt.Errorf("加载运行配置失败: %w", err) } plain := make(map[string]string, len(rows)) byKey := make(map[string]model.AppConfig, len(rows)) for _, row := range rows { byKey[row.Key] = row if !row.IsSecret { plain[row.Key] = row.Value continue } dec, err := secretbox.Decrypt(row.Value) if err != nil { // 主密钥更换后旧密文不可解:视为未配置,回退环境变量,避免整站起不来 // Logan.Li fmt.Printf("[运行配置] %s 解密失败(主密钥可能已更换),已回退环境变量: %v\n", row.Key, err) continue } plain[row.Key] = dec } s.mu.Lock() s.rows = byKey s.plain = plain s.mu.Unlock() return nil } // resolve 解析单个配置项的生效值与来源:DB → 环境变量 → 默认 // Logan.Li func resolve(it Item) (string, string) { if s := std.Load(); s != nil { s.mu.RLock() v, ok := s.plain[it.Key] s.mu.RUnlock() if ok && strings.TrimSpace(v) != "" { return v, SourcePage } } if v := strings.TrimSpace(os.Getenv(it.Key)); v != "" { return v, SourceEnv } if strings.TrimSpace(it.Default) != "" { return it.Default, SourceDefault } return "", SourceMissing } // ============ 消费点读取 API ============ // String 读取字符串配置(未注册的键直接读环境变量) // Logan.Li func String(key string) string { it, ok := Lookup(key) if !ok { return strings.TrimSpace(os.Getenv(key)) } v, _ := resolve(it) return v } // Bool 读取布尔配置(1/true/yes/on 视为真) // Logan.Li func Bool(key string) bool { switch strings.ToLower(strings.TrimSpace(String(key))) { case "1", "true", "yes", "on": return true } return false } // List 读取逗号分隔列表(自动去空、去重、保序) // Logan.Li func List(key string) []string { raw := String(key) if strings.TrimSpace(raw) == "" { return nil } out := make([]string, 0, 4) seen := map[string]bool{} for _, p := range strings.Split(raw, ",") { p = strings.TrimSpace(p) if p == "" || seen[p] { continue } seen[p] = true out = append(out, p) } return out } // ============ 页面视图与写入 ============ // Overview 配置中心总览(按分组,机密项只给掩码) // Logan.Li func (s *Service) Overview() *dto.RuntimeOverview { groups := make([]dto.RuntimeGroupView, 0, len(GroupOrder)) index := make(map[string]int, len(GroupOrder)) for _, name := range GroupOrder { index[name] = len(groups) groups = append(groups, dto.RuntimeGroupView{Name: name, Items: make([]dto.RuntimeItemView, 0, 4)}) } var bootstrapKeys, editableKeys []string for _, it := range Items() { val, source := resolve(it) view := dto.RuntimeItemView{ Key: it.Key, Label: it.Label, Group: it.Group, Description: it.Description, Kind: it.Kind, Value: val, Masked: val, Source: source, Default: it.Default, Editable: it.Editable, RequiresRestart: it.RequiresRestart, Danger: it.Danger, ReadOnlyReason: it.ReadOnlyReason, Placeholder: it.Placeholder, Configured: strings.TrimSpace(val) != "", } if it.Kind == KindSecret { view.Value = "" // 机密永不回传明文 // Logan.Li view.Masked = maskSecret(val) view.Fingerprint = fingerprint(val) } if row, ok := s.row(it.Key); ok { view.UpdatedAt = row.UpdatedAt.Format("2006-01-02 15:04:05") view.UpdatedBy = row.UpdatedBy } if it.Editable { editableKeys = append(editableKeys, it.Key) } else { bootstrapKeys = append(bootstrapKeys, it.Key) } gi, ok := index[it.Group] if !ok { continue } groups[gi].Items = append(groups[gi].Items, view) } return &dto.RuntimeOverview{Groups: groups, BootstrapKeys: bootstrapKeys, PageEditableKeys: editableKeys} } // row 读取单行元数据 // Logan.Li func (s *Service) row(key string) (model.AppConfig, bool) { s.mu.RLock() defer s.mu.RUnlock() row, ok := s.rows[key] return row, ok } // Update 批量保存配置(先全量校验再落库,避免半成功) // Logan.Li func (s *Service) Update(values map[string]string, actor, ip string) (*dto.RuntimeOverview, error) { if len(values) == 0 { return s.Overview(), nil } normalized := make(map[string]string, len(values)) items := make(map[string]Item, len(values)) for key, raw := range values { it, ok := Lookup(key) if !ok { return nil, fmt.Errorf("未知配置项:%s", key) } if !it.Editable { return nil, fmt.Errorf("「%s」不支持页面配置:%s", it.Label, it.ReadOnlyReason) } v, err := validate(it, raw) if err != nil { return nil, err } normalized[key] = v items[key] = it } // 生产模式下禁止放行未验签回调 // Logan.Li if v, ok := normalized["ALLOW_UNVERIFIED_WEBHOOK"]; ok && v == "1" && strings.EqualFold(os.Getenv("GIN_MODE"), "release") { return nil, errors.New("生产模式(GIN_MODE=release)禁止开启「允许未验签回调」") } err := s.db.Transaction(func(tx *gorm.DB) error { for key, v := range normalized { it := items[key] if v == "" { // 空值 = 清除页面配置,回退环境变量/默认 // Logan.Li if err := tx.Where("key = ?", key).Delete(&model.AppConfig{}).Error; err != nil { return err } continue } stored, err := encodeValue(it, v) if err != nil { return err } row := model.AppConfig{ Key: key, Value: stored, IsSecret: it.Kind == KindSecret, UpdatedBy: actor, } if err := tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error; err != nil { return err } } return nil }) if err != nil { return nil, errors.New("保存运行配置失败:" + err.Error()) } if err := s.reload(); err != nil { return nil, err } s.audit(actor, ip, "update", normalized, items) return s.Overview(), nil } // Reset 恢复默认:删除页面值,回退环境变量/注册表默认 // Logan.Li func (s *Service) Reset(keys []string, actor, ip string) (*dto.RuntimeOverview, error) { if len(keys) == 0 { return s.Overview(), nil } for _, key := range keys { it, ok := Lookup(key) if !ok { return nil, fmt.Errorf("未知配置项:%s", key) } if !it.Editable { return nil, fmt.Errorf("「%s」不支持页面配置:%s", it.Label, it.ReadOnlyReason) } } if err := s.db.Where("key IN ?", keys).Delete(&model.AppConfig{}).Error; err != nil { return nil, errors.New("恢复默认失败") } if err := s.reload(); err != nil { return nil, err } changed := make(map[string]string, len(keys)) for _, k := range keys { changed[k] = "" } s.audit(actor, ip, "reset", changed, nil) return s.Overview(), nil } // encodeValue 机密项落库前加密,其余原样 // Logan.Li func encodeValue(it Item, v string) (string, error) { if it.Kind != KindSecret { return v, nil } enc, err := secretbox.Encrypt(v) if err != nil { return "", fmt.Errorf("加密「%s」失败: %w", it.Label, err) } return enc, nil } // audit 记录配置变更(机密项只记掩码) // Logan.Li func (s *Service) audit(actor, ip, action string, changed map[string]string, items map[string]Item) { if s.db == nil || len(changed) == 0 { return } names := make([]string, 0, len(changed)) details := make([]string, 0, len(changed)) for key, v := range changed { it, ok := items[key] if !ok { it, _ = Lookup(key) } names = append(names, it.Label) if action == "reset" || v == "" { details = append(details, it.Label+"=已恢复默认") continue } if it.Kind == KindSecret { details = append(details, it.Label+"=已更新("+maskSecret(v)+")") continue } details = append(details, it.Label+"="+v) } logRow := model.AuditLog{ Username: actor, Action: action, TargetType: "app_config", TargetName: strings.Join(names, "、"), Detail: "运行配置变更:" + strings.Join(details, ";"), Result: "success", IP: ip, } _ = s.db.Create(&logRow).Error } // ============ 校验与展示工具 ============ // validate 按类型校验并归一化配置值(空串表示清除) // Logan.Li func validate(it Item, raw string) (string, error) { v := strings.TrimSpace(raw) switch it.Kind { case KindBool: if v == "" { return "", nil } switch strings.ToLower(v) { case "1", "true", "yes", "on": return "1", nil case "0", "false", "no", "off": return "0", nil } return "", fmt.Errorf("「%s」只能是开启或关闭", it.Label) case KindList: if v == "" { return "", nil } parts := make([]string, 0, 4) for _, p := range strings.Split(v, ",") { if p = strings.TrimSpace(p); p != "" { parts = append(parts, p) } } if it.Key == "TRUSTED_PROXIES" { for _, p := range parts { if _, _, err := net.ParseCIDR(p); err != nil { if _, aerr := netip.ParseAddr(p); aerr != nil { return "", fmt.Errorf("「%s」中的 %s 不是合法 IP 或 CIDR", it.Label, p) } } } } if it.Key == "CORS_ORIGINS" { for _, p := range parts { if !strings.Contains(p, "://") && p != "null" { return "", fmt.Errorf("「%s」需带协议前缀(如 https://app.example.com),非法值:%s", it.Label, p) } } } return strings.Join(parts, ","), nil case KindSecret: if v == "" { return "", nil } if len(v) < 16 { return "", fmt.Errorf("「%s」长度不足 16 位,强度太低", it.Label) } return v, nil default: if len(v) > 500 { return "", fmt.Errorf("「%s」过长(上限 500 字符)", it.Label) } if it.Key == "PAYMENT_PUBLIC_BASE_URL" || it.Key == "SUCCESS_URL" { if v != "" { u, err := url.Parse(v) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { return "", fmt.Errorf("「%s」需为完整的 http/https 地址", it.Label) } } } return v, nil } } // maskSecret 机密值掩码:8 个圆点 + 末 4 位 // Logan.Li func maskSecret(v string) string { if strings.TrimSpace(v) == "" { return "" } r := []rune(v) if len(r) <= 8 { return strings.Repeat("•", 8) } return strings.Repeat("•", 8) + string(r[len(r)-4:]) } // fingerprint 机密值指纹(SHA256 前 4 字节 hex),用于人工核对是否为同一密钥 // Logan.Li func fingerprint(v string) string { if strings.TrimSpace(v) == "" { return "" } sum := sha256.Sum256([]byte(v)) return strings.ToUpper(hex.EncodeToString(sum[:4])) }