21 Commits
Author SHA1 Message Date
mei 48bb6b8864 fix(workflows) 2026-07-28 22:16:26 +08:00
mei 58b30fa41a update(tests) 2026-07-28 22:13:08 +08:00
mei 2d73157a60 feat(workflows): check 2026-07-28 22:12:17 +08:00
meiandClaude Fable 5 ce01604f13 ci: 新增发版流水线,构建产物上传 Release 并推送镜像到 ghcr
推送 v 开头的 tag 时自动执行:
- frontend: 前端只构建一次(含 vue-tsc 类型检查),产物由各平台共用,
  保证所有二进制内嵌的前端完全一致
- build: 交叉编译 linux/windows/darwin 的 amd64 与 arm64 共 5 个目标,
  打包时附带 README/LICENSE/docs
- release: 汇总产物、生成 checksums.txt、创建 Release(自动生成发布说明)
- docker: buildx 构建 amd64+arm64 镜像推送 ghcr,标签按 semver 展开

Dockerfile 改为交叉编译友好:前端与后端构建阶段固定在 BUILDPLATFORM,
用 TARGETARCH 做 Go 交叉编译,避免构建 arm64 镜像时 npm 与 go build
被丢进 QEMU 执行。同时补上 -trimpath。

只使用内置 GITHUB_TOKEN,无需额外 secret。

文档: 新增 docs/release.md,README 补充镜像拉取与二进制下载说明,
docker-compose 默认镜像改为 ghcr。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 07:38:44 +08:00
meiandClaude Fable 5 ae7082f13c feat: 管理员后台配置 + PostgreSQL 支持
管理员后台:
- User 新增 IsAdmin,第一个注册的用户自动成为管理员;
  老库升级时自动把最早注册的用户提升为管理员
- 新增 AdminMiddleware 与 /api/v1/admin/config 读写接口
- 可改开放注册、检查间隔、规则重试时长、日志保留条数,保存即生效
- 检查间隔改动后自动重排定时任务,为此把 cron 抽成 handler/scheduler
- 监听地址、数据库、会话密钥等启动项不开放,接口只返回只读展示
- 设置页新增"系统配置"区块,仅管理员可见

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 07:17:16 +08:00
meiandClaude Fable 5 54d4fbe24a feat: 深色模式圆形揭示动画 + B 站私信规则
主题切换:
- 抽出 useTheme 全局单例,主题在挂载前初始化,登录页不再闪白底
- 用 View Transitions API 以点击点为圆心做圆形揭示;不支持的浏览器
  加 .no-vt 类退化为整页淡变,两者互斥避免揭示区露出过渡中的颜色
- 尊重 prefers-reduced-motion;过渡被浏览器跳过时静默降级为直接切换
- 主题按钮图标旋转切换

新规则:
- bilibili-private-message:向指定 UID 发送 B 站私信,
  发送者 UID 自动从账号 Cookie 读取

视觉打磨:
- 统计数字改为缓出计数动画,等宽数字避免跳动时抖动
- 补 :focus-visible 焦点环

说明:当前 SDK 未提供视频投稿接口,无法支持定时投稿视频,
详见 docs/bilibili-config.md。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:15:02 +08:00
meiandClaude Fable 5 f8c6115737 feat(web): 移动端适配
- 侧边栏在窄屏改为抽屉:汉堡按钮唤起、遮罩点击关闭、切换路由自动收起
- 页头纵向排列,操作按钮与筛选控件撑满一行
- 对话框改用视口百分比宽度,表单标签改为顶部对齐,内容区限高可滚动
- 表格隐藏次要列并把操作列固定在右侧,仍可横向滚动查看全部
- 仪表盘倒计时环与信息上下排列,统计卡改两列
- 分页器在窄屏收缩为精简布局
- 输入框字号提到 16px,避免 iOS Safari 聚焦时自动缩放
- 新增 useIsMobile composable 处理 CSS 覆盖不到的部分

顺带修复汉堡按钮在桌面端未隐藏:.menu-btn 与后声明的 .icon-btn
特异性相同被覆盖,改用 .icon-btn.menu-btn 复合选择器。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:57:52 +08:00
meiandClaude Fable 5 0d766020bc fix: 手动触发全部成功时被误报为失败
trigger 接口的 fails 是 nil 切片,无失败时序列化成 JSON null,
前端读 failed.length 抛异常并落入 catch 分支,把成功显示成"触发失败"。
改为初始化空切片,前端同时加空值兜底。

顺带修复 logstore.trim 用 First 查询保留位置,日志未超出保留条数时
返回 ErrRecordNotFound,导致每写一条日志都刷一行 gorm 警告。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:34:29 +08:00
meiandClaude Fable 5 d5ede38848 chore: 补充 web/dist/.gitkeep 保证未构建前端时 go:embed 可编译
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:20:10 +08:00
meiandClaude Fable 5 79774070fc feat: 完善后端并新增 Vue3 WebUI
后端:
- 修复登录校验、DB 并发(单例+WAL+纯Go sqlite)、会话密钥持久化等核心缺陷
- 新增 Timer CRUD/签到/手动触发、执行日志、仪表盘、驱动元数据等 API
- 统一驱动接口: 带 context 的执行器、真实错误返回、敏感字段掩码、连通性测试
- 补全钉钉执行器; Timer 触发/提醒去重; 配置支持默认值与环境变量覆盖

前端:
- Vite+Vue3+TS+Pinia+Element Plus, 由驱动元数据动态渲染配置表单
- 登录/仪表盘(倒计时环)/定时器/规则/账号/日志/设置页面, 支持暗色模式
- 主题色 #66ccff, 信号波纹 logo, go:embed 单二进制部署

