2026-08-06 16:33:45 +08:00
|
|
|
|
// Package config 负责运行参数和文件路径。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 改动本文件前必读 admin/AGENTS.md。最容易踩的一条:
|
|
|
|
|
|
// 路径必须走 DataDir(),不许硬编码、不许直接用 os.Getwd(),
|
|
|
|
|
|
// 打包成 exe 后那些写法会失效。详见 docs/admin/02-architecture.md §6。
|
|
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-09 11:49:13 +08:00
|
|
|
|
"fmt"
|
2026-08-06 16:33:45 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"strings"
|
2026-08-06 16:56:18 +08:00
|
|
|
|
"time"
|
2026-08-09 11:49:13 +08:00
|
|
|
|
|
|
|
|
|
|
"github.com/goccy/go-yaml"
|
2026-08-06 16:33:45 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
// OnlineThreshold 是判定客户端"在线"的时间窗:
|
|
|
|
|
|
// 最近一次调接口在这个时间之内就算在线,否则离线。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 取值要**宽松**:客户端执行长任务期间不会调 claim,
|
|
|
|
|
|
// 太短会被误判成离线。取「轮询周期 + 最长任务时长」量级。
|
|
|
|
|
|
// 本项目有意不做心跳,见 docs/admin/04-client-api.md §3。
|
|
|
|
|
|
const OnlineThreshold = 10 * time.Minute
|
|
|
|
|
|
|
2026-08-10 01:22:22 +08:00
|
|
|
|
const (
|
|
|
|
|
|
databaseHostEnv = "CMAUTOBUY_DB_HOST"
|
|
|
|
|
|
databasePortEnv = "CMAUTOBUY_DB_PORT"
|
|
|
|
|
|
databaseNameEnv = "CMAUTOBUY_DB_NAME"
|
|
|
|
|
|
databaseUserEnv = "CMAUTOBUY_DB_USER"
|
|
|
|
|
|
databasePasswordEnv = "CMAUTOBUY_DB_PASSWORD"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// DatabaseConfig 是生产 MySQL 8 的连接配置。
|
|
|
|
|
|
// 密码只从环境变量读取,不能写进 config.yaml、日志或工单。
|
|
|
|
|
|
type DatabaseConfig struct {
|
|
|
|
|
|
Host string
|
|
|
|
|
|
Port string
|
|
|
|
|
|
Name string
|
|
|
|
|
|
User string
|
|
|
|
|
|
Password string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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),
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Host == "" {
|
|
|
|
|
|
cfg.Host = "127.0.0.1"
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Port == "" {
|
|
|
|
|
|
cfg.Port = "3307"
|
|
|
|
|
|
}
|
|
|
|
|
|
var missing []string
|
|
|
|
|
|
if cfg.Name == "" {
|
|
|
|
|
|
missing = append(missing, databaseNameEnv)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.User == "" {
|
|
|
|
|
|
missing = append(missing, databaseUserEnv)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Password == "" {
|
|
|
|
|
|
missing = append(missing, databasePasswordEnv)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(missing) > 0 {
|
|
|
|
|
|
return DatabaseConfig{}, fmt.Errorf("缺少 MySQL 配置环境变量:%s", strings.Join(missing, "、"))
|
|
|
|
|
|
}
|
|
|
|
|
|
return cfg, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 16:33:45 +08:00
|
|
|
|
// 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")
|
|
|
|
|
|
}
|
2026-08-09 11:49:13 +08:00
|
|
|
|
|
|
|
|
|
|
// ---------- 顺运宝配置(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"`
|
2026-08-09 12:35:02 +08:00
|
|
|
|
|
|
|
|
|
|
// 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"`
|
2026-08-09 11:49:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// String 把密码打码,防止 %v、log.Printf("%+v", cfg) 这类写法
|
|
|
|
|
|
// 不小心把明文密码带进日志——日志可能被贴进工单排查问题。
|
|
|
|
|
|
//
|
|
|
|
|
|
// `[必须]` 这是 admin/AGENTS.md「日志、页面、导出里不得出现 token、密码、Cookie」
|
|
|
|
|
|
// 的从源头防护,不依赖每个调用方都记得手动打码。
|
|
|
|
|
|
func (c SybConfig) String() string {
|
|
|
|
|
|
pw := "(空)"
|
|
|
|
|
|
if c.Password != "" {
|
|
|
|
|
|
pw = "****"
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf(
|
2026-08-09 12:35:02 +08:00
|
|
|
|
"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)
|
2026-08-09 11:49:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Config 是 config.yaml 的顶层结构。目前只有顺运宝一节,
|
|
|
|
|
|
// 后续如果要给别的模块加配置,在这里加新的字段即可。
|
|
|
|
|
|
type Config struct {
|
|
|
|
|
|
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,并填入顺运宝账号密码:\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
|
|
|
|
|
|
}
|