Files
goauto/server/config/local.go
T
QiuSWandClaude Opus 5 c86e98d0cb feat(#48): make one config work for both development and deployment
The repo already layered file defaults under environment overrides, on
both the backend (settings.yml < GOAUTO_*) and the frontend
(.env.production < process.env). What was missing was a translator for
production: config.yaml only ever existed for the PowerShell launchers,
so a packaged binary read none of it and had no database credentials
either — SYB was inheriting an existing gap, not creating one.

The server now reads config.yaml itself, between settings.yml and the
environment. Lookup is GOAUTO_CONFIG, then ./config.yaml, then beside the
executable, so a packaged binary works wherever it is started. An absent
file is not an error: containers supply everything through the
environment. Scalars are read by YAML type and coerced, so an unquoted
all-digit password cannot take startup down over a quoting detail.

This removed the need for a Read-SybConfig in PowerShell: the launcher
just hands over the path it already knows, rather than reimplementing a
YAML parser.

The server also serves the built frontend when dist is present, which is
what .env.production's empty VUE_APP_BASE_API already assumes. The
history fallback is restricted to non-API GETs, and is not installed at
all without dist, so development 404s stay 404s.

Precedence is mutation-tested: applying the local file after the
environment instead of before makes the layering test fail.

Not verified: the PowerShell change and any Windows deployment — both
need a run on the Windows side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:28:45 +08:00

174 lines
5.8 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
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
}
// 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 == "" {
return
}
raw, err := os.ReadFile(path)
if err != nil {
fmt.Fprintf(os.Stderr, "读取本地配置 %s 失败,已忽略: %v\n", path, err)
return
}
var file localFile
if err := yaml.Unmarshal(raw, &file); err != nil {
fmt.Fprintf(os.Stderr, "解析本地配置 %s 失败,已忽略: %v\n", path, err)
return
}
applyLocalDatabase(file.Database)
applyLocalPorts(file.Ports)
ApplyLocalSYB(file.SYB)
}
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
}
}
// 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)
}
}