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>
174 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|