diff --git a/README.md b/README.md index 8b74c49..4055f32 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,32 @@ cd web/frontend && npm run dev | `enable_registry` | `true` | 是否开放注册(系统无用户时始终允许注册第一个账号) | | `timeout_duration_hours` | `6` | 规则执行失败后指数退避重试的最长时间(小时) | | `check_interval_minutes` | `10` | 检查定时器的间隔(分钟) | +| `database_driver` | `sqlite` | 数据库驱动,`sqlite` 或 `postgres` | | `database_path` | `data.db` | sqlite 数据库路径 | +| `database_dsn` | 空 | postgres 连接串,见 [docs/database.md](docs/database.md) | | `session_secret` | 自动生成 | 会话加密密钥,自动生成并持久化 | | `session_max_age_hours` | `168` | 会话有效期(小时) | | `allowed_origins` | `[]` | 允许跨域的来源,前端本地开发时可填 `["http://localhost:5173"]` | | `log_retain_count` | `500` | 每个用户保留的执行日志条数 | -环境变量覆盖:`GOODBABY_CONFIG`(配置文件路径)、`GOODBABY_LISTEN_ADDR`、`GOODBABY_DB_PATH`、`GOODBABY_SESSION_SECRET`、`GOODBABY_ENABLE_REGISTRY`。 +环境变量覆盖:`GOODBABY_CONFIG`(配置文件路径)、`GOODBABY_LISTEN_ADDR`、`GOODBABY_DB_DRIVER`、`GOODBABY_DB_PATH`、`GOODBABY_DB_DSN`、`GOODBABY_SESSION_SECRET`、`GOODBABY_ENABLE_REGISTRY`。 + +### 管理员后台 + +**第一个注册的用户自动成为管理员**(老版本升级上来时,注册最早的用户会被自动提升)。 + +管理员在「设置」页面会多出一个「系统配置」区块,可以直接修改并立即生效,无需重启: + +* 开放注册 +* 检查间隔(保存后会自动重排定时任务) +* 规则重试时长 +* 日志保留条数 + +监听地址、数据库、会话密钥这类启动项不在后台开放,只能改配置文件。 + +## 数据库 + +默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。 ## 驱动配置 diff --git a/api/admin/admin.go b/api/admin/admin.go new file mode 100644 index 0000000..332ba9f --- /dev/null +++ b/api/admin/admin.go @@ -0,0 +1,63 @@ +// Package admin 提供管理员可用的系统配置接口 +package admin + +import ( + "log" + + "github.com/gin-gonic/gin" + "github.com/ssdomei232/goodBaby/api/response" + "github.com/ssdomei232/goodBaby/configs" + "github.com/ssdomei232/goodBaby/handler/scheduler" +) + +// HandleGetConfig 返回当前可编辑的系统配置,以及一些只读的运行时信息 +func HandleGetConfig(c *gin.Context) { + config, err := configs.GetConfig() + if err != nil { + response.ServerError(c, "读取配置失败") + return + } + + response.OK(c, gin.H{ + "config": config.Editable(), + // 只读信息,让管理员知道当前跑在什么环境上,但不能在这里改 + "readonly": gin.H{ + "listen_addr": config.ListenAddr, + "database_driver": config.DatabaseDriver, + }, + }) +} + +// HandleUpdateConfig 更新可编辑的系统配置 +func HandleUpdateConfig(c *gin.Context) { + var req configs.EditableConfig + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "输入参数错误") + return + } + + before, err := configs.GetConfig() + if err != nil { + response.ServerError(c, "读取配置失败") + return + } + + updated, err := configs.ApplyEditable(req) + if err != nil { + // Validate 的错误可以直接展示给用户 + response.BadRequest(c, err.Error()) + return + } + + // 检查间隔变了就重排定时任务,免得改完还要重启 + if before.CheckIntervalMinutes != updated.CheckIntervalMinutes { + if err := scheduler.Reschedule(updated.CheckIntervalMinutes); err != nil { + log.Printf("重排定时任务失败: %v", err) + response.ServerError(c, "配置已保存,但定时任务重排失败,请重启服务") + return + } + log.Printf("检查间隔已更新为 %d 分钟", updated.CheckIntervalMinutes) + } + + response.OK(c, updated.Editable()) +} diff --git a/api/user/user.go b/api/user/user.go index 89db12e..3dec652 100644 --- a/api/user/user.go +++ b/api/user/user.go @@ -42,6 +42,8 @@ func HandleRegistry(c *gin.Context) { user := model.User{ Username: registryRequest.Username, Password: registryRequest.Password, + // 第一个注册的用户是管理员 + IsAdmin: userCount == 0, } if err := user.IsValid(); err != nil { @@ -228,3 +230,19 @@ func AuthMiddleware() gin.HandlerFunc { c.Next() } } + +// AdminMiddleware 要求当前用户是管理员,需挂在 AuthMiddleware 之后 +func AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + userInfo, err := GetUserInfoByGinCtx(c) + if err != nil { + response.AbortWith(c, http.StatusUnauthorized, "未登录") + return + } + if !userInfo.IsAdmin { + response.AbortWith(c, http.StatusForbidden, "需要管理员权限") + return + } + c.Next() + } +} diff --git a/configs/config.go b/configs/config.go index 83f656f..4438dda 100644 --- a/configs/config.go +++ b/configs/config.go @@ -25,8 +25,13 @@ type Config struct { TimeoutDurationHours int `json:"timeout_duration_hours"` // 检查 timer 的间隔(分钟) CheckIntervalMinutes int `json:"check_interval_minutes"` - // sqlite 数据库文件路径 + // 数据库驱动,"sqlite"(默认) 或 "postgres" + DatabaseDriver string `json:"database_driver"` + // sqlite 数据库文件路径,DatabaseDriver 为 sqlite 时使用 DatabasePath string `json:"database_path"` + // postgres 连接串,DatabaseDriver 为 postgres 时使用 + // 例如 postgres://user:pass@localhost:5432/goodbaby?sslmode=disable + DatabaseDSN string `json:"database_dsn"` // session 加密密钥,为空时自动生成并写回配置文件 SessionSecret string `json:"session_secret"` // session 有效期(小时) @@ -37,12 +42,19 @@ type Config struct { LogRetainCount int `json:"log_retain_count"` } +// 支持的数据库驱动 +const ( + DriverSQLite = "sqlite" + DriverPostgres = "postgres" +) + func defaultConfig() Config { return Config{ ListenAddr: ":8088", EnableRegistry: true, TimeoutDurationHours: 6, CheckIntervalMinutes: 10, + DatabaseDriver: DriverSQLite, DatabasePath: "data.db", SessionMaxAgeHours: 24 * 7, AllowedOrigins: []string{}, @@ -135,6 +147,9 @@ func normalize(c *Config) bool { if c.DatabasePath == "" { c.DatabasePath, changed = def.DatabasePath, true } + if c.DatabaseDriver == "" { + c.DatabaseDriver, changed = def.DatabaseDriver, true + } if c.SessionMaxAgeHours <= 0 { c.SessionMaxAgeHours, changed = def.SessionMaxAgeHours, true } @@ -155,6 +170,16 @@ func applyEnvOverrides(c *Config) { if v := os.Getenv("GOODBABY_DB_PATH"); v != "" { c.DatabasePath = v } + if v := os.Getenv("GOODBABY_DB_DRIVER"); v != "" { + c.DatabaseDriver = v + } + if v := os.Getenv("GOODBABY_DB_DSN"); v != "" { + c.DatabaseDSN = v + // 给了连接串却没显式指定驱动时,按 postgres 处理 + if os.Getenv("GOODBABY_DB_DRIVER") == "" { + c.DatabaseDriver = DriverPostgres + } + } if v := os.Getenv("GOODBABY_SESSION_SECRET"); v != "" { c.SessionSecret = v } diff --git a/configs/editable.go b/configs/editable.go new file mode 100644 index 0000000..a277de4 --- /dev/null +++ b/configs/editable.go @@ -0,0 +1,60 @@ +package configs + +import "fmt" + +// EditableConfig 是管理员可以在 WebUI 里修改的配置子集。 +// +// 只收录改完立即生效的项。监听地址、数据库、session 密钥这类需要重启 +// 或改错会导致服务起不来的配置,只能改配置文件。 +type EditableConfig struct { + EnableRegistry bool `json:"enable_registry"` + TimeoutDurationHours int `json:"timeout_duration_hours"` + CheckIntervalMinutes int `json:"check_interval_minutes"` + LogRetainCount int `json:"log_retain_count"` +} + +// Editable 从完整配置中取出可编辑的部分 +func (c Config) Editable() EditableConfig { + return EditableConfig{ + EnableRegistry: c.EnableRegistry, + TimeoutDurationHours: c.TimeoutDurationHours, + CheckIntervalMinutes: c.CheckIntervalMinutes, + LogRetainCount: c.LogRetainCount, + } +} + +// Validate 校验管理员提交的配置 +func (e EditableConfig) Validate() error { + if e.TimeoutDurationHours < 1 || e.TimeoutDurationHours > 72 { + return fmt.Errorf("规则重试时长必须在 1~72 小时之间") + } + if e.CheckIntervalMinutes < 1 || e.CheckIntervalMinutes > 1440 { + return fmt.Errorf("检查间隔必须在 1~1440 分钟之间") + } + if e.LogRetainCount < 0 || e.LogRetainCount > 100000 { + return fmt.Errorf("日志保留条数必须在 0~100000 之间,0 表示不限制") + } + return nil +} + +// ApplyEditable 把可编辑部分合并进当前配置并持久化,返回更新后的完整配置 +func ApplyEditable(e EditableConfig) (Config, error) { + if err := e.Validate(); err != nil { + return Config{}, err + } + + current, err := GetConfig() + if err != nil { + return Config{}, err + } + + current.EnableRegistry = e.EnableRegistry + current.TimeoutDurationHours = e.TimeoutDurationHours + current.CheckIntervalMinutes = e.CheckIntervalMinutes + current.LogRetainCount = e.LogRetainCount + + if err := Save(current); err != nil { + return Config{}, err + } + return current, nil +} diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..153bd06 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,89 @@ +# 数据库配置 + +goodBaby 默认使用 SQLite,零配置开箱即用;也可以切换到 PostgreSQL。 + +## SQLite(默认) + +```json +{ + "database_driver": "sqlite", + "database_path": "data.db" +} +``` + +用的是纯 Go 的 [glebarez/sqlite](https://github.com/glebarez/sqlite),不需要 CGO,编译出来就是一个静态二进制。 + +已开启 WAL 与 10 秒 busy timeout;因为 SQLite 是单写模型,连接池被限制为 1 条连接,避免并发执行规则时出现 `database is locked`。 + +## PostgreSQL + +```json +{ + "database_driver": "postgres", + "database_dsn": "postgres://user:password@localhost:5432/goodbaby?sslmode=disable" +} +``` + +切换到 postgres 后 `database_path` 会被忽略。连接池为最大 20 条连接、5 条空闲、连接最长存活 1 小时。 + +启动时会先 `Ping` 一次,配置写错会在启动阶段直接报错退出,而不是等到第一次查询才失败。 + +### 环境变量 + +配置文件之外也可以用环境变量覆盖,适合容器部署: + +| 变量 | 说明 | +| --- | --- | +| `GOODBABY_DB_DRIVER` | `sqlite` 或 `postgres` | +| `GOODBABY_DB_DSN` | postgres 连接串 | +| `GOODBABY_DB_PATH` | sqlite 文件路径 | + +只设置了 `GOODBABY_DB_DSN` 而没设置 `GOODBABY_DB_DRIVER` 时,会自动按 postgres 处理。 + +### docker compose 示例 + +```yaml +services: + goodbaby: + image: mei232/goodbaby:v2.1.0 + ports: + - "8088:8088" + environment: + - TZ=Asia/Shanghai + - GIN_MODE=release + - GOODBABY_DB_DSN=postgres://goodbaby:goodbaby@postgres:5432/goodbaby?sslmode=disable + volumes: + - ./data:/app/data + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + postgres: + image: postgres:17-alpine + environment: + - POSTGRES_USER=goodbaby + - POSTGRES_PASSWORD=goodbaby + - POSTGRES_DB=goodbaby + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U goodbaby"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + +volumes: + pgdata: +``` + +## 切换数据库 + +两种数据库之间**没有**自动数据迁移。切换驱动后是一个全新的空库,需要重新注册账号并配置规则。 + +如果要搬运已有数据,得自己从 SQLite 导出再导入 PostgreSQL(表结构一致:`users` / `timers` / `rules` / `accounts` / `execution_logs`)。 + +## 建表 + +首次启动会自动执行 `AutoMigrate` 建表并补齐新增字段,不需要手动执行 SQL。 diff --git a/go.mod b/go.mod index 90f3c8a..bb96b78 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,10 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -64,6 +68,7 @@ require ( golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.9 // indirect + gorm.io/driver/postgres v1.6.0 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 0b38f1b..8832d2f 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,14 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -119,6 +127,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -157,6 +166,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= diff --git a/handler/db/db.go b/handler/db/db.go index a38d284..7ba35ad 100644 --- a/handler/db/db.go +++ b/handler/db/db.go @@ -4,10 +4,12 @@ import ( "fmt" "log" "sync" + "time" "github.com/glebarez/sqlite" "github.com/ssdomei232/goodBaby/configs" "github.com/ssdomei232/goodBaby/model" + "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) @@ -38,19 +40,68 @@ func MustInit() *gorm.DB { if err := AutoMigrate(gormDB); err != nil { log.Fatalf("数据库迁移失败: %v", err) } + if err := ensureAdmin(gormDB); err != nil { + log.Fatalf("初始化管理员失败: %v", err) + } return gormDB } +// ensureAdmin 保证系统里至少有一个管理员。 +// +// IsAdmin 是后加的字段,老数据库升级上来时所有人都不是管理员, +// 这里把最早注册的那个用户提升为管理员。 +func ensureAdmin(gormDB *gorm.DB) error { + var adminCount int64 + if err := gormDB.Model(&model.User{}).Where("is_admin = ?", true).Count(&adminCount).Error; err != nil { + return err + } + if adminCount > 0 { + return nil + } + + var first model.User + if err := gormDB.Order("id ASC").First(&first).Error; err != nil { + // 一个用户都没有,等第一个注册的人来当管理员 + return nil + } + + if err := gormDB.Model(&model.User{}).Where("id = ?", first.ID). + Update("is_admin", true).Error; err != nil { + return err + } + log.Printf("已将用户 %s (ID: %d) 提升为管理员", first.Username, first.ID) + return nil +} + func open() (*gorm.DB, error) { config, err := configs.GetConfig() if err != nil { return nil, fmt.Errorf("读取配置失败: %w", err) } - dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", config.DatabasePath) - gormDB, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Warn), - }) + gormConfig := &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)} + + var ( + gormDB *gorm.DB + driver = config.DatabaseDriver + ) + + switch driver { + case configs.DriverPostgres: + if config.DatabaseDSN == "" { + return nil, fmt.Errorf("使用 postgres 时必须配置 database_dsn") + } + gormDB, err = gorm.Open(postgres.Open(config.DatabaseDSN), gormConfig) + case configs.DriverSQLite, "": + driver = configs.DriverSQLite + dsn := fmt.Sprintf( + "file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", + config.DatabasePath, + ) + gormDB, err = gorm.Open(sqlite.Open(dsn), gormConfig) + default: + return nil, fmt.Errorf("不支持的数据库驱动: %s(可选 sqlite / postgres)", config.DatabaseDriver) + } if err != nil { return nil, fmt.Errorf("打开数据库失败: %w", err) } @@ -59,10 +110,23 @@ func open() (*gorm.DB, error) { if err != nil { return nil, err } - // sqlite 单写多读,限制连接数避免写冲突 - sqlDB.SetMaxOpenConns(1) - sqlDB.SetMaxIdleConns(1) + if driver == configs.DriverSQLite { + // sqlite 单写多读,限制连接数避免写冲突 + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + } else { + sqlDB.SetMaxOpenConns(20) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(time.Hour) + } + + // 提前握手,配置写错时在启动阶段就报错,而不是等到第一次查询 + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + + log.Printf("数据库已连接 (driver=%s)", driver) return gormDB, nil } diff --git a/handler/scheduler/scheduler.go b/handler/scheduler/scheduler.go new file mode 100644 index 0000000..a6817c4 --- /dev/null +++ b/handler/scheduler/scheduler.go @@ -0,0 +1,76 @@ +// Package scheduler 管理定时检查任务,支持运行时调整间隔 +package scheduler + +import ( + "fmt" + "sync" + + "github.com/robfig/cron/v3" + "github.com/ssdomei232/goodBaby/handler/checker" +) + +var ( + mu sync.Mutex + runner *cron.Cron + entryID cron.EntryID +) + +// Start 启动定时检查,interval 单位为分钟 +func Start(intervalMinutes int) error { + mu.Lock() + defer mu.Unlock() + + if runner != nil { + return fmt.Errorf("定时任务已经启动") + } + + runner = cron.New() + id, err := runner.AddFunc(spec(intervalMinutes), checker.CheckTimers) + if err != nil { + runner = nil + return fmt.Errorf("注册定时任务失败: %w", err) + } + + entryID = id + runner.Start() + return nil +} + +// Reschedule 用新的间隔重排定时任务,管理员改配置后调用 +func Reschedule(intervalMinutes int) error { + mu.Lock() + defer mu.Unlock() + + if runner == nil { + return fmt.Errorf("定时任务尚未启动") + } + + // cron 不支持原地改周期,只能撤掉旧的再加一个 + runner.Remove(entryID) + id, err := runner.AddFunc(spec(intervalMinutes), checker.CheckTimers) + if err != nil { + return fmt.Errorf("注册定时任务失败: %w", err) + } + + entryID = id + return nil +} + +// Stop 停止定时任务,等待正在执行的任务结束 +func Stop() { + mu.Lock() + defer mu.Unlock() + + if runner == nil { + return + } + <-runner.Stop().Done() + runner = nil +} + +func spec(intervalMinutes int) string { + if intervalMinutes <= 0 { + intervalMinutes = 10 + } + return fmt.Sprintf("@every %dm", intervalMinutes) +} diff --git a/main.go b/main.go index 46fd557..1a6d10b 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,14 @@ package main import ( - "fmt" "log" "net/http" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" - "github.com/robfig/cron/v3" "github.com/ssdomei232/goodBaby/api/account" + "github.com/ssdomei232/goodBaby/api/admin" "github.com/ssdomei232/goodBaby/api/dashboard" apilog "github.com/ssdomei232/goodBaby/api/log" apimeta "github.com/ssdomei232/goodBaby/api/meta" @@ -17,9 +16,9 @@ import ( "github.com/ssdomei232/goodBaby/api/timer" "github.com/ssdomei232/goodBaby/api/user" "github.com/ssdomei232/goodBaby/configs" - "github.com/ssdomei232/goodBaby/handler/checker" "github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/runner" + "github.com/ssdomei232/goodBaby/handler/scheduler" "github.com/ssdomei232/goodBaby/web" ) @@ -38,13 +37,11 @@ func main() { db.MustInit() runner.InitExecutorRegistry() - // 启动定时检查 - c := cron.New() - spec := fmt.Sprintf("@every %dm", config.CheckIntervalMinutes) - if _, err := c.AddFunc(spec, checker.CheckTimers); err != nil { - log.Fatalf("注册定时任务失败: %v", err) + // 启动定时检查(间隔可由管理员在 WebUI 调整,无需重启) + if err := scheduler.Start(config.CheckIntervalMinutes); err != nil { + log.Fatalf("启动定时任务失败: %v", err) } - c.Start() + defer scheduler.Stop() r := gin.Default() @@ -122,6 +119,14 @@ func main() { logs.GET("/", apilog.HandleGetLogs) logs.DELETE("/", apilog.HandleClearLogs) } + + // 仅管理员可用 + adminGroup := authorized.Group("/admin") + adminGroup.Use(user.AdminMiddleware()) + { + adminGroup.GET("/config", admin.HandleGetConfig) + adminGroup.PUT("/config", admin.HandleUpdateConfig) + } } // 挂载内嵌前端 diff --git a/model/user.go b/model/user.go index f18832a..050e01c 100644 --- a/model/user.go +++ b/model/user.go @@ -23,6 +23,8 @@ type User struct { CreateAt int64 `json:"create_at"` Username string `gorm:"uniqueIndex" json:"username"` Password string `json:"-"` + // 管理员可以修改系统配置。第一个注册的用户自动成为管理员。 + IsAdmin bool `gorm:"default:false" json:"is_admin"` // 钉钉机器人配置(JSON 字符串),用于接收提醒 DingTalkConfig *string `json:"dingtalk_config"` } diff --git a/web/frontend/src/api/index.ts b/web/frontend/src/api/index.ts index 289e5b8..c711667 100644 --- a/web/frontend/src/api/index.ts +++ b/web/frontend/src/api/index.ts @@ -2,6 +2,8 @@ import { api } from './client' import type { Account, AccountRequest, + AdminConfig, + AdminConfigResponse, DashboardOverview, ExecutionLog, LogPage, @@ -79,4 +81,9 @@ export const logApi = { clear: () => api.delete('/logs/'), } +export const adminApi = { + getConfig: () => api.get('/admin/config'), + updateConfig: (body: AdminConfig) => api.put('/admin/config', body), +} + export type { ExecutionLog } diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 78eff5c..01e085e 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -9,6 +9,7 @@ export interface UserInfo { id: number create_at: number username: string + is_admin: boolean dingtalk_config: string | null } @@ -153,5 +154,22 @@ export interface SiteInfo { check_interval_minutes: number } +/** 管理员可在 WebUI 修改的系统配置 */ +export interface AdminConfig { + enable_registry: boolean + timeout_duration_hours: number + check_interval_minutes: number + log_retain_count: number +} + +export interface AdminConfigResponse { + config: AdminConfig + /** 只读的运行时信息,只能改配置文件 */ + readonly: { + listen_addr: string + database_driver: string + } +} + /** 后端对 secret 字段的掩码占位符,原样提交时表示保留旧值 */ export const MASK_PLACEHOLDER = '********' diff --git a/web/frontend/src/views/SettingsView.vue b/web/frontend/src/views/SettingsView.vue index 09943df..a74b161 100644 --- a/web/frontend/src/views/SettingsView.vue +++ b/web/frontend/src/views/SettingsView.vue @@ -1,8 +1,9 @@ @@ -110,9 +156,12 @@ onMounted(async () => { - + {{ userStore.user?.username }} + + 管理员 + {{ formatDateTime(userStore.user?.create_at ?? 0) }} @@ -120,6 +169,79 @@ onMounted(async () => { + + + +

+ 这些配置对整个站点生效,保存后立即生效,无需重启。监听地址、数据库等启动项只能改配置文件。 +

+ + + + +
+ 关闭后新用户无法注册。系统内还没有任何用户时始终允许注册,避免全新部署无法创建账号。 +
+
+ + + + 分钟 +
多久检查一次定时器是否到期。间隔越短,触发越及时。
+
+ + + + 小时 +
规则执行失败后按指数退避重试的最长时间。
+
+ + + + 条 / 用户 +
超出后自动清理最旧的记录,0 表示不限制。
+
+ + +
+ 监听地址 {{ adminReadonly.listen_addr }} + + 数据库 + + {{ driverLabels[adminReadonly.database_driver] ?? adminReadonly.database_driver }} + + +
+
+ + + + 保存系统配置 + + +
+
+

@@ -175,4 +297,39 @@ onMounted(async () => { .section { margin-bottom: 16px; } + +.admin-tag { + margin-left: 6px; +} + +.cfg-number { + width: 150px; +} + +.unit { + margin-left: 8px; + color: var(--el-text-color-secondary); + font-size: 13px; +} + +.readonly-info { + display: flex; + flex-wrap: wrap; + gap: 4px 18px; +} + +.readonly-info code { + color: var(--el-text-color-primary); +} + +@media (max-width: 768px) { + .cfg-number { + width: 100%; + } + + .unit { + display: inline-block; + margin: 6px 0 0; + } +}