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:
@@ -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)。
|
||||
|
||||
## 驱动配置
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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。
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// 挂载内嵌前端
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 = '********'
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user