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
+13
View File
@@ -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: "你的密码"
+28 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -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 页。
## 已建立的工程入口
| 功能 | 当前目录 |
+4
View File
@@ -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
}
+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)
}
}
}