Files
gozerosandCursor bd427b57a1 feat: 换到 Gin 主流栈并补齐运维能力
HTTP 用 Gin,配置用 Viper,日志用 zap,数据用 sqlx;保留原有接口与会商行为。
顺带补上就绪探活、请求号、滚动日志、systemd 与 Dockerfile。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 09:51:41 +08:00

227 lines
7.5 KiB
Go
Raw Permalink 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 从环境变量装载运行配置。
//
// 为什么用 Viper:Go 服务里读配置的常见入口,以后若要加配置文件不用再改调用方。
// 约定:当前只认环境变量,键名与 Java / 旧版 Go 完全一致(EBA_*、ANALYSIS_*、LOG_*、SERVER_PORT)。
// 边界:空字符串当成没配,回落到联调缺省;非法数字同样回落,不在这里把进程打崩(致命项交给 Validate)。
package config
import (
"fmt"
"strings"
"github.com/spf13/viper"
)
// Config 运行配置(环境变量优先,与 Java EBA_* / analysis.* 对齐)。
type Config struct {
Port int
// DBDSN MySQL 连接串。优先 EBA_DB_DSN;否则用 HOST/PORT/NAME/USER/PASSWORD 拼,或解析 JDBC URL。
DBDSN string
AnalysisMode string // live | demo。demo 时强制走假进度,不再打 DeerFlow。
AuthEnabled bool // false 时写操作不强制账号头,仅联调。
DemoEnabled bool // 真模型与假进度二选一;live 下只有显式打开才为 true。
LivePollMS int
DemoTickMS int
LiveMaxInflight int // 真模型并发上限。小于 1 时 Load 会抬回 2。
DeerFlowBaseURL string
DeerFlowUser string
DeerFlowPassword string
DeerFlowCoordinator string
DeerFlowStreamIdleTimeout int // 毫秒。流式读空闲超过这个值视为卡住。
LogLevel string
LogFile string
LogMaxMB int
LogMaxAgeDays int
LogMaxBackups int
// SchemaPath 建表脚本路径;空则尝试 ./schema.sql。
SchemaPath string
// StrictConfig true 时:live 使用联调默认/空 DeerFlow 口令直接拒绝启动。
StrictConfig bool
// Version 构建信息(也可用环境变量 EBA_VERSION;ldflags 可再覆盖二进制)。
Version string
}
// Load 从环境变量加载;缺省贴近本机联调。
//
// ANALYSIS_MODE=demo 时无视 worker 开关,一律假进度。
// ANALYSIS_MODE=live 且没有显式设置 EBA_WORKER_DEMO_ENABLED 时,强制关掉 demo,
// 避免壳环境里残留的默认值把真模型服务悄悄变成演示。
func Load() Config {
v := newEnv()
c := Config{
Port: vInt(v, "SERVER_PORT", 18097),
AnalysisMode: strings.ToLower(vString(v, "ANALYSIS_MODE", "live")),
AuthEnabled: vBool(v, "ANALYSIS_AUTH_ENABLED", false),
DemoEnabled: vBool(v, "EBA_WORKER_DEMO_ENABLED", false),
LivePollMS: vInt(v, "EBA_WORKER_LIVE_POLL_MS", 1000),
DemoTickMS: vInt(v, "EBA_WORKER_TICK_MS", 400),
LiveMaxInflight: vInt(v, "EBA_WORKER_LIVE_MAX", 2),
DeerFlowBaseURL: strings.TrimRight(vString(v, "EBA_DF_BASE_URL", "http://127.0.0.1:8001"), "/"),
DeerFlowUser: vString(v, "EBA_DF_USER", "admin"),
DeerFlowPassword: vString(v, "EBA_DF_PASSWORD", "123ewq"),
DeerFlowCoordinator: vString(v, "EBA_DF_COORDINATOR", "roundtable-coordinator"),
DeerFlowStreamIdleTimeout: vInt(v, "EBA_DF_STREAM_IDLE_TIMEOUT_MS", 180000),
LogLevel: vString(v, "LOG_LEVEL", "info"),
LogFile: vString(v, "LOG_FILE", ""),
LogMaxMB: vInt(v, "LOG_MAX_MB", 100),
LogMaxAgeDays: vInt(v, "LOG_MAX_AGE_DAYS", 14),
LogMaxBackups: vInt(v, "LOG_MAX_BACKUPS", 14),
SchemaPath: vString(v, "EBA_SCHEMA_PATH", "schema.sql"),
StrictConfig: vBool(v, "EBA_STRICT_CONFIG", false),
Version: vString(v, "EBA_VERSION", "dev"),
}
c.DBDSN = resolveDSN(v)
if c.AnalysisMode == "demo" {
c.DemoEnabled = true
}
if c.AnalysisMode == "live" && !v.IsSet("EBA_WORKER_DEMO_ENABLED") {
c.DemoEnabled = false
}
if c.LiveMaxInflight < 1 {
c.LiveMaxInflight = 2
}
return c
}
// Validate 启动前校验;返回警告列表。strict 时致命错误用 error 返回,调用方应退出进程。
func (c Config) Validate() (warnings []string, err error) {
switch c.AnalysisMode {
case "live", "demo":
default:
return nil, fmt.Errorf("ANALYSIS_MODE 只能是 live 或 demo,当前=%q", c.AnalysisMode)
}
if c.Port < 1 || c.Port > 65535 {
return nil, fmt.Errorf("SERVER_PORT 非法: %d", c.Port)
}
if c.DBDSN == "" {
return nil, fmt.Errorf("数据库 DSN 为空,请配置 EBA_DB_DSN 或 EBA_DB_*")
}
weakDF := c.DeerFlowPassword == "123ewq" || c.DeerFlowPassword == ""
weakHint := "正式环境请改 EBA_DF_PASSWORD,勿用联调默认口令"
// live 声明下就要管 DF 口令(即使误开了 demo 开关也不放过 strict)。
if c.AnalysisMode == "live" {
if !c.DemoEnabled && c.DeerFlowBaseURL == "" {
return nil, fmt.Errorf("live 模式必须配置 EBA_DF_BASE_URL")
}
if weakDF {
msg := "DeerFlow 仍使用联调默认/空密码:" + weakHint
if c.StrictConfig {
return nil, fmt.Errorf("%s(EBA_STRICT_CONFIG=true)", msg)
}
warnings = append(warnings, msg)
}
}
if !c.AuthEnabled {
warnings = append(warnings, "ANALYSIS_AUTH_ENABLED=false:写操作不强制账号头(仅联调)")
}
return warnings, nil
}
// newEnv 只绑定进程环境变量,不读配置文件,避免工作目录里偶然的 yaml 盖掉部署注入的 EBA_*。
func newEnv() *viper.Viper {
v := viper.New()
v.AutomaticEnv()
return v
}
func resolveDSN(v *viper.Viper) string {
if d := strings.TrimSpace(v.GetString("EBA_DB_DSN")); v.IsSet("EBA_DB_DSN") && d != "" {
return ensureMultiStatements(d)
}
user := vString(v, "EBA_DB_USER", "eba")
pass := vString(v, "EBA_DB_PASSWORD", "eba_local_dev")
jdbc := ""
if v.IsSet("EBA_DB_URL") {
jdbc = strings.TrimSpace(v.GetString("EBA_DB_URL"))
}
if jdbc != "" {
if dsn, ok := jdbcToDSN(jdbc, user, pass); ok {
return dsn
}
}
host := vString(v, "EBA_DB_HOST", "127.0.0.1")
port := vString(v, "EBA_DB_PORT", "3306")
name := vString(v, "EBA_DB_NAME", "eightbf_analysis")
return user + ":" + pass + "@tcp(" + host + ":" + port + ")/" + name +
"?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
}
func jdbcToDSN(jdbc, user, pass string) (string, bool) {
s := strings.TrimPrefix(jdbc, "jdbc:mysql://")
if s == jdbc {
return "", false
}
hostPath := s
if i := strings.Index(s, "?"); i >= 0 {
hostPath = s[:i]
}
host := hostPath
db := "eightbf_analysis"
if i := strings.Index(hostPath, "/"); i >= 0 {
host = hostPath[:i]
db = hostPath[i+1:]
}
params := "parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
return user + ":" + pass + "@tcp(" + host + ")/" + db + "?" + params, true
}
func ensureMultiStatements(dsn string) string {
if strings.Contains(dsn, "multiStatements=") {
return dsn
}
if strings.Contains(dsn, "?") {
return dsn + "&multiStatements=true"
}
return dsn + "?multiStatements=true"
}
// vString 未设置或空白都用缺省。Viper 对空环境变量会返回空串,这里与旧版 os.Getenv 行为对齐。
func vString(v *viper.Viper, key, def string) string {
if !v.IsSet(key) {
return def
}
s := strings.TrimSpace(v.GetString(key))
if s == "" {
return def
}
return s
}
func vBool(v *viper.Viper, key string, def bool) bool {
if !v.IsSet(key) {
return def
}
switch strings.ToLower(strings.TrimSpace(v.GetString(key))) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return def
}
}
func vInt(v *viper.Viper, key string, def int) int {
if !v.IsSet(key) {
return def
}
s := strings.TrimSpace(v.GetString(key))
if s == "" {
return def
}
var n int
if _, err := fmt.Sscan(s, &n); err != nil {
return def
}
return n
}