构建: Dockerfile 增加前端构建阶段; 补充各驱动配置文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:19:41 +08:00
mei 89e8b7fd66 feat: 完善账号配置验证器 2026-06-20 22:29:21 +08:00
mei 033fd91aa1 feat(internal): 账号配置验证器 2026-06-20 06:50:43 +08:00
mei 5da78f9deb refactor(internal): 分离runner 2026-06-19 18:47:36 +08:00
mei 7957a91609 refactor(handler): 重构runner及checker架构 2026-06-06 19:31:16 +08:00
mei 7c4fb1e1d8 fix 2026-06-06 15:25:38 +08:00
mei 821d4b2d53 chore: stop tracking data.db and apply gitignore 2026-06-06 15:24:51 +08:00
mei 189eab8a0a feat(checker): checker 2026-04-06 10:10:31 +08:00
mei 80e427d7c3 feat(api): 删除账号 2026-04-05 22:46:11 +08:00
mei 52dc548c53 feat(bilibili): bilibili login 2026-04-05 22:28:38 +08:00
mei 202f53915f fix: delete extra file 2026-04-05 10:38:19 +08:00
mei 6190a8853a feat: v2(Incomplete,very early version) 2026-04-05 10:36:22 +08:00
136 changed files with 12212 additions and 1397 deletions
+34
View File
@@ -0,0 +1,34 @@
name: Quality check
on:
push:
branches:
- "*"
pull_request:
permissions:
contents: read
jobs:
web-ui:
name: Web UI
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web/frontend/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: web/frontend
- name: Build
run: npm run build
working-directory: web/frontend
+216
View File
@@ -0,0 +1,216 @@
name: Release
# 推送 v 开头的 tag 时触发,例如 git tag v2.2.0 && git push origin v2.2.0
on:
push:
tags:
- 'v*'
# 允许手动跑一次做验证(不会创建 Release,只构建产物)
workflow_dispatch:
permissions:
contents: write # 创建 Release
packages: write # 推送镜像到 ghcr
env:
GO_VERSION: '1.25'
NODE_VERSION: '22'
IMAGE_NAME: ${{ github.repository }}
jobs:
# ==========================================================
# 前端只构建一次,产物给所有平台的二进制共用
# ==========================================================
frontend:
name: 构建前端
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/frontend/package-lock.json
- name: 安装依赖
working-directory: web/frontend
run: npm ci
- name: 类型检查与构建
working-directory: web/frontend
run: npm run build
- name: 上传前端产物
uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
retention-days: 1
if-no-files-found: error
# ==========================================================
# 交叉编译各平台二进制
# ==========================================================
build:
name: 构建 ${{ matrix.goos }}/${{ matrix.goarch }}
needs: frontend
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: windows
goarch: amd64
ext: .exe
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: 取回前端产物
uses: actions/download-artifact@v4
with:
name: web-dist
path: web/dist
- name: 准备版本信息
id: vars
run: |
VERSION="${GITHUB_REF_NAME}"
# 手动触发时没有 tag,用短 commit 顶替
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
VERSION="dev-$(git rev-parse --short HEAD)"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: 编译
env:
CGO_ENABLED: '0' # sqlite 用纯 Go 实现,无需 CGO,可安全交叉编译
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
go build -trimpath \
-ldflags="-w -s \
-X main.version=${{ steps.vars.outputs.version }} \
-X main.buildDate=${{ steps.vars.outputs.build_date }} \
-X main.gitCommit=${GITHUB_SHA::7}" \
-o "goodbaby${{ matrix.ext }}" .
- name: 打包
id: pack
run: |
NAME="goodbaby_${{ steps.vars.outputs.version }}_${{ matrix.goos }}_${{ matrix.goarch }}"
mkdir -p "dist/${NAME}"
cp "goodbaby${{ matrix.ext }}" "dist/${NAME}/"
cp README.md LICENSE "dist/${NAME}/"
cp -r docs "dist/${NAME}/docs"
cd dist
if [ "${{ matrix.goos }}" = "windows" ]; then
zip -qr "${NAME}.zip" "${NAME}"
echo "archive=dist/${NAME}.zip" >> "$GITHUB_OUTPUT"
else
tar -czf "${NAME}.tar.gz" "${NAME}"
echo "archive=dist/${NAME}.tar.gz" >> "$GITHUB_OUTPUT"
fi
- name: 上传构建产物
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.goos }}-${{ matrix.goarch }}
path: ${{ steps.pack.outputs.archive }}
retention-days: 7
if-no-files-found: error
# ==========================================================
# 汇总产物并创建 Release(仅 tag 触发时执行)
# ==========================================================
release:
name: 发布 Release
needs: build
runs-on: ubuntu-latest
if: github.ref_type == 'tag'
steps:
- name: 取回全部产物
uses: actions/download-artifact@v4
with:
pattern: release-*
path: dist
merge-multiple: true
- name: 生成校验和
working-directory: dist
run: |
sha256sum * > checksums.txt
cat checksums.txt
- name: 创建 Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" dist/* \
--repo "${GITHUB_REPOSITORY}" \
--title "${GITHUB_REF_NAME}" \
--generate-notes
# ==========================================================
# 构建多架构镜像并推送 ghcr
# ==========================================================
docker:
name: 构建并推送镜像
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# 交叉编译由 Dockerfile 里的 TARGETARCH 完成,
# 这里的 QEMU 只用于最终 alpine 运行阶段的镜像组装
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: 登录 ghcr
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 生成镜像标签
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
type=sha,format=short,enable=${{ github.ref_type != 'tag' }}
- name: 构建并推送
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
GIT_COMMIT=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
+10 -3
View File
@@ -23,6 +23,13 @@ go.work
goodBaby goodBaby
config.json config.json
cookies.txt data.db
tmp/ data.db-shm
bye.json data.db-wal
/data/
# ---> Frontend
node_modules/
# 前端构建产物不入库,仅保留 .gitkeep 以满足 go:embed
web/dist/*
!web/dist/.gitkeep
+36 -6
View File
@@ -1,7 +1,24 @@
# ====================== # ======================
# 构建阶段 (Builder Stage) # 前端构建阶段 (Frontend Stage)
# ====================== # ======================
FROM golang:1.24-alpine AS builder # 固定在构建机架构上运行:前端产物与目标架构无关,
# 否则交叉构建 arm64 镜像时 npm 要跑在 QEMU 里,慢得离谱
FROM --platform=$BUILDPLATFORM 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)
# ======================
# 同样固定在构建机架构上,用 Go 的交叉编译产出目标架构的二进制。
# 依赖里没有 CGO(sqlite 用的是纯 Go 实现),所以交叉编译是安全的。
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -10,26 +27,39 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# 用前端构建产物覆盖占位的 dist
COPY --from=frontend /app/web/dist /app/web/dist
ARG VERSION="1.0.0" ARG VERSION="dev"
ARG BUILD_DATE="unknown" ARG BUILD_DATE="unknown"
ARG GIT_COMMIT="unknown" ARG GIT_COMMIT="unknown"
# TARGETARCH 由 buildx 自动注入(amd64 / arm64 / ...)
ARG TARGETARCH
ARG TARGETOS=linux
RUN CGO_ENABLED=0 \ RUN CGO_ENABLED=0 \
GOOS=linux \ GOOS=${TARGETOS} \
GOARCH=amd64 \ GOARCH=${TARGETARCH} \
go build \ go build \
-trimpath \
-ldflags="-w -s -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE} -X main.gitCommit=${GIT_COMMIT}" \ -ldflags="-w -s -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE} -X main.gitCommit=${GIT_COMMIT}" \
-o /app/main . -o /app/main .
# ====================== # ======================
# 运行阶段 (Runtime Stage) # 运行阶段 (Runtime Stage)
# ====================== # ======================
FROM alpine:3.18 FROM alpine:3.20
WORKDIR /app WORKDIR /app
RUN apk add --no-cache tzdata ca-certificates
COPY --from=builder /app/main /app/main COPY --from=builder /app/main /app/main
# 数据(config.json / data.db)统一放在 /app/data,挂载这个目录即可
ENV GOODBABY_CONFIG=/app/data/config.json \
GOODBABY_DB_PATH=/app/data/data.db
VOLUME ["/app/data"]
EXPOSE 8088 EXPOSE 8088
CMD ["/app/main"] CMD ["/app/main"]
+119 -69
View File
@@ -1,87 +1,137 @@
# goodBaby # goodBaby v2
摇篮系统 摇篮系统 —— 为独居青年准备的「死人开关」(Dead Man's Switch)。
一个人在大城市生活,最怕的是出了事都没人知道。goodBaby 让你定期回来报一声平安;一旦超过设定的期限没有签到,它就会替你把预先托付的留言送出去:给家人的邮件、给朋友的 QQ / 钉钉消息、一条 B 站动态,或是公开你的 GitHub 仓库,把作品留给世界。
[![通过雨云一键部署](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/7125/cat_) [![通过雨云一键部署](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/7125/cat_)
## 他会做什么 ## 功能
1. 接受 singnal 请求 * **WebUI**:内置 Vue3 前端,注册 / 登录、定时器、规则、账号、执行日志、设置全部可视化操作
2. 当定时器超时(过长时间没有收到请求)时,触发 `trigger` 函数 * **定时器 (Timer)**:设定签到周期与提前提醒时间,到期前通过钉钉机器人提醒你签到
3. 发送消息到QQ(依赖[CatBot](https://github.com/ssdomei232/CatBot)) * **规则 (Rule)**:定时器到期后要执行的动作,支持:
4. 发送邮件 * 发送邮件 (SMTP)
5. 将 Github 上的遗言仓库设置为 public * 发布 B 站动态
6. 发送一条 Bilibili 动态 * 发送 QQ 消息 (OneBot / NapCat)
7. 当倒计时小于48小时时,向用钉钉机器人发消息提醒你 * 发送钉钉机器人消息
* 公开 GitHub 仓库
* **账号 (Account)**:集中管理第三方凭据,支持连通性测试,敏感字段(密码/Cookie/Token)不会回显
* **执行日志**:每次规则执行与提醒都有记录,规则支持手动测试
* **配置测试**:账号可一键测试连通性;规则可手动触发验证
## 快速开始
### Docker Compose (推荐)
```bash
docker compose up -d
```
首次启动会在 `./data` 下自动生成 `config.json` 与 `data.db`。
打开 `http://localhost:8088`,按引导创建第一个账号即可。
### 直接拉取镜像
每次发版会自动构建 `linux/amd64` 与 `linux/arm64` 镜像推送到 GitHub Container Registry:
```bash
docker run -d --name goodbaby -p 8088:8088 -v ./data:/app/data ghcr.io/ssdomei232/goodbaby:latest
```
可用标签:`latest`、`v2.2.0`(完整版本)、`2.2`(次版本)、`2`(主版本)。
### 下载预编译二进制
[Releases](https://github.com/ssdomei232/goodBaby/releases) 提供 Linux / Windows / macOS 的 amd64 与 arm64 产物,
解压后直接运行即可(前端已内嵌,无需额外部署)。压缩包旁的 `checksums.txt` 可校验完整性:
```bash
sha256sum -c checksums.txt --ignore-missing
```
### 从源码构建
需要 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
```
## 配置 ## 配置
> [!TIP] 配置文件默认为工作目录下的 `config.json`,首次启动自动生成,字段均有默认值:
> 更详细的配置注解请前往 [Wiki 页面](https://github.com/ssdomei232/goodBaby/wiki)
参照 config.example.json 修改 config.json | 字段 | 默认值 | 说明 |
| --- | --- | --- |
| `listen_addr` | `:8088` | HTTP 监听地址 |
| `enable_registry` | `true` | 是否开放注册(系统无用户时始终允许注册第一个账号) |
| `timeout_duration_hours` | `6` | 规则执行失败后指数退避重试的最长时间(小时) |
| `check_interval_minutes` | `10` | 检查定时器的间隔(分钟) |
| `database_driver` | `sqlite` | 数据库驱动,`sqlite` 或 `postgres` |
| `database_path` | `data.db` | sqlite 数据库路径 |
| `database_dsn` | 空 | postgres 连接串,见 [docs/database.md](docs/database.md) |
| `session_secret` | 自动生成 | 会话加密密钥,自动生成并持久化 |
| `session_max_age_hours` | `168` | 会话有效期(小时) |
| `allowed_origins` | `[]` | 允许跨域的来源,前端本地开发时可填 `["http://localhost:5173"]` |
| `log_retain_count` | `500` | 每个用户保留的执行日志条数 |
```json 环境变量覆盖:`GOODBABY_CONFIG`(配置文件路径)、`GOODBABY_LISTEN_ADDR`、`GOODBABY_DB_DRIVER`、`GOODBABY_DB_PATH`、`GOODBABY_DB_DSN`、`GOODBABY_SESSION_SECRET`、`GOODBABY_ENABLE_REGISTRY`。
{
"signal_secret": "xxx", // 发送 signal请求时需要的密钥
"debug": false, // debug 模式
"disconnect_duration": 80, // 失连时间,超出后会触发系统(小时)
"bili_msg": "摇篮系统测试", // 哔哩哔哩动态的附加消息
"mail_config": {
"host": "smtp.example.com",
"port": 465,
"user": "xxx",
"pass": "xxx",
"mail_list": ["xxx@example.com", "xxx1@example.com"],// 要发送的邮箱
"mail_title": "摇篮系统已触发", // 邮件标题
"mail_content": "这是摇篮系统的邮件内容。" // 要附加的邮件内容
}, // 邮件配置
"basic": {
"name": "xx", // 你的名字
"qq_number": "123", // 你的QQ号
"nickname": "xx_123", // 网名
"age": 16, // 你的年龄
"cause_stop": "自杀" // 你最可能的死因
},
"github_config": {
"owner": "example", // github用户名
"repos": ["example-repo1", "example-repo2"], // 要公开的 github 仓库
"token": "ghp_xxxx" // github token
},
"dingtalk_bot": {
"access_token": "xxx",
"secret": "xxx"
}, // 钉钉机器人(用于提醒发送signal和bilibili过期提醒)
"onebot_config": {
"url": "http://localhost:5700",
"token": "xxx",
"send_groups": [12345678, 87654321], // 要发送到的Onebot群组
"msg": "这是OneBot的消息内容。" // Onebot消息
}
}
```
## 运行 ### 管理员后台
```bash **第一个注册的用户自动成为管理员**(老版本升级上来时,注册最早的用户会被自动提升)。
mkdir goodbaby
cd goodbaby
wget https://raw.githubusercontent.com/ssdomei232/goodBaby/refs/heads/main/docker-compose.yml
docker compose up -d
docker logs goodbaby ## 扫码登陆后才能使用
```
在日志中找到 Bilibili 的登陆二维码或在文件管理中找到`tmp/qrcode.png`,打开Bilibili APP 扫码登陆 管理员在「设置」页面会多出一个「系统配置」区块,可以直接修改并立即生效,无需重启:
> [!NOTE] * 开放注册
> 只有在登陆 Bilibili 后程序才会正常运行 * 检查间隔(保存后会自动重排定时任务)
* 规则重试时长
* 日志保留条数
## 使用 监听地址、数据库、会话密钥这类启动项不在后台开放,只能改配置文件。
访问你开放的端口(默认为8088),进入WebUI,输入你的密钥,点击保存(密钥会存储到浏览器的localStorage中),此时你可以在WebUI查看倒计时和发送Signal ## 数据库
## Todo list 默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。
- [ ] 熔断机制 ## 发版
- [ ] Webhook 自定义请求
- [ ] 保存定时器状态 推送 `v` 开头的 tag 即可自动构建产物、创建 Release 并推送镜像到 ghcr,
详见 [docs/release.md](docs/release.md)。
## 驱动配置
各驱动的账号 / 规则配置说明见 [docs/](docs/):
* [Bilibili](docs/bilibili-config.md)
* [Email](docs/email-config.md)
* [OneBot (QQ)](docs/onebot-config.md)
* [钉钉机器人](docs/dingtalk-config.md)
* [GitHub](docs/github-config.md)
## 开发:新增一种规则类型
1. 在 `drivers/<name>/` 下实现:
* 规则验证器(实现 `ruleConfigChecker.RuleValidator`,`Meta()` 返回表单元数据)
* 执行器(实现 `runner.RuleExecutor`)
* 如需第三方凭据,再实现账号验证器(`accountConfigChecker.AccountValidator`,可选实现 `AccountTester` 支持连通性测试)
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/interface.go` 中注册
3. 前端无需改动 —— WebUI 会根据 `Meta()` 返回的字段描述自动渲染配置表单
+247
View File
@@ -0,0 +1,247 @@
package account
import (
"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) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(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
}
// 之前这里完全没有走校验器,任何 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) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
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 {
response.Unauthorized(c, "获取用户信息失败")
return
}
accountID, err := parseID(c.Param("accountID"))
if err != nil {
response.BadRequest(c, "账号ID格式错误")
return
}
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 {
response.Unauthorized(c, "获取用户信息失败")
return
}
accountID, err := parseID(c.Param("accountID"))
if err != nil {
response.BadRequest(c, "账号ID格式错误")
return
}
// 删除相关规则
rules, err := getRulesByAccountID(accountID, userInfo.ID)
if err != nil {
response.ServerError(c, "获取相关规则失败")
return
}
for _, oneRule := range rules {
if err := rule.DeleteRuleByID(oneRule.ID, userInfo.ID); err != nil {
response.ServerError(c, "删除相关规则失败")
return
}
}
// 删除账号
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Where("id = ? AND uid = ?", accountID, userInfo.ID).
Delete(&model.Account{}).Error; err != nil {
response.ServerError(c, "删除账号失败")
return
}
response.OK(c, "删除账号成功")
}
+61
View File
@@ -0,0 +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
}
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
}
+63
View File
@@ -0,0 +1,63 @@
// Package admin 提供管理员可用的系统配置接口
package admin
import (
"log"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/handler/scheduler"
)
// HandleGetConfig 返回当前可编辑的系统配置,以及一些只读的运行时信息
func HandleGetConfig(c *gin.Context) {
config, err := configs.GetConfig()
if err != nil {
response.ServerError(c, "读取配置失败")
return
}
response.OK(c, gin.H{
"config": config.Editable(),
// 只读信息,让管理员知道当前跑在什么环境上,但不能在这里改
"readonly": gin.H{
"listen_addr": config.ListenAddr,
"database_driver": config.DatabaseDriver,
},
})
}
// HandleUpdateConfig 更新可编辑的系统配置
func HandleUpdateConfig(c *gin.Context) {
var req configs.EditableConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
before, err := configs.GetConfig()
if err != nil {
response.ServerError(c, "读取配置失败")
return
}
updated, err := configs.ApplyEditable(req)
if err != nil {
// Validate 的错误可以直接展示给用户
response.BadRequest(c, err.Error())
return
}
// 检查间隔变了就重排定时任务,免得改完还要重启
if before.CheckIntervalMinutes != updated.CheckIntervalMinutes {
if err := scheduler.Reschedule(updated.CheckIntervalMinutes); err != nil {
log.Printf("重排定时任务失败: %v", err)
response.ServerError(c, "配置已保存,但定时任务重排失败,请重启服务")
return
}
log.Printf("检查间隔已更新为 %d 分钟", updated.CheckIntervalMinutes)
}
response.OK(c, updated.Editable())
}
+80
View File
@@ -0,0 +1,80 @@
// Package dashboard 汇总首页需要的统计数据
package dashboard
import (
"time"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
// HandleGetOverview 返回首页概览:各类对象数量、最紧急的 Timer、最近的执行日志
func HandleGetOverview(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
timers := []model.Timer{}
if err := gormDB.Where("uid = ?", userInfo.ID).Find(&timers).Error; err != nil {
response.ServerError(c, "获取统计数据失败")
return
}
var ruleCount, accountCount int64
gormDB.Model(&model.Rule{}).Where("uid = ?", userInfo.ID).Count(&ruleCount)
gormDB.Model(&model.Account{}).Where("uid = ?", userInfo.ID).Count(&accountCount)
recentLogs := []model.ExecutionLog{}
gormDB.Where("uid = ?", userInfo.ID).Order("id DESC").Limit(10).Find(&recentLogs)
now := time.Now().Unix()
var (
enabledCount int
triggeredCount int
urgent *model.Timer
)
for i := range timers {
timer := timers[i]
if !timer.Enabled {
continue
}
enabledCount++
if timer.Triggered {
triggeredCount++
continue
}
// 剩余时间最短的启用中 Timer
if urgent == nil || timer.NextSignTime() < urgent.NextSignTime() {
urgent = &timers[i]
}
}
overview := gin.H{
"timer_count": len(timers),
"enabled_timers": enabledCount,
"triggered_count": triggeredCount,
"rule_count": ruleCount,
"account_count": accountCount,
"server_time": now,
"recent_logs": recentLogs,
}
if urgent != nil {
overview["urgent_timer"] = urgent
overview["urgent_seconds_left"] = urgent.NextSignTime() - now
}
response.OK(c, overview)
}
+102
View File
@@ -0,0 +1,102 @@
// Package log 提供规则执行日志的查询接口
package log
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
const (
defaultPageSize = 20
maxPageSize = 200
)
// HandleGetLogs 分页查询当前用户的执行日志
func HandleGetLogs(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
page := intQuery(c, "page", 1, 1, 1<<20)
pageSize := intQuery(c, "page_size", defaultPageSize, 1, maxPageSize)
query := gormDB.Model(&model.ExecutionLog{}).Where("uid = ?", userInfo.ID)
if ruleID := c.Query("rule_id"); ruleID != "" {
query = query.Where("rule_id = ?", ruleID)
}
if success := c.Query("success"); success == "true" || success == "false" {
query = query.Where("success = ?", success == "true")
}
var total int64
if err := query.Count(&total).Error; err != nil {
response.ServerError(c, "获取执行日志失败")
return
}
logs := []model.ExecutionLog{}
if err := query.Order("id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&logs).Error; err != nil {
response.ServerError(c, "获取执行日志失败")
return
}
response.OK(c, gin.H{
"total": total,
"page": page,
"page_size": pageSize,
"items": logs,
})
}
// HandleClearLogs 清空当前用户的执行日志
func HandleClearLogs(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Where("uid = ?", userInfo.ID).Delete(&model.ExecutionLog{}).Error; err != nil {
response.ServerError(c, "清空执行日志失败")
return
}
response.OK(c, "已清空执行日志")
}
func intQuery(c *gin.Context, key string, fallback, min, max int) int {
raw := c.Query(key)
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value < min {
return fallback
}
if value > max {
return max
}
return value
}
+44
View File
@@ -0,0 +1,44 @@
// Package meta 向 WebUI 暴露驱动能力与站点信息
package meta
import (
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
)
// HandleGetProviders 返回所有账号类型与规则类型的元数据
//
// 前端据此动态渲染配置表单,新增驱动时前端无需改动。
func HandleGetProviders(c *gin.Context) {
response.OK(c, gin.H{
"accounts": accountConfigChecker.InitValidatorRegistry().Metas(),
"rules": ruleConfigChecker.InitValidatorRegistry().Metas(),
})
}
// HandleGetSiteInfo 返回无需登录即可获取的站点信息
func HandleGetSiteInfo(c *gin.Context) {
config, err := configs.GetConfig()
if err != nil {
response.ServerError(c, "获取配置失败")
return
}
userCount, err := user.CountUsers()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
response.OK(c, gin.H{
"name": "goodBaby",
// 系统内还没有用户时始终允许注册,方便全新部署创建第一个账号
"enable_registry": config.EnableRegistry || userCount == 0,
"need_initial_user": userCount == 0,
"check_interval_minutes": config.CheckIntervalMinutes,
})
}
+52
View File
@@ -0,0 +1,52 @@
// Package response 统一 API 的返回格式
//
// {"code": 200, "data": ...}
//
// 出错时 data 为可以直接展示给用户的错误信息。
package response
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/model"
)
// OK 返回成功响应
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": data})
}
// Fail 返回错误响应
func Fail(c *gin.Context, code int, msg string) {
c.JSON(code, gin.H{"code": code, "data": msg})
}
// AbortWith 返回错误响应并终止后续 handler
func AbortWith(c *gin.Context, code int, msg string) {
c.AbortWithStatusJSON(code, gin.H{"code": code, "data": msg})
}
// BadRequest 参数错误
func BadRequest(c *gin.Context, msg string) { Fail(c, http.StatusBadRequest, msg) }
// Unauthorized 未登录
func Unauthorized(c *gin.Context, msg string) { Fail(c, http.StatusUnauthorized, msg) }
// NotFound 资源不存在
func NotFound(c *gin.Context, msg string) { Fail(c, http.StatusNotFound, msg) }
// ServerError 服务端错误
func ServerError(c *gin.Context, msg string) { Fail(c, http.StatusInternalServerError, msg) }
// FromError 按错误类型选择合适的状态码:
// 参数校验错误返回 400,其余返回 500
func FromError(c *gin.Context, err error, fallback string) {
var validationErr model.ValidationError
if errors.As(err, &validationErr) {
BadRequest(c, validationErr.Msg)
return
}
ServerError(c, fallback)
}
+236
View File
@@ -0,0 +1,236 @@
package rule
import (
"context"
"fmt"
"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) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
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) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
var req model.RuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
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
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Create(&newRule).Error; err != nil {
response.ServerError(c, "创建规则失败")
return
}
response.OK(c, maskRule(newRule))
}
// HandleEditRule 编辑规则
func HandleEditRule(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
}
existing, err := findRule(ruleID, userInfo.ID)
if err != nil {
response.NotFound(c, "规则不存在")
return
}
var req model.RuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
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
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Save(&updated).Error; err != nil {
response.ServerError(c, "更新规则失败")
return
}
response.OK(c, maskRule(updated))
}
// HandleDeleteRule 删除规则
func HandleDeleteRule(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
}
if err := DeleteRuleByID(ruleID, userInfo.ID); err != nil {
response.ServerError(c, "删除规则失败")
return
}
response.OK(c, "规则删除成功")
}
// HandleTestRule 立即执行一次规则用于验证配置
//
// 使用较短的超时,避免在 WebUI 上等待数小时的指数退避。
func HandleTestRule(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
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)
}
+105
View File
@@ -0,0 +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"
)
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
}
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}).Error
}
// checkRuleConfigAccountAndTimerExist 检查关联的 Timer 与账号是否存在且归属当前用户
//
// requiredAccountType 为空表示该规则类型不需要账号。
func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType string) error {
gormDB, err := db.GetGormDB()
if err != nil {
return err
}
var count int64
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 requiredAccountType == "" {
// 该类型不需要账号,忽略前端可能传来的 account_id
rule.AccountID = 0
return nil
}
if rule.AccountID == 0 {
return model.ErrValidation("该规则类型必须关联一个账号")
}
var account model.Account
if err := gormDB.Where("id = ? AND uid = ?", rule.AccountID, rule.UID).First(&account).Error; err != nil {
return model.ErrValidation("关联的账号不存在")
}
if account.Type != requiredAccountType {
return model.ErrValidation(fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredAccountType, account.Type))
}
return nil
}
// maskRule 掩码规则配置中的敏感字段
func maskRule(target model.Rule) model.Rule {
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(target.Type); ok {
target.ConfigJson = meta.Mask(target.ConfigJson, ruleMeta.Fields)
}
return target
}
func maskRules(rules []model.Rule) []model.Rule {
masked := make([]model.Rule, 0, len(rules))
for _, r := range rules {
masked = append(masked, maskRule(r))
}
return masked
}
+289
View File
@@ -0,0 +1,289 @@
package timer
import (
"time"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
"gorm.io/gorm"
)
// HandleGetAllTimers 获取当前用户的所有 Timer
func HandleGetAllTimers(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
timers := []model.Timer{}
if err := gormDB.Where("uid = ?", userInfo.ID).Order("id DESC").Find(&timers).Error; err != nil {
response.ServerError(c, "获取 Timer 失败")
return
}
response.OK(c, timers)
}
// HandleGetTimer 获取单个 Timer 及其关联规则数量
func HandleGetTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
timer, err := findTimer(timerID, userInfo.ID)
if err != nil {
response.NotFound(c, "Timer 不存在")
return
}
ruleCount, err := countRules(timerID, userInfo.ID)
if err != nil {
response.ServerError(c, "获取关联规则失败")
return
}
response.OK(c, gin.H{"timer": timer, "rule_count": ruleCount})
}
// HandleCreateTimer 创建 Timer
func HandleCreateTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
var req model.TimerRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
if err := req.Validate(); err != nil {
response.FromError(c, err, "创建 Timer 失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
now := time.Now().Unix()
timer := model.Timer{
UID: userInfo.ID,
Name: req.Name,
Description: req.Description,
Enabled: boolOr(req.Enabled, true),
SignDerationSeconds: req.SignDerationSeconds,
RemindTimeSeconds: req.RemindTimeSeconds,
// 创建即视为完成一次签到,从当前时间开始计时
LastSign: now,
CreateAt: now,
}
if err := gormDB.Create(&timer).Error; err != nil {
response.ServerError(c, "创建 Timer 失败")
return
}
response.OK(c, timer)
}
// HandleEditTimer 编辑 Timer
func HandleEditTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
timer, err := findTimer(timerID, userInfo.ID)
if err != nil {
response.NotFound(c, "Timer 不存在")
return
}
var req model.TimerRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
if err := req.Validate(); err != nil {
response.FromError(c, err, "更新 Timer 失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
timer.Name = req.Name
timer.Description = req.Description
timer.Enabled = boolOr(req.Enabled, timer.Enabled)
timer.SignDerationSeconds = req.SignDerationSeconds
timer.RemindTimeSeconds = req.RemindTimeSeconds
if err := gormDB.Save(timer).Error; err != nil {
response.ServerError(c, "更新 Timer 失败")
return
}
response.OK(c, timer)
}
// HandleSignTimer 签到:重置计时并解除已触发状态
func HandleSignTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
timer, err := findTimer(timerID, userInfo.ID)
if err != nil {
response.NotFound(c, "Timer 不存在")
return
}
if err := signTimer(timer); err != nil {
response.ServerError(c, "签到失败")
return
}
response.OK(c, timer)
}
// HandleSignAll 一键签到当前用户的所有启用中的 Timer
func HandleSignAll(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
now := time.Now().Unix()
result := gormDB.Model(&model.Timer{}).
Where("uid = ? AND enabled = ?", userInfo.ID, true).
Updates(map[string]any{
"last_sign": now,
"last_remind": 0,
"triggered": false,
})
if result.Error != nil {
response.ServerError(c, "签到失败")
return
}
response.OK(c, gin.H{"signed": result.RowsAffected, "last_sign": now})
}
// HandleDeleteTimer 删除 Timer
//
// Timer 被删除后,挂在它下面的规则就没有触发来源了,因此一并删除。
func HandleDeleteTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
if _, err := findTimer(timerID, userInfo.ID); err != nil {
response.NotFound(c, "Timer 不存在")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
err = gormDB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("timer_id = ? AND uid = ?", timerID, userInfo.ID).Delete(&model.Rule{}).Error; err != nil {
return err
}
return tx.Where("id = ? AND uid = ?", timerID, userInfo.ID).Delete(&model.Timer{}).Error
})
if err != nil {
response.ServerError(c, "删除 Timer 失败")
return
}
response.OK(c, "Timer 删除成功")
}
// HandleCheckDeleteTimer 删除前查询会被一并删除的规则
func HandleCheckDeleteTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
rules := []model.Rule{}
if err := gormDB.Where("timer_id = ? AND uid = ?", timerID, userInfo.ID).Find(&rules).Error; err != nil {
response.ServerError(c, "获取相关规则失败")
return
}
response.OK(c, rules)
}
+72
View File
@@ -0,0 +1,72 @@
package timer
import (
"strconv"
"time"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
func parseID(raw string) (uint, error) {
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
func boolOr(v *bool, fallback bool) bool {
if v == nil {
return fallback
}
return *v
}
// findTimer 查找属于该用户的 Timer
func findTimer(timerID, uid uint) (*model.Timer, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var timer model.Timer
if err := gormDB.Where("id = ? AND uid = ?", timerID, uid).First(&timer).Error; err != nil {
return nil, err
}
return &timer, nil
}
func countRules(timerID, uid uint) (int64, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var count int64
err = gormDB.Model(&model.Rule{}).Where("timer_id = ? AND uid = ?", timerID, uid).Count(&count).Error
return count, err
}
// signTimer 完成一次签到:重置计时、清除提醒与触发标记
func signTimer(timer *model.Timer) error {
gormDB, err := db.GetGormDB()
if err != nil {
return err
}
now := time.Now().Unix()
if err := gormDB.Model(&model.Timer{}).Where("id = ?", timer.ID).
Updates(map[string]any{
"last_sign": now,
"last_remind": 0,
"triggered": false,
}).Error; err != nil {
return err
}
timer.LastSign = now
timer.LastRemind = 0
timer.Triggered = false
return nil
}
+86
View File
@@ -0,0 +1,86 @@
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()
// 并发执行所有规则,收集失败信息
//
// fails 必须初始化为空切片:nil 切片会被序列化成 JSON null,
// 前端读 failed.length 时会抛异常,把全部成功误报成触发失败。
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,
})
}
+189
View File
@@ -0,0 +1,189 @@
package user
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"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) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
// 验证密码
func verifyPassword(hashedPassword, inputPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(inputPassword))
}
func createUser(u *model.User) error {
gormDB, err := db.GetGormDB()
if err != nil {
return err
}
hashedPassword, err := encryptPassword(u.Password)
if err != nil {
return err
}
u.Password = hashedPassword
u.CreateAt = time.Now().Unix()
return gormDB.Create(u).Error
}
// IsUsernameTaken 用户名是否已被占用
func IsUsernameTaken(username string) (bool, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return false, err
}
var count int64
if err := gormDB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
return false, err
}
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 {
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
}
return &user, nil
}
// GetUserByID 通过 ID 获取用户信息
func GetUserByID(id uint) (*model.User, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
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
}
+248
View File
@@ -0,0 +1,248 @@
package user
import (
"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) {
config, err := configs.GetConfig()
if err != nil {
response.ServerError(c, "获取配置文件失败")
return
}
userCount, err := CountUsers()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if !config.EnableRegistry && userCount > 0 {
response.Fail(c, http.StatusForbidden, "注册功能已关闭")
return
}
var registryRequest model.UserRegistryReuest
if err := c.ShouldBindJSON(&registryRequest); err != nil {
response.BadRequest(c, "输入错误")
return
}
user := model.User{
Username: registryRequest.Username,
Password: registryRequest.Password,
// 第一个注册的用户是管理员
IsAdmin: userCount == 0,
}
if err := user.IsValid(); err != nil {
response.BadRequest(c, err.Error())
return
}
taken, err := IsUsernameTaken(user.Username)
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if taken {
response.BadRequest(c, "用户名已存在")
return
}
if err := createUser(&user); err != nil {
response.ServerError(c, "注册失败")
return
}
if err := setSession(c, &user); err != nil {
response.ServerError(c, "写入会话失败")
return
}
response.OK(c, "注册成功")
}
// HandleLogin 处理登录请求
func HandleLogin(c *gin.Context) {
var loginRequest model.UserRegistryReuest
if err := c.ShouldBindJSON(&loginRequest); err != nil {
response.BadRequest(c, "输入错误")
return
}
if loginRequest.Username == "" || loginRequest.Password == "" {
response.BadRequest(c, "用户名或密码不能为空")
return
}
user, err := verifyUser(loginRequest.Username, loginRequest.Password)
switch {
case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrWrongPassword):
// 不区分“用户不存在”和“密码错误”,避免泄露用户名是否存在
response.Unauthorized(c, "用户名或密码错误")
return
case err != nil:
response.ServerError(c, "登录失败")
return
}
if err := setSession(c, user); err != nil {
response.ServerError(c, "写入会话失败")
return
}
response.OK(c, "登录成功")
}
// HandleLogout 退出登录
func HandleLogout(c *gin.Context) {
session := sessions.Default(c)
session.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 {
response.Unauthorized(c, "获取用户信息失败")
return
}
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)
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()
}
}
// AdminMiddleware 要求当前用户是管理员,需挂在 AuthMiddleware 之后
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
userInfo, err := GetUserInfoByGinCtx(c)
if err != nil {
response.AbortWith(c, http.StatusUnauthorized, "未登录")
return
}
if !userInfo.IsAdmin {
response.AbortWith(c, http.StatusForbidden, "需要管理员权限")
return
}
c.Next()
}
}
-37
View File
@@ -1,37 +0,0 @@
{
"signal_secret": "xxx",
"debug": true,
"disconnect_duration": 80,
"bili_msg": "摇篮系统测试",
"mail_config": {
"host": "smtp.example.com",
"port": 465,
"user": "xxx",
"pass": "xxx",
"mail_list": ["xxx@example.com", "xxx1@example.com"],
"mail_title": "摇篮系统已触发",
"mail_content": "这是摇篮系统的邮件内容。"
},
"basic": {
"name": "xx",
"qq_number": "123",
"nickname": "xx_123",
"age": 16,
"cause_stop": "自杀"
},
"github_config": {
"owner": "example",
"repos": ["example-repo1", "example-repo2"],
"token": "ghp_xxxx"
},
"dingtalk_bot": {
"access_token": "xxx",
"secret": "xxx"
},
"onebot_config": {
"url": "http://localhost:5700",
"token": "xxx",
"send_groups": [12345678, 87654321],
"msg": "这是OneBot的消息内容。"
}
}
+199 -48
View File
@@ -1,69 +1,220 @@
package configs package configs
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"os" "os"
"strconv"
"sync"
) )
// 默认配置文件路径,可通过环境变量 GOODBABY_CONFIG 覆盖
const defaultConfigPath = "config.json"
type Config struct { type Config struct {
SignalSecret string `json:"signal_secret"` // HTTP 监听地址,如 ":8088"
Debug bool `json:"debug"` ListenAddr string `json:"listen_addr"`
DisconnectDuration int `json:"disconnect_duration"` // Hours // 是否开放注册
BiliMsg string `json:"bili_msg"` EnableRegistry bool `json:"enable_registry"`
Basic Basic `json:"basic"` // 规则执行的最长重试时间(小时),用于指数退避的整体超时
MailConfig MailConfig `json:"mail_config"` TimeoutDurationHours int `json:"timeout_duration_hours"`
GithubConfig GithubConfig `json:"github_config"` // 检查 timer 的间隔(分钟)
DingtalkBot DingtalkBot `json:"dingtalk_bot"` CheckIntervalMinutes int `json:"check_interval_minutes"`
OneBotConfig OneBotConfig `json:"onebot_config"` // 数据库驱动,"sqlite"(默认) 或 "postgres"
DatabaseDriver string `json:"database_driver"`
// sqlite 数据库文件路径,DatabaseDriver 为 sqlite 时使用
DatabasePath string `json:"database_path"`
// postgres 连接串,DatabaseDriver 为 postgres 时使用
// 例如 postgres://user:pass@localhost:5432/goodbaby?sslmode=disable
DatabaseDSN string `json:"database_dsn"`
// session 加密密钥,为空时自动生成并写回配置文件
SessionSecret string `json:"session_secret"`
// session 有效期(小时)
SessionMaxAgeHours int `json:"session_max_age_hours"`
// 允许跨域访问的来源,开发前端时使用,如 ["http://localhost:5173"]
AllowedOrigins []string `json:"allowed_origins"`
// 每个用户保留的执行日志条数,<=0 表示不限制
LogRetainCount int `json:"log_retain_count"`
} }
type MailConfig struct { // 支持的数据库驱动
Host string `json:"host"` const (
Port int `json:"port"` DriverSQLite = "sqlite"
User string `json:"user"` DriverPostgres = "postgres"
Password string `json:"pass"` )
MailList []string `json:"mail_list"`
MailTitle string `json:"mail_title"` func defaultConfig() Config {
MailContent string `json:"mail_content"` return Config{
ListenAddr: ":8088",
EnableRegistry: true,
TimeoutDurationHours: 6,
CheckIntervalMinutes: 10,
DatabaseDriver: DriverSQLite,
DatabasePath: "data.db",
SessionMaxAgeHours: 24 * 7,
AllowedOrigins: []string{},
LogRetainCount: 500,
}
} }
type GithubConfig struct { var (
Owner string `json:"owner"` once sync.Once
Repos []string `json:"repos"` cached Config
Token string `json:"token"` 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
} }
type Basic struct { // MustGetConfig 与 GetConfig 相同,但在出错时直接退出,用于启动阶段
Name string `json:"name"` func MustGetConfig() Config {
Age int `json:"age"` cfg, err := GetConfig()
NickName string `json:"nickname"`
QQNumber string `json:"qq_number"`
CauseStop string `json:"cause_stop"`
}
type DingtalkBot struct {
AccessToken string `json:"access_token"`
Secret string `json:"secret"`
}
type OneBotConfig struct {
URL string `json:"url"` // 例如 "http://localhost:5700"
Token string `json:"token"`
SendGroups []int `json:"send_groups"`
Msg string `json:"msg"`
}
// Get config from json file
func GetConfig() (config Config, err error) {
content, err := os.ReadFile("config.json")
if err != nil { if err != nil {
return config, err log.Fatalf("加载配置失败: %v", err)
}
return cfg
}
func configPath() string {
if p := os.Getenv("GOODBABY_CONFIG"); p != "" {
return p
}
return defaultConfigPath
}
func load() (Config, error) {
path := configPath()
config := defaultConfig()
content, err := os.ReadFile(path)
switch {
case err == nil:
if err := json.Unmarshal(content, &config); err != nil {
return config, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
}
case errors.Is(err, fs.ErrNotExist):
log.Printf("配置文件 %s 不存在,使用默认配置创建", path)
default:
return config, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
} }
err = json.Unmarshal(content, &config) changed := normalize(&config)
if err != nil { applyEnvOverrides(&config)
return config, err
if changed {
if err := save(path, config); err != nil {
// 只读挂载等场景下写回失败不应阻断启动
log.Printf("警告: 写回配置文件失败: %v", err)
}
} }
return config, nil return config, nil
} }
// normalize 补齐缺失/非法的配置项,返回是否发生了修改
func normalize(c *Config) bool {
def := defaultConfig()
changed := false
if c.ListenAddr == "" {
c.ListenAddr, changed = def.ListenAddr, true
}
if c.TimeoutDurationHours <= 0 {
c.TimeoutDurationHours, changed = def.TimeoutDurationHours, true
}
if c.CheckIntervalMinutes <= 0 {
c.CheckIntervalMinutes, changed = def.CheckIntervalMinutes, true
}
if c.DatabasePath == "" {
c.DatabasePath, changed = def.DatabasePath, true
}
if c.DatabaseDriver == "" {
c.DatabaseDriver, changed = def.DatabaseDriver, 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_DB_DRIVER"); v != "" {
c.DatabaseDriver = v
}
if v := os.Getenv("GOODBABY_DB_DSN"); v != "" {
c.DatabaseDSN = v
// 给了连接串却没显式指定驱动时,按 postgres 处理
if os.Getenv("GOODBABY_DB_DRIVER") == "" {
c.DatabaseDriver = DriverPostgres
}
}
if v := os.Getenv("GOODBABY_SESSION_SECRET"); v != "" {
c.SessionSecret = v
}
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)
}
+60
View File
@@ -0,0 +1,60 @@
package configs
import "fmt"
// EditableConfig 是管理员可以在 WebUI 里修改的配置子集。
//
// 只收录改完立即生效的项。监听地址、数据库、session 密钥这类需要重启
// 或改错会导致服务起不来的配置,只能改配置文件。
type EditableConfig struct {
EnableRegistry bool `json:"enable_registry"`
TimeoutDurationHours int `json:"timeout_duration_hours"`
CheckIntervalMinutes int `json:"check_interval_minutes"`
LogRetainCount int `json:"log_retain_count"`
}
// Editable 从完整配置中取出可编辑的部分
func (c Config) Editable() EditableConfig {
return EditableConfig{
EnableRegistry: c.EnableRegistry,
TimeoutDurationHours: c.TimeoutDurationHours,
CheckIntervalMinutes: c.CheckIntervalMinutes,
LogRetainCount: c.LogRetainCount,
}
}
// Validate 校验管理员提交的配置
func (e EditableConfig) Validate() error {
if e.TimeoutDurationHours < 1 || e.TimeoutDurationHours > 72 {
return fmt.Errorf("规则重试时长必须在 1~72 小时之间")
}
if e.CheckIntervalMinutes < 1 || e.CheckIntervalMinutes > 1440 {
return fmt.Errorf("检查间隔必须在 1~1440 分钟之间")
}
if e.LogRetainCount < 0 || e.LogRetainCount > 100000 {
return fmt.Errorf("日志保留条数必须在 0~100000 之间,0 表示不限制")
}
return nil
}
// ApplyEditable 把可编辑部分合并进当前配置并持久化,返回更新后的完整配置
func ApplyEditable(e EditableConfig) (Config, error) {
if err := e.Validate(); err != nil {
return Config{}, err
}
current, err := GetConfig()
if err != nil {
return Config{}, err
}
current.EnableRegistry = e.EnableRegistry
current.TimeoutDurationHours = e.TimeoutDurationHours
current.CheckIntervalMinutes = e.CheckIntervalMinutes
current.LogRetainCount = e.LogRetainCount
if err := Save(current); err != nil {
return Config{}, err
}
return current, nil
}
+6 -9
View File
@@ -1,18 +1,15 @@
services: services:
goodbaby: goodbaby:
image: mei232/goodbaby:v0.2.0 # 发版镜像由 GitHub Actions 自动推送到 ghcr
image: ghcr.io/ssdomei232/goodbaby:latest
# 想用本地源码构建时把上面一行注释掉,保留 build
build: .
ports: ports:
- "8088:8088" - "8088:8088"
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- GIN_MODE=release - GIN_MODE=release
restart: unless-stopped restart: unless-stopped
networks:
- app-network
volumes: volumes:
- ./config.json:/app/config.json:ro # sqlite 需要写权限,config.json 首次启动会自动生成,不能挂成只读
- ./tmp:/app/tmp - ./data:/app/data
networks:
app-network:
driver: bridge
+61
View File
@@ -0,0 +1,61 @@
# Bilibili 配置
账号类型: `bilibili`。
支持的规则类型:
| 规则类型 | 说明 |
| --- | --- |
| `bilibili-dynamic` | 发布一条纯文本动态 |
| `bilibili-private-message` | 向指定用户发送私信 |
## 账号配置 (Account Config)
```json
{
"raw_cookies": "SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx; ..."
}
```
登录 [bilibili.com](https://www.bilibili.com) 后,从浏览器开发者工具(F12 → 网络 → 任意请求 → 请求头 → Cookie)复制完整的 Cookie 字符串。
在 WebUI 的账号页面可以点击“测试”验证 Cookie 是否有效。
## 动态规则配置 (`bilibili-dynamic`)
```json
{
"msg": "要发送的动态内容"
}
```
定时器到期后会以该账号发布一条纯文本动态。
## 私信规则配置 (`bilibili-private-message`)
```json
{
"receiver_uids": [2, 123456],
"msg": "要发送的私信内容"
}
```
`receiver_uids` 是接收者的 UID 列表,即对方空间地址 `space.bilibili.com/` 后面的那串数字。
发送者 UID 由程序自动从账号 Cookie 中读取,不需要填写。
注意:B 站对私信有频率限制与陌生人私信限制,如果对方设置了“仅关注的人可以给我发私信”,发送会失败。失败信息可以在执行日志里看到。
## 关于视频投稿
当前使用的 SDK [`CuteReimu/bilibili`](https://github.com/CuteReimu/bilibili) **没有提供视频投稿(上传稿件)接口**,因此暂不支持“定时投稿视频”这类规则。
该 SDK 与上传相关的能力只有 `UploadDynamicBfs`(为图片动态上传图片),不包含投稿所需的 `preupload` / 分片上传 / `archive/add` 流程。
若要支持视频投稿,需要:
1. 自行实现 B 站投稿协议(预上传 → 分片上传 → 合并 → 提交稿件),且该协议未公开、变动频繁;
2. 给 goodBaby 增加文件上传与存储能力(当前只存 JSON 配置,不保存任何文件);
3. 处理大文件的断点续传与磁盘占用。
这是一个独立的功能模块,不在现有驱动的改造范围内。
+89
View File
@@ -0,0 +1,89 @@
# 数据库配置
goodBaby 默认使用 SQLite,零配置开箱即用;也可以切换到 PostgreSQL。
## SQLite(默认)
```json
{
"database_driver": "sqlite",
"database_path": "data.db"
}
```
用的是纯 Go 的 [glebarez/sqlite](https://github.com/glebarez/sqlite),不需要 CGO,编译出来就是一个静态二进制。
已开启 WAL 与 10 秒 busy timeout;因为 SQLite 是单写模型,连接池被限制为 1 条连接,避免并发执行规则时出现 `database is locked`。
## PostgreSQL
```json
{
"database_driver": "postgres",
"database_dsn": "postgres://user:password@localhost:5432/goodbaby?sslmode=disable"
}
```
切换到 postgres 后 `database_path` 会被忽略。连接池为最大 20 条连接、5 条空闲、连接最长存活 1 小时。
启动时会先 `Ping` 一次,配置写错会在启动阶段直接报错退出,而不是等到第一次查询才失败。
### 环境变量
配置文件之外也可以用环境变量覆盖,适合容器部署:
| 变量 | 说明 |
| --- | --- |
| `GOODBABY_DB_DRIVER` | `sqlite` 或 `postgres` |
| `GOODBABY_DB_DSN` | postgres 连接串 |
| `GOODBABY_DB_PATH` | sqlite 文件路径 |
只设置了 `GOODBABY_DB_DSN` 而没设置 `GOODBABY_DB_DRIVER` 时,会自动按 postgres 处理。
### docker compose 示例
```yaml
services:
goodbaby:
image: mei232/goodbaby:v2.1.0
ports:
- "8088:8088"
environment:
- TZ=Asia/Shanghai
- GIN_MODE=release
- GOODBABY_DB_DSN=postgres://goodbaby:goodbaby@postgres:5432/goodbaby?sslmode=disable
volumes:
- ./data:/app/data
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=goodbaby
- POSTGRES_PASSWORD=goodbaby
- POSTGRES_DB=goodbaby
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U goodbaby"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
volumes:
pgdata:
```
## 切换数据库
两种数据库之间**没有**自动数据迁移。切换驱动后是一个全新的空库,需要重新注册账号并配置规则。
如果要搬运已有数据,得自己从 SQLite 导出再导入 PostgreSQL(表结构一致:`users` / `timers` / `rules` / `accounts` / `execution_logs`)。
## 建表
首次启动会自动执行 `AutoMigrate` 建表并补齐新增字段,不需要手动执行 SQL。
+32
View File
@@ -0,0 +1,32 @@
# 钉钉机器人配置
钉钉在 goodBaby 中有两个用途:
1. **提醒渠道**:定时器临近到期时提醒你签到,在 WebUI 的“设置”页配置
2. **规则类型** (`dingtalk`):定时器到期后向钉钉群发送消息,不需要关联账号
## 获取机器人凭据
1. 在钉钉群中:群设置 → 智能群助手 → 添加机器人 → 自定义
2. 安全设置推荐选择“加签”,记下 Secret
3. 创建后复制 Webhook 地址中 `access_token=` 后面的值
## 规则配置 (Rule Config)
```json
{
"access_token": "机器人 access_token",
"secret": "加签 Secret(未开启加签可留空)",
"title": "消息标题",
"msg": "消息内容,支持 Markdown"
}
```
## 提醒渠道配置(设置页)
```json
{
"access_token": "机器人 access_token",
"secret": "加签 Secret"
}
```
+39
View File
@@ -0,0 +1,39 @@
# Email 配置
账号类型: `email`,规则类型: `email`。
## 账号配置 (Account Config)
```json
{
"smtp_server": "smtp.example.com",
"port": 465,
"security": "ssl",
"username": "you@example.com",
"password": "你的密码或授权码",
"from": "you@example.com",
"test_destination": "test@example.com"
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `smtp_server` | 是 | SMTP 服务器地址 |
| `port` | 是 | 端口,SSL 一般为 465,STARTTLS 一般为 587 |
| `security` | 否 | `ssl` / `starttls` / `none`,默认 `ssl` |
| `username` | 是 | 登录用户名,通常是邮箱地址 |
| `password` | 是 | 密码或授权码(QQ 邮箱、163 等需要使用授权码) |
| `from` | 否 | 发件人地址,默认使用 `username` |
| `test_destination` | 否 | 填写后,点击“测试”会真的发送一封测试邮件到该地址 |
## 规则配置 (Rule Config)
```json
{
"title": "邮件标题",
"msg": "邮件正文",
"destinations": ["a@example.com", "b@example.com"]
}
```
每个收件人独立发送、独立重试,互不影响。
+33
View File
@@ -0,0 +1,33 @@
# GitHub 配置
账号类型: `github`,规则类型: `github-repo-public`。
定时器到期后,把指定的私有仓库设置为公开。
## 账号配置 (Account Config)
```json
{
"token": "ghp_xxxxxxxx",
"owner": "your-github-name"
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `token` | 是 | Personal Access Token,需要 `repo` 权限 |
| `owner` | 是 | 仓库所有者(用户名或组织名) |
Token 在 GitHub → Settings → Developer settings → Personal access tokens 创建。
在 WebUI 的账号页面可以点击“测试”验证 Token 是否有效。
## 规则配置 (Rule Config)
```json
{
"repos": ["repo-a", "repo-b"]
}
```
只填仓库名,不含所有者。
+33
View File
@@ -0,0 +1,33 @@
# OneBot (QQ) 配置
账号类型: `onebot`,规则类型: `onebot`。
对接 [NapCat](https://napneko.github.io/)、go-cqhttp 等实现了 OneBot HTTP API 的服务。
## 账号配置 (Account Config)
```json
{
"url": "http://localhost:3000",
"token": "your_access_token"
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `url` | 是 | OneBot HTTP 服务地址 |
| `token` | 是 | Access Token |
在 WebUI 的账号页面可以点击“测试”验证连通性(调用 `get_login_info`)。
## 规则配置 (Rule Config)
```json
{
"msg": "要发送的消息",
"send_groups": [123456789],
"send_users": [987654321]
}
```
`send_groups`(群号)与 `send_users`(好友 QQ 号)至少填写一项。
+71
View File
@@ -0,0 +1,71 @@
# 发版流程
发版由 [`.github/workflows/release.yml`](../.github/workflows/release.yml) 全自动完成,
触发条件是推送一个 `v` 开头的 tag。
## 发一个版本
```bash
git tag v2.2.0
git push origin v2.2.0
```
推上去之后 workflow 会依次做四件事:
| 阶段 | 内容 |
| --- | --- |
| `frontend` | 跑一次 `npm ci && npm run build`(含 `vue-tsc` 类型检查),产物作为 artifact 共享 |
| `build` | 5 个平台交叉编译,打包成 tar.gz / zip |
| `release` | 汇总产物、生成 `checksums.txt`、创建 GitHub Release(发布说明自动生成) |
| `docker` | buildx 构建 `linux/amd64` + `linux/arm64` 镜像推送到 ghcr |
前端只构建一次,所有平台的二进制共用同一份产物,保证各平台内嵌的前端完全一致。
## 产物
二进制覆盖:
* `linux/amd64`、`linux/arm64`
* `windows/amd64`
* `darwin/amd64`、`darwin/arm64`
每个压缩包内含可执行文件、`README.md`、`LICENSE` 与 `docs/`。
镜像标签由 tag 推导:`v2.2.0` 会产出 `2.2.0`、`2.2`、`2` 与 `latest`。
## 版本信息注入
编译时通过 ldflags 注入,启动日志里能看到:
```
goodBaby v2.2.0 (a1b2c3d, 2026-07-27T07:00:00Z) 启动中...
```
对应 `main.go` 里的 `version` / `gitCommit` / `buildDate` 三个变量。
## 关于交叉编译
项目依赖里没有 CGO——sqlite 用的是纯 Go 的 `glebarez/sqlite`——所以所有平台都能用
`CGO_ENABLED=0` 直接交叉编译,不需要各平台的 runner。
Dockerfile 里前端与后端构建阶段都标了 `--platform=$BUILDPLATFORM`,配合 `TARGETARCH`
做 Go 交叉编译,因此构建 arm64 镜像时不会把 npm 和 go build 丢进 QEMU 里跑。
## 手动验证
workflow 支持 `workflow_dispatch` 手动触发。手动跑时**不会创建 Release**(`release` job 有
`if: github.ref_type == 'tag'` 保护),只会构建产物并推一个 `sha-xxxxxxx` 标签的镜像,
适合在正式打 tag 前先验证流程。
## 权限
workflow 只用了内置的 `GITHUB_TOKEN`,不需要额外配置 secret:
```yaml
permissions:
contents: write # 创建 Release
packages: write # 推送镜像到 ghcr
```
首次推送后,包默认是私有的。如果要公开,去仓库的 Packages 页面把
`goodbaby` 这个包的可见性改成 Public。
+74
View File
@@ -0,0 +1,74 @@
package bilibili
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// BilibiliAccountConfigValidator B站账号配置验证器
type BilibiliAccountConfigValidator struct{}
func (v *BilibiliAccountConfigValidator) GetType() string {
return AccountType
}
func (v *BilibiliAccountConfigValidator) Validate(config string) error {
cfg, err := parseAccount(config)
if err != nil {
return err
}
if cfg.RawCookies == "" {
return fmt.Errorf("B站账号配置中 cookies 不能为空")
}
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
}
+41
View File
@@ -0,0 +1,41 @@
package bilibili
import (
"context"
"fmt"
"github.com/CuteReimu/bilibili/v2"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// SendBiliDynamicMsg 在 Bilibili 动态发送消息
//
// 暂时没有处理 429 和 403 的区别对待
func SendBiliDynamicMsg(ctx context.Context, rule *model.Rule) error {
biliClient, err := getBiliClient(rule)
if err != nil {
return fmt.Errorf("获取B站客户端失败: %w", err)
}
biliDynamicConfig, err := getBiliDynamicConfig(rule)
if err != nil {
return fmt.Errorf("获取B站动态配置失败: %w", err)
}
dynamicParams := bilibili.CreateDynamicParam{
DynamicId: 0,
Type: 4,
Rid: 0,
Content: biliDynamicConfig.Msg,
}
if err := retry.Do(ctx, func() error {
_, err := biliClient.CreateDynamic(dynamicParams)
return err
}); err != nil {
return fmt.Errorf("发送B站动态失败: %w", err)
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package bilibili
const (
// AccountType B 站账号类型标识
AccountType = "bilibili"
// RuleTypeDynamic 发送 B 站动态的规则类型标识
RuleTypeDynamic = "bilibili-dynamic"
// RuleTypePrivateMessage 发送 B 站私信的规则类型标识
RuleTypePrivateMessage = "bilibili-private-message"
)
type BiliAccount struct {
RawCookies string `json:"raw_cookies"`
}
type BiliDynamicConfig struct {
Msg string `json:"msg"`
}
// BiliPrivateMessageConfig 私信规则配置
type BiliPrivateMessageConfig struct {
// 接收者的 UID 列表
ReceiverUids []int64 `json:"receiver_uids"`
Msg string `json:"msg"`
}
+80
View File
@@ -0,0 +1,80 @@
package bilibili
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/CuteReimu/bilibili/v2"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// 私信消息类型:1 为文字
const privateMsgTypeText = 1
// SendBiliPrivateMessage 向配置中的每个 UID 发送一条 B 站私信
func SendBiliPrivateMessage(ctx context.Context, rule *model.Rule) error {
client, err := getBiliClient(rule)
if err != nil {
return fmt.Errorf("获取B站客户端失败: %w", err)
}
config, err := getPrivateMessageConfig(rule)
if err != nil {
return err
}
// 发私信需要带上自己的 UID,从账号信息里取
account, err := client.GetAccountInformation()
if err != nil {
return fmt.Errorf("获取B站账号信息失败(cookie 可能已失效): %w", err)
}
if account == nil || account.Mid == 0 {
return fmt.Errorf("B站 cookie 无效或已过期")
}
var fails []string
for _, uid := range config.ReceiverUids {
err := retry.Do(ctx, func() error {
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
SenderUid: account.Mid,
ReceiverId: int(uid),
ReceiverType: 1,
MsgType: privateMsgTypeText,
Timestamp: int(time.Now().Unix()),
Content: buildTextContent(config.Msg),
})
return err
})
if err != nil {
fails = append(fails, fmt.Sprintf("UID %d: %v", uid, err))
}
}
if len(fails) > 0 {
return fmt.Errorf("%d/%d 条私信发送失败: %s",
len(fails), len(config.ReceiverUids), strings.Join(fails, "; "))
}
return nil
}
// buildTextContent 文字私信的 content 是一个 JSON 字符串 {"content":"..."}
func buildTextContent(msg string) string {
payload, err := json.Marshal(map[string]string{"content": msg})
if err != nil {
// msg 是普通字符串,序列化不会失败;兜底也返回合法 JSON
return `{"content":""}`
}
return string(payload)
}
func getPrivateMessageConfig(rule *model.Rule) (*BiliPrivateMessageConfig, error) {
var config BiliPrivateMessageConfig
if err := json.Unmarshal([]byte(rule.ConfigJson), &config); err != nil {
return nil, fmt.Errorf("解析B站私信规则配置失败: %w", err)
}
return &config, nil
}
+102
View File
@@ -0,0 +1,102 @@
package bilibili
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// BilibiliDynamicRuleValidator B站动态规则验证器
type BilibiliDynamicRuleValidator struct{}
func (v *BilibiliDynamicRuleValidator) GetType() string {
return RuleTypeDynamic
}
func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error {
var config BiliDynamicConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析B站动态规则配置失败: %v", err)
}
if config.Msg == "" {
return fmt.Errorf("B站动态规则配置中 msg 不能为空")
}
if len([]rune(config.Msg)) > 1000 {
return fmt.Errorf("B站动态内容过长")
}
return nil
}
// BilibiliPrivateMessageRuleValidator B站私信规则验证器
type BilibiliPrivateMessageRuleValidator struct{}
func (v *BilibiliPrivateMessageRuleValidator) GetType() string {
return RuleTypePrivateMessage
}
func (v *BilibiliPrivateMessageRuleValidator) Validate(configJSON string) error {
var config BiliPrivateMessageConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析B站私信规则配置失败: %v", err)
}
if config.Msg == "" {
return fmt.Errorf("B站私信规则配置中 msg 不能为空")
}
if len(config.ReceiverUids) == 0 {
return fmt.Errorf("B站私信规则配置中 receiver_uids 不能为空")
}
for _, uid := range config.ReceiverUids {
if uid <= 0 {
return fmt.Errorf("B站私信规则配置中存在非法的 UID: %d", uid)
}
}
return nil
}
func (v *BilibiliPrivateMessageRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: RuleTypePrivateMessage,
Label: "发送 B 站私信",
Description: "触发时以关联的 B 站账号向指定用户发送私信。对方需要能接收你的私信。",
Docs: "docs/bilibili-config.md",
AccountType: AccountType,
Fields: []meta.Field{
{
Key: "receiver_uids",
Label: "接收者 UID",
Type: meta.FieldNumberList,
Required: true,
Placeholder: "2",
Help: "对方空间地址 space.bilibili.com/ 后面的那串数字",
},
{Key: "msg", Label: "私信内容", Type: meta.FieldTextarea, Required: true},
},
}
}
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: "要发送的动态正文",
},
},
}
}
+34
View File
@@ -0,0 +1,34 @@
package bilibili
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/model"
)
// BilibiliDynamicExecutor B站动态执行器
type BilibiliDynamicExecutor struct{}
func (e *BilibiliDynamicExecutor) GetType() string {
return RuleTypeDynamic
}
func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行B站动态规则: %s (ID: %d)", rule.Name, rule.ID)
return SendBiliDynamicMsg(ctx, rule)
}
// BilibiliPrivateMessageExecutor B站私信执行器
type BilibiliPrivateMessageExecutor struct{}
func (e *BilibiliPrivateMessageExecutor) GetType() string {
return RuleTypePrivateMessage
}
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行B站私信规则: %s (ID: %d)", rule.Name, rule.ID)
return SendBiliPrivateMessage(ctx, rule)
}
+35
View File
@@ -0,0 +1,35 @@
package bilibili
import (
"encoding/json"
"github.com/CuteReimu/bilibili/v2"
"github.com/ssdomei232/goodBaby/handler/db"
"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) {
var accountConfig BiliAccount
if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
return nil, err
}
return newClient(accountConfig.RawCookies), nil
}
// 获取 Bilibili Dynamic Config
func getBiliDynamicConfig(rule *model.Rule) (*BiliDynamicConfig, error) {
var biliDynamicConfig BiliDynamicConfig
if err := json.Unmarshal([]byte(rule.ConfigJson), &biliDynamicConfig); err != nil {
return nil, err
}
return &biliDynamicConfig, nil
}
+33
View File
@@ -0,0 +1,33 @@
// Package dingtalk 提供钉钉自定义机器人的提醒与规则执行能力
package dingtalk
import (
"fmt"
"github.com/CatchZeng/dingtalk/pkg/dingtalk"
"github.com/ssdomei232/goodBaby/model"
)
// 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
}
+17
View File
@@ -0,0 +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"`
}
+58
View File
@@ -0,0 +1,58 @@
package dingtalk
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// DingTalkRuleValidator 钉钉规则验证器
type DingTalkRuleValidator struct{}
func (v *DingTalkRuleValidator) GetType() string {
return RuleType
}
func (v *DingTalkRuleValidator) Validate(configJSON string) error {
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
}
+31
View File
@@ -0,0 +1,31 @@
package dingtalk
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// DingTalkExecutor 钉钉机器人执行器
//
// 之前只注册了校验器却没有执行器,导致钉钉规则可以创建但永远不会被执行。
type DingTalkExecutor struct{}
func (e *DingTalkExecutor) GetType() string {
return RuleType
}
func (e *DingTalkExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行钉钉规则: %s (ID: %d)", rule.Name, rule.ID)
config, err := ParseRuleConfig(rule.ConfigJson)
if err != nil {
return err
}
return retry.Do(ctx, func() error {
return send(&config.DingTalkConfig, config.Title, config.Msg)
})
}
+20
View File
@@ -0,0 +1,20 @@
package dingtalk
import (
"encoding/json"
"github.com/ssdomei232/goodBaby/model"
)
func getDingTalkConfigFromUser(user *model.User) *DingTalkConfig {
if user.DingTalkConfig == nil {
return nil
}
var config DingTalkConfig
err := json.Unmarshal([]byte(*user.DingTalkConfig), &config)
if err != nil {
return nil
}
return &config
}
+113
View File
@@ -0,0 +1,113 @@
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 AccountType
}
func (v *EmailAccountConfigValidator) Validate(config string) error {
cfg, err := parseAccountConfig(config)
if err != nil {
return err
}
if cfg.SMTPServer == "" {
return fmt.Errorf("邮箱账号配置中 SMTP 服务器不能为空")
}
if cfg.Port <= 0 || cfg.Port > 65535 {
return fmt.Errorf("邮箱账号配置中 SMTP 端口不合法")
}
if cfg.Username == "" {
return fmt.Errorf("邮箱账号配置中用户名不能为空")
}
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
}
+51
View File
@@ -0,0 +1,51 @@
package email
import (
"context"
"fmt"
"strings"
"sync"
"github.com/ssdomei232/goodBaby/model"
)
// SendMail 向规则配置里的所有地址发送邮件。
//
// 每个地址单独重试,互不阻塞;全部失败/部分失败都会汇总成错误返回,
// 由 runner 记录到执行日志中。
func SendMail(ctx context.Context, rule *model.Rule) error {
emailRule, err := GetEmailRuleFromRule(rule)
if err != nil {
return fmt.Errorf("获取邮件规则配置失败: %w", err)
}
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
}
+50
View File
@@ -0,0 +1,50 @@
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"`
// 加密方式,留空时按 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 {
Msg string `json:"msg"`
Title string `json:"title"`
Destinations []string `json:"destinations"` // 邮件地址列表
}
+59
View File
@@ -0,0 +1,59 @@
package email
import (
"encoding/json"
"fmt"
"net/mail"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// EmailRuleValidator 邮件规则验证器
type EmailRuleValidator struct{}
func (v *EmailRuleValidator) GetType() string {
return RuleType
}
func (v *EmailRuleValidator) Validate(configJSON string) error {
var config EmailRule
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析邮件规则配置失败: %v", err)
}
if config.Title == "" {
return fmt.Errorf("邮件规则配置中 title 不能为空")
}
if config.Msg == "" {
return fmt.Errorf("邮件规则配置中 msg 不能为空")
}
if len(config.Destinations) == 0 {
return fmt.Errorf("邮件规则配置中 destinations 不能为空")
}
// 验证邮箱地址格式
for _, dest := range config.Destinations {
if _, err := mail.ParseAddress(dest); err != nil {
return fmt.Errorf("无效的邮箱地址: %s", dest)
}
}
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"},
},
}
}
+21
View File
@@ -0,0 +1,21 @@
package email
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/model"
)
// EmailExecutor 邮件执行器
type EmailExecutor struct{}
func (e *EmailExecutor) GetType() string {
return RuleType
}
func (e *EmailExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行邮件规则: %s (ID: %d)", rule.Name, rule.ID)
return SendMail(ctx, rule)
}
+91
View File
@@ -0,0 +1,91 @@
package email
import (
"context"
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
"github.com/wneessen/go-mail"
)
// GetEmailAccountFromRule 从 Rule 中获取 EmailAccount 配置
func GetEmailAccountFromRule(rule *model.Rule) (*EmailAccountConfig, error) {
var emailAccountConfig EmailAccountConfig
if err := db.LoadAccountConfig(rule.AccountID, &emailAccountConfig); err != nil {
return nil, err
}
return &emailAccountConfig, nil
}
// GetEmailRuleFromRule 从 Rule 中获取 EmailRule 配置
func GetEmailRuleFromRule(rule *model.Rule) (*EmailRule, error) {
var emailRule EmailRule
if err := json.Unmarshal([]byte(rule.ConfigJson), &emailRule); err != nil {
return nil, err
}
return &emailRule, nil
}
// 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 {
return nil, fmt.Errorf("创建邮件客户端失败: %w", err)
}
return client, nil
}
func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.Msg, error) {
message := mail.NewMsg()
if err := message.From(cfg.FromOrDefault()); err != nil {
return nil, fmt.Errorf("设置发件人失败: %w", err)
}
if err := message.To(address); err != nil {
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, 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
}
+75
View File
@@ -0,0 +1,75 @@
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 AccountType
}
func (v *GitHubAccountConfigValidator) Validate(config string) error {
cfg, err := parseAccount(config)
if err != nil {
return err
}
if cfg.Token == "" {
return fmt.Errorf("GitHub账号配置中Token不能为空")
}
if cfg.Owner == "" {
return fmt.Errorf("GitHub账号配置中Owner不能为空")
}
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
}
+34
View File
@@ -0,0 +1,34 @@
package github
import (
"context"
"fmt"
"strings"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// 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 {
err := retry.Do(ctx, func() error {
return SetRepositoryPublic(ctx, account.Token, account.Owner, repo)
})
if err != nil {
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
}
+17
View File
@@ -0,0 +1,17 @@
package github
const (
// AccountType GitHub 账号类型标识
AccountType = "github"
// RuleTypeMakeRepoPublic 公开仓库的规则类型标识
RuleTypeMakeRepoPublic = "github-repo-public"
)
type GithubReposConfig struct {
Repos []string `json:"repos"`
}
type GithubAccount struct {
Token string `json:"token"`
Owner string `json:"owner"`
}
+56
View File
@@ -0,0 +1,56 @@
package github
import (
"encoding/json"
"fmt"
"strings"
"github.com/ssdomei232/goodBaby/internal/meta"
)
type GithubMakeRepositoryPublicRuleValidator struct{}
func (v *GithubMakeRepositoryPublicRuleValidator) GetType() string {
return RuleTypeMakeRepoPublic
}
func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) error {
var config GithubReposConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析 GitHub 规则配置失败: %v", err)
}
if len(config.Repos) == 0 {
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 账号",
},
},
}
}
+21
View File
@@ -0,0 +1,21 @@
package github
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/model"
)
// GithubMakeRepoPublicExecutor GitHub仓库公开执行器
type GithubMakeRepoPublicExecutor struct{}
func (e *GithubMakeRepoPublicExecutor) GetType() string {
return RuleTypeMakeRepoPublic
}
func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行GitHub仓库公开规则: %s (ID: %d)", rule.Name, rule.ID)
return MakeRepositoryPublic(ctx, rule)
}
+38
View File
@@ -0,0 +1,38 @@
package github
import (
"context"
"encoding/json"
"fmt"
"github.com/google/go-github/v84/github"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
// 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
if err := db.LoadAccountConfig(rule.AccountID, &githubAccount); err != nil {
return nil, nil, err
}
return &githubReposConfig, &githubAccount, nil
}
// SetRepositoryPublic 将仓库设置为 public
func SetRepositoryPublic(ctx context.Context, token, owner, repo string) error {
client := github.NewClient(nil).WithAuthToken(token)
opts := &github.Repository{
Visibility: github.Ptr("public"),
}
_, _, err := client.Repositories.Edit(ctx, owner, repo, opts)
return err
}
+72
View File
@@ -0,0 +1,72 @@
package onebot
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// OneBotAccountConfigValidator OneBot账号配置验证器
type OneBotAccountConfigValidator struct{}
func (v *OneBotAccountConfigValidator) GetType() string {
return AccountType
}
func (v *OneBotAccountConfigValidator) Validate(config string) error {
cfg, err := parseAccount(config)
if err != nil {
return err
}
if cfg.Token == "" {
return fmt.Errorf("OneBot账号配置中Token不能为空")
}
if cfg.URL == "" {
return fmt.Errorf("OneBot账号配置中URL不能为空")
}
parsed, err := url.Parse(cfg.URL)
if err != nil || parsed.Host == "" || !strings.HasPrefix(parsed.Scheme, "http") {
return fmt.Errorf("OneBot账号配置中URL不合法,应形如 http://localhost:3000")
}
return nil
}
// 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
}
+53
View File
@@ -0,0 +1,53 @@
package onebot
const (
// AccountType OneBot 账号类型标识
AccountType = "onebot"
// RuleType 发送 OneBot 消息的规则类型标识
RuleType = "onebot"
)
type OneBotConfig struct {
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
}
+51
View File
@@ -0,0 +1,51 @@
package onebot
import (
"context"
"fmt"
"strings"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// 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 {
return fmt.Errorf("获取OneBot账户失败: %w", err)
}
var fails []string
total := 0
for _, groupID := range oneBotConfig.SendGroups {
total++
err := retry.Do(ctx, func() error {
return sendGroupMsg(oneBotAccount, groupID, oneBotConfig.Msg)
})
if err != nil {
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
}
+52
View File
@@ -0,0 +1,52 @@
package onebot
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// OneBotRuleValidator OneBot规则验证器
type OneBotRuleValidator struct{}
func (v *OneBotRuleValidator) GetType() string {
return RuleType
}
func (v *OneBotRuleValidator) Validate(configJSON string) error {
var config OneBotConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析OneBot规则配置失败: %v", err)
}
if config.Msg == "" {
return fmt.Errorf("OneBot规则配置中 msg 不能为空")
}
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"},
},
}
}
+21
View File
@@ -0,0 +1,21 @@
package onebot
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/model"
)
// OneBotExecutor OneBot执行器
type OneBotExecutor struct{}
func (e *OneBotExecutor) GetType() string {
return RuleType
}
func (e *OneBotExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行OneBot规则: %s (ID: %d)", rule.Name, rule.ID)
return SendOneBotMsg(ctx, rule)
}
+59
View File
@@ -0,0 +1,59 @@
package onebot
import (
"encoding/json"
"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, error) {
var oneBotConfig OneBotConfig
if err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig); err != nil {
return nil, fmt.Errorf("解析OneBot规则配置失败: %w", err)
}
return &oneBotConfig, nil
}
// 从 Rule 中获取 OneBotAccount
func getOneBotAccountFromRule(rule *model.Rule) (*OneBotAccount, error) {
var oneBotAccount OneBotAccount
if err := db.LoadAccountConfig(rule.AccountID, &oneBotAccount); err != nil {
return nil, err
}
return &oneBotAccount, nil
}
// 发送一条群消息
//
// 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,
})
}
// 发送一条私聊消息
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()
}
+34 -10
View File
@@ -1,14 +1,20 @@
module github.com/ssdomei232/goodBaby module github.com/ssdomei232/goodBaby
go 1.24.10 go 1.25.0
require ( require (
github.com/CatchZeng/dingtalk v1.5.0 github.com/CatchZeng/dingtalk v1.5.0
github.com/CuteReimu/bilibili/v2 v2.3.5 github.com/CuteReimu/bilibili/v2 v2.3.5
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/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/robfig/cron/v3 v3.0.1
github.com/ssdomei232/napcat-http-go-sdk v1.0.0 github.com/ssdomei232/napcat-http-go-sdk v1.0.0
github.com/wneessen/go-mail v0.7.2 github.com/wneessen/go-mail v0.7.2
golang.org/x/crypto v0.48.0
gorm.io/gorm v1.25.12
) )
require ( require (
@@ -16,37 +22,55 @@ require (
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/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
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/spf13/cast v1.9.2 // indirect github.com/spf13/cast v1.9.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.42.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/tools v0.42.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
) )
+73 -17
View File
@@ -10,19 +10,29 @@ github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQ
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -33,15 +43,44 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA=
github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/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=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -56,8 +95,9 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -70,6 +110,9 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -84,6 +127,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -99,26 +143,38 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
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=
+58
View File
@@ -0,0 +1,58 @@
package checker
import (
"log"
"time"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/handler/reminder"
"github.com/ssdomei232/goodBaby/handler/runner"
"github.com/ssdomei232/goodBaby/model"
)
// CheckTimers 定时检查所有 timers 是否需要被触发
//
// 同时向即将到期的 timers 对应的用户发送提醒
func CheckTimers() {
gormDB, err := db.GetGormDB()
if err != nil {
log.Printf("failed to get gorm db: %v", err)
return
}
// 1. 获取所有启用中的 timers
var timers []model.Timer
if err := gormDB.Where("enabled = ?", true).Find(&timers).Error; err != nil {
log.Printf("failed to get timers: %v", err)
return
}
currentTime := time.Now().Unix()
// 2. 检查每个 timer 是否需要被提醒或触发
for i := range timers {
timer := timers[i]
// 已触发过的 timer 在用户重新签到之前不再处理
if timer.Triggered {
continue
}
nextSignTime := timer.NextSignTime()
remindTime := timer.RemindAt()
if currentTime >= nextSignTime {
runner.Runner(&timer)
continue
}
// 每个签到周期只提醒一次
if currentTime >= remindTime && timer.LastRemind < remindTime {
reminder.Reminder(&timer)
if err := gormDB.Model(&model.Timer{}).Where("id = ?", timer.ID).
Update("last_remind", currentTime).Error; err != nil {
log.Printf("更新 Timer(ID: %d) 提醒时间失败: %v", timer.ID, err)
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
package db
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/model"
)
// LoadAccountConfig 读取账号并把 Config 字段反序列化到 out。
//
// 各个 driver 之前都各自实现了一遍这段逻辑,其中 github driver 甚至
// 直接把驱动内的结构体当成 gorm model 查询,这里统一收敛。
func LoadAccountConfig(accountID uint, out any) error {
if accountID == 0 {
return fmt.Errorf("该规则没有关联账号")
}
gormDB, err := GetGormDB()
if err != nil {
return err
}
var account model.Account
if err := gormDB.First(&account, accountID).Error; err != nil {
return fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
}
if err := json.Unmarshal([]byte(account.Config), out); err != nil {
return fmt.Errorf("解析账号(ID: %d)配置失败: %w", accountID, err)
}
return nil
}
+142
View File
@@ -0,0 +1,142 @@
package db
import (
"fmt"
"log"
"sync"
"time"
"github.com/glebarez/sqlite"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/model"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var (
once sync.Once
instance *gorm.DB
initErr error
)
// GetGormDB 返回全局唯一的 gorm 连接。
//
// 之前每次调用都会新开一个 sqlite 连接,在并发执行规则时很容易触发
// "database is locked",这里改为单例并开启 WAL。
func GetGormDB() (*gorm.DB, error) {
once.Do(func() {
instance, initErr = open()
})
return instance, initErr
}
// MustInit 在启动阶段初始化数据库并执行迁移,失败直接退出
func MustInit() *gorm.DB {
gormDB, err := GetGormDB()
if err != nil {
log.Fatalf("初始化数据库失败: %v", err)
}
if err := AutoMigrate(gormDB); err != nil {
log.Fatalf("数据库迁移失败: %v", err)
}
if err := ensureAdmin(gormDB); err != nil {
log.Fatalf("初始化管理员失败: %v", err)
}
return gormDB
}
// ensureAdmin 保证系统里至少有一个管理员。
//
// IsAdmin 是后加的字段,老数据库升级上来时所有人都不是管理员,
// 这里把最早注册的那个用户提升为管理员。
func ensureAdmin(gormDB *gorm.DB) error {
var adminCount int64
if err := gormDB.Model(&model.User{}).Where("is_admin = ?", true).Count(&adminCount).Error; err != nil {
return err
}
if adminCount > 0 {
return nil
}
var first model.User
if err := gormDB.Order("id ASC").First(&first).Error; err != nil {
// 一个用户都没有,等第一个注册的人来当管理员
return nil
}
if err := gormDB.Model(&model.User{}).Where("id = ?", first.ID).
Update("is_admin", true).Error; err != nil {
return err
}
log.Printf("已将用户 %s (ID: %d) 提升为管理员", first.Username, first.ID)
return nil
}
func open() (*gorm.DB, error) {
config, err := configs.GetConfig()
if err != nil {
return nil, fmt.Errorf("读取配置失败: %w", err)
}
gormConfig := &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)}
var (
gormDB *gorm.DB
driver = config.DatabaseDriver
)
switch driver {
case configs.DriverPostgres:
if config.DatabaseDSN == "" {
return nil, fmt.Errorf("使用 postgres 时必须配置 database_dsn")
}
gormDB, err = gorm.Open(postgres.Open(config.DatabaseDSN), gormConfig)
case configs.DriverSQLite, "":
driver = configs.DriverSQLite
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
config.DatabasePath,
)
gormDB, err = gorm.Open(sqlite.Open(dsn), gormConfig)
default:
return nil, fmt.Errorf("不支持的数据库驱动: %s(可选 sqlite / postgres)", config.DatabaseDriver)
}
if err != nil {
return nil, fmt.Errorf("打开数据库失败: %w", err)
}
sqlDB, err := gormDB.DB()
if err != nil {
return nil, err
}
if driver == configs.DriverSQLite {
// sqlite 单写多读,限制连接数避免写冲突
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
} else {
sqlDB.SetMaxOpenConns(20)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(time.Hour)
}
// 提前握手,配置写错时在启动阶段就报错,而不是等到第一次查询
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
log.Printf("数据库已连接 (driver=%s)", driver)
return gormDB, nil
}
// AutoMigrate 建表 / 补齐新增字段
func AutoMigrate(gormDB *gorm.DB) error {
return gormDB.AutoMigrate(
&model.User{},
&model.Timer{},
&model.Rule{},
&model.Account{},
&model.ExecutionLog{},
)
}
+65
View File
@@ -0,0 +1,65 @@
// 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
}
// 用 Find 而不是 First:日志没超出保留条数是常态,
// First 找不到记录会返回 ErrRecordNotFound,每写一条日志都刷一行 gorm 警告。
var cutoff []model.ExecutionLog
if err := gormDB.Where("uid = ?", uid).
Order("id DESC").
Offset(config.LogRetainCount).
Limit(1).
Find(&cutoff).Error; err != nil {
log.Printf("清理执行日志失败: %v", err)
return
}
if len(cutoff) == 0 {
// 没有超出保留条数
return
}
if err := gormDB.Where("uid = ? AND id <= ?", uid, cutoff[0].ID).
Delete(&model.ExecutionLog{}).Error; err != nil {
log.Printf("清理执行日志失败: %v", err)
}
}
+56
View File
@@ -0,0 +1,56 @@
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 即将到期时,向用户发送提醒
func Reminder(timer *model.Timer) {
gormDB, err := db.GetGormDB()
if err != nil {
log.Printf("failed to get gorm db: %v", err)
return
}
// 1. 获取 timer 对应的 user
var user model.User
if err := gormDB.Where("id = ?", timer.UID).First(&user).Error; err != nil {
log.Printf("failed to get user: %v", err)
return
}
// 2. 向用户发送提醒
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,
})
}
+92
View File
@@ -0,0 +1,92 @@
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"
"github.com/ssdomei232/goodBaby/model"
)
// RuleExecutor 规则执行器接口
type RuleExecutor interface {
// Execute 执行规则,ctx 决定了重试的最长时间
Execute(ctx context.Context, rule *model.Rule) error
// GetType 获取执行器支持的规则类型
GetType() string
}
// ExecutorRegistry 执行器注册表
type ExecutorRegistry struct {
executors map[string]RuleExecutor
}
// NewExecutorRegistry 创建新的执行器注册表
func NewExecutorRegistry() *ExecutorRegistry {
return &ExecutorRegistry{
executors: make(map[string]RuleExecutor),
}
}
// Register 注册规则执行器
func (er *ExecutorRegistry) Register(executor RuleExecutor) {
er.executors[executor.GetType()] = executor
}
// Execute 根据规则类型执行规则
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(ctx, rule)
}
// GetSupportedTypes 获取所有支持的规则类型
func (er *ExecutorRegistry) GetSupportedTypes() []string {
types := make([]string, 0, len(er.executors))
for t := range er.executors {
types = append(types, t)
}
sort.Strings(types)
return types
}
var (
registryOnce sync.Once
globalExecutorRegistry *ExecutorRegistry
)
// InitExecutorRegistry 初始化执行器注册表并注册所有执行器
func InitExecutorRegistry() *ExecutorRegistry {
registryOnce.Do(func() {
registry := NewExecutorRegistry()
// 注册所有规则执行器
registry.Register(&bilibili.BilibiliDynamicExecutor{})
registry.Register(&bilibili.BilibiliPrivateMessageExecutor{})
registry.Register(&email.EmailExecutor{})
registry.Register(&github.GithubMakeRepoPublicExecutor{})
registry.Register(&onebot.OneBotExecutor{})
registry.Register(&dingtalk.DingTalkExecutor{})
// 未来添加新规则类型时,在这里注册即可
globalExecutorRegistry = registry
})
return globalExecutorRegistry
}
// GetGlobalExecutorRegistry 获取全局执行器注册表
func GetGlobalExecutorRegistry() *ExecutorRegistry {
if globalExecutorRegistry == nil {
panic("执行器注册表未初始化,请先调用 InitExecutorRegistry()")
}
return globalExecutorRegistry
}
+85
View File
@@ -0,0 +1,85 @@
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 执行某个 Timer 下所有启用的规则。
//
// 触发前先把 Timer 标记为已触发,避免规则执行期间(可能长达数小时)
// 被下一轮检查重复触发;用户重新签到后 Triggered 会被重置。
func Runner(timer *model.Timer) {
gormDB, err := db.GetGormDB()
if err != nil {
log.Printf("failed to get gorm db: %v", err)
return
}
// 1. 获取所有需要执行的 Rule
var rules []model.Rule
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. 标记已触发
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, trigger string) error {
ctx, cancel := retry.ExecutionContext()
defer cancel()
return ExecuteRuleWithContext(ctx, rule, trigger)
}
// ExecuteRuleWithContext 使用给定 context 执行规则,手动测试时可传入较短的超时
func ExecuteRuleWithContext(ctx context.Context, rule *model.Rule, trigger string) error {
err := GetGlobalExecutorRegistry().Execute(ctx, rule)
message := "执行成功"
if err != nil {
message = err.Error()
log.Printf("执行规则失败 [ID: %d, Type: %s]: %v", rule.ID, rule.Type, err)
}
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
}
+76
View File
@@ -0,0 +1,76 @@
// Package scheduler 管理定时检查任务,支持运行时调整间隔
package scheduler
import (
"fmt"
"sync"
"github.com/robfig/cron/v3"
"github.com/ssdomei232/goodBaby/handler/checker"
)
var (
mu sync.Mutex
runner *cron.Cron
entryID cron.EntryID
)
// Start 启动定时检查,interval 单位为分钟
func Start(intervalMinutes int) error {
mu.Lock()
defer mu.Unlock()
if runner != nil {
return fmt.Errorf("定时任务已经启动")
}
runner = cron.New()
id, err := runner.AddFunc(spec(intervalMinutes), checker.CheckTimers)
if err != nil {
runner = nil
return fmt.Errorf("注册定时任务失败: %w", err)
}
entryID = id
runner.Start()
return nil
}
// Reschedule 用新的间隔重排定时任务,管理员改配置后调用
func Reschedule(intervalMinutes int) error {
mu.Lock()
defer mu.Unlock()
if runner == nil {
return fmt.Errorf("定时任务尚未启动")
}
// cron 不支持原地改周期,只能撤掉旧的再加一个
runner.Remove(entryID)
id, err := runner.AddFunc(spec(intervalMinutes), checker.CheckTimers)
if err != nil {
return fmt.Errorf("注册定时任务失败: %w", err)
}
entryID = id
return nil
}
// Stop 停止定时任务,等待正在执行的任务结束
func Stop() {
mu.Lock()
defer mu.Unlock()
if runner == nil {
return
}
<-runner.Stop().Done()
runner = nil
}
func spec(intervalMinutes int) string {
if intervalMinutes <= 0 {
intervalMinutes = 10
}
return fmt.Sprintf("@every %dm", intervalMinutes)
}
+127
View File
@@ -0,0 +1,127 @@
package accountConfigChecker
import (
"encoding/json"
"fmt"
"sort"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// AccountValidator 账号验证器接口
type AccountValidator interface {
// Validate 验证账号配置是否合法
Validate(config string) error
// GetType 获取验证器支持的账号类型
GetType() string
// Meta 返回给 WebUI 渲染表单用的元数据
Meta() meta.AccountMeta
}
// AccountTester 可选接口:支持真实连通性测试的账号类型实现它
type AccountTester interface {
// Test 使用给定配置访问一次第三方服务,验证凭据是否可用
Test(config string) error
}
// ValidatorRegistry 验证器注册表
type ValidatorRegistry struct {
validators map[string]AccountValidator
}
// NewValidatorRegistry 创建新的验证器注册表
func NewValidatorRegistry() *ValidatorRegistry {
return &ValidatorRegistry{
validators: make(map[string]AccountValidator),
}
}
// Register 注册账号验证器
func (vr *ValidatorRegistry) Register(validator AccountValidator) {
vr.validators[validator.GetType()] = validator
}
// Validate 根据账号类型验证配置
func (vr *ValidatorRegistry) Validate(accountType string, config string) error {
validator, exists := vr.validators[accountType]
if !exists {
return fmt.Errorf("不支持的账号类型: %s", accountType)
}
if config == "" {
return fmt.Errorf("账号配置不能为空")
}
// 首先验证 JSON 格式是否正确
var js json.RawMessage
if err := json.Unmarshal([]byte(config), &js); err != nil {
return fmt.Errorf("账号配置 JSON 格式错误: %v", err)
}
// 调用具体验证器的验证逻辑
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
}
+31
View File
@@ -0,0 +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 {
once.Do(func() {
r := NewValidatorRegistry()
// 注册所有账号验证器
r.Register(&bilibili.BilibiliAccountConfigValidator{})
r.Register(&email.EmailAccountConfigValidator{})
r.Register(&github.GitHubAccountConfigValidator{})
r.Register(&onebot.OneBotAccountConfigValidator{})
registry = r
})
return registry
}
-29
View File
@@ -1,29 +0,0 @@
package base
import (
"fmt"
"log"
"time"
"github.com/ssdomei232/goodBaby/configs"
)
func GetBasicInfo() string {
config, err := configs.GetConfig()
if err != nil {
log.Printf("获取配置文件失败: %v", err)
return "获取配置文件失败"
}
now := time.Now()
lastSignalTime := now.Add(-time.Duration(config.DisconnectDuration) * time.Hour).Format("2006-01-02 15:04:05")
if config.Debug {
return fmt.Sprintf("此消息为测试消息\n\n%s(QQ号:%s)已于%s至%s(UTC+8)之间去世,享年%d岁,死因可能为%s(此死因为推测最可能的死因,请勿完全相信,请等待后续消息)",
config.Basic.Name, config.Basic.QQNumber, lastSignalTime, time.Now().Format("2006-01-02 15:04:05"), config.Basic.Age, config.Basic.CauseStop)
} else {
return fmt.Sprintf("%s(QQ号:%s,网名:%s)已于%s至%s(UTC+8)之间去世,享年%d岁,死因可能为%s(此死因为推测最可能的死因,请勿完全相信,请等待后续消息)",
config.Basic.Name, config.Basic.NickName, config.Basic.QQNumber, lastSignalTime, time.Now().Format("2006-01-02 15:04:05"), config.Basic.Age, config.Basic.CauseStop)
}
}
-19
View File
@@ -1,19 +0,0 @@
package frontend
import (
"html/template"
"io/fs"
"net/http"
"github.com/gin-gonic/gin"
)
// loadTemplates loads templates from the embedded file system
func LoadTemplates(filesystem fs.FS) *template.Template {
templ := template.Must(template.New("").ParseFS(filesystem, "*.html"))
return templ
}
func IndexPage(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", nil)
}
+70
View File
@@ -0,0 +1,70 @@
package meta
import "encoding/json"
// MaskPlaceholder 是掩码后的占位值。
// 提交编辑时如果某个 secret 字段仍是这个值,服务端会保留原值。
const MaskPlaceholder = "********"
// Mask 把配置 JSON 中的敏感字段替换为占位符
func Mask(configJSON string, fields []Field) string {
secrets := SecretKeys(fields)
if len(secrets) == 0 || configJSON == "" {
return configJSON
}
var raw map[string]any
if err := json.Unmarshal([]byte(configJSON), &raw); err != nil {
return configJSON
}
for _, key := range secrets {
if v, ok := raw[key]; ok {
if s, isStr := v.(string); !isStr || s == "" {
continue
}
raw[key] = MaskPlaceholder
}
}
masked, err := json.Marshal(raw)
if err != nil {
return configJSON
}
return string(masked)
}
// Unmask 用旧配置补回被掩码的字段,返回可以落库的配置 JSON
func Unmask(newConfigJSON, oldConfigJSON string, fields []Field) string {
secrets := SecretKeys(fields)
if len(secrets) == 0 || newConfigJSON == "" || oldConfigJSON == "" {
return newConfigJSON
}
var newRaw, oldRaw map[string]any
if err := json.Unmarshal([]byte(newConfigJSON), &newRaw); err != nil {
return newConfigJSON
}
if err := json.Unmarshal([]byte(oldConfigJSON), &oldRaw); err != nil {
return newConfigJSON
}
restored := false
for _, key := range secrets {
if s, ok := newRaw[key].(string); ok && s == MaskPlaceholder {
if old, exists := oldRaw[key]; exists {
newRaw[key] = old
restored = true
}
}
}
if !restored {
return newConfigJSON
}
merged, err := json.Marshal(newRaw)
if err != nil {
return newConfigJSON
}
return string(merged)
}
+63
View File
@@ -0,0 +1,63 @@
// Package meta 定义驱动向前端暴露的元数据。
//
// WebUI 依靠这些描述动态渲染账号 / 规则的配置表单,
// 新增一个驱动时只要提供 Meta(),前端无需改动。
package meta
// FieldType 决定前端使用哪种输入控件
type FieldType string
const (
FieldString FieldType = "string" // 单行文本
FieldPassword FieldType = "password" // 密码框
FieldTextarea FieldType = "textarea" // 多行文本
FieldNumber FieldType = "number" // 数字
FieldStringList FieldType = "string-list" // 字符串数组
FieldNumberList FieldType = "number-list" // 数字数组
FieldBool FieldType = "bool" // 开关
)
// Field 描述配置 JSON 中的一个字段
type Field struct {
Key string `json:"key"`
Label string `json:"label"`
Type FieldType `json:"type"`
Required bool `json:"required"`
Placeholder string `json:"placeholder,omitempty"`
Help string `json:"help,omitempty"`
// Secret 为 true 的字段在读取接口中会被掩码
Secret bool `json:"secret,omitempty"`
Default any `json:"default,omitempty"`
}
// AccountMeta 账号类型的元数据
type AccountMeta struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Docs string `json:"docs,omitempty"`
Testable bool `json:"testable"`
Fields []Field `json:"fields"`
}
// RuleMeta 规则类型的元数据
type RuleMeta struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Docs string `json:"docs,omitempty"`
// AccountType 该规则需要的账号类型,为空表示不需要关联账号
AccountType string `json:"account_type"`
Fields []Field `json:"fields"`
}
// SecretKeys 返回需要掩码的字段名
func SecretKeys(fields []Field) []string {
keys := make([]string, 0, len(fields))
for _, f := range fields {
if f.Secret {
keys = append(keys, f.Key)
}
}
return keys
}
-23
View File
@@ -1,23 +0,0 @@
package reminder
import (
"fmt"
"log"
"time"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/internal/sender"
)
func Reminder() {
config, err := configs.GetConfig()
if err != nil {
log.Printf("获取配置文件失败: %v", err)
}
remainingTime := GlobalTimerManager.GetRemainingTime()
if remainingTime <= 48*time.Hour {
sendMsg := fmt.Sprintf("距离摇篮系统触发还有%d小时,请尽快发送Signal", int(remainingTime.Hours()))
sender.SendDingTalkMsg("摇篮系统触发提醒", sendMsg, config.DingtalkBot.AccessToken, config.DingtalkBot.Secret)
}
}
-52
View File
@@ -1,52 +0,0 @@
package reminder
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type TimerManager struct {
duration time.Duration
resetTime time.Time
mutex sync.RWMutex
}
var GlobalTimerManager *TimerManager
func InitTimerManager(duration time.Duration) {
GlobalTimerManager = &TimerManager{
duration: duration,
resetTime: time.Now(),
}
}
func (tm *TimerManager) Reset() {
tm.mutex.Lock()
defer tm.mutex.Unlock()
tm.resetTime = time.Now()
}
func (tm *TimerManager) GetRemainingTime() time.Duration {
tm.mutex.RLock()
defer tm.mutex.RUnlock()
elapsed := time.Since(tm.resetTime)
remaining := tm.duration - elapsed
if remaining < 0 {
return 0
}
return remaining
}
func HandleTimerStatus(c *gin.Context) {
remainingTime := GlobalTimerManager.GetRemainingTime()
c.JSON(http.StatusOK, gin.H{
"code": 200,
"remaining_time": remainingTime.Seconds(), // 返回剩余秒数
"message": "ok",
})
}
+46
View File
@@ -0,0 +1,46 @@
// Package retry 封装规则执行使用的指数退避重试
package retry
import (
"context"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/ssdomei232/goodBaby/configs"
)
// 手动测试时使用的超时,避免用户在 WebUI 上等待数小时
const TestTimeout = 30 * time.Second
// Do 在 ctx 允许的时间内按指数退避执行 op
//
// 超时后返回最后一次真实的业务错误,而不是干巴巴的 context deadline exceeded。
func Do(ctx context.Context, op func() error) error {
var lastErr error
_, err := backoff.Retry(ctx, func() (struct{}, error) {
e := op()
if e != nil {
lastErr = e
}
return struct{}{}, e
}, backoff.WithBackOff(backoff.NewExponentialBackOff()))
if err != nil && lastErr != nil {
return lastErr
}
return err
}
// ExecutionTimeout 返回配置中的规则执行超时时间
func ExecutionTimeout() time.Duration {
config, err := configs.GetConfig()
if err != nil || config.TimeoutDurationHours <= 0 {
return 6 * time.Hour
}
return time.Duration(config.TimeoutDurationHours) * time.Hour
}
// ExecutionContext 返回一个带规则执行超时的 context
func ExecutionContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), ExecutionTimeout())
}
+86
View File
@@ -0,0 +1,86 @@
package ruleConfigChecker
import (
"encoding/json"
"fmt"
"sort"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// RuleValidator 规则验证器接口
type RuleValidator interface {
// Validate 验证规则配置是否合法
Validate(configJSON string) error
// GetType 获取验证器支持的规则类型
GetType() string
// Meta 返回给 WebUI 渲染表单用的元数据
Meta() meta.RuleMeta
}
// ValidatorRegistry 验证器注册表
type ValidatorRegistry struct {
validators map[string]RuleValidator
}
// NewValidatorRegistry 创建新的验证器注册表
func NewValidatorRegistry() *ValidatorRegistry {
return &ValidatorRegistry{
validators: make(map[string]RuleValidator),
}
}
// Register 注册规则验证器
func (vr *ValidatorRegistry) Register(validator RuleValidator) {
vr.validators[validator.GetType()] = validator
}
// Validate 根据规则类型验证配置
func (vr *ValidatorRegistry) Validate(ruleType string, configJSON string) error {
validator, exists := vr.validators[ruleType]
if !exists {
return fmt.Errorf("不支持的规则类型: %s", ruleType)
}
if configJSON == "" {
return fmt.Errorf("规则配置不能为空")
}
// 首先验证 JSON 格式是否正确
var js json.RawMessage
if err := json.Unmarshal([]byte(configJSON), &js); err != nil {
return fmt.Errorf("规则配置 JSON 格式错误: %v", err)
}
// 调用具体验证器的验证逻辑
return validator.Validate(configJSON)
}
// 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.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
}
+34
View File
@@ -0,0 +1,34 @@
package ruleConfigChecker
import (
"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"
)
var (
once sync.Once
registry *ValidatorRegistry
)
// InitValidatorRegistry 返回全局唯一的规则验证器注册表
func InitValidatorRegistry() *ValidatorRegistry {
once.Do(func() {
r := NewValidatorRegistry()
// 注册所有规则验证器
r.Register(&bilibili.BilibiliDynamicRuleValidator{})
r.Register(&bilibili.BilibiliPrivateMessageRuleValidator{})
r.Register(&email.EmailRuleValidator{})
r.Register(&onebot.OneBotRuleValidator{})
r.Register(&dingtalk.DingTalkRuleValidator{})
r.Register(&github.GithubMakeRepositoryPublicRuleValidator{})
registry = r
})
return registry
}
-283
View File
@@ -1,283 +0,0 @@
package sender
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/CuteReimu/bilibili/v2"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/internal/base"
)
// check tmp dir exist
func EnsureTmpDirectory() error {
_, err := os.Stat("tmp")
if os.IsNotExist(err) {
err = os.Mkdir("tmp", 0755)
if err != nil {
return err
}
log.Println("已创建tmp目录")
} else if err != nil {
return err
} else {
log.Println("tmp目录已存在")
}
return nil
}
func isCookiesExpired(cookiesString string) bool {
// parase expires date from cookie string
lines := strings.SplitSeq(cookiesString, "\n")
for line := range lines {
if strings.Contains(line, "Expires=") {
// 提取过期时间字符串
expireStr := ""
parts := strings.SplitSeq(line, ";")
for part := range parts {
part = strings.TrimSpace(part)
if after, ok := strings.CutPrefix(part, "Expires="); ok {
expireStr = after
break
}
}
if expireStr != "" {
// phrase expirres date
expireTime, err := http.ParseTime(expireStr)
if err != nil {
log.Printf("解析过期时间失败: %v", err)
return true // if phrase failed, cookie expired
}
// compare expires date and now
if time.Now().After(expireTime) {
return true // expired
}
}
}
}
return false // not expired
}
// Qrcode login
func LoginWithQRCode(biliClient *bilibili.Client) {
qrCode, err := biliClient.GetQRCode()
if err != nil {
log.Printf("获取二维码失败: %v", err)
return
}
log.Println("请使用哔哩哔哩APP扫码登录或进入文件目录寻找qrcode.png查看二维码")
buf, _ := qrCode.Encode()
os.WriteFile("tmp/qrcode.png", buf, 0644)
qrCode.Print()
result, err := biliClient.LoginWithQRCode(bilibili.LoginWithQRCodeParam{
QrcodeKey: qrCode.QrcodeKey,
})
if err != nil || result.Code != 0 {
log.Printf("登录失败: %v", err)
return
}
log.Println("登录成功")
// save new cookie
saveCookies(biliClient)
}
// save cookie to file
func saveCookies(client *bilibili.Client) {
cookiesString := client.GetCookiesString()
err := os.WriteFile("tmp/cookies.txt", []byte(cookiesString), 0644)
if err != nil {
log.Printf("保存cookie失败: %v", err)
}
}
// 从文件加载cookie并验证有效性
func LoadCookies(client *bilibili.Client) bool {
// 检查cookie文件是否存在
if _, err := os.Stat("tmp/cookies.txt"); os.IsNotExist(err) {
return false
}
// 读取cookie
cookiesBytes, err := os.ReadFile("tmp/cookies.txt")
if err != nil {
log.Printf("读取cookie文件失败: %v", err)
return false
}
cookiesString := string(cookiesBytes)
client.SetCookiesString(cookiesString)
// 验证cookie是否过期
if isCookiesExpired(cookiesString) {
log.Println("cookie已过期")
return false
}
return true
}
// 在Bilibili动态发送死亡通告
func SendBili(biliClient *bilibili.Client) {
config, err := configs.GetConfig()
if err != nil {
log.Printf("获取配置文件失败: %v", err)
return
}
stopMsg := fmt.Sprintf("%s\n\n%s\n\n本消息由自动程序发送", base.GetBasicInfo(), config.BiliMsg)
var dynamicParams bilibili.CreateDynamicParam
dynamicParams = bilibili.CreateDynamicParam{
DynamicId: 0,
Type: 4,
Rid: 0,
Content: stopMsg,
}
// 重试机制
retryCount := 10 // 重试次数
delay := time.Second * 2 // 初始重试延迟
for i := 0; i <= retryCount; i++ {
_, err := biliClient.CreateDynamic(dynamicParams)
if err == nil {
log.Println("动态发送成功")
return
}
log.Printf("动态发送失败 (尝试 %d/%d): %v", i+1, retryCount+1, err)
// 如果不是最后一次尝试,则等待后重试
if i < retryCount {
log.Printf("等待 %v 后进行第 %d 次重试", delay, i+2)
time.Sleep(delay)
delay *= 2 // 指数退避
}
}
log.Println("动态发送最终失败,已达到最大重试次数")
}
// 检查cookie有效性
func CheckCookieValidity(biliClient *bilibili.Client) {
// 读取存储的cookie文件
if _, err := os.Stat("tmp/cookies.txt"); os.IsNotExist(err) {
log.Println("cookie文件不存在,需要重新登录")
triggerLoginRequest(biliClient)
return
}
cookiesBytes, err := os.ReadFile("tmp/cookies.txt")
if err != nil {
log.Printf("读取cookie文件失败: %v", err)
triggerLoginRequest(biliClient)
return
}
cookiesString := string(cookiesBytes)
// 检查cookie是否即将过期(提前1天提醒)
if isCookiesExpiringSoon(cookiesString) {
log.Println("cookie即将过期,需要重新登录")
triggerLoginRequest(biliClient)
}
}
// 检查cookie是否即将过期(提前1天)
func isCookiesExpiringSoon(cookiesString string) bool {
lines := strings.SplitSeq(cookiesString, "\n")
for line := range lines {
if strings.Contains(line, "Expires=") {
// 提取过期时间字符串
expireStr := ""
parts := strings.SplitSeq(line, ";")
for part := range parts {
part = strings.TrimSpace(part)
if after, ok := strings.CutPrefix(part, "Expires="); ok {
expireStr = after
break
}
}
if expireStr != "" {
// 解析过期时间
expireTime, err := http.ParseTime(expireStr)
if err != nil {
log.Printf("解析过期时间失败: %v", err)
return true // 如果解析失败,认为需要重新登录
}
// 检查是否即将过期(提前1天)
if time.Now().Add(24 * time.Hour).After(expireTime) {
return true // 即将过期
}
}
}
}
return false
}
// 触发登录请求
func triggerLoginRequest(biliClient *bilibili.Client) {
// 获取配置
config, err := configs.GetConfig()
if err != nil {
log.Printf("获取配置文件失败: %v", err)
return
}
// 生成新的二维码
qrCode, err := biliClient.GetQRCode()
if err != nil {
log.Printf("获取二维码失败: %v", err)
return
}
// 在命令行打印二维码
log.Println("需要重新登录哔哩哔哩")
qrCode.Print()
// 通过Dingtalk发送登录请求
title := "B站登录请求 - Cookie即将过期"
msg := "您的B站cookie即将过期,请尽快重启摇篮系统进行登陆,请尽快完成登录操作。"
// 发送Dingtalk通知
if config.DingtalkBot.AccessToken != "" && config.DingtalkBot.Secret != "" {
SendDingTalkMsg(title, msg, config.DingtalkBot.AccessToken, config.DingtalkBot.Secret)
}
}
// Enable periodic cookie checks
func StartCookieChecker(cookieCheckTimer *time.Ticker, biliClient *bilibili.Client) {
// check cookie per hour
cookieCheckTimer = time.NewTicker(1 * time.Hour)
go func() {
for {
<-cookieCheckTimer.C
CheckCookieValidity(biliClient)
}
}()
}
func InitBilibili(biliClient *bilibili.Client) {
// try cached cookie
if !LoadCookies(biliClient) {
// if cookie check failed, request qrcode login
LoginWithQRCode(biliClient)
} else {
log.Println("使用已存储的有效cookie登录")
}
}
-16
View File
@@ -1,16 +0,0 @@
package sender
import (
"log"
"github.com/CatchZeng/dingtalk/pkg/dingtalk"
)
func SendDingTalkMsg(title string, msg string, accessToken string, secret string) {
client := dingtalk.NewClient(accessToken, secret)
sendMsg := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
_, _, err := client.Send(sendMsg)
if err != nil {
log.Printf("发送钉钉消息失败: %v", err)
}
}
-99
View File
@@ -1,99 +0,0 @@
package sender
import (
"fmt"
"log"
"time"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/internal/base"
"github.com/wneessen/go-mail"
)
const (
maxRetries = 10
retryDelay = 10 * time.Second
)
// 向邮件列表发送死亡通告
func SendMail() {
config, err := configs.GetConfig()
if err != nil {
log.Printf("获取配置文件失败: %v", err)
return
}
stopMsg := fmt.Sprintf("%s\n\n%s\n\n本消息由自动程序发送", base.GetBasicInfo(), config.MailConfig.MailContent)
for _, address := range config.MailConfig.MailList {
sendMailMsgWithRetry(address, stopMsg, config.MailConfig.MailTitle)
}
}
func sendMailMsgWithRetry(address string, msg string, title string) {
var lastErr error
// 重试循环
for i := 0; i <= maxRetries; i++ {
if i > 0 {
log.Printf("第 %d 次重试发送邮件给 %s", i, address)
}
err := sendMailMsg(address, msg, title)
if err == nil {
// 发送成功
fmt.Printf("邮件成功发送给 %s\n", address)
return
}
lastErr = err
log.Printf("发送邮件给 %s 失败: %v", address, err)
// 如果不是最后一次重试,则等待后重试
if i < maxRetries {
time.Sleep(retryDelay * time.Duration(i+1)) // 递增延迟
}
}
// 所有重试都失败
log.Printf("发送邮件给 %s 经过 %d 次重试后仍然失败: %v", address, maxRetries, lastErr)
}
func sendMailMsg(address string, msg string, title string) error {
config, err := configs.GetConfig()
if err != nil {
return fmt.Errorf("获取配置文件失败: %v", err)
}
client, err := mail.NewClient(
config.MailConfig.Host,
mail.WithPort(config.MailConfig.Port),
mail.WithSSL(),
mail.WithUsername(config.MailConfig.User),
mail.WithPassword(config.MailConfig.Password),
mail.WithSMTPAuth(mail.SMTPAuthPlain),
)
if err != nil {
return fmt.Errorf("创建邮件客户端失败: %v", err)
}
// 创建邮件
message := mail.NewMsg()
if err := message.From(config.MailConfig.User); err != nil {
return fmt.Errorf("设置发件人失败: %v", err)
}
if err := message.To(address); err != nil {
return fmt.Errorf("设置收件人失败: %v", err)
}
message.Subject(title)
message.SetBodyString(mail.TypeTextPlain, msg)
// 发送邮件
if err := client.DialAndSend(message); err != nil {
return fmt.Errorf("发送邮件失败: %v", err)
}
return nil
}
-36
View File
@@ -1,36 +0,0 @@
package sender
import (
"log"
"time"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/pkg/github"
)
// 将repos设置为public
func Github() {
config, err := configs.GetConfig()
if err != nil {
log.Fatalln(err)
}
for _, repo := range config.GithubConfig.Repos {
maxRetries := 10
for attempt := range maxRetries {
err := github.MakeRepositoryPublic(config.GithubConfig.Owner, repo, config.GithubConfig.Token)
if err == nil {
break
}
log.Printf("尝试设置仓库 %s 为公开失败 (尝试 %d/%d): %v", repo, attempt+1, maxRetries, err)
// 如果不是最后一次尝试,则等待一段时间后重试
if attempt < maxRetries-1 {
time.Sleep(time.Duration(attempt+1) * time.Second) // 逐步增加等待时间
} else {
log.Printf("设置仓库 %s 为公开最终失败", repo)
}
}
}
}
-43
View File
@@ -1,43 +0,0 @@
package sender
import (
"fmt"
"log"
"strconv"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/internal/base"
napcat "github.com/ssdomei232/napcat-http-go-sdk"
)
func SendOneBot() {
config, err := configs.GetConfig()
if err != nil {
log.Printf("获取配置文件失败: %v", err)
return
}
stopMsg := fmt.Sprintf("%s\n\n%s\n\n本消息由自动程序发送", base.GetBasicInfo(), config.OneBotConfig.Msg)
for _, groupId := range config.OneBotConfig.SendGroups {
sendOneBotMsg(groupId, stopMsg)
}
}
// 发送一条消息到OneBot
func sendOneBotMsg(groupID int, msg string) {
config, err := configs.GetConfig()
if err != nil {
log.Printf("获取配置文件失败: %v", err)
return
}
client := napcat.NewClient(config.OneBotConfig.Token, config.OneBotConfig.URL)
err = client.SendGroupMsg(strconv.Itoa(groupID), msg)
if err != nil {
log.Printf("发送消息到OneBot失败: %v", err)
} else {
log.Printf("成功发送消息到OneBot群 %d", groupID)
}
}
+141 -98
View File
@@ -1,121 +1,164 @@
package main package main
import ( import (
"embed"
"io/fs"
"log" "log"
"net/http" "net/http"
"time"
"github.com/CuteReimu/bilibili/v2" "github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/robfig/cron/v3" "github.com/ssdomei232/goodBaby/api/account"
"github.com/ssdomei232/goodBaby/api/admin"
"github.com/ssdomei232/goodBaby/api/dashboard"
apilog "github.com/ssdomei232/goodBaby/api/log"
apimeta "github.com/ssdomei232/goodBaby/api/meta"
"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/configs"
"github.com/ssdomei232/goodBaby/internal/frontend" "github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/internal/reminder" "github.com/ssdomei232/goodBaby/handler/runner"
"github.com/ssdomei232/goodBaby/internal/sender" "github.com/ssdomei232/goodBaby/handler/scheduler"
"github.com/ssdomei232/goodBaby/web"
) )
//go:embed static/* var (
var staticFiles embed.FS version = "dev"
buildDate = "unknown"
//go:embed templates/* gitCommit = "unknown"
var templateFiles embed.FS )
var timer *time.Timer
var duration time.Duration
var biliClient *bilibili.Client
var cookieCheckTimer *time.Ticker
func init() {
// 1. 加载配置文件
config, err := configs.GetConfig()
if err != nil {
log.Printf("获取配置文件失败: %v", err)
}
if config.Debug {
duration = time.Duration(config.DisconnectDuration) * time.Second
} else {
duration = time.Duration(config.DisconnectDuration) * time.Hour
}
// 2. 检查并创建tmp目录
if err := sender.EnsureTmpDirectory(); err != nil {
log.Printf("创建tmp目录失败: %v", err)
}
// 3. 初始化哔哩哔哩和定时器
biliClient = bilibili.New()
sender.InitBilibili(biliClient)
sender.StartCookieChecker(cookieCheckTimer, biliClient) // Check Bilibili cookie
reminder.InitTimerManager(duration)
timer = time.NewTimer(duration)
go func() {
<-timer.C
trigger(config)
}()
}
// 触发器
func trigger(config configs.Config) {
go sender.SendOneBot()
go sender.SendMail()
go sender.Github()
if !config.Debug {
go sender.SendBili(biliClient)
}
}
func main() { func main() {
config, err := configs.GetConfig() log.Printf("goodBaby %s (%s, %s) 启动中...", version, gitCommit, buildDate)
if err != nil {
log.Printf("获取配置文件失败: %v", err) config := configs.MustGetConfig()
// 初始化数据库(建表/迁移)与执行器
db.MustInit()
runner.InitExecutorRegistry()
// 启动定时检查(间隔可由管理员在 WebUI 调整,无需重启)
if err := scheduler.Start(config.CheckIntervalMinutes); err != nil {
log.Fatalf("启动定时任务失败: %v", err)
} }
// 定时任务 defer scheduler.Stop()
reminder.Reminder()
c := cron.New()
if config.Debug {
c.AddFunc("@every 1s", reminder.Reminder)
} else {
c.AddFunc("@every 1h", reminder.Reminder)
}
c.Start()
// 前端/后端
r := gin.Default() r := gin.Default()
templFS, _ := fs.Sub(templateFiles, "templates")
r.SetHTMLTemplate(frontend.LoadTemplates(templFS))
staticFS, _ := fs.Sub(staticFiles, "static")
r.StaticFS("/static", http.FS(staticFS))
r.GET("/", frontend.IndexPage) // 持久化的 session 密钥:重启后已登录用户不会掉线
r.GET("/signal", handleSignal) store := cookie.NewStore([]byte(config.SessionSecret))
r.GET("/timer/status", reminder.HandleTimerStatus) store.Options(sessions.Options{
r.Run(":8088") 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)
}
// 需要认证的路由组
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")
{
rules.GET("/", rule.HandleGetAllRules)
rules.POST("/", rule.HandleCreateRule)
rules.PUT("/:ruleID", rule.HandleEditRule)
rules.POST("/:ruleID/test", rule.HandleTestRule)
rules.DELETE("/:ruleID", rule.HandleDeleteRule)
}
accounts := authorized.Group("/accounts")
{
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)
}
// 仅管理员可用
adminGroup := authorized.Group("/admin")
adminGroup.Use(user.AdminMiddleware())
{
adminGroup.GET("/config", admin.HandleGetConfig)
adminGroup.PUT("/config", admin.HandleUpdateConfig)
}
}
// 挂载内嵌前端
web.Register(r)
log.Printf("goodBaby 正在监听 %s", config.ListenAddr)
if err := r.Run(config.ListenAddr); err != nil {
log.Fatalf("启动 HTTP 服务失败: %v", err)
}
} }
// Handle Signal // corsMiddleware 允许配置中的来源跨域访问(携带 Cookie)
func handleSignal(c *gin.Context) { func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
config, err := configs.GetConfig() allowed := make(map[string]struct{}, len(allowedOrigins))
if err != nil { for _, origin := range allowedOrigins {
log.Printf("获取配置文件失败: %v", err) allowed[origin] = struct{}{}
} }
secret := c.Query("secret") return func(c *gin.Context) {
if secret != config.SignalSecret { origin := c.GetHeader("Origin")
c.JSON(403, gin.H{ if _, ok := allowed[origin]; ok {
"code": 403, c.Header("Access-Control-Allow-Origin", origin)
"message": "secret error", 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 return
} }
timer.Reset(duration) c.Next()
reminder.GlobalTimerManager.Reset() }
c.JSON(200, gin.H{
"code": 200,
"message": "ok",
})
log.Println("触发信号")
} }
+35
View File
@@ -0,0 +1,35 @@
package model
// Account 保存执行规则时需要用到的第三方凭据
type Account struct {
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
}
+11
View File
@@ -0,0 +1,11 @@
package model
// ValidationError 表示一个可以直接展示给用户的参数校验错误
type ValidationError struct {
Msg string
}
func (e ValidationError) Error() string { return e.Msg }
// ErrValidation 构造一个参数校验错误
func ErrValidation(msg string) error { return ValidationError{Msg: msg} }
+22
View File
@@ -0,0 +1,22 @@
package model
// 执行日志的触发来源
const (
TriggerTimer = "timer" // Timer 到期自动触发
TriggerManual = "manual" // 用户在 WebUI 手动测试
TriggerRemind = "remind" // 提醒通知
)
// ExecutionLog 记录每一次规则执行/提醒的结果,供 WebUI 展示
type ExecutionLog struct {
ID uint `gorm:"primaryKey" json:"id"`
UID uint `gorm:"index" json:"uid"`
RuleID uint `json:"rule_id"`
RuleName string `json:"rule_name"`
RuleType string `json:"rule_type"`
TimerID uint `json:"timer_id"`
Trigger string `json:"trigger"`
Success bool `json:"success"`
Message string `json:"message"`
CreateAt int64 `gorm:"index" json:"create_at"`
}
+47
View File
@@ -0,0 +1,47 @@
package model
// Rule 描述一个 Timer 到期后要执行的动作
type Rule struct {
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
}
+64
View File
@@ -0,0 +1,64 @@
package model
// Timer 是摇篮系统的核心:用户需要在 SignDerationSeconds 内完成一次签到,
// 否则挂在这个 Timer 下的所有规则都会被触发。
type Timer struct {
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"`
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
}
+43
View File
@@ -0,0 +1,43 @@
package model
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 `gorm:"uniqueIndex" json:"username"`
Password string `json:"-"`
// 管理员可以修改系统配置。第一个注册的用户自动成为管理员。
IsAdmin bool `gorm:"default:false" json:"is_admin"`
// 钉钉机器人配置(JSON 字符串),用于接收提醒
DingTalkConfig *string `json:"dingtalk_config"`
}
func (u *User) IsValid() error {
if u.Username == "" || u.Password == "" {
return fmt.Errorf("用户名或密码不能为空")
}
if len(u.Username) > 32 || len(u.Username) < 2 {
return fmt.Errorf("用户名过长或过短")
}
if len(u.Password) > 64 || len(u.Password) < 6 {
return fmt.Errorf("密码过长或过短")
}
return nil
}
-102
View File
@@ -1,102 +0,0 @@
package github
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
)
// RepositoryVisibility 用于更新仓库可见性的请求体
type RepositoryVisibility struct {
Private bool `json:"private"`
}
// MakeRepositoryPublic 将指定的GitHub仓库从私有设置为公开
func MakeRepositoryPublic(owner, repo, token string) error {
// GitHub API URL for updating repository visibility
url := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
// 创建请求体,将 private 设置为 false 表示公开仓库
visibility := RepositoryVisibility{
Private: false, // false 表示公开仓库
}
// 将请求体序列化为 JSON
jsonData, err := json.Marshal(visibility)
if err != nil {
return fmt.Errorf("序列化请求体失败: %v", err)
}
// 创建 PATCH 请求
req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 设置请求头
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("Content-Type", "application/json")
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("请求失败,状态码: %d", resp.StatusCode)
}
log.Printf("仓库 %s/%s 已成功设置为公开", owner, repo)
return nil
}
// MakeRepositoryPrivate 将指定的GitHub仓库从公开设置为私有(附加功能)
func MakeRepositoryPrivate(owner, repo, token string) error {
// GitHub API URL for updating repository visibility
url := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
// 创建请求体,将 private 设置为 true 表示私有仓库
visibility := RepositoryVisibility{
Private: true, // true 表示私有仓库
}
// 将请求体序列化为 JSON
jsonData, err := json.Marshal(visibility)
if err != nil {
return fmt.Errorf("序列化请求体失败: %v", err)
}
// 创建 PATCH 请求
req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 设置请求头
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("Content-Type", "application/json")
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("请求失败,状态码: %d", resp.StatusCode)
}
log.Printf("仓库 %s/%s 已成功设置为私有", owner, repo)
return nil
}
-200
View File
@@ -1,200 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f8fafc;
color: #334155;
min-height: 100vh;
padding: 20px;
line-height: 1.6;
}
.container {
max-width: 640px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
header {
text-align: center;
background: #f1f5f9;
padding: 32px 24px;
border-bottom: 1px solid #e2e8f0;
}
header h1 {
font-size: 1.875rem;
font-weight: 600;
color: #1e293b;
letter-spacing: -0.025em;
}
.card {
padding: 32px;
border-bottom: 1px solid #e2e8f0;
}
.card:last-child {
border-bottom: none;
}
.card h2 {
color: #1e293b;
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group label {
font-weight: 500;
color: #334155;
font-size: 0.875rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background: #f8fafc;
}
.form-group input:focus {
outline: none;
border-color: #94a3b8;
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.status-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.timer-display {
font-size: 2rem;
font-weight: 600;
color: #1e293b;
font-family: monospace;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #3b82f6;
width: 0%;
transition: width 0.3s ease;
}
.controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.btn-primary, .btn-secondary, .btn-danger {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #64748b;
color: white;
}
.btn-secondary:hover {
background: #475569;
}
.btn-danger {
background: #ef4444;
color: white;
padding: 12px 24px;
font-weight: 500;
}
.btn-danger:hover {
background: #dc2626;
}
.hint {
font-size: 0.875rem;
color: #64748b;
margin-top: 4px;
}
footer {
text-align: center;
padding: 24px;
color: #64748b;
font-size: 0.875rem;
background: #f1f5f9;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 6px;
color: white;
font-size: 0.875rem;
transform: translateX(200%);
transition: transform 0.3s ease;
z-index: 1000;
max-width: 300px;
word-wrap: break-word;
}
.notification.success {
background: #22c55e;
}
.notification.error {
background: #ef4444;
}
.notification.show {
transform: translateX(0);
}
-136
View File
@@ -1,136 +0,0 @@
class TimerController {
constructor() {
this.secret = localStorage.getItem('timerSecret') || '';
this.baseURL = window.location.origin;
this.initElements();
this.bindEvents();
this.updateSecretDisplay();
// 自动刷新状态
this.startAutoRefresh();
}
initElements() {
this.secretInput = document.getElementById('secret');
this.saveSecretBtn = document.getElementById('save-secret');
this.refreshStatusBtn = document.getElementById('refresh-status');
this.sendSignalBtn = document.getElementById('send-signal');
this.remainingTimeEl = document.getElementById('remaining-time');
this.progressFillEl = document.getElementById('progress-fill');
this.notificationEl = document.getElementById('notification');
}
bindEvents() {
this.saveSecretBtn.addEventListener('click', () => this.saveSecret());
this.refreshStatusBtn.addEventListener('click', () => this.fetchStatus());
this.sendSignalBtn.addEventListener('click', () => this.sendSignal());
// 回车键保存密钥
this.secretInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.saveSecret();
}
});
}
saveSecret() {
const secret = this.secretInput.value.trim();
if (!secret) {
this.showNotification('请输入密钥', 'error');
return;
}
this.secret = secret;
localStorage.setItem('timerSecret', secret);
this.updateSecretDisplay();
this.showNotification('密钥已保存', 'success');
this.fetchStatus(); // 保存后自动刷新状态
}
updateSecretDisplay() {
if (this.secret) {
this.secretInput.placeholder = '密钥已保存';
this.secretInput.value = '';
} else {
this.secretInput.placeholder = '输入您的访问密钥';
}
}
async fetchStatus() {
try {
const response = await fetch(`${this.baseURL}/timer/status`);
const data = await response.json();
if (data.code === 200) {
this.displayTime(data.remaining_time);
} else {
this.showNotification('获取状态失败', 'error');
}
} catch (error) {
console.error('Error fetching status:', error);
this.showNotification('网络错误,请稍后重试', 'error');
}
}
displayTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
this.remainingTimeEl.textContent =
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
// 更新进度条(假设最大时间为24小时)
const maxTime = 24 * 3600; // 24小时
const progress = Math.min(100, (seconds / maxTime) * 100);
this.progressFillEl.style.width = `${100 - progress}%`;
}
async sendSignal() {
if (!this.secret) {
this.showNotification('请先设置密钥', 'error');
return;
}
try {
const response = await fetch(`${this.baseURL}/signal?secret=${encodeURIComponent(this.secret)}`);
const data = await response.json();
if (data.code === 200) {
this.showNotification('信号发送成功!', 'success');
setTimeout(() => this.fetchStatus(), 1000); // 1秒后刷新状态
} else {
this.showNotification(`操作失败: ${data.message}`, 'error');
}
} catch (error) {
console.error('Error sending signal:', error);
this.showNotification('网络错误,请稍后重试', 'error');
}
}
showNotification(message, type) {
this.notificationEl.textContent = message;
this.notificationEl.className = `notification ${type} show`;
setTimeout(() => {
this.notificationEl.classList.remove('show');
}, 3000);
}
startAutoRefresh() {
// 每30秒自动刷新一次状态
setInterval(() => {
if (this.secret) {
this.fetchStatus();
}
}, 30000);
// 页面加载完成后立即获取一次状态
setTimeout(() => this.fetchStatus(), 1000);
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
new TimerController();
});
-56
View File
@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoodBaby</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="container">
<header>
<h1>GoodBaby</h1>
</header>
<main>
<section class="card">
<h2>访问设置</h2>
<div class="form-group">
<label for="secret">密钥</label>
<input type="password" id="secret" placeholder="输入您的访问密钥">
<button id="save-secret" class="btn-secondary">保存</button>
</div>
</section>
<section class="card">
<h2>状态信息</h2>
<div class="status-container">
<div class="timer-display">
<span id="remaining-time">--:--:--</span>
</div>
<div class="progress-bar">
<div id="progress-fill" class="progress-fill"></div>
</div>
<button id="refresh-status" class="btn-primary">刷新状态</button>
</div>
</section>
<section class="card">
<h2>执行操作</h2>
<div class="controls">
<button id="send-signal" class="btn-danger">发送信号</button>
<p class="hint">点击按钮重置定时器</p>
</div>
</section>
</main>
<footer>
<p>© 2026 GoodBaby</p>
</footer>
</div>
<div id="notification" class="notification"></div>
<script src="/static/js/script.js"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
// Package web 把前端构建产物嵌入到二进制中并挂载到 gin
package web
import (
"embed"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed all:dist
var distFS embed.FS
// Register 把前端静态资源挂到根路径。
//
// 未匹配任何 API 路由的 GET 请求回退到 index.html,交给前端路由处理。
func Register(r *gin.Engine) {
dist, err := fs.Sub(distFS, "dist")
if err != nil {
panic(err)
}
fileServer := http.FileServer(http.FS(dist))
r.NoRoute(func(c *gin.Context) {
// API 404 保持 JSON 返回
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"code": http.StatusNotFound, "data": "接口不存在"})
return
}
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
c.Status(http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(dist, path); err != nil {
// SPA 回退:交给 index.html 处理前端路由
c.Request.URL.Path = "/"
}
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

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