feat: 支持从 config.yaml 读取 MySQL 配置 (#83)
This commit is contained in:
+76
-24
@@ -31,14 +31,16 @@ const (
|
||||
databasePasswordEnv = "CMAUTOBUY_DB_PASSWORD"
|
||||
)
|
||||
|
||||
// DatabaseConfig 是生产 MySQL 8 的连接配置。
|
||||
// 密码只从环境变量读取,不能写进 config.yaml、日志或工单。
|
||||
// DatabaseConfig 是 MySQL 8 的连接配置。
|
||||
//
|
||||
// 本地运行可以从被 Git 忽略的 config.yaml 读取;线上部署仍建议使用
|
||||
// CMAUTOBUY_DB_* 环境变量。无论来源是什么,密码都不能写进日志或工单。
|
||||
type DatabaseConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Name string
|
||||
User string
|
||||
Password string
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
Name string `yaml:"name"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
// String 永远隐藏密码,防止排错时用 %v 把凭据写进日志。
|
||||
@@ -51,16 +53,66 @@ func (c DatabaseConfig) String() string {
|
||||
c.Host, c.Port, c.Name, c.User, password)
|
||||
}
|
||||
|
||||
// LoadDatabaseFromEnv 读取生产数据库配置。
|
||||
// Host/Port 使用线上同机部署的安全默认值;库名、账号和密码必须显式提供。
|
||||
func LoadDatabaseFromEnv() (DatabaseConfig, error) {
|
||||
cfg := DatabaseConfig{
|
||||
Host: strings.TrimSpace(os.Getenv(databaseHostEnv)),
|
||||
Port: strings.TrimSpace(os.Getenv(databasePortEnv)),
|
||||
Name: strings.TrimSpace(os.Getenv(databaseNameEnv)),
|
||||
User: strings.TrimSpace(os.Getenv(databaseUserEnv)),
|
||||
Password: os.Getenv(databasePasswordEnv),
|
||||
// LoadDatabase 读取 MySQL 配置。config.yaml 适合本地双击 run_admin.bat;
|
||||
// CMAUTOBUY_DB_* 环境变量按字段覆盖文件配置,供线上 systemd 安全注入。
|
||||
//
|
||||
// config.yaml 不存在时仍可完全依靠环境变量启动,保持线上部署兼容。
|
||||
func LoadDatabase() (DatabaseConfig, error) {
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return DatabaseConfig{}, fmt.Errorf("无法确定 config.yaml 应该在的位置: %w", err)
|
||||
}
|
||||
return loadDatabaseFromPath(path)
|
||||
}
|
||||
|
||||
// loadDatabaseFromPath 把文件读取单独抽出来,方便单元测试使用临时配置文件,
|
||||
// 不依赖测试可执行文件所在的临时目录。
|
||||
func loadDatabaseFromPath(path string) (DatabaseConfig, error) {
|
||||
var fileConfig DatabaseConfig
|
||||
raw, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
cfg, parseErr := parseConfig(raw, path)
|
||||
if parseErr != nil {
|
||||
return DatabaseConfig{}, parseErr
|
||||
}
|
||||
fileConfig = cfg.Database
|
||||
} else if !os.IsNotExist(err) {
|
||||
return DatabaseConfig{}, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
|
||||
return mergeDatabaseConfig(fileConfig)
|
||||
}
|
||||
|
||||
// LoadDatabaseFromEnv 只读取环境变量,供集成测试创建隔离的 _test 数据库。
|
||||
// 生产入口和迁移命令应使用 LoadDatabase。
|
||||
func LoadDatabaseFromEnv() (DatabaseConfig, error) {
|
||||
return mergeDatabaseConfig(DatabaseConfig{})
|
||||
}
|
||||
|
||||
// mergeDatabaseConfig 把环境变量合并到文件配置上。环境变量只要非空,
|
||||
// 就覆盖对应 YAML 字段,避免线上误读部署目录里遗留的本地配置。
|
||||
func mergeDatabaseConfig(cfg DatabaseConfig) (DatabaseConfig, error) {
|
||||
overrides := map[string]*string{
|
||||
databaseHostEnv: &cfg.Host,
|
||||
databasePortEnv: &cfg.Port,
|
||||
databaseNameEnv: &cfg.Name,
|
||||
databaseUserEnv: &cfg.User,
|
||||
databasePasswordEnv: &cfg.Password,
|
||||
}
|
||||
for envName, target := range overrides {
|
||||
value := os.Getenv(envName)
|
||||
if envName != databasePasswordEnv {
|
||||
value = strings.TrimSpace(value)
|
||||
}
|
||||
if value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Host = strings.TrimSpace(cfg.Host)
|
||||
cfg.Port = strings.TrimSpace(cfg.Port)
|
||||
cfg.Name = strings.TrimSpace(cfg.Name)
|
||||
cfg.User = strings.TrimSpace(cfg.User)
|
||||
if cfg.Host == "" {
|
||||
cfg.Host = "127.0.0.1"
|
||||
}
|
||||
@@ -69,16 +121,16 @@ func LoadDatabaseFromEnv() (DatabaseConfig, error) {
|
||||
}
|
||||
var missing []string
|
||||
if cfg.Name == "" {
|
||||
missing = append(missing, databaseNameEnv)
|
||||
missing = append(missing, "database.name / "+databaseNameEnv)
|
||||
}
|
||||
if cfg.User == "" {
|
||||
missing = append(missing, databaseUserEnv)
|
||||
missing = append(missing, "database.user / "+databaseUserEnv)
|
||||
}
|
||||
if cfg.Password == "" {
|
||||
missing = append(missing, databasePasswordEnv)
|
||||
missing = append(missing, "database.password / "+databasePasswordEnv)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return DatabaseConfig{}, fmt.Errorf("缺少 MySQL 配置环境变量:%s", strings.Join(missing, "、"))
|
||||
return DatabaseConfig{}, fmt.Errorf("缺少 MySQL 配置:%s", strings.Join(missing, "、"))
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -180,10 +232,10 @@ func (c SybConfig) String() string {
|
||||
c.BaseURL, c.Username, pw, c.PageSize, c.MaxMatches, c.SyncFrom, c.OcrURL, c.OcrMaxAttempts)
|
||||
}
|
||||
|
||||
// Config 是 config.yaml 的顶层结构。目前只有顺运宝一节,
|
||||
// 后续如果要给别的模块加配置,在这里加新的字段即可。
|
||||
// Config 是 config.yaml 的顶层结构。
|
||||
type Config struct {
|
||||
Syb SybConfig `yaml:"syb"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Syb SybConfig `yaml:"syb"`
|
||||
}
|
||||
|
||||
// configFileName 是 config.yaml 相对 exe(或 go run 时相对工作目录)的文件名。
|
||||
@@ -236,7 +288,7 @@ func Load() (*Config, error) {
|
||||
func missingConfigError(path string) error {
|
||||
return fmt.Errorf(
|
||||
"没有找到配置文件 %s。\n"+
|
||||
"请复制 config.example.yaml 为 config.yaml,并填入顺运宝账号密码:\n"+
|
||||
"请复制 config.example.yaml 为 config.yaml,并填入 MySQL 和顺运宝账号密码:\n"+
|
||||
" Windows: copy admin\\config.example.yaml admin\\config.yaml\n"+
|
||||
" Linux: cp admin/config.example.yaml admin/config.yaml",
|
||||
path)
|
||||
|
||||
@@ -36,6 +36,95 @@ func TestLoadDatabaseFromEnv_缺少必填项时只报变量名(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatabaseFromPath_可以只用YAML配置(t *testing.T) {
|
||||
clearDatabaseEnv(t)
|
||||
path := writeConfigFile(t, `
|
||||
database:
|
||||
host: 127.0.0.1
|
||||
port: "13307"
|
||||
name: autobuy
|
||||
user: buy
|
||||
password: "yaml-secret"
|
||||
`)
|
||||
|
||||
cfg, err := loadDatabaseFromPath(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Host != "127.0.0.1" || cfg.Port != "13307" || cfg.Name != "autobuy" || cfg.User != "buy" {
|
||||
t.Fatalf("YAML 数据库配置读取错误:%s", cfg)
|
||||
}
|
||||
if cfg.Password != "yaml-secret" {
|
||||
t.Fatal("YAML 数据库密码没有原样读出")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatabaseFromPath_环境变量按字段覆盖YAML(t *testing.T) {
|
||||
clearDatabaseEnv(t)
|
||||
t.Setenv(databasePortEnv, "23307")
|
||||
t.Setenv(databaseUserEnv, "env-user")
|
||||
t.Setenv(databasePasswordEnv, "env-secret")
|
||||
path := writeConfigFile(t, `
|
||||
database:
|
||||
host: 127.0.0.1
|
||||
port: "13307"
|
||||
name: autobuy
|
||||
user: yaml-user
|
||||
password: "yaml-secret"
|
||||
`)
|
||||
|
||||
cfg, err := loadDatabaseFromPath(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Host != "127.0.0.1" || cfg.Name != "autobuy" {
|
||||
t.Fatalf("未覆盖字段应该保留 YAML 值:%s", cfg)
|
||||
}
|
||||
if cfg.Port != "23307" || cfg.User != "env-user" || cfg.Password != "env-secret" {
|
||||
t.Fatalf("环境变量没有按字段覆盖 YAML:%s", cfg)
|
||||
}
|
||||
if strings.Contains(cfg.String(), "env-secret") || strings.Contains(cfg.String(), "yaml-secret") {
|
||||
t.Fatalf("配置字符串泄露密码:%s", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatabaseFromPath_文件不存在时完整环境变量仍可启动(t *testing.T) {
|
||||
clearDatabaseEnv(t)
|
||||
t.Setenv(databaseNameEnv, "autobuy")
|
||||
t.Setenv(databaseUserEnv, "buy")
|
||||
t.Setenv(databasePasswordEnv, "env-secret")
|
||||
|
||||
cfg, err := loadDatabaseFromPath(filepath.Join(t.TempDir(), "missing.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Host != "127.0.0.1" || cfg.Port != "3307" {
|
||||
t.Fatalf("线上环境变量模式应该保留安全默认地址,实际:%s", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func clearDatabaseEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
databaseHostEnv,
|
||||
databasePortEnv,
|
||||
databaseNameEnv,
|
||||
databaseUserEnv,
|
||||
databasePasswordEnv,
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfigFile(t *testing.T, yamlText string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), configFileName)
|
||||
if err := os.WriteFile(path, []byte(yamlText), 0o600); err != nil {
|
||||
t.Fatalf("写测试配置文件失败: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// loadFrom 是测试专用的小工具:把一段 YAML 文本写到临时目录里的
|
||||
// config.yaml,绕开 ConfigPath()(它依赖 os.Executable,测试环境里
|
||||
// 不可控),直接测 Load 里"读文件 + 解析"这段逻辑。
|
||||
|
||||
Reference in New Issue
Block a user