从 cwd 向上解析 backend 根,并写入本地开发启动约定。 Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
4.5 KiB
Go
181 lines
4.5 KiB
Go
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
|
||
DBDSN string
|
||
DevMode bool
|
||
}
|
||
|
||
// Parse 解析配置:环境变量 > app.ini > 默认值。
|
||
// 工作目录不跟 os.Getwd() 走:从 cwd 向上查找 backend 根(含 app.ini 或源码标记),
|
||
// 避免在仓库根 / cmd/jiang13 启动时把上传文件写进另一套 data/。
|
||
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,
|
||
}
|
||
|
||
// 尝试加载 app.ini
|
||
iniPath := filepath.Join(workPath, "app.ini")
|
||
if _, err := os.Stat(iniPath); err == nil {
|
||
f, err := ini.Load(iniPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("加载 app.ini 失败: %w", err)
|
||
}
|
||
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("paths"); s.HasKey("DATA") {
|
||
dataRel := s.Key("DATA").String()
|
||
if !filepath.IsAbs(dataRel) {
|
||
cfg.DataDir = filepath.Join(workPath, dataRel)
|
||
} else {
|
||
cfg.DataDir = dataRel
|
||
}
|
||
}
|
||
if s := f.Section("app"); s.HasKey("DEV_MODE") {
|
||
cfg.DevMode = s.Key("DEV_MODE").MustBool(true)
|
||
}
|
||
}
|
||
|
||
// 环境变量覆盖
|
||
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 := os.Getenv("DEV_MODE"); v != "" {
|
||
cfg.DevMode = strings.EqualFold(v, "true") || v == "1"
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
}
|