Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
goodBaby
|
||||||
config.json
|
config.json
|
||||||
cookies.txt
|
data.db
|
||||||
tmp/
|
data.db-shm
|
||||||
bye.json
|
data.db-wal
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# ---> Frontend
|
||||||
|
node_modules/
|
||||||
|
# 前端构建产物不入库,仅保留 .gitkeep 以满足 go:embed
|
||||||
|
web/dist/*
|
||||||
|
!web/dist/.gitkeep
|
||||||
|
|||||||
+36
-6
@@ -1,7 +1,24 @@
|
|||||||
# ======================
|
# ======================
|
||||||
# 构建阶段 (Builder Stage)
|
# 前端构建阶段 (Frontend Stage)
|
||||||
# ======================
|
# ======================
|
||||||
FROM golang:1.24-alpine AS builder
|
# 固定在构建机架构上运行:前端产物与目标架构无关,
|
||||||
|
# 否则交叉构建 arm64 镜像时 npm 要跑在 QEMU 里,慢得离谱
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
|
||||||
|
|
||||||
|
WORKDIR /app/web/frontend
|
||||||
|
|
||||||
|
COPY web/frontend/package.json web/frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY web/frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# 后端构建阶段 (Builder Stage)
|
||||||
|
# ======================
|
||||||
|
# 同样固定在构建机架构上,用 Go 的交叉编译产出目标架构的二进制。
|
||||||
|
# 依赖里没有 CGO(sqlite 用的是纯 Go 实现),所以交叉编译是安全的。
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,26 +27,39 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# 用前端构建产物覆盖占位的 dist
|
||||||
|
COPY --from=frontend /app/web/dist /app/web/dist
|
||||||
|
|
||||||
ARG VERSION="1.0.0"
|
ARG VERSION="dev"
|
||||||
ARG BUILD_DATE="unknown"
|
ARG BUILD_DATE="unknown"
|
||||||
ARG GIT_COMMIT="unknown"
|
ARG GIT_COMMIT="unknown"
|
||||||
|
# TARGETARCH 由 buildx 自动注入(amd64 / arm64 / ...)
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG TARGETOS=linux
|
||||||
RUN CGO_ENABLED=0 \
|
RUN CGO_ENABLED=0 \
|
||||||
GOOS=linux \
|
GOOS=${TARGETOS} \
|
||||||
GOARCH=amd64 \
|
GOARCH=${TARGETARCH} \
|
||||||
go build \
|
go build \
|
||||||
|
-trimpath \
|
||||||
-ldflags="-w -s -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE} -X main.gitCommit=${GIT_COMMIT}" \
|
-ldflags="-w -s -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE} -X main.gitCommit=${GIT_COMMIT}" \
|
||||||
-o /app/main .
|
-o /app/main .
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# 运行阶段 (Runtime Stage)
|
# 运行阶段 (Runtime Stage)
|
||||||
# ======================
|
# ======================
|
||||||
FROM alpine:3.18
|
FROM alpine:3.20
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata ca-certificates
|
||||||
|
|
||||||
COPY --from=builder /app/main /app/main
|
COPY --from=builder /app/main /app/main
|
||||||
|
|
||||||
|
# 数据(config.json / data.db)统一放在 /app/data,挂载这个目录即可
|
||||||
|
ENV GOODBABY_CONFIG=/app/data/config.json \
|
||||||
|
GOODBABY_DB_PATH=/app/data/data.db
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
EXPOSE 8088
|
EXPOSE 8088
|
||||||
|
|
||||||
CMD ["/app/main"]
|
CMD ["/app/main"]
|
||||||
@@ -1,87 +1,131 @@
|
|||||||
# goodBaby
|
# goodBaby v2
|
||||||
|
|
||||||
摇篮系统
|
  
|
||||||
|
|
||||||
|
根据《中国心血管健康与疾病报告2023》,中国 每年 猝死总人数(含心源性和非心源性)或达80万-100万,18-35岁人群占比为38%,也就是说,每天18-35岁人群有近1000人猝死
|
||||||
|
|
||||||
|
伴随着中国人口老龄化加剧,年轻人的牵挂越来越多,面对着可能的猝死,我们或许真的需要做好准备
|
||||||
|
|
||||||
|
所以我写了goodBaby来解决这个问题,他和apple app store里的“死了么”形式上有些接近,不过goodBaby支持了更多好玩的通知方式,比如bilibili动态,github仓库,邮件,OneBot,还可以方便的自托管
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/7125/cat_)
|
[](https://app.rainyun.com/apps/rca/store/7125/cat_)
|
||||||
|
|
||||||
## 他会做什么
|
## 功能
|
||||||
|
|
||||||
1. 接受 singnal 请求
|
* **WebUI**:内置 Vue3 前端,注册 / 登录、定时器、规则、账号、执行日志、设置全部可视化操作
|
||||||
2. 当定时器超时(过长时间没有收到请求)时,触发 `trigger` 函数
|
* **定时器 (Timer)**:设定签到周期与提前提醒时间,到期前通过钉钉机器人提醒你签到
|
||||||
3. 发送消息到QQ(依赖[CatBot](https://github.com/ssdomei232/CatBot))
|
* **规则 (Rule)**:定时器到期后要执行的动作,支持:
|
||||||
4. 发送邮件
|
* 发送邮件 (SMTP)
|
||||||
5. 将 Github 上的遗言仓库设置为 public
|
* 发布 B 站动态
|
||||||
6. 发送一条 Bilibili 动态
|
* 发送 QQ 消息 (OneBot / NapCat)
|
||||||
7. 当倒计时小于48小时时,向用钉钉机器人发消息提醒你
|
* 发送钉钉机器人消息
|
||||||
|
* 公开 GitHub 仓库
|
||||||
|
* **账号 (Account)**:集中管理第三方凭据,支持连通性测试,敏感字段(密码/Cookie/Token)不会回显
|
||||||
|
* **执行日志**:每次规则执行与提醒都有记录,规则支持手动测试
|
||||||
|
* **配置测试**:账号可一键测试连通性;规则可手动触发验证
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### Docker Compose (推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
首次启动会在 `./data` 下自动生成 `config.json` 与 `data.db`。
|
||||||
|
打开 `http://localhost:8088`,按引导创建第一个账号即可。
|
||||||
|
|
||||||
|
### 直接拉取镜像
|
||||||
|
|
||||||
|
每次发版会自动构建 `linux/amd64` 与 `linux/arm64` 镜像推送到 GitHub Container Registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name goodbaby -p 8088:8088 -v ./data:/app/data ghcr.io/ssdomei232/goodbaby:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 下载预编译二进制
|
||||||
|
|
||||||
|
[Releases](https://github.com/ssdomei232/goodBaby/releases) 提供 Linux / Windows / macOS 的 amd64 与 arm64 产物,
|
||||||
|
解压后直接运行即可。压缩包旁的 `checksums.txt` 可校验完整性:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c checksums.txt --ignore-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
### 从源码构建
|
||||||
|
|
||||||
|
需要 Go 1.25+ 与 Node.js 20+:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 构建前端
|
||||||
|
cd web/frontend && npm install && npm run build && cd ../..
|
||||||
|
|
||||||
|
# 2. 构建后端
|
||||||
|
go build -o goodbaby .
|
||||||
|
|
||||||
|
# 3. 运行
|
||||||
|
./goodbaby
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 终端 1: 启动后端 (监听 :8088)
|
||||||
|
go run .
|
||||||
|
|
||||||
|
# 终端 2: 启动前端 dev server (监听 :5173,API 代理到 :8088)
|
||||||
|
cd web/frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
> [!TIP]
|
配置文件默认为工作目录下的 `config.json`,首次启动自动生成,字段均有默认值:
|
||||||
> 更详细的配置注解请前往 [Wiki 页面](https://github.com/ssdomei232/goodBaby/wiki)
|
|
||||||
|
|
||||||
参照 config.example.json 修改 config.json
|
| 字段 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `listen_addr` | `:8088` | HTTP 监听地址 |
|
||||||
|
| `enable_registry` | `true` | 是否开放注册(系统无用户时始终允许注册第一个账号) |
|
||||||
|
| `timeout_duration_hours` | `6` | 规则执行失败后指数退避重试的最长时间(小时) |
|
||||||
|
| `check_interval_minutes` | `10` | 检查定时器的间隔(分钟) |
|
||||||
|
| `database_driver` | `sqlite` | 数据库驱动,`sqlite` 或 `postgres` |
|
||||||
|
| `database_path` | `data.db` | sqlite 数据库路径 |
|
||||||
|
| `database_dsn` | 空 | postgres 连接串,见 [docs/database.md](docs/database.md) |
|
||||||
|
| `session_secret` | 自动生成 | 会话加密密钥,自动生成并持久化 |
|
||||||
|
| `session_max_age_hours` | `168` | 会话有效期(小时) |
|
||||||
|
| `allowed_origins` | `[]` | 允许跨域的来源,前端本地开发时可填 `["http://localhost:5173"]` |
|
||||||
|
| `log_retain_count` | `500` | 每个用户保留的执行日志条数 |
|
||||||
|
|
||||||
```json
|
环境变量覆盖:`GOODBABY_CONFIG`(配置文件路径)、`GOODBABY_LISTEN_ADDR`、`GOODBABY_DB_DRIVER`、`GOODBABY_DB_PATH`、`GOODBABY_DB_DSN`、`GOODBABY_SESSION_SECRET`、`GOODBABY_ENABLE_REGISTRY`。
|
||||||
{
|
|
||||||
"signal_secret": "xxx", // 发送 signal请求时需要的密钥
|
|
||||||
"debug": false, // debug 模式
|
|
||||||
"disconnect_duration": 80, // 失连时间,超出后会触发系统(小时)
|
|
||||||
"bili_msg": "摇篮系统测试", // 哔哩哔哩动态的附加消息
|
|
||||||
"mail_config": {
|
|
||||||
"host": "smtp.example.com",
|
|
||||||
"port": 465,
|
|
||||||
"user": "xxx",
|
|
||||||
"pass": "xxx",
|
|
||||||
"mail_list": ["xxx@example.com", "xxx1@example.com"],// 要发送的邮箱
|
|
||||||
"mail_title": "摇篮系统已触发", // 邮件标题
|
|
||||||
"mail_content": "这是摇篮系统的邮件内容。" // 要附加的邮件内容
|
|
||||||
}, // 邮件配置
|
|
||||||
"basic": {
|
|
||||||
"name": "xx", // 你的名字
|
|
||||||
"qq_number": "123", // 你的QQ号
|
|
||||||
"nickname": "xx_123", // 网名
|
|
||||||
"age": 16, // 你的年龄
|
|
||||||
"cause_stop": "自杀" // 你最可能的死因
|
|
||||||
},
|
|
||||||
"github_config": {
|
|
||||||
"owner": "example", // github用户名
|
|
||||||
"repos": ["example-repo1", "example-repo2"], // 要公开的 github 仓库
|
|
||||||
"token": "ghp_xxxx" // github token
|
|
||||||
},
|
|
||||||
"dingtalk_bot": {
|
|
||||||
"access_token": "xxx",
|
|
||||||
"secret": "xxx"
|
|
||||||
}, // 钉钉机器人(用于提醒发送signal和bilibili过期提醒)
|
|
||||||
"onebot_config": {
|
|
||||||
"url": "http://localhost:5700",
|
|
||||||
"token": "xxx",
|
|
||||||
"send_groups": [12345678, 87654321], // 要发送到的Onebot群组
|
|
||||||
"msg": "这是OneBot的消息内容。" // Onebot消息
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 运行
|
### 管理员后台
|
||||||
|
|
||||||
```bash
|
**第一个注册的用户自动成为管理员**。
|
||||||
mkdir goodbaby
|
|
||||||
cd goodbaby
|
|
||||||
wget https://raw.githubusercontent.com/ssdomei232/goodBaby/refs/heads/main/docker-compose.yml
|
|
||||||
docker compose up -d
|
|
||||||
docker logs goodbaby ## 扫码登陆后才能使用
|
|
||||||
```
|
|
||||||
|
|
||||||
在日志中找到 Bilibili 的登陆二维码或在文件管理中找到`tmp/qrcode.png`,打开Bilibili APP 扫码登陆
|
## 数据库
|
||||||
|
|
||||||
> [!NOTE]
|
默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。
|
||||||
> 只有在登陆 Bilibili 后程序才会正常运行
|
|
||||||
|
|
||||||
## 使用
|
## 发版
|
||||||
|
|
||||||
访问你开放的端口(默认为8088),进入WebUI,输入你的密钥,点击保存(密钥会存储到浏览器的localStorage中),此时你可以在WebUI查看倒计时和发送Signal
|
推送 `v` 开头的 tag 即可自动构建产物、创建 Release 并推送镜像到 ghcr,
|
||||||
|
详见 [docs/release.md](docs/release.md)。
|
||||||
|
|
||||||
## Todo list
|
## 驱动配置
|
||||||
|
|
||||||
- [ ] 熔断机制
|
各驱动的账号 / 规则配置说明见 [docs/](docs/):
|
||||||
- [ ] Webhook 自定义请求
|
|
||||||
- [ ] 保存定时器状态
|
* [Bilibili](docs/bilibili-config.md)
|
||||||
|
* [Email](docs/email-config.md)
|
||||||
|
* [OneBot (QQ)](docs/onebot-config.md)
|
||||||
|
* [钉钉机器人](docs/dingtalk-config.md)
|
||||||
|
* [GitHub](docs/github-config.md)
|
||||||
|
* [饭碗警告](docs/fwalert-config.md)
|
||||||
|
|
||||||
|
## 开发:新增一种规则类型
|
||||||
|
|
||||||
|
1. 在 `drivers/<name>/` 下实现:
|
||||||
|
* 规则验证器(实现 `ruleConfigChecker.RuleValidator`,`Meta()` 返回表单元数据)
|
||||||
|
* 执行器(实现 `runner.RuleExecutor`)
|
||||||
|
* 如需第三方凭据,再实现账号验证器(`accountConfigChecker.AccountValidator`,可选实现 `AccountTester` 支持连通性测试)
|
||||||
|
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/interface.go` 中注册
|
||||||
|
3. 前端无需改动 —— WebUI 会根据 `Meta()` 返回的字段描述自动渲染配置表单
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/rule"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/user"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleAddAccount 添加账号
|
||||||
|
func HandleAddAccount(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.AccountRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "输入参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
response.FromError(c, err, "添加账号失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 之前这里完全没有走校验器,任何 JSON 都能存进去,直到规则触发才暴露问题
|
||||||
|
registry := accountConfigChecker.InitValidatorRegistry()
|
||||||
|
if err := registry.Validate(req.Type, req.Config); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newAccount := model.Account{
|
||||||
|
UID: userInfo.ID,
|
||||||
|
Name: req.Name,
|
||||||
|
Type: req.Type,
|
||||||
|
Config: req.Config,
|
||||||
|
CreateAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gormDB.Create(&newAccount).Error; err != nil {
|
||||||
|
response.ServerError(c, "添加账号失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, maskAccount(newAccount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetAllAccounts 获取用户的所有账号
|
||||||
|
func HandleGetAllAccounts(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := gormDB.Where("uid = ?", userInfo.ID)
|
||||||
|
if accountType := c.Query("type"); accountType != "" {
|
||||||
|
query = query.Where("type = ?", accountType)
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts := []model.Account{}
|
||||||
|
if err := query.Order("id DESC").Find(&accounts).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取账号失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, maskAccounts(accounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEditAccount 编辑账号
|
||||||
|
func HandleEditAccount(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := parseID(c.Param("accountID"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "账号ID格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := findAccount(accountID, userInfo.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.NotFound(c, "账号不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.AccountRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "输入参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
response.FromError(c, err, "更新账号失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Type != existing.Type {
|
||||||
|
response.BadRequest(c, "不支持修改账号类型,请新建账号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := accountConfigChecker.InitValidatorRegistry()
|
||||||
|
// 前端提交的密码等字段可能是掩码占位符,用旧配置补回
|
||||||
|
config := meta.Unmask(req.Config, existing.Config, registry.FieldsOf(req.Type))
|
||||||
|
|
||||||
|
if err := registry.Validate(req.Type, config); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Name = req.Name
|
||||||
|
existing.Config = config
|
||||||
|
if err := gormDB.Save(existing).Error; err != nil {
|
||||||
|
response.ServerError(c, "更新账号失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, maskAccount(*existing))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTestAccount 测试账号凭据是否可用
|
||||||
|
func HandleTestAccount(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := parseID(c.Param("accountID"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "账号ID格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := findAccount(accountID, userInfo.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.NotFound(c, "账号不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := accountConfigChecker.InitValidatorRegistry()
|
||||||
|
if err := registry.Test(existing.Type, existing.Config); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, "账号可用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCheckDeleteAccount 检查删除账号请求
|
||||||
|
//
|
||||||
|
// 删除账号会同时删除相关规则,先请求该接口获取受影响的规则
|
||||||
|
func HandleCheckDeleteAccount(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := parseID(c.Param("accountID"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "账号ID格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := getRulesByAccountID(accountID, userInfo.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取相关规则失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteAccount 删除账号及其关联规则
|
||||||
|
func HandleDeleteAccount(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := parseID(c.Param("accountID"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "账号ID格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账号是否归属请求用户
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除账号
|
||||||
|
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,75 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseID(raw string) (uint, error) {
|
||||||
|
id, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAccount 查找属于该用户的账号
|
||||||
|
func findAccount(accountID, uid uint) (*model.Account, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var account model.Account
|
||||||
|
if err := gormDB.Where("id = ? AND uid = ?", accountID, uid).First(&account).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRulesByAccountID 获取账号相关的规则列表
|
||||||
|
func getRulesByAccountID(accountID uint, uid uint) ([]*model.Rule, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := []*model.Rule{}
|
||||||
|
if err := gormDB.Where("account_id = ? AND uid = ?", accountID, uid).Find(&rules).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskAccount 掩码账号配置中的敏感字段,避免 Cookie/密码原样返回给前端
|
||||||
|
func maskAccount(account model.Account) model.Account {
|
||||||
|
fields := accountConfigChecker.InitValidatorRegistry().FieldsOf(account.Type)
|
||||||
|
account.Config = meta.Mask(account.Config, fields)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskAccounts(accounts []model.Account) []model.Account {
|
||||||
|
masked := make([]model.Account, 0, len(accounts))
|
||||||
|
for _, a := range accounts {
|
||||||
|
masked = append(masked, maskAccount(a))
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,80 @@
|
|||||||
|
// Package dashboard 汇总首页需要的统计数据
|
||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/user"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGetOverview 返回首页概览:各类对象数量、最紧急的 Timer、最近的执行日志
|
||||||
|
func HandleGetOverview(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timers := []model.Timer{}
|
||||||
|
if err := gormDB.Where("uid = ?", userInfo.ID).Find(&timers).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取统计数据失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ruleCount, accountCount int64
|
||||||
|
gormDB.Model(&model.Rule{}).Where("uid = ?", userInfo.ID).Count(&ruleCount)
|
||||||
|
gormDB.Model(&model.Account{}).Where("uid = ?", userInfo.ID).Count(&accountCount)
|
||||||
|
|
||||||
|
recentLogs := []model.ExecutionLog{}
|
||||||
|
gormDB.Where("uid = ?", userInfo.ID).Order("id DESC").Limit(10).Find(&recentLogs)
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
var (
|
||||||
|
enabledCount int
|
||||||
|
triggeredCount int
|
||||||
|
urgent *model.Timer
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range timers {
|
||||||
|
timer := timers[i]
|
||||||
|
if !timer.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enabledCount++
|
||||||
|
if timer.Triggered {
|
||||||
|
triggeredCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 剩余时间最短的启用中 Timer
|
||||||
|
if urgent == nil || timer.NextSignTime() < urgent.NextSignTime() {
|
||||||
|
urgent = &timers[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overview := gin.H{
|
||||||
|
"timer_count": len(timers),
|
||||||
|
"enabled_timers": enabledCount,
|
||||||
|
"triggered_count": triggeredCount,
|
||||||
|
"rule_count": ruleCount,
|
||||||
|
"account_count": accountCount,
|
||||||
|
"server_time": now,
|
||||||
|
"recent_logs": recentLogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
if urgent != nil {
|
||||||
|
overview["urgent_timer"] = urgent
|
||||||
|
overview["urgent_seconds_left"] = urgent.NextSignTime() - now
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, overview)
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
// Package log 提供规则执行日志的查询接口
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/user"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPageSize = 20
|
||||||
|
maxPageSize = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGetLogs 分页查询当前用户的执行日志
|
||||||
|
func HandleGetLogs(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := intQuery(c, "page", 1, 1, 1<<20)
|
||||||
|
pageSize := intQuery(c, "page_size", defaultPageSize, 1, maxPageSize)
|
||||||
|
|
||||||
|
query := gormDB.Model(&model.ExecutionLog{}).Where("uid = ?", userInfo.ID)
|
||||||
|
if ruleID := c.Query("rule_id"); ruleID != "" {
|
||||||
|
query = query.Where("rule_id = ?", ruleID)
|
||||||
|
}
|
||||||
|
if success := c.Query("success"); success == "true" || success == "false" {
|
||||||
|
query = query.Where("success = ?", success == "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取执行日志失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := []model.ExecutionLog{}
|
||||||
|
if err := query.Order("id DESC").
|
||||||
|
Offset((page - 1) * pageSize).
|
||||||
|
Limit(pageSize).
|
||||||
|
Find(&logs).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取执行日志失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
"items": logs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleClearLogs 清空当前用户的执行日志
|
||||||
|
func HandleClearLogs(c *gin.Context) {
|
||||||
|
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gormDB.Where("uid = ?", userInfo.ID).Delete(&model.ExecutionLog{}).Error; err != nil {
|
||||||
|
response.ServerError(c, "清空执行日志失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, "已清空执行日志")
|
||||||
|
}
|
||||||
|
|
||||||
|
func intQuery(c *gin.Context, key string, fallback, min, max int) int {
|
||||||
|
raw := c.Query(key)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || value < min {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Package meta 向 WebUI 暴露驱动能力与站点信息
|
||||||
|
package meta
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/user"
|
||||||
|
"github.com/ssdomei232/goodBaby/configs"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleGetProviders 返回所有账号类型与规则类型的元数据
|
||||||
|
//
|
||||||
|
// 前端据此动态渲染配置表单,新增驱动时前端无需改动。
|
||||||
|
func HandleGetProviders(c *gin.Context) {
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"accounts": accountConfigChecker.InitValidatorRegistry().Metas(),
|
||||||
|
"rules": ruleConfigChecker.InitValidatorRegistry().Metas(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetSiteInfo 返回无需登录即可获取的站点信息
|
||||||
|
func HandleGetSiteInfo(c *gin.Context) {
|
||||||
|
config, err := configs.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userCount, err := user.CountUsers()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"name": "goodBaby",
|
||||||
|
// 系统内还没有用户时始终允许注册,方便全新部署创建第一个账号
|
||||||
|
"enable_registry": config.EnableRegistry || userCount == 0,
|
||||||
|
"need_initial_user": userCount == 0,
|
||||||
|
"check_interval_minutes": config.CheckIntervalMinutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,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/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
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(req.Type)
|
||||||
|
if !ok {
|
||||||
|
return model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", req.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查关联的 Timer 与账号是否存在且属于当前用户
|
||||||
|
if err := checkRuleConfigAccountAndTimerExist(rule, ruleMeta.AccountType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ruleConfigChecker.InitValidatorRegistry().Validate(req.Type, rule.ConfigJson); err != nil {
|
||||||
|
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmaskRuleConfig 把提交上来的掩码字段还原成旧值
|
||||||
|
func unmaskRuleConfig(ruleType, newConfig, oldConfig string) string {
|
||||||
|
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType)
|
||||||
|
if !ok {
|
||||||
|
return newConfig
|
||||||
|
}
|
||||||
|
return meta.Unmask(newConfig, oldConfig, ruleMeta.Fields)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package rule
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseID(raw string) (uint, error) {
|
||||||
|
id, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolOr(v *bool, fallback bool) bool {
|
||||||
|
if v == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRule 查找属于该用户的规则
|
||||||
|
func findRule(ruleID, uid uint) (*model.Rule, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var target model.Rule
|
||||||
|
if err := gormDB.Where("id = ? AND uid = ?", ruleID, uid).First(&target).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRuleByID 根据 ID 删除规则
|
||||||
|
func DeleteRuleByID(id uint, uid uint) error {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRuleConfigAccountAndTimerExist 检查关联的 Timer 与账号是否存在且归属当前用户
|
||||||
|
//
|
||||||
|
// requiredAccountType 为空表示该规则类型不需要账号。
|
||||||
|
func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType string) error {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := gormDB.Model(&model.Timer{}).
|
||||||
|
Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return model.ErrValidation("关联的 Timer 不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if requiredAccountType == "" {
|
||||||
|
// 该类型不需要账号,忽略前端可能传来的 account_id
|
||||||
|
rule.AccountID = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.AccountID == 0 {
|
||||||
|
return model.ErrValidation("该规则类型必须关联一个账号")
|
||||||
|
}
|
||||||
|
|
||||||
|
var account model.Account
|
||||||
|
if err := gormDB.Where("id = ? AND uid = ?", rule.AccountID, rule.UID).First(&account).Error; err != nil {
|
||||||
|
return model.ErrValidation("关联的账号不存在")
|
||||||
|
}
|
||||||
|
if account.Type != requiredAccountType {
|
||||||
|
return model.ErrValidation(fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredAccountType, account.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskRule 掩码规则配置中的敏感字段
|
||||||
|
func maskRule(target model.Rule) model.Rule {
|
||||||
|
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(target.Type); ok {
|
||||||
|
target.ConfigJson = meta.Mask(target.ConfigJson, ruleMeta.Fields)
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskRules(rules []model.Rule) []model.Rule {
|
||||||
|
masked := make([]model.Rule, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
masked = append(masked, maskRule(r))
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRuleOwnerUID 获取规则所属用户的 UID
|
||||||
|
func getRuleOwnerUID(ruleID uint) (uint, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rule model.Rule
|
||||||
|
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&rule).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule.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,189 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrUserNotFound 用户不存在
|
||||||
|
var ErrUserNotFound = errors.New("用户不存在")
|
||||||
|
|
||||||
|
// ErrWrongPassword 密码错误
|
||||||
|
var ErrWrongPassword = errors.New("用户名或密码错误")
|
||||||
|
|
||||||
|
// contextKey AuthMiddleware 把当前用户放进 gin.Context 时使用的 key
|
||||||
|
const contextKey = "currentUser"
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
func encryptPassword(password string) (string, error) {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
func verifyPassword(hashedPassword, inputPassword string) error {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(inputPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUser(u *model.User) error {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := encryptPassword(u.Password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Password = hashedPassword
|
||||||
|
u.CreateAt = time.Now().Unix()
|
||||||
|
|
||||||
|
return gormDB.Create(u).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUsernameTaken 用户名是否已被占用
|
||||||
|
func IsUsernameTaken(username string) (bool, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := gormDB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUsers 返回已注册用户数,用于首个用户的引导注册
|
||||||
|
func CountUsers() (int64, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err = gormDB.Model(&model.User{}).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyUser 校验用户名与密码,成功时返回用户记录
|
||||||
|
//
|
||||||
|
// 原实现把 First 与 Select().Scan() 串在同一条语句上,实际拿不到密码哈希,
|
||||||
|
// 且用 sql.ErrNoRows 去比较 gorm 的错误永远不成立。
|
||||||
|
func verifyUser(username, password string) (*model.User, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := gormDB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := verifyPassword(user.Password, password); err != nil {
|
||||||
|
return nil, ErrWrongPassword
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserInfo 通过用户名获取用户信息
|
||||||
|
func GetUserInfo(username string) (*model.User, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := gormDB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID 通过 ID 获取用户信息
|
||||||
|
func GetUserByID(id uint) (*model.User, error) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := gormDB.First(&user, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserInfoByGinCtx 获取当前登录用户
|
||||||
|
//
|
||||||
|
// AuthMiddleware 已经查过一次库并写入了 context,这里优先读缓存。
|
||||||
|
func GetUserInfoByGinCtx(c *gin.Context) (*model.User, error) {
|
||||||
|
if cached, ok := c.Get(contextKey); ok {
|
||||||
|
if user, ok := cached.(*model.User); ok {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessions.Default(c)
|
||||||
|
uid, ok := session.Get("uid").(uint)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := GetUserByID(uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(contextKey, user)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSession 登录成功后写入会话
|
||||||
|
func setSession(c *gin.Context, user *model.User) error {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set("uid", user.ID)
|
||||||
|
session.Set("username", user.Username)
|
||||||
|
return session.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateNotifyConfig 校验钉钉提醒配置
|
||||||
|
func validateNotifyConfig(raw *string) error {
|
||||||
|
if raw == nil || *raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(*raw), &config); err != nil {
|
||||||
|
return fmt.Errorf("钉钉配置 JSON 格式错误: %v", err)
|
||||||
|
}
|
||||||
|
if config.AccessToken == "" {
|
||||||
|
return fmt.Errorf("钉钉配置中 access_token 不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/configs"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleRegistry 处理注册请求,配置文件中可设置禁用注册
|
||||||
|
//
|
||||||
|
// 系统内还没有任何用户时始终允许注册,否则全新部署将无法创建第一个账号。
|
||||||
|
func HandleRegistry(c *gin.Context) {
|
||||||
|
config, err := configs.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取配置文件失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userCount, err := CountUsers()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.EnableRegistry && userCount > 0 {
|
||||||
|
response.Fail(c, http.StatusForbidden, "注册功能已关闭")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var registryRequest model.UserRegistryReuest
|
||||||
|
if err := c.ShouldBindJSON(®istryRequest); err != nil {
|
||||||
|
response.BadRequest(c, "输入错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := model.User{
|
||||||
|
Username: registryRequest.Username,
|
||||||
|
Password: registryRequest.Password,
|
||||||
|
// 第一个注册的用户是管理员
|
||||||
|
IsAdmin: userCount == 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.IsValid(); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
taken, err := IsUsernameTaken(user.Username)
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
response.BadRequest(c, "用户名已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createUser(&user); err != nil {
|
||||||
|
response.ServerError(c, "注册失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setSession(c, &user); err != nil {
|
||||||
|
response.ServerError(c, "写入会话失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, "注册成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogin 处理登录请求
|
||||||
|
func HandleLogin(c *gin.Context) {
|
||||||
|
var loginRequest model.UserRegistryReuest
|
||||||
|
if err := c.ShouldBindJSON(&loginRequest); err != nil {
|
||||||
|
response.BadRequest(c, "输入错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if loginRequest.Username == "" || loginRequest.Password == "" {
|
||||||
|
response.BadRequest(c, "用户名或密码不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := verifyUser(loginRequest.Username, loginRequest.Password)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrWrongPassword):
|
||||||
|
// 不区分“用户不存在”和“密码错误”,避免泄露用户名是否存在
|
||||||
|
response.Unauthorized(c, "用户名或密码错误")
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
response.ServerError(c, "登录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setSession(c, user); err != nil {
|
||||||
|
response.ServerError(c, "写入会话失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, "登录成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogout 退出登录
|
||||||
|
func HandleLogout(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Clear()
|
||||||
|
session.Options(sessions.Options{Path: "/", MaxAge: -1})
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
response.ServerError(c, "退出登录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, "已退出登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetUserInfo 获取当前登录用户信息
|
||||||
|
func HandleGetUserInfo(c *gin.Context) {
|
||||||
|
userInfo, err := GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleChangePassword 修改密码
|
||||||
|
func HandleChangePassword(c *gin.Context) {
|
||||||
|
userInfo, err := GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.ChangePasswordRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "输入错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := verifyUser(userInfo.Username, req.OldPassword); err != nil {
|
||||||
|
response.BadRequest(c, "原密码错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := model.User{Username: userInfo.Username, Password: req.NewPassword}
|
||||||
|
if err := candidate.IsValid(); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashed, err := encryptPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "修改密码失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := gormDB.Model(&model.User{}).Where("id = ?", userInfo.ID).
|
||||||
|
Update("password", hashed).Error; err != nil {
|
||||||
|
response.ServerError(c, "修改密码失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, "密码修改成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateNotifyConfig 更新提醒渠道(钉钉机器人)配置
|
||||||
|
func HandleUpdateNotifyConfig(c *gin.Context) {
|
||||||
|
userInfo, err := GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "获取用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.NotifyConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "输入错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateNotifyConfig(req.DingTalkConfig); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "服务器内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gormDB.Model(&model.User{}).Where("id = ?", userInfo.ID).
|
||||||
|
Update("ding_talk_config", req.DingTalkConfig).Error; err != nil {
|
||||||
|
response.ServerError(c, "保存提醒配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, "提醒配置已保存")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware 认证中间件,同时把当前用户放进 context 供后续 handler 复用
|
||||||
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
uid, ok := session.Get("uid").(uint)
|
||||||
|
if !ok {
|
||||||
|
response.AbortWith(c, http.StatusUnauthorized, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := GetUserByID(uid)
|
||||||
|
if err != nil {
|
||||||
|
// 用户已被删除,清理会话
|
||||||
|
session.Clear()
|
||||||
|
_ = session.Save()
|
||||||
|
response.AbortWith(c, http.StatusUnauthorized, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(contextKey, user)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMiddleware 要求当前用户是管理员,需挂在 AuthMiddleware 之后
|
||||||
|
func AdminMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userInfo, err := GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.AbortWith(c, http.StatusUnauthorized, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !userInfo.IsAdmin {
|
||||||
|
response.AbortWith(c, http.StatusForbidden, "需要管理员权限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"signal_secret": "xxx",
|
|
||||||
"debug": true,
|
|
||||||
"disconnect_duration": 80,
|
|
||||||
"bili_msg": "摇篮系统测试",
|
|
||||||
"mail_config": {
|
|
||||||
"host": "smtp.example.com",
|
|
||||||
"port": 465,
|
|
||||||
"user": "xxx",
|
|
||||||
"pass": "xxx",
|
|
||||||
"mail_list": ["xxx@example.com", "xxx1@example.com"],
|
|
||||||
"mail_title": "摇篮系统已触发",
|
|
||||||
"mail_content": "这是摇篮系统的邮件内容。"
|
|
||||||
},
|
|
||||||
"basic": {
|
|
||||||
"name": "xx",
|
|
||||||
"qq_number": "123",
|
|
||||||
"nickname": "xx_123",
|
|
||||||
"age": 16,
|
|
||||||
"cause_stop": "自杀"
|
|
||||||
},
|
|
||||||
"github_config": {
|
|
||||||
"owner": "example",
|
|
||||||
"repos": ["example-repo1", "example-repo2"],
|
|
||||||
"token": "ghp_xxxx"
|
|
||||||
},
|
|
||||||
"dingtalk_bot": {
|
|
||||||
"access_token": "xxx",
|
|
||||||
"secret": "xxx"
|
|
||||||
},
|
|
||||||
"onebot_config": {
|
|
||||||
"url": "http://localhost:5700",
|
|
||||||
"token": "xxx",
|
|
||||||
"send_groups": [12345678, 87654321],
|
|
||||||
"msg": "这是OneBot的消息内容。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+199
-48
@@ -1,69 +1,220 @@
|
|||||||
package configs
|
package configs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 默认配置文件路径,可通过环境变量 GOODBABY_CONFIG 覆盖
|
||||||
|
const defaultConfigPath = "config.json"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SignalSecret string `json:"signal_secret"`
|
// HTTP 监听地址,如 ":8088"
|
||||||
Debug bool `json:"debug"`
|
ListenAddr string `json:"listen_addr"`
|
||||||
DisconnectDuration int `json:"disconnect_duration"` // Hours
|
// 是否开放注册
|
||||||
BiliMsg string `json:"bili_msg"`
|
EnableRegistry bool `json:"enable_registry"`
|
||||||
Basic Basic `json:"basic"`
|
// 规则执行的最长重试时间(小时),用于指数退避的整体超时
|
||||||
MailConfig MailConfig `json:"mail_config"`
|
TimeoutDurationHours int `json:"timeout_duration_hours"`
|
||||||
GithubConfig GithubConfig `json:"github_config"`
|
// 检查 timer 的间隔(分钟)
|
||||||
DingtalkBot DingtalkBot `json:"dingtalk_bot"`
|
CheckIntervalMinutes int `json:"check_interval_minutes"`
|
||||||
OneBotConfig OneBotConfig `json:"onebot_config"`
|
// 数据库驱动,"sqlite"(默认) 或 "postgres"
|
||||||
|
DatabaseDriver string `json:"database_driver"`
|
||||||
|
// sqlite 数据库文件路径,DatabaseDriver 为 sqlite 时使用
|
||||||
|
DatabasePath string `json:"database_path"`
|
||||||
|
// postgres 连接串,DatabaseDriver 为 postgres 时使用
|
||||||
|
// 例如 postgres://user:pass@localhost:5432/goodbaby?sslmode=disable
|
||||||
|
DatabaseDSN string `json:"database_dsn"`
|
||||||
|
// session 加密密钥,为空时自动生成并写回配置文件
|
||||||
|
SessionSecret string `json:"session_secret"`
|
||||||
|
// session 有效期(小时)
|
||||||
|
SessionMaxAgeHours int `json:"session_max_age_hours"`
|
||||||
|
// 允许跨域访问的来源,开发前端时使用,如 ["http://localhost:5173"]
|
||||||
|
AllowedOrigins []string `json:"allowed_origins"`
|
||||||
|
// 每个用户保留的执行日志条数,<=0 表示不限制
|
||||||
|
LogRetainCount int `json:"log_retain_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MailConfig struct {
|
// 支持的数据库驱动
|
||||||
Host string `json:"host"`
|
const (
|
||||||
Port int `json:"port"`
|
DriverSQLite = "sqlite"
|
||||||
User string `json:"user"`
|
DriverPostgres = "postgres"
|
||||||
Password string `json:"pass"`
|
)
|
||||||
MailList []string `json:"mail_list"`
|
|
||||||
MailTitle string `json:"mail_title"`
|
func defaultConfig() Config {
|
||||||
MailContent string `json:"mail_content"`
|
return Config{
|
||||||
|
ListenAddr: ":8088",
|
||||||
|
EnableRegistry: true,
|
||||||
|
TimeoutDurationHours: 6,
|
||||||
|
CheckIntervalMinutes: 10,
|
||||||
|
DatabaseDriver: DriverSQLite,
|
||||||
|
DatabasePath: "data.db",
|
||||||
|
SessionMaxAgeHours: 24 * 7,
|
||||||
|
AllowedOrigins: []string{},
|
||||||
|
LogRetainCount: 500,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type GithubConfig struct {
|
var (
|
||||||
Owner string `json:"owner"`
|
once sync.Once
|
||||||
Repos []string `json:"repos"`
|
cached Config
|
||||||
Token string `json:"token"`
|
loadErr error
|
||||||
|
cacheMux sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetConfig 读取配置,只在首次调用时读盘,之后返回缓存。
|
||||||
|
//
|
||||||
|
// 配置文件不存在时会以默认值创建一份,缺失的字段会被补齐并写回。
|
||||||
|
func GetConfig() (Config, error) {
|
||||||
|
once.Do(func() {
|
||||||
|
cfg, err := load()
|
||||||
|
cacheMux.Lock()
|
||||||
|
cached, loadErr = cfg, err
|
||||||
|
cacheMux.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
cacheMux.RLock()
|
||||||
|
defer cacheMux.RUnlock()
|
||||||
|
return cached, loadErr
|
||||||
}
|
}
|
||||||
|
|
||||||
type Basic struct {
|
// MustGetConfig 与 GetConfig 相同,但在出错时直接退出,用于启动阶段
|
||||||
Name string `json:"name"`
|
func MustGetConfig() Config {
|
||||||
Age int `json:"age"`
|
cfg, err := GetConfig()
|
||||||
NickName string `json:"nickname"`
|
|
||||||
QQNumber string `json:"qq_number"`
|
|
||||||
CauseStop string `json:"cause_stop"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DingtalkBot struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
Secret string `json:"secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OneBotConfig struct {
|
|
||||||
URL string `json:"url"` // 例如 "http://localhost:5700"
|
|
||||||
Token string `json:"token"`
|
|
||||||
SendGroups []int `json:"send_groups"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get config from json file
|
|
||||||
func GetConfig() (config Config, err error) {
|
|
||||||
content, err := os.ReadFile("config.json")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config, err
|
log.Fatalf("加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func configPath() string {
|
||||||
|
if p := os.Getenv("GOODBABY_CONFIG"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return defaultConfigPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() (Config, error) {
|
||||||
|
path := configPath()
|
||||||
|
config := defaultConfig()
|
||||||
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if err := json.Unmarshal(content, &config); err != nil {
|
||||||
|
return config, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||||
|
}
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
log.Printf("配置文件 %s 不存在,使用默认配置创建", path)
|
||||||
|
default:
|
||||||
|
return config, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(content, &config)
|
changed := normalize(&config)
|
||||||
if err != nil {
|
applyEnvOverrides(&config)
|
||||||
return config, err
|
|
||||||
|
if changed {
|
||||||
|
if err := save(path, config); err != nil {
|
||||||
|
// 只读挂载等场景下写回失败不应阻断启动
|
||||||
|
log.Printf("警告: 写回配置文件失败: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalize 补齐缺失/非法的配置项,返回是否发生了修改
|
||||||
|
func normalize(c *Config) bool {
|
||||||
|
def := defaultConfig()
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
if c.ListenAddr == "" {
|
||||||
|
c.ListenAddr, changed = def.ListenAddr, true
|
||||||
|
}
|
||||||
|
if c.TimeoutDurationHours <= 0 {
|
||||||
|
c.TimeoutDurationHours, changed = def.TimeoutDurationHours, true
|
||||||
|
}
|
||||||
|
if c.CheckIntervalMinutes <= 0 {
|
||||||
|
c.CheckIntervalMinutes, changed = def.CheckIntervalMinutes, true
|
||||||
|
}
|
||||||
|
if c.DatabasePath == "" {
|
||||||
|
c.DatabasePath, changed = def.DatabasePath, true
|
||||||
|
}
|
||||||
|
if c.DatabaseDriver == "" {
|
||||||
|
c.DatabaseDriver, changed = def.DatabaseDriver, true
|
||||||
|
}
|
||||||
|
if c.SessionMaxAgeHours <= 0 {
|
||||||
|
c.SessionMaxAgeHours, changed = def.SessionMaxAgeHours, true
|
||||||
|
}
|
||||||
|
if c.SessionSecret == "" {
|
||||||
|
// 随机生成一次并持久化,避免每次重启都让所有用户掉线
|
||||||
|
c.SessionSecret, changed = randomHex(32), true
|
||||||
|
}
|
||||||
|
if c.AllowedOrigins == nil {
|
||||||
|
c.AllowedOrigins, changed = def.AllowedOrigins, true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEnvOverrides(c *Config) {
|
||||||
|
if v := os.Getenv("GOODBABY_LISTEN_ADDR"); v != "" {
|
||||||
|
c.ListenAddr = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("GOODBABY_DB_PATH"); v != "" {
|
||||||
|
c.DatabasePath = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("GOODBABY_DB_DRIVER"); v != "" {
|
||||||
|
c.DatabaseDriver = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("GOODBABY_DB_DSN"); v != "" {
|
||||||
|
c.DatabaseDSN = v
|
||||||
|
// 给了连接串却没显式指定驱动时,按 postgres 处理
|
||||||
|
if os.Getenv("GOODBABY_DB_DRIVER") == "" {
|
||||||
|
c.DatabaseDriver = DriverPostgres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv("GOODBABY_SESSION_SECRET"); v != "" {
|
||||||
|
c.SessionSecret = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("GOODBABY_ENABLE_REGISTRY"); v != "" {
|
||||||
|
if b, err := strconv.ParseBool(v); err == nil {
|
||||||
|
c.EnableRegistry = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(path string, c Config) error {
|
||||||
|
content, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, content, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save 持久化配置并刷新缓存,供管理接口修改运行时配置使用
|
||||||
|
func Save(c Config) error {
|
||||||
|
normalize(&c)
|
||||||
|
if err := save(configPath(), c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheMux.Lock()
|
||||||
|
cached, loadErr = c, nil
|
||||||
|
cacheMux.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(n int) string {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
panic(fmt.Sprintf("无法生成随机密钥: %v", err))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package configs
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// EditableConfig 是管理员可以在 WebUI 里修改的配置子集。
|
||||||
|
//
|
||||||
|
// 只收录改完立即生效的项。监听地址、数据库、session 密钥这类需要重启
|
||||||
|
// 或改错会导致服务起不来的配置,只能改配置文件。
|
||||||
|
type EditableConfig struct {
|
||||||
|
EnableRegistry bool `json:"enable_registry"`
|
||||||
|
TimeoutDurationHours int `json:"timeout_duration_hours"`
|
||||||
|
CheckIntervalMinutes int `json:"check_interval_minutes"`
|
||||||
|
LogRetainCount int `json:"log_retain_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editable 从完整配置中取出可编辑的部分
|
||||||
|
func (c Config) Editable() EditableConfig {
|
||||||
|
return EditableConfig{
|
||||||
|
EnableRegistry: c.EnableRegistry,
|
||||||
|
TimeoutDurationHours: c.TimeoutDurationHours,
|
||||||
|
CheckIntervalMinutes: c.CheckIntervalMinutes,
|
||||||
|
LogRetainCount: c.LogRetainCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 校验管理员提交的配置
|
||||||
|
func (e EditableConfig) Validate() error {
|
||||||
|
if e.TimeoutDurationHours < 1 || e.TimeoutDurationHours > 72 {
|
||||||
|
return fmt.Errorf("规则重试时长必须在 1~72 小时之间")
|
||||||
|
}
|
||||||
|
if e.CheckIntervalMinutes < 1 || e.CheckIntervalMinutes > 1440 {
|
||||||
|
return fmt.Errorf("检查间隔必须在 1~1440 分钟之间")
|
||||||
|
}
|
||||||
|
if e.LogRetainCount < 0 || e.LogRetainCount > 100000 {
|
||||||
|
return fmt.Errorf("日志保留条数必须在 0~100000 之间,0 表示不限制")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyEditable 把可编辑部分合并进当前配置并持久化,返回更新后的完整配置
|
||||||
|
func ApplyEditable(e EditableConfig) (Config, error) {
|
||||||
|
if err := e.Validate(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
current.EnableRegistry = e.EnableRegistry
|
||||||
|
current.TimeoutDurationHours = e.TimeoutDurationHours
|
||||||
|
current.CheckIntervalMinutes = e.CheckIntervalMinutes
|
||||||
|
current.LogRetainCount = e.LogRetainCount
|
||||||
|
|
||||||
|
if err := Save(current); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
+6
-9
@@ -1,18 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
goodbaby:
|
goodbaby:
|
||||||
image: mei232/goodbaby:v0.2.0
|
# 发版镜像由 GitHub Actions 自动推送到 ghcr
|
||||||
|
image: ghcr.io/ssdomei232/goodbaby:latest
|
||||||
|
# 想用本地源码构建时把上面一行注释掉,保留 build
|
||||||
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8088:8088"
|
- "8088:8088"
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
- GIN_MODE=release
|
- GIN_MODE=release
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/app/config.json:ro
|
# sqlite 需要写权限,config.json 首次启动会自动生成,不能挂成只读
|
||||||
- ./tmp:/app/tmp
|
- ./data:/app/data
|
||||||
|
|
||||||
networks:
|
|
||||||
app-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Bilibili 配置
|
||||||
|
|
||||||
|
账号类型: `bilibili`。
|
||||||
|
|
||||||
|
支持的规则类型:
|
||||||
|
|
||||||
|
| 规则类型 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `bilibili-dynamic` | 发布一条纯文本动态 |
|
||||||
|
| `bilibili-private-message` | 向指定用户发送私信 |
|
||||||
|
|
||||||
|
## 账号配置 (Account Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"raw_cookies": "SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx; ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
登录 [bilibili.com](https://www.bilibili.com) 后,从浏览器开发者工具(F12 → 网络 → 任意请求 → 请求头 → Cookie)复制完整的 Cookie 字符串。
|
||||||
|
|
||||||
|
在 WebUI 的账号页面可以点击“测试”验证 Cookie 是否有效。
|
||||||
|
|
||||||
|
## 动态规则配置 (`bilibili-dynamic`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg": "要发送的动态内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
定时器到期后会以该账号发布一条纯文本动态。
|
||||||
|
|
||||||
|
## 私信规则配置 (`bilibili-private-message`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"receiver_uids": [2, 123456],
|
||||||
|
"msg": "要发送的私信内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`receiver_uids` 是接收者的 UID 列表,即对方空间地址 `space.bilibili.com/` 后面的那串数字。
|
||||||
|
|
||||||
|
发送者 UID 由程序自动从账号 Cookie 中读取,不需要填写。
|
||||||
|
|
||||||
|
注意:B 站对私信有频率限制与陌生人私信限制,如果对方设置了“仅关注的人可以给我发私信”,发送会失败。失败信息可以在执行日志里看到。
|
||||||
|
|
||||||
|
## 关于视频投稿
|
||||||
|
|
||||||
|
当前使用的 SDK [`CuteReimu/bilibili`](https://github.com/CuteReimu/bilibili) **没有提供视频投稿(上传稿件)接口**,因此暂不支持“定时投稿视频”这类规则。
|
||||||
|
|
||||||
|
该 SDK 与上传相关的能力只有 `UploadDynamicBfs`(为图片动态上传图片),不包含投稿所需的 `preupload` / 分片上传 / `archive/add` 流程。
|
||||||
|
|
||||||
|
若要支持视频投稿,需要:
|
||||||
|
|
||||||
|
1. 自行实现 B 站投稿协议(预上传 → 分片上传 → 合并 → 提交稿件),且该协议未公开、变动频繁;
|
||||||
|
2. 给 goodBaby 增加文件上传与存储能力(当前只存 JSON 配置,不保存任何文件);
|
||||||
|
3. 处理大文件的断点续传与磁盘占用。
|
||||||
|
|
||||||
|
这是一个独立的功能模块,不在现有驱动的改造范围内。
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 数据库配置
|
||||||
|
|
||||||
|
goodBaby 默认使用 SQLite,零配置开箱即用;也可以切换到 PostgreSQL。
|
||||||
|
|
||||||
|
## SQLite(默认)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"database_driver": "sqlite",
|
||||||
|
"database_path": "data.db"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用的是纯 Go 的 [glebarez/sqlite](https://github.com/glebarez/sqlite),不需要 CGO,编译出来就是一个静态二进制。
|
||||||
|
|
||||||
|
已开启 WAL 与 10 秒 busy timeout;因为 SQLite 是单写模型,连接池被限制为 1 条连接,避免并发执行规则时出现 `database is locked`。
|
||||||
|
|
||||||
|
## PostgreSQL
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"database_driver": "postgres",
|
||||||
|
"database_dsn": "postgres://user:password@localhost:5432/goodbaby?sslmode=disable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
切换到 postgres 后 `database_path` 会被忽略。连接池为最大 20 条连接、5 条空闲、连接最长存活 1 小时。
|
||||||
|
|
||||||
|
启动时会先 `Ping` 一次,配置写错会在启动阶段直接报错退出,而不是等到第一次查询才失败。
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
配置文件之外也可以用环境变量覆盖,适合容器部署:
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GOODBABY_DB_DRIVER` | `sqlite` 或 `postgres` |
|
||||||
|
| `GOODBABY_DB_DSN` | postgres 连接串 |
|
||||||
|
| `GOODBABY_DB_PATH` | sqlite 文件路径 |
|
||||||
|
|
||||||
|
只设置了 `GOODBABY_DB_DSN` 而没设置 `GOODBABY_DB_DRIVER` 时,会自动按 postgres 处理。
|
||||||
|
|
||||||
|
### docker compose 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
goodbaby:
|
||||||
|
image: mei232/goodbaby:v2.1.0
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- GIN_MODE=release
|
||||||
|
- GOODBABY_DB_DSN=postgres://goodbaby:goodbaby@postgres:5432/goodbaby?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=goodbaby
|
||||||
|
- POSTGRES_PASSWORD=goodbaby
|
||||||
|
- POSTGRES_DB=goodbaby
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U goodbaby"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
## 切换数据库
|
||||||
|
|
||||||
|
两种数据库之间**没有**自动数据迁移。切换驱动后是一个全新的空库,需要重新注册账号并配置规则。
|
||||||
|
|
||||||
|
如果要搬运已有数据,得自己从 SQLite 导出再导入 PostgreSQL(表结构一致:`users` / `timers` / `rules` / `accounts` / `execution_logs`)。
|
||||||
|
|
||||||
|
## 建表
|
||||||
|
|
||||||
|
首次启动会自动执行 `AutoMigrate` 建表并补齐新增字段,不需要手动执行 SQL。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 钉钉机器人配置
|
||||||
|
|
||||||
|
钉钉在 goodBaby 中有两个用途:
|
||||||
|
|
||||||
|
1. **提醒渠道**:定时器临近到期时提醒你签到,在 WebUI 的“设置”页配置
|
||||||
|
2. **规则类型** (`dingtalk`):定时器到期后向钉钉群发送消息,不需要关联账号
|
||||||
|
|
||||||
|
## 获取机器人凭据
|
||||||
|
|
||||||
|
1. 在钉钉群中:群设置 → 智能群助手 → 添加机器人 → 自定义
|
||||||
|
2. 安全设置推荐选择“加签”,记下 Secret
|
||||||
|
3. 创建后复制 Webhook 地址中 `access_token=` 后面的值
|
||||||
|
|
||||||
|
## 规则配置 (Rule Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "机器人 access_token",
|
||||||
|
"secret": "加签 Secret(未开启加签可留空)",
|
||||||
|
"title": "消息标题",
|
||||||
|
"msg": "消息内容,支持 Markdown"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 提醒渠道配置(设置页)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "机器人 access_token",
|
||||||
|
"secret": "加签 Secret"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Email 配置
|
||||||
|
|
||||||
|
账号类型: `email`,规则类型: `email`。
|
||||||
|
|
||||||
|
## 账号配置 (Account Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"smtp_server": "smtp.example.com",
|
||||||
|
"port": 465,
|
||||||
|
"security": "ssl",
|
||||||
|
"username": "you@example.com",
|
||||||
|
"password": "你的密码或授权码",
|
||||||
|
"from": "you@example.com",
|
||||||
|
"test_destination": "test@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `smtp_server` | 是 | SMTP 服务器地址 |
|
||||||
|
| `port` | 是 | 端口,SSL 一般为 465,STARTTLS 一般为 587 |
|
||||||
|
| `security` | 否 | `ssl` / `starttls` / `none`,默认 `ssl` |
|
||||||
|
| `username` | 是 | 登录用户名,通常是邮箱地址 |
|
||||||
|
| `password` | 是 | 密码或授权码(QQ 邮箱、163 等需要使用授权码) |
|
||||||
|
| `from` | 否 | 发件人地址,默认使用 `username` |
|
||||||
|
| `test_destination` | 否 | 填写后,点击“测试”会真的发送一封测试邮件到该地址 |
|
||||||
|
|
||||||
|
## 规则配置 (Rule Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "邮件标题",
|
||||||
|
"msg": "邮件正文",
|
||||||
|
"destinations": ["a@example.com", "b@example.com"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每个收件人独立发送、独立重试,互不影响。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 饭碗警告配置
|
||||||
|
|
||||||
|
规则类型: `fwalert`。
|
||||||
|
|
||||||
|
定时器到期后,请求饭碗警告的 Webhook 地址
|
||||||
|
|
||||||
|
饭碗警告: 比较简单的(不用实名认证)支持电话和短信通知的第三方平台: `https://fwalert.com/`,价格不算最低,但比较方便
|
||||||
|
|
||||||
|
## 规则配置 (Rule Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"webhook_url": "https://fwalert.com/xxx-xxx-xxx-xxx-xxx",
|
||||||
|
"msg": "消息内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 饭碗警告平台配置
|
||||||
|
|
||||||
|
goodbaby 在请求饭碗警告时将`msg`放在请求体中,参照下面和官方教程设置:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `变量名` | `message` |
|
||||||
|
| `变量来源` | `请求体(body)` |
|
||||||
|
| `提取规则` | `序列化数据` |
|
||||||
|
| `键` | `message` |
|
||||||
|
| `通知简述` | 随便填 |
|
||||||
|
| `通知正文` | `{{message}}` |
|
||||||
|
| `联系方式类型` | 参照官方文档添加并选择 |
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# GitHub 配置
|
||||||
|
|
||||||
|
账号类型: `github`,规则类型: `github-repo-public`。
|
||||||
|
|
||||||
|
定时器到期后,把指定的私有仓库设置为公开。
|
||||||
|
|
||||||
|
## 账号配置 (Account Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "ghp_xxxxxxxx",
|
||||||
|
"owner": "your-github-name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `token` | 是 | Personal Access Token,需要 `repo` 权限 |
|
||||||
|
| `owner` | 是 | 仓库所有者(用户名或组织名) |
|
||||||
|
|
||||||
|
Token 在 GitHub → Settings → Developer settings → Personal access tokens 创建。
|
||||||
|
|
||||||
|
在 WebUI 的账号页面可以点击“测试”验证 Token 是否有效。
|
||||||
|
|
||||||
|
## 规则配置 (Rule Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repos": ["repo-a", "repo-b"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
只填仓库名,不含所有者。
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# OneBot (QQ) 配置
|
||||||
|
|
||||||
|
账号类型: `onebot`,规则类型: `onebot`。
|
||||||
|
|
||||||
|
对接 [NapCat](https://napneko.github.io/)、go-cqhttp 等实现了 OneBot HTTP API 的服务。
|
||||||
|
|
||||||
|
## 账号配置 (Account Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"token": "your_access_token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `url` | 是 | OneBot HTTP 服务地址 |
|
||||||
|
| `token` | 是 | Access Token |
|
||||||
|
|
||||||
|
在 WebUI 的账号页面可以点击“测试”验证连通性(调用 `get_login_info`)。
|
||||||
|
|
||||||
|
## 规则配置 (Rule Config)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg": "要发送的消息",
|
||||||
|
"send_groups": [123456789],
|
||||||
|
"send_users": [987654321]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`send_groups`(群号)与 `send_users`(好友 QQ 号)至少填写一项。
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 发版流程
|
||||||
|
|
||||||
|
发版由 [`.github/workflows/release.yml`](../.github/workflows/release.yml) 全自动完成,
|
||||||
|
触发条件是推送一个 `v` 开头的 tag。
|
||||||
|
|
||||||
|
## 发一个版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v2.2.0
|
||||||
|
git push origin v2.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
推上去之后 workflow 会依次做四件事:
|
||||||
|
|
||||||
|
| 阶段 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `frontend` | 跑一次 `npm ci && npm run build`(含 `vue-tsc` 类型检查),产物作为 artifact 共享 |
|
||||||
|
| `build` | 5 个平台交叉编译,打包成 tar.gz / zip |
|
||||||
|
| `release` | 汇总产物、生成 `checksums.txt`、创建 GitHub Release(发布说明自动生成) |
|
||||||
|
| `docker` | buildx 构建 `linux/amd64` + `linux/arm64` 镜像推送到 ghcr |
|
||||||
|
|
||||||
|
前端只构建一次,所有平台的二进制共用同一份产物,保证各平台内嵌的前端完全一致。
|
||||||
|
|
||||||
|
## 产物
|
||||||
|
|
||||||
|
二进制覆盖:
|
||||||
|
|
||||||
|
* `linux/amd64`、`linux/arm64`
|
||||||
|
* `windows/amd64`
|
||||||
|
* `darwin/amd64`、`darwin/arm64`
|
||||||
|
|
||||||
|
每个压缩包内含可执行文件、`README.md`、`LICENSE` 与 `docs/`。
|
||||||
|
|
||||||
|
镜像标签由 tag 推导:`v2.2.0` 会产出 `2.2.0`、`2.2`、`2` 与 `latest`。
|
||||||
|
|
||||||
|
## 版本信息注入
|
||||||
|
|
||||||
|
编译时通过 ldflags 注入,启动日志里能看到:
|
||||||
|
|
||||||
|
```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,63 @@
|
|||||||
|
package alidns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"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 := parseAccount(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.AK == "" || cfg.SK == "" {
|
||||||
|
return fmt.Errorf("阿里云账号配置不完整")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccount(config string) (*AliDNSAccount, error) {
|
||||||
|
var cfg AliDNSAccount
|
||||||
|
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析阿里云账号配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AliDNSAccountConfigValidator) Test(config string) error {
|
||||||
|
cfg, err := parseAccount(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
aliDNSClient, err := getAliDNSClient(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,33 @@
|
|||||||
|
package alidns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deleteAliDNSRecord deletes a DNS record from Alibaba Cloud DNS based on the provided rule.
|
||||||
|
func deleteAliDNSRecord(ctx context.Context, rule *model.Rule) error {
|
||||||
|
aliDNSClient, err := getAliDNSClientFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deleteRecordConfig, err := getDeleteRecordConfig(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retry.Do(ctx, func() error {
|
||||||
|
_, err := aliDNSClient.DeleteDomainRecord(&client.DeleteDomainRecordRequest{
|
||||||
|
RecordId: &deleteRecordConfig.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,40 @@
|
|||||||
|
package alidns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 阿里云删除记录规则验证器
|
||||||
|
type AliDNSDeleteRecordRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *AliDNSDeleteRecordRuleValidator) GetType() string {
|
||||||
|
return RuleTypeDeleteRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AliDNSDeleteRecordRuleValidator) Validate(configJSON string) error {
|
||||||
|
var config DeleteRecordConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析阿里云删除记录规则配置失败: %v", 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,21 @@
|
|||||||
|
package alidns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeleteAliDNSRecordExecutor 删除阿里云DNS记录执行器
|
||||||
|
type DeleteAliDNSRecordExecutor struct{}
|
||||||
|
|
||||||
|
func (e *DeleteAliDNSRecordExecutor) GetType() string {
|
||||||
|
return RuleTypeDeleteRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DeleteAliDNSRecordExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行删除阿里云DNS记录规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||||
|
|
||||||
|
return deleteAliDNSRecord(ctx, rule)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package alidns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
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"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getAliDNSClient initializes and returns an Alibaba Cloud DNS client based on the provided rule's account configuration. It retrieves the account configuration from the database, sets up the necessary credentials, and creates a new client instance for interacting with Alibaba Cloud DNS services.
|
||||||
|
func getAliDNSClientFromRule(rule *model.Rule) (client *alidns.Client, err error) {
|
||||||
|
var accountConfig AliDNSAccount
|
||||||
|
|
||||||
|
// get config
|
||||||
|
if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAliDNSClient(accountConfig.AK, accountConfig.SK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAliDNSClient(ak string, sk string) (client *alidns.Client, err error) {
|
||||||
|
// init aliyun account config
|
||||||
|
credentialsConfig := new(credentials.Config).
|
||||||
|
SetType("access_key").
|
||||||
|
SetAccessKeyId(ak).
|
||||||
|
SetAccessKeySecret(sk)
|
||||||
|
akCredential, err := credentials.NewCredential(credentialsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
config := &openapi.Config{}
|
||||||
|
config.Credential = akCredential
|
||||||
|
config.Endpoint = tea.String("alidns.aliyuncs.com")
|
||||||
|
|
||||||
|
// create aliyun account client
|
||||||
|
client, _ = alidns.NewClient(config)
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDeleteRecordConfig retrieves the configuration for deleting DNS records from the provided rule. It unmarshals the rule's configuration JSON into a DeleteRecordConfig structure and returns it.
|
||||||
|
func getDeleteRecordConfig(rule *model.Rule) (*DeleteRecordConfig, error) {
|
||||||
|
var deleteRecordConfig DeleteRecordConfig
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(rule.ConfigJson), &deleteRecordConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &deleteRecordConfig, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package bilibili
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BilibiliAccountConfigValidator B站账号配置验证器
|
||||||
|
type BilibiliAccountConfigValidator struct{}
|
||||||
|
|
||||||
|
func (v *BilibiliAccountConfigValidator) GetType() string {
|
||||||
|
return AccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *BilibiliAccountConfigValidator) Validate(config string) error {
|
||||||
|
cfg, err := parseAccount(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RawCookies == "" {
|
||||||
|
return fmt.Errorf("B站账号配置中 cookies 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 用 cookie 拉一次账号信息,验证 cookie 是否还有效
|
||||||
|
func (v *BilibiliAccountConfigValidator) Test(config string) error {
|
||||||
|
cfg, err := parseAccount(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := newClient(cfg.RawCookies)
|
||||||
|
info, err := client.GetAccountInformation()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("B站 cookie 校验失败: %w", err)
|
||||||
|
}
|
||||||
|
if info == nil || info.Mid == 0 {
|
||||||
|
return fmt.Errorf("B站 cookie 无效或已过期")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *BilibiliAccountConfigValidator) Meta() meta.AccountMeta {
|
||||||
|
return meta.AccountMeta{
|
||||||
|
Type: AccountType,
|
||||||
|
Label: "哔哩哔哩",
|
||||||
|
Description: "使用浏览器中的完整 Cookie 登录 B 站,用于发送动态。",
|
||||||
|
Docs: "docs/bilibili-config.md",
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{
|
||||||
|
Key: "raw_cookies",
|
||||||
|
Label: "Cookies",
|
||||||
|
Type: meta.FieldTextarea,
|
||||||
|
Required: true,
|
||||||
|
Secret: true,
|
||||||
|
Placeholder: "SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx",
|
||||||
|
Help: "登录 bilibili.com 后从浏览器开发者工具复制完整的 Cookie 字符串",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccount(config string) (*BiliAccount, error) {
|
||||||
|
var cfg BiliAccount
|
||||||
|
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析B站账号配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package bilibili
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/CuteReimu/bilibili/v2"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendBiliDynamicMsg 在 Bilibili 动态发送消息
|
||||||
|
//
|
||||||
|
// 暂时没有处理 429 和 403 的区别对待
|
||||||
|
func SendBiliDynamicMsg(ctx context.Context, rule *model.Rule) error {
|
||||||
|
biliClient, err := getBiliClient(rule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取B站客户端失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
biliDynamicConfig, err := getBiliDynamicConfig(rule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取B站动态配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamicParams := bilibili.CreateDynamicParam{
|
||||||
|
DynamicId: 0,
|
||||||
|
Type: 4,
|
||||||
|
Rid: 0,
|
||||||
|
Content: biliDynamicConfig.Msg,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := retry.Do(ctx, func() error {
|
||||||
|
_, err := biliClient.CreateDynamic(dynamicParams)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("发送B站动态失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package bilibili
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccountType B 站账号类型标识
|
||||||
|
AccountType = "bilibili"
|
||||||
|
// RuleTypeDynamic 发送 B 站动态的规则类型标识
|
||||||
|
RuleTypeDynamic = "bilibili-dynamic"
|
||||||
|
// RuleTypePrivateMessage 发送 B 站私信的规则类型标识
|
||||||
|
RuleTypePrivateMessage = "bilibili-private-message"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BiliAccount struct {
|
||||||
|
RawCookies string `json:"raw_cookies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BiliDynamicConfig struct {
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BiliPrivateMessageConfig 私信规则配置
|
||||||
|
type BiliPrivateMessageConfig struct {
|
||||||
|
// 接收者的 UID 列表
|
||||||
|
ReceiverUids []int64 `json:"receiver_uids"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package bilibili
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/CuteReimu/bilibili/v2"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 私信消息类型:1 为文字
|
||||||
|
const privateMsgTypeText = 1
|
||||||
|
|
||||||
|
// SendBiliPrivateMessage 向配置中的每个 UID 发送一条 B 站私信
|
||||||
|
func SendBiliPrivateMessage(ctx context.Context, rule *model.Rule) error {
|
||||||
|
client, err := getBiliClient(rule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取B站客户端失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := getPrivateMessageConfig(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发私信需要带上自己的 UID,从账号信息里取
|
||||||
|
account, err := client.GetAccountInformation()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取B站账号信息失败(cookie 可能已失效): %w", err)
|
||||||
|
}
|
||||||
|
if account == nil || account.Mid == 0 {
|
||||||
|
return fmt.Errorf("B站 cookie 无效或已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
var fails []string
|
||||||
|
for _, uid := range config.ReceiverUids {
|
||||||
|
err := retry.Do(ctx, func() error {
|
||||||
|
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
|
||||||
|
SenderUid: account.Mid,
|
||||||
|
ReceiverId: int(uid),
|
||||||
|
ReceiverType: 1,
|
||||||
|
MsgType: privateMsgTypeText,
|
||||||
|
Timestamp: int(time.Now().Unix()),
|
||||||
|
Content: buildTextContent(config.Msg),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fails = append(fails, fmt.Sprintf("UID %d: %v", uid, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fails) > 0 {
|
||||||
|
return fmt.Errorf("%d/%d 条私信发送失败: %s",
|
||||||
|
len(fails), len(config.ReceiverUids), strings.Join(fails, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTextContent 文字私信的 content 是一个 JSON 字符串 {"content":"..."}
|
||||||
|
func buildTextContent(msg string) string {
|
||||||
|
payload, err := json.Marshal(map[string]string{"content": msg})
|
||||||
|
if err != nil {
|
||||||
|
// msg 是普通字符串,序列化不会失败;兜底也返回合法 JSON
|
||||||
|
return `{"content":""}`
|
||||||
|
}
|
||||||
|
return string(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPrivateMessageConfig(rule *model.Rule) (*BiliPrivateMessageConfig, error) {
|
||||||
|
var config BiliPrivateMessageConfig
|
||||||
|
if err := json.Unmarshal([]byte(rule.ConfigJson), &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析B站私信规则配置失败: %w", err)
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package bilibili
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BilibiliDynamicRuleValidator B站动态规则验证器
|
||||||
|
type BilibiliDynamicRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *BilibiliDynamicRuleValidator) GetType() string {
|
||||||
|
return RuleTypeDynamic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error {
|
||||||
|
var config BiliDynamicConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析B站动态规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Msg == "" {
|
||||||
|
return fmt.Errorf("B站动态规则配置中 msg 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len([]rune(config.Msg)) > 1000 {
|
||||||
|
return fmt.Errorf("B站动态内容过长")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BilibiliPrivateMessageRuleValidator B站私信规则验证器
|
||||||
|
type BilibiliPrivateMessageRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *BilibiliPrivateMessageRuleValidator) GetType() string {
|
||||||
|
return RuleTypePrivateMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *BilibiliPrivateMessageRuleValidator) Validate(configJSON string) error {
|
||||||
|
var config BiliPrivateMessageConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析B站私信规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Msg == "" {
|
||||||
|
return fmt.Errorf("B站私信规则配置中 msg 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.ReceiverUids) == 0 {
|
||||||
|
return fmt.Errorf("B站私信规则配置中 receiver_uids 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uid := range config.ReceiverUids {
|
||||||
|
if uid <= 0 {
|
||||||
|
return fmt.Errorf("B站私信规则配置中存在非法的 UID: %d", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *BilibiliPrivateMessageRuleValidator) Meta() meta.RuleMeta {
|
||||||
|
return meta.RuleMeta{
|
||||||
|
Type: RuleTypePrivateMessage,
|
||||||
|
Label: "发送 B 站私信",
|
||||||
|
Description: "触发时以关联的 B 站账号向指定用户发送私信。对方需要能接收你的私信。",
|
||||||
|
Docs: "docs/bilibili-config.md",
|
||||||
|
AccountType: AccountType,
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{
|
||||||
|
Key: "receiver_uids",
|
||||||
|
Label: "接收者 UID",
|
||||||
|
Type: meta.FieldNumberList,
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "2",
|
||||||
|
Help: "对方空间地址 space.bilibili.com/ 后面的那串数字",
|
||||||
|
},
|
||||||
|
{Key: "msg", Label: "私信内容", Type: meta.FieldTextarea, Required: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *BilibiliDynamicRuleValidator) Meta() meta.RuleMeta {
|
||||||
|
return meta.RuleMeta{
|
||||||
|
Type: RuleTypeDynamic,
|
||||||
|
Label: "发送 B 站动态",
|
||||||
|
Description: "触发时以关联的 B 站账号发送一条动态。",
|
||||||
|
Docs: "docs/bilibili-config.md",
|
||||||
|
AccountType: AccountType,
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{
|
||||||
|
Key: "msg",
|
||||||
|
Label: "动态内容",
|
||||||
|
Type: meta.FieldTextarea,
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "要发送的动态正文",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package bilibili
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BilibiliDynamicExecutor B站动态执行器
|
||||||
|
type BilibiliDynamicExecutor struct{}
|
||||||
|
|
||||||
|
func (e *BilibiliDynamicExecutor) GetType() string {
|
||||||
|
return RuleTypeDynamic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行B站动态规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||||
|
|
||||||
|
return SendBiliDynamicMsg(ctx, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BilibiliPrivateMessageExecutor B站私信执行器
|
||||||
|
type BilibiliPrivateMessageExecutor struct{}
|
||||||
|
|
||||||
|
func (e *BilibiliPrivateMessageExecutor) GetType() string {
|
||||||
|
return RuleTypePrivateMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行B站私信规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||||
|
|
||||||
|
return SendBiliPrivateMessage(ctx, rule)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package bilibili
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/CuteReimu/bilibili/v2"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newClient(rawCookies string) *bilibili.Client {
|
||||||
|
client := bilibili.New()
|
||||||
|
client.SetRawCookies(rawCookies)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Bilibili Client
|
||||||
|
func getBiliClient(rule *model.Rule) (*bilibili.Client, error) {
|
||||||
|
var accountConfig BiliAccount
|
||||||
|
if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newClient(accountConfig.RawCookies), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Bilibili Dynamic Config
|
||||||
|
func getBiliDynamicConfig(rule *model.Rule) (*BiliDynamicConfig, error) {
|
||||||
|
var biliDynamicConfig BiliDynamicConfig
|
||||||
|
if err := json.Unmarshal([]byte(rule.ConfigJson), &biliDynamicConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &biliDynamicConfig, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Package dingtalk 提供钉钉自定义机器人的提醒与规则执行能力
|
||||||
|
package dingtalk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/CatchZeng/dingtalk/pkg/dingtalk"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendDingTalkMsg 使用用户配置的钉钉机器人发送提醒
|
||||||
|
//
|
||||||
|
// 用户没有配置钉钉机器人时返回 ErrNoUserConfig
|
||||||
|
func SendDingTalkMsg(user *model.User, title string, msg string) error {
|
||||||
|
config := getDingTalkConfigFromUser(user)
|
||||||
|
if config == nil {
|
||||||
|
return ErrNoUserConfig
|
||||||
|
}
|
||||||
|
return send(config, title, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoUserConfig 表示用户没有配置钉钉提醒
|
||||||
|
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
|
||||||
|
|
||||||
|
func send(config *DingTalkConfig, title, msg string) error {
|
||||||
|
client := dingtalk.NewClient(config.AccessToken, config.Secret)
|
||||||
|
message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
|
||||||
|
|
||||||
|
if _, _, err := client.Send(message); err != nil {
|
||||||
|
return fmt.Errorf("发送钉钉消息失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package dingtalk
|
||||||
|
|
||||||
|
// RuleType 钉钉机器人规则类型标识
|
||||||
|
const RuleType = "dingtalk"
|
||||||
|
|
||||||
|
// DingTalkConfig 钉钉自定义机器人凭据,用户级提醒配置也用这个结构
|
||||||
|
type DingTalkConfig struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DingTalkRuleConfig 钉钉规则配置:凭据 + 要发送的内容
|
||||||
|
type DingTalkRuleConfig struct {
|
||||||
|
DingTalkConfig
|
||||||
|
Title string `json:"title"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package dingtalk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DingTalkRuleValidator 钉钉规则验证器
|
||||||
|
type DingTalkRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *DingTalkRuleValidator) GetType() string {
|
||||||
|
return RuleType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *DingTalkRuleValidator) Validate(configJSON string) error {
|
||||||
|
config, err := ParseRuleConfig(configJSON)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.AccessToken == "" {
|
||||||
|
return fmt.Errorf("钉钉规则配置中 access_token 不能为空")
|
||||||
|
}
|
||||||
|
if config.Title == "" {
|
||||||
|
return fmt.Errorf("钉钉规则配置中 title 不能为空")
|
||||||
|
}
|
||||||
|
if config.Msg == "" {
|
||||||
|
return fmt.Errorf("钉钉规则配置中 msg 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *DingTalkRuleValidator) Meta() meta.RuleMeta {
|
||||||
|
return meta.RuleMeta{
|
||||||
|
Type: RuleType,
|
||||||
|
Label: "钉钉机器人消息",
|
||||||
|
Description: "触发时通过钉钉自定义机器人发送一条 Markdown 消息,不需要关联账号。",
|
||||||
|
AccountType: "", // 凭据直接写在规则里,无需账号
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{Key: "access_token", Label: "Access Token", Type: meta.FieldPassword, Required: true, Secret: true, Help: "机器人 Webhook 中 access_token 参数的值"},
|
||||||
|
{Key: "secret", Label: "加签 Secret", Type: meta.FieldPassword, Secret: true, Help: "机器人安全设置选择“加签”时填写"},
|
||||||
|
{Key: "title", Label: "消息标题", Type: meta.FieldString, Required: true},
|
||||||
|
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "支持 Markdown"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRuleConfig 解析钉钉规则配置
|
||||||
|
func ParseRuleConfig(configJSON string) (*DingTalkRuleConfig, error) {
|
||||||
|
var config DingTalkRuleConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析钉钉规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package dingtalk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DingTalkExecutor 钉钉机器人执行器
|
||||||
|
type DingTalkExecutor struct{}
|
||||||
|
|
||||||
|
func (e *DingTalkExecutor) GetType() string {
|
||||||
|
return RuleType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DingTalkExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行钉钉规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||||
|
|
||||||
|
config, err := ParseRuleConfig(rule.ConfigJson)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return retry.Do(ctx, func() error {
|
||||||
|
return send(&config.DingTalkConfig, config.Title, config.Msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dingtalk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getDingTalkConfigFromUser(user *model.User) *DingTalkConfig {
|
||||||
|
if user.DingTalkConfig == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var config DingTalkConfig
|
||||||
|
err := json.Unmarshal([]byte(*user.DingTalkConfig), &config)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &config
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmailAccountConfigValidator 邮箱账号配置验证器
|
||||||
|
type EmailAccountConfigValidator struct{}
|
||||||
|
|
||||||
|
func (v *EmailAccountConfigValidator) GetType() string {
|
||||||
|
return AccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *EmailAccountConfigValidator) Validate(config string) error {
|
||||||
|
cfg, err := parseAccountConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SMTPServer == "" {
|
||||||
|
return fmt.Errorf("邮箱账号配置中 SMTP 服务器不能为空")
|
||||||
|
}
|
||||||
|
if cfg.Port <= 0 || cfg.Port > 65535 {
|
||||||
|
return fmt.Errorf("邮箱账号配置中 SMTP 端口不合法")
|
||||||
|
}
|
||||||
|
if cfg.Username == "" {
|
||||||
|
return fmt.Errorf("邮箱账号配置中用户名不能为空")
|
||||||
|
}
|
||||||
|
if cfg.Password == "" {
|
||||||
|
return fmt.Errorf("邮箱账号配置中密码不能为空")
|
||||||
|
}
|
||||||
|
switch cfg.SecurityOrDefault() {
|
||||||
|
case SecuritySSL, SecuritySTARTTLS, SecurityNone:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的加密方式: %s", cfg.Security)
|
||||||
|
}
|
||||||
|
if cfg.From != "" {
|
||||||
|
if _, err := mail.ParseAddress(cfg.From); err != nil {
|
||||||
|
return fmt.Errorf("发件人地址不合法: %s", cfg.From)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.TestDestination != "" {
|
||||||
|
if _, err := mail.ParseAddress(cfg.TestDestination); err != nil {
|
||||||
|
return fmt.Errorf("测试收件地址不合法: %s", cfg.TestDestination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 连接 SMTP 服务器并完成认证;填写了测试收件地址时会真的发一封测试邮件
|
||||||
|
func (v *EmailAccountConfigValidator) Test(config string) error {
|
||||||
|
cfg, err := parseAccountConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := newSMTPClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if cfg.TestDestination == "" {
|
||||||
|
if err := client.DialWithContext(ctx); err != nil {
|
||||||
|
return fmt.Errorf("连接 SMTP 服务器失败: %w", err)
|
||||||
|
}
|
||||||
|
return client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := buildMessage(cfg, cfg.TestDestination, "goodBaby 测试邮件", "这是一封来自 goodBaby 的测试邮件,收到即表示邮箱账号配置正确。")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.DialAndSendWithContext(ctx, message); err != nil {
|
||||||
|
return fmt.Errorf("发送测试邮件失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *EmailAccountConfigValidator) Meta() meta.AccountMeta {
|
||||||
|
return meta.AccountMeta{
|
||||||
|
Type: AccountType,
|
||||||
|
Label: "邮箱 (SMTP)",
|
||||||
|
Description: "配置一个用于发信的 SMTP 账号。",
|
||||||
|
Docs: "docs/email-config.md",
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{Key: "smtp_server", Label: "SMTP 服务器", Type: meta.FieldString, Required: true, Placeholder: "smtp.example.com"},
|
||||||
|
{Key: "port", Label: "端口", Type: meta.FieldNumber, Required: true, Default: 465, Help: "SSL 一般为 465,STARTTLS 一般为 587"},
|
||||||
|
{Key: "security", Label: "加密方式", Type: meta.FieldString, Required: false, Default: SecuritySSL, Help: "可选 ssl / starttls / none,留空按 ssl 处理"},
|
||||||
|
{Key: "username", Label: "用户名", Type: meta.FieldString, Required: true, Placeholder: "you@example.com"},
|
||||||
|
{Key: "password", Label: "密码 / 授权码", Type: meta.FieldPassword, Required: true, Secret: true},
|
||||||
|
{Key: "from", Label: "发件人地址", Type: meta.FieldString, Help: "留空则使用用户名作为发件人"},
|
||||||
|
{Key: "test_destination", Label: "测试收件地址", Type: meta.FieldString, Help: "填写后点击“测试”会真的发送一封测试邮件"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccountConfig(config string) (*EmailAccountConfig, error) {
|
||||||
|
var cfg EmailAccountConfig
|
||||||
|
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析邮箱账号配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendMail 向规则配置里的所有地址发送邮件。
|
||||||
|
//
|
||||||
|
// 每个地址单独重试,互不阻塞;全部失败/部分失败都会汇总成错误返回,
|
||||||
|
// 由 runner 记录到执行日志中。
|
||||||
|
func SendMail(ctx context.Context, rule *model.Rule) error {
|
||||||
|
emailRule, err := GetEmailRuleFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取邮件规则配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountConfig, err := GetEmailAccountFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取邮件账户配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
fails []string
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, destination := range emailRule.Destinations {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(address string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := sendMailMsgWithRetry(ctx, accountConfig, emailRule, address); err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
fails = append(fails, fmt.Sprintf("%s: %v", address, err))
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}(destination)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if len(fails) > 0 {
|
||||||
|
return fmt.Errorf("%d/%d 封邮件发送失败: %s",
|
||||||
|
len(fails), len(emailRule.Destinations), strings.Join(fails, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccountType 邮箱账号类型标识
|
||||||
|
AccountType = "email"
|
||||||
|
// RuleType 发送邮件的规则类型标识
|
||||||
|
RuleType = "email"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SMTP 加密方式
|
||||||
|
const (
|
||||||
|
SecuritySSL = "ssl" // 隐式 TLS,一般是 465 端口
|
||||||
|
SecuritySTARTTLS = "starttls" // 显式 TLS,一般是 587 端口
|
||||||
|
SecurityNone = "none" // 不加密,一般是 25 端口
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailAccountConfig struct {
|
||||||
|
SMTPServer string `json:"smtp_server"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
// 加密方式,留空时按 ssl 处理(兼容旧配置)
|
||||||
|
Security string `json:"security"`
|
||||||
|
// 发件人地址,留空时使用 Username
|
||||||
|
From string `json:"from"`
|
||||||
|
// 用于测试邮件发送的地址
|
||||||
|
TestDestination string `json:"test_destination"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityOrDefault 返回加密方式,兼容没有该字段的旧配置
|
||||||
|
func (c *EmailAccountConfig) SecurityOrDefault() string {
|
||||||
|
if c.Security == "" {
|
||||||
|
return SecuritySSL
|
||||||
|
}
|
||||||
|
return c.Security
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromOrDefault 返回发件人地址
|
||||||
|
func (c *EmailAccountConfig) FromOrDefault() string {
|
||||||
|
if c.From == "" {
|
||||||
|
return c.Username
|
||||||
|
}
|
||||||
|
return c.From
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailRule struct {
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Destinations []string `json:"destinations"` // 邮件地址列表
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmailRuleValidator 邮件规则验证器
|
||||||
|
type EmailRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *EmailRuleValidator) GetType() string {
|
||||||
|
return RuleType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *EmailRuleValidator) Validate(configJSON string) error {
|
||||||
|
var config EmailRule
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析邮件规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Title == "" {
|
||||||
|
return fmt.Errorf("邮件规则配置中 title 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Msg == "" {
|
||||||
|
return fmt.Errorf("邮件规则配置中 msg 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.Destinations) == 0 {
|
||||||
|
return fmt.Errorf("邮件规则配置中 destinations 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证邮箱地址格式
|
||||||
|
for _, dest := range config.Destinations {
|
||||||
|
if _, err := mail.ParseAddress(dest); err != nil {
|
||||||
|
return fmt.Errorf("无效的邮箱地址: %s", dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *EmailRuleValidator) Meta() meta.RuleMeta {
|
||||||
|
return meta.RuleMeta{
|
||||||
|
Type: RuleType,
|
||||||
|
Label: "发送邮件",
|
||||||
|
Description: "触发时通过关联的 SMTP 账号向指定地址发送邮件。",
|
||||||
|
Docs: "docs/email-config.md",
|
||||||
|
AccountType: AccountType,
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{Key: "title", Label: "邮件标题", Type: meta.FieldString, Required: true},
|
||||||
|
{Key: "msg", Label: "邮件正文", Type: meta.FieldTextarea, Required: true},
|
||||||
|
{Key: "destinations", Label: "收件人", Type: meta.FieldStringList, Required: true, Placeholder: "someone@example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmailExecutor 邮件执行器
|
||||||
|
type EmailExecutor struct{}
|
||||||
|
|
||||||
|
func (e *EmailExecutor) GetType() string {
|
||||||
|
return RuleType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmailExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行邮件规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||||
|
|
||||||
|
return SendMail(ctx, rule)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
"github.com/wneessen/go-mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetEmailAccountFromRule 从 Rule 中获取 EmailAccount 配置
|
||||||
|
func GetEmailAccountFromRule(rule *model.Rule) (*EmailAccountConfig, error) {
|
||||||
|
var emailAccountConfig EmailAccountConfig
|
||||||
|
if err := db.LoadAccountConfig(rule.AccountID, &emailAccountConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &emailAccountConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmailRuleFromRule 从 Rule 中获取 EmailRule 配置
|
||||||
|
func GetEmailRuleFromRule(rule *model.Rule) (*EmailRule, error) {
|
||||||
|
var emailRule EmailRule
|
||||||
|
if err := json.Unmarshal([]byte(rule.ConfigJson), &emailRule); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &emailRule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSMTPClient 按账号配置里的加密方式创建 SMTP 客户端
|
||||||
|
func newSMTPClient(cfg *EmailAccountConfig) (*mail.Client, error) {
|
||||||
|
options := []mail.Option{
|
||||||
|
mail.WithPort(cfg.Port),
|
||||||
|
mail.WithUsername(cfg.Username),
|
||||||
|
mail.WithPassword(cfg.Password),
|
||||||
|
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cfg.SecurityOrDefault() {
|
||||||
|
case SecuritySSL:
|
||||||
|
options = append(options, mail.WithSSL())
|
||||||
|
case SecuritySTARTTLS:
|
||||||
|
options = append(options, mail.WithTLSPolicy(mail.TLSMandatory))
|
||||||
|
case SecurityNone:
|
||||||
|
options = append(options, mail.WithTLSPolicy(mail.NoTLS))
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := mail.NewClient(cfg.SMTPServer, options...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建邮件客户端失败: %w", err)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.Msg, error) {
|
||||||
|
message := mail.NewMsg()
|
||||||
|
if err := message.From(cfg.FromOrDefault()); err != nil {
|
||||||
|
return nil, fmt.Errorf("设置发件人失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := message.To(address); err != nil {
|
||||||
|
return nil, fmt.Errorf("设置收件人失败: %w", err)
|
||||||
|
}
|
||||||
|
message.Subject(title)
|
||||||
|
message.SetBodyString(mail.TypeTextPlain, body)
|
||||||
|
return message, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMailMsgWithRetry(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
|
||||||
|
return retry.Do(ctx, func() error {
|
||||||
|
return sendMailMsg(ctx, cfg, rule, address)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMailMsg(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
|
||||||
|
client, err := newSMTPClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := buildMessage(cfg, address, rule.Title, rule.Msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.DialAndSendWithContext(ctx, message); err != nil {
|
||||||
|
return fmt.Errorf("发送邮件失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package fwalert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// send 发送消息
|
||||||
|
func send(config *FwAlertRuleConfig) error {
|
||||||
|
restyClient := resty.New()
|
||||||
|
req := restyClient.R()
|
||||||
|
|
||||||
|
req.SetBody(FwAlertRequest{
|
||||||
|
Message: config.Msg,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := req.Execute(resty.MethodPost, config.WebhookURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != 200 {
|
||||||
|
return fmt.Errorf("发送饭碗警告失败: %s", resp.Status())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package fwalert
|
||||||
|
|
||||||
|
const RuleTypeFwalert = "fwalert"
|
||||||
|
|
||||||
|
type FwAlertRuleConfig struct {
|
||||||
|
WebhookURL string `json:"webhook_url"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FwAlertRequest struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package fwalert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FwalertRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *FwalertRuleValidator) GetType() string {
|
||||||
|
return RuleTypeFwalert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *FwalertRuleValidator) Validate(configJSON string) error {
|
||||||
|
var config FwAlertRuleConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(config.WebhookURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("饭碗警告规则配置中 webhook_url 格式不正确: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("饭碗警告规则配置中 webhook_url 必须是 HTTP 或 HTTPS URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Msg == "" {
|
||||||
|
return fmt.Errorf("饭碗警告规则配置中 msg 不能为空")
|
||||||
|
}
|
||||||
|
if len([]rune(config.Msg)) > 1000 {
|
||||||
|
return fmt.Errorf("饭碗警告内容过长")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *FwalertRuleValidator) Meta() meta.RuleMeta {
|
||||||
|
return meta.RuleMeta{
|
||||||
|
Type: RuleTypeFwalert,
|
||||||
|
Label: "发送饭碗警告",
|
||||||
|
Description: "触发时发送饭碗警告消息。",
|
||||||
|
Docs: "docs/fwalert-config.md",
|
||||||
|
AccountType: "",
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{Key: "webhook_url", Label: "Webhook URL", Type: meta.FieldString, Required: true, Help: "饭碗警告的触发地址", Placeholder: "https://fwalert.com/xxx-xxx-xxx-xxx-xxx"},
|
||||||
|
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "最大长度 1000 个字符"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRuleConfig 解析饭碗警告规则配置
|
||||||
|
func ParseRuleConfig(configJSON string) (*FwAlertRuleConfig, error) {
|
||||||
|
var config FwAlertRuleConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package fwalert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FwalertExecutor 饭碗警告执行器
|
||||||
|
type FwalertExecutor struct{}
|
||||||
|
|
||||||
|
func (e *FwalertExecutor) GetType() string {
|
||||||
|
return RuleTypeFwalert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FwalertExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行饭碗警告规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||||
|
|
||||||
|
config, err := ParseRuleConfig(rule.ConfigJson)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return retry.Do(ctx, func() error {
|
||||||
|
return send(config)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/go-github/v84/github"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitHubAccountConfigValidator GitHub账号配置验证器
|
||||||
|
type GitHubAccountConfigValidator struct{}
|
||||||
|
|
||||||
|
func (v *GitHubAccountConfigValidator) GetType() string {
|
||||||
|
return AccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *GitHubAccountConfigValidator) Validate(config string) error {
|
||||||
|
cfg, err := parseAccount(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Token == "" {
|
||||||
|
return fmt.Errorf("GitHub账号配置中Token不能为空")
|
||||||
|
}
|
||||||
|
if cfg.Owner == "" {
|
||||||
|
return fmt.Errorf("GitHub账号配置中Owner不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 用 token 拉一次当前用户信息,验证 token 是否有效
|
||||||
|
func (v *GitHubAccountConfigValidator) Test(config string) error {
|
||||||
|
cfg, err := parseAccount(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := github.NewClient(nil).WithAuthToken(cfg.Token)
|
||||||
|
user, _, err := client.Users.Get(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GitHub Token 校验失败: %w", err)
|
||||||
|
}
|
||||||
|
if user == nil || user.GetLogin() == "" {
|
||||||
|
return fmt.Errorf("GitHub Token 无效")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *GitHubAccountConfigValidator) Meta() meta.AccountMeta {
|
||||||
|
return meta.AccountMeta{
|
||||||
|
Type: AccountType,
|
||||||
|
Label: "GitHub",
|
||||||
|
Description: "使用 Personal Access Token 操作仓库,需要 repo 权限。",
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{Key: "token", Label: "Personal Access Token", Type: meta.FieldPassword, Required: true, Secret: true, Placeholder: "ghp_xxx", Help: "需要勾选 repo 权限"},
|
||||||
|
{Key: "owner", Label: "仓库所有者", Type: meta.FieldString, Required: true, Placeholder: "your-github-name", Help: "用户名或组织名"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccount(config string) (*GithubAccount, error) {
|
||||||
|
var cfg GithubAccount
|
||||||
|
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析GitHub账号配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MakeRepositoryPublic 将规则中配置的仓库设置为 public
|
||||||
|
func MakeRepositoryPublic(ctx context.Context, rule *model.Rule) error {
|
||||||
|
reposConfig, account, err := GetGithubReposAndAccountFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fails []string
|
||||||
|
for _, repo := range reposConfig.Repos {
|
||||||
|
err := retry.Do(ctx, func() error {
|
||||||
|
return SetRepositoryPublic(ctx, account.Token, account.Owner, repo)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fails = append(fails, fmt.Sprintf("%s: %v", repo, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fails) > 0 {
|
||||||
|
return fmt.Errorf("%d/%d 个仓库设置为 public 失败: %s",
|
||||||
|
len(fails), len(reposConfig.Repos), strings.Join(fails, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccountType GitHub 账号类型标识
|
||||||
|
AccountType = "github"
|
||||||
|
// RuleTypeMakeRepoPublic 公开仓库的规则类型标识
|
||||||
|
RuleTypeMakeRepoPublic = "github-repo-public"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GithubReposConfig struct {
|
||||||
|
Repos []string `json:"repos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GithubAccount struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GithubMakeRepositoryPublicRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *GithubMakeRepositoryPublicRuleValidator) GetType() string {
|
||||||
|
return RuleTypeMakeRepoPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) error {
|
||||||
|
var config GithubReposConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析 GitHub 规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.Repos) == 0 {
|
||||||
|
return fmt.Errorf("GitHub 规则配置错误: 至少需要指定一个仓库")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, repo := range config.Repos {
|
||||||
|
if strings.TrimSpace(repo) == "" {
|
||||||
|
return fmt.Errorf("GitHub 规则配置错误: 仓库名不能为空")
|
||||||
|
}
|
||||||
|
if strings.Contains(repo, "/") {
|
||||||
|
return fmt.Errorf("GitHub 规则配置错误: 只填写仓库名(不含所有者),收到 %q", repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *GithubMakeRepositoryPublicRuleValidator) Meta() meta.RuleMeta {
|
||||||
|
return meta.RuleMeta{
|
||||||
|
Type: RuleTypeMakeRepoPublic,
|
||||||
|
Label: "公开 GitHub 仓库",
|
||||||
|
Description: "触发时把指定的私有仓库改为公开。",
|
||||||
|
AccountType: AccountType,
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{
|
||||||
|
Key: "repos",
|
||||||
|
Label: "仓库列表",
|
||||||
|
Type: meta.FieldStringList,
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "my-repo",
|
||||||
|
Help: "只填仓库名,所有者取自关联的 GitHub 账号",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GithubMakeRepoPublicExecutor GitHub仓库公开执行器
|
||||||
|
type GithubMakeRepoPublicExecutor struct{}
|
||||||
|
|
||||||
|
func (e *GithubMakeRepoPublicExecutor) GetType() string {
|
||||||
|
return RuleTypeMakeRepoPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行GitHub仓库公开规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||||
|
|
||||||
|
return MakeRepositoryPublic(ctx, rule)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/go-github/v84/github"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetGithubReposAndAccountFromRule 从 rule 中获取 GithubReposConfig 和 GithubAccount
|
||||||
|
func GetGithubReposAndAccountFromRule(rule *model.Rule) (*GithubReposConfig, *GithubAccount, error) {
|
||||||
|
var githubReposConfig GithubReposConfig
|
||||||
|
if err := json.Unmarshal([]byte(rule.ConfigJson), &githubReposConfig); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("解析 GitHub 规则配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var githubAccount GithubAccount
|
||||||
|
if err := db.LoadAccountConfig(rule.AccountID, &githubAccount); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &githubReposConfig, &githubAccount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRepositoryPublic 将仓库设置为 public
|
||||||
|
func SetRepositoryPublic(ctx context.Context, token, owner, repo string) error {
|
||||||
|
client := github.NewClient(nil).WithAuthToken(token)
|
||||||
|
|
||||||
|
opts := &github.Repository{
|
||||||
|
Visibility: github.Ptr("public"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := client.Repositories.Edit(ctx, owner, repo, opts)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package onebot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OneBotAccountConfigValidator OneBot账号配置验证器
|
||||||
|
type OneBotAccountConfigValidator struct{}
|
||||||
|
|
||||||
|
func (v *OneBotAccountConfigValidator) GetType() string {
|
||||||
|
return AccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *OneBotAccountConfigValidator) Validate(config string) error {
|
||||||
|
cfg, err := parseAccount(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Token == "" {
|
||||||
|
return fmt.Errorf("OneBot账号配置中Token不能为空")
|
||||||
|
}
|
||||||
|
if cfg.URL == "" {
|
||||||
|
return fmt.Errorf("OneBot账号配置中URL不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(cfg.URL)
|
||||||
|
if err != nil || parsed.Host == "" || !strings.HasPrefix(parsed.Scheme, "http") {
|
||||||
|
return fmt.Errorf("OneBot账号配置中URL不合法,应形如 http://localhost:3000")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 调用 get_login_info 验证地址与 Token
|
||||||
|
func (v *OneBotAccountConfigValidator) Test(config string) error {
|
||||||
|
cfg, err := parseAccount(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp apiResponse
|
||||||
|
if err := newClient(cfg).DoRequest("POST", "/get_login_info", map[string]any{}, &resp); err != nil {
|
||||||
|
return fmt.Errorf("连接 OneBot 失败: %w", err)
|
||||||
|
}
|
||||||
|
return resp.err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *OneBotAccountConfigValidator) Meta() meta.AccountMeta {
|
||||||
|
return meta.AccountMeta{
|
||||||
|
Type: AccountType,
|
||||||
|
Label: "OneBot (QQ)",
|
||||||
|
Description: "对接 NapCat / go-cqhttp 等 OneBot HTTP 服务,用于发送 QQ 消息。",
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{Key: "url", Label: "HTTP 服务地址", Type: meta.FieldString, Required: true, Placeholder: "http://localhost:3000"},
|
||||||
|
{Key: "token", Label: "Access Token", Type: meta.FieldPassword, Required: true, Secret: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccount(config string) (*OneBotAccount, error) {
|
||||||
|
var cfg OneBotAccount
|
||||||
|
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析OneBot账号配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package onebot
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccountType OneBot 账号类型标识
|
||||||
|
AccountType = "onebot"
|
||||||
|
// RuleType 发送 OneBot 消息的规则类型标识
|
||||||
|
RuleType = "onebot"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OneBotConfig struct {
|
||||||
|
SendGroups []int64 `json:"send_groups"` // 群号列表
|
||||||
|
SendUsers []int64 `json:"send_users"` // 好友 QQ 号列表
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OneBotAccount struct {
|
||||||
|
URL string `json:"url"` // 例如 "http://localhost:5700"
|
||||||
|
Token string `json:"token"` // 例如 "your_token_here"
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiResponse OneBot HTTP API 的通用响应信封
|
||||||
|
type apiResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Retcode int `json:"retcode"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Wording string `json:"wording"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// err 把 OneBot 返回的业务错误转换成 Go error
|
||||||
|
func (r *apiResponse) err() error {
|
||||||
|
if r.Status == "ok" && r.Retcode == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
detail := r.Wording
|
||||||
|
if detail == "" {
|
||||||
|
detail = r.Message
|
||||||
|
}
|
||||||
|
if detail == "" {
|
||||||
|
detail = "请检查 OneBot 地址与 Token"
|
||||||
|
}
|
||||||
|
return &APIError{Status: r.Status, Retcode: r.Retcode, Detail: detail}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError OneBot 返回的业务错误
|
||||||
|
type APIError struct {
|
||||||
|
Status string
|
||||||
|
Retcode int
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
return "OneBot 返回错误(status=" + e.Status + "): " + e.Detail
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package onebot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendOneBotMsg 向规则中配置的群和好友发送消息
|
||||||
|
func SendOneBotMsg(ctx context.Context, rule *model.Rule) error {
|
||||||
|
oneBotConfig, err := getOneBotConfigFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
oneBotAccount, err := getOneBotAccountFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取OneBot账户失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fails []string
|
||||||
|
total := 0
|
||||||
|
|
||||||
|
for _, groupID := range oneBotConfig.SendGroups {
|
||||||
|
total++
|
||||||
|
err := retry.Do(ctx, func() error {
|
||||||
|
return sendGroupMsg(oneBotAccount, groupID, oneBotConfig.Msg)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fails = append(fails, fmt.Sprintf("群 %d: %v", groupID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, userID := range oneBotConfig.SendUsers {
|
||||||
|
total++
|
||||||
|
err := retry.Do(ctx, func() error {
|
||||||
|
return sendPrivateMsg(oneBotAccount, userID, oneBotConfig.Msg)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fails = append(fails, fmt.Sprintf("好友 %d: %v", userID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fails) > 0 {
|
||||||
|
return fmt.Errorf("%d/%d 条消息发送失败: %s", len(fails), total, strings.Join(fails, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package onebot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OneBotRuleValidator OneBot规则验证器
|
||||||
|
type OneBotRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *OneBotRuleValidator) GetType() string {
|
||||||
|
return RuleType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *OneBotRuleValidator) Validate(configJSON string) error {
|
||||||
|
var config OneBotConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析OneBot规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Msg == "" {
|
||||||
|
return fmt.Errorf("OneBot规则配置中 msg 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.SendGroups) == 0 && len(config.SendUsers) == 0 {
|
||||||
|
return fmt.Errorf("OneBot规则配置中 send_groups 与 send_users 不能同时为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range append(append([]int64{}, config.SendGroups...), config.SendUsers...) {
|
||||||
|
if id <= 0 {
|
||||||
|
return fmt.Errorf("OneBot规则配置中存在非法的群号/QQ号: %d", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *OneBotRuleValidator) Meta() meta.RuleMeta {
|
||||||
|
return meta.RuleMeta{
|
||||||
|
Type: RuleType,
|
||||||
|
Label: "发送 QQ 消息",
|
||||||
|
Description: "触发时通过 OneBot 向指定的群或好友发送消息。",
|
||||||
|
AccountType: AccountType,
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true},
|
||||||
|
{Key: "send_groups", Label: "群号", Type: meta.FieldNumberList, Placeholder: "123456789"},
|
||||||
|
{Key: "send_users", Label: "好友 QQ 号", Type: meta.FieldNumberList, Placeholder: "123456789"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package onebot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OneBotExecutor OneBot执行器
|
||||||
|
type OneBotExecutor struct{}
|
||||||
|
|
||||||
|
func (e *OneBotExecutor) GetType() string {
|
||||||
|
return RuleType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *OneBotExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行OneBot规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||||
|
|
||||||
|
return SendOneBotMsg(ctx, rule)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package onebot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
napcat "github.com/ssdomei232/napcat-http-go-sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newClient(account *OneBotAccount) *napcat.Client {
|
||||||
|
return napcat.NewClient(account.Token, account.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Rule 中获取 OneBotConfig
|
||||||
|
func getOneBotConfigFromRule(rule *model.Rule) (*OneBotConfig, error) {
|
||||||
|
var oneBotConfig OneBotConfig
|
||||||
|
if err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析OneBot规则配置失败: %w", err)
|
||||||
|
}
|
||||||
|
return &oneBotConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Rule 中获取 OneBotAccount
|
||||||
|
func getOneBotAccountFromRule(rule *model.Rule) (*OneBotAccount, error) {
|
||||||
|
var oneBotAccount OneBotAccount
|
||||||
|
if err := db.LoadAccountConfig(rule.AccountID, &oneBotAccount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &oneBotAccount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送一条群消息
|
||||||
|
//
|
||||||
|
// SDK 只返回传输层错误,这里自己解析 OneBot 的响应信封,
|
||||||
|
// 否则 token 错误、群号不存在这类失败会被当成发送成功。
|
||||||
|
func sendGroupMsg(account *OneBotAccount, groupID int64, msg string) error {
|
||||||
|
return call(account, "/send_group_msg", map[string]any{
|
||||||
|
"group_id": groupID,
|
||||||
|
"message": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送一条私聊消息
|
||||||
|
func sendPrivateMsg(account *OneBotAccount, userID int64, msg string) error {
|
||||||
|
return call(account, "/send_private_msg", map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
"message": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(account *OneBotAccount, endpoint string, payload map[string]any) error {
|
||||||
|
var resp apiResponse
|
||||||
|
if err := newClient(account).DoRequest("POST", endpoint, payload, &resp); err != nil {
|
||||||
|
return fmt.Errorf("请求 OneBot 失败: %w", err)
|
||||||
|
}
|
||||||
|
return resp.err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package rainyun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"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 := parseAccount(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.APIKey == "" {
|
||||||
|
return fmt.Errorf("雨云账号配置中APIKey不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccount(config string) (*RainyunAccount, error) {
|
||||||
|
var cfg RainyunAccount
|
||||||
|
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析雨云账号配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *RainyunAccountConfigValidator) Test(config string) error {
|
||||||
|
cfg, err := parseAccount(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,22 @@
|
|||||||
|
package rainyun
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccountType Rainyun 账号类型标识
|
||||||
|
AccountType = "rainyun"
|
||||||
|
// RuleTypeWorkOrder 发送 Rainyun 工单消息的规则类型标识
|
||||||
|
RuleTypeWorkOrder = "rainyun-workorder"
|
||||||
|
RuleTypeRunAway = "rainyun-runaway"
|
||||||
|
)
|
||||||
|
|
||||||
|
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,126 @@
|
|||||||
|
package rainyun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SendRainyunWorkorderMsg(ctx context.Context, rule *model.Rule) error {
|
||||||
|
rainyunConfig, err := getRainyunConfigFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rainyunAccount, err := getRainyunAccountFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = retry.Do(ctx, func() error {
|
||||||
|
return sendRainyunWorkorderMsg(rainyunConfig, rainyunAccount)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendRainyunWorkorderMsg(rainyunRuleConfig *RainyunWorkOrderRule, rainyunAccount *RainyunAccount) error {
|
||||||
|
client := rain.NewClient(rainyunAccount.APIKey)
|
||||||
|
workorderClient := workorder.Client{Client: client}
|
||||||
|
|
||||||
|
_, err := workorderClient.CreateWorkOrder(&workorder.CreateWorkerorderRequest{
|
||||||
|
Content: rainyunRuleConfig.Msg,
|
||||||
|
IsAuthed: false,
|
||||||
|
IsUrgent: 1,
|
||||||
|
Title: rainyunRuleConfig.Title,
|
||||||
|
Type: "reward",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RainyunRunAway(ctx context.Context, rule *model.Rule) error {
|
||||||
|
rainyunAccount, err := getRainyunAccountFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client := rain.NewClient(rainyunAccount.APIKey)
|
||||||
|
|
||||||
|
var fails []string
|
||||||
|
total := 0
|
||||||
|
|
||||||
|
// 重装所有RCS
|
||||||
|
rcsClient := rcs.Client{Client: client}
|
||||||
|
options := rain.EncodingStandardQueryParameters(1, 20)
|
||||||
|
rcsList, err := rcsClient.GetRcsList(options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rcsList.Data.TotalRecords == 0 {
|
||||||
|
return nil
|
||||||
|
} else if rcsList.Data.TotalRecords > 20 {
|
||||||
|
rcsTotal := rcsList.Data.TotalRecords
|
||||||
|
options := rain.EncodingStandardQueryParameters(1, rcsTotal)
|
||||||
|
rcsList, err = rcsClient.GetRcsList(options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rcs := range rcsList.Data.Records {
|
||||||
|
total++
|
||||||
|
err := retry.Do(context.Background(), func() error {
|
||||||
|
return reinstallOneRCS(rcs.ID, &rcsClient)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fails = append(fails, fmt.Sprintf("RCS %d: %v", rcs.ID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重装所有RGS
|
||||||
|
rgsClient := rgs.Client{Client: client}
|
||||||
|
rgsOptions := rain.EncodingStandardQueryParameters(1, 20)
|
||||||
|
rgsList, err := rgsClient.GetRgsList(rgsOptions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rgsList.Data.TotalRecords == 0 {
|
||||||
|
return nil
|
||||||
|
} else if rgsList.Data.TotalRecords > 20 {
|
||||||
|
rgsTotal := rgsList.Data.TotalRecords
|
||||||
|
rgsOptions := rain.EncodingStandardQueryParameters(1, rgsTotal)
|
||||||
|
rgsList, err = rgsClient.GetRgsList(rgsOptions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rgs := range rgsList.Data.Records {
|
||||||
|
total++
|
||||||
|
err := retry.Do(context.Background(), func() error {
|
||||||
|
return reinstallOneRGS(rgs.ID, &rgsClient)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fails = append(fails, fmt.Sprintf("RGS %d: %v", rgs.ID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fails) > 0 {
|
||||||
|
return fmt.Errorf("%d/%d 台云服务器重装失败: %s", len(fails), total, strings.Join(fails, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package rainyun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"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 {
|
||||||
|
var config RainyunWorkOrderRule
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析雨云规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Title == "" {
|
||||||
|
return fmt.Errorf("雨云规则配置中 title 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Msg == "" {
|
||||||
|
return fmt.Errorf("雨云规则配置中 msg 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
var config RainyunRunAwayRule
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析雨云规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.IK != "我已知晓" {
|
||||||
|
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,33 @@
|
|||||||
|
package rainyun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RainyunExecutor 雨云执行器
|
||||||
|
type RainyunWorkorderExecutor struct{}
|
||||||
|
|
||||||
|
func (e *RainyunWorkorderExecutor) GetType() string {
|
||||||
|
return RuleTypeWorkOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行雨云工单规则: %s", rule.Name)
|
||||||
|
|
||||||
|
return SendRainyunWorkorderMsg(ctx, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RainyunRunAwayExecutor 雨云跑路执行器
|
||||||
|
type RainyunRunAwayExecutor struct{}
|
||||||
|
|
||||||
|
func (e *RainyunRunAwayExecutor) GetType() string {
|
||||||
|
return RuleTypeRunAway
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RainyunRunAwayExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行雨云跑路规则: %s", rule.Name)
|
||||||
|
return RainyunRunAway(ctx, rule)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package rainyun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 从 Rule 中获取 RainyunConfig
|
||||||
|
func getRainyunConfigFromRule(rule *model.Rule) (*RainyunWorkOrderRule, error) {
|
||||||
|
var rainyunConfig RainyunWorkOrderRule
|
||||||
|
if err := json.Unmarshal([]byte(rule.ConfigJson), &rainyunConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析Rainyun规则配置失败: %w", err)
|
||||||
|
}
|
||||||
|
return &rainyunConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Rule 中获取 RainyunAccount
|
||||||
|
func getRainyunAccountFromRule(rule *model.Rule) (*RainyunAccount, error) {
|
||||||
|
var rainyunAccount RainyunAccount
|
||||||
|
if err := db.LoadAccountConfig(rule.AccountID, &rainyunAccount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rainyunAccount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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" {
|
||||||
|
rcsReinstallRequest := &rcs.ReinstallRcsRequest{
|
||||||
|
OsID: os.ID,
|
||||||
|
ResetOsd: true,
|
||||||
|
}
|
||||||
|
_, err := rcsClient.ReinstallRcs(rcsID, rcsReinstallRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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" {
|
||||||
|
rgsReinstallRequest := &rcs.ReinstallRcsRequest{
|
||||||
|
OsID: os.ID,
|
||||||
|
ResetOsd: true,
|
||||||
|
}
|
||||||
|
_, err := rgsClient.Reinstallgs(rgsID, rgsReinstallRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,52 +1,92 @@
|
|||||||
module github.com/ssdomei232/goodBaby
|
module github.com/ssdomei232/goodBaby
|
||||||
|
|
||||||
go 1.24.10
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/CatchZeng/dingtalk v1.5.0
|
github.com/CatchZeng/dingtalk v1.5.0
|
||||||
github.com/CuteReimu/bilibili/v2 v2.3.5
|
github.com/CuteReimu/bilibili/v2 v2.3.5
|
||||||
|
github.com/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/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/robfig/cron/v3 v3.0.1
|
||||||
github.com/ssdomei232/napcat-http-go-sdk v1.0.0
|
github.com/ssdomei232/napcat-http-go-sdk v1.0.0
|
||||||
|
github.com/ssdomei232/rainyun-go-sdk/v2 v2.0.6
|
||||||
github.com/wneessen/go-mail v0.7.2
|
github.com/wneessen/go-mail v0.7.2
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Baozisoftware/qrcode-terminal-go v0.0.0-20170407111555-c0650d8dff0f // indirect
|
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 v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.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/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/go-resty/resty/v2 v2.17.2 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/google/go-querystring v1.2.0 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
|
github.com/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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
github.com/spf13/cast v1.9.2 // indirect
|
github.com/spf13/cast v1.9.2 // indirect
|
||||||
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.42.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/mod v0.27.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
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 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI=
|
||||||
bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA=
|
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 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/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 h1:3YNpGIdeZ4pTfjjVyC2G1CCzWocMmYniBY8CXiOY9Ss=
|
||||||
github.com/CatchZeng/dingtalk v1.5.0/go.mod h1:/CT0Eskr26XfjAybLBLV+DjvPHaznwiFXRBndQp7zZ8=
|
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 h1:0MpBOJL6Q7kP3lFItC7NQ3MLG/I1uIzZBjemXIE0CRM=
|
||||||
github.com/CuteReimu/bilibili/v2 v2.3.5/go.mod h1:FHKH1cc04M/qpQhF+DaFIBFleLtvgMwYfZCNhHK/kBQ=
|
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 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
|
github.com/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||||
|
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -33,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-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
||||||
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang/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 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
|
||||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA=
|
||||||
|
github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ=
|
||||||
|
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||||
|
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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=
|
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-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
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 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
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 h1:BY3iqqlZw5letiCwTmfMrhyqsx0a96eL5LpDuinnOSE=
|
||||||
github.com/ssdomei232/napcat-http-go-sdk v1.0.0/go.mod h1:yPsSaMs3OyKMVPXwIs5ZB5G532vRi1GvL/WiWUEwblU=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
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/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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
|
||||||
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
|
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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
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 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
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 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
|
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,33 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadAccountConfig 读取账号并把 Config 字段反序列化到 out。
|
||||||
|
//
|
||||||
|
// 各个 driver 之前都各自实现了一遍这段逻辑,其中 github driver 甚至
|
||||||
|
// 直接把驱动内的结构体当成 gorm model 查询,这里统一收敛。
|
||||||
|
func LoadAccountConfig(accountID uint, out any) error {
|
||||||
|
if accountID == 0 {
|
||||||
|
return fmt.Errorf("该规则没有关联账号")
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var account model.Account
|
||||||
|
if err := gormDB.First(&account, accountID).Error; err != nil {
|
||||||
|
return fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(account.Config), out); err != nil {
|
||||||
|
return fmt.Errorf("解析账号(ID: %d)配置失败: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/ssdomei232/goodBaby/configs"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
once sync.Once
|
||||||
|
instance *gorm.DB
|
||||||
|
initErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetGormDB 返回全局唯一的 gorm 连接。
|
||||||
|
//
|
||||||
|
// 之前每次调用都会新开一个 sqlite 连接,在并发执行规则时很容易触发
|
||||||
|
// "database is locked",这里改为单例并开启 WAL。
|
||||||
|
func GetGormDB() (*gorm.DB, error) {
|
||||||
|
once.Do(func() {
|
||||||
|
instance, initErr = open()
|
||||||
|
})
|
||||||
|
return instance, initErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustInit 在启动阶段初始化数据库并执行迁移,失败直接退出
|
||||||
|
func MustInit() *gorm.DB {
|
||||||
|
gormDB, err := GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("初始化数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := AutoMigrate(gormDB); err != nil {
|
||||||
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := ensureAdmin(gormDB); err != nil {
|
||||||
|
log.Fatalf("初始化管理员失败: %v", err)
|
||||||
|
}
|
||||||
|
return gormDB
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAdmin 保证系统里至少有一个管理员。
|
||||||
|
//
|
||||||
|
// IsAdmin 是后加的字段,老数据库升级上来时所有人都不是管理员,
|
||||||
|
// 这里把最早注册的那个用户提升为管理员。
|
||||||
|
func ensureAdmin(gormDB *gorm.DB) error {
|
||||||
|
var adminCount int64
|
||||||
|
if err := gormDB.Model(&model.User{}).Where("is_admin = ?", true).Count(&adminCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if adminCount > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var first model.User
|
||||||
|
if err := gormDB.Order("id ASC").First(&first).Error; err != nil {
|
||||||
|
// 一个用户都没有,等第一个注册的人来当管理员
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gormDB.Model(&model.User{}).Where("id = ?", first.ID).
|
||||||
|
Update("is_admin", true).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("已将用户 %s (ID: %d) 提升为管理员", first.Username, first.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func open() (*gorm.DB, error) {
|
||||||
|
config, err := configs.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gormConfig := &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)}
|
||||||
|
|
||||||
|
var (
|
||||||
|
gormDB *gorm.DB
|
||||||
|
driver = config.DatabaseDriver
|
||||||
|
)
|
||||||
|
|
||||||
|
switch driver {
|
||||||
|
case configs.DriverPostgres:
|
||||||
|
if config.DatabaseDSN == "" {
|
||||||
|
return nil, fmt.Errorf("使用 postgres 时必须配置 database_dsn")
|
||||||
|
}
|
||||||
|
gormDB, err = gorm.Open(postgres.Open(config.DatabaseDSN), gormConfig)
|
||||||
|
case configs.DriverSQLite, "":
|
||||||
|
driver = configs.DriverSQLite
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
|
||||||
|
config.DatabasePath,
|
||||||
|
)
|
||||||
|
gormDB, err = gorm.Open(sqlite.Open(dsn), gormConfig)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("不支持的数据库驱动: %s(可选 sqlite / postgres)", config.DatabaseDriver)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开数据库失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := gormDB.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if driver == configs.DriverSQLite {
|
||||||
|
// sqlite 单写多读,限制连接数避免写冲突
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
sqlDB.SetMaxIdleConns(1)
|
||||||
|
} else {
|
||||||
|
sqlDB.SetMaxOpenConns(20)
|
||||||
|
sqlDB.SetMaxIdleConns(5)
|
||||||
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提前握手,配置写错时在启动阶段就报错,而不是等到第一次查询
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("数据库已连接 (driver=%s)", driver)
|
||||||
|
return gormDB, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoMigrate 建表 / 补齐新增字段
|
||||||
|
func AutoMigrate(gormDB *gorm.DB) error {
|
||||||
|
return gormDB.AutoMigrate(
|
||||||
|
&model.User{},
|
||||||
|
&model.Timer{},
|
||||||
|
&model.Rule{},
|
||||||
|
&model.Account{},
|
||||||
|
&model.ExecutionLog{},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Package logstore 负责写入与清理规则执行日志
|
||||||
|
package logstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/configs"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record 写入一条执行日志,失败只记录到标准输出,不影响主流程
|
||||||
|
func Record(entry *model.ExecutionLog) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("写入执行日志失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.CreateAt == 0 {
|
||||||
|
entry.CreateAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gormDB.Create(entry).Error; err != nil {
|
||||||
|
log.Printf("写入执行日志失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trim(entry.UID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim 按配置保留每个用户最近的 N 条日志
|
||||||
|
func trim(uid uint) {
|
||||||
|
config, err := configs.GetConfig()
|
||||||
|
if err != nil || config.LogRetainCount <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 Find 而不是 First:日志没超出保留条数是常态,
|
||||||
|
// First 找不到记录会返回 ErrRecordNotFound,每写一条日志都刷一行 gorm 警告。
|
||||||
|
var cutoff []model.ExecutionLog
|
||||||
|
if err := gormDB.Where("uid = ?", uid).
|
||||||
|
Order("id DESC").
|
||||||
|
Offset(config.LogRetainCount).
|
||||||
|
Limit(1).
|
||||||
|
Find(&cutoff).Error; err != nil {
|
||||||
|
log.Printf("清理执行日志失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(cutoff) == 0 {
|
||||||
|
// 没有超出保留条数
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gormDB.Where("uid = ? AND id <= ?", uid, cutoff[0].ID).
|
||||||
|
Delete(&model.ExecutionLog{}).Error; err != nil {
|
||||||
|
log.Printf("清理执行日志失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package reminder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/logstore"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reminder 在 checker 检查到 timer 即将到期时,向用户发送提醒
|
||||||
|
func Reminder(timer *model.Timer) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to get gorm db: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 获取 timer 对应的 user
|
||||||
|
var user model.User
|
||||||
|
if err := gormDB.Where("id = ?", timer.UID).First(&user).Error; err != nil {
|
||||||
|
log.Printf("failed to get user: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 向用户发送提醒
|
||||||
|
deadline := time.Unix(timer.NextSignTime(), 0).Format("2006-01-02 15:04:05")
|
||||||
|
title := fmt.Sprintf("goodBaby: %s 即将到期", timer.Name)
|
||||||
|
msg := fmt.Sprintf("### %s\n\n定时器 **%s** 将在 %s 到期。\n\n请及时签到,否则关联的规则会被执行。", title, timer.Name, deadline)
|
||||||
|
|
||||||
|
err = dingtalk.SendDingTalkMsg(&user, title, msg)
|
||||||
|
if errors.Is(err, dingtalk.ErrNoUserConfig) {
|
||||||
|
// 用户没有配置提醒渠道,不算失败
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := "提醒发送成功"
|
||||||
|
if err != nil {
|
||||||
|
message = err.Error()
|
||||||
|
log.Printf("发送提醒失败 [Timer ID: %d]: %v", timer.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logstore.Record(&model.ExecutionLog{
|
||||||
|
UID: timer.UID,
|
||||||
|
TimerID: timer.ID,
|
||||||
|
RuleName: timer.Name,
|
||||||
|
RuleType: "reminder",
|
||||||
|
Trigger: model.TriggerRemind,
|
||||||
|
Success: err == nil,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RuleExecutor 规则执行器接口
|
||||||
|
type RuleExecutor interface {
|
||||||
|
// Execute 执行规则,ctx 决定了重试的最长时间
|
||||||
|
Execute(ctx context.Context, rule *model.Rule) error
|
||||||
|
// GetType 获取执行器支持的规则类型
|
||||||
|
GetType() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutorRegistry 执行器注册表
|
||||||
|
type ExecutorRegistry struct {
|
||||||
|
executors map[string]RuleExecutor
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutorRegistry 创建新的执行器注册表
|
||||||
|
func NewExecutorRegistry() *ExecutorRegistry {
|
||||||
|
return &ExecutorRegistry{
|
||||||
|
executors: make(map[string]RuleExecutor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册规则执行器
|
||||||
|
func (er *ExecutorRegistry) Register(executor RuleExecutor) {
|
||||||
|
er.executors[executor.GetType()] = executor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute 根据规则类型执行规则
|
||||||
|
func (er *ExecutorRegistry) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
executor, exists := er.executors[rule.Type]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("不支持的规则类型: %s", rule.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
return executor.Execute(ctx, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSupportedTypes 获取所有支持的规则类型
|
||||||
|
func (er *ExecutorRegistry) GetSupportedTypes() []string {
|
||||||
|
types := make([]string, 0, len(er.executors))
|
||||||
|
for t := range er.executors {
|
||||||
|
types = append(types, t)
|
||||||
|
}
|
||||||
|
sort.Strings(types)
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
registryOnce sync.Once
|
||||||
|
globalExecutorRegistry *ExecutorRegistry
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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,85 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/logstore"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runner 执行某个 Timer 下所有启用的规则。
|
||||||
|
//
|
||||||
|
// 触发前先把 Timer 标记为已触发,避免规则执行期间(可能长达数小时)
|
||||||
|
// 被下一轮检查重复触发;用户重新签到后 Triggered 会被重置。
|
||||||
|
func Runner(timer *model.Timer) {
|
||||||
|
gormDB, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to get gorm db: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 获取所有需要执行的 Rule
|
||||||
|
var rules []model.Rule
|
||||||
|
if err := gormDB.Where("timer_id = ? AND enabled = ?", timer.ID, true).Find(&rules).Error; err != nil {
|
||||||
|
log.Printf("failed to get rules: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 标记已触发
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := gormDB.Model(&model.Timer{}).Where("id = ?", timer.ID).
|
||||||
|
Updates(map[string]any{"triggered": true, "last_trigger": now}).Error; err != nil {
|
||||||
|
log.Printf("标记 Timer(ID: %d) 已触发失败: %v", timer.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Timer %s (ID: %d) 已到期,开始执行 %d 条规则", timer.Name, timer.ID, len(rules))
|
||||||
|
|
||||||
|
// 3. 并发执行每个 Rule
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range rules {
|
||||||
|
rule := rules[i]
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ExecuteRule(&rule, model.TriggerTimer)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteRule 执行单个规则并记录执行日志
|
||||||
|
func ExecuteRule(rule *model.Rule, trigger string) error {
|
||||||
|
ctx, cancel := retry.ExecutionContext()
|
||||||
|
defer cancel()
|
||||||
|
return ExecuteRuleWithContext(ctx, rule, trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteRuleWithContext 使用给定 context 执行规则,手动测试时可传入较短的超时
|
||||||
|
func ExecuteRuleWithContext(ctx context.Context, rule *model.Rule, trigger string) error {
|
||||||
|
err := GetGlobalExecutorRegistry().Execute(ctx, rule)
|
||||||
|
|
||||||
|
message := "执行成功"
|
||||||
|
if err != nil {
|
||||||
|
message = err.Error()
|
||||||
|
log.Printf("执行规则失败 [ID: %d, Type: %s]: %v", rule.ID, rule.Type, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logstore.Record(&model.ExecutionLog{
|
||||||
|
UID: rule.UID,
|
||||||
|
RuleID: rule.ID,
|
||||||
|
RuleName: rule.Name,
|
||||||
|
RuleType: rule.Type,
|
||||||
|
TimerID: rule.TimerID,
|
||||||
|
Trigger: trigger,
|
||||||
|
Success: err == nil,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Package scheduler 管理定时检查任务,支持运行时调整间隔
|
||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
runner *cron.Cron
|
||||||
|
entryID cron.EntryID
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start 启动定时检查,interval 单位为分钟
|
||||||
|
func Start(intervalMinutes int) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if runner != nil {
|
||||||
|
return fmt.Errorf("定时任务已经启动")
|
||||||
|
}
|
||||||
|
|
||||||
|
runner = cron.New()
|
||||||
|
id, err := runner.AddFunc(spec(intervalMinutes), checker.CheckTimers)
|
||||||
|
if err != nil {
|
||||||
|
runner = nil
|
||||||
|
return fmt.Errorf("注册定时任务失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryID = id
|
||||||
|
runner.Start()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reschedule 用新的间隔重排定时任务,管理员改配置后调用
|
||||||
|
func Reschedule(intervalMinutes int) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if runner == nil {
|
||||||
|
return fmt.Errorf("定时任务尚未启动")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cron 不支持原地改周期,只能撤掉旧的再加一个
|
||||||
|
runner.Remove(entryID)
|
||||||
|
id, err := runner.AddFunc(spec(intervalMinutes), checker.CheckTimers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("注册定时任务失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryID = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 停止定时任务,等待正在执行的任务结束
|
||||||
|
func Stop() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if runner == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
<-runner.Stop().Done()
|
||||||
|
runner = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func spec(intervalMinutes int) string {
|
||||||
|
if intervalMinutes <= 0 {
|
||||||
|
intervalMinutes = 10
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("@every %dm", intervalMinutes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package accountConfigChecker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountValidator 账号验证器接口
|
||||||
|
type AccountValidator interface {
|
||||||
|
// Validate 验证账号配置是否合法
|
||||||
|
Validate(config string) error
|
||||||
|
// GetType 获取验证器支持的账号类型
|
||||||
|
GetType() string
|
||||||
|
// Meta 返回给 WebUI 渲染表单用的元数据
|
||||||
|
Meta() meta.AccountMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountTester 可选接口:支持真实连通性测试的账号类型实现它
|
||||||
|
type AccountTester interface {
|
||||||
|
// Test 使用给定配置访问一次第三方服务,验证凭据是否可用
|
||||||
|
Test(config string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatorRegistry 验证器注册表
|
||||||
|
type ValidatorRegistry struct {
|
||||||
|
validators map[string]AccountValidator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidatorRegistry 创建新的验证器注册表
|
||||||
|
func NewValidatorRegistry() *ValidatorRegistry {
|
||||||
|
return &ValidatorRegistry{
|
||||||
|
validators: make(map[string]AccountValidator),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册账号验证器
|
||||||
|
func (vr *ValidatorRegistry) Register(validator AccountValidator) {
|
||||||
|
vr.validators[validator.GetType()] = validator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 根据账号类型验证配置
|
||||||
|
func (vr *ValidatorRegistry) Validate(accountType string, config string) error {
|
||||||
|
validator, exists := vr.validators[accountType]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("不支持的账号类型: %s", accountType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config == "" {
|
||||||
|
return fmt.Errorf("账号配置不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首先验证 JSON 格式是否正确
|
||||||
|
var js json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(config), &js); err != nil {
|
||||||
|
return fmt.Errorf("账号配置 JSON 格式错误: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用具体验证器的验证逻辑
|
||||||
|
return validator.Validate(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 对账号配置做一次真实的连通性测试
|
||||||
|
func (vr *ValidatorRegistry) Test(accountType string, config string) error {
|
||||||
|
validator, exists := vr.validators[accountType]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("不支持的账号类型: %s", accountType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := vr.Validate(accountType, config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tester, ok := validator.(AccountTester)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("账号类型 %s 暂不支持连通性测试", accountType)
|
||||||
|
}
|
||||||
|
return tester.Test(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSupportedTypes 获取所有支持的账号类型
|
||||||
|
func (vr *ValidatorRegistry) GetSupportedTypes() []string {
|
||||||
|
types := make([]string, 0, len(vr.validators))
|
||||||
|
for t := range vr.validators {
|
||||||
|
types = append(types, t)
|
||||||
|
}
|
||||||
|
sort.Strings(types)
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metas 返回所有账号类型的元数据,按类型名排序
|
||||||
|
func (vr *ValidatorRegistry) Metas() []meta.AccountMeta {
|
||||||
|
metas := make([]meta.AccountMeta, 0, len(vr.validators))
|
||||||
|
for _, v := range vr.validators {
|
||||||
|
metas = append(metas, withTestable(v))
|
||||||
|
}
|
||||||
|
sort.Slice(metas, func(i, j int) bool { return metas[i].Type < metas[j].Type })
|
||||||
|
return metas
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaOf 返回指定账号类型的元数据
|
||||||
|
func (vr *ValidatorRegistry) MetaOf(accountType string) (meta.AccountMeta, bool) {
|
||||||
|
v, ok := vr.validators[accountType]
|
||||||
|
if !ok {
|
||||||
|
return meta.AccountMeta{}, false
|
||||||
|
}
|
||||||
|
return withTestable(v), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldsOf 返回指定账号类型的字段描述,类型不存在时返回 nil
|
||||||
|
func (vr *ValidatorRegistry) FieldsOf(accountType string) []meta.Field {
|
||||||
|
m, ok := vr.MetaOf(accountType)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.Fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTestable(v AccountValidator) meta.AccountMeta {
|
||||||
|
m := v.Meta()
|
||||||
|
if _, ok := v.(AccountTester); ok {
|
||||||
|
m.Testable = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,70 @@
|
|||||||
|
package meta
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// MaskPlaceholder 是掩码后的占位值。
|
||||||
|
// 提交编辑时如果某个 secret 字段仍是这个值,服务端会保留原值。
|
||||||
|
const MaskPlaceholder = "********"
|
||||||
|
|
||||||
|
// Mask 把配置 JSON 中的敏感字段替换为占位符
|
||||||
|
func Mask(configJSON string, fields []Field) string {
|
||||||
|
secrets := SecretKeys(fields)
|
||||||
|
if len(secrets) == 0 || configJSON == "" {
|
||||||
|
return configJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &raw); err != nil {
|
||||||
|
return configJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range secrets {
|
||||||
|
if v, ok := raw[key]; ok {
|
||||||
|
if s, isStr := v.(string); !isStr || s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw[key] = MaskPlaceholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
masked, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return configJSON
|
||||||
|
}
|
||||||
|
return string(masked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmask 用旧配置补回被掩码的字段,返回可以落库的配置 JSON
|
||||||
|
func Unmask(newConfigJSON, oldConfigJSON string, fields []Field) string {
|
||||||
|
secrets := SecretKeys(fields)
|
||||||
|
if len(secrets) == 0 || newConfigJSON == "" || oldConfigJSON == "" {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
var newRaw, oldRaw map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(newConfigJSON), &newRaw); err != nil {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(oldConfigJSON), &oldRaw); err != nil {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
restored := false
|
||||||
|
for _, key := range secrets {
|
||||||
|
if s, ok := newRaw[key].(string); ok && s == MaskPlaceholder {
|
||||||
|
if old, exists := oldRaw[key]; exists {
|
||||||
|
newRaw[key] = old
|
||||||
|
restored = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !restored {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, err := json.Marshal(newRaw)
|
||||||
|
if err != nil {
|
||||||
|
return newConfigJSON
|
||||||
|
}
|
||||||
|
return string(merged)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package meta 定义驱动向前端暴露的元数据。
|
||||||
|
//
|
||||||
|
// WebUI 依靠这些描述动态渲染账号 / 规则的配置表单,
|
||||||
|
// 新增一个驱动时只要提供 Meta(),前端无需改动。
|
||||||
|
package meta
|
||||||
|
|
||||||
|
// FieldType 决定前端使用哪种输入控件
|
||||||
|
type FieldType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FieldString FieldType = "string" // 单行文本
|
||||||
|
FieldPassword FieldType = "password" // 密码框
|
||||||
|
FieldTextarea FieldType = "textarea" // 多行文本
|
||||||
|
FieldNumber FieldType = "number" // 数字
|
||||||
|
FieldStringList FieldType = "string-list" // 字符串数组
|
||||||
|
FieldNumberList FieldType = "number-list" // 数字数组
|
||||||
|
FieldBool FieldType = "bool" // 开关
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field 描述配置 JSON 中的一个字段
|
||||||
|
type Field struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type FieldType `json:"type"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
|
Help string `json:"help,omitempty"`
|
||||||
|
// Secret 为 true 的字段在读取接口中会被掩码
|
||||||
|
Secret bool `json:"secret,omitempty"`
|
||||||
|
Default any `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountMeta 账号类型的元数据
|
||||||
|
type AccountMeta struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Docs string `json:"docs,omitempty"`
|
||||||
|
Testable bool `json:"testable"`
|
||||||
|
Fields []Field `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleMeta 规则类型的元数据
|
||||||
|
type RuleMeta struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Docs string `json:"docs,omitempty"`
|
||||||
|
// AccountType 该规则需要的账号类型,为空表示不需要关联账号
|
||||||
|
AccountType string `json:"account_type"`
|
||||||
|
Fields []Field `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretKeys 返回需要掩码的字段名
|
||||||
|
func SecretKeys(fields []Field) []string {
|
||||||
|
keys := make([]string, 0, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
if f.Secret {
|
||||||
|
keys = append(keys, f.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package reminder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ssdomei232/goodBaby/configs"
|
|
||||||
"github.com/ssdomei232/goodBaby/internal/sender"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Reminder() {
|
|
||||||
config, err := configs.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取配置文件失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
remainingTime := GlobalTimerManager.GetRemainingTime()
|
|
||||||
if remainingTime <= 48*time.Hour {
|
|
||||||
sendMsg := fmt.Sprintf("距离摇篮系统触发还有%d小时,请尽快发送Signal", int(remainingTime.Hours()))
|
|
||||||
sender.SendDingTalkMsg("摇篮系统触发提醒", sendMsg, config.DingtalkBot.AccessToken, config.DingtalkBot.Secret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package reminder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TimerManager struct {
|
|
||||||
duration time.Duration
|
|
||||||
resetTime time.Time
|
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
var GlobalTimerManager *TimerManager
|
|
||||||
|
|
||||||
func InitTimerManager(duration time.Duration) {
|
|
||||||
GlobalTimerManager = &TimerManager{
|
|
||||||
duration: duration,
|
|
||||||
resetTime: time.Now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tm *TimerManager) Reset() {
|
|
||||||
tm.mutex.Lock()
|
|
||||||
defer tm.mutex.Unlock()
|
|
||||||
tm.resetTime = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tm *TimerManager) GetRemainingTime() time.Duration {
|
|
||||||
tm.mutex.RLock()
|
|
||||||
defer tm.mutex.RUnlock()
|
|
||||||
|
|
||||||
elapsed := time.Since(tm.resetTime)
|
|
||||||
remaining := tm.duration - elapsed
|
|
||||||
if remaining < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return remaining
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleTimerStatus(c *gin.Context) {
|
|
||||||
remainingTime := GlobalTimerManager.GetRemainingTime()
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"code": 200,
|
|
||||||
"remaining_time": remainingTime.Seconds(), // 返回剩余秒数
|
|
||||||
"message": "ok",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Package retry 封装规则执行使用的指数退避重试
|
||||||
|
package retry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v5"
|
||||||
|
"github.com/ssdomei232/goodBaby/configs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 手动测试时使用的超时,避免用户在 WebUI 上等待数小时
|
||||||
|
const TestTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// Do 在 ctx 允许的时间内按指数退避执行 op
|
||||||
|
//
|
||||||
|
// 超时后返回最后一次真实的业务错误,而不是干巴巴的 context deadline exceeded。
|
||||||
|
func Do(ctx context.Context, op func() error) error {
|
||||||
|
var lastErr error
|
||||||
|
_, err := backoff.Retry(ctx, func() (struct{}, error) {
|
||||||
|
e := op()
|
||||||
|
if e != nil {
|
||||||
|
lastErr = e
|
||||||
|
}
|
||||||
|
return struct{}{}, e
|
||||||
|
}, backoff.WithBackOff(backoff.NewExponentialBackOff()))
|
||||||
|
|
||||||
|
if err != nil && lastErr != nil {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionTimeout 返回配置中的规则执行超时时间
|
||||||
|
func ExecutionTimeout() time.Duration {
|
||||||
|
config, err := configs.GetConfig()
|
||||||
|
if err != nil || config.TimeoutDurationHours <= 0 {
|
||||||
|
return 6 * time.Hour
|
||||||
|
}
|
||||||
|
return time.Duration(config.TimeoutDurationHours) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionContext 返回一个带规则执行超时的 context
|
||||||
|
func ExecutionContext() (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), ExecutionTimeout())
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package ruleConfigChecker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RuleValidator 规则验证器接口
|
||||||
|
type RuleValidator interface {
|
||||||
|
// Validate 验证规则配置是否合法
|
||||||
|
Validate(configJSON string) error
|
||||||
|
// GetType 获取验证器支持的规则类型
|
||||||
|
GetType() string
|
||||||
|
// Meta 返回给 WebUI 渲染表单用的元数据
|
||||||
|
Meta() meta.RuleMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatorRegistry 验证器注册表
|
||||||
|
type ValidatorRegistry struct {
|
||||||
|
validators map[string]RuleValidator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidatorRegistry 创建新的验证器注册表
|
||||||
|
func NewValidatorRegistry() *ValidatorRegistry {
|
||||||
|
return &ValidatorRegistry{
|
||||||
|
validators: make(map[string]RuleValidator),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册规则验证器
|
||||||
|
func (vr *ValidatorRegistry) Register(validator RuleValidator) {
|
||||||
|
vr.validators[validator.GetType()] = validator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 根据规则类型验证配置
|
||||||
|
func (vr *ValidatorRegistry) Validate(ruleType string, configJSON string) error {
|
||||||
|
validator, exists := vr.validators[ruleType]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("不支持的规则类型: %s", ruleType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if configJSON == "" {
|
||||||
|
return fmt.Errorf("规则配置不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首先验证 JSON 格式是否正确
|
||||||
|
var js json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &js); err != nil {
|
||||||
|
return fmt.Errorf("规则配置 JSON 格式错误: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用具体验证器的验证逻辑
|
||||||
|
return validator.Validate(configJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSupportedTypes 获取所有支持的规则类型
|
||||||
|
func (vr *ValidatorRegistry) GetSupportedTypes() []string {
|
||||||
|
types := make([]string, 0, len(vr.validators))
|
||||||
|
for t := range vr.validators {
|
||||||
|
types = append(types, t)
|
||||||
|
}
|
||||||
|
sort.Strings(types)
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metas 返回所有规则类型的元数据,按类型名排序
|
||||||
|
func (vr *ValidatorRegistry) Metas() []meta.RuleMeta {
|
||||||
|
metas := make([]meta.RuleMeta, 0, len(vr.validators))
|
||||||
|
for _, v := range vr.validators {
|
||||||
|
metas = append(metas, v.Meta())
|
||||||
|
}
|
||||||
|
sort.Slice(metas, func(i, j int) bool { return metas[i].Type < metas[j].Type })
|
||||||
|
return metas
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaOf 返回指定规则类型的元数据
|
||||||
|
func (vr *ValidatorRegistry) MetaOf(ruleType string) (meta.RuleMeta, bool) {
|
||||||
|
v, ok := vr.validators[ruleType]
|
||||||
|
if !ok {
|
||||||
|
return meta.RuleMeta{}, false
|
||||||
|
}
|
||||||
|
return v.Meta(), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package ruleConfigChecker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
once sync.Once
|
||||||
|
registry *ValidatorRegistry
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitValidatorRegistry 返回全局唯一的规则验证器注册表
|
||||||
|
func InitValidatorRegistry() *ValidatorRegistry {
|
||||||
|
once.Do(func() {
|
||||||
|
r := NewValidatorRegistry()
|
||||||
|
|
||||||
|
// 注册所有规则验证器
|
||||||
|
r.Register(&bilibili.BilibiliDynamicRuleValidator{})
|
||||||
|
r.Register(&bilibili.BilibiliPrivateMessageRuleValidator{})
|
||||||
|
r.Register(&email.EmailRuleValidator{})
|
||||||
|
r.Register(&onebot.OneBotRuleValidator{})
|
||||||
|
r.Register(&dingtalk.DingTalkRuleValidator{})
|
||||||
|
r.Register(&github.GithubMakeRepositoryPublicRuleValidator{})
|
||||||
|
r.Register(&fwalert.FwalertRuleValidator{})
|
||||||
|
r.Register(&rainyun.RainyunWorkorderRuleValidator{})
|
||||||
|
r.Register(&rainyun.RainyunRunAwayRuleValidator{})
|
||||||
|
r.Register(&alidns.AliDNSDeleteRecordRuleValidator{})
|
||||||
|
|
||||||
|
registry = r
|
||||||
|
})
|
||||||
|
return registry
|
||||||
|
}
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
package sender
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/CuteReimu/bilibili/v2"
|
|
||||||
"github.com/ssdomei232/goodBaby/configs"
|
|
||||||
"github.com/ssdomei232/goodBaby/internal/base"
|
|
||||||
)
|
|
||||||
|
|
||||||
// check tmp dir exist
|
|
||||||
func EnsureTmpDirectory() error {
|
|
||||||
_, err := os.Stat("tmp")
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
err = os.Mkdir("tmp", 0755)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Println("已创建tmp目录")
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
} else {
|
|
||||||
log.Println("tmp目录已存在")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCookiesExpired(cookiesString string) bool {
|
|
||||||
// parase expires date from cookie string
|
|
||||||
lines := strings.SplitSeq(cookiesString, "\n")
|
|
||||||
for line := range lines {
|
|
||||||
if strings.Contains(line, "Expires=") {
|
|
||||||
// 提取过期时间字符串
|
|
||||||
expireStr := ""
|
|
||||||
parts := strings.SplitSeq(line, ";")
|
|
||||||
for part := range parts {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if after, ok := strings.CutPrefix(part, "Expires="); ok {
|
|
||||||
expireStr = after
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if expireStr != "" {
|
|
||||||
// phrase expirres date
|
|
||||||
expireTime, err := http.ParseTime(expireStr)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("解析过期时间失败: %v", err)
|
|
||||||
return true // if phrase failed, cookie expired
|
|
||||||
}
|
|
||||||
|
|
||||||
// compare expires date and now
|
|
||||||
if time.Now().After(expireTime) {
|
|
||||||
return true // expired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false // not expired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Qrcode login
|
|
||||||
func LoginWithQRCode(biliClient *bilibili.Client) {
|
|
||||||
qrCode, err := biliClient.GetQRCode()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取二维码失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("请使用哔哩哔哩APP扫码登录或进入文件目录寻找qrcode.png查看二维码")
|
|
||||||
buf, _ := qrCode.Encode()
|
|
||||||
os.WriteFile("tmp/qrcode.png", buf, 0644)
|
|
||||||
qrCode.Print()
|
|
||||||
|
|
||||||
result, err := biliClient.LoginWithQRCode(bilibili.LoginWithQRCodeParam{
|
|
||||||
QrcodeKey: qrCode.QrcodeKey,
|
|
||||||
})
|
|
||||||
if err != nil || result.Code != 0 {
|
|
||||||
log.Printf("登录失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("登录成功")
|
|
||||||
|
|
||||||
// save new cookie
|
|
||||||
saveCookies(biliClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
// save cookie to file
|
|
||||||
func saveCookies(client *bilibili.Client) {
|
|
||||||
cookiesString := client.GetCookiesString()
|
|
||||||
err := os.WriteFile("tmp/cookies.txt", []byte(cookiesString), 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("保存cookie失败: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从文件加载cookie并验证有效性
|
|
||||||
func LoadCookies(client *bilibili.Client) bool {
|
|
||||||
// 检查cookie文件是否存在
|
|
||||||
if _, err := os.Stat("tmp/cookies.txt"); os.IsNotExist(err) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取cookie
|
|
||||||
cookiesBytes, err := os.ReadFile("tmp/cookies.txt")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("读取cookie文件失败: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
cookiesString := string(cookiesBytes)
|
|
||||||
client.SetCookiesString(cookiesString)
|
|
||||||
|
|
||||||
// 验证cookie是否过期
|
|
||||||
if isCookiesExpired(cookiesString) {
|
|
||||||
log.Println("cookie已过期")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在Bilibili动态发送死亡通告
|
|
||||||
func SendBili(biliClient *bilibili.Client) {
|
|
||||||
config, err := configs.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取配置文件失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stopMsg := fmt.Sprintf("%s\n\n%s\n\n本消息由自动程序发送", base.GetBasicInfo(), config.BiliMsg)
|
|
||||||
|
|
||||||
var dynamicParams bilibili.CreateDynamicParam
|
|
||||||
dynamicParams = bilibili.CreateDynamicParam{
|
|
||||||
DynamicId: 0,
|
|
||||||
Type: 4,
|
|
||||||
Rid: 0,
|
|
||||||
Content: stopMsg,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重试机制
|
|
||||||
retryCount := 10 // 重试次数
|
|
||||||
delay := time.Second * 2 // 初始重试延迟
|
|
||||||
|
|
||||||
for i := 0; i <= retryCount; i++ {
|
|
||||||
_, err := biliClient.CreateDynamic(dynamicParams)
|
|
||||||
if err == nil {
|
|
||||||
log.Println("动态发送成功")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("动态发送失败 (尝试 %d/%d): %v", i+1, retryCount+1, err)
|
|
||||||
|
|
||||||
// 如果不是最后一次尝试,则等待后重试
|
|
||||||
if i < retryCount {
|
|
||||||
log.Printf("等待 %v 后进行第 %d 次重试", delay, i+2)
|
|
||||||
time.Sleep(delay)
|
|
||||||
delay *= 2 // 指数退避
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("动态发送最终失败,已达到最大重试次数")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查cookie有效性
|
|
||||||
func CheckCookieValidity(biliClient *bilibili.Client) {
|
|
||||||
// 读取存储的cookie文件
|
|
||||||
if _, err := os.Stat("tmp/cookies.txt"); os.IsNotExist(err) {
|
|
||||||
log.Println("cookie文件不存在,需要重新登录")
|
|
||||||
triggerLoginRequest(biliClient)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cookiesBytes, err := os.ReadFile("tmp/cookies.txt")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("读取cookie文件失败: %v", err)
|
|
||||||
triggerLoginRequest(biliClient)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cookiesString := string(cookiesBytes)
|
|
||||||
|
|
||||||
// 检查cookie是否即将过期(提前1天提醒)
|
|
||||||
if isCookiesExpiringSoon(cookiesString) {
|
|
||||||
log.Println("cookie即将过期,需要重新登录")
|
|
||||||
triggerLoginRequest(biliClient)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查cookie是否即将过期(提前1天)
|
|
||||||
func isCookiesExpiringSoon(cookiesString string) bool {
|
|
||||||
lines := strings.SplitSeq(cookiesString, "\n")
|
|
||||||
for line := range lines {
|
|
||||||
if strings.Contains(line, "Expires=") {
|
|
||||||
// 提取过期时间字符串
|
|
||||||
expireStr := ""
|
|
||||||
parts := strings.SplitSeq(line, ";")
|
|
||||||
for part := range parts {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if after, ok := strings.CutPrefix(part, "Expires="); ok {
|
|
||||||
expireStr = after
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if expireStr != "" {
|
|
||||||
// 解析过期时间
|
|
||||||
expireTime, err := http.ParseTime(expireStr)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("解析过期时间失败: %v", err)
|
|
||||||
return true // 如果解析失败,认为需要重新登录
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否即将过期(提前1天)
|
|
||||||
if time.Now().Add(24 * time.Hour).After(expireTime) {
|
|
||||||
return true // 即将过期
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 触发登录请求
|
|
||||||
func triggerLoginRequest(biliClient *bilibili.Client) {
|
|
||||||
// 获取配置
|
|
||||||
config, err := configs.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取配置文件失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成新的二维码
|
|
||||||
qrCode, err := biliClient.GetQRCode()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取二维码失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在命令行打印二维码
|
|
||||||
log.Println("需要重新登录哔哩哔哩")
|
|
||||||
qrCode.Print()
|
|
||||||
|
|
||||||
// 通过Dingtalk发送登录请求
|
|
||||||
title := "B站登录请求 - Cookie即将过期"
|
|
||||||
msg := "您的B站cookie即将过期,请尽快重启摇篮系统进行登陆,请尽快完成登录操作。"
|
|
||||||
|
|
||||||
// 发送Dingtalk通知
|
|
||||||
if config.DingtalkBot.AccessToken != "" && config.DingtalkBot.Secret != "" {
|
|
||||||
SendDingTalkMsg(title, msg, config.DingtalkBot.AccessToken, config.DingtalkBot.Secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable periodic cookie checks
|
|
||||||
func StartCookieChecker(cookieCheckTimer *time.Ticker, biliClient *bilibili.Client) {
|
|
||||||
// check cookie per hour
|
|
||||||
cookieCheckTimer = time.NewTicker(1 * time.Hour)
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
<-cookieCheckTimer.C
|
|
||||||
CheckCookieValidity(biliClient)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func InitBilibili(biliClient *bilibili.Client) {
|
|
||||||
// try cached cookie
|
|
||||||
if !LoadCookies(biliClient) {
|
|
||||||
// if cookie check failed, request qrcode login
|
|
||||||
LoginWithQRCode(biliClient)
|
|
||||||
} else {
|
|
||||||
log.Println("使用已存储的有效cookie登录")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user