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:
@@ -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
@@ -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"]
|
||||||
@@ -1,16 +1,94 @@
|
|||||||
# goodBaby v2
|
# goodBaby v2
|
||||||
|
|
||||||
摇篮系统
|
摇篮系统 —— 为独居青年准备的「死人开关」(Dead Man's Switch)。
|
||||||
|
|
||||||
|
一个人在大城市生活,最怕的是出了事都没人知道。goodBaby 让你定期回来报一声平安;一旦超过设定的期限没有签到,它就会替你把预先托付的留言送出去:给家人的邮件、给朋友的 QQ / 钉钉消息、一条 B 站动态,或是公开你的 GitHub 仓库,把作品留给世界。
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/7125/cat_)
|
[](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()` 返回的字段描述自动渲染配置表单
|
||||||
|
|||||||
+168
-51
@@ -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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{"code": 200, "data": rules})
|
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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+151
-103
@@ -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
|
// HandleEditRule 编辑规则
|
||||||
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": "规则创建成功"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑规则
|
|
||||||
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 {
|
||||||
|
response.ServerError(c, "删除规则失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, "规则删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTestRule 立即执行一次规则用于验证配置
|
||||||
|
//
|
||||||
|
// 使用较短的超时,避免在 WebUI 上等待数小时的指数退避。
|
||||||
|
func HandleTestRule(c *gin.Context) {
|
||||||
|
userInfo, err := user.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": "规则删除成功"})
|
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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
+127
-23
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-59
@@ -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(®istryRequest); 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := verifyUser(loginRequest.Username, loginRequest.Password)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrWrongPassword):
|
||||||
|
// 不区分“用户不存在”和“密码错误”,避免泄露用户名是否存在
|
||||||
|
response.Unauthorized(c, "用户名或密码错误")
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
response.ServerError(c, "登录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := sessions.Default(c)
|
||||||
session.Set("username", user.Username)
|
session.Clear()
|
||||||
session.Save()
|
session.Options(sessions.Options{Path: "/", MaxAge: -1})
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
c.JSON(200, gin.H{"code": 200, "data": "登录成功"})
|
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
@@ -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",
|
||||||
if err != nil {
|
EnableRegistry: true,
|
||||||
return config, err
|
TimeoutDurationHours: 6,
|
||||||
|
CheckIntervalMinutes: 10,
|
||||||
|
DatabasePath: "data.db",
|
||||||
|
SessionMaxAgeHours: 24 * 7,
|
||||||
|
AllowedOrigins: []string{},
|
||||||
|
LogRetainCount: 500,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(content, &config)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := normalize(&config)
|
||||||
|
applyEnvOverrides(&config)
|
||||||
|
|
||||||
|
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
@@ -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
@@ -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": "要发送的动态内容"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
定时器到期后会以该账号发布一条纯文本动态。
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每个收件人独立发送、独立重试,互不影响。
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
只填仓库名,不含所有者。
|
||||||
@@ -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 号)至少填写一项。
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "要发送的动态正文",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
return ErrNoUserConfig
|
||||||
|
}
|
||||||
|
return send(config, title, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := dingtalk.NewClient(dingtalkAccount.AccessToken, dingtalkAccount.Secret)
|
// ErrNoUserConfig 表示用户没有配置钉钉提醒
|
||||||
sendMsg := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
|
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
|
||||||
_, _, err := client.Send(sendMsg)
|
|
||||||
if err != nil {
|
func send(config *DingTalkConfig, title, msg string) error {
|
||||||
log.Printf("发送钉钉消息失败: %v", err)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
return client, nil
|
||||||
|
|
||||||
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()))
|
func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.Msg, error) {
|
||||||
if err != nil {
|
|
||||||
log.Printf("发送邮件失败: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendMailMsg(address string, rule *model.Rule) 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
|
||||||
}
|
}
|
||||||
|
|
||||||
message.Subject(emailRule.Title)
|
func sendMailMsgWithRetry(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
|
||||||
message.SetBodyString(mail.TypeTextPlain, emailRule.Msg)
|
return retry.Do(ctx, func() error {
|
||||||
|
return sendMailMsg(ctx, cfg, rule, address)
|
||||||
// 发送邮件
|
})
|
||||||
if err := client.DialAndSend(message); err != nil {
|
|
||||||
return fmt.Errorf("发送邮件失败: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendMailMsg(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
|
||||||
|
client, err := newSMTPClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := buildMessage(cfg, address, rule.Title, rule.Msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.DialAndSendWithContext(ctx, message); err != nil {
|
||||||
|
return fmt.Errorf("发送邮件失败: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 账号",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
// SetRepositoryPublic 将仓库设置为 public
|
||||||
if err != nil {
|
func SetRepositoryPublic(ctx context.Context, token, owner, repo string) error {
|
||||||
log.Printf("获取 GitHub 账户失败: %v", err)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &githubReposConfig, &githubAccount
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将仓库设置为 public
|
|
||||||
func SetRepositoryPublic(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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-24
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 从 Rule 中获取 OneBotConfig
|
func newClient(account *OneBotAccount) *napcat.Client {
|
||||||
func getOneBotConfigFromRule(rule *model.Rule) *OneBotConfig {
|
return napcat.NewClient(account.Token, account.URL)
|
||||||
var oneBotConfig OneBotConfig
|
|
||||||
err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return &oneBotConfig
|
|
||||||
|
// 从 Rule 中获取 OneBotConfig
|
||||||
|
func getOneBotConfigFromRule(rule *model.Rule) (*OneBotConfig, error) {
|
||||||
|
var oneBotConfig OneBotConfig
|
||||||
|
if err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析OneBot规则配置失败: %w", err)
|
||||||
|
}
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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{},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 获取全局执行器注册表
|
||||||
|
|||||||
+57
-10
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeRule 执行单个规则
|
log.Printf("Timer %s (ID: %d) 已到期,开始执行 %d 条规则", timer.Name, timer.ID, len(rules))
|
||||||
func executeRule(rule *model.Rule) {
|
|
||||||
executorRegistry := GetGlobalExecutorRegistry()
|
|
||||||
|
|
||||||
if err := executorRegistry.Execute(rule); err != nil {
|
// 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 执行单个规则并记录执行日志
|
||||||
|
func ExecuteRule(rule *model.Rule, trigger string) error {
|
||||||
|
ctx, cancel := retry.ExecutionContext()
|
||||||
|
defer cancel()
|
||||||
|
return ExecuteRuleWithContext(ctx, rule, trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} }
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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>
|
||||||
Generated
+1901
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -0,0 +1,6 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
@@ -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)),
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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 },
|
||||||
|
]
|
||||||
@@ -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
Reference in New Issue
Block a user