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:
@@ -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: "你的密码"
|
||||
|
||||
@@ -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 页。
|
||||
|
||||
## 已建立的工程入口
|
||||
|
||||
| 功能 | 当前目录 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,4 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
r1.GET("/deptTree", api.Get2Tree)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
//r1.GET("/menuids", api.GetMenuIDS)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
|
||||
r.PUT("/:id", api.Update)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ func InitSysRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Rou
|
||||
}
|
||||
// 需要认证
|
||||
sysCheckRoleRouterInit(g, authMiddleware)
|
||||
// 前端构建产物(存在时才注册),见 InitSPARouter 的说明
|
||||
InitSPARouter(r)
|
||||
return g
|
||||
}
|
||||
|
||||
|
||||
@@ -36,4 +36,4 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
{
|
||||
v1auth.GET("/getinfo", api.GetInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ func setup() {
|
||||
//1. 读取配置
|
||||
config.Setup(
|
||||
file.NewSource(file.WithPath(configYml)),
|
||||
ext.ApplyLocalConfig,
|
||||
ext.ApplyEnvironment,
|
||||
database.Setup,
|
||||
storage.Setup,
|
||||
|
||||
@@ -51,7 +51,8 @@ func run() {
|
||||
//1. 读取配置
|
||||
config.Setup(
|
||||
file.NewSource(file.WithPath(configYml)),
|
||||
ext.ApplyEnvironment,
|
||||
ext.ApplyLocalConfig,
|
||||
ext.ApplyEnvironment,
|
||||
initDB,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user