feat: 完善后端并新增 Vue3 WebUI

后端:
- 修复登录校验、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>
This commit is contained in:
mei
2026-07-26 22:19:41 +08:00
co-authored by Claude Fable 5
parent 89e8b7fd66
commit 79774070fc
110 changed files with 8936 additions and 849 deletions
+9
View File
@@ -24,3 +24,12 @@ go.work
goodBaby goodBaby
config.json config.json
data.db data.db
data.db-shm
data.db-wal
/data/
# ---> Frontend
node_modules/
# 前端构建产物不入库,仅保留 .gitkeep 以满足 go:embed
web/dist/*
!web/dist/.gitkeep
+26 -5
View File
@@ -1,7 +1,20 @@
# ====================== # ======================
# 构建阶段 (Builder Stage) # 前端构建阶段 (Frontend Stage)
# ====================== # ======================
FROM golang:1.24-alpine AS builder FROM node:22-alpine AS frontend
WORKDIR /app/web/frontend
COPY web/frontend/package.json web/frontend/package-lock.json ./
RUN npm ci
COPY web/frontend/ ./
RUN npm run build
# ======================
# 后端构建阶段 (Builder Stage)
# ======================
FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -10,13 +23,14 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# 用前端构建产物覆盖占位的 dist
COPY --from=frontend /app/web/dist /app/web/dist
ARG VERSION="1.0.0" ARG VERSION="2.0.0"
ARG BUILD_DATE="unknown" ARG BUILD_DATE="unknown"
ARG GIT_COMMIT="unknown" ARG GIT_COMMIT="unknown"
RUN CGO_ENABLED=0 \ RUN CGO_ENABLED=0 \
GOOS=linux \ GOOS=linux \
GOARCH=amd64 \
go build \ go build \
-ldflags="-w -s -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE} -X main.gitCommit=${GIT_COMMIT}" \ -ldflags="-w -s -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE} -X main.gitCommit=${GIT_COMMIT}" \
-o /app/main . -o /app/main .
@@ -24,12 +38,19 @@ RUN CGO_ENABLED=0 \
# ====================== # ======================
# 运行阶段 (Runtime Stage) # 运行阶段 (Runtime Stage)
# ====================== # ======================
FROM alpine:3.18 FROM alpine:3.20
WORKDIR /app WORKDIR /app
RUN apk add --no-cache tzdata ca-certificates
COPY --from=builder /app/main /app/main COPY --from=builder /app/main /app/main
# 数据(config.json / data.db)统一放在 /app/data,挂载这个目录即可
ENV GOODBABY_CONFIG=/app/data/config.json \
GOODBABY_DB_PATH=/app/data/data.db
VOLUME ["/app/data"]
EXPOSE 8088 EXPOSE 8088
CMD ["/app/main"] CMD ["/app/main"]
+86 -8
View File
@@ -1,16 +1,94 @@
# goodBaby v2 # goodBaby v2
摇篮系统 摇篮系统 —— 为独居青年准备的「死人开关」(Dead Man's Switch)。
一个人在大城市生活,最怕的是出了事都没人知道。goodBaby 让你定期回来报一声平安;一旦超过设定的期限没有签到,它就会替你把预先托付的留言送出去:给家人的邮件、给朋友的 QQ / 钉钉消息、一条 B 站动态,或是公开你的 GitHub 仓库,把作品留给世界。
[![通过雨云一键部署](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/7125/cat_) [![通过雨云一键部署](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/7125/cat_)
## 他会做什么 ## 功能
船新版本正在开发... * **WebUI**:内置 Vue3 前端,注册 / 登录、定时器、规则、账号、执行日志、设置全部可视化操作
* **定时器 (Timer)**:设定签到周期与提前提醒时间,到期前通过钉钉机器人提醒你签到
* **规则 (Rule)**:定时器到期后要执行的动作,支持:
* 发送邮件 (SMTP)
* 发布 B 站动态
* 发送 QQ 消息 (OneBot / NapCat)
* 发送钉钉机器人消息
* 公开 GitHub 仓库
* **账号 (Account)**:集中管理第三方凭据,支持连通性测试,敏感字段(密码/Cookie/Token)不会回显
* **执行日志**:每次规则执行与提醒都有记录,规则支持手动测试
* **配置测试**:账号可一键测试连通性;规则可手动触发验证
将支持: ## 快速开始
* WebUI ### Docker Compose (推荐)
* 更多通知渠道
* 更灵活的设置 ```bash
* 配置测试 docker compose up -d
```
首次启动会在 `./data` 下自动生成 `config.json` 与 `data.db`。
打开 `http://localhost:8088`,按引导创建第一个账号即可。
### 从源码构建
需要 Go 1.25+ 与 Node.js 20+:
```bash
# 1. 构建前端(产物输出到 web/dist,会被 go:embed 打进二进制)
cd web/frontend && npm install && npm run build && cd ../..
# 2. 构建后端
go build -o goodbaby .
# 3. 运行
./goodbaby
```
### 本地开发
```bash
# 终端 1: 启动后端 (监听 :8088)
go run .
# 终端 2: 启动前端 dev server (监听 :5173,API 代理到 :8088)
cd web/frontend && npm run dev
```
## 配置
配置文件默认为工作目录下的 `config.json`,首次启动自动生成,字段均有默认值:
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| `listen_addr` | `:8088` | HTTP 监听地址 |
| `enable_registry` | `true` | 是否开放注册(系统无用户时始终允许注册第一个账号) |
| `timeout_duration_hours` | `6` | 规则执行失败后指数退避重试的最长时间(小时) |
| `check_interval_minutes` | `10` | 检查定时器的间隔(分钟) |
| `database_path` | `data.db` | sqlite 数据库路径 |
| `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`。
## 驱动配置
各驱动的账号 / 规则配置说明见 [docs/](docs/):
* [Bilibili](docs/bilibili-config.md)
* [Email](docs/email-config.md)
* [OneBot (QQ)](docs/onebot-config.md)
* [钉钉机器人](docs/dingtalk-config.md)
* [GitHub](docs/github-config.md)
## 开发:新增一种规则类型
1. 在 `drivers/<name>/` 下实现:
* 规则验证器(实现 `ruleConfigChecker.RuleValidator`,`Meta()` 返回表单元数据)
* 执行器(实现 `runner.RuleExecutor`)
* 如需第三方凭据,再实现账号验证器(`accountConfigChecker.AccountValidator`,可选实现 `AccountTester` 支持连通性测试)
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/interface.go` 中注册
3. 前端无需改动 —— WebUI 会根据 `Meta()` 返回的字段描述自动渲染配置表单
+171 -54
View File
@@ -1,116 +1,232 @@
package account package account
import ( import (
"strconv" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/rule" "github.com/ssdomei232/goodBaby/api/rule"
"github.com/ssdomei232/goodBaby/api/user" "github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
"github.com/ssdomei232/goodBaby/internal/meta"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 添加账号 // HandleAddAccount 添加账号
func HandleAddAccount(c *gin.Context) { func HandleAddAccount(c *gin.Context) {
gormDB, err := db.GetGormDB()
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
return
}
userInfo, err := user.GetUserInfoByGinCtx(c) userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil { if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) response.Unauthorized(c, "获取用户信息失败")
return return
} }
var newAccount model.Account var req model.AccountRequest
if err := c.BindJSON(&newAccount); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"code": 400, "data": "输入参数错误"}) response.BadRequest(c, "输入参数错误")
return return
} }
newAccount.UID = userInfo.ID if err := req.Validate(); err != nil {
result := gormDB.Create(&newAccount) response.FromError(c, err, "添加账号失败")
if result.Error != nil {
c.JSON(500, gin.H{"code": 500, "data": "添加账号失败"})
return return
} }
c.JSON(200, gin.H{"code": 200, "data": "添加账号成功"}) // 之前这里完全没有走校验器,任何 JSON 都能存进去,直到规则触发才暴露问题
registry := accountConfigChecker.InitValidatorRegistry()
if err := registry.Validate(req.Type, req.Config); err != nil {
response.BadRequest(c, err.Error())
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
newAccount := model.Account{
UID: userInfo.ID,
Name: req.Name,
Type: req.Type,
Config: req.Config,
CreateAt: time.Now().Unix(),
}
if err := gormDB.Create(&newAccount).Error; err != nil {
response.ServerError(c, "添加账号失败")
return
}
response.OK(c, maskAccount(newAccount))
} }
// 获取用户的所有账号 // HandleGetAllAccounts 获取用户的所有账号
func HandleGetAllAccounts(c *gin.Context) { func HandleGetAllAccounts(c *gin.Context) {
gormDB, err := db.GetGormDB()
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
return
}
userInfo, err := user.GetUserInfoByGinCtx(c) userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil { if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) response.Unauthorized(c, "获取用户信息失败")
return return
} }
var accounts []model.Account gormDB, err := db.GetGormDB()
result := gormDB.Where("uid = ?", userInfo.ID).Find(&accounts) if err != nil {
if result.Error != nil { response.ServerError(c, "服务器内部错误")
c.JSON(500, gin.H{"code": 500, "data": "获取账号失败"})
return return
} }
c.JSON(200, gin.H{"code": 200, "data": accounts}) query := gormDB.Where("uid = ?", userInfo.ID)
if accountType := c.Query("type"); accountType != "" {
query = query.Where("type = ?", accountType)
}
accounts := []model.Account{}
if err := query.Order("id DESC").Find(&accounts).Error; err != nil {
response.ServerError(c, "获取账号失败")
return
}
response.OK(c, maskAccounts(accounts))
} }
// 检查删除账号请求 // HandleEditAccount 编辑账号
func HandleEditAccount(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
accountID, err := parseID(c.Param("accountID"))
if err != nil {
response.BadRequest(c, "账号ID格式错误")
return
}
existing, err := findAccount(accountID, userInfo.ID)
if err != nil {
response.NotFound(c, "账号不存在")
return
}
var req model.AccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
if err := req.Validate(); err != nil {
response.FromError(c, err, "更新账号失败")
return
}
if req.Type != existing.Type {
response.BadRequest(c, "不支持修改账号类型,请新建账号")
return
}
registry := accountConfigChecker.InitValidatorRegistry()
// 前端提交的密码等字段可能是掩码占位符,用旧配置补回
config := meta.Unmask(req.Config, existing.Config, registry.FieldsOf(req.Type))
if err := registry.Validate(req.Type, config); err != nil {
response.BadRequest(c, err.Error())
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
existing.Name = req.Name
existing.Config = config
if err := gormDB.Save(existing).Error; err != nil {
response.ServerError(c, "更新账号失败")
return
}
response.OK(c, maskAccount(*existing))
}
// HandleTestAccount 测试账号凭据是否可用
func HandleTestAccount(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
accountID, err := parseID(c.Param("accountID"))
if err != nil {
response.BadRequest(c, "账号ID格式错误")
return
}
existing, err := findAccount(accountID, userInfo.ID)
if err != nil {
response.NotFound(c, "账号不存在")
return
}
registry := accountConfigChecker.InitValidatorRegistry()
if err := registry.Test(existing.Type, existing.Config); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, "账号可用")
}
// HandleCheckDeleteAccount 检查删除账号请求
// //
// 删除账号会同时删除相关规则,先请求该接口获取受影响的规则 // 删除账号会同时删除相关规则,先请求该接口获取受影响的规则
func HandleCheckDeleteAccount(c *gin.Context) { func HandleCheckDeleteAccount(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c) userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil { if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) response.Unauthorized(c, "获取用户信息失败")
return
}
accountID, err := strconv.Atoi(c.Param("accountID"))
if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "账号ID格式错误"})
return return
} }
rules, err := getRulesByAccountID(uint(accountID), userInfo.ID) accountID, err := parseID(c.Param("accountID"))
if err != nil { if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "获取相关规则失败"}) response.BadRequest(c, "账号ID格式错误")
return return
} }
c.JSON(200, gin.H{"code": 200, "data": rules}) rules, err := getRulesByAccountID(accountID, userInfo.ID)
if err != nil {
response.ServerError(c, "获取相关规则失败")
return
}
response.OK(c, rules)
} }
// 删除账号 // HandleDeleteAccount 删除账号及其关联规则
func HandleDeleteAccount(c *gin.Context) { func HandleDeleteAccount(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c) userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil { if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) response.Unauthorized(c, "获取用户信息失败")
return return
} }
accountID, err := strconv.Atoi(c.Param("accountID"))
accountID, err := parseID(c.Param("accountID"))
if err != nil { if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "账号ID格式错误"}) response.BadRequest(c, "账号ID格式错误")
return return
} }
// 删除相关规则 // 删除相关规则
rules, err := getRulesByAccountID(uint(accountID), userInfo.ID) rules, err := getRulesByAccountID(accountID, userInfo.ID)
if err != nil { if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "获取相关规则失败"}) response.ServerError(c, "获取相关规则失败")
return return
} }
for _, oneRule := range rules { for _, oneRule := range rules {
err = rule.DeleteRuleByID(oneRule.ID, userInfo.ID) if err := rule.DeleteRuleByID(oneRule.ID, userInfo.ID); err != nil {
if err != nil { response.ServerError(c, "删除相关规则失败")
c.JSON(500, gin.H{"code": 500, "data": "删除相关规则失败"})
return return
} }
} }
@@ -118,13 +234,14 @@ func HandleDeleteAccount(c *gin.Context) {
// 删除账号 // 删除账号
gormDB, err := db.GetGormDB() gormDB, err := db.GetGormDB()
if err != nil { if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"}) response.ServerError(c, "服务器内部错误")
return return
} }
result := gormDB.Where("id = ? AND uid = ?", accountID, userInfo.ID).Delete(&model.Account{}) if err := gormDB.Where("id = ? AND uid = ?", accountID, userInfo.ID).
if result.Error != nil { Delete(&model.Account{}).Error; err != nil {
c.JSON(500, gin.H{"code": 500, "data": "删除账号失败"}) response.ServerError(c, "删除账号失败")
return return
} }
c.JSON(200, gin.H{"code": 200, "data": "删除账号成功"})
response.OK(c, "删除账号成功")
} }
+45 -5
View File
@@ -1,21 +1,61 @@
package account package account
import ( import (
"strconv"
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
"github.com/ssdomei232/goodBaby/internal/meta"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 获取账号相关的规则列表 func parseID(raw string) (uint, error) {
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
// findAccount 查找属于该用户的账号
func findAccount(accountID, uid uint) (*model.Account, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var account model.Account
if err := gormDB.Where("id = ? AND uid = ?", accountID, uid).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
// getRulesByAccountID 获取账号相关的规则列表
func getRulesByAccountID(accountID uint, uid uint) ([]*model.Rule, error) { func getRulesByAccountID(accountID uint, uid uint) ([]*model.Rule, error) {
gormDB, err := db.GetGormDB() gormDB, err := db.GetGormDB()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var rules []*model.Rule rules := []*model.Rule{}
result := gormDB.Where("account_id = ? AND uid = ?", accountID, uid).Find(&rules) if err := gormDB.Where("account_id = ? AND uid = ?", accountID, uid).Find(&rules).Error; err != nil {
if result.Error != nil { return nil, err
return nil, result.Error
} }
return rules, nil return rules, nil
} }
// maskAccount 掩码账号配置中的敏感字段,避免 Cookie/密码原样返回给前端
func maskAccount(account model.Account) model.Account {
fields := accountConfigChecker.InitValidatorRegistry().FieldsOf(account.Type)
account.Config = meta.Mask(account.Config, fields)
return account
}
func maskAccounts(accounts []model.Account) []model.Account {
masked := make([]model.Account, 0, len(accounts))
for _, a := range accounts {
masked = append(masked, maskAccount(a))
}
return masked
}
+80
View File
@@ -0,0 +1,80 @@
// Package dashboard 汇总首页需要的统计数据
package dashboard
import (
"time"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
// HandleGetOverview 返回首页概览:各类对象数量、最紧急的 Timer、最近的执行日志
func HandleGetOverview(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
timers := []model.Timer{}
if err := gormDB.Where("uid = ?", userInfo.ID).Find(&timers).Error; err != nil {
response.ServerError(c, "获取统计数据失败")
return
}
var ruleCount, accountCount int64
gormDB.Model(&model.Rule{}).Where("uid = ?", userInfo.ID).Count(&ruleCount)
gormDB.Model(&model.Account{}).Where("uid = ?", userInfo.ID).Count(&accountCount)
recentLogs := []model.ExecutionLog{}
gormDB.Where("uid = ?", userInfo.ID).Order("id DESC").Limit(10).Find(&recentLogs)
now := time.Now().Unix()
var (
enabledCount int
triggeredCount int
urgent *model.Timer
)
for i := range timers {
timer := timers[i]
if !timer.Enabled {
continue
}
enabledCount++
if timer.Triggered {
triggeredCount++
continue
}
// 剩余时间最短的启用中 Timer
if urgent == nil || timer.NextSignTime() < urgent.NextSignTime() {
urgent = &timers[i]
}
}
overview := gin.H{
"timer_count": len(timers),
"enabled_timers": enabledCount,
"triggered_count": triggeredCount,
"rule_count": ruleCount,
"account_count": accountCount,
"server_time": now,
"recent_logs": recentLogs,
}
if urgent != nil {
overview["urgent_timer"] = urgent
overview["urgent_seconds_left"] = urgent.NextSignTime() - now
}
response.OK(c, overview)
}
+102
View File
@@ -0,0 +1,102 @@
// Package log 提供规则执行日志的查询接口
package log
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
const (
defaultPageSize = 20
maxPageSize = 200
)
// HandleGetLogs 分页查询当前用户的执行日志
func HandleGetLogs(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
page := intQuery(c, "page", 1, 1, 1<<20)
pageSize := intQuery(c, "page_size", defaultPageSize, 1, maxPageSize)
query := gormDB.Model(&model.ExecutionLog{}).Where("uid = ?", userInfo.ID)
if ruleID := c.Query("rule_id"); ruleID != "" {
query = query.Where("rule_id = ?", ruleID)
}
if success := c.Query("success"); success == "true" || success == "false" {
query = query.Where("success = ?", success == "true")
}
var total int64
if err := query.Count(&total).Error; err != nil {
response.ServerError(c, "获取执行日志失败")
return
}
logs := []model.ExecutionLog{}
if err := query.Order("id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&logs).Error; err != nil {
response.ServerError(c, "获取执行日志失败")
return
}
response.OK(c, gin.H{
"total": total,
"page": page,
"page_size": pageSize,
"items": logs,
})
}
// HandleClearLogs 清空当前用户的执行日志
func HandleClearLogs(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Where("uid = ?", userInfo.ID).Delete(&model.ExecutionLog{}).Error; err != nil {
response.ServerError(c, "清空执行日志失败")
return
}
response.OK(c, "已清空执行日志")
}
func intQuery(c *gin.Context, key string, fallback, min, max int) int {
raw := c.Query(key)
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value < min {
return fallback
}
if value > max {
return max
}
return value
}
+44
View File
@@ -0,0 +1,44 @@
// Package meta 向 WebUI 暴露驱动能力与站点信息
package meta
import (
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
)
// HandleGetProviders 返回所有账号类型与规则类型的元数据
//
// 前端据此动态渲染配置表单,新增驱动时前端无需改动。
func HandleGetProviders(c *gin.Context) {
response.OK(c, gin.H{
"accounts": accountConfigChecker.InitValidatorRegistry().Metas(),
"rules": ruleConfigChecker.InitValidatorRegistry().Metas(),
})
}
// HandleGetSiteInfo 返回无需登录即可获取的站点信息
func HandleGetSiteInfo(c *gin.Context) {
config, err := configs.GetConfig()
if err != nil {
response.ServerError(c, "获取配置失败")
return
}
userCount, err := user.CountUsers()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
response.OK(c, gin.H{
"name": "goodBaby",
// 系统内还没有用户时始终允许注册,方便全新部署创建第一个账号
"enable_registry": config.EnableRegistry || userCount == 0,
"need_initial_user": userCount == 0,
"check_interval_minutes": config.CheckIntervalMinutes,
})
}
+52
View File
@@ -0,0 +1,52 @@
// Package response 统一 API 的返回格式
//
// {"code": 200, "data": ...}
//
// 出错时 data 为可以直接展示给用户的错误信息。
package response
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/model"
)
// OK 返回成功响应
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": data})
}
// Fail 返回错误响应
func Fail(c *gin.Context, code int, msg string) {
c.JSON(code, gin.H{"code": code, "data": msg})
}
// AbortWith 返回错误响应并终止后续 handler
func AbortWith(c *gin.Context, code int, msg string) {
c.AbortWithStatusJSON(code, gin.H{"code": code, "data": msg})
}
// BadRequest 参数错误
func BadRequest(c *gin.Context, msg string) { Fail(c, http.StatusBadRequest, msg) }
// Unauthorized 未登录
func Unauthorized(c *gin.Context, msg string) { Fail(c, http.StatusUnauthorized, msg) }
// NotFound 资源不存在
func NotFound(c *gin.Context, msg string) { Fail(c, http.StatusNotFound, msg) }
// ServerError 服务端错误
func ServerError(c *gin.Context, msg string) { Fail(c, http.StatusInternalServerError, msg) }
// FromError 按错误类型选择合适的状态码:
// 参数校验错误返回 400,其余返回 500
func FromError(c *gin.Context, err error, fallback string) {
var validationErr model.ValidationError
if errors.As(err, &validationErr) {
BadRequest(c, validationErr.Msg)
return
}
ServerError(c, fallback)
}
+152 -104
View File
@@ -1,188 +1,236 @@
package rule package rule
import ( import (
"context"
"fmt" "fmt"
"strconv" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user" "github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/handler/runner"
"github.com/ssdomei232/goodBaby/internal/meta"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker" "github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 获取用户的所有规则 // HandleGetAllRules 获取用户的所有规则,支持按 timer_id 过滤
func HandleGetAllRules(c *gin.Context) { func HandleGetAllRules(c *gin.Context) {
gormDB, err := db.GetGormDB()
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "获取规则失败"})
return
}
userInfo, err := user.GetUserInfoByGinCtx(c) userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil { if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) response.Unauthorized(c, "获取用户信息失败")
return return
} }
var rules []model.Rule gormDB, err := db.GetGormDB()
result := gormDB.Where("uid = ?", userInfo.ID).Find(&rules) if err != nil {
if result.Error != nil { response.ServerError(c, "服务器内部错误")
c.JSON(500, gin.H{"code": 500, "data": "获取规则失败"})
return return
} }
c.JSON(200, gin.H{"code": 200, "data": rules}) query := gormDB.Where("uid = ?", userInfo.ID)
if raw := c.Query("timer_id"); raw != "" {
timerID, err := parseID(raw)
if err != nil {
response.BadRequest(c, "timer_id 格式错误")
return
}
query = query.Where("timer_id = ?", timerID)
}
rules := []model.Rule{}
if err := query.Order("id DESC").Find(&rules).Error; err != nil {
response.ServerError(c, "获取规则失败")
return
}
response.OK(c, maskRules(rules))
} }
// 创建新规则 // HandleCreateRule 创建新规则
func HandleCreateRule(c *gin.Context) { func HandleCreateRule(c *gin.Context) {
gormDB, err := db.GetGormDB()
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
return
}
userInfo, err := user.GetUserInfoByGinCtx(c) userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil { if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) response.Unauthorized(c, "获取用户信息失败")
return return
} }
var newRule model.Rule var req model.RuleRequest
if err := c.BindJSON(&newRule); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"code": 400, "data": "输入参数错误"}) response.BadRequest(c, "输入参数错误")
return return
} }
if newRule.Type == "" { newRule := model.Rule{
c.JSON(400, gin.H{"code": 400, "data": "规则类型不能为空"}) UID: userInfo.ID,
Name: req.Name,
TimerID: req.TimerID,
AccountID: req.AccountID,
Type: req.Type,
ConfigJson: req.ConfigJson,
Enabled: boolOr(req.Enabled, true),
CreateAt: time.Now().Unix(),
}
if err := validateRule(&req, &newRule); err != nil {
response.FromError(c, err, "创建规则失败")
return return
} }
if newRule.Name == "" { gormDB, err := db.GetGormDB()
c.JSON(400, gin.H{"code": 400, "data": "规则名称不能为空"}) if err != nil {
response.ServerError(c, "服务器内部错误")
return return
} }
// 检查关联账号和 Timer 是否存在(可以没有关联账号) if err := gormDB.Create(&newRule).Error; err != nil {
if exist, err := checkRuleConfigAccountAndTimerExist(newRule); err != nil { response.ServerError(c, "创建规则失败")
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
return
} else if !exist {
c.JSON(400, gin.H{"code": 400, "data": "关联的账户或Timer不存在"})
return return
} }
// 规则校验 response.OK(c, maskRule(newRule))
validatorRegistry := ruleConfigChecker.InitValidatorRegistry()
if err := validatorRegistry.Validate(newRule.Type, newRule.ConfigJson); err != nil {
c.JSON(400, gin.H{"code": 400, "data": fmt.Sprintf("规则配置验证失败: %s", err.Error())})
return
}
newRule.UID = userInfo.ID
result := gormDB.Create(&newRule)
if result.Error != nil {
c.JSON(500, gin.H{"code": 500, "data": "创建规则失败"})
return
}
c.JSON(200, gin.H{"code": 200, "data": "规则创建成功"})
} }
// 编辑规则 // HandleEditRule 编辑规则
func HandleEditRule(c *gin.Context) { func HandleEditRule(c *gin.Context) {
gormDB, err := db.GetGormDB()
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
return
}
userInfo, err := user.GetUserInfoByGinCtx(c) userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil { if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) response.Unauthorized(c, "获取用户信息失败")
return return
} }
ruleID, err := strconv.Atoi(c.Param("ruleID")) ruleID, err := parseID(c.Param("ruleID"))
if err != nil { if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "规则 ID 格式错误"}) response.BadRequest(c, "规则 ID 格式错误")
return
}
var rule model.Rule
result := gormDB.Where("id = ? AND uid = ?", ruleID, userInfo.ID).First(&rule)
if result.Error != nil {
c.JSON(404, gin.H{"code": 404, "data": "规则不存在"})
return return
} }
var updatedRule model.Rule existing, err := findRule(ruleID, userInfo.ID)
if err := c.BindJSON(&updatedRule); err != nil { if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "输入参数错误"}) response.NotFound(c, "规则不存在")
return return
} }
if updatedRule.Type == "" { var req model.RuleRequest
c.JSON(400, gin.H{"code": 400, "data": "规则类型不能为空"}) if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return return
} }
if updatedRule.Name == "" { updated := *existing
c.JSON(400, gin.H{"code": 400, "data": "规则名称不能为空"}) updated.Name = req.Name
updated.Type = req.Type
updated.TimerID = req.TimerID
updated.AccountID = req.AccountID
updated.Enabled = boolOr(req.Enabled, existing.Enabled)
// 前端提交的敏感字段可能是掩码占位符,用旧配置补回
updated.ConfigJson = unmaskRuleConfig(req.Type, req.ConfigJson, existing.ConfigJson)
req.ConfigJson = updated.ConfigJson
if err := validateRule(&req, &updated); err != nil {
response.FromError(c, err, "更新规则失败")
return return
} }
// 检查关联账号和 Timer 是否存在(可以没有关联账号) gormDB, err := db.GetGormDB()
if exist, err := checkRuleConfigAccountAndTimerExist(updatedRule); err != nil { if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"}) response.ServerError(c, "服务器内部错误")
return
} else if !exist {
c.JSON(400, gin.H{"code": 400, "data": "关联的账户或Timer不存在"})
return return
} }
// 规则校验 if err := gormDB.Save(&updated).Error; err != nil {
validatorRegistry := ruleConfigChecker.InitValidatorRegistry() response.ServerError(c, "更新规则失败")
if err := validatorRegistry.Validate(updatedRule.Type, updatedRule.ConfigJson); err != nil {
c.JSON(400, gin.H{"code": 400, "data": fmt.Sprintf("规则配置验证失败: %s", err.Error())})
return return
} }
rule.Name = updatedRule.Name response.OK(c, maskRule(updated))
rule.Type = updatedRule.Type
rule.ConfigJson = updatedRule.ConfigJson
rule.AccountID = updatedRule.AccountID
rule.TimerID = updatedRule.TimerID
result = gormDB.Save(&rule)
if result.Error != nil {
c.JSON(500, gin.H{"code": 500, "data": "更新规则失败"})
return
}
c.JSON(200, gin.H{"code": 200, "data": "规则更新成功"})
} }
// 根据删除规则 // HandleDeleteRule 删除规则
func HandleDeleteRule(c *gin.Context) { func HandleDeleteRule(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c) userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil { if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) response.Unauthorized(c, "获取用户信息失败")
return return
} }
ruleID, err := strconv.Atoi(c.Param("ruleID")) ruleID, err := parseID(c.Param("ruleID"))
if err != nil { if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "规则 ID 格式错误"}) response.BadRequest(c, "规则 ID 格式错误")
return return
} }
err = DeleteRuleByID(uint(ruleID), userInfo.ID) if err := DeleteRuleByID(ruleID, userInfo.ID); err != nil {
if err != nil { response.ServerError(c, "删除规则失败")
c.JSON(500, gin.H{"code": 500, "data": "删除规则失败"})
return return
} }
c.JSON(200, gin.H{"code": 200, "data": "规则删除成功"}) response.OK(c, "规则删除成功")
}
// HandleTestRule 立即执行一次规则用于验证配置
//
// 使用较短的超时,避免在 WebUI 上等待数小时的指数退避。
func HandleTestRule(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
ruleID, err := parseID(c.Param("ruleID"))
if err != nil {
response.BadRequest(c, "规则 ID 格式错误")
return
}
target, err := findRule(ruleID, userInfo.ID)
if err != nil {
response.NotFound(c, "规则不存在")
return
}
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
defer cancel()
if err := runner.ExecuteRuleWithContext(ctx, target, model.TriggerManual); err != nil {
response.BadRequest(c, fmt.Sprintf("规则执行失败: %s", err.Error()))
return
}
response.OK(c, "规则执行成功")
}
// validateRule 校验规则的通用字段、关联对象与类型专属配置
func validateRule(req *model.RuleRequest, rule *model.Rule) error {
if err := req.Validate(); err != nil {
return err
}
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(req.Type)
if !ok {
return model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", req.Type))
}
// 检查关联的 Timer 与账号是否存在且属于当前用户
if err := checkRuleConfigAccountAndTimerExist(rule, ruleMeta.AccountType); err != nil {
return err
}
if err := ruleConfigChecker.InitValidatorRegistry().Validate(req.Type, rule.ConfigJson); err != nil {
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
}
return nil
}
// unmaskRuleConfig 把提交上来的掩码字段还原成旧值
func unmaskRuleConfig(ruleType, newConfig, oldConfig string) string {
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType)
if !ok {
return newConfig
}
return meta.Unmask(newConfig, oldConfig, ruleMeta.Fields)
} }
+78 -25
View File
@@ -1,52 +1,105 @@
package rule package rule
import ( import (
"fmt"
"strconv"
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/internal/meta"
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 根据 ID 删除规则 func parseID(raw string) (uint, error) {
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
func boolOr(v *bool, fallback bool) bool {
if v == nil {
return fallback
}
return *v
}
// findRule 查找属于该用户的规则
func findRule(ruleID, uid uint) (*model.Rule, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var target model.Rule
if err := gormDB.Where("id = ? AND uid = ?", ruleID, uid).First(&target).Error; err != nil {
return nil, err
}
return &target, nil
}
// DeleteRuleByID 根据 ID 删除规则
func DeleteRuleByID(id uint, uid uint) error { func DeleteRuleByID(id uint, uid uint) error {
gormDB, err := db.GetGormDB() gormDB, err := db.GetGormDB()
if err != nil { if err != nil {
return err return err
} }
result := gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}) return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}).Error
return result.Error
} }
// 检查关联账号和 Timer 是否存在(可以没有关联账号) // checkRuleConfigAccountAndTimerExist 检查关联的 Timer 与账号是否存在且归属当前用户
func checkRuleConfigAccountAndTimerExist(rule model.Rule) (exist bool, err error) { //
// requiredAccountType 为空表示该规则类型不需要账号。
func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType string) error {
gormDB, err := db.GetGormDB() gormDB, err := db.GetGormDB()
if err != nil { if err != nil {
return false, err return err
}
if rule.TimerID == 0 {
return false, nil
} }
var count int64 var count int64
if rule.AccountID != 0 { if err := gormDB.Model(&model.Timer{}).
result := gormDB.Model(&model.Account{}).Where("id = ? AND uid = ?", rule.AccountID, rule.UID).Count(&count) Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
if result.Error != nil { return err
return false, result.Error
} }
if count == 0 { if count == 0 {
return false, nil return model.ErrValidation("关联的 Timer 不存在")
}
} }
if rule.TimerID != 0 { if requiredAccountType == "" {
result := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count) // 该类型不需要账号,忽略前端可能传来的 account_id
if result.Error != nil { rule.AccountID = 0
return false, result.Error return nil
}
if count == 0 {
return false, nil
}
} }
return true, nil if rule.AccountID == 0 {
return model.ErrValidation("该规则类型必须关联一个账号")
}
var account model.Account
if err := gormDB.Where("id = ? AND uid = ?", rule.AccountID, rule.UID).First(&account).Error; err != nil {
return model.ErrValidation("关联的账号不存在")
}
if account.Type != requiredAccountType {
return model.ErrValidation(fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredAccountType, account.Type))
}
return nil
}
// maskRule 掩码规则配置中的敏感字段
func maskRule(target model.Rule) model.Rule {
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(target.Type); ok {
target.ConfigJson = meta.Mask(target.ConfigJson, ruleMeta.Fields)
}
return target
}
func maskRules(rules []model.Rule) []model.Rule {
masked := make([]model.Rule, 0, len(rules))
for _, r := range rules {
masked = append(masked, maskRule(r))
}
return masked
} }
+289
View File
@@ -0,0 +1,289 @@
package timer
import (
"time"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
"gorm.io/gorm"
)
// HandleGetAllTimers 获取当前用户的所有 Timer
func HandleGetAllTimers(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
timers := []model.Timer{}
if err := gormDB.Where("uid = ?", userInfo.ID).Order("id DESC").Find(&timers).Error; err != nil {
response.ServerError(c, "获取 Timer 失败")
return
}
response.OK(c, timers)
}
// HandleGetTimer 获取单个 Timer 及其关联规则数量
func HandleGetTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
timer, err := findTimer(timerID, userInfo.ID)
if err != nil {
response.NotFound(c, "Timer 不存在")
return
}
ruleCount, err := countRules(timerID, userInfo.ID)
if err != nil {
response.ServerError(c, "获取关联规则失败")
return
}
response.OK(c, gin.H{"timer": timer, "rule_count": ruleCount})
}
// HandleCreateTimer 创建 Timer
func HandleCreateTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
var req model.TimerRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
if err := req.Validate(); err != nil {
response.FromError(c, err, "创建 Timer 失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
now := time.Now().Unix()
timer := model.Timer{
UID: userInfo.ID,
Name: req.Name,
Description: req.Description,
Enabled: boolOr(req.Enabled, true),
SignDerationSeconds: req.SignDerationSeconds,
RemindTimeSeconds: req.RemindTimeSeconds,
// 创建即视为完成一次签到,从当前时间开始计时
LastSign: now,
CreateAt: now,
}
if err := gormDB.Create(&timer).Error; err != nil {
response.ServerError(c, "创建 Timer 失败")
return
}
response.OK(c, timer)
}
// HandleEditTimer 编辑 Timer
func HandleEditTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
timer, err := findTimer(timerID, userInfo.ID)
if err != nil {
response.NotFound(c, "Timer 不存在")
return
}
var req model.TimerRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
if err := req.Validate(); err != nil {
response.FromError(c, err, "更新 Timer 失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
timer.Name = req.Name
timer.Description = req.Description
timer.Enabled = boolOr(req.Enabled, timer.Enabled)
timer.SignDerationSeconds = req.SignDerationSeconds
timer.RemindTimeSeconds = req.RemindTimeSeconds
if err := gormDB.Save(timer).Error; err != nil {
response.ServerError(c, "更新 Timer 失败")
return
}
response.OK(c, timer)
}
// HandleSignTimer 签到:重置计时并解除已触发状态
func HandleSignTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
timer, err := findTimer(timerID, userInfo.ID)
if err != nil {
response.NotFound(c, "Timer 不存在")
return
}
if err := signTimer(timer); err != nil {
response.ServerError(c, "签到失败")
return
}
response.OK(c, timer)
}
// HandleSignAll 一键签到当前用户的所有启用中的 Timer
func HandleSignAll(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
now := time.Now().Unix()
result := gormDB.Model(&model.Timer{}).
Where("uid = ? AND enabled = ?", userInfo.ID, true).
Updates(map[string]any{
"last_sign": now,
"last_remind": 0,
"triggered": false,
})
if result.Error != nil {
response.ServerError(c, "签到失败")
return
}
response.OK(c, gin.H{"signed": result.RowsAffected, "last_sign": now})
}
// HandleDeleteTimer 删除 Timer
//
// Timer 被删除后,挂在它下面的规则就没有触发来源了,因此一并删除。
func HandleDeleteTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
if _, err := findTimer(timerID, userInfo.ID); err != nil {
response.NotFound(c, "Timer 不存在")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
err = gormDB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("timer_id = ? AND uid = ?", timerID, userInfo.ID).Delete(&model.Rule{}).Error; err != nil {
return err
}
return tx.Where("id = ? AND uid = ?", timerID, userInfo.ID).Delete(&model.Timer{}).Error
})
if err != nil {
response.ServerError(c, "删除 Timer 失败")
return
}
response.OK(c, "Timer 删除成功")
}
// HandleCheckDeleteTimer 删除前查询会被一并删除的规则
func HandleCheckDeleteTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
rules := []model.Rule{}
if err := gormDB.Where("timer_id = ? AND uid = ?", timerID, userInfo.ID).Find(&rules).Error; err != nil {
response.ServerError(c, "获取相关规则失败")
return
}
response.OK(c, rules)
}
+72
View File
@@ -0,0 +1,72 @@
package timer
import (
"strconv"
"time"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
func parseID(raw string) (uint, error) {
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
func boolOr(v *bool, fallback bool) bool {
if v == nil {
return fallback
}
return *v
}
// findTimer 查找属于该用户的 Timer
func findTimer(timerID, uid uint) (*model.Timer, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var timer model.Timer
if err := gormDB.Where("id = ? AND uid = ?", timerID, uid).First(&timer).Error; err != nil {
return nil, err
}
return &timer, nil
}
func countRules(timerID, uid uint) (int64, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var count int64
err = gormDB.Model(&model.Rule{}).Where("timer_id = ? AND uid = ?", timerID, uid).Count(&count).Error
return count, err
}
// signTimer 完成一次签到:重置计时、清除提醒与触发标记
func signTimer(timer *model.Timer) error {
gormDB, err := db.GetGormDB()
if err != nil {
return err
}
now := time.Now().Unix()
if err := gormDB.Model(&model.Timer{}).Where("id = ?", timer.ID).
Updates(map[string]any{
"last_sign": now,
"last_remind": 0,
"triggered": false,
}).Error; err != nil {
return err
}
timer.LastSign = now
timer.LastRemind = 0
timer.Triggered = false
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package timer
import (
"context"
"sync"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/handler/runner"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// HandleTriggerTimer 手动触发一个 Timer,用于调试。
//
// 会真实执行该 Timer 下所有启用的规则,但不会改变 Timer 的签到/触发状态;
// 使用较短的测试超时,避免在页面上等待数小时的重试。
func HandleTriggerTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
if _, err := findTimer(timerID, userInfo.ID); err != nil {
response.NotFound(c, "Timer 不存在")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
var rules []model.Rule
if err := gormDB.Where("timer_id = ? AND uid = ? AND enabled = ?", timerID, userInfo.ID, true).
Find(&rules).Error; err != nil {
response.ServerError(c, "获取关联规则失败")
return
}
if len(rules) == 0 {
response.BadRequest(c, "该定时器下没有启用的规则")
return
}
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
defer cancel()
// 并发执行所有规则,收集失败信息
var (
wg sync.WaitGroup
mu sync.Mutex
fails []string
)
for i := range rules {
rule := rules[i]
wg.Add(1)
go func() {
defer wg.Done()
if err := runner.ExecuteRuleWithContext(ctx, &rule, model.TriggerManual); err != nil {
mu.Lock()
fails = append(fails, rule.Name+": "+err.Error())
mu.Unlock()
}
}()
}
wg.Wait()
response.OK(c, gin.H{
"total": len(rules),
"failed": fails,
})
}
+128 -24
View File
@@ -1,6 +1,9 @@
package user package user
import ( import (
"encoding/json"
"errors"
"fmt"
"time" "time"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
@@ -8,15 +11,25 @@ import (
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
// ErrUserNotFound 用户不存在
var ErrUserNotFound = errors.New("用户不存在")
// ErrWrongPassword 密码错误
var ErrWrongPassword = errors.New("用户名或密码错误")
// contextKey AuthMiddleware 把当前用户放进 gin.Context 时使用的 key
const contextKey = "currentUser"
// 加密密码 // 加密密码
func encryptPassword(password string) (string, error) { func encryptPassword(password string) (string, error) {
hashedID, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(hashedID), nil return string(hashed), nil
} }
// 验证密码 // 验证密码
@@ -35,30 +48,62 @@ func createUser(u *model.User) error {
return err return err
} }
u.Password = hashedPassword u.Password = hashedPassword
u.CreateAt = time.Now().Unix() u.CreateAt = time.Now().Unix()
result := gormDB.Create(u) return gormDB.Create(u).Error
return result.Error
} }
// 校验密码 // IsUsernameTaken 用户名是否已被占用
func verifyUser(u *model.User) error { func IsUsernameTaken(username string) (bool, error) {
gormDB, err := db.GetGormDB() gormDB, err := db.GetGormDB()
if err != nil { if err != nil {
return err return false, err
} }
var hashedPassword string var count int64
result := gormDB.Where("username = ?", u.Username).First(&model.User{}).Select("password").Scan(&hashedPassword) if err := gormDB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
if result.Error != nil { return false, err
return result.Error
} }
return count > 0, nil
return verifyPassword(hashedPassword, u.Password)
} }
// 通过用户名获取用户信息 // CountUsers 返回已注册用户数,用于首个用户的引导注册
func CountUsers() (int64, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var count int64
err = gormDB.Model(&model.User{}).Count(&count).Error
return count, err
}
// verifyUser 校验用户名与密码,成功时返回用户记录
//
// 原实现把 First 与 Select().Scan() 串在同一条语句上,实际拿不到密码哈希,
// 且用 sql.ErrNoRows 去比较 gorm 的错误永远不成立。
func verifyUser(username, password string) (*model.User, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var user model.User
if err := gormDB.Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
if err := verifyPassword(user.Password, password); err != nil {
return nil, ErrWrongPassword
}
return &user, nil
}
// GetUserInfo 通过用户名获取用户信息
func GetUserInfo(username string) (*model.User, error) { func GetUserInfo(username string) (*model.User, error) {
gormDB, err := db.GetGormDB() gormDB, err := db.GetGormDB()
if err != nil { if err != nil {
@@ -66,20 +111,79 @@ func GetUserInfo(username string) (*model.User, error) {
} }
var user model.User var user model.User
result := gormDB.Where("username = ?", username).First(&user) if err := gormDB.Where("username = ?", username).First(&user).Error; err != nil {
if result.Error != nil { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, result.Error return nil, ErrUserNotFound
}
return nil, err
} }
return &user, nil return &user, nil
} }
// 通过 gin.Context 获取用户信息 // GetUserByID 通过 ID 获取用户信息
func GetUserInfoByGinCtx(c *gin.Context) (userInfo *model.User, err error) { func GetUserByID(id uint) (*model.User, error) {
session := sessions.Default(c) gormDB, err := db.GetGormDB()
username := session.Get("username")
userInfo, err = GetUserInfo(username.(string))
if err != nil { if err != nil {
return nil, err return nil, err
} }
return userInfo, nil
var user model.User
if err := gormDB.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return &user, nil
}
// GetUserInfoByGinCtx 获取当前登录用户
//
// AuthMiddleware 已经查过一次库并写入了 context,这里优先读缓存。
func GetUserInfoByGinCtx(c *gin.Context) (*model.User, error) {
if cached, ok := c.Get(contextKey); ok {
if user, ok := cached.(*model.User); ok {
return user, nil
}
}
session := sessions.Default(c)
uid, ok := session.Get("uid").(uint)
if !ok {
return nil, ErrUserNotFound
}
user, err := GetUserByID(uid)
if err != nil {
return nil, err
}
c.Set(contextKey, user)
return user, nil
}
// setSession 登录成功后写入会话
func setSession(c *gin.Context, user *model.User) error {
session := sessions.Default(c)
session.Set("uid", user.ID)
session.Set("username", user.Username)
return session.Save()
}
// validateNotifyConfig 校验钉钉提醒配置
func validateNotifyConfig(raw *string) error {
if raw == nil || *raw == "" {
return nil
}
var config struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal([]byte(*raw), &config); err != nil {
return fmt.Errorf("钉钉配置 JSON 格式错误: %v", err)
}
if config.AccessToken == "" {
return fmt.Errorf("钉钉配置中 access_token 不能为空")
}
return nil
} }
+170 -58
View File
@@ -1,118 +1,230 @@
package user package user
import ( import (
"database/sql" "errors"
"net/http"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/configs" "github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 处理注册请求,配置文件中可设置禁用注册 // HandleRegistry 处理注册请求,配置文件中可设置禁用注册
//
// 系统内还没有任何用户时始终允许注册,否则全新部署将无法创建第一个账号。
func HandleRegistry(c *gin.Context) { func HandleRegistry(c *gin.Context) {
var userRegistryRequest model.UserRegistryReuest
var err error
config, err := configs.GetConfig() config, err := configs.GetConfig()
if err != nil { if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "获取配置文件失败"}) response.ServerError(c, "获取配置文件失败")
return return
} }
if !config.EnableRegistry { userCount, err := CountUsers()
c.JSON(403, gin.H{"code": 403, "data": "注册功能已关闭"})
return
}
err = c.BindJSON(&userRegistryRequest)
if err != nil { if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "输入错误"}) response.ServerError(c, "服务器内部错误")
return
}
if !config.EnableRegistry && userCount > 0 {
response.Fail(c, http.StatusForbidden, "注册功能已关闭")
return
}
var registryRequest model.UserRegistryReuest
if err := c.ShouldBindJSON(&registryRequest); err != nil {
response.BadRequest(c, "输入错误")
return return
} }
user := model.User{ user := model.User{
Username: userRegistryRequest.Username, Username: registryRequest.Username,
Password: userRegistryRequest.Password, Password: registryRequest.Password,
} }
if err = user.IsValid(); err != nil { if err := user.IsValid(); err != nil {
c.JSON(400, gin.H{"code": 400, "data": err.Error()}) response.BadRequest(c, err.Error())
return return
} }
if isExist := user.IsExist(); isExist { taken, err := IsUsernameTaken(user.Username)
c.JSON(400, gin.H{"code": 400, "data": "用户名已存在"}) if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if taken {
response.BadRequest(c, "用户名已存在")
return return
} }
if err = createUser(&user); err != nil { if err := createUser(&user); err != nil {
c.JSON(500, gin.H{"code": 500, "data": "注册失败"}) response.ServerError(c, "注册失败")
return return
} }
session := sessions.Default(c) if err := setSession(c, &user); err != nil {
session.Set("username", user.Username) response.ServerError(c, "写入会话失败")
session.Save() return
}
c.JSON(200, gin.H{"code": 200, "data": "注册成功"}) response.OK(c, "注册成功")
} }
// 处理登录请求 // HandleLogin 处理登录请求
func HandleLogin(c *gin.Context) { func HandleLogin(c *gin.Context) {
var userLoginRequest model.UserRegistryReuest var loginRequest model.UserRegistryReuest
var err error if err := c.ShouldBindJSON(&loginRequest); err != nil {
response.BadRequest(c, "输入错误")
err = c.BindJSON(&userLoginRequest)
if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "输入错误"})
return return
} }
user := model.User{ if loginRequest.Username == "" || loginRequest.Password == "" {
Username: userLoginRequest.Username, response.BadRequest(c, "用户名或密码不能为空")
Password: userLoginRequest.Password,
}
if err = user.IsValid(); err != nil {
c.JSON(400, gin.H{"code": 400, "data": err.Error()})
}
if err = verifyUser(&user); err == sql.ErrNoRows {
c.JSON(400, gin.H{"code": 400, "data": "用户不存在"})
return
} else if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "登录失败"})
return return
} }
session := sessions.Default(c) user, err := verifyUser(loginRequest.Username, loginRequest.Password)
session.Set("username", user.Username) switch {
session.Save() case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrWrongPassword):
// 不区分“用户不存在”和“密码错误”,避免泄露用户名是否存在
response.Unauthorized(c, "用户名或密码错误")
return
case err != nil:
response.ServerError(c, "登录失败")
return
}
c.JSON(200, gin.H{"code": 200, "data": "登录成功"}) if err := setSession(c, user); err != nil {
response.ServerError(c, "写入会话失败")
return
}
response.OK(c, "登录成功")
} }
// 处理获取用户信息请求 // HandleLogout 退出登录
func HandleLogout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Options(sessions.Options{Path: "/", MaxAge: -1})
if err := session.Save(); err != nil {
response.ServerError(c, "退出登录失败")
return
}
response.OK(c, "已退出登录")
}
// HandleGetUserInfo 获取当前登录用户信息
func HandleGetUserInfo(c *gin.Context) { func HandleGetUserInfo(c *gin.Context) {
userInfo, err := GetUserInfoByGinCtx(c) userInfo, err := GetUserInfoByGinCtx(c)
if err != nil { if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "获取用户信息失败"}) response.Unauthorized(c, "获取用户信息失败")
return return
} }
c.JSON(200, gin.H{"code": 200, "data": userInfo}) response.OK(c, userInfo)
} }
// 认证中间件 // HandleChangePassword 修改密码
func HandleChangePassword(c *gin.Context) {
userInfo, err := GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
var req model.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入错误")
return
}
if _, err := verifyUser(userInfo.Username, req.OldPassword); err != nil {
response.BadRequest(c, "原密码错误")
return
}
candidate := model.User{Username: userInfo.Username, Password: req.NewPassword}
if err := candidate.IsValid(); err != nil {
response.BadRequest(c, err.Error())
return
}
hashed, err := encryptPassword(req.NewPassword)
if err != nil {
response.ServerError(c, "修改密码失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Model(&model.User{}).Where("id = ?", userInfo.ID).
Update("password", hashed).Error; err != nil {
response.ServerError(c, "修改密码失败")
return
}
response.OK(c, "密码修改成功")
}
// HandleUpdateNotifyConfig 更新提醒渠道(钉钉机器人)配置
func HandleUpdateNotifyConfig(c *gin.Context) {
userInfo, err := GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
var req model.NotifyConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入错误")
return
}
if err := validateNotifyConfig(req.DingTalkConfig); err != nil {
response.BadRequest(c, err.Error())
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Model(&model.User{}).Where("id = ?", userInfo.ID).
Update("ding_talk_config", req.DingTalkConfig).Error; err != nil {
response.ServerError(c, "保存提醒配置失败")
return
}
response.OK(c, "提醒配置已保存")
}
// AuthMiddleware 认证中间件,同时把当前用户放进 context 供后续 handler 复用
func AuthMiddleware() gin.HandlerFunc { func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
username := session.Get("username") uid, ok := session.Get("uid").(uint)
if username == nil { if !ok {
c.JSON(401, gin.H{"code": 401, "data": "未登录"}) response.AbortWith(c, http.StatusUnauthorized, "未登录")
c.Abort()
return return
} }
user, err := GetUserByID(uid)
if err != nil {
// 用户已被删除,清理会话
session.Clear()
_ = session.Save()
response.AbortWith(c, http.StatusUnauthorized, "未登录")
return
}
c.Set(contextKey, user)
c.Next() c.Next()
} }
} }
+175 -11
View File
@@ -1,31 +1,195 @@
package configs package configs
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"os" "os"
"strconv"
"sync"
) )
// type DingtalkBot struct { // 默认配置文件路径,可通过环境变量 GOODBABY_CONFIG 覆盖
// AccessToken string `json:"access_token"` const defaultConfigPath = "config.json"
// Secret string `json:"secret"`
// }
type Config struct { type Config struct {
// HTTP 监听地址,如 ":8088"
ListenAddr string `json:"listen_addr"`
// 是否开放注册
EnableRegistry bool `json:"enable_registry"` EnableRegistry bool `json:"enable_registry"`
// 规则执行的最长重试时间(小时),用于指数退避的整体超时
TimeoutDurationHours int `json:"timeout_duration_hours"` 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"`
} }
// Get config from json file func defaultConfig() Config {
func GetConfig() (config Config, err error) { return Config{
content, err := os.ReadFile("config.json") 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 { if err != nil {
return config, err 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)
} }
err = json.Unmarshal(content, &config) changed := normalize(&config)
if err != nil { applyEnvOverrides(&config)
return config, err
if changed {
if err := save(path, config); err != nil {
// 只读挂载等场景下写回失败不应阻断启动
log.Printf("警告: 写回配置文件失败: %v", err)
}
} }
return config, nil 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)
}
+4 -9
View File
@@ -1,18 +1,13 @@
services: services:
goodbaby: goodbaby:
image: mei232/goodbaby:v2.0.1 image: mei232/goodbaby:v2.1.0
build: .
ports: ports:
- "8088:8088" - "8088:8088"
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- GIN_MODE=release - GIN_MODE=release
restart: unless-stopped restart: unless-stopped
networks:
- app-network
volumes: volumes:
- ./config.json:/app/config.json:ro # sqlite 需要写权限,config.json 首次启动会自动生成,不能挂成只读
- ./data.db:/app/data.db:ro - ./data:/app/data
networks:
app-network:
driver: bridge
+13 -5
View File
@@ -1,17 +1,25 @@
# Bilibili # Bilibili 配置
## Account Config 账号类型: `bilibili`,规则类型: `bilibili-dynamic`。
## 账号配置 (Account Config)
```json ```json
{ {
"raw_cookies": "xxxx" "raw_cookies": "SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx; ..."
} }
``` ```
## Dynamic Config 登录 [bilibili.com](https://www.bilibili.com) 后,从浏览器开发者工具(F12 → 网络 → 任意请求 → 请求头 → Cookie)复制完整的 Cookie 字符串。
在 WebUI 的账号页面可以点击“测试”验证 Cookie 是否有效。
## 规则配置 (Rule Config)
```json ```json
{ {
"msg": "something" "msg": "要发送的动态内容"
} }
``` ```
定时器到期后会以该账号发布一条纯文本动态。
+32
View File
@@ -0,0 +1,32 @@
# 钉钉机器人配置
钉钉在 goodBaby 中有两个用途:
1. **提醒渠道**:定时器临近到期时提醒你签到,在 WebUI 的“设置”页配置
2. **规则类型** (`dingtalk`):定时器到期后向钉钉群发送消息,不需要关联账号
## 获取机器人凭据
1. 在钉钉群中:群设置 → 智能群助手 → 添加机器人 → 自定义
2. 安全设置推荐选择“加签”,记下 Secret
3. 创建后复制 Webhook 地址中 `access_token=` 后面的值
## 规则配置 (Rule Config)
```json
{
"access_token": "机器人 access_token",
"secret": "加签 Secret(未开启加签可留空)",
"title": "消息标题",
"msg": "消息内容,支持 Markdown"
}
```
## 提醒渠道配置(设置页)
```json
{
"access_token": "机器人 access_token",
"secret": "加签 Secret"
}
```
+38 -2
View File
@@ -1,3 +1,39 @@
# Email Config # Email 配置
## Email Account Config 账号类型: `email`,规则类型: `email`。
## 账号配置 (Account Config)
```json
{
"smtp_server": "smtp.example.com",
"port": 465,
"security": "ssl",
"username": "you@example.com",
"password": "你的密码或授权码",
"from": "you@example.com",
"test_destination": "test@example.com"
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `smtp_server` | 是 | SMTP 服务器地址 |
| `port` | 是 | 端口,SSL 一般为 465,STARTTLS 一般为 587 |
| `security` | 否 | `ssl` / `starttls` / `none`,默认 `ssl` |
| `username` | 是 | 登录用户名,通常是邮箱地址 |
| `password` | 是 | 密码或授权码(QQ 邮箱、163 等需要使用授权码) |
| `from` | 否 | 发件人地址,默认使用 `username` |
| `test_destination` | 否 | 填写后,点击“测试”会真的发送一封测试邮件到该地址 |
## 规则配置 (Rule Config)
```json
{
"title": "邮件标题",
"msg": "邮件正文",
"destinations": ["a@example.com", "b@example.com"]
}
```
每个收件人独立发送、独立重试,互不影响。
+33
View File
@@ -0,0 +1,33 @@
# GitHub 配置
账号类型: `github`,规则类型: `github-repo-public`。
定时器到期后,把指定的私有仓库设置为公开。
## 账号配置 (Account Config)
```json
{
"token": "ghp_xxxxxxxx",
"owner": "your-github-name"
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `token` | 是 | Personal Access Token,需要 `repo` 权限 |
| `owner` | 是 | 仓库所有者(用户名或组织名) |
Token 在 GitHub → Settings → Developer settings → Personal access tokens 创建。
在 WebUI 的账号页面可以点击“测试”验证 Token 是否有效。
## 规则配置 (Rule Config)
```json
{
"repos": ["repo-a", "repo-b"]
}
```
只填仓库名,不含所有者。
+33
View File
@@ -0,0 +1,33 @@
# OneBot (QQ) 配置
账号类型: `onebot`,规则类型: `onebot`。
对接 [NapCat](https://napneko.github.io/)、go-cqhttp 等实现了 OneBot HTTP API 的服务。
## 账号配置 (Account Config)
```json
{
"url": "http://localhost:3000",
"token": "your_access_token"
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `url` | 是 | OneBot HTTP 服务地址 |
| `token` | 是 | Access Token |
在 WebUI 的账号页面可以点击“测试”验证连通性(调用 `get_login_info`)。
## 规则配置 (Rule Config)
```json
{
"msg": "要发送的消息",
"send_groups": [123456789],
"send_users": [987654321]
}
```
`send_groups`(群号)与 `send_users`(好友 QQ 号)至少填写一项。
+52 -4
View File
@@ -3,19 +3,21 @@ package bilibili
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
) )
// BilibiliAccountConfigValidator B站账号配置验证器 // BilibiliAccountConfigValidator B站账号配置验证器
type BilibiliAccountConfigValidator struct{} type BilibiliAccountConfigValidator struct{}
func (v *BilibiliAccountConfigValidator) GetType() string { func (v *BilibiliAccountConfigValidator) GetType() string {
return "bilibili-account" return AccountType
} }
func (v *BilibiliAccountConfigValidator) Validate(config string) error { func (v *BilibiliAccountConfigValidator) Validate(config string) error {
var cfg BiliAccount cfg, err := parseAccount(config)
if err := json.Unmarshal([]byte(config), &cfg); err != nil { if err != nil {
return fmt.Errorf("解析B站账号配置失败: %v", err) return err
} }
if cfg.RawCookies == "" { if cfg.RawCookies == "" {
@@ -24,3 +26,49 @@ func (v *BilibiliAccountConfigValidator) Validate(config string) error {
return nil return nil
} }
// Test 用 cookie 拉一次账号信息,验证 cookie 是否还有效
func (v *BilibiliAccountConfigValidator) Test(config string) error {
cfg, err := parseAccount(config)
if err != nil {
return err
}
client := newClient(cfg.RawCookies)
info, err := client.GetAccountInformation()
if err != nil {
return fmt.Errorf("B站 cookie 校验失败: %w", err)
}
if info == nil || info.Mid == 0 {
return fmt.Errorf("B站 cookie 无效或已过期")
}
return nil
}
func (v *BilibiliAccountConfigValidator) Meta() meta.AccountMeta {
return meta.AccountMeta{
Type: AccountType,
Label: "哔哩哔哩",
Description: "使用浏览器中的完整 Cookie 登录 B 站,用于发送动态。",
Docs: "docs/bilibili-config.md",
Fields: []meta.Field{
{
Key: "raw_cookies",
Label: "Cookies",
Type: meta.FieldTextarea,
Required: true,
Secret: true,
Placeholder: "SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx",
Help: "登录 bilibili.com 后从浏览器开发者工具复制完整的 Cookie 字符串",
},
},
}
}
func parseAccount(config string) (*BiliAccount, error) {
var cfg BiliAccount
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析B站账号配置失败: %v", err)
}
return &cfg, nil
}
+17 -32
View File
@@ -2,55 +2,40 @@ package bilibili
import ( import (
"context" "context"
"log" "fmt"
"time"
"github.com/CuteReimu/bilibili/v2" "github.com/CuteReimu/bilibili/v2"
"github.com/cenkalti/backoff/v5" "github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 在Bilibili动态发送消息 // SendBiliDynamicMsg 在 Bilibili 动态发送消息
// //
// 暂时没有处理 429 和 403 的区别对待 // 暂时没有处理 429 和 403 的区别对待
func SendBiliDynamicMsg(rule *model.Rule) { func SendBiliDynamicMsg(ctx context.Context, rule *model.Rule) error {
config, err := configs.GetConfig()
if err != nil {
log.Printf("获取配置失败: %v", err)
return
}
var timeout time.Duration = time.Duration(config.TimeoutDurationHours) * time.Hour
biliClient, err := getBiliClient(rule) biliClient, err := getBiliClient(rule)
if err != nil { if err != nil {
log.Printf("获取B站客户端失败: %v", err) return fmt.Errorf("获取B站客户端失败: %w", err)
return
}
biliDyncmicConfig, err := getBiliDynamicConfig(rule)
if err != nil {
log.Printf("获取B站动态配置失败: %v", err)
return
} }
var dynamicParams bilibili.CreateDynamicParam biliDynamicConfig, err := getBiliDynamicConfig(rule)
dynamicParams = bilibili.CreateDynamicParam{ if err != nil {
return fmt.Errorf("获取B站动态配置失败: %w", err)
}
dynamicParams := bilibili.CreateDynamicParam{
DynamicId: 0, DynamicId: 0,
Type: 4, Type: 4,
Rid: 0, Rid: 0,
Content: biliDyncmicConfig.Msg, Content: biliDynamicConfig.Msg,
} }
ctx, cancel := context.WithTimeout(context.Background(), timeout) if err := retry.Do(ctx, func() error {
defer cancel()
operation := func() (string, error) {
_, err := biliClient.CreateDynamic(dynamicParams) _, err := biliClient.CreateDynamic(dynamicParams)
return "", err return err
}); err != nil {
return fmt.Errorf("发送B站动态失败: %w", err)
} }
_, err = backoff.Retry(ctx, operation, backoff.WithBackOff(backoff.NewExponentialBackOff())) return nil
if err != nil {
log.Printf("发送B站动态失败: %v", err)
}
} }
+7
View File
@@ -1,5 +1,12 @@
package bilibili package bilibili
const (
// AccountType B 站账号类型标识
AccountType = "bilibili"
// RuleTypeDynamic 发送 B 站动态的规则类型标识
RuleTypeDynamic = "bilibili-dynamic"
)
type BiliAccount struct { type BiliAccount struct {
RawCookies string `json:"raw_cookies"` RawCookies string `json:"raw_cookies"`
} }
+26 -1
View File
@@ -3,13 +3,15 @@ package bilibili
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
) )
// BilibiliDynamicRuleValidator B站动态规则验证器 // BilibiliDynamicRuleValidator B站动态规则验证器
type BilibiliDynamicRuleValidator struct{} type BilibiliDynamicRuleValidator struct{}
func (v *BilibiliDynamicRuleValidator) GetType() string { func (v *BilibiliDynamicRuleValidator) GetType() string {
return "bilibili-dynamic" return RuleTypeDynamic
} }
func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error { func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error {
@@ -22,5 +24,28 @@ func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error {
return fmt.Errorf("B站动态规则配置中 msg 不能为空") return fmt.Errorf("B站动态规则配置中 msg 不能为空")
} }
if len([]rune(config.Msg)) > 1000 {
return fmt.Errorf("B站动态内容过长")
}
return nil return nil
} }
func (v *BilibiliDynamicRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: RuleTypeDynamic,
Label: "发送 B 站动态",
Description: "触发时以关联的 B 站账号发送一条动态。",
Docs: "docs/bilibili-config.md",
AccountType: AccountType,
Fields: []meta.Field{
{
Key: "msg",
Label: "动态内容",
Type: meta.FieldTextarea,
Required: true,
Placeholder: "要发送的动态正文",
},
},
}
}
+4 -5
View File
@@ -1,6 +1,7 @@
package bilibili package bilibili
import ( import (
"context"
"log" "log"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
@@ -10,13 +11,11 @@ import (
type BilibiliDynamicExecutor struct{} type BilibiliDynamicExecutor struct{}
func (e *BilibiliDynamicExecutor) GetType() string { func (e *BilibiliDynamicExecutor) GetType() string {
return "bilibili-dynamic" return RuleTypeDynamic
} }
func (e *BilibiliDynamicExecutor) Execute(rule *model.Rule) error { func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行B站动态规则: %s (ID: %d)", rule.Name, rule.ID) log.Printf("执行B站动态规则: %s (ID: %d)", rule.Name, rule.ID)
SendBiliDynamicMsg(rule) return SendBiliDynamicMsg(ctx, rule)
return nil
} }
+10 -22
View File
@@ -8,38 +8,26 @@ import (
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
func newClient(rawCookies string) *bilibili.Client {
client := bilibili.New()
client.SetRawCookies(rawCookies)
return client
}
// 获取 Bilibili Client // 获取 Bilibili Client
func getBiliClient(rule *model.Rule) (*bilibili.Client, error) { func getBiliClient(rule *model.Rule) (*bilibili.Client, error) {
client := bilibili.New() var accountConfig BiliAccount
gormDB, err := db.GetGormDB() if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
if err != nil {
return nil, err return nil, err
} }
// 1. 通过 account_id 获取到对应的 account 配置 return newClient(accountConfig.RawCookies), nil
var biliAccount model.Account
result := gormDB.Where("id = ?", rule.AccountID).First(&biliAccount)
if result.Error != nil {
return nil, result.Error
}
// 2. 通过 account 配置中的 config 字段获取到 CookiesString
var biliAccountConfig BiliAccount
err = json.Unmarshal([]byte(biliAccount.Config), &biliAccountConfig)
if err != nil {
return nil, err
}
client.SetRawCookies(biliAccountConfig.RawCookies)
return client, nil
} }
// 获取 Bilibili Dynamic Config // 获取 Bilibili Dynamic Config
func getBiliDynamicConfig(rule *model.Rule) (*BiliDynamicConfig, error) { func getBiliDynamicConfig(rule *model.Rule) (*BiliDynamicConfig, error) {
var biliDynamicConfig BiliDynamicConfig var biliDynamicConfig BiliDynamicConfig
err := json.Unmarshal([]byte(rule.ConfigJson), &biliDynamicConfig) if err := json.Unmarshal([]byte(rule.ConfigJson), &biliDynamicConfig); err != nil {
if err != nil {
return nil, err return nil, err
} }
+23 -13
View File
@@ -1,23 +1,33 @@
// dingtalk 仅用于发送提醒 // Package dingtalk 提供钉钉自定义机器人的提醒与规则执行能力
package dingtalk package dingtalk
import ( import (
"log" "fmt"
"github.com/CatchZeng/dingtalk/pkg/dingtalk" "github.com/CatchZeng/dingtalk/pkg/dingtalk"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
func SendDingTalkMsg(user *model.User, title string, msg string) { // SendDingTalkMsg 使用用户配置的钉钉机器人发送提醒
dingtalkAccount := getDingTalkConfigFromUser(user) //
if dingtalkAccount == nil { // 用户没有配置钉钉机器人时返回 ErrNoUserConfig
return func SendDingTalkMsg(user *model.User, title string, msg string) error {
} config := getDingTalkConfigFromUser(user)
if config == nil {
client := dingtalk.NewClient(dingtalkAccount.AccessToken, dingtalkAccount.Secret) return ErrNoUserConfig
sendMsg := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
_, _, err := client.Send(sendMsg)
if err != nil {
log.Printf("发送钉钉消息失败: %v", err)
} }
return send(config, title, msg)
}
// ErrNoUserConfig 表示用户没有配置钉钉提醒
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
func send(config *DingTalkConfig, title, msg string) error {
client := dingtalk.NewClient(config.AccessToken, config.Secret)
message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
if _, _, err := client.Send(message); err != nil {
return fmt.Errorf("发送钉钉消息失败: %w", err)
}
return nil
} }
+11
View File
@@ -1,6 +1,17 @@
package dingtalk package dingtalk
// RuleType 钉钉机器人规则类型标识
const RuleType = "dingtalk"
// DingTalkConfig 钉钉自定义机器人凭据,用户级提醒配置也用这个结构
type DingTalkConfig struct { type DingTalkConfig struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
Secret string `json:"secret"` Secret string `json:"secret"`
} }
// DingTalkRuleConfig 钉钉规则配置:凭据 + 要发送的内容
type DingTalkRuleConfig struct {
DingTalkConfig
Title string `json:"title"`
Msg string `json:"msg"`
}
+36 -4
View File
@@ -3,24 +3,56 @@ package dingtalk
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
) )
// DingTalkRuleValidator 钉钉规则验证器 // DingTalkRuleValidator 钉钉规则验证器
type DingTalkRuleValidator struct{} type DingTalkRuleValidator struct{}
func (v *DingTalkRuleValidator) GetType() string { func (v *DingTalkRuleValidator) GetType() string {
return "dingtalk" return RuleType
} }
func (v *DingTalkRuleValidator) Validate(configJSON string) error { func (v *DingTalkRuleValidator) Validate(configJSON string) error {
var config DingTalkConfig config, err := ParseRuleConfig(configJSON)
if err := json.Unmarshal([]byte(configJSON), &config); err != nil { if err != nil {
return fmt.Errorf("解析钉钉规则配置失败: %v", err) return err
} }
if config.AccessToken == "" { if config.AccessToken == "" {
return fmt.Errorf("钉钉规则配置中 access_token 不能为空") return fmt.Errorf("钉钉规则配置中 access_token 不能为空")
} }
if config.Title == "" {
return fmt.Errorf("钉钉规则配置中 title 不能为空")
}
if config.Msg == "" {
return fmt.Errorf("钉钉规则配置中 msg 不能为空")
}
return nil return nil
} }
func (v *DingTalkRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: RuleType,
Label: "钉钉机器人消息",
Description: "触发时通过钉钉自定义机器人发送一条 Markdown 消息,不需要关联账号。",
AccountType: "", // 凭据直接写在规则里,无需账号
Fields: []meta.Field{
{Key: "access_token", Label: "Access Token", Type: meta.FieldPassword, Required: true, Secret: true, Help: "机器人 Webhook 中 access_token 参数的值"},
{Key: "secret", Label: "加签 Secret", Type: meta.FieldPassword, Secret: true, Help: "机器人安全设置选择“加签”时填写"},
{Key: "title", Label: "消息标题", Type: meta.FieldString, Required: true},
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "支持 Markdown"},
},
}
}
// ParseRuleConfig 解析钉钉规则配置
func ParseRuleConfig(configJSON string) (*DingTalkRuleConfig, error) {
var config DingTalkRuleConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析钉钉规则配置失败: %v", err)
}
return &config, nil
}
+31
View File
@@ -0,0 +1,31 @@
package dingtalk
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// DingTalkExecutor 钉钉机器人执行器
//
// 之前只注册了校验器却没有执行器,导致钉钉规则可以创建但永远不会被执行。
type DingTalkExecutor struct{}
func (e *DingTalkExecutor) GetType() string {
return RuleType
}
func (e *DingTalkExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行钉钉规则: %s (ID: %d)", rule.Name, rule.ID)
config, err := ParseRuleConfig(rule.ConfigJson)
if err != nil {
return err
}
return retry.Do(ctx, func() error {
return send(&config.DingTalkConfig, config.Title, config.Msg)
})
}
+84 -7
View File
@@ -1,29 +1,33 @@
package email package email
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/mail"
"github.com/ssdomei232/goodBaby/internal/meta"
"github.com/ssdomei232/goodBaby/internal/retry"
) )
// EmailAccountConfigValidator 邮箱账号配置验证器 // EmailAccountConfigValidator 邮箱账号配置验证器
type EmailAccountConfigValidator struct{} type EmailAccountConfigValidator struct{}
func (v *EmailAccountConfigValidator) GetType() string { func (v *EmailAccountConfigValidator) GetType() string {
return "email-account" return AccountType
} }
func (v *EmailAccountConfigValidator) Validate(config string) error { func (v *EmailAccountConfigValidator) Validate(config string) error {
// 邮箱账号配置验证逻辑 cfg, err := parseAccountConfig(config)
var cfg EmailAccountConfig if err != nil {
if err := json.Unmarshal([]byte(config), &cfg); err != nil { return err
return fmt.Errorf("解析邮箱账号配置失败: %v", err)
} }
if cfg.SMTPServer == "" { if cfg.SMTPServer == "" {
return fmt.Errorf("邮箱账号配置中 SMTP 服务器不能为空") return fmt.Errorf("邮箱账号配置中 SMTP 服务器不能为空")
} }
if cfg.Port == 0 { if cfg.Port <= 0 || cfg.Port > 65535 {
return fmt.Errorf("邮箱账号配置中 SMTP 端口不能为空") return fmt.Errorf("邮箱账号配置中 SMTP 端口不合法")
} }
if cfg.Username == "" { if cfg.Username == "" {
return fmt.Errorf("邮箱账号配置中用户名不能为空") return fmt.Errorf("邮箱账号配置中用户名不能为空")
@@ -31,6 +35,79 @@ func (v *EmailAccountConfigValidator) Validate(config string) error {
if cfg.Password == "" { if cfg.Password == "" {
return fmt.Errorf("邮箱账号配置中密码不能为空") return fmt.Errorf("邮箱账号配置中密码不能为空")
} }
switch cfg.SecurityOrDefault() {
case SecuritySSL, SecuritySTARTTLS, SecurityNone:
default:
return fmt.Errorf("不支持的加密方式: %s", cfg.Security)
}
if cfg.From != "" {
if _, err := mail.ParseAddress(cfg.From); err != nil {
return fmt.Errorf("发件人地址不合法: %s", cfg.From)
}
}
if cfg.TestDestination != "" {
if _, err := mail.ParseAddress(cfg.TestDestination); err != nil {
return fmt.Errorf("测试收件地址不合法: %s", cfg.TestDestination)
}
}
return nil return nil
} }
// Test 连接 SMTP 服务器并完成认证;填写了测试收件地址时会真的发一封测试邮件
func (v *EmailAccountConfigValidator) Test(config string) error {
cfg, err := parseAccountConfig(config)
if err != nil {
return err
}
client, err := newSMTPClient(cfg)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
defer cancel()
if cfg.TestDestination == "" {
if err := client.DialWithContext(ctx); err != nil {
return fmt.Errorf("连接 SMTP 服务器失败: %w", err)
}
return client.Close()
}
message, err := buildMessage(cfg, cfg.TestDestination, "goodBaby 测试邮件", "这是一封来自 goodBaby 的测试邮件,收到即表示邮箱账号配置正确。")
if err != nil {
return err
}
if err := client.DialAndSendWithContext(ctx, message); err != nil {
return fmt.Errorf("发送测试邮件失败: %w", err)
}
return nil
}
func (v *EmailAccountConfigValidator) Meta() meta.AccountMeta {
return meta.AccountMeta{
Type: AccountType,
Label: "邮箱 (SMTP)",
Description: "配置一个用于发信的 SMTP 账号。",
Docs: "docs/email-config.md",
Fields: []meta.Field{
{Key: "smtp_server", Label: "SMTP 服务器", Type: meta.FieldString, Required: true, Placeholder: "smtp.example.com"},
{Key: "port", Label: "端口", Type: meta.FieldNumber, Required: true, Default: 465, Help: "SSL 一般为 465,STARTTLS 一般为 587"},
{Key: "security", Label: "加密方式", Type: meta.FieldString, Required: false, Default: SecuritySSL, Help: "可选 ssl / starttls / none,留空按 ssl 处理"},
{Key: "username", Label: "用户名", Type: meta.FieldString, Required: true, Placeholder: "you@example.com"},
{Key: "password", Label: "密码 / 授权码", Type: meta.FieldPassword, Required: true, Secret: true},
{Key: "from", Label: "发件人地址", Type: meta.FieldString, Help: "留空则使用用户名作为发件人"},
{Key: "test_destination", Label: "测试收件地址", Type: meta.FieldString, Help: "填写后点击“测试”会真的发送一封测试邮件"},
},
}
}
func parseAccountConfig(config string) (*EmailAccountConfig, error) {
var cfg EmailAccountConfig
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析邮箱账号配置失败: %v", err)
}
return &cfg, nil
}
+38 -7
View File
@@ -1,20 +1,51 @@
package email package email
import ( import (
"log" "context"
"fmt"
"strings"
"sync"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 向邮件列表发送消息 // SendMail 向规则配置里的所有地址发送邮件。
func SendMail(rule *model.Rule) { //
// 每个地址单独重试,互不阻塞;全部失败/部分失败都会汇总成错误返回,
// 由 runner 记录到执行日志中。
func SendMail(ctx context.Context, rule *model.Rule) error {
emailRule, err := GetEmailRuleFromRule(rule) emailRule, err := GetEmailRuleFromRule(rule)
if err != nil { if err != nil {
log.Printf("获取邮件规则配置失败: %v", err) return fmt.Errorf("获取邮件规则配置失败: %w", err)
return
} }
for _, destinations := range emailRule.Destinations { accountConfig, err := GetEmailAccountFromRule(rule)
go sendMailMsgWithRetry(rule, destinations) // 使用 goroutine 发送邮件,避免指数退避阻塞其他地址 if err != nil {
return fmt.Errorf("获取邮件账户配置失败: %w", err)
} }
var (
wg sync.WaitGroup
mu sync.Mutex
fails []string
)
for _, destination := range emailRule.Destinations {
wg.Add(1)
go func(address string) {
defer wg.Done()
if err := sendMailMsgWithRetry(ctx, accountConfig, emailRule, address); err != nil {
mu.Lock()
fails = append(fails, fmt.Sprintf("%s: %v", address, err))
mu.Unlock()
}
}(destination)
}
wg.Wait()
if len(fails) > 0 {
return fmt.Errorf("%d/%d 封邮件发送失败: %s",
len(fails), len(emailRule.Destinations), strings.Join(fails, "; "))
}
return nil
} }
+36 -1
View File
@@ -1,11 +1,46 @@
package email package email
const (
// AccountType 邮箱账号类型标识
AccountType = "email"
// RuleType 发送邮件的规则类型标识
RuleType = "email"
)
// SMTP 加密方式
const (
SecuritySSL = "ssl" // 隐式 TLS,一般是 465 端口
SecuritySTARTTLS = "starttls" // 显式 TLS,一般是 587 端口
SecurityNone = "none" // 不加密,一般是 25 端口
)
type EmailAccountConfig struct { type EmailAccountConfig struct {
SMTPServer string `json:"smtp_server"` SMTPServer string `json:"smtp_server"`
Port int `json:"port"` Port int `json:"port"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
TestDestination string `json:"test_destination"` // 用于测试邮件发送的地址 // 加密方式,留空时按 ssl 处理(兼容旧配置)
Security string `json:"security"`
// 发件人地址,留空时使用 Username
From string `json:"from"`
// 用于测试邮件发送的地址
TestDestination string `json:"test_destination"`
}
// SecurityOrDefault 返回加密方式,兼容没有该字段的旧配置
func (c *EmailAccountConfig) SecurityOrDefault() string {
if c.Security == "" {
return SecuritySSL
}
return c.Security
}
// FromOrDefault 返回发件人地址
func (c *EmailAccountConfig) FromOrDefault() string {
if c.From == "" {
return c.Username
}
return c.From
} }
type EmailRule struct { type EmailRule struct {
+18 -1
View File
@@ -4,13 +4,15 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/mail" "net/mail"
"github.com/ssdomei232/goodBaby/internal/meta"
) )
// EmailRuleValidator 邮件规则验证器 // EmailRuleValidator 邮件规则验证器
type EmailRuleValidator struct{} type EmailRuleValidator struct{}
func (v *EmailRuleValidator) GetType() string { func (v *EmailRuleValidator) GetType() string {
return "email" return RuleType
} }
func (v *EmailRuleValidator) Validate(configJSON string) error { func (v *EmailRuleValidator) Validate(configJSON string) error {
@@ -40,3 +42,18 @@ func (v *EmailRuleValidator) Validate(configJSON string) error {
return nil return nil
} }
func (v *EmailRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: RuleType,
Label: "发送邮件",
Description: "触发时通过关联的 SMTP 账号向指定地址发送邮件。",
Docs: "docs/email-config.md",
AccountType: AccountType,
Fields: []meta.Field{
{Key: "title", Label: "邮件标题", Type: meta.FieldString, Required: true},
{Key: "msg", Label: "邮件正文", Type: meta.FieldTextarea, Required: true},
{Key: "destinations", Label: "收件人", Type: meta.FieldStringList, Required: true, Placeholder: "someone@example.com"},
},
}
}
+4 -5
View File
@@ -1,6 +1,7 @@
package email package email
import ( import (
"context"
"log" "log"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
@@ -10,13 +11,11 @@ import (
type EmailExecutor struct{} type EmailExecutor struct{}
func (e *EmailExecutor) GetType() string { func (e *EmailExecutor) GetType() string {
return "email" return RuleType
} }
func (e *EmailExecutor) Execute(rule *model.Rule) error { func (e *EmailExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行邮件规则: %s (ID: %d)", rule.Name, rule.ID) log.Printf("执行邮件规则: %s (ID: %d)", rule.Name, rule.ID)
SendMail(rule) return SendMail(ctx, rule)
return nil
} }
+53 -78
View File
@@ -4,113 +4,88 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
"github.com/wneessen/go-mail" "github.com/wneessen/go-mail"
) )
// 从 Rule 中获取 EmailAccount 配置 // GetEmailAccountFromRule 从 Rule 中获取 EmailAccount 配置
func GetEmailAccountFromRule(rule *model.Rule) (*EmailAccountConfig, error) { func GetEmailAccountFromRule(rule *model.Rule) (*EmailAccountConfig, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
// 1. 通过 account_id 获取到对应的 account 配置
var emailAccount model.Account
result := gormDB.Where("id = ?", rule.AccountID).First(&emailAccount)
if result.Error != nil {
return nil, result.Error
}
// 2. 通过 account 配置中的 config 字段获取到 EmailAccount
var emailAccountConfig EmailAccountConfig var emailAccountConfig EmailAccountConfig
err = json.Unmarshal([]byte(emailAccount.Config), &emailAccountConfig) if err := db.LoadAccountConfig(rule.AccountID, &emailAccountConfig); err != nil {
if err != nil {
return nil, err return nil, err
} }
return &emailAccountConfig, nil return &emailAccountConfig, nil
} }
// 从 Rule 中获取 EmailRule 配置 // GetEmailRuleFromRule 从 Rule 中获取 EmailRule 配置
func GetEmailRuleFromRule(rule *model.Rule) (*EmailRule, error) { func GetEmailRuleFromRule(rule *model.Rule) (*EmailRule, error) {
var emailRule EmailRule var emailRule EmailRule
err := json.Unmarshal([]byte(rule.ConfigJson), &emailRule) if err := json.Unmarshal([]byte(rule.ConfigJson), &emailRule); err != nil {
if err != nil {
return nil, err return nil, err
} }
return &emailRule, nil return &emailRule, nil
} }
func sendMailMsgWithRetry(rule *model.Rule, address string) {
config, err := configs.GetConfig() // newSMTPClient 按账号配置里的加密方式创建 SMTP 客户端
func newSMTPClient(cfg *EmailAccountConfig) (*mail.Client, error) {
options := []mail.Option{
mail.WithPort(cfg.Port),
mail.WithUsername(cfg.Username),
mail.WithPassword(cfg.Password),
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
}
switch cfg.SecurityOrDefault() {
case SecuritySSL:
options = append(options, mail.WithSSL())
case SecuritySTARTTLS:
options = append(options, mail.WithTLSPolicy(mail.TLSMandatory))
case SecurityNone:
options = append(options, mail.WithTLSPolicy(mail.NoTLS))
}
client, err := mail.NewClient(cfg.SMTPServer, options...)
if err != nil { if err != nil {
log.Printf("获取配置失败: %v", err) return nil, fmt.Errorf("创建邮件客户端失败: %w", err)
return
}
var timeout time.Duration = time.Duration(config.TimeoutDurationHours) * time.Hour
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
operation := func() (string, error) {
err := sendMailMsg(address, rule)
return "", err
}
_, err = backoff.Retry(ctx, operation, backoff.WithBackOff(backoff.NewExponentialBackOff()))
if err != nil {
log.Printf("发送邮件失败: %v", err)
} }
return client, nil
} }
func sendMailMsg(address string, rule *model.Rule) error { func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.Msg, error) {
emailAccountConfig, err := GetEmailAccountFromRule(rule)
if err != nil {
log.Printf("获取邮件账户配置失败: %v", err)
return fmt.Errorf("获取邮件账户配置失败: %v", err)
}
emailRule, err := GetEmailRuleFromRule(rule)
if err != nil {
log.Printf("获取邮件规则配置失败: %v", err)
return fmt.Errorf("获取邮件规则配置失败: %v", err)
}
client, err := mail.NewClient(
emailAccountConfig.SMTPServer,
mail.WithPort(emailAccountConfig.Port),
mail.WithSSL(),
mail.WithUsername(emailAccountConfig.Username),
mail.WithPassword(emailAccountConfig.Password),
mail.WithSMTPAuth(mail.SMTPAuthPlain),
)
if err != nil {
return fmt.Errorf("创建邮件客户端失败: %v", err)
}
// 创建邮件
message := mail.NewMsg() message := mail.NewMsg()
if err := message.From(emailAccountConfig.Username); err != nil { if err := message.From(cfg.FromOrDefault()); err != nil {
return fmt.Errorf("设置发件人失败: %v", err) return nil, fmt.Errorf("设置发件人失败: %w", err)
} }
if err := message.To(address); err != nil { if err := message.To(address); err != nil {
return fmt.Errorf("设置收件人失败: %v", err) return nil, fmt.Errorf("设置收件人失败: %w", err)
}
message.Subject(title)
message.SetBodyString(mail.TypeTextPlain, body)
return message, nil
}
func sendMailMsgWithRetry(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
return retry.Do(ctx, func() error {
return sendMailMsg(ctx, cfg, rule, address)
})
}
func sendMailMsg(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
client, err := newSMTPClient(cfg)
if err != nil {
return err
} }
message.Subject(emailRule.Title) message, err := buildMessage(cfg, address, rule.Title, rule.Msg)
message.SetBodyString(mail.TypeTextPlain, emailRule.Msg) if err != nil {
return err
// 发送邮件
if err := client.DialAndSend(message); err != nil {
return fmt.Errorf("发送邮件失败: %v", err)
} }
if err := client.DialAndSendWithContext(ctx, message); err != nil {
return fmt.Errorf("发送邮件失败: %w", err)
}
return nil return nil
} }
+50 -5
View File
@@ -1,22 +1,26 @@
package github package github
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/google/go-github/v84/github"
"github.com/ssdomei232/goodBaby/internal/meta"
"github.com/ssdomei232/goodBaby/internal/retry"
) )
// GitHubAccountConfigValidator GitHub账号配置验证器 // GitHubAccountConfigValidator GitHub账号配置验证器
type GitHubAccountConfigValidator struct{} type GitHubAccountConfigValidator struct{}
func (v *GitHubAccountConfigValidator) GetType() string { func (v *GitHubAccountConfigValidator) GetType() string {
return "github-account" return AccountType
} }
func (v *GitHubAccountConfigValidator) Validate(config string) error { func (v *GitHubAccountConfigValidator) Validate(config string) error {
// GitHub账号配置验证逻辑 cfg, err := parseAccount(config)
var cfg GithubAccount if err != nil {
if err := json.Unmarshal([]byte(config), &cfg); err != nil { return err
return fmt.Errorf("解析GitHub账号配置失败: %v", err)
} }
if cfg.Token == "" { if cfg.Token == "" {
@@ -28,3 +32,44 @@ func (v *GitHubAccountConfigValidator) Validate(config string) error {
return nil return nil
} }
// Test 用 token 拉一次当前用户信息,验证 token 是否有效
func (v *GitHubAccountConfigValidator) Test(config string) error {
cfg, err := parseAccount(config)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
defer cancel()
client := github.NewClient(nil).WithAuthToken(cfg.Token)
user, _, err := client.Users.Get(ctx, "")
if err != nil {
return fmt.Errorf("GitHub Token 校验失败: %w", err)
}
if user == nil || user.GetLogin() == "" {
return fmt.Errorf("GitHub Token 无效")
}
return nil
}
func (v *GitHubAccountConfigValidator) Meta() meta.AccountMeta {
return meta.AccountMeta{
Type: AccountType,
Label: "GitHub",
Description: "使用 Personal Access Token 操作仓库,需要 repo 权限。",
Fields: []meta.Field{
{Key: "token", Label: "Personal Access Token", Type: meta.FieldPassword, Required: true, Secret: true, Placeholder: "ghp_xxx", Help: "需要勾选 repo 权限"},
{Key: "owner", Label: "仓库所有者", Type: meta.FieldString, Required: true, Placeholder: "your-github-name", Help: "用户名或组织名"},
},
}
}
func parseAccount(config string) (*GithubAccount, error) {
var cfg GithubAccount
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析GitHub账号配置失败: %v", err)
}
return &cfg, nil
}
+17 -25
View File
@@ -2,41 +2,33 @@ package github
import ( import (
"context" "context"
"log" "fmt"
"time" "strings"
"github.com/cenkalti/backoff/v5" "github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 将repos设置为public // MakeRepositoryPublic 将规则中配置的仓库设置为 public
func MakeRepositoryPublic(rule *model.Rule) { func MakeRepositoryPublic(ctx context.Context, rule *model.Rule) error {
reposConfig, account := GetGithubReposAndAccountFromRule(rule) reposConfig, account, err := GetGithubReposAndAccountFromRule(rule)
if reposConfig == nil || account == nil { if err != nil {
log.Printf("无法获取 GitHub 配置或账户信息") return err
return
} }
var fails []string
for _, repo := range reposConfig.Repos { for _, repo := range reposConfig.Repos {
config, err := configs.GetConfig() err := retry.Do(ctx, func() error {
return SetRepositoryPublic(ctx, account.Token, account.Owner, repo)
})
if err != nil { if err != nil {
log.Printf("获取配置失败: %v", err) fails = append(fails, fmt.Sprintf("%s: %v", repo, err))
return
} }
var timeout time.Duration = time.Duration(config.TimeoutDurationHours) * time.Hour
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
operation := func() (string, error) {
err := SetRepositoryPublic(account.Token, account.Owner, repo)
return "", err
} }
_, err = backoff.Retry(ctx, operation, backoff.WithBackOff(backoff.NewExponentialBackOff())) if len(fails) > 0 {
if err != nil { return fmt.Errorf("%d/%d 个仓库设置为 public 失败: %s",
log.Printf("将仓库 %s 设置为 public 失败: %v", repo, err) len(fails), len(reposConfig.Repos), strings.Join(fails, "; "))
}
} }
return nil
} }
+10 -3
View File
@@ -1,10 +1,17 @@
package github package github
const (
// AccountType GitHub 账号类型标识
AccountType = "github"
// RuleTypeMakeRepoPublic 公开仓库的规则类型标识
RuleTypeMakeRepoPublic = "github-repo-public"
)
type GithubReposConfig struct { type GithubReposConfig struct {
Repos []string `yaml:"repos"` Repos []string `json:"repos"`
} }
type GithubAccount struct { type GithubAccount struct {
Token string Token string `json:"token"`
Owner string Owner string `json:"owner"`
} }
+32 -1
View File
@@ -3,12 +3,15 @@ package github
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"github.com/ssdomei232/goodBaby/internal/meta"
) )
type GithubMakeRepositoryPublicRuleValidator struct{} type GithubMakeRepositoryPublicRuleValidator struct{}
func (v *GithubMakeRepositoryPublicRuleValidator) GetType() string { func (v *GithubMakeRepositoryPublicRuleValidator) GetType() string {
return "github_make_repository_public" return RuleTypeMakeRepoPublic
} }
func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) error { func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) error {
@@ -21,5 +24,33 @@ func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) er
return fmt.Errorf("GitHub 规则配置错误: 至少需要指定一个仓库") return fmt.Errorf("GitHub 规则配置错误: 至少需要指定一个仓库")
} }
for _, repo := range config.Repos {
if strings.TrimSpace(repo) == "" {
return fmt.Errorf("GitHub 规则配置错误: 仓库名不能为空")
}
if strings.Contains(repo, "/") {
return fmt.Errorf("GitHub 规则配置错误: 只填写仓库名(不含所有者),收到 %q", repo)
}
}
return nil return nil
} }
func (v *GithubMakeRepositoryPublicRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: RuleTypeMakeRepoPublic,
Label: "公开 GitHub 仓库",
Description: "触发时把指定的私有仓库改为公开。",
AccountType: AccountType,
Fields: []meta.Field{
{
Key: "repos",
Label: "仓库列表",
Type: meta.FieldStringList,
Required: true,
Placeholder: "my-repo",
Help: "只填仓库名,所有者取自关联的 GitHub 账号",
},
},
}
}
+4 -5
View File
@@ -1,6 +1,7 @@
package github package github
import ( import (
"context"
"log" "log"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
@@ -10,13 +11,11 @@ import (
type GithubMakeRepoPublicExecutor struct{} type GithubMakeRepoPublicExecutor struct{}
func (e *GithubMakeRepoPublicExecutor) GetType() string { func (e *GithubMakeRepoPublicExecutor) GetType() string {
return "github-make-repo-public" return RuleTypeMakeRepoPublic
} }
func (e *GithubMakeRepoPublicExecutor) Execute(rule *model.Rule) error { func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行GitHub仓库公开规则: %s (ID: %d)", rule.Name, rule.ID) log.Printf("执行GitHub仓库公开规则: %s (ID: %d)", rule.Name, rule.ID)
MakeRepositoryPublic(rule) return MakeRepositoryPublic(ctx, rule)
return nil
} }
+12 -31
View File
@@ -3,55 +3,36 @@ package github
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log" "fmt"
"github.com/google/go-github/v84/github" "github.com/google/go-github/v84/github"
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 从 rule 中获取 GithubReposConfig 和 GithubAccount // GetGithubReposAndAccountFromRule 从 rule 中获取 GithubReposConfig 和 GithubAccount
func GetGithubReposAndAccountFromRule(rule *model.Rule) (*GithubReposConfig, *GithubAccount) { func GetGithubReposAndAccountFromRule(rule *model.Rule) (*GithubReposConfig, *GithubAccount, error) {
var githubReposConfig GithubReposConfig var githubReposConfig GithubReposConfig
if err := json.Unmarshal([]byte(rule.ConfigJson), &githubReposConfig); err != nil {
return nil, nil, fmt.Errorf("解析 GitHub 规则配置失败: %w", err)
}
var githubAccount GithubAccount var githubAccount GithubAccount
if err := db.LoadAccountConfig(rule.AccountID, &githubAccount); err != nil {
// 获取 GithubReposConfig return nil, nil, err
err := json.Unmarshal([]byte(rule.ConfigJson), &githubReposConfig)
if err != nil {
log.Printf("获取 GitHub 配置失败: %v", err)
return nil, nil
} }
// 获取 GithubAccount return &githubReposConfig, &githubAccount, nil
gormDB, err := db.GetGormDB()
if err != nil {
log.Printf("获取 Gorm DB 失败: %v", err)
return nil, nil
}
err = gormDB.Where("id = ?", rule.AccountID).First(&githubAccount).Error
if err != nil {
log.Printf("获取 GitHub 账户失败: %v", err)
return nil, nil
}
return &githubReposConfig, &githubAccount
} }
// 将仓库设置为 public // SetRepositoryPublic 将仓库设置为 public
func SetRepositoryPublic(token, owner, repo string) error { func SetRepositoryPublic(ctx context.Context, token, owner, repo string) error {
ctx := context.Background()
// 1. 初始化客户端
client := github.NewClient(nil).WithAuthToken(token) client := github.NewClient(nil).WithAuthToken(token)
// 2. 准备修改的参数
opts := &github.Repository{ opts := &github.Repository{
Visibility: github.Ptr("public"), Visibility: github.Ptr("public"),
} }
// 3. 执行更新操作
_, _, err := client.Repositories.Edit(ctx, owner, repo, opts) _, _, err := client.Repositories.Edit(ctx, owner, repo, opts)
return err return err
} }
+48 -5
View File
@@ -3,19 +3,23 @@ package onebot
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"strings"
"github.com/ssdomei232/goodBaby/internal/meta"
) )
// AccountConfigValidator OneBot账号配置验证器接口 // OneBotAccountConfigValidator OneBot账号配置验证器
type OneBotAccountConfigValidator struct{} type OneBotAccountConfigValidator struct{}
func (v *OneBotAccountConfigValidator) GetType() string { func (v *OneBotAccountConfigValidator) GetType() string {
return "onebot" return AccountType
} }
func (v *OneBotAccountConfigValidator) Validate(config string) error { func (v *OneBotAccountConfigValidator) Validate(config string) error {
var cfg OneBotAccount cfg, err := parseAccount(config)
if err := json.Unmarshal([]byte(config), &cfg); err != nil { if err != nil {
return fmt.Errorf("解析OneBot账号配置失败: %v", err) return err
} }
if cfg.Token == "" { if cfg.Token == "" {
@@ -25,5 +29,44 @@ func (v *OneBotAccountConfigValidator) Validate(config string) error {
return fmt.Errorf("OneBot账号配置中URL不能为空") return fmt.Errorf("OneBot账号配置中URL不能为空")
} }
parsed, err := url.Parse(cfg.URL)
if err != nil || parsed.Host == "" || !strings.HasPrefix(parsed.Scheme, "http") {
return fmt.Errorf("OneBot账号配置中URL不合法,应形如 http://localhost:3000")
}
return nil return nil
} }
// Test 调用 get_login_info 验证地址与 Token
func (v *OneBotAccountConfigValidator) Test(config string) error {
cfg, err := parseAccount(config)
if err != nil {
return err
}
var resp apiResponse
if err := newClient(cfg).DoRequest("POST", "/get_login_info", map[string]any{}, &resp); err != nil {
return fmt.Errorf("连接 OneBot 失败: %w", err)
}
return resp.err()
}
func (v *OneBotAccountConfigValidator) Meta() meta.AccountMeta {
return meta.AccountMeta{
Type: AccountType,
Label: "OneBot (QQ)",
Description: "对接 NapCat / go-cqhttp 等 OneBot HTTP 服务,用于发送 QQ 消息。",
Fields: []meta.Field{
{Key: "url", Label: "HTTP 服务地址", Type: meta.FieldString, Required: true, Placeholder: "http://localhost:3000"},
{Key: "token", Label: "Access Token", Type: meta.FieldPassword, Required: true, Secret: true},
},
}
}
func parseAccount(config string) (*OneBotAccount, error) {
var cfg OneBotAccount
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析OneBot账号配置失败: %v", err)
}
return &cfg, nil
}
+43 -1
View File
@@ -1,7 +1,15 @@
package onebot package onebot
const (
// AccountType OneBot 账号类型标识
AccountType = "onebot"
// RuleType 发送 OneBot 消息的规则类型标识
RuleType = "onebot"
)
type OneBotConfig struct { type OneBotConfig struct {
SendGroups []int `json:"send_groups"` SendGroups []int64 `json:"send_groups"` // 群号列表
SendUsers []int64 `json:"send_users"` // 好友 QQ 号列表
Msg string `json:"msg"` Msg string `json:"msg"`
} }
@@ -9,3 +17,37 @@ type OneBotAccount struct {
URL string `json:"url"` // 例如 "http://localhost:5700" URL string `json:"url"` // 例如 "http://localhost:5700"
Token string `json:"token"` // 例如 "your_token_here" Token string `json:"token"` // 例如 "your_token_here"
} }
// apiResponse OneBot HTTP API 的通用响应信封
type apiResponse struct {
Status string `json:"status"`
Retcode int `json:"retcode"`
Message string `json:"message"`
Wording string `json:"wording"`
}
// err 把 OneBot 返回的业务错误转换成 Go error
func (r *apiResponse) err() error {
if r.Status == "ok" && r.Retcode == 0 {
return nil
}
detail := r.Wording
if detail == "" {
detail = r.Message
}
if detail == "" {
detail = "请检查 OneBot 地址与 Token"
}
return &APIError{Status: r.Status, Retcode: r.Retcode, Detail: detail}
}
// APIError OneBot 返回的业务错误
type APIError struct {
Status string
Retcode int
Detail string
}
func (e *APIError) Error() string {
return "OneBot 返回错误(status=" + e.Status + "): " + e.Detail
}
+30 -21
View File
@@ -2,41 +2,50 @@ package onebot
import ( import (
"context" "context"
"log" "fmt"
"time" "strings"
"github.com/cenkalti/backoff/v5" "github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
func SendOneBotMsg(rule *model.Rule) { // SendOneBotMsg 向规则中配置的群和好友发送消息
oneBotConfig := getOneBotConfigFromRule(rule) func SendOneBotMsg(ctx context.Context, rule *model.Rule) error {
oneBotConfig, err := getOneBotConfigFromRule(rule)
if err != nil {
return err
}
oneBotAccount, err := getOneBotAccountFromRule(rule) oneBotAccount, err := getOneBotAccountFromRule(rule)
if err != nil { if err != nil {
log.Printf("获取OneBot账户失败: %v", err) return fmt.Errorf("获取OneBot账户失败: %w", err)
return
} }
var fails []string
total := 0
for _, groupID := range oneBotConfig.SendGroups { for _, groupID := range oneBotConfig.SendGroups {
config, err := configs.GetConfig() total++
err := retry.Do(ctx, func() error {
return sendGroupMsg(oneBotAccount, groupID, oneBotConfig.Msg)
})
if err != nil { if err != nil {
log.Printf("获取配置失败: %v", err) fails = append(fails, fmt.Sprintf("群 %d: %v", groupID, err))
return
} }
var timeout time.Duration = time.Duration(config.TimeoutDurationHours) * time.Hour
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
operation := func() (string, error) {
err := sendOneBotMsg(oneBotAccount, groupID, oneBotConfig.Msg)
return "", err
} }
_, err = backoff.Retry(ctx, operation, backoff.WithBackOff(backoff.NewExponentialBackOff())) for _, userID := range oneBotConfig.SendUsers {
total++
err := retry.Do(ctx, func() error {
return sendPrivateMsg(oneBotAccount, userID, oneBotConfig.Msg)
})
if err != nil { if err != nil {
log.Printf("发送OneBot消息失败: %v", err) fails = append(fails, fmt.Sprintf("好友 %d: %v", userID, err))
} }
} }
if len(fails) > 0 {
return fmt.Errorf("%d/%d 条消息发送失败: %s", len(fails), total, strings.Join(fails, "; "))
}
return nil
} }
+25 -3
View File
@@ -3,13 +3,15 @@ package onebot
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
) )
// OneBotRuleValidator OneBot规则验证器 // OneBotRuleValidator OneBot规则验证器
type OneBotRuleValidator struct{} type OneBotRuleValidator struct{}
func (v *OneBotRuleValidator) GetType() string { func (v *OneBotRuleValidator) GetType() string {
return "onebot" return RuleType
} }
func (v *OneBotRuleValidator) Validate(configJSON string) error { func (v *OneBotRuleValidator) Validate(configJSON string) error {
@@ -22,9 +24,29 @@ func (v *OneBotRuleValidator) Validate(configJSON string) error {
return fmt.Errorf("OneBot规则配置中 msg 不能为空") return fmt.Errorf("OneBot规则配置中 msg 不能为空")
} }
if len(config.SendGroups) == 0 { if len(config.SendGroups) == 0 && len(config.SendUsers) == 0 {
return fmt.Errorf("OneBot规则配置中 send_groups 不能为空") return fmt.Errorf("OneBot规则配置中 send_groups 与 send_users 不能同时为空")
}
for _, id := range append(append([]int64{}, config.SendGroups...), config.SendUsers...) {
if id <= 0 {
return fmt.Errorf("OneBot规则配置中存在非法的群号/QQ号: %d", id)
}
} }
return nil return nil
} }
func (v *OneBotRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: RuleType,
Label: "发送 QQ 消息",
Description: "触发时通过 OneBot 向指定的群或好友发送消息。",
AccountType: AccountType,
Fields: []meta.Field{
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true},
{Key: "send_groups", Label: "群号", Type: meta.FieldNumberList, Placeholder: "123456789"},
{Key: "send_users", Label: "好友 QQ 号", Type: meta.FieldNumberList, Placeholder: "123456789"},
},
}
}
+4 -5
View File
@@ -1,6 +1,7 @@
package onebot package onebot
import ( import (
"context"
"log" "log"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
@@ -10,13 +11,11 @@ import (
type OneBotExecutor struct{} type OneBotExecutor struct{}
func (e *OneBotExecutor) GetType() string { func (e *OneBotExecutor) GetType() string {
return "onebot" return RuleType
} }
func (e *OneBotExecutor) Execute(rule *model.Rule) error { func (e *OneBotExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行OneBot规则: %s (ID: %d)", rule.Name, rule.ID) log.Printf("执行OneBot规则: %s (ID: %d)", rule.Name, rule.ID)
SendOneBotMsg(rule) return SendOneBotMsg(ctx, rule)
return nil
} }
+34 -22
View File
@@ -2,46 +2,58 @@ package onebot
import ( import (
"encoding/json" "encoding/json"
"strconv" "fmt"
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
napcat "github.com/ssdomei232/napcat-http-go-sdk" napcat "github.com/ssdomei232/napcat-http-go-sdk"
) )
func newClient(account *OneBotAccount) *napcat.Client {
return napcat.NewClient(account.Token, account.URL)
}
// 从 Rule 中获取 OneBotConfig // 从 Rule 中获取 OneBotConfig
func getOneBotConfigFromRule(rule *model.Rule) *OneBotConfig { func getOneBotConfigFromRule(rule *model.Rule) (*OneBotConfig, error) {
var oneBotConfig OneBotConfig var oneBotConfig OneBotConfig
err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig) if err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig); err != nil {
if err != nil { return nil, fmt.Errorf("解析OneBot规则配置失败: %w", err)
return nil
} }
return &oneBotConfig return &oneBotConfig, nil
} }
// 从 Rule 中获取 OneBotAccount // 从 Rule 中获取 OneBotAccount
func getOneBotAccountFromRule(rule *model.Rule) (*OneBotAccount, error) { func getOneBotAccountFromRule(rule *model.Rule) (*OneBotAccount, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var oneBotAccountEntry model.Account
gormDB.Where("id = ?", rule.AccountID).First(&oneBotAccountEntry)
var oneBotAccount OneBotAccount var oneBotAccount OneBotAccount
err = json.Unmarshal([]byte(oneBotAccountEntry.Config), &oneBotAccount) if err := db.LoadAccountConfig(rule.AccountID, &oneBotAccount); err != nil {
if err != nil {
return nil, err return nil, err
} }
return &oneBotAccount, nil return &oneBotAccount, nil
} }
// 发送一条消息到OneBot // 发送一条群消息
func sendOneBotMsg(account *OneBotAccount, groupID int, msg string) error { //
client := napcat.NewClient(account.Token, account.URL) // SDK 只返回传输层错误,这里自己解析 OneBot 的响应信封,
// 否则 token 错误、群号不存在这类失败会被当成发送成功。
func sendGroupMsg(account *OneBotAccount, groupID int64, msg string) error {
return call(account, "/send_group_msg", map[string]any{
"group_id": groupID,
"message": msg,
})
}
err := client.SendGroupMsg(strconv.Itoa(groupID), msg) // 发送一条私聊消息
return err func sendPrivateMsg(account *OneBotAccount, userID int64, msg string) error {
return call(account, "/send_private_msg", map[string]any{
"user_id": userID,
"message": msg,
})
}
func call(account *OneBotAccount, endpoint string, payload map[string]any) error {
var resp apiResponse
if err := newClient(account).DoRequest("POST", endpoint, payload, &resp); err != nil {
return fmt.Errorf("请求 OneBot 失败: %w", err)
}
return resp.err()
} }
+9 -2
View File
@@ -8,12 +8,12 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3 github.com/cenkalti/backoff/v5 v5.0.3
github.com/gin-contrib/sessions v1.0.4 github.com/gin-contrib/sessions v1.0.4
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/glebarez/sqlite v1.11.0
github.com/google/go-github/v84 v84.0.0 github.com/google/go-github/v84 v84.0.0
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/ssdomei232/napcat-http-go-sdk v1.0.0 github.com/ssdomei232/napcat-http-go-sdk v1.0.0
github.com/wneessen/go-mail v0.7.2 github.com/wneessen/go-mail v0.7.2
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.48.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
) )
@@ -22,8 +22,10 @@ require (
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect
@@ -31,6 +33,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect github.com/google/go-querystring v1.2.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect github.com/gorilla/sessions v1.4.0 // indirect
@@ -41,13 +44,13 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/spf13/cast v1.9.2 // indirect github.com/spf13/cast v1.9.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -61,4 +64,8 @@ require (
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
) )
+21 -4
View File
@@ -17,6 +17,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
@@ -27,6 +29,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -53,6 +59,10 @@ github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJ
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
@@ -77,8 +87,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -94,6 +102,9 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -146,7 +157,13 @@ 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+24 -17
View File
@@ -10,9 +10,9 @@ import (
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// 定时检查所有 timers 是否需要被触发 // CheckTimers 定时检查所有 timers 是否需要被触发
// //
// 同时向即将被触发的 timers 发送通知 // 同时向即将到期的 timers 对应的用户发送提醒
func CheckTimers() { func CheckTimers() {
gormDB, err := db.GetGormDB() gormDB, err := db.GetGormDB()
if err != nil { if err != nil {
@@ -20,32 +20,39 @@ func CheckTimers() {
return return
} }
// 1. 获取所有 timers // 1. 获取所有启用中的 timers
var timers []model.Timer var timers []model.Timer
if err := gormDB.Find(&timers).Error; err != nil { if err := gormDB.Where("enabled = ?", true).Find(&timers).Error; err != nil {
log.Printf("failed to get timers: %v", err) log.Printf("failed to get timers: %v", err)
return return
} }
// 2. 检查每个 timer 是否需要被触发
for _, timer := range timers {
// 计算下次触发时间
nextSignTime := timer.LastSign + timer.SignDerationSeconds
// 计算提醒时间
remindTime := nextSignTime - timer.RemindTimeSeconds
// 获取当前时间
currentTime := time.Now().Unix() currentTime := time.Now().Unix()
if currentTime >= remindTime && currentTime < nextSignTime { // 2. 检查每个 timer 是否需要被提醒或触发
// 发送通知 for i := range timers {
reminder.Reminder(&timer) timer := timers[i]
// 已触发过的 timer 在用户重新签到之前不再处理
if timer.Triggered {
continue
} }
nextSignTime := timer.NextSignTime()
remindTime := timer.RemindAt()
if currentTime >= nextSignTime { if currentTime >= nextSignTime {
// 触发 timer
runner.Runner(&timer) runner.Runner(&timer)
continue
}
// 每个签到周期只提醒一次
if currentTime >= remindTime && timer.LastRemind < remindTime {
reminder.Reminder(&timer)
if err := gormDB.Model(&model.Timer{}).Where("id = ?", timer.ID).
Update("last_remind", currentTime).Error; err != nil {
log.Printf("更新 Timer(ID: %d) 提醒时间失败: %v", timer.ID, err)
}
} }
} }
} }
+33
View File
@@ -0,0 +1,33 @@
package db
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/model"
)
// LoadAccountConfig 读取账号并把 Config 字段反序列化到 out。
//
// 各个 driver 之前都各自实现了一遍这段逻辑,其中 github driver 甚至
// 直接把驱动内的结构体当成 gorm model 查询,这里统一收敛。
func LoadAccountConfig(accountID uint, out any) error {
if accountID == 0 {
return fmt.Errorf("该规则没有关联账号")
}
gormDB, err := GetGormDB()
if err != nil {
return err
}
var account model.Account
if err := gormDB.First(&account, accountID).Error; err != nil {
return fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
}
if err := json.Unmarshal([]byte(account.Config), out); err != nil {
return fmt.Errorf("解析账号(ID: %d)配置失败: %w", accountID, err)
}
return nil
}
+66 -4
View File
@@ -1,16 +1,78 @@
package db package db
import ( import (
"fmt"
"log" "log"
"sync"
"gorm.io/driver/sqlite" "github.com/glebarez/sqlite"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/model"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger"
) )
var (
once sync.Once
instance *gorm.DB
initErr error
)
// GetGormDB 返回全局唯一的 gorm 连接。
//
// 之前每次调用都会新开一个 sqlite 连接,在并发执行规则时很容易触发
// "database is locked",这里改为单例并开启 WAL。
func GetGormDB() (*gorm.DB, error) { func GetGormDB() (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open("file:data.db"), &gorm.Config{}) once.Do(func() {
instance, initErr = open()
})
return instance, initErr
}
// MustInit 在启动阶段初始化数据库并执行迁移,失败直接退出
func MustInit() *gorm.DB {
gormDB, err := GetGormDB()
if err != nil { if err != nil {
log.Fatal("打开数据库失败:", err) log.Fatalf("初始化数据库失败: %v", err)
} }
return db, nil if err := AutoMigrate(gormDB); err != nil {
log.Fatalf("数据库迁移失败: %v", err)
}
return gormDB
}
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),
})
if err != nil {
return nil, fmt.Errorf("打开数据库失败: %w", err)
}
sqlDB, err := gormDB.DB()
if err != nil {
return nil, err
}
// sqlite 单写多读,限制连接数避免写冲突
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
return gormDB, nil
}
// AutoMigrate 建表 / 补齐新增字段
func AutoMigrate(gormDB *gorm.DB) error {
return gormDB.AutoMigrate(
&model.User{},
&model.Timer{},
&model.Rule{},
&model.Account{},
&model.ExecutionLog{},
)
} }
+60
View File
@@ -0,0 +1,60 @@
// Package logstore 负责写入与清理规则执行日志
package logstore
import (
"log"
"time"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
// Record 写入一条执行日志,失败只记录到标准输出,不影响主流程
func Record(entry *model.ExecutionLog) {
gormDB, err := db.GetGormDB()
if err != nil {
log.Printf("写入执行日志失败: %v", err)
return
}
if entry.CreateAt == 0 {
entry.CreateAt = time.Now().Unix()
}
if err := gormDB.Create(entry).Error; err != nil {
log.Printf("写入执行日志失败: %v", err)
return
}
trim(entry.UID)
}
// trim 按配置保留每个用户最近的 N 条日志
func trim(uid uint) {
config, err := configs.GetConfig()
if err != nil || config.LogRetainCount <= 0 {
return
}
gormDB, err := db.GetGormDB()
if err != nil {
return
}
var cutoff model.ExecutionLog
err = gormDB.Where("uid = ?", uid).
Order("id DESC").
Offset(config.LogRetainCount).
Limit(1).
First(&cutoff).Error
if err != nil {
// 没有超出保留条数
return
}
if err := gormDB.Where("uid = ? AND id <= ?", uid, cutoff.ID).
Delete(&model.ExecutionLog{}).Error; err != nil {
log.Printf("清理执行日志失败: %v", err)
}
}
+30 -2
View File
@@ -1,14 +1,18 @@
package reminder package reminder
import ( import (
"errors"
"fmt"
"log" "log"
"time"
"github.com/ssdomei232/goodBaby/drivers/dingtalk" "github.com/ssdomei232/goodBaby/drivers/dingtalk"
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/handler/logstore"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// reminder 负责在 checker 检查到 timer 即将被触发时,向用户发送提醒 // Reminder 在 checker 检查到 timer 即将到期时,向用户发送提醒
func Reminder(timer *model.Timer) { func Reminder(timer *model.Timer) {
gormDB, err := db.GetGormDB() gormDB, err := db.GetGormDB()
if err != nil { if err != nil {
@@ -24,5 +28,29 @@ func Reminder(timer *model.Timer) {
} }
// 2. 向用户发送提醒 // 2. 向用户发送提醒
dingtalk.SendDingTalkMsg(&user, "您的 goodbaby timer 即将被触发", "您的 goodbaby timer 即将被触发") deadline := time.Unix(timer.NextSignTime(), 0).Format("2006-01-02 15:04:05")
title := fmt.Sprintf("goodBaby: %s 即将到期", timer.Name)
msg := fmt.Sprintf("### %s\n\n定时器 **%s** 将在 %s 到期。\n\n请及时签到,否则关联的规则会被执行。", title, timer.Name, deadline)
err = dingtalk.SendDingTalkMsg(&user, title, msg)
if errors.Is(err, dingtalk.ErrNoUserConfig) {
// 用户没有配置提醒渠道,不算失败
return
}
message := "提醒发送成功"
if err != nil {
message = err.Error()
log.Printf("发送提醒失败 [Timer ID: %d]: %v", timer.ID, err)
}
logstore.Record(&model.ExecutionLog{
UID: timer.UID,
TimerID: timer.ID,
RuleName: timer.Name,
RuleType: "reminder",
Trigger: model.TriggerRemind,
Success: err == nil,
Message: message,
})
} }
+17 -6
View File
@@ -1,9 +1,13 @@
package runner package runner
import ( import (
"context"
"fmt" "fmt"
"sort"
"sync"
"github.com/ssdomei232/goodBaby/drivers/bilibili" "github.com/ssdomei232/goodBaby/drivers/bilibili"
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
"github.com/ssdomei232/goodBaby/drivers/email" "github.com/ssdomei232/goodBaby/drivers/email"
"github.com/ssdomei232/goodBaby/drivers/github" "github.com/ssdomei232/goodBaby/drivers/github"
"github.com/ssdomei232/goodBaby/drivers/onebot" "github.com/ssdomei232/goodBaby/drivers/onebot"
@@ -12,8 +16,8 @@ import (
// RuleExecutor 规则执行器接口 // RuleExecutor 规则执行器接口
type RuleExecutor interface { type RuleExecutor interface {
// Execute 执行规则 // Execute 执行规则,ctx 决定了重试的最长时间
Execute(rule *model.Rule) error Execute(ctx context.Context, rule *model.Rule) error
// GetType 获取执行器支持的规则类型 // GetType 获取执行器支持的规则类型
GetType() string GetType() string
} }
@@ -36,13 +40,13 @@ func (er *ExecutorRegistry) Register(executor RuleExecutor) {
} }
// Execute 根据规则类型执行规则 // Execute 根据规则类型执行规则
func (er *ExecutorRegistry) Execute(rule *model.Rule) error { func (er *ExecutorRegistry) Execute(ctx context.Context, rule *model.Rule) error {
executor, exists := er.executors[rule.Type] executor, exists := er.executors[rule.Type]
if !exists { if !exists {
return fmt.Errorf("不支持的规则类型: %s", rule.Type) return fmt.Errorf("不支持的规则类型: %s", rule.Type)
} }
return executor.Execute(rule) return executor.Execute(ctx, rule)
} }
// GetSupportedTypes 获取所有支持的规则类型 // GetSupportedTypes 获取所有支持的规则类型
@@ -51,13 +55,18 @@ func (er *ExecutorRegistry) GetSupportedTypes() []string {
for t := range er.executors { for t := range er.executors {
types = append(types, t) types = append(types, t)
} }
sort.Strings(types)
return types return types
} }
var globalExecutorRegistry *ExecutorRegistry var (
registryOnce sync.Once
globalExecutorRegistry *ExecutorRegistry
)
// InitExecutorRegistry 初始化执行器注册表并注册所有执行器 // InitExecutorRegistry 初始化执行器注册表并注册所有执行器
func InitExecutorRegistry() *ExecutorRegistry { func InitExecutorRegistry() *ExecutorRegistry {
registryOnce.Do(func() {
registry := NewExecutorRegistry() registry := NewExecutorRegistry()
// 注册所有规则执行器 // 注册所有规则执行器
@@ -65,10 +74,12 @@ func InitExecutorRegistry() *ExecutorRegistry {
registry.Register(&email.EmailExecutor{}) registry.Register(&email.EmailExecutor{})
registry.Register(&github.GithubMakeRepoPublicExecutor{}) registry.Register(&github.GithubMakeRepoPublicExecutor{})
registry.Register(&onebot.OneBotExecutor{}) registry.Register(&onebot.OneBotExecutor{})
registry.Register(&dingtalk.DingTalkExecutor{})
// 未来添加新规则类型时,在这里注册即可 // 未来添加新规则类型时,在这里注册即可
globalExecutorRegistry = registry globalExecutorRegistry = registry
return registry })
return globalExecutorRegistry
} }
// GetGlobalExecutorRegistry 获取全局执行器注册表 // GetGlobalExecutorRegistry 获取全局执行器注册表
+56 -9
View File
@@ -1,13 +1,21 @@
package runner package runner
import ( import (
"context"
"log" "log"
"sync"
"time"
"github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/handler/logstore"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model" "github.com/ssdomei232/goodBaby/model"
) )
// runner 执行需要被执行的 Rule // Runner 执行某个 Timer 下所有启用的规则。
//
// 触发前先把 Timer 标记为已触发,避免规则执行期间(可能长达数小时)
// 被下一轮检查重复触发;用户重新签到后 Triggered 会被重置。
func Runner(timer *model.Timer) { func Runner(timer *model.Timer) {
gormDB, err := db.GetGormDB() gormDB, err := db.GetGormDB()
if err != nil { if err != nil {
@@ -17,22 +25,61 @@ func Runner(timer *model.Timer) {
// 1. 获取所有需要执行的 Rule // 1. 获取所有需要执行的 Rule
var rules []model.Rule var rules []model.Rule
if err := gormDB.Where("timer_id = ?", timer.ID).Find(&rules).Error; err != nil { if err := gormDB.Where("timer_id = ? AND enabled = ?", timer.ID, true).Find(&rules).Error; err != nil {
log.Printf("failed to get rules: %v", err) log.Printf("failed to get rules: %v", err)
return return
} }
// 2. 执行每个 Rule // 2. 标记已触发
for _, rule := range rules { now := time.Now().Unix()
go executeRule(&rule) if err := gormDB.Model(&model.Timer{}).Where("id = ?", timer.ID).
Updates(map[string]any{"triggered": true, "last_trigger": now}).Error; err != nil {
log.Printf("标记 Timer(ID: %d) 已触发失败: %v", timer.ID, err)
return
} }
log.Printf("Timer %s (ID: %d) 已到期,开始执行 %d 条规则", timer.Name, timer.ID, len(rules))
// 3. 并发执行每个 Rule
var wg sync.WaitGroup
for i := range rules {
rule := rules[i]
wg.Add(1)
go func() {
defer wg.Done()
ExecuteRule(&rule, model.TriggerTimer)
}()
}
wg.Wait()
} }
// executeRule 执行单个规则 // ExecuteRule 执行单个规则并记录执行日志
func executeRule(rule *model.Rule) { func ExecuteRule(rule *model.Rule, trigger string) error {
executorRegistry := GetGlobalExecutorRegistry() ctx, cancel := retry.ExecutionContext()
defer cancel()
return ExecuteRuleWithContext(ctx, rule, trigger)
}
if err := executorRegistry.Execute(rule); err != nil { // ExecuteRuleWithContext 使用给定 context 执行规则,手动测试时可传入较短的超时
func ExecuteRuleWithContext(ctx context.Context, rule *model.Rule, trigger string) error {
err := GetGlobalExecutorRegistry().Execute(ctx, rule)
message := "执行成功"
if err != nil {
message = err.Error()
log.Printf("执行规则失败 [ID: %d, Type: %s]: %v", rule.ID, rule.Type, err) log.Printf("执行规则失败 [ID: %d, Type: %s]: %v", rule.ID, rule.Type, err)
} }
logstore.Record(&model.ExecutionLog{
UID: rule.UID,
RuleID: rule.ID,
RuleName: rule.Name,
RuleType: rule.Type,
TimerID: rule.TimerID,
Trigger: trigger,
Success: err == nil,
Message: message,
})
return err
} }
+67 -1
View File
@@ -3,14 +3,25 @@ package accountConfigChecker
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort"
"github.com/ssdomei232/goodBaby/internal/meta"
) )
// AccountValidator 账号验证器接口 // AccountValidator 账号验证器接口
type AccountValidator interface { type AccountValidator interface {
// Validate 验证账号配置是否合法 // Validate 验证账号配置是否合法
Validate(account string) error Validate(config string) error
// GetType 获取验证器支持的账号类型 // GetType 获取验证器支持的账号类型
GetType() string GetType() string
// Meta 返回给 WebUI 渲染表单用的元数据
Meta() meta.AccountMeta
}
// AccountTester 可选接口:支持真实连通性测试的账号类型实现它
type AccountTester interface {
// Test 使用给定配置访问一次第三方服务,验证凭据是否可用
Test(config string) error
} }
// ValidatorRegistry 验证器注册表 // ValidatorRegistry 验证器注册表
@@ -51,11 +62,66 @@ func (vr *ValidatorRegistry) Validate(accountType string, config string) error {
return validator.Validate(config) return validator.Validate(config)
} }
// Test 对账号配置做一次真实的连通性测试
func (vr *ValidatorRegistry) Test(accountType string, config string) error {
validator, exists := vr.validators[accountType]
if !exists {
return fmt.Errorf("不支持的账号类型: %s", accountType)
}
if err := vr.Validate(accountType, config); err != nil {
return err
}
tester, ok := validator.(AccountTester)
if !ok {
return fmt.Errorf("账号类型 %s 暂不支持连通性测试", accountType)
}
return tester.Test(config)
}
// GetSupportedTypes 获取所有支持的账号类型 // GetSupportedTypes 获取所有支持的账号类型
func (vr *ValidatorRegistry) GetSupportedTypes() []string { func (vr *ValidatorRegistry) GetSupportedTypes() []string {
types := make([]string, 0, len(vr.validators)) types := make([]string, 0, len(vr.validators))
for t := range vr.validators { for t := range vr.validators {
types = append(types, t) types = append(types, t)
} }
sort.Strings(types)
return types return types
} }
// Metas 返回所有账号类型的元数据,按类型名排序
func (vr *ValidatorRegistry) Metas() []meta.AccountMeta {
metas := make([]meta.AccountMeta, 0, len(vr.validators))
for _, v := range vr.validators {
metas = append(metas, withTestable(v))
}
sort.Slice(metas, func(i, j int) bool { return metas[i].Type < metas[j].Type })
return metas
}
// MetaOf 返回指定账号类型的元数据
func (vr *ValidatorRegistry) MetaOf(accountType string) (meta.AccountMeta, bool) {
v, ok := vr.validators[accountType]
if !ok {
return meta.AccountMeta{}, false
}
return withTestable(v), true
}
// FieldsOf 返回指定账号类型的字段描述,类型不存在时返回 nil
func (vr *ValidatorRegistry) FieldsOf(accountType string) []meta.Field {
m, ok := vr.MetaOf(accountType)
if !ok {
return nil
}
return m.Fields
}
func withTestable(v AccountValidator) meta.AccountMeta {
m := v.Meta()
if _, ok := v.(AccountTester); ok {
m.Testable = true
}
return m
}
+16 -5
View File
@@ -1,20 +1,31 @@
package accountConfigChecker package accountConfigChecker
import ( import (
"sync"
"github.com/ssdomei232/goodBaby/drivers/bilibili" "github.com/ssdomei232/goodBaby/drivers/bilibili"
"github.com/ssdomei232/goodBaby/drivers/email" "github.com/ssdomei232/goodBaby/drivers/email"
"github.com/ssdomei232/goodBaby/drivers/github" "github.com/ssdomei232/goodBaby/drivers/github"
"github.com/ssdomei232/goodBaby/drivers/onebot" "github.com/ssdomei232/goodBaby/drivers/onebot"
) )
var (
once sync.Once
registry *ValidatorRegistry
)
// InitValidatorRegistry 返回全局唯一的账号验证器注册表
func InitValidatorRegistry() *ValidatorRegistry { func InitValidatorRegistry() *ValidatorRegistry {
registry := NewValidatorRegistry() once.Do(func() {
r := NewValidatorRegistry()
// 注册所有账号验证器 // 注册所有账号验证器
registry.Register(&bilibili.BilibiliAccountConfigValidator{}) r.Register(&bilibili.BilibiliAccountConfigValidator{})
registry.Register(&email.EmailAccountConfigValidator{}) r.Register(&email.EmailAccountConfigValidator{})
registry.Register(&github.GitHubAccountConfigValidator{}) r.Register(&github.GitHubAccountConfigValidator{})
registry.Register(&onebot.OneBotAccountConfigValidator{}) r.Register(&onebot.OneBotAccountConfigValidator{})
registry = r
})
return registry return registry
} }
+70
View File
@@ -0,0 +1,70 @@
package meta
import "encoding/json"
// MaskPlaceholder 是掩码后的占位值。
// 提交编辑时如果某个 secret 字段仍是这个值,服务端会保留原值。
const MaskPlaceholder = "********"
// Mask 把配置 JSON 中的敏感字段替换为占位符
func Mask(configJSON string, fields []Field) string {
secrets := SecretKeys(fields)
if len(secrets) == 0 || configJSON == "" {
return configJSON
}
var raw map[string]any
if err := json.Unmarshal([]byte(configJSON), &raw); err != nil {
return configJSON
}
for _, key := range secrets {
if v, ok := raw[key]; ok {
if s, isStr := v.(string); !isStr || s == "" {
continue
}
raw[key] = MaskPlaceholder
}
}
masked, err := json.Marshal(raw)
if err != nil {
return configJSON
}
return string(masked)
}
// Unmask 用旧配置补回被掩码的字段,返回可以落库的配置 JSON
func Unmask(newConfigJSON, oldConfigJSON string, fields []Field) string {
secrets := SecretKeys(fields)
if len(secrets) == 0 || newConfigJSON == "" || oldConfigJSON == "" {
return newConfigJSON
}
var newRaw, oldRaw map[string]any
if err := json.Unmarshal([]byte(newConfigJSON), &newRaw); err != nil {
return newConfigJSON
}
if err := json.Unmarshal([]byte(oldConfigJSON), &oldRaw); err != nil {
return newConfigJSON
}
restored := false
for _, key := range secrets {
if s, ok := newRaw[key].(string); ok && s == MaskPlaceholder {
if old, exists := oldRaw[key]; exists {
newRaw[key] = old
restored = true
}
}
}
if !restored {
return newConfigJSON
}
merged, err := json.Marshal(newRaw)
if err != nil {
return newConfigJSON
}
return string(merged)
}
+63
View File
@@ -0,0 +1,63 @@
// Package meta 定义驱动向前端暴露的元数据。
//
// WebUI 依靠这些描述动态渲染账号 / 规则的配置表单,
// 新增一个驱动时只要提供 Meta(),前端无需改动。
package meta
// FieldType 决定前端使用哪种输入控件
type FieldType string
const (
FieldString FieldType = "string" // 单行文本
FieldPassword FieldType = "password" // 密码框
FieldTextarea FieldType = "textarea" // 多行文本
FieldNumber FieldType = "number" // 数字
FieldStringList FieldType = "string-list" // 字符串数组
FieldNumberList FieldType = "number-list" // 数字数组
FieldBool FieldType = "bool" // 开关
)
// Field 描述配置 JSON 中的一个字段
type Field struct {
Key string `json:"key"`
Label string `json:"label"`
Type FieldType `json:"type"`
Required bool `json:"required"`
Placeholder string `json:"placeholder,omitempty"`
Help string `json:"help,omitempty"`
// Secret 为 true 的字段在读取接口中会被掩码
Secret bool `json:"secret,omitempty"`
Default any `json:"default,omitempty"`
}
// AccountMeta 账号类型的元数据
type AccountMeta struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Docs string `json:"docs,omitempty"`
Testable bool `json:"testable"`
Fields []Field `json:"fields"`
}
// RuleMeta 规则类型的元数据
type RuleMeta struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Docs string `json:"docs,omitempty"`
// AccountType 该规则需要的账号类型,为空表示不需要关联账号
AccountType string `json:"account_type"`
Fields []Field `json:"fields"`
}
// SecretKeys 返回需要掩码的字段名
func SecretKeys(fields []Field) []string {
keys := make([]string, 0, len(fields))
for _, f := range fields {
if f.Secret {
keys = append(keys, f.Key)
}
}
return keys
}
+46
View File
@@ -0,0 +1,46 @@
// Package retry 封装规则执行使用的指数退避重试
package retry
import (
"context"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/ssdomei232/goodBaby/configs"
)
// 手动测试时使用的超时,避免用户在 WebUI 上等待数小时
const TestTimeout = 30 * time.Second
// Do 在 ctx 允许的时间内按指数退避执行 op
//
// 超时后返回最后一次真实的业务错误,而不是干巴巴的 context deadline exceeded。
func Do(ctx context.Context, op func() error) error {
var lastErr error
_, err := backoff.Retry(ctx, func() (struct{}, error) {
e := op()
if e != nil {
lastErr = e
}
return struct{}{}, e
}, backoff.WithBackOff(backoff.NewExponentialBackOff()))
if err != nil && lastErr != nil {
return lastErr
}
return err
}
// ExecutionTimeout 返回配置中的规则执行超时时间
func ExecutionTimeout() time.Duration {
config, err := configs.GetConfig()
if err != nil || config.TimeoutDurationHours <= 0 {
return 6 * time.Hour
}
return time.Duration(config.TimeoutDurationHours) * time.Hour
}
// ExecutionContext 返回一个带规则执行超时的 context
func ExecutionContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), ExecutionTimeout())
}
+25
View File
@@ -3,6 +3,9 @@ package ruleConfigChecker
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort"
"github.com/ssdomei232/goodBaby/internal/meta"
) )
// RuleValidator 规则验证器接口 // RuleValidator 规则验证器接口
@@ -11,6 +14,8 @@ type RuleValidator interface {
Validate(configJSON string) error Validate(configJSON string) error
// GetType 获取验证器支持的规则类型 // GetType 获取验证器支持的规则类型
GetType() string GetType() string
// Meta 返回给 WebUI 渲染表单用的元数据
Meta() meta.RuleMeta
} }
// ValidatorRegistry 验证器注册表 // ValidatorRegistry 验证器注册表
@@ -57,5 +62,25 @@ func (vr *ValidatorRegistry) GetSupportedTypes() []string {
for t := range vr.validators { for t := range vr.validators {
types = append(types, t) types = append(types, t)
} }
sort.Strings(types)
return types return types
} }
// Metas 返回所有规则类型的元数据,按类型名排序
func (vr *ValidatorRegistry) Metas() []meta.RuleMeta {
metas := make([]meta.RuleMeta, 0, len(vr.validators))
for _, v := range vr.validators {
metas = append(metas, v.Meta())
}
sort.Slice(metas, func(i, j int) bool { return metas[i].Type < metas[j].Type })
return metas
}
// MetaOf 返回指定规则类型的元数据
func (vr *ValidatorRegistry) MetaOf(ruleType string) (meta.RuleMeta, bool) {
v, ok := vr.validators[ruleType]
if !ok {
return meta.RuleMeta{}, false
}
return v.Meta(), true
}
+17 -7
View File
@@ -1,6 +1,8 @@
package ruleConfigChecker package ruleConfigChecker
import ( import (
"sync"
"github.com/ssdomei232/goodBaby/drivers/bilibili" "github.com/ssdomei232/goodBaby/drivers/bilibili"
"github.com/ssdomei232/goodBaby/drivers/dingtalk" "github.com/ssdomei232/goodBaby/drivers/dingtalk"
"github.com/ssdomei232/goodBaby/drivers/email" "github.com/ssdomei232/goodBaby/drivers/email"
@@ -8,16 +10,24 @@ import (
"github.com/ssdomei232/goodBaby/drivers/onebot" "github.com/ssdomei232/goodBaby/drivers/onebot"
) )
// InitValidatorRegistry 初始化验证器注册表并注册所有验证器 var (
once sync.Once
registry *ValidatorRegistry
)
// InitValidatorRegistry 返回全局唯一的规则验证器注册表
func InitValidatorRegistry() *ValidatorRegistry { func InitValidatorRegistry() *ValidatorRegistry {
registry := NewValidatorRegistry() once.Do(func() {
r := NewValidatorRegistry()
// 注册所有规则验证器 // 注册所有规则验证器
registry.Register(&bilibili.BilibiliDynamicRuleValidator{}) r.Register(&bilibili.BilibiliDynamicRuleValidator{})
registry.Register(&email.EmailRuleValidator{}) r.Register(&email.EmailRuleValidator{})
registry.Register(&onebot.OneBotRuleValidator{}) r.Register(&onebot.OneBotRuleValidator{})
registry.Register(&dingtalk.DingTalkRuleValidator{}) r.Register(&dingtalk.DingTalkRuleValidator{})
registry.Register(&github.GithubMakeRepositoryPublicRuleValidator{}) r.Register(&github.GithubMakeRepositoryPublicRuleValidator{})
registry = r
})
return registry return registry
} }
+98 -10
View File
@@ -1,33 +1,71 @@
package main package main
import ( import (
"crypto/rand"
"fmt" "fmt"
"log"
"net/http"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie" "github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
"github.com/ssdomei232/goodBaby/api/account" "github.com/ssdomei232/goodBaby/api/account"
"github.com/ssdomei232/goodBaby/api/dashboard"
apilog "github.com/ssdomei232/goodBaby/api/log"
apimeta "github.com/ssdomei232/goodBaby/api/meta"
"github.com/ssdomei232/goodBaby/api/rule" "github.com/ssdomei232/goodBaby/api/rule"
"github.com/ssdomei232/goodBaby/api/timer"
"github.com/ssdomei232/goodBaby/api/user" "github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/handler/checker" "github.com/ssdomei232/goodBaby/handler/checker"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/handler/runner" "github.com/ssdomei232/goodBaby/handler/runner"
"github.com/ssdomei232/goodBaby/web"
)
var (
version = "dev"
buildDate = "unknown"
gitCommit = "unknown"
) )
func main() { func main() {
log.Printf("goodBaby %s (%s, %s) 启动中...", version, gitCommit, buildDate)
config := configs.MustGetConfig()
// 初始化数据库(建表/迁移)与执行器
db.MustInit()
runner.InitExecutorRegistry() runner.InitExecutorRegistry()
// 启动定时检查
c := cron.New() c := cron.New()
c.AddFunc("@every 10m", checker.CheckTimers) spec := fmt.Sprintf("@every %dm", config.CheckIntervalMinutes)
if _, err := c.AddFunc(spec, checker.CheckTimers); err != nil {
log.Fatalf("注册定时任务失败: %v", err)
}
c.Start() c.Start()
r := gin.Default() r := gin.Default()
store := cookie.NewStore(generateRandomKey(32))
// 持久化的 session 密钥:重启后已登录用户不会掉线
store := cookie.NewStore([]byte(config.SessionSecret))
store.Options(sessions.Options{
Path: "/",
MaxAge: config.SessionMaxAgeHours * 3600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
r.Use(sessions.Sessions("goodbaby-session", store)) r.Use(sessions.Sessions("goodbaby-session", store))
// 前端本地开发时的 CORS 支持
if len(config.AllowedOrigins) > 0 {
r.Use(corsMiddleware(config.AllowedOrigins))
}
v1 := r.Group("/api/v1") v1 := r.Group("/api/v1")
{ {
v1.GET("/site", apimeta.HandleGetSiteInfo)
v1.POST("/user/registry", user.HandleRegistry) v1.POST("/user/registry", user.HandleRegistry)
v1.POST("/user/login", user.HandleLogin) v1.POST("/user/login", user.HandleLogin)
} }
@@ -36,9 +74,28 @@ func main() {
authorized := v1.Group("/") authorized := v1.Group("/")
authorized.Use(user.AuthMiddleware()) authorized.Use(user.AuthMiddleware())
{ {
authorized.GET("/providers", apimeta.HandleGetProviders)
authorized.GET("/dashboard", dashboard.HandleGetOverview)
users := authorized.Group("/user") users := authorized.Group("/user")
{ {
users.GET("/info", user.HandleGetUserInfo) users.GET("/info", user.HandleGetUserInfo)
users.POST("/logout", user.HandleLogout)
users.POST("/password", user.HandleChangePassword)
users.PUT("/notify", user.HandleUpdateNotifyConfig)
}
timers := authorized.Group("/timers")
{
timers.GET("/", timer.HandleGetAllTimers)
timers.POST("/", timer.HandleCreateTimer)
timers.POST("/sign", timer.HandleSignAll)
timers.GET("/:timerID", timer.HandleGetTimer)
timers.PUT("/:timerID", timer.HandleEditTimer)
timers.POST("/:timerID/sign", timer.HandleSignTimer)
timers.POST("/:timerID/trigger", timer.HandleTriggerTimer)
timers.GET("/:timerID/check", timer.HandleCheckDeleteTimer)
timers.DELETE("/:timerID", timer.HandleDeleteTimer)
} }
rules := authorized.Group("/rules") rules := authorized.Group("/rules")
@@ -46,6 +103,7 @@ func main() {
rules.GET("/", rule.HandleGetAllRules) rules.GET("/", rule.HandleGetAllRules)
rules.POST("/", rule.HandleCreateRule) rules.POST("/", rule.HandleCreateRule)
rules.PUT("/:ruleID", rule.HandleEditRule) rules.PUT("/:ruleID", rule.HandleEditRule)
rules.POST("/:ruleID/test", rule.HandleTestRule)
rules.DELETE("/:ruleID", rule.HandleDeleteRule) rules.DELETE("/:ruleID", rule.HandleDeleteRule)
} }
@@ -53,19 +111,49 @@ func main() {
{ {
accounts.GET("/", account.HandleGetAllAccounts) accounts.GET("/", account.HandleGetAllAccounts)
accounts.POST("/", account.HandleAddAccount) accounts.POST("/", account.HandleAddAccount)
accounts.PUT("/:accountID", account.HandleEditAccount)
accounts.POST("/:accountID/test", account.HandleTestAccount)
accounts.GET("/:accountID/check", account.HandleCheckDeleteAccount) accounts.GET("/:accountID/check", account.HandleCheckDeleteAccount)
accounts.DELETE("/:accountID", account.HandleDeleteAccount) accounts.DELETE("/:accountID", account.HandleDeleteAccount)
} }
logs := authorized.Group("/logs")
{
logs.GET("/", apilog.HandleGetLogs)
logs.DELETE("/", apilog.HandleClearLogs)
}
} }
r.Run(":8088") // 挂载内嵌前端
web.Register(r)
log.Printf("goodBaby 正在监听 %s", config.ListenAddr)
if err := r.Run(config.ListenAddr); err != nil {
log.Fatalf("启动 HTTP 服务失败: %v", err)
}
} }
func generateRandomKey(length int) []byte { // corsMiddleware 允许配置中的来源跨域访问(携带 Cookie)
key := make([]byte, length) func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
_, err := rand.Read(key) allowed := make(map[string]struct{}, len(allowedOrigins))
if err != nil { for _, origin := range allowedOrigins {
panic(fmt.Sprintf("无法生成随机密钥: %v", err)) allowed[origin] = struct{}{}
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if _, ok := allowed[origin]; ok {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type")
c.Header("Vary", "Origin")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
} }
return key
} }
+28 -2
View File
@@ -1,9 +1,35 @@
package model package model
// Account 保存执行规则时需要用到的第三方凭据
type Account struct { type Account struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UID uint `json:"uid"` UID uint `gorm:"index" json:"uid"`
Name string `json:"name"` // 账号名称 Name string `json:"name"` // 账号名称
Type string `json:"type"` // 账号类型,如 "bilibili" Type string `json:"type"` // 账号类型,如 "bilibili"
Config string `json:"config"` // 存储账号相关配置,如cookie等 Config string `json:"config"` // 存储账号相关配置,如 cookie 等
CreateAt int64 `json:"create_at"`
}
// AccountRequest 创建/编辑账号的请求体
type AccountRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Config string `json:"config"`
}
// Validate 校验账号请求中与类型无关的通用部分
func (r *AccountRequest) Validate() error {
if r.Name == "" {
return ErrValidation("账号名称不能为空")
}
if len(r.Name) > 64 {
return ErrValidation("账号名称过长")
}
if r.Type == "" {
return ErrValidation("账号类型不能为空")
}
if r.Config == "" {
return ErrValidation("账号配置不能为空")
}
return nil
} }
+11
View File
@@ -0,0 +1,11 @@
package model
// ValidationError 表示一个可以直接展示给用户的参数校验错误
type ValidationError struct {
Msg string
}
func (e ValidationError) Error() string { return e.Msg }
// ErrValidation 构造一个参数校验错误
func ErrValidation(msg string) error { return ValidationError{Msg: msg} }
+22
View File
@@ -0,0 +1,22 @@
package model
// 执行日志的触发来源
const (
TriggerTimer = "timer" // Timer 到期自动触发
TriggerManual = "manual" // 用户在 WebUI 手动测试
TriggerRemind = "remind" // 提醒通知
)
// ExecutionLog 记录每一次规则执行/提醒的结果,供 WebUI 展示
type ExecutionLog struct {
ID uint `gorm:"primaryKey" json:"id"`
UID uint `gorm:"index" json:"uid"`
RuleID uint `json:"rule_id"`
RuleName string `json:"rule_name"`
RuleType string `json:"rule_type"`
TimerID uint `json:"timer_id"`
Trigger string `json:"trigger"`
Success bool `json:"success"`
Message string `json:"message"`
CreateAt int64 `gorm:"index" json:"create_at"`
}
+38 -2
View File
@@ -1,11 +1,47 @@
package model package model
// Rule 描述一个 Timer 到期后要执行的动作
type Rule struct { type Rule struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
UID uint `json:"uid"` UID uint `gorm:"index" json:"uid"`
TimerID uint `json:"timer_id"` TimerID uint `gorm:"index" json:"timer_id"`
Name string `json:"name"` Name string `json:"name"`
// 关联账号,部分规则类型(如钉钉)不需要账号,此时为 0
AccountID uint `json:"account_id"`
// 规则类型,见 internal/ruleConfigChecker 注册表
Type string `json:"type"`
// 规则配置,JSON 字符串
ConfigJson string `json:"config_json"`
Enabled bool `gorm:"default:true" json:"enabled"`
CreateAt int64 `json:"create_at"`
}
// RuleRequest 创建/编辑规则的请求体
type RuleRequest struct {
Name string `json:"name"`
TimerID uint `json:"timer_id"`
AccountID uint `json:"account_id"` AccountID uint `json:"account_id"`
Type string `json:"type"` Type string `json:"type"`
ConfigJson string `json:"config_json"` ConfigJson string `json:"config_json"`
Enabled *bool `json:"enabled"`
}
// Validate 校验规则请求中与类型无关的通用部分
func (r *RuleRequest) Validate() error {
if r.Name == "" {
return ErrValidation("规则名称不能为空")
}
if len(r.Name) > 64 {
return ErrValidation("规则名称过长")
}
if r.Type == "" {
return ErrValidation("规则类型不能为空")
}
if r.TimerID == 0 {
return ErrValidation("必须关联一个 Timer")
}
if r.ConfigJson == "" {
return ErrValidation("规则配置不能为空")
}
return nil
} }
+56 -2
View File
@@ -1,10 +1,64 @@
package model package model
// Timer 是摇篮系统的核心:用户需要在 SignDerationSeconds 内完成一次签到,
// 否则挂在这个 Timer 下的所有规则都会被触发。
type Timer struct { type Timer struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UID uint `json:"uid"` UID uint `gorm:"index" json:"uid"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"`
// 是否启用,停用后不再提醒也不会触发
Enabled bool `gorm:"default:true" json:"enabled"`
// 签到有效期(秒),超过这个时间没有签到就会触发规则
SignDerationSeconds int64 `json:"sign_deration_seconds"` SignDerationSeconds int64 `json:"sign_deration_seconds"`
// 上次签到时间(Unix 秒)
LastSign int64 `json:"last_sign"` LastSign int64 `json:"last_sign"`
RemindTimeSeconds int64 `json:"remind_time_seconds"` // 提前多少秒提醒 // 提前多少秒提醒
RemindTimeSeconds int64 `json:"remind_time_seconds"`
// 上次发送提醒时对应的签到周期,用于避免重复提醒
LastRemind int64 `json:"last_remind"`
// 是否已经触发过,触发后进入静默状态直到用户重新签到
Triggered bool `json:"triggered"`
// 上次触发时间
LastTrigger int64 `json:"last_trigger"`
CreateAt int64 `json:"create_at"`
}
// NextSignTime 下次必须完成签到的时间点
func (t *Timer) NextSignTime() int64 {
return t.LastSign + t.SignDerationSeconds
}
// RemindAt 应该发送提醒的时间点
func (t *Timer) RemindAt() int64 {
return t.NextSignTime() - t.RemindTimeSeconds
}
// TimerRequest 创建/编辑 Timer 的请求体
type TimerRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Enabled *bool `json:"enabled"`
SignDerationSeconds int64 `json:"sign_deration_seconds"`
RemindTimeSeconds int64 `json:"remind_time_seconds"`
}
// Validate 校验 Timer 请求参数
func (r *TimerRequest) Validate() error {
if r.Name == "" {
return ErrValidation("Timer 名称不能为空")
}
if len(r.Name) > 64 {
return ErrValidation("Timer 名称过长")
}
if r.SignDerationSeconds < 60 {
return ErrValidation("签到周期不能小于 60 秒")
}
if r.RemindTimeSeconds < 0 {
return ErrValidation("提醒提前量不能为负数")
}
if r.RemindTimeSeconds >= r.SignDerationSeconds {
return ErrValidation("提醒提前量必须小于签到周期")
}
return nil
} }
+14 -21
View File
@@ -1,21 +1,29 @@
package model package model
import ( import "fmt"
"fmt"
"github.com/ssdomei232/goodBaby/handler/db"
)
type UserRegistryReuest struct { type UserRegistryReuest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
} }
// ChangePasswordRequest 修改密码请求
type ChangePasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
// NotifyConfigRequest 修改提醒渠道配置的请求
type NotifyConfigRequest struct {
DingTalkConfig *string `json:"dingtalk_config"`
}
type User struct { type User struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
CreateAt int64 `json:"create_at"` CreateAt int64 `json:"create_at"`
Username string `json:"username"` Username string `gorm:"uniqueIndex" json:"username"`
Password string `json:"-"` Password string `json:"-"`
// 钉钉机器人配置(JSON 字符串),用于接收提醒
DingTalkConfig *string `json:"dingtalk_config"` DingTalkConfig *string `json:"dingtalk_config"`
} }
@@ -31,18 +39,3 @@ func (u *User) IsValid() error {
} }
return nil return nil
} }
func (u *User) IsExist() bool {
db, err := db.GetGormDB()
if err != nil {
return false
}
var count int64
result := db.Where("username = ?", u.Username).Count(&count)
if result.Error != nil {
return false
}
return count > 0
}
-102
View File
@@ -1,102 +0,0 @@
package github
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
)
// RepositoryVisibility 用于更新仓库可见性的请求体
type RepositoryVisibility struct {
Private bool `json:"private"`
}
// MakeRepositoryPublic 将指定的GitHub仓库从私有设置为公开
func MakeRepositoryPublic(owner, repo, token string) error {
// GitHub API URL for updating repository visibility
url := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
// 创建请求体,将 private 设置为 false 表示公开仓库
visibility := RepositoryVisibility{
Private: false, // false 表示公开仓库
}
// 将请求体序列化为 JSON
jsonData, err := json.Marshal(visibility)
if err != nil {
return fmt.Errorf("序列化请求体失败: %v", err)
}
// 创建 PATCH 请求
req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 设置请求头
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("Content-Type", "application/json")
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("请求失败,状态码: %d", resp.StatusCode)
}
log.Printf("仓库 %s/%s 已成功设置为公开", owner, repo)
return nil
}
// MakeRepositoryPrivate 将指定的GitHub仓库从公开设置为私有(附加功能)
func MakeRepositoryPrivate(owner, repo, token string) error {
// GitHub API URL for updating repository visibility
url := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
// 创建请求体,将 private 设置为 true 表示私有仓库
visibility := RepositoryVisibility{
Private: true, // true 表示私有仓库
}
// 将请求体序列化为 JSON
jsonData, err := json.Marshal(visibility)
if err != nil {
return fmt.Errorf("序列化请求体失败: %v", err)
}
// 创建 PATCH 请求
req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 设置请求头
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("Content-Type", "application/json")
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("请求失败,状态码: %d", resp.StatusCode)
}
log.Printf("仓库 %s/%s 已成功设置为私有", owner, repo)
return nil
}
+49
View File
@@ -0,0 +1,49 @@
// Package web 把前端构建产物嵌入到二进制中并挂载到 gin
package web
import (
"embed"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed all:dist
var distFS embed.FS
// Register 把前端静态资源挂到根路径。
//
// 未匹配任何 API 路由的 GET 请求回退到 index.html,交给前端路由处理。
func Register(r *gin.Engine) {
dist, err := fs.Sub(distFS, "dist")
if err != nil {
panic(err)
}
fileServer := http.FileServer(http.FS(dist))
r.NoRoute(func(c *gin.Context) {
// API 404 保持 JSON 返回
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"code": http.StatusNotFound, "data": "接口不存在"})
return
}
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
c.Status(http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(dist, path); err != nil {
// SPA 回退:交给 index.html 处理前端路由
c.Request.URL.Path = "/"
}
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='6' fill='%2366ccff'/%3E%3Cg stroke='white' stroke-width='2' stroke-linecap='round' fill='none'%3E%3Cpath d='M8.6 15.4a4.8 4.8 0 0 1 0-6.8'/%3E%3Cpath d='M15.4 8.6a4.8 4.8 0 0 1 0 6.8'/%3E%3C/g%3E%3Ccircle cx='12' cy='12' r='2.2' fill='white'/%3E%3C/svg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>goodBaby 摇篮系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1901
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"vue": "^3.5.39",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vue-tsc": "^3.3.5"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+6
View File
@@ -0,0 +1,6 @@
<script setup lang="ts">
</script>
<template>
<RouterView />
</template>
+56
View File
@@ -0,0 +1,56 @@
import axios, { AxiosError } from 'axios'
import type { ApiResponse } from './types'
/** 业务错误:后端返回了非 200 的 code,message 可直接展示给用户 */
export class ApiError extends Error {
code: number
constructor(code: number, message: string) {
super(message)
this.code = code
}
}
const http = axios.create({
baseURL: '/api/v1',
timeout: 60_000,
withCredentials: true,
})
/** 未登录时的回调,由 router 注入,跳转到登录页 */
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler
}
async function request<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> {
try {
const response = await promise
return response.data.data
} catch (error) {
const axiosError = error as AxiosError<ApiResponse<string>>
const status = axiosError.response?.status ?? 0
const data = axiosError.response?.data
if (status === 401) {
onUnauthorized?.()
}
const message =
typeof data?.data === 'string' && data.data !== ''
? data.data
: axiosError.message || '网络错误'
throw new ApiError(status, message)
}
}
export const api = {
get: <T>(url: string, params?: Record<string, unknown>) =>
request<T>(http.get<ApiResponse<T>>(url, { params })),
post: <T>(url: string, body?: unknown) =>
request<T>(http.post<ApiResponse<T>>(url, body)),
put: <T>(url: string, body?: unknown) =>
request<T>(http.put<ApiResponse<T>>(url, body)),
delete: <T>(url: string) => request<T>(http.delete<ApiResponse<T>>(url)),
}
+82
View File
@@ -0,0 +1,82 @@
import { api } from './client'
import type {
Account,
AccountRequest,
DashboardOverview,
ExecutionLog,
LogPage,
Providers,
Rule,
RuleRequest,
SiteInfo,
Timer,
TimerRequest,
UserInfo,
} from './types'
export const siteApi = {
info: () => api.get<SiteInfo>('/site'),
}
export const userApi = {
register: (username: string, password: string) =>
api.post<string>('/user/registry', { username, password }),
login: (username: string, password: string) =>
api.post<string>('/user/login', { username, password }),
logout: () => api.post<string>('/user/logout'),
info: () => api.get<UserInfo>('/user/info'),
changePassword: (oldPassword: string, newPassword: string) =>
api.post<string>('/user/password', {
old_password: oldPassword,
new_password: newPassword,
}),
updateNotify: (dingtalkConfig: string | null) =>
api.put<string>('/user/notify', { dingtalk_config: dingtalkConfig }),
}
export const dashboardApi = {
overview: () => api.get<DashboardOverview>('/dashboard'),
}
export const providerApi = {
all: () => api.get<Providers>('/providers'),
}
export const timerApi = {
list: () => api.get<Timer[]>('/timers/'),
get: (id: number) => api.get<{ timer: Timer; rule_count: number }>(`/timers/${id}`),
create: (body: TimerRequest) => api.post<Timer>('/timers/', body),
update: (id: number, body: TimerRequest) => api.put<Timer>(`/timers/${id}`, body),
sign: (id: number) => api.post<Timer>(`/timers/${id}/sign`),
signAll: () => api.post<{ signed: number; last_sign: number }>('/timers/sign'),
trigger: (id: number) => api.post<{ total: number; failed: string[] }>(`/timers/${id}/trigger`),
checkDelete: (id: number) => api.get<Rule[]>(`/timers/${id}/check`),
remove: (id: number) => api.delete<string>(`/timers/${id}`),
}
export const ruleApi = {
list: (timerId?: number) =>
api.get<Rule[]>('/rules/', timerId ? { timer_id: timerId } : undefined),
create: (body: RuleRequest) => api.post<Rule>('/rules/', body),
update: (id: number, body: RuleRequest) => api.put<Rule>(`/rules/${id}`, body),
test: (id: number) => api.post<string>(`/rules/${id}/test`),
remove: (id: number) => api.delete<string>(`/rules/${id}`),
}
export const accountApi = {
list: (type?: string) =>
api.get<Account[]>('/accounts/', type ? { type } : undefined),
create: (body: AccountRequest) => api.post<Account>('/accounts/', body),
update: (id: number, body: AccountRequest) => api.put<Account>(`/accounts/${id}`, body),
test: (id: number) => api.post<string>(`/accounts/${id}/test`),
checkDelete: (id: number) => api.get<Rule[]>(`/accounts/${id}/check`),
remove: (id: number) => api.delete<string>(`/accounts/${id}`),
}
export const logApi = {
list: (params: { page?: number; page_size?: number; rule_id?: number; success?: string }) =>
api.get<LogPage>('/logs/', params),
clear: () => api.delete<string>('/logs/'),
}
export type { ExecutionLog }
+157
View File
@@ -0,0 +1,157 @@
// 与后端 model / internal/meta 对应的类型定义
export interface ApiResponse<T = unknown> {
code: number
data: T
}
export interface UserInfo {
id: number
create_at: number
username: string
dingtalk_config: string | null
}
export interface Timer {
id: number
uid: number
name: string
description: string
enabled: boolean
sign_deration_seconds: number
last_sign: number
remind_time_seconds: number
last_remind: number
triggered: boolean
last_trigger: number
create_at: number
}
export interface TimerRequest {
name: string
description: string
enabled?: boolean
sign_deration_seconds: number
remind_time_seconds: number
}
export interface Rule {
id: number
uid: number
timer_id: number
name: string
account_id: number
type: string
config_json: string
enabled: boolean
create_at: number
}
export interface RuleRequest {
name: string
timer_id: number
account_id: number
type: string
config_json: string
enabled?: boolean
}
export interface Account {
id: number
uid: number
name: string
type: string
config: string
create_at: number
}
export interface AccountRequest {
name: string
type: string
config: string
}
export type FieldType =
| 'string'
| 'password'
| 'textarea'
| 'number'
| 'string-list'
| 'number-list'
| 'bool'
export interface MetaField {
key: string
label: string
type: FieldType
required: boolean
placeholder?: string
help?: string
secret?: boolean
default?: unknown
}
export interface AccountMeta {
type: string
label: string
description?: string
docs?: string
testable: boolean
fields: MetaField[]
}
export interface RuleMeta {
type: string
label: string
description?: string
docs?: string
account_type: string
fields: MetaField[]
}
export interface Providers {
accounts: AccountMeta[]
rules: RuleMeta[]
}
export interface ExecutionLog {
id: number
uid: number
rule_id: number
rule_name: string
rule_type: string
timer_id: number
trigger: 'timer' | 'manual' | 'remind'
success: boolean
message: string
create_at: number
}
export interface LogPage {
total: number
page: number
page_size: number
items: ExecutionLog[]
}
export interface DashboardOverview {
timer_count: number
enabled_timers: number
triggered_count: number
rule_count: number
account_count: number
server_time: number
recent_logs: ExecutionLog[]
urgent_timer?: Timer
urgent_seconds_left?: number
}
export interface SiteInfo {
name: string
enable_registry: boolean
need_initial_user: boolean
check_interval_minutes: number
}
/** 后端对 secret 字段的掩码占位符,原样提交时表示保留旧值 */
export const MASK_PLACEHOLDER = '********'
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+206
View File
@@ -0,0 +1,206 @@
/* ============================================
goodBaby 设计系统
主色调:#66ccff 天依蓝
============================================ */
:root {
/* 品牌色板 */
--gb-primary: #66ccff;
/* 深一档的品牌色,用于浅色背景上的文字,保证可读性 */
--gb-primary-deep: #2b9cd8;
--gb-ink: #17191f;
--gb-ink-soft: #232630;
--gb-bg: #f4f6f8;
--gb-card: #ffffff;
/* 覆盖 Element Plus 主色 */
--el-color-primary: #66ccff;
--el-color-primary-light-3: #8cd9ff;
--el-color-primary-light-5: #a8e2ff;
--el-color-primary-light-7: #c7edff;
--el-color-primary-light-8: #d9f3ff;
--el-color-primary-light-9: #ecf9ff;
--el-color-primary-dark-2: #3fa9e0;
/* 圆角与阴影 */
--el-border-radius-base: 8px;
--gb-radius-card: 14px;
--gb-shadow-card: 0 1px 2px rgb(16 24 40 / 0.04), 0 1px 8px rgb(16 24 40 / 0.05);
--gb-shadow-hover: 0 6px 18px rgb(16 24 40 / 0.1);
--gb-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}
html.dark {
--gb-bg: #101216;
--gb-card: #1a1d23;
--gb-shadow-card: 0 1px 2px rgb(0 0 0 / 0.4);
--gb-shadow-hover: 0 6px 20px rgb(0 0 0 / 0.5);
--el-bg-color: #1a1d23;
--el-bg-color-overlay: #21252d;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
background: var(--gb-bg);
font-family:
'Inter', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* ---------- 通用工具类 ---------- */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
}
.muted {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
/* ---------- Element Plus 外观微调 ---------- */
.el-card {
border: none;
border-radius: var(--gb-radius-card);
box-shadow: var(--gb-shadow-card);
transition:
box-shadow 0.3s var(--gb-ease),
transform 0.3s var(--gb-ease);
}
.el-card__header {
font-weight: 600;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.el-button {
transition:
all 0.2s var(--gb-ease),
transform 0.15s var(--gb-ease);
}
/* #66ccff 底色偏浅,主按钮用深墨色文字保证对比度 */
.el-button--primary {
--el-button-text-color: #0b2b3d;
--el-button-hover-text-color: #0b2b3d;
--el-button-active-text-color: #0b2b3d;
font-weight: 600;
}
.el-button:not(.is-disabled):active {
transform: scale(0.97);
}
.el-dialog {
border-radius: 16px;
}
.el-table {
--el-table-border-color: var(--el-border-color-extra-light);
--el-table-header-bg-color: transparent;
}
.el-table th.el-table__cell {
font-weight: 600;
color: var(--el-text-color-secondary);
}
.el-tag {
border-radius: 6px;
}
/* 提示条统一柔和外观 */
.el-alert {
border-radius: 10px;
}
/* ---------- 页面切换动画 ---------- */
.page-enter-active,
.page-leave-active {
transition:
opacity 0.22s var(--gb-ease),
transform 0.22s var(--gb-ease);
}
.page-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-6px);
}
/* 列表项入场 */
@keyframes gb-rise {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.gb-rise {
animation: gb-rise 0.35s var(--gb-ease) both;
}
/* 危险状态的呼吸效果 */
@keyframes gb-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.gb-pulse {
animation: gb-pulse 1.6s ease-in-out infinite;
}
/* ---------- 滚动条 ---------- */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgb(140 145 155 / 0.35);
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(140 145 155 / 0.55);
}
::-webkit-scrollbar-track {
background: transparent;
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+179
View File
@@ -0,0 +1,179 @@
<script setup lang="ts">
// 根据驱动元数据(MetaField[])动态渲染配置表单。
// 通过 v-model 双向绑定一个 JSON 字符串,内部维护解析后的对象。
import { ref, watch } from 'vue'
import type { MetaField } from '@/api/types'
const props = defineProps<{
fields: MetaField[]
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
type ConfigValue = Record<string, unknown>
const form = ref<ConfigValue>({})
/** 字符串数组/数字数组字段在 UI 中用多行文本编辑,每行一项 */
const listDrafts = ref<Record<string, string>>({})
function defaultFor(field: MetaField): unknown {
if (field.default !== undefined && field.default !== null) return field.default
switch (field.type) {
case 'number':
return undefined
case 'bool':
return false
case 'string-list':
case 'number-list':
return []
default:
return ''
}
}
function parseIncoming(raw: string) {
let parsed: ConfigValue = {}
if (raw) {
try {
parsed = JSON.parse(raw) as ConfigValue
} catch {
parsed = {}
}
}
const next: ConfigValue = {}
const drafts: Record<string, string> = {}
for (const field of props.fields) {
const value = parsed[field.key] ?? defaultFor(field)
next[field.key] = value
if (field.type === 'string-list' || field.type === 'number-list') {
drafts[field.key] = Array.isArray(value) ? value.join('\n') : ''
}
}
form.value = next
listDrafts.value = drafts
}
function serialize(): string {
const out: ConfigValue = {}
for (const field of props.fields) {
let value = form.value[field.key]
if (field.type === 'string-list') {
value = (listDrafts.value[field.key] ?? '')
.split('\n')
.map((s) => s.trim())
.filter((s) => s !== '')
} else if (field.type === 'number-list') {
value = (listDrafts.value[field.key] ?? '')
.split('\n')
.map((s) => s.trim())
.filter((s) => s !== '')
.map((s) => Number(s))
.filter((n) => !Number.isNaN(n))
} else if (field.type === 'number') {
if (value === '' || value === undefined || value === null) continue
value = Number(value)
}
if (value === '' || value === undefined) continue
out[field.key] = value
}
return JSON.stringify(out)
}
let syncing = false
watch(
() => [props.modelValue, props.fields] as const,
() => {
if (syncing) return
parseIncoming(props.modelValue)
},
{ immediate: true, deep: false },
)
watch(
[form, listDrafts],
() => {
syncing = true
emit('update:modelValue', serialize())
// 下一轮微任务再解除,避免回环触发 parseIncoming
queueMicrotask(() => {
syncing = false
})
},
{ deep: true },
)
</script>
<template>
<div class="config-form">
<el-form-item
v-for="field in fields"
:key="field.key"
:label="field.label"
:required="field.required"
>
<!-- 单行文本 -->
<el-input
v-if="field.type === 'string'"
v-model="form[field.key] as string"
:placeholder="field.placeholder"
clearable
/>
<!-- 密码 -->
<el-input
v-else-if="field.type === 'password'"
v-model="form[field.key] as string"
type="password"
show-password
:placeholder="field.placeholder"
/>
<!-- 多行文本 -->
<el-input
v-else-if="field.type === 'textarea'"
v-model="form[field.key] as string"
type="textarea"
:rows="4"
:placeholder="field.placeholder"
/>
<!-- 数字 -->
<el-input-number
v-else-if="field.type === 'number'"
v-model="form[field.key] as number"
:min="0"
:controls="false"
:placeholder="field.placeholder"
style="width: 100%"
/>
<!-- 开关 -->
<el-switch v-else-if="field.type === 'bool'" v-model="form[field.key] as boolean" />
<!-- 列表:每行一项 -->
<el-input
v-else
v-model="listDrafts[field.key]"
type="textarea"
:rows="3"
:placeholder="field.placeholder ? `${field.placeholder}\n(每行一项)` : '每行一项'"
/>
<div v-if="field.help" class="muted field-help">{{ field.help }}</div>
</el-form-item>
</div>
</template>
<style scoped>
.field-help {
line-height: 1.5;
margin-top: 4px;
width: 100%;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
// 品牌标志:信号波纹,寓意「定期发出的平安信号」
defineProps<{ size?: number }>()
</script>
<template>
<svg
:width="size ?? 22"
:height="size ?? 22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none" />
<path d="M8.6 15.4a4.8 4.8 0 0 1 0-6.8" />
<path d="M15.4 8.6a4.8 4.8 0 0 1 0 6.8" />
<path d="M5.8 18.2a8.8 8.8 0 0 1 0-12.4" />
<path d="M18.2 5.8a8.8 8.8 0 0 1 0 12.4" />
</svg>
</template>
+313
View File
@@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Odometer,
Timer as TimerIcon,
Operation,
User,
Document,
Setting,
SwitchButton,
Moon,
Sunny,
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import LogoMark from '@/components/LogoMark.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const menus = [
{ path: '/dashboard', title: '仪表盘', icon: Odometer },
{ path: '/timers', title: '定时器', icon: TimerIcon },
{ path: '/rules', title: '规则', icon: Operation },
{ path: '/accounts', title: '账号', icon: User },
{ path: '/logs', title: '执行日志', icon: Document },
{ path: '/settings', title: '设置', icon: Setting },
]
const activePath = computed(() => `/${route.path.split('/')[1] ?? ''}`)
const isDark = ref(document.documentElement.classList.contains('dark'))
function toggleDark() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('gb-theme', isDark.value ? 'dark' : 'light')
}
// 恢复上次的主题选择
if (localStorage.getItem('gb-theme') === 'dark' && !isDark.value) {
toggleDark()
}
async function handleLogout() {
await userStore.logout()
ElMessage.success('已退出登录')
router.push({ name: 'login' })
}
</script>
<template>
<div class="layout">
<!-- 侧边栏 -->
<aside class="aside">
<div class="logo">
<div class="logo-mark"><LogoMark :size="22" /></div>
<div class="logo-name">
<span class="logo-text">goodBaby</span>
<span class="logo-sub">摇篮系统</span>
</div>
</div>
<nav class="nav">
<RouterLink
v-for="item in menus"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: activePath === item.path }"
>
<el-icon :size="17"><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</RouterLink>
</nav>
<div class="aside-footer">
<div class="footer-quote">goodBaby v2</div>
</div>
</aside>
<!-- 主区域 -->
<div class="content">
<header class="header">
<div class="header-title">{{ route.meta.title ?? '' }}</div>
<div class="header-actions">
<button class="icon-btn" :title="isDark ? '切换到亮色' : '切换到暗色'" @click="toggleDark">
<el-icon :size="17"><Moon v-if="!isDark" /><Sunny v-else /></el-icon>
</button>
<el-dropdown>
<span class="user-chip">
<span class="user-avatar">{{ userStore.user?.username?.[0]?.toUpperCase() }}</span>
{{ userStore.user?.username }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/settings')">
<el-icon><Setting /></el-icon>设置
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<main class="main">
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
</div>
</div>
</template>
<style scoped>
.layout {
height: 100%;
display: flex;
}
/* ---------- 侧边栏:墨色 + 药丸导航 ---------- */
.aside {
width: 224px;
flex-shrink: 0;
background: linear-gradient(180deg, var(--gb-ink) 0%, var(--gb-ink-soft) 100%);
display: flex;
flex-direction: column;
padding: 20px 14px;
box-sizing: border-box;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 22px;
}
.logo-mark {
width: 38px;
height: 38px;
border-radius: 12px;
background: linear-gradient(135deg, var(--gb-primary) 0%, #3fa9e0 100%);
display: flex;
align-items: center;
justify-content: center;
color: #0b2b3d;
box-shadow: 0 4px 12px rgb(102 204 255 / 0.45);
}
.logo-name {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.logo-text {
color: #fff;
font-size: 17px;
font-weight: 700;
letter-spacing: -0.01em;
}
.logo-sub {
color: rgb(255 255 255 / 0.45);
font-size: 11px;
letter-spacing: 0.2em;
}
.nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 13px;
border-radius: 10px;
color: rgb(255 255 255 / 0.62);
text-decoration: none;
font-size: 14px;
transition:
background 0.2s var(--gb-ease),
color 0.2s var(--gb-ease),
transform 0.2s var(--gb-ease);
}
.nav-item:hover {
background: rgb(255 255 255 / 0.07);
color: rgb(255 255 255 / 0.9);
transform: translateX(2px);
}
.nav-item.active {
background: linear-gradient(135deg, var(--gb-primary) 0%, #3fa9e0 100%);
color: #0b2b3d;
font-weight: 600;
box-shadow: 0 4px 14px rgb(102 204 255 / 0.4);
}
.aside-footer {
margin-top: auto;
padding: 12px 8px 4px;
}
.footer-quote {
color: rgb(255 255 255 / 0.28);
font-size: 12px;
line-height: 1.9;
letter-spacing: 0.04em;
}
/* ---------- 主区域 ---------- */
.content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.header {
height: 60px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
background: color-mix(in srgb, var(--gb-card) 82%, transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--el-border-color-extra-light);
position: sticky;
top: 0;
z-index: 10;
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-actions {
display: flex;
align-items: center;
gap: 14px;
}
.icon-btn {
width: 34px;
height: 34px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s var(--gb-ease);
}
.icon-btn:hover {
background: var(--el-fill-color);
}
.user-chip {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--el-text-color-primary);
font-size: 14px;
outline: none;
padding: 4px 10px 4px 4px;
border-radius: 999px;
transition: background 0.2s var(--gb-ease);
}
.user-chip:hover {
background: var(--el-fill-color);
}
.user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, var(--gb-primary) 0%, #3fa9e0 100%);
color: #0b2b3d;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.main {
flex: 1;
overflow-y: auto;
padding: 24px 28px 40px;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+83
View File
@@ -0,0 +1,83 @@
import { createRouter, createWebHistory } from 'vue-router'
import { setUnauthorizedHandler } from '@/api/client'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
children: [
{ path: '', redirect: '/dashboard' },
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { title: '仪表盘' },
},
{
path: 'timers',
name: 'timers',
component: () => import('@/views/TimersView.vue'),
meta: { title: '定时器' },
},
{
path: 'rules',
name: 'rules',
component: () => import('@/views/RulesView.vue'),
meta: { title: '规则' },
},
{
path: 'accounts',
name: 'accounts',
component: () => import('@/views/AccountsView.vue'),
meta: { title: '账号' },
},
{
path: 'logs',
name: 'logs',
component: () => import('@/views/LogsView.vue'),
meta: { title: '执行日志' },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
meta: { title: '设置' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
],
})
router.beforeEach(async (to) => {
if (to.meta.public) return true
const userStore = useUserStore()
if (!userStore.loaded) {
await userStore.fetchUser()
}
if (!userStore.user) {
return { name: 'login', query: { redirect: to.fullPath } }
}
return true
})
// API 返回 401 时清空用户状态并跳到登录页
setUnauthorizedHandler(() => {
const userStore = useUserStore()
userStore.clear()
if (router.currentRoute.value.name !== 'login') {
router.push({ name: 'login' })
}
})
export default router
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { providerApi } from '@/api'
import type { AccountMeta, Providers, RuleMeta } from '@/api/types'
/** 驱动元数据:账号/规则类型的表单描述,登录后拉取一次全局共享 */
export const useMetaStore = defineStore('meta', () => {
const providers = ref<Providers | null>(null)
async function ensureLoaded() {
if (!providers.value) {
providers.value = await providerApi.all()
}
return providers.value
}
const accountMetas = computed<AccountMeta[]>(() => providers.value?.accounts ?? [])
const ruleMetas = computed<RuleMeta[]>(() => providers.value?.rules ?? [])
function accountMeta(type: string): AccountMeta | undefined {
return accountMetas.value.find((m) => m.type === type)
}
function ruleMeta(type: string): RuleMeta | undefined {
return ruleMetas.value.find((m) => m.type === type)
}
function accountLabel(type: string): string {
return accountMeta(type)?.label ?? type
}
function ruleLabel(type: string): string {
return ruleMeta(type)?.label ?? type
}
return {
providers,
accountMetas,
ruleMetas,
ensureLoaded,
accountMeta,
ruleMeta,
accountLabel,
ruleLabel,
}
})
+35
View File
@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { userApi } from '@/api'
import type { UserInfo } from '@/api/types'
export const useUserStore = defineStore('user', () => {
const user = ref<UserInfo | null>(null)
const loaded = ref(false)
/** 拉取当前登录用户,未登录时静默失败 */
async function fetchUser(): Promise<UserInfo | null> {
try {
user.value = await userApi.info()
} catch {
user.value = null
} finally {
loaded.value = true
}
return user.value
}
async function logout() {
try {
await userApi.logout()
} finally {
user.value = null
}
}
function clear() {
user.value = null
}
return { user, loaded, fetchUser, logout, clear }
})
+41
View File
@@ -0,0 +1,41 @@
/** 时间与时长的展示工具 */
export function formatDateTime(unixSeconds: number): string {
if (!unixSeconds) return '-'
const date = new Date(unixSeconds * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/** 把秒数格式化为 "3天 4小时" 这样的可读时长 */
export function formatDuration(totalSeconds: number): string {
if (totalSeconds <= 0) return '0秒'
const units: Array<[number, string]> = [
[86400, '天'],
[3600, '小时'],
[60, '分钟'],
[1, '秒'],
]
const parts: string[] = []
let remain = Math.floor(totalSeconds)
for (const [size, label] of units) {
if (remain >= size) {
parts.push(`${Math.floor(remain / size)}${label}`)
remain %= size
}
if (parts.length >= 2) break
}
return parts.join(' ') || '0秒'
}
/** 常用签到周期预设(秒) */
export const durationPresets: Array<{ label: string; seconds: number }> = [
{ label: '1 天', seconds: 86400 },
{ label: '3 天', seconds: 3 * 86400 },
{ label: '7 天', seconds: 7 * 86400 },
{ label: '14 天', seconds: 14 * 86400 },
{ label: '30 天', seconds: 30 * 86400 },
{ label: '90 天', seconds: 90 * 86400 },
]
+207
View File
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Connection, Plus } from '@element-plus/icons-vue'
import { accountApi } from '@/api'
import { ApiError } from '@/api/client'
import type { Account } from '@/api/types'
import { useMetaStore } from '@/stores/meta'
import { formatDateTime } from '@/utils/format'
import ConfigForm from '@/components/ConfigForm.vue'
const metaStore = useMetaStore()
const accounts = ref<Account[]>([])
const loading = ref(false)
const testingId = ref<number | null>(null)
// ---- 创建 / 编辑对话框 ----
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const form = ref({
name: '',
type: '',
config: '',
})
const dialogTitle = computed(() => (editingId.value === null ? '添加账号' : '编辑账号'))
const currentMeta = computed(() => metaStore.accountMeta(form.value.type))
async function refresh() {
loading.value = true
try {
accounts.value = await accountApi.list()
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = null
form.value = { name: '', type: metaStore.accountMetas[0]?.type ?? '', config: '' }
dialogVisible.value = true
}
function openEdit(account: Account) {
editingId.value = account.id
form.value = { name: account.name, type: account.type, config: account.config }
dialogVisible.value = true
}
async function save() {
if (!form.value.name || !form.value.type) {
ElMessage.warning('请填写账号名称并选择类型')
return
}
saving.value = true
try {
if (editingId.value === null) {
await accountApi.create(form.value)
ElMessage.success('账号已添加')
} else {
await accountApi.update(editingId.value, form.value)
ElMessage.success('账号已更新')
}
dialogVisible.value = false
await refresh()
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function test(account: Account) {
testingId.value = account.id
try {
await accountApi.test(account.id)
ElMessage.success(`「${account.name}」测试通过`)
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '测试失败')
} finally {
testingId.value = null
}
}
async function remove(account: Account) {
try {
const affected = await accountApi.checkDelete(account.id)
const warning =
affected.length > 0
? `删除后,使用该账号的 ${affected.length} 条规则也会被一并删除:${affected
.map((r) => r.name)
.join('、')}`
: '没有规则使用该账号。'
await ElMessageBox.confirm(warning, `删除账号「${account.name}」?`, {
type: 'warning',
confirmButtonText: '删除',
confirmButtonClass: 'el-button--danger',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await accountApi.remove(account.id)
ElMessage.success('已删除')
await refresh()
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '删除失败')
}
}
onMounted(async () => {
await metaStore.ensureLoaded()
await refresh()
})
</script>
<template>
<div v-loading="loading">
<div class="page-header">
<div>
<h2>账号</h2>
<div class="muted">保存第三方服务的凭据,供规则执行时使用。敏感字段保存后显示为 ********</div>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">添加账号</el-button>
</div>
<el-empty v-if="!loading && accounts.length === 0" description="还没有账号">
<el-button type="primary" @click="openCreate">添加账号</el-button>
</el-empty>
<el-card v-else class="gb-rise">
<el-table :data="accounts">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="类型" width="160">
<template #default="{ row }">
<el-tag>{{ metaStore.accountLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatDateTime(row.create_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<el-button
v-if="metaStore.accountMeta(row.type)?.testable"
size="small"
:icon="Connection"
:loading="testingId === row.id"
@click="test(row)"
>
测试
</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
<el-form label-width="110px">
<el-form-item label="账号名称" required>
<el-input v-model="form.name" placeholder="给这个账号起个名字" maxlength="64" />
</el-form-item>
<el-form-item label="账号类型" required>
<el-select
v-model="form.type"
:disabled="editingId !== null"
style="width: 100%"
@change="form.config = ''"
>
<el-option
v-for="m in metaStore.accountMetas"
:key="m.type"
:label="m.label"
:value="m.type"
/>
</el-select>
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
</el-form-item>
<ConfigForm
v-if="currentMeta"
v-model="form.config"
:fields="currentMeta.fields"
/>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
</style>

Some files were not shown because too many files have changed in this diff Show More