Files
cmautobuy/admin/config/config.go
T

305 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package config 负责运行参数和文件路径。
//
// 改动本文件前必读 admin/AGENTS.md。最容易踩的一条:
// 路径必须走 DataDir(),不许硬编码、不许直接用 os.Getwd(),
// 打包成 exe 后那些写法会失效。详见 docs/admin/02-architecture.md §6。
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/goccy/go-yaml"
)
// OnlineThreshold 是判定客户端"在线"的时间窗:
// 最近一次调接口在这个时间之内就算在线,否则离线。
//
// 取值要**宽松**:客户端执行长任务期间不会调 claim,
// 太短会被误判成离线。取「轮询周期 + 最长任务时长」量级。
// 本项目有意不做心跳,见 docs/admin/04-client-api.md §3。
const OnlineThreshold = 10 * time.Minute
const (
databaseHostEnv = "CMAUTOBUY_DB_HOST"
databasePortEnv = "CMAUTOBUY_DB_PORT"
databaseNameEnv = "CMAUTOBUY_DB_NAME"
databaseUserEnv = "CMAUTOBUY_DB_USER"
databasePasswordEnv = "CMAUTOBUY_DB_PASSWORD"
)
// DatabaseConfig 是 MySQL 8 的连接配置。
//
// 本地运行可以从被 Git 忽略的 config.yaml 读取;线上部署仍建议使用
// CMAUTOBUY_DB_* 环境变量。无论来源是什么,密码都不能写进日志或工单。
type DatabaseConfig struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
Name string `yaml:"name"`
User string `yaml:"user"`
Password string `yaml:"password"`
}
// String 永远隐藏密码,防止排错时用 %v 把凭据写进日志。
func (c DatabaseConfig) String() string {
password := "(空)"
if c.Password != "" {
password = "****"
}
return fmt.Sprintf("DatabaseConfig{Host:%s Port:%s Name:%s User:%s Password:%s}",
c.Host, c.Port, c.Name, c.User, password)
}
// 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"
}
if cfg.Port == "" {
cfg.Port = "3307"
}
var missing []string
if cfg.Name == "" {
missing = append(missing, "database.name / "+databaseNameEnv)
}
if cfg.User == "" {
missing = append(missing, "database.user / "+databaseUserEnv)
}
if cfg.Password == "" {
missing = append(missing, "database.password / "+databasePasswordEnv)
}
if len(missing) > 0 {
return DatabaseConfig{}, fmt.Errorf("缺少 MySQL 配置:%s", strings.Join(missing, "、"))
}
return cfg, nil
}
// DataDir 返回可写数据目录,不存在就创建。
//
// 打包成 exe 后 = exe 旁边的 data/
// go run 时 = 当前工作目录下的 data/
//
// 目录结构见 docs/admin/02-architecture.md §6:
//
// data/
// ├── admin.db
// ├── logs/
// └── uploads/
func DataDir() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
dir := filepath.Join(filepath.Dir(exe), "data")
// go run 会把程序编译到系统临时目录再执行,
// 这种情况下 exe 旁边不是项目目录,要改用当前工作目录。
if isTempBuild(exe) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
dir = filepath.Join(wd, "data")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
// SubDir 返回 data/ 下的子目录,不存在就创建。
// 例如 SubDir("logs") -> <data>/logs
func SubDir(name string) (string, error) {
base, err := DataDir()
if err != nil {
return "", err
}
dir := filepath.Join(base, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
// isTempBuild 判断这个可执行文件是不是 go run 生成的临时产物。
func isTempBuild(exe string) bool {
tmp := os.TempDir()
if tmp != "" && strings.HasPrefix(exe, tmp) {
return true
}
// go run 的产物路径里通常带 go-build 字样
return strings.Contains(filepath.ToSlash(exe), "/go-build")
}
// ---------- 顺运宝配置(config.yaml) ----------
// SybConfig 是 config.yaml 里 `syb:` 一节,见 admin/config.example.yaml
// 和 docs/admin/08-顺运宝接口.md §8。
type SybConfig struct {
BaseURL string `yaml:"base_url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
PageSize int `yaml:"page_size"`
MaxMatches int `yaml:"max_matches"`
SyncFrom string `yaml:"sync_from"`
// OcrURL 是验证码自动识别服务的地址(工单 #47)。
//
// `[必须]` 留空 = 禁用自动识别,登录时直接走手工输入弹窗,不报错——
// 这是有意的降级路径,见 docs/admin/08-顺运宝接口.md「验证码自动识别」一节。
OcrURL string `yaml:"ocr_url"`
// OcrMaxAttempts 是自动识别失败后的重试次数上限,<=0 时代码里退化成 5
// (示例脚本 raw_data/shunyunbaoerp_single.py 用的也是 5)。
OcrMaxAttempts int `yaml:"ocr_max_attempts"`
}
// String 把密码打码,防止 %v、log.Printf("%+v", cfg) 这类写法
// 不小心把明文密码带进日志——日志可能被贴进工单排查问题。
//
// `[必须]` 这是 admin/AGENTS.md「日志、页面、导出里不得出现 token、密码、Cookie」
// 的从源头防护,不依赖每个调用方都记得手动打码。
func (c SybConfig) String() string {
pw := "(空)"
if c.Password != "" {
pw = "****"
}
return fmt.Sprintf(
"SybConfig{BaseURL:%s Username:%s Password:%s PageSize:%d MaxMatches:%d SyncFrom:%s OcrURL:%s OcrMaxAttempts:%d}",
c.BaseURL, c.Username, pw, c.PageSize, c.MaxMatches, c.SyncFrom, c.OcrURL, c.OcrMaxAttempts)
}
// Config 是 config.yaml 的顶层结构。
type Config struct {
Database DatabaseConfig `yaml:"database"`
Syb SybConfig `yaml:"syb"`
}
// configFileName 是 config.yaml 相对 exe(或 go run 时相对工作目录)的文件名。
// 和 admin/config.example.yaml 同一目录,方便操作员按提示复制。
const configFileName = "config.yaml"
// ConfigPath 返回 config.yaml 应该在的路径(不保证文件存在)。
//
// 路径规则和 DataDir 一致(exe 旁边;go run 时是当前工作目录),
// 因为 config.example.yaml 的说明就是"复制到 admin/ 目录",
// 和 data/ 同级。
func ConfigPath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
dir := filepath.Dir(exe)
if isTempBuild(exe) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
dir = wd
}
return filepath.Join(dir, configFileName), nil
}
// Load 读取并解析 config.yaml。
//
// `[必须]` 文件不存在时给出「复制 config.example.yaml」的明确提示,
// 不是一句冷冰冰的「读取失败」——初级程序员第一次跑起来大概率会踩到这个。
func Load() (*Config, error) {
path, err := ConfigPath()
if err != nil {
return nil, fmt.Errorf("无法确定 config.yaml 应该在的位置: %w", err)
}
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, missingConfigError(path)
}
return nil, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
}
return parseConfig(raw, path)
}
// missingConfigError 是缺文件时的提示,抽成函数是为了配置测试
// (config_test.go)能直接复用同一句提示,不用真的绕开 os.Executable。
func missingConfigError(path string) error {
return fmt.Errorf(
"没有找到配置文件 %s。\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)
}
// parseConfig 解析 YAML 内容,抽成函数同样是为了让测试不依赖 os.Executable。
func parseConfig(raw []byte, path string) (*Config, error) {
var cfg Config
if err := yaml.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
}
return &cfg, nil
}