Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3831950780 | ||
|
|
9220cf82aa | ||
|
|
4a61527952 | ||
|
|
48bb6b8864 | ||
|
|
58b30fa41a | ||
|
|
2d73157a60 | ||
|
|
ce01604f13 | ||
|
|
ae7082f13c | ||
|
|
54d4fbe24a | ||
|
|
f8c6115737 | ||
|
|
0d766020bc | ||
|
|
d5ede38848 | ||
|
|
79774070fc | ||
|
|
89e8b7fd66 | ||
|
|
033fd91aa1 | ||
|
|
5da78f9deb | ||
|
|
7957a91609 | ||
|
|
7c4fb1e1d8 | ||
|
|
821d4b2d53 | ||
|
|
189eab8a0a | ||
|
|
80e427d7c3 | ||
|
|
52dc548c53 | ||
|
|
202f53915f | ||
|
|
6190a8853a |
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -1,87 +1,132 @@
|
|||||||
# goodBaby
|
# goodBaby v2
|
||||||
|
|
||||||
摇篮系统
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
根据《中国心血管健康与疾病报告2023》,中国 每年 猝死总人数(含心源性和非心源性)或达80万-100万,18-35岁人群占比为38%,也就是说,每天18-35岁人群有近1000人猝死
|
||||||
|
|
||||||
|
伴随着中国人口老龄化加剧,年轻人的牵挂越来越多,面对着可能的猝死,我们或许真的需要做好准备
|
||||||
|
|
||||||
|
所以我写了goodBaby来解决这个问题,他和apple app store里的“死了么”形式上有些接近,不过goodBaby支持了更多好玩的通知方式,比如bilibili动态,github仓库,邮件,OneBot,还可以方便的自托管
|
||||||
[](https://app.rainyun.com/apps/rca/store/7125/cat_)
|
[](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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 下载预编译二进制
|
||||||
|
|
||||||
|
[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. 构建前端
|
||||||
|
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]
|
默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。
|
||||||
> 只有在登陆 Bilibili 后程序才会正常运行
|
|
||||||
|
|
||||||
## 使用
|
## 发版
|
||||||
|
|
||||||
访问你开放的端口(默认为8088),进入WebUI,输入你的密钥,点击保存(密钥会存储到浏览器的localStorage中),此时你可以在WebUI查看倒计时和发送Signal
|
推送 `v` 开头的 tag 即可自动构建产物、创建 Release 并推送镜像到 ghcr,
|
||||||
|
详见 [docs/release.md](docs/release.md)。
|
||||||
|
|
||||||
## Todo list
|
## 驱动配置
|
||||||
|
|
||||||
- [ ] 熔断机制
|
各驱动的账号 / 规则配置说明见 [docs/](docs/):
|
||||||
- [ ] Webhook 自定义请求
|
|
||||||
- [ ] 保存定时器状态
|
* [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)
|
||||||
|
* [饭碗警告](docs/fwalert-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()` 返回的字段描述自动渲染配置表单
|
||||||
|
|||||||
@@ -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, "删除账号成功")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Package dashboard 汇总首页需要的统计数据
|
||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/user"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGetOverview 返回首页概览:各类对象数量、最紧急的 Timer、最近的执行日志
|
||||||
|
func HandleGetOverview(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timers := []model.Timer{}
|
||||||
|
if err := gormDB.Where("uid = ?", userInfo.ID).Find(&timers).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取统计数据失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ruleCount, accountCount int64
|
||||||
|
gormDB.Model(&model.Rule{}).Where("uid = ?", userInfo.ID).Count(&ruleCount)
|
||||||
|
gormDB.Model(&model.Account{}).Where("uid = ?", userInfo.ID).Count(&accountCount)
|
||||||
|
|
||||||
|
recentLogs := []model.ExecutionLog{}
|
||||||
|
gormDB.Where("uid = ?", userInfo.ID).Order("id DESC").Limit(10).Find(&recentLogs)
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
var (
|
||||||
|
enabledCount int
|
||||||
|
triggeredCount int
|
||||||
|
urgent *model.Timer
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range timers {
|
||||||
|
timer := timers[i]
|
||||||
|
if !timer.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enabledCount++
|
||||||
|
if timer.Triggered {
|
||||||
|
triggeredCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 剩余时间最短的启用中 Timer
|
||||||
|
if urgent == nil || timer.NextSignTime() < urgent.NextSignTime() {
|
||||||
|
urgent = &timers[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overview := gin.H{
|
||||||
|
"timer_count": len(timers),
|
||||||
|
"enabled_timers": enabledCount,
|
||||||
|
"triggered_count": triggeredCount,
|
||||||
|
"rule_count": ruleCount,
|
||||||
|
"account_count": accountCount,
|
||||||
|
"server_time": now,
|
||||||
|
"recent_logs": recentLogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
if urgent != nil {
|
||||||
|
overview["urgent_timer"] = urgent
|
||||||
|
overview["urgent_seconds_left"] = urgent.NextSignTime() - now
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, overview)
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
// Package log 提供规则执行日志的查询接口
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/user"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPageSize = 20
|
||||||
|
maxPageSize = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGetLogs 分页查询当前用户的执行日志
|
||||||
|
func HandleGetLogs(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := intQuery(c, "page", 1, 1, 1<<20)
|
||||||
|
pageSize := intQuery(c, "page_size", defaultPageSize, 1, maxPageSize)
|
||||||
|
|
||||||
|
query := gormDB.Model(&model.ExecutionLog{}).Where("uid = ?", userInfo.ID)
|
||||||
|
if ruleID := c.Query("rule_id"); ruleID != "" {
|
||||||
|
query = query.Where("rule_id = ?", ruleID)
|
||||||
|
}
|
||||||
|
if success := c.Query("success"); success == "true" || success == "false" {
|
||||||
|
query = query.Where("success = ?", success == "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取执行日志失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := []model.ExecutionLog{}
|
||||||
|
if err := query.Order("id DESC").
|
||||||
|
Offset((page - 1) * pageSize).
|
||||||
|
Limit(pageSize).
|
||||||
|
Find(&logs).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取执行日志失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
"items": logs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleClearLogs 清空当前用户的执行日志
|
||||||
|
func HandleClearLogs(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gormDB.Where("uid = ?", userInfo.ID).Delete(&model.ExecutionLog{}).Error; err != nil {
|
||||||
|
response.ServerError(c, "清空执行日志失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, "已清空执行日志")
|
||||||
|
}
|
||||||
|
|
||||||
|
func intQuery(c *gin.Context, key string, fallback, min, max int) int {
|
||||||
|
raw := c.Query(key)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || value < min {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Package meta 向 WebUI 暴露驱动能力与站点信息
|
||||||
|
package meta
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/user"
|
||||||
|
"github.com/ssdomei232/goodBaby/configs"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGetProviders 返回所有账号类型与规则类型的元数据
|
||||||
|
//
|
||||||
|
// 前端据此动态渲染配置表单,新增驱动时前端无需改动。
|
||||||
|
func HandleGetProviders(c *gin.Context) {
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"accounts": accountConfigChecker.InitValidatorRegistry().Metas(),
|
||||||
|
"rules": ruleConfigChecker.InitValidatorRegistry().Metas(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetSiteInfo 返回无需登录即可获取的站点信息
|
||||||
|
func HandleGetSiteInfo(c *gin.Context) {
|
||||||
|
config, err := configs.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userCount, err := user.CountUsers()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"name": "goodBaby",
|
||||||
|
// 系统内还没有用户时始终允许注册,方便全新部署创建第一个账号
|
||||||
|
"enable_registry": config.EnableRegistry || userCount == 0,
|
||||||
|
"need_initial_user": userCount == 0,
|
||||||
|
"check_interval_minutes": config.CheckIntervalMinutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Package response 统一 API 的返回格式
|
||||||
|
//
|
||||||
|
// {"code": 200, "data": ...}
|
||||||
|
//
|
||||||
|
// 出错时 data 为可以直接展示给用户的错误信息。
|
||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OK 返回成功响应
|
||||||
|
func OK(c *gin.Context, data any) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail 返回错误响应
|
||||||
|
func Fail(c *gin.Context, code int, msg string) {
|
||||||
|
c.JSON(code, gin.H{"code": code, "data": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbortWith 返回错误响应并终止后续 handler
|
||||||
|
func AbortWith(c *gin.Context, code int, msg string) {
|
||||||
|
c.AbortWithStatusJSON(code, gin.H{"code": code, "data": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BadRequest 参数错误
|
||||||
|
func BadRequest(c *gin.Context, msg string) { Fail(c, http.StatusBadRequest, msg) }
|
||||||
|
|
||||||
|
// Unauthorized 未登录
|
||||||
|
func Unauthorized(c *gin.Context, msg string) { Fail(c, http.StatusUnauthorized, msg) }
|
||||||
|
|
||||||
|
// NotFound 资源不存在
|
||||||
|
func NotFound(c *gin.Context, msg string) { Fail(c, http.StatusNotFound, msg) }
|
||||||
|
|
||||||
|
// ServerError 服务端错误
|
||||||
|
func ServerError(c *gin.Context, msg string) { Fail(c, http.StatusInternalServerError, msg) }
|
||||||
|
|
||||||
|
// FromError 按错误类型选择合适的状态码:
|
||||||
|
// 参数校验错误返回 400,其余返回 500
|
||||||
|
func FromError(c *gin.Context, err error, fallback string) {
|
||||||
|
var validationErr model.ValidationError
|
||||||
|
if errors.As(err, &validationErr) {
|
||||||
|
BadRequest(c, validationErr.Msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ServerError(c, fallback)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package timer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/user"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGetAllTimers 获取当前用户的所有 Timer
|
||||||
|
func HandleGetAllTimers(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timers := []model.Timer{}
|
||||||
|
if err := gormDB.Where("uid = ?", userInfo.ID).Order("id DESC").Find(&timers).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取 Timer 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, timers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetTimer 获取单个 Timer 及其关联规则数量
|
||||||
|
func HandleGetTimer(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timerID, err := parseID(c.Param("timerID"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Timer ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer, err := findTimer(timerID, userInfo.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.NotFound(c, "Timer 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleCount, err := countRules(timerID, userInfo.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取关联规则失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, gin.H{"timer": timer, "rule_count": ruleCount})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCreateTimer 创建 Timer
|
||||||
|
func HandleCreateTimer(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.TimerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "输入参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
response.FromError(c, err, "创建 Timer 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
timer := model.Timer{
|
||||||
|
UID: userInfo.ID,
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
Enabled: boolOr(req.Enabled, true),
|
||||||
|
SignDerationSeconds: req.SignDerationSeconds,
|
||||||
|
RemindTimeSeconds: req.RemindTimeSeconds,
|
||||||
|
// 创建即视为完成一次签到,从当前时间开始计时
|
||||||
|
LastSign: now,
|
||||||
|
CreateAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gormDB.Create(&timer).Error; err != nil {
|
||||||
|
response.ServerError(c, "创建 Timer 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEditTimer 编辑 Timer
|
||||||
|
func HandleEditTimer(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timerID, err := parseID(c.Param("timerID"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Timer ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer, err := findTimer(timerID, userInfo.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.NotFound(c, "Timer 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.TimerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "输入参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
response.FromError(c, err, "更新 Timer 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.Name = req.Name
|
||||||
|
timer.Description = req.Description
|
||||||
|
timer.Enabled = boolOr(req.Enabled, timer.Enabled)
|
||||||
|
timer.SignDerationSeconds = req.SignDerationSeconds
|
||||||
|
timer.RemindTimeSeconds = req.RemindTimeSeconds
|
||||||
|
|
||||||
|
if err := gormDB.Save(timer).Error; err != nil {
|
||||||
|
response.ServerError(c, "更新 Timer 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSignTimer 签到:重置计时并解除已触发状态
|
||||||
|
func HandleSignTimer(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timerID, err := parseID(c.Param("timerID"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Timer ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer, err := findTimer(timerID, userInfo.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.NotFound(c, "Timer 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := signTimer(timer); err != nil {
|
||||||
|
response.ServerError(c, "签到失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSignAll 一键签到当前用户的所有启用中的 Timer
|
||||||
|
func HandleSignAll(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
result := gormDB.Model(&model.Timer{}).
|
||||||
|
Where("uid = ? AND enabled = ?", userInfo.ID, true).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"last_sign": now,
|
||||||
|
"last_remind": 0,
|
||||||
|
"triggered": false,
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
response.ServerError(c, "签到失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, gin.H{"signed": result.RowsAffected, "last_sign": now})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteTimer 删除 Timer
|
||||||
|
//
|
||||||
|
// Timer 被删除后,挂在它下面的规则就没有触发来源了,因此一并删除。
|
||||||
|
func HandleDeleteTimer(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timerID, err := parseID(c.Param("timerID"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Timer ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := findTimer(timerID, userInfo.ID); err != nil {
|
||||||
|
response.NotFound(c, "Timer 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = gormDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("timer_id = ? AND uid = ?", timerID, userInfo.ID).Delete(&model.Rule{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("id = ? AND uid = ?", timerID, userInfo.ID).Delete(&model.Timer{}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "删除 Timer 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, "Timer 删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCheckDeleteTimer 删除前查询会被一并删除的规则
|
||||||
|
func HandleCheckDeleteTimer(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timerID, err := parseID(c.Param("timerID"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Timer ID 格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := []model.Rule{}
|
||||||
|
if err := gormDB.Where("timer_id = ? AND uid = ?", timerID, userInfo.ID).Find(&rules).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取相关规则失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, rules)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package timer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseID(raw string) (uint, error) {
|
||||||
|
id, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolOr(v *bool, fallback bool) bool {
|
||||||
|
if v == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
|
// findTimer 查找属于该用户的 Timer
|
||||||
|
func findTimer(timerID, uid uint) (*model.Timer, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var timer model.Timer
|
||||||
|
if err := gormDB.Where("id = ? AND uid = ?", timerID, uid).First(&timer).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &timer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countRules(timerID, uid uint) (int64, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err = gormDB.Model(&model.Rule{}).Where("timer_id = ? AND uid = ?", timerID, uid).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// signTimer 完成一次签到:重置计时、清除提醒与触发标记
|
||||||
|
func signTimer(timer *model.Timer) error {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := gormDB.Model(&model.Timer{}).Where("id = ?", timer.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"last_sign": now,
|
||||||
|
"last_remind": 0,
|
||||||
|
"triggered": false,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.LastSign = now
|
||||||
|
timer.LastRemind = 0
|
||||||
|
timer.Triggered = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(®istryRequest); 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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. 处理大文件的断点续传与磁盘占用。
|
||||||
|
|
||||||
|
这是一个独立的功能模块,不在现有驱动的改造范围内。
|
||||||
@@ -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。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每个收件人独立发送、独立重试,互不影响。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 饭碗警告配置
|
||||||
|
|
||||||
|
规则类型: `fwalert`。
|
||||||
|
|
||||||
|
定时器到期后,请求饭碗警告的 Webhook 地址
|
||||||
|
|
||||||
|
饭碗警告: 比较简单的(不用实名认证)支持电话和短信通知的第三方平台: `https://fwalert.com/`,价格不算最低,但比较方便
|
||||||
|
|
||||||
|
## 规则配置 (Rule Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"webhook_url": "https://fwalert.com/xxx-xxx-xxx-xxx-xxx",
|
||||||
|
"msg": "消息内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 饭碗警告平台配置
|
||||||
|
|
||||||
|
goodbaby 在请求饭碗警告时将`msg`放在请求体中,参照下面和官方教程设置:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `变量名` | `message` |
|
||||||
|
| `变量来源` | `请求体(body)` |
|
||||||
|
| `提取规则` | `序列化数据` |
|
||||||
|
| `键` | `message` |
|
||||||
|
| `通知简述` | 随便填 |
|
||||||
|
| `通知正文` | `{{message}}` |
|
||||||
|
| `联系方式类型` | 参照官方文档添加并选择 |
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# GitHub 配置
|
||||||
|
|
||||||
|
账号类型: `github`,规则类型: `github-repo-public`。
|
||||||
|
|
||||||
|
定时器到期后,把指定的私有仓库设置为公开。
|
||||||
|
|
||||||
|
## 账号配置 (Account Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "ghp_xxxxxxxx",
|
||||||
|
"owner": "your-github-name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `token` | 是 | Personal Access Token,需要 `repo` 权限 |
|
||||||
|
| `owner` | 是 | 仓库所有者(用户名或组织名) |
|
||||||
|
|
||||||
|
Token 在 GitHub → Settings → Developer settings → Personal access tokens 创建。
|
||||||
|
|
||||||
|
在 WebUI 的账号页面可以点击“测试”验证 Token 是否有效。
|
||||||
|
|
||||||
|
## 规则配置 (Rule Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repos": ["repo-a", "repo-b"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
只填仓库名,不含所有者。
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# OneBot (QQ) 配置
|
||||||
|
|
||||||
|
账号类型: `onebot`,规则类型: `onebot`。
|
||||||
|
|
||||||
|
对接 [NapCat](https://napneko.github.io/)、go-cqhttp 等实现了 OneBot HTTP API 的服务。
|
||||||
|
|
||||||
|
## 账号配置 (Account Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"token": "your_access_token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `url` | 是 | OneBot HTTP 服务地址 |
|
||||||
|
| `token` | 是 | Access Token |
|
||||||
|
|
||||||
|
在 WebUI 的账号页面可以点击“测试”验证连通性(调用 `get_login_info`)。
|
||||||
|
|
||||||
|
## 规则配置 (Rule Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg": "要发送的消息",
|
||||||
|
"send_groups": [123456789],
|
||||||
|
"send_users": [987654321]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`send_groups`(群号)与 `send_users`(好友 QQ 号)至少填写一项。
|
||||||
@@ -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。
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: "要发送的动态正文",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"` // 邮件地址列表
|
||||||
|
}
|
||||||
@@ -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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package fwalert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// send 发送消息
|
||||||
|
func send(config *FwAlertRuleConfig) error {
|
||||||
|
restyClient := resty.New()
|
||||||
|
req := restyClient.R()
|
||||||
|
|
||||||
|
req.SetBody(FwAlertRequest{
|
||||||
|
Message: config.Msg,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := req.Execute(resty.MethodPost, config.WebhookURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != 200 {
|
||||||
|
return fmt.Errorf("发送饭碗警告失败: %s", resp.Status())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package fwalert
|
||||||
|
|
||||||
|
const RuleTypeFwalert = "fwalert"
|
||||||
|
|
||||||
|
type FwAlertRuleConfig struct {
|
||||||
|
WebhookURL string `json:"webhook_url"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FwAlertRequest struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package fwalert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FwalertRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *FwalertRuleValidator) GetType() string {
|
||||||
|
return RuleTypeFwalert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *FwalertRuleValidator) Validate(configJSON string) error {
|
||||||
|
var config FwAlertRuleConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(config.WebhookURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("饭碗警告规则配置中 webhook_url 格式不正确: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("饭碗警告规则配置中 webhook_url 必须是 HTTP 或 HTTPS URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Msg == "" {
|
||||||
|
return fmt.Errorf("饭碗警告规则配置中 msg 不能为空")
|
||||||
|
}
|
||||||
|
if len([]rune(config.Msg)) > 1000 {
|
||||||
|
return fmt.Errorf("饭碗警告内容过长")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *FwalertRuleValidator) Meta() meta.RuleMeta {
|
||||||
|
return meta.RuleMeta{
|
||||||
|
Type: RuleTypeFwalert,
|
||||||
|
Label: "发送饭碗警告",
|
||||||
|
Description: "触发时发送饭碗警告消息。",
|
||||||
|
Docs: "docs/fwalert-config.md",
|
||||||
|
AccountType: "",
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{Key: "webhook_url", Label: "Webhook URL", Type: meta.FieldString, Required: true, Help: "饭碗警告的触发地址", Placeholder: "https://fwalert.com/xxx-xxx-xxx-xxx-xxx"},
|
||||||
|
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "最大长度 1000 个字符"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRuleConfig 解析饭碗警告规则配置
|
||||||
|
func ParseRuleConfig(configJSON string) (*FwAlertRuleConfig, error) {
|
||||||
|
var config FwAlertRuleConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package fwalert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FwalertExecutor 饭碗警告执行器
|
||||||
|
type FwalertExecutor struct{}
|
||||||
|
|
||||||
|
func (e *FwalertExecutor) GetType() string {
|
||||||
|
return RuleTypeFwalert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FwalertExecutor) 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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 账号",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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/fwalert"
|
||||||
|
"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{})
|
||||||
|
registry.Register(&fwalert.FwalertExecutor{})
|
||||||
|
// 未来添加新规则类型时,在这里注册即可
|
||||||
|
|
||||||
|
globalExecutorRegistry = registry
|
||||||
|
})
|
||||||
|
return globalExecutorRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGlobalExecutorRegistry 获取全局执行器注册表
|
||||||
|
func GetGlobalExecutorRegistry() *ExecutorRegistry {
|
||||||
|
if globalExecutorRegistry == nil {
|
||||||
|
panic("执行器注册表未初始化,请先调用 InitExecutorRegistry()")
|
||||||
|
}
|
||||||
|
return globalExecutorRegistry
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package meta
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// MaskPlaceholder 是掩码后的占位值。
|
||||||
|
// 提交编辑时如果某个 secret 字段仍是这个值,服务端会保留原值。
|
||||||
|
const MaskPlaceholder = "********"
|
||||||
|
|
||||||
|
// Mask 把配置 JSON 中的敏感字段替换为占位符
|
||||||
|
func Mask(configJSON string, fields []Field) string {
|
||||||
|
secrets := SecretKeys(fields)
|
||||||
|
if len(secrets) == 0 || configJSON == "" {
|
||||||
|
return configJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &raw); err != nil {
|
||||||
|
return configJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range secrets {
|
||||||
|
if v, ok := raw[key]; ok {
|
||||||
|
if s, isStr := v.(string); !isStr || s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw[key] = MaskPlaceholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
masked, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return configJSON
|
||||||
|
}
|
||||||
|
return string(masked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmask 用旧配置补回被掩码的字段,返回可以落库的配置 JSON
|
||||||
|
func Unmask(newConfigJSON, oldConfigJSON string, fields []Field) string {
|
||||||
|
secrets := SecretKeys(fields)
|
||||||
|
if len(secrets) == 0 || newConfigJSON == "" || oldConfigJSON == "" {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
var newRaw, oldRaw map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(newConfigJSON), &newRaw); err != nil {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(oldConfigJSON), &oldRaw); err != nil {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
restored := false
|
||||||
|
for _, key := range secrets {
|
||||||
|
if s, ok := newRaw[key].(string); ok && s == MaskPlaceholder {
|
||||||
|
if old, exists := oldRaw[key]; exists {
|
||||||
|
newRaw[key] = old
|
||||||
|
restored = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !restored {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, err := json.Marshal(newRaw)
|
||||||
|
if err != nil {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
return string(merged)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package meta 定义驱动向前端暴露的元数据。
|
||||||
|
//
|
||||||
|
// WebUI 依靠这些描述动态渲染账号 / 规则的配置表单,
|
||||||
|
// 新增一个驱动时只要提供 Meta(),前端无需改动。
|
||||||
|
package meta
|
||||||
|
|
||||||
|
// FieldType 决定前端使用哪种输入控件
|
||||||
|
type FieldType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FieldString FieldType = "string" // 单行文本
|
||||||
|
FieldPassword FieldType = "password" // 密码框
|
||||||
|
FieldTextarea FieldType = "textarea" // 多行文本
|
||||||
|
FieldNumber FieldType = "number" // 数字
|
||||||
|
FieldStringList FieldType = "string-list" // 字符串数组
|
||||||
|
FieldNumberList FieldType = "number-list" // 数字数组
|
||||||
|
FieldBool FieldType = "bool" // 开关
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field 描述配置 JSON 中的一个字段
|
||||||
|
type Field struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type FieldType `json:"type"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
|
Help string `json:"help,omitempty"`
|
||||||
|
// Secret 为 true 的字段在读取接口中会被掩码
|
||||||
|
Secret bool `json:"secret,omitempty"`
|
||||||
|
Default any `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountMeta 账号类型的元数据
|
||||||
|
type AccountMeta struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Docs string `json:"docs,omitempty"`
|
||||||
|
Testable bool `json:"testable"`
|
||||||
|
Fields []Field `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleMeta 规则类型的元数据
|
||||||
|
type RuleMeta struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Docs string `json:"docs,omitempty"`
|
||||||
|
// AccountType 该规则需要的账号类型,为空表示不需要关联账号
|
||||||
|
AccountType string `json:"account_type"`
|
||||||
|
Fields []Field `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretKeys 返回需要掩码的字段名
|
||||||
|
func SecretKeys(fields []Field) []string {
|
||||||
|
keys := make([]string, 0, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
if f.Secret {
|
||||||
|
keys = append(keys, f.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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/fwalert"
|
||||||
|
"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{})
|
||||||
|
r.Register(&fwalert.FwalertRuleValidator{})
|
||||||
|
|
||||||
|
registry = r
|
||||||
|
})
|
||||||
|
return registry
|
||||||
|
}
|
||||||
@@ -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登录")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
// Handle Signal
|
SameSite: http.SameSiteLaxMode,
|
||||||
func handleSignal(c *gin.Context) {
|
|
||||||
config, err := configs.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取配置文件失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret := c.Query("secret")
|
|
||||||
if secret != config.SignalSecret {
|
|
||||||
c.JSON(403, gin.H{
|
|
||||||
"code": 403,
|
|
||||||
"message": "secret error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timer.Reset(duration)
|
|
||||||
reminder.GlobalTimerManager.Reset()
|
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"code": 200,
|
|
||||||
"message": "ok",
|
|
||||||
})
|
})
|
||||||
log.Println("触发信号")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// corsMiddleware 允许配置中的来源跨域访问(携带 Cookie)
|
||||||
|
func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
|
||||||
|
allowed := make(map[string]struct{}, len(allowedOrigins))
|
||||||
|
for _, origin := range allowedOrigins {
|
||||||
|
allowed[origin] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
if _, ok := allowed[origin]; ok {
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
c.Header("Vary", "Origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// ValidationError 表示一个可以直接展示给用户的参数校验错误
|
||||||
|
type ValidationError struct {
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ValidationError) Error() string { return e.Msg }
|
||||||
|
|
||||||
|
// ErrValidation 构造一个参数校验错误
|
||||||
|
func ErrValidation(msg string) error { return ValidationError{Msg: msg} }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// 执行日志的触发来源
|
||||||
|
const (
|
||||||
|
TriggerTimer = "timer" // Timer 到期自动触发
|
||||||
|
TriggerManual = "manual" // 用户在 WebUI 手动测试
|
||||||
|
TriggerRemind = "remind" // 提醒通知
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecutionLog 记录每一次规则执行/提醒的结果,供 WebUI 展示
|
||||||
|
type ExecutionLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UID uint `gorm:"index" json:"uid"`
|
||||||
|
RuleID uint `json:"rule_id"`
|
||||||
|
RuleName string `json:"rule_name"`
|
||||||
|
RuleType string `json:"rule_type"`
|
||||||
|
TimerID uint `json:"timer_id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CreateAt int64 `gorm:"index" json:"create_at"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user