package config import ( "fmt" "os" "path/filepath" "strconv" "strings" "gopkg.in/ini.v1" ) // Config 应用全局配置 type Config struct { WorkPath string Port int DataDir string JWTSecret string SettingsMasterKey string // 32 字节标准 Base64;加密邮件/存储凭据。留空则禁止保存凭据 DBDSN string DevMode bool SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用 CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量) } // appIniKey 描述 app.ini 中应由启动补全的键(已有值不改写)。 type appIniKey struct { section string key string value string // 缺项时写入的缺省值(空串表示键存在但待填写) comment string // 写在键上方的注释(不含前导分号外的格式由 ini 库处理) } // appIniSchema 是本地配置清单;启动时把缺失键补进现有 app.ini,避免升级后站长看不到新字段。 var appIniSchema = []appIniKey{ {section: "server", key: "HTTP_PORT", value: "3001"}, {section: "database", key: "DSN", value: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", comment: "PostgreSQL 连接串(部署时请修改账号密码)"}, {section: "security", key: "JWT_SECRET", value: "", comment: "留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值"}, {section: "security", key: "SETTINGS_MASTER_KEY", value: "", comment: "32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。\n环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。"}, {section: "paths", key: "DATA", value: "data"}, {section: "app", key: "DEV_MODE", value: "true"}, {section: "app", key: "SITE_URL", value: "", comment: "对外正式 origin,无尾斜杠。账号邮件链接与 Next 的 SITE_URL 必须一致。\n本地可填 http://localhost:3000;生产填 https://bbs.example.com。环境变量 SITE_URL 优先。"}, {section: "app", key: "CORS_ORIGINS", value: "", comment: "额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。同源反代通常可留空。"}, } // Parse 解析配置:环境变量 > app.ini > 默认值。 // 工作目录不跟 os.Getwd() 走:从 cwd 向上查找 backend 根(含 app.ini 或源码标记), // 避免在仓库根 / cmd/jiang13 启动时把上传文件写进另一套 data/。 // 若 app.ini 缺少 schema 中的键,会就地补全缺省行(不覆盖已有值)。 func Parse() (*Config, error) { workPath, err := resolveWorkPath() if err != nil { return nil, err } // 默认值 cfg := &Config{ WorkPath: workPath, Port: 3001, DataDir: filepath.Join(workPath, "data"), DBDSN: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", DevMode: true, } iniPath := filepath.Join(workPath, "app.ini") f, err := loadAndEnsureAppIni(iniPath) if err != nil { return nil, err } if f != nil { applyIni(cfg, f) } // 环境变量覆盖 if v := os.Getenv("HTTP_PORT"); v != "" { if p, err := strconv.Atoi(v); err == nil { cfg.Port = p } } if v := os.Getenv("DB_DSN"); v != "" { cfg.DBDSN = v } if v := os.Getenv("JWT_SECRET"); v != "" { cfg.JWTSecret = v } if v := strings.TrimSpace(os.Getenv("SETTINGS_MASTER_KEY")); v != "" { cfg.SettingsMasterKey = v } if v := os.Getenv("DEV_MODE"); v != "" { cfg.DevMode = strings.EqualFold(v, "true") || v == "1" } if v := strings.TrimSpace(os.Getenv("SITE_URL")); v != "" { cfg.SiteURL = strings.TrimRight(v, "/") } if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" { cfg.CORSOrigins = splitCSVOrigins(v) } if v := strings.TrimSpace(os.Getenv("DATA_DIR")); v != "" { abs, err := filepath.Abs(v) if err != nil { return nil, fmt.Errorf("解析 DATA_DIR 失败: %w", err) } cfg.DataDir = abs } if err := rejectStrayDataDir(cfg.DataDir); err != nil { return nil, err } // 确保数据目录存在 if err := os.MkdirAll(cfg.DataDir, 0755); err != nil { return nil, fmt.Errorf("创建数据目录失败: %w", err) } // JWT 密钥:留空则自动生成并持久化 if strings.TrimSpace(cfg.JWTSecret) == "" { secretFile := filepath.Join(cfg.DataDir, ".jwt_secret") if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 { cfg.JWTSecret = string(data) } else { cfg.JWTSecret = generateSecret(32) _ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600) } } return cfg, nil } func applyIni(cfg *Config, f *ini.File) { if s := f.Section("server"); s.HasKey("HTTP_PORT") { if v, err := s.Key("HTTP_PORT").Int(); err == nil { cfg.Port = v } } if s := f.Section("database"); s.HasKey("DSN") { cfg.DBDSN = s.Key("DSN").String() } if s := f.Section("security"); s.HasKey("JWT_SECRET") { cfg.JWTSecret = s.Key("JWT_SECRET").String() } if s := f.Section("security"); s.HasKey("SETTINGS_MASTER_KEY") { cfg.SettingsMasterKey = strings.TrimSpace(s.Key("SETTINGS_MASTER_KEY").String()) } if s := f.Section("paths"); s.HasKey("DATA") { dataRel := s.Key("DATA").String() if !filepath.IsAbs(dataRel) { cfg.DataDir = filepath.Join(cfg.WorkPath, dataRel) } else { cfg.DataDir = dataRel } } if s := f.Section("app"); s.HasKey("DEV_MODE") { cfg.DevMode = s.Key("DEV_MODE").MustBool(true) } if s := f.Section("app"); s.HasKey("SITE_URL") { if v := strings.TrimSpace(s.Key("SITE_URL").String()); v != "" { cfg.SiteURL = strings.TrimRight(v, "/") } } if s := f.Section("app"); s.HasKey("CORS_ORIGINS") { if v := strings.TrimSpace(s.Key("CORS_ORIGINS").String()); v != "" { cfg.CORSOrigins = splitCSVOrigins(v) } } } // loadAndEnsureAppIni 加载 app.ini;不存在则按 schema 新建;已存在则只补缺失键。 func loadAndEnsureAppIni(path string) (*ini.File, error) { _, statErr := os.Stat(path) missingFile := os.IsNotExist(statErr) if statErr != nil && !missingFile { return nil, fmt.Errorf("读取 app.ini 失败: %w", statErr) } var f *ini.File var err error if missingFile { f = ini.Empty() } else { f, err = ini.Load(path) if err != nil { return nil, fmt.Errorf("加载 app.ini 失败: %w", err) } } changed := missingFile for _, spec := range appIniSchema { sec := f.Section(spec.section) if !sec.HasKey(spec.key) { val := spec.value // 开发态缺 SITE_URL 时写入本机前端 origin,生产(DEV_MODE=false)只留空键提示填写 if spec.section == "app" && spec.key == "SITE_URL" && val == "" && sec.Key("DEV_MODE").MustBool(true) { val = "http://localhost:3000" } k, kerr := sec.NewKey(spec.key, val) if kerr != nil { return nil, fmt.Errorf("补全 app.ini %s.%s 失败: %w", spec.section, spec.key, kerr) } if spec.comment != "" { k.Comment = spec.comment } changed = true } } if changed { if err := f.SaveTo(path); err != nil { return nil, fmt.Errorf("写回 app.ini 失败: %w", err) } } return f, nil } // AllowOrigin 供 CORS 中间件判断浏览器 Origin。 // 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致); // 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。 func (c *Config) AllowOrigin(origin string) bool { if origin == "" { return false } origin = strings.TrimRight(origin, "/") if c.DevMode { return origin == "http://localhost:3000" || origin == "http://127.0.0.1:3000" } if c.SiteURL != "" && origin == c.SiteURL { return true } for _, o := range c.CORSOrigins { if origin == o { return true } } return false } func splitCSVOrigins(v string) []string { parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, p := range parts { s := strings.TrimRight(strings.TrimSpace(p), "/") if s != "" { out = append(out, s) } } return out } // resolveWorkPath 定位后端根目录(app.ini 与 data/ 所在处)。 // 优先级:JIANG13_WORK_PATH > 从 cwd 向上找 app.ini > 源码树 backend/ > cwd。 func resolveWorkPath() (string, error) { if v := strings.TrimSpace(os.Getenv("JIANG13_WORK_PATH")); v != "" { abs, err := filepath.Abs(v) if err != nil { return "", fmt.Errorf("解析 JIANG13_WORK_PATH 失败: %w", err) } return abs, nil } cwd, err := os.Getwd() if err != nil { return "", fmt.Errorf("获取工作目录失败: %w", err) } dir := cwd for range 8 { if hasAppIni(dir) { return dir, nil } nested := filepath.Join(dir, "backend") if hasAppIni(nested) { return nested, nil } if isSourceBackendRoot(dir) { return dir, nil } if isSourceBackendRoot(nested) { return nested, nil } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return cwd, nil } func hasAppIni(dir string) bool { _, err := os.Stat(filepath.Join(dir, "app.ini")) return err == nil } func isSourceBackendRoot(dir string) bool { _, e1 := os.Stat(filepath.Join(dir, "go.mod")) _, e2 := os.Stat(filepath.Join(dir, "cmd", "jiang13")) _, e3 := os.Stat(filepath.Join(dir, "app.ini.example")) return e1 == nil && e2 == nil && e3 == nil } // rejectStrayDataDir 拦住把上传写进 cmd/jiang13/data 的错误 cwd。 func rejectStrayDataDir(dataDir string) error { slash := filepath.ToSlash(dataDir) if strings.Contains(slash, "/cmd/jiang13/") { return fmt.Errorf("拒绝使用数据目录 %s:这是在 cmd/jiang13 下启动产生的错误路径。请在 backend/ 执行 go run ./cmd/jiang13,或设置 JIANG13_WORK_PATH 指向 backend 目录", dataDir) } return nil } func generateSecret(n int) string { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, n) for i := range b { b[i] = chars[i%len(chars)] } return string(b) }