初始化提交
This commit is contained in:
@@ -174,3 +174,6 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Docker data
|
||||
docker_data/
|
||||
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
# WXAuto Center AI 开发流程规则
|
||||
|
||||
## 概述
|
||||
|
||||
本项目是 WXAuto Center 中控系统,采用 FastAPI + PostgreSQL + Redis 架构。本规则用于规范 AI Agent 开发流程,确保代码质量和项目一致性。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
wxauto_api/
|
||||
├── api/ # API 路由层
|
||||
│ ├── __init__.py
|
||||
│ ├── nodes.py # 节点管理
|
||||
│ ├── messages.py # 消息发送
|
||||
│ ├── external.py # 外部接口
|
||||
│ ├── plugins.py # 插件管理
|
||||
│ └── callback.py # 回调接口
|
||||
├── services/ # 业务逻辑层
|
||||
│ ├── node_manager.py
|
||||
│ ├── monitor_service.py
|
||||
│ └── log_service.py
|
||||
├── models.py # 数据模型
|
||||
├── config.py # 配置管理
|
||||
├── main.py # FastAPI 主入口
|
||||
├── data/ # 数据目录
|
||||
│ ├── db/ # PostgreSQL 数据
|
||||
│ ├── redis/ # Redis 数据
|
||||
│ └── logs/ # 应用日志
|
||||
├── doc/ # 项目文档
|
||||
├── scripts/ # 部署脚本
|
||||
├── static/ # 前端资源
|
||||
└── plugins/ # 插件系统
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发流程
|
||||
|
||||
### 1. 需求分析 (Requirement Analysis)
|
||||
|
||||
**原则**:在开始任何代码修改前,必须充分理解需求。
|
||||
|
||||
#### 执行步骤
|
||||
|
||||
1. **澄清需求**
|
||||
- 明确功能目标和边界
|
||||
- 确认输入输出格式
|
||||
- 识别依赖关系和影响范围
|
||||
|
||||
2. **影响评估**
|
||||
- 确定修改涉及的文件
|
||||
- 评估对现有功能的影响
|
||||
- 检查是否有breaking change
|
||||
|
||||
3. **任务分解**
|
||||
- 使用 TodoWrite 工具创建任务清单
|
||||
- 优先级排序
|
||||
- 预估任务间的依赖
|
||||
|
||||
#### 检查清单
|
||||
|
||||
- [ ] 需求已明确,无歧义
|
||||
- [ ] 影响范围已评估
|
||||
- [ ] 任务已分解并创建 Todo
|
||||
- [ ] 依赖项已识别
|
||||
|
||||
---
|
||||
|
||||
### 2. 设计 (Design)
|
||||
|
||||
**原则**:设计先行,避免返工。
|
||||
|
||||
#### 执行步骤
|
||||
|
||||
1. **接口设计**
|
||||
- RESTful API 设计规范
|
||||
- 请求/响应格式定义
|
||||
- 错误码设计
|
||||
|
||||
2. **数据模型设计**
|
||||
- 数据库表结构(如需)
|
||||
- 配置项设计
|
||||
- 数据流设计
|
||||
|
||||
3. **模块设计**
|
||||
- 公共函数抽取
|
||||
- 模块边界定义
|
||||
- 依赖关系梳理
|
||||
|
||||
#### 检查清单
|
||||
|
||||
- [ ] API 接口设计已确定
|
||||
- [ ] 数据模型已设计(如需)
|
||||
- [ ] 模块划分已明确
|
||||
- [ ] 向后兼容性已考虑
|
||||
|
||||
---
|
||||
|
||||
### 3. 实现 (Implementation)
|
||||
|
||||
**原则**:遵循代码规范,保持一致性。
|
||||
|
||||
#### 代码规范
|
||||
|
||||
1. **文件头部注释**
|
||||
```python
|
||||
# ///
|
||||
# filename.py
|
||||
# 描述:模块功能描述
|
||||
# 作者:AI Generated
|
||||
# 创建日期:YYYY-MM-DD
|
||||
# ///
|
||||
```
|
||||
|
||||
2. **函数注释**
|
||||
```python
|
||||
async def function_name(param1: str, param2: int) -> Dict[str, Any]:
|
||||
"""
|
||||
函数功能说明
|
||||
|
||||
Args:
|
||||
param1: 参数1说明
|
||||
param2: 参数2说明
|
||||
|
||||
Returns:
|
||||
返回值说明
|
||||
|
||||
Raises:
|
||||
HTTPException: 异常说明
|
||||
"""
|
||||
```
|
||||
|
||||
3. **命名规范**
|
||||
- 类名:PascalCase (如 `NodeManager`)
|
||||
- 函数名:snake_case (如 `send_message`)
|
||||
- 常量:UPPER_SNAKE_CASE (如 `MAX_RETRIES`)
|
||||
- API 字段:snake_case (如 `node_id`, `wechat_status`)
|
||||
|
||||
4. **路径规范**
|
||||
- 项目根目录:`/Users/skn/Desktop/wxauto_api`
|
||||
- 使用绝对路径
|
||||
- 数据目录:`data/db`, `data/redis`, `data/logs`
|
||||
|
||||
#### 执行步骤
|
||||
|
||||
1. **创建 Todo 跟踪任务**
|
||||
```python
|
||||
TodoWrite(todos=[
|
||||
{"id": "1", "content": "任务1", "status": "in_progress"},
|
||||
{"id": "2", "content": "任务2", "status": "pending"},
|
||||
])
|
||||
```
|
||||
|
||||
2. **按优先级实现**
|
||||
- 先核心功能,后辅助功能
|
||||
- 先底层模块,后上层调用
|
||||
- 先接口定义,后具体实现
|
||||
|
||||
3. **同步更新相关文件**
|
||||
- API 路由 ↔ 服务层
|
||||
- 前端 ↔ 后端接口
|
||||
- 配置 ↔ 代码
|
||||
|
||||
#### 检查清单
|
||||
|
||||
- [ ] 代码符合注释规范
|
||||
- [ ] 函数有文档字符串
|
||||
- [ ] 命名符合规范
|
||||
- [ ] 错误处理完善
|
||||
- [ ] 无硬编码配置
|
||||
|
||||
---
|
||||
|
||||
### 4. 代码审核 (Code Review)
|
||||
|
||||
**原则**:主动发现并修复问题。
|
||||
|
||||
#### 自检清单
|
||||
|
||||
1. **逻辑检查**
|
||||
- [ ] 所有分支都有处理
|
||||
- [ ] 异常情况已处理
|
||||
- [ ] 边界条件已考虑
|
||||
|
||||
2. **安全检查**
|
||||
- [ ] 无硬编码密钥
|
||||
- [ ] 输入已验证
|
||||
- [ ] SQL 注入防护
|
||||
- [ ] 权限检查完整
|
||||
|
||||
3. **性能检查**
|
||||
- [ ] 无阻塞操作在 async 中
|
||||
- [ ] 数据库连接正确关闭
|
||||
- [ ] 无内存泄漏
|
||||
|
||||
4. **一致性检查**
|
||||
- [ ] `node_id` vs `node_name` 使用正确
|
||||
- [ ] 配置命名一致
|
||||
- [ ] API 响应格式一致
|
||||
|
||||
#### 执行步骤
|
||||
|
||||
1. **代码自查**
|
||||
- 逐文件检查
|
||||
- 逐函数验证
|
||||
|
||||
2. **路径验证**
|
||||
- Grep 搜索关键路径引用
|
||||
- 确认无残留 `wxauto_center` 等旧路径
|
||||
|
||||
3. **导入验证**
|
||||
```bash
|
||||
python -c "from module import something"
|
||||
```
|
||||
|
||||
#### 检查清单
|
||||
|
||||
- [ ] 代码通过自检清单
|
||||
- [ ] 无路径错误
|
||||
- [ ] 导入测试通过
|
||||
- [ ] 配置一致性检查通过
|
||||
|
||||
---
|
||||
|
||||
### 5. 测试 (Testing)
|
||||
|
||||
**原则**:测试驱动开发,验证所有功能。
|
||||
|
||||
#### 测试类型
|
||||
|
||||
1. **单元测试**
|
||||
- 工具函数测试
|
||||
- 数据模型测试
|
||||
- 业务逻辑测试
|
||||
|
||||
2. **集成测试**
|
||||
- API 接口测试
|
||||
- 数据库操作测试
|
||||
- Redis 操作测试
|
||||
|
||||
3. **手动测试**
|
||||
- 前端功能测试
|
||||
- 端到端流程测试
|
||||
|
||||
#### 执行步骤
|
||||
|
||||
1. **启动服务**
|
||||
```bash
|
||||
# 本地开发
|
||||
python main.py
|
||||
|
||||
# Docker 环境
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. **API 测试**
|
||||
```bash
|
||||
# 获取节点列表
|
||||
curl -X GET "http://localhost:8080/api/v1/nodes/" \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 发送消息
|
||||
curl -X POST "http://localhost:8080/api/v1/external/send" \
|
||||
-H "X-ExternalKey: your-external-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"node_id": "wx1", "who": "文件传输助手", "msg": "test"}'
|
||||
```
|
||||
|
||||
3. **验证清单**
|
||||
- [ ] API 请求成功
|
||||
- [ ] 响应格式正确
|
||||
- [ ] 错误处理正确
|
||||
- [ ] 前端显示正确
|
||||
|
||||
#### 检查清单
|
||||
|
||||
- [ ] 核心 API 测试通过
|
||||
- [ ] 错误场景测试通过
|
||||
- [ ] 前端功能正常
|
||||
- [ ] 日志输出正常
|
||||
|
||||
---
|
||||
|
||||
### 6. 文档更新 (Documentation)
|
||||
|
||||
**原则**:文档与代码同步更新。
|
||||
|
||||
#### 文档类型
|
||||
|
||||
1. **API 文档** (`doc/API.md`)
|
||||
- 接口说明
|
||||
- 请求/响应示例
|
||||
- 错误码说明
|
||||
|
||||
2. **架构文档** (`doc/ARCHITECTURE.md`)
|
||||
- 系统架构图
|
||||
- 模块说明
|
||||
- 流程图
|
||||
|
||||
3. **部署文档** (`scripts/README.md`)
|
||||
- Docker 部署说明
|
||||
- 环境配置
|
||||
- 故障排查
|
||||
|
||||
#### 执行步骤
|
||||
|
||||
1. **识别需更新的文档**
|
||||
- 新增功能 → API 文档
|
||||
- 架构变更 → 架构文档
|
||||
- 部署变更 → 部署文档
|
||||
|
||||
2. **更新文档内容**
|
||||
- 接口签名变更
|
||||
- 响应格式变更
|
||||
- 配置项变更
|
||||
|
||||
3. **添加变更记录**
|
||||
- 版本号
|
||||
- 变更日期
|
||||
- 变更说明
|
||||
|
||||
#### 文档格式规范
|
||||
|
||||
**接口文档示例**
|
||||
```markdown
|
||||
### POST /api/v1/resource
|
||||
|
||||
发送消息
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| node_id | string | 是 | 节点ID |
|
||||
| who | string | 是 | 接收人 |
|
||||
| msg | string | 是 | 消息内容 |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {...}
|
||||
}
|
||||
```
|
||||
|
||||
**错误码**
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 404 | 节点不存在 |
|
||||
| 503 | 节点不可用 |
|
||||
```
|
||||
|
||||
#### 检查清单
|
||||
|
||||
- [ ] API 文档已更新
|
||||
- [ ] 响应示例完整
|
||||
- [ ] 错误码已说明
|
||||
- [ ] 变更已记录
|
||||
|
||||
---
|
||||
|
||||
### 7. 脚本与部署 (Scripts & Deployment)
|
||||
|
||||
**原则**:部署脚本自动化,环境一致。
|
||||
|
||||
#### 脚本类型
|
||||
|
||||
1. **启动脚本** (`scripts/start_*.sh`)
|
||||
- 环境检查
|
||||
- 服务启动/停止
|
||||
- 状态查看
|
||||
|
||||
2. **Docker 配置** (`Dockerfile`, `docker-compose.yml`)
|
||||
- 多架构支持 (arm64/amd64)
|
||||
- 数据卷挂载
|
||||
- 环境变量
|
||||
|
||||
#### 执行步骤
|
||||
|
||||
1. **更新启动脚本**
|
||||
- 路径正确性
|
||||
- 架构兼容性
|
||||
|
||||
2. **更新 Docker 配置**
|
||||
- 镜像版本
|
||||
- 端口映射
|
||||
- 数据目录
|
||||
|
||||
3. **验证部署**
|
||||
```bash
|
||||
# 本地构建测试
|
||||
docker build -t wxauto-center:test .
|
||||
|
||||
# 启动测试
|
||||
docker compose up -d
|
||||
|
||||
# 验证服务
|
||||
curl http://localhost:8080/api/v1/nodes/
|
||||
```
|
||||
|
||||
#### 检查清单
|
||||
|
||||
- [ ] 启动脚本路径正确
|
||||
- [ ] Docker 配置正确
|
||||
- [ ] 数据目录已创建
|
||||
- [ ] 端口无冲突
|
||||
- [ ] 多架构支持(如需)
|
||||
|
||||
---
|
||||
|
||||
## 流程总结
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ AI 开发流程 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. 需求分析 → 2. 设计 → 3. 实现 → 4. 审核 → 5. 测试 │
|
||||
│ ↓ │
|
||||
│ 6. 文档更新 → 7. 部署脚本 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 关键规则
|
||||
|
||||
1. **Todo 跟踪**:每个任务使用 TodoWrite 跟踪
|
||||
2. **先设计后实现**:避免返工
|
||||
3. **边做边测**:不要最后才测试
|
||||
4. **文档同步**:代码变更即文档变更
|
||||
5. **路径规范**:使用绝对路径,项目根目录为 `/Users/skn/Desktop/wxauto_api`
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 启动服务
|
||||
python main.py
|
||||
|
||||
# Docker 启动
|
||||
docker compose up -d
|
||||
|
||||
# API 测试
|
||||
curl http://localhost:8080/api/v1/nodes/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### 配置管理
|
||||
- 开发环境:`config.json`
|
||||
- 环境变量:`.env`
|
||||
- Docker 环境:通过 `docker-compose.yml` 配置
|
||||
|
||||
### 数据库
|
||||
- PostgreSQL:端口 5432
|
||||
- Redis:端口 6379
|
||||
- 数据目录:`data/db`, `data/redis`
|
||||
|
||||
### 日志
|
||||
- 应用日志:`data/logs/app.log`
|
||||
- Docker 日志:`docker compose logs -f`
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# ///
|
||||
# Dockerfile
|
||||
# 描述:WXAuto Center Docker 镜像构建文件
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
tzdata \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
COPY config.py .
|
||||
COPY config.json .
|
||||
COPY models.py .
|
||||
COPY database_service.py .
|
||||
COPY run.py .
|
||||
COPY api ./api
|
||||
COPY services ./services
|
||||
COPY utils ./utils
|
||||
COPY plugins ./plugins
|
||||
COPY static ./static
|
||||
|
||||
RUN mkdir -p /app/logs /app/plugins/custom
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -1,2 +1,106 @@
|
||||
# wxauto_api
|
||||
# ///
|
||||
# README.md
|
||||
# 描述:WXAuto Center 项目说明
|
||||
# 作者:User
|
||||
# 创建日期:2026-04-06
|
||||
# 更新日期:2026-04-07
|
||||
# ///
|
||||
|
||||
# WXAuto Center
|
||||
|
||||
微信多节点中控系统 - 模块化、可扩展的微信管理平台。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **多节点管理** - 同时管理多个微信节点
|
||||
- **消息发送** - 支持单发、群发、批量发送
|
||||
- **插件系统** - 支持自定义插件扩展、表单化配置、通知功能
|
||||
- **定时任务** - 支持 Cron 表达式的定时任务
|
||||
- **Webhook 通知** - 支持 Bark、钉钉、企业微信等
|
||||
- **外部接口** - 提供 RESTful API 供外部调用
|
||||
- **监控告警** - 节点健康监控和告警
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动服务
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 2. 访问后台
|
||||
|
||||
打开浏览器访问 http://localhost:8080
|
||||
|
||||
### 3. 默认配置
|
||||
|
||||
- API Key: `your-external-api-key`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
wxauto_api/
|
||||
├── api/ # API 路由
|
||||
├── services/ # 业务服务(含插件通知服务)
|
||||
├── plugins/ # 插件系统
|
||||
│ ├── base.py # 插件基类
|
||||
│ ├── plugin_config_schema.py # 表单化配置系统
|
||||
│ └── advanced_sender_plugin.py # 高级多节点推送插件
|
||||
├── static/ # 前端静态文件
|
||||
└── doc/ # 开发文档
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [API 文档](doc/wxauto_center/API.md)
|
||||
- [插件开发指南](doc/wxauto_center/PLUGIN.md)
|
||||
- [架构文档](doc/wxauto_center/ARCHITECTURE.md)
|
||||
- [数据库文档](doc/wxauto_center/DATABASE.md)
|
||||
|
||||
## API 示例
|
||||
|
||||
### 发送消息
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/v1/messages/send" \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"node_id": "wx1", "who": "文件传输助手", "msg": "Hello"}'
|
||||
```
|
||||
|
||||
### 插件管理
|
||||
|
||||
```bash
|
||||
# 获取所有插件
|
||||
curl "http://localhost:8080/api/v1/plugins/" \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 获取插件表单Schema
|
||||
curl "http://localhost:8080/api/v1/plugins/advanced_sender/schema" \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 获取插件日志
|
||||
curl "http://localhost:8080/api/v1/plugins/advanced_sender/logs?lines=50" \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
```
|
||||
|
||||
### 配置告警通知
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/v1/webhook/" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "插件通知",
|
||||
"url": "https://api.day.app/your-bark-key",
|
||||
"format": "bark",
|
||||
"event_types": ["plugin_notification"]
|
||||
}'
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Python 3.11 + FastAPI
|
||||
- **数据库**: PostgreSQL
|
||||
- **缓存**: Redis
|
||||
- **容器**: Docker
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
# ///
|
||||
# callback.py
|
||||
# 描述:回调接口,用于重置和检测节点微信状态
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from services.node_manager import node_manager
|
||||
from services.log_service import log_service
|
||||
from database_service import get_db_service
|
||||
from utils import require_api_key
|
||||
|
||||
|
||||
router = APIRouter(prefix="/callback", tags=["回调接口"])
|
||||
|
||||
|
||||
class NodeResetRequest(BaseModel):
|
||||
node_id: str
|
||||
|
||||
|
||||
class NodeCallbackRequest(BaseModel):
|
||||
node_id: str
|
||||
|
||||
|
||||
async def sync_node_from_db(node_id: str) -> bool:
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
return False
|
||||
node = db_service.get_node(node_id)
|
||||
if not node:
|
||||
return False
|
||||
existing = node_manager.nodes.get(node_id)
|
||||
if existing:
|
||||
existing.api_url = node.api_url
|
||||
existing.api_key = node.api_key
|
||||
else:
|
||||
await node_manager.add_node(
|
||||
node_id=node.node_id,
|
||||
name=node.name,
|
||||
api_url=node.api_url,
|
||||
api_key=node.api_key,
|
||||
description=node.description or "",
|
||||
group=node.group or "default"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/node/reset", summary="重置节点微信状态")
|
||||
async def reset_node_wechat_status(request: NodeResetRequest, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
"""
|
||||
重置节点的微信状态为online
|
||||
通过发送消息到文件传输助手来检测微信是否正常
|
||||
如果发送成功,则重置状态为online
|
||||
"""
|
||||
node_id = request.node_id
|
||||
await sync_node_from_db(node_id)
|
||||
node = await node_manager.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
|
||||
await node_manager.call_node_api(node_id, "/api/wechat/initialize", method="POST")
|
||||
|
||||
result = await node_manager.call_node_api(
|
||||
node_id,
|
||||
"/api/message/send",
|
||||
method="POST",
|
||||
data={"receiver": "文件传输助手", "message": "WXAuto Center 状态检测消息"}
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
node.wechat_status = "online"
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, wechat_status="online")
|
||||
log_service.success(f"Node {node_id} WeChat status reset to online", "Callback")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "WeChat status reset",
|
||||
"data": {
|
||||
"node_id": node_id,
|
||||
"wechat_status": "online",
|
||||
"check_result": "success"
|
||||
}
|
||||
}
|
||||
else:
|
||||
node.wechat_status = "offline"
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, wechat_status="offline")
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
log_service.error(f"Node {node_id} WeChat status check failed: {error_msg}", "Callback")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "WeChat status check failed",
|
||||
"data": {
|
||||
"node_id": node_id,
|
||||
"wechat_status": "offline",
|
||||
"check_result": "failed",
|
||||
"error": error_msg
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/node/check", summary="检测节点微信状态")
|
||||
async def check_node_wechat_status(request: NodeCallbackRequest, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
"""
|
||||
检测节点的微信状态
|
||||
通过发送消息到文件传输助手来检测微信是否正常
|
||||
返回检测结果
|
||||
"""
|
||||
node_id = request.node_id
|
||||
await sync_node_from_db(node_id)
|
||||
node = await node_manager.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
|
||||
await node_manager.call_node_api(node_id, "/api/wechat/initialize", method="POST")
|
||||
|
||||
result = await node_manager.call_node_api(
|
||||
node_id,
|
||||
"/api/message/send",
|
||||
method="POST",
|
||||
data={"receiver": "文件传输助手", "message": "WXAuto Center 状态检测消息"}
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
node.wechat_status = "online"
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, wechat_status="online")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "WeChat is normal",
|
||||
"data": {
|
||||
"node_id": node_id,
|
||||
"wechat_status": "online",
|
||||
"api_status": node.status
|
||||
}
|
||||
}
|
||||
else:
|
||||
node.wechat_status = "offline"
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, wechat_status="offline")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "WeChat is abnormal",
|
||||
"data": {
|
||||
"node_id": node_id,
|
||||
"wechat_status": "offline",
|
||||
"api_status": node.status,
|
||||
"error": result.get("error", "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/status", summary="获取节点回调状态")
|
||||
async def get_node_callback_status(node_id: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
"""
|
||||
获取节点的当前状态,用于回调确认
|
||||
"""
|
||||
node = await node_manager.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name,
|
||||
"api_url": node.api_url,
|
||||
"api_status": node.status,
|
||||
"wechat_status": node.wechat_status,
|
||||
"is_healthy": node.is_healthy,
|
||||
"enabled": node.enabled,
|
||||
"group": node.group
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# ///
|
||||
# external.py
|
||||
# 描述:外部扩展接口,供其他程序调用中控系统
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.node_manager import node_manager
|
||||
from plugins.base import PluginRegistry
|
||||
from utils import require_external_key
|
||||
|
||||
|
||||
router = APIRouter(prefix="/external", tags=["外部扩展接口"])
|
||||
|
||||
|
||||
class ExternalMessageRequest(BaseModel):
|
||||
node_id: str
|
||||
who: str
|
||||
msg: str
|
||||
msg_type: str = "text"
|
||||
|
||||
|
||||
class DataTriggerRequest(BaseModel):
|
||||
node_id: str
|
||||
source: str
|
||||
event: str
|
||||
data: Dict[str, Any]
|
||||
auto_send: bool = True
|
||||
target: Optional[str] = None
|
||||
|
||||
|
||||
class PluginExecuteRequest(BaseModel):
|
||||
plugin_name: str
|
||||
action: str
|
||||
params: Dict[str, Any] = {}
|
||||
|
||||
|
||||
async def sync_node_from_db(node_id: str) -> bool:
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
return False
|
||||
node = db_service.get_node(node_id)
|
||||
if not node:
|
||||
return False
|
||||
existing = node_manager.nodes.get(node_id)
|
||||
if existing:
|
||||
existing.api_url = node.api_url
|
||||
existing.api_key = node.api_key
|
||||
else:
|
||||
await node_manager.add_node(
|
||||
node_id=node.node_id,
|
||||
name=node.name,
|
||||
api_url=node.api_url,
|
||||
api_key=node.api_key,
|
||||
description=node.description or "",
|
||||
group=node.group or "default"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/send", summary="外部接口-发送消息")
|
||||
async def external_send_message(request: ExternalMessageRequest,
|
||||
_=Depends(require_external_key)) -> Dict[str, Any]:
|
||||
await sync_node_from_db(request.node_id)
|
||||
result = await node_manager.send_message(
|
||||
node_id=request.node_id,
|
||||
who=request.who,
|
||||
msg=request.msg,
|
||||
msg_type=request.msg_type
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/trigger", summary="外部接口-触发数据处理")
|
||||
async def external_trigger(request: DataTriggerRequest,
|
||||
_=Depends(require_external_key)) -> Dict[str, Any]:
|
||||
plugin = PluginRegistry.get(request.source)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{request.source}' not found")
|
||||
|
||||
if not plugin.enabled:
|
||||
raise HTTPException(status_code=400, detail=f"Plugin '{request.source}' is disabled")
|
||||
|
||||
execute_result = plugin.execute({
|
||||
"event": request.event,
|
||||
"data": request.data
|
||||
})
|
||||
|
||||
if request.auto_send and request.target:
|
||||
await sync_node_from_db(request.node_id)
|
||||
msg = request.data.get("message", str(request.data))
|
||||
send_result = await node_manager.send_message(
|
||||
node_id=request.node_id,
|
||||
who=request.target,
|
||||
msg=msg
|
||||
)
|
||||
execute_result["send_result"] = send_result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"plugin_result": execute_result,
|
||||
"data_triggered": True
|
||||
}
|
||||
|
||||
|
||||
@router.post("/plugin/execute", summary="外部接口-执行插件")
|
||||
async def execute_plugin(request: PluginExecuteRequest,
|
||||
_=Depends(require_external_key)) -> Dict[str, Any]:
|
||||
plugin = PluginRegistry.get(request.plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{request.plugin_name}' not found")
|
||||
|
||||
if not plugin.enabled:
|
||||
raise HTTPException(status_code=400, detail=f"Plugin '{request.plugin_name}' is disabled")
|
||||
|
||||
result = plugin.execute(request.params)
|
||||
return {"success": True, "result": result}
|
||||
|
||||
|
||||
@router.get("/plugins", summary="外部接口-获取可用插件列表")
|
||||
async def get_plugins(_=Depends(require_external_key)) -> Dict[str, Any]:
|
||||
plugins = PluginRegistry.get_all()
|
||||
enabled_plugins = [p for p in plugins if p.enabled]
|
||||
return {
|
||||
"success": True,
|
||||
"plugins": [p.get_info() for p in enabled_plugins],
|
||||
"count": len(enabled_plugins)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/nodes", summary="外部接口-获取可用节点")
|
||||
async def get_available_nodes(_=Depends(require_external_key)) -> Dict[str, Any]:
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
nodes = db_service.get_enabled_nodes()
|
||||
return {"success": True, "nodes": [n.to_dict() for n in nodes], "count": len(nodes)}
|
||||
nodes = await node_manager.get_enabled_nodes()
|
||||
return {"success": True, "nodes": [n.to_dict() for n in nodes], "count": len(nodes)}
|
||||
|
||||
|
||||
@router.post("/ai/process", summary="外部接口-AI处理消息")
|
||||
async def ai_process_message(
|
||||
input_text: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
model: Optional[str] = None,
|
||||
_=Depends(require_external_key)
|
||||
) -> Dict[str, Any]:
|
||||
ai_plugin = PluginRegistry.get("simple_ai_agent")
|
||||
if not ai_plugin or not ai_plugin.enabled:
|
||||
raise HTTPException(status_code=400, detail="AI agent not available")
|
||||
|
||||
if model:
|
||||
ai_plugin.model = model
|
||||
|
||||
result = ai_plugin.process(input_text, context or {})
|
||||
return {"success": True, "result": result}
|
||||
|
||||
|
||||
@router.post("/webhook/{event}", summary="外部接口-Webhook接收")
|
||||
async def receive_webhook(event: str,
|
||||
payload: Dict[str, Any],
|
||||
_=Depends(require_external_key)) -> Dict[str, Any]:
|
||||
webhook_plugin = PluginRegistry.get("webhook_trigger")
|
||||
if webhook_plugin and webhook_plugin.enabled:
|
||||
result = webhook_plugin.execute({
|
||||
"event": event,
|
||||
"data": payload
|
||||
})
|
||||
return {"success": True, "result": result}
|
||||
return {"success": True, "message": "Webhook received", "event": event}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# ///
|
||||
# messages.py
|
||||
# 描述:消息发送API路由
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.node_manager import node_manager
|
||||
from utils import require_api_key, check_node_health
|
||||
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["消息管理"])
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
node_id: str
|
||||
who: str
|
||||
msg: str
|
||||
msg_type: str = "text"
|
||||
|
||||
|
||||
class SendGroupMessageRequest(BaseModel):
|
||||
node_id: str
|
||||
group_name: str
|
||||
msg: str
|
||||
msg_type: str = "text"
|
||||
|
||||
|
||||
class BatchMessageRequest(BaseModel):
|
||||
messages: List[Dict[str, Any]]
|
||||
|
||||
|
||||
async def sync_node_from_db(node_id: str) -> bool:
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
return False
|
||||
node = db_service.get_node(node_id)
|
||||
if not node:
|
||||
return False
|
||||
existing = node_manager.nodes.get(node_id)
|
||||
if existing:
|
||||
existing.api_url = node.api_url
|
||||
existing.api_key = node.api_key
|
||||
else:
|
||||
await node_manager.add_node(
|
||||
node_id=node.node_id,
|
||||
name=node.name,
|
||||
api_url=node.api_url,
|
||||
api_key=node.api_key,
|
||||
description=node.description or "",
|
||||
group=node.group or "default"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/send", summary="发送消息")
|
||||
async def send_message(request: SendMessageRequest,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
await sync_node_from_db(request.node_id)
|
||||
await check_node_health(request.node_id)
|
||||
|
||||
result = await node_manager.send_message(
|
||||
node_id=request.node_id,
|
||||
who=request.who,
|
||||
msg=request.msg,
|
||||
msg_type=request.msg_type
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/send_group", summary="发送群消息")
|
||||
async def send_group_message(request: SendGroupMessageRequest,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
await sync_node_from_db(request.node_id)
|
||||
await check_node_health(request.node_id)
|
||||
|
||||
result = await node_manager.send_message_to_group(
|
||||
node_id=request.node_id,
|
||||
group_name=request.group_name,
|
||||
msg=request.msg,
|
||||
msg_type=request.msg_type
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/batch", summary="批量发送消息")
|
||||
async def batch_send(request: BatchMessageRequest,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
results = []
|
||||
for msg_item in request.messages:
|
||||
node_id = msg_item.get("node_id")
|
||||
who = msg_item.get("who")
|
||||
msg = msg_item.get("msg")
|
||||
msg_type = msg_item.get("msg_type", "text")
|
||||
|
||||
if not all([node_id, who, msg]):
|
||||
results.append({
|
||||
"success": False,
|
||||
"error": "Missing required fields",
|
||||
"item": msg_item
|
||||
})
|
||||
continue
|
||||
|
||||
is_healthy, reason = await node_manager.is_node_healthy(node_id)
|
||||
if not is_healthy:
|
||||
results.append({
|
||||
"success": False,
|
||||
"error": f"Node not healthy: {reason}",
|
||||
"node_id": node_id,
|
||||
"who": who
|
||||
})
|
||||
continue
|
||||
|
||||
result = await node_manager.send_message(node_id, who, msg, msg_type)
|
||||
results.append({"node_id": node_id, "who": who, "result": result})
|
||||
|
||||
return {"success": True, "results": results, "total": len(results)}
|
||||
|
||||
|
||||
@router.post("/template/{node_id}/{who}", summary="发送模板消息")
|
||||
async def send_template_message(node_id: str, who: str,
|
||||
template: str,
|
||||
params: Dict[str, str],
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
await check_node_health(node_id)
|
||||
|
||||
try:
|
||||
msg = template.format(**params)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Missing template parameter: {e}")
|
||||
|
||||
result = await node_manager.send_message(node_id, who, msg)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/history/{node_id}/{chat_name}", summary="获取消息历史")
|
||||
async def get_message_history(node_id: str, chat_name: str,
|
||||
limit: int = 50,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
await check_node_health(node_id)
|
||||
|
||||
result = await node_manager.get_messages(node_id, chat_name, limit)
|
||||
return result
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
# ///
|
||||
# nodes.py
|
||||
# 描述:节点管理API路由
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.node_manager import node_manager
|
||||
from config import save_config_to_file, get_settings, NodeConfig
|
||||
from utils import require_api_key
|
||||
|
||||
|
||||
router = APIRouter(prefix="/nodes", tags=["节点管理"])
|
||||
|
||||
|
||||
class NodeAddRequest(BaseModel):
|
||||
node_id: str
|
||||
name: str
|
||||
api_url: str
|
||||
api_key: str
|
||||
description: str = ""
|
||||
group: str = "default"
|
||||
|
||||
|
||||
class NodeUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
api_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
group: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
def save_nodes_config():
|
||||
nodes_data = node_manager.to_config()
|
||||
get_settings().nodes = [NodeConfig(**n) for n in nodes_data]
|
||||
save_config_to_file()
|
||||
|
||||
|
||||
@router.get("/", summary="获取所有节点")
|
||||
async def get_nodes(group: Optional[str] = None, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
nodes = db_service.get_all_nodes()
|
||||
result_nodes = [n.to_dict() for n in nodes]
|
||||
return {"success": True, "data": result_nodes, "count": len(result_nodes)}
|
||||
|
||||
if group:
|
||||
nodes = await node_manager.get_nodes_by_group(group)
|
||||
else:
|
||||
nodes = await node_manager.get_all_nodes()
|
||||
|
||||
result_nodes = []
|
||||
for node in nodes:
|
||||
node_dict = node.to_dict()
|
||||
result_nodes.append(node_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result_nodes,
|
||||
"count": len(result_nodes)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{node_id}", summary="获取指定节点信息")
|
||||
async def get_node(node_id: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
node = db_service.get_node(node_id)
|
||||
if node:
|
||||
return {"success": True, "data": node.to_dict()}
|
||||
node = await node_manager.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
return {"success": True, "data": node.to_dict()}
|
||||
|
||||
|
||||
@router.post("/", summary="添加节点")
|
||||
async def add_node(request: NodeAddRequest, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.add_node(
|
||||
node_id=request.node_id,
|
||||
name=request.name,
|
||||
api_url=request.api_url,
|
||||
api_key=request.api_key,
|
||||
description=request.description or "",
|
||||
group=request.group or "default"
|
||||
)
|
||||
|
||||
success = await node_manager.add_node(
|
||||
node_id=request.node_id,
|
||||
name=request.name,
|
||||
api_url=request.api_url,
|
||||
api_key=request.api_key,
|
||||
description=request.description,
|
||||
group=request.group
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=f"Node '{request.node_id}' already exists")
|
||||
save_nodes_config()
|
||||
return {"success": True, "message": f"Node '{request.node_id}' added successfully"}
|
||||
|
||||
|
||||
@router.put("/{node_id}", summary="更新节点信息")
|
||||
async def update_node(node_id: str, request: NodeUpdateRequest,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, **update_data)
|
||||
|
||||
success = await node_manager.update_node(node_id, **update_data)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
save_nodes_config()
|
||||
return {"success": True, "message": f"Node '{node_id}' updated successfully"}
|
||||
|
||||
|
||||
@router.delete("/{node_id}", summary="删除节点")
|
||||
async def delete_node(node_id: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.delete_node(node_id)
|
||||
|
||||
success = await node_manager.remove_node(node_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
save_nodes_config()
|
||||
return {"success": True, "message": f"Node '{node_id}' deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/{node_id}/status", summary="检查节点状态")
|
||||
async def check_node_status(node_id: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
api_status = await node_manager.check_node_status(node_id)
|
||||
is_healthy, health_msg = await node_manager.is_node_healthy(node_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"node_id": node_id,
|
||||
"api_status": api_status,
|
||||
"is_healthy": is_healthy,
|
||||
"health_message": health_msg
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{node_id}/enable", summary="启用节点")
|
||||
async def enable_node(node_id: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, enabled=True)
|
||||
success = await node_manager.update_node(node_id, enabled=True)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
save_nodes_config()
|
||||
return {"success": True, "message": f"Node '{node_id}' enabled"}
|
||||
|
||||
|
||||
@router.post("/{node_id}/disable", summary="禁用节点")
|
||||
async def disable_node(node_id: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, enabled=False)
|
||||
success = await node_manager.update_node(node_id, enabled=False)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
save_nodes_config()
|
||||
return {"success": True, "message": f"Node '{node_id}' disabled"}
|
||||
|
||||
|
||||
@router.get("/{node_id}/friends", summary="获取节点好友列表")
|
||||
async def get_friends(node_id: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
result = await node_manager.get_friends_list(node_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{node_id}/groups", summary="获取节点群列表")
|
||||
async def get_groups(node_id: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
result = await node_manager.get_groups_list(node_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{node_id}/messages/{chat_name}", summary="获取聊天消息")
|
||||
async def get_messages(node_id: str, chat_name: str, limit: int = 20,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
result = await node_manager.get_messages(node_id, chat_name, limit)
|
||||
return result
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
# ///
|
||||
# plugins.py
|
||||
# 描述:插件管理API路由
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
from plugins.base import PluginRegistry, PluginType
|
||||
from utils import require_api_key
|
||||
from database_service import get_db_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/plugins", tags=["插件管理"])
|
||||
|
||||
|
||||
class PluginConfigRequest(BaseModel):
|
||||
name: str
|
||||
config: Dict[str, Any]
|
||||
|
||||
|
||||
def _sync_plugin_from_db(plugin_name: str):
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
return
|
||||
plugin_config = db_service.get_plugin_config(plugin_name)
|
||||
plugin = PluginRegistry.get(plugin_name)
|
||||
if not plugin:
|
||||
return
|
||||
if plugin_config:
|
||||
import json
|
||||
plugin.initialize(json.loads(plugin_config.config_json) if plugin_config.config_json else {})
|
||||
if plugin_config.enabled:
|
||||
plugin.enable()
|
||||
else:
|
||||
plugin.disable()
|
||||
|
||||
|
||||
@router.get("/", summary="获取所有插件")
|
||||
async def get_plugins(_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
import json
|
||||
plugins = PluginRegistry.get_all()
|
||||
result = []
|
||||
for p in plugins:
|
||||
info = p.get_info()
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
pc = db_service.get_plugin_config(p.plugin_name)
|
||||
if pc:
|
||||
info["config"] = json.loads(pc.config_json) if pc.config_json else {}
|
||||
info["enabled"] = pc.enabled
|
||||
result.append(info)
|
||||
return {
|
||||
"success": True,
|
||||
"plugins": result,
|
||||
"count": len(result)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hotloaded", summary="获取热加载插件列表")
|
||||
async def get_hotloaded_plugins(_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
try:
|
||||
from plugins.plugin_loader import hot_plugin_loader
|
||||
plugins = hot_plugin_loader.get_loaded_plugins()
|
||||
return {
|
||||
"success": True,
|
||||
"plugins": plugins,
|
||||
"count": len(plugins),
|
||||
"plugin_dir": hot_plugin_loader.plugin_dir
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/hotreload", summary="手动触发插件热重载")
|
||||
async def trigger_hot_reload(_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
try:
|
||||
from plugins.plugin_loader import hot_plugin_loader
|
||||
hot_plugin_loader.scan_and_load_plugins()
|
||||
plugins = hot_plugin_loader.get_loaded_plugins()
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Hot reload triggered",
|
||||
"plugins": plugins
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/type/{plugin_type}", summary="按类型获取插件")
|
||||
async def get_plugins_by_type(plugin_type: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
try:
|
||||
ptype = PluginType(plugin_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid plugin type: {plugin_type}")
|
||||
|
||||
plugins = PluginRegistry.get_by_type(ptype)
|
||||
return {
|
||||
"success": True,
|
||||
"plugins": [p.get_info() for p in plugins],
|
||||
"count": len(plugins)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/custom/list", summary="列出自定义插件文件")
|
||||
async def list_custom_plugins(_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
try:
|
||||
import os
|
||||
custom_dir = "/app/plugins/custom"
|
||||
if not os.path.exists(custom_dir):
|
||||
return {"success": True, "plugins": [], "count": 0}
|
||||
|
||||
files = []
|
||||
for f in os.listdir(custom_dir):
|
||||
if f.endswith('.py') and not f.startswith('_'):
|
||||
filepath = os.path.join(custom_dir, f)
|
||||
stat = os.stat(filepath)
|
||||
files.append({
|
||||
"name": f,
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"path": filepath
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"plugins": files,
|
||||
"count": len(files),
|
||||
"directory": custom_dir
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/custom/create", summary="创建自定义插件文件")
|
||||
async def create_custom_plugin(name: str, code: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
try:
|
||||
import os
|
||||
custom_dir = "/app/plugins/custom"
|
||||
os.makedirs(custom_dir, exist_ok=True)
|
||||
|
||||
safe_name = name.replace("..", "").replace("/", "").replace("\\", "")
|
||||
if not safe_name.endswith(".py"):
|
||||
safe_name += ".py"
|
||||
|
||||
filepath = os.path.join(custom_dir, safe_name)
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(code)
|
||||
|
||||
from plugins.plugin_loader import hot_plugin_loader
|
||||
hot_plugin_loader.scan_and_load_plugins()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Plugin {safe_name} created",
|
||||
"path": filepath
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.delete("/custom/{plugin_name}", summary="删除自定义插件")
|
||||
async def delete_custom_plugin(plugin_name: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
try:
|
||||
import os
|
||||
custom_dir = "/app/plugins/custom"
|
||||
|
||||
safe_name = plugin_name.replace("..", "").replace("/", "").replace("\\", "")
|
||||
if not safe_name.endswith(".py"):
|
||||
safe_name += ".py"
|
||||
|
||||
filepath = os.path.join(custom_dir, safe_name)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
from plugins.plugin_loader import hot_plugin_loader
|
||||
hot_plugin_loader.scan_and_load_plugins()
|
||||
|
||||
return {"success": True, "message": f"Plugin {safe_name} deleted"}
|
||||
else:
|
||||
return {"success": False, "error": "Plugin file not found"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/custom/{plugin_name}/download", summary="下载自定义插件源码")
|
||||
async def download_custom_plugin(plugin_name: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
try:
|
||||
import os
|
||||
custom_dir = "/app/plugins/custom"
|
||||
|
||||
safe_name = plugin_name.replace("..", "").replace("/", "").replace("\\", "")
|
||||
if not safe_name.endswith(".py"):
|
||||
safe_name += ".py"
|
||||
|
||||
filepath = os.path.join(custom_dir, safe_name)
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, 'r') as f:
|
||||
code = f.read()
|
||||
return {
|
||||
"success": True,
|
||||
"name": safe_name,
|
||||
"code": code
|
||||
}
|
||||
else:
|
||||
return {"success": False, "error": "Plugin file not found"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/{plugin_name}", summary="获取插件详情")
|
||||
async def get_plugin(plugin_name: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
import json
|
||||
plugin = PluginRegistry.get(plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
|
||||
info = plugin.get_info()
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
pc = db_service.get_plugin_config(plugin_name)
|
||||
if pc:
|
||||
info["config"] = json.loads(pc.config_json) if pc.config_json else {}
|
||||
info["enabled"] = pc.enabled
|
||||
return {"success": True, "plugin": info}
|
||||
|
||||
|
||||
@router.post("/{plugin_name}/enable", summary="启用插件")
|
||||
async def enable_plugin(plugin_name: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
plugin = PluginRegistry.get(plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
|
||||
PluginRegistry.enable(plugin_name)
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
pc = db_service.get_plugin_config(plugin_name)
|
||||
if pc:
|
||||
db_service.update_plugin_enabled(plugin_name, True)
|
||||
else:
|
||||
db_service.save_plugin_config(plugin_name, plugin.config or {}, True)
|
||||
return {"success": True, "message": f"Plugin '{plugin_name}' enabled"}
|
||||
|
||||
|
||||
@router.post("/{plugin_name}/disable", summary="禁用插件")
|
||||
async def disable_plugin(plugin_name: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
plugin = PluginRegistry.get(plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
|
||||
PluginRegistry.disable(plugin_name)
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
pc = db_service.get_plugin_config(plugin_name)
|
||||
if pc:
|
||||
db_service.update_plugin_enabled(plugin_name, False)
|
||||
else:
|
||||
db_service.save_plugin_config(plugin_name, plugin.config or {}, False)
|
||||
return {"success": True, "message": f"Plugin '{plugin_name}' disabled"}
|
||||
|
||||
|
||||
@router.put("/{plugin_name}/config", summary="更新插件配置")
|
||||
async def update_plugin_config(plugin_name: str,
|
||||
request: PluginConfigRequest,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
plugin = PluginRegistry.get(plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
|
||||
|
||||
success = plugin.initialize(request.config)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to initialize plugin '{plugin_name}'")
|
||||
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
pc = db_service.get_plugin_config(plugin_name)
|
||||
enabled = pc.enabled if pc else False
|
||||
db_service.save_plugin_config(plugin_name, request.config, enabled)
|
||||
|
||||
return {"success": True, "message": f"Plugin '{plugin_name}' config updated"}
|
||||
|
||||
|
||||
@router.post("/{plugin_name}/execute", summary="执行插件")
|
||||
async def execute_plugin(plugin_name: str,
|
||||
request: Optional[Dict[str, Any]] = None,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
plugin = PluginRegistry.get(plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
|
||||
|
||||
if not plugin.enabled:
|
||||
return {"success": False, "error": f"Plugin '{plugin_name}' is disabled"}
|
||||
|
||||
params = request or {}
|
||||
result = plugin.execute(params)
|
||||
return {"success": True, "result": result}
|
||||
|
||||
|
||||
@router.get("/{plugin_name}/logs", summary="获取插件日志")
|
||||
async def get_plugin_logs(
|
||||
plugin_name: str,
|
||||
lines: int = 100,
|
||||
level: Optional[str] = None,
|
||||
_=Depends(require_api_key)
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
import logging
|
||||
import os
|
||||
|
||||
plugin = PluginRegistry.get(plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
|
||||
|
||||
log_dir = "/app/data/logs"
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
log_file = os.path.join(log_dir, f"{plugin_name}.log")
|
||||
app_log = os.path.join(log_dir, "app.log")
|
||||
|
||||
all_logs = []
|
||||
|
||||
if os.path.exists(log_file):
|
||||
with open(log_file, 'r', encoding='utf-8') as f:
|
||||
all_logs.extend(f.readlines())
|
||||
|
||||
if os.path.exists(app_log):
|
||||
with open(app_log, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if plugin_name in line or f"[{plugin_name}]" in line:
|
||||
all_logs.append(line)
|
||||
|
||||
filtered_logs = []
|
||||
for line in all_logs:
|
||||
if level:
|
||||
if level.upper() in line.upper():
|
||||
filtered_logs.append(line.strip())
|
||||
else:
|
||||
filtered_logs.append(line.strip())
|
||||
|
||||
filtered_logs = filtered_logs[-lines:] if len(filtered_logs) > lines else filtered_logs
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"plugin_name": plugin_name,
|
||||
"log_file": log_file,
|
||||
"count": len(filtered_logs),
|
||||
"logs": filtered_logs
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/{plugin_name}/schema", summary="获取插件配置表单Schema")
|
||||
async def get_plugin_schema(plugin_name: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
try:
|
||||
plugin = PluginRegistry.get(plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
|
||||
|
||||
schema = None
|
||||
if hasattr(plugin, 'get_config_schema'):
|
||||
schema = plugin.get_config_schema()
|
||||
elif hasattr(plugin, 'config_schema'):
|
||||
schema = plugin.config_schema
|
||||
|
||||
if schema:
|
||||
if callable(schema):
|
||||
schema = schema()
|
||||
if hasattr(schema, 'to_form_schema'):
|
||||
return {
|
||||
"success": True,
|
||||
"schema": schema.to_form_schema(),
|
||||
"has_form": True
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"schema": schema,
|
||||
"has_form": True
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"schema": None,
|
||||
"has_form": False,
|
||||
"message": "此插件暂无表单配置"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/{plugin_name}/logs/clear", summary="清空插件日志")
|
||||
async def clear_plugin_logs(plugin_name: str, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
try:
|
||||
import os
|
||||
|
||||
plugin = PluginRegistry.get(plugin_name)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
|
||||
|
||||
log_file = f"/app/data/logs/{plugin_name}.log"
|
||||
|
||||
if os.path.exists(log_file):
|
||||
with open(log_file, 'w') as f:
|
||||
f.write("")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Plugin '{plugin_name}' logs cleared"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# ///
|
||||
# webhook.py
|
||||
# 描述:Webhook管理API路由
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
# ///
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database_service import get_db_service
|
||||
from utils import require_api_key
|
||||
|
||||
|
||||
router = APIRouter(prefix="/webhook", tags=["Webhook管理"])
|
||||
|
||||
|
||||
class WebhookAddRequest(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
format: str = "bark"
|
||||
event_types: List[str] = []
|
||||
|
||||
|
||||
class WebhookUpdateRequest(BaseModel):
|
||||
name: str = None
|
||||
url: str = None
|
||||
format: str = None
|
||||
enabled: bool = None
|
||||
event_types: List[str] = None
|
||||
|
||||
|
||||
@router.get("/", summary="获取所有Webhook")
|
||||
async def get_webhooks(_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
webhooks = db_service.get_all_webhooks()
|
||||
return {
|
||||
"success": True,
|
||||
"data": [w.to_dict() for w in webhooks],
|
||||
"count": len(webhooks)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{webhook_id}", summary="获取指定Webhook")
|
||||
async def get_webhook(webhook_id: int, _=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
webhook = db_service.get_webhook(webhook_id)
|
||||
if not webhook:
|
||||
raise HTTPException(status_code=404, detail=f"Webhook {webhook_id} not found")
|
||||
|
||||
return {"success": True, "data": webhook.to_dict()}
|
||||
|
||||
|
||||
@router.post("/", summary="添加Webhook")
|
||||
async def add_webhook(request: WebhookAddRequest,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
success = db_service.add_webhook(
|
||||
name=request.name,
|
||||
url=request.url,
|
||||
format=request.format,
|
||||
event_types=request.event_types
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to add webhook")
|
||||
|
||||
return {"success": True, "message": "Webhook added successfully"}
|
||||
|
||||
|
||||
@router.put("/{webhook_id}", summary="更新Webhook")
|
||||
async def update_webhook(webhook_id: int,
|
||||
request: WebhookUpdateRequest,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
success = db_service.update_webhook(webhook_id, **update_data)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Webhook {webhook_id} not found")
|
||||
|
||||
return {"success": True, "message": "Webhook updated successfully"}
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}", summary="删除Webhook")
|
||||
async def delete_webhook(webhook_id: int,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
success = db_service.delete_webhook(webhook_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Webhook {webhook_id} not found")
|
||||
|
||||
return {"success": True, "message": "Webhook deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/enable", summary="启用Webhook")
|
||||
async def enable_webhook(webhook_id: int,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
success = db_service.update_webhook(webhook_id, enabled=True)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Webhook {webhook_id} not found")
|
||||
|
||||
return {"success": True, "message": "Webhook enabled"}
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/disable", summary="禁用Webhook")
|
||||
async def disable_webhook(webhook_id: int,
|
||||
_=Depends(require_api_key)) -> Dict[str, Any]:
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
success = db_service.update_webhook(webhook_id, enabled=False)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Webhook {webhook_id} not found")
|
||||
|
||||
return {"success": True, "message": "Webhook disabled"}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"app_name": "WXAuto Center",
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080,
|
||||
"debug": true,
|
||||
"secret_key": "wxauto-center-secret-key-change-in-production",
|
||||
"workers": 1,
|
||||
"cors_origins": ["*"],
|
||||
"api_prefix": "/api/v1",
|
||||
"external_api_key": "your-external-api-key",
|
||||
"monitor": {
|
||||
"enabled": true,
|
||||
"check_interval": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
# ///
|
||||
# config.py
|
||||
# 描述:系统配置管理,支持多环境配置
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class NodeConfig(BaseModel):
|
||||
node_id: str
|
||||
name: str
|
||||
api_url: str
|
||||
api_key: str
|
||||
enabled: bool = True
|
||||
description: str = ""
|
||||
group: str = "default"
|
||||
|
||||
|
||||
class PluginConfig(BaseModel):
|
||||
name: str
|
||||
enabled: bool = True
|
||||
config: Dict = {}
|
||||
|
||||
|
||||
class WebhookConfig(BaseModel):
|
||||
urls: List[str] = []
|
||||
enabled: bool = False
|
||||
timeout: int = 10
|
||||
retry_times: int = 3
|
||||
format: str = "wechat"
|
||||
event_types: List[str] = ["api_error", "wechat_offline", "wechat_online", "node_offline", "node_online"]
|
||||
|
||||
|
||||
class MonitorConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
check_interval: int = 60
|
||||
|
||||
|
||||
class RedisConfig(BaseModel):
|
||||
url: str = "redis://localhost:6379/0"
|
||||
db: int = 0
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class DatabaseConfig(BaseModel):
|
||||
url: str = "postgresql://wxauto:wxauto@localhost:5432/wxauto"
|
||||
pool_size: int = 10
|
||||
max_overflow: int = 20
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "WXAuto Center"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8080
|
||||
debug: bool = False
|
||||
secret_key: str = "wxauto-center-secret-key-change-in-production"
|
||||
|
||||
nodes: List[NodeConfig] = []
|
||||
plugins: List[PluginConfig] = []
|
||||
webhook: WebhookConfig = WebhookConfig()
|
||||
monitor: MonitorConfig = MonitorConfig()
|
||||
redis: RedisConfig = RedisConfig()
|
||||
database: DatabaseConfig = DatabaseConfig()
|
||||
|
||||
cors_origins: List[str] = ["*"]
|
||||
api_prefix: str = "/api/v1"
|
||||
external_api_key: str = "your-external-api-key"
|
||||
workers: int = 1
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
populate_by_name = True
|
||||
extra = "allow"
|
||||
env_nested_delimiter = "_"
|
||||
|
||||
_settings: Optional[Settings] = None
|
||||
_config_file: str = "config.json"
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
_settings.database.url = os.environ.get("DATABASE_URL", _settings.database.url)
|
||||
_settings.redis.url = os.environ.get("REDIS_URL", _settings.redis.url)
|
||||
return _settings
|
||||
|
||||
|
||||
def update_settings(**kwargs):
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(_settings, key):
|
||||
setattr(_settings, key, value)
|
||||
|
||||
|
||||
def load_config_from_file(config_file: str = "config.json") -> bool:
|
||||
global _settings, _config_file
|
||||
_config_file = config_file
|
||||
|
||||
if not os.path.exists(config_file):
|
||||
return False
|
||||
|
||||
try:
|
||||
import json
|
||||
with open(config_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
settings = get_settings()
|
||||
settings.app_name = data.get("app_name", settings.app_name)
|
||||
settings.host = data.get("host", settings.host)
|
||||
settings.port = data.get("port", settings.port)
|
||||
settings.debug = data.get("debug", settings.debug)
|
||||
settings.secret_key = data.get("secret_key", settings.secret_key)
|
||||
settings.cors_origins = data.get("cors_origins", settings.cors_origins)
|
||||
settings.api_prefix = data.get("api_prefix", settings.api_prefix)
|
||||
settings.external_api_key = data.get("external_api_key", settings.external_api_key)
|
||||
|
||||
plugins_data = data.get("plugins", [])
|
||||
settings.plugins = [PluginConfig(**p) for p in plugins_data]
|
||||
|
||||
monitor_data = data.get("monitor", {})
|
||||
settings.monitor = MonitorConfig(**monitor_data) if monitor_data else MonitorConfig()
|
||||
|
||||
settings.workers = data.get("workers", 1)
|
||||
|
||||
settings.database.url = os.environ.get("DATABASE_URL", settings.database.url)
|
||||
settings.redis.url = os.environ.get("REDIS_URL", settings.redis.url)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def save_config_to_file(config_file: str = None):
|
||||
global _config_file
|
||||
if config_file:
|
||||
_config_file = config_file
|
||||
|
||||
import json
|
||||
settings = get_settings()
|
||||
config_data = {
|
||||
"app_name": settings.app_name,
|
||||
"host": settings.host,
|
||||
"port": settings.port,
|
||||
"debug": settings.debug,
|
||||
"secret_key": settings.secret_key,
|
||||
"plugins": [p.model_dump() for p in settings.plugins],
|
||||
"monitor": settings.monitor.model_dump(),
|
||||
"workers": settings.workers,
|
||||
"cors_origins": settings.cors_origins,
|
||||
"api_prefix": settings.api_prefix,
|
||||
"external_api_key": settings.external_api_key,
|
||||
}
|
||||
with open(_config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
def __init__(self, config_file: str = "config.json"):
|
||||
self.config_file = config_file
|
||||
|
||||
def get_node(self, node_id: str) -> Optional[NodeConfig]:
|
||||
settings = get_settings()
|
||||
for node in settings.nodes:
|
||||
if node.node_id == node_id:
|
||||
return node
|
||||
return None
|
||||
|
||||
def get_all_nodes(self) -> List[NodeConfig]:
|
||||
return get_settings().nodes
|
||||
|
||||
def get_enabled_nodes(self) -> List[NodeConfig]:
|
||||
return [n for n in get_settings().nodes if n.enabled]
|
||||
|
||||
def add_node(self, node: NodeConfig) -> bool:
|
||||
existing = self.get_node(node.node_id)
|
||||
if existing:
|
||||
return False
|
||||
get_settings().nodes.append(node)
|
||||
return True
|
||||
|
||||
def update_node(self, node_id: str, **kwargs) -> bool:
|
||||
for i, node in enumerate(get_settings().nodes):
|
||||
if node.node_id == node_id:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(node, key):
|
||||
setattr(node, key, value)
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_node(self, node_id: str) -> bool:
|
||||
for i, node in enumerate(get_settings().nodes):
|
||||
if node.node_id == node_id:
|
||||
get_settings().nodes.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def save_config(self):
|
||||
save_config_to_file(self.config_file)
|
||||
|
||||
def load_config(self) -> bool:
|
||||
return load_config_from_file(self.config_file)
|
||||
@@ -0,0 +1,283 @@
|
||||
# ///
|
||||
# database_service.py
|
||||
# 描述:数据库服务层
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from models import DatabaseManager, Node, LogEntry, WebhookUrl, PluginConfig
|
||||
|
||||
|
||||
class DatabaseService:
|
||||
def __init__(self, db_manager: DatabaseManager):
|
||||
self.db = db_manager
|
||||
|
||||
# ==================== Node Operations ====================
|
||||
|
||||
def get_all_nodes(self) -> List[Node]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(Node).all()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_node(self, node_id: str) -> Optional[Node]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(Node).filter(Node.node_id == node_id).first()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_nodes_by_group(self, group: str) -> List[Node]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(Node).filter(Node.group == group).all()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_enabled_nodes(self) -> List[Node]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(Node).filter(Node.enabled == True).all()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def add_node(self, node_id: str, name: str, api_url: str, api_key: str,
|
||||
description: str = "", group: str = "default") -> bool:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
existing = session.query(Node).filter(Node.node_id == node_id).first()
|
||||
if existing:
|
||||
return False
|
||||
node = Node(
|
||||
node_id=node_id,
|
||||
name=name,
|
||||
api_url=api_url,
|
||||
api_key=api_key,
|
||||
description=description,
|
||||
group=group
|
||||
)
|
||||
session.add(node)
|
||||
session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
session.rollback()
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def update_node(self, node_id: str, **kwargs) -> bool:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
node = session.query(Node).filter(Node.node_id == node_id).first()
|
||||
if not node:
|
||||
return False
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(node, key):
|
||||
setattr(node, key, value)
|
||||
node.updated_at = datetime.now()
|
||||
session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
session.rollback()
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def delete_node(self, node_id: str) -> bool:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
node = session.query(Node).filter(Node.node_id == node_id).first()
|
||||
if not node:
|
||||
return False
|
||||
session.delete(node)
|
||||
session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
session.rollback()
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ==================== Webhook Operations ====================
|
||||
|
||||
def get_all_webhooks(self) -> List[WebhookUrl]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(WebhookUrl).all()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_webhook(self, webhook_id: int) -> Optional[WebhookUrl]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(WebhookUrl).filter(WebhookUrl.id == webhook_id).first()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_enabled_webhooks(self) -> List[WebhookUrl]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(WebhookUrl).filter(WebhookUrl.enabled == True).all()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def add_webhook(self, name: str, url: str, format: str = "bark",
|
||||
event_types: List[str] = None) -> bool:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
webhook = WebhookUrl(
|
||||
name=name,
|
||||
url=url,
|
||||
format=format,
|
||||
event_types=",".join(event_types) if event_types else ""
|
||||
)
|
||||
session.add(webhook)
|
||||
session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
session.rollback()
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def update_webhook(self, webhook_id: int, **kwargs) -> bool:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
webhook = session.query(WebhookUrl).filter(WebhookUrl.id == webhook_id).first()
|
||||
if not webhook:
|
||||
return False
|
||||
for key, value in kwargs.items():
|
||||
if key == "event_types" and isinstance(value, list):
|
||||
value = ",".join(value)
|
||||
if hasattr(webhook, key):
|
||||
setattr(webhook, key, value)
|
||||
webhook.updated_at = datetime.now()
|
||||
session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
session.rollback()
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def delete_webhook(self, webhook_id: int) -> bool:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
webhook = session.query(WebhookUrl).filter(WebhookUrl.id == webhook_id).first()
|
||||
if not webhook:
|
||||
return False
|
||||
session.delete(webhook)
|
||||
session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
session.rollback()
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ==================== Plugin Config Operations ====================
|
||||
|
||||
def get_plugin_config(self, plugin_name: str) -> Optional[PluginConfig]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(PluginConfig).filter(PluginConfig.plugin_name == plugin_name).first()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_all_plugin_configs(self) -> List[PluginConfig]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(PluginConfig).all()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def save_plugin_config(self, plugin_name: str, config: Dict[str, Any], enabled: bool = False) -> bool:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
import json
|
||||
existing = session.query(PluginConfig).filter(PluginConfig.plugin_name == plugin_name).first()
|
||||
if existing:
|
||||
existing.config_json = json.dumps(config)
|
||||
existing.enabled = enabled
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
plugin_config = PluginConfig(
|
||||
plugin_name=plugin_name,
|
||||
config_json=json.dumps(config),
|
||||
enabled=enabled
|
||||
)
|
||||
session.add(plugin_config)
|
||||
session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
session.rollback()
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def update_plugin_enabled(self, plugin_name: str, enabled: bool) -> bool:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
plugin_config = session.query(PluginConfig).filter(PluginConfig.plugin_name == plugin_name).first()
|
||||
if plugin_config:
|
||||
plugin_config.enabled = enabled
|
||||
plugin_config.updated_at = datetime.now()
|
||||
session.commit()
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
session.rollback()
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ==================== Log Operations ====================
|
||||
|
||||
def add_log(self, level: str, source: str, message: str) -> bool:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
log = LogEntry(
|
||||
level=level,
|
||||
source=source,
|
||||
message=message
|
||||
)
|
||||
session.add(log)
|
||||
session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
session.rollback()
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_recent_logs(self, count: int = 50) -> List[LogEntry]:
|
||||
session = self.db.get_session()
|
||||
try:
|
||||
return session.query(LogEntry).order_by(
|
||||
LogEntry.timestamp.desc()
|
||||
).limit(count).all()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
_db_service: Optional[DatabaseService] = None
|
||||
_db_manager: Optional[DatabaseManager] = None
|
||||
|
||||
|
||||
def init_database(database_url: str):
|
||||
global _db_service, _db_manager
|
||||
_db_manager = DatabaseManager(database_url)
|
||||
_db_manager.create_tables()
|
||||
_db_service = DatabaseService(_db_manager)
|
||||
|
||||
|
||||
def get_db_service() -> DatabaseService:
|
||||
return _db_service
|
||||
|
||||
|
||||
def get_db_manager() -> DatabaseManager:
|
||||
return _db_manager
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
# WxAuto HTTP API 使用文档
|
||||
|
||||
## 接口认证
|
||||
|
||||
所有API请求都需要在请求头中包含API密钥:
|
||||
```http
|
||||
X-API-Key: test-key-2
|
||||
```
|
||||
|
||||
## API接口示例
|
||||
|
||||
### 1. API密钥验证
|
||||
|
||||
验证API密钥是否有效。
|
||||
|
||||
```bash
|
||||
curl -X POST http://10.255.0.90:5000/api/auth/verify \
|
||||
-H "X-API-Key: test-key-2"
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "验证成功",
|
||||
"data": {
|
||||
"valid": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 微信基础功能
|
||||
|
||||
#### 2.1 初始化微信
|
||||
|
||||
初始化微信实例,建议在使用其他接口前先调用此接口。
|
||||
|
||||
```bash
|
||||
curl -X POST http://10.255.0.90:5000/api/wechat/initialize \
|
||||
-H "X-API-Key: test-key-2"
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "初始化成功",
|
||||
"data": {
|
||||
"status": "connected"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 获取微信状态
|
||||
|
||||
检查微信连接状态。
|
||||
|
||||
```bash
|
||||
curl -X GET http://10.255.0.90:5000/api/wechat/status \
|
||||
-H "X-API-Key: test-key-2"
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"status": "online"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 消息相关接口
|
||||
|
||||
#### 3.1 发送普通文本消息
|
||||
|
||||
发送普通文本消息到指定联系人或群组。
|
||||
|
||||
```bash
|
||||
curl -X POST http://10.255.0.90:5000/api/message/send \
|
||||
-H "X-API-Key: test-key-2" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"receiver": "文件传输助手",
|
||||
"message": "这是一条测试消息",
|
||||
"at_list": ["张三", "李四"],
|
||||
"clear": true
|
||||
}'
|
||||
```
|
||||
|
||||
参数说明:
|
||||
- receiver: 接收者(联系人或群组名称)
|
||||
- message: 消息内容
|
||||
- at_list: (可选)需要@的群成员列表
|
||||
- clear: (可选)是否清除输入框,默认为true
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "发送成功",
|
||||
"data": {
|
||||
"message_id": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 发送打字机模式消息
|
||||
|
||||
使用打字机模式发送消息(模拟真人打字效果)。
|
||||
|
||||
```bash
|
||||
curl -X POST http://10.255.0.90:5000/api/message/send-typing \
|
||||
-H "X-API-Key: test-key-2" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"receiver": "文件传输助手",
|
||||
"message": "这是一条打字机模式消息\n这是第二行",
|
||||
"at_list": ["张三"],
|
||||
"clear": true
|
||||
}'
|
||||
```
|
||||
|
||||
参数说明同普通消息发送。
|
||||
|
||||
#### 3.3 发送文件
|
||||
|
||||
发送一个或多个文件。
|
||||
|
||||
```bash
|
||||
curl -X POST http://10.255.0.90:5000/api/message/send-file \
|
||||
-H "X-API-Key: test-key-2" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"receiver": "文件传输助手",
|
||||
"file_paths": [
|
||||
"D:/test/file1.txt",
|
||||
"D:/test/image.jpg"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
参数说明:
|
||||
- receiver: 接收者
|
||||
- file_paths: 要发送的文件路径列表
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "发送成功",
|
||||
"data": {
|
||||
"success_count": 2,
|
||||
"failed_files": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 群组相关接口
|
||||
|
||||
#### 4.1 获取群列表
|
||||
|
||||
获取当前账号的群聊列表。
|
||||
|
||||
```bash
|
||||
curl -X GET http://10.255.0.90:5000/api/group/list \
|
||||
-H "X-API-Key: test-key-2"
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"groups": [
|
||||
{"name": "测试群1"},
|
||||
{"name": "测试群2"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 群组管理
|
||||
|
||||
执行群组管理操作(如重命名、退群等)。
|
||||
|
||||
```bash
|
||||
curl -X POST http://10.255.0.90:5000/api/group/manage \
|
||||
-H "X-API-Key: test-key-2" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"group_name": "测试群",
|
||||
"action": "rename",
|
||||
"params": {
|
||||
"new_name": "新群名称"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
支持的操作类型:
|
||||
- rename: 重命名群组
|
||||
- quit: 退出群组
|
||||
|
||||
### 5. 联系人相关接口
|
||||
|
||||
#### 5.1 获取好友列表
|
||||
|
||||
获取当前账号的好友列表。
|
||||
|
||||
```bash
|
||||
curl -X GET http://10.255.0.90:5000/api/contact/list \
|
||||
-H "X-API-Key: test-key-2"
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"friends": [
|
||||
{"nickname": "张三"},
|
||||
{"nickname": "李四"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 健康检查接口
|
||||
|
||||
获取服务和微信连接状态。
|
||||
|
||||
```bash
|
||||
curl -X GET http://10.255.0.90:5000/api/health \
|
||||
-H "X-API-Key: test-key-2"
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "服务正常",
|
||||
"data": {
|
||||
"status": "ok",
|
||||
"wechat_status": "connected",
|
||||
"uptime": 3600
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码说明
|
||||
|
||||
- 0: 成功
|
||||
- 1001: 认证失败(API密钥无效)
|
||||
- 1002: 参数错误
|
||||
- 2001: 微信未初始化
|
||||
- 2002: 微信已掉线
|
||||
- 3001: 发送消息失败
|
||||
- 3002: 获取消息失败
|
||||
- 4001: 群操作失败
|
||||
- 5001: 好友操作失败
|
||||
- 5000: 服务器内部错误
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. 在使用其他接口前,先调用初始化接口
|
||||
2. 使用健康检查接口监控服务状态
|
||||
3. 合理处理错误码,做好重试机制
|
||||
4. 注意文件发送时的路径正确性
|
||||
5. 群发消息时建议加入适当延时
|
||||
|
||||
## PowerShell示例
|
||||
|
||||
如果您使用PowerShell,可以使用以下格式发送请求:
|
||||
|
||||
```powershell
|
||||
$headers = @{
|
||||
"X-API-Key" = "test-key-2"
|
||||
"Content-Type" = "application/json"
|
||||
}
|
||||
|
||||
$body = @{
|
||||
receiver = "文件传输助手"
|
||||
message = "测试消息"
|
||||
} | ConvertTo-Json
|
||||
|
||||
Invoke-RestMethod -Method Post -Uri "http://10.255.0.90:5000/api/message/send" -Headers $headers -Body $body
|
||||
```
|
||||
|
||||
## Python示例
|
||||
|
||||
使用Python requests库的示例:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = "test-key-2"
|
||||
BASE_URL = "http://10.255.0.90:5000/api"
|
||||
|
||||
headers = {
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 发送消息示例
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/message/send",
|
||||
headers=headers,
|
||||
json={
|
||||
"receiver": "文件传输助手",
|
||||
"message": "测试消息"
|
||||
}
|
||||
)
|
||||
|
||||
print(response.json())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,344 @@
|
||||
# ///
|
||||
# API.md
|
||||
# 描述:WXAuto Center API 接口文档
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
# 更新日期:2026-04-06
|
||||
# ///
|
||||
|
||||
# WXAuto Center API 接口文档
|
||||
|
||||
## 概述
|
||||
|
||||
本系统提供完整的 RESTful API,支持节点管理、消息发送、插件管理、外部程序接入、Webhook 管理等功能。所有 API 均使用 JSON 格式进行请求和响应。
|
||||
|
||||
## 基础信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| Base URL | `http://{host}:{port}/api/v1` |
|
||||
| 默认端口 | 8080 |
|
||||
| 内部 API 认证 | Header: `X-API-Key: your-external-api-key` |
|
||||
| 外部 API 认证 | Header: `X-ExternalKey: your-external-api-key` |
|
||||
| 响应格式 | JSON |
|
||||
|
||||
## API 列表
|
||||
|
||||
### 节点管理 `/api/v1/nodes`
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/nodes/` | 获取所有节点 |
|
||||
| GET | `/nodes/{node_id}` | 获取指定节点信息 |
|
||||
| POST | `/nodes/` | 添加节点 |
|
||||
| PUT | `/nodes/{node_id}` | 更新节点信息 |
|
||||
| DELETE | `/nodes/{node_id}` | 删除节点 |
|
||||
| GET | `/nodes/{node_id}/status` | 检查节点状态 |
|
||||
| POST | `/nodes/{node_id}/enable` | 启用节点 |
|
||||
| POST | `/nodes/{node_id}/disable` | 禁用节点 |
|
||||
| GET | `/nodes/{node_id}/friends` | 获取节点好友列表 |
|
||||
| GET | `/nodes/{node_id}/groups` | 获取节点群列表 |
|
||||
| GET | `/nodes/{node_id}/messages/{chat_name}` | 获取聊天消息 |
|
||||
|
||||
### 消息管理 `/api/v1/messages`
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/messages/send` | 发送消息 |
|
||||
| POST | `/messages/send_group` | 发送群消息 |
|
||||
| POST | `/messages/batch` | 批量发送消息 |
|
||||
| POST | `/messages/template/{node_id}/{who}` | 发送模板消息 |
|
||||
| GET | `/messages/history/{node_id}/{chat_name}` | 获取消息历史 |
|
||||
|
||||
### 插件管理 `/api/v1/plugins`
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/plugins/` | 获取所有插件 |
|
||||
| GET | `/plugins/{plugin_name}` | 获取插件详情 |
|
||||
| GET | `/plugins/{plugin_name}/schema` | 获取插件表单Schema |
|
||||
| POST | `/plugins/{plugin_name}/enable` | 启用插件 |
|
||||
| POST | `/plugins/{plugin_name}/disable` | 禁用插件 |
|
||||
| PUT | `/plugins/{plugin_name}/config` | 更新插件配置 |
|
||||
| POST | `/plugins/{plugin_name}/execute` | 执行插件 |
|
||||
| GET | `/plugins/{plugin_name}/logs` | 获取插件日志 |
|
||||
| POST | `/plugins/{plugin_name}/logs/clear` | 清空插件日志 |
|
||||
| GET | `/plugins/type/{plugin_type}` | 按类型获取插件 |
|
||||
|
||||
### 回调接口 `/api/v1/callback`
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/callback/node/reset` | 重置节点微信状态 |
|
||||
| POST | `/callback/node/check` | 检测节点微信状态 |
|
||||
| GET | `/callback/node/{node_id}/status` | 获取节点回调状态 |
|
||||
|
||||
### Webhook 管理 `/api/v1/webhook`
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/webhook/` | 获取所有Webhook |
|
||||
| GET | `/webhook/{webhook_id}` | 获取指定Webhook |
|
||||
| POST | `/webhook/` | 添加Webhook |
|
||||
| PUT | `/webhook/{webhook_id}` | 更新Webhook |
|
||||
| DELETE | `/webhook/{webhook_id}` | 删除Webhook |
|
||||
| POST | `/webhook/{webhook_id}/enable` | 启用Webhook |
|
||||
| POST | `/webhook/{webhook_id}/disable` | 禁用Webhook |
|
||||
|
||||
### 外部接口 `/api/v1/external`
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/external/send` | 外部接口-发送消息 |
|
||||
| POST | `/external/trigger` | 外部接口-触发数据处理 |
|
||||
| POST | `/external/plugin/execute` | 外部接口-执行插件 |
|
||||
| GET | `/external/plugins` | 外部接口-获取可用插件列表 |
|
||||
| GET | `/external/nodes` | 外部接口-获取可用节点 |
|
||||
| POST | `/external/ai/process` | 外部接口-AI处理消息 |
|
||||
| POST | `/external/webhook/{event}` | 外部接口-Webhook接收 |
|
||||
|
||||
---
|
||||
|
||||
## 节点管理 API 详解
|
||||
|
||||
### 获取所有节点
|
||||
|
||||
```
|
||||
GET /api/v1/nodes/
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"name": "微信1",
|
||||
"api_url": "http://192.168.20.84:5000",
|
||||
"enabled": true,
|
||||
"description": "",
|
||||
"group": "wechat",
|
||||
"status": "error",
|
||||
"wechat_status": "online",
|
||||
"is_healthy": false
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 添加节点
|
||||
|
||||
```
|
||||
POST /api/v1/nodes/
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"name": "微信1",
|
||||
"api_url": "http://192.168.20.84:5000",
|
||||
"api_key": "your-node-api-key",
|
||||
"description": "测试节点",
|
||||
"group": "wechat"
|
||||
}
|
||||
```
|
||||
|
||||
### 更新节点
|
||||
|
||||
```
|
||||
PUT /api/v1/nodes/{node_id}
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"api_url": "http://192.168.20.84:5000",
|
||||
"api_key": "updated-key"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 消息发送 API 详解
|
||||
|
||||
### 发送消息
|
||||
|
||||
```
|
||||
POST /api/v1/messages/send
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"who": "文件传输助手",
|
||||
"msg": "测试消息",
|
||||
"msg_type": "text"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "消息发送成功"
|
||||
}
|
||||
```
|
||||
|
||||
### 发送群消息
|
||||
|
||||
```
|
||||
POST /api/v1/messages/send_group
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"group_name": "测试群",
|
||||
"msg": "群消息测试",
|
||||
"msg_type": "text"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 插件管理 API 详解
|
||||
|
||||
### 获取所有插件
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "http_scheduled_sender",
|
||||
"version": "1.0.0",
|
||||
"type": "scheduled_task",
|
||||
"description": "定时HTTP数据源推送",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"node_id": "wx1",
|
||||
"receiver": "asq",
|
||||
"cron": "0 8 * * *"
|
||||
}
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 配置插件
|
||||
|
||||
```
|
||||
PUT /api/v1/plugins/{plugin_name}/config
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"name": "http_scheduled_sender",
|
||||
"config": {
|
||||
"node_id": "wx1",
|
||||
"receiver": "文件传输助手",
|
||||
"cron": "0 8 * * *",
|
||||
"http_url": "https://api.example.com/news",
|
||||
"message_template": "📰 {title}\n{url}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 执行插件
|
||||
|
||||
```
|
||||
POST /api/v1/plugins/{plugin_name}/execute
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"action": "preview"
|
||||
}
|
||||
```
|
||||
|
||||
**action 选项:**
|
||||
- `send` - 执行完整流程
|
||||
- `fetch` - 仅请求API
|
||||
- `parse` - 请求并解析数据
|
||||
- `preview` - 预览格式化后的消息
|
||||
- `test` - 测试cron表达式
|
||||
|
||||
---
|
||||
|
||||
## 通用响应格式
|
||||
|
||||
**成功:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {},
|
||||
"message": "操作成功"
|
||||
}
|
||||
```
|
||||
|
||||
**失败:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误描述"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 插件类型
|
||||
|
||||
| 类型 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| `message_handler` | MESSAGE_HANDLER | 消息处理器 |
|
||||
| `data_source` | DATA_SOURCE | 数据源 |
|
||||
| `action_trigger` | ACTION_TRIGGER | 动作触发器 |
|
||||
| `ai_agent` | AI_AGENT | AI智能体 |
|
||||
| `scheduled_task` | SCHEDULED_TASK | 定时任务 |
|
||||
| `http_scheduled_sender` | HTTP_SCHEDULED_SENDER | 定时HTTP推送 |
|
||||
| `custom` | CUSTOM | 自定义 |
|
||||
|
||||
---
|
||||
|
||||
## 节点数据模型
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"name": "微信1",
|
||||
"api_url": "http://192.168.20.84:5000",
|
||||
"api_key": "your-key",
|
||||
"enabled": true,
|
||||
"description": "",
|
||||
"group": "wechat",
|
||||
"status": "active",
|
||||
"wechat_status": "online",
|
||||
"is_healthy": true
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| node_id | string | 节点唯一标识 |
|
||||
| name | string | 节点显示名称 |
|
||||
| api_url | string | 节点API地址 |
|
||||
| api_key | string | 节点API密钥 |
|
||||
| enabled | bool | 是否启用 |
|
||||
| description | string | 节点描述 |
|
||||
| group | string | 节点分组 |
|
||||
| status | string | API状态 (active/inactive/error) |
|
||||
| wechat_status | string | 微信状态 (online/offline) |
|
||||
| is_healthy | bool | 健康状态 |
|
||||
@@ -0,0 +1,235 @@
|
||||
# ///
|
||||
# ARCHITECTURE.md
|
||||
# 描述:WXAuto Center 系统架构文档
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# 更新日期:2026-04-06
|
||||
# ///
|
||||
|
||||
# WXAuto Center 系统架构文档
|
||||
|
||||
## 概述
|
||||
|
||||
WXAuto Center 是一个模块化的微信多节点中控系统,采用前后端分离架构,支持多节点管理、消息发送、插件扩展、定时任务等功能。
|
||||
|
||||
## 系统架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ WXAuto Center │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Frontend (Static) │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │Dashboard │ │ Nodes │ │ Messages │ │ Plugins │ │ │
|
||||
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ FastAPI Backend │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ │
|
||||
│ │ │nodes router │ │messages router│ │plugins router│ │webhook router│ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ │
|
||||
│ │ │callback router│ │external router│ │webhook service│ │queue service│ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────────┼────────────────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Redis │ │ PostgreSQL │ │ Nodes │ │
|
||||
│ │ (Queue) │ │ (Storage) │ │ (External) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. 前端 (Frontend)
|
||||
|
||||
静态文件服务,基于原生 JavaScript 开发。
|
||||
|
||||
**页面模块:**
|
||||
| 页面 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 首页/仪表盘 | app.js | 系统概览 |
|
||||
| 节点管理 | app.js | 节点 CRUD |
|
||||
| 快捷消息 | app.js | 快速发送消息 |
|
||||
| 监控告警 | app.js | Webhook 配置 |
|
||||
| 日志查看 | app.js | 系统日志 |
|
||||
| 外部接入 | app.js | 外部 API 文档 |
|
||||
| 插件管理 | app.js | 插件配置 |
|
||||
|
||||
### 2. 后端 (Backend)
|
||||
|
||||
基于 FastAPI 的 RESTful API 服务。
|
||||
|
||||
**API 模块:**
|
||||
| 模块 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| nodes | /api/v1/nodes | 节点管理 |
|
||||
| messages | /api/v1/messages | 消息发送 |
|
||||
| plugins | /api/v1/plugins | 插件管理 |
|
||||
| webhook | /api/v1/webhook | Webhook 管理 |
|
||||
| callback | /api/v1/callback | 回调接口 |
|
||||
| external | /api/v1/external | 外部接口 |
|
||||
|
||||
### 3. 服务层 (Services)
|
||||
|
||||
| 服务 | 说明 |
|
||||
|------|------|
|
||||
| node_manager | 节点管理器,内存缓存 |
|
||||
| monitor_service | 节点健康监控服务 |
|
||||
| scheduler_service | 定时任务调度服务 |
|
||||
| log_service | 日志服务 |
|
||||
| queue_service | 消息队列服务 |
|
||||
| webhook_service | Webhook 通知服务 |
|
||||
|
||||
### 4. 插件系统 (Plugins)
|
||||
|
||||
| 插件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| http_data_source | DATA_SOURCE | HTTP 数据源 |
|
||||
| database_query | DATA_SOURCE | 数据库查询 |
|
||||
| webhook_trigger | ACTION_TRIGGER | Webhook 触发 |
|
||||
| simple_ai_agent | AI_AGENT | AI 智能体 |
|
||||
| http_scheduled_sender | SCHEDULED_TASK | 定时 HTTP 推送 |
|
||||
|
||||
### 5. 数据层 (Data)
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| PostgreSQL | 主数据库,存储节点、Webhook、日志、插件配置 |
|
||||
| Redis | 消息队列、缓存 |
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
wxauto_api/
|
||||
├── main.py # 应用入口
|
||||
├── config.py # 配置管理
|
||||
├── models.py # 数据模型
|
||||
├── database_service.py # 数据库服务
|
||||
├── requirements.txt # Python 依赖
|
||||
├── Dockerfile # Docker 镜像
|
||||
├── docker-compose.yml # Docker 编排
|
||||
│
|
||||
├── api/ # API 路由
|
||||
│ ├── __init__.py
|
||||
│ ├── nodes.py # 节点管理
|
||||
│ ├── messages.py # 消息发送
|
||||
│ ├── plugins.py # 插件管理
|
||||
│ ├── webhook.py # Webhook 管理
|
||||
│ ├── callback.py # 回调接口
|
||||
│ └── external.py # 外部接口
|
||||
│
|
||||
├── services/ # 业务服务
|
||||
│ ├── __init__.py
|
||||
│ ├── node_manager.py # 节点管理
|
||||
│ ├── monitor_service.py # 健康监控
|
||||
│ ├── scheduler_service.py # 定时调度
|
||||
│ ├── log_service.py # 日志服务
|
||||
│ ├── queue_service.py # 队列服务
|
||||
│ └── webhook_service.py # Webhook 服务
|
||||
│
|
||||
├── plugins/ # 插件系统
|
||||
│ ├── __init__.py
|
||||
│ ├── base.py # 插件基类
|
||||
│ ├── builtin.py # 内置插件
|
||||
│ └── http_scheduled_sender.py # 定时推送插件
|
||||
│
|
||||
├── utils/ # 工具函数
|
||||
│ └── auth.py # 认证中间件
|
||||
│
|
||||
├── static/ # 静态文件
|
||||
│ ├── index.html # 主页面
|
||||
│ └── js/ # JavaScript
|
||||
│ ├── api.js # API 封装
|
||||
│ ├── app.js # 主应用
|
||||
│ └── pages/
|
||||
│ └── templates.js # 页面模板
|
||||
│
|
||||
└── doc/ # 文档
|
||||
└── wxauto_center/
|
||||
├── ARCHITECTURE.md # 架构文档
|
||||
├── API.md # API 文档
|
||||
├── DATABASE.md # 数据库文档
|
||||
├── FLOWS.md # 流程文档
|
||||
└── PLUGIN.md # 插件文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 前端 | HTML/CSS/JavaScript | 原生开发,无框架依赖 |
|
||||
| 后端 | Python 3.11 + FastAPI | 高性能异步框架 |
|
||||
| 数据库 | PostgreSQL | 关系型数据库 |
|
||||
| 缓存/队列 | Redis | 内存数据库、消息队列 |
|
||||
| 容器 | Docker + Docker Compose | 容器化部署 |
|
||||
| ORM | SQLAlchemy | Python ORM |
|
||||
| HTTP 客户端 | httpx | 异步 HTTP 客户端 |
|
||||
|
||||
---
|
||||
|
||||
## 部署架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Docker Network │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ wxauto-center│ │ wxauto-redis │ │wxauto-postgres│ │
|
||||
│ │ :8080 │ │ :6379 │ │ :5432 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ External Nodes │ │ External │
|
||||
│ (wxauto nodes) │ │ Webhook URLs │
|
||||
│ :5000 │ │ │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `POSTGRES_USER` | PostgreSQL 用户 | wxauto |
|
||||
| `POSTGRES_PASSWORD` | PostgreSQL 密码 | wxauto_password |
|
||||
| `POSTGRES_DB` | 数据库名 | wxauto |
|
||||
| `REDIS_PASSWORD` | Redis 密码 | wxauto_redis |
|
||||
| `API_KEY` | API 认证密钥 | your-external-api-key |
|
||||
| `EXTERNAL_KEY` | 外部接口密钥 | your-external-api-key |
|
||||
|
||||
---
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 1. API 认证
|
||||
- 内部 API:Header `X-API-Key`
|
||||
- 外部 API:Header `X-ExternalKey`
|
||||
|
||||
### 2. 异常处理
|
||||
- 全局异常处理器
|
||||
- 所有 API 调用包裹 try/except
|
||||
- 10 秒 HTTP 超时
|
||||
|
||||
### 3. 数据一致性
|
||||
- 数据库优先原则
|
||||
- 启动时从数据库加载配置
|
||||
- 操作时同时更新内存和数据库
|
||||
@@ -0,0 +1,618 @@
|
||||
# ///
|
||||
# CONFIG.md
|
||||
# 描述:配置文件说明
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
# 最后更新:2026-04-06
|
||||
# ///
|
||||
|
||||
# WXAuto Center 配置文件说明
|
||||
|
||||
## 概述
|
||||
|
||||
系统配置支持多种配置源,按优先级从高到低依次为:
|
||||
1. 环境变量(`.env` 文件)
|
||||
2. 配置文件(`config.json`)
|
||||
3. 代码默认值
|
||||
|
||||
## 配置文件结构
|
||||
|
||||
### config.json
|
||||
|
||||
主配置文件,仅包含应用配置。**节点配置和 Webhook 配置已迁移到数据库**。
|
||||
|
||||
```json
|
||||
{
|
||||
"app_name": "WXAuto Center",
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080,
|
||||
"debug": true,
|
||||
"secret_key": "wxauto-center-secret-key-change-in-production",
|
||||
"workers": 1,
|
||||
"cors_origins": ["*"],
|
||||
"api_prefix": "/api/v1",
|
||||
"external_api_key": "your-external-api-key",
|
||||
"monitor": {
|
||||
"enabled": true,
|
||||
"check_interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .env 环境变量文件
|
||||
|
||||
Docker 环境变量配置文件。
|
||||
|
||||
```
|
||||
# ///
|
||||
# .env
|
||||
# 描述:环境变量配置(用于 Docker 环境)
|
||||
# ///
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=postgresql://wxauto:wxauto_password@postgres:5432/wxauto
|
||||
|
||||
# Redis 配置
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# 应用配置
|
||||
APP_NAME=WXAuto Center
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
DEBUG=true
|
||||
SECRET_KEY=wxauto-center-secret-key-change-in-production
|
||||
WORKERS=1
|
||||
|
||||
# API 配置
|
||||
API_PREFIX=/api/v1
|
||||
EXTERNAL_API_KEY=your-external-api-key
|
||||
CORS_ORIGINS=["*"]
|
||||
|
||||
# 监控配置
|
||||
MONITOR_ENABLED=true
|
||||
MONITOR_CHECK_INTERVAL=30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置项详细说明
|
||||
|
||||
### 应用配置
|
||||
|
||||
#### app_name
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 类型 | string |
|
||||
| 默认值 | "WXAuto Center" |
|
||||
| 环境变量 | APP_NAME |
|
||||
| 说明 | 应用名称,显示在页面标题和日志中 |
|
||||
|
||||
#### host
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 类型 | string |
|
||||
| 默认值 | "0.0.0.0" |
|
||||
| 环境变量 | HOST |
|
||||
| 说明 | 服务监听地址,0.0.0.0 表示监听所有网络接口 |
|
||||
|
||||
#### port
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 类型 | int |
|
||||
| 默认值 | 8080 |
|
||||
| 环境变量 | PORT |
|
||||
| 说明 | 服务监听端口 |
|
||||
|
||||
#### debug
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 类型 | bool |
|
||||
| 默认值 | false |
|
||||
| 环境变量 | DEBUG |
|
||||
| 说明 | 调试模式,启用时支持热重载 |
|
||||
|
||||
#### secret_key
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 类型 | string |
|
||||
| 默认值 | "wxauto-center-secret-key-change-in-production" |
|
||||
| 环境变量 | SECRET_KEY |
|
||||
| 说明 | 应用密钥,用于会话加密等安全用途,生产环境必须修改 |
|
||||
|
||||
---
|
||||
|
||||
### API 配置
|
||||
|
||||
#### api_prefix
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 类型 | string |
|
||||
| 默认值 | "/api/v1" |
|
||||
| 环境变量 | API_PREFIX |
|
||||
| 说明 | API 路由前缀,所有 API 路径都会此前缀开头 |
|
||||
|
||||
#### external_api_key
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 类型 | string |
|
||||
| 默认值 | "your-external-api-key" |
|
||||
| 环境变量 | EXTERNAL_API_KEY |
|
||||
| 说明 | 外部 API 密钥,用于外部系统调用时的认证 |
|
||||
|
||||
#### cors_origins
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 类型 | array[string] |
|
||||
| 默认值 | ["*"] |
|
||||
| 环境变量 | CORS_ORIGINS |
|
||||
| 说明 | 允许的 CORS 来源,* 表示允许所有来源(仅限开发环境使用) |
|
||||
|
||||
---
|
||||
|
||||
## 节点配置 (NodeConfig)
|
||||
|
||||
**重要更新**:节点配置已从 config.json 迁移到 PostgreSQL 数据库的 `nodes` 表。
|
||||
|
||||
### 配置存储
|
||||
|
||||
| 存储位置 | 说明 |
|
||||
|----------|------|
|
||||
| PostgreSQL | nodes 表(生产环境推荐) |
|
||||
| config.json | 已废弃,不再支持 |
|
||||
|
||||
### 节点数据模型
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"name": "微信1",
|
||||
"api_url": "http://192.168.1.100:5000",
|
||||
"api_key": "your-node-api-key",
|
||||
"enabled": true,
|
||||
"description": "主节点",
|
||||
"group": "default",
|
||||
"status": "active",
|
||||
"wechat_status": "online",
|
||||
"is_healthy": true
|
||||
}
|
||||
```
|
||||
|
||||
### 节点标识说明
|
||||
|
||||
每个节点有两个标识:
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| node_id | 节点唯一标识,用于 API 调用 | "wx1", "node_001" |
|
||||
| name | 节点显示名称,用于界面展示 | "微信1", "测试节点" |
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| node_id | string | 是 | - | 节点唯一标识(不可重复) |
|
||||
| name | string | 否 | 等于 node_id | 节点显示名称 |
|
||||
| api_url | string | 是 | - | 节点 API 服务地址 |
|
||||
| api_key | string | 是 | - | 节点 API 密钥 |
|
||||
| enabled | bool | 否 | true | 是否启用该节点 |
|
||||
| description | string | 否 | "" | 节点描述信息 |
|
||||
| group | string | 否 | "default" | 节点分组,用于分类管理 |
|
||||
|
||||
### API 管理节点
|
||||
|
||||
通过 API 管理节点配置:
|
||||
|
||||
```bash
|
||||
# 添加节点
|
||||
curl -X POST http://localhost:8080/api/v1/nodes \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-d '{
|
||||
"node_id": "wx1",
|
||||
"name": "微信1",
|
||||
"api_url": "http://192.168.1.100:5000",
|
||||
"api_key": "your-node-api-key",
|
||||
"description": "主节点",
|
||||
"group": "production"
|
||||
}'
|
||||
|
||||
# 获取所有节点
|
||||
curl http://localhost:8080/api/v1/nodes \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 更新节点
|
||||
curl -X PUT http://localhost:8080/api/v1/nodes/wx1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-d '{"enabled": false}'
|
||||
|
||||
# 删除节点
|
||||
curl -X DELETE http://localhost:8080/api/v1/nodes/wx1 \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
```
|
||||
|
||||
### 节点分组
|
||||
|
||||
节点分组功能允许将节点按业务线或用途进行分类管理:
|
||||
|
||||
```bash
|
||||
# 查询特定分组的节点
|
||||
curl "http://localhost:8080/api/v1/nodes?group=production" \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook 配置 (WebhookConfig)
|
||||
|
||||
**重要更新**:Webhook 配置已从 config.json 迁移到 PostgreSQL 数据库的 `webhook_urls` 表。
|
||||
|
||||
### 配置存储
|
||||
|
||||
| 存储位置 | 说明 |
|
||||
|----------|------|
|
||||
| PostgreSQL | webhook_urls 表(推荐) |
|
||||
| config.json | 已废弃,不再支持 |
|
||||
|
||||
### Webhook 数据模型
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "企业微信告警",
|
||||
"url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx",
|
||||
"format": "wechat",
|
||||
"enabled": true,
|
||||
"event_types": "wechat_offline,wechat_online,node_offline,node_online",
|
||||
"created_at": "2026-04-06T10:00:00",
|
||||
"updated_at": "2026-04-06T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| name | string | 是 | - | Webhook 名称 |
|
||||
| url | string | 是 | - | Webhook URL 地址 |
|
||||
| format | string | 否 | "bark" | 消息格式:bark 或 wechat |
|
||||
| enabled | bool | 否 | true | 是否启用 |
|
||||
| event_types | string | 否 | 全部事件 | 需要触发 Webhook 的事件类型 |
|
||||
|
||||
### 支持的事件类型
|
||||
|
||||
| 事件类型 | 说明 | 触发时机 |
|
||||
|----------|------|----------|
|
||||
| api_error | API 错误 | 调用节点 API 失败 |
|
||||
| wechat_offline | 微信掉线 | 微信状态变为 offline |
|
||||
| wechat_online | 微信上线 | 微信状态变为 online/connected |
|
||||
| node_offline | 节点离线 | 节点 API 不可达 |
|
||||
| node_online | 节点在线 | 节点 API 恢复可用 |
|
||||
|
||||
### 消息格式
|
||||
|
||||
#### 企业微信格式 (format: "wechat")
|
||||
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "【WXAuto Center 告警】\n事件类型: 微信掉线\n节点: wx1\n状态: offline\n时间: 2026-04-05 10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Bark 格式 (format: "bark")
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "WXAuto告警 - 微信掉线",
|
||||
"body": "wx1\n状态: offline\n时间: 2026-04-05 10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### API 管理 Webhook
|
||||
|
||||
```bash
|
||||
# 添加 Webhook
|
||||
curl -X POST http://localhost:8080/api/v1/webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-d '{
|
||||
"name": "企业微信告警",
|
||||
"url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx",
|
||||
"format": "wechat",
|
||||
"enabled": true,
|
||||
"event_types": "wechat_offline,wechat_online,node_offline,node_online"
|
||||
}'
|
||||
|
||||
# 获取所有 Webhook
|
||||
curl http://localhost:8080/api/v1/webhook \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 更新 Webhook
|
||||
curl -X PUT http://localhost:8080/api/v1/webhook/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-d '{"enabled": false}'
|
||||
|
||||
# 删除 Webhook
|
||||
curl -X DELETE http://localhost:8080/api/v1/webhook/1 \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 启用/禁用 Webhook
|
||||
curl -X POST http://localhost:8080/api/v1/webhook/1/enable \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
curl -X POST http://localhost:8080/api/v1/webhook/1/disable \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 监控配置 (MonitorConfig)
|
||||
|
||||
监控服务负责定时检测节点和微信状态。
|
||||
|
||||
```json
|
||||
{
|
||||
"monitor": {
|
||||
"enabled": true,
|
||||
"check_interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| enabled | bool | 否 | true | 是否启用监控服务 |
|
||||
| check_interval | int | 否 | 30 | 检测间隔(秒) |
|
||||
|
||||
### 检测逻辑
|
||||
|
||||
1. **API 检测**:定期调用节点 `/health` 接口检查节点是否在线
|
||||
2. **微信检测**:定期调用 `/api/wechat/initialize` 检测微信连接状态
|
||||
3. **状态变化**:当检测到状态变化时,触发 Webhook 通知
|
||||
|
||||
---
|
||||
|
||||
## 数据库配置
|
||||
|
||||
数据库配置通过环境变量设置:
|
||||
|
||||
| 环境变量 | 说明 | 默认值 |
|
||||
|----------|------|--------|
|
||||
| DATABASE_URL | PostgreSQL 连接 URL | postgresql://wxauto:wxauto_password@postgres:5432/wxauto |
|
||||
|
||||
### Docker 环境
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://wxauto:wxauto_password@postgres:5432/wxauto
|
||||
```
|
||||
|
||||
### 本地环境
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://wxauto:wxauto_password@localhost:5432/wxauto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Redis 配置
|
||||
|
||||
Redis 用于消息队列和缓存支持。
|
||||
|
||||
| 环境变量 | 说明 | 默认值 |
|
||||
|----------|------|--------|
|
||||
| REDIS_URL | Redis 连接 URL | redis://redis:6379/0 |
|
||||
|
||||
### Docker 环境
|
||||
|
||||
```bash
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
```
|
||||
|
||||
### 本地环境
|
||||
|
||||
```bash
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workers 配置
|
||||
|
||||
Workers 用于多进程部署,提高并发处理能力。
|
||||
|
||||
```json
|
||||
{
|
||||
"workers": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| workers | int | 否 | 1 | 工作进程数,1 表示单进程模式 |
|
||||
|
||||
### 启动方式
|
||||
|
||||
| 模式 | workers | 说明 |
|
||||
|------|---------|------|
|
||||
| 单进程 | 1 | 默认模式,不启用多进程 |
|
||||
| 多进程 | >1 | 启用多进程模式,需要 Redis 支持 |
|
||||
|
||||
**注意**:多进程模式需要 Redis 支持,队列服务依赖 Redis 进行进程间通信。
|
||||
|
||||
### 启动命令
|
||||
|
||||
```bash
|
||||
# 单进程
|
||||
python main.py
|
||||
|
||||
# 多进程
|
||||
python main.py --workers 4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整配置示例
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"app_name": "WXAuto Center",
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080,
|
||||
"debug": false,
|
||||
"secret_key": "your-production-secret-key",
|
||||
"api_prefix": "/api/v1",
|
||||
"external_api_key": "your-external-api-key",
|
||||
"cors_origins": ["http://localhost:3000"],
|
||||
"workers": 1,
|
||||
"monitor": {
|
||||
"enabled": true,
|
||||
"check_interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .env
|
||||
|
||||
```bash
|
||||
# 数据库配置
|
||||
DATABASE_URL=postgresql://wxauto:wxauto_password@postgres:5432/wxauto
|
||||
|
||||
# Redis 配置
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# 应用配置
|
||||
APP_NAME=WXAuto Center
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
DEBUG=false
|
||||
SECRET_KEY=your-production-secret-key
|
||||
WORKERS=1
|
||||
|
||||
# API 配置
|
||||
API_PREFIX=/api/v1
|
||||
EXTERNAL_API_KEY=your-external-api-key
|
||||
CORS_ORIGINS=["http://localhost:3000"]
|
||||
|
||||
# 监控配置
|
||||
MONITOR_ENABLED=true
|
||||
MONITOR_CHECK_INTERVAL=30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 环境变量配置优先级
|
||||
|
||||
某些敏感配置项可以通过环境变量覆盖配置文件:
|
||||
|
||||
| 环境变量 | 对应配置项 |
|
||||
|----------|------------|
|
||||
| APP_NAME | app_name |
|
||||
| HOST | host |
|
||||
| PORT | port |
|
||||
| DEBUG | debug |
|
||||
| SECRET_KEY | secret_key |
|
||||
| API_PREFIX | api_prefix |
|
||||
| EXTERNAL_API_KEY | external_api_key |
|
||||
| CORS_ORIGINS | cors_origins |
|
||||
| WORKERS | workers |
|
||||
| DATABASE_URL | database.url |
|
||||
| REDIS_URL | redis.url |
|
||||
| MONITOR_ENABLED | monitor.enabled |
|
||||
| MONITOR_CHECK_INTERVAL | monitor.check_interval |
|
||||
|
||||
---
|
||||
|
||||
## 配置加载与保存流程
|
||||
|
||||
```
|
||||
启动时 (startup_event)
|
||||
│
|
||||
├──► load_config_from_file("config.json")
|
||||
│ │
|
||||
│ ▼
|
||||
│ 解析 JSON 配置
|
||||
│ │
|
||||
│ ▼
|
||||
│ 设置 Settings 各属性
|
||||
│ │
|
||||
│ ▼
|
||||
│ 初始化数据库连接
|
||||
│ │
|
||||
│ ▼
|
||||
│ 从数据库加载节点配置 (nodes 表)
|
||||
│ │
|
||||
│ ▼
|
||||
│ NodeManager.add_node() 注册节点
|
||||
│
|
||||
└──► register_builtin_plugins()
|
||||
|
||||
关闭时 (shutdown_event)
|
||||
│
|
||||
├──► monitor_service.stop()
|
||||
│
|
||||
└──► 保存配置到文件 (如需要)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### nodes 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| node_id | VARCHAR(50) | 节点唯一标识符 |
|
||||
| name | VARCHAR(100) | 节点显示名称 |
|
||||
| api_url | VARCHAR(255) | 节点 API 地址 |
|
||||
| api_key | VARCHAR(255) | 节点 API 密钥 |
|
||||
| enabled | BOOLEAN | 是否启用 |
|
||||
| description | TEXT | 节点描述 |
|
||||
| group | VARCHAR(50) | 节点分组 |
|
||||
| status | VARCHAR(20) | API 状态 |
|
||||
| wechat_status | VARCHAR(20) | 微信状态 |
|
||||
| is_healthy | BOOLEAN | 健康状态 |
|
||||
| created_at | TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 更新时间 |
|
||||
|
||||
### webhook_urls 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | SERIAL | Webhook ID |
|
||||
| name | VARCHAR(100) | Webhook 名称 |
|
||||
| url | VARCHAR(500) | Webhook URL 地址 |
|
||||
| format | VARCHAR(20) | 消息格式 (bark/wechat) |
|
||||
| enabled | BOOLEAN | 是否启用 |
|
||||
| event_types | TEXT | 支持的事件类型 |
|
||||
| created_at | TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 更新时间 |
|
||||
|
||||
### logs 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | SERIAL | 日志 ID |
|
||||
| timestamp | TIMESTAMP | 日志时间 |
|
||||
| level | VARCHAR(20) | 日志级别 |
|
||||
| source | VARCHAR(50) | 日志来源 |
|
||||
| message | TEXT | 日志消息 |
|
||||
|
||||
详细表结构请参阅 [DATABASE.md](./DATABASE.md)。
|
||||
@@ -0,0 +1,282 @@
|
||||
# ///
|
||||
# DATABASE.md
|
||||
# 描述:WXAuto Center 数据库设计文档
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# 更新日期:2026-04-06
|
||||
# ///
|
||||
|
||||
# WXAuto Center 数据库设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
本系统使用 PostgreSQL 数据库,通过 SQLAlchemy ORM 进行数据访问。数据库包含以下表:
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| `nodes` | 节点配置表 |
|
||||
| `webhook_urls` | Webhook URL 表 |
|
||||
| `logs` | 日志表 |
|
||||
| `plugin_configs` | 插件配置表 |
|
||||
|
||||
## ER 图
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ nodes │ │ webhook_urls │
|
||||
├─────────────────┤ ├─────────────────┤
|
||||
│ node_id (PK) │ │ id (PK) │
|
||||
│ name │ │ name │
|
||||
│ api_url │ │ url │
|
||||
│ api_key │ │ format │
|
||||
│ enabled │ │ enabled │
|
||||
│ description │ │ event_types │
|
||||
│ group │ │ created_at │
|
||||
│ status │ │ updated_at │
|
||||
│ wechat_status │ └─────────────────┘
|
||||
│ is_healthy │
|
||||
│ health_message │
|
||||
│ created_at │
|
||||
│ updated_at │
|
||||
└─────────────────┘
|
||||
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ logs │ │ plugin_configs │
|
||||
├─────────────────┤ ├─────────────────┤
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ timestamp │ │ plugin_name │
|
||||
│ level │ │ config_json │
|
||||
│ source │ │ enabled │
|
||||
│ message │ │ created_at │
|
||||
└─────────────────┘ │ updated_at │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 表结构详解
|
||||
|
||||
### 1. nodes - 节点配置表
|
||||
|
||||
存储所有微信节点的信息。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `node_id` | VARCHAR(50) | **PK** | 节点唯一标识 |
|
||||
| `name` | VARCHAR(100) | NOT NULL | 节点显示名称 |
|
||||
| `api_url` | VARCHAR(255) | NOT NULL | 节点 API 地址 |
|
||||
| `api_key` | VARCHAR(255) | NOT NULL | 节点 API 密钥 |
|
||||
| `enabled` | BOOLEAN | DEFAULT true | 是否启用 |
|
||||
| `description` | TEXT | DEFAULT '' | 节点描述 |
|
||||
| `group` | VARCHAR(50) | DEFAULT 'default' | 节点分组 |
|
||||
| `status` | VARCHAR(20) | DEFAULT 'inactive' | API 状态 |
|
||||
| `wechat_status` | VARCHAR(20) | DEFAULT 'online' | 微信状态 |
|
||||
| `is_healthy` | BOOLEAN | DEFAULT false | 健康检查状态 |
|
||||
| `health_message` | VARCHAR(255) | DEFAULT '' | 健康检查消息 |
|
||||
| `created_at` | TIMESTAMP | DEFAULT now | 创建时间 |
|
||||
| `updated_at` | TIMESTAMP | DEFAULT now | 更新时间 |
|
||||
|
||||
**状态值说明:**
|
||||
|
||||
| status 值 | 说明 |
|
||||
|------------|------|
|
||||
| `active` | API 正常响应 |
|
||||
| `inactive` | API 未响应 |
|
||||
| `error` | API 返回错误 |
|
||||
|
||||
| wechat_status 值 | 说明 |
|
||||
|------------------|------|
|
||||
| `online` | 微信已登录 |
|
||||
| `offline` | 微信未登录 |
|
||||
|
||||
**SQL:**
|
||||
```sql
|
||||
CREATE TABLE nodes (
|
||||
node_id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
api_url VARCHAR(255) NOT NULL,
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
enabled BOOLEAN DEFAULT true,
|
||||
description TEXT DEFAULT '',
|
||||
group VARCHAR(50) DEFAULT 'default',
|
||||
status VARCHAR(20) DEFAULT 'inactive',
|
||||
wechat_status VARCHAR(20) DEFAULT 'online',
|
||||
is_healthy BOOLEAN DEFAULT false,
|
||||
health_message VARCHAR(255) DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. webhook_urls - Webhook URL 表
|
||||
|
||||
存储告警 webhook 配置。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | INTEGER | **PK**, AUTO | 主键 |
|
||||
| `name` | VARCHAR(100) | NOT NULL | Webhook 名称 |
|
||||
| `url` | VARCHAR(500) | NOT NULL | Webhook URL |
|
||||
| `format` | VARCHAR(20) | DEFAULT 'bark' | 消息格式 |
|
||||
| `enabled` | BOOLEAN | DEFAULT true | 是否启用 |
|
||||
| `event_types` | TEXT | DEFAULT '' | 触发事件类型(逗号分隔) |
|
||||
| `created_at` | TIMESTAMP | DEFAULT now | 创建时间 |
|
||||
| `updated_at` | TIMESTAMP | DEFAULT now | 更新时间 |
|
||||
|
||||
**format 支持的格式:**
|
||||
- `bark` - Bark 推送格式
|
||||
- `wecom` - 企业微信
|
||||
- `dingtalk` - 钉钉
|
||||
- `feishu` - 飞书
|
||||
- `telegram` - Telegram
|
||||
- `serverchan` - Server酱
|
||||
|
||||
**event_types 支持的类型:**
|
||||
- `node_offline` - 节点离线
|
||||
- `node_error` - 节点错误
|
||||
- `wechat_offline` - 微信离线
|
||||
- `all` - 所有事件
|
||||
|
||||
---
|
||||
|
||||
### 3. logs - 日志表
|
||||
|
||||
存储系统运行日志。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | INTEGER | **PK**, AUTO | 主键 |
|
||||
| `timestamp` | TIMESTAMP | DEFAULT now | 日志时间 |
|
||||
| `level` | VARCHAR(20) | NOT NULL | 日志级别 |
|
||||
| `source` | VARCHAR(50) | NOT NULL | 日志来源 |
|
||||
| `message` | TEXT | NOT NULL | 日志消息 |
|
||||
|
||||
**level 值:**
|
||||
- `INFO` - 信息
|
||||
- `WARNING` - 警告
|
||||
- `ERROR` - 错误
|
||||
- `SUCCESS` - 成功
|
||||
- `DEBUG` - 调试
|
||||
|
||||
---
|
||||
|
||||
### 4. plugin_configs - 插件配置表
|
||||
|
||||
存储插件的配置和启用状态。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | INTEGER | **PK**, AUTO | 主键 |
|
||||
| `plugin_name` | VARCHAR(100) | **UNIQUE**, NOT NULL | 插件名称 |
|
||||
| `config_json` | TEXT | DEFAULT '{}' | 插件配置(JSON格式) |
|
||||
| `enabled` | BOOLEAN | DEFAULT false | 是否启用 |
|
||||
| `created_at` | TIMESTAMP | DEFAULT now | 创建时间 |
|
||||
| `updated_at` | TIMESTAMP | DEFAULT now | 更新时间 |
|
||||
|
||||
**config_json 示例:**
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"receiver": "文件传输助手",
|
||||
"cron": "0 8 * * *",
|
||||
"http_url": "https://api.example.com/news",
|
||||
"message_template": "📰 {title}\n{url}"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库操作
|
||||
|
||||
### DatabaseService 主要方法
|
||||
|
||||
#### 节点操作
|
||||
```python
|
||||
# 获取所有节点
|
||||
db_service.get_all_nodes() -> List[Node]
|
||||
|
||||
# 获取指定节点
|
||||
db_service.get_node(node_id: str) -> Optional[Node]
|
||||
|
||||
# 添加节点
|
||||
db_service.add_node(node_id, name, api_url, api_key, description, group)
|
||||
|
||||
# 更新节点
|
||||
db_service.update_node(node_id, **kwargs) -> bool
|
||||
|
||||
# 删除节点
|
||||
db_service.delete_node(node_id) -> bool
|
||||
|
||||
# 获取启用的节点
|
||||
db_service.get_enabled_nodes() -> List[Node]
|
||||
```
|
||||
|
||||
#### Webhook 操作
|
||||
```python
|
||||
# 获取所有 Webhook
|
||||
db_service.get_all_webhooks() -> List[WebhookUrl]
|
||||
|
||||
# 获取启用的 Webhook
|
||||
db_service.get_enabled_webhooks() -> List[WebhookUrl]
|
||||
|
||||
# 添加 Webhook
|
||||
db_service.add_webhook(name, url, format, event_types) -> bool
|
||||
|
||||
# 更新 Webhook
|
||||
db_service.update_webhook(webhook_id, **kwargs) -> bool
|
||||
|
||||
# 删除 Webhook
|
||||
db_service.delete_webhook(webhook_id) -> bool
|
||||
```
|
||||
|
||||
#### 插件配置操作
|
||||
```python
|
||||
# 获取插件配置
|
||||
db_service.get_plugin_config(plugin_name: str) -> Optional[PluginConfig]
|
||||
|
||||
# 获取所有插件配置
|
||||
db_service.get_all_plugin_configs() -> List[PluginConfig]
|
||||
|
||||
# 保存插件配置
|
||||
db_service.save_plugin_config(plugin_name, config, enabled) -> bool
|
||||
|
||||
# 更新插件启用状态
|
||||
db_service.update_plugin_enabled(plugin_name, enabled) -> bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 索引
|
||||
|
||||
```sql
|
||||
-- nodes 表索引
|
||||
CREATE INDEX idx_nodes_enabled ON nodes(enabled);
|
||||
CREATE INDEX idx_nodes_group ON nodes(group);
|
||||
CREATE INDEX idx_nodes_status ON nodes(status);
|
||||
|
||||
-- webhook_urls 表索引
|
||||
CREATE INDEX idx_webhook_enabled ON webhook_urls(enabled);
|
||||
|
||||
-- logs 表索引
|
||||
CREATE INDEX idx_logs_timestamp ON logs(timestamp);
|
||||
CREATE INDEX idx_logs_level ON logs(level);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 迁移注意
|
||||
|
||||
如果数据库已存在,新增 `plugin_configs` 表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS plugin_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
plugin_name VARCHAR(100) UNIQUE NOT NULL,
|
||||
config_json TEXT DEFAULT '{}',
|
||||
enabled BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,770 @@
|
||||
# WXAuto Center 开发指南
|
||||
|
||||
## 开发环境搭建
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Python 3.8+
|
||||
- Node.js 14+ (可选,用于前端开发)
|
||||
- Git
|
||||
|
||||
### 克隆与安装
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone <repository-url>
|
||||
cd wxauto_api/wxauto_center
|
||||
|
||||
# 创建虚拟环境(推荐)
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# 或 venv\Scripts\activate # Windows
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 配置开发环境
|
||||
|
||||
```bash
|
||||
# 复制环境变量示例文件
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件
|
||||
vim .env
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
# 方式1:使用 run.py(推荐)
|
||||
python run.py
|
||||
|
||||
# 方式2:单进程开发模式
|
||||
python run.py --reload
|
||||
|
||||
# 方式3:使用 uvicorn
|
||||
uvicorn main:app --host 0.0.0.0 --port 8080 --reload
|
||||
|
||||
# 方式4:多进程模式(需要 Redis)
|
||||
python run.py --workers 4
|
||||
```
|
||||
|
||||
### 访问服务
|
||||
|
||||
- 主界面:http://localhost:8080/static/index.html
|
||||
- API 文档:http://localhost:8080/docs
|
||||
- ReDoc:http://localhost:8080/redoc
|
||||
|
||||
---
|
||||
|
||||
## 项目结构详解
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
wxauto_center/
|
||||
├── main.py # FastAPI 应用入口
|
||||
├── run.py # 多进程启动脚本
|
||||
├── config.py # 配置管理模块
|
||||
├── api/ # API 路由目录
|
||||
│ ├── __init__.py
|
||||
│ ├── nodes.py # 节点管理 API
|
||||
│ ├── messages.py # 消息发送 API
|
||||
│ ├── external.py # 外部扩展 API
|
||||
│ ├── callback.py # 回调接口 API
|
||||
│ └── plugins.py # 插件管理 API
|
||||
├── services/ # 业务逻辑目录
|
||||
│ ├── __init__.py
|
||||
│ ├── node_manager.py # 节点管理器
|
||||
│ ├── monitor_service.py # 监控服务
|
||||
│ ├── log_service.py # 日志服务(支持Redis+文件持久化)
|
||||
│ └── queue_service.py # 消息队列服务(支持Redis后端)
|
||||
├── plugins/ # 插件目录
|
||||
│ ├── __init__.py
|
||||
│ ├── base.py # 插件基类
|
||||
│ └── builtin.py # 内置插件
|
||||
├── models/ # 数据模型目录
|
||||
├── utils/ # 工具函数目录
|
||||
├── static/ # 静态文件目录
|
||||
│ ├── index.html # 主页面
|
||||
│ ├── css/ # 样式目录
|
||||
│ └── js/ # JavaScript 目录
|
||||
├── logs/ # 日志目录
|
||||
│ └── app.log # 应用日志
|
||||
├── config.json # 配置文件
|
||||
└── requirements.txt # 依赖列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码架构说明
|
||||
|
||||
### 主入口 (main.py)
|
||||
|
||||
`main.py` 是 FastAPI 应用的入口文件,负责:
|
||||
|
||||
1. 初始化 FastAPI 应用
|
||||
2. 配置 CORS 中间件
|
||||
3. 注册路由(nodes, messages, external, plugins)
|
||||
4. 处理启动/关闭事件
|
||||
|
||||
关键代码流程:
|
||||
|
||||
```python
|
||||
# 启动事件
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
load_config_from_file("config.json") # 加载配置
|
||||
register_builtin_plugins() # 注册内置插件
|
||||
for node_data in config_manager.get_all_nodes():
|
||||
await node_manager.add_node(...) # 添加节点
|
||||
await monitor_service.start() # 启动监控服务
|
||||
|
||||
# 关闭事件
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
await monitor_service.stop() # 停止监控
|
||||
save_config_to_file("config.json") # 保存配置
|
||||
```
|
||||
|
||||
### 配置管理 (config.py)
|
||||
|
||||
采用 Pydantic Settings 进行配置管理:
|
||||
|
||||
```python
|
||||
# 配置模型
|
||||
Settings # 主配置类
|
||||
NodeConfig # 节点配置
|
||||
PluginConfig # 插件配置
|
||||
WebhookConfig # Webhook 配置
|
||||
MonitorConfig # 监控配置
|
||||
|
||||
# 配置管理器
|
||||
ConfigManager # 提供节点配置的增删改查
|
||||
|
||||
# 配置加载/保存
|
||||
load_config_from_file() # 从文件加载
|
||||
save_config_to_file() # 保存到文件
|
||||
```
|
||||
|
||||
### 节点管理器 (services/node_manager.py)
|
||||
|
||||
`NodeManager` 是核心服务类,负责与 WXAuto-HTTP-API 节点通信。
|
||||
|
||||
```python
|
||||
# 节点状态
|
||||
class NodeStatus:
|
||||
ACTIVE = "active" # API 可用
|
||||
INACTIVE = "inactive" # API 不可用
|
||||
ERROR = "error" # API 错误
|
||||
|
||||
# 节点类
|
||||
class Node:
|
||||
node_id: str # 节点唯一标识
|
||||
name: str # 节点显示名称
|
||||
api_url: str
|
||||
api_key: str
|
||||
description: str
|
||||
group: str
|
||||
enabled: bool
|
||||
status: str # NodeStatus
|
||||
wechat_status: str
|
||||
is_healthy: bool
|
||||
health_message: str
|
||||
|
||||
# NodeManager 方法
|
||||
class NodeManager:
|
||||
async def add_node(...) # 添加节点
|
||||
async def remove_node(node_id) # 删除节点
|
||||
async def get_node(node_id) # 获取单个节点
|
||||
async def get_all_nodes() # 获取所有节点
|
||||
async def get_enabled_nodes() # 获取已启用节点
|
||||
async def get_nodes_by_group() # 按分组获取节点
|
||||
async def update_node(node_id, **kwargs) # 更新节点
|
||||
async def check_node_status(node_id) # 检查节点状态
|
||||
async def is_node_healthy(node_id) # 检查节点健康状态
|
||||
async def send_message(node_id, who, msg, msg_type) # 发送消息
|
||||
async def send_message_to_group(node_id, group_name, msg, msg_type) # 发送群消息
|
||||
async def get_friends_list(node_id) # 获取好友列表
|
||||
async def get_groups_list(node_id) # 获取群列表
|
||||
async def get_messages(node_id, chat_name, limit) # 获取消息历史
|
||||
|
||||
**注意**:已移除 `broadcast_message()` 方法,广播功能不再支持。
|
||||
|
||||
### 监控服务 (services/monitor_service.py)
|
||||
|
||||
`MonitorService` 负责定时检测节点和微信状态。
|
||||
|
||||
```python
|
||||
class MonitorService:
|
||||
async def start() # 启动监控
|
||||
async def stop() # 停止监控
|
||||
async def _monitor_loop() # 主监控循环
|
||||
async def _check_all_nodes() # 检查所有节点
|
||||
async def _check_node(node_id) # 检查单个节点
|
||||
async def _send_webhook(event, data) # 发送Webhook通知
|
||||
def get_last_status(node_id) # 获取最后状态
|
||||
def get_all_status() # 获取所有状态
|
||||
```
|
||||
|
||||
### 日志服务 (services/log_service.py)
|
||||
|
||||
`LogService` 提供日志记录和实时推送功能。
|
||||
|
||||
```python
|
||||
class LogService:
|
||||
def __init__(self, max_logs=500):
|
||||
self.max_logs = max_logs
|
||||
self._logs: deque = deque(maxlen=max_logs) # 限制500条
|
||||
self._subscribers: List[asyncio.Queue] = []
|
||||
|
||||
# 日志方法
|
||||
def info/warning/error/success(message, source)
|
||||
|
||||
# SSE 订阅
|
||||
async def subscribe() -> asyncio.Queue # 订阅日志流
|
||||
def unsubscribe(queue) # 取消订阅
|
||||
|
||||
# 日志查询
|
||||
def get_recent_logs(count) # 获取最近日志
|
||||
def get_all_logs() # 获取所有日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 插件开发指南
|
||||
|
||||
### 插件架构
|
||||
|
||||
系统采用插件化设计,支持扩展:
|
||||
|
||||
```
|
||||
PluginBase (抽象基类)
|
||||
├── MessageHandlerPlugin
|
||||
├── DataSourcePlugin
|
||||
├── ActionTriggerPlugin
|
||||
└── AIAgentPlugin
|
||||
```
|
||||
|
||||
### 开发自定义插件
|
||||
|
||||
#### 1. 创建插件类
|
||||
|
||||
```python
|
||||
# my_plugin.py
|
||||
from plugins.base import PluginBase, PluginType
|
||||
|
||||
class MyCustomPlugin(PluginBase):
|
||||
plugin_name = "my_custom_plugin"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_type = PluginType.CUSTOM
|
||||
plugin_description = "我的自定义插件"
|
||||
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
self.config = config
|
||||
# 初始化逻辑
|
||||
return True
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 插件逻辑
|
||||
return {"success": True, "result": "done"}
|
||||
```
|
||||
|
||||
#### 2. 注册插件
|
||||
|
||||
```python
|
||||
# 在 builtin.py 或 main.py 中注册
|
||||
from plugins.base import PluginRegistry
|
||||
|
||||
my_plugin = MyCustomPlugin()
|
||||
my_plugin.initialize({"key": "value"})
|
||||
PluginRegistry.register(my_plugin)
|
||||
```
|
||||
|
||||
### 插件类型详解
|
||||
|
||||
#### DataSourcePlugin
|
||||
|
||||
用于从外部数据源获取数据:
|
||||
|
||||
```python
|
||||
class MyDataSourcePlugin(DataSourcePlugin):
|
||||
plugin_name = "my_data_source"
|
||||
|
||||
def fetch_data(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
# 获取数据逻辑
|
||||
return [...]
|
||||
```
|
||||
|
||||
#### AIAgentPlugin
|
||||
|
||||
用于 AI 消息处理:
|
||||
|
||||
```python
|
||||
class MyAIAgentPlugin(AIAgentPlugin):
|
||||
plugin_name = "my_ai_agent"
|
||||
|
||||
def process(self, input_text: str, context: Dict[str, Any]) -> str:
|
||||
# 处理输入,返回 AI 回复
|
||||
return "AI 回复"
|
||||
|
||||
def get_response(self, messages: List[Dict[str, str]]) -> str:
|
||||
# 获取对话响应
|
||||
return "AI 回复"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 调用示例
|
||||
|
||||
### 使用 Python 调用
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
API_KEY = "your-external-api-key"
|
||||
BASE_URL = "http://localhost:8080/api/v1"
|
||||
|
||||
headers = {
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 添加节点
|
||||
response = httpx.post(
|
||||
f"{BASE_URL}/nodes",
|
||||
json={
|
||||
"node_id": "wx1",
|
||||
"name": "微信1",
|
||||
"api_url": "http://192.168.1.100:5000",
|
||||
"api_key": "node-api-key"
|
||||
},
|
||||
headers=headers
|
||||
)
|
||||
print(response.json())
|
||||
|
||||
# 发送消息
|
||||
response = httpx.post(
|
||||
f"{BASE_URL}/messages/send",
|
||||
json={
|
||||
"node_id": "wx1",
|
||||
"who": "wxid_friend",
|
||||
"msg": "Hello!"
|
||||
},
|
||||
headers=headers
|
||||
)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
### 使用 curl 调用
|
||||
|
||||
```bash
|
||||
# 添加节点
|
||||
curl -X POST http://localhost:8080/api/v1/nodes \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"node_id": "wx1", "name": "微信1", "api_url": "http://192.168.1.100:5000", "api_key": "key"}'
|
||||
|
||||
# 发送消息
|
||||
curl -X POST http://localhost:8080/api/v1/messages/send \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"node_id": "wx1", "who": "wxid_friend", "msg": "Hello"}'
|
||||
|
||||
# 获取节点状态
|
||||
curl http://localhost:8080/api/v1/nodes/wx1/status \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 获取监控状态
|
||||
curl http://localhost:8080/api/v1/monitor/status \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 获取最近日志
|
||||
curl "http://localhost:8080/api/v1/logs/recent?count=20" \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 重置节点微信状态(回调接口)
|
||||
curl -X POST http://localhost:8080/api/v1/callback/node/reset \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"node_id": "wx1"}'
|
||||
```
|
||||
|
||||
### 使用 JavaScript 调用
|
||||
|
||||
```javascript
|
||||
const API_KEY = 'your-external-api-key';
|
||||
const BASE_URL = 'http://localhost:8080/api/v1';
|
||||
|
||||
async function sendMessage(nodeId, who, msg) {
|
||||
const response = await fetch(`${BASE_URL}/messages/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
node_id: nodeId,
|
||||
who: who,
|
||||
msg: msg
|
||||
})
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
sendMessage('wx1', 'wxid_friend', 'Hello!')
|
||||
.then(result => console.log(result));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端开发
|
||||
|
||||
### 前端架构
|
||||
|
||||
前端采用纯 JavaScript 模块化设计:
|
||||
|
||||
```
|
||||
static/
|
||||
├── index.html # 主页面
|
||||
├── css/
|
||||
│ └── style.css # 样式文件
|
||||
└── js/
|
||||
├── api.js # API 调用封装
|
||||
├── app.js # 主逻辑
|
||||
└── pages/
|
||||
└── templates.js # 页面模板
|
||||
```
|
||||
|
||||
### 页面模块
|
||||
|
||||
系统前端包含以下页面:
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| dashboard | 系统概览 |
|
||||
| nodes | 节点管理 |
|
||||
| monitor | 监控状态 |
|
||||
| send | 消息发送 |
|
||||
| logs | 日志查看 |
|
||||
| external | 外部接口 |
|
||||
| docs | 文档 |
|
||||
| settings | 设置 |
|
||||
|
||||
### API 封装 (api.js)
|
||||
|
||||
```javascript
|
||||
// 基础配置
|
||||
const API_BASE = '/api/v1';
|
||||
const API_KEY = 'your-api-key';
|
||||
|
||||
// 通用请求函数
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
const headers = {
|
||||
'X-API-Key': API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// API 方法封装
|
||||
const API = {
|
||||
// 节点管理
|
||||
getNodes: (group) => apiRequest(`/nodes${group ? `?group=${group}` : ''}`),
|
||||
addNode: (data) => apiRequest('/nodes', { method: 'POST', body: JSON.stringify(data) }),
|
||||
deleteNode: (name) => apiRequest(`/nodes/${name}`, { method: 'DELETE' }),
|
||||
|
||||
// 消息发送
|
||||
sendMessage: (data) => apiRequest('/messages/send', { method: 'POST', body: JSON.stringify(data) }),
|
||||
sendGroupMessage: (data) => apiRequest('/messages/send_group', { method: 'POST', body: JSON.stringify(data) }),
|
||||
batchSendMessage: (data) => apiRequest('/messages/batch', { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
// 监控状态
|
||||
getMonitorStatus: () => apiRequest('/monitor/status'),
|
||||
getRecentLogs: (count) => apiRequest(`/logs/recent?count=${count || 50}`)
|
||||
};
|
||||
```
|
||||
|
||||
### SSE 日志订阅
|
||||
|
||||
```javascript
|
||||
async function subscribeLogs(callback) {
|
||||
const eventSource = new EventSource('/api/v1/logs/stream');
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const logEntry = JSON.parse(event.data);
|
||||
callback(logEntry);
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE Error:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return eventSource; // 使用完毕后调用 close()
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const es = subscribeLogs((log) => {
|
||||
console.log(`[${log.level}] ${log.message}`);
|
||||
});
|
||||
// 关闭时: es.close();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 安装测试依赖
|
||||
pip install pytest pytest-asyncio httpx
|
||||
|
||||
# 运行所有测试
|
||||
pytest
|
||||
|
||||
# 运行指定测试文件
|
||||
pytest tests/test_nodes.py
|
||||
|
||||
# 带详细输出
|
||||
pytest -v
|
||||
```
|
||||
|
||||
### 编写测试
|
||||
|
||||
```python
|
||||
# tests/test_nodes.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from main import app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_node():
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"name": "test_node",
|
||||
"api_url": "http://localhost:5000",
|
||||
"api_key": "test_key"
|
||||
},
|
||||
headers={"X-API-Key": "test-key"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 启用调试模式
|
||||
|
||||
```bash
|
||||
# 方式1:环境变量
|
||||
export DEBUG=true
|
||||
python main.py
|
||||
|
||||
# 方式2:命令行参数(如果支持)
|
||||
uvicorn main:app --reload --log-level debug
|
||||
|
||||
# 方式3:修改 .env
|
||||
DEBUG=true
|
||||
```
|
||||
|
||||
### 查看详细日志
|
||||
|
||||
系统内置日志服务,可通过 API 查看:
|
||||
|
||||
```bash
|
||||
# 获取最近日志
|
||||
curl http://localhost:8080/api/v1/logs/recent?count=100 \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
# 订阅实时日志流
|
||||
curl -N http://localhost:8080/api/v1/logs/stream \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### API 测试工具
|
||||
|
||||
- FastAPI 内置 docs:http://localhost:8080/docs
|
||||
- ReDoc:http://localhost:8080/redoc
|
||||
- Postman / Insomnia
|
||||
|
||||
---
|
||||
|
||||
## 代码规范
|
||||
|
||||
### Python 代码规范
|
||||
|
||||
遵循 PEP 8 和项目规则:
|
||||
|
||||
- 使用 4 空格缩进
|
||||
- 类名使用 PascalCase
|
||||
- 函数/变量使用 camelCase
|
||||
- 常量使用 UPPER_SNAKE_CASE
|
||||
- 每个文件包含头部注释
|
||||
|
||||
### 注释要求
|
||||
|
||||
```python
|
||||
# ///
|
||||
# filename.py
|
||||
# 描述:该文件负责 [核心功能]
|
||||
# 作者:AI Generated
|
||||
# 创建日期:YYYY-MM-DD
|
||||
# ///
|
||||
|
||||
def function_name(param1, param2):
|
||||
"""
|
||||
函数简要描述
|
||||
|
||||
@param {type} param1 - 参数说明
|
||||
@return {type} 返回值说明
|
||||
@throws {异常类型} 异常情况说明
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署注意事项
|
||||
|
||||
### 生产环境配置
|
||||
|
||||
1. 修改 `secret_key` 为强密码
|
||||
2. 设置 `debug: false`
|
||||
3. 配置正确的 `cors_origins`
|
||||
4. 使用 HTTPS
|
||||
5. 配置防火墙规则
|
||||
|
||||
### 使用 Nginx 反向代理
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用 systemd 服务
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/wxauto-center.service
|
||||
[Unit]
|
||||
Description=WXAuto Center Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your-user
|
||||
WorkingDirectory=/path/to/wxauto_center
|
||||
ExecStart=/path/to/venv/bin/python main.py
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl enable wxauto-center
|
||||
sudo systemctl start wxauto-center
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键实现细节
|
||||
|
||||
### 1. 健康检测机制 (is_node_healthy)
|
||||
|
||||
每次发送消息前,系统会调用此方法检测节点健康状态:
|
||||
|
||||
```python
|
||||
async def is_node_healthy(self, name: str) -> tuple[bool, str]:
|
||||
# 1. 检查 API 状态
|
||||
api_status = await self.check_node_status(name)
|
||||
if api_status != NodeStatus.ACTIVE:
|
||||
return False, f"API status: {api_status}"
|
||||
|
||||
# 2. 调用 /api/wechat/initialize 检测微信
|
||||
init_result = await self.call_node_api(name, "/api/wechat/initialize", method="POST")
|
||||
|
||||
# 3. 解析返回状态
|
||||
if not init_result.get("success"):
|
||||
return False, f"WeChat check failed: {init_result.get('error')}"
|
||||
|
||||
# 4. 检查 code 和 status
|
||||
wx_code = init_result.get("code") or init_result.get("data", {}).get("code")
|
||||
wx_status = init_result.get("data", {}).get("data", {}).get("status")
|
||||
|
||||
if wx_code != 0:
|
||||
return False, "WeChat error: ..."
|
||||
|
||||
if wx_status not in ["connected", "online"]:
|
||||
return False, f"WeChat status: {wx_status}"
|
||||
|
||||
return True, "OK"
|
||||
```
|
||||
|
||||
### 2. 消息发送流程
|
||||
|
||||
```python
|
||||
async def send_message(self, name: str, who: str, msg: str, msg_type: str = "text"):
|
||||
# 1. 先初始化微信
|
||||
await self.call_node_api(name, "/api/wechat/initialize", method="POST")
|
||||
|
||||
# 2. 发送消息
|
||||
return await self.call_node_api(
|
||||
name,
|
||||
"/api/message/send",
|
||||
method="POST",
|
||||
data={"receiver": who, "message": msg}
|
||||
)
|
||||
```
|
||||
|
||||
### 3. 监控告警流程
|
||||
|
||||
```python
|
||||
async def _check_node(self, node_id: str):
|
||||
# 检测 API 状态变化
|
||||
api_status = await node_manager.check_node_status(node_id)
|
||||
if old_api_status != api_status:
|
||||
event = "node_online" if api_status == "active" else "node_offline"
|
||||
await self._send_webhook(event, {...})
|
||||
|
||||
# 检测微信状态变化(通过发送消息检测)
|
||||
wechat_status = node.wechat_status # 从节点对象获取
|
||||
if old_wechat_status != wechat_status:
|
||||
event = "wechat_online" if wechat_status == "online" else "wechat_offline"
|
||||
await self._send_webhook(event, {...})
|
||||
```
|
||||
|
||||
**注意**:微信状态现在通过消息发送结果自动更新,不需要主动检测。
|
||||
@@ -0,0 +1,662 @@
|
||||
# WXAuto Center 业务流程文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档详细描述 WXAuto Center 的核心业务流程,包括消息发送流程、回调重置流程、监控告警流程、健康检测流程和日志发布订阅流程。
|
||||
|
||||
---
|
||||
|
||||
## 1. 节点标识系统
|
||||
|
||||
### 1.1 node_id 与 name 的区别
|
||||
|
||||
每个节点有两个标识:
|
||||
|
||||
| 字段 | 说明 | 示例 | 用途 |
|
||||
|------|------|------|------|
|
||||
| node_id | 节点唯一标识 | "wx1", "node_001" | API 调用、系统内部引用 |
|
||||
| name | 节点显示名称 | "微信1", "测试节点" | 界面展示、用户友好提示 |
|
||||
|
||||
### 1.2 节点数据模型
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"name": "微信1",
|
||||
"api_url": "http://192.168.20.18:5000",
|
||||
"api_key": "key",
|
||||
"enabled": true,
|
||||
"description": "",
|
||||
"group": "wechat",
|
||||
"status": "active",
|
||||
"wechat_status": "online",
|
||||
"is_healthy": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 消息发送流程
|
||||
|
||||
### 2.1 流程图
|
||||
|
||||
```
|
||||
外部请求
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ POST /api/v1/messages/send │
|
||||
│ 或 │
|
||||
│ POST /api/v1/external/send │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ check_node_health(node_id) │
|
||||
│ 调用 /api/wechat/initialize 检测微信状态 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├──► 微信状态异常?
|
||||
│ │
|
||||
│ ▼ (是)
|
||||
│ 返回 HTTP 503
|
||||
│ "Node 'wx1' is not healthy..."
|
||||
│ │
|
||||
│ ▼
|
||||
│ 流程结束
|
||||
│
|
||||
▼ (否)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ node_manager.send_message(node_id, who, msg, msg_type) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├──► POST /api/wechat/initialize (初始化微信)
|
||||
│
|
||||
└──► POST /api/message/send (发送消息)
|
||||
│
|
||||
▼
|
||||
发送成功?
|
||||
│
|
||||
├──► 是 → wechat_status = "online"
|
||||
│ │
|
||||
│ ▼
|
||||
│ 返回成功响应
|
||||
│
|
||||
└──► 否 → wechat_status = "offline"
|
||||
│
|
||||
▼
|
||||
返回错误响应
|
||||
```
|
||||
|
||||
### 2.2 详细步骤说明
|
||||
|
||||
| 步骤 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | 接收发送消息请求 | 解析 node_id, who, msg, msg_type |
|
||||
| 2 | 健康检测 | 调用 is_node_healthy() 检测节点 |
|
||||
| 3 | 初始化微信 | 调用 /api/wechat/initialize |
|
||||
| 4 | 发送消息 | 调用 /api/message/send |
|
||||
| 5 | 更新状态 | 根据发送结果更新 wechat_status |
|
||||
| 6 | 返回响应 | 返回发送结果给客户端 |
|
||||
|
||||
### 2.3 代码逻辑
|
||||
|
||||
```python
|
||||
async def send_message(node_id: str, who: str, msg: str, msg_type: str = "text"):
|
||||
# 1. 检查节点是否健康
|
||||
is_healthy, health_msg = await node_manager.is_node_healthy(node_id)
|
||||
if not is_healthy:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Node '{node_id}' is not healthy: {health_msg}"
|
||||
)
|
||||
|
||||
# 2. 初始化微信
|
||||
await node_manager.call_node_api(node_id, "/api/wechat/initialize", method="POST")
|
||||
|
||||
# 3. 发送消息
|
||||
result = await node_manager.call_node_api(
|
||||
node_id,
|
||||
"/api/message/send",
|
||||
method="POST",
|
||||
data={"receiver": who, "message": msg}
|
||||
)
|
||||
|
||||
# 4. 更新状态
|
||||
if result.get("code") == 0:
|
||||
node_manager.update_node_status(node_id, wechat_status="online")
|
||||
else:
|
||||
node_manager.update_node_status(node_id, wechat_status="offline")
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 回调重置流程
|
||||
|
||||
### 3.1 流程图
|
||||
|
||||
```
|
||||
外部节点回调
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ POST /api/v1/callback/node/reset │
|
||||
│ 请求体: {"node_id": "wx1"} │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 调用 /api/wechat/initialize 初始化微信 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 调用 /api/message/send 发送检测消息到"文件传输助手" │
|
||||
│ receiver: "filehelper" │
|
||||
│ message: "WXAuto Center 检测消息" │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
检测结果
|
||||
│
|
||||
├──► 发送成功
|
||||
│ │
|
||||
│ ▼
|
||||
│ wechat_status = "online"
|
||||
│ │
|
||||
│ ▼
|
||||
│ 返回 {"success": true, "wechat_status": "online"}
|
||||
│
|
||||
└──► 发送失败
|
||||
│
|
||||
▼
|
||||
wechat_status = "offline"
|
||||
│
|
||||
▼
|
||||
返回 {"success": false, "wechat_status": "offline"}
|
||||
│
|
||||
▼
|
||||
MonitorService 检测到状态变化
|
||||
│
|
||||
▼
|
||||
触发 Webhook 告警 (wechat_offline)
|
||||
```
|
||||
|
||||
### 3.2 详细步骤说明
|
||||
|
||||
| 步骤 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | 接收重置请求 | 解析 node_id |
|
||||
| 2 | 初始化微信 | 调用 /api/wechat/initialize |
|
||||
| 3 | 发送检测消息 | 发送消息到文件传输助手(filehelper) |
|
||||
| 4 | 判断结果 | 根据发送是否成功设置状态 |
|
||||
| 5 | 触发告警 | 状态变化时触发 Webhook |
|
||||
|
||||
### 3.3 回调接口说明
|
||||
|
||||
**POST /api/v1/callback/node/reset**
|
||||
|
||||
用于重置节点的微信状态。当微信掉线后,可以通过此接口重新检测微信状态。
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例 - 成功**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "微信状态已重置",
|
||||
"data": {
|
||||
"node_id": "wx1",
|
||||
"wechat_status": "online",
|
||||
"check_result": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例 - 失败**:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "微信状态检测失败",
|
||||
"data": {
|
||||
"node_id": "wx1",
|
||||
"wechat_status": "offline",
|
||||
"check_result": "failed",
|
||||
"error": "Unknown error"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 监控告警流程
|
||||
|
||||
### 4.1 流程图
|
||||
|
||||
```
|
||||
MonitorService 启动
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ _monitor_loop() 定时循环 │
|
||||
│ 间隔: check_interval (默认 60 秒) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ _check_all_nodes() │
|
||||
│ 遍历所有节点 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
对每个节点执行 _check_node(node_id):
|
||||
│
|
||||
├──► api_check_enabled?
|
||||
│ │
|
||||
│ ▼ (是)
|
||||
│ check_node_status(node_id)
|
||||
│ │
|
||||
│ ▼
|
||||
│ API 状态变化?
|
||||
│ │
|
||||
│ ├──► 是 → 触发 Webhook (node_online/node_offline)
|
||||
│ │
|
||||
│ └──► 否 → 无操作
|
||||
│
|
||||
└──► wechat_check_enabled?
|
||||
│
|
||||
▼ (是)
|
||||
initialize_wechat(node_id)
|
||||
│
|
||||
▼
|
||||
get_wechat_status(node_id)
|
||||
│
|
||||
▼
|
||||
微信状态变化?
|
||||
│
|
||||
├──► 是 → 触发 Webhook (wechat_online/wechat_offline)
|
||||
│
|
||||
└──► 否 → 无操作
|
||||
```
|
||||
|
||||
### 4.2 支持的事件类型
|
||||
|
||||
| 事件类型 | 触发条件 | 消息内容 |
|
||||
|----------|----------|----------|
|
||||
| api_error | API 调用失败 | "API Error: {error}" |
|
||||
| wechat_offline | 微信状态变为 offline | "微信掉线" |
|
||||
| wechat_online | 微信状态变为 online/connected | "微信上线" |
|
||||
| node_offline | 节点 API 不可用 | "节点离线" |
|
||||
| node_online | 节点 API 恢复可用 | "节点在线" |
|
||||
|
||||
### 4.3 Webhook 消息格式
|
||||
|
||||
**企业微信格式**:
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "【WXAuto Center 告警】\n事件类型: 微信掉线\n节点: wx1 (微信1)\n状态: offline\n时间: 2026-04-05 10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bark 格式**:
|
||||
```json
|
||||
{
|
||||
"title": "WXAuto告警 - 微信掉线",
|
||||
"body": "wx1\n状态: offline\n时间: 2026-04-05 10:00:00",
|
||||
"icon": "https://..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 健康检测流程
|
||||
|
||||
### 5.1 流程图
|
||||
|
||||
```
|
||||
is_node_healthy(node_id) 调用
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ check_node_status(node_id) │
|
||||
│ 调用 GET /health 检测 API 可用性 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
API 状态检查
|
||||
│
|
||||
├──► API 不可用 → 返回 (False, "API status: {status}")
|
||||
│
|
||||
▼ (API 可用)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ /api/wechat/initialize │
|
||||
│ 调用 POST /api/wechat/initialize 初始化微信 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
解析返回状态 (code, message, status)
|
||||
│
|
||||
├──► code != 0?
|
||||
│ │
|
||||
│ ├──► "无效"/"1400"/"拒绝访问"
|
||||
│ │ │
|
||||
│ │ ▼
|
||||
│ │ 返回 (False, "WeChat window invalid")
|
||||
│ │
|
||||
│ └──► 其他错误消息
|
||||
│ │
|
||||
│ ▼
|
||||
│ 返回 (False, "WeChat error: {message}")
|
||||
│
|
||||
├──► status not in ["connected", "online"]?
|
||||
│ │
|
||||
│ └──► 是 → 返回 (False, "WeChat status: {status}")
|
||||
│
|
||||
└──► 其他情况
|
||||
│
|
||||
▼
|
||||
返回 (True, "OK")
|
||||
```
|
||||
|
||||
### 5.2 健康检测判断逻辑
|
||||
|
||||
```python
|
||||
async def is_node_healthy(node_id: str) -> tuple[bool, str]:
|
||||
# 1. 检查 API 状态
|
||||
api_status = await self.check_node_status(node_id)
|
||||
if api_status != NodeStatus.ACTIVE:
|
||||
return False, f"API status: {api_status}"
|
||||
|
||||
# 2. 调用 /api/wechat/initialize 检测微信
|
||||
init_result = await self.call_node_api(node_id, "/api/wechat/initialize", method="POST")
|
||||
|
||||
# 3. 解析返回状态
|
||||
if not init_result.get("success"):
|
||||
return False, f"WeChat check failed: {init_result.get('error')}"
|
||||
|
||||
wx_code = init_result.get("code") or init_result.get("data", {}).get("code")
|
||||
wx_status = init_result.get("data", {}).get("data", {}).get("status")
|
||||
|
||||
if wx_code != 0:
|
||||
return False, "WeChat error: ..."
|
||||
|
||||
if wx_status not in ["connected", "online"]:
|
||||
return False, f"WeChat status: {wx_status}"
|
||||
|
||||
return True, "OK"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 微信状态机
|
||||
|
||||
### 6.1 状态定义
|
||||
|
||||
| 状态 | 说明 | 可转换状态 |
|
||||
|------|------|------------|
|
||||
| online | 微信正常在线(默认/成功状态) | offline |
|
||||
| offline | 微信掉线或不可用 | online |
|
||||
|
||||
### 6.2 状态转换图
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ online │
|
||||
│ (默认/成功) │
|
||||
└──────┬───────┘
|
||||
│
|
||||
发送消息失败
|
||||
或微信掉线检测
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ offline │
|
||||
└──────┬───────┘
|
||||
│
|
||||
回调重置成功
|
||||
(文件传输助手检测)
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ online │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### 6.3 状态转换场景
|
||||
|
||||
| 场景 | 原状态 | 新状态 | 触发条件 |
|
||||
|------|--------|--------|----------|
|
||||
| 消息发送成功 | online | online | code == 0 |
|
||||
| 消息发送失败 | online | offline | code != 0 |
|
||||
| 微信掉线 | online | offline | 监控检测到异常 |
|
||||
| 回调重置成功 | offline | online | 检测消息发送成功 |
|
||||
| 回调重置失败 | offline | offline | 检测消息发送失败 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 日志发布订阅流程
|
||||
|
||||
### 7.1 流程图
|
||||
|
||||
```
|
||||
业务操作调用
|
||||
│
|
||||
├──► log_service.info(message, source)
|
||||
├──► log_service.warning(message, source)
|
||||
├──► log_service.error(message, source)
|
||||
└──► log_service.success(message, source)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ LogEntry 创建 │
|
||||
│ {timestamp, level, source, message} │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├──► 存入 deque (本地缓存,最多 500 条)
|
||||
│
|
||||
└──► 推送到 SSE Queue
|
||||
│
|
||||
▼
|
||||
所有订阅者收到消息
|
||||
│
|
||||
▼
|
||||
unsubscribe() 取消订阅
|
||||
```
|
||||
|
||||
### 7.2 SSE 订阅示例
|
||||
|
||||
```javascript
|
||||
// 前端订阅日志流
|
||||
const eventSource = new EventSource('/api/v1/logs/stream');
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const logEntry = JSON.parse(event.data);
|
||||
console.log(`[${logEntry.level}] ${logEntry.message}`);
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE Error:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
```
|
||||
|
||||
### 7.3 日志级别
|
||||
|
||||
| 级别 | 说明 | 使用场景 |
|
||||
|------|------|----------|
|
||||
| INFO | 信息 | 一般操作日志 |
|
||||
| WARNING | 警告 | 异常但可恢复 |
|
||||
| ERROR | 错误 | 操作失败 |
|
||||
| SUCCESS | 成功 | 操作成功确认 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 批量操作流程
|
||||
|
||||
### 8.1 广播消息流程
|
||||
|
||||
```
|
||||
广播请求
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ POST /api/v1/messages/broadcast │
|
||||
│ 请求体: {node_ids, who, msg, msg_type} │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
node_ids 为空?
|
||||
│
|
||||
├──► 是 → 获取所有已启用节点
|
||||
│
|
||||
▼ (否)
|
||||
使用指定的 node_ids
|
||||
│
|
||||
▼
|
||||
对每个节点并行发送消息
|
||||
│
|
||||
├──► 节点1 → send_message() → 结果1
|
||||
├──► 节点2 → send_message() → 结果2
|
||||
└──► 节点N → send_message() → 结果N
|
||||
│
|
||||
▼
|
||||
汇总结果
|
||||
│
|
||||
▼
|
||||
返回批量结果
|
||||
```
|
||||
|
||||
### 8.2 批量发送流程
|
||||
|
||||
```
|
||||
批量请求
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ POST /api/v1/messages/batch │
|
||||
│ 请求体: {messages: [{node_id, who, msg, msg_type}, ...]} │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
验证消息列表
|
||||
│
|
||||
▼
|
||||
对每条消息并行发送
|
||||
│
|
||||
├──► 消息1 → send_message() → 结果1
|
||||
├──► 消息2 → send_message() → 结果2
|
||||
└──► 消息N → send_message() → 结果N
|
||||
│
|
||||
▼
|
||||
汇总结果
|
||||
│
|
||||
▼
|
||||
返回批量结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 插件执行流程
|
||||
|
||||
### 9.1 外部触发流程
|
||||
|
||||
```
|
||||
外部请求
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ POST /api/v1/external/trigger │
|
||||
│ 请求体: {source, event, data, auto_send, target, node_id} │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
获取插件
|
||||
│
|
||||
▼
|
||||
执行插件
|
||||
│
|
||||
├──► plugin.execute(event, data)
|
||||
│
|
||||
▼
|
||||
auto_send = true?
|
||||
│
|
||||
├──► 是 → 发送消息到 target
|
||||
│ │
|
||||
│ ▼
|
||||
│ 返回 {plugin_result, send_result}
|
||||
│
|
||||
└──► 否 → 返回 {plugin_result}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 完整状态流转图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 系统启动 │
|
||||
│ 节点创建 (wechat_status=online) │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ 正常运行 │
|
||||
│ wechat_status=online │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
┌─────────────────┴─────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ 消息发送成功 │ │ 消息发送失败 │
|
||||
│ wechat_status= │ │ wechat_status= │
|
||||
│ online │ │ offline │
|
||||
└───────────────────────┘ └───────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ 调用回调重置接口 │
|
||||
│ POST /callback/node/ │
|
||||
│ reset │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ 重置成功 │ │ 重置失败 │
|
||||
│ 发送检测消息成功 │ │ 发送检测消息失败 │
|
||||
│ wechat_status= │ │ wechat_status= │
|
||||
│ online │ │ offline │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ 触发 Webhook 告警 │
|
||||
│ wechat_offline │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
| 日期 | 更新内容 |
|
||||
|------|----------|
|
||||
| 2026-04-05 | 创建 FLOWS.md 业务流程文档 |
|
||||
| 2026-04-05 | 添加消息发送流程图 |
|
||||
| 2026-04-05 | 添加回调重置流程图 |
|
||||
| 2026-04-05 | 添加监控告警流程图 |
|
||||
| 2026-04-05 | 添加健康检测流程图 |
|
||||
| 2026-04-05 | 添加微信状态机说明 |
|
||||
| 2026-04-05 | 添加日志发布订阅流程图 |
|
||||
@@ -0,0 +1,615 @@
|
||||
# WXAuto Center 模块索引文档
|
||||
|
||||
## 1. 模块概览
|
||||
|
||||
WXAuto Center 采用分层架构,主要分为以下模块:
|
||||
|
||||
| 层级 | 模块 | 路径 | 职责 |
|
||||
|------|------|------|------|
|
||||
| 接入层 | API Routes | `api/` | 处理 HTTP 请求,提供 RESTful 接口 |
|
||||
| 业务逻辑层 | Services | `services/` | 核心业务逻辑处理 |
|
||||
| 公共工具层 | Utils | `utils/` | 公共函数和依赖注入 |
|
||||
| 插件系统 | Plugins | `plugins/` | 扩展功能插件 |
|
||||
| 配置层 | Config | `config.py` | 系统配置管理 |
|
||||
| 入口 | Main | `main.py` | FastAPI 应用入口 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块详细说明
|
||||
|
||||
### 2.1 接入层 (API Layer)
|
||||
|
||||
#### api/nodes.py - 节点管理 API
|
||||
|
||||
**文件路径**:[nodes.py](../../wxauto_center/api/nodes.py)
|
||||
|
||||
**职责**:
|
||||
- 节点的增删改查 (CRUD)
|
||||
- 节点状态检测
|
||||
- 微信初始化
|
||||
- 节点启用/禁用
|
||||
- 获取好友列表、群列表、聊天消息
|
||||
|
||||
**主要端点**:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /nodes | 获取所有节点 |
|
||||
| GET | /nodes/{node_id} | 获取指定节点 |
|
||||
| POST | /nodes | 添加节点 |
|
||||
| PUT | /nodes/{node_id} | 更新节点 |
|
||||
| DELETE | /nodes/{node_id} | 删除节点 |
|
||||
| POST | /nodes/{node_id}/enable | 启用节点 |
|
||||
| POST | /nodes/{node_id}/disable | 禁用节点 |
|
||||
| GET | /nodes/{node_id}/status | 检查节点状态 |
|
||||
| GET | /nodes/{node_id}/wechat/status | 获取微信状态 |
|
||||
| POST | /nodes/{node_id}/wechat/initialize | 初始化微信 |
|
||||
|
||||
**依赖**:`services.node_manager`, `utils.require_api_key`
|
||||
|
||||
**节点标识说明**:所有接口使用 `node_id` 作为节点唯一标识。
|
||||
|
||||
---
|
||||
|
||||
#### api/messages.py - 消息管理 API
|
||||
|
||||
**文件路径**:[messages.py](../../wxauto_center/api/messages.py)
|
||||
|
||||
**职责**:
|
||||
- 发送消息
|
||||
- 发送群消息
|
||||
- 批量发送消息
|
||||
- 发送模板消息
|
||||
- 获取消息历史
|
||||
|
||||
**主要端点**:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /messages/send | 发送消息 |
|
||||
| POST | /messages/send_group | 发送群消息 |
|
||||
| POST | /messages/batch | 批量发送 |
|
||||
| POST | /messages/template/{node_id}/{who} | 发送模板消息 |
|
||||
| GET | /messages/history/{node_id}/{chat_name} | 获取消息历史 |
|
||||
|
||||
**依赖**:`services.node_manager`, `utils.require_api_key`, `utils.check_node_health`
|
||||
|
||||
**说明**:已移除广播功能。
|
||||
|
||||
---
|
||||
|
||||
#### api/external.py - 外部扩展 API
|
||||
|
||||
**文件路径**:[external.py](../../wxauto_center/api/external.py)
|
||||
|
||||
**职责**:
|
||||
- 供外部系统调用的接口
|
||||
- 外部消息发送
|
||||
- 数据处理触发
|
||||
- 插件执行
|
||||
- AI 消息处理
|
||||
- Webhook 接收
|
||||
|
||||
**主要端点**:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /external/send | 外部接口-发送消息 |
|
||||
| POST | /external/trigger | 外部接口-触发数据处理 |
|
||||
| POST | /external/plugin/execute | 外部接口-执行插件 |
|
||||
| GET | /external/plugins | 外部接口-获取插件列表 |
|
||||
| GET | /external/nodes | 外部接口-获取可用节点 |
|
||||
| POST | /external/ai/process | 外部接口-AI处理消息 |
|
||||
| POST | /external/webhook/{event} | 外部接口-Webhook接收 |
|
||||
|
||||
**认证方式**:`X-ExternalKey` Header
|
||||
|
||||
**依赖**:`services.node_manager`, `plugins.base.PluginRegistry`, `utils.require_external_key`
|
||||
|
||||
---
|
||||
|
||||
#### api/plugins.py - 插件管理 API
|
||||
|
||||
**文件路径**:[plugins.py](../../wxauto_center/api/plugins.py)
|
||||
|
||||
**职责**:
|
||||
- 插件注册和管理
|
||||
- 插件启用/禁用
|
||||
- 插件配置更新
|
||||
- 按类型获取插件
|
||||
|
||||
**主要端点**:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /plugins | 获取所有插件 |
|
||||
| GET | /plugins/{plugin_name} | 获取插件详情 |
|
||||
| POST | /plugins/{plugin_name}/enable | 启用插件 |
|
||||
| POST | /plugins/{plugin_name}/disable | 禁用插件 |
|
||||
| PUT | /plugins/{plugin_name}/config | 更新插件配置 |
|
||||
| POST | /plugins/{plugin_name}/execute | 执行插件 |
|
||||
| GET | /plugins/type/{plugin_type} | 按类型获取插件 |
|
||||
|
||||
**依赖**:`plugins.base.PluginRegistry`, `utils.require_api_key`
|
||||
|
||||
---
|
||||
|
||||
#### api/callback.py - 回调接口 API
|
||||
|
||||
**文件路径**:[callback.py](../../wxauto_center/api/callback.py)
|
||||
|
||||
**职责**:
|
||||
- 外部节点回调
|
||||
- 节点微信状态重置
|
||||
- 节点微信状态检测
|
||||
- 节点状态查询
|
||||
|
||||
**主要端点**:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /callback/node/reset | 重置节点微信状态 |
|
||||
| POST | /callback/node/check | 检测节点微信状态 |
|
||||
| GET | /callback/node/{node_id}/status | 获取节点回调状态 |
|
||||
|
||||
**业务流程**:
|
||||
```
|
||||
1. 发送消息 → 失败 → wechat_status = offline
|
||||
2. 回调重置接口 → 发送检测消息 → 成功 → wechat_status = online
|
||||
3. 监控服务检测状态变化 → 触发Webhook告警
|
||||
```
|
||||
|
||||
**依赖**:`services.node_manager`, `services.log_service`, `utils.require_api_key`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 业务逻辑层 (Service Layer)
|
||||
|
||||
#### services/node_manager.py - 节点管理服务
|
||||
|
||||
**文件路径**:[node_manager.py](../../wxauto_center/services/node_manager.py)
|
||||
|
||||
**职责**:
|
||||
- 节点注册和注销
|
||||
- 节点通信管理
|
||||
- API 调用封装
|
||||
- 消息发送
|
||||
- 节点状态检测
|
||||
|
||||
**核心类**:
|
||||
|
||||
| 类名 | 说明 |
|
||||
|------|------|
|
||||
| NodeStatus | 节点状态枚举 (ACTIVE, INACTIVE, ERROR) |
|
||||
| Node | 节点数据模型(包含 node_id 和 name) |
|
||||
| NodeManager | 节点管理服务类 |
|
||||
|
||||
**主要方法**:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| add_node() | 添加节点 |
|
||||
| remove_node(node_id) | 删除节点 |
|
||||
| get_node(node_id) | 获取节点 |
|
||||
| get_all_nodes() | 获取所有节点 |
|
||||
| get_enabled_nodes() | 获取已启用节点 |
|
||||
| check_node_status(node_id) | 检测节点状态 |
|
||||
| is_node_healthy(node_id) | 判断节点是否健康 |
|
||||
| call_node_api() | 调用节点 API |
|
||||
| send_message(node_id, who, msg, msg_type) | 发送消息 |
|
||||
| send_message_to_group(node_id, group_name, msg, msg_type) | 发送群消息 |
|
||||
|
||||
**内部方法**:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| _send_message_internal() | 发送消息的内部方法 (复用逻辑) |
|
||||
|
||||
**依赖**:`services.log_service`
|
||||
|
||||
---
|
||||
|
||||
#### services/monitor_service.py - 监控服务
|
||||
|
||||
**文件路径**:[monitor_service.py](../../wxauto_center/services/monitor_service.py)
|
||||
|
||||
**职责**:
|
||||
- 定时检测节点状态
|
||||
- 检测 API 状态变化
|
||||
- 检测微信状态变化
|
||||
- 触发 Webhook 告警
|
||||
|
||||
**主要方法**:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| start() | 启动监控服务 |
|
||||
| stop() | 停止监控服务 |
|
||||
| get_last_status() | 获取节点最后状态 |
|
||||
| get_all_status() | 获取所有节点状态 |
|
||||
|
||||
**内部方法**:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| _monitor_loop() | 监控主循环 |
|
||||
| _check_all_nodes() | 检测所有节点 |
|
||||
| _check_node() | 检测单个节点 |
|
||||
| _send_webhook() | 发送 Webhook 通知 |
|
||||
|
||||
**依赖**:`config.get_settings`, `services.node_manager`, `services.log_service`
|
||||
|
||||
---
|
||||
|
||||
#### services/log_service.py - 日志服务
|
||||
|
||||
**文件路径**:[log_service.py](../../wxauto_center/services/log_service.py)
|
||||
|
||||
**职责**:
|
||||
- 日志记录
|
||||
- 日志缓存 (deque,最多 500 条)
|
||||
- SSE 实时推送
|
||||
- 发布订阅模式
|
||||
- Redis 缓存支持
|
||||
- 文件持久化 (logs/app.log)
|
||||
|
||||
**日志级别**:INFO, WARNING, ERROR, SUCCESS
|
||||
|
||||
**主要方法**:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| info() | 记录 INFO 日志 |
|
||||
| warning() | 记录 WARNING 日志 |
|
||||
| error() | 记录 ERROR 日志 |
|
||||
| success() | 记录 SUCCESS 日志 |
|
||||
| subscribe() | 订阅日志流 |
|
||||
| unsubscribe() | 取消订阅 |
|
||||
| get_recent_logs() | 获取最近日志 |
|
||||
| get_all_logs() | 获取所有日志 |
|
||||
|
||||
---
|
||||
|
||||
#### services/queue_service.py - 消息队列服务
|
||||
|
||||
**文件路径**:[queue_service.py](../../wxauto_center/services/queue_service.py)
|
||||
|
||||
**职责**:
|
||||
- 消息队列管理
|
||||
- Redis 后端支持(可选)
|
||||
- 本地队列回退
|
||||
- 异步任务处理
|
||||
|
||||
**队列名称**:
|
||||
|
||||
| 队列名 | 说明 |
|
||||
|--------|------|
|
||||
| MESSAGE_SEND | 消息发送队列 |
|
||||
| MESSAGE_BROADCAST | 消息广播队列(已废弃) |
|
||||
|
||||
**主要方法**:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| enqueue() | 入队操作 |
|
||||
| dequeue() | 出队操作(异步) |
|
||||
| get_queue_length() | 获取队列长度 |
|
||||
| start_worker() | 启动工作进程 |
|
||||
| stop_worker() | 停止工作进程 |
|
||||
|
||||
**依赖**:Redis (可选),若 Redis 不可用则使用本地 asyncio.Queue
|
||||
|
||||
---
|
||||
|
||||
### 2.3 公共工具层 (Utils Layer)
|
||||
|
||||
#### utils/common.py - 公共函数
|
||||
|
||||
**文件路径**:[common.py](../../wxauto_center/utils/common.py)
|
||||
|
||||
**职责**:
|
||||
- API Key 验证依赖 (require_api_key)
|
||||
- External Key 验证依赖 (require_external_key)
|
||||
|
||||
**主要函数**:
|
||||
|
||||
| 函数名 | 说明 |
|
||||
|--------|------|
|
||||
| require_api_key | API Key 验证依赖,用于 X-API-Key Header |
|
||||
| require_external_key | External Key 验证依赖,用于 X-ExternalKey Header |
|
||||
|
||||
**使用示例**:
|
||||
|
||||
```python
|
||||
from utils import require_api_key
|
||||
|
||||
@router.post("/endpoint")
|
||||
async def endpoint(_=Depends(require_api_key)):
|
||||
# 处理请求
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### utils/helpers.py - 业务辅助函数
|
||||
|
||||
**文件路径**:[helpers.py](../../wxauto_center/utils/helpers.py)
|
||||
|
||||
**职责**:
|
||||
- 节点健康检查
|
||||
- 响应构建
|
||||
- 结果解析
|
||||
|
||||
**主要函数**:
|
||||
|
||||
| 函数名 | 说明 |
|
||||
|--------|------|
|
||||
| check_node_health() | 检查节点健康状态,不健康则抛 503 异常 |
|
||||
| build_success_response() | 构建成功响应 |
|
||||
| build_error_response() | 构建错误响应 |
|
||||
| parse_node_result() | 解析节点 API 调用结果 |
|
||||
|
||||
---
|
||||
|
||||
### 2.4 插件系统 (Plugin Layer)
|
||||
|
||||
#### plugins/base.py - 插件基类
|
||||
|
||||
**文件路径**:[base.py](../../wxauto_center/plugins/base.py)
|
||||
|
||||
**职责**:
|
||||
- 定义插件接口
|
||||
- 插件注册表
|
||||
|
||||
**核心类**:
|
||||
|
||||
| 类名 | 说明 |
|
||||
|------|------|
|
||||
| PluginType | 插件类型枚举 |
|
||||
| PluginBase | 插件基类 |
|
||||
| MessageHandlerPlugin | 消息处理插件基类 |
|
||||
| DataSourcePlugin | 数据源插件基类 |
|
||||
| ActionTriggerPlugin | 动作触发插件基类 |
|
||||
| AIAgentPlugin | AI 智能体插件基类 |
|
||||
| PluginRegistry | 插件注册表 |
|
||||
|
||||
**PluginType 枚举值**:
|
||||
- MESSAGE_HANDLER = "message_handler"
|
||||
- DATA_SOURCE = "data_source"
|
||||
- ACTION_TRIGGER = "action_trigger"
|
||||
- AI_AGENT = "ai_agent"
|
||||
- CUSTOM = "custom"
|
||||
|
||||
---
|
||||
|
||||
#### plugins/builtin.py - 内置插件
|
||||
|
||||
**文件路径**:[builtin.py](../../wxauto_center/plugins/builtin.py)
|
||||
|
||||
**内置插件**:
|
||||
|
||||
| 插件名 | 类名 | 说明 |
|
||||
|--------|------|------|
|
||||
| http_data_source | HTTPDataSourcePlugin | HTTP 数据源插件,从外部 API 获取数据 |
|
||||
| database_query | DatabaseQueryPlugin | 数据库查询插件 (预留) |
|
||||
| webhook_trigger | WebhookTriggerPlugin | Webhook 触发器 |
|
||||
| simple_ai_agent | SimpleAIAgentPlugin | 简单 AI 智能体 |
|
||||
|
||||
**注册函数**:`register_builtin_plugins()`
|
||||
|
||||
---
|
||||
|
||||
### 2.5 配置层 (Config Layer)
|
||||
|
||||
#### config.py - 配置管理
|
||||
|
||||
**文件路径**:[config.py](../../wxauto_center/config.py)
|
||||
|
||||
**职责**:
|
||||
- 系统配置管理
|
||||
- 配置文件读写
|
||||
- 多环境支持
|
||||
|
||||
**核心配置类**:
|
||||
|
||||
| 类名 | 说明 |
|
||||
|------|------|
|
||||
| NodeConfig | 节点配置 |
|
||||
| PluginConfig | 插件配置 |
|
||||
| WebhookConfig | Webhook 配置 |
|
||||
| MonitorConfig | 监控配置 |
|
||||
| Settings | 应用设置 (继承 BaseSettings) |
|
||||
| ConfigManager | 配置管理器 |
|
||||
|
||||
**主要函数**:
|
||||
|
||||
| 函数名 | 说明 |
|
||||
|--------|------|
|
||||
| get_settings() | 获取全局设置单例 |
|
||||
| update_settings() | 更新设置 |
|
||||
| load_config_from_file() | 从文件加载配置 |
|
||||
| save_config_to_file() | 保存配置到文件 |
|
||||
|
||||
**ConfigManager 方法**:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| get_node() | 获取节点配置 |
|
||||
| get_all_nodes() | 获取所有节点配置 |
|
||||
| get_enabled_nodes() | 获取已启用节点配置 |
|
||||
| add_node() | 添加节点配置 |
|
||||
| update_node() | 更新节点配置 |
|
||||
| remove_node() | 删除节点配置 |
|
||||
| save_config() | 保存配置 |
|
||||
| load_config() | 加载配置 |
|
||||
|
||||
---
|
||||
|
||||
### 2.6 入口 (Main)
|
||||
|
||||
#### main.py - 应用入口
|
||||
|
||||
**文件路径**:[main.py](../../wxauto_center/main.py)
|
||||
|
||||
**职责**:
|
||||
- FastAPI 应用初始化
|
||||
- 中间件配置
|
||||
- 路由注册
|
||||
- 启动/关闭事件处理
|
||||
- 配置管理 API
|
||||
- 监控状态 API
|
||||
- 日志流 API
|
||||
|
||||
---
|
||||
|
||||
## 3. 模块依赖关系
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ main.py │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ config │ │ api/* │ │ services│
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ┌───────────┐ ┌─────────┐
|
||||
│ │ utils/* │ │plugins/*│
|
||||
│ └───────────┘ └─────────┘
|
||||
│ │ │
|
||||
└─────────────────┼─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ external │
|
||||
│ (WXAuto-HTTP │
|
||||
│ -API) │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
**依赖详解**:
|
||||
|
||||
| 模块 | 被依赖 | 依赖 |
|
||||
|------|--------|------|
|
||||
| main.py | - | config, api, services, plugins |
|
||||
| config.py | api, services, main | pydantic, pydantic_settings |
|
||||
| api/* | main | services, plugins, utils, config |
|
||||
| services/* | api | config, log_service |
|
||||
| utils/* | api | config |
|
||||
| plugins/* | api, main | httpx |
|
||||
| log_service | services | - |
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码导览
|
||||
|
||||
### 4.1 新增代码路径
|
||||
|
||||
重构后新增的公共模块:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [utils/__init__.py](../../wxauto_center/utils/__init__.py) | 工具模块导出 |
|
||||
| [utils/common.py](../../wxauto_center/utils/common.py) | API Key 验证依赖 |
|
||||
| [utils/helpers.py](../../wxauto_center/utils/helpers.py) | 业务辅助函数 |
|
||||
|
||||
### 4.2 重构变更
|
||||
|
||||
#### 删除的重复代码
|
||||
|
||||
以下文件中删除了重复定义的 `require_api_key` 函数:
|
||||
|
||||
- `api/nodes.py` - 现在从 `utils` 导入
|
||||
- `api/messages.py` - 现在从 `utils` 导入
|
||||
- `api/external.py` - 现在从 `utils` 导入
|
||||
- `api/plugins.py` - 现在从 `utils` 导入
|
||||
- `api/callback.py` - 现在从 `utils` 导入
|
||||
|
||||
#### 重构的方法
|
||||
|
||||
| 文件 | 原方法 | 新方法 |
|
||||
|------|--------|--------|
|
||||
| services/node_manager.py | send_message() 和 send_message_to_group() 重复代码 | 新增 _send_message_internal() 内部方法 |
|
||||
| api/callback.py | reset_node_wechat_status() 和 check_node_wechat_status() 重复逻辑 | 新增 _check_wechat_and_update_status() 公共函数 |
|
||||
|
||||
### 4.3 代码审计指南
|
||||
|
||||
#### 审计要点
|
||||
|
||||
1. **API Key 验证**
|
||||
- 所有 API 端点必须使用 `Depends(require_api_key)` 或 `Depends(require_external_key)`
|
||||
- 检查 `X-API-Key` 和 `X-ExternalKey` 是否正确使用
|
||||
|
||||
2. **节点健康检查**
|
||||
- 发送消息前必须调用 `check_node_health()`
|
||||
- 使用 `utils.check_node_health` 而非直接调用 `node_manager.is_node_healthy`
|
||||
|
||||
3. **配置保存**
|
||||
- 修改节点配置后必须调用 `save_nodes_config()` 保存
|
||||
- 修改全局配置后必须调用 `save_config_to_file()` 保存
|
||||
|
||||
4. **错误处理**
|
||||
- 使用 `build_success_response()` 和 `build_error_response()` 构建响应
|
||||
- 节点不存在时抛出 `HTTPException(status_code=404)`
|
||||
|
||||
5. **异步处理**
|
||||
- 所有 `node_manager` 方法都是异步的,使用 `await`
|
||||
- API 端点需要 `async def`
|
||||
|
||||
---
|
||||
|
||||
## 5. 模块索引
|
||||
|
||||
### 5.1 文件快速查找
|
||||
|
||||
| 功能 | 文件路径 |
|
||||
|------|----------|
|
||||
| API Key 验证 | [utils/common.py](../../wxauto_center/utils/common.py) |
|
||||
| 节点健康检查 | [utils/helpers.py](../../wxauto_center/utils/helpers.py) |
|
||||
| 节点管理 | [services/node_manager.py](../../wxauto_center/services/node_manager.py) |
|
||||
| 状态监控 | [services/monitor_service.py](../../wxauto_center/services/monitor_service.py) |
|
||||
| 日志服务 | [services/log_service.py](../../wxauto_center/services/log_service.py) |
|
||||
| 节点 API | [api/nodes.py](../../wxauto_center/api/nodes.py) |
|
||||
| 消息 API | [api/messages.py](../../wxauto_center/api/messages.py) |
|
||||
| 外部 API | [api/external.py](../../wxauto_center/api/external.py) |
|
||||
| 插件 API | [api/plugins.py](../../wxauto_center/api/plugins.py) |
|
||||
| 回调 API | [api/callback.py](../../wxauto_center/api/callback.py) |
|
||||
| 插件基类 | [plugins/base.py](../../wxauto_center/plugins/base.py) |
|
||||
| 内置插件 | [plugins/builtin.py](../../wxauto_center/plugins/builtin.py) |
|
||||
| 配置管理 | [config.py](../../wxauto_center/config.py) |
|
||||
| 应用入口 | [main.py](../../wxauto_center/main.py) |
|
||||
|
||||
### 5.2 类和函数查找
|
||||
|
||||
| 类/函数 | 文件 | 行号 |
|
||||
|---------|------|------|
|
||||
| NodeStatus | [node_manager.py](../../wxauto_center/services/node_manager.py#L15) | L15 |
|
||||
| Node | [node_manager.py](../../wxauto_center/services/node_manager.py#L21) | L21 |
|
||||
| NodeManager | [node_manager.py](../../wxauto_center/services/node_manager.py#L46) | L46 |
|
||||
| MonitorService | [monitor_service.py](../../wxauto_center/services/monitor_service.py#L18) | L18 |
|
||||
| LogService | [log_service.py](../../wxauto_center/services/log_service.py#L14) | L14 |
|
||||
| PluginBase | [base.py](../../wxauto_center/plugins/base.py#L21) | L21 |
|
||||
| PluginRegistry | [base.py](../../wxauto_center/plugins/base.py#L92) | L92 |
|
||||
| require_api_key | [common.py](../../wxauto_center/utils/common.py#L10) | L10 |
|
||||
| require_external_key | [common.py](../../wxauto_center/utils/common.py#L30) | L30 |
|
||||
| check_node_health | [helpers.py](../../wxauto_center/utils/helpers.py#L20) | L20 |
|
||||
| ConfigManager | [config.py](../../wxauto_center/config.py#L148) | L148 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 更新日志
|
||||
|
||||
| 日期 | 更新内容 |
|
||||
|------|----------|
|
||||
| 2026-04-05 | 更新 API 路径,将 node_name 替换为 node_id |
|
||||
| 2026-04-05 | 重构代码,新增 utils 模块,抽取公共函数 |
|
||||
| 2026-04-05 | 新增 MODULES.md 模块索引文档 |
|
||||
| 2026-04-05 | 更新 API.md,添加回调接口文档 |
|
||||
| 2026-04-05 | 更新 ARCHITECTURE.md,添加回调流程图和微信状态机 |
|
||||
| 2026-04-05 | 新增 QueueService 消息队列服务模块 |
|
||||
| 2026-04-05 | 更新 LogService 支持 Redis 缓存和文件持久化 |
|
||||
| 2026-04-05 | 移除广播功能相关文档 |
|
||||
@@ -0,0 +1,615 @@
|
||||
# ///
|
||||
# PLUGIN.md
|
||||
# 描述:插件系统开发指南
|
||||
# 作者:User
|
||||
# 创建日期:2026-04-05
|
||||
# 更新日期:2026-04-07
|
||||
# ///
|
||||
|
||||
# WXAuto Center 插件系统开发指南
|
||||
|
||||
## 目录
|
||||
|
||||
1. [概述](#概述)
|
||||
2. [插件类型](#插件类型)
|
||||
3. [创建插件](#创建插件)
|
||||
4. [表单化配置系统](#表单化配置系统)
|
||||
5. [插件通知系统](#插件通知系统)
|
||||
6. [热加载插件](#热加载插件)
|
||||
7. [内置插件](#内置插件)
|
||||
8. [高级多节点推送插件](#高级多节点推送插件)
|
||||
9. [插件管理API](#插件管理api)
|
||||
10. [前端使用](#前端使用)
|
||||
11. [最佳实践](#最佳实践)
|
||||
12. [目录结构](#目录结构)
|
||||
13. [生命周期](#生命周期)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
WXAuto Center 采用模块化插件架构,支持热插拔功能扩展。开发者可以创建自定义插件来处理消息、数据源、定时任务等。
|
||||
|
||||
### 核心组件
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| `PluginBase` | 所有插件的基类 |
|
||||
| `ScheduledTaskPlugin` | 定时任务插件基类(含通知功能) |
|
||||
| `PluginRegistry` | 插件注册表,管理所有插件 |
|
||||
| `PluginType` | 插件类型枚举 |
|
||||
| `PluginConfigSchema` | 表单化配置Schema系统 |
|
||||
| `HotReloadPluginLoader` | 热加载服务,监控插件目录变化 |
|
||||
| `plugin_notification_service` | 插件通知服务 |
|
||||
|
||||
---
|
||||
|
||||
## 插件类型
|
||||
|
||||
| 类型 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| `MESSAGE_HANDLER` | `message_handler` | 消息处理器,用于自动回复、消息过滤 |
|
||||
| `DATA_SOURCE` | `data_source` | 数据源,用于获取天气、新闻、股票等 |
|
||||
| `ACTION_TRIGGER` | `action_trigger` | 动作触发器,用于Webhook回调、定时任务 |
|
||||
| `AI_AGENT` | `ai_agent` | AI智能体,用于LLM对话、智能回复 |
|
||||
| `SCHEDULED_TASK` | `scheduled_task` | 定时任务,执行周期性操作 |
|
||||
| `HTTP_SCHEDULED_SENDER` | `http_scheduled_sender` | 定时HTTP推送,定时请求API并发送结果 |
|
||||
| `CUSTOM` | `custom` | 自定义类型 |
|
||||
|
||||
---
|
||||
|
||||
## 创建插件
|
||||
|
||||
### 基础结构
|
||||
|
||||
```python
|
||||
# ///
|
||||
# my_plugin.py
|
||||
# 描述:我的自定义插件
|
||||
# ///
|
||||
|
||||
from plugins.base import PluginBase, PluginType
|
||||
|
||||
class MyPlugin(PluginBase):
|
||||
plugin_name = "my_plugin"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_type = PluginType.CUSTOM
|
||||
plugin_description = "我的自定义插件"
|
||||
plugin_author = "开发者名称"
|
||||
|
||||
def initialize(self, config: dict) -> bool:
|
||||
self.config = config
|
||||
return True
|
||||
|
||||
def execute(self, params: dict) -> dict:
|
||||
return {"success": True, "result": "done"}
|
||||
```
|
||||
|
||||
### 定时任务插件
|
||||
|
||||
```python
|
||||
# ///
|
||||
# my_scheduled_plugin.py
|
||||
# 描述:定时任务插件示例
|
||||
# ///
|
||||
|
||||
from plugins.base import ScheduledTaskPlugin
|
||||
from plugins.plugin_config_schema import (
|
||||
PluginConfigSchema, ConfigSection, ConfigField, FieldType
|
||||
)
|
||||
|
||||
def get_config_schema() -> PluginConfigSchema:
|
||||
schema = PluginConfigSchema("my_scheduled_plugin")
|
||||
schema.add_section(ConfigSection(
|
||||
name="basic",
|
||||
label="基本设置",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="cron",
|
||||
label="执行周期",
|
||||
field_type=FieldType.CRON,
|
||||
default="*/5 * * * *"
|
||||
),
|
||||
ConfigField(
|
||||
name="enabled",
|
||||
label="启用插件",
|
||||
field_type=FieldType.BOOLEAN,
|
||||
default=False
|
||||
)
|
||||
]
|
||||
))
|
||||
return schema
|
||||
|
||||
class MyScheduledPlugin(ScheduledTaskPlugin):
|
||||
plugin_name = "my_scheduled_plugin"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_description = "定时任务插件"
|
||||
plugin_author = "开发者"
|
||||
config_schema = staticmethod(get_config_schema)
|
||||
|
||||
def get_cron_expression(self) -> str:
|
||||
return self.cron_expression
|
||||
|
||||
def should_run_and_get_next(self) -> tuple:
|
||||
return True, None
|
||||
|
||||
def run_task(self) -> dict:
|
||||
self.notify("任务完成", "定时任务执行成功")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 表单化配置系统
|
||||
|
||||
### 字段类型
|
||||
|
||||
| 类型 | 值 | 说明 | 对应UI |
|
||||
|------|-----|------|--------|
|
||||
| `STRING` | `string` | 文本输入 | input[type=text] |
|
||||
| `PASSWORD` | `password` | 密码输入 | input[type=password] |
|
||||
| `NUMBER` | `number` | 数字输入 | input[type=number] |
|
||||
| `BOOLEAN` | `boolean` | 开关 | checkbox |
|
||||
| `SELECT` | `select` | 下拉选择 | select |
|
||||
| `TEXTAREA` | `textarea` | 多行文本 | textarea |
|
||||
| `CRON` | `cron` | Cron表达式 | input[type=text] |
|
||||
|
||||
### 配置示例
|
||||
|
||||
```python
|
||||
from plugins.plugin_config_schema import (
|
||||
PluginConfigSchema, ConfigSection, ConfigField, FieldType
|
||||
)
|
||||
|
||||
def get_config_schema() -> PluginConfigSchema:
|
||||
schema = PluginConfigSchema("my_plugin")
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="basic",
|
||||
label="基本设置",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="cron",
|
||||
label="执行周期",
|
||||
field_type=FieldType.CRON,
|
||||
required=True,
|
||||
default="*/5 * * * *",
|
||||
description="Cron表达式,如 */5 * * * *"
|
||||
),
|
||||
ConfigField(
|
||||
name="node_id",
|
||||
label="节点ID",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
default="wx1"
|
||||
),
|
||||
ConfigField(
|
||||
name="receiver",
|
||||
label="接收人",
|
||||
field_type=FieldType.STRING,
|
||||
required=True
|
||||
),
|
||||
ConfigField(
|
||||
name="timeout",
|
||||
label="超时时间",
|
||||
field_type=FieldType.NUMBER,
|
||||
default=15,
|
||||
min_value=5,
|
||||
max_value=60
|
||||
),
|
||||
ConfigField(
|
||||
name="enabled",
|
||||
label="启用插件",
|
||||
field_type=FieldType.BOOLEAN,
|
||||
default=False
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
return schema
|
||||
```
|
||||
|
||||
### API 调用
|
||||
|
||||
```bash
|
||||
# 获取插件表单Schema
|
||||
curl http://localhost:8080/api/v1/plugins/{plugin_name}/schema \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
|
||||
# 响应示例
|
||||
{
|
||||
"success": true,
|
||||
"schema": {
|
||||
"plugin_name": "my_plugin",
|
||||
"sections": [
|
||||
{
|
||||
"name": "basic",
|
||||
"label": "基本设置",
|
||||
"fields": [
|
||||
{"name": "cron", "label": "执行周期", "type": "cron", ...}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"has_form": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 插件通知系统
|
||||
|
||||
所有继承 `ScheduledTaskPlugin` 的插件都可以使用通知功能。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```python
|
||||
# 普通通知
|
||||
self.notify(title, message, level)
|
||||
|
||||
# 快捷方法
|
||||
self.notify_error(title, message) # 错误通知
|
||||
self.notify_warning(title, message) # 警告通知
|
||||
self.notify_success(title, message) # 成功通知
|
||||
```
|
||||
|
||||
### 配置接收
|
||||
|
||||
在告警配置中添加 Webhook,勾选 **"插件通知"** 事件类型:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/v1/webhook/" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "插件通知",
|
||||
"url": "https://api.day.app/your-bark-key",
|
||||
"format": "bark",
|
||||
"event_types": ["plugin_notification"]
|
||||
}'
|
||||
```
|
||||
|
||||
### 事件类型
|
||||
|
||||
| 事件类型 | 说明 |
|
||||
|----------|------|
|
||||
| `api_error` | API错误 |
|
||||
| `wechat_offline` | 微信离线 |
|
||||
| `wechat_online` | 微信上线 |
|
||||
| `node_offline` | 节点离线 |
|
||||
| `node_online` | 节点上线 |
|
||||
| `plugin_notification` | 插件通知 |
|
||||
|
||||
---
|
||||
|
||||
## 热加载插件
|
||||
|
||||
### 工作原理
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HotReloadPluginLoader (独立线程) │
|
||||
│ │
|
||||
│ 1. watchdog 监控 /app/plugins/custom 目录 │
|
||||
│ 2. 检测到 .py 文件变化 → 自动重新加载 │
|
||||
│ 3. importlib 动态导入模块 │
|
||||
│ 4. 自动注册到 PluginRegistry │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 插件目录
|
||||
|
||||
- **Docker 内路径**: `/app/plugins/custom`
|
||||
- **宿主机路径**: `./plugins/custom`
|
||||
|
||||
### 挂载配置
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
volumes:
|
||||
- ./plugins/custom:/app/plugins/custom # 双向同步
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级多节点推送插件
|
||||
|
||||
### 简介
|
||||
|
||||
`advanced_sender` 是高级多节点数据推送插件,支持:
|
||||
- 多数据源独立配置
|
||||
- 每数据源独立节点和接收人设置
|
||||
- 外部 MySQL 数据库去重
|
||||
- 班次时段路由
|
||||
- Cookie 失效检测和通知
|
||||
- 熔断保护机制
|
||||
|
||||
### 表单配置
|
||||
|
||||
```
|
||||
默认设置(可被数据源覆盖)
|
||||
├── 默认节点ID: wx1
|
||||
└── 默认接收人: asq
|
||||
|
||||
数据源1
|
||||
├── Cookie: [文本框]
|
||||
├── 节点ID: [留空使用默认]
|
||||
└── 接收人: [留空使用默认]
|
||||
|
||||
数据源2
|
||||
├── Cookie: [文本框]
|
||||
├── 节点ID: [留空使用默认]
|
||||
└── 接收人: [留空使用默认]
|
||||
|
||||
外部数据库
|
||||
├── 数据库地址: 192.168.2.27
|
||||
├── 端口: 3306
|
||||
├── 用户名: root2
|
||||
├── 密码: ****
|
||||
├── 数据库名: addb
|
||||
└── 表名: user_data
|
||||
|
||||
抓取设置
|
||||
├── 请求超时(秒): 15
|
||||
└── 数据源间隔(秒): 20
|
||||
```
|
||||
|
||||
### 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `cron` | 执行周期 | `*/2 * * * *` |
|
||||
| `enable_cron` | 启用定时任务 | false |
|
||||
| `node_id` | 默认节点ID | wx1 |
|
||||
| `receiver` | 默认接收人 | asq |
|
||||
| `cookie_1` | 数据源1 Cookie | - |
|
||||
| `cookie_2` | 数据源2 Cookie | - |
|
||||
| `node_id_1` | 数据源1节点ID(留空用默认) | - |
|
||||
| `receiver_1` | 数据源1接收人(留空用默认) | - |
|
||||
| `node_id_2` | 数据源2节点ID(留空用默认) | - |
|
||||
| `receiver_2` | 数据源2接收人(留空用默认) | - |
|
||||
| `db_host` | 数据库地址 | 192.168.2.27 |
|
||||
| `db_port` | 端口 | 3306 |
|
||||
| `db_user` | 用户名 | root2 |
|
||||
| `db_password` | 密码 | root@root |
|
||||
| `db_name` | 数据库名 | addb |
|
||||
| `db_table` | 表名 | user_data |
|
||||
| `fetch_timeout` | 请求超时(秒) | 15 |
|
||||
| `send_interval` | 数据源间隔(秒) | 20 |
|
||||
|
||||
### 班次路由
|
||||
|
||||
| 时段 | 路由规则 |
|
||||
|------|----------|
|
||||
| 0-7点 | 跳过 |
|
||||
| 8-16点 | 发送 |
|
||||
| 17-21点 | 发送 |
|
||||
| 22-23点 | 跳过 |
|
||||
|
||||
### 操作
|
||||
|
||||
| action | 说明 |
|
||||
|--------|------|
|
||||
| `send` | 执行完整流程 |
|
||||
| `test` | 测试配置 |
|
||||
| `test_send` | 测试发送(可指定node_id和receiver) |
|
||||
| `query_pending` | 查询待发送数据 |
|
||||
|
||||
### 测试发送
|
||||
|
||||
```bash
|
||||
# 使用默认节点和接收人
|
||||
curl -X POST "http://localhost:8080/api/v1/plugins/advanced_sender/execute" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"action": "test_send", "msg": "测试消息"}'
|
||||
|
||||
# 指定节点和接收人
|
||||
curl -X POST "http://localhost:8080/api/v1/plugins/advanced_sender/execute" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"action": "test_send", "node_id": "wx1", "receiver": "asq", "msg": "测试"}'
|
||||
```
|
||||
|
||||
### 获取日志
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/api/v1/plugins/advanced_sender/logs?lines=100" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### Cookie 失效检测
|
||||
|
||||
- 当 HTTP 返回 403/401 时,判定为 Cookie 失效
|
||||
- 当数据源返回空数据时,判定为 Cookie 可能失效
|
||||
- 首次检测到问题发送告警通知
|
||||
- 恢复成功后发送成功通知
|
||||
- 后续执行不再重复报警
|
||||
|
||||
---
|
||||
|
||||
## 插件管理API
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET /api/v1/plugins/` | GET | 获取所有插件 |
|
||||
| `GET /api/v1/plugins/hotloaded` | GET | 获取热加载插件列表 |
|
||||
| `POST /api/v1/plugins/hotreload` | POST | 手动触发热重载 |
|
||||
| `GET /api/v1/plugins/{name}` | GET | 获取插件详情 |
|
||||
| `GET /api/v1/plugins/{name}/schema` | GET | 获取插件表单Schema |
|
||||
| `POST /api/v1/plugins/{name}/enable` | POST | 启用插件 |
|
||||
| `POST /api/v1/plugins/{name}/disable` | POST | 禁用插件 |
|
||||
| `PUT /api/v1/plugins/{name}/config` | PUT | 更新插件配置 |
|
||||
| `POST /api/v1/plugins/{name}/execute` | POST | 执行插件 |
|
||||
| `GET /api/v1/plugins/{name}/logs` | GET | 获取插件日志 |
|
||||
| `POST /api/v1/plugins/{name}/logs/clear` | POST | 清空插件日志 |
|
||||
|
||||
### 配置插件示例
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:8080/api/v1/plugins/advanced_sender/config" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "advanced_sender",
|
||||
"config": {
|
||||
"cron": "*/2 * * * *",
|
||||
"enable_cron": true,
|
||||
"node_id": "wx1",
|
||||
"receiver": "asq",
|
||||
"cookie_1": "PHPSESSID=xxx; think_var=zh-cn",
|
||||
"cookie_2": "PHPSESSID=yyy; think_var=zh-cn",
|
||||
"db_host": "192.168.2.27",
|
||||
"db_port": 3306,
|
||||
"db_user": "root2",
|
||||
"db_password": "root@root",
|
||||
"db_name": "addb",
|
||||
"db_table": "user_data"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 执行插件示例
|
||||
|
||||
```bash
|
||||
# 执行发送任务
|
||||
curl -X POST "http://localhost:8080/api/v1/plugins/advanced_sender/execute" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action": "send"}'
|
||||
|
||||
# 测试配置
|
||||
curl -X POST "http://localhost:8080/api/v1/plugins/advanced_sender/execute" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action": "test"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端使用
|
||||
|
||||
访问 **插件管理** 页面(侧边栏菜单):
|
||||
|
||||
1. **查看插件列表** - 显示所有已注册插件
|
||||
2. **启用/禁用** - 点击按钮控制插件状态
|
||||
3. **配置** - 点击配置按钮打开表单化配置界面
|
||||
4. **日志** - 点击日志按钮查看插件独立日志
|
||||
5. **执行** - 点击执行按钮测试插件
|
||||
|
||||
### 配置界面
|
||||
|
||||
- 表单化配置,无需手动编辑 JSON
|
||||
- Section 可折叠,方便管理
|
||||
- 支持所有字段类型(文本、数字、密码、开关、下拉等)
|
||||
- 自动验证必填项
|
||||
|
||||
### 日志界面
|
||||
|
||||
- 实时显示插件日志
|
||||
- 支持刷新
|
||||
- 日志文件保存在 `/app/data/logs/{plugin_name}.log`
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 使用表单化配置
|
||||
|
||||
```python
|
||||
from plugins.plugin_config_schema import (
|
||||
PluginConfigSchema, ConfigSection, ConfigField, FieldType
|
||||
)
|
||||
|
||||
def get_config_schema() -> PluginConfigSchema:
|
||||
schema = PluginConfigSchema("my_plugin")
|
||||
schema.add_section(ConfigSection(
|
||||
name="basic",
|
||||
label="基本设置",
|
||||
fields=[
|
||||
ConfigField(name="key", label="键", field_type=FieldType.STRING, required=True)
|
||||
]
|
||||
))
|
||||
return schema
|
||||
|
||||
class MyPlugin(ScheduledTaskPlugin):
|
||||
config_schema = staticmethod(get_config_schema)
|
||||
```
|
||||
|
||||
### 2. 使用通知功能
|
||||
|
||||
```python
|
||||
class MyPlugin(ScheduledTaskPlugin):
|
||||
def run_task(self):
|
||||
try:
|
||||
# 业务逻辑
|
||||
self.notify_success("任务完成", "发送了 10 条消息")
|
||||
except Exception as e:
|
||||
self.notify_error("任务失败", str(e))
|
||||
```
|
||||
|
||||
### 3. 错误处理
|
||||
|
||||
```python
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
return {"success": True, "data": result}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
```
|
||||
|
||||
### 4. 定时任务防重复
|
||||
|
||||
```python
|
||||
def run_task(self) -> Dict[str, Any]:
|
||||
if self._running:
|
||||
logger.warning("Previous task still running, skipping")
|
||||
return {"success": False, "error": "上一次执行尚未完成"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
wxauto_api/
|
||||
├── plugins/
|
||||
│ ├── __init__.py
|
||||
│ ├── base.py # 插件基类和注册表
|
||||
│ ├── builtin.py # 内置插件实现
|
||||
│ ├── plugin_loader.py # 热加载插件加载器
|
||||
│ ├── plugin_config_schema.py # 表单化配置系统
|
||||
│ ├── http_scheduled_sender.py # 定时HTTP推送插件
|
||||
│ ├── advanced_sender_plugin.py # 高级多节点推送插件
|
||||
│ └── custom/ # 自定义插件目录(热加载)
|
||||
│ ├── __init__.py
|
||||
│ └── my_plugin.py
|
||||
├── services/
|
||||
│ └── plugin_notification_service.py # 插件通知服务
|
||||
├── static/
|
||||
│ ├── js/
|
||||
│ │ └── app.js # 前端插件管理
|
||||
│ └── css/
|
||||
│ └── style.css
|
||||
└── doc/
|
||||
└── wxauto_center/
|
||||
└── PLUGIN.md # 本文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 生命周期
|
||||
|
||||
1. **注册** - `PluginRegistry.register(plugin)`
|
||||
2. **初始化** - `plugin.initialize(config)`
|
||||
3. **启用** - `plugin.enable()`
|
||||
4. **执行** - `plugin.execute(params)` 或 `plugin.run_task()`
|
||||
5. **禁用** - `plugin.disable()`
|
||||
6. **卸载** - `PluginRegistry.unregister(name)`
|
||||
|
||||
### 热加载流程
|
||||
|
||||
1. **启动** - `HotReloadPluginLoader.start()`
|
||||
2. **扫描** - 扫描 `/app/plugins/custom` 目录
|
||||
3. **监听** - `watchdog` 监控文件变化
|
||||
4. **加载** - `importlib` 动态导入模块
|
||||
5. **注册** - 自动注册到 `PluginRegistry`
|
||||
6. **停止** - `HotReloadPluginLoader.stop()`
|
||||
@@ -0,0 +1,286 @@
|
||||
# WXAuto Center 中控系统
|
||||
|
||||
## 项目简介
|
||||
|
||||
WXAuto Center 是一款基于 FastAPI + PostgreSQL + Redis 构建的多节点微信管理中控系统,为企业级微信自动化运营提供集中管理平台。该系统能够同时管理多个 WXAuto-HTTP-API 节点,实现消息统一发送、节点状态监控、实时日志查看和外部程序接入等功能。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- **多节点微信管理**:支持同时接入多个微信节点,按组分类管理
|
||||
- **节点标识系统**:每个节点拥有唯一的 `node_id`(如 "wx1", "node_001")和显示用的 `name`(如 "微信1", "测试节点")
|
||||
- **消息发送**:支持单发、群发、批量发送、模板消息等多种发送模式
|
||||
- **健康检测机制**:通过 `/api/wechat/initialize` 接口检测微信状态,确保消息发送前微信正常运行
|
||||
- **Webhook 告警**:支持企业微信(wechat)和 Bark 两种格式,节点/微信状态变化时自动通知
|
||||
- **实时日志**:通过 SSE(Server-Sent Events)技术实现日志实时推送,默认保留最近500条
|
||||
- **Redis 消息队列**:支持 Redis 后端的消息队列,用于异步消息处理
|
||||
- **多进程支持**:支持多 worker 部署,适应高并发场景
|
||||
- **外部程序接入**:提供标准 RESTful API,支持外部系统调用
|
||||
- **插件系统**:支持扩展插件,包括数据源、AI 智能体、Webhook触发器等
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Python 3.8+
|
||||
- Redis (用于消息队列和多进程场景)
|
||||
- FastAPI
|
||||
- httpx(异步 HTTP 客户端)
|
||||
- uvicorn(ASGI 服务器)
|
||||
- pydantic
|
||||
- pydantic-settings
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd wxauto_center
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 复制配置示例文件
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件,修改必要的配置项
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 单进程启动(默认)
|
||||
python run.py
|
||||
|
||||
# 多进程启动(需要 Redis)
|
||||
python run.py --workers 4
|
||||
|
||||
# 带参数启动
|
||||
python run.py --host 0.0.0.0 --port 8080 --workers 4
|
||||
|
||||
# 开发模式(支持热重载,单进程)
|
||||
python run.py --reload
|
||||
|
||||
# 调试模式
|
||||
python run.py --debug
|
||||
```
|
||||
|
||||
**替代启动方式**:
|
||||
|
||||
```bash
|
||||
# 使用 uvicorn 直接启动
|
||||
uvicorn main:app --host 0.0.0.0 --port 8080 --reload
|
||||
```
|
||||
|
||||
### 访问地址
|
||||
|
||||
- 主界面:http://localhost:8080/static/index.html
|
||||
- API 文档:http://localhost:8080/docs
|
||||
- ReDoc 文档:http://localhost:8080/redoc
|
||||
|
||||
### 快速使用示例
|
||||
|
||||
#### 1. 添加节点
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/nodes \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-d '{
|
||||
"node_id": "wx1",
|
||||
"name": "微信1",
|
||||
"api_url": "http://192.168.20.18:5000",
|
||||
"api_key": "your-node-api-key",
|
||||
"description": "生产节点",
|
||||
"group": "wechat"
|
||||
}'
|
||||
```
|
||||
|
||||
**节点标识说明**:
|
||||
- `node_id`:节点唯一标识,如 "wx1", "node_001",用于 API 调用
|
||||
- `name`:节点显示名称,如 "微信1", "测试节点",用于界面展示
|
||||
|
||||
#### 2. 发送消息
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/messages/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-d '{
|
||||
"node_id": "wx1",
|
||||
"who": "wxid_friend",
|
||||
"msg": "您好,这是一条测试消息",
|
||||
"msg_type": "text"
|
||||
}'
|
||||
```
|
||||
|
||||
#### 3. 查询节点状态
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/nodes/wx1/status \
|
||||
-H "X-API-Key: your-external-api-key"
|
||||
```
|
||||
|
||||
#### 4. 重置微信状态(当微信掉线时)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/callback/node/reset \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-external-api-key" \
|
||||
-d '{
|
||||
"node_id": "wx1"
|
||||
}'
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
wxauto_api/
|
||||
├── main.py # FastAPI 主入口
|
||||
├── run.py # 启动脚本
|
||||
├── config.py # 配置管理
|
||||
├── config.json # 应用配置(不含节点和 Webhook)
|
||||
├── .env # Docker 环境变量配置
|
||||
├── models.py # 数据模型(Node, LogEntry, WebhookUrl)
|
||||
├── database_service.py # 数据库服务
|
||||
├── api/ # API 路由模块
|
||||
│ ├── __init__.py
|
||||
│ ├── nodes.py # 节点管理 API
|
||||
│ ├── messages.py # 消息发送 API
|
||||
│ ├── external.py # 外部扩展接口
|
||||
│ ├── callback.py # 回调接口
|
||||
│ ├── plugins.py # 插件管理 API
|
||||
│ └── webhook.py # Webhook 管理 API
|
||||
├── services/ # 业务逻辑服务
|
||||
│ ├── node_manager.py # 节点管理器
|
||||
│ ├── monitor_service.py # 监控服务
|
||||
│ └── log_service.py # 日志服务
|
||||
├── plugins/ # 插件系统
|
||||
│ ├── base.py # 插件基类
|
||||
│ └── builtin.py # 内置插件
|
||||
├── static/ # Web 静态资源
|
||||
│ ├── index.html # 主页面
|
||||
│ ├── css/style.css # 样式表
|
||||
│ └── js/ # JavaScript 模块
|
||||
├── scripts/ # 脚本目录
|
||||
│ ├── start_amd64.sh # amd64 启动脚本
|
||||
│ ├── start_arm64.sh # arm64 启动脚本
|
||||
│ └── database/ # 数据库脚本
|
||||
├── doc/ # 文档目录
|
||||
│ └── wxauto_center/ # 项目文档
|
||||
├── data/ # 数据目录
|
||||
│ ├── db/ # PostgreSQL 数据
|
||||
│ ├── redis/ # Redis 数据
|
||||
│ └── logs/ # 应用日志
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
└── Dockerfile # Docker 构建文件
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
系统配置支持多种配置源,按优先级从高到低依次为:
|
||||
1. 环境变量(`.env` 文件)
|
||||
2. 配置文件(`config.json`)
|
||||
3. 代码默认值
|
||||
|
||||
### 配置文件
|
||||
|
||||
**config.json** - 应用配置(不含节点和 Webhook):
|
||||
|
||||
```json
|
||||
{
|
||||
"app_name": "WXAuto Center",
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080,
|
||||
"debug": true,
|
||||
"secret_key": "wxauto-center-secret-key-change-in-production",
|
||||
"workers": 1,
|
||||
"cors_origins": ["*"],
|
||||
"api_prefix": "/api/v1",
|
||||
"external_api_key": "your-external-api-key",
|
||||
"monitor": {
|
||||
"enabled": true,
|
||||
"check_interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**.env** - Docker 环境变量配置:
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://wxauto:wxauto_password@postgres:5432/wxauto
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
APP_NAME=WXAuto Center
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
DEBUG=true
|
||||
SECRET_KEY=wxauto-center-secret-key-change-in-production
|
||||
WORKERS=1
|
||||
API_PREFIX=/api/v1
|
||||
EXTERNAL_API_KEY=your-external-api-key
|
||||
CORS_ORIGINS=["*"]
|
||||
MONITOR_ENABLED=true
|
||||
MONITOR_CHECK_INTERVAL=30
|
||||
```
|
||||
|
||||
### 节点配置
|
||||
|
||||
节点配置存储在 PostgreSQL 的 `nodes` 表中,运行时从数据库加载。详细说明请参阅 [CONFIG.md](./CONFIG.md)。
|
||||
|
||||
### Webhook 配置
|
||||
|
||||
Webhook 配置存储在 PostgreSQL 的 `webhook_urls` 表中,支持企业微信和 Bark 两种格式。详细说明请参阅 [CONFIG.md](./CONFIG.md)。
|
||||
|
||||
## API 文档
|
||||
|
||||
系统提供完整的 RESTful API,包括:
|
||||
|
||||
- **节点管理**:`/api/v1/nodes` - 节点的添加、删除、启用/禁用、状态检测
|
||||
- **消息发送**:`/api/v1/messages` - 单发、群发、批量发送、模板消息
|
||||
- **外部接口**:`/api/v1/external` - 供外部程序调用的接口
|
||||
- **回调接口**:`/api/v1/callback` - 节点状态重置、状态检测
|
||||
- **插件管理**:`/api/v1/plugins` - 插件的注册和调用
|
||||
- **配置管理**:`/api/v1/config/webhook`, `/api/v1/config/monitor` - Webhook和监控配置
|
||||
- **监控状态**:`/api/v1/monitor/status` - 实时监控状态
|
||||
- **日志服务**:`/api/v1/logs/*` - 日志查询和实时推送(SSE)
|
||||
|
||||
详细 API 说明请参阅 [API.md](./API.md)
|
||||
|
||||
## 业务流程
|
||||
|
||||
系统主要业务流程包括消息发送流程、回调重置流程和监控告警流程。详细说明请参阅 [FLOWS.md](./FLOWS.md)。
|
||||
|
||||
## 开发指南
|
||||
|
||||
如需进行二次开发或扩展功能,请参阅 [DEVELOPMENT.md](./DEVELOPMENT.md),其中包含:
|
||||
|
||||
- 开发环境搭建
|
||||
- 代码结构说明
|
||||
- 插件开发指南
|
||||
- API 调用示例
|
||||
|
||||
## 常见问题
|
||||
|
||||
如遇到问题,请参阅 [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)
|
||||
|
||||
## 核心设计理念
|
||||
|
||||
### 1. 节点标识系统
|
||||
|
||||
每个节点有两个标识:
|
||||
- `node_id`:节点唯一标识符,用于 API 调用和系统内部引用,示例:"wx1", "node_001"
|
||||
- `name`:节点显示名称,用于界面展示和用户友好提示,示例:"微信1", "测试节点"
|
||||
|
||||
### 2. 健康检测机制
|
||||
|
||||
每次发送消息前,系统会自动调用 `/api/wechat/initialize` 接口初始化微信,确保微信处于可发送消息的状态。这是官方推荐的正确流程。
|
||||
|
||||
### 3. 监控告警
|
||||
|
||||
监控服务定时检测所有节点和微信状态,当状态发生变化时自动触发Webhook通知。支持的事件类型包括:api_error、wechat_offline、wechat_online、node_offline、node_online。
|
||||
|
||||
### 4. 日志服务
|
||||
|
||||
采用内存存储+发布订阅模式,支持SSE实时推送。默认保留最近500条日志,足够应对日常调试和监控需求。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目仅供学习交流使用,请勿用于违规用途。
|
||||
@@ -0,0 +1,619 @@
|
||||
# WXAuto Center 常见问题与解决方案
|
||||
|
||||
## 概述
|
||||
|
||||
本文档收集了 WXAuto Center 使用过程中可能遇到的常见问题及其解决方案。
|
||||
|
||||
---
|
||||
|
||||
## 1. 服务启动问题
|
||||
|
||||
### 1.1 端口被占用
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```
|
||||
Error: [Errno 48] Address already in use
|
||||
```
|
||||
|
||||
**可能原因**:
|
||||
- 另一个进程正在使用 8080 端口
|
||||
- 上一次启动的服务未正常关闭
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 查找占用端口的进程
|
||||
lsof -i :8080 # macOS
|
||||
# 或
|
||||
netstat -tlnp | grep 8080 # Linux
|
||||
|
||||
# 终止占用进程
|
||||
kill -9 <PID>
|
||||
|
||||
# 或使用其他端口启动
|
||||
# 修改 config.json 中 port 为其他值,如 8081
|
||||
```
|
||||
|
||||
### 1.2 依赖库安装失败
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'fastapi'
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 确保使用 Python 3.8+
|
||||
python --version
|
||||
|
||||
# 升级 pip
|
||||
pip install --upgrade pip
|
||||
|
||||
# 重新安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 或使用国内镜像
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
```
|
||||
|
||||
### 1.3 配置文件格式错误
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```
|
||||
JSONDecodeError: Expecting property name enclosed in double quotes
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
检查 `config.json` 文件是否为有效的 JSON 格式:
|
||||
|
||||
```bash
|
||||
# 验证 JSON 格式
|
||||
python -c "import json; json.load(open('config.json'))"
|
||||
|
||||
# 或使用在线 JSON 验证工具
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. API 认证问题
|
||||
|
||||
### 2.1 API Key 无效
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Invalid API Key"
|
||||
}
|
||||
```
|
||||
|
||||
**可能原因**:
|
||||
- 请求时未提供 `X-API-Key` Header
|
||||
- 提供的 API Key 与配置不匹配
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确认在请求头中添加了正确的 Header:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: your-external-api-key" http://localhost:8080/api/v1/nodes
|
||||
```
|
||||
|
||||
2. 检查 `config.json` 或 `.env` 中的 `external_api_key` 配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"external_api_key": "your-external-api-key"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 CORS 跨域问题
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin' header is present on the requested resource
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 在 `config.json` 中配置允许的来源:
|
||||
|
||||
```json
|
||||
{
|
||||
"cors_origins": ["http://localhost:3000", "https://your-domain.com"]
|
||||
}
|
||||
```
|
||||
|
||||
2. 或设置 `cors_origins": ["*"]`(仅开发环境)
|
||||
|
||||
---
|
||||
|
||||
## 3. 节点管理问题
|
||||
|
||||
### 3.1 节点添加失败
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Node 'xxx' already exists"
|
||||
}
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
- 节点名称具有唯一性,不能重复
|
||||
- 使用不同的名称添加节点
|
||||
- 或先删除已有节点后再添加
|
||||
|
||||
```bash
|
||||
# 删除节点
|
||||
curl -X DELETE http://localhost:8080/api/v1/nodes/node1 \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 3.2 节点连接失败
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"is_healthy": false,
|
||||
"health_message": "API status: error"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**可能原因**:
|
||||
- 节点服务未启动
|
||||
- API URL 配置错误
|
||||
- 网络不通
|
||||
- API Key 不正确
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确认节点服务正在运行:
|
||||
|
||||
```bash
|
||||
# 在节点服务器上检查
|
||||
curl http://节点IP:5000/health
|
||||
```
|
||||
|
||||
2. 检查节点配置:
|
||||
|
||||
```bash
|
||||
# 获取节点信息
|
||||
curl http://localhost:8080/api/v1/nodes/node1 \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
3. 测试节点 API 连通性:
|
||||
|
||||
```bash
|
||||
# 直接测试节点 API
|
||||
curl -X POST http://节点IP:5000/api/wechat/initialize \
|
||||
-H "X-API-Key: 节点API密钥"
|
||||
```
|
||||
|
||||
### 3.3 节点健康检测失败
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```json
|
||||
{
|
||||
"health_message": "WeChat error: 无效"
|
||||
}
|
||||
```
|
||||
|
||||
**可能原因**:
|
||||
- 微信窗口未打开或已关闭
|
||||
- 微信版本不兼容
|
||||
- WXAuto-HTTP-API 服务异常
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 在对应节点上手动初始化微信:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/nodes/node1/wechat/initialize \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
2. 检查节点服务器的微信客户端是否正常运行
|
||||
|
||||
3. 重启节点服务
|
||||
|
||||
---
|
||||
|
||||
## 4. 消息发送问题
|
||||
|
||||
### 4.1 消息发送失败 - 节点不健康
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Node 'node1' is not healthy: WeChat check failed. Message sending failed."
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP 状态码**:503
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 先检查节点状态:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/nodes/node1/status \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
2. 确保微信客户端在节点机器上正常运行
|
||||
|
||||
3. 手动初始化微信:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/nodes/node1/wechat/initialize \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 4.2 消息发送失败 - 接收者不存在
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"code": 1,
|
||||
"message": "receiver not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确认接收者的 wxid 或群ID 是否正确
|
||||
2. 获取好友/群列表确认:
|
||||
|
||||
```bash
|
||||
# 获取好友列表
|
||||
curl http://localhost:8080/api/v1/nodes/node1/friends \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
# 获取群列表
|
||||
curl http://localhost:8080/api/v1/nodes/node1/groups \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 4.3 消息类型不支持
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"code": 1,
|
||||
"message": "unsupported message type"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
目前系统仅支持 `text` 类型消息,消息发送时指定 `msg_type`:
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"who": "wxid_friend",
|
||||
"msg": "Hello!",
|
||||
"msg_type": "text"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Webhook 问题
|
||||
|
||||
### 5.1 Webhook 通知发送失败
|
||||
|
||||
**问题描述**:
|
||||
|
||||
日志中出现 `Webhook通知发送失败` 相关信息。
|
||||
|
||||
**可能原因**:
|
||||
- Webhook URL 配置错误
|
||||
- 网络不通
|
||||
- Webhook 服务不可用
|
||||
- 消息格式不被接受
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查 Webhook 配置:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/config/webhook \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
2. 测试 Webhook URL 是否可用:
|
||||
|
||||
```bash
|
||||
# 测试企业微信 Webhook
|
||||
curl -X POST "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"msgtype": "text", "text": {"content": "test"}}'
|
||||
```
|
||||
|
||||
3. 检查 Webhook 事件类型配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_types": ["api_error", "wechat_offline", "wechat_online", "node_offline", "node_online"]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Bark 通知格式问题
|
||||
|
||||
**问题描述**:
|
||||
|
||||
Bark 格式的 Webhook 发送失败。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
确保 Webhook URL 正确,格式为:`https://api.day.app/your-bark-id`
|
||||
|
||||
---
|
||||
|
||||
## 6. 监控服务问题
|
||||
|
||||
### 6.1 监控服务未启动
|
||||
|
||||
**问题描述**:
|
||||
|
||||
日志中没有看到监控服务的输出,状态变化不触发 Webhook。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查监控配置:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/config/monitor \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
2. 确保 `enabled` 为 `true`
|
||||
|
||||
3. 检查监控状态:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/monitor/status \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 6.2 监控检测间隔过长
|
||||
|
||||
**问题描述**:
|
||||
|
||||
状态变化后很久才收到 Webhook 通知。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
调整 `check_interval` 配置:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/config/monitor \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"enabled": true,
|
||||
"check_interval": 30
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 日志问题
|
||||
|
||||
### 7.1 SSE 日志流断开
|
||||
|
||||
**问题描述**:
|
||||
|
||||
前端日志流连接经常断开。
|
||||
|
||||
**可能原因**:
|
||||
- 网络问题
|
||||
- 服务器负载过高
|
||||
- 客户端处理不及时
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查网络连接
|
||||
2. 实现断线重连机制:
|
||||
|
||||
```javascript
|
||||
function connectLogStream() {
|
||||
const eventSource = new EventSource('/api/v1/logs/stream');
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const log = JSON.parse(event.data);
|
||||
// 处理日志
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.log('Connection lost, reconnecting...');
|
||||
setTimeout(connectLogStream, 3000);
|
||||
};
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 日志丢失
|
||||
|
||||
**问题描述**:
|
||||
|
||||
日志数量超过 500 条后,早期的日志被清除。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
这是系统设计行为,`LogService` 默认只保留最近 500 条日志。如需更多日志存储,可以:
|
||||
|
||||
1. 修改 `log_service.py` 中的 `max_logs` 参数
|
||||
2. 实现日志持久化存储(写入文件或数据库)
|
||||
|
||||
---
|
||||
|
||||
## 8. 插件问题
|
||||
|
||||
### 8.1 插件未注册
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Plugin 'xxx' not found"
|
||||
}
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查插件是否已注册:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/plugins \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
2. 确认内置插件是否在启动时正确注册(检查 `builtin.py`)
|
||||
|
||||
### 8.2 插件执行失败
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Plugin execution failed"
|
||||
}
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查插件配置是否正确
|
||||
2. 查看系统日志获取详细错误信息
|
||||
3. 测试插件 API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/external/plugin/execute \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"plugin_name": "plugin_name",
|
||||
"action": "execute",
|
||||
"params": {}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 性能问题
|
||||
|
||||
### 9.1 高并发下响应慢
|
||||
|
||||
**可能原因**:
|
||||
- 节点数量过多
|
||||
- 网络延迟
|
||||
- 同步阻塞操作
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 增加监控检测间隔
|
||||
2. 使用异步调用优化
|
||||
3. 部署多实例负载均衡
|
||||
|
||||
### 9.2 内存占用过高
|
||||
|
||||
**可能原因**:
|
||||
- 日志队列过大
|
||||
- 订阅者过多
|
||||
- 节点对象未及时释放
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 减少 `LogService` 的 `max_logs` 参数
|
||||
2. 及时关闭不需要的 SSE 连接
|
||||
3. 定期重启服务清理内存
|
||||
|
||||
---
|
||||
|
||||
## 10. 安全问题
|
||||
|
||||
### 10.1 API Key 泄露
|
||||
|
||||
**风险**:外部系统可能非法访问 API
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 立即更换 API Key
|
||||
2. 限制 CORS 来源
|
||||
3. 使用 HTTPS 传输
|
||||
4. 定期轮换 API Key
|
||||
|
||||
### 10.2 生产环境安全建议
|
||||
|
||||
1. 修改默认的 `secret_key`
|
||||
2. 设置 `debug: false`
|
||||
3. 配置正确的 `cors_origins`
|
||||
4. 使用强密码的 API Key
|
||||
5. 启用 HTTPS
|
||||
6. 配置防火墙规则
|
||||
|
||||
---
|
||||
|
||||
## 诊断命令汇总
|
||||
|
||||
```bash
|
||||
# 检查服务健康状态
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 检查监控状态
|
||||
curl http://localhost:8080/api/v1/monitor/status \
|
||||
-H "X-API-Key: your-key"
|
||||
|
||||
# 检查节点状态
|
||||
curl http://localhost:8080/api/v1/nodes \
|
||||
-H "X-API-Key: your-key"
|
||||
|
||||
# 检查 Webhook 配置
|
||||
curl http://localhost:8080/api/v1/config/webhook \
|
||||
-H "X-API-Key: your-key"
|
||||
|
||||
# 检查最近日志
|
||||
curl "http://localhost:8080/api/v1/logs/recent?count=20" \
|
||||
-H "X-API-Key: your-key"
|
||||
|
||||
# 检查插件列表
|
||||
curl http://localhost:8080/api/v1/plugins \
|
||||
-H "X-API-Key: your-key"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 获取帮助
|
||||
|
||||
如果以上方案无法解决您的问题,请:
|
||||
|
||||
1. 查看详细日志输出
|
||||
2. 检查系统环境配置
|
||||
3. 确认网络连通性
|
||||
4. 查看 [API.md](./API.md) 确认 API 使用正确
|
||||
5. 查看 [ARCHITECTURE.md](./ARCHITECTURE.md) 了解系统架构
|
||||
@@ -0,0 +1,271 @@
|
||||
# WXAuto Relogin - API 接口文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
本文档定义 WXAuto Relogin 功能所需的 API 接口。
|
||||
|
||||
## 2. 回调接口
|
||||
|
||||
### 2.1 接收二维码
|
||||
|
||||
客户端上传二维码到中控。
|
||||
|
||||
```
|
||||
POST /api/v1/callback/relogin/qrcode
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
X-API-Key: your-api-key
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"qrcode_base64": "data:image/png;base64,iVBORw0KGgo...",
|
||||
"expires_in": 120
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| node_id | string | 是 | 节点ID |
|
||||
| qrcode_base64 | string | 是 | Base64编码的PNG图片,带data URI前缀 |
|
||||
| expires_in | integer | 否 | 过期时间(秒),默认120 |
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "二维码已接收",
|
||||
"data": {
|
||||
"status": "qrcode_ready",
|
||||
"expires_at": "2026-04-07T15:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 更新重登录状态
|
||||
|
||||
```
|
||||
POST /api/v1/callback/relogin/status
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"status": "scanned",
|
||||
"message": "用户已扫码"
|
||||
}
|
||||
```
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| waiting_qrcode | 等待获取二维码 |
|
||||
| qrcode_ready | 二维码已就绪 |
|
||||
| scanned | 已扫码,待确认 |
|
||||
| confirmed | 已确认,登录中 |
|
||||
| completed | 登录完成 |
|
||||
| failed | 登录失败 |
|
||||
|
||||
## 3. 节点接口
|
||||
|
||||
### 3.1 获取重登录状态
|
||||
|
||||
```
|
||||
GET /api/v1/nodes/{node_id}/relogin/status
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"node_id": "wx1",
|
||||
"status": "qrcode_ready",
|
||||
"qrcode_base64": "data:image/png;base64,...",
|
||||
"qrcode_expires_at": "2026-04-07T15:30:00",
|
||||
"created_at": "2026-04-07T15:00:00",
|
||||
"updated_at": "2026-04-07T15:05:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 清除重登录状态
|
||||
|
||||
```
|
||||
DELETE /api/v1/nodes/{node_id}/relogin/status
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "状态已清除"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 客户端管理接口
|
||||
|
||||
### 4.1 注册客户端
|
||||
|
||||
```
|
||||
POST /api/v1/relogin/clients
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"client_name": "服务器1",
|
||||
"webhook_url": "http://192.168.1.100:8081/webhook/relogin",
|
||||
"secret_key": "optional_secret_for_signing"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"client_name": "服务器1",
|
||||
"webhook_url": "http://192.168.1.100:8081/webhook/relogin",
|
||||
"enabled": true,
|
||||
"created_at": "2026-04-07T15:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 获取客户端列表
|
||||
|
||||
```
|
||||
GET /api/v1/relogin/clients
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"client_name": "服务器1",
|
||||
"webhook_url": "http://192.168.1.100:8081/webhook/relogin",
|
||||
"enabled": true,
|
||||
"created_at": "2026-04-07T15:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 更新客户端
|
||||
|
||||
```
|
||||
PUT /api/v1/relogin/clients/{client_id}
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 删除客户端
|
||||
|
||||
```
|
||||
DELETE /api/v1/relogin/clients/{client_id}
|
||||
```
|
||||
|
||||
## 5. Webhook 告警格式
|
||||
|
||||
中控向客户端发送的告警格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "wechat_offline",
|
||||
"node_id": "wx1",
|
||||
"node_name": "微信1",
|
||||
"wechat_status": "offline",
|
||||
"timestamp": "2026-04-07T15:00:00",
|
||||
"message": "节点 wx1 微信掉线"
|
||||
}
|
||||
```
|
||||
|
||||
**event 类型:**
|
||||
| 事件 | 说明 |
|
||||
|------|------|
|
||||
| wechat_offline | 微信掉线 |
|
||||
| wechat_online | 微信上线 |
|
||||
| node_offline | 节点离线 |
|
||||
| node_online | 节点上线 |
|
||||
|
||||
## 6. 签名验证(可选)
|
||||
|
||||
如果配置了 secret_key,客户端应验证请求签名:
|
||||
|
||||
```
|
||||
X-Signature: sha256=xxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
**验证算法:**
|
||||
```python
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
expected = hmac.new(
|
||||
secret.encode(),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(f"sha256={expected}", signature)
|
||||
```
|
||||
|
||||
## 7. 错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 404 | 节点不存在 |
|
||||
| 400 | 参数错误 |
|
||||
| 401 | API Key无效 |
|
||||
| 403 | 无权限 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
## 8. 使用示例
|
||||
|
||||
### 8.1 完整重登录流程
|
||||
|
||||
```bash
|
||||
# 1. 微信掉线,中控发送告警到客户端
|
||||
# 客户端接收: POST /webhook/relogin
|
||||
|
||||
# 2. 客户端从节点获取二维码
|
||||
curl -o qrcode.png http://node-api:5000/api/wechat/qrcode \
|
||||
-H "X-API-Key: node-api-key"
|
||||
|
||||
# 3. 客户端上传二维码到中控
|
||||
curl -X POST http://center:8080/api/v1/callback/relogin/qrcode \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"node_id": "wx1",
|
||||
"qrcode_base64": "data:image/png;base64,...",
|
||||
"expires_in": 120
|
||||
}'
|
||||
|
||||
# 4. 前端轮询获取状态
|
||||
curl http://center:8080/api/v1/nodes/wx1/relogin/status \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
# 5. 用户扫码确认后,节点回调通知中控
|
||||
curl -X POST http://center:8080/api/v1/callback/node/check \
|
||||
-d '{"node_id": "wx1", "status": "online"}'
|
||||
|
||||
# 6. 客户端轮询发现 completed
|
||||
curl http://center:8080/api/v1/nodes/wx1/relogin/status
|
||||
# 返回: {"status": "completed"}
|
||||
```
|
||||
@@ -0,0 +1,375 @@
|
||||
# WXAuto Relogin - 客户端开发指南
|
||||
|
||||
## 1. 概述
|
||||
|
||||
本文档说明如何开发 WXAuto Relogin 客户端程序,用于接收中控告警并自动处理微信重登录。
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
- Python 3.11+
|
||||
- FastAPI (Webhook服务)
|
||||
- httpx (HTTP客户端)
|
||||
- asyncio (异步处理)
|
||||
|
||||
## 3. 项目结构
|
||||
|
||||
```
|
||||
wxauto-relogin-client/
|
||||
├── client/
|
||||
│ ├── __init__.py
|
||||
│ ├── webhook_server.py # Webhook接收服务
|
||||
│ ├── qrcode_handler.py # 二维码处理
|
||||
│ ├── node_client.py # 节点通信
|
||||
│ └── config.py # 配置
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 4. 配置
|
||||
|
||||
```python
|
||||
# client/config.py
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ClientConfig(BaseModel):
|
||||
center_url: str = "http://localhost:8080"
|
||||
center_api_key: str = "your-external-api-key"
|
||||
client_name: str = "relogin-client-1"
|
||||
secret_key: str = "" # 可选,用于签名验证
|
||||
listen_host: str = "0.0.0.0"
|
||||
listen_port: int = 8081
|
||||
|
||||
config = ClientConfig()
|
||||
```
|
||||
|
||||
## 5. Webhook 接收服务
|
||||
|
||||
```python
|
||||
# client/webhook_server.py
|
||||
from fastapi import FastAPI, HTTPException, Header
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from .qrcode_handler import QRCodeHandler
|
||||
from .config import config
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("relogin-client")
|
||||
|
||||
app = FastAPI()
|
||||
qrcode_handler = QRCodeHandler()
|
||||
|
||||
class ReloginAlert(BaseModel):
|
||||
event: str
|
||||
node_id: str
|
||||
node_name: str
|
||||
wechat_status: str
|
||||
timestamp: str
|
||||
|
||||
@app.post("/webhook/relogin")
|
||||
async def receive_alert(
|
||||
alert: ReloginAlert,
|
||||
x_signature: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
接收中控的掉线告警
|
||||
"""
|
||||
logger.info(f"Received alert: {alert.node_id} - {alert.wechat_status}")
|
||||
|
||||
# 验证签名(如果配置了secret_key)
|
||||
if config.secret_key:
|
||||
# TODO: 实现签名验证
|
||||
pass
|
||||
|
||||
# 触发重登录流程
|
||||
asyncio.create_task(handle_relogin(alert))
|
||||
|
||||
return {"status": "accepted", "node_id": alert.node_id}
|
||||
|
||||
async def handle_relogin(alert: ReloginAlert):
|
||||
"""
|
||||
处理重登录流程
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting relogin for {alert.node_id}")
|
||||
|
||||
# 1. 获取二维码
|
||||
qrcode_base64 = await qrcode_handler.get_and_upload_qrcode(alert.node_id)
|
||||
if qrcode_base64:
|
||||
logger.info(f"QR code uploaded for {alert.node_id}")
|
||||
|
||||
# 2. 等待扫码确认
|
||||
success = await qrcode_handler.wait_for_scan(alert.node_id, timeout=180)
|
||||
if success:
|
||||
logger.info(f"ReLogin successful for {alert.node_id}")
|
||||
else:
|
||||
logger.warning(f"ReLogin timeout for {alert.node_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ReLogin failed for {alert.node_id}: {e}")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host=config.listen_host, port=config.listen_port)
|
||||
```
|
||||
|
||||
## 6. 二维码处理
|
||||
|
||||
```python
|
||||
# client/qrcode_handler.py
|
||||
import httpx
|
||||
import asyncio
|
||||
import time
|
||||
import base64
|
||||
from typing import Optional
|
||||
|
||||
from .config import config
|
||||
|
||||
class QRCodeHandler:
|
||||
def __init__(self):
|
||||
self.center_url = config.center_url
|
||||
self.api_key = config.center_api_key
|
||||
|
||||
async def get_qrcode_from_node(self, api_url: str, node_api_key: str) -> Optional[bytes]:
|
||||
"""
|
||||
从wxauto节点获取二维码
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{api_url}/api/wechat/qrcode",
|
||||
headers={"X-API-Key": node_api_key}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Failed to get QR code from node: {e}")
|
||||
return None
|
||||
|
||||
async def upload_qrcode_to_center(self, node_id: str, qrcode_bytes: bytes, expires_in: int = 120):
|
||||
"""
|
||||
上传二维码到中控
|
||||
"""
|
||||
qrcode_base64 = base64.b64encode(qrcode_bytes).decode()
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.center_url}/api/v1/callback/relogin/qrcode",
|
||||
headers={"X-API-Key": self.api_key},
|
||||
json={
|
||||
"node_id": node_id,
|
||||
"qrcode_base64": f"data:image/png;base64,{qrcode_base64}",
|
||||
"expires_in": expires_in
|
||||
}
|
||||
)
|
||||
return response.json().get("success", False)
|
||||
|
||||
async def get_node_info(self, node_id: str) -> Optional[dict]:
|
||||
"""
|
||||
从中控获取节点信息(包含API地址和密钥)
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.center_url}/api/v1/nodes/{node_id}",
|
||||
headers={"X-API-Key": self.api_key}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json().get("data")
|
||||
return None
|
||||
|
||||
async def get_and_upload_qrcode(self, node_id: str) -> Optional[str]:
|
||||
"""
|
||||
获取二维码并上传到中控
|
||||
"""
|
||||
# 1. 获取节点信息
|
||||
node_info = await self.get_node_info(node_id)
|
||||
if not node_info:
|
||||
print(f"Node {node_id} not found")
|
||||
return None
|
||||
|
||||
# 2. 获取二维码
|
||||
qrcode_bytes = await self.get_qrcode_from_node(
|
||||
api_url=node_info["api_url"],
|
||||
node_api_key=node_info["api_key"]
|
||||
)
|
||||
if not qrcode_bytes:
|
||||
print(f"Failed to get QR code for {node_id}")
|
||||
return None
|
||||
|
||||
# 3. 上传到中控
|
||||
success = await self.upload_qrcode_to_center(node_id, qrcode_bytes)
|
||||
if success:
|
||||
return f"data:image/png;base64,{base64.b64encode(qrcode_bytes).decode()}"
|
||||
return None
|
||||
|
||||
async def wait_for_scan(self, node_id: str, timeout: int = 180) -> bool:
|
||||
"""
|
||||
轮询中控,等待扫码确认
|
||||
"""
|
||||
start_time = time.time()
|
||||
interval = 2 # 每2秒轮询一次
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.center_url}/api/v1/nodes/{node_id}/relogin/status",
|
||||
headers={"X-API-Key": self.api_key}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json().get("data", {})
|
||||
status = data.get("status")
|
||||
|
||||
print(f"Status: {status}")
|
||||
|
||||
if status == "completed":
|
||||
return True
|
||||
elif status == "failed":
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Polling error: {e}")
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
return False
|
||||
```
|
||||
|
||||
## 7. 节点通信客户端
|
||||
|
||||
```python
|
||||
# client/node_client.py
|
||||
import httpx
|
||||
from typing import Optional
|
||||
|
||||
class NodeClient:
|
||||
def __init__(self, api_url: str, api_key: str):
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
|
||||
async def get_qrcode(self) -> Optional[bytes]:
|
||||
"""
|
||||
获取登录二维码
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/api/wechat/qrcode",
|
||||
headers={"X-API-Key": self.api_key}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Failed to get QR code: {e}")
|
||||
return None
|
||||
|
||||
async def check_login_status(self) -> dict:
|
||||
"""
|
||||
检查登录状态
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/api/wechat/status",
|
||||
headers={"X-API-Key": self.api_key}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return {"logged_in": False}
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
|
||||
async def logout(self) -> bool:
|
||||
"""
|
||||
登出微信
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/api/wechat/logout",
|
||||
headers={"X-API-Key": self.api_key}
|
||||
)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f"Logout failed: {e}")
|
||||
return False
|
||||
```
|
||||
|
||||
## 8. 依赖
|
||||
|
||||
```txt
|
||||
# requirements.txt
|
||||
fastapi>=0.100.0
|
||||
uvicorn>=0.23.0
|
||||
httpx>=0.24.0
|
||||
pydantic>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
```
|
||||
|
||||
## 9. 运行
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 运行客户端
|
||||
python -m client.webhook_server
|
||||
```
|
||||
|
||||
## 10. Docker 部署
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY client/ ./client/
|
||||
|
||||
CMD ["python", "-m", "client.webhook_server"]
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
relogin-client:
|
||||
build: .
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
- CENTER_URL=http://your-center:8080
|
||||
- CENTER_API_KEY=your-api-key
|
||||
- CLIENT_NAME=relogin-1
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## 11. 完整示例
|
||||
|
||||
```python
|
||||
# main.py
|
||||
import asyncio
|
||||
from client.webhook_server import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8081)
|
||||
```
|
||||
|
||||
## 12. 注意事项
|
||||
|
||||
1. **网络安全**: 确保客户端Webhook端口能从公网访问
|
||||
2. **签名验证**: 生产环境建议启用signature验证
|
||||
3. **超时设置**: 二维码有效期通常120秒,需要及时处理
|
||||
4. **重试机制**: 建议添加失败重试逻辑
|
||||
5. **日志记录**: 保留完整日志便于排查问题
|
||||
@@ -0,0 +1,400 @@
|
||||
# WXAuto Relogin - 微信自动重登录方案
|
||||
|
||||
## 1. 背景问题
|
||||
|
||||
### 1.1 当前痛点
|
||||
- 微信掉线后,需要人工介入扫码登录
|
||||
- 节点多时,人工操作繁琐
|
||||
- 无法远程自动恢复微信会话
|
||||
|
||||
### 1.2 现有架构
|
||||
```
|
||||
中控(Center) ←→ 节点(Node)
|
||||
↑
|
||||
API通信/回调
|
||||
```
|
||||
|
||||
## 2. 解决方案概述
|
||||
|
||||
### 2.1 核心理念
|
||||
```
|
||||
微信掉线 → 中控告警 → 客户端接收 → 获取二维码 → 回传中控 → 前端展示扫码
|
||||
```
|
||||
|
||||
### 2.2 系统架构
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WXAuto Center │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 告警系统 │→ │ 插件系统 │← │ 前端展示 │ │
|
||||
│ │ (Webhook) │ │(wxautorelogin)│ │ (二维码) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ ↑ │ │
|
||||
│ │ ┌──────────────┐ │ │
|
||||
│ └─────────│ 回调接口 │───────────┘ │
|
||||
│ │ /callback │ │
|
||||
│ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↑ HTTP
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WXAuto Relogin Client │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 消息接收 │ │ 二维码获取 │→ │ 状态回调 │ │
|
||||
│ │ (Webhook) │ │(get QR code) │ │ (重置微信) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 3. 实现方案
|
||||
|
||||
### 3.1 中控端改动
|
||||
|
||||
#### 3.1.1 新增 API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST /api/v1/callback/relogin/qrcode` | 接收二维码 | 客户端回传二维码图片 |
|
||||
| `GET /api/v1/nodes/{node_id}/relogin/status` | 重登录状态 | 查询节点重登录进度 |
|
||||
|
||||
#### 3.1.2 二维码存储结构
|
||||
|
||||
```python
|
||||
# NodeReloginStatus
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"status": "waiting_qrcode" | "qrcode_ready" | "scanned" | "confirmed" | "completed" | "failed",
|
||||
"qrcode_base64": "data:image/png;base64,...",
|
||||
"qrcode_expires_at": "2026-04-07T15:30:00",
|
||||
"created_at": "2026-04-07T15:00:00",
|
||||
"updated_at": "2026-04-07T15:05:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.3 前端展示
|
||||
|
||||
- 节点详情页增加"重登录"标签页
|
||||
- 实时显示二维码(轮询 /api/v1/nodes/{node_id}/relogin/status)
|
||||
- 二维码过期提示
|
||||
|
||||
### 3.2 客户端改动
|
||||
|
||||
#### 3.2.1 Webhook 接收器
|
||||
|
||||
客户端需要暴露一个 HTTP 接口接收中控告警:
|
||||
|
||||
```python
|
||||
# client/webhook_server.py
|
||||
from fastapi import FastAPI
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.post("/webhook/relogin")
|
||||
async def receive_alert(data: dict):
|
||||
"""
|
||||
接收中控的掉线告警
|
||||
{
|
||||
"event": "wechat_offline",
|
||||
"node_id": "wx1",
|
||||
"node_name": "微信1",
|
||||
"wechat_status": "offline",
|
||||
"timestamp": "2026-04-07T15:00:00"
|
||||
}
|
||||
"""
|
||||
# 触发获取二维码流程
|
||||
await trigger_relogin(node_id=data["node_id"])
|
||||
return {"status": "ok"}
|
||||
|
||||
async def trigger_relogin(node_id: str):
|
||||
# 1. 调用节点API获取二维码
|
||||
# 2. 上传二维码到中控
|
||||
# 3. 等待扫码确认
|
||||
pass
|
||||
```
|
||||
|
||||
#### 3.2.2 二维码获取流程
|
||||
|
||||
```python
|
||||
async def get_qrcode_from_node(api_url: str, api_key: str) -> bytes:
|
||||
"""
|
||||
从wxauto节点获取二维码
|
||||
GET /api/wechat/qrcode
|
||||
返回: PNG图片二进制
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{api_url}/api/wechat/qrcode",
|
||||
headers={"X-API-Key": api_key}
|
||||
)
|
||||
return response.content
|
||||
|
||||
async def upload_qrcode_to_center(center_url: str, node_id: str, qrcode_bytes: bytes):
|
||||
"""
|
||||
上传二维码到中控
|
||||
POST /api/v1/callback/relogin/qrcode
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
import base64
|
||||
qrcode_base64 = base64.b64encode(qrcode_bytes).decode()
|
||||
await client.post(
|
||||
f"{center_url}/api/v1/callback/relogin/qrcode",
|
||||
json={
|
||||
"node_id": node_id,
|
||||
"qrcode_base64": qrcode_base64
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### 3.2.3 扫码状态轮询
|
||||
|
||||
```python
|
||||
async def wait_for_scan(center_url: str, node_id: str, timeout: int = 120):
|
||||
"""
|
||||
轮询中控接口,等待用户扫码确认
|
||||
"""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{center_url}/api/v1/nodes/{node_id}/relogin/status"
|
||||
)
|
||||
status = response.json()
|
||||
if status["status"] == "completed":
|
||||
return True
|
||||
elif status["status"] == "failed":
|
||||
return False
|
||||
await asyncio.sleep(2)
|
||||
return False
|
||||
```
|
||||
|
||||
## 4. 完整流程时序
|
||||
|
||||
### 4.1 时序图
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ 微信 │ │ 节点 │ │ 中控 │ │ 客户端 │ │ 用户 │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │ │ │
|
||||
│ 掉线 │ │ │ │
|
||||
│───────────────→│ │ │ │
|
||||
│ │ wechat_offline callback │ │
|
||||
│ │───────────────→│ │ │
|
||||
│ │ │ Webhook告警 │ │
|
||||
│ │ │───────────────→│ │
|
||||
│ │ │ │ │
|
||||
│ │ │ │ 获取二维码 │
|
||||
│ │ GET /qrcode │ │───────────────→│
|
||||
│ 返回二维码 │←──────────────│ │ │
|
||||
│←───────────────│ │ │ │
|
||||
│ │ │ │ 上传二维码 │
|
||||
│ │ │←──────────────│ │
|
||||
│ │ │ │ │
|
||||
│ │ │ 前端展示二维码│ │
|
||||
│ │ │───────────────→│ 扫码 │
|
||||
│ │ │ │←──────────────│
|
||||
│ 扫码确认 │ │ │ │
|
||||
│←───────────────│ │ │ │
|
||||
│ 登录成功 │ │ │ │
|
||||
│───────────────→│ │ │ │
|
||||
│ │ wechat_online callback │ │
|
||||
│ │───────────────→│ │ │
|
||||
│ │ │ 更新状态 │ │
|
||||
│ │ │───────────────→│ 完成 │
|
||||
```
|
||||
|
||||
### 4.2 详细步骤
|
||||
|
||||
| 步骤 | 参与者 | 操作 | API/协议 |
|
||||
|------|--------|------|----------|
|
||||
| 1 | 微信 | 掉线检测 | - |
|
||||
| 2 | 节点 | 回调通知中控 | POST /callback |
|
||||
| 3 | 中控 | 触发Webhook告警 | HTTP |
|
||||
| 4 | 客户端 | 接收告警 | POST /webhook/relogin |
|
||||
| 5 | 客户端 | 请求节点二维码 | GET /api/wechat/qrcode |
|
||||
| 6 | 节点 | 返回二维码图片 | HTTP |
|
||||
| 7 | 客户端 | 上传二维码到中控 | POST /callback/relogin/qrcode |
|
||||
| 8 | 中控 | 存储并通知前端 | WebSocket/轮询 |
|
||||
| 9 | 前端 | 展示二维码 | - |
|
||||
| 10 | 用户 | 扫码确认 | - |
|
||||
| 11 | 微信 | 登录成功 | - |
|
||||
| 12 | 节点 | 回调通知中控 | POST /callback |
|
||||
| 13 | 中控 | 更新节点状态 | - |
|
||||
|
||||
## 5. 数据库设计
|
||||
|
||||
### 5.1 新增表
|
||||
|
||||
```sql
|
||||
-- 节点重登录状态表
|
||||
CREATE TABLE node_relogin_status (
|
||||
id SERIAL PRIMARY KEY,
|
||||
node_id VARCHAR(50) UNIQUE NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'idle',
|
||||
qrcode_base64 TEXT,
|
||||
qrcode_expires_at TIMESTAMP,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 客户端配置表
|
||||
CREATE TABLE relogin_clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
client_name VARCHAR(100) NOT NULL,
|
||||
webhook_url VARCHAR(500) NOT NULL,
|
||||
secret_key VARCHAR(100),
|
||||
enabled BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 状态枚举
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `idle` | 空闲,无重登录任务 |
|
||||
| `waiting_qrcode` | 等待获取二维码 |
|
||||
| `qrcode_ready` | 二维码已就绪 |
|
||||
| `scanned` | 已扫码,待确认 |
|
||||
| `confirmed` | 已确认,登录中 |
|
||||
| `completed` | 登录完成 |
|
||||
| `failed` | 登录失败 |
|
||||
|
||||
## 6. API 接口详细设计
|
||||
|
||||
### 6.1 回调接口 - 接收二维码
|
||||
|
||||
```
|
||||
POST /api/v1/callback/relogin/qrcode
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"node_id": "wx1",
|
||||
"qrcode_base64": "data:image/png;base64,iVBORw0KGgo...",
|
||||
"expires_in": 120
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "二维码已接收"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 查询重登录状态
|
||||
|
||||
```
|
||||
GET /api/v1/nodes/{node_id}/relogin/status
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"node_id": "wx1",
|
||||
"status": "qrcode_ready",
|
||||
"qrcode_base64": "data:image/png;base64,...",
|
||||
"qrcode_expires_at": "2026-04-07T15:30:00",
|
||||
"created_at": "2026-04-07T15:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 客户端注册
|
||||
|
||||
```
|
||||
POST /api/v1/relogin/clients
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"client_name": "服务器1",
|
||||
"webhook_url": "http://192.168.1.100:8081/webhook/relogin",
|
||||
"secret_key": "optional_secret"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 客户端列表
|
||||
|
||||
```
|
||||
GET /api/v1/relogin/clients
|
||||
```
|
||||
|
||||
## 7. 安全考虑
|
||||
|
||||
### 7.1 客户端认证
|
||||
- 客户端注册时分配 secret_key
|
||||
- 回调接口需要携带签名验证
|
||||
- 中控可配置允许的客户端IP白名单
|
||||
|
||||
### 7.2 回调签名验证
|
||||
|
||||
```python
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
```
|
||||
|
||||
## 8. 文件结构
|
||||
|
||||
```
|
||||
wxauto_api/
|
||||
├── plugins/
|
||||
│ └── wxautorelogin_plugin.py # 重登录插件
|
||||
├── api/
|
||||
│ └── relogin.py # 重登录相关API
|
||||
├── services/
|
||||
│ └── relogin_service.py # 重登录服务
|
||||
└── doc/
|
||||
└── wxautorelogin/
|
||||
├── README.md # 本文档
|
||||
├── CLIENT.md # 客户端开发指南
|
||||
└── API.md # API接口文档
|
||||
|
||||
wxauto-relogin-client/
|
||||
├── client/
|
||||
│ ├── __init__.py
|
||||
│ ├── webhook_server.py # Webhook接收服务
|
||||
│ ├── qrcode_handler.py # 二维码处理
|
||||
│ └── config.py # 配置
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 9. 可行性结论
|
||||
|
||||
### 9.1 技术可行性:✅ 可行
|
||||
|
||||
1. **wxauto节点已提供QR码接口** - 通过 `/api/wechat/qrcode` 获取
|
||||
2. **中控已有回调机制** - 可复用现有 callback 系统
|
||||
3. **前端支持实时更新** - 可通过轮询或WebSocket展示二维码
|
||||
|
||||
### 9.2 实施难度:🟡 中等
|
||||
|
||||
1. 需要开发客户端程序
|
||||
2. 需要新增API接口
|
||||
3. 需要前端新增重登录页面
|
||||
|
||||
### 9.3 建议实施顺序
|
||||
|
||||
1. **Phase 1**: 中控端API开发
|
||||
2. **Phase 2**: 客户端程序开发
|
||||
3. **Phase 3**: 前端页面开发
|
||||
4. **Phase 4**: 整体联调测试
|
||||
|
||||
## 10. 后续优化方向
|
||||
|
||||
- 支持短信/邮件通知
|
||||
- 支持多客户端负载均衡
|
||||
- 支持扫码历史记录
|
||||
- 支持自动重试机制
|
||||
@@ -0,0 +1,70 @@
|
||||
# ///
|
||||
# docker-compose.yml
|
||||
# 描述:Docker Compose 配置 - ARM64 架构 (Apple Silicon Mac)
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: wxauto-postgres
|
||||
environment:
|
||||
POSTGRES_USER: wxauto
|
||||
POSTGRES_PASSWORD: wxauto_password
|
||||
POSTGRES_DB: wxauto
|
||||
volumes:
|
||||
- ./docker_data/db:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wxauto"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: wxauto-redis
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- ./docker_data/redis:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
wxauto-center:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: wxauto-center
|
||||
environment:
|
||||
DATABASE_URL: postgresql://wxauto:wxauto_password@postgres:5432/wxauto
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
APP_NAME: WXAuto Center
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8080
|
||||
DEBUG: "true"
|
||||
SECRET_KEY: wxauto-center-secret-key-change-in-production
|
||||
WORKERS: "1"
|
||||
API_PREFIX: /api/v1
|
||||
EXTERNAL_API_KEY: your-external-api-key
|
||||
TZ: Asia/Shanghai
|
||||
PYTHONTZ: Asia/Shanghai
|
||||
volumes:
|
||||
- ./docker_data/logs:/app/logs
|
||||
- ./plugins/custom:/app/plugins/custom
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,208 @@
|
||||
# ///
|
||||
# main.py
|
||||
# 描述:FastAPI主入口,启动中控系统服务
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
|
||||
from config import get_settings, load_config_from_file, save_config_to_file
|
||||
from api import nodes, messages, external, plugins, callback, webhook
|
||||
from plugins.builtin import register_builtin_plugins
|
||||
from plugins.plugin_loader import hot_plugin_loader, create_sample_plugin
|
||||
from services.node_manager import node_manager
|
||||
from services.monitor_service import monitor_service
|
||||
from services.log_service import log_service
|
||||
from services.scheduler_service import scheduler_service
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
description="WXAuto中控系统 - 多节点微信控制平台",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
log_service.error(f"Global exception: {str(exc)}", "Error")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"success": False, "error": str(exc)}
|
||||
)
|
||||
|
||||
|
||||
app.include_router(nodes.router, prefix=settings.api_prefix)
|
||||
app.include_router(messages.router, prefix=settings.api_prefix)
|
||||
app.include_router(external.router, prefix=settings.api_prefix)
|
||||
app.include_router(plugins.router, prefix=settings.api_prefix)
|
||||
app.include_router(callback.router, prefix=settings.api_prefix)
|
||||
app.include_router(webhook.router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
load_config_from_file("config.json")
|
||||
register_builtin_plugins()
|
||||
|
||||
from database_service import init_database, get_db_service
|
||||
from config import get_settings
|
||||
settings = get_settings()
|
||||
init_database(settings.database.url)
|
||||
log_service.info("Database initialized", "Startup")
|
||||
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
for node in db_service.get_all_nodes():
|
||||
await node_manager.add_node(
|
||||
node_id=node.node_id,
|
||||
name=node.name,
|
||||
api_url=node.api_url,
|
||||
api_key=node.api_key,
|
||||
description=node.description or "",
|
||||
group=node.group or "default"
|
||||
)
|
||||
log_service.info(f"Loaded {len(db_service.get_all_nodes())} nodes from database", "Startup")
|
||||
|
||||
for plugin in db_service.get_all_plugin_configs():
|
||||
from plugins.base import PluginRegistry
|
||||
p = PluginRegistry.get(plugin.plugin_name)
|
||||
if p:
|
||||
import json
|
||||
config = json.loads(plugin.config_json) if plugin.config_json else {}
|
||||
p.initialize(config)
|
||||
if plugin.enabled:
|
||||
p.enable()
|
||||
else:
|
||||
p.disable()
|
||||
log_service.info("Plugins loaded from database", "Startup")
|
||||
|
||||
from services.plugin_notification_service import plugin_notification_service
|
||||
plugin_notification_service.initialize()
|
||||
log_service.info("Plugin notification service initialized", "Startup")
|
||||
|
||||
await monitor_service.start()
|
||||
await scheduler_service.start()
|
||||
hot_plugin_loader.start()
|
||||
create_sample_plugin()
|
||||
|
||||
print(f"[{settings.app_name}] Started successfully")
|
||||
print(f"API Documentation: http://{settings.host}:{settings.port}/docs")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
await monitor_service.stop()
|
||||
await scheduler_service.stop()
|
||||
hot_plugin_loader.stop()
|
||||
save_config_to_file("config.json")
|
||||
print(f"[{settings.app_name}] Shutdown, config saved")
|
||||
|
||||
|
||||
@app.get("/", summary="首页")
|
||||
async def root():
|
||||
return RedirectResponse(url="/static/index.html")
|
||||
|
||||
|
||||
@app.get("/health", summary="健康检查")
|
||||
async def health():
|
||||
return {"status": "ok", "app": settings.app_name}
|
||||
|
||||
|
||||
@app.get("/api/v1/config/monitor", summary="获取监控配置")
|
||||
async def get_monitor_config():
|
||||
return {
|
||||
"success": True,
|
||||
"data": settings.monitor.model_dump()
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/config/monitor", summary="更新监控配置")
|
||||
async def update_monitor_config(request: dict):
|
||||
from config import MonitorConfig
|
||||
global settings
|
||||
settings.monitor = MonitorConfig(
|
||||
enabled=request.get("enabled", True),
|
||||
check_interval=request.get("check_interval", 60)
|
||||
)
|
||||
save_config_to_file()
|
||||
return {"success": True, "message": "Monitor config updated"}
|
||||
|
||||
|
||||
@app.get("/api/v1/monitor/status", summary="获取监控状态")
|
||||
async def get_monitor_status():
|
||||
return {
|
||||
"success": True,
|
||||
"data": monitor_service.get_all_status()
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/logs/recent", summary="获取最近日志")
|
||||
async def get_recent_logs(count: int = 50):
|
||||
return {
|
||||
"success": True,
|
||||
"data": log_service.get_recent_logs(count)
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/logs/stream", summary="日志流")
|
||||
async def log_stream():
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
async def event_generator():
|
||||
queue = await log_service.subscribe()
|
||||
try:
|
||||
while True:
|
||||
log_entry = await queue.get()
|
||||
yield f"data: {json.dumps(log_entry)}\n\n"
|
||||
except Exception:
|
||||
log_service.unsubscribe(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
static_path = os.path.join(os.path.dirname(__file__), "static")
|
||||
if os.path.exists(static_path):
|
||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||
|
||||
|
||||
def main():
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=settings.debug
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
# ///
|
||||
# models.py
|
||||
# 描述:数据库模型,使用 SQLAlchemy ORM
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from sqlalchemy import create_engine, Column, String, Boolean, Integer, DateTime, Text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
_asia_tz = pytz.timezone('Asia/Shanghai')
|
||||
|
||||
|
||||
def now_asia():
|
||||
return datetime.now(_asia_tz)
|
||||
|
||||
|
||||
class Node(Base):
|
||||
__tablename__ = "nodes"
|
||||
|
||||
node_id = Column(String(50), primary_key=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
api_url = Column(String(255), nullable=False)
|
||||
api_key = Column(String(255), nullable=False)
|
||||
enabled = Column(Boolean, default=True)
|
||||
description = Column(Text, default="")
|
||||
group = Column(String(50), default="default", name="node_group")
|
||||
status = Column(String(20), default="inactive")
|
||||
wechat_status = Column(String(20), default="online")
|
||||
is_healthy = Column(Boolean, default=False)
|
||||
health_message = Column(String(255), default="")
|
||||
created_at = Column(DateTime, default=now_asia)
|
||||
updated_at = Column(DateTime, default=now_asia, onupdate=now_asia)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"node_id": self.node_id,
|
||||
"name": self.name,
|
||||
"api_url": self.api_url,
|
||||
"api_key": self.api_key,
|
||||
"enabled": self.enabled,
|
||||
"description": self.description,
|
||||
"group": self.group,
|
||||
"status": self.status,
|
||||
"wechat_status": self.wechat_status,
|
||||
"is_healthy": self.is_healthy,
|
||||
"health_message": self.health_message,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
class WebhookUrl(Base):
|
||||
__tablename__ = "webhook_urls"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
url = Column(String(500), nullable=False)
|
||||
format = Column(String(20), default="bark")
|
||||
enabled = Column(Boolean, default=True)
|
||||
event_types = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=now_asia)
|
||||
updated_at = Column(DateTime, default=now_asia, onupdate=now_asia)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"url": self.url,
|
||||
"format": self.format,
|
||||
"enabled": self.enabled,
|
||||
"event_types": self.event_types.split(",") if self.event_types else [],
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
class LogEntry(Base):
|
||||
__tablename__ = "logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
timestamp = Column(DateTime, default=now_asia)
|
||||
level = Column(String(20), nullable=False)
|
||||
source = Column(String(50), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"timestamp": self.timestamp.strftime("%Y-%m-%d %H:%M:%S") if self.timestamp else None,
|
||||
"level": self.level,
|
||||
"source": self.source,
|
||||
"message": self.message
|
||||
}
|
||||
|
||||
|
||||
class PluginConfig(Base):
|
||||
__tablename__ = "plugin_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
plugin_name = Column(String(100), unique=True, nullable=False)
|
||||
config_json = Column(Text, default="{}")
|
||||
enabled = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=now_asia)
|
||||
updated_at = Column(DateTime, default=now_asia, onupdate=now_asia)
|
||||
|
||||
def to_dict(self):
|
||||
import json
|
||||
return {
|
||||
"id": self.id,
|
||||
"plugin_name": self.plugin_name,
|
||||
"config": json.loads(self.config_json) if self.config_json else {},
|
||||
"enabled": self.enabled,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
def __init__(self, database_url: str):
|
||||
self.engine = create_engine(database_url, echo=False, pool_pre_ping=True)
|
||||
self.SessionLocal = sessionmaker(bind=self.engine)
|
||||
|
||||
def create_tables(self):
|
||||
Base.metadata.create_all(self.engine)
|
||||
|
||||
def get_session(self):
|
||||
return self.SessionLocal()
|
||||
|
||||
def close(self):
|
||||
self.engine.dispose()
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "advanced_sender",
|
||||
"config": {
|
||||
"cron": "*/2 * * * *",
|
||||
"external_db": {
|
||||
"host": "192.168.2.27",
|
||||
"user": "root2",
|
||||
"password": "root@root",
|
||||
"database": "addb",
|
||||
"port": 3306,
|
||||
"charset": "utf8mb4"
|
||||
},
|
||||
"db_table": "user_data",
|
||||
"sendto_field": "sendto",
|
||||
"datasources": [
|
||||
{
|
||||
"name": "source1",
|
||||
"url": "https://xiansuo2.lfgzyx.com/admin/customer/sales/index?addtabs=1&sort=id&order=desc&offset=0&limit=50",
|
||||
"headers": {
|
||||
"Cookie": "PHPSESSID=fccjr2k0j8kb19tn0rdmlvkob4; think_var=zh-cn; keeplogin=1041%7C86400%7C1775608752%7C5e9aac1d434f86f0586746d873cdfec5",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
"verify_ssl": true,
|
||||
"zhid": "1"
|
||||
},
|
||||
{
|
||||
"name": "source2",
|
||||
"url": "https://xiansuo2.lfgzyx.com/admin/customer/sales/index?addtabs=1&sort=id&order=desc&offset=0&limit=50",
|
||||
"headers": {
|
||||
"Cookie": "PHPSESSID=3hk0unlg9f27bgbg992n52k567; think_var=zh-cn",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
"verify_ssl": true,
|
||||
"zhid": "2"
|
||||
}
|
||||
],
|
||||
"node_id": "wx1",
|
||||
"receiver": "asq",
|
||||
"fetch_timeout": 15,
|
||||
"send_interval_between_sources": 20
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
# ///
|
||||
# advanced_sender_plugin.py
|
||||
# 描述:高级多节点数据推送插件,支持表单化配置和变量赋值
|
||||
# 功能:多数据源获取 → 去重入库 → 按记录时间路由发送到微信
|
||||
# 作者:User
|
||||
# 创建日期:2026-04-06
|
||||
# requires: requests, pymysql
|
||||
# ///
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime, time as dtime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from contextlib import contextmanager
|
||||
from croniter import croniter
|
||||
from plugins.base import ScheduledTaskPlugin
|
||||
from plugins.plugin_config_schema import (
|
||||
PluginConfigSchema, ConfigSection, ConfigField, FieldType,
|
||||
create_variable_assignment_field, cron_validator
|
||||
)
|
||||
|
||||
PLUGIN_LOG_DIR = "/app/data/logs"
|
||||
os.makedirs(PLUGIN_LOG_DIR, exist_ok=True)
|
||||
|
||||
plugin_logger = logging.getLogger("advanced_sender")
|
||||
plugin_logger.setLevel(logging.INFO)
|
||||
|
||||
file_handler = logging.FileHandler(os.path.join(PLUGIN_LOG_DIR, "advanced_sender.log"), encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||
|
||||
if not plugin_logger.handlers:
|
||||
plugin_logger.addHandler(file_handler)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
plugin_logger.addHandler(console_handler)
|
||||
|
||||
logger = plugin_logger
|
||||
|
||||
|
||||
def get_config_schema() -> PluginConfigSchema:
|
||||
schema = PluginConfigSchema("advanced_sender")
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="basic",
|
||||
label="基本设置",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="cron",
|
||||
label="执行周期",
|
||||
field_type=FieldType.CRON,
|
||||
required=True,
|
||||
default="*/2 * * * *",
|
||||
description="Cron表达式"
|
||||
),
|
||||
ConfigField(
|
||||
name="enable_cron",
|
||||
label="启用定时任务",
|
||||
field_type=FieldType.BOOLEAN,
|
||||
default=False
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="node",
|
||||
label="默认设置(可被数据源覆盖)",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="node_id",
|
||||
label="默认节点ID",
|
||||
field_type=FieldType.STRING,
|
||||
default="wx1",
|
||||
placeholder="如 wx1"
|
||||
),
|
||||
ConfigField(
|
||||
name="receiver",
|
||||
label="默认接收人",
|
||||
field_type=FieldType.STRING,
|
||||
default="asq",
|
||||
placeholder="联系人名称"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="database",
|
||||
label="外部数据库",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="db_host",
|
||||
label="数据库地址",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
default="192.168.2.27",
|
||||
placeholder="192.168.1.100"
|
||||
),
|
||||
ConfigField(
|
||||
name="db_port",
|
||||
label="端口",
|
||||
field_type=FieldType.NUMBER,
|
||||
default=3306,
|
||||
min_value=1,
|
||||
max_value=65535
|
||||
),
|
||||
ConfigField(
|
||||
name="db_user",
|
||||
label="用户名",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
default="root2"
|
||||
),
|
||||
ConfigField(
|
||||
name="db_password",
|
||||
label="密码",
|
||||
field_type=FieldType.PASSWORD,
|
||||
default="root@root"
|
||||
),
|
||||
ConfigField(
|
||||
name="db_name",
|
||||
label="数据库名",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
default="addb"
|
||||
),
|
||||
ConfigField(
|
||||
name="db_table",
|
||||
label="表名",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
default="user_data"
|
||||
),
|
||||
ConfigField(
|
||||
name="sendto_field",
|
||||
label="发送状态字段",
|
||||
field_type=FieldType.STRING,
|
||||
default="sendto"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="cookie_source1",
|
||||
label="数据源1",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="cookie_1",
|
||||
label="Cookie",
|
||||
field_type=FieldType.TEXTAREA,
|
||||
placeholder="PHPSESSID=xxx; think_var=zh-cn; keeplogin=...",
|
||||
description="数据源1的完整Cookie"
|
||||
),
|
||||
ConfigField(
|
||||
name="node_id_1",
|
||||
label="节点ID",
|
||||
field_type=FieldType.STRING,
|
||||
default="",
|
||||
placeholder="留空使用默认节点"
|
||||
),
|
||||
ConfigField(
|
||||
name="receiver_1",
|
||||
label="接收人",
|
||||
field_type=FieldType.STRING,
|
||||
default="",
|
||||
placeholder="留空使用默认接收人"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="cookie_source2",
|
||||
label="数据源2",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="cookie_2",
|
||||
label="Cookie",
|
||||
field_type=FieldType.TEXTAREA,
|
||||
placeholder="PHPSESSID=xxx; think_var=zh-cn; keeplogin=..."
|
||||
),
|
||||
ConfigField(
|
||||
name="node_id_2",
|
||||
label="节点ID",
|
||||
field_type=FieldType.STRING,
|
||||
default="",
|
||||
placeholder="留空使用默认节点"
|
||||
),
|
||||
ConfigField(
|
||||
name="receiver_2",
|
||||
label="接收人",
|
||||
field_type=FieldType.STRING,
|
||||
default="",
|
||||
placeholder="留空使用默认接收人"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="fetch",
|
||||
label="抓取设置",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="fetch_timeout",
|
||||
label="请求超时(秒)",
|
||||
field_type=FieldType.NUMBER,
|
||||
default=15,
|
||||
min_value=5,
|
||||
max_value=60
|
||||
),
|
||||
ConfigField(
|
||||
name="send_interval",
|
||||
label="数据源间隔(秒)",
|
||||
field_type=FieldType.NUMBER,
|
||||
default=20,
|
||||
min_value=0,
|
||||
max_value=60,
|
||||
description="两个数据源之间的等待时间"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
def __init__(self, failure_threshold: int = 5, timeout: int = 60):
|
||||
self.failure_threshold = failure_threshold
|
||||
self.timeout = timeout
|
||||
self.failures = 0
|
||||
self.last_failure_time: Optional[float] = None
|
||||
self.state = "closed"
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def call(self, func, *args, **kwargs):
|
||||
with self._lock:
|
||||
if self.state == "open":
|
||||
if time.time() - self.last_failure_time >= self.timeout:
|
||||
self.state = "half-open"
|
||||
logger.info("Circuit breaker: OPEN -> HALF-OPEN")
|
||||
else:
|
||||
raise CircuitBreakerOpen("Circuit breaker is OPEN")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
with self._lock:
|
||||
if self.state == "half-open":
|
||||
self.state = "closed"
|
||||
self.failures = 0
|
||||
logger.info("Circuit breaker: HALF-OPEN -> CLOSED")
|
||||
return result
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.failures += 1
|
||||
self.last_failure_time = time.time()
|
||||
if self.failures >= self.failure_threshold:
|
||||
self.state = "open"
|
||||
logger.warning(f"Circuit breaker: CLOSED -> OPEN (failures={self.failures})")
|
||||
raise
|
||||
|
||||
|
||||
class CircuitBreakerOpen(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AdvancedSenderPlugin(ScheduledTaskPlugin):
|
||||
plugin_name = "advanced_sender"
|
||||
plugin_version = "6.0.0"
|
||||
plugin_description = "高级多节点数据推送插件,支持表单化配置和Cookie变量"
|
||||
plugin_author = "User"
|
||||
config_schema = staticmethod(get_config_schema)
|
||||
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = False
|
||||
self._last_run_time: Optional[datetime] = None
|
||||
self._running = False
|
||||
self._semaphore: Optional[asyncio.Semaphore] = None
|
||||
|
||||
self._cookie_error_reported_1 = False
|
||||
self._cookie_error_reported_2 = False
|
||||
|
||||
self.cron_expression = "*/2 * * * *"
|
||||
|
||||
self.db_host = "192.168.2.27"
|
||||
self.db_port = 3306
|
||||
self.db_user = "root2"
|
||||
self.db_password = "root@root"
|
||||
self.db_name = "addb"
|
||||
self.db_table = "user_data"
|
||||
self.sendto_field = "sendto"
|
||||
|
||||
self.cookie_1 = ""
|
||||
self.cookie_2 = ""
|
||||
|
||||
self.node_id = "wx1"
|
||||
self.receiver = "asq"
|
||||
|
||||
self.node_id_1 = ""
|
||||
self.receiver_1 = ""
|
||||
self.node_id_2 = ""
|
||||
self.receiver_2 = ""
|
||||
self.fetch_timeout = 15
|
||||
self.send_interval = 20
|
||||
|
||||
self._api_circuit = CircuitBreaker(failure_threshold=3, timeout=30)
|
||||
self._db_circuit = CircuitBreaker(failure_threshold=3, timeout=30)
|
||||
|
||||
def _get_external_db_config(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"host": self.db_host,
|
||||
"user": self.db_user,
|
||||
"password": self.db_password,
|
||||
"database": self.db_name,
|
||||
"port": self.db_port,
|
||||
"charset": "utf8mb4"
|
||||
}
|
||||
|
||||
def _get_datasources(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "source1",
|
||||
"url": "https://xiansuo2.lfgzyx.com/admin/customer/sales/index?addtabs=1&sort=id&order=desc&offset=0&limit=50",
|
||||
"headers": {
|
||||
"Cookie": self.cookie_1,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
"verify_ssl": True,
|
||||
"zhid": "1",
|
||||
"node_id": self.node_id_1 or self.node_id,
|
||||
"receiver": self.receiver_1 or self.receiver
|
||||
},
|
||||
{
|
||||
"name": "source2",
|
||||
"url": "https://xiansuo2.lfgzyx.com/admin/customer/sales/index?addtabs=1&sort=id&order=desc&offset=0&limit=50",
|
||||
"headers": {
|
||||
"Cookie": self.cookie_2,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
"verify_ssl": True,
|
||||
"zhid": "2",
|
||||
"node_id": self.node_id_2 or self.node_id,
|
||||
"receiver": self.receiver_2 or self.receiver
|
||||
}
|
||||
]
|
||||
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
self.config = config
|
||||
self.cron_expression = config.get("cron", "*/2 * * * *")
|
||||
|
||||
self.db_host = config.get("db_host", self.db_host)
|
||||
self.db_port = config.get("db_port", 3306)
|
||||
self.db_user = config.get("db_user", self.db_user)
|
||||
self.db_password = config.get("db_password", self.db_password)
|
||||
self.db_name = config.get("db_name", self.db_name)
|
||||
self.db_table = config.get("db_table", "user_data")
|
||||
self.sendto_field = config.get("sendto_field", "sendto")
|
||||
|
||||
self.cookie_1 = config.get("cookie_1", "")
|
||||
self.cookie_2 = config.get("cookie_2", "")
|
||||
|
||||
self.node_id = config.get("node_id", "wx1")
|
||||
self.receiver = config.get("receiver", "asq")
|
||||
|
||||
self.node_id_1 = config.get("node_id_1", "")
|
||||
self.receiver_1 = config.get("receiver_1", "")
|
||||
self.node_id_2 = config.get("node_id_2", "")
|
||||
self.receiver_2 = config.get("receiver_2", "")
|
||||
|
||||
self.fetch_timeout = max(5, min(60, config.get("fetch_timeout", 15)))
|
||||
self.send_interval = config.get("send_interval", 20)
|
||||
|
||||
logger.info(f"AdvancedSenderPlugin initialized: cron={self.cron_expression}")
|
||||
logger.info(f"Cookie1: {self.cookie_1[:30] if self.cookie_1 else 'empty'}..., Cookie2: {self.cookie_2[:30] if self.cookie_2 else 'empty'}...")
|
||||
return True
|
||||
|
||||
def enable(self):
|
||||
self._enabled = True
|
||||
self.enabled = True
|
||||
logger.info("AdvancedSenderPlugin enabled")
|
||||
|
||||
def disable(self):
|
||||
self._enabled = False
|
||||
self.enabled = False
|
||||
logger.info("AdvancedSenderPlugin disabled")
|
||||
|
||||
def get_cron_expression(self) -> str:
|
||||
return self.cron_expression
|
||||
|
||||
def should_run_and_get_next(self) -> tuple:
|
||||
try:
|
||||
now = datetime.now()
|
||||
current_hour = now.hour
|
||||
|
||||
if current_hour < 8 or current_hour >= 22:
|
||||
interval_minutes = 15
|
||||
else:
|
||||
interval_minutes = 2
|
||||
|
||||
cron = croniter(self.cron_expression, now)
|
||||
prev_run = cron.get_prev(datetime)
|
||||
next_run = cron.get_next(datetime)
|
||||
|
||||
if self._last_run_time is None or self._last_run_time != prev_run:
|
||||
self._last_run_time = prev_run
|
||||
return True, next_run
|
||||
|
||||
return False, next_run
|
||||
except Exception as e:
|
||||
logger.error(f"should_run_and_get_next error: {e}")
|
||||
return False, None
|
||||
|
||||
@contextmanager
|
||||
def _db_connection(self):
|
||||
conn = None
|
||||
try:
|
||||
import pymysql
|
||||
conn = pymysql.connect(
|
||||
host=self.db_host,
|
||||
user=self.db_user,
|
||||
password=self.db_password,
|
||||
database=self.db_name,
|
||||
port=self.db_port,
|
||||
charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
connect_timeout=5,
|
||||
read_timeout=10,
|
||||
write_timeout=10
|
||||
)
|
||||
yield conn
|
||||
finally:
|
||||
if conn:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _fetch_from_datasource(self, datasource: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
source_name = datasource.get('name', 'unknown')
|
||||
source_num = datasource.get('zhid', '0')
|
||||
try:
|
||||
import requests
|
||||
url = datasource["url"]
|
||||
if "_=" in url:
|
||||
url = url + str(int(time.time() * 1000))
|
||||
|
||||
logger.info(f"Fetching from {source_name}: {url}")
|
||||
logger.info(f"Cookie: {datasource['headers'].get('Cookie', '')[:50]}...")
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=datasource.get("headers", {}),
|
||||
verify=datasource.get("verify_ssl", True),
|
||||
timeout=self.fetch_timeout
|
||||
)
|
||||
|
||||
if response.status_code == 403 or response.status_code == 401:
|
||||
error_msg = f"数据源{source_name}Cookie失效 (HTTP {response.status_code})"
|
||||
logger.error(error_msg)
|
||||
if source_num == '1' and not self._cookie_error_reported_1:
|
||||
self._cookie_error_reported_1 = True
|
||||
self.notify_error("Cookie失效告警", error_msg)
|
||||
elif source_num == '2' and not self._cookie_error_reported_2:
|
||||
self._cookie_error_reported_2 = True
|
||||
self.notify_error("Cookie失效告警", error_msg)
|
||||
return []
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
total = data.get('total', 0)
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
|
||||
if total == 0 and len(rows) == 0:
|
||||
error_msg = f"数据源{source_name}返回空数据,Cookie可能已失效"
|
||||
logger.warning(error_msg)
|
||||
if source_num == '1' and not self._cookie_error_reported_1:
|
||||
self._cookie_error_reported_1 = True
|
||||
self.notify_warning("数据源为空告警", error_msg)
|
||||
elif source_num == '2' and not self._cookie_error_reported_2:
|
||||
self._cookie_error_reported_2 = True
|
||||
self.notify_warning("数据源为空告警", error_msg)
|
||||
return []
|
||||
|
||||
if source_num == '1' and self._cookie_error_reported_1:
|
||||
self._cookie_error_reported_1 = False
|
||||
self.notify_success("数据源恢复", f"数据源{source_name}已恢复正常")
|
||||
elif source_num == '2' and self._cookie_error_reported_2:
|
||||
self._cookie_error_reported_2 = False
|
||||
self.notify_success("数据源恢复", f"数据源{source_name}已恢复正常")
|
||||
|
||||
logger.info(f"Fetched data: total={total}")
|
||||
return rows
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"数据源{source_name}请求失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
if source_num == '1' and not self._cookie_error_reported_1:
|
||||
self._cookie_error_reported_1 = True
|
||||
self.notify_error("数据源请求失败", error_msg)
|
||||
elif source_num == '2' and not self._cookie_error_reported_2:
|
||||
self._cookie_error_reported_2 = True
|
||||
self.notify_error("数据源请求失败", error_msg)
|
||||
return []
|
||||
|
||||
def _check_id_exists(self, conn, id_value: Any) -> bool:
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
sql = f"SELECT id FROM {self.db_table} WHERE id = %s"
|
||||
cursor.execute(sql, (id_value,))
|
||||
result = cursor.fetchone()
|
||||
return result is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Check ID exists failed: {e}")
|
||||
return False
|
||||
|
||||
def _insert_data(self, conn, data: Dict[str, Any], zhid: str) -> bool:
|
||||
try:
|
||||
def get_val(field):
|
||||
val = data.get(field)
|
||||
return val if val is not None else None
|
||||
|
||||
other_text = json.dumps(data.get("other_text")) if data.get("other_text") else None
|
||||
|
||||
sql = f"""
|
||||
INSERT INTO {self.db_table} (
|
||||
id, source_id, name, phone, location, province_id, city_id, address,
|
||||
app_name, keshi, create_time, update_time, admin_id, distribute_admin_id,
|
||||
distribute_time, follow_up_status, is_recover, recover_time, `desc`,
|
||||
other, wx, ggmc, gid, recover_time_text, weight_id_text,
|
||||
follow_up_status_text, follow_text, origin_id_text, admin_id_text,
|
||||
sendto, zhid
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, 0, %s
|
||||
)
|
||||
"""
|
||||
|
||||
values = (
|
||||
get_val("id"), get_val("source_id"), get_val("name"), get_val("phone"),
|
||||
get_val("location"), get_val("province_id"), get_val("city_id"), get_val("address"),
|
||||
get_val("app_name"), get_val("keshi"), get_val("create_time"), get_val("update_time"),
|
||||
get_val("admin_id"), get_val("distribute_admin_id"), get_val("distribute_time"),
|
||||
get_val("follow_up_status"), get_val("is_recover"), get_val("recover_time"),
|
||||
get_val("desc"), other_text, get_val("wx"), get_val("ggmc"),
|
||||
get_val("gid"), get_val("recover_time_text"), get_val("weight_id_text"),
|
||||
get_val("follow_up_status_text"), get_val("follow_text"), get_val("origin_id_text"),
|
||||
get_val("admin_id_text"), zhid
|
||||
)
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(sql, values)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully inserted data, ID: {data.get('id')}")
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f"Insert data failed (ID: {data.get('id')}): {e}")
|
||||
return False
|
||||
|
||||
def _get_pending_records(self, conn) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
sql = f"SELECT * FROM {self.db_table} WHERE {self.sendto_field} = 0 ORDER BY id DESC"
|
||||
cursor.execute(sql)
|
||||
return cursor.fetchall()
|
||||
except Exception as e:
|
||||
logger.error(f"Get pending records failed: {e}")
|
||||
return []
|
||||
|
||||
def _get_pending_records_by_zhid(self, conn, zhid: str) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
sql = f"SELECT * FROM {self.db_table} WHERE {self.sendto_field} = 0 AND zhid = %s ORDER BY id DESC"
|
||||
cursor.execute(sql, (zhid,))
|
||||
return cursor.fetchall()
|
||||
except Exception as e:
|
||||
logger.error(f"Get pending records by zhid failed: {e}")
|
||||
return []
|
||||
|
||||
def _update_sendto(self, conn, ids: List[int]) -> bool:
|
||||
if not ids:
|
||||
return True
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
placeholders = ", ".join(["%s"] * len(ids))
|
||||
sql = f"UPDATE {self.db_table} SET {self.sendto_field} = 1 WHERE id IN ({placeholders})"
|
||||
cursor.execute(sql, ids)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f"Update sendto failed: {e}")
|
||||
return False
|
||||
|
||||
def _sync_node_to_manager(self):
|
||||
from services.node_manager import node_manager
|
||||
from database_service import get_db_service
|
||||
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
node = db_service.get_node(self.node_id)
|
||||
if node:
|
||||
existing = node_manager.nodes.get(self.node_id)
|
||||
if existing:
|
||||
existing.api_url = node.api_url
|
||||
existing.api_key = node.api_key
|
||||
else:
|
||||
from services.node_manager import Node
|
||||
new_node = Node(
|
||||
node_id=node.node_id,
|
||||
name=node.name,
|
||||
api_url=node.api_url,
|
||||
api_key=node.api_key,
|
||||
description=node.description or "",
|
||||
group=node.group or "default"
|
||||
)
|
||||
node_manager.nodes[node.node_id] = new_node
|
||||
logger.info(f"Node {self.node_id} synced to node_manager")
|
||||
|
||||
def _send_via_center(self, node_id: str, who: str, msg: str) -> Dict[str, Any]:
|
||||
from services.node_manager import node_manager
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
self._sync_node_to_manager()
|
||||
effective_node_id = node_id or self.node_id
|
||||
|
||||
def _send_in_thread():
|
||||
async def _send_async():
|
||||
return await node_manager.send_message(
|
||||
node_id=effective_node_id,
|
||||
who=who,
|
||||
msg=msg,
|
||||
msg_type="text"
|
||||
)
|
||||
return asyncio.run(_send_async())
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(_send_in_thread)
|
||||
result = future.result(timeout=15)
|
||||
logger.info(f"Message sent via center to {who} via {effective_node_id}: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Send via center failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _process_pending_records(self, conn, records: List[Dict[str, Any]], datasource: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not records:
|
||||
logger.info("No records to process")
|
||||
return {"success": True, "sent": 0, "skipped": 0}
|
||||
|
||||
target_node_id = datasource.get("node_id", self.node_id)
|
||||
target_receiver = datasource.get("receiver", self.receiver)
|
||||
source_name = datasource.get("name", "unknown")
|
||||
|
||||
logger.info(f"Processing {len(records)} pending records for {source_name} -> {target_node_id}/{target_receiver}")
|
||||
|
||||
sent_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for record in records:
|
||||
record_id = record.get("id")
|
||||
phone = record.get("phone", "")
|
||||
create_time = record.get("create_time")
|
||||
myid = record.get("myid", 0)
|
||||
|
||||
logger.info(f"Record ID: {record_id}")
|
||||
|
||||
if create_time:
|
||||
create_time_dt = create_time if hasattr(create_time, 'hour') else datetime.now()
|
||||
hour = create_time_dt.hour
|
||||
|
||||
if 0 <= hour <= 7:
|
||||
logger.info(f"Hour {hour} in 0-7: skip")
|
||||
skipped_count += 1
|
||||
continue
|
||||
elif 8 <= hour <= 16:
|
||||
if myid % 2 == 0:
|
||||
target = target_receiver
|
||||
else:
|
||||
target = target_receiver
|
||||
logger.info(f"Hour {hour}, myid={myid}: sending to {target}")
|
||||
elif 17 <= hour <= 21:
|
||||
target = target_receiver
|
||||
logger.info(f"Hour {hour}: sending to {target}")
|
||||
elif 22 <= hour <= 23:
|
||||
logger.info(f"Hour {hour} in 22-23: skip")
|
||||
skipped_count += 1
|
||||
continue
|
||||
else:
|
||||
target = target_receiver
|
||||
|
||||
msg = f"{phone}\r\n时间:{create_time}"
|
||||
result = self._send_via_center(target_node_id, target, msg)
|
||||
if result.get("success"):
|
||||
sent_count += 1
|
||||
|
||||
return {"success": True, "sent": sent_count, "skipped": skipped_count, "total": len(records)}
|
||||
|
||||
def run_task(self) -> Dict[str, Any]:
|
||||
if self._running:
|
||||
logger.warning("Previous task still running, skipping")
|
||||
return {"success": False, "error": "上一次执行尚未完成"}
|
||||
|
||||
self._running = True
|
||||
try:
|
||||
logger.info(f"Starting task at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
try:
|
||||
with self._db_connection() as conn:
|
||||
datasources = self._get_datasources()
|
||||
all_pending_records = []
|
||||
|
||||
for idx, datasource in enumerate(datasources):
|
||||
rows = self._fetch_from_datasource(datasource)
|
||||
logger.info(f"Source {datasource.get('name')}: fetched {len(rows)} rows")
|
||||
|
||||
for row in rows:
|
||||
record_id = row.get("id")
|
||||
if not record_id:
|
||||
logger.warning("Record missing ID, skip")
|
||||
continue
|
||||
|
||||
if self._check_id_exists(conn, record_id):
|
||||
logger.info(f"ID {record_id} exists, skip")
|
||||
continue
|
||||
|
||||
self._insert_data(conn, row, datasource.get("zhid", "0"))
|
||||
|
||||
if idx < len(datasources) - 1 and self.send_interval > 0:
|
||||
logger.info(f"Waiting {self.send_interval}s before next source...")
|
||||
time.sleep(self.send_interval)
|
||||
|
||||
for datasource in datasources:
|
||||
records = self._get_pending_records_by_zhid(conn, datasource.get("zhid", "0"))
|
||||
if records:
|
||||
send_result = self._process_pending_records(conn, records, datasource)
|
||||
logger.info(f"Source {datasource.get('name')} send result: {send_result}")
|
||||
all_pending_records.extend(records)
|
||||
|
||||
if all_pending_records:
|
||||
ids = [r["id"] for r in all_pending_records]
|
||||
self._update_sendto(conn, ids)
|
||||
logger.info(f"Updated {len(ids)} records sendto=1")
|
||||
|
||||
return {"success": True, "message": "Task completed", "total": len(all_pending_records)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database operation error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"run_task error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
def aggregate_results(self, results: List[Dict[str, Any]], shift_name: str = "") -> Dict[str, Any]:
|
||||
success_count = sum(1 for r in results if isinstance(r, dict) and r.get("success"))
|
||||
total_sent = sum(r.get("sent", 0) for r in results if isinstance(r, dict))
|
||||
total_skipped = sum(r.get("skipped", 0) for r in results if isinstance(r, dict))
|
||||
return {
|
||||
"success": success_count == len(results),
|
||||
"shift": shift_name,
|
||||
"total": len(results),
|
||||
"success_count": success_count,
|
||||
"total_sent": total_sent,
|
||||
"total_skipped": total_skipped,
|
||||
"results": results
|
||||
}
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
action = params.get("action", "send")
|
||||
|
||||
if action == "send":
|
||||
return self.run_task()
|
||||
elif action == "test":
|
||||
return self.test_config()
|
||||
elif action == "test_send":
|
||||
msg = params.get("msg", "测试消息")
|
||||
node_id = params.get("node_id", self.node_id)
|
||||
receiver = params.get("receiver", self.receiver)
|
||||
return self._send_via_center(node_id, receiver, msg)
|
||||
elif action == "query_pending":
|
||||
try:
|
||||
with self._db_connection() as conn:
|
||||
records = self._get_pending_records(conn)
|
||||
return {"success": True, "count": len(records), "records": records[:10]}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
return {"success": False, "error": "未知操作"}
|
||||
|
||||
def test_config(self) -> Dict[str, Any]:
|
||||
try:
|
||||
cron = croniter(self.cron_expression, datetime.now())
|
||||
prev_run = cron.get_prev(datetime)
|
||||
next_run = cron.get_next(datetime)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"cron": self.cron_expression,
|
||||
"prev_run": str(prev_run),
|
||||
"next_run": str(next_run),
|
||||
"node_id": self.node_id,
|
||||
"receiver": self.receiver,
|
||||
"datasource1": {
|
||||
"node_id": self.node_id_1 or "(default)",
|
||||
"receiver": self.receiver_1 or "(default)"
|
||||
},
|
||||
"datasource2": {
|
||||
"node_id": self.node_id_2 or "(default)",
|
||||
"receiver": self.receiver_2 or "(default)"
|
||||
},
|
||||
"db_host": self.db_host,
|
||||
"db_name": self.db_name,
|
||||
"db_table": self.db_table,
|
||||
"cookie_1": self.cookie_1[:50] + "..." if len(self.cookie_1) > 50 else self.cookie_1,
|
||||
"cookie_2": self.cookie_2[:50] + "..." if len(self.cookie_2) > 50 else self.cookie_2,
|
||||
"config": {
|
||||
"fetch_timeout": self.fetch_timeout,
|
||||
"send_interval": self.send_interval
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
# ///
|
||||
# base.py
|
||||
# 描述:插件基类定义,插件系统核心接口
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# 更新日期:2026-04-06
|
||||
# ///
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PluginType(Enum):
|
||||
MESSAGE_HANDLER = "message_handler"
|
||||
DATA_SOURCE = "data_source"
|
||||
ACTION_TRIGGER = "action_trigger"
|
||||
AI_AGENT = "ai_agent"
|
||||
SCHEDULED_TASK = "scheduled_task"
|
||||
HTTP_SCHEDULED_SENDER = "http_scheduled_sender"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class PluginBase(ABC):
|
||||
plugin_name: str = ""
|
||||
plugin_version: str = "1.0.0"
|
||||
plugin_type: PluginType = PluginType.CUSTOM
|
||||
plugin_description: str = ""
|
||||
plugin_author: str = ""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = False
|
||||
self.config: Dict[str, Any] = {}
|
||||
self._metadata: Dict[str, Any] = {}
|
||||
|
||||
@abstractmethod
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
def enable(self):
|
||||
self.enabled = True
|
||||
|
||||
def disable(self):
|
||||
self.enabled = False
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.plugin_name,
|
||||
"version": self.plugin_version,
|
||||
"type": self.plugin_type.value,
|
||||
"description": self.plugin_description,
|
||||
"author": self.plugin_author,
|
||||
"enabled": self.enabled,
|
||||
"config": self.config,
|
||||
"metadata": self._metadata,
|
||||
}
|
||||
|
||||
def set_metadata(self, key: str, value: Any):
|
||||
self._metadata[key] = value
|
||||
|
||||
def get_metadata(self, key: str, default: Any = None) -> Any:
|
||||
return self._metadata.get(key, default)
|
||||
|
||||
def validate_config(self, config: Dict[str, Any], required_fields: List[str]) -> tuple:
|
||||
missing = [f for f in required_fields if not config.get(f)]
|
||||
if missing:
|
||||
return False, f"缺少必填字段: {', '.join(missing)}"
|
||||
return True, ""
|
||||
|
||||
|
||||
class MessageHandlerPlugin(PluginBase):
|
||||
plugin_type = PluginType.MESSAGE_HANDLER
|
||||
|
||||
@abstractmethod
|
||||
def handle_message(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def should_handle(self, message: Dict[str, Any]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class DataSourcePlugin(PluginBase):
|
||||
plugin_type = PluginType.DATA_SOURCE
|
||||
|
||||
@abstractmethod
|
||||
def fetch_data(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
|
||||
|
||||
class ActionTriggerPlugin(PluginBase):
|
||||
plugin_type = PluginType.ACTION_TRIGGER
|
||||
|
||||
@abstractmethod
|
||||
def trigger(self, action: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
def get_supported_actions(self) -> List[str]:
|
||||
return []
|
||||
|
||||
|
||||
class AIAgentPlugin(PluginBase):
|
||||
plugin_type = PluginType.AI_AGENT
|
||||
|
||||
@abstractmethod
|
||||
def process(self, input_text: str, context: Dict[str, Any]) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_response(self, messages: List[Dict[str, str]]) -> str:
|
||||
pass
|
||||
|
||||
def get_system_prompt(self) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class ScheduledTaskPlugin(PluginBase):
|
||||
plugin_type = PluginType.SCHEDULED_TASK
|
||||
|
||||
@abstractmethod
|
||||
def get_cron_expression(self) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def should_run_and_get_next(self) -> tuple:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run_task(self) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
def on_task_success(self, result: Dict[str, Any]):
|
||||
pass
|
||||
|
||||
def on_task_error(self, error: str):
|
||||
pass
|
||||
|
||||
def notify(self, title: str, message: str, level: str = "info") -> bool:
|
||||
from services.plugin_notification_service import plugin_notification_service
|
||||
return plugin_notification_service.send_notification(
|
||||
plugin_name=self.plugin_name,
|
||||
title=title,
|
||||
message=message,
|
||||
level=level
|
||||
)
|
||||
|
||||
def notify_error(self, title: str, message: str) -> bool:
|
||||
return self.notify(title, message, level="error")
|
||||
|
||||
def notify_warning(self, title: str, message: str) -> bool:
|
||||
return self.notify(title, message, level="warning")
|
||||
|
||||
def notify_success(self, title: str, message: str) -> bool:
|
||||
return self.notify(title, message, level="success")
|
||||
|
||||
|
||||
class HTTPScheduledSenderPlugin(PluginBase):
|
||||
plugin_type = PluginType.HTTP_SCHEDULED_SENDER
|
||||
|
||||
@abstractmethod
|
||||
def get_cron_expression(self) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def should_run_and_get_next(self) -> tuple:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run_task(self) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_data(self) -> Any:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_items(self, data: Any) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_message(self, items: List[Dict[str, Any]]) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
_plugins: Dict[str, PluginBase] = {}
|
||||
_callbacks: List[Callable] = []
|
||||
_event_handlers: Dict[str, List[Callable]] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, plugin: PluginBase, name: str = None) -> bool:
|
||||
plugin_name = name or plugin.plugin_name
|
||||
if plugin_name in cls._plugins:
|
||||
return False
|
||||
cls._plugins[plugin_name] = plugin
|
||||
cls._emit_event("plugin_registered", plugin)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, name: str) -> bool:
|
||||
if name in cls._plugins:
|
||||
plugin = cls._plugins[name]
|
||||
del cls._plugins[name]
|
||||
cls._emit_event("plugin_unregistered", plugin)
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> Optional[PluginBase]:
|
||||
return cls._plugins.get(name)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls) -> List[PluginBase]:
|
||||
return list(cls._plugins.values())
|
||||
|
||||
@classmethod
|
||||
def get_by_type(cls, plugin_type: PluginType) -> List[PluginBase]:
|
||||
return [p for p in cls._plugins.values() if p.plugin_type == plugin_type]
|
||||
|
||||
@classmethod
|
||||
def get_scheduled_tasks(cls) -> List[ScheduledTaskPlugin]:
|
||||
result = []
|
||||
for p in cls._plugins.values():
|
||||
if p.plugin_type == PluginType.SCHEDULED_TASK and p.enabled:
|
||||
result.append(p)
|
||||
elif p.plugin_type == PluginType.HTTP_SCHEDULED_SENDER and p.enabled:
|
||||
result.append(p)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def enable(cls, name: str) -> bool:
|
||||
plugin = cls.get(name)
|
||||
if plugin:
|
||||
plugin.enable()
|
||||
cls._emit_event("plugin_enabled", plugin)
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def disable(cls, name: str) -> bool:
|
||||
plugin = cls.get(name)
|
||||
if plugin:
|
||||
plugin.disable()
|
||||
cls._emit_event("plugin_disabled", plugin)
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def on_register(cls, callback: Callable):
|
||||
cls._callbacks.append(callback)
|
||||
|
||||
@classmethod
|
||||
def on_event(cls, event: str, handler: Callable):
|
||||
if event not in cls._event_handlers:
|
||||
cls._event_handlers[event] = []
|
||||
cls._event_handlers[event].append(handler)
|
||||
|
||||
@classmethod
|
||||
def _emit_event(cls, event: str, *args, **kwargs):
|
||||
for handler in cls._event_handlers.get(event, []):
|
||||
try:
|
||||
handler(*args, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_plugin_types(cls) -> Dict[str, str]:
|
||||
return {
|
||||
pt.value: pt.name.replace("_", " ").title()
|
||||
for pt in PluginType
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
# ///
|
||||
# builtin.py
|
||||
# 描述:内置插件实现
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# 更新日期:2026-04-06
|
||||
# ///
|
||||
|
||||
import httpx
|
||||
from typing import Any, Dict, List
|
||||
from plugins.base import (
|
||||
PluginBase,
|
||||
PluginType,
|
||||
MessageHandlerPlugin,
|
||||
DataSourcePlugin,
|
||||
AIAgentPlugin,
|
||||
PluginRegistry,
|
||||
)
|
||||
from plugins.http_scheduled_sender import HTTPScheduledSenderPlugin
|
||||
|
||||
|
||||
class HTTPDataSourcePlugin(DataSourcePlugin):
|
||||
plugin_name = "http_data_source"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_description = "HTTP数据源插件,从外部API获取数据"
|
||||
plugin_author = "AI Generated"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.base_url = ""
|
||||
self.timeout = 30
|
||||
self.headers = {}
|
||||
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
self.config = config
|
||||
self.base_url = config.get("base_url", "")
|
||||
self.timeout = config.get("timeout", 30)
|
||||
self.headers = config.get("headers", {})
|
||||
return True
|
||||
|
||||
def fetch_data(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
return []
|
||||
|
||||
try:
|
||||
response = httpx.get(
|
||||
f"{self.base_url}{query}",
|
||||
params=params,
|
||||
headers=self.headers,
|
||||
timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
elif isinstance(data, dict) and "data" in data:
|
||||
return data["data"]
|
||||
return [data] if data else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
query = params.get("query", "/")
|
||||
query_params = params.get("params", {})
|
||||
data = self.fetch_data(query, query_params)
|
||||
return {"success": True, "data": data, "count": len(data)}
|
||||
|
||||
|
||||
class DatabaseQueryPlugin(DataSourcePlugin):
|
||||
plugin_name = "database_query"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_description = "数据库查询插件,通过SQL或API查询数据"
|
||||
plugin_author = "AI Generated"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.connection_string = ""
|
||||
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
self.config = config
|
||||
self.connection_string = config.get("connection_string", "")
|
||||
return True
|
||||
|
||||
def fetch_data(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
source_type = params.get("source_type", "api")
|
||||
if source_type == "api":
|
||||
return {"success": True, "data": [], "message": "Use http_data_source plugin"}
|
||||
return {"success": False, "data": [], "message": "Unknown source type"}
|
||||
|
||||
|
||||
class WebhookTriggerPlugin(PluginBase):
|
||||
plugin_name = "webhook_trigger"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_description = "Webhook触发器,接收外部webhook并触发相应动作"
|
||||
plugin_author = "AI Generated"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.triggers: Dict[str, str] = {}
|
||||
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
self.config = config
|
||||
self.triggers = config.get("triggers", {})
|
||||
return True
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
event = params.get("event", "")
|
||||
callback_url = self.triggers.get(event, "")
|
||||
if not callback_url:
|
||||
return {"success": False, "message": f"No trigger for event: {event}"}
|
||||
|
||||
try:
|
||||
response = httpx.post(callback_url, json=params.get("data", {}), timeout=10)
|
||||
return {"success": True, "status_code": response.status_code}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def add_trigger(self, event: str, callback_url: str):
|
||||
self.triggers[event] = callback_url
|
||||
|
||||
def remove_trigger(self, event: str) -> bool:
|
||||
if event in self.triggers:
|
||||
del self.triggers[event]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class SimpleAIAgentPlugin(AIAgentPlugin):
|
||||
plugin_name = "simple_ai_agent"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_description = "简单AI智能体,支持调用外部LLM API"
|
||||
plugin_author = "AI Generated"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.api_url = ""
|
||||
self.api_key = ""
|
||||
self.model = "gpt-3.5-turbo"
|
||||
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
self.config = config
|
||||
self.api_url = config.get("api_url", "")
|
||||
self.api_key = config.get("api_key", "")
|
||||
self.model = config.get("model", "gpt-3.5-turbo")
|
||||
return True
|
||||
|
||||
def process(self, input_text: str, context: Dict[str, Any]) -> str:
|
||||
messages = [{"role": "user", "content": input_text}]
|
||||
return self.get_response(messages)
|
||||
|
||||
def get_response(self, messages: List[Dict[str, str]]) -> str:
|
||||
if not self.api_url or not self.api_key:
|
||||
return "AI agent not configured"
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
self.api_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": messages
|
||||
},
|
||||
timeout=60
|
||||
)
|
||||
result = response.json()
|
||||
return result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
input_text = params.get("input", "")
|
||||
context = params.get("context", {})
|
||||
result = self.process(input_text, context)
|
||||
return {"success": True, "result": result}
|
||||
|
||||
|
||||
def register_builtin_plugins():
|
||||
from plugins.advanced_sender_plugin import AdvancedSenderPlugin
|
||||
builtin_plugins = [
|
||||
HTTPDataSourcePlugin(),
|
||||
DatabaseQueryPlugin(),
|
||||
WebhookTriggerPlugin(),
|
||||
SimpleAIAgentPlugin(),
|
||||
HTTPScheduledSenderPlugin(),
|
||||
AdvancedSenderPlugin(),
|
||||
]
|
||||
|
||||
for plugin in builtin_plugins:
|
||||
PluginRegistry.register(plugin)
|
||||
@@ -0,0 +1,125 @@
|
||||
# Custom Plugins Directory
|
||||
|
||||
此目录用于存放热加载的自定义插件。
|
||||
|
||||
## 热加载机制
|
||||
|
||||
1. 插件文件放在此目录下
|
||||
2. 修改插件文件后会自动重新加载
|
||||
3. 无需重启 Docker
|
||||
|
||||
## 创建插件
|
||||
|
||||
```python
|
||||
# ///
|
||||
# my_plugin.py
|
||||
# 描述:我的自定义插件
|
||||
# ///
|
||||
|
||||
from plugins.base import PluginBase, PluginType
|
||||
|
||||
class MyPlugin(PluginBase):
|
||||
plugin_name = "my_plugin"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_type = PluginType.CUSTOM
|
||||
plugin_description = "我的自定义插件"
|
||||
plugin_author = "开发者"
|
||||
|
||||
def initialize(self, config: dict) -> bool:
|
||||
self.config = config
|
||||
return True
|
||||
|
||||
def execute(self, params: dict) -> dict:
|
||||
return {"success": True, "result": "done"}
|
||||
```
|
||||
|
||||
## 定时任务插件示例
|
||||
|
||||
```python
|
||||
# ///
|
||||
# my_scheduled_plugin.py
|
||||
# 描述:定时任务插件示例
|
||||
# ///
|
||||
|
||||
from plugins.base import ScheduledTaskPlugin
|
||||
from plugins.plugin_config_schema import (
|
||||
PluginConfigSchema, ConfigSection, ConfigField, FieldType
|
||||
)
|
||||
|
||||
def get_config_schema() -> PluginConfigSchema:
|
||||
schema = PluginConfigSchema("my_scheduled_plugin")
|
||||
schema.add_section(ConfigSection(
|
||||
name="basic",
|
||||
label="基本设置",
|
||||
fields=[
|
||||
ConfigField(name="cron", label="执行周期", field_type=FieldType.CRON, default="*/5 * * * *"),
|
||||
ConfigField(name="enabled", label="启用", field_type=FieldType.BOOLEAN, default=False)
|
||||
]
|
||||
))
|
||||
return schema
|
||||
|
||||
class MyScheduledPlugin(ScheduledTaskPlugin):
|
||||
plugin_name = "my_scheduled_plugin"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_description = "定时任务插件"
|
||||
plugin_author = "开发者"
|
||||
config_schema = staticmethod(get_config_schema)
|
||||
|
||||
def get_cron_expression(self) -> str:
|
||||
return self.cron_expression
|
||||
|
||||
def should_run_and_get_next(self) -> tuple:
|
||||
return True, None
|
||||
|
||||
def run_task(self) -> dict:
|
||||
self.notify_success("任务完成", "执行成功")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
## 通知功能
|
||||
|
||||
所有继承 `ScheduledTaskPlugin` 的插件可以使用通知:
|
||||
|
||||
```python
|
||||
self.notify("标题", "消息", "info")
|
||||
self.notify_error("错误", "错误详情")
|
||||
self.notify_warning("警告", "警告内容")
|
||||
self.notify_success("成功", "成功详情")
|
||||
```
|
||||
|
||||
## 表单化配置
|
||||
|
||||
```python
|
||||
from plugins.plugin_config_schema import (
|
||||
PluginConfigSchema, ConfigSection, ConfigField, FieldType
|
||||
)
|
||||
|
||||
def get_config_schema() -> PluginConfigSchema:
|
||||
schema = PluginConfigSchema("my_plugin")
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="basic",
|
||||
label="基本设置",
|
||||
fields=[
|
||||
ConfigField(name="key", label="键", field_type=FieldType.STRING, required=True),
|
||||
ConfigField(name="timeout", label="超时", field_type=FieldType.NUMBER, default=15),
|
||||
ConfigField(name="enabled", label="启用", field_type=FieldType.BOOLEAN, default=False)
|
||||
]
|
||||
))
|
||||
|
||||
return schema
|
||||
```
|
||||
|
||||
## 字段类型
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `STRING` | 文本输入 |
|
||||
| `PASSWORD` | 密码输入 |
|
||||
| `NUMBER` | 数字输入 |
|
||||
| `BOOLEAN` | 开关 |
|
||||
| `SELECT` | 下拉选择 |
|
||||
| `TEXTAREA` | 多行文本 |
|
||||
| `CRON` | Cron表达式 |
|
||||
|
||||
更多信息请参阅 [插件开发指南](../doc/wxauto_center/PLUGIN.md)
|
||||
@@ -0,0 +1,15 @@
|
||||
from plugins.base import PluginBase, PluginType
|
||||
|
||||
class MyPlugin(PluginBase):
|
||||
plugin_name = "my_plugin122112"
|
||||
plugin_version = "5.0.0"
|
||||
plugin_type = PluginType.CUSTOM
|
||||
plugin_description = "我的自定义插件测试yixia"
|
||||
plugin_author = "User"
|
||||
|
||||
def initialize(self, config) -> bool:
|
||||
self.config = config
|
||||
return True
|
||||
|
||||
def execute(self, params) -> dict:
|
||||
return {"success": True, "message": "Hello!"}
|
||||
@@ -0,0 +1,24 @@
|
||||
# ///
|
||||
# sample_plugin.py
|
||||
# 描述:示例自定义插件
|
||||
# ///
|
||||
|
||||
from plugins.base import PluginBase, PluginType
|
||||
|
||||
class SamplePlugin(PluginBase):
|
||||
plugin_name = "sample_plugin"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_type = PluginType.CUSTOM
|
||||
plugin_description = "示例自定义插件"
|
||||
plugin_author = "User"
|
||||
|
||||
def initialize(self, config) -> bool:
|
||||
self.config = config
|
||||
return True
|
||||
|
||||
def execute(self, params) -> dict:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Sample plugin executed!",
|
||||
"params": params
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
# ///
|
||||
# http_scheduled_sender.py
|
||||
# 描述:定时HTTP数据源推送插件,可配置请求参数和响应解析
|
||||
# 支持定时从HTTP API获取数据并发送到指定节点
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
# ///
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import concurrent.futures
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from croniter import croniter
|
||||
from plugins.base import ScheduledTaskPlugin
|
||||
|
||||
|
||||
class HTTPScheduledSenderPlugin(ScheduledTaskPlugin):
|
||||
plugin_name = "http_scheduled_sender"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_description = "定时HTTP数据源推送,可配置请求参数和响应解析"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.node_id = ""
|
||||
self.receiver = ""
|
||||
self.cron_expression = "0 8 * * *"
|
||||
self._enabled = False
|
||||
self._last_run_time = None
|
||||
|
||||
self.http_url = ""
|
||||
self.http_method = "GET"
|
||||
self.http_headers = {}
|
||||
self.http_body = None
|
||||
self.http_params = {}
|
||||
self.http_timeout = 30
|
||||
|
||||
self.response_data_path = ""
|
||||
self.message_template = "{title}\n{url}"
|
||||
self.items = []
|
||||
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
self.config = config
|
||||
self.node_id = config.get("node_id", "")
|
||||
self.receiver = config.get("receiver", "")
|
||||
self.cron_expression = config.get("cron", "0 8 * * *")
|
||||
|
||||
self.http_url = config.get("http_url", "")
|
||||
self.http_method = config.get("http_method", "GET")
|
||||
self.http_headers = config.get("http_headers", {})
|
||||
self.http_body = config.get("http_body")
|
||||
self.http_params = config.get("http_params", {})
|
||||
self.http_timeout = config.get("http_timeout", 30)
|
||||
|
||||
self.response_data_path = config.get("response_data_path", "")
|
||||
self.message_template = config.get("message_template", "{title}\n{url}")
|
||||
|
||||
return True
|
||||
|
||||
def enable(self):
|
||||
self._enabled = True
|
||||
self.enabled = True
|
||||
|
||||
def disable(self):
|
||||
self._enabled = False
|
||||
self.enabled = False
|
||||
|
||||
def get_cron_expression(self) -> str:
|
||||
return self.cron_expression
|
||||
|
||||
def should_run_and_get_next(self) -> tuple:
|
||||
try:
|
||||
now = datetime.now()
|
||||
cron = croniter(self.cron_expression, now)
|
||||
prev_run = cron.get_prev(datetime)
|
||||
next_run = cron.get_next(datetime)
|
||||
if self._last_run_time is None or self._last_run_time != prev_run:
|
||||
self._last_run_time = prev_run
|
||||
return True, next_run
|
||||
return False, next_run
|
||||
except Exception:
|
||||
return False, None
|
||||
|
||||
def run_task(self) -> Dict[str, Any]:
|
||||
if not self.node_id or not self.receiver:
|
||||
return {"success": False, "error": "未配置 node_id 或 receiver"}
|
||||
|
||||
if not self.http_url:
|
||||
return {"success": False, "error": "未配置 http_url"}
|
||||
|
||||
data = self.fetch_data()
|
||||
if not data:
|
||||
return {"success": False, "error": "获取数据失败"}
|
||||
|
||||
items = self.parse_items(data)
|
||||
if not items:
|
||||
return {"success": False, "error": "解析数据失败"}
|
||||
|
||||
message = self.format_message(items)
|
||||
return self.send_message(message)
|
||||
|
||||
def fetch_data(self) -> Any:
|
||||
try:
|
||||
import httpx
|
||||
with httpx.Client(timeout=self.http_timeout) as client:
|
||||
if self.http_method.upper() == "GET":
|
||||
response = client.get(self.http_url, headers=self.http_headers, params=self.http_params)
|
||||
elif self.http_method.upper() == "POST":
|
||||
response = client.post(self.http_url, headers=self.http_headers, json=self.http_body, params=self.http_params)
|
||||
elif self.http_method.upper() == "PUT":
|
||||
response = client.put(self.http_url, headers=self.http_headers, json=self.http_body, params=self.http_params)
|
||||
else:
|
||||
response = client.request(self.http_method, self.http_url, headers=self.http_headers, json=self.http_body)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
return self._get_sample_data()
|
||||
|
||||
def _get_sample_data(self) -> Any:
|
||||
return [
|
||||
{"title": "示例新闻:人工智能改变生活", "url": "https://example.com/news/1"},
|
||||
{"title": "科技动态:新能源技术突破", "url": "https://example.com/news/2"},
|
||||
{"title": "财经速递:股市创新高", "url": "https://example.com/news/3"},
|
||||
]
|
||||
|
||||
def parse_items(self, data: Any) -> List[Dict[str, Any]]:
|
||||
if not data:
|
||||
return []
|
||||
|
||||
if not self.response_data_path:
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return [data]
|
||||
|
||||
try:
|
||||
parts = self.response_data_path.split(".")
|
||||
current = data
|
||||
for part in parts:
|
||||
if isinstance(current, dict):
|
||||
current = current.get(part, {})
|
||||
elif isinstance(current, list):
|
||||
idx = int(part) if part.isdigit() else 0
|
||||
current = current[idx] if idx < len(current) else {}
|
||||
if isinstance(current, list):
|
||||
return current
|
||||
return [current] if current else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def format_message(self, items: List[Dict[str, Any]]) -> str:
|
||||
if not items:
|
||||
return "暂无数据"
|
||||
|
||||
lines = []
|
||||
for i, item in enumerate(items[:10], 1):
|
||||
try:
|
||||
msg = self.message_template
|
||||
for key in item:
|
||||
placeholder = "{" + key + "}"
|
||||
if placeholder in msg:
|
||||
value = item.get(key, "")
|
||||
if isinstance(value, (dict, list)):
|
||||
value = json.dumps(value, ensure_ascii=False)
|
||||
msg = msg.replace(placeholder, str(value))
|
||||
lines.append(msg)
|
||||
if i < len(items[:10]):
|
||||
lines.append("")
|
||||
except Exception:
|
||||
continue
|
||||
return "\n".join(lines) if lines else "数据格式化失败"
|
||||
|
||||
def send_message(self, message: str) -> Dict[str, Any]:
|
||||
try:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(self._send_message_sync, message)
|
||||
return future.result(timeout=30)
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _send_message_sync(self, message: str) -> Dict[str, Any]:
|
||||
from services.node_manager import node_manager
|
||||
from database_service import get_db_service
|
||||
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
node = db_service.get_node(self.node_id)
|
||||
if node:
|
||||
existing = node_manager.nodes.get(self.node_id)
|
||||
if existing:
|
||||
existing.api_url = node.api_url
|
||||
existing.api_key = node.api_key
|
||||
else:
|
||||
from services.node_manager import Node
|
||||
new_node = Node(
|
||||
node_id=node.node_id,
|
||||
name=node.name,
|
||||
api_url=node.api_url,
|
||||
api_key=node.api_key,
|
||||
description=node.description or "",
|
||||
group=node.group or "default"
|
||||
)
|
||||
node_manager.nodes[node.node_id] = new_node
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(
|
||||
node_manager.send_message(
|
||||
node_id=self.node_id,
|
||||
who=self.receiver,
|
||||
msg=message,
|
||||
msg_type="text"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
action = params.get("action", "send")
|
||||
if action == "send":
|
||||
return self.run_task()
|
||||
elif action == "fetch":
|
||||
data = self.fetch_data()
|
||||
return {"success": True, "data": data}
|
||||
elif action == "parse":
|
||||
data = self.fetch_data()
|
||||
items = self.parse_items(data)
|
||||
return {"success": True, "items": items, "count": len(items)}
|
||||
elif action == "preview":
|
||||
data = self.fetch_data()
|
||||
items = self.parse_items(data)
|
||||
message = self.format_message(items)
|
||||
return {"success": True, "message": message}
|
||||
elif action == "test":
|
||||
try:
|
||||
cron = croniter(self.cron_expression, datetime.now())
|
||||
prev_run = cron.get_prev(datetime)
|
||||
next_run = cron.get_next(datetime)
|
||||
return {
|
||||
"success": True,
|
||||
"cron": self.cron_expression,
|
||||
"prev_run": str(prev_run),
|
||||
"next_run": str(next_run)
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
return {"success": False, "error": "未知操作"}
|
||||
@@ -0,0 +1,181 @@
|
||||
# ///
|
||||
# news_plugin.py
|
||||
# 描述:新闻定时推送插件,定时获取新闻并发送到指定节点,支持 cron 表达式
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
# ///
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import concurrent.futures
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
from croniter import croniter
|
||||
from plugins.base import ScheduledTaskPlugin
|
||||
|
||||
|
||||
class NewsScheduledPlugin(ScheduledTaskPlugin):
|
||||
plugin_name = "news_scheduled"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_description = "新闻定时推送插件,支持 cron 表达式定时发送新闻到微信"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.node_id = ""
|
||||
self.receiver = ""
|
||||
self.news_count = 5
|
||||
self.cron_expression = "0 8 * * *"
|
||||
self.news_api_url = "https://news.runoob.com/api/news"
|
||||
self._enabled = False
|
||||
self._last_run_time = None
|
||||
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
self.config = config
|
||||
self.node_id = config.get("node_id", "")
|
||||
self.receiver = config.get("receiver", "")
|
||||
self.news_count = config.get("news_count", 5)
|
||||
self.cron_expression = config.get("cron", "0 8 * * *")
|
||||
self.news_api_url = config.get("news_api_url", "https://news.runoob.com/api/news")
|
||||
return True
|
||||
|
||||
def enable(self):
|
||||
self._enabled = True
|
||||
self.enabled = True
|
||||
|
||||
def disable(self):
|
||||
self._enabled = False
|
||||
self.enabled = False
|
||||
|
||||
def get_cron_expression(self) -> str:
|
||||
return self.cron_expression
|
||||
|
||||
def should_run_and_get_next(self) -> tuple:
|
||||
try:
|
||||
now = datetime.now()
|
||||
cron = croniter(self.cron_expression, now)
|
||||
prev_run = cron.get_prev(datetime)
|
||||
next_run = cron.get_next(datetime)
|
||||
if self._last_run_time is None or self._last_run_time != prev_run:
|
||||
self._last_run_time = prev_run
|
||||
return True, next_run
|
||||
return False, next_run
|
||||
except Exception as e:
|
||||
return False, None
|
||||
|
||||
def run_task(self) -> Dict[str, Any]:
|
||||
if not self.node_id or not self.receiver:
|
||||
return {"success": False, "error": "未配置 node_id 或 receiver"}
|
||||
|
||||
news = self.fetch_news()
|
||||
if not news:
|
||||
return {"success": False, "error": "获取新闻失败"}
|
||||
|
||||
message = self.format_news_message(news)
|
||||
try:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(self._send_message_sync, message)
|
||||
return future.result(timeout=30)
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _send_message_sync(self, message: str) -> Dict[str, Any]:
|
||||
from services.node_manager import node_manager
|
||||
from database_service import get_db_service
|
||||
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
node = db_service.get_node(self.node_id)
|
||||
if node:
|
||||
existing = node_manager.nodes.get(self.node_id)
|
||||
if existing:
|
||||
existing.api_url = node.api_url
|
||||
existing.api_key = node.api_key
|
||||
else:
|
||||
from services.node_manager import Node
|
||||
new_node = Node(
|
||||
node_id=node.node_id,
|
||||
name=node.name,
|
||||
api_url=node.api_url,
|
||||
api_key=node.api_key,
|
||||
description=node.description or "",
|
||||
group=node.group or "default"
|
||||
)
|
||||
node_manager.nodes[node.node_id] = new_node
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(
|
||||
node_manager.send_message(
|
||||
node_id=self.node_id,
|
||||
who=self.receiver,
|
||||
msg=message,
|
||||
msg_type="text"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def fetch_news(self) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
import httpx
|
||||
response = httpx.get(self.news_api_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
return data[:self.news_count]
|
||||
elif isinstance(data, dict) and "data" in data:
|
||||
return data["data"][:self.news_count]
|
||||
return self._get_sample_news()
|
||||
except Exception as e:
|
||||
return self._get_sample_news()
|
||||
|
||||
def _get_sample_news(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{"title": "科技新闻:人工智能新突破", "url": "https://example.com/tech-ai"},
|
||||
{"title": "财经:股市今日上涨", "url": "https://example.com/stock"},
|
||||
{"title": "体育:足球比赛精彩回顾", "url": "https://example.com/football"},
|
||||
{"title": "娱乐:电影票房排行", "url": "https://example.com/movie"},
|
||||
{"title": "社会:民生政策解读", "url": "https://example.com/policy"},
|
||||
][:self.news_count]
|
||||
|
||||
def format_news_message(self, news: List[Dict[str, Any]]) -> str:
|
||||
if not news:
|
||||
return "今日暂无新闻"
|
||||
|
||||
lines = ["📰 今日新闻", ""]
|
||||
for i, item in enumerate(news, 1):
|
||||
title = item.get("title", item.get("name", "无标题"))
|
||||
url = item.get("url", "")
|
||||
lines.append(f"{i}. {title}")
|
||||
if url:
|
||||
lines.append(f" {url}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
action = params.get("action", "send")
|
||||
if action == "send":
|
||||
return self.run_task()
|
||||
elif action == "fetch":
|
||||
news = self.fetch_news()
|
||||
return {"success": True, "data": news, "count": len(news)}
|
||||
elif action == "preview":
|
||||
news = self.fetch_news()
|
||||
message = self.format_news_message(news)
|
||||
return {"success": True, "message": message}
|
||||
elif action == "test":
|
||||
cron = croniter(self.cron_expression, datetime.now())
|
||||
prev_run = cron.get_prev(datetime)
|
||||
next_run = cron.get_next(datetime)
|
||||
return {
|
||||
"success": True,
|
||||
"cron": self.cron_expression,
|
||||
"prev_run": str(prev_run),
|
||||
"next_run": str(next_run)
|
||||
}
|
||||
return {"success": False, "error": "未知操作"}
|
||||
@@ -0,0 +1,363 @@
|
||||
# ///
|
||||
# plugin_config_schema.py
|
||||
# 描述:插件配置Schema系统,支持变量定义和表单生成
|
||||
# 作者:User
|
||||
# 创建日期:2026-04-07
|
||||
# ///
|
||||
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FieldType(Enum):
|
||||
STRING = "string"
|
||||
PASSWORD = "password"
|
||||
NUMBER = "number"
|
||||
BOOLEAN = "boolean"
|
||||
SELECT = "select"
|
||||
TEXTAREA = "textarea"
|
||||
JSON = "json"
|
||||
CRON = "cron"
|
||||
DATASOURCE_LIST = "datasource_list"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigField:
|
||||
name: str
|
||||
label: str
|
||||
field_type: FieldType = FieldType.STRING
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
description: str = ""
|
||||
options: List[Dict[str, str]] = field(default_factory=list)
|
||||
placeholder: str = ""
|
||||
min_value: Any = None
|
||||
max_value: Any = None
|
||||
validation: Optional[Callable] = None
|
||||
depends_on: Optional[str] = None
|
||||
show_when: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigSection:
|
||||
name: str
|
||||
label: str
|
||||
fields: List[ConfigField] = field(default_factory=list)
|
||||
description: str = ""
|
||||
|
||||
|
||||
class PluginConfigSchema:
|
||||
def __init__(self, plugin_name: str):
|
||||
self.plugin_name = plugin_name
|
||||
self.sections: List[ConfigSection] = []
|
||||
self.variables: Dict[str, Any] = {}
|
||||
|
||||
def add_section(self, section: ConfigSection) -> "PluginConfigSchema":
|
||||
self.sections.append(section)
|
||||
return self
|
||||
|
||||
def add_variable(self, name: str, value: Any, description: str = "") -> "PluginConfigSchema":
|
||||
self.variables[name] = {
|
||||
"value": value,
|
||||
"description": description
|
||||
}
|
||||
return self
|
||||
|
||||
def to_form_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"plugin_name": self.plugin_name,
|
||||
"sections": [
|
||||
{
|
||||
"name": s.name,
|
||||
"label": s.label,
|
||||
"description": s.description,
|
||||
"fields": [
|
||||
{
|
||||
"name": f.name,
|
||||
"label": f.label,
|
||||
"type": f.field_type.value,
|
||||
"required": f.required,
|
||||
"default": f.default,
|
||||
"description": f.description,
|
||||
"options": f.options,
|
||||
"placeholder": f.placeholder,
|
||||
"min_value": f.min_value,
|
||||
"max_value": f.max_value,
|
||||
"depends_on": f.depends_on,
|
||||
"show_when": f.show_when
|
||||
}
|
||||
for f in s.fields
|
||||
]
|
||||
}
|
||||
for s in self.sections
|
||||
],
|
||||
"variables": self.variables
|
||||
}
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> tuple:
|
||||
errors = []
|
||||
for section in self.sections:
|
||||
for field in section.fields:
|
||||
if field.required and (field.name not in config or not config[field.name]):
|
||||
errors.append(f"{section.label} - {field.label} 为必填项")
|
||||
if field.validation and field.name in config:
|
||||
try:
|
||||
field.validation(config[field.name])
|
||||
except Exception as e:
|
||||
errors.append(f"{section.label} - {field.label} 验证失败: {str(e)}")
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def resolve_variables(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
resolved = config.copy()
|
||||
for var_name, var_info in self.variables.items():
|
||||
if var_name in resolved:
|
||||
continue
|
||||
resolved[var_name] = var_info["value"]
|
||||
return resolved
|
||||
|
||||
|
||||
def cron_validator(value: str) -> bool:
|
||||
parts = value.strip().split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError("Cron表达式必须有5个部分")
|
||||
return True
|
||||
|
||||
|
||||
def get_basic_config_schema() -> PluginConfigSchema:
|
||||
schema = PluginConfigSchema("basic")
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="basic",
|
||||
label="基本设置",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="cron",
|
||||
label="执行周期",
|
||||
field_type=FieldType.CRON,
|
||||
required=True,
|
||||
default="*/5 * * * *",
|
||||
description="Cron表达式,如 */5 * * * * 表示每5分钟执行"
|
||||
),
|
||||
ConfigField(
|
||||
name="enabled",
|
||||
label="启用插件",
|
||||
field_type=FieldType.BOOLEAN,
|
||||
default=False,
|
||||
description="是否启用此插件"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="node",
|
||||
label="节点设置",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="node_id",
|
||||
label="节点ID",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
default="wx1",
|
||||
placeholder="如 wx1",
|
||||
description="微信节点ID"
|
||||
),
|
||||
ConfigField(
|
||||
name="receiver",
|
||||
label="默认接收人",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
placeholder="联系人名称",
|
||||
description="默认消息接收人"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def get_datasource_schema() -> PluginConfigSchema:
|
||||
schema = PluginConfigSchema("datasource")
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="datasource",
|
||||
label="数据源配置",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="fetch_timeout",
|
||||
label="请求超时(秒)",
|
||||
field_type=FieldType.NUMBER,
|
||||
default=15,
|
||||
min_value=5,
|
||||
max_value=60,
|
||||
description="HTTP请求超时时间"
|
||||
),
|
||||
ConfigField(
|
||||
name="verify_ssl",
|
||||
label="验证SSL证书",
|
||||
field_type=FieldType.BOOLEAN,
|
||||
default=True,
|
||||
description="是否验证SSL证书"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="http_url",
|
||||
label="HTTP数据源",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="url",
|
||||
label="请求URL",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
placeholder="https://example.com/api",
|
||||
description="数据请求URL"
|
||||
),
|
||||
ConfigField(
|
||||
name="headers",
|
||||
label="请求头",
|
||||
field_type=FieldType.JSON,
|
||||
default={},
|
||||
description="HTTP请求头,JSON格式"
|
||||
),
|
||||
ConfigField(
|
||||
name="response_data_path",
|
||||
label="数据路径",
|
||||
field_type=FieldType.STRING,
|
||||
description="从响应中提取数据的路径,如 data.items"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def get_external_db_schema() -> PluginConfigSchema:
|
||||
schema = PluginConfigSchema("external_db")
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="external_db",
|
||||
label="外部数据库",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="host",
|
||||
label="数据库地址",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
placeholder="192.168.1.100",
|
||||
description="MySQL数据库地址"
|
||||
),
|
||||
ConfigField(
|
||||
name="port",
|
||||
label="端口",
|
||||
field_type=FieldType.NUMBER,
|
||||
default=3306,
|
||||
min_value=1,
|
||||
max_value=65535,
|
||||
description="数据库端口"
|
||||
),
|
||||
ConfigField(
|
||||
name="user",
|
||||
label="用户名",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
description="数据库用户名"
|
||||
),
|
||||
ConfigField(
|
||||
name="password",
|
||||
label="密码",
|
||||
field_type=FieldType.PASSWORD,
|
||||
description="数据库密码"
|
||||
),
|
||||
ConfigField(
|
||||
name="database",
|
||||
label="数据库名",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
description="数据库名称"
|
||||
),
|
||||
ConfigField(
|
||||
name="charset",
|
||||
label="字符集",
|
||||
field_type=FieldType.STRING,
|
||||
default="utf8mb4",
|
||||
description="数据库字符集"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
schema.add_section(ConfigSection(
|
||||
name="db_table",
|
||||
label="数据表配置",
|
||||
fields=[
|
||||
ConfigField(
|
||||
name="db_table",
|
||||
label="表名",
|
||||
field_type=FieldType.STRING,
|
||||
required=True,
|
||||
description="数据表名称"
|
||||
),
|
||||
ConfigField(
|
||||
name="sendto_field",
|
||||
label="发送状态字段",
|
||||
field_type=FieldType.STRING,
|
||||
default="sendto",
|
||||
description="标记发送状态的字段名"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def create_cookie_field(label: str, name: str = "cookie", required: bool = True) -> ConfigField:
|
||||
return ConfigField(
|
||||
name=name,
|
||||
label=label,
|
||||
field_type=FieldType.STRING,
|
||||
required=required,
|
||||
placeholder="PHPSESSID=xxx; think_var=zh-cn",
|
||||
description="Cookie字符串,多个用分号分隔"
|
||||
)
|
||||
|
||||
|
||||
def create_variable_assignment_field(
|
||||
name: str,
|
||||
label: str,
|
||||
source_options: List[Dict[str, str]] = None,
|
||||
default_source: str = "static"
|
||||
) -> List[ConfigField]:
|
||||
if source_options is None:
|
||||
source_options = [
|
||||
{"value": "static", "label": "固定值"},
|
||||
{"value": "cookie", "label": "从Cookie提取"},
|
||||
{"value": "header", "label": "从响应Header提取"},
|
||||
{"value": "env", "label": "环境变量"}
|
||||
]
|
||||
|
||||
return [
|
||||
ConfigField(
|
||||
name=f"{name}_source",
|
||||
label=f"{label}来源",
|
||||
field_type=FieldType.SELECT,
|
||||
default=default_source,
|
||||
options=source_options,
|
||||
description=f"选择{label}的来源方式"
|
||||
),
|
||||
ConfigField(
|
||||
name=f"{name}_value",
|
||||
label=f"{label}值",
|
||||
field_type=FieldType.STRING,
|
||||
default="",
|
||||
placeholder="固定值或提取表达式",
|
||||
description=f"当来源为固定值时直接填写,否则填写提取表达式"
|
||||
),
|
||||
ConfigField(
|
||||
name=f"{name}_extract_pattern",
|
||||
label=f"{label}提取规则",
|
||||
field_type=FieldType.STRING,
|
||||
placeholder="如: key=(.*?); 或正则表达式",
|
||||
description="从原始值中提取的规则,支持正则或字符串截取"
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,305 @@
|
||||
# ///
|
||||
# plugin_loader.py
|
||||
# 描述:热加载插件系统,支持外部插件目录和动态加载
|
||||
# 功能:监控插件目录变化,自动注册/卸载插件,无需重启
|
||||
# 支持插件自定义依赖自动安装
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
# ///
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional, Type, Any
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler, FileSystemEvent
|
||||
|
||||
from plugins.base import PluginBase, PluginRegistry
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("plugin_loader")
|
||||
|
||||
|
||||
class DependencyManager:
|
||||
def __init__(self):
|
||||
self._installed_cache: Dict[str, bool] = {}
|
||||
|
||||
def check_and_install(self, requirements: List[str]) -> tuple:
|
||||
missing = []
|
||||
for req in requirements:
|
||||
if not self._is_installed(req):
|
||||
missing.append(req)
|
||||
|
||||
if not missing:
|
||||
return True, []
|
||||
|
||||
logger.warning(f"Missing dependencies: {missing}")
|
||||
success, failed = self._install_packages(missing)
|
||||
return success, failed
|
||||
|
||||
def _is_installed(self, package: str) -> bool:
|
||||
if package in self._installed_cache:
|
||||
return self._installed_cache[package]
|
||||
|
||||
pkg_name = package.split(">")[0].split("<")[0].split("=")[0].strip()
|
||||
try:
|
||||
importlib.import_module(pkg_name.replace("-", "_"))
|
||||
self._installed_cache[package] = True
|
||||
return True
|
||||
except ImportError:
|
||||
self._installed_cache[package] = False
|
||||
return False
|
||||
|
||||
def _install_packages(self, packages: List[str]) -> tuple:
|
||||
success = []
|
||||
failed = []
|
||||
|
||||
for package in packages:
|
||||
try:
|
||||
logger.info(f"Installing dependency: {package}")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", package, "-q"],
|
||||
timeout=60,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL
|
||||
)
|
||||
self._installed_cache[package] = True
|
||||
success.append(package)
|
||||
logger.info(f"Installed: {package}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to install {package}: {e}")
|
||||
self._installed_cache[package] = False
|
||||
failed.append(package)
|
||||
|
||||
return len(failed) == 0, failed
|
||||
|
||||
|
||||
class PluginFileHandler(FileSystemEventHandler):
|
||||
def __init__(self, loader: 'HotReloadPluginLoader'):
|
||||
self.loader = loader
|
||||
|
||||
def on_created(self, event: FileSystemEvent):
|
||||
if not event.is_directory and event.src_path.endswith('.py'):
|
||||
logger.info(f"New plugin file detected: {event.src_path}")
|
||||
time.sleep(0.5)
|
||||
self.loader.scan_and_load_plugins()
|
||||
|
||||
def on_modified(self, event: FileSystemEvent):
|
||||
if not event.is_directory and event.src_path.endswith('.py'):
|
||||
logger.info(f"Plugin file modified: {event.src_path}")
|
||||
time.sleep(0.5)
|
||||
self.loader.scan_and_load_plugins()
|
||||
|
||||
def on_deleted(self, event: FileSystemEvent):
|
||||
if not event.is_directory and event.src_path.endswith('.py'):
|
||||
logger.info(f"Plugin file deleted: {event.src_path}")
|
||||
self.loader.scan_and_load_plugins()
|
||||
|
||||
|
||||
class HotReloadPluginLoader:
|
||||
def __init__(self, plugin_dir: str = "/app/plugins/custom"):
|
||||
self.plugin_dir = plugin_dir
|
||||
self._loaded_files: Dict[str, float] = {}
|
||||
self._loaded_classes: Dict[str, Type[PluginBase]] = {}
|
||||
self._observer: Optional[Observer] = None
|
||||
self._running = False
|
||||
self._dep_manager = DependencyManager()
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
|
||||
os.makedirs(self.plugin_dir, exist_ok=True)
|
||||
logger.info(f"Starting hot-reload plugin loader on: {self.plugin_dir}")
|
||||
|
||||
self.scan_and_load_plugins()
|
||||
|
||||
self._observer = Observer()
|
||||
event_handler = PluginFileHandler(self)
|
||||
self._observer.schedule(event_handler, self.plugin_dir, recursive=False)
|
||||
self._observer.start()
|
||||
self._running = True
|
||||
logger.info("Hot-reload plugin loader started")
|
||||
|
||||
def stop(self):
|
||||
if self._observer:
|
||||
self._observer.stop()
|
||||
self._observer.join()
|
||||
self._running = False
|
||||
logger.info("Hot-reload plugin loader stopped")
|
||||
|
||||
def scan_and_load_plugins(self):
|
||||
if not os.path.exists(self.plugin_dir):
|
||||
return
|
||||
|
||||
current_files = {}
|
||||
for filename in os.listdir(self.plugin_dir):
|
||||
if filename.endswith('.py') and not filename.startswith('_'):
|
||||
filepath = os.path.join(self.plugin_dir, filename)
|
||||
mtime = os.path.getmtime(filepath)
|
||||
current_files[filepath] = mtime
|
||||
|
||||
new_files = set(current_files.keys()) - set(self._loaded_files.keys())
|
||||
modified_files = [
|
||||
f for f in current_files
|
||||
if f in self._loaded_files and current_files[f] > self._loaded_files[f]
|
||||
]
|
||||
deleted_files = set(self._loaded_files.keys()) - set(current_files.keys())
|
||||
|
||||
for filepath in deleted_files:
|
||||
self._unload_plugin_by_file(filepath)
|
||||
|
||||
for filepath in list(new_files) + modified_files:
|
||||
self._load_plugin_from_file(filepath)
|
||||
|
||||
self._loaded_files = current_files
|
||||
|
||||
def _load_plugin_from_file(self, filepath: str):
|
||||
try:
|
||||
module_name = self._get_module_name(filepath)
|
||||
|
||||
if not self._check_plugin_dependencies(filepath):
|
||||
return
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
||||
if not spec or not spec.loader:
|
||||
return
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if (inspect.isclass(obj)
|
||||
and issubclass(obj, PluginBase)
|
||||
and obj != PluginBase):
|
||||
|
||||
plugin_instance = obj()
|
||||
self._register_plugin(plugin_instance)
|
||||
self._loaded_classes[plugin_instance.plugin_name] = obj
|
||||
logger.info(f"Loaded plugin: {plugin_instance.plugin_name} v{plugin_instance.plugin_version}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load plugin from {filepath}: {e}")
|
||||
|
||||
def _check_plugin_dependencies(self, filepath: str) -> bool:
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
requires = self._extract_requirements(content)
|
||||
if not requires:
|
||||
return True
|
||||
|
||||
success, failed = self._dep_manager.check_and_install(requires)
|
||||
if not success:
|
||||
logger.error(f"Plugin {filepath} has missing dependencies: {failed}")
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check dependencies for {filepath}: {e}")
|
||||
return False
|
||||
|
||||
def _extract_requirements(self, content: str) -> List[str]:
|
||||
requirements = []
|
||||
|
||||
for line in content.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('#') and 'requires:' in line.lower():
|
||||
req_part = line.split('requires:')[1].strip()
|
||||
requirements.extend([r.strip() for r in req_part.split(',')])
|
||||
|
||||
if line.startswith('import ') or line.startswith('from '):
|
||||
pass
|
||||
|
||||
return [r for r in requirements if r]
|
||||
|
||||
def _unload_plugin_by_file(self, filepath: str):
|
||||
module_name = self._get_module_name(filepath)
|
||||
|
||||
to_remove = []
|
||||
for plugin_name, cls in self._loaded_classes.items():
|
||||
cls_module = inspect.getmodule(cls)
|
||||
if cls_module and cls_module.__name__ == module_name:
|
||||
PluginRegistry.unregister(plugin_name)
|
||||
logger.info(f"Unloaded plugin: {plugin_name}")
|
||||
to_remove.append(plugin_name)
|
||||
|
||||
for plugin_name in to_remove:
|
||||
del self._loaded_classes[plugin_name]
|
||||
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
if filepath in self._loaded_files:
|
||||
del self._loaded_files[filepath]
|
||||
|
||||
def _register_plugin(self, plugin: PluginBase):
|
||||
existing = PluginRegistry.get(plugin.plugin_name)
|
||||
if existing:
|
||||
PluginRegistry.unregister(plugin.plugin_name)
|
||||
logger.info(f"Replacing existing plugin: {plugin.plugin_name}")
|
||||
|
||||
PluginRegistry.register(plugin)
|
||||
logger.info(f"Registered plugin: {plugin.plugin_name}")
|
||||
|
||||
def _get_module_name(self, filepath: str) -> str:
|
||||
filename = os.path.basename(filepath)
|
||||
module_name = filename[:-3]
|
||||
return f"custom_plugins.{module_name}"
|
||||
|
||||
def get_loaded_plugins(self) -> List[Dict[str, Any]]:
|
||||
return [p.get_info() for p in PluginRegistry.get_all()
|
||||
if p.plugin_name in self._loaded_classes]
|
||||
|
||||
def get_dependency_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"installed": list(self._dep_manager._installed_cache.keys()),
|
||||
"status": "ok"
|
||||
}
|
||||
|
||||
|
||||
def create_sample_plugin() -> str:
|
||||
sample_code = '''# ///
|
||||
# sample_plugin.py
|
||||
# 描述:示例自定义插件
|
||||
# requires: httpx, pytz
|
||||
# ///
|
||||
|
||||
from plugins.base import PluginBase, PluginType
|
||||
|
||||
class SamplePlugin(PluginBase):
|
||||
plugin_name = "sample_plugin"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_type = PluginType.CUSTOM
|
||||
plugin_description = "示例自定义插件"
|
||||
plugin_author = "User"
|
||||
|
||||
def initialize(self, config) -> bool:
|
||||
self.config = config
|
||||
return True
|
||||
|
||||
def execute(self, params) -> dict:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Sample plugin executed!",
|
||||
"params": params
|
||||
}
|
||||
'''
|
||||
|
||||
os.makedirs("/app/plugins/custom", exist_ok=True)
|
||||
sample_path = "/app/plugins/custom/sample_plugin.py"
|
||||
|
||||
if not os.path.exists(sample_path):
|
||||
with open(sample_path, 'w') as f:
|
||||
f.write(sample_code)
|
||||
logger.info(f"Created sample plugin at: {sample_path}")
|
||||
|
||||
return sample_path
|
||||
|
||||
|
||||
hot_plugin_loader = HotReloadPluginLoader()
|
||||
@@ -0,0 +1,15 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
httpx>=0.25.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-multipart>=0.0.6
|
||||
redis>=5.0.0
|
||||
rq>=1.15.0
|
||||
sqlalchemy>=2.0.0
|
||||
psycopg2-binary>=2.9.0
|
||||
pytz>=2024.1
|
||||
croniter>=2.0.0
|
||||
pymysql>=1.1.0
|
||||
requests>=2.31.0
|
||||
watchdog>=3.0.0
|
||||
@@ -0,0 +1,54 @@
|
||||
# ///
|
||||
# run.py
|
||||
# 描述:多进程启动脚本
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="WXAuto Center 启动脚本")
|
||||
parser.add_argument("--host", type=str, default=None, help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=None, help="监听端口")
|
||||
parser.add_argument("--workers", type=int, default=None, help="工作进程数")
|
||||
parser.add_argument("--reload", action="store_true", help="启用热重载")
|
||||
parser.add_argument("--debug", action="store_true", help="调试模式")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
from config import load_config_from_file, get_settings
|
||||
load_config_from_file("config.json")
|
||||
settings = get_settings()
|
||||
|
||||
host = args.host or settings.host
|
||||
port = args.port or settings.port
|
||||
workers = args.workers or settings.workers
|
||||
debug = args.debug or settings.debug
|
||||
|
||||
if workers > 1:
|
||||
print(f"Starting with {workers} workers...")
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=host,
|
||||
port=port,
|
||||
workers=workers,
|
||||
reload=False
|
||||
)
|
||||
else:
|
||||
print(f"Starting in single worker mode...")
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=args.reload,
|
||||
log_level="debug" if debug else "info"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# ///
|
||||
# clean_logs.sh
|
||||
# 描述:清理旧日志
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
#
|
||||
# 使用方式:
|
||||
# ./clean_logs.sh # 清理 30 天前的日志
|
||||
# ./clean_logs.sh 7 # 清理 7 天前的日志
|
||||
# ./clean_logs.sh --dry-run # 预览要清理的日志数量
|
||||
# ///
|
||||
|
||||
DB_HOST=${DB_HOST:-localhost}
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
DB_NAME=${DB_NAME:-wxauto}
|
||||
DB_USER=${DB_USER:-wxauto}
|
||||
DB_PASS=${DB_PASS:-wxauto_password}
|
||||
|
||||
export PGPASSWORD="$DB_PASS"
|
||||
|
||||
DAYS=${1:-30}
|
||||
DRY_RUN=false
|
||||
|
||||
if [ "$1" == "--dry-run" ]; then
|
||||
DRY_RUN=true
|
||||
DAYS=${2:-30}
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" == true ]; then
|
||||
echo "=== 预览将清理的日志 (${DAYS} 天前) ==="
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT COUNT(*) as logs_to_delete FROM logs WHERE timestamp < NOW() - INTERVAL '$DAYS days';"
|
||||
else
|
||||
echo "=== 清理 ${DAYS} 天前的日志 ==="
|
||||
read -p "确认删除? (y/n) " confirm
|
||||
if [ "$confirm" == "y" ]; then
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"DELETE FROM logs WHERE timestamp < NOW() - INTERVAL '$DAYS days';"
|
||||
echo "清理完成"
|
||||
else
|
||||
echo "已取消"
|
||||
fi
|
||||
fi
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# ///
|
||||
# query_logs.sh
|
||||
# 描述:查询最近日志
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
#
|
||||
# 使用方式:
|
||||
# ./query_logs.sh # 最近 50 条
|
||||
# ./query_logs.sh 100 # 最近 100 条
|
||||
# ./query_logs.sh 50 error # 最近 50 条 error 日志
|
||||
# ///
|
||||
|
||||
DB_HOST=${DB_HOST:-localhost}
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
DB_NAME=${DB_NAME:-wxauto}
|
||||
DB_USER=${DB_USER:-wxauto}
|
||||
DB_PASS=${DB_PASS:-wxauto_password}
|
||||
|
||||
export PGPASSWORD="$DB_PASS"
|
||||
|
||||
LIMIT=${1:-50}
|
||||
LEVEL_FILTER=${2:-}
|
||||
|
||||
if [ -n "$LEVEL_FILTER" ]; then
|
||||
echo "=== 最近 ${LIMIT} 条 ${LEVEL_FILTER} 日志 ==="
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT id, to_char(timestamp, 'YYYY-MM-DD HH24:MI:SS') as time, level, source, message \
|
||||
FROM logs WHERE level = '$LEVEL_FILTER' ORDER BY timestamp DESC LIMIT $LIMIT;"
|
||||
else
|
||||
echo "=== 最近 ${LIMIT} 条日志 ==="
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT id, to_char(timestamp, 'YYYY-MM-DD HH24:MI:SS') as time, level, source, message \
|
||||
FROM logs ORDER BY timestamp DESC LIMIT $LIMIT;"
|
||||
fi
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# ///
|
||||
# query_nodes.sh
|
||||
# 描述:查询所有节点
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
#
|
||||
# 使用方式:
|
||||
# ./query_nodes.sh # 查询所有节点
|
||||
# ./query_nodes.sh <node_id> # 查询指定节点
|
||||
# ///
|
||||
|
||||
DB_HOST=${DB_HOST:-localhost}
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
DB_NAME=${DB_NAME:-wxauto}
|
||||
DB_USER=${DB_USER:-wxauto}
|
||||
DB_PASS=${DB_PASS:-wxauto_password}
|
||||
|
||||
export PGPASSWORD="$DB_PASS"
|
||||
|
||||
if [ -n "$1" ]; then
|
||||
echo "=== 节点信息: $1 ==="
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT * FROM nodes WHERE node_id = '$1';"
|
||||
else
|
||||
echo "=== 所有节点 ==="
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT node_id, name, api_url, enabled, status, wechat_status, is_healthy, group FROM nodes ORDER BY node_id;"
|
||||
fi
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# ///
|
||||
# query_webhooks.sh
|
||||
# 描述:查询所有 Webhook 配置
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
#
|
||||
# 使用方式:
|
||||
# ./query_webhooks.sh # 查询所有 webhook
|
||||
# ./query_webhooks.sh <webhook_id> # 查询指定 webhook
|
||||
# ///
|
||||
|
||||
DB_HOST=${DB_HOST:-localhost}
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
DB_NAME=${DB_NAME:-wxauto}
|
||||
DB_USER=${DB_USER:-wxauto}
|
||||
DB_PASS=${DB_PASS:-wxauto_password}
|
||||
|
||||
export PGPASSWORD="$DB_PASS"
|
||||
|
||||
if [ -n "$1" ]; then
|
||||
echo "=== Webhook ID: $1 ==="
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT * FROM webhook_urls WHERE id = $1;"
|
||||
else
|
||||
echo "=== 所有 Webhook ==="
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT id, name, url, format, enabled, event_types FROM webhook_urls ORDER BY id;"
|
||||
fi
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# ///
|
||||
# table_stats.sh
|
||||
# 描述:查看数据库表统计信息
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
#
|
||||
# 使用方式:
|
||||
# ./table_stats.sh
|
||||
# ///
|
||||
|
||||
DB_HOST=${DB_HOST:-localhost}
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
DB_NAME=${DB_NAME:-wxauto}
|
||||
DB_USER=${DB_USER:-wxauto}
|
||||
DB_PASS=${DB_PASS:-wxauto_password}
|
||||
|
||||
export PGPASSWORD="$DB_PASS"
|
||||
|
||||
echo "=== 数据库表统计 ==="
|
||||
echo ""
|
||||
|
||||
echo "--- nodes 表 ---"
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT COUNT(*) as total_nodes FROM nodes;"
|
||||
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT enabled, COUNT(*) FROM nodes GROUP BY enabled;"
|
||||
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT status, COUNT(*) FROM nodes GROUP BY status;"
|
||||
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT wechat_status, COUNT(*) FROM nodes GROUP BY wechat_status;"
|
||||
|
||||
echo ""
|
||||
echo "--- webhook_urls 表 ---"
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT COUNT(*) as total_webhooks FROM webhook_urls;"
|
||||
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT enabled, COUNT(*) FROM webhook_urls GROUP BY enabled;"
|
||||
|
||||
echo ""
|
||||
echo "--- logs 表 ---"
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT COUNT(*) as total_logs FROM logs;"
|
||||
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT level, COUNT(*) FROM logs GROUP BY level ORDER BY count DESC;"
|
||||
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"SELECT to_char(timestamp, 'YYYY-MM-DD') as date, COUNT(*) \
|
||||
FROM logs GROUP BY date ORDER BY date DESC LIMIT 7;"
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
# ///
|
||||
# start_amd64.sh
|
||||
# 描述:AMD64 架构启动脚本 (Intel/AMD 服务器)
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
#
|
||||
# 使用方式:
|
||||
# ./start_amd64.sh # 启动所有服务
|
||||
# ./start_amd64.sh status # 查看服务状态
|
||||
# ./start_amd64.sh stop # 停止所有服务
|
||||
# ./start_amd64.sh logs # 查看日志
|
||||
# ./start_amd64.sh rebuild # 重新构建镜像
|
||||
# ///
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "❌ Docker 未安装,请先安装 Docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
echo "❌ Docker Compose 未安装"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
start() {
|
||||
echo "🚀 启动 WXAuto Center (AMD64)..."
|
||||
|
||||
mkdir -p data/db data/redis data/logs
|
||||
|
||||
docker compose up -d
|
||||
|
||||
echo "✅ 服务启动完成!"
|
||||
echo " - PostgreSQL: localhost:5432"
|
||||
echo " - Redis: localhost:6379"
|
||||
echo " - WXAuto Center: http://localhost:8080"
|
||||
echo " - API文档: http://localhost:8080/docs"
|
||||
}
|
||||
|
||||
stop() {
|
||||
echo "🛑 停止 WXAuto Center..."
|
||||
docker compose down
|
||||
echo "✅ 服务已停止"
|
||||
}
|
||||
|
||||
status() {
|
||||
echo "📊 服务状态:"
|
||||
docker compose ps
|
||||
}
|
||||
|
||||
logs() {
|
||||
echo "📋 日志 (Ctrl+C 退出):"
|
||||
docker compose logs -f
|
||||
}
|
||||
|
||||
rebuild() {
|
||||
echo "🔨 重新构建镜像..."
|
||||
docker compose down
|
||||
docker build -t wxauto-center:latest .
|
||||
docker compose up -d
|
||||
echo "✅ 重建完成"
|
||||
}
|
||||
|
||||
case "${1:-start}" in
|
||||
start)
|
||||
check_docker
|
||||
start
|
||||
;;
|
||||
stop)
|
||||
stop
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
logs)
|
||||
logs
|
||||
;;
|
||||
rebuild)
|
||||
rebuild
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {start|stop|status|logs|rebuild}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
# ///
|
||||
# start_arm64.sh
|
||||
# 描述:ARM64 架构启动脚本 (Apple Silicon Mac)
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
#
|
||||
# 使用方式:
|
||||
# ./start_arm64.sh # 启动所有服务
|
||||
# ./start_arm64.sh status # 查看服务状态
|
||||
# ./start_arm64.sh stop # 停止所有服务
|
||||
# ./start_arm64.sh logs # 查看日志
|
||||
# ./start_arm64.sh rebuild # 重新构建镜像
|
||||
# ///
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "❌ Docker 未安装,请先安装 Docker Desktop"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
echo "❌ Docker Compose 未安装,请先安装 Docker Desktop"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
start() {
|
||||
echo "🚀 启动 WXAuto Center (ARM64)..."
|
||||
|
||||
mkdir -p data/db data/redis data/logs
|
||||
|
||||
docker compose up -d
|
||||
|
||||
echo "✅ 服务启动完成!"
|
||||
echo " - PostgreSQL: localhost:5432"
|
||||
echo " - Redis: localhost:6379"
|
||||
echo " - WXAuto Center: http://localhost:8080"
|
||||
echo " - API文档: http://localhost:8080/docs"
|
||||
}
|
||||
|
||||
stop() {
|
||||
echo "🛑 停止 WXAuto Center..."
|
||||
docker compose down
|
||||
echo "✅ 服务已停止"
|
||||
}
|
||||
|
||||
status() {
|
||||
echo "📊 服务状态:"
|
||||
docker compose ps
|
||||
}
|
||||
|
||||
logs() {
|
||||
echo "📋 日志 (Ctrl+C 退出):"
|
||||
docker compose logs -f
|
||||
}
|
||||
|
||||
rebuild() {
|
||||
echo "🔨 重新构建镜像..."
|
||||
docker compose down
|
||||
docker build -t wxauto-center:latest .
|
||||
docker compose up -d
|
||||
echo "✅ 重建完成"
|
||||
}
|
||||
|
||||
case "${1:-start}" in
|
||||
start)
|
||||
check_docker
|
||||
start
|
||||
;;
|
||||
stop)
|
||||
stop
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
logs)
|
||||
logs
|
||||
;;
|
||||
rebuild)
|
||||
rebuild
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {start|stop|status|logs|rebuild}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,11 @@
|
||||
# ///
|
||||
# __init__.py
|
||||
# 描述:服务模块导出
|
||||
# ///
|
||||
|
||||
from services.node_manager import node_manager
|
||||
from services.monitor_service import monitor_service
|
||||
from services.log_service import log_service
|
||||
from services.queue_service import queue_service
|
||||
|
||||
__all__ = ["node_manager", "monitor_service", "log_service", "queue_service"]
|
||||
@@ -0,0 +1,145 @@
|
||||
# ///
|
||||
# log_service.py
|
||||
# 描述:日志服务,支持Redis缓存和文件持久化
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from collections import deque
|
||||
|
||||
from config import get_settings
|
||||
|
||||
|
||||
class LogService:
|
||||
def __init__(self, max_logs: int = 500, log_file: str = "logs/app.log"):
|
||||
self.max_logs = max_logs
|
||||
self.log_file = log_file
|
||||
self._logs: deque = deque(maxlen=max_logs)
|
||||
self._subscribers: List[asyncio.Queue] = []
|
||||
self._log_levels = ["INFO", "WARNING", "ERROR", "SUCCESS"]
|
||||
self._redis_client = None
|
||||
self._redis_available = False
|
||||
self._init_redis()
|
||||
|
||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||
self._load_from_file()
|
||||
|
||||
def _init_redis(self):
|
||||
try:
|
||||
import redis
|
||||
settings = get_settings()
|
||||
redis_config = settings.redis
|
||||
self._redis_client = redis.from_url(
|
||||
redis_config.url,
|
||||
db=redis_config.db,
|
||||
password=redis_config.password,
|
||||
decode_responses=True
|
||||
)
|
||||
self._redis_client.ping()
|
||||
self._redis_available = True
|
||||
except Exception:
|
||||
self._redis_available = False
|
||||
self._redis_client = None
|
||||
|
||||
def _load_from_file(self):
|
||||
if not os.path.exists(self.log_file):
|
||||
return
|
||||
try:
|
||||
with open(self.log_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
try:
|
||||
log_entry = json.loads(line.strip())
|
||||
if len(self._logs) < self.max_logs:
|
||||
self._logs.append(log_entry)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save_to_file(self, log_entry: Dict[str, Any]):
|
||||
try:
|
||||
with open(self.log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save_to_redis(self, log_entry: Dict[str, Any]):
|
||||
if not self._redis_available:
|
||||
return
|
||||
try:
|
||||
self._redis_client.lpush("wxauto:logs", json.dumps(log_entry, ensure_ascii=False))
|
||||
self._redis_client.ltrim("wxauto:logs", 0, self.max_logs - 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add_log(self, message: str, level: str = "INFO", source: str = "System"):
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"level": level,
|
||||
"source": source,
|
||||
"message": message
|
||||
}
|
||||
|
||||
self._logs.append(log_entry)
|
||||
|
||||
if self._redis_available:
|
||||
self._save_to_redis(log_entry)
|
||||
else:
|
||||
self._save_to_file(log_entry)
|
||||
|
||||
self._notify_subscribers(log_entry)
|
||||
|
||||
def info(self, message: str, source: str = "System"):
|
||||
self.add_log(message, "INFO", source)
|
||||
|
||||
def warning(self, message: str, source: str = "System"):
|
||||
self.add_log(message, "WARNING", source)
|
||||
|
||||
def error(self, message: str, source: str = "System"):
|
||||
self.add_log(message, "ERROR", source)
|
||||
|
||||
def success(self, message: str, source: str = "System"):
|
||||
self.add_log(message, "SUCCESS", source)
|
||||
|
||||
async def subscribe(self) -> asyncio.Queue:
|
||||
queue = asyncio.Queue(maxsize=100)
|
||||
self._subscribers.append(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, queue: asyncio.Queue):
|
||||
if queue in self._subscribers:
|
||||
self._subscribers.remove(queue)
|
||||
|
||||
async def _notify_subscribers(self, log_entry: Dict[str, Any]):
|
||||
for queue in self._subscribers:
|
||||
try:
|
||||
await queue.put(log_entry)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_recent_logs(self, count: int = 50) -> List[Dict[str, Any]]:
|
||||
if self._redis_available:
|
||||
try:
|
||||
logs = self._redis_client.lrange("wxauto:logs", 0, count - 1)
|
||||
return [json.loads(log) for log in reversed(logs)]
|
||||
except Exception:
|
||||
pass
|
||||
return list(self._logs)[-count:]
|
||||
|
||||
def get_all_logs(self) -> List[Dict[str, Any]]:
|
||||
if self._redis_available:
|
||||
try:
|
||||
logs = self._redis_client.lrange("wxauto:logs", 0, self.max_logs - 1)
|
||||
return [json.loads(log) for log in reversed(logs)]
|
||||
except Exception:
|
||||
pass
|
||||
return list(self._logs)
|
||||
|
||||
|
||||
log_service = LogService()
|
||||
@@ -0,0 +1,179 @@
|
||||
# ///
|
||||
# monitor_service.py
|
||||
# 描述:节点状态监控服务,定时检测API和微信状态
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
import httpx
|
||||
|
||||
from config import get_settings, WebhookConfig
|
||||
from services.node_manager import node_manager
|
||||
from services.log_service import log_service
|
||||
from database_service import get_db_service
|
||||
|
||||
|
||||
class MonitorService:
|
||||
def __init__(self):
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._last_status: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
async def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._monitor_loop())
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _monitor_loop(self):
|
||||
settings = get_settings()
|
||||
log_service.info("监控服务启动", "Monitor")
|
||||
while self._running:
|
||||
try:
|
||||
await self._check_all_nodes()
|
||||
except Exception as e:
|
||||
log_service.error(f"监控循环错误: {e}", "Monitor")
|
||||
await asyncio.sleep(settings.monitor.check_interval)
|
||||
|
||||
async def _check_all_nodes(self):
|
||||
settings = get_settings()
|
||||
if not settings.monitor.enabled:
|
||||
return
|
||||
|
||||
for node in await node_manager.get_all_nodes():
|
||||
await self._check_node(node.node_id)
|
||||
|
||||
async def _check_node(self, node_id: str):
|
||||
node = await node_manager.get_node(node_id)
|
||||
if not node:
|
||||
return
|
||||
|
||||
api_status_changed = False
|
||||
wechat_status_changed = False
|
||||
old_api_status = self._last_status.get(node_id, {}).get("api_status")
|
||||
old_wechat_status = self._last_status.get(node_id, {}).get("wechat_status")
|
||||
|
||||
api_status = await node_manager.check_node_status(node_id)
|
||||
if old_api_status is not None and old_api_status != api_status:
|
||||
api_status_changed = True
|
||||
self._last_status.setdefault(node_id, {})["api_status"] = api_status
|
||||
|
||||
wechat_status = node.wechat_status
|
||||
if old_wechat_status is not None and old_wechat_status != wechat_status:
|
||||
wechat_status_changed = True
|
||||
self._last_status.setdefault(node_id, {})["wechat_status"] = wechat_status
|
||||
|
||||
if api_status_changed:
|
||||
event = "node_online" if self._last_status[node_id]["api_status"] == "active" else "node_offline"
|
||||
msg = f"节点 {node.name} ({node_id}) API状态变为 {self._last_status[node_id]['api_status']}"
|
||||
log_service.info(msg, "Monitor")
|
||||
if event == "node_offline":
|
||||
log_service.error(msg, "Monitor")
|
||||
await self._send_webhook(event, {
|
||||
"node_id": node_id,
|
||||
"node_name": node.name,
|
||||
"api_status": self._last_status[node_id]["api_status"],
|
||||
"message": msg
|
||||
})
|
||||
|
||||
if wechat_status_changed:
|
||||
event = "wechat_online" if wechat_status == "online" else "wechat_offline"
|
||||
msg = f"节点 {node.name} ({node_id}) 微信状态变为 {wechat_status}"
|
||||
log_service.info(msg, "Monitor")
|
||||
if event == "wechat_offline":
|
||||
log_service.warning(msg, "Monitor")
|
||||
await self._send_webhook(event, {
|
||||
"node_id": node_id,
|
||||
"node_name": node.name,
|
||||
"wechat_status": wechat_status,
|
||||
"message": msg
|
||||
})
|
||||
|
||||
async def _send_webhook(self, event: str, data: Dict[str, Any]):
|
||||
settings = get_settings()
|
||||
webhook_config: WebhookConfig = settings.webhook
|
||||
|
||||
db_service = get_db_service()
|
||||
webhooks = []
|
||||
if db_service:
|
||||
webhooks = db_service.get_enabled_webhooks()
|
||||
|
||||
if not webhooks:
|
||||
if not webhook_config.enabled or not webhook_config.urls:
|
||||
return
|
||||
webhooks = [{"url": url, "format": webhook_config.format} for url in webhook_config.urls]
|
||||
|
||||
event_names = {
|
||||
"api_error": "API错误",
|
||||
"wechat_offline": "微信掉线",
|
||||
"wechat_online": "微信上线",
|
||||
"node_offline": "节点离线",
|
||||
"node_online": "节点在线"
|
||||
}
|
||||
|
||||
event_name = event_names.get(event, event)
|
||||
node_name = data.get('node_name', 'N/A')
|
||||
status_info = data.get('wechat_status') or data.get('api_status') or 'N/A'
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
for webhook in webhooks:
|
||||
url = webhook.url if hasattr(webhook, 'url') else webhook.get('url')
|
||||
format_type = webhook.format if hasattr(webhook, 'format') else webhook.get('format', 'bark')
|
||||
event_types = webhook.event_types if hasattr(webhook, 'event_types') else []
|
||||
|
||||
if event_types and event not in event_types:
|
||||
continue
|
||||
|
||||
if format_type == "bark":
|
||||
title = f"WXAuto告警 - {event_name}"
|
||||
content = f"{node_name}\n状态: {status_info}\n时间: {timestamp}"
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": content,
|
||||
"icon": "https://is3-ssl.mzstatic.com/image/thumb/Purple122/v4/8e/1a/b5/8e1ab5a2-09c7-2c06-ae03-2056b0348d2c/AppIcon-Production-20x20@2x.png/0x0ss.png"
|
||||
}
|
||||
else:
|
||||
content = f"""【WXAuto Center 告警】
|
||||
事件类型: {event_name}
|
||||
节点名称: {node_name}
|
||||
状态信息: {status_info}
|
||||
时间: {timestamp}"""
|
||||
payload = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
|
||||
for attempt in range(webhook_config.retry_times):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=webhook_config.timeout) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
if response.status_code == 200:
|
||||
log_service.success(f"Webhook通知发送成功: {event} -> {url}", "Monitor")
|
||||
break
|
||||
except Exception as e:
|
||||
log_service.warning(f"Webhook发送失败 (尝试 {attempt + 1}): {e}", "Monitor")
|
||||
else:
|
||||
log_service.error(f"Webhook通知发送失败 (已重试{webhook_config.retry_times}次): {event} -> {url}", "Monitor")
|
||||
|
||||
def get_last_status(self, node_id: str) -> Dict[str, str]:
|
||||
return self._last_status.get(node_id, {})
|
||||
|
||||
def get_all_status(self) -> Dict[str, Dict[str, str]]:
|
||||
return self._last_status.copy()
|
||||
|
||||
|
||||
monitor_service = MonitorService()
|
||||
@@ -0,0 +1,225 @@
|
||||
# ///
|
||||
# node_manager.py
|
||||
# 描述:节点管理服务,负责与WXAUTO-HTTP-API节点通信
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, List, Optional, Any
|
||||
import httpx
|
||||
|
||||
from services.log_service import log_service
|
||||
from database_service import get_db_service
|
||||
|
||||
|
||||
class NodeStatus:
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class Node:
|
||||
def __init__(self, node_id: str, name: str, api_url: str, api_key: str, description: str = "", group: str = "default"):
|
||||
self.node_id = node_id
|
||||
self.name = name
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.description = description
|
||||
self.group = group
|
||||
self.enabled = True
|
||||
self.status = NodeStatus.INACTIVE
|
||||
self.wechat_status = "online"
|
||||
self.is_healthy = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"node_id": self.node_id,
|
||||
"name": self.name,
|
||||
"api_url": self.api_url,
|
||||
"enabled": self.enabled,
|
||||
"description": self.description,
|
||||
"group": self.group,
|
||||
"status": self.status,
|
||||
"wechat_status": self.wechat_status,
|
||||
"is_healthy": self.is_healthy
|
||||
}
|
||||
|
||||
|
||||
class NodeManager:
|
||||
def __init__(self):
|
||||
self.nodes: Dict[str, Node] = {}
|
||||
|
||||
async def add_node(self, node_id: str, name: str, api_url: str, api_key: str, description: str = "", group: str = "default") -> bool:
|
||||
if node_id in self.nodes:
|
||||
return False
|
||||
self.nodes[node_id] = Node(node_id, name, api_url, api_key, description, group)
|
||||
await self.check_node_status(node_id)
|
||||
return True
|
||||
|
||||
async def remove_node(self, node_id: str) -> bool:
|
||||
if node_id not in self.nodes:
|
||||
return False
|
||||
del self.nodes[node_id]
|
||||
return True
|
||||
|
||||
async def get_node(self, node_id: str) -> Optional[Node]:
|
||||
return self.nodes.get(node_id)
|
||||
|
||||
async def get_all_nodes(self) -> List[Node]:
|
||||
return list(self.nodes.values())
|
||||
|
||||
async def get_enabled_nodes(self) -> List[Node]:
|
||||
return [n for n in self.nodes.values() if n.enabled]
|
||||
|
||||
async def get_nodes_by_group(self, group: str) -> List[Node]:
|
||||
return [n for n in self.nodes.values() if n.group == group]
|
||||
|
||||
def to_config(self) -> List[Dict[str, Any]]:
|
||||
return [{
|
||||
"node_id": n.node_id,
|
||||
"name": n.name,
|
||||
"api_url": n.api_url,
|
||||
"api_key": n.api_key,
|
||||
"enabled": n.enabled,
|
||||
"description": n.description,
|
||||
"group": n.group
|
||||
} for n in self.nodes.values()]
|
||||
|
||||
async def update_node(self, node_id: str, **kwargs) -> bool:
|
||||
node = self.nodes.get(node_id)
|
||||
if not node:
|
||||
return False
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(node, key):
|
||||
setattr(node, key, value)
|
||||
if "api_url" in kwargs or "api_key" in kwargs:
|
||||
await self.check_node_status(node_id)
|
||||
return True
|
||||
|
||||
async def check_node_status(self, node_id: str) -> str:
|
||||
node = self.nodes.get(node_id)
|
||||
if not node or not node.enabled:
|
||||
return NodeStatus.INACTIVE
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{node.api_url}/health",
|
||||
headers={"X-API-Key": node.api_key}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
node.status = NodeStatus.ACTIVE
|
||||
else:
|
||||
node.status = NodeStatus.ERROR
|
||||
except Exception:
|
||||
node.status = NodeStatus.ERROR
|
||||
return node.status
|
||||
|
||||
async def is_node_healthy(self, node_id: str) -> tuple[bool, str]:
|
||||
node = self.nodes.get(node_id)
|
||||
if not node or not node.enabled:
|
||||
return False, "Node not found or disabled"
|
||||
|
||||
api_status = await self.check_node_status(node_id)
|
||||
if api_status != NodeStatus.ACTIVE:
|
||||
return False, f"API status: {api_status}"
|
||||
|
||||
return True, "OK"
|
||||
|
||||
async def call_node_api(self, node_id: str, endpoint: str, method: str = "GET", data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
node = self.nodes.get(node_id)
|
||||
if not node or not node.enabled:
|
||||
return {"success": False, "error": f"Node '{node_id}' not found or disabled"}
|
||||
|
||||
url = f"{node.api_url}{endpoint}"
|
||||
headers = {"X-API-Key": node.api_key}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
if method.upper() == "GET":
|
||||
response = await client.get(url, headers=headers)
|
||||
elif method.upper() == "POST":
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
else:
|
||||
return {"success": False, "error": f"Unsupported method: {method}"}
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
return {"success": False, "error": f"HTTP {response.status_code}", "url": url}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e), "url": url}
|
||||
|
||||
async def send_message(self, node_id: str, who: str, msg: str, msg_type: str = "text") -> Dict[str, Any]:
|
||||
node = self.nodes.get(node_id)
|
||||
await self.call_node_api(node_id, "/api/wechat/initialize", method="POST")
|
||||
result = await self.call_node_api(
|
||||
node_id,
|
||||
"/api/message/send",
|
||||
method="POST",
|
||||
data={"receiver": who, "message": msg}
|
||||
)
|
||||
if node:
|
||||
node.wechat_status = "online" if result.get("success") else "offline"
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, wechat_status=node.wechat_status)
|
||||
return result
|
||||
|
||||
async def send_message_to_group(self, node_id: str, group_name: str, msg: str, msg_type: str = "text") -> Dict[str, Any]:
|
||||
node = self.nodes.get(node_id)
|
||||
await self.call_node_api(node_id, "/api/wechat/initialize", method="POST")
|
||||
result = await self.call_node_api(
|
||||
node_id,
|
||||
"/api/message/send",
|
||||
method="POST",
|
||||
data={"receiver": group_name, "message": msg}
|
||||
)
|
||||
if node:
|
||||
node.wechat_status = "online" if result.get("success") else "offline"
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, wechat_status=node.wechat_status)
|
||||
return result
|
||||
|
||||
async def get_friends_list(self, node_id: str) -> Dict[str, Any]:
|
||||
return await self.call_node_api(node_id, "/api/friend/list", method="GET")
|
||||
|
||||
async def get_groups_list(self, node_id: str) -> Dict[str, Any]:
|
||||
return await self.call_node_api(node_id, "/api/group/list", method="GET")
|
||||
|
||||
async def get_messages(self, node_id: str, chat_name: str, limit: int = 20) -> Dict[str, Any]:
|
||||
return await self.call_node_api(
|
||||
node_id,
|
||||
"/api/message/get-history",
|
||||
method="POST",
|
||||
data={"chat_name": chat_name, "save_pic": False, "save_video": False, "save_file": False, "save_voice": False}
|
||||
)
|
||||
|
||||
async def reset_wechat_status(self, node_id: str) -> Dict[str, Any]:
|
||||
node = self.nodes.get(node_id)
|
||||
if not node:
|
||||
return {"success": False, "error": f"Node '{node_id}' not found"}
|
||||
|
||||
await self.call_node_api(node_id, "/api/wechat/initialize", method="POST")
|
||||
result = await self.call_node_api(
|
||||
node_id,
|
||||
"/api/message/send",
|
||||
method="POST",
|
||||
data={"receiver": "文件传输助手", "message": "WXAuto Center 状态检测消息"}
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
node.wechat_status = "online"
|
||||
else:
|
||||
node.wechat_status = "offline"
|
||||
|
||||
db_service = get_db_service()
|
||||
if db_service:
|
||||
db_service.update_node(node_id, wechat_status=node.wechat_status)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
node_manager = NodeManager()
|
||||
@@ -0,0 +1,148 @@
|
||||
# ///
|
||||
# plugin_notification_service.py
|
||||
# 描述:插件通知服务,支持向配置的地址发送插件告警通知
|
||||
# 作者:User
|
||||
# 创建日期:2026-04-07
|
||||
# ///
|
||||
|
||||
import logging
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("plugin_notification")
|
||||
|
||||
|
||||
class PluginNotificationService:
|
||||
def __init__(self):
|
||||
self._notification_url: Optional[str] = None
|
||||
self._enabled: bool = False
|
||||
self._webhook_configs: List[Dict[str, Any]] = []
|
||||
self._plugin_webhook_map: Dict[str, str] = {}
|
||||
|
||||
def initialize(self):
|
||||
from database_service import get_db_service
|
||||
db_service = get_db_service()
|
||||
if not db_service:
|
||||
logger.warning("Database service not available, plugin notification disabled")
|
||||
return
|
||||
|
||||
try:
|
||||
self._load_notification_config(db_service)
|
||||
self._load_webhook_configs(db_service)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize plugin notification service: {e}")
|
||||
|
||||
def _load_notification_config(self, db_service):
|
||||
try:
|
||||
from models import WebhookUrl
|
||||
webhooks = db_service.get_all_webhooks()
|
||||
for wh in webhooks:
|
||||
event_types = getattr(wh, 'event_types', '') or ''
|
||||
if 'plugin_notification' in event_types or event_types == '*':
|
||||
self._notification_url = wh.url
|
||||
self._enabled = wh.enabled
|
||||
logger.info(f"Plugin notification enabled: {wh.name} - {wh.url}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load notification config: {e}")
|
||||
|
||||
def _load_webhook_configs(self, db_service):
|
||||
try:
|
||||
from models import WebhookUrl
|
||||
webhooks = db_service.get_all_webhooks()
|
||||
self._webhook_configs = []
|
||||
for wh in webhooks:
|
||||
event_types = getattr(wh, 'event_types', '') or ''
|
||||
event_types_list = event_types.split(',') if isinstance(event_types, str) else []
|
||||
self._webhook_configs.append({
|
||||
'id': wh.id,
|
||||
'name': wh.name,
|
||||
'url': wh.url,
|
||||
'format': getattr(wh, 'format', 'bark'),
|
||||
'enabled': wh.enabled,
|
||||
'event_types': event_types_list
|
||||
})
|
||||
if 'plugin_notification' in event_types_list or '*' in event_types_list:
|
||||
self._notification_url = wh.url
|
||||
self._enabled = wh.enabled
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load webhook configs: {e}")
|
||||
|
||||
def reload_config(self):
|
||||
self.initialize()
|
||||
|
||||
def send_notification(
|
||||
self,
|
||||
plugin_name: str,
|
||||
title: str,
|
||||
message: str,
|
||||
level: str = "info"
|
||||
) -> bool:
|
||||
logger.info(f"send_notification called: {plugin_name} - {title}, enabled={self._enabled}, url={self._notification_url}")
|
||||
|
||||
if not self._enabled or not self._notification_url:
|
||||
logger.warning(f"Plugin notification disabled or no URL: enabled={self._enabled}, url={self._notification_url}")
|
||||
logger.info(f"Available webhook configs: {self._webhook_configs}")
|
||||
return False
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
content = f"""【{plugin_name} 插件通知】
|
||||
级别: {level.upper()}
|
||||
标题: {title}
|
||||
消息: {message}
|
||||
时间: {timestamp}"""
|
||||
|
||||
payload = {
|
||||
"title": f"[{level.upper()}] {plugin_name}",
|
||||
"body": content,
|
||||
"icon": "https://is3-ssl.mzstatic.com/image/thumb/Purple122/v4/8e/1a/b5/8e1ab5a2-09c7-2c06-ae03-2056b0348d2c/AppIcon-Production-20x20@2x.png/0x0ss.png"
|
||||
}
|
||||
|
||||
for config in self._webhook_configs:
|
||||
if not config['enabled']:
|
||||
continue
|
||||
event_types = config['event_types']
|
||||
if 'plugin_notification' not in event_types and '*' not in event_types:
|
||||
continue
|
||||
|
||||
try:
|
||||
url = config['url']
|
||||
format_type = config.get('format', 'bark')
|
||||
|
||||
if format_type == 'wechat':
|
||||
payload = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
|
||||
result = self._send_webhook(url, payload)
|
||||
if result:
|
||||
logger.info(f"Plugin notification sent: {plugin_name} - {level} -> {config['name']}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send plugin notification: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def _send_webhook(self, url: str, payload: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
with httpx.Client(timeout=10) as client:
|
||||
response = client.post(url, json=payload)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook request failed: {e}")
|
||||
return False
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get_notification_url(self) -> Optional[str]:
|
||||
return self._notification_url
|
||||
|
||||
|
||||
plugin_notification_service = PluginNotificationService()
|
||||
@@ -0,0 +1,119 @@
|
||||
# ///
|
||||
# queue_service.py
|
||||
# 描述:消息队列服务,支持Redis后端
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from config import get_settings
|
||||
|
||||
|
||||
class QueueName(Enum):
|
||||
MESSAGE_SEND = "wxauto:queue:message_send"
|
||||
MESSAGE_BROADCAST = "wxauto:queue:message_broadcast"
|
||||
|
||||
|
||||
class QueueService:
|
||||
def __init__(self):
|
||||
self._redis_client = None
|
||||
self._redis_available = False
|
||||
self._local_queue = asyncio.Queue()
|
||||
self._init_redis()
|
||||
self._worker_task = None
|
||||
self._running = False
|
||||
|
||||
def _init_redis(self):
|
||||
try:
|
||||
import redis
|
||||
settings = get_settings()
|
||||
redis_config = settings.redis
|
||||
self._redis_client = redis.from_url(
|
||||
redis_config.url,
|
||||
db=redis_config.db,
|
||||
password=redis_config.password,
|
||||
decode_responses=True
|
||||
)
|
||||
self._redis_client.ping()
|
||||
self._redis_available = True
|
||||
except Exception:
|
||||
self._redis_available = False
|
||||
self._redis_client = None
|
||||
|
||||
def enqueue(self, queue_name: QueueName, data: Dict[str, Any]) -> bool:
|
||||
job_data = {
|
||||
"data": data,
|
||||
"enqueued_at": datetime.now().isoformat(),
|
||||
"status": "pending"
|
||||
}
|
||||
|
||||
if self._redis_available:
|
||||
try:
|
||||
self._redis_client.rpush(queue_name.value, json.dumps(job_data, ensure_ascii=False))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
asyncio.create_task(self._local_enqueue(queue_name, job_data))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _local_enqueue(self, queue_name: QueueName, job_data: Dict[str, Any]):
|
||||
await self._local_queue.put((queue_name, job_data))
|
||||
|
||||
async def dequeue(self, queue_name: QueueName, timeout: int = 1) -> Optional[Dict[str, Any]]:
|
||||
if self._redis_available:
|
||||
try:
|
||||
result = self._redis_client.blpop(queue_name.value, timeout=timeout)
|
||||
if result:
|
||||
_, job_data = result
|
||||
return json.loads(job_data)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
queue_item = await asyncio.wait_for(self._local_queue.get(), timeout=timeout)
|
||||
if queue_item and queue_item[0] == queue_name:
|
||||
return queue_item[1]
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_queue_length(self, queue_name: QueueName) -> int:
|
||||
if self._redis_available:
|
||||
try:
|
||||
return self._redis_client.llen(queue_name.value)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def start_worker(self, processor: Callable):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._worker_task = asyncio.create_task(self._worker_loop(processor))
|
||||
|
||||
async def _worker_loop(self, processor: Callable):
|
||||
while self._running:
|
||||
for queue_name in [QueueName.MESSAGE_SEND]:
|
||||
job = await self.dequeue(queue_name, timeout=1)
|
||||
if job:
|
||||
try:
|
||||
await processor(job)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def stop_worker(self):
|
||||
self._running = False
|
||||
if self._worker_task:
|
||||
self._worker_task.cancel()
|
||||
|
||||
|
||||
queue_service = QueueService()
|
||||
@@ -0,0 +1,66 @@
|
||||
# ///
|
||||
# scheduler_service.py
|
||||
# 描述:定时任务调度服务,支持 cron 表达式
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-06
|
||||
# ///
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from croniter import croniter
|
||||
from plugins.base import PluginRegistry
|
||||
from services.log_service import log_service
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
def __init__(self):
|
||||
self._running = False
|
||||
self._task = None
|
||||
self._last_run_times: Dict[str, float] = {}
|
||||
|
||||
async def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._scheduler_loop())
|
||||
log_service.info("调度服务启动", "Scheduler")
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
log_service.info("调度服务已停止", "Scheduler")
|
||||
|
||||
async def _scheduler_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
await self._check_and_run_tasks()
|
||||
except Exception as e:
|
||||
log_service.error(f"调度循环错误: {e}", "Scheduler")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
async def _check_and_run_tasks(self):
|
||||
tasks = PluginRegistry.get_scheduled_tasks()
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
for task in tasks:
|
||||
try:
|
||||
should_run, next_run = task.should_run_and_get_next()
|
||||
if should_run:
|
||||
log_service.info(f"执行定时任务: {task.plugin_name}", "Scheduler")
|
||||
result = task.run_task()
|
||||
if result.get("success"):
|
||||
log_service.success(f"定时任务完成: {task.plugin_name}", "Scheduler")
|
||||
else:
|
||||
log_service.error(f"定时任务失败: {task.plugin_name} - {result.get('error')}", "Scheduler")
|
||||
self._last_run_times[task.plugin_name] = current_time
|
||||
except Exception as e:
|
||||
log_service.error(f"执行定时任务 {task.plugin_name} 出错: {e}", "Scheduler")
|
||||
|
||||
|
||||
scheduler_service = SchedulerService()
|
||||
@@ -0,0 +1,685 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #f5f6fa;
|
||||
color: #333;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar-header span {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 15px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
border-left-color: #4a9eff;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 15px 20px;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.server-status {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.server-status.online { color: #52c41a; }
|
||||
.server-status.offline { color: #f5222d; }
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 220px;
|
||||
padding: 25px;
|
||||
max-width: calc(100% - 220px);
|
||||
}
|
||||
|
||||
.page {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.stat-card.success .stat-icon { color: #52c41a; }
|
||||
.stat-card.warning .stat-icon { color: #fa8c16; }
|
||||
.stat-card.danger .stat-icon { color: #f5222d; }
|
||||
|
||||
.nodes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
.node-card.warning { border-left-color: #fa8c16; }
|
||||
.node-card.offline { border-left-color: #f5222d; }
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.node-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.online { background: #e6f7e6; color: #52c41a; }
|
||||
.status-badge.warning { background: #fff1e6; color: #fa8c16; }
|
||||
.status-badge.offline { background: #fff1f0; color: #f5222d; }
|
||||
|
||||
.node-info {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.node-info p {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nodes-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input, .select, .textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #4a9eff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #3a8eff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fff1f0;
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #ffccc7;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 12px 30px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.checkbox-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 15px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-types {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.send-form, .broadcast-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.send-form .btn-lg, .broadcast-form .btn-lg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.api-info {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.api-endpoint {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.api-endpoint label, .api-example label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.docs-content h3 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.docs-content h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.docs-content p {
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.docs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.docs-table th, .docs-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.docs-table th {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.about-info {
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
width: 450px;
|
||||
max-width: 90%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 20px;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 15px 0 10px;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.section-arrow {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.field-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
padding: 12px 25px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all 0.3s;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: #e6f7e6;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: #fff1f0;
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.status-online { color: #52c41a; }
|
||||
.status-inactive { color: #999; }
|
||||
.status-error { color: #f5222d; }
|
||||
.status-wechat-online { color: #52c41a; }
|
||||
.status-wechat-offline { color: #f5222d; }
|
||||
.status-wechat-unknown { color: #999; }
|
||||
|
||||
small {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.sidebar-header h1, .sidebar-header span, .nav-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 60px;
|
||||
max-width: calc(100% - 60px);
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.log-section {
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
min-height: 400px;
|
||||
max-height: calc(100vh - 250px);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-weight: 600;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.log-source {
|
||||
color: #9cdcfe;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WXAuto Center - 中控系统</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<div id="modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal()">×</span>
|
||||
<h2 id="modalTitle">添加节点</h2>
|
||||
<form id="nodeForm">
|
||||
<input type="hidden" id="nodeIdOld">
|
||||
<div class="form-group">
|
||||
<label>节点ID</label>
|
||||
<input type="text" id="nodeId" class="input" placeholder="唯一标识符,如: wx1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>节点名称</label>
|
||||
<input type="text" id="nodeName" class="input" placeholder="显示名称,如: 微信1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API URL</label>
|
||||
<input type="text" id="nodeApiUrl" class="input" placeholder="http://localhost:5000" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="nodeApiKey" class="input" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<input type="text" id="nodeGroup" class="input" value="default">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<input type="text" id="nodeDesc" class="input">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">保存</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/pages/templates.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
const API_KEY = 'your-external-api-key';
|
||||
const API_BASE = '/api/v1';
|
||||
const REQUEST_TIMEOUT = 30000;
|
||||
|
||||
let pendingRequests = 0;
|
||||
let loadingToast = null;
|
||||
let loadingTimer = null;
|
||||
|
||||
function showNonBlockingLoading(message = '加载中...') {
|
||||
pendingRequests++;
|
||||
updateLoadingUI(message);
|
||||
|
||||
if (loadingTimer) clearTimeout(loadingTimer);
|
||||
loadingTimer = setTimeout(() => {
|
||||
console.warn('Some requests may be stuck, force clearing');
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
function hideNonBlockingLoading() {
|
||||
pendingRequests = Math.max(0, pendingRequests - 1);
|
||||
updateLoadingUI(pendingRequests > 0 ? '处理中...' : null);
|
||||
}
|
||||
|
||||
function updateLoadingUI(message) {
|
||||
if (!message) {
|
||||
if (loadingToast) {
|
||||
loadingToast.remove();
|
||||
loadingToast = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loadingToast) {
|
||||
loadingToast = document.createElement('div');
|
||||
loadingToast.id = 'loading-toast';
|
||||
loadingToast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
document.body.appendChild(loadingToast);
|
||||
}
|
||||
|
||||
const spinner = '<div style="width:16px;height:16px;border:2px solid #fff;border-top-color:transparent;border-radius:50%;animation:spin 0.8s linear infinite"></div>';
|
||||
const style = '<style>@keyframes spin{to{transform:rotate(360deg)}}</style>';
|
||||
if (!document.getElementById('loading-spinner-style')) {
|
||||
document.head.insertAdjacentHTML('beforeend', style);
|
||||
}
|
||||
loadingToast.innerHTML = spinner + `<span>${message}</span><span style="font-size:12px;opacity:0.7">(${pendingRequests})</span>`;
|
||||
}
|
||||
|
||||
async function api(endpoint, options = {}) {
|
||||
const method = options.method || 'GET';
|
||||
const isMutation = ['POST', 'PUT', 'DELETE'].includes(method);
|
||||
const startMsg = `请求${isMutation ? '修改' : '获取'}中...`;
|
||||
|
||||
showNonBlockingLoading(startMsg);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
showToast('请求超时,请稍后重试', 'error');
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'X-API-Key': API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
const response = await fetch(API_BASE + endpoint, {
|
||||
...options,
|
||||
headers,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = `HTTP ${response.status}: ${response.statusText}`;
|
||||
if (isMutation) {
|
||||
showToast('操作失败: ' + error, 'error');
|
||||
}
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (isMutation && data.success === false && data.error) {
|
||||
showToast(data.error, 'error');
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
return { success: false, error: '请求超时' };
|
||||
}
|
||||
const errorMsg = err.message || '网络错误';
|
||||
if (isMutation) {
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
return { success: false, error: errorMsg };
|
||||
|
||||
} finally {
|
||||
hideNonBlockingLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const apiClient = {
|
||||
get: (endpoint) => api(endpoint),
|
||||
post: (endpoint, data) => api(endpoint, { method: 'POST', body: JSON.stringify(data) }),
|
||||
put: (endpoint, data) => api(endpoint, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (endpoint) => api(endpoint, { method: 'DELETE' }),
|
||||
|
||||
nodes: {
|
||||
list: () => api('/nodes/'),
|
||||
get: (name) => api(`/nodes/${name}`),
|
||||
create: (data) => api('/nodes/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (name, data) => api(`/nodes/${name}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (name) => api(`/nodes/${name}`, { method: 'DELETE' }),
|
||||
status: (name) => api(`/nodes/${name}/status`)
|
||||
},
|
||||
|
||||
messages: {
|
||||
send: (data) => api('/messages/send', { method: 'POST', body: JSON.stringify(data) }),
|
||||
broadcast: (data) => api('/messages/broadcast', { method: 'POST', body: JSON.stringify(data) })
|
||||
},
|
||||
|
||||
config: {
|
||||
getMonitor: () => api('/config/monitor'),
|
||||
updateMonitor: (data) => api('/config/monitor', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getWebhook: () => api('/config/webhook'),
|
||||
updateWebhook: (data) => api('/config/webhook', { method: 'POST', body: JSON.stringify(data) })
|
||||
},
|
||||
|
||||
logs: {
|
||||
recent: (count = 50) => api(`/logs/recent?count=${count}`)
|
||||
}
|
||||
};
|
||||
|
||||
window.api = api;
|
||||
window.apiClient = apiClient;
|
||||
@@ -0,0 +1,759 @@
|
||||
// ///
|
||||
// app.js
|
||||
// 描述:中控系统前端主逻辑
|
||||
// 作者:AI Generated
|
||||
// 创建日期:2026-04-05
|
||||
// ///
|
||||
|
||||
let currentPage = 'dashboard';
|
||||
let logPollInterval = null;
|
||||
let lastLogTime = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderApp();
|
||||
});
|
||||
|
||||
function renderApp() {
|
||||
document.getElementById('app').innerHTML = APP_TEMPLATE;
|
||||
initNavEvents();
|
||||
showPage('dashboard');
|
||||
}
|
||||
|
||||
function initNavEvents() {
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
showPage(item.dataset.page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showPage(pageName) {
|
||||
currentPage = pageName;
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelector(`[data-page="${pageName}"]`).classList.add('active');
|
||||
|
||||
const content = document.getElementById('page-content');
|
||||
content.innerHTML = `<div class="page active">${PAGES[pageName] || '<p>页面不存在</p>'}</div>`;
|
||||
|
||||
if (pageName === 'dashboard') loadDashboard();
|
||||
if (pageName === 'nodes') loadNodesForTable();
|
||||
if (pageName === 'monitor') { loadMonitorConfig(); loadWebhookTable(); }
|
||||
if (pageName === 'send') loadNodesForSend();
|
||||
if (pageName === 'logs') startLogStream();
|
||||
if (pageName === 'external') initExternalPage();
|
||||
if (pageName === 'plugins') setTimeout(loadPluginTable, 50);
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const result = await api('/nodes/');
|
||||
if (result.success) renderDashboard(result.data);
|
||||
}
|
||||
|
||||
async function loadNodesForTable() {
|
||||
const result = await api('/nodes/');
|
||||
if (result.success) renderNodesTable(result.data);
|
||||
}
|
||||
|
||||
function renderDashboard(nodes) {
|
||||
const total = nodes.length;
|
||||
const online = nodes.filter(n => n.status === 'active' && n.wechat_status === 'online').length;
|
||||
const offline = nodes.filter(n => n.status !== 'active' || n.wechat_status !== 'online').length;
|
||||
|
||||
const setText = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
setText('onlineCount', online);
|
||||
setText('offlineCount', offline);
|
||||
setText('totalMessages', total);
|
||||
setText('todayMessages', '-');
|
||||
}
|
||||
|
||||
function renderNodesTable(nodes) {
|
||||
const container = document.getElementById('nodesTable');
|
||||
if (!container) return;
|
||||
if (!nodes || !nodes.length) {
|
||||
container.innerHTML = '<p class="empty">暂无节点</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>名称</th><th>分组</th><th>API状态</th><th>微信状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${nodes.map(n => {
|
||||
const statusClass = n.status !== 'active' ? 'offline' : (n.wechat_status !== 'online' ? 'warning' : 'online');
|
||||
return `<tr>
|
||||
<td>${n.node_id}</td>
|
||||
<td>${n.name}</td>
|
||||
<td>${n.group}</td>
|
||||
<td><span class="status-${n.status}">${n.status}</span></td>
|
||||
<td><span class="status-wechat-${n.wechat_status}">${n.wechat_status}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-xs" onclick="checkNodeStatus('${n.node_id}')">检测</button>
|
||||
<button class="btn btn-xs btn-secondary" onclick="editNode('${n.node_id}')">编辑</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="deleteNode('${n.node_id}')">删除</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadNodesForSend() {
|
||||
const result = await api('/nodes/');
|
||||
if (result.success) {
|
||||
const options = '<option value="">选择节点</option>' +
|
||||
result.data.map(n => `<option value="${n.node_id}">${n.name} (${n.node_id})</option>`).join('');
|
||||
document.getElementById('sendNode').innerHTML = options;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await loadDashboard();
|
||||
showToast('刷新完成');
|
||||
}
|
||||
|
||||
async function checkNodeStatus(nodeId) {
|
||||
const result = await api(`/nodes/${nodeId}/status`);
|
||||
if (result.success) {
|
||||
showToast(`检测完成: API=${result.data.api_status}, 微信=${result.data.wechat_status || 'N/A'}`);
|
||||
}
|
||||
await loadNodesForTable();
|
||||
}
|
||||
|
||||
async function sendQuickMessage() {
|
||||
const nodeEl = document.getElementById('sendNode');
|
||||
const whoEl = document.getElementById('sendWho');
|
||||
const msgEl = document.getElementById('quickMsg');
|
||||
if (!nodeEl || !whoEl || !msgEl) {
|
||||
showToast('页面未正确加载', 'error');
|
||||
return;
|
||||
}
|
||||
const nodeId = nodeEl.value;
|
||||
const who = whoEl.value;
|
||||
const msg = msgEl.value;
|
||||
if (!nodeId || !who || !msg) {
|
||||
showToast('请填写完整信息', 'error');
|
||||
return;
|
||||
}
|
||||
const result = await api('/messages/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ node_id: nodeId, who, msg })
|
||||
});
|
||||
if (result.success) {
|
||||
showToast('发送成功');
|
||||
msgEl.value = '';
|
||||
} else {
|
||||
showToast('发送失败: ' + (result.error || result.detail), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonitorConfig() {
|
||||
const result = await api('/config/monitor');
|
||||
if (result.success) {
|
||||
const enabledEl = document.getElementById('monitorEnabled');
|
||||
const intervalEl = document.getElementById('checkInterval');
|
||||
if (enabledEl) enabledEl.checked = result.data.enabled;
|
||||
if (intervalEl) intervalEl.value = result.data.check_interval;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMonitorConfig() {
|
||||
const config = {
|
||||
enabled: document.getElementById('monitorEnabled').checked,
|
||||
check_interval: parseInt(document.getElementById('checkInterval').value) || 60
|
||||
};
|
||||
const result = await api('/config/monitor', { method: 'POST', body: JSON.stringify(config) });
|
||||
if (result.success) showToast('监控配置已更新');
|
||||
}
|
||||
|
||||
async function loadWebhookTable() {
|
||||
const result = await api('/webhook/');
|
||||
if (result.success) renderWebhookTable(result.data || []);
|
||||
}
|
||||
|
||||
function renderWebhookTable(webhooks) {
|
||||
const container = document.getElementById('webhookTable');
|
||||
if (!container) return;
|
||||
if (!webhooks.length) {
|
||||
container.innerHTML = '<p class="empty">暂无 Webhook,请点击添加</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>URL</th><th>格式</th><th>事件</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${webhooks.map(w => {
|
||||
const eventLabels = {
|
||||
'api_error': 'API错误',
|
||||
'wechat_offline': '微信离线',
|
||||
'wechat_online': '微信上线',
|
||||
'node_offline': '节点离线',
|
||||
'node_online': '节点上线'
|
||||
};
|
||||
const events = Array.isArray(w.event_types) ? w.event_types.map(e => eventLabels[e] || e).join(', ') : '-';
|
||||
return `<tr>
|
||||
<td>${w.name || '-'}</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">${w.url || '-'}</td>
|
||||
<td>${w.format === 'bark' ? 'Bark' : '企业微信'}</td>
|
||||
<td style="font-size: 12px;">${events}</td>
|
||||
<td><span class="status-${w.enabled ? 'online' : 'offline'}">${w.enabled ? '启用' : '禁用'}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-xs" onclick="editWebhook(${w.id})">编辑</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="deleteWebhook(${w.id})">删除</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function showAddWebhookModal(webhook = null) {
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const form = document.getElementById('nodeForm');
|
||||
|
||||
title.textContent = webhook ? '编辑 Webhook' : '添加 Webhook';
|
||||
form.innerHTML = `
|
||||
<input type="hidden" id="webhookId" value="${webhook ? webhook.id : ''}">
|
||||
<div class="form-group">
|
||||
<label>名称</label>
|
||||
<input type="text" id="webhookName" class="input" value="${webhook ? webhook.name : ''}" placeholder="如:Bark告警">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text" id="webhookUrl" class="input" value="${webhook ? webhook.url : ''}" placeholder="https://api.day.app/xxx">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>格式</label>
|
||||
<select id="webhookFormat" class="select">
|
||||
<option value="bark" ${webhook && webhook.format === 'bark' ? 'selected' : ''}>Bark</option>
|
||||
<option value="wechat" ${webhook && webhook.format === 'wechat' ? 'selected' : ''}>企业微信</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>启用</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="webhookEnabled" ${!webhook || webhook.enabled ? 'checked' : ''}>
|
||||
启用此 Webhook
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>接收事件</label>
|
||||
<div class="event-types">
|
||||
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="api_error" ${webhook && webhook.event_types && webhook.event_types.includes('api_error') ? 'checked' : ''}> API错误</label>
|
||||
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="wechat_offline" ${webhook && webhook.event_types && webhook.event_types.includes('wechat_offline') ? 'checked' : ''}> 微信离线</label>
|
||||
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="wechat_online" ${webhook && webhook.event_types && webhook.event_types.includes('wechat_online') ? 'checked' : ''}> 微信上线</label>
|
||||
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="node_offline" ${webhook && webhook.event_types && webhook.event_types.includes('node_offline') ? 'checked' : ''}> 节点离线</label>
|
||||
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="node_online" ${webhook && webhook.event_types && webhook.event_types.includes('node_online') ? 'checked' : ''}> 节点上线</label>
|
||||
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="plugin_notification" ${webhook && webhook.event_types && webhook.event_types.includes('plugin_notification') ? 'checked' : ''}> 插件通知</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" onclick="saveWebhook()">保存</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
|
||||
</div>
|
||||
`;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
async function editWebhook(id) {
|
||||
const result = await api(`/webhook/${id}`);
|
||||
if (result.success && result.data) {
|
||||
showAddWebhookModal(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWebhook(id) {
|
||||
if (!confirm('确定删除此 Webhook?')) return;
|
||||
const result = await api(`/webhook/${id}`, { method: 'DELETE' });
|
||||
if (result.success) {
|
||||
showToast('Webhook 已删除');
|
||||
loadWebhookTable();
|
||||
} else {
|
||||
showToast('删除失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWebhook() {
|
||||
const id = document.getElementById('webhookId').value;
|
||||
const eventTypes = Array.from(document.querySelectorAll('.webhook-event:checked')).map(cb => cb.value);
|
||||
const data = {
|
||||
name: document.getElementById('webhookName').value,
|
||||
url: document.getElementById('webhookUrl').value,
|
||||
format: document.getElementById('webhookFormat').value,
|
||||
enabled: document.getElementById('webhookEnabled').checked,
|
||||
event_types: eventTypes
|
||||
};
|
||||
|
||||
if (!data.url) {
|
||||
showToast('请输入 URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
if (id) {
|
||||
result = await api(`/webhook/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
} else {
|
||||
result = await api('/webhook/', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
showToast(id ? 'Webhook 已更新' : 'Webhook 已添加');
|
||||
closeModal();
|
||||
loadWebhookTable();
|
||||
} else {
|
||||
showToast('保存失败: ' + (result.error || ''), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function startLogStream() {
|
||||
const result = await api('/logs/recent?count=200');
|
||||
if (result.success && result.data) {
|
||||
const container = document.getElementById('logContainer');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
if (result.data.length > 0) {
|
||||
lastLogTime = result.data[0].timestamp;
|
||||
result.data.forEach(entry => addLogEntry(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (logPollInterval) clearInterval(logPollInterval);
|
||||
logPollInterval = setInterval(async () => {
|
||||
const res = await api('/logs/recent?count=20');
|
||||
if (res.success && res.data && res.data.length > 0) {
|
||||
const newLogs = res.data.filter(log => {
|
||||
if (!lastLogTime) return true;
|
||||
return new Date(log.timestamp) > new Date(lastLogTime);
|
||||
});
|
||||
if (newLogs.length > 0) {
|
||||
lastLogTime = newLogs[0].timestamp;
|
||||
const container = document.getElementById('logContainer');
|
||||
if (container) newLogs.forEach(entry => addLogEntry(entry));
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function addLogEntry(entry) {
|
||||
const container = document.getElementById('logContainer');
|
||||
if (!container) return;
|
||||
const colors = { 'INFO': '#4a9eff', 'WARNING': '#fa8c16', 'ERROR': '#f5222d', 'SUCCESS': '#52c41a' };
|
||||
const color = colors[entry.level] || '#666';
|
||||
const html = `<div class="log-entry">
|
||||
<span class="log-time">${entry.timestamp}</span>
|
||||
<span class="log-level" style="color: ${color}">${entry.level}</span>
|
||||
<span class="log-source">[${entry.source}]</span>
|
||||
<span class="log-message">${entry.message}</span>
|
||||
</div>`;
|
||||
container.insertAdjacentHTML('afterbegin', html);
|
||||
if (container.children.length > 500) container.lastChild.remove();
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
const container = document.getElementById('logContainer');
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
|
||||
function initExternalPage() {
|
||||
document.getElementById('externalApiUrl').textContent = `${window.location.origin}/api/v1/external/send`;
|
||||
document.getElementById('webhookReceiveUrl').textContent = `${window.location.origin}/api/v1/external/webhook/{event}`;
|
||||
}
|
||||
|
||||
async function loadPluginTable() {
|
||||
const result = await api('/plugins/');
|
||||
if (result.success) renderPluginTable(result.plugins || []);
|
||||
}
|
||||
|
||||
function renderPluginTable(plugins) {
|
||||
const container = document.getElementById('pluginTable');
|
||||
if (!container) return;
|
||||
if (!plugins.length) {
|
||||
container.innerHTML = '<p class="empty">暂无插件</p>';
|
||||
return;
|
||||
}
|
||||
const typeLabels = {
|
||||
'message_handler': '消息处理',
|
||||
'data_source': '数据源',
|
||||
'action_trigger': '动作触发',
|
||||
'ai_agent': 'AI智能体',
|
||||
'custom': '自定义'
|
||||
};
|
||||
container.innerHTML = `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>版本</th><th>类型</th><th>描述</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${plugins.map(p => `
|
||||
<tr>
|
||||
<td>${p.name || '-'}</td>
|
||||
<td>${p.version || '-'}</td>
|
||||
<td>${typeLabels[p.type] || p.type || '-'}</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">${p.description || '-'}</td>
|
||||
<td><span class="status-${p.enabled ? 'online' : 'offline'}">${p.enabled ? '启用' : '禁用'}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-xs" onclick="togglePlugin('${p.name}')">${p.enabled ? '禁用' : '启用'}</button>
|
||||
<button class="btn btn-xs" onclick="configPlugin('${p.name}')">配置</button>
|
||||
<button class="btn btn-xs btn-secondary" onclick="showPluginLogs('${p.name}')">日志</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
async function togglePlugin(name) {
|
||||
const result = await api(`/plugins/${name}`);
|
||||
if (result.success && result.plugin) {
|
||||
const action = result.plugin.enabled ? 'disable' : 'enable';
|
||||
const res = await api(`/plugins/${name}/${action}`, { method: 'POST' });
|
||||
if (res.success) {
|
||||
showToast(res.message);
|
||||
loadPluginTable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function configPlugin(name) {
|
||||
Promise.all([
|
||||
api(`/plugins/${name}`),
|
||||
api(`/plugins/${name}/schema`)
|
||||
]).then(([pluginResult, schemaResult]) => {
|
||||
if (pluginResult.success) {
|
||||
if (schemaResult.success && schemaResult.has_form && schemaResult.schema) {
|
||||
showPluginConfigForm(pluginResult.plugin, schemaResult.schema);
|
||||
} else {
|
||||
showPluginConfigModal(pluginResult.plugin);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showPluginConfigForm(plugin, schema) {
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const form = document.getElementById('nodeForm');
|
||||
|
||||
title.textContent = `配置插件: ${plugin.name}`;
|
||||
let formHtml = '';
|
||||
|
||||
const currentConfig = plugin.config || {};
|
||||
|
||||
if (schema.sections) {
|
||||
schema.sections.forEach((section, sIdx) => {
|
||||
const sectionId = `plugin-section-${sIdx}`;
|
||||
formHtml += `<div class="form-section">
|
||||
<h4 class="section-header" onclick="togglePluginSection('${sectionId}')">
|
||||
<span>${section.label}</span>
|
||||
<span class="section-arrow" id="${sectionId}-arrow">▼</span>
|
||||
</h4>
|
||||
<div class="section-content" id="${sectionId}" style="display: block;">`;
|
||||
|
||||
section.fields.forEach(field => {
|
||||
const value = currentConfig[field.name] !== undefined ? currentConfig[field.name] : field.default;
|
||||
|
||||
if (field.type === 'string' || field.type === 'password') {
|
||||
formHtml += `<div class="form-group">
|
||||
<label>${field.label}</label>
|
||||
<input type="${field.type === 'password' ? 'password' : 'text'}"
|
||||
id="plugin_field_${field.name}"
|
||||
value="${escapeHtml(value || '')}"
|
||||
placeholder="${field.placeholder || ''}"
|
||||
${field.required ? 'required' : ''}>
|
||||
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
|
||||
</div>`;
|
||||
} else if (field.type === 'number') {
|
||||
formHtml += `<div class="form-group">
|
||||
<label>${field.label}</label>
|
||||
<input type="number"
|
||||
id="plugin_field_${field.name}"
|
||||
value="${value}"
|
||||
min="${field.min_value || ''}"
|
||||
max="${field.max_value || ''}"
|
||||
${field.required ? 'required' : ''}>
|
||||
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
|
||||
</div>`;
|
||||
} else if (field.type === 'boolean') {
|
||||
formHtml += `<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="plugin_field_${field.name}" ${value ? 'checked' : ''}>
|
||||
${field.label}
|
||||
</label>
|
||||
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
|
||||
</div>`;
|
||||
} else if (field.type === 'textarea') {
|
||||
formHtml += `<div class="form-group">
|
||||
<label>${field.label}</label>
|
||||
<textarea id="plugin_field_${field.name}"
|
||||
rows="3"
|
||||
placeholder="${field.placeholder || ''}">${escapeHtml(value || '')}</textarea>
|
||||
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
|
||||
</div>`;
|
||||
} else if (field.type === 'select' && field.options) {
|
||||
let optionsHtml = field.options.map(opt =>
|
||||
`<option value="${opt.value}" ${value === opt.value ? 'selected' : ''}>${opt.label}</option>`
|
||||
).join('');
|
||||
formHtml += `<div class="form-group">
|
||||
<label>${field.label}</label>
|
||||
<select id="plugin_field_${field.name}">${optionsHtml}</select>
|
||||
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
|
||||
</div>`;
|
||||
} else if (field.type === 'cron') {
|
||||
formHtml += `<div class="form-group">
|
||||
<label>${field.label}</label>
|
||||
<input type="text"
|
||||
id="plugin_field_${field.name}"
|
||||
value="${escapeHtml(value || '')}"
|
||||
placeholder="*/5 * * * *">
|
||||
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
|
||||
</div>`;
|
||||
} else {
|
||||
formHtml += `<div class="form-group">
|
||||
<label>${field.label}</label>
|
||||
<input type="text"
|
||||
id="plugin_field_${field.name}"
|
||||
value="${escapeHtml(value || '')}"
|
||||
placeholder="${field.placeholder || ''}">
|
||||
</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
formHtml += `</div></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
formHtml += `<input type="hidden" id="pluginName" value="${plugin.name}">`;
|
||||
formHtml += `<input type="hidden" id="pluginSchema" value='${JSON.stringify(schema).replace(/'/g, "'")}'>`;
|
||||
|
||||
form.innerHTML = formHtml + `
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" onclick="savePluginConfigFromForm()">保存</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
|
||||
</div>
|
||||
`;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function togglePluginSection(sectionId) {
|
||||
const content = document.getElementById(sectionId);
|
||||
const arrow = document.getElementById(sectionId + '-arrow');
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
arrow.textContent = '▼';
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
arrow.textContent = '▶';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (typeof text !== 'string') text = String(text);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function savePluginConfigFromForm() {
|
||||
const name = document.getElementById('pluginName').value;
|
||||
const schema = JSON.parse(document.getElementById('pluginSchema').value);
|
||||
|
||||
const config = {};
|
||||
|
||||
if (schema.sections) {
|
||||
schema.sections.forEach(section => {
|
||||
section.fields.forEach(field => {
|
||||
const input = document.getElementById(`plugin_field_${field.name}`);
|
||||
if (!input) return;
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
config[field.name] = input.checked;
|
||||
} else if (field.type === 'number') {
|
||||
config[field.name] = parseFloat(input.value) || 0;
|
||||
} else {
|
||||
config[field.name] = input.value;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const result = await api(`/plugins/${name}/config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, config })
|
||||
});
|
||||
if (result.success) {
|
||||
showToast('配置已保存');
|
||||
closeModal();
|
||||
loadPluginTable();
|
||||
} else {
|
||||
showToast('保存失败: ' + (result.error || ''), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showPluginConfigModal(plugin) {
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const form = document.getElementById('nodeForm');
|
||||
|
||||
title.textContent = `配置插件: ${plugin.name} (JSON)`;
|
||||
const configJson = JSON.stringify(plugin.config || {}, null, 2);
|
||||
form.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label>配置 (JSON)</label>
|
||||
<textarea id="pluginConfigJson" class="textarea" rows="6" placeholder='{"key": "value"}'>${configJson}</textarea>
|
||||
</div>
|
||||
<input type="hidden" id="pluginName" value="${plugin.name}">
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" onclick="savePluginConfig()">保存</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
|
||||
</div>
|
||||
`;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
async function savePluginConfig() {
|
||||
const name = document.getElementById('pluginName').value;
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(document.getElementById('pluginConfigJson').value);
|
||||
} catch (e) {
|
||||
showToast('JSON格式错误', 'error');
|
||||
return;
|
||||
}
|
||||
const result = await api(`/plugins/${name}/config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, config })
|
||||
});
|
||||
if (result.success) {
|
||||
showToast('配置已保存');
|
||||
closeModal();
|
||||
loadPluginTable();
|
||||
} else {
|
||||
showToast('保存失败: ' + (result.error || ''), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function showPluginLogs(pluginName) {
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const form = document.getElementById('nodeForm');
|
||||
|
||||
title.textContent = `插件日志: ${pluginName}`;
|
||||
form.innerHTML = `
|
||||
<div class="form-group">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<label>日志内容</label>
|
||||
<button type="button" class="btn btn-xs" onclick="refreshPluginLogs('${pluginName}')">刷新</button>
|
||||
</div>
|
||||
<div id="pluginLogsContent" style="background: #1e1e1e; color: #d4d4d4; padding: 10px; border-radius: 4px; max-height: 400px; overflow-y: auto; font-family: monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all;">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="logPluginName" value="${pluginName}">
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">关闭</button>
|
||||
</div>
|
||||
`;
|
||||
modal.style.display = 'flex';
|
||||
|
||||
await refreshPluginLogs(pluginName);
|
||||
}
|
||||
|
||||
async function refreshPluginLogs(pluginName) {
|
||||
const logsContent = document.getElementById('pluginLogsContent');
|
||||
if (!logsContent) return;
|
||||
|
||||
logsContent.textContent = '加载中...';
|
||||
|
||||
const result = await api(`/plugins/${pluginName}/logs?lines=100`);
|
||||
|
||||
if (result.success) {
|
||||
if (result.logs && result.logs.length > 0) {
|
||||
logsContent.textContent = result.logs.join('\n');
|
||||
logsContent.scrollTop = logsContent.scrollHeight;
|
||||
} else {
|
||||
logsContent.textContent = '暂无日志';
|
||||
}
|
||||
} else {
|
||||
logsContent.textContent = '加载失败: ' + (result.error || '未知错误');
|
||||
}
|
||||
}
|
||||
|
||||
function showAddNodeModal(node = null) {
|
||||
document.getElementById('modalTitle').textContent = '添加节点';
|
||||
document.getElementById('nodeForm').reset();
|
||||
document.getElementById('nodeIdOld').value = '';
|
||||
document.getElementById('nodeId').disabled = false;
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function editNode(nodeId) {
|
||||
const result = await api(`/nodes/${nodeId}`);
|
||||
if (result.success) {
|
||||
const node = result.data;
|
||||
document.getElementById('modalTitle').textContent = '编辑节点';
|
||||
document.getElementById('nodeIdOld').value = node.node_id;
|
||||
document.getElementById('nodeId').value = node.node_id;
|
||||
document.getElementById('nodeId').disabled = true;
|
||||
document.getElementById('nodeName').value = node.name;
|
||||
document.getElementById('nodeApiUrl').value = node.api_url;
|
||||
document.getElementById('nodeApiKey').value = node.api_key;
|
||||
document.getElementById('nodeGroup').value = node.group;
|
||||
document.getElementById('nodeDesc').value = node.description || '';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNode(nodeId) {
|
||||
if (!confirm(`确定删除节点 ${nodeId}?`)) return;
|
||||
const result = await api(`/nodes/${nodeId}`, { method: 'DELETE' });
|
||||
if (result.success) {
|
||||
showToast('节点已删除');
|
||||
await loadNodesForTable();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('nodeForm').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const oldId = document.getElementById('nodeIdOld').value;
|
||||
const nodeData = {
|
||||
node_id: document.getElementById('nodeId').value,
|
||||
name: document.getElementById('nodeName').value,
|
||||
api_url: document.getElementById('nodeApiUrl').value,
|
||||
api_key: document.getElementById('nodeApiKey').value,
|
||||
group: document.getElementById('nodeGroup').value,
|
||||
description: document.getElementById('nodeDesc').value
|
||||
};
|
||||
let result;
|
||||
if (oldId) {
|
||||
result = await api(`/nodes/${oldId}`, { method: 'PUT', body: JSON.stringify(nodeData) });
|
||||
} else {
|
||||
result = await api('/nodes/', { method: 'POST', body: JSON.stringify(nodeData) });
|
||||
}
|
||||
if (result.success) {
|
||||
showToast(oldId ? '节点已更新' : '节点已添加');
|
||||
closeModal();
|
||||
await loadNodesForTable();
|
||||
} else {
|
||||
showToast('失败: ' + (result.error || result.detail), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal').style.display = 'none';
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.className = 'toast show ' + type;
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', renderApp);
|
||||
@@ -0,0 +1,190 @@
|
||||
const APP_TEMPLATE = `
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>WXAuto</h1>
|
||||
<span>中控系统</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="nav-item active" data-page="dashboard">
|
||||
<span class="nav-icon">📊</span>
|
||||
<span>控制台</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="nodes">
|
||||
<span class="nav-icon">📱</span>
|
||||
<span>节点管理</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="monitor">
|
||||
<span class="nav-icon">🔔</span>
|
||||
<span>监控告警</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="send">
|
||||
<span class="nav-icon">✉️</span>
|
||||
<span>快捷消息</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="logs">
|
||||
<span class="nav-icon">📋</span>
|
||||
<span>实时日志</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="external">
|
||||
<span class="nav-icon">🔗</span>
|
||||
<span>外部接入</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="plugins">
|
||||
<span class="nav-icon">🧩</span>
|
||||
<span>插件管理</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<div id="page-content"></div>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const PAGES = {
|
||||
dashboard: `
|
||||
<div class="page-header">
|
||||
<h1>控制台</h1>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">在线节点</div>
|
||||
<div class="stat-value" id="onlineCount">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">离线节点</div>
|
||||
<div class="stat-value" id="offlineCount">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总消息数</div>
|
||||
<div class="stat-value" id="totalMessages">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">今日消息</div>
|
||||
<div class="stat-value" id="todayMessages">-</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
nodes: `
|
||||
<div class="page-header">
|
||||
<h1>节点管理</h1>
|
||||
<button class="btn btn-primary" onclick="showAddNodeModal()">
|
||||
<span class="icon">➕</span> 添加节点
|
||||
</button>
|
||||
</div>
|
||||
<div class="node-list" id="nodesTable">
|
||||
<div class="loading">加载中...</div>
|
||||
</div>
|
||||
`,
|
||||
monitor: `
|
||||
<div class="page-header">
|
||||
<h1>监控告警</h1>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="settings-card">
|
||||
<h3>🔔 监控设置</h3>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="monitorEnabled" onchange="updateMonitorConfig()">
|
||||
启用节点监控
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>检测间隔(秒)</label>
|
||||
<input type="number" id="checkInterval" class="input input-sm" value="30" min="10" onchange="updateMonitorConfig()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h3>📊 回调地址</h3>
|
||||
<div class="form-group">
|
||||
<label>Webhook 接收地址</label>
|
||||
<p style="color: #666; font-size: 12px; margin: 5px 0;">
|
||||
配置此地址用于接收微信状态回调
|
||||
</p>
|
||||
<code>/api/v1/callback/node/reset</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-header" style="margin-top: 30px;">
|
||||
<h3>🔗 Webhook 渠道</h3>
|
||||
<button class="btn btn-primary" onclick="showAddWebhookModal()">
|
||||
<span class="icon">➕</span> 添加 Webhook
|
||||
</button>
|
||||
</div>
|
||||
<div class="webhook-list" id="webhookTable">
|
||||
<div class="loading">加载中...</div>
|
||||
</div>
|
||||
`,
|
||||
send: `
|
||||
<div class="page-header">
|
||||
<h1>快捷消息</h1>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
<div class="form-group">
|
||||
<label>选择节点</label>
|
||||
<select id="sendNode" class="select">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>接收人</label>
|
||||
<input type="text" id="sendWho" class="input" placeholder="接收人昵称或备注">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>消息内容</label>
|
||||
<textarea id="quickMsg" class="textarea" rows="3" placeholder="输入消息内容..."></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="sendQuickMessage()">
|
||||
<span class="icon">📤</span> 发送
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
logs: `
|
||||
<div class="page-header">
|
||||
<h1>实时日志</h1>
|
||||
<button class="btn btn-secondary" onclick="loadLogs()">
|
||||
<span class="icon">🔄</span> 刷新
|
||||
</button>
|
||||
</div>
|
||||
<div class="log-list" id="logContainer">
|
||||
<div class="loading">加载中...</div>
|
||||
</div>
|
||||
`,
|
||||
external: `
|
||||
<div class="page-header">
|
||||
<h1>外部接入</h1>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
<h3>API 接入信息</h3>
|
||||
<div class="form-group">
|
||||
<label>API 地址</label>
|
||||
<code id="externalApiUrl">/api/v1/external/send</code>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>认证密钥</label>
|
||||
<input type="text" id="externalApiKey" class="input" value="your-external-api-key" readonly>
|
||||
</div>
|
||||
<h3 style="margin-top: 20px;">接入示例 (cURL)</h3>
|
||||
<pre class="code-block">curl -X POST "http://your-server:8080/api/v1/external/send" \\
|
||||
-H "X-ExternalKey: your-external-api-key" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"node_id": "wx1", "who": "文件传输助手", "msg": "测试消息"}'</pre>
|
||||
</div>
|
||||
`,
|
||||
plugins: `
|
||||
<div class="page-header">
|
||||
<h1>插件管理</h1>
|
||||
</div>
|
||||
<div class="plugin-list" id="pluginTable">
|
||||
<div class="loading">加载中...</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
window.PAGES = PAGES;
|
||||
window.APP_TEMPLATE = APP_TEMPLATE;
|
||||
|
||||
function getPage(name) {
|
||||
return PAGES[name] || '<p>页面不存在</p>';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# ///
|
||||
# __init__.py
|
||||
# 描述:工具模块
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from utils.common import require_api_key, require_external_key
|
||||
from utils.helpers import check_node_health, build_success_response, build_error_response
|
||||
|
||||
__all__ = [
|
||||
"require_api_key",
|
||||
"require_external_key",
|
||||
"check_node_health",
|
||||
"build_success_response",
|
||||
"build_error_response",
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
# ///
|
||||
# common.py
|
||||
# 描述:公共工具函数和依赖注入
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import Header, HTTPException, Depends
|
||||
from config import get_settings
|
||||
|
||||
|
||||
def require_api_key(x_api_key: Optional[str] = Header(None, alias="X-API-Key")):
|
||||
"""
|
||||
API Key 验证依赖
|
||||
|
||||
用于验证 X-API-Key Header 是否有效
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 - API Key 无效或缺失
|
||||
|
||||
Returns:
|
||||
str: 有效的 API Key
|
||||
"""
|
||||
if not x_api_key:
|
||||
raise HTTPException(status_code=401, detail="API Key required")
|
||||
settings = get_settings()
|
||||
if x_api_key != settings.external_api_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid API Key")
|
||||
return x_api_key
|
||||
|
||||
|
||||
def require_external_key(x_externalkey: Optional[str] = Header(None, alias="X-ExternalKey")):
|
||||
"""
|
||||
外部 API Key 验证依赖
|
||||
|
||||
用于验证 X-ExternalKey Header 是否有效
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 - External API Key 无效或缺失
|
||||
|
||||
Returns:
|
||||
str: 有效的 External API Key
|
||||
"""
|
||||
if not x_externalkey:
|
||||
raise HTTPException(status_code=401, detail="External API Key required")
|
||||
settings = get_settings()
|
||||
if x_externalkey != settings.external_api_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid External API Key")
|
||||
return x_externalkey
|
||||
@@ -0,0 +1,77 @@
|
||||
# ///
|
||||
# helpers.py
|
||||
# 描述:业务辅助函数
|
||||
# 作者:AI Generated
|
||||
# 创建日期:2026-04-05
|
||||
# ///
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.node_manager import node_manager
|
||||
|
||||
|
||||
async def check_node_health(node_id: str) -> None:
|
||||
"""
|
||||
检查节点健康状态
|
||||
|
||||
如果节点不健康,抛出 503 异常
|
||||
|
||||
Args:
|
||||
node_id: 节点ID
|
||||
|
||||
Raises:
|
||||
HTTPException: 503 - 节点不健康
|
||||
"""
|
||||
is_healthy, reason = await node_manager.is_node_healthy(node_id)
|
||||
if not is_healthy:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Node '{node_id}' is not healthy: {reason}. Message sending failed."
|
||||
)
|
||||
|
||||
|
||||
def build_success_response(data: Any = None, message: str = "操作成功") -> Dict[str, Any]:
|
||||
"""
|
||||
构建成功响应
|
||||
|
||||
Args:
|
||||
data: 响应数据
|
||||
message: 响应消息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 成功响应格式
|
||||
"""
|
||||
response = {"success": True, "message": message}
|
||||
if data is not None:
|
||||
response["data"] = data
|
||||
return response
|
||||
|
||||
|
||||
def build_error_response(error: str, code: int = 400) -> Dict[str, Any]:
|
||||
"""
|
||||
构建错误响应
|
||||
|
||||
Args:
|
||||
error: 错误信息
|
||||
code: HTTP 状态码
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 错误响应格式
|
||||
"""
|
||||
return {"success": False, "error": error}
|
||||
|
||||
|
||||
def parse_node_result(result: Dict[str, Any]) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
解析节点 API 调用结果
|
||||
|
||||
Args:
|
||||
result: 节点返回结果
|
||||
|
||||
Returns:
|
||||
tuple[bool, Optional[str]]: (是否成功, 错误信息)
|
||||
"""
|
||||
if result.get("success"):
|
||||
return True, None
|
||||
return False, result.get("error", "Unknown error")
|
||||
Reference in New Issue
Block a user