Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b656cebee | ||
|
|
0d89b7fe8f | ||
|
|
360169ee64 | ||
|
|
59cf99ae22 | ||
|
|
74ff904598 | ||
|
|
0a68d6358d | ||
|
|
df5165d4a3 | ||
|
|
868731c95e | ||
|
|
6990166dfc | ||
|
|
fa07a6f0e4 | ||
|
|
d428a7db2e | ||
|
|
8163d0d9d4 | ||
|
|
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
|
||||
config.json
|
||||
cookies.txt
|
||||
tmp/
|
||||
bye.json
|
||||
data.db
|
||||
data.db-shm
|
||||
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
|
||||
|
||||
@@ -10,26 +27,39 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
# 用前端构建产物覆盖占位的 dist
|
||||
COPY --from=frontend /app/web/dist /app/web/dist
|
||||
|
||||
ARG VERSION="1.0.0"
|
||||
ARG VERSION="dev"
|
||||
ARG BUILD_DATE="unknown"
|
||||
ARG GIT_COMMIT="unknown"
|
||||
# TARGETARCH 由 buildx 自动注入(amd64 / arm64 / ...)
|
||||
ARG TARGETARCH
|
||||
ARG TARGETOS=linux
|
||||
RUN CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOARCH=amd64 \
|
||||
GOOS=${TARGETOS} \
|
||||
GOARCH=${TARGETARCH} \
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags="-w -s -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE} -X main.gitCommit=${GIT_COMMIT}" \
|
||||
-o /app/main .
|
||||
|
||||
# ======================
|
||||
# 运行阶段 (Runtime Stage)
|
||||
# ======================
|
||||
FROM alpine:3.18
|
||||
FROM alpine:3.20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache tzdata ca-certificates
|
||||
|
||||
COPY --from=builder /app/main /app/main
|
||||
|
||||
# 数据(config.json / data.db)统一放在 /app/data,挂载这个目录即可
|
||||
ENV GOODBABY_CONFIG=/app/data/config.json \
|
||||
GOODBABY_DB_PATH=/app/data/data.db
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE 8088
|
||||
|
||||
CMD ["/app/main"]
|
||||
@@ -1,87 +1,160 @@
|
||||
# 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_)
|
||||
|
||||
## 他会做什么
|
||||
## 功能
|
||||
|
||||
1. 接受 singnal 请求
|
||||
2. 当定时器超时(过长时间没有收到请求)时,触发 `trigger` 函数
|
||||
3. 发送消息到QQ(依赖[CatBot](https://github.com/ssdomei232/CatBot))
|
||||
4. 发送邮件
|
||||
5. 将 Github 上的遗言仓库设置为 public
|
||||
6. 发送一条 Bilibili 动态
|
||||
7. 当倒计时小于48小时时,向用钉钉机器人发消息提醒你
|
||||
* **WebUI**:内置 Vue3 前端,注册 / 登录、定时器、规则、账号、执行日志、设置全部可视化操作
|
||||
* **定时器 (Timer)**:设定签到周期与提前提醒时间,到期前通过钉钉机器人提醒你签到
|
||||
* **规则 (Rule)**:定时器到期后要执行的动作,支持:
|
||||
* 发送邮件 (SMTP)
|
||||
* 发布 B 站动态
|
||||
* 发送 QQ 消息 (OneBot / NapCat)
|
||||
* 发送钉钉机器人消息
|
||||
* 公开 GitHub 仓库
|
||||
* **账号 (Account)**:集中管理第三方凭据,支持连通性测试,敏感字段(密码/Cookie/Token)不会回显
|
||||
* **消息网关 (Gateway)**:为外部系统生成 Webhook 地址,投递进来的消息会触发绑定在该网关上的「网关规则」
|
||||
* **网关规则**:与定时器规则分开存储、分开管理,规则里的消息字段(msg / message / body)会被投递内容替换
|
||||
* **执行日志**:每次规则执行与提醒都有记录,规则支持手动测试
|
||||
* **配置测试**:账号可一键测试连通性;规则可手动触发验证
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 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]
|
||||
> 更详细的配置注解请前往 [Wiki 页面](https://github.com/ssdomei232/goodBaby/wiki)
|
||||
配置文件默认为工作目录下的 `config.json`,首次启动自动生成,字段均有默认值:
|
||||
|
||||
参照 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
|
||||
{
|
||||
"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消息
|
||||
}
|
||||
}
|
||||
环境变量覆盖:`GOODBABY_CONFIG`(配置文件路径)、`GOODBABY_LISTEN_ADDR`、`GOODBABY_DB_DRIVER`、`GOODBABY_DB_PATH`、`GOODBABY_DB_DSN`、`GOODBABY_SESSION_SECRET`、`GOODBABY_ENABLE_REGISTRY`。
|
||||
|
||||
### 管理员后台
|
||||
|
||||
**第一个注册的用户自动成为管理员**。
|
||||
|
||||
### API Key
|
||||
|
||||
每个用户在注册时会自动生成 API Key,可在 WebUI 的“设置”页面查看。调用需要认证的 API 时,在请求头中任选其一:
|
||||
|
||||
```http
|
||||
X-API-Key: gb_<your-api-key>
|
||||
```
|
||||
|
||||
## 运行
|
||||
或:
|
||||
|
||||
```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 ## 扫码登陆后才能使用
|
||||
```http
|
||||
Authorization: Bearer gb_<your-api-key>
|
||||
```
|
||||
|
||||
在日志中找到 Bilibili 的登陆二维码或在文件管理中找到`tmp/qrcode.png`,打开Bilibili APP 扫码登陆
|
||||
## 数据库
|
||||
|
||||
> [!NOTE]
|
||||
> 只有在登陆 Bilibili 后程序才会正常运行
|
||||
默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。
|
||||
|
||||
## 使用
|
||||
## 发版
|
||||
|
||||
访问你开放的端口(默认为8088),进入WebUI,输入你的密钥,点击保存(密钥会存储到浏览器的localStorage中),此时你可以在WebUI查看倒计时和发送Signal
|
||||
推送 `v` 开头的 tag 即可自动构建产物、创建 Release 并推送镜像到 ghcr,
|
||||
详见 [docs/release.md](docs/release.md)。
|
||||
|
||||
## Todo list
|
||||
## 驱动配置
|
||||
|
||||
- [ ] 熔断机制
|
||||
- [ ] Webhook 自定义请求
|
||||
- [ ] 保存定时器状态
|
||||
各驱动的账号 / 规则配置说明见 [docs/](docs/):
|
||||
|
||||
* [Bilibili](docs/bilibili-config.md)
|
||||
* [Email](docs/email-config.md)
|
||||
* [OneBot (QQ)](docs/onebot-config.md)
|
||||
* [钉钉机器人](docs/dingtalk-config.md)
|
||||
* [GitHub](docs/github-config.md)
|
||||
* [饭碗警告](docs/fwalert-config.md)
|
||||
* [消息网关](docs/gateway-config.md)
|
||||
|
||||
## 开发:新增一种规则类型
|
||||
|
||||
driver 只负责把动作做出去:规则本体与关联账号由 `handler/runner` 组装成 `model.RuleTask` 后传进来,
|
||||
driver 内部不访问数据库、不写日志,需要账号凭据时从 `task.Account` 里解析。
|
||||
|
||||
1. 在 `drivers/<name>/` 下实现:
|
||||
* `tool.go`:解析规则 / 账号配置的 `ParseXxx` 函数
|
||||
* 规则验证器(实现 `ruleConfigChecker.RuleValidator`,`Meta()` 返回表单元数据)
|
||||
* 执行器(实现 `runner.RuleExecutor`,方法签名为 `Execute(ctx, *model.RuleTask)`)
|
||||
* 对外动作(底层函数)保持原子:一次调用只做一件事,用 `retry.Do(ctx, ...)` 包裹重试
|
||||
* 如需第三方凭据,再实现账号验证器(`accountConfigChecker.AccountValidator`,可选实现 `AccountTester` 支持连通性测试)
|
||||
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/reg.go` 中注册
|
||||
3. 前端无需改动 —— WebUI 会根据 `Meta()` 返回的字段描述自动渲染配置表单
|
||||
|
||||
## 开发:新增一种消息网关
|
||||
|
||||
1. 在 `internal/gateway/` 下实现 `Gateway` 接口(`GetType()` / `Meta()` / `Deliver()`),
|
||||
`Deliver` 拿到的 `Task` 里已经包含网关与待触发的规则,实现里不访问数据库
|
||||
2. 在 `internal/gateway/reg.go` 中注册
|
||||
3. 前端无需改动 —— 新建网关时的类型选项与说明来自 `Meta()`
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
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
|
||||
}
|
||||
|
||||
gatewayRules, err := getGatewayRulesByAccountID(accountID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取相关网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"rules": rules,
|
||||
"gateway_rules": gatewayRules,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 检查账号是否归属请求用户
|
||||
ownerUID, err := getAccountOwnerUID(accountID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取账号所属用户失败")
|
||||
return
|
||||
}
|
||||
|
||||
if ownerUID != userInfo.ID {
|
||||
response.Forbidden(c, "无权限操作该账号")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 删除相关的消息网关规则
|
||||
gatewayRules, err := getGatewayRulesByAccountID(accountID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取相关网关规则失败")
|
||||
return
|
||||
}
|
||||
for _, oneRule := range gatewayRules {
|
||||
if err := rule.DeleteGatewayRuleByID(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,89 @@
|
||||
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
|
||||
}
|
||||
|
||||
// getGatewayRulesByAccountID 获取账号相关的消息网关规则列表
|
||||
func getGatewayRulesByAccountID(accountID uint, uid uint) ([]*model.GatewayRule, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := []*model.GatewayRule{}
|
||||
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
|
||||
}
|
||||
|
||||
// getAccountOwnerUID 获取账号所属用户的 UID
|
||||
func getAccountOwnerUID(accountID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.Select("uid").Where("id = ?", accountID).First(&account).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return account.UID, nil
|
||||
}
|
||||
@@ -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,84 @@
|
||||
// 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, gatewayCount, gatewayRuleCount, accountCount int64
|
||||
gormDB.Model(&model.Rule{}).Where("uid = ?", userInfo.ID).Count(&ruleCount)
|
||||
gormDB.Model(&model.MessageGateway{}).Where("uid = ?", userInfo.ID).Count(&gatewayCount)
|
||||
gormDB.Model(&model.GatewayRule{}).Where("uid = ?", userInfo.ID).Count(&gatewayRuleCount)
|
||||
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,
|
||||
"gateway_count": gatewayCount,
|
||||
"gateway_rule_count": gatewayRuleCount,
|
||||
"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)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package gateway 提供消息网关的 HTTP 接口。
|
||||
//
|
||||
// 数据库读写都发生在这一层,真正的投递逻辑在 internal/gateway 里,
|
||||
// 这里只负责鉴权、取数、组装投递任务。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ssdomei232/goodBaby/api/response"
|
||||
"github.com/ssdomei232/goodBaby/api/user"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
gatewaycore "github.com/ssdomei232/goodBaby/internal/gateway"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// webhookRequest 外部系统投递消息的请求体
|
||||
type webhookRequest struct {
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// HandleList 获取当前用户的所有消息网关
|
||||
func HandleList(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
items := []model.MessageGateway{}
|
||||
if err := dbConn.Where("uid = ?", userInfo.ID).Order("id DESC").Find(&items).Error; err != nil {
|
||||
response.ServerError(c, "获取网关失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, items)
|
||||
}
|
||||
|
||||
// HandleCreate 创建消息网关
|
||||
func HandleCreate(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.MessageGatewayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if err := req.Validate(); err != nil {
|
||||
response.FromError(c, err, "创建网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 不填类型时用默认网关,填了就必须是已注册的类型
|
||||
if req.Type == "" {
|
||||
req.Type = model.GatewayTypeWebhook
|
||||
}
|
||||
if _, ok := gatewaycore.InitGatewayRegistry().Resolve(req.Type); !ok {
|
||||
response.BadRequest(c, "不支持的消息网关类型: "+req.Type)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := gatewaycore.NewToken()
|
||||
if err != nil {
|
||||
response.ServerError(c, "生成网关 Token 失败")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "创建网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
item := model.MessageGateway{
|
||||
UID: userInfo.ID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Token: token,
|
||||
CreateAt: time.Now().Unix(),
|
||||
}
|
||||
if err := dbConn.Create(&item).Error; err != nil {
|
||||
response.ServerError(c, "创建网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
// HandleDelete 删除消息网关,绑定在它下面的规则一并删除
|
||||
func HandleDelete(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
gatewayID, err := parseID(c.Param("gatewayID"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "网关 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "删除网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
err = dbConn.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("gateway_id = ? AND uid = ?", gatewayID, userInfo.ID).
|
||||
Delete(&model.GatewayRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ? AND uid = ?", gatewayID, userInfo.ID).
|
||||
Delete(&model.MessageGateway{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "删除网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "网关已删除")
|
||||
}
|
||||
|
||||
// HandleWebhook 接收外部系统投递的消息,触发绑定在该网关上的规则
|
||||
func HandleWebhook(c *gin.Context) {
|
||||
var req webhookRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
|
||||
response.BadRequest(c, "message 不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "网关不可用")
|
||||
return
|
||||
}
|
||||
|
||||
var target model.MessageGateway
|
||||
if err := dbConn.Where("token = ?", c.Param("token")).First(&target).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, "网关不存在")
|
||||
return
|
||||
}
|
||||
|
||||
rules := []model.GatewayRule{}
|
||||
if err := dbConn.Where("uid = ? AND gateway_id = ? AND enabled = ?", target.UID, target.ID, true).
|
||||
Find(&rules).Error; err != nil {
|
||||
response.ServerError(c, "读取网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 投递需要在 HTTP 请求内返回结果,因此用较短的超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := gatewaycore.InitGatewayRegistry().Deliver(ctx, target.Type, &gatewaycore.Task{
|
||||
Gateway: &target,
|
||||
Rules: rules,
|
||||
Message: gatewaycore.Message{
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Content: strings.TrimSpace(req.Message),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, result)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package gateway
|
||||
|
||||
import "strconv"
|
||||
|
||||
// parseID 解析 URL 里的数字 ID
|
||||
func parseID(raw string) (uint, error) {
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// 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 gatewayRuleID := c.Query("gateway_rule_id"); gatewayRuleID != "" {
|
||||
query = query.Where("gateway_rule_id = ?", gatewayRuleID)
|
||||
}
|
||||
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,46 @@
|
||||
// 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/gateway"
|
||||
"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(),
|
||||
"gateways": gateway.InitGatewayRegistry().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,55 @@
|
||||
// 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) }
|
||||
|
||||
// Forbidden 无权限
|
||||
func Forbidden(c *gin.Context, msg string) { Fail(c, http.StatusForbidden, 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,246 @@
|
||||
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/gateway"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// HandleGetAllGatewayRules 获取用户的消息网关规则,支持按 gateway_id 过滤
|
||||
//
|
||||
// 消息网关规则和定时器规则是两张表,互不影响,因此这里单独一组接口。
|
||||
func HandleGetAllGatewayRules(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("gateway_id"); raw != "" {
|
||||
gatewayID, err := parseID(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "gateway_id 格式错误")
|
||||
return
|
||||
}
|
||||
query = query.Where("gateway_id = ?", gatewayID)
|
||||
}
|
||||
|
||||
rules := []model.GatewayRule{}
|
||||
if err := query.Order("id DESC").Find(&rules).Error; err != nil {
|
||||
response.ServerError(c, "获取网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, maskGatewayRules(rules))
|
||||
}
|
||||
|
||||
// HandleCreateGatewayRule 创建消息网关规则
|
||||
func HandleCreateGatewayRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.GatewayRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
newRule := model.GatewayRule{
|
||||
UID: userInfo.ID,
|
||||
Name: req.Name,
|
||||
GatewayID: req.GatewayID,
|
||||
AccountID: req.AccountID,
|
||||
Type: req.Type,
|
||||
ConfigJson: req.ConfigJson,
|
||||
Enabled: boolOr(req.Enabled, true),
|
||||
CreateAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if err := validateGatewayRule(&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, maskGatewayRule(newRule))
|
||||
}
|
||||
|
||||
// HandleEditGatewayRule 编辑消息网关规则
|
||||
func HandleEditGatewayRule(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 := findGatewayRule(ruleID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "网关规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.GatewayRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
updated := *existing
|
||||
updated.Name = req.Name
|
||||
updated.Type = req.Type
|
||||
updated.GatewayID = req.GatewayID
|
||||
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 := validateGatewayRule(&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, maskGatewayRule(updated))
|
||||
}
|
||||
|
||||
// HandleDeleteGatewayRule 删除消息网关规则
|
||||
func HandleDeleteGatewayRule(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 := findGatewayRule(ruleID, userInfo.ID); err != nil {
|
||||
response.NotFound(c, "网关规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := DeleteGatewayRuleByID(ruleID, userInfo.ID); err != nil {
|
||||
response.ServerError(c, "删除网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "网关规则删除成功")
|
||||
}
|
||||
|
||||
// HandleTestGatewayRule 立即执行一次网关规则,用于验证配置
|
||||
//
|
||||
// 与投递不同:这里用规则里保存的配置,不覆盖外部消息。
|
||||
func HandleTestGatewayRule(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 := findGatewayRule(ruleID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "网关规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 用一条示例消息走一次真实的投递覆盖,让用户看到规则最终会发出什么内容
|
||||
if configJSON, applied, err := gateway.ApplyMessage(target.ConfigJson, gateway.TestMessage); err == nil && applied {
|
||||
target.ConfigJson = configJSON
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := runner.ExecuteGatewayRuleWithContext(ctx, target, model.TriggerManual); err != nil {
|
||||
response.BadRequest(c, fmt.Sprintf("规则执行失败: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "规则执行成功")
|
||||
}
|
||||
|
||||
// validateGatewayRule 校验网关规则的通用字段、关联对象与类型专属配置
|
||||
func validateGatewayRule(req *model.GatewayRuleRequest, rule *model.GatewayRule) error {
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkGatewayExists(rule.GatewayID, rule.UID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleMeta, err := ruleMetaOf(req.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 标题/内容由投递请求提供,页面不填,这里补占位值后再校验
|
||||
rule.ConfigJson = meta.FillGatewayMessages(rule.ConfigJson, ruleMeta.Fields)
|
||||
if err := validateRuleConfig(req.Type, rule.ConfigJson); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID, err := checkAccount(rule.AccountID, rule.UID, ruleMeta.AccountType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rule.AccountID = accountID
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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
|
||||
}
|
||||
|
||||
ownerUID, err := getRuleOwnerUID(ruleID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取规则所属用户失败")
|
||||
return
|
||||
}
|
||||
if ownerUID != userInfo.ID {
|
||||
response.Forbidden(c, "无权限操作该规则")
|
||||
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
|
||||
}
|
||||
|
||||
// 检查关联的 Timer 是否存在、账号是否可用
|
||||
if err := checkTimerExists(rule.TimerID, rule.UID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleMeta, err := ruleMetaOf(req.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRuleConfig(req.Type, rule.ConfigJson); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID, err := checkAccount(rule.AccountID, rule.UID, ruleMeta.AccountType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rule.AccountID = accountID
|
||||
|
||||
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,218 @@
|
||||
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
|
||||
}
|
||||
|
||||
// findGatewayRule 查找属于该用户的消息网关规则
|
||||
func findGatewayRule(ruleID, uid uint) (*model.GatewayRule, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var target model.GatewayRule
|
||||
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
|
||||
}
|
||||
|
||||
// DeleteGatewayRuleByID 根据 ID 删除消息网关规则
|
||||
func DeleteGatewayRuleByID(id uint, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.GatewayRule{}).Error
|
||||
}
|
||||
|
||||
// checkTimerExists 检查关联的 Timer 是否存在且归属当前用户
|
||||
func checkTimerExists(timerID, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", timerID, uid).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return model.ErrValidation("关联的 Timer 不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkGatewayExists 检查关联的消息网关是否存在且归属当前用户
|
||||
func checkGatewayExists(gatewayID, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := gormDB.Model(&model.MessageGateway{}).Where("id = ? AND uid = ?", gatewayID, uid).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return model.ErrValidation("关联的消息网关不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAccount 校验规则关联的账号存在、归属当前用户且类型匹配。
|
||||
//
|
||||
// requiredType 为空表示该规则类型不需要账号,此时返回 0,调用方据此清掉 account_id。
|
||||
func checkAccount(accountID, uid uint, requiredType string) (uint, error) {
|
||||
if requiredType == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if accountID == 0 {
|
||||
return 0, model.ErrValidation("该规则类型必须关联一个账号")
|
||||
}
|
||||
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.Where("id = ? AND uid = ?", accountID, uid).First(&account).Error; err != nil {
|
||||
return 0, model.ErrValidation("关联的账号不存在")
|
||||
}
|
||||
if account.Type != requiredType {
|
||||
return 0, model.ErrValidation(
|
||||
fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredType, account.Type))
|
||||
}
|
||||
return accountID, nil
|
||||
}
|
||||
|
||||
// ruleMetaOf 取规则类型的元数据,不支持的类型返回校验错误
|
||||
func ruleMetaOf(ruleType string) (meta.RuleMeta, error) {
|
||||
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType)
|
||||
if !ok {
|
||||
return meta.RuleMeta{}, model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", ruleType))
|
||||
}
|
||||
return ruleMeta, nil
|
||||
}
|
||||
|
||||
// validateRuleConfig 校验类型专属的规则配置
|
||||
func validateRuleConfig(ruleType, configJSON string) error {
|
||||
if err := ruleConfigChecker.InitValidatorRegistry().Validate(ruleType, configJSON); err != nil {
|
||||
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskRule 掩码定时器规则配置中的敏感字段
|
||||
func maskRule(target model.Rule) model.Rule {
|
||||
target.ConfigJson = maskConfig(target.Type, target.ConfigJson)
|
||||
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
|
||||
}
|
||||
|
||||
// maskGatewayRule 掩码消息网关规则配置中的敏感字段
|
||||
func maskGatewayRule(target model.GatewayRule) model.GatewayRule {
|
||||
target.ConfigJson = maskConfig(target.Type, target.ConfigJson)
|
||||
return target
|
||||
}
|
||||
|
||||
func maskGatewayRules(rules []model.GatewayRule) []model.GatewayRule {
|
||||
masked := make([]model.GatewayRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
masked = append(masked, maskGatewayRule(r))
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// maskConfig 掩码规则配置中的敏感字段
|
||||
func maskConfig(ruleType, configJSON string) string {
|
||||
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType); ok {
|
||||
return meta.Mask(configJSON, ruleMeta.Fields)
|
||||
}
|
||||
return configJSON
|
||||
}
|
||||
|
||||
// getRuleOwnerUID 获取定时器规则所属用户的 UID
|
||||
func getRuleOwnerUID(ruleID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var target model.Rule
|
||||
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&target).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return target.UID, nil
|
||||
}
|
||||
|
||||
// getGatewayRuleOwnerUID 获取消息网关规则所属用户的 UID
|
||||
func getGatewayRuleOwnerUID(ruleID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var target model.GatewayRule
|
||||
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&target).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return target.UID, nil
|
||||
}
|
||||
@@ -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,86 @@
|
||||
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
|
||||
}
|
||||
|
||||
// getTimerOwnerUID 获取 Timer 的所属用户 ID
|
||||
func getTimerOwnerUID(timerID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var timer model.Timer
|
||||
if err := gormDB.Select("uid").Where("id = ?", timerID).First(&timer).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return timer.UID, 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,226 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
|
||||
"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
|
||||
if u.APIKey == "" {
|
||||
u.APIKey, err = generateAPIKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.CreateAt = time.Now().Unix()
|
||||
|
||||
return gormDB.Create(u).Error
|
||||
}
|
||||
|
||||
func generateAPIKey() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gb_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func ensureAPIKey(user *model.User) error {
|
||||
if user.APIKey != "" {
|
||||
return nil
|
||||
}
|
||||
key, err := generateAPIKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gormDB.Model(&model.User{}).Where("id = ? AND (api_key IS NULL OR api_key = '')", user.ID).Update("api_key", key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
user.APIKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if err := ensureAPIKey(&user); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 复用钉钉驱动里的解析逻辑,避免两边对配置格式的理解不一致
|
||||
config, err := dingtalk.ParseAccountConfig(*raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.AccessToken == "" {
|
||||
return fmt.Errorf("钉钉配置中 access_token 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
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) {
|
||||
apiKey := c.GetHeader("X-API-Key")
|
||||
if apiKey == "" {
|
||||
auth := c.GetHeader("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
|
||||
apiKey = auth[len(prefix):]
|
||||
}
|
||||
}
|
||||
if apiKey != "" {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err == nil {
|
||||
var user model.User
|
||||
if gormDB.Where("api_key = ?", apiKey).First(&user).Error == nil {
|
||||
c.Set(contextKey, &user)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
response.AbortWith(c, http.StatusUnauthorized, "API Key 无效")
|
||||
return
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 默认配置文件路径,可通过环境变量 GOODBABY_CONFIG 覆盖
|
||||
const defaultConfigPath = "config.json"
|
||||
|
||||
type Config struct {
|
||||
SignalSecret string `json:"signal_secret"`
|
||||
Debug bool `json:"debug"`
|
||||
DisconnectDuration int `json:"disconnect_duration"` // Hours
|
||||
BiliMsg string `json:"bili_msg"`
|
||||
Basic Basic `json:"basic"`
|
||||
MailConfig MailConfig `json:"mail_config"`
|
||||
GithubConfig GithubConfig `json:"github_config"`
|
||||
DingtalkBot DingtalkBot `json:"dingtalk_bot"`
|
||||
OneBotConfig OneBotConfig `json:"onebot_config"`
|
||||
// HTTP 监听地址,如 ":8088"
|
||||
ListenAddr string `json:"listen_addr"`
|
||||
// 是否开放注册
|
||||
EnableRegistry bool `json:"enable_registry"`
|
||||
// 规则执行的最长重试时间(小时),用于指数退避的整体超时
|
||||
TimeoutDurationHours int `json:"timeout_duration_hours"`
|
||||
// 检查 timer 的间隔(分钟)
|
||||
CheckIntervalMinutes int `json:"check_interval_minutes"`
|
||||
// 数据库驱动,"sqlite"(默认) 或 "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"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"pass"`
|
||||
MailList []string `json:"mail_list"`
|
||||
MailTitle string `json:"mail_title"`
|
||||
MailContent string `json:"mail_content"`
|
||||
// 支持的数据库驱动
|
||||
const (
|
||||
DriverSQLite = "sqlite"
|
||||
DriverPostgres = "postgres"
|
||||
)
|
||||
|
||||
func defaultConfig() Config {
|
||||
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 {
|
||||
Owner string `json:"owner"`
|
||||
Repos []string `json:"repos"`
|
||||
Token string `json:"token"`
|
||||
var (
|
||||
once sync.Once
|
||||
cached Config
|
||||
loadErr error
|
||||
cacheMux sync.RWMutex
|
||||
)
|
||||
|
||||
// GetConfig 读取配置,只在首次调用时读盘,之后返回缓存。
|
||||
//
|
||||
// 配置文件不存在时会以默认值创建一份,缺失的字段会被补齐并写回。
|
||||
func GetConfig() (Config, error) {
|
||||
once.Do(func() {
|
||||
cfg, err := load()
|
||||
cacheMux.Lock()
|
||||
cached, loadErr = cfg, err
|
||||
cacheMux.Unlock()
|
||||
})
|
||||
|
||||
cacheMux.RLock()
|
||||
defer cacheMux.RUnlock()
|
||||
return cached, loadErr
|
||||
}
|
||||
|
||||
type Basic struct {
|
||||
Name string `json:"name"`
|
||||
Age int `json:"age"`
|
||||
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")
|
||||
// MustGetConfig 与 GetConfig 相同,但在出错时直接退出,用于启动阶段
|
||||
func MustGetConfig() Config {
|
||||
cfg, err := GetConfig()
|
||||
if err != nil {
|
||||
return config, err
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func configPath() string {
|
||||
if p := os.Getenv("GOODBABY_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
return defaultConfigPath
|
||||
}
|
||||
|
||||
func load() (Config, error) {
|
||||
path := configPath()
|
||||
config := defaultConfig()
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
if err := json.Unmarshal(content, &config); err != nil {
|
||||
return config, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
log.Printf("配置文件 %s 不存在,使用默认配置创建", path)
|
||||
default:
|
||||
return config, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(content, &config)
|
||||
if err != nil {
|
||||
return config, err
|
||||
changed := normalize(&config)
|
||||
applyEnvOverrides(&config)
|
||||
|
||||
if changed {
|
||||
if err := save(path, config); err != nil {
|
||||
// 只读挂载等场景下写回失败不应阻断启动
|
||||
log.Printf("警告: 写回配置文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// normalize 补齐缺失/非法的配置项,返回是否发生了修改
|
||||
func normalize(c *Config) bool {
|
||||
def := defaultConfig()
|
||||
changed := false
|
||||
|
||||
if c.ListenAddr == "" {
|
||||
c.ListenAddr, changed = def.ListenAddr, true
|
||||
}
|
||||
if c.TimeoutDurationHours <= 0 {
|
||||
c.TimeoutDurationHours, changed = def.TimeoutDurationHours, true
|
||||
}
|
||||
if c.CheckIntervalMinutes <= 0 {
|
||||
c.CheckIntervalMinutes, changed = def.CheckIntervalMinutes, true
|
||||
}
|
||||
if c.DatabasePath == "" {
|
||||
c.DatabasePath, changed = def.DatabasePath, true
|
||||
}
|
||||
if c.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:
|
||||
goodbaby:
|
||||
image: mei232/goodbaby:v0.2.0
|
||||
# 发版镜像由 GitHub Actions 自动推送到 ghcr
|
||||
image: ghcr.io/ssdomei232/goodbaby:latest
|
||||
# 想用本地源码构建时把上面一行注释掉,保留 build
|
||||
build: .
|
||||
ports:
|
||||
- "8088:8088"
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- GIN_MODE=release
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./tmp:/app/tmp
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
# sqlite 需要写权限,config.json 首次启动会自动生成,不能挂成只读
|
||||
- ./data:/app/data
|
||||
|
||||
@@ -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,92 @@
|
||||
# 数据库配置
|
||||
|
||||
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` / `gateway_rules` / `message_gateways` / `accounts` / `execution_logs`)。
|
||||
|
||||
## 建表
|
||||
|
||||
首次启动会自动执行 `AutoMigrate` 建表并补齐新增字段,不需要手动执行 SQL。
|
||||
|
||||
升级到 v2.2.0 时,如果 `rules` 表里还有靠 `gateway_id` 标识的历史消息网关规则,
|
||||
启动阶段会自动把它们搬到 `gateway_rules` 表,不需要手工处理。
|
||||
@@ -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,55 @@
|
||||
# 消息网关配置
|
||||
|
||||
消息网关让外部系统来触发 goodBaby 的动作:为网关生成一个 Webhook 地址,
|
||||
外部系统往这个地址 POST 一条消息,绑定在该网关上的「网关规则」就会被执行。
|
||||
|
||||
## 创建网关
|
||||
|
||||
在 WebUI 的「消息网关」页面点击「新建网关」:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| 网关类型 | 目前只有 `webhook`,新类型由 `internal/gateway` 注册后自动出现在这里 |
|
||||
| 网关名称 | 便于识别的名字,例如「生产环境告警」 |
|
||||
|
||||
创建后会得到一个地址:
|
||||
|
||||
```text
|
||||
https://<你的域名>/api/v1/gateways/gw_xxxxxxxx/webhook
|
||||
```
|
||||
|
||||
Token 由服务端随机生成,无法在页面上修改;删除网关会连同它的网关规则一起删除。
|
||||
|
||||
## 投递消息
|
||||
|
||||
```bash
|
||||
curl -X POST https://<你的域名>/api/v1/gateways/gw_xxxxxxxx/webhook \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"title": "数据库告警", "message": "主库连接数超过 90%"}'
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `message` | 是 | 消息正文,会覆盖网关规则配置里的消息字段 |
|
||||
| `title` | 否 | 消息标题,规则配置里存在 `title` 字段时会被替换 |
|
||||
|
||||
响应里会给出本次触发的规则总数与失败明细:
|
||||
|
||||
```json
|
||||
{ "code": 200, "data": { "total": 2, "failed": ["发送邮件: 连接 SMTP 服务器失败"] } }
|
||||
```
|
||||
|
||||
## 网关规则
|
||||
|
||||
网关规则在「网关规则」页面维护,和定时器规则是两张表、两个页面,互不影响。
|
||||
只需配置「发给谁」这类固定信息:名称 / 类型 / 关联账号 / 类型专属配置 / 启用。
|
||||
|
||||
**消息标题与内容不用在规则里预先写**:邮件标题、邮件正文、QQ 消息内容、钉钉标题与内容、
|
||||
B 站动态与私信内容、饭碗警告内容这些字段在「网关规则」页面上不会让用户填写
|
||||
(页面上只显示一行提示),投递时统一由请求里的 `title` / `message` 填充。
|
||||
|
||||
没有消息字段的规则(例如「公开 GitHub 仓库」「删除阿里云 DNS 记录」)也能挂到网关上,
|
||||
这类规则固定按自己的配置执行,投递的消息只起到触发器的作用。
|
||||
|
||||
规则编辑页的「测试」按钮会用一条示例消息走一次真实的投递覆盖,
|
||||
方便你在执行日志里确认最终发出的内容。
|
||||
@@ -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 注入,启动日志里能看到:
|
||||
|
||||
```log
|
||||
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,54 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
)
|
||||
|
||||
type AliDNSAccountConfigValidator struct{}
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) GetType() string { return AccountType }
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.AK == "" || cfg.SK == "" {
|
||||
return fmt.Errorf("阿里云账号配置不完整")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aliDNSClient, err := newClient(cfg.AK, cfg.SK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = aliDNSClient.DescribeDnsProductInstances(&client.DescribeDnsProductInstancesRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
return meta.AccountMeta{
|
||||
Type: AccountType,
|
||||
Label: "阿里云",
|
||||
Description: "阿里云国内站",
|
||||
Fields: []meta.Field{
|
||||
{Key: "ak", Label: "Access Key", Type: meta.FieldPassword, Required: true, Secret: true, Placeholder: "xxxxxxxxx"},
|
||||
{Key: "sk", Label: "Secret Key", Type: meta.FieldPassword, Required: true, Secret: true, Placeholder: "xxxxxxxxx"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
alidns "github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// DeleteRecord 用给定账号删除一条阿里云 DNS 解析记录
|
||||
func DeleteRecord(ctx context.Context, account *AliDNSAccount, config *DeleteRecordConfig) error {
|
||||
client, err := newClient(account.AK, account.SK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := retry.Do(ctx, func() error {
|
||||
_, err := client.DeleteDomainRecord(&alidns.DeleteDomainRecordRequest{
|
||||
RecordId: &config.RecordID,
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("删除阿里云DNS记录失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package alidns
|
||||
|
||||
const (
|
||||
AccountType = "alidns"
|
||||
RuleTypeDeleteRecord = "alidns-delete-record"
|
||||
)
|
||||
|
||||
type AliDNSAccount struct {
|
||||
AK string `json:"ak"`
|
||||
SK string `json:"sk"`
|
||||
}
|
||||
|
||||
type DeleteRecordConfig struct {
|
||||
RecordID string `json:"record_id"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
)
|
||||
|
||||
// 阿里云删除记录规则验证器
|
||||
type AliDNSDeleteRecordRuleValidator struct{}
|
||||
|
||||
func (v *AliDNSDeleteRecordRuleValidator) GetType() string {
|
||||
return RuleTypeDeleteRecord
|
||||
}
|
||||
|
||||
func (v *AliDNSDeleteRecordRuleValidator) Validate(configJSON string) error {
|
||||
config, err := ParseDeleteRecordConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.RecordID == "" {
|
||||
return fmt.Errorf("阿里云删除记录规则配置中 record_id 不能为空")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *AliDNSDeleteRecordRuleValidator) Meta() meta.RuleMeta {
|
||||
return meta.RuleMeta{
|
||||
Type: RuleTypeDeleteRecord,
|
||||
Label: "删除阿里云DNS解析记录",
|
||||
Description: "触发时删除阿里云DNS解析记录",
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{Key: "record_id", Label: "记录ID", Type: meta.FieldString, Required: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// DeleteAliDNSRecordExecutor 删除阿里云DNS记录执行器
|
||||
type DeleteAliDNSRecordExecutor struct{}
|
||||
|
||||
func (e *DeleteAliDNSRecordExecutor) GetType() string {
|
||||
return RuleTypeDeleteRecord
|
||||
}
|
||||
|
||||
func (e *DeleteAliDNSRecordExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := ParseDeleteRecordConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DeleteRecord(ctx, account, config)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
alidns "github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
"github.com/aliyun/credentials-go/credentials"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析阿里云账号配置
|
||||
func ParseAccountConfig(config string) (*AliDNSAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析阿里云账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account AliDNSAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析阿里云账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseDeleteRecordConfig 解析删除解析记录规则配置
|
||||
func ParseDeleteRecordConfig(configJSON string) (*DeleteRecordConfig, error) {
|
||||
var config DeleteRecordConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析阿里云删除记录规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newClient 用 AccessKey 创建阿里云 DNS 客户端
|
||||
func newClient(ak string, sk string) (*alidns.Client, error) {
|
||||
credentialsConfig := new(credentials.Config).
|
||||
SetType("access_key").
|
||||
SetAccessKeyId(ak).
|
||||
SetAccessKeySecret(sk)
|
||||
|
||||
akCredential, err := credentials.NewCredential(credentialsConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建阿里云凭据失败: %w", err)
|
||||
}
|
||||
|
||||
return alidns.NewClient(&openapi.Config{
|
||||
Credential: akCredential,
|
||||
Endpoint: tea.String("alidns.aliyuncs.com"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"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 := ParseAccountConfig(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 := ParseAccountConfig(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 字符串",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/CuteReimu/bilibili/v2"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// SendDynamic 用给定账号发送一条 B 站动态
|
||||
//
|
||||
// 暂时没有处理 429 和 403 的区别对待
|
||||
func SendDynamic(ctx context.Context, account *BiliAccount, config *BiliDynamicConfig) error {
|
||||
client := newClient(account.RawCookies)
|
||||
params := bilibili.CreateDynamicParam{
|
||||
DynamicId: 0,
|
||||
Type: 4,
|
||||
Rid: 0,
|
||||
Content: config.Msg,
|
||||
}
|
||||
|
||||
if err := retry.Do(ctx, func() error {
|
||||
_, err := client.CreateDynamic(params)
|
||||
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,74 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/CuteReimu/bilibili/v2"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// privateMsgTypeText 私信消息类型:1 为文字
|
||||
const privateMsgTypeText = 1
|
||||
|
||||
// SendPrivateMessage 用给定账号向配置中的每个 UID 发送一条 B 站私信
|
||||
//
|
||||
// 每个接收者单独重试、互不阻塞,最后汇总失败信息交给上层记录。
|
||||
func SendPrivateMessage(ctx context.Context, account *BiliAccount, config *BiliPrivateMessageConfig) error {
|
||||
client := newClient(account.RawCookies)
|
||||
|
||||
// 发私信需要带上自己的 UID,从账号信息里取
|
||||
selfUID, err := fetchSelfUID(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fails []string
|
||||
for _, uid := range config.ReceiverUids {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendPrivateMessage(client, selfUID, uid, config.Msg)
|
||||
}); 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
|
||||
}
|
||||
|
||||
// fetchSelfUID 取当前账号的 UID,私信接口需要它
|
||||
func fetchSelfUID(ctx context.Context, client *bilibili.Client) (int, error) {
|
||||
var selfUID int
|
||||
if err := retry.Do(ctx, func() error {
|
||||
info, err := client.GetAccountInformation()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info == nil || info.Mid == 0 {
|
||||
return retry.Permanent(fmt.Errorf("B站 cookie 无效或已过期"))
|
||||
}
|
||||
selfUID = info.Mid
|
||||
return nil
|
||||
}); err != nil {
|
||||
return 0, fmt.Errorf("获取B站账号信息失败: %w", err)
|
||||
}
|
||||
return selfUID, nil
|
||||
}
|
||||
|
||||
// sendPrivateMessage 发送一条私信
|
||||
func sendPrivateMessage(client *bilibili.Client, senderUID int, receiverUID int64, msg string) error {
|
||||
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
|
||||
SenderUid: senderUID,
|
||||
ReceiverId: int(receiverUID),
|
||||
ReceiverType: 1,
|
||||
MsgType: privateMsgTypeText,
|
||||
Timestamp: int(time.Now().Unix()),
|
||||
Content: buildTextContent(msg),
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"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 {
|
||||
config, err := ParseDynamicConfig(configJSON)
|
||||
if err != nil {
|
||||
return 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 {
|
||||
config, err := ParsePrivateMessageConfig(configJSON)
|
||||
if err != nil {
|
||||
return 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, GatewayMessage: 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,
|
||||
GatewayMessage: true,
|
||||
Placeholder: "要发送的动态正文",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// BilibiliDynamicExecutor B站动态执行器
|
||||
type BilibiliDynamicExecutor struct{}
|
||||
|
||||
func (e *BilibiliDynamicExecutor) GetType() string {
|
||||
return RuleTypeDynamic
|
||||
}
|
||||
|
||||
func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := ParseDynamicConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendDynamic(ctx, account, config)
|
||||
}
|
||||
|
||||
// BilibiliPrivateMessageExecutor B站私信执行器
|
||||
type BilibiliPrivateMessageExecutor struct{}
|
||||
|
||||
func (e *BilibiliPrivateMessageExecutor) GetType() string {
|
||||
return RuleTypePrivateMessage
|
||||
}
|
||||
|
||||
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := ParsePrivateMessageConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendPrivateMessage(ctx, account, config)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/CuteReimu/bilibili/v2"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析 B 站账号配置
|
||||
func ParseAccountConfig(config string) (*BiliAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析B站账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account BiliAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析B站账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseDynamicConfig 解析 B 站动态规则配置
|
||||
func ParseDynamicConfig(configJSON string) (*BiliDynamicConfig, error) {
|
||||
var config BiliDynamicConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析B站动态规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ParsePrivateMessageConfig 解析 B 站私信规则配置
|
||||
func ParsePrivateMessageConfig(configJSON string) (*BiliPrivateMessageConfig, error) {
|
||||
var config BiliPrivateMessageConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析B站私信规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newClient 按账号配置创建 B 站客户端
|
||||
func newClient(rawCookies string) *bilibili.Client {
|
||||
client := bilibili.New()
|
||||
client.SetRawCookies(rawCookies)
|
||||
return client
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package dingtalk 提供钉钉自定义机器人的提醒与规则执行能力
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/CatchZeng/dingtalk/pkg/dingtalk"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// ErrNoUserConfig 表示用户没有配置钉钉提醒
|
||||
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
|
||||
|
||||
// SendDingTalkMsg 使用用户配置的钉钉机器人发送提醒
|
||||
//
|
||||
// 用户没有配置钉钉机器人时返回 ErrNoUserConfig
|
||||
func SendDingTalkMsg(user *model.User, title string, msg string) error {
|
||||
if user.DingTalkConfig == nil {
|
||||
return ErrNoUserConfig
|
||||
}
|
||||
|
||||
config, err := ParseAccountConfig(*user.DingTalkConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := send(config, title, msg); err != nil {
|
||||
return fmt.Errorf("发送钉钉消息失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMessage 用规则里配置的机器人发送消息
|
||||
func SendMessage(ctx context.Context, config *DingTalkRuleConfig) error {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return send(&config.DingTalkConfig, config.Title, config.Msg)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送钉钉消息失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// send 调用一次机器人接口
|
||||
func send(config *DingTalkConfig, title, msg string) error {
|
||||
client := dingtalk.NewClient(config.AccessToken, config.Secret)
|
||||
message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
|
||||
|
||||
_, _, err := client.Send(message)
|
||||
return err
|
||||
}
|
||||
@@ -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,48 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"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, GatewayMessage: true},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true, Help: "支持 Markdown"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// DingTalkExecutor 钉钉机器人执行器
|
||||
type DingTalkExecutor struct{}
|
||||
|
||||
func (e *DingTalkExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *DingTalkExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
// 钉钉机器人凭据直接写在规则配置里,不需要关联账号
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendMessage(ctx, config)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析钉钉机器人凭据配置
|
||||
func ParseAccountConfig(config string) (*DingTalkConfig, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析钉钉机器人配置失败: 配置为空")
|
||||
}
|
||||
|
||||
var account DingTalkConfig
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析钉钉机器人配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// 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,104 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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: "填写后点击“测试”会真的发送一封测试邮件"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// SendMail 用给定 SMTP 账号向规则配置里的所有地址发送邮件。
|
||||
//
|
||||
// 每个地址单独重试、互不阻塞;全部失败/部分失败都会汇总成错误返回,
|
||||
// 由 runner 记录到执行日志中。
|
||||
func SendMail(ctx context.Context, account *EmailAccountConfig, config *EmailRule) error {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
fails []string
|
||||
)
|
||||
|
||||
for _, destination := range config.Destinations {
|
||||
wg.Add(1)
|
||||
go func(address string) {
|
||||
defer wg.Done()
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendMail(ctx, account, config, address)
|
||||
}); err != nil {
|
||||
mu.Lock()
|
||||
fails = append(fails, fmt.Sprintf("%s: %v", address, err))
|
||||
mu.Unlock()
|
||||
}
|
||||
}(destination)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(fails) > 0 {
|
||||
// 并发发送的顺序不确定,排序后错误信息才是稳定的
|
||||
sort.Strings(fails)
|
||||
return fmt.Errorf("%d/%d 封邮件发送失败: %s",
|
||||
len(fails), len(config.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,58 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"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 {
|
||||
config, err := ParseRuleConfig(configJSON)
|
||||
if err != nil {
|
||||
return 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, GatewayMessage: true},
|
||||
{Key: "msg", Label: "邮件正文", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
|
||||
{Key: "destinations", Label: "收件人", Type: meta.FieldStringList, Required: true, Placeholder: "someone@example.com"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// EmailExecutor 邮件执行器
|
||||
type EmailExecutor struct{}
|
||||
|
||||
func (e *EmailExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *EmailExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendMail(ctx, account, config)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/wneessen/go-mail"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析邮箱账号配置
|
||||
func ParseAccountConfig(config string) (*EmailAccountConfig, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析邮箱账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account EmailAccountConfig
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析邮箱账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析邮件规则配置
|
||||
func ParseRuleConfig(configJSON string) (*EmailRule, error) {
|
||||
var config EmailRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析邮件规则配置失败: %v", err)
|
||||
}
|
||||
return &config, 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
|
||||
}
|
||||
|
||||
// buildMessage 组装一封邮件
|
||||
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
|
||||
}
|
||||
|
||||
// sendMail 给一个地址发送一封邮件
|
||||
func sendMail(ctx context.Context, cfg *EmailAccountConfig, config *EmailRule, address string) error {
|
||||
client, err := newSMTPClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message, err := buildMessage(cfg, address, config.Title, config.Msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := client.DialAndSendWithContext(ctx, message); err != nil {
|
||||
return fmt.Errorf("发送邮件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// Send 发送一条饭碗警告
|
||||
func Send(ctx context.Context, config *FwAlertRuleConfig) error {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return send(config)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送饭碗警告失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// send 调用一次 Webhook
|
||||
func send(config *FwAlertRuleConfig) error {
|
||||
resp, err := resty.New().R().
|
||||
SetBody(FwAlertRequest{Message: config.Msg}).
|
||||
Execute(resty.MethodPost, config.WebhookURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode() != 200 {
|
||||
return fmt.Errorf("Webhook 返回状态码 %s", resp.Status())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package fwalert
|
||||
|
||||
// RuleType 饭碗警告规则类型标识
|
||||
const RuleType = "fwalert"
|
||||
|
||||
type FwAlertRuleConfig struct {
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type FwAlertRequest struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
)
|
||||
|
||||
// FwalertRuleValidator 饭碗警告规则验证器
|
||||
type FwalertRuleValidator struct{}
|
||||
|
||||
func (v *FwalertRuleValidator) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (v *FwalertRuleValidator) Validate(configJSON string) error {
|
||||
config, err := ParseRuleConfig(configJSON)
|
||||
if err != nil {
|
||||
return 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: RuleType,
|
||||
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, GatewayMessage: true, Help: "最大长度 1000 个字符"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// FwalertExecutor 饭碗警告执行器
|
||||
type FwalertExecutor struct{}
|
||||
|
||||
func (e *FwalertExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *FwalertExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
// 饭碗警告的凭据就是 Webhook 地址,直接写在规则配置里
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return Send(ctx, config)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 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,66 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 := ParseAccountConfig(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 := ParseAccountConfig(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: "用户名或组织名"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// MakeRepositoryPublic 把配置里的仓库逐个设置为 public
|
||||
//
|
||||
// 每个仓库单独重试、互不阻塞,最后汇总失败信息交给上层记录。
|
||||
func MakeRepositoryPublic(ctx context.Context, account *GithubAccount, config *GithubReposConfig) error {
|
||||
var fails []string
|
||||
for _, repo := range config.Repos {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return SetRepositoryPublic(ctx, account.Token, account.Owner, repo)
|
||||
}); err != nil {
|
||||
fails = append(fails, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(fails) > 0 {
|
||||
return fmt.Errorf("%d/%d 个仓库设置为 public 失败: %s",
|
||||
len(fails), len(config.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,55 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"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 {
|
||||
config, err := ParseReposConfig(configJSON)
|
||||
if err != nil {
|
||||
return 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,28 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// GithubMakeRepoPublicExecutor GitHub仓库公开执行器
|
||||
type GithubMakeRepoPublicExecutor struct{}
|
||||
|
||||
func (e *GithubMakeRepoPublicExecutor) GetType() string {
|
||||
return RuleTypeMakeRepoPublic
|
||||
}
|
||||
|
||||
func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := ParseReposConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return MakeRepositoryPublic(ctx, account, config)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/go-github/v84/github"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析 GitHub 账号配置
|
||||
func ParseAccountConfig(config string) (*GithubAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析GitHub账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account GithubAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseReposConfig 解析仓库规则配置
|
||||
func ParseReposConfig(configJSON string) (*GithubReposConfig, error) {
|
||||
var config GithubReposConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SetRepositoryPublic 把一个仓库设置为 public
|
||||
func SetRepositoryPublic(ctx context.Context, token, owner, repo string) error {
|
||||
client := github.NewClient(nil).WithAuthToken(token)
|
||||
if _, _, err := client.Repositories.Edit(ctx, owner, repo, &github.Repository{
|
||||
Visibility: github.Ptr("public"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("设置仓库 %s 为 public 失败: %w", repo, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"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 := ParseAccountConfig(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 := ParseAccountConfig(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},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,40 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// SendMessage 向规则中配置的群和好友发送消息
|
||||
//
|
||||
// 每个目标单独重试、互不阻塞,最后汇总失败信息交给上层记录。
|
||||
func SendMessage(ctx context.Context, account *OneBotAccount, config *OneBotConfig) error {
|
||||
var fails []string
|
||||
total := 0
|
||||
|
||||
for _, groupID := range config.SendGroups {
|
||||
total++
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendGroupMsg(account, groupID, config.Msg)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("群 %d: %v", groupID, err))
|
||||
}
|
||||
}
|
||||
|
||||
for _, userID := range config.SendUsers {
|
||||
total++
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendPrivateMsg(account, userID, config.Msg)
|
||||
}); 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,51 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"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 {
|
||||
config, err := ParseRuleConfig(configJSON)
|
||||
if err != nil {
|
||||
return 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, GatewayMessage: true},
|
||||
{Key: "send_groups", Label: "群号", Type: meta.FieldNumberList, Placeholder: "123456789"},
|
||||
{Key: "send_users", Label: "好友 QQ 号", Type: meta.FieldNumberList, Placeholder: "123456789"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// OneBotExecutor OneBot执行器
|
||||
type OneBotExecutor struct{}
|
||||
|
||||
func (e *OneBotExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *OneBotExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendMessage(ctx, account, config)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
napcat "github.com/ssdomei232/napcat-http-go-sdk"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析 OneBot 账号配置
|
||||
func ParseAccountConfig(config string) (*OneBotAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析OneBot账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account OneBotAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析 OneBot 规则配置
|
||||
func ParseRuleConfig(configJSON string) (*OneBotConfig, error) {
|
||||
var config OneBotConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newClient 创建 OneBot HTTP 客户端
|
||||
func newClient(account *OneBotAccount) *napcat.Client {
|
||||
return napcat.NewClient(account.Token, account.URL)
|
||||
}
|
||||
|
||||
// sendGroupMsg 发送一条群消息
|
||||
func sendGroupMsg(account *OneBotAccount, groupID int64, msg string) error {
|
||||
return call(account, "/send_group_msg", map[string]any{
|
||||
"group_id": groupID,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// sendPrivateMsg 发送一条私聊消息
|
||||
func sendPrivateMsg(account *OneBotAccount, userID int64, msg string) error {
|
||||
return call(account, "/send_private_msg", map[string]any{
|
||||
"user_id": userID,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// call 调用一次 OneBot HTTP API。
|
||||
//
|
||||
// SDK 只返回传输层错误,这里自己解析 OneBot 的响应信封,
|
||||
// 否则 token 错误、群号不存在这类失败会被当成发送成功。
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
rain "github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/common"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/user"
|
||||
)
|
||||
|
||||
// RainyunAccountConfigValidator Rainyun账号配置验证器
|
||||
type RainyunAccountConfigValidator struct{}
|
||||
|
||||
func (v *RainyunAccountConfigValidator) GetType() string { return AccountType }
|
||||
|
||||
func (v *RainyunAccountConfigValidator) Validate(config string) error {
|
||||
// 解析配置
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.APIKey == "" {
|
||||
return fmt.Errorf("雨云账号配置中APIKey不能为空")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *RainyunAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := rain.NewClient(cfg.APIKey)
|
||||
userClient := user.Client{Client: client}
|
||||
_, err = userClient.GetUserInfo()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("测试雨云账号配置失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *RainyunAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
return meta.AccountMeta{
|
||||
Type: AccountType,
|
||||
Label: "雨云",
|
||||
Description: "对接雨云,方便各位代理/运维删库跑路",
|
||||
Fields: []meta.Field{
|
||||
{Key: "api_key", Label: "API Key", Type: meta.FieldPassword, Required: true, Secret: true, Placeholder: "xxxxxxxxx"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package rainyun
|
||||
|
||||
const (
|
||||
// AccountType Rainyun 账号类型标识
|
||||
AccountType = "rainyun"
|
||||
// RuleTypeWorkOrder 发送 Rainyun 工单消息的规则类型标识
|
||||
RuleTypeWorkOrder = "rainyun-workorder"
|
||||
RuleTypeRunAway = "rainyun-runaway"
|
||||
)
|
||||
|
||||
// pageSize 拉取云服务器列表时每页的条数
|
||||
const pageSize = 20
|
||||
|
||||
// runAwayConfirmText 跑路规则要求用户输入的免责声明
|
||||
const runAwayConfirmText = "我已知晓"
|
||||
|
||||
type RainyunWorkOrderRule struct {
|
||||
Title string `json:"title"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type RainyunAccount struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
type RainyunRunAwayRule struct {
|
||||
IK string `json:"ik"`
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
rain "github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/common"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rcs"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rgs"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/workorder"
|
||||
)
|
||||
|
||||
// SendWorkOrder 用给定账号发送一条雨云工单
|
||||
func SendWorkOrder(ctx context.Context, account *RainyunAccount, config *RainyunWorkOrderRule) error {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return createWorkOrder(account, config)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送雨云工单失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createWorkOrder 调用一次工单接口
|
||||
func createWorkOrder(account *RainyunAccount, config *RainyunWorkOrderRule) error {
|
||||
workorderClient := workorder.Client{Client: rain.NewClient(account.APIKey)}
|
||||
|
||||
_, err := workorderClient.CreateWorkOrder(&workorder.CreateWorkerorderRequest{
|
||||
Content: config.Msg,
|
||||
IsAuthed: false,
|
||||
IsUrgent: 1,
|
||||
Title: config.Title,
|
||||
Type: "reward",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RunAway 重装账号下所有云服务器(一键跑路)
|
||||
func RunAway(ctx context.Context, account *RainyunAccount) error {
|
||||
client := rain.NewClient(account.APIKey)
|
||||
|
||||
rcsFails, rcsTotal, err := reinstallAllRcs(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rgsFails, rgsTotal, err := reinstallAllRgs(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fails := append(rcsFails, rgsFails...)
|
||||
total := rcsTotal + rgsTotal
|
||||
if len(fails) > 0 {
|
||||
return fmt.Errorf("%d/%d 台云服务器重装失败: %s", len(fails), total, strings.Join(fails, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reinstallAllRcs 重装全部 RCS,返回失败信息与云服务器总数
|
||||
func reinstallAllRcs(ctx context.Context, client *rain.Client) ([]string, int, error) {
|
||||
rcsClient := rcs.Client{Client: client}
|
||||
|
||||
ids, err := listRcsIDs(&rcsClient)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var fails []string
|
||||
for _, id := range ids {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return reinstallOneRCS(id, &rcsClient)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RCS %d: %v", id, err))
|
||||
}
|
||||
}
|
||||
return fails, len(ids), nil
|
||||
}
|
||||
|
||||
// reinstallAllRgs 重装全部 RGS,返回失败信息与云服务器总数
|
||||
func reinstallAllRgs(ctx context.Context, client *rain.Client) ([]string, int, error) {
|
||||
rgsClient := rgs.Client{Client: client}
|
||||
|
||||
ids, err := listRgsIDs(&rgsClient)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var fails []string
|
||||
for _, id := range ids {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return reinstallOneRGS(id, &rgsClient)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RGS %d: %v", id, err))
|
||||
}
|
||||
}
|
||||
return fails, len(ids), nil
|
||||
}
|
||||
|
||||
// listRcsIDs 取账号下全部 RCS 的 ID,超过一页时重新按总数拉取
|
||||
func listRcsIDs(client *rcs.Client) ([]int, error) {
|
||||
first, err := client.GetRcsList(rain.EncodingStandardQueryParameters(1, pageSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(first.Data.Records))
|
||||
for _, server := range first.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
if first.Data.TotalRecords <= pageSize {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
all, err := client.GetRcsList(rain.EncodingStandardQueryParameters(1, first.Data.TotalRecords))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids = ids[:0]
|
||||
for _, server := range all.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// listRgsIDs 取账号下全部 RGS 的 ID,超过一页时重新按总数拉取
|
||||
func listRgsIDs(client *rgs.Client) ([]int, error) {
|
||||
first, err := client.GetRgsList(rain.EncodingStandardQueryParameters(1, pageSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(first.Data.Records))
|
||||
for _, server := range first.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
if first.Data.TotalRecords <= pageSize {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
all, err := client.GetRgsList(rain.EncodingStandardQueryParameters(1, first.Data.TotalRecords))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids = ids[:0]
|
||||
for _, server := range all.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
)
|
||||
|
||||
// Rainyun规则验证器
|
||||
type RainyunWorkorderRuleValidator struct{}
|
||||
|
||||
func (v *RainyunWorkorderRuleValidator) GetType() string {
|
||||
return RuleTypeWorkOrder
|
||||
}
|
||||
|
||||
func (v *RainyunWorkorderRuleValidator) Validate(configJSON string) error {
|
||||
config, err := ParseWorkOrderConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Title == "" {
|
||||
return fmt.Errorf("雨云规则配置中 title 不能为空")
|
||||
}
|
||||
|
||||
if config.Msg == "" {
|
||||
return fmt.Errorf("雨云规则配置中 msg 不能为空")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *RainyunWorkorderRuleValidator) Meta() meta.RuleMeta {
|
||||
return meta.RuleMeta{
|
||||
Type: RuleTypeWorkOrder,
|
||||
Label: "发送雨云工单",
|
||||
Description: "触发时发送雨云工单",
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{Key: "title", Label: "工单标题", Type: meta.FieldTextarea, Required: true},
|
||||
{Key: "msg", Label: "工单内容", Type: meta.FieldTextarea, Required: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RainyunRunAwayRuleValidator struct{}
|
||||
|
||||
func (v *RainyunRunAwayRuleValidator) GetType() string {
|
||||
return RuleTypeRunAway
|
||||
}
|
||||
|
||||
func (v *RainyunRunAwayRuleValidator) Validate(configJSON string) error {
|
||||
config, err := ParseRunAwayConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.IK != runAwayConfirmText {
|
||||
return fmt.Errorf("请输入正确内容")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *RainyunRunAwayRuleValidator) Meta() meta.RuleMeta {
|
||||
return meta.RuleMeta{
|
||||
Type: RuleTypeRunAway,
|
||||
Label: "重置雨云账号中所有云服务器(一键跑路)",
|
||||
Description: "触发时会重装雨云账号下所有云服务器来实现跑路",
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{Key: "ik", Label: "免责声明", Type: meta.FieldTextarea, Required: true, Placeholder: "该操作引发的损失及法律责任归属账号所有人,本软件一律不承担,请输入\"我已知晓\""},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// RainyunWorkorderExecutor 雨云工单执行器
|
||||
type RainyunWorkorderExecutor struct{}
|
||||
|
||||
func (e *RainyunWorkorderExecutor) GetType() string {
|
||||
return RuleTypeWorkOrder
|
||||
}
|
||||
|
||||
func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := ParseWorkOrderConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendWorkOrder(ctx, account, config)
|
||||
}
|
||||
|
||||
// RainyunRunAwayExecutor 雨云跑路执行器
|
||||
type RainyunRunAwayExecutor struct{}
|
||||
|
||||
func (e *RainyunRunAwayExecutor) GetType() string {
|
||||
return RuleTypeRunAway
|
||||
}
|
||||
|
||||
func (e *RainyunRunAwayExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 跑路规则只要求用户填一句免责声明,执行时重新校验一次
|
||||
config, err := ParseRunAwayConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.IK != runAwayConfirmText {
|
||||
return fmt.Errorf("请输入正确内容")
|
||||
}
|
||||
|
||||
return RunAway(ctx, account)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/public"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rcs"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rgs"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析雨云账号配置
|
||||
func ParseAccountConfig(config string) (*RainyunAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析雨云账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account RainyunAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseWorkOrderConfig 解析雨云工单规则配置
|
||||
func ParseWorkOrderConfig(configJSON string) (*RainyunWorkOrderRule, error) {
|
||||
var config RainyunWorkOrderRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云工单规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ParseRunAwayConfig 解析雨云跑路规则配置
|
||||
func ParseRunAwayConfig(configJSON string) (*RainyunRunAwayRule, error) {
|
||||
var config RainyunRunAwayRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云跑路规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// reinstallOneRCS 重装一台 RCS
|
||||
func reinstallOneRCS(rcsID int, rcsClient *rcs.Client) error {
|
||||
rcsDetail, err := rcsClient.GetRcsDetails(rcsID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rcsOSList, err := public.GetRcsOSList()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, os := range rcsOSList.Data {
|
||||
if os.Region == rcsDetail.Data.Data.Node.Region && os.OsType == "linux" {
|
||||
_, err := rcsClient.ReinstallRcs(rcsID, &rcs.ReinstallRcsRequest{
|
||||
OsID: os.ID,
|
||||
ResetOsd: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("没有找到可用的 linux 镜像")
|
||||
}
|
||||
|
||||
// reinstallOneRGS 重装一台 RGS
|
||||
func reinstallOneRGS(rgsID int, rgsClient *rgs.Client) error {
|
||||
rgsDetail, err := rgsClient.GetRgsDetails(rgsID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rgsOSList, err := public.GetRgsOSList()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, os := range rgsOSList.Data {
|
||||
if os.Region == rgsDetail.Data.Data.Node.Region && os.OsType == "linux" {
|
||||
_, err := rgsClient.Reinstallgs(rgsID, &rcs.ReinstallRcsRequest{
|
||||
OsID: os.ID,
|
||||
ResetOsd: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("没有找到可用的 linux 镜像")
|
||||
}
|
||||
@@ -1,52 +1,92 @@
|
||||
module github.com/ssdomei232/goodBaby
|
||||
|
||||
go 1.24.10
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/CatchZeng/dingtalk v1.5.0
|
||||
github.com/CuteReimu/bilibili/v2 v2.3.5
|
||||
github.com/alibabacloud-go/alidns-20150109/v5 v5.6.0
|
||||
github.com/alibabacloud-go/darabonba-openapi v0.2.1
|
||||
github.com/alibabacloud-go/tea v1.5.2
|
||||
github.com/aliyun/credentials-go v1.4.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/glebarez/sqlite v1.11.0
|
||||
github.com/go-resty/resty/v2 v2.17.2
|
||||
github.com/google/go-github/v84 v84.0.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/ssdomei232/napcat-http-go-sdk v1.0.0
|
||||
github.com/ssdomei232/rainyun-go-sdk/v2 v2.0.6
|
||||
github.com/wneessen/go-mail v0.7.2
|
||||
golang.org/x/crypto v0.48.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Baozisoftware/qrcode-terminal-go v0.0.0-20170407111555-c0650d8dff0f // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 // indirect
|
||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0 // indirect
|
||||
github.com/alibabacloud-go/tea-utils v1.4.3 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
|
||||
github.com/alibabacloud-go/tea-xml v1.1.2 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
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/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/gorilla/websocket v1.5.3 // 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/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/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/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/ini.v1 v1.67.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
|
||||
)
|
||||
|
||||
@@ -1,28 +1,103 @@
|
||||
bou.ke/monkey v1.0.2 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI=
|
||||
bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/Baozisoftware/qrcode-terminal-go v0.0.0-20170407111555-c0650d8dff0f h1:2dk3eOnYllh+wUOuDhOoC2vUVoJF/5z478ryJ+wzEII=
|
||||
github.com/Baozisoftware/qrcode-terminal-go v0.0.0-20170407111555-c0650d8dff0f/go.mod h1:4a58ifQTEe2uwwsaqbh3i2un5/CBPg+At/qHpt18Tmk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/CatchZeng/dingtalk v1.5.0 h1:3YNpGIdeZ4pTfjjVyC2G1CCzWocMmYniBY8CXiOY9Ss=
|
||||
github.com/CatchZeng/dingtalk v1.5.0/go.mod h1:/CT0Eskr26XfjAybLBLV+DjvPHaznwiFXRBndQp7zZ8=
|
||||
github.com/CuteReimu/bilibili/v2 v2.3.5 h1:0MpBOJL6Q7kP3lFItC7NQ3MLG/I1uIzZBjemXIE0CRM=
|
||||
github.com/CuteReimu/bilibili/v2 v2.3.5/go.mod h1:FHKH1cc04M/qpQhF+DaFIBFleLtvgMwYfZCNhHK/kBQ=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
|
||||
github.com/alibabacloud-go/alidns-20150109/v5 v5.6.0 h1:dfHe5OpLz/vGINeU8ZOB1Y0dANk4+Td023zMaf+8rew=
|
||||
github.com/alibabacloud-go/alidns-20150109/v5 v5.6.0/go.mod h1:1tXuCJoXdeAY9uBxgJ6ZLbv+SOe/4SJhUMSNCz9pOng=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
|
||||
github.com/alibabacloud-go/darabonba-openapi v0.2.1 h1:WyzxxKvhdVDlwpAMOHgAiCJ+NXa6g5ZWPFEzaK/ewwY=
|
||||
github.com/alibabacloud-go/darabonba-openapi v0.2.1/go.mod h1:zXOqLbpIqq543oioL9IuuZYOQgHQ5B8/n5OPrnko8aY=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.0/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
|
||||
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
|
||||
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
|
||||
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
|
||||
github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
|
||||
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
|
||||
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
|
||||
github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU=
|
||||
github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
|
||||
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
|
||||
github.com/alibabacloud-go/tea-utils v1.4.3 h1:8SzwmmRrOnQ09Hf5a9GyfJc0d7Sjv6fmsZoF4UDbFjo=
|
||||
github.com/alibabacloud-go/tea-utils v1.4.3/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.2 h1:oLxa7JUXm2EDFzMg+7oRsYc+kutgCVwm+bZlhhmvW5M=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
|
||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
|
||||
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
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/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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
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/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -33,21 +108,73 @@ 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-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
||||
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.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
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/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
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/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/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.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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
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.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
@@ -56,69 +183,237 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/ssdomei232/napcat-http-go-sdk v1.0.0 h1:BY3iqqlZw5letiCwTmfMrhyqsx0a96eL5LpDuinnOSE=
|
||||
github.com/ssdomei232/napcat-http-go-sdk v1.0.0/go.mod h1:yPsSaMs3OyKMVPXwIs5ZB5G532vRi1GvL/WiWUEwblU=
|
||||
github.com/ssdomei232/rainyun-go-sdk/v2 v2.0.6 h1:tjratVKpPk3lc2bw+oVnIzVZG79mFlvUBez+FbX86pQ=
|
||||
github.com/ssdomei232/rainyun-go-sdk/v2 v2.0.6/go.mod h1:Wf9X0qEpKQ1/uD4iUGGoPLHydiCxULqCVk+JCoVqdIg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
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.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
|
||||
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
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/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.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.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
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/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
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 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/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=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
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,28 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// GetAccount 读取一个账号,返回数据库里的原始记录。
|
||||
//
|
||||
// 规则执行需要的账号凭据由上层(handler/runner)在这里取好再传给 driver,
|
||||
// driver 自己不碰数据库。accountID 为 0 表示该规则不需要账号,返回 (nil, nil)。
|
||||
func GetAccount(accountID uint) (*model.Account, error) {
|
||||
if accountID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
gormDB, err := GetGormDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.First(&account, accountID).Error; err != nil {
|
||||
return nil, fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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)
|
||||
}
|
||||
if err := migrateGatewayRules(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.GatewayRule{},
|
||||
&model.Account{},
|
||||
&model.ExecutionLog{},
|
||||
&model.MessageGateway{},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// legacyRule 是迁移前的 rules 表结构,只用来读出已经废弃的 gateway_id
|
||||
type legacyRule struct {
|
||||
ID uint
|
||||
UID uint
|
||||
GatewayID uint
|
||||
Name string
|
||||
AccountID uint
|
||||
Type string
|
||||
ConfigJson string
|
||||
Enabled bool
|
||||
CreateAt int64
|
||||
}
|
||||
|
||||
// migrateGatewayRules 把历史数据里混在 rules 表中的消息网关规则搬到 gateway_rules 表。
|
||||
//
|
||||
// v2.1 之前消息网关规则和定时器规则共用 rules 表,靠 gateway_id 区分;
|
||||
// 现在两者分表存储,这里做一次性搬迁,避免老用户升级后规则凭空消失。
|
||||
func migrateGatewayRules(gormDB *gorm.DB) error {
|
||||
if !gormDB.Migrator().HasColumn("rules", "gateway_id") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacy []legacyRule
|
||||
if err := gormDB.Table("rules").Where("gateway_id <> 0").Find(&legacy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(legacy) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gormDB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, rule := range legacy {
|
||||
moved := model.GatewayRule{
|
||||
UID: rule.UID,
|
||||
GatewayID: rule.GatewayID,
|
||||
Name: rule.Name,
|
||||
AccountID: rule.AccountID,
|
||||
Type: rule.Type,
|
||||
ConfigJson: rule.ConfigJson,
|
||||
Enabled: rule.Enabled,
|
||||
CreateAt: rule.CreateAt,
|
||||
}
|
||||
if err := tx.Create(&moved).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM rules WHERE id = ?", rule.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Printf("已迁移 %d 条消息网关规则到 gateway_rules 表", len(legacy))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -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,71 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// RuleExecutor 规则执行器接口。
|
||||
//
|
||||
// 执行一条规则需要的一切(规则本体、账号凭据)都由上层组装成 model.RuleTask,
|
||||
// driver 只负责把动作做出去:不查数据库、不写日志,因此每个实现都能单独测试。
|
||||
type RuleExecutor interface {
|
||||
// Execute 执行规则,ctx 决定了重试的最长时间
|
||||
Execute(ctx context.Context, task *model.RuleTask) 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, task *model.RuleTask) error {
|
||||
executor, exists := er.executors[task.Rule.Type]
|
||||
if !exists {
|
||||
return fmt.Errorf("不支持的规则类型: %s", task.Rule.Type)
|
||||
}
|
||||
|
||||
return executor.Execute(ctx, task)
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// GetGlobalExecutorRegistry 获取全局执行器注册表
|
||||
func GetGlobalExecutorRegistry() *ExecutorRegistry {
|
||||
if globalExecutorRegistry == nil {
|
||||
panic("执行器注册表未初始化,请先调用 InitExecutorRegistry()")
|
||||
}
|
||||
return globalExecutorRegistry
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"github.com/ssdomei232/goodBaby/drivers/alidns"
|
||||
"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/drivers/rainyun"
|
||||
)
|
||||
|
||||
// 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{})
|
||||
registry.Register(&rainyun.RainyunWorkorderExecutor{})
|
||||
registry.Register(&rainyun.RainyunRunAwayExecutor{})
|
||||
registry.Register(&alidns.DeleteAliDNSRecordExecutor{})
|
||||
// 未来添加新规则类型时,在这里注册即可
|
||||
|
||||
globalExecutorRegistry = registry
|
||||
})
|
||||
return globalExecutorRegistry
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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()
|
||||
}
|
||||
|
||||
// NewTask 组装执行一条规则所需的输入:规则本体 + 从数据库取出的关联账号。
|
||||
//
|
||||
// 数据库访问只发生在这一层,driver 拿到的 task 已经是自包含的。
|
||||
func NewTask(rule *model.Rule) (*model.RuleTask, error) {
|
||||
account, err := db.GetAccount(rule.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.RuleTask{Rule: rule, Account: account}, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
task, err := NewTask(rule)
|
||||
return execTask(ctx, task, err, &model.ExecutionLog{
|
||||
UID: rule.UID,
|
||||
RuleID: rule.ID,
|
||||
RuleName: rule.Name,
|
||||
RuleType: rule.Type,
|
||||
TimerID: rule.TimerID,
|
||||
Trigger: trigger,
|
||||
})
|
||||
}
|
||||
|
||||
// ExecuteGatewayRuleWithContext 执行一条消息网关规则,日志里带上网关信息。
|
||||
//
|
||||
// 与定时器规则走同一条执行链路,区别只有触发来源与日志字段。
|
||||
func ExecuteGatewayRuleWithContext(ctx context.Context, rule *model.GatewayRule, trigger string) error {
|
||||
task, err := NewTask(rule.AsRule())
|
||||
return execTask(ctx, task, err, &model.ExecutionLog{
|
||||
UID: rule.UID,
|
||||
RuleName: rule.Name,
|
||||
RuleType: rule.Type,
|
||||
GatewayID: rule.GatewayID,
|
||||
GatewayRuleID: rule.ID,
|
||||
Trigger: trigger,
|
||||
})
|
||||
}
|
||||
|
||||
// execTask 执行规则并写入执行日志。
|
||||
//
|
||||
// taskErr 是组装输入时的错误(例如账号已被删除):这时不会走到 driver,
|
||||
// 但同样要留下一条失败日志,方便用户在 WebUI 上看到原因。
|
||||
func execTask(ctx context.Context, task *model.RuleTask, taskErr error, entry *model.ExecutionLog) error {
|
||||
if taskErr != nil {
|
||||
return record(entry, taskErr)
|
||||
}
|
||||
|
||||
log.Printf("执行规则 [%s] %s (触发来源: %s)", entry.RuleType, entry.RuleName, entry.Trigger)
|
||||
return record(entry, GetGlobalExecutorRegistry().Execute(ctx, task))
|
||||
}
|
||||
|
||||
// record 写入执行日志并返回原始错误
|
||||
func record(entry *model.ExecutionLog, err error) error {
|
||||
entry.Success = err == nil
|
||||
entry.Message = "执行成功"
|
||||
if err != nil {
|
||||
entry.Message = err.Error()
|
||||
log.Printf("执行规则失败 [%s] %s: %v", entry.RuleType, entry.RuleName, err)
|
||||
}
|
||||
|
||||
logstore.Record(entry)
|
||||
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,35 @@
|
||||
package accountConfigChecker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/drivers/alidns"
|
||||
"github.com/ssdomei232/goodBaby/drivers/bilibili"
|
||||
"github.com/ssdomei232/goodBaby/drivers/email"
|
||||
"github.com/ssdomei232/goodBaby/drivers/github"
|
||||
"github.com/ssdomei232/goodBaby/drivers/onebot"
|
||||
"github.com/ssdomei232/goodBaby/drivers/rainyun"
|
||||
)
|
||||
|
||||
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{})
|
||||
r.Register(&rainyun.RainyunAccountConfigValidator{})
|
||||
r.Register(&alidns.AliDNSAccountConfigValidator{})
|
||||
|
||||
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,101 @@
|
||||
// Package gateway 定义消息网关的扩展接口与注册表。
|
||||
//
|
||||
// 与 drivers 的分工:driver 负责把一条规则执行出去(发邮件、发消息……),
|
||||
// gateway 负责把外部系统投递进来的消息派发给绑定在网关上的规则。
|
||||
// 新增一种网关只要实现 Gateway 并在 reg.go 里注册。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// Message 是外部系统投递进来的一条消息
|
||||
type Message struct {
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Result 是一次投递的汇总结果
|
||||
type Result struct {
|
||||
Total int `json:"total"`
|
||||
Failed []string `json:"failed"`
|
||||
}
|
||||
|
||||
// Task 是一次投递所需的全部输入:网关与待触发的规则都由上层从数据库取出
|
||||
type Task struct {
|
||||
Gateway *model.MessageGateway
|
||||
Rules []model.GatewayRule
|
||||
Message Message
|
||||
}
|
||||
|
||||
// Gateway 一种消息网关
|
||||
type Gateway interface {
|
||||
// GetType 获取网关类型标识
|
||||
GetType() string
|
||||
// Meta 返回给 WebUI 展示用的元数据
|
||||
Meta() meta.GatewayMeta
|
||||
// Deliver 把投递进来的消息派发给 task 里的规则
|
||||
Deliver(ctx context.Context, task *Task) (*Result, error)
|
||||
}
|
||||
|
||||
// Registry 网关注册表
|
||||
type Registry struct {
|
||||
gateways map[string]Gateway
|
||||
}
|
||||
|
||||
// NewRegistry 创建新的网关注册表
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
gateways: make(map[string]Gateway),
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册消息网关
|
||||
func (r *Registry) Register(g Gateway) {
|
||||
r.gateways[g.GetType()] = g
|
||||
}
|
||||
|
||||
// Resolve 按类型取网关实现,类型为空时回退到默认类型
|
||||
func (r *Registry) Resolve(gatewayType string) (Gateway, bool) {
|
||||
if gatewayType == "" {
|
||||
gatewayType = model.GatewayTypeWebhook
|
||||
}
|
||||
|
||||
g, exists := r.gateways[gatewayType]
|
||||
return g, exists
|
||||
}
|
||||
|
||||
// Deliver 按网关类型派发一次投递
|
||||
func (r *Registry) Deliver(ctx context.Context, gatewayType string, task *Task) (*Result, error) {
|
||||
g, exists := r.Resolve(gatewayType)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("不支持的消息网关类型: %s", gatewayType)
|
||||
}
|
||||
|
||||
return g.Deliver(ctx, task)
|
||||
}
|
||||
|
||||
// GetSupportedTypes 获取所有支持的网关类型
|
||||
func (r *Registry) GetSupportedTypes() []string {
|
||||
types := make([]string, 0, len(r.gateways))
|
||||
for t := range r.gateways {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// Metas 返回所有网关类型的元数据,按类型名排序
|
||||
func (r *Registry) Metas() []meta.GatewayMeta {
|
||||
metas := make([]meta.GatewayMeta, 0, len(r.gateways))
|
||||
for _, g := range r.gateways {
|
||||
metas = append(metas, g.Meta())
|
||||
}
|
||||
sort.Slice(metas, func(i, j int) bool { return metas[i].Type < metas[j].Type })
|
||||
return metas
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package gateway
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
registry *Registry
|
||||
)
|
||||
|
||||
// InitGatewayRegistry 返回全局唯一的消息网关注册表
|
||||
func InitGatewayRegistry() *Registry {
|
||||
once.Do(func() {
|
||||
r := NewRegistry()
|
||||
|
||||
// 注册所有消息网关
|
||||
r.Register(&WebhookGateway{})
|
||||
// 未来添加新网关类型时,在这里注册即可
|
||||
|
||||
registry = r
|
||||
})
|
||||
return registry
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user