package config import ( "fmt" "os" "path/filepath" "strconv" "strings" "time" sdkconfig "github.com/go-admin-team/go-admin-core/sdk/config" "gopkg.in/yaml.v2" ) // LocalConfigName is the deployment-local, never-tracked configuration file. // It sits between settings.yml and the environment: // // settings.yml (tracked defaults, no secrets) // < config.yaml (this file: deployment-local values and credentials) // < GOAUTO_* environment variables (highest, for services and containers) // // Reading it in-process is what lets a packaged binary run on its own, with the // same file and the same format the development launcher scripts already use. const LocalConfigName = "config.yaml" // LocalConfigPath resolves which config.yaml to read, or "" when there is none. // // A missing file is not an error: a container or a Windows service supplies // everything through the environment and has no such file at all. func LocalConfigPath() string { if explicit := strings.TrimSpace(os.Getenv("GOAUTO_CONFIG")); explicit != "" { return explicit } if _, err := os.Stat(LocalConfigName); err == nil { return LocalConfigName } // One level up. The server is normally started from server/ while // config.yaml lives at the repository root, so without this the file is // found only when a launcher happens to export GOAUTO_CONFIG — and a // developer running `go run .` by hand gets no local configuration at all. parent := filepath.Join("..", LocalConfigName) if _, err := os.Stat(parent); err == nil { return parent } // Next to the executable, so double-clicking a packaged binary works no // matter what the working directory happens to be. if executable, err := os.Executable(); err == nil { beside := filepath.Join(filepath.Dir(executable), LocalConfigName) if _, err := os.Stat(beside); err == nil { return beside } } return "" } // localFile mirrors config.yaml as loosely typed maps rather than typed // structs. // // `[必须]` This is deliberate. YAML types an unquoted all-digit password as an // integer, so a typed string field would fail to unmarshal and take the whole // startup down over a quoting detail. Reading scalars as `any` and coercing // them means a numeric password, port, or account id all work whether or not // somebody remembered the quotes. type localFile struct { Database map[string]any `yaml:"database"` Ports map[string]any `yaml:"ports"` SYB map[string]any `yaml:"syb"` } // ApplyLocalConfig loads config.yaml, if one is present, over the values // already read from settings.yml. It runs before ApplyEnvironment so the // environment keeps the last word. // // It never returns an error: a malformed or absent local file must not be the // difference between a server that starts and one that does not, and the // values it would have supplied are all individually optional. func ApplyLocalConfig() { path := LocalConfigPath() if path == "" { // `[必须]` Say so out loud. Silently doing nothing here surfaces much // later as a puzzling "credential not configured" error that never // mentions the file the operator actually edited. logInfo("本地配置:未找到 %s(依次查找 GOAUTO_CONFIG、当前目录、上级目录、可执行文件同级目录),仅使用 settings.yml 和环境变量", LocalConfigName) return } raw, err := os.ReadFile(path) if err != nil { logInfo("本地配置:读取 %s 失败,已忽略: %v", path, err) return } var file localFile if err := yaml.Unmarshal(raw, &file); err != nil { logInfo("本地配置:解析 %s 失败,已忽略: %v", path, err) return } applyLocalDatabase(file.Database) applyLocalPorts(file.Ports) ApplyLocalSYB(file.SYB) logInfo("本地配置:已加载 %s", path) } // LogEffectiveConfig prints what the layered configuration actually resolved // to, without printing any secret. It runs after every layer has been applied. // // `[必须]` Report presence, never values. An operator needs to know whether the // credentials arrived, not what they are — server logs get pasted into tickets. func LogEffectiveConfig() { syb := ExtConfig.SYB.Resolved() source := "未配置" switch { case strings.TrimSpace(os.Getenv("GOAUTO_SYB_USERNAME")) != "": source = "环境变量" case syb.HasCredentials(): source = LocalConfigName } logInfo("顺云宝配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v", syb.HasCredentials(), source, syb.BaseURL, syb.OcrURL != "") } func applyLocalDatabase(database map[string]any) { host := scalar(database, "host") user := scalar(database, "user") name := scalar(database, "name") port := scalar(database, "port") if host == "" || user == "" || name == "" || port == "" { return } // `[必须]` The password is read without trimming: leading or trailing // whitespace can be part of a credential, and silently changing it would // produce an authentication failure with no visible cause. password, _ := database["password"].(string) if password == "" { password = scalar(database, "password") } dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", user, password, host, port, name) sdkconfig.DatabaseConfig.Driver = "mysql" sdkconfig.DatabaseConfig.Source = dsn for _, database := range sdkconfig.DatabasesConfig { database.Driver = "mysql" database.Source = dsn } } func applyLocalPorts(ports map[string]any) { port, err := strconv.Atoi(scalar(ports, "server")) if err != nil || port < 1 || port > 65535 { return } sdkconfig.ApplicationConfig.Port = int64(port) } // ApplyLocalSYB folds a config.yaml `syb:` section into ExtConfig. Only keys // actually present override what settings.yml supplied, so config.yaml can // carry credentials alone and leave the operational knobs where they are. func ApplyLocalSYB(syb map[string]any) { if username := strings.TrimSpace(scalar(syb, "username")); username != "" { ExtConfig.SYB.Username = username } if password := scalar(syb, "password"); password != "" { ExtConfig.SYB.Password = password } if baseURL := strings.TrimSpace(scalar(syb, "base_url")); baseURL != "" { ExtConfig.SYB.BaseURL = baseURL } if ocrURL := strings.TrimSpace(scalar(syb, "ocr_url")); ocrURL != "" { ExtConfig.SYB.OcrURL = ocrURL } if pageSize, err := strconv.Atoi(scalar(syb, "page_size")); err == nil && pageSize > 0 { ExtConfig.SYB.PageSize = pageSize } if maxMatches, err := strconv.Atoi(scalar(syb, "max_matches")); err == nil && maxMatches > 0 { ExtConfig.SYB.MaxMatches = maxMatches } if attempts, err := strconv.Atoi(scalar(syb, "ocr_max_attempts")); err == nil && attempts > 0 { ExtConfig.SYB.OcrMaxAttempts = attempts } } // logInfo writes an informational startup line to stdout. // // `[必须]` Not stderr. The development launcher pipes the server through // `2>&1 | Tee-Object`, which turns every stderr write into a PowerShell // NativeCommandError — informational lines would show up as a red error block // and look like a failed startup. func logInfo(format string, args ...any) { fmt.Printf(time.Now().Format("2006/01/02 15:04:05")+" "+format+"\n", args...) } // scalar renders one YAML value as a string regardless of how YAML typed it. // // `[必须]` Floats are formatted without an exponent and without a trailing // ".0": YAML reads a bare 3307 as int but some values arrive as float64, and // "3307.0" is not a usable port. func scalar(values map[string]any, key string) string { if values == nil { return "" } switch value := values[key].(type) { case nil: return "" case string: return value case bool: return strconv.FormatBool(value) case int: return strconv.Itoa(value) case int64: return strconv.FormatInt(value, 10) case float64: return strconv.FormatFloat(value, 'f', -1, 64) default: return fmt.Sprintf("%v", value) } }