feat: 管理员后台配置 + PostgreSQL 支持

管理员后台:
- User 新增 IsAdmin,第一个注册的用户自动成为管理员;
  老库升级时自动把最早注册的用户提升为管理员
- 新增 AdminMiddleware 与 /api/v1/admin/config 读写接口
- 可改开放注册、检查间隔、规则重试时长、日志保留条数,保存即生效
- 检查间隔改动后自动重排定时任务,为此把 cron 抽成 handler/scheduler
- 监听地址、数据库、会话密钥等启动项不开放,接口只返回只读展示
- 设置页新增"系统配置"区块,仅管理员可见

PostgreSQL:
- 新增 database_driver / database_dsn 配置,默认仍为 sqlite
- postgres 用独立连接池(20/5/1h),sqlite 保持单连接避免写锁
- 启动时先 Ping,配置写错在启动阶段就报错而不是等到首次查询
- 支持 GOODBABY_DB_DRIVER / GOODBABY_DB_DSN 环境变量,
  只给 DSN 时自动按 postgres 处理

文档: 新增 docs/database.md(含 compose 示例与切换说明),README 补充管理员与数据库章节

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mei
2026-07-27 07:17:16 +08:00
co-authored by Claude Fable 5
parent 54d4fbe24a
commit ae7082f13c
15 changed files with 640 additions and 21 deletions
+20 -1
View File
@@ -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)。
## 驱动配置
+63
View File
@@ -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())
}
+18
View File
@@ -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()
}
}
+26 -1
View File
@@ -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
}
+60
View File
@@ -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
}
+89
View File
@@ -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。
+5
View File
@@ -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
+11
View File
@@ -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=
+71 -7
View File
@@ -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
}
+76
View File
@@ -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)
}
+14 -9
View File
@@ -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)
}
}
// 挂载内嵌前端
+2
View File
@@ -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"`
}
+7
View File
@@ -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<string>('/logs/'),
}
export const adminApi = {
getConfig: () => api.get<AdminConfigResponse>('/admin/config'),
updateConfig: (body: AdminConfig) => api.put<AdminConfig>('/admin/config', body),
}
export type { ExecutionLog }
+18
View File
@@ -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 = '********'
+160 -3
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { userApi } from '@/api'
import { adminApi, userApi } from '@/api'
import { ApiError } from '@/api/client'
import type { AdminConfig, AdminConfigResponse } from '@/api/types'
import { useUserStore } from '@/stores/user'
import { formatDateTime } from '@/utils/format'
import { useIsMobile } from '@/composables/useBreakpoint'
@@ -11,6 +12,50 @@ const isMobile = useIsMobile()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.user?.is_admin === true)
// ---- 系统配置(仅管理员) ----
const adminLoading = ref(false)
const adminSaving = ref(false)
const adminReadonly = ref<AdminConfigResponse['readonly'] | null>(null)
const adminConfig = ref<AdminConfig>({
enable_registry: true,
timeout_duration_hours: 6,
check_interval_minutes: 10,
log_retain_count: 500,
})
async function loadAdminConfig() {
if (!isAdmin.value) return
adminLoading.value = true
try {
const result = await adminApi.getConfig()
adminConfig.value = result.config
adminReadonly.value = result.readonly
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '读取系统配置失败')
} finally {
adminLoading.value = false
}
}
async function saveAdminConfig() {
adminSaving.value = true
try {
adminConfig.value = await adminApi.updateConfig(adminConfig.value)
ElMessage.success('系统配置已保存并生效')
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '保存失败')
} finally {
adminSaving.value = false
}
}
const driverLabels: Record<string, string> = {
sqlite: 'SQLite',
postgres: 'PostgreSQL',
}
// ---- 钉钉提醒配置 ----
const notifySaving = ref(false)
const dingtalk = ref({
@@ -96,6 +141,7 @@ onMounted(async () => {
await userStore.fetchUser()
}
loadNotifyFromUser()
await loadAdminConfig()
})
</script>
@@ -110,9 +156,12 @@ onMounted(async () => {
<el-card class="section">
<template #header>账号信息</template>
<el-descriptions :column="2">
<el-descriptions :column="isMobile ? 1 : 2">
<el-descriptions-item label="用户名">
{{ userStore.user?.username }}
<el-tag v-if="isAdmin" type="primary" size="small" effect="light" class="admin-tag">
管理员
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="注册时间">
{{ formatDateTime(userStore.user?.create_at ?? 0) }}
@@ -120,6 +169,79 @@ onMounted(async () => {
</el-descriptions>
</el-card>
<!-- 系统配置:仅管理员可见 -->
<el-card v-if="isAdmin" v-loading="adminLoading" class="section">
<template #header>系统配置</template>
<p class="muted">
这些配置对整个站点生效,保存后立即生效,无需重启。监听地址、数据库等启动项只能改配置文件。
</p>
<el-form
:label-width="isMobile ? 'auto' : '150px'"
:label-position="isMobile ? 'top' : 'right'"
style="max-width: 620px"
>
<el-form-item label="开放注册">
<el-switch v-model="adminConfig.enable_registry" />
<div class="muted">
关闭后新用户无法注册。系统内还没有任何用户时始终允许注册,避免全新部署无法创建账号。
</div>
</el-form-item>
<el-form-item label="检查间隔">
<el-input-number
v-model="adminConfig.check_interval_minutes"
:min="1"
:max="1440"
class="cfg-number"
/>
<span class="unit">分钟</span>
<div class="muted">多久检查一次定时器是否到期。间隔越短,触发越及时。</div>
</el-form-item>
<el-form-item label="规则重试时长">
<el-input-number
v-model="adminConfig.timeout_duration_hours"
:min="1"
:max="72"
class="cfg-number"
/>
<span class="unit">小时</span>
<div class="muted">规则执行失败后按指数退避重试的最长时间。</div>
</el-form-item>
<el-form-item label="日志保留条数">
<el-input-number
v-model="adminConfig.log_retain_count"
:min="0"
:max="100000"
:step="100"
class="cfg-number"
/>
<span class="unit">条 / 用户</span>
<div class="muted">超出后自动清理最旧的记录,0 表示不限制。</div>
</el-form-item>
<el-form-item v-if="adminReadonly" label="运行环境">
<div class="muted readonly-info">
<span>监听地址 <code class="mono">{{ adminReadonly.listen_addr }}</code></span>
<span>
数据库
<code class="mono">
{{ driverLabels[adminReadonly.database_driver] ?? adminReadonly.database_driver }}
</code>
</span>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="adminSaving" @click="saveAdminConfig">
保存系统配置
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="section">
<template #header>提醒渠道(钉钉机器人)</template>
<p class="muted">
@@ -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;
}
}
</style>