// Package config 负责运行参数和文件路径。 // // 改动本文件前必读 admin/AGENTS.md。最容易踩的一条: // 路径必须走 DataDir(),不许硬编码、不许直接用 os.Getwd(), // 打包成 exe 后那些写法会失效。详见 docs/admin/02-architecture.md §6。 package config import ( "fmt" "os" "path/filepath" "strings" "time" "github.com/goccy/go-yaml" ) // OnlineThreshold 是判定客户端"在线"的时间窗: // 最近一次调接口在这个时间之内就算在线,否则离线。 // // 取值要**宽松**:客户端执行长任务期间不会调 claim, // 太短会被误判成离线。取「轮询周期 + 最长任务时长」量级。 // 本项目有意不做心跳,见 docs/admin/04-client-api.md §3。 const OnlineThreshold = 10 * time.Minute const ( databaseHostEnv = "CMAUTOBUY_DB_HOST" databasePortEnv = "CMAUTOBUY_DB_PORT" databaseNameEnv = "CMAUTOBUY_DB_NAME" databaseUserEnv = "CMAUTOBUY_DB_USER" databasePasswordEnv = "CMAUTOBUY_DB_PASSWORD" databaseTLSModeEnv = "CMAUTOBUY_DB_TLS_MODE" databaseTLSCAEnv = "CMAUTOBUY_DB_TLS_CA" ) const ( DatabaseTLSDisabled = "disabled" DatabaseTLSVerifyCA = "verify_ca" ) // DatabaseConfig 是 MySQL 8 的连接配置。 // // 本地运行可以从被 Git 忽略的 config.yaml 读取;线上部署仍建议使用 // CMAUTOBUY_DB_* 环境变量。无论来源是什么,密码都不能写进日志或工单。 type DatabaseConfig struct { Host string `yaml:"host"` Port string `yaml:"port"` Name string `yaml:"name"` User string `yaml:"user"` Password string `yaml:"password"` TLSMode string `yaml:"tls_mode"` TLSCA string `yaml:"tls_ca"` } // String 永远隐藏密码,防止排错时用 %v 把凭据写进日志。 func (c DatabaseConfig) String() string { password := "(空)" if c.Password != "" { password = "****" } return fmt.Sprintf("DatabaseConfig{Host:%s Port:%s Name:%s User:%s Password:%s TLSMode:%s TLSCA:%s}", c.Host, c.Port, c.Name, c.User, password, c.TLSMode, c.TLSCA) } // LoadDatabase 读取 MySQL 配置。config.yaml 适合本地双击 run_admin.bat; // CMAUTOBUY_DB_* 环境变量按字段覆盖文件配置,供线上 systemd 安全注入。 // // config.yaml 不存在时仍可完全依靠环境变量启动,保持线上部署兼容。 func LoadDatabase() (DatabaseConfig, error) { path, err := ConfigPath() if err != nil { return DatabaseConfig{}, fmt.Errorf("无法确定 config.yaml 应该在的位置: %w", err) } return loadDatabaseFromPath(path) } // loadDatabaseFromPath 把文件读取单独抽出来,方便单元测试使用临时配置文件, // 不依赖测试可执行文件所在的临时目录。 func loadDatabaseFromPath(path string) (DatabaseConfig, error) { var fileConfig DatabaseConfig raw, err := os.ReadFile(path) if err == nil { cfg, parseErr := parseConfig(raw, path) if parseErr != nil { return DatabaseConfig{}, parseErr } fileConfig = cfg.Database } else if !os.IsNotExist(err) { return DatabaseConfig{}, fmt.Errorf("读取配置文件 %s 失败: %w", path, err) } return mergeDatabaseConfig(fileConfig) } // LoadDatabaseFromEnv 只读取环境变量,供集成测试创建隔离的 _test 数据库。 // 生产入口和迁移命令应使用 LoadDatabase。 func LoadDatabaseFromEnv() (DatabaseConfig, error) { return mergeDatabaseConfig(DatabaseConfig{}) } // mergeDatabaseConfig 把环境变量合并到文件配置上。环境变量只要非空, // 就覆盖对应 YAML 字段,避免线上误读部署目录里遗留的本地配置。 func mergeDatabaseConfig(cfg DatabaseConfig) (DatabaseConfig, error) { overrides := map[string]*string{ databaseHostEnv: &cfg.Host, databasePortEnv: &cfg.Port, databaseNameEnv: &cfg.Name, databaseUserEnv: &cfg.User, databasePasswordEnv: &cfg.Password, databaseTLSModeEnv: &cfg.TLSMode, databaseTLSCAEnv: &cfg.TLSCA, } for envName, target := range overrides { value := os.Getenv(envName) if envName != databasePasswordEnv { value = strings.TrimSpace(value) } if value != "" { *target = value } } cfg.Host = strings.TrimSpace(cfg.Host) cfg.Port = strings.TrimSpace(cfg.Port) cfg.Name = strings.TrimSpace(cfg.Name) cfg.User = strings.TrimSpace(cfg.User) cfg.TLSMode = strings.ToLower(strings.TrimSpace(cfg.TLSMode)) cfg.TLSCA = strings.TrimSpace(cfg.TLSCA) if cfg.Host == "" { cfg.Host = "127.0.0.1" } if cfg.Port == "" { cfg.Port = "3307" } if cfg.TLSMode == "" { cfg.TLSMode = DatabaseTLSDisabled } var missing []string if cfg.Name == "" { missing = append(missing, "database.name / "+databaseNameEnv) } if cfg.User == "" { missing = append(missing, "database.user / "+databaseUserEnv) } if cfg.Password == "" { missing = append(missing, "database.password / "+databasePasswordEnv) } if cfg.TLSMode == DatabaseTLSVerifyCA && cfg.TLSCA == "" { missing = append(missing, "database.tls_ca / "+databaseTLSCAEnv) } if len(missing) > 0 { return DatabaseConfig{}, fmt.Errorf("缺少 MySQL 配置:%s", strings.Join(missing, "、")) } if cfg.TLSMode != DatabaseTLSDisabled && cfg.TLSMode != DatabaseTLSVerifyCA { return DatabaseConfig{}, fmt.Errorf( "不支持的 MySQL TLS 模式 %q:database.tls_mode / %s 只能是 %s 或 %s", cfg.TLSMode, databaseTLSModeEnv, DatabaseTLSDisabled, DatabaseTLSVerifyCA) } return cfg, nil } // DataDir 返回可写数据目录,不存在就创建。 // // 打包成 exe 后 = exe 旁边的 data/ // go run 时 = 当前工作目录下的 data/ // // 目录结构见 docs/admin/02-architecture.md §6: // // data/ // ├── admin.db // ├── logs/ // └── uploads/ func DataDir() (string, error) { exe, err := os.Executable() if err != nil { return "", err } dir := filepath.Join(filepath.Dir(exe), "data") // go run 会把程序编译到系统临时目录再执行, // 这种情况下 exe 旁边不是项目目录,要改用当前工作目录。 if isTempBuild(exe) { wd, err := os.Getwd() if err != nil { return "", err } dir = filepath.Join(wd, "data") } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } return dir, nil } // SubDir 返回 data/ 下的子目录,不存在就创建。 // 例如 SubDir("logs") -> /logs func SubDir(name string) (string, error) { base, err := DataDir() if err != nil { return "", err } dir := filepath.Join(base, name) if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } return dir, nil } // isTempBuild 判断这个可执行文件是不是 go run 生成的临时产物。 func isTempBuild(exe string) bool { tmp := os.TempDir() if tmp != "" && strings.HasPrefix(exe, tmp) { return true } // go run 的产物路径里通常带 go-build 字样 return strings.Contains(filepath.ToSlash(exe), "/go-build") } // ---------- 顺运宝配置(config.yaml) ---------- // SybConfig 是 config.yaml 里 `syb:` 一节,见 admin/config.example.yaml // 和 docs/admin/08-顺运宝接口.md §8。 type SybConfig struct { BaseURL string `yaml:"base_url"` Username string `yaml:"username"` Password string `yaml:"password"` PageSize int `yaml:"page_size"` MaxMatches int `yaml:"max_matches"` SyncFrom string `yaml:"sync_from"` // OcrURL 是验证码自动识别服务的地址(工单 #47)。 // // `[必须]` 留空 = 禁用自动识别,登录时直接走手工输入弹窗,不报错—— // 这是有意的降级路径,见 docs/admin/08-顺运宝接口.md「验证码自动识别」一节。 OcrURL string `yaml:"ocr_url"` // OcrMaxAttempts 是自动识别失败后的重试次数上限,<=0 时代码里退化成 5 // (示例脚本 raw_data/shunyunbaoerp_single.py 用的也是 5)。 OcrMaxAttempts int `yaml:"ocr_max_attempts"` } // String 把密码打码,防止 %v、log.Printf("%+v", cfg) 这类写法 // 不小心把明文密码带进日志——日志可能被贴进工单排查问题。 // // `[必须]` 这是 admin/AGENTS.md「日志、页面、导出里不得出现 token、密码、Cookie」 // 的从源头防护,不依赖每个调用方都记得手动打码。 func (c SybConfig) String() string { pw := "(空)" if c.Password != "" { pw = "****" } return fmt.Sprintf( "SybConfig{BaseURL:%s Username:%s Password:%s PageSize:%d MaxMatches:%d SyncFrom:%s OcrURL:%s OcrMaxAttempts:%d}", c.BaseURL, c.Username, pw, c.PageSize, c.MaxMatches, c.SyncFrom, c.OcrURL, c.OcrMaxAttempts) } // Config 是 config.yaml 的顶层结构。 type Config struct { Database DatabaseConfig `yaml:"database"` Syb SybConfig `yaml:"syb"` } // configFileName 是 config.yaml 相对 exe(或 go run 时相对工作目录)的文件名。 // 和 admin/config.example.yaml 同一目录,方便操作员按提示复制。 const configFileName = "config.yaml" // ConfigPath 返回 config.yaml 应该在的路径(不保证文件存在)。 // // 路径规则和 DataDir 一致(exe 旁边;go run 时是当前工作目录), // 因为 config.example.yaml 的说明就是"复制到 admin/ 目录", // 和 data/ 同级。 func ConfigPath() (string, error) { exe, err := os.Executable() if err != nil { return "", err } dir := filepath.Dir(exe) if isTempBuild(exe) { wd, err := os.Getwd() if err != nil { return "", err } dir = wd } return filepath.Join(dir, configFileName), nil } // Load 读取并解析 config.yaml。 // // `[必须]` 文件不存在时给出「复制 config.example.yaml」的明确提示, // 不是一句冷冰冰的「读取失败」——初级程序员第一次跑起来大概率会踩到这个。 func Load() (*Config, error) { path, err := ConfigPath() if err != nil { return nil, fmt.Errorf("无法确定 config.yaml 应该在的位置: %w", err) } raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, missingConfigError(path) } return nil, fmt.Errorf("读取配置文件 %s 失败: %w", path, err) } return parseConfig(raw, path) } // missingConfigError 是缺文件时的提示,抽成函数是为了配置测试 // (config_test.go)能直接复用同一句提示,不用真的绕开 os.Executable。 func missingConfigError(path string) error { return fmt.Errorf( "没有找到配置文件 %s。\n"+ "请复制 config.example.yaml 为 config.yaml,并填入 MySQL 和顺运宝账号密码:\n"+ " Windows: copy admin\\config.example.yaml admin\\config.yaml\n"+ " Linux: cp admin/config.example.yaml admin/config.yaml", path) } // parseConfig 解析 YAML 内容,抽成函数同样是为了让测试不依赖 os.Executable。 func parseConfig(raw []byte, path string) (*Config, error) { var cfg Config if err := yaml.Unmarshal(raw, &cfg); err != nil { return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, err) } return &cfg, nil }