初始化提交

This commit is contained in:
2026-04-07 14:11:45 +08:00
parent 46342134b6
commit c412e4de5f
69 changed files with 19321 additions and 1 deletions
+3
View File
@@ -174,3 +174,6 @@ cython_debug/
# PyPI configuration file
.pypirc
# Docker data
docker_data/
+464
View File
@@ -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
View File
@@ -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"]
+105 -1
View File
@@ -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
View File
+182
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+213
View File
@@ -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)
+283
View 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
+317
View File
@@ -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
+344
View File
@@ -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 | 健康状态 |
+235
View File
@@ -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 认证
- 内部 APIHeader `X-API-Key`
- 外部 APIHeader `X-ExternalKey`
### 2. 异常处理
- 全局异常处理器
- 所有 API 调用包裹 try/except
- 10 秒 HTTP 超时
### 3. 数据一致性
- 数据库优先原则
- 启动时从数据库加载配置
- 操作时同时更新内存和数据库
+618
View File
@@ -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)。
+282
View File
@@ -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
);
```
+770
View File
@@ -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
- ReDochttp://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 内置 docshttp://localhost:8080/docs
- ReDochttp://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, {...})
```
**注意**:微信状态现在通过消息发送结果自动更新,不需要主动检测。
+662
View File
@@ -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 | 添加日志发布订阅流程图 |
+615
View File
@@ -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 | 移除广播功能相关文档 |
+615
View File
@@ -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()`
+286
View File
@@ -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 两种格式,节点/微信状态变化时自动通知
- **实时日志**:通过 SSEServer-Sent Events)技术实现日志实时推送,默认保留最近500条
- **Redis 消息队列**:支持 Redis 后端的消息队列,用于异步消息处理
- **多进程支持**:支持多 worker 部署,适应高并发场景
- **外部程序接入**:提供标准 RESTful API,支持外部系统调用
- **插件系统**:支持扩展插件,包括数据源、AI 智能体、Webhook触发器等
## 快速开始
### 环境要求
- Python 3.8+
- Redis (用于消息队列和多进程场景)
- FastAPI
- httpx(异步 HTTP 客户端)
- uvicornASGI 服务器)
- 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条日志,足够应对日常调试和监控需求。
## 许可证
本项目仅供学习交流使用,请勿用于违规用途。
+619
View File
@@ -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) 了解系统架构
+271
View File
@@ -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"}
```
+375
View File
@@ -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. **日志记录**: 保留完整日志便于排查问题
+400
View File
@@ -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. 后续优化方向
- 支持短信/邮件通知
- 支持多客户端负载均衡
- 支持扫码历史记录
- 支持自动重试机制
+70
View File
@@ -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
+208
View File
@@ -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()
+136
View File
@@ -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()
+48
View File
@@ -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
}
}
View File
+839
View File
@@ -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
View File
@@ -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
}
+195
View File
@@ -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)
+125
View File
@@ -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)
+15
View File
@@ -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!"}
+24
View File
@@ -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
}
+251
View File
@@ -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": "未知操作"}
+181
View File
@@ -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": "未知操作"}
+363
View File
@@ -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="从原始值中提取的规则,支持正则或字符串截取"
)
]
+305
View File
@@ -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()
+15
View File
@@ -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
+54
View File
@@ -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()
+44
View File
@@ -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
+35
View File
@@ -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
+29
View File
@@ -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
+29
View File
@@ -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
+54
View File
@@ -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;"
+93
View File
@@ -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
+93
View File
@@ -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
+11
View File
@@ -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"]
+145
View File
@@ -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()
+179
View File
@@ -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()
+225
View File
@@ -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()
+148
View File
@@ -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()
+119
View File
@@ -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()
+66
View File
@@ -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()
+685
View File
@@ -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;
}
+56
View File
@@ -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()">&times;</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>
+155
View File
@@ -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;
+759
View File
@@ -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, "&#39;")}'>`;
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);
+190
View File
@@ -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>';
}
+17
View File
@@ -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",
]
+50
View File
@@ -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
+77
View File
@@ -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")