后端: - 修复登录校验、DB 并发(单例+WAL+纯Go sqlite)、会话密钥持久化等核心缺陷 - 新增 Timer CRUD/签到/手动触发、执行日志、仪表盘、驱动元数据等 API - 统一驱动接口: 带 context 的执行器、真实错误返回、敏感字段掩码、连通性测试 - 补全钉钉执行器; Timer 触发/提醒去重; 配置支持默认值与环境变量覆盖 前端: - Vite+Vue3+TS+Pinia+Element Plus, 由驱动元数据动态渲染配置表单 - 登录/仪表盘(倒计时环)/定时器/规则/账号/日志/设置页面, 支持暗色模式 - 主题色 #66ccff, 信号波纹 logo, go:embed 单二进制部署 构建: Dockerfile 增加前端构建阶段; 补充各驱动配置文档 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
196 lines
4.9 KiB
Go
196 lines
4.9 KiB
Go
package configs
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
)
|
|
|
|
// 默认配置文件路径,可通过环境变量 GOODBABY_CONFIG 覆盖
|
|
const defaultConfigPath = "config.json"
|
|
|
|
type Config struct {
|
|
// HTTP 监听地址,如 ":8088"
|
|
ListenAddr string `json:"listen_addr"`
|
|
// 是否开放注册
|
|
EnableRegistry bool `json:"enable_registry"`
|
|
// 规则执行的最长重试时间(小时),用于指数退避的整体超时
|
|
TimeoutDurationHours int `json:"timeout_duration_hours"`
|
|
// 检查 timer 的间隔(分钟)
|
|
CheckIntervalMinutes int `json:"check_interval_minutes"`
|
|
// sqlite 数据库文件路径
|
|
DatabasePath string `json:"database_path"`
|
|
// session 加密密钥,为空时自动生成并写回配置文件
|
|
SessionSecret string `json:"session_secret"`
|
|
// session 有效期(小时)
|
|
SessionMaxAgeHours int `json:"session_max_age_hours"`
|
|
// 允许跨域访问的来源,开发前端时使用,如 ["http://localhost:5173"]
|
|
AllowedOrigins []string `json:"allowed_origins"`
|
|
// 每个用户保留的执行日志条数,<=0 表示不限制
|
|
LogRetainCount int `json:"log_retain_count"`
|
|
}
|
|
|
|
func defaultConfig() Config {
|
|
return Config{
|
|
ListenAddr: ":8088",
|
|
EnableRegistry: true,
|
|
TimeoutDurationHours: 6,
|
|
CheckIntervalMinutes: 10,
|
|
DatabasePath: "data.db",
|
|
SessionMaxAgeHours: 24 * 7,
|
|
AllowedOrigins: []string{},
|
|
LogRetainCount: 500,
|
|
}
|
|
}
|
|
|
|
var (
|
|
once sync.Once
|
|
cached Config
|
|
loadErr error
|
|
cacheMux sync.RWMutex
|
|
)
|
|
|
|
// GetConfig 读取配置,只在首次调用时读盘,之后返回缓存。
|
|
//
|
|
// 配置文件不存在时会以默认值创建一份,缺失的字段会被补齐并写回。
|
|
func GetConfig() (Config, error) {
|
|
once.Do(func() {
|
|
cfg, err := load()
|
|
cacheMux.Lock()
|
|
cached, loadErr = cfg, err
|
|
cacheMux.Unlock()
|
|
})
|
|
|
|
cacheMux.RLock()
|
|
defer cacheMux.RUnlock()
|
|
return cached, loadErr
|
|
}
|
|
|
|
// MustGetConfig 与 GetConfig 相同,但在出错时直接退出,用于启动阶段
|
|
func MustGetConfig() Config {
|
|
cfg, err := GetConfig()
|
|
if err != nil {
|
|
log.Fatalf("加载配置失败: %v", err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func configPath() string {
|
|
if p := os.Getenv("GOODBABY_CONFIG"); p != "" {
|
|
return p
|
|
}
|
|
return defaultConfigPath
|
|
}
|
|
|
|
func load() (Config, error) {
|
|
path := configPath()
|
|
config := defaultConfig()
|
|
|
|
content, err := os.ReadFile(path)
|
|
switch {
|
|
case err == nil:
|
|
if err := json.Unmarshal(content, &config); err != nil {
|
|
return config, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
|
}
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
log.Printf("配置文件 %s 不存在,使用默认配置创建", path)
|
|
default:
|
|
return config, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
|
}
|
|
|
|
changed := normalize(&config)
|
|
applyEnvOverrides(&config)
|
|
|
|
if changed {
|
|
if err := save(path, config); err != nil {
|
|
// 只读挂载等场景下写回失败不应阻断启动
|
|
log.Printf("警告: 写回配置文件失败: %v", err)
|
|
}
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// normalize 补齐缺失/非法的配置项,返回是否发生了修改
|
|
func normalize(c *Config) bool {
|
|
def := defaultConfig()
|
|
changed := false
|
|
|
|
if c.ListenAddr == "" {
|
|
c.ListenAddr, changed = def.ListenAddr, true
|
|
}
|
|
if c.TimeoutDurationHours <= 0 {
|
|
c.TimeoutDurationHours, changed = def.TimeoutDurationHours, true
|
|
}
|
|
if c.CheckIntervalMinutes <= 0 {
|
|
c.CheckIntervalMinutes, changed = def.CheckIntervalMinutes, true
|
|
}
|
|
if c.DatabasePath == "" {
|
|
c.DatabasePath, changed = def.DatabasePath, true
|
|
}
|
|
if c.SessionMaxAgeHours <= 0 {
|
|
c.SessionMaxAgeHours, changed = def.SessionMaxAgeHours, true
|
|
}
|
|
if c.SessionSecret == "" {
|
|
// 随机生成一次并持久化,避免每次重启都让所有用户掉线
|
|
c.SessionSecret, changed = randomHex(32), true
|
|
}
|
|
if c.AllowedOrigins == nil {
|
|
c.AllowedOrigins, changed = def.AllowedOrigins, true
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func applyEnvOverrides(c *Config) {
|
|
if v := os.Getenv("GOODBABY_LISTEN_ADDR"); v != "" {
|
|
c.ListenAddr = v
|
|
}
|
|
if v := os.Getenv("GOODBABY_DB_PATH"); v != "" {
|
|
c.DatabasePath = v
|
|
}
|
|
if v := os.Getenv("GOODBABY_SESSION_SECRET"); v != "" {
|
|
c.SessionSecret = v
|
|
}
|
|
if v := os.Getenv("GOODBABY_ENABLE_REGISTRY"); v != "" {
|
|
if b, err := strconv.ParseBool(v); err == nil {
|
|
c.EnableRegistry = b
|
|
}
|
|
}
|
|
}
|
|
|
|
func save(path string, c Config) error {
|
|
content, err := json.MarshalIndent(c, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, content, 0o600)
|
|
}
|
|
|
|
// Save 持久化配置并刷新缓存,供管理接口修改运行时配置使用
|
|
func Save(c Config) error {
|
|
normalize(&c)
|
|
if err := save(configPath(), c); err != nil {
|
|
return err
|
|
}
|
|
|
|
cacheMux.Lock()
|
|
cached, loadErr = c, nil
|
|
cacheMux.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func randomHex(n int) string {
|
|
buf := make([]byte, n)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
panic(fmt.Sprintf("无法生成随机密钥: %v", err))
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|