diff --git a/.gitignore b/.gitignore index 194f8c9..1c2a6eb 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,13 @@ go.work goodBaby config.json -data.db \ No newline at end of file +data.db +data.db-shm +data.db-wal +/data/ + +# ---> Frontend +node_modules/ +# 前端构建产物不入库,仅保留 .gitkeep 以满足 go:embed +web/dist/* +!web/dist/.gitkeep diff --git a/Dockerfile b/Dockerfile index 8a5b60d..4ac56c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -10,13 +23,14 @@ COPY go.mod go.sum ./ RUN go mod download 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 GIT_COMMIT="unknown" RUN CGO_ENABLED=0 \ GOOS=linux \ - GOARCH=amd64 \ go build \ -ldflags="-w -s -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE} -X main.gitCommit=${GIT_COMMIT}" \ -o /app/main . @@ -24,12 +38,19 @@ RUN CGO_ENABLED=0 \ # ====================== # 运行阶段 (Runtime Stage) # ====================== -FROM alpine:3.18 +FROM alpine:3.20 WORKDIR /app +RUN apk add --no-cache tzdata ca-certificates + 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 -CMD ["/app/main"] \ No newline at end of file +CMD ["/app/main"] diff --git a/README.md b/README.md index 32ffe93..8b74c49 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,94 @@ # goodBaby v2 -摇篮系统 +摇篮系统 —— 为独居青年准备的「死人开关」(Dead Man's Switch)。 + +一个人在大城市生活,最怕的是出了事都没人知道。goodBaby 让你定期回来报一声平安;一旦超过设定的期限没有签到,它就会替你把预先托付的留言送出去:给家人的邮件、给朋友的 QQ / 钉钉消息、一条 B 站动态,或是公开你的 GitHub 仓库,把作品留给世界。 [![通过雨云一键部署](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/7125/cat_) -## 他会做什么 +## 功能 -船新版本正在开发... +* **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//` 下实现: + * 规则验证器(实现 `ruleConfigChecker.RuleValidator`,`Meta()` 返回表单元数据) + * 执行器(实现 `runner.RuleExecutor`) + * 如需第三方凭据,再实现账号验证器(`accountConfigChecker.AccountValidator`,可选实现 `AccountTester` 支持连通性测试) +2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/interface.go` 中注册 +3. 前端无需改动 —— WebUI 会根据 `Meta()` 返回的字段描述自动渲染配置表单 diff --git a/api/account/account.go b/api/account/account.go index bdf0119..55b743d 100644 --- a/api/account/account.go +++ b/api/account/account.go @@ -1,116 +1,232 @@ package account import ( - "strconv" + "time" "github.com/gin-gonic/gin" + "github.com/ssdomei232/goodBaby/api/response" "github.com/ssdomei232/goodBaby/api/rule" "github.com/ssdomei232/goodBaby/api/user" "github.com/ssdomei232/goodBaby/handler/db" + "github.com/ssdomei232/goodBaby/internal/accountConfigChecker" + "github.com/ssdomei232/goodBaby/internal/meta" "github.com/ssdomei232/goodBaby/model" ) -// 添加账号 +// HandleAddAccount 添加账号 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) if err != nil { - c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) + response.Unauthorized(c, "获取用户信息失败") return } - var newAccount model.Account - if err := c.BindJSON(&newAccount); err != nil { - c.JSON(400, gin.H{"code": 400, "data": "输入参数错误"}) + var req model.AccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "输入参数错误") return } - newAccount.UID = userInfo.ID - result := gormDB.Create(&newAccount) - if result.Error != nil { - c.JSON(500, gin.H{"code": 500, "data": "添加账号失败"}) + if err := req.Validate(); err != nil { + response.FromError(c, err, "添加账号失败") 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) { - gormDB, err := db.GetGormDB() - if err != nil { - c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"}) - return - } - userInfo, err := user.GetUserInfoByGinCtx(c) if err != nil { - c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) + response.Unauthorized(c, "获取用户信息失败") return } - var accounts []model.Account - result := gormDB.Where("uid = ?", userInfo.ID).Find(&accounts) - if result.Error != nil { - c.JSON(500, gin.H{"code": 500, "data": "获取账号失败"}) + gormDB, err := db.GetGormDB() + if err != nil { + response.ServerError(c, "服务器内部错误") 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) { userInfo, err := user.GetUserInfoByGinCtx(c) if err != nil { - c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) - return - } - accountID, err := strconv.Atoi(c.Param("accountID")) - if err != nil { - c.JSON(400, gin.H{"code": 400, "data": "账号ID格式错误"}) + response.Unauthorized(c, "获取用户信息失败") return } - rules, err := getRulesByAccountID(uint(accountID), userInfo.ID) + accountID, err := parseID(c.Param("accountID")) if err != nil { - c.JSON(500, gin.H{"code": 500, "data": "获取相关规则失败"}) + response.BadRequest(c, "账号ID格式错误") return } - c.JSON(200, gin.H{"code": 200, "data": rules}) + rules, err := getRulesByAccountID(accountID, userInfo.ID) + if err != nil { + response.ServerError(c, "获取相关规则失败") + return + } + + response.OK(c, rules) } -// 删除账号 +// HandleDeleteAccount 删除账号及其关联规则 func HandleDeleteAccount(c *gin.Context) { userInfo, err := user.GetUserInfoByGinCtx(c) if err != nil { - c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) + response.Unauthorized(c, "获取用户信息失败") return } - accountID, err := strconv.Atoi(c.Param("accountID")) + + accountID, err := parseID(c.Param("accountID")) if err != nil { - c.JSON(400, gin.H{"code": 400, "data": "账号ID格式错误"}) + response.BadRequest(c, "账号ID格式错误") return } // 删除相关规则 - rules, err := getRulesByAccountID(uint(accountID), userInfo.ID) + rules, err := getRulesByAccountID(accountID, userInfo.ID) if err != nil { - c.JSON(500, gin.H{"code": 500, "data": "获取相关规则失败"}) + response.ServerError(c, "获取相关规则失败") return } for _, oneRule := range rules { - err = rule.DeleteRuleByID(oneRule.ID, userInfo.ID) - if err != nil { - c.JSON(500, gin.H{"code": 500, "data": "删除相关规则失败"}) + if err := rule.DeleteRuleByID(oneRule.ID, userInfo.ID); err != nil { + response.ServerError(c, "删除相关规则失败") return } } @@ -118,13 +234,14 @@ func HandleDeleteAccount(c *gin.Context) { // 删除账号 gormDB, err := db.GetGormDB() if err != nil { - c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"}) + response.ServerError(c, "服务器内部错误") return } - result := gormDB.Where("id = ? AND uid = ?", accountID, userInfo.ID).Delete(&model.Account{}) - if result.Error != nil { - c.JSON(500, gin.H{"code": 500, "data": "删除账号失败"}) + if err := gormDB.Where("id = ? AND uid = ?", accountID, userInfo.ID). + Delete(&model.Account{}).Error; err != nil { + response.ServerError(c, "删除账号失败") return } - c.JSON(200, gin.H{"code": 200, "data": "删除账号成功"}) + + response.OK(c, "删除账号成功") } diff --git a/api/account/tool.go b/api/account/tool.go index 1240e4a..534f004 100644 --- a/api/account/tool.go +++ b/api/account/tool.go @@ -1,21 +1,61 @@ package account import ( + "strconv" + "github.com/ssdomei232/goodBaby/handler/db" + "github.com/ssdomei232/goodBaby/internal/accountConfigChecker" + "github.com/ssdomei232/goodBaby/internal/meta" "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) { gormDB, err := db.GetGormDB() if err != nil { return nil, err } - var rules []*model.Rule - result := gormDB.Where("account_id = ? AND uid = ?", accountID, uid).Find(&rules) - if result.Error != nil { - return nil, result.Error + rules := []*model.Rule{} + if err := gormDB.Where("account_id = ? AND uid = ?", accountID, uid).Find(&rules).Error; err != nil { + return nil, err } 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 +} diff --git a/api/dashboard/dashboard.go b/api/dashboard/dashboard.go new file mode 100644 index 0000000..59231bc --- /dev/null +++ b/api/dashboard/dashboard.go @@ -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) +} diff --git a/api/log/log.go b/api/log/log.go new file mode 100644 index 0000000..bd7f1ed --- /dev/null +++ b/api/log/log.go @@ -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 +} diff --git a/api/meta/meta.go b/api/meta/meta.go new file mode 100644 index 0000000..848b531 --- /dev/null +++ b/api/meta/meta.go @@ -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, + }) +} diff --git a/api/response/response.go b/api/response/response.go new file mode 100644 index 0000000..ed9de36 --- /dev/null +++ b/api/response/response.go @@ -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) +} diff --git a/api/rule/rule.go b/api/rule/rule.go index 69bb16e..3737166 100644 --- a/api/rule/rule.go +++ b/api/rule/rule.go @@ -1,188 +1,236 @@ package rule import ( + "context" "fmt" - "strconv" + "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/handler/runner" + "github.com/ssdomei232/goodBaby/internal/meta" + "github.com/ssdomei232/goodBaby/internal/retry" "github.com/ssdomei232/goodBaby/internal/ruleConfigChecker" "github.com/ssdomei232/goodBaby/model" ) -// 获取用户的所有规则 +// HandleGetAllRules 获取用户的所有规则,支持按 timer_id 过滤 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) if err != nil { - c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) + response.Unauthorized(c, "获取用户信息失败") return } - var rules []model.Rule - result := gormDB.Where("uid = ?", userInfo.ID).Find(&rules) - if result.Error != nil { - c.JSON(500, gin.H{"code": 500, "data": "获取规则失败"}) + gormDB, err := db.GetGormDB() + if err != nil { + response.ServerError(c, "服务器内部错误") 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) { - gormDB, err := db.GetGormDB() - if err != nil { - c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"}) - return - } - userInfo, err := user.GetUserInfoByGinCtx(c) if err != nil { - c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) + response.Unauthorized(c, "获取用户信息失败") return } - var newRule model.Rule - if err := c.BindJSON(&newRule); err != nil { - c.JSON(400, gin.H{"code": 400, "data": "输入参数错误"}) + var req model.RuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "输入参数错误") return } - if newRule.Type == "" { - c.JSON(400, gin.H{"code": 400, "data": "规则类型不能为空"}) + newRule := model.Rule{ + 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 } - if newRule.Name == "" { - c.JSON(400, gin.H{"code": 400, "data": "规则名称不能为空"}) + gormDB, err := db.GetGormDB() + if err != nil { + response.ServerError(c, "服务器内部错误") return } - // 检查关联账号和 Timer 是否存在(可以没有关联账号) - if exist, err := checkRuleConfigAccountAndTimerExist(newRule); err != nil { - c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"}) - return - } else if !exist { - c.JSON(400, gin.H{"code": 400, "data": "关联的账户或Timer不存在"}) + if err := gormDB.Create(&newRule).Error; err != nil { + response.ServerError(c, "创建规则失败") return } - // 规则校验 - validatorRegistry := ruleConfigChecker.InitValidatorRegistry() - if err := validatorRegistry.Validate(newRule.Type, newRule.ConfigJson); err != nil { - c.JSON(400, gin.H{"code": 400, "data": fmt.Sprintf("规则配置验证失败: %s", err.Error())}) - return - } - - newRule.UID = userInfo.ID - result := gormDB.Create(&newRule) - if result.Error != nil { - c.JSON(500, gin.H{"code": 500, "data": "创建规则失败"}) - return - } - - c.JSON(200, gin.H{"code": 200, "data": "规则创建成功"}) + response.OK(c, maskRule(newRule)) } -// 编辑规则 +// HandleEditRule 编辑规则 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) if err != nil { - c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) + response.Unauthorized(c, "获取用户信息失败") return } - ruleID, err := strconv.Atoi(c.Param("ruleID")) + ruleID, err := parseID(c.Param("ruleID")) if err != nil { - c.JSON(400, gin.H{"code": 400, "data": "规则 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": "规则不存在"}) + response.BadRequest(c, "规则 ID 格式错误") return } - var updatedRule model.Rule - if err := c.BindJSON(&updatedRule); err != nil { - c.JSON(400, gin.H{"code": 400, "data": "输入参数错误"}) + existing, err := findRule(ruleID, userInfo.ID) + if err != nil { + response.NotFound(c, "规则不存在") return } - if updatedRule.Type == "" { - c.JSON(400, gin.H{"code": 400, "data": "规则类型不能为空"}) + var req model.RuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "输入参数错误") return } - if updatedRule.Name == "" { - c.JSON(400, gin.H{"code": 400, "data": "规则名称不能为空"}) + updated := *existing + 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 } - // 检查关联账号和 Timer 是否存在(可以没有关联账号) - if exist, err := checkRuleConfigAccountAndTimerExist(updatedRule); err != nil { - c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"}) - return - } else if !exist { - c.JSON(400, gin.H{"code": 400, "data": "关联的账户或Timer不存在"}) + gormDB, err := db.GetGormDB() + if err != nil { + response.ServerError(c, "服务器内部错误") return } - // 规则校验 - validatorRegistry := ruleConfigChecker.InitValidatorRegistry() - if err := validatorRegistry.Validate(updatedRule.Type, updatedRule.ConfigJson); err != nil { - c.JSON(400, gin.H{"code": 400, "data": fmt.Sprintf("规则配置验证失败: %s", err.Error())}) + if err := gormDB.Save(&updated).Error; err != nil { + response.ServerError(c, "更新规则失败") return } - rule.Name = updatedRule.Name - 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": "规则更新成功"}) + response.OK(c, maskRule(updated)) } -// 根据删除规则 +// HandleDeleteRule 删除规则 func HandleDeleteRule(c *gin.Context) { userInfo, err := user.GetUserInfoByGinCtx(c) if err != nil { - c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"}) + response.Unauthorized(c, "获取用户信息失败") return } - ruleID, err := strconv.Atoi(c.Param("ruleID")) + ruleID, err := parseID(c.Param("ruleID")) if err != nil { - c.JSON(400, gin.H{"code": 400, "data": "规则 ID 格式错误"}) + response.BadRequest(c, "规则 ID 格式错误") return } - err = DeleteRuleByID(uint(ruleID), userInfo.ID) - if err != nil { - c.JSON(500, gin.H{"code": 500, "data": "删除规则失败"}) + if err := DeleteRuleByID(ruleID, userInfo.ID); err != nil { + response.ServerError(c, "删除规则失败") return } - c.JSON(200, gin.H{"code": 200, "data": "规则删除成功"}) + response.OK(c, "规则删除成功") +} + +// HandleTestRule 立即执行一次规则用于验证配置 +// +// 使用较短的超时,避免在 WebUI 上等待数小时的指数退避。 +func HandleTestRule(c *gin.Context) { + userInfo, err := user.GetUserInfoByGinCtx(c) + if err != nil { + response.Unauthorized(c, "获取用户信息失败") + return + } + + ruleID, err := parseID(c.Param("ruleID")) + if err != nil { + response.BadRequest(c, "规则 ID 格式错误") + return + } + + target, err := findRule(ruleID, userInfo.ID) + if err != nil { + response.NotFound(c, "规则不存在") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout) + defer cancel() + + if err := runner.ExecuteRuleWithContext(ctx, target, model.TriggerManual); err != nil { + response.BadRequest(c, fmt.Sprintf("规则执行失败: %s", err.Error())) + return + } + + response.OK(c, "规则执行成功") +} + +// validateRule 校验规则的通用字段、关联对象与类型专属配置 +func validateRule(req *model.RuleRequest, rule *model.Rule) error { + if err := req.Validate(); err != nil { + return err + } + + ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(req.Type) + if !ok { + return model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", req.Type)) + } + + // 检查关联的 Timer 与账号是否存在且属于当前用户 + if err := checkRuleConfigAccountAndTimerExist(rule, ruleMeta.AccountType); err != nil { + return err + } + + if err := ruleConfigChecker.InitValidatorRegistry().Validate(req.Type, rule.ConfigJson); err != nil { + return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error())) + } + + return nil +} + +// unmaskRuleConfig 把提交上来的掩码字段还原成旧值 +func unmaskRuleConfig(ruleType, newConfig, oldConfig string) string { + ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType) + if !ok { + return newConfig + } + return meta.Unmask(newConfig, oldConfig, ruleMeta.Fields) } diff --git a/api/rule/tool.go b/api/rule/tool.go index 3b10125..5a3826b 100644 --- a/api/rule/tool.go +++ b/api/rule/tool.go @@ -1,52 +1,105 @@ package rule import ( + "fmt" + "strconv" + "github.com/ssdomei232/goodBaby/handler/db" + "github.com/ssdomei232/goodBaby/internal/meta" + "github.com/ssdomei232/goodBaby/internal/ruleConfigChecker" "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 { gormDB, err := db.GetGormDB() if err != nil { return err } - result := gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}) - return result.Error + return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}).Error } -// 检查关联账号和 Timer 是否存在(可以没有关联账号) -func checkRuleConfigAccountAndTimerExist(rule model.Rule) (exist bool, err error) { +// checkRuleConfigAccountAndTimerExist 检查关联的 Timer 与账号是否存在且归属当前用户 +// +// requiredAccountType 为空表示该规则类型不需要账号。 +func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType string) error { gormDB, err := db.GetGormDB() if err != nil { - return false, err - } - - if rule.TimerID == 0 { - return false, nil + return err } var count int64 - if rule.AccountID != 0 { - result := gormDB.Model(&model.Account{}).Where("id = ? AND uid = ?", rule.AccountID, rule.UID).Count(&count) - if result.Error != nil { - return false, result.Error - } - if count == 0 { - return false, nil - } + if err := gormDB.Model(&model.Timer{}). + Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return model.ErrValidation("关联的 Timer 不存在") } - if rule.TimerID != 0 { - result := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count) - if result.Error != nil { - return false, result.Error - } - if count == 0 { - return false, nil - } + if requiredAccountType == "" { + // 该类型不需要账号,忽略前端可能传来的 account_id + rule.AccountID = 0 + return 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 } diff --git a/api/timer/timer.go b/api/timer/timer.go new file mode 100644 index 0000000..e6f4a91 --- /dev/null +++ b/api/timer/timer.go @@ -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) +} diff --git a/api/timer/tool.go b/api/timer/tool.go new file mode 100644 index 0000000..0ac3fc9 --- /dev/null +++ b/api/timer/tool.go @@ -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 +} diff --git a/api/timer/trigger.go b/api/timer/trigger.go new file mode 100644 index 0000000..b7f984f --- /dev/null +++ b/api/timer/trigger.go @@ -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, + }) +} diff --git a/api/user/tool.go b/api/user/tool.go index 6ae6688..e613682 100644 --- a/api/user/tool.go +++ b/api/user/tool.go @@ -1,6 +1,9 @@ package user import ( + "encoding/json" + "errors" + "fmt" "time" "github.com/gin-contrib/sessions" @@ -8,15 +11,25 @@ import ( "github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/model" "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) { - hashedID, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } - return string(hashedID), nil + return string(hashed), nil } // 验证密码 @@ -35,30 +48,62 @@ func createUser(u *model.User) error { return err } u.Password = hashedPassword - u.CreateAt = time.Now().Unix() - result := gormDB.Create(u) - return result.Error + return gormDB.Create(u).Error } -// 校验密码 -func verifyUser(u *model.User) error { +// IsUsernameTaken 用户名是否已被占用 +func IsUsernameTaken(username string) (bool, error) { gormDB, err := db.GetGormDB() if err != nil { - return err + return false, err } - var hashedPassword string - result := gormDB.Where("username = ?", u.Username).First(&model.User{}).Select("password").Scan(&hashedPassword) - if result.Error != nil { - return result.Error + var count int64 + if err := gormDB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil { + return false, err } - - return verifyPassword(hashedPassword, u.Password) + return count > 0, nil } -// 通过用户名获取用户信息 +// 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) { gormDB, err := db.GetGormDB() if err != nil { @@ -66,20 +111,79 @@ func GetUserInfo(username string) (*model.User, error) { } var user model.User - result := gormDB.Where("username = ?", username).First(&user) - if result.Error != nil { - return nil, result.Error + if err := gormDB.Where("username = ?", username).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err } return &user, nil } -// 通过 gin.Context 获取用户信息 -func GetUserInfoByGinCtx(c *gin.Context) (userInfo *model.User, err error) { - session := sessions.Default(c) - username := session.Get("username") - userInfo, err = GetUserInfo(username.(string)) +// GetUserByID 通过 ID 获取用户信息 +func GetUserByID(id uint) (*model.User, error) { + gormDB, err := db.GetGormDB() if err != nil { 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 } diff --git a/api/user/user.go b/api/user/user.go index b16fa87..89db12e 100644 --- a/api/user/user.go +++ b/api/user/user.go @@ -1,118 +1,230 @@ package user import ( - "database/sql" + "errors" + "net/http" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "github.com/ssdomei232/goodBaby/api/response" "github.com/ssdomei232/goodBaby/configs" + "github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/model" ) -// 处理注册请求,配置文件中可设置禁用注册 +// HandleRegistry 处理注册请求,配置文件中可设置禁用注册 +// +// 系统内还没有任何用户时始终允许注册,否则全新部署将无法创建第一个账号。 func HandleRegistry(c *gin.Context) { - var userRegistryRequest model.UserRegistryReuest - var err error - config, err := configs.GetConfig() if err != nil { - c.JSON(500, gin.H{"code": 500, "data": "获取配置文件失败"}) + response.ServerError(c, "获取配置文件失败") return } - if !config.EnableRegistry { - c.JSON(403, gin.H{"code": 403, "data": "注册功能已关闭"}) - return - } - - err = c.BindJSON(&userRegistryRequest) + userCount, err := CountUsers() 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 } user := model.User{ - Username: userRegistryRequest.Username, - Password: userRegistryRequest.Password, + Username: registryRequest.Username, + Password: registryRequest.Password, } - if err = user.IsValid(); err != nil { - c.JSON(400, gin.H{"code": 400, "data": err.Error()}) + if err := user.IsValid(); err != nil { + response.BadRequest(c, err.Error()) return } - if isExist := user.IsExist(); isExist { - c.JSON(400, gin.H{"code": 400, "data": "用户名已存在"}) + taken, err := IsUsernameTaken(user.Username) + if err != nil { + response.ServerError(c, "服务器内部错误") + return + } + if taken { + response.BadRequest(c, "用户名已存在") return } - if err = createUser(&user); err != nil { - c.JSON(500, gin.H{"code": 500, "data": "注册失败"}) + if err := createUser(&user); err != nil { + response.ServerError(c, "注册失败") return } - session := sessions.Default(c) - session.Set("username", user.Username) - session.Save() + if err := setSession(c, &user); err != nil { + response.ServerError(c, "写入会话失败") + return + } - c.JSON(200, gin.H{"code": 200, "data": "注册成功"}) + response.OK(c, "注册成功") } -// 处理登录请求 +// HandleLogin 处理登录请求 func HandleLogin(c *gin.Context) { - var userLoginRequest model.UserRegistryReuest - var err error - - err = c.BindJSON(&userLoginRequest) - if err != nil { - c.JSON(400, gin.H{"code": 400, "data": "输入错误"}) + var loginRequest model.UserRegistryReuest + if err := c.ShouldBindJSON(&loginRequest); err != nil { + response.BadRequest(c, "输入错误") return } - user := model.User{ - Username: userLoginRequest.Username, - 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": "登录失败"}) + if loginRequest.Username == "" || loginRequest.Password == "" { + response.BadRequest(c, "用户名或密码不能为空") return } - session := sessions.Default(c) - session.Set("username", user.Username) - session.Save() + 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 + } - c.JSON(200, gin.H{"code": 200, "data": "登录成功"}) + if err := setSession(c, user); err != nil { + response.ServerError(c, "写入会话失败") + return + } + + response.OK(c, "登录成功") } -// 处理获取用户信息请求 +// HandleLogout 退出登录 +func HandleLogout(c *gin.Context) { + session := sessions.Default(c) + session.Clear() + session.Options(sessions.Options{Path: "/", MaxAge: -1}) + if err := session.Save(); err != nil { + response.ServerError(c, "退出登录失败") + return + } + response.OK(c, "已退出登录") +} + +// HandleGetUserInfo 获取当前登录用户信息 func HandleGetUserInfo(c *gin.Context) { userInfo, err := GetUserInfoByGinCtx(c) if err != nil { - c.JSON(500, gin.H{"code": 500, "data": "获取用户信息失败"}) + response.Unauthorized(c, "获取用户信息失败") 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 { return func(c *gin.Context) { session := sessions.Default(c) - username := session.Get("username") - if username == nil { - c.JSON(401, gin.H{"code": 401, "data": "未登录"}) - c.Abort() + uid, ok := session.Get("uid").(uint) + if !ok { + response.AbortWith(c, http.StatusUnauthorized, "未登录") return } + + user, err := GetUserByID(uid) + if err != nil { + // 用户已被删除,清理会话 + session.Clear() + _ = session.Save() + response.AbortWith(c, http.StatusUnauthorized, "未登录") + return + } + + c.Set(contextKey, user) c.Next() } } diff --git a/configs/config.go b/configs/config.go index 42550fc..83f656f 100644 --- a/configs/config.go +++ b/configs/config.go @@ -1,31 +1,195 @@ package configs import ( + "crypto/rand" + "encoding/hex" "encoding/json" + "errors" + "fmt" + "io/fs" + "log" "os" + "strconv" + "sync" ) -// type DingtalkBot struct { -// AccessToken string `json:"access_token"` -// Secret string `json:"secret"` -// } +// 默认配置文件路径,可通过环境变量 GOODBABY_CONFIG 覆盖 +const defaultConfigPath = "config.json" type Config struct { - EnableRegistry bool `json:"enable_registry"` - TimeoutDurationHours int `json:"timeout_duration_hours"` + // HTTP 监听地址,如 ":8088" + ListenAddr string `json:"listen_addr"` + // 是否开放注册 + EnableRegistry bool `json:"enable_registry"` + // 规则执行的最长重试时间(小时),用于指数退避的整体超时 + 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 GetConfig() (config Config, err error) { - content, err := os.ReadFile("config.json") +func defaultConfig() Config { + return Config{ + ListenAddr: ":8088", + EnableRegistry: true, + TimeoutDurationHours: 6, + CheckIntervalMinutes: 10, + DatabasePath: "data.db", + SessionMaxAgeHours: 24 * 7, + AllowedOrigins: []string{}, + LogRetainCount: 500, + } +} + +var ( + once sync.Once + cached Config + loadErr error + cacheMux sync.RWMutex +) + +// GetConfig 读取配置,只在首次调用时读盘,之后返回缓存。 +// +// 配置文件不存在时会以默认值创建一份,缺失的字段会被补齐并写回。 +func GetConfig() (Config, error) { + once.Do(func() { + cfg, err := load() + cacheMux.Lock() + cached, loadErr = cfg, err + cacheMux.Unlock() + }) + + cacheMux.RLock() + defer cacheMux.RUnlock() + return cached, loadErr +} + +// MustGetConfig 与 GetConfig 相同,但在出错时直接退出,用于启动阶段 +func MustGetConfig() Config { + cfg, err := GetConfig() if err != nil { - return config, err + log.Fatalf("加载配置失败: %v", err) + } + return cfg +} + +func configPath() string { + if p := os.Getenv("GOODBABY_CONFIG"); p != "" { + return p + } + return defaultConfigPath +} + +func load() (Config, error) { + path := configPath() + config := defaultConfig() + + content, err := os.ReadFile(path) + switch { + case err == nil: + if err := json.Unmarshal(content, &config); err != nil { + return config, fmt.Errorf("解析配置文件 %s 失败: %w", path, err) + } + case errors.Is(err, fs.ErrNotExist): + log.Printf("配置文件 %s 不存在,使用默认配置创建", path) + default: + return config, fmt.Errorf("读取配置文件 %s 失败: %w", path, err) } - err = json.Unmarshal(content, &config) - if err != nil { - return config, err + changed := normalize(&config) + applyEnvOverrides(&config) + + if changed { + if err := save(path, config); err != nil { + // 只读挂载等场景下写回失败不应阻断启动 + log.Printf("警告: 写回配置文件失败: %v", err) + } } 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) +} diff --git a/docker-compose.yml b/docker-compose.yml index f0e4244..2d0dd8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,13 @@ services: goodbaby: - image: mei232/goodbaby:v2.0.1 + image: mei232/goodbaby:v2.1.0 + build: . ports: - "8088:8088" environment: - TZ=Asia/Shanghai - GIN_MODE=release restart: unless-stopped - networks: - - app-network volumes: - - ./config.json:/app/config.json:ro - - ./data.db:/app/data.db:ro - -networks: - app-network: - driver: bridge \ No newline at end of file + # sqlite 需要写权限,config.json 首次启动会自动生成,不能挂成只读 + - ./data:/app/data diff --git a/docs/bilibili-config.md b/docs/bilibili-config.md index e73206b..4b4e84d 100644 --- a/docs/bilibili-config.md +++ b/docs/bilibili-config.md @@ -1,17 +1,25 @@ -# Bilibili +# Bilibili 配置 -## Account Config +账号类型: `bilibili`,规则类型: `bilibili-dynamic`。 + +## 账号配置 (Account Config) ```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 { - "msg": "something" + "msg": "要发送的动态内容" } ``` + +定时器到期后会以该账号发布一条纯文本动态。 diff --git a/docs/dingtalk-config.md b/docs/dingtalk-config.md new file mode 100644 index 0000000..478e373 --- /dev/null +++ b/docs/dingtalk-config.md @@ -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" +} +``` diff --git a/docs/email-config.md b/docs/email-config.md index b03405b..3b24aca 100644 --- a/docs/email-config.md +++ b/docs/email-config.md @@ -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"] +} +``` + +每个收件人独立发送、独立重试,互不影响。 diff --git a/docs/github-config.md b/docs/github-config.md new file mode 100644 index 0000000..8a63e06 --- /dev/null +++ b/docs/github-config.md @@ -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"] +} +``` + +只填仓库名,不含所有者。 diff --git a/docs/onebot-config.md b/docs/onebot-config.md new file mode 100644 index 0000000..44599d9 --- /dev/null +++ b/docs/onebot-config.md @@ -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 号)至少填写一项。 diff --git a/drivers/bilibili/accountConfigChecker.go b/drivers/bilibili/accountConfigChecker.go index 298bdf8..41b0d39 100644 --- a/drivers/bilibili/accountConfigChecker.go +++ b/drivers/bilibili/accountConfigChecker.go @@ -3,19 +3,21 @@ package bilibili import ( "encoding/json" "fmt" + + "github.com/ssdomei232/goodBaby/internal/meta" ) // BilibiliAccountConfigValidator B站账号配置验证器 type BilibiliAccountConfigValidator struct{} func (v *BilibiliAccountConfigValidator) GetType() string { - return "bilibili-account" + return AccountType } func (v *BilibiliAccountConfigValidator) Validate(config string) error { - var cfg BiliAccount - if err := json.Unmarshal([]byte(config), &cfg); err != nil { - return fmt.Errorf("解析B站账号配置失败: %v", err) + cfg, err := parseAccount(config) + if err != nil { + return err } if cfg.RawCookies == "" { @@ -24,3 +26,49 @@ func (v *BilibiliAccountConfigValidator) Validate(config string) error { 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 +} diff --git a/drivers/bilibili/bilibili.go b/drivers/bilibili/bilibili.go index 733fa6e..8786f0e 100644 --- a/drivers/bilibili/bilibili.go +++ b/drivers/bilibili/bilibili.go @@ -2,55 +2,40 @@ package bilibili import ( "context" - "log" - "time" + "fmt" "github.com/CuteReimu/bilibili/v2" - "github.com/cenkalti/backoff/v5" - "github.com/ssdomei232/goodBaby/configs" + "github.com/ssdomei232/goodBaby/internal/retry" "github.com/ssdomei232/goodBaby/model" ) -// 在Bilibili动态发送消息 +// SendBiliDynamicMsg 在 Bilibili 动态发送消息 // // 暂时没有处理 429 和 403 的区别对待 -func SendBiliDynamicMsg(rule *model.Rule) { - config, err := configs.GetConfig() - if err != nil { - log.Printf("获取配置失败: %v", err) - return - } - var timeout time.Duration = time.Duration(config.TimeoutDurationHours) * time.Hour - +func SendBiliDynamicMsg(ctx context.Context, rule *model.Rule) error { biliClient, err := getBiliClient(rule) if err != nil { - log.Printf("获取B站客户端失败: %v", err) - return - } - biliDyncmicConfig, err := getBiliDynamicConfig(rule) - if err != nil { - log.Printf("获取B站动态配置失败: %v", err) - return + return fmt.Errorf("获取B站客户端失败: %w", err) } - var dynamicParams bilibili.CreateDynamicParam - dynamicParams = bilibili.CreateDynamicParam{ + biliDynamicConfig, err := getBiliDynamicConfig(rule) + if err != nil { + return fmt.Errorf("获取B站动态配置失败: %w", err) + } + + dynamicParams := bilibili.CreateDynamicParam{ DynamicId: 0, Type: 4, Rid: 0, - Content: biliDyncmicConfig.Msg, + Content: biliDynamicConfig.Msg, } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - operation := func() (string, error) { + if err := retry.Do(ctx, func() error { _, 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())) - if err != nil { - log.Printf("发送B站动态失败: %v", err) - } + return nil } diff --git a/drivers/bilibili/model.go b/drivers/bilibili/model.go index 5589a6f..d2eca89 100644 --- a/drivers/bilibili/model.go +++ b/drivers/bilibili/model.go @@ -1,5 +1,12 @@ package bilibili +const ( + // AccountType B 站账号类型标识 + AccountType = "bilibili" + // RuleTypeDynamic 发送 B 站动态的规则类型标识 + RuleTypeDynamic = "bilibili-dynamic" +) + type BiliAccount struct { RawCookies string `json:"raw_cookies"` } diff --git a/drivers/bilibili/ruleConfigChecker.go b/drivers/bilibili/ruleConfigChecker.go index 06e0109..8bdfd0a 100644 --- a/drivers/bilibili/ruleConfigChecker.go +++ b/drivers/bilibili/ruleConfigChecker.go @@ -3,13 +3,15 @@ package bilibili import ( "encoding/json" "fmt" + + "github.com/ssdomei232/goodBaby/internal/meta" ) // BilibiliDynamicRuleValidator B站动态规则验证器 type BilibiliDynamicRuleValidator struct{} func (v *BilibiliDynamicRuleValidator) GetType() string { - return "bilibili-dynamic" + return RuleTypeDynamic } func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error { @@ -22,5 +24,28 @@ func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error { return fmt.Errorf("B站动态规则配置中 msg 不能为空") } + if len([]rune(config.Msg)) > 1000 { + return fmt.Errorf("B站动态内容过长") + } + 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: "要发送的动态正文", + }, + }, + } +} diff --git a/drivers/bilibili/runner.go b/drivers/bilibili/runner.go index 07e34e4..4596bd7 100644 --- a/drivers/bilibili/runner.go +++ b/drivers/bilibili/runner.go @@ -1,6 +1,7 @@ package bilibili import ( + "context" "log" "github.com/ssdomei232/goodBaby/model" @@ -10,13 +11,11 @@ import ( type BilibiliDynamicExecutor struct{} 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) - SendBiliDynamicMsg(rule) - - return nil + return SendBiliDynamicMsg(ctx, rule) } diff --git a/drivers/bilibili/tool.go b/drivers/bilibili/tool.go index 70bff62..39e866b 100644 --- a/drivers/bilibili/tool.go +++ b/drivers/bilibili/tool.go @@ -8,38 +8,26 @@ import ( "github.com/ssdomei232/goodBaby/model" ) +func newClient(rawCookies string) *bilibili.Client { + client := bilibili.New() + client.SetRawCookies(rawCookies) + return client +} + // 获取 Bilibili Client func getBiliClient(rule *model.Rule) (*bilibili.Client, error) { - client := bilibili.New() - gormDB, err := db.GetGormDB() - if err != nil { + var accountConfig BiliAccount + if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil { return nil, err } - // 1. 通过 account_id 获取到对应的 account 配置 - 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 + return newClient(accountConfig.RawCookies), nil } // 获取 Bilibili Dynamic Config func getBiliDynamicConfig(rule *model.Rule) (*BiliDynamicConfig, error) { var biliDynamicConfig BiliDynamicConfig - err := json.Unmarshal([]byte(rule.ConfigJson), &biliDynamicConfig) - if err != nil { + if err := json.Unmarshal([]byte(rule.ConfigJson), &biliDynamicConfig); err != nil { return nil, err } diff --git a/drivers/dingtalk/dingtalk.go b/drivers/dingtalk/dingtalk.go index 9867bed..0a45557 100644 --- a/drivers/dingtalk/dingtalk.go +++ b/drivers/dingtalk/dingtalk.go @@ -1,23 +1,33 @@ -// dingtalk 仅用于发送提醒 +// Package dingtalk 提供钉钉自定义机器人的提醒与规则执行能力 package dingtalk import ( - "log" + "fmt" "github.com/CatchZeng/dingtalk/pkg/dingtalk" "github.com/ssdomei232/goodBaby/model" ) -func SendDingTalkMsg(user *model.User, title string, msg string) { - dingtalkAccount := getDingTalkConfigFromUser(user) - if dingtalkAccount == nil { - return - } - - client := dingtalk.NewClient(dingtalkAccount.AccessToken, dingtalkAccount.Secret) - sendMsg := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg) - _, _, err := client.Send(sendMsg) - if err != nil { - log.Printf("发送钉钉消息失败: %v", err) +// SendDingTalkMsg 使用用户配置的钉钉机器人发送提醒 +// +// 用户没有配置钉钉机器人时返回 ErrNoUserConfig +func SendDingTalkMsg(user *model.User, title string, msg string) error { + config := getDingTalkConfigFromUser(user) + if config == nil { + return ErrNoUserConfig } + return send(config, title, msg) +} + +// ErrNoUserConfig 表示用户没有配置钉钉提醒 +var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人") + +func send(config *DingTalkConfig, title, msg string) error { + client := dingtalk.NewClient(config.AccessToken, config.Secret) + message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg) + + if _, _, err := client.Send(message); err != nil { + return fmt.Errorf("发送钉钉消息失败: %w", err) + } + return nil } diff --git a/drivers/dingtalk/model.go b/drivers/dingtalk/model.go index eedc7ab..220a8cd 100644 --- a/drivers/dingtalk/model.go +++ b/drivers/dingtalk/model.go @@ -1,6 +1,17 @@ package dingtalk +// RuleType 钉钉机器人规则类型标识 +const RuleType = "dingtalk" + +// DingTalkConfig 钉钉自定义机器人凭据,用户级提醒配置也用这个结构 type DingTalkConfig struct { AccessToken string `json:"access_token"` Secret string `json:"secret"` } + +// DingTalkRuleConfig 钉钉规则配置:凭据 + 要发送的内容 +type DingTalkRuleConfig struct { + DingTalkConfig + Title string `json:"title"` + Msg string `json:"msg"` +} diff --git a/drivers/dingtalk/ruleConfigChecker.go b/drivers/dingtalk/ruleConfigChecker.go index 1fc66f7..5ee1fc3 100644 --- a/drivers/dingtalk/ruleConfigChecker.go +++ b/drivers/dingtalk/ruleConfigChecker.go @@ -3,24 +3,56 @@ package dingtalk import ( "encoding/json" "fmt" + + "github.com/ssdomei232/goodBaby/internal/meta" ) // DingTalkRuleValidator 钉钉规则验证器 type DingTalkRuleValidator struct{} func (v *DingTalkRuleValidator) GetType() string { - return "dingtalk" + return RuleType } func (v *DingTalkRuleValidator) Validate(configJSON string) error { - var config DingTalkConfig - if err := json.Unmarshal([]byte(configJSON), &config); err != nil { - return fmt.Errorf("解析钉钉规则配置失败: %v", err) + config, err := ParseRuleConfig(configJSON) + if err != nil { + return err } if config.AccessToken == "" { return fmt.Errorf("钉钉规则配置中 access_token 不能为空") } + if config.Title == "" { + return fmt.Errorf("钉钉规则配置中 title 不能为空") + } + if config.Msg == "" { + return fmt.Errorf("钉钉规则配置中 msg 不能为空") + } 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 +} diff --git a/drivers/dingtalk/runner.go b/drivers/dingtalk/runner.go new file mode 100644 index 0000000..0f49e2a --- /dev/null +++ b/drivers/dingtalk/runner.go @@ -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) + }) +} diff --git a/drivers/email/accountConfigChecker.go b/drivers/email/accountConfigChecker.go index 39aed17..145aaab 100644 --- a/drivers/email/accountConfigChecker.go +++ b/drivers/email/accountConfigChecker.go @@ -1,29 +1,33 @@ package email import ( + "context" "encoding/json" "fmt" + "net/mail" + + "github.com/ssdomei232/goodBaby/internal/meta" + "github.com/ssdomei232/goodBaby/internal/retry" ) // EmailAccountConfigValidator 邮箱账号配置验证器 type EmailAccountConfigValidator struct{} func (v *EmailAccountConfigValidator) GetType() string { - return "email-account" + return AccountType } func (v *EmailAccountConfigValidator) Validate(config string) error { - // 邮箱账号配置验证逻辑 - var cfg EmailAccountConfig - if err := json.Unmarshal([]byte(config), &cfg); err != nil { - return fmt.Errorf("解析邮箱账号配置失败: %v", err) + cfg, err := parseAccountConfig(config) + if err != nil { + return err } if cfg.SMTPServer == "" { return fmt.Errorf("邮箱账号配置中 SMTP 服务器不能为空") } - if cfg.Port == 0 { - return fmt.Errorf("邮箱账号配置中 SMTP 端口不能为空") + if cfg.Port <= 0 || cfg.Port > 65535 { + return fmt.Errorf("邮箱账号配置中 SMTP 端口不合法") } if cfg.Username == "" { return fmt.Errorf("邮箱账号配置中用户名不能为空") @@ -31,6 +35,79 @@ func (v *EmailAccountConfigValidator) Validate(config string) error { if cfg.Password == "" { 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 } + +// 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 +} diff --git a/drivers/email/email.go b/drivers/email/email.go index 674aaa2..de364a3 100644 --- a/drivers/email/email.go +++ b/drivers/email/email.go @@ -1,20 +1,51 @@ package email import ( - "log" + "context" + "fmt" + "strings" + "sync" "github.com/ssdomei232/goodBaby/model" ) -// 向邮件列表发送消息 -func SendMail(rule *model.Rule) { +// SendMail 向规则配置里的所有地址发送邮件。 +// +// 每个地址单独重试,互不阻塞;全部失败/部分失败都会汇总成错误返回, +// 由 runner 记录到执行日志中。 +func SendMail(ctx context.Context, rule *model.Rule) error { emailRule, err := GetEmailRuleFromRule(rule) if err != nil { - log.Printf("获取邮件规则配置失败: %v", err) - return + return fmt.Errorf("获取邮件规则配置失败: %w", err) } - for _, destinations := range emailRule.Destinations { - go sendMailMsgWithRetry(rule, destinations) // 使用 goroutine 发送邮件,避免指数退避阻塞其他地址 + accountConfig, err := GetEmailAccountFromRule(rule) + 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 } diff --git a/drivers/email/model.go b/drivers/email/model.go index ca4bafa..92716e6 100644 --- a/drivers/email/model.go +++ b/drivers/email/model.go @@ -1,11 +1,46 @@ 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 { - SMTPServer string `json:"smtp_server"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - TestDestination string `json:"test_destination"` // 用于测试邮件发送的地址 + SMTPServer string `json:"smtp_server"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + // 加密方式,留空时按 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 { diff --git a/drivers/email/ruleConfigChecker.go b/drivers/email/ruleConfigChecker.go index 9f9cf19..5710526 100644 --- a/drivers/email/ruleConfigChecker.go +++ b/drivers/email/ruleConfigChecker.go @@ -4,13 +4,15 @@ import ( "encoding/json" "fmt" "net/mail" + + "github.com/ssdomei232/goodBaby/internal/meta" ) // EmailRuleValidator 邮件规则验证器 type EmailRuleValidator struct{} func (v *EmailRuleValidator) GetType() string { - return "email" + return RuleType } func (v *EmailRuleValidator) Validate(configJSON string) error { @@ -40,3 +42,18 @@ func (v *EmailRuleValidator) Validate(configJSON string) error { 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"}, + }, + } +} diff --git a/drivers/email/runner.go b/drivers/email/runner.go index 8cd47ac..bd21ad4 100644 --- a/drivers/email/runner.go +++ b/drivers/email/runner.go @@ -1,6 +1,7 @@ package email import ( + "context" "log" "github.com/ssdomei232/goodBaby/model" @@ -10,13 +11,11 @@ import ( type EmailExecutor struct{} 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) - SendMail(rule) - - return nil + return SendMail(ctx, rule) } diff --git a/drivers/email/tool.go b/drivers/email/tool.go index db929f9..de6d8c7 100644 --- a/drivers/email/tool.go +++ b/drivers/email/tool.go @@ -4,113 +4,88 @@ import ( "context" "encoding/json" "fmt" - "log" - "time" - "github.com/cenkalti/backoff/v5" - "github.com/ssdomei232/goodBaby/configs" "github.com/ssdomei232/goodBaby/handler/db" + "github.com/ssdomei232/goodBaby/internal/retry" "github.com/ssdomei232/goodBaby/model" "github.com/wneessen/go-mail" ) -// 从 Rule 中获取 EmailAccount 配置 +// GetEmailAccountFromRule 从 Rule 中获取 EmailAccount 配置 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 - err = json.Unmarshal([]byte(emailAccount.Config), &emailAccountConfig) - if err != nil { + if err := db.LoadAccountConfig(rule.AccountID, &emailAccountConfig); err != nil { return nil, err } - return &emailAccountConfig, nil } -// 从 Rule 中获取 EmailRule 配置 +// GetEmailRuleFromRule 从 Rule 中获取 EmailRule 配置 func GetEmailRuleFromRule(rule *model.Rule) (*EmailRule, error) { var emailRule EmailRule - err := json.Unmarshal([]byte(rule.ConfigJson), &emailRule) - if err != nil { + if err := json.Unmarshal([]byte(rule.ConfigJson), &emailRule); err != nil { return nil, err } 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 { - log.Printf("获取配置失败: %v", err) - return - } - var timeout time.Duration = time.Duration(config.TimeoutDurationHours) * time.Hour - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - operation := func() (string, error) { - err := sendMailMsg(address, rule) - return "", err - } - - _, err = backoff.Retry(ctx, operation, backoff.WithBackOff(backoff.NewExponentialBackOff())) - if err != nil { - log.Printf("发送邮件失败: %v", err) + return nil, fmt.Errorf("创建邮件客户端失败: %w", err) } + return client, nil } -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) - } - - // 创建邮件 +func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.Msg, error) { message := mail.NewMsg() - if err := message.From(emailAccountConfig.Username); err != nil { - return fmt.Errorf("设置发件人失败: %v", err) + if err := message.From(cfg.FromOrDefault()); err != nil { + return nil, fmt.Errorf("设置发件人失败: %w", err) } - if err := message.To(address); err != nil { - return fmt.Errorf("设置收件人失败: %v", err) + return nil, fmt.Errorf("设置收件人失败: %w", err) + } + message.Subject(title) + message.SetBodyString(mail.TypeTextPlain, body) + return message, nil +} + +func sendMailMsgWithRetry(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error { + return retry.Do(ctx, func() error { + return sendMailMsg(ctx, cfg, rule, address) + }) +} + +func sendMailMsg(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error { + client, err := newSMTPClient(cfg) + if err != nil { + return err } - message.Subject(emailRule.Title) - message.SetBodyString(mail.TypeTextPlain, emailRule.Msg) - - // 发送邮件 - if err := client.DialAndSend(message); err != nil { - return fmt.Errorf("发送邮件失败: %v", 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 } diff --git a/drivers/github/accountConfigChecker.go b/drivers/github/accountConfigChecker.go index 8a04651..c969bf5 100644 --- a/drivers/github/accountConfigChecker.go +++ b/drivers/github/accountConfigChecker.go @@ -1,22 +1,26 @@ package github import ( + "context" "encoding/json" "fmt" + + "github.com/google/go-github/v84/github" + "github.com/ssdomei232/goodBaby/internal/meta" + "github.com/ssdomei232/goodBaby/internal/retry" ) // GitHubAccountConfigValidator GitHub账号配置验证器 type GitHubAccountConfigValidator struct{} func (v *GitHubAccountConfigValidator) GetType() string { - return "github-account" + return AccountType } func (v *GitHubAccountConfigValidator) Validate(config string) error { - // GitHub账号配置验证逻辑 - var cfg GithubAccount - if err := json.Unmarshal([]byte(config), &cfg); err != nil { - return fmt.Errorf("解析GitHub账号配置失败: %v", err) + cfg, err := parseAccount(config) + if err != nil { + return err } if cfg.Token == "" { @@ -28,3 +32,44 @@ func (v *GitHubAccountConfigValidator) Validate(config string) error { 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 +} diff --git a/drivers/github/github.go b/drivers/github/github.go index aea03b0..6503e87 100644 --- a/drivers/github/github.go +++ b/drivers/github/github.go @@ -2,41 +2,33 @@ package github import ( "context" - "log" - "time" + "fmt" + "strings" - "github.com/cenkalti/backoff/v5" - "github.com/ssdomei232/goodBaby/configs" + "github.com/ssdomei232/goodBaby/internal/retry" "github.com/ssdomei232/goodBaby/model" ) -// 将repos设置为public -func MakeRepositoryPublic(rule *model.Rule) { - reposConfig, account := GetGithubReposAndAccountFromRule(rule) - if reposConfig == nil || account == nil { - log.Printf("无法获取 GitHub 配置或账户信息") - return +// MakeRepositoryPublic 将规则中配置的仓库设置为 public +func MakeRepositoryPublic(ctx context.Context, rule *model.Rule) error { + reposConfig, account, err := GetGithubReposAndAccountFromRule(rule) + if err != nil { + return err } + var fails []string 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 { - log.Printf("获取配置失败: %v", 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 err != nil { - log.Printf("将仓库 %s 设置为 public 失败: %v", repo, err) + fails = append(fails, fmt.Sprintf("%s: %v", repo, err)) } } + + if len(fails) > 0 { + return fmt.Errorf("%d/%d 个仓库设置为 public 失败: %s", + len(fails), len(reposConfig.Repos), strings.Join(fails, "; ")) + } + return nil } diff --git a/drivers/github/model.go b/drivers/github/model.go index 1a205d3..5242e5f 100644 --- a/drivers/github/model.go +++ b/drivers/github/model.go @@ -1,10 +1,17 @@ package github +const ( + // AccountType GitHub 账号类型标识 + AccountType = "github" + // RuleTypeMakeRepoPublic 公开仓库的规则类型标识 + RuleTypeMakeRepoPublic = "github-repo-public" +) + type GithubReposConfig struct { - Repos []string `yaml:"repos"` + Repos []string `json:"repos"` } type GithubAccount struct { - Token string - Owner string + Token string `json:"token"` + Owner string `json:"owner"` } diff --git a/drivers/github/ruleConfigChecker.go b/drivers/github/ruleConfigChecker.go index 7cb1d1c..a5a9702 100644 --- a/drivers/github/ruleConfigChecker.go +++ b/drivers/github/ruleConfigChecker.go @@ -3,12 +3,15 @@ package github import ( "encoding/json" "fmt" + "strings" + + "github.com/ssdomei232/goodBaby/internal/meta" ) type GithubMakeRepositoryPublicRuleValidator struct{} func (v *GithubMakeRepositoryPublicRuleValidator) GetType() string { - return "github_make_repository_public" + return RuleTypeMakeRepoPublic } func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) error { @@ -21,5 +24,33 @@ func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) er 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 } + +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 账号", + }, + }, + } +} diff --git a/drivers/github/runner.go b/drivers/github/runner.go index 6069ebc..76352c7 100644 --- a/drivers/github/runner.go +++ b/drivers/github/runner.go @@ -1,6 +1,7 @@ package github import ( + "context" "log" "github.com/ssdomei232/goodBaby/model" @@ -10,13 +11,11 @@ import ( type GithubMakeRepoPublicExecutor struct{} 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) - MakeRepositoryPublic(rule) - - return nil + return MakeRepositoryPublic(ctx, rule) } diff --git a/drivers/github/tool.go b/drivers/github/tool.go index dcf95ae..36aed48 100644 --- a/drivers/github/tool.go +++ b/drivers/github/tool.go @@ -3,55 +3,36 @@ package github import ( "context" "encoding/json" - "log" + "fmt" "github.com/google/go-github/v84/github" "github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/model" ) -// 从 rule 中获取 GithubReposConfig 和 GithubAccount -func GetGithubReposAndAccountFromRule(rule *model.Rule) (*GithubReposConfig, *GithubAccount) { +// GetGithubReposAndAccountFromRule 从 rule 中获取 GithubReposConfig 和 GithubAccount +func GetGithubReposAndAccountFromRule(rule *model.Rule) (*GithubReposConfig, *GithubAccount, error) { var githubReposConfig GithubReposConfig + if err := json.Unmarshal([]byte(rule.ConfigJson), &githubReposConfig); err != nil { + return nil, nil, fmt.Errorf("解析 GitHub 规则配置失败: %w", err) + } + var githubAccount GithubAccount - - // 获取 GithubReposConfig - err := json.Unmarshal([]byte(rule.ConfigJson), &githubReposConfig) - if err != nil { - log.Printf("获取 GitHub 配置失败: %v", err) - return nil, nil + if err := db.LoadAccountConfig(rule.AccountID, &githubAccount); err != nil { + return nil, nil, err } - // 获取 GithubAccount - gormDB, err := db.GetGormDB() - if err != nil { - log.Printf("获取 Gorm DB 失败: %v", err) - return nil, nil - } - - err = gormDB.Where("id = ?", rule.AccountID).First(&githubAccount).Error - if err != nil { - log.Printf("获取 GitHub 账户失败: %v", err) - return nil, nil - } - - return &githubReposConfig, &githubAccount + return &githubReposConfig, &githubAccount, nil } -// 将仓库设置为 public -func SetRepositoryPublic(token, owner, repo string) error { - ctx := context.Background() - - // 1. 初始化客户端 +// SetRepositoryPublic 将仓库设置为 public +func SetRepositoryPublic(ctx context.Context, token, owner, repo string) error { client := github.NewClient(nil).WithAuthToken(token) - // 2. 准备修改的参数 opts := &github.Repository{ Visibility: github.Ptr("public"), } - // 3. 执行更新操作 _, _, err := client.Repositories.Edit(ctx, owner, repo, opts) - return err } diff --git a/drivers/onebot/accountConfigChecker.go b/drivers/onebot/accountConfigChecker.go index e74ade0..c3b1f01 100644 --- a/drivers/onebot/accountConfigChecker.go +++ b/drivers/onebot/accountConfigChecker.go @@ -3,19 +3,23 @@ package onebot import ( "encoding/json" "fmt" + "net/url" + "strings" + + "github.com/ssdomei232/goodBaby/internal/meta" ) -// AccountConfigValidator OneBot账号配置验证器接口 +// OneBotAccountConfigValidator OneBot账号配置验证器 type OneBotAccountConfigValidator struct{} func (v *OneBotAccountConfigValidator) GetType() string { - return "onebot" + return AccountType } func (v *OneBotAccountConfigValidator) Validate(config string) error { - var cfg OneBotAccount - if err := json.Unmarshal([]byte(config), &cfg); err != nil { - return fmt.Errorf("解析OneBot账号配置失败: %v", err) + cfg, err := parseAccount(config) + if err != nil { + return err } if cfg.Token == "" { @@ -25,5 +29,44 @@ func (v *OneBotAccountConfigValidator) Validate(config string) error { 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 } + +// 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 +} diff --git a/drivers/onebot/model.go b/drivers/onebot/model.go index eda95c0..6f4212e 100644 --- a/drivers/onebot/model.go +++ b/drivers/onebot/model.go @@ -1,11 +1,53 @@ package onebot +const ( + // AccountType OneBot 账号类型标识 + AccountType = "onebot" + // RuleType 发送 OneBot 消息的规则类型标识 + RuleType = "onebot" +) + type OneBotConfig struct { - SendGroups []int `json:"send_groups"` - Msg string `json:"msg"` + SendGroups []int64 `json:"send_groups"` // 群号列表 + SendUsers []int64 `json:"send_users"` // 好友 QQ 号列表 + Msg string `json:"msg"` } type OneBotAccount struct { URL string `json:"url"` // 例如 "http://localhost:5700" 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 +} diff --git a/drivers/onebot/onebot.go b/drivers/onebot/onebot.go index 40180a9..e8041b0 100644 --- a/drivers/onebot/onebot.go +++ b/drivers/onebot/onebot.go @@ -2,41 +2,50 @@ package onebot import ( "context" - "log" - "time" + "fmt" + "strings" - "github.com/cenkalti/backoff/v5" - "github.com/ssdomei232/goodBaby/configs" + "github.com/ssdomei232/goodBaby/internal/retry" "github.com/ssdomei232/goodBaby/model" ) -func SendOneBotMsg(rule *model.Rule) { - oneBotConfig := getOneBotConfigFromRule(rule) +// SendOneBotMsg 向规则中配置的群和好友发送消息 +func SendOneBotMsg(ctx context.Context, rule *model.Rule) error { + oneBotConfig, err := getOneBotConfigFromRule(rule) + if err != nil { + return err + } + oneBotAccount, err := getOneBotAccountFromRule(rule) if err != nil { - log.Printf("获取OneBot账户失败: %v", err) - return + return fmt.Errorf("获取OneBot账户失败: %w", err) } + var fails []string + total := 0 + 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 { - log.Printf("获取配置失败: %v", 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())) - if err != nil { - log.Printf("发送OneBot消息失败: %v", err) + fails = append(fails, fmt.Sprintf("群 %d: %v", groupID, err)) } } + + for _, userID := range oneBotConfig.SendUsers { + total++ + err := retry.Do(ctx, func() error { + return sendPrivateMsg(oneBotAccount, userID, oneBotConfig.Msg) + }) + if err != nil { + 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 } diff --git a/drivers/onebot/ruleConfigChecker.go b/drivers/onebot/ruleConfigChecker.go index 18eebdc..7a93757 100644 --- a/drivers/onebot/ruleConfigChecker.go +++ b/drivers/onebot/ruleConfigChecker.go @@ -3,13 +3,15 @@ package onebot import ( "encoding/json" "fmt" + + "github.com/ssdomei232/goodBaby/internal/meta" ) // OneBotRuleValidator OneBot规则验证器 type OneBotRuleValidator struct{} func (v *OneBotRuleValidator) GetType() string { - return "onebot" + return RuleType } func (v *OneBotRuleValidator) Validate(configJSON string) error { @@ -22,9 +24,29 @@ func (v *OneBotRuleValidator) Validate(configJSON string) error { return fmt.Errorf("OneBot规则配置中 msg 不能为空") } - if len(config.SendGroups) == 0 { - return fmt.Errorf("OneBot规则配置中 send_groups 不能为空") + if len(config.SendGroups) == 0 && len(config.SendUsers) == 0 { + 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 } + +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"}, + }, + } +} diff --git a/drivers/onebot/runner.go b/drivers/onebot/runner.go index c8c3026..d7338d2 100644 --- a/drivers/onebot/runner.go +++ b/drivers/onebot/runner.go @@ -1,6 +1,7 @@ package onebot import ( + "context" "log" "github.com/ssdomei232/goodBaby/model" @@ -10,13 +11,11 @@ import ( type OneBotExecutor struct{} 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) - SendOneBotMsg(rule) - - return nil + return SendOneBotMsg(ctx, rule) } diff --git a/drivers/onebot/tool.go b/drivers/onebot/tool.go index a025ae7..8cdc81a 100644 --- a/drivers/onebot/tool.go +++ b/drivers/onebot/tool.go @@ -2,46 +2,58 @@ package onebot import ( "encoding/json" - "strconv" + "fmt" "github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/model" napcat "github.com/ssdomei232/napcat-http-go-sdk" ) +func newClient(account *OneBotAccount) *napcat.Client { + return napcat.NewClient(account.Token, account.URL) +} + // 从 Rule 中获取 OneBotConfig -func getOneBotConfigFromRule(rule *model.Rule) *OneBotConfig { +func getOneBotConfigFromRule(rule *model.Rule) (*OneBotConfig, error) { var oneBotConfig OneBotConfig - err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig) - if err != nil { - return nil + if err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig); err != nil { + return nil, fmt.Errorf("解析OneBot规则配置失败: %w", err) } - return &oneBotConfig + return &oneBotConfig, nil } // 从 Rule 中获取 OneBotAccount 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 - err = json.Unmarshal([]byte(oneBotAccountEntry.Config), &oneBotAccount) - if err != nil { + if err := db.LoadAccountConfig(rule.AccountID, &oneBotAccount); err != nil { return nil, err } - 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() } diff --git a/go.mod b/go.mod index a4f5489..90f3c8a 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,12 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 github.com/gin-contrib/sessions v1.0.4 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/robfig/cron/v3 v3.0.1 github.com/ssdomei232/napcat-http-go-sdk v1.0.0 github.com/wneessen/go-mail v0.7.2 golang.org/x/crypto v0.48.0 - gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 ) @@ -22,8 +22,10 @@ require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // 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/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/universal-translator v0.18.1 // 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-yaml v1.18.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/securecookie v1.1.2 // 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/mattn/go-colorable v0.1.14 // 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/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.5.1 // 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/spf13/cast v1.9.2 // 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/tools v0.42.0 // 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 ) diff --git a/go.sum b/go.sum index 4a67c06..0b38f1b 100644 --- a/go.sum +++ b/go.sum @@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 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-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/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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 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/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 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-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-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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 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/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/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/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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/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= diff --git a/handler/checker/checker.go b/handler/checker/checker.go index b540aa6..2037f92 100644 --- a/handler/checker/checker.go +++ b/handler/checker/checker.go @@ -10,9 +10,9 @@ import ( "github.com/ssdomei232/goodBaby/model" ) -// 定时检查所有 timers 是否需要被触发 +// CheckTimers 定时检查所有 timers 是否需要被触发 // -// 同时向即将被触发的 timers 发送通知 +// 同时向即将到期的 timers 对应的用户发送提醒 func CheckTimers() { gormDB, err := db.GetGormDB() if err != nil { @@ -20,32 +20,39 @@ func CheckTimers() { return } - // 1. 获取所有 timers + // 1. 获取所有启用中的 timers 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) return } - // 2. 检查每个 timer 是否需要被触发 - for _, timer := range timers { - // 计算下次触发时间 - nextSignTime := timer.LastSign + timer.SignDerationSeconds + currentTime := time.Now().Unix() - // 计算提醒时间 - remindTime := nextSignTime - timer.RemindTimeSeconds + // 2. 检查每个 timer 是否需要被提醒或触发 + for i := range timers { + timer := timers[i] - // 获取当前时间 - currentTime := time.Now().Unix() - - if currentTime >= remindTime && currentTime < nextSignTime { - // 发送通知 - reminder.Reminder(&timer) + // 已触发过的 timer 在用户重新签到之前不再处理 + if timer.Triggered { + continue } + nextSignTime := timer.NextSignTime() + remindTime := timer.RemindAt() + if currentTime >= nextSignTime { - // 触发 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) + } } } } diff --git a/handler/db/account.go b/handler/db/account.go new file mode 100644 index 0000000..0156520 --- /dev/null +++ b/handler/db/account.go @@ -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 +} diff --git a/handler/db/db.go b/handler/db/db.go index d816254..a38d284 100644 --- a/handler/db/db.go +++ b/handler/db/db.go @@ -1,16 +1,78 @@ package db import ( + "fmt" "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/logger" ) +var ( + once sync.Once + instance *gorm.DB + initErr error +) + +// GetGormDB 返回全局唯一的 gorm 连接。 +// +// 之前每次调用都会新开一个 sqlite 连接,在并发执行规则时很容易触发 +// "database is locked",这里改为单例并开启 WAL。 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 { - 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{}, + ) } diff --git a/handler/logstore/logstore.go b/handler/logstore/logstore.go new file mode 100644 index 0000000..654b049 --- /dev/null +++ b/handler/logstore/logstore.go @@ -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) + } +} diff --git a/handler/reminder/reminder.go b/handler/reminder/reminder.go index b95b764..39648bf 100644 --- a/handler/reminder/reminder.go +++ b/handler/reminder/reminder.go @@ -1,14 +1,18 @@ package reminder import ( + "errors" + "fmt" "log" + "time" "github.com/ssdomei232/goodBaby/drivers/dingtalk" "github.com/ssdomei232/goodBaby/handler/db" + "github.com/ssdomei232/goodBaby/handler/logstore" "github.com/ssdomei232/goodBaby/model" ) -// reminder 负责在 checker 检查到 timer 即将被触发时,向用户发送提醒 +// Reminder 在 checker 检查到 timer 即将到期时,向用户发送提醒 func Reminder(timer *model.Timer) { gormDB, err := db.GetGormDB() if err != nil { @@ -24,5 +28,29 @@ func Reminder(timer *model.Timer) { } // 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, + }) } diff --git a/handler/runner/interface.go b/handler/runner/interface.go index c52c23e..0587f3d 100644 --- a/handler/runner/interface.go +++ b/handler/runner/interface.go @@ -1,9 +1,13 @@ package runner import ( + "context" "fmt" + "sort" + "sync" "github.com/ssdomei232/goodBaby/drivers/bilibili" + "github.com/ssdomei232/goodBaby/drivers/dingtalk" "github.com/ssdomei232/goodBaby/drivers/email" "github.com/ssdomei232/goodBaby/drivers/github" "github.com/ssdomei232/goodBaby/drivers/onebot" @@ -12,8 +16,8 @@ import ( // RuleExecutor 规则执行器接口 type RuleExecutor interface { - // Execute 执行规则 - Execute(rule *model.Rule) error + // Execute 执行规则,ctx 决定了重试的最长时间 + Execute(ctx context.Context, rule *model.Rule) error // GetType 获取执行器支持的规则类型 GetType() string } @@ -36,13 +40,13 @@ func (er *ExecutorRegistry) Register(executor RuleExecutor) { } // 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] if !exists { return fmt.Errorf("不支持的规则类型: %s", rule.Type) } - return executor.Execute(rule) + return executor.Execute(ctx, rule) } // GetSupportedTypes 获取所有支持的规则类型 @@ -51,24 +55,31 @@ func (er *ExecutorRegistry) GetSupportedTypes() []string { for t := range er.executors { types = append(types, t) } + sort.Strings(types) return types } -var globalExecutorRegistry *ExecutorRegistry +var ( + registryOnce sync.Once + globalExecutorRegistry *ExecutorRegistry +) // InitExecutorRegistry 初始化执行器注册表并注册所有执行器 func InitExecutorRegistry() *ExecutorRegistry { - registry := NewExecutorRegistry() + registryOnce.Do(func() { + registry := NewExecutorRegistry() - // 注册所有规则执行器 - registry.Register(&bilibili.BilibiliDynamicExecutor{}) - registry.Register(&email.EmailExecutor{}) - registry.Register(&github.GithubMakeRepoPublicExecutor{}) - registry.Register(&onebot.OneBotExecutor{}) - // 未来添加新规则类型时,在这里注册即可 + // 注册所有规则执行器 + registry.Register(&bilibili.BilibiliDynamicExecutor{}) + registry.Register(&email.EmailExecutor{}) + registry.Register(&github.GithubMakeRepoPublicExecutor{}) + registry.Register(&onebot.OneBotExecutor{}) + registry.Register(&dingtalk.DingTalkExecutor{}) + // 未来添加新规则类型时,在这里注册即可 - globalExecutorRegistry = registry - return registry + globalExecutorRegistry = registry + }) + return globalExecutorRegistry } // GetGlobalExecutorRegistry 获取全局执行器注册表 diff --git a/handler/runner/runner.go b/handler/runner/runner.go index f5d8c3c..50c8ec4 100644 --- a/handler/runner/runner.go +++ b/handler/runner/runner.go @@ -1,13 +1,21 @@ package runner import ( + "context" "log" + "sync" + "time" "github.com/ssdomei232/goodBaby/handler/db" + "github.com/ssdomei232/goodBaby/handler/logstore" + "github.com/ssdomei232/goodBaby/internal/retry" "github.com/ssdomei232/goodBaby/model" ) -// runner 执行需要被执行的 Rule +// Runner 执行某个 Timer 下所有启用的规则。 +// +// 触发前先把 Timer 标记为已触发,避免规则执行期间(可能长达数小时) +// 被下一轮检查重复触发;用户重新签到后 Triggered 会被重置。 func Runner(timer *model.Timer) { gormDB, err := db.GetGormDB() if err != nil { @@ -17,22 +25,61 @@ func Runner(timer *model.Timer) { // 1. 获取所有需要执行的 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) return } - // 2. 执行每个 Rule - for _, rule := range rules { - go executeRule(&rule) + // 2. 标记已触发 + now := time.Now().Unix() + if err := gormDB.Model(&model.Timer{}).Where("id = ?", timer.ID). + Updates(map[string]any{"triggered": true, "last_trigger": now}).Error; err != nil { + log.Printf("标记 Timer(ID: %d) 已触发失败: %v", timer.ID, err) + return } + + log.Printf("Timer %s (ID: %d) 已到期,开始执行 %d 条规则", timer.Name, timer.ID, len(rules)) + + // 3. 并发执行每个 Rule + var wg sync.WaitGroup + for i := range rules { + rule := rules[i] + wg.Add(1) + go func() { + defer wg.Done() + ExecuteRule(&rule, model.TriggerTimer) + }() + } + wg.Wait() } -// executeRule 执行单个规则 -func executeRule(rule *model.Rule) { - executorRegistry := GetGlobalExecutorRegistry() +// ExecuteRule 执行单个规则并记录执行日志 +func ExecuteRule(rule *model.Rule, trigger string) error { + ctx, cancel := retry.ExecutionContext() + defer cancel() + return ExecuteRuleWithContext(ctx, rule, trigger) +} - if err := executorRegistry.Execute(rule); err != nil { +// ExecuteRuleWithContext 使用给定 context 执行规则,手动测试时可传入较短的超时 +func ExecuteRuleWithContext(ctx context.Context, rule *model.Rule, trigger string) error { + err := GetGlobalExecutorRegistry().Execute(ctx, rule) + + message := "执行成功" + if err != nil { + message = err.Error() log.Printf("执行规则失败 [ID: %d, Type: %s]: %v", rule.ID, rule.Type, err) } + + 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 } diff --git a/internal/accountConfigChecker/interface.go b/internal/accountConfigChecker/interface.go index e3228b7..52ab164 100644 --- a/internal/accountConfigChecker/interface.go +++ b/internal/accountConfigChecker/interface.go @@ -3,14 +3,25 @@ package accountConfigChecker import ( "encoding/json" "fmt" + "sort" + + "github.com/ssdomei232/goodBaby/internal/meta" ) // AccountValidator 账号验证器接口 type AccountValidator interface { // Validate 验证账号配置是否合法 - Validate(account string) error + Validate(config string) error // GetType 获取验证器支持的账号类型 GetType() string + // Meta 返回给 WebUI 渲染表单用的元数据 + Meta() meta.AccountMeta +} + +// AccountTester 可选接口:支持真实连通性测试的账号类型实现它 +type AccountTester interface { + // Test 使用给定配置访问一次第三方服务,验证凭据是否可用 + Test(config string) error } // ValidatorRegistry 验证器注册表 @@ -51,11 +62,66 @@ func (vr *ValidatorRegistry) Validate(accountType string, config string) error { 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 获取所有支持的账号类型 func (vr *ValidatorRegistry) GetSupportedTypes() []string { types := make([]string, 0, len(vr.validators)) for t := range vr.validators { types = append(types, t) } + sort.Strings(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 +} diff --git a/internal/accountConfigChecker/reg.go b/internal/accountConfigChecker/reg.go index 90fd94e..27cc8a7 100644 --- a/internal/accountConfigChecker/reg.go +++ b/internal/accountConfigChecker/reg.go @@ -1,20 +1,31 @@ package accountConfigChecker import ( + "sync" + "github.com/ssdomei232/goodBaby/drivers/bilibili" "github.com/ssdomei232/goodBaby/drivers/email" "github.com/ssdomei232/goodBaby/drivers/github" "github.com/ssdomei232/goodBaby/drivers/onebot" ) +var ( + once sync.Once + registry *ValidatorRegistry +) + +// InitValidatorRegistry 返回全局唯一的账号验证器注册表 func InitValidatorRegistry() *ValidatorRegistry { - registry := NewValidatorRegistry() + once.Do(func() { + r := NewValidatorRegistry() - // 注册所有账号验证器 - registry.Register(&bilibili.BilibiliAccountConfigValidator{}) - registry.Register(&email.EmailAccountConfigValidator{}) - registry.Register(&github.GitHubAccountConfigValidator{}) - registry.Register(&onebot.OneBotAccountConfigValidator{}) + // 注册所有账号验证器 + r.Register(&bilibili.BilibiliAccountConfigValidator{}) + r.Register(&email.EmailAccountConfigValidator{}) + r.Register(&github.GitHubAccountConfigValidator{}) + r.Register(&onebot.OneBotAccountConfigValidator{}) + registry = r + }) return registry } diff --git a/internal/meta/mask.go b/internal/meta/mask.go new file mode 100644 index 0000000..d960d44 --- /dev/null +++ b/internal/meta/mask.go @@ -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) +} diff --git a/internal/meta/meta.go b/internal/meta/meta.go new file mode 100644 index 0000000..c1342b3 --- /dev/null +++ b/internal/meta/meta.go @@ -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 +} diff --git a/internal/retry/retry.go b/internal/retry/retry.go new file mode 100644 index 0000000..5d3e995 --- /dev/null +++ b/internal/retry/retry.go @@ -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()) +} diff --git a/internal/ruleConfigChecker/interface.go b/internal/ruleConfigChecker/interface.go index 44662cb..d0aac18 100644 --- a/internal/ruleConfigChecker/interface.go +++ b/internal/ruleConfigChecker/interface.go @@ -3,6 +3,9 @@ package ruleConfigChecker import ( "encoding/json" "fmt" + "sort" + + "github.com/ssdomei232/goodBaby/internal/meta" ) // RuleValidator 规则验证器接口 @@ -11,6 +14,8 @@ type RuleValidator interface { Validate(configJSON string) error // GetType 获取验证器支持的规则类型 GetType() string + // Meta 返回给 WebUI 渲染表单用的元数据 + Meta() meta.RuleMeta } // ValidatorRegistry 验证器注册表 @@ -57,5 +62,25 @@ func (vr *ValidatorRegistry) GetSupportedTypes() []string { for t := range vr.validators { types = append(types, t) } + sort.Strings(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 +} diff --git a/internal/ruleConfigChecker/reg.go b/internal/ruleConfigChecker/reg.go index 49dbf36..4a6a74b 100644 --- a/internal/ruleConfigChecker/reg.go +++ b/internal/ruleConfigChecker/reg.go @@ -1,6 +1,8 @@ package ruleConfigChecker import ( + "sync" + "github.com/ssdomei232/goodBaby/drivers/bilibili" "github.com/ssdomei232/goodBaby/drivers/dingtalk" "github.com/ssdomei232/goodBaby/drivers/email" @@ -8,16 +10,24 @@ import ( "github.com/ssdomei232/goodBaby/drivers/onebot" ) -// InitValidatorRegistry 初始化验证器注册表并注册所有验证器 +var ( + once sync.Once + registry *ValidatorRegistry +) + +// InitValidatorRegistry 返回全局唯一的规则验证器注册表 func InitValidatorRegistry() *ValidatorRegistry { - registry := NewValidatorRegistry() + once.Do(func() { + r := NewValidatorRegistry() - // 注册所有规则验证器 - registry.Register(&bilibili.BilibiliDynamicRuleValidator{}) - registry.Register(&email.EmailRuleValidator{}) - registry.Register(&onebot.OneBotRuleValidator{}) - registry.Register(&dingtalk.DingTalkRuleValidator{}) - registry.Register(&github.GithubMakeRepositoryPublicRuleValidator{}) + // 注册所有规则验证器 + r.Register(&bilibili.BilibiliDynamicRuleValidator{}) + r.Register(&email.EmailRuleValidator{}) + r.Register(&onebot.OneBotRuleValidator{}) + r.Register(&dingtalk.DingTalkRuleValidator{}) + r.Register(&github.GithubMakeRepositoryPublicRuleValidator{}) + registry = r + }) return registry } diff --git a/main.go b/main.go index b0e21d0..46fd557 100644 --- a/main.go +++ b/main.go @@ -1,33 +1,71 @@ package main import ( - "crypto/rand" "fmt" + "log" + "net/http" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" "github.com/ssdomei232/goodBaby/api/account" + "github.com/ssdomei232/goodBaby/api/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/timer" "github.com/ssdomei232/goodBaby/api/user" + "github.com/ssdomei232/goodBaby/configs" "github.com/ssdomei232/goodBaby/handler/checker" + "github.com/ssdomei232/goodBaby/handler/db" "github.com/ssdomei232/goodBaby/handler/runner" + "github.com/ssdomei232/goodBaby/web" +) + +var ( + version = "dev" + buildDate = "unknown" + gitCommit = "unknown" ) func main() { + log.Printf("goodBaby %s (%s, %s) 启动中...", version, gitCommit, buildDate) + + config := configs.MustGetConfig() + + // 初始化数据库(建表/迁移)与执行器 + db.MustInit() runner.InitExecutorRegistry() + // 启动定时检查 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() 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)) + // 前端本地开发时的 CORS 支持 + if len(config.AllowedOrigins) > 0 { + r.Use(corsMiddleware(config.AllowedOrigins)) + } + v1 := r.Group("/api/v1") { + v1.GET("/site", apimeta.HandleGetSiteInfo) v1.POST("/user/registry", user.HandleRegistry) v1.POST("/user/login", user.HandleLogin) } @@ -36,9 +74,28 @@ func main() { authorized := v1.Group("/") authorized.Use(user.AuthMiddleware()) { + authorized.GET("/providers", apimeta.HandleGetProviders) + authorized.GET("/dashboard", dashboard.HandleGetOverview) + users := authorized.Group("/user") { 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") @@ -46,6 +103,7 @@ func main() { rules.GET("/", rule.HandleGetAllRules) rules.POST("/", rule.HandleCreateRule) rules.PUT("/:ruleID", rule.HandleEditRule) + rules.POST("/:ruleID/test", rule.HandleTestRule) rules.DELETE("/:ruleID", rule.HandleDeleteRule) } @@ -53,19 +111,49 @@ func main() { { accounts.GET("/", account.HandleGetAllAccounts) accounts.POST("/", account.HandleAddAccount) + accounts.PUT("/:accountID", account.HandleEditAccount) + accounts.POST("/:accountID/test", account.HandleTestAccount) accounts.GET("/:accountID/check", account.HandleCheckDeleteAccount) 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 { - key := make([]byte, length) - _, err := rand.Read(key) - if err != nil { - panic(fmt.Sprintf("无法生成随机密钥: %v", err)) +// corsMiddleware 允许配置中的来源跨域访问(携带 Cookie) +func corsMiddleware(allowedOrigins []string) gin.HandlerFunc { + allowed := make(map[string]struct{}, len(allowedOrigins)) + for _, origin := range allowedOrigins { + 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 } diff --git a/model/account.go b/model/account.go index 3ae8dfa..2fafe7d 100644 --- a/model/account.go +++ b/model/account.go @@ -1,9 +1,35 @@ package model +// Account 保存执行规则时需要用到的第三方凭据 type Account struct { - ID uint `gorm:"primaryKey" json:"id"` - UID uint `json:"uid"` - Name string `json:"name"` // 账号名称 - Type string `json:"type"` // 账号类型,如 "bilibili" - Config string `json:"config"` // 存储账号相关配置,如cookie等 + ID uint `gorm:"primaryKey" json:"id"` + UID uint `gorm:"index" json:"uid"` + Name string `json:"name"` // 账号名称 + Type string `json:"type"` // 账号类型,如 "bilibili" + 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 } diff --git a/model/error.go b/model/error.go new file mode 100644 index 0000000..6e3afb5 --- /dev/null +++ b/model/error.go @@ -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} } diff --git a/model/log.go b/model/log.go new file mode 100644 index 0000000..16d28fb --- /dev/null +++ b/model/log.go @@ -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"` +} diff --git a/model/rule.go b/model/rule.go index f4fc049..eb404f9 100644 --- a/model/rule.go +++ b/model/rule.go @@ -1,11 +1,47 @@ package model +// Rule 描述一个 Timer 到期后要执行的动作 type Rule struct { - ID uint `json:"id" gorm:"primaryKey"` - UID uint `json:"uid"` - TimerID uint `json:"timer_id"` + ID uint `json:"id" gorm:"primaryKey"` + UID uint `gorm:"index" json:"uid"` + TimerID uint `gorm:"index" json:"timer_id"` + 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"` Type string `json:"type"` 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 } diff --git a/model/timer.go b/model/timer.go index 7658187..527e8c2 100644 --- a/model/timer.go +++ b/model/timer.go @@ -1,10 +1,64 @@ package model +// Timer 是摇篮系统的核心:用户需要在 SignDerationSeconds 内完成一次签到, +// 否则挂在这个 Timer 下的所有规则都会被触发。 type Timer struct { - ID uint `gorm:"primaryKey" json:"id"` - UID uint `json:"uid"` + ID uint `gorm:"primaryKey" json:"id"` + UID uint `gorm:"index" json:"uid"` + Name string `json:"name"` + Description string `json:"description"` + // 是否启用,停用后不再提醒也不会触发 + Enabled bool `gorm:"default:true" json:"enabled"` + // 签到有效期(秒),超过这个时间没有签到就会触发规则 + SignDerationSeconds int64 `json:"sign_deration_seconds"` + // 上次签到时间(Unix 秒) + LastSign int64 `json:"last_sign"` + // 提前多少秒提醒 + 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"` - LastSign int64 `json:"last_sign"` - RemindTimeSeconds int64 `json:"remind_time_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 } diff --git a/model/user.go b/model/user.go index cd8a54d..f18832a 100644 --- a/model/user.go +++ b/model/user.go @@ -1,21 +1,29 @@ package model -import ( - "fmt" - - "github.com/ssdomei232/goodBaby/handler/db" -) +import "fmt" type UserRegistryReuest struct { Username string `json:"username"` 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 { - ID uint `json:"id" gorm:"primaryKey"` - CreateAt int64 `json:"create_at"` - Username string `json:"username"` - Password string `json:"-"` + ID uint `json:"id" gorm:"primaryKey"` + CreateAt int64 `json:"create_at"` + Username string `gorm:"uniqueIndex" json:"username"` + Password string `json:"-"` + // 钉钉机器人配置(JSON 字符串),用于接收提醒 DingTalkConfig *string `json:"dingtalk_config"` } @@ -31,18 +39,3 @@ func (u *User) IsValid() error { } 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 -} diff --git a/pkg/github/github.go b/pkg/github/github.go deleted file mode 100644 index ff0ea4e..0000000 --- a/pkg/github/github.go +++ /dev/null @@ -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 -} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..581e71d --- /dev/null +++ b/web/embed.go @@ -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) + }) +} diff --git a/web/frontend/.gitignore b/web/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/frontend/.gitignore @@ -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? diff --git a/web/frontend/.vscode/extensions.json b/web/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/web/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/web/frontend/README.md b/web/frontend/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/web/frontend/README.md @@ -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 ` + + diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json new file mode 100644 index 0000000..332e527 --- /dev/null +++ b/web/frontend/package-lock.json @@ -0,0 +1,1901 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "license": "MIT", + "peer": true + }, + "node_modules/@vue/language-core": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.8.tgz", + "integrity": "sha512-ieGT8jJdhhy0mGzStZhsg/qPw5bQZJg5yF+3+XU6saf4sM7yo9ZXy3h+nCwrm2+b4qS/SypkNdR2jAF3uei9tA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.3", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz", + "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.5" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT", + "peer": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/nostics": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz", + "integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT", + "peer": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.2.tgz", + "integrity": "sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==", + "license": "MIT", + "dependencies": { + "nostics": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/devtools-api": "^8.1.5", + "typescript": ">=5.6.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "@vue/devtools-api": { + "optional": false + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.8.tgz", + "integrity": "sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.8.tgz", + "integrity": "sha512-xXmYlVQpcwJDWyGlqbHrGVOl1h3UOsASymRibrHc+iy9j/UNnOrOn4u+fntHz4D6Cs74RtapeqVV6CzJeg+UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.8" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/web/frontend/package.json b/web/frontend/package.json new file mode 100644 index 0000000..c700e4f --- /dev/null +++ b/web/frontend/package.json @@ -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" + } +} diff --git a/web/frontend/public/favicon.svg b/web/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/web/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/frontend/public/icons.svg b/web/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/web/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue new file mode 100644 index 0000000..4a01003 --- /dev/null +++ b/web/frontend/src/App.vue @@ -0,0 +1,6 @@ + + + diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts new file mode 100644 index 0000000..7dc7df3 --- /dev/null +++ b/web/frontend/src/api/client.ts @@ -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(promise: Promise<{ data: ApiResponse }>): Promise { + try { + const response = await promise + return response.data.data + } catch (error) { + const axiosError = error as AxiosError> + 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: (url: string, params?: Record) => + request(http.get>(url, { params })), + post: (url: string, body?: unknown) => + request(http.post>(url, body)), + put: (url: string, body?: unknown) => + request(http.put>(url, body)), + delete: (url: string) => request(http.delete>(url)), +} diff --git a/web/frontend/src/api/index.ts b/web/frontend/src/api/index.ts new file mode 100644 index 0000000..289e5b8 --- /dev/null +++ b/web/frontend/src/api/index.ts @@ -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('/site'), +} + +export const userApi = { + register: (username: string, password: string) => + api.post('/user/registry', { username, password }), + login: (username: string, password: string) => + api.post('/user/login', { username, password }), + logout: () => api.post('/user/logout'), + info: () => api.get('/user/info'), + changePassword: (oldPassword: string, newPassword: string) => + api.post('/user/password', { + old_password: oldPassword, + new_password: newPassword, + }), + updateNotify: (dingtalkConfig: string | null) => + api.put('/user/notify', { dingtalk_config: dingtalkConfig }), +} + +export const dashboardApi = { + overview: () => api.get('/dashboard'), +} + +export const providerApi = { + all: () => api.get('/providers'), +} + +export const timerApi = { + list: () => api.get('/timers/'), + get: (id: number) => api.get<{ timer: Timer; rule_count: number }>(`/timers/${id}`), + create: (body: TimerRequest) => api.post('/timers/', body), + update: (id: number, body: TimerRequest) => api.put(`/timers/${id}`, body), + sign: (id: number) => api.post(`/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(`/timers/${id}/check`), + remove: (id: number) => api.delete(`/timers/${id}`), +} + +export const ruleApi = { + list: (timerId?: number) => + api.get('/rules/', timerId ? { timer_id: timerId } : undefined), + create: (body: RuleRequest) => api.post('/rules/', body), + update: (id: number, body: RuleRequest) => api.put(`/rules/${id}`, body), + test: (id: number) => api.post(`/rules/${id}/test`), + remove: (id: number) => api.delete(`/rules/${id}`), +} + +export const accountApi = { + list: (type?: string) => + api.get('/accounts/', type ? { type } : undefined), + create: (body: AccountRequest) => api.post('/accounts/', body), + update: (id: number, body: AccountRequest) => api.put(`/accounts/${id}`, body), + test: (id: number) => api.post(`/accounts/${id}/test`), + checkDelete: (id: number) => api.get(`/accounts/${id}/check`), + remove: (id: number) => api.delete(`/accounts/${id}`), +} + +export const logApi = { + list: (params: { page?: number; page_size?: number; rule_id?: number; success?: string }) => + api.get('/logs/', params), + clear: () => api.delete('/logs/'), +} + +export type { ExecutionLog } diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts new file mode 100644 index 0000000..78eff5c --- /dev/null +++ b/web/frontend/src/api/types.ts @@ -0,0 +1,157 @@ +// 与后端 model / internal/meta 对应的类型定义 + +export interface ApiResponse { + 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 = '********' diff --git a/web/frontend/src/assets/hero.png b/web/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/web/frontend/src/assets/hero.png differ diff --git a/web/frontend/src/assets/main.css b/web/frontend/src/assets/main.css new file mode 100644 index 0000000..d5a6fdd --- /dev/null +++ b/web/frontend/src/assets/main.css @@ -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; +} diff --git a/web/frontend/src/assets/vite.svg b/web/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/web/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/web/frontend/src/components/ConfigForm.vue b/web/frontend/src/components/ConfigForm.vue new file mode 100644 index 0000000..b7888eb --- /dev/null +++ b/web/frontend/src/components/ConfigForm.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/web/frontend/src/components/LogoMark.vue b/web/frontend/src/components/LogoMark.vue new file mode 100644 index 0000000..f880ee0 --- /dev/null +++ b/web/frontend/src/components/LogoMark.vue @@ -0,0 +1,22 @@ + + + diff --git a/web/frontend/src/layouts/MainLayout.vue b/web/frontend/src/layouts/MainLayout.vue new file mode 100644 index 0000000..1d9b544 --- /dev/null +++ b/web/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/web/frontend/src/main.ts b/web/frontend/src/main.ts new file mode 100644 index 0000000..94c3918 --- /dev/null +++ b/web/frontend/src/main.ts @@ -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') diff --git a/web/frontend/src/router/index.ts b/web/frontend/src/router/index.ts new file mode 100644 index 0000000..ddab536 --- /dev/null +++ b/web/frontend/src/router/index.ts @@ -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 diff --git a/web/frontend/src/stores/meta.ts b/web/frontend/src/stores/meta.ts new file mode 100644 index 0000000..ae18968 --- /dev/null +++ b/web/frontend/src/stores/meta.ts @@ -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(null) + + async function ensureLoaded() { + if (!providers.value) { + providers.value = await providerApi.all() + } + return providers.value + } + + const accountMetas = computed(() => providers.value?.accounts ?? []) + const ruleMetas = computed(() => 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, + } +}) diff --git a/web/frontend/src/stores/user.ts b/web/frontend/src/stores/user.ts new file mode 100644 index 0000000..3ba3473 --- /dev/null +++ b/web/frontend/src/stores/user.ts @@ -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(null) + const loaded = ref(false) + + /** 拉取当前登录用户,未登录时静默失败 */ + async function fetchUser(): Promise { + 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 } +}) diff --git a/web/frontend/src/utils/format.ts b/web/frontend/src/utils/format.ts new file mode 100644 index 0000000..d770a8d --- /dev/null +++ b/web/frontend/src/utils/format.ts @@ -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 }, +] diff --git a/web/frontend/src/views/AccountsView.vue b/web/frontend/src/views/AccountsView.vue new file mode 100644 index 0000000..721d8d9 --- /dev/null +++ b/web/frontend/src/views/AccountsView.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/web/frontend/src/views/DashboardView.vue b/web/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..b554fbf --- /dev/null +++ b/web/frontend/src/views/DashboardView.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/web/frontend/src/views/LoginView.vue b/web/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..b1d77b8 --- /dev/null +++ b/web/frontend/src/views/LoginView.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/web/frontend/src/views/LogsView.vue b/web/frontend/src/views/LogsView.vue new file mode 100644 index 0000000..6b87ac6 --- /dev/null +++ b/web/frontend/src/views/LogsView.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/web/frontend/src/views/RulesView.vue b/web/frontend/src/views/RulesView.vue new file mode 100644 index 0000000..014ec6b --- /dev/null +++ b/web/frontend/src/views/RulesView.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/web/frontend/src/views/SettingsView.vue b/web/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..b1dd2be --- /dev/null +++ b/web/frontend/src/views/SettingsView.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/web/frontend/src/views/TimersView.vue b/web/frontend/src/views/TimersView.vue new file mode 100644 index 0000000..6e43ece --- /dev/null +++ b/web/frontend/src/views/TimersView.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/web/frontend/tsconfig.app.json b/web/frontend/tsconfig.app.json new file mode 100644 index 0000000..b057190 --- /dev/null +++ b/web/frontend/tsconfig.app.json @@ -0,0 +1,18 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "paths": { + "@/*": ["./src/*"] + }, + "allowArbitraryExtensions": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/web/frontend/tsconfig.json b/web/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/frontend/tsconfig.node.json b/web/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/web/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts new file mode 100644 index 0000000..f14323c --- /dev/null +++ b/web/frontend/vite.config.ts @@ -0,0 +1,29 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + port: 5173, + proxy: { + // 本地开发时把 API 请求转发到 Go 后端 + '/api': { + target: 'http://localhost:8088', + changeOrigin: true, + }, + }, + }, + build: { + // 构建产物直接输出到 Go embed 的目录 + outDir: '../dist', + emptyOutDir: true, + }, +})