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>
This commit is contained in:
QiuSW
2026-08-20 09:28:45 +08:00
co-authored by Claude Opus 5
parent 7d07fa52b7
commit c86e98d0cb
16 changed files with 597 additions and 9 deletions
+72
View File
@@ -0,0 +1,72 @@
package router
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// SPADirEnv overrides where the built frontend lives. Without it the server
// looks for ./dist next to the working directory.
const SPADirEnv = "GOAUTO_WEB_DIST"
// InitSPARouter serves the built Vue app from the same origin as the API.
//
// Same-origin is what web/.env.production already assumes: VUE_APP_BASE_API is
// empty there, so the bundle issues relative requests. Serving dist from this
// binary makes that assumption true without a second web server and without
// CORS.
//
// `[必须]` Registering this is conditional on dist actually existing. In
// development the frontend runs under vite on its own port and there is no
// dist; a NoRoute handler installed anyway would turn every genuine 404 into
// an HTML page, which is far more confusing than a plain 404.
func InitSPARouter(engine *gin.Engine) {
dist := strings.TrimSpace(os.Getenv(SPADirEnv))
if dist == "" {
dist = "dist"
}
index := filepath.Join(dist, "index.html")
if _, err := os.Stat(index); err != nil {
return
}
engine.Static("/assets", filepath.Join(dist, "assets"))
for _, name := range []string{"favicon.ico", "logo.png"} {
if path := filepath.Join(dist, name); fileExists(path) {
engine.StaticFile("/"+name, path)
}
}
// History-mode routing means any unmatched non-API GET is a client route
// and must return index.html so the app can boot and route it itself.
//
// `[必须]` API paths are excluded. Answering a mistyped API call with 200
// and an HTML body would surface in the client as a JSON parse error
// instead of the 404 it actually is.
engine.NoRoute(func(c *gin.Context) {
if c.Request.Method != http.MethodGet || isAPIPath(c.Request.URL.Path) {
c.Status(http.StatusNotFound)
return
}
c.File(index)
})
}
// isAPIPath reports whether a path belongs to the server rather than the SPA.
func isAPIPath(path string) bool {
for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/", "/ws/", "/wslogout/", "/info"} {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
+93
View File
@@ -0,0 +1,93 @@
package router
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func newSPAEngine(t *testing.T, withDist bool) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
dist := filepath.Join(t.TempDir(), "dist")
if withDist {
if err := os.MkdirAll(filepath.Join(dist, "assets"), 0o755); err != nil {
t.Fatalf("建目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("<!doctype html>SPA"), 0o644); err != nil {
t.Fatalf("写 index.html 失败: %v", err)
}
}
t.Setenv(SPADirEnv, dist)
engine := gin.New()
engine.GET("/api/admin/v1/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") })
InitSPARouter(engine)
return engine
}
func do(engine *gin.Engine, method, path string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(method, path, nil))
return recorder
}
// history 模式:前端路由必须回退到 index.html,否则刷新页面就是 404。
func TestUnmatchedGetFallsBackToIndex(t *testing.T) {
engine := newSPAEngine(t, true)
response := do(engine, http.MethodGet, "/goauto/syb-products")
if response.Code != http.StatusOK {
t.Fatalf("前端路由应回退到 index.html,实际 %d", response.Code)
}
if body := response.Body.String(); body != "<!doctype html>SPA" {
t.Fatalf("返回的不是 index.html: %q", body)
}
}
// `[必须]` 写错的 API 路径必须还是 404。回一个 200 + HTML,客户端看到的会是
// JSON 解析错误,而不是"这个接口不存在"。
func TestUnknownAPIPathStaysA404(t *testing.T) {
engine := newSPAEngine(t, true)
for _, path := range []string{
"/api/admin/v1/does-not-exist",
"/api/v1/nope",
"/swagger/admin/index.html",
"/static/missing.png",
"/ws/1/2",
"/info",
} {
if response := do(engine, http.MethodGet, path); response.Code != http.StatusNotFound {
t.Fatalf("%s 应返回 404,实际 %d", path, response.Code)
}
}
}
// 真实存在的接口不能被 SPA 回退影响。
func TestRegisteredAPIStillWorks(t *testing.T) {
engine := newSPAEngine(t, true)
if response := do(engine, http.MethodGet, "/api/admin/v1/ping"); response.Code != http.StatusOK {
t.Fatalf("已注册的接口被影响了: %d", response.Code)
}
}
// 非 GET 请求不该拿到 HTML——POST 到不存在的路径就是 404。
func TestNonGetDoesNotFallBack(t *testing.T) {
engine := newSPAEngine(t, true)
if response := do(engine, http.MethodPost, "/whatever"); response.Code != http.StatusNotFound {
t.Fatalf("POST 不应回退到 index.html,实际 %d", response.Code)
}
}
// 开发环境没有 dist,绝不能注册 NoRoute——否则每个真 404 都变成一张 HTML。
func TestWithoutDistNoFallbackIsInstalled(t *testing.T) {
engine := newSPAEngine(t, false)
if response := do(engine, http.MethodGet, "/goauto/syb-products"); response.Code != http.StatusNotFound {
t.Fatalf("没有 dist 时不应回退,实际 %d", response.Code)
}
if response := do(engine, http.MethodGet, "/api/admin/v1/ping"); response.Code != http.StatusOK {
t.Fatalf("没有 dist 时接口仍应正常: %d", response.Code)
}
}
+1 -1
View File
@@ -29,4 +29,4 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
r1.GET("/deptTree", api.Get2Tree)
}
}
}
+1 -1
View File
@@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.GET("/:id", api.Get)
r.DELETE("", api.Delete)
}
}
}
+1 -1
View File
@@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
//r1.GET("/menuids", api.GetMenuIDS)
}
}
}
+1 -1
View File
@@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.GET("/:id", api.Get)
r.DELETE("", api.Delete)
}
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
r.PUT("/:id", api.Update)
r.DELETE("", api.Delete)
}
}
}
+2
View File
@@ -29,6 +29,8 @@ func InitSysRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Rou
}
// 需要认证
sysCheckRoleRouterInit(g, authMiddleware)
// 前端构建产物(存在时才注册),见 InitSPARouter 的说明
InitSPARouter(r)
return g
}
+1 -1
View File
@@ -36,4 +36,4 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
{
v1auth.GET("/getinfo", api.GetInfo)
}
}
}
+1
View File
@@ -63,6 +63,7 @@ func setup() {
//1. 读取配置
config.Setup(
file.NewSource(file.WithPath(configYml)),
ext.ApplyLocalConfig,
ext.ApplyEnvironment,
database.Setup,
storage.Setup,
+2 -1
View File
@@ -51,7 +51,8 @@ func run() {
//1. 读取配置
config.Setup(
file.NewSource(file.WithPath(configYml)),
ext.ApplyEnvironment,
ext.ApplyLocalConfig,
ext.ApplyEnvironment,
initDB,
)
} else {
+173
View File
@@ -0,0 +1,173 @@
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)
}
}
+203
View File
@@ -0,0 +1,203 @@
package config
import (
"os"
"path/filepath"
"testing"
sdkconfig "github.com/go-admin-team/go-admin-core/sdk/config"
)
func writeLocalConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), LocalConfigName)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写入配置失败: %v", err)
}
t.Setenv("GOAUTO_CONFIG", path)
return path
}
func restoreConfigState(t *testing.T) {
t.Helper()
database := *sdkconfig.DatabaseConfig
application := *sdkconfig.ApplicationConfig
ext := ExtConfig.SYB
t.Cleanup(func() {
*sdkconfig.DatabaseConfig = database
*sdkconfig.ApplicationConfig = application
ExtConfig.SYB = ext
})
}
func TestApplyLocalConfigReadsDatabasePortsAndSYB(t *testing.T) {
restoreConfigState(t)
writeLocalConfig(t, `
database:
host: 127.0.0.1
port: 3307
user: root
password: "123456"
name: goauto
ports:
server: 8010
web: 9527
syb:
username: operator
password: "654321"
`)
ApplyLocalConfig()
if sdkconfig.DatabaseConfig.Driver != "mysql" {
t.Fatalf("driver 不对: %q", sdkconfig.DatabaseConfig.Driver)
}
want := "root:123456@tcp(127.0.0.1:3307)/goauto?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
if sdkconfig.DatabaseConfig.Source != want {
t.Fatalf("DSN 不对:\n 实际 %s\n 期望 %s", sdkconfig.DatabaseConfig.Source, want)
}
if sdkconfig.ApplicationConfig.Port != 8010 {
t.Fatalf("端口不对: %d", sdkconfig.ApplicationConfig.Port)
}
if ExtConfig.SYB.Username != "operator" || ExtConfig.SYB.Password != "654321" {
t.Fatalf("SYB 凭据没有读进来: %+v", ExtConfig.SYB)
}
}
// `[必须]` 纯数字密码不加引号会被 YAML 读成整数。这是上游踩过的坑,
// 也是用户第一版 config.yaml 的实际写法——绝不能因此启动失败或丢值。
func TestApplyLocalConfigToleratesUnquotedNumericPassword(t *testing.T) {
restoreConfigState(t)
writeLocalConfig(t, `
database:
host: 127.0.0.1
port: 3307
user: root
password: 123456
name: goauto
syb:
username: operator
password: 654321
`)
ApplyLocalConfig()
if got := sdkconfig.DatabaseConfig.Source; got != "root:123456@tcp(127.0.0.1:3307)/goauto?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s" {
t.Fatalf("数字密码没有被正确还原: %s", got)
}
if ExtConfig.SYB.Password != "654321" {
t.Fatalf("SYB 数字密码没有被正确还原: %q", ExtConfig.SYB.Password)
}
}
// 分层的核心:环境变量必须压过 config.yaml。
func TestEnvironmentOverridesLocalConfig(t *testing.T) {
restoreConfigState(t)
writeLocalConfig(t, `
database:
host: 127.0.0.1
port: 3307
user: root
password: "fromfile"
name: goauto
ports:
server: 8010
syb:
username: from-file
password: "from-file"
`)
t.Setenv("GOAUTO_DB_DSN", "envuser:envpass@tcp(10.0.0.1:3306)/envdb")
t.Setenv("GOAUTO_SERVER_PORT", "9000")
t.Setenv("GOAUTO_SYB_USERNAME", "from-env")
t.Setenv("GOAUTO_SYB_PASSWORD", "from-env-pass")
// 真实启动顺序:先 config.yaml,再环境变量。
ApplyLocalConfig()
ApplyEnvironment()
if sdkconfig.DatabaseConfig.Source != "envuser:envpass@tcp(10.0.0.1:3306)/envdb" {
t.Fatalf("环境变量没有压过 config.yaml: %s", sdkconfig.DatabaseConfig.Source)
}
if sdkconfig.ApplicationConfig.Port != 9000 {
t.Fatalf("端口应以环境变量为准: %d", sdkconfig.ApplicationConfig.Port)
}
if ExtConfig.SYB.Username != "from-env" || ExtConfig.SYB.Password != "from-env-pass" {
t.Fatalf("SYB 凭据应以环境变量为准: %+v", ExtConfig.SYB)
}
}
// 只写凭据、不写其它项时,settings.yml 里的运维参数必须原样保留。
func TestLocalConfigWithOnlyCredentialsKeepsSettingsValues(t *testing.T) {
restoreConfigState(t)
ExtConfig.SYB = SYB{BaseURL: "https://from-settings", PageSize: 20, MaxMatches: 10000, OcrURL: "https://ocr", OcrMaxAttempts: 5}
writeLocalConfig(t, `
syb:
username: operator
password: "654321"
`)
ApplyLocalConfig()
if ExtConfig.SYB.BaseURL != "https://from-settings" || ExtConfig.SYB.PageSize != 20 ||
ExtConfig.SYB.MaxMatches != 10000 || ExtConfig.SYB.OcrURL != "https://ocr" || ExtConfig.SYB.OcrMaxAttempts != 5 {
t.Fatalf("settings.yml 的非机密项被意外覆盖了: %+v", ExtConfig.SYB)
}
if ExtConfig.SYB.Username != "operator" {
t.Fatalf("凭据没有生效: %+v", ExtConfig.SYB)
}
}
// 容器场景根本没有 config.yaml,必须安静地当作正常情况。
func TestAbsentLocalConfigIsASilentNoOp(t *testing.T) {
restoreConfigState(t)
t.Setenv("GOAUTO_CONFIG", "")
if path := LocalConfigPath(); path != "" {
t.Fatalf("测试环境不应找到 config.yaml,实际找到 %s", path)
}
ApplyLocalConfig()
}
// 但显式指定了 GOAUTO_CONFIG 却指不到文件,是配置错误,要给出提示——
// 这和"压根没有这个文件"是两回事,不能一起静默掉。
func TestExplicitlyNamedMissingConfigStillStartsButIsReported(t *testing.T) {
restoreConfigState(t)
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
t.Setenv("GOAUTO_CONFIG", missing)
if LocalConfigPath() != missing {
t.Fatal("显式指定的路径应原样返回,好让读取失败时报出来")
}
ApplyLocalConfig()
}
// 配置文件写坏了也不能让服务起不来——它提供的每一项都是可选的。
func TestMalformedLocalConfigIsIgnored(t *testing.T) {
restoreConfigState(t)
writeLocalConfig(t, "database: [this is not a mapping\n")
ApplyLocalConfig()
}
// 数据库段缺字段时不能拼出半截 DSN,宁可完全不动。
func TestIncompleteDatabaseSectionIsIgnored(t *testing.T) {
restoreConfigState(t)
sdkconfig.DatabaseConfig.Source = "untouched"
writeLocalConfig(t, `
database:
host: 127.0.0.1
user: root
`)
ApplyLocalConfig()
if sdkconfig.DatabaseConfig.Source != "untouched" {
t.Fatalf("字段不全时不应改写 DSN: %s", sdkconfig.DatabaseConfig.Source)
}
}
func TestScalarRendersYAMLTypesWithoutExponentOrTrailingZero(t *testing.T) {
values := map[string]any{"int": 3307, "float": float64(8010), "str": "x", "bool": true, "big": float64(1e7)}
for key, want := range map[string]string{
"int": "3307", "float": "8010", "str": "x", "bool": "true", "big": "10000000", "missing": "",
} {
if got := scalar(values, key); got != want {
t.Fatalf("%s: 期望 %q,实际 %q", key, want, got)
}
}
}