From c86e98d0cb946868b4efd73b076a12474c8a90ee Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 20 Aug 2026 09:28:45 +0800 Subject: [PATCH] feat(#48): make one config work for both development and deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config.example.yaml | 13 ++ docs/02-architecture-and-code-map.md | 30 +++- scripts/start-server.ps1 | 4 + server/app/admin/router/spa.go | 72 ++++++++ server/app/admin/router/spa_test.go | 93 +++++++++++ server/app/admin/router/sys_dept.go | 2 +- server/app/admin/router/sys_login_log.go | 2 +- server/app/admin/router/sys_menu.go | 2 +- server/app/admin/router/sys_opera_log.go | 2 +- server/app/admin/router/sys_post.go | 2 +- server/app/admin/router/sys_router.go | 2 + server/app/admin/router/sys_user.go | 2 +- server/cmd/api/server.go | 1 + server/cmd/migrate/server.go | 3 +- server/config/local.go | 173 +++++++++++++++++++ server/config/local_test.go | 203 +++++++++++++++++++++++ 16 files changed, 597 insertions(+), 9 deletions(-) create mode 100644 server/app/admin/router/spa.go create mode 100644 server/app/admin/router/spa_test.go create mode 100644 server/config/local.go create mode 100644 server/config/local_test.go diff --git a/config.example.yaml b/config.example.yaml index 763b136..10e65ad 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -8,3 +8,16 @@ database: ports: server: 8000 web: 9527 + +# 顺云宝(SYB)ERP 凭据,见 docs/12-syb-erp-interface.md。 +# +# `[必须]` 只放凭据。base_url、page_size、max_matches、ocr_url 等非机密项在 +# server/config/settings.yml 的 extend.syb 下,不要在这里重复定义。 +# +# `[必须]` 密码要加引号。本项目容忍不加引号的纯数字密码,但 YAML 会把它读成 +# 整数,前导零会丢——加引号是唯一安全的写法。 +# +# 不配置 syb 段时服务端照常启动,只是 SYB 商品页的「从 SYB 导入」会提示未配置。 +syb: + username: 你的顺云宝账号 + password: "你的密码" diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 925ce1d..ddadf6b 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: a94218dc2c7cbf1c5d56af808a02bf65aaa19964 -synchronized_at: 2026-08-19T08:53:52Z +wiki_revision: a81b23771f859e89d16a8626735f1f3425d063e2 +synchronized_at: 2026-08-20T01:27:29Z # 架构与代码地图 @@ -68,6 +68,32 @@ Android Portal/Agent 数据库使用两个可空 guard 列表达跨数据库唯一约束:活动任务的 `active_slot=1`,运行中设备的 `device_run_slot=1`;终态记录对应列为 `NULL`。复合唯一索引据此保证同商品最多一个活动任务、同设备最多一个运行中任务,同时允许保留任意数量的终态历史任务。状态与 guard 列还有数据库检查约束,必须在同一条状态变更语句中更新。 +## 配置分层 + +配置分三层,下层覆盖上层: + +| 层 | 文件 | 是否进 Git | 放什么 | +|---|---|---|---| +| 1 默认值 | `server/config/settings.yml` | **是** | 应用配置和非机密运维参数(`extend.syb` 的 `baseurl` / `pagesize` / `maxmatches` / `ocrurl`) | +| 2 部署本地值 | `config.yaml`(仓库根) | **否**(`.gitignore`) | 数据库、端口、顺云宝凭据 | +| 3 覆盖值 | `GOAUTO_*` 环境变量 | — | 服务、容器和 CI 用;优先级最高 | + +`[必须]` **凭据只允许出现在第 2、3 层。** `settings.yml` 被 Git 跟踪,任何时候都不能往里写账号密码。 + +服务端自己读第 2 层(`config/local.go` 的 `ApplyLocalConfig`),查找顺序为 `GOAUTO_CONFIG` 指定的路径 → 当前目录 `./config.yaml` → 可执行文件同级目录。**找不到不是错误**:容器场景只用环境变量,本来就没有这个文件。回调注册在 `ApplyEnvironment` 之前,环境变量因此始终有最后决定权。 + +`[必须]` 第 2 层的标量按 YAML 原始类型读取后统一转字符串。纯数字密码不加引号会被 YAML 读成整数,这里不能因此启动失败——但仍应加引号,否则前导零会丢。 + +开发启动脚本把 `config.yaml` 的路径通过 `GOAUTO_CONFIG` 传给服务端,不在 PowerShell 里重复解析 YAML。 + +## 前端伺服 + +`web/.env.production` 的 `VUE_APP_BASE_API` 为空,即生产构建发的是相对请求,**前端与接口必须同源**。服务端在 `dist/index.html` 存在时伺服构建产物并为 history 路由回退到 `index.html`(`app/admin/router/spa.go`)。 + +`[必须]` 回退只对非接口路径的 GET 生效。给写错的接口路径回 200 + HTML,客户端看到的会是 JSON 解析错误而不是 404。 + +`[必须]` `dist` 不存在时不注册 `NoRoute`。开发环境前端跑在 vite 独立端口上,装了回退会把每个真 404 变成一张 HTML 页。 + ## 已建立的工程入口 | 功能 | 当前目录 | diff --git a/scripts/start-server.ps1 b/scripts/start-server.ps1 index 4301fec..a4d4bde 100644 --- a/scripts/start-server.ps1 +++ b/scripts/start-server.ps1 @@ -196,6 +196,9 @@ try { Remove-Item Env:MYSQL_PWD -ErrorAction SilentlyContinue } + # 让服务端自己读同一个 config.yaml,取出 syb 凭据等本地配置。 + # 下面的 GOAUTO_* 变量优先级高于文件,所以数据库和端口仍以脚本算出的为准。 + $env:GOAUTO_CONFIG = $ConfigPath $env:GOAUTO_DB_DRIVER = "mysql" $env:GOAUTO_DB_DSN = "${DatabaseUser}:${plainPassword}@tcp(${DatabaseHost}:${DatabasePort})/${DatabaseName}?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s" $env:GOAUTO_SERVER_PORT = [string]$portConfig.Server @@ -242,5 +245,6 @@ finally { Remove-Item Env:GOAUTO_DB_DSN -ErrorAction SilentlyContinue Remove-Item Env:GOAUTO_DB_DRIVER -ErrorAction SilentlyContinue Remove-Item Env:GOAUTO_SERVER_PORT -ErrorAction SilentlyContinue + Remove-Item Env:GOAUTO_CONFIG -ErrorAction SilentlyContinue $plainPassword = $null } diff --git a/server/app/admin/router/spa.go b/server/app/admin/router/spa.go new file mode 100644 index 0000000..9903f77 --- /dev/null +++ b/server/app/admin/router/spa.go @@ -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() +} diff --git a/server/app/admin/router/spa_test.go b/server/app/admin/router/spa_test.go new file mode 100644 index 0000000..6d9a112 --- /dev/null +++ b/server/app/admin/router/spa_test.go @@ -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("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 != "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) + } +} diff --git a/server/app/admin/router/sys_dept.go b/server/app/admin/router/sys_dept.go index f939374..393f906 100644 --- a/server/app/admin/router/sys_dept.go +++ b/server/app/admin/router/sys_dept.go @@ -29,4 +29,4 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle r1.GET("/deptTree", api.Get2Tree) } -} \ No newline at end of file +} diff --git a/server/app/admin/router/sys_login_log.go b/server/app/admin/router/sys_login_log.go index 61b4e83..9867507 100644 --- a/server/app/admin/router/sys_login_log.go +++ b/server/app/admin/router/sys_login_log.go @@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi r.GET("/:id", api.Get) r.DELETE("", api.Delete) } -} \ No newline at end of file +} diff --git a/server/app/admin/router/sys_menu.go b/server/app/admin/router/sys_menu.go index b5cad5a..d0da95c 100644 --- a/server/app/admin/router/sys_menu.go +++ b/server/app/admin/router/sys_menu.go @@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle //r1.GET("/menuids", api.GetMenuIDS) } -} \ No newline at end of file +} diff --git a/server/app/admin/router/sys_opera_log.go b/server/app/admin/router/sys_opera_log.go index d24d54f..0e1d8eb 100644 --- a/server/app/admin/router/sys_opera_log.go +++ b/server/app/admin/router/sys_opera_log.go @@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi r.GET("/:id", api.Get) r.DELETE("", api.Delete) } -} \ No newline at end of file +} diff --git a/server/app/admin/router/sys_post.go b/server/app/admin/router/sys_post.go index e299a5d..7255a30 100644 --- a/server/app/admin/router/sys_post.go +++ b/server/app/admin/router/sys_post.go @@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew r.PUT("/:id", api.Update) r.DELETE("", api.Delete) } -} \ No newline at end of file +} diff --git a/server/app/admin/router/sys_router.go b/server/app/admin/router/sys_router.go index b713e4b..3176a17 100644 --- a/server/app/admin/router/sys_router.go +++ b/server/app/admin/router/sys_router.go @@ -29,6 +29,8 @@ func InitSysRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Rou } // 需要认证 sysCheckRoleRouterInit(g, authMiddleware) + // 前端构建产物(存在时才注册),见 InitSPARouter 的说明 + InitSPARouter(r) return g } diff --git a/server/app/admin/router/sys_user.go b/server/app/admin/router/sys_user.go index 4a545a6..4fc7673 100644 --- a/server/app/admin/router/sys_user.go +++ b/server/app/admin/router/sys_user.go @@ -36,4 +36,4 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle { v1auth.GET("/getinfo", api.GetInfo) } -} \ No newline at end of file +} diff --git a/server/cmd/api/server.go b/server/cmd/api/server.go index 01b3399..ed13f0c 100644 --- a/server/cmd/api/server.go +++ b/server/cmd/api/server.go @@ -63,6 +63,7 @@ func setup() { //1. 读取配置 config.Setup( file.NewSource(file.WithPath(configYml)), + ext.ApplyLocalConfig, ext.ApplyEnvironment, database.Setup, storage.Setup, diff --git a/server/cmd/migrate/server.go b/server/cmd/migrate/server.go index 1b57da9..4f31e25 100644 --- a/server/cmd/migrate/server.go +++ b/server/cmd/migrate/server.go @@ -51,7 +51,8 @@ func run() { //1. 读取配置 config.Setup( file.NewSource(file.WithPath(configYml)), - ext.ApplyEnvironment, + ext.ApplyLocalConfig, + ext.ApplyEnvironment, initDB, ) } else { diff --git a/server/config/local.go b/server/config/local.go new file mode 100644 index 0000000..b74af03 --- /dev/null +++ b/server/config/local.go @@ -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) + } +} diff --git a/server/config/local_test.go b/server/config/local_test.go new file mode 100644 index 0000000..710532c --- /dev/null +++ b/server/config/local_test.go @@ -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) + } + } +}