Merge pull request 'Dev qing' (#14) from dev_qing into main
Reviewed-on: qing/micro_servers#14
This commit is contained in:
commit
edb53d079c
42 files changed
+5649
-370
No files matched your search
+19
-4
@@ -16,10 +16,20 @@
|
||||
|
||||
```bash
|
||||
cd BotOps
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
python app.py # 默认 ws://localhost:8765
|
||||
python app.py ws://your-host:8765 # 指定 aibox 地址
|
||||
|
||||
# 一键初始化(创建 venv + 安装依赖)
|
||||
bash _set.sh
|
||||
|
||||
# 启动
|
||||
bash _run.sh # 默认 ws://localhost:8765
|
||||
bash _run.sh ws://your-host:8765 # 指定 aibox 地址
|
||||
```
|
||||
|
||||
### Nuitka 编译为独立二进制
|
||||
|
||||
```bash
|
||||
bash _inst.sh # 产出 ./botops.bin(单文件,无需 Python 环境)
|
||||
./botops.bin [ws://host:port]
|
||||
```
|
||||
|
||||
## 命令
|
||||
@@ -34,7 +44,12 @@ BotOps/
|
||||
├── app.py # Textual App:布局 + 消息路由 + 快捷键
|
||||
├── ws_client.py # WebSocket 协议层(自动重连)
|
||||
├── commands.py # 斜杠命令注册表
|
||||
├── _run.sh # 启动脚本
|
||||
├── _set.sh # 初始化脚本(venv + 依赖安装)
|
||||
├── _inst.sh # Nuitka 编译脚本(产出 botops.bin)
|
||||
├── requirements.txt # Python 依赖
|
||||
└── widgets/
|
||||
├── __init__.py
|
||||
├── chat_view.py # 聊天区:AI 回复 Markdown 增量流式渲染
|
||||
└── status_bar.py # 底部状态栏(连接状态)
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# micro_servers
|
||||
|
||||
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python 与 Go 混合实现。
|
||||
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。
|
||||
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python、Go 与仓颉(Cangjie) 混合实现。
|
||||
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。此外包含独立的机器人硬件控制模块 `bot4cj` 和嵌入式脚本引擎 `libtry`。
|
||||
|
||||
## 架构总览
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 |
|
||||
| [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
|
||||
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
|
||||
| [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) |
|
||||
|
||||
### core(核心模块 · 进程托管服务)
|
||||
|
||||
@@ -68,7 +69,7 @@
|
||||
- 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。
|
||||
- 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。
|
||||
|
||||
### gateway(应用层网关 · v0.5.2)
|
||||
### gateway(应用层网关 · v0.5.3)
|
||||
|
||||
- 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。
|
||||
- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。
|
||||
@@ -136,7 +137,7 @@
|
||||
### smtp(邮件服务)
|
||||
|
||||
- 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。
|
||||
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,60 秒有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。
|
||||
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,连续失败 5 次自动作废并返回 `invalidated`,兼容旧字段 `input_captcha`)、`health`。
|
||||
- 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。
|
||||
|
||||
### aibox(独立 AI 智能体系统)
|
||||
@@ -145,6 +146,14 @@
|
||||
- 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。
|
||||
- 默认模型 `deepseek-v4-flash`,配置在 `config.json`;详见 [aibox/README.md](aibox/README.md)。
|
||||
|
||||
### bot4cj(机器人硬件脚本解析器)
|
||||
|
||||
- 基于仓颉(Cangjie)编程语言和 Linux I2C 接口的机器人动作脚本解析器,面向树莓派 Dashun 蜘蛛机器人平台(10 舵机 + 2 云台)。
|
||||
- 解析 `.mt` 矩阵脚本(行列式表格语法,支持 `@Servo` / `@Action` 等块),编译为舵机角度序列执行。
|
||||
- 硬件层通过 I2C 驱动 PCA9685(PWM 舵机控制,50Hz,脉宽 0.5–2.5ms)、MPU6050(陀螺仪/加速度计,10ms 采样 + 卡尔曼滤波)、Speaker(正弦波发声)。
|
||||
- 附带数学库(矩阵运算、NN/CNN、Adam 优化器)、CUDA GPU 加速 kernel 和 Lua 硬件模块(ADS1115 / ADS1256 / SPI)。
|
||||
- 与主干微服务完全独立,需部署在树莓派上独立运行。
|
||||
|
||||
## 共享基础库
|
||||
|
||||
| 库 | 说明 |
|
||||
@@ -153,6 +162,7 @@
|
||||
| [libhttp](libhttp/) | HTTP/WebSocket 库:`HttpServer`、`HttpClient`、`WebSocketServer/Client`(基于 Boost.Beast) |
|
||||
| [libcrypt](libcrypt/) | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` |
|
||||
| [libsqlite3](libsqlite3/) | SQLite3 C++ 封装(Header-only) |
|
||||
| [libtry](libtry/) | 嵌入式脚本解释器(~741 行 C),手写递归下降词法/语法/执行引擎,支持变量/数组/函数/控制流,宽字符(中文标识符),用于嵌入式操作界面 shell |
|
||||
|
||||
### Python 共享组件
|
||||
|
||||
@@ -176,6 +186,21 @@
|
||||
| 8009 | captcha | 127.0.0.1 | 验证码服务(pymsg/TCP,经 gateway 代理) |
|
||||
| 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) |
|
||||
| 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) |
|
||||
| — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) |
|
||||
|
||||
## 测试
|
||||
|
||||
[tests/](tests/) 包含一套黑盒测试套件,基于子进程调用与 HTTP 请求验证各微服务功能正确性:
|
||||
|
||||
| 测试文件 | 被测模块 | 说明 |
|
||||
|----------|----------|------|
|
||||
| `test_db_*.py` | db | SQL-over-TCP 的 select/insert/update/delete、日期哨兵、边界条件 |
|
||||
| `test_gateway_*.py` | gateway | 用户注册/登录/Token 校验、速率限制、CAPTCHA 集成 |
|
||||
| `test_waf_*.py` | waf | coraza 规则匹配与拦截、白名单放行 |
|
||||
| `test_dnfs_*.py` | dnfs | 节点树 CRUD、AI 审核集成 |
|
||||
|
||||
- 默认使用 `config.py` 中的生产地址(127.0.0.1),每个测试函数前后自动备份/恢复 db 数据,保证不污染线上数据。
|
||||
- 测试依赖 `pymsg`、`Flask`、`flask-cors`、`requests`、`Pillow`,详见 [tests/requirements-test.txt](tests/requirements-test.txt)。
|
||||
|
||||
## 依赖
|
||||
|
||||
@@ -193,7 +218,7 @@ bash _setup.deb.sh # Debian/Ubuntu
|
||||
# 或
|
||||
bash _setup.rhel.sh # RHEL/CentOS/Rocky/Fedora
|
||||
|
||||
# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> core)
|
||||
# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> smtp -> core)
|
||||
bash _inst.sh
|
||||
```
|
||||
|
||||
@@ -206,7 +231,7 @@ bash _run.sh # 运行
|
||||
|
||||
部署路径约定为 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务。
|
||||
|
||||
> **注意**:`ai_review` 未包含在 `_inst.sh` 中,需单独进入 `ai_review/` 目录执行 `_set.sh` 安装,并配置 `.env` 文件(DeepSeek API 密钥等)。
|
||||
> **注意**:`ai_review` 与 `captcha` 未包含在 `_inst.sh` 中,需分别进入 `ai_review/`、`captcha/` 目录执行 `_set.sh` 安装。其中 `ai_review` 需额外配置 `.env` 文件(DeepSeek API 密钥等)。
|
||||
|
||||
> **注意**:waf 首次编译需联网下载 Go 依赖,[waf/_set.sh](waf/_set.sh) 会自动调用 [waf/_goproxy.sh](waf/_goproxy.sh) 设置 GOPROXY 国内镜像。如需手动设置:`go env -w GOPROXY=https://goproxy.cn,direct`。
|
||||
|
||||
@@ -254,7 +279,7 @@ gateway 改为内部 HTTP 模式([example.ini](gateway/example.ini) 中 `tls =
|
||||
- **服务按业务能力拆分**:WAF 网关、应用网关、前端托管、节点树、数据库、内容审核各自独立、独立端口、独立数据存储。
|
||||
- **API 网关模式**:`waf` 作为边缘网关统一入口,`gateway` 承担鉴权与反向代理,内部服务通过 `closed` 列表隔离。
|
||||
- **进程编排/守护**:`core` 作为服务管理器拉起并守护子进程,类似简易版 supervisor。
|
||||
- **多语言异构**:C++(core / gateway)、Python(static / dnfs / db / ai_review)、Go(waf)混合,对外 HTTP、内部数据访问走 pymsg/TCP。
|
||||
- **多语言异构**:C++(core / gateway)、Python(static / dnfs / db / ai_review)、Go(waf)、仓颉/Cangjie(bot4cj)混合,对外 HTTP、内部数据访问走 pymsg/TCP。
|
||||
- **数据访问层解耦**:`db` 服务将数据库操作收敛为统一的 pymsg/TCP 接口,业务服务无需直连数据库。
|
||||
- **AI 能力集成**:`ai_review` 以 RAG 增强的 LLM 审核为业务层提供内容安全能力,支持人工反馈闭环。
|
||||
- **集中式可观测性**:`db` 内置日志表与自动清理,各服务通过 `log_client.py` SDK 异步推送日志(pymsg),实现全链路汇聚,无需引入额外中间件。
|
||||
|
||||
+65
-12
@@ -115,6 +115,7 @@ AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生,
|
||||
| `reload_skills` | 热重载扩展技能 |
|
||||
| `create_skill` | 创建新技能(AI 自主编写 Python 脚本) |
|
||||
| `install_skill` | 技能安装指引 |
|
||||
| `ask_user` | 向用户提问(AI 主动交互) |
|
||||
| `update_identity` | 更新用户身份信息(`00_IDENTITY.md`) |
|
||||
| `update_soul` | 更新 AI 自我定义(`99_SOUL.md`,智能体进化) |
|
||||
| `save_memory_and_end_conversation` | 保存记忆并结束对话 |
|
||||
@@ -374,36 +375,88 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
{
|
||||
"system": {
|
||||
"save_dir": "~/.aibox",
|
||||
"datetime_format": "%Y-%m-%d %H:%M:%S",
|
||||
"date_format": "%Y%m%d_%H%M%S",
|
||||
"debug_level": 1,
|
||||
"load_history_by_default": true,
|
||||
"log_file": "~/.aibox/ai.log",
|
||||
"log_max_size": 10485760,
|
||||
"log_backup_count": 5,
|
||||
"websocket_host": "localhost",
|
||||
"websocket_port": 8765,
|
||||
"debug_level": 1,
|
||||
"log_file": "~/.aibox/ai.log",
|
||||
"skills_dir": "~/.aibox/skills",
|
||||
"prompts_dir": "~/.aibox/prompts"
|
||||
},
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
"check_interval": 1.0,
|
||||
"max_tasks_per_run": 10,
|
||||
"broadcast_enabled": true
|
||||
},
|
||||
"api": {
|
||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
||||
"deepseek_model": "deepseek-v4-flash",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"timeout": 30,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"context": {
|
||||
"max_history_messages": 500,
|
||||
"max_context_messages": 120,
|
||||
"max_tokens": 8192,
|
||||
"max_history_conversations": 5,
|
||||
"preserve_system_prompts": true,
|
||||
"compress_enabled": true,
|
||||
"compress_message_threshold": 200,
|
||||
"compress_token_threshold": 120000,
|
||||
"compress_ratio": 0.2,
|
||||
"compress_keep_recent": 8
|
||||
},
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
"check_interval": 1.0,
|
||||
"max_tasks_per_run": 10
|
||||
"memo": {
|
||||
"default_reminder_minutes": 60,
|
||||
"max_memos_per_check": 10,
|
||||
"allow_user_tasks": true
|
||||
},
|
||||
"api": {
|
||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
||||
"deepseek_model": "deepseek-v4-flash",
|
||||
"api_key_env": "DEEPSEEK_API_KEY"
|
||||
"files": {
|
||||
"max_file_size": 10485710
|
||||
},
|
||||
"commands": {
|
||||
"enabled": true
|
||||
},
|
||||
"web": {
|
||||
"timeout": 10,
|
||||
"delay": 0,
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"respect_robots": false,
|
||||
"max_download_size": 104857600
|
||||
},
|
||||
"memory": {
|
||||
"max_search_results": 100
|
||||
"db_name": "memories.db",
|
||||
"max_search_results": 100,
|
||||
"default_importance": 2
|
||||
},
|
||||
"conversation": {
|
||||
"current_file": "current_conversation.json",
|
||||
"history_file": "conversation_history.json",
|
||||
"archive_dir": "archive"
|
||||
},
|
||||
"tools": {
|
||||
"max_result_length": 10000
|
||||
"max_result_length": 10000,
|
||||
"save_result_to_file": false,
|
||||
"tool_results_dir": "tool_results"
|
||||
},
|
||||
"compressor": {
|
||||
"min_messages_to_compress": 5,
|
||||
"weight_decay_factor": 0.01,
|
||||
"max_input_chars": 6000,
|
||||
"llm_max_tokens": 800
|
||||
},
|
||||
"proxy": {
|
||||
"type": "",
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,823 @@
|
||||
# AIBOX 模块技术手册
|
||||
|
||||
> 文档版本:2026-07-30
|
||||
> 代码版本:v2.0
|
||||
> 语言:Python 3.8+
|
||||
> 定位:单 AI 智能体系统(独立于微服务体系)
|
||||
|
||||
---
|
||||
|
||||
## 一、模块概述
|
||||
|
||||
### 1.1 定位
|
||||
|
||||
**AIBOX** 是一个基于 DeepSeek API 的单 AI 智能体系统,通过 WebSocket 与客户端双向通信。所有任务(文件操作、网页访问、记忆管理、任务调度等)都由 AI 对话驱动执行。
|
||||
|
||||
### 1.2 与微服务体系的关系
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 启动方式 | `core`(PMC) 通过 `init.txt` 拉起 |
|
||||
| 监听端口 | 8765(WebSocket) |
|
||||
| 与主干关系 | **完全独立**,不调用 waf/gateway/dnfs 等服务 |
|
||||
| 数据存储 | 本地 `~/.aibox/`(不使用 db:8005) |
|
||||
| 前端 | 独立 HTML 页面 |
|
||||
|
||||
### 1.3 核心特性
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| AI 对话 | DeepSeek API + 思考模式(reasoning 流式输出) |
|
||||
| 实时通信 | WebSocket 双向通信 + 流式输出 |
|
||||
| 工具系统 | 19 个内置工具(文件/网页/记忆/任务/命令等) |
|
||||
| 技能扩展 | 热重载 `.py` 脚本和 `.md` 技能 |
|
||||
| 记忆管理 | 自动保存/检索对话记忆 |
|
||||
| 任务调度 | 一次性/重复/即时任务 |
|
||||
| 自我进化 | AI 动态更新角色与用户画像 |
|
||||
| 对话压缩 | 自动压缩长对话历史 |
|
||||
| 多语言 | 中英自动检测 |
|
||||
|
||||
---
|
||||
|
||||
## 二、技术架构
|
||||
|
||||
### 2.1 技术栈
|
||||
|
||||
| 层 | 技术 | 用途 |
|
||||
|----|------|------|
|
||||
| 语言 | Python 3.8+ | 主程序 |
|
||||
| 通信 | websockets | WebSocket 服务器 |
|
||||
| AI | DeepSeek API | 智能对话、工具调用、流式输出 |
|
||||
| 存储 | SQLite | 记忆数据库、备忘录任务 |
|
||||
| HTTP | requests | 网页访问、API 调用 |
|
||||
| HTML 解析 | beautifulsoup4 | 网页内容提取 |
|
||||
| SSH | paramiko | SSH 远程执行(技能) |
|
||||
| 加密 | cryptography | AES 加密(技能) |
|
||||
|
||||
### 2.2 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WebSocket 客户端 │
|
||||
│ (entry.html / frontend.html / client.py / voice_client.py) │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│ ws://localhost:8765
|
||||
┌────────────────────────▼────────────────────────────────────┐
|
||||
│ WebSocket 服务器 (main.py) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ 消息处理器 │ │ 命令处理器 │ │ 任务调度器 │ │
|
||||
│ │ (handle_msg) │ │ (handle_cmd) │ │ (MemoScheduler) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
|
||||
└─────────┼─────────────────┼───────────────────┼─────────────┘
|
||||
│ │ │
|
||||
┌─────────▼─────────────────▼───────────────────▼─────────────┐
|
||||
│ AI 对话引擎 (DeepSeekChat) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ 提示词管理器 │ │ 工具注册器 │ │ 对话压缩器 │ │
|
||||
│ │ (PromptMgr) │ │ (ToolRegistry)│ │ (ConversationComp)│ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
|
||||
└─────────┼─────────────────┼───────────────────┼─────────────┘
|
||||
│ │ │
|
||||
┌─────────▼─────────────────▼───────────────────▼─────────────┐
|
||||
│ 数据持久层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ 记忆数据库 │ │ 备忘录 DB │ │ 对话历史管理器 │ │
|
||||
│ │ (memories.db) │ │ (memos.db) │ │ (JSON files) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 核心数据流
|
||||
|
||||
```
|
||||
客户端请求 → WebSocketHandler.handle_message()
|
||||
↓
|
||||
┌─── 消息类型判断 ───┬─── 命令类型判断 ───┬─── 任务操作判断 ───┐
|
||||
│ type="message" │ type="command" │ type="task" │
|
||||
└─────────┬─────────┴─────────┬─────────┴─────────┬─────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
AI 对话引擎 命令处理器 任务管理器
|
||||
(DeepSeekChat) (handle_command) (handle_task)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
工具调用循环 系统命令执行 任务 CRUD 操作
|
||||
(ToolRegistry) (/new, /list...) (add, complete...)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
流式响应生成 命令结果返回 任务状态广播
|
||||
(chunk → complete) (command_result) (task_status)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、代码结构
|
||||
|
||||
### 3.1 文件结构
|
||||
|
||||
```
|
||||
aibox/
|
||||
├── main.py # 核心(~8000 行)
|
||||
│ ├── WebSocketHandler # WebSocket 消息处理
|
||||
│ ├── DeepSeekChat # AI 对话引擎
|
||||
│ ├── PromptManager # 提示词管理
|
||||
│ ├── Tool + 19 个工具类 # 工具系统
|
||||
│ ├── MemoScheduler # 任务调度器
|
||||
│ ├── MemoryManager # 记忆管理
|
||||
│ └── ConfigManager # 配置管理
|
||||
├── client.py # CLI WebSocket 客户端
|
||||
├── voice_client.py # 语音客户端(语音输入/输出)
|
||||
├── entry.html # Web 前端入口
|
||||
├── frontend.html # Web 主页面
|
||||
├── skills/ # 内置扩展技能
|
||||
│ ├── hello-world.py # 示例技能
|
||||
│ ├── bing_search.py # 必应搜索
|
||||
│ ├── ssh-exec.py # SSH 远程执行
|
||||
│ ├── sftp-transfer.py # SFTP 传输
|
||||
│ ├── facerec.py # 人脸识别
|
||||
│ ├── aes-crypto.py # AES 加密
|
||||
│ ├── gitclient.py # Git 操作
|
||||
│ └── *.md # Markdown 技能(prompt)
|
||||
├── tasks/ITIL.md # ITIL 任务模板
|
||||
├── SKILLS_FORMAT.md # 技能格式规范
|
||||
├── requirements.txt
|
||||
├── _run.sh # core 拉起的启动脚本
|
||||
├── _runcli.sh # CLI 启动
|
||||
├── _set.sh # 依赖安装
|
||||
├── _setenv.sh # 环境变量设置
|
||||
└── README.md # 用户文档
|
||||
```
|
||||
|
||||
### 3.2 main.py 核心类
|
||||
|
||||
| 类名 | 行号 | 功能 |
|
||||
|------|------|------|
|
||||
| `Tool` | L3642 | 工具基类(抽象方法:get_name/execute) |
|
||||
| `WebSocketHandler` | ~L1900 | WebSocket 消息处理 |
|
||||
| `DeepSeekChat` | ~L2400 | AI 对话引擎 |
|
||||
| `PromptManager` | ~L1050 | 提示词文件管理 |
|
||||
| `ConfigManager` | ~L1080 | 配置加载/合并 |
|
||||
| `MemoryManager` | ~L5200 | 记忆 CRUD |
|
||||
| `MemoScheduler` | ~L4700 | 任务调度器 |
|
||||
| `ConversationCompressor` | ~L6480 | 对话压缩 |
|
||||
|
||||
### 3.3 运行期数据
|
||||
|
||||
```
|
||||
~/.aibox/
|
||||
├── config.json # 配置(自动生成默认值)
|
||||
├── prompts/
|
||||
│ ├── 00_IDENTITY.md # 用户身份(AI 可更新)
|
||||
│ ├── 1_*.md # 基础技能提示词
|
||||
│ ├── 99_SOUL.md # AI 灵魂定义(AI 可更新)
|
||||
│ └── 100_STATUS.md # AI 状态记录
|
||||
├── skills/ # 用户扩展技能
|
||||
├── memories.db # 记忆库(SQLite)
|
||||
├── memos.db # 备忘录任务(SQLite)
|
||||
├── current_conversation.json # 当前对话上下文
|
||||
├── conversation_history.json # 对话索引
|
||||
├── archive/ # 历史对话归档
|
||||
├── tool_results/ # 工具执行结果缓存
|
||||
└── ai.log # 日志(自动轮转)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、工具系统
|
||||
|
||||
### 4.1 工具基类
|
||||
|
||||
```python
|
||||
class Tool:
|
||||
"""工具基类,所有工具必须继承此类"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""返回工具名称(抽象方法,必须实现)"""
|
||||
pass
|
||||
|
||||
def get_description(self) -> str:
|
||||
"""返回工具描述"""
|
||||
return ""
|
||||
|
||||
def get_parameters(self) -> Dict:
|
||||
"""返回工具参数(JSON Schema 格式)"""
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
def execute(self, **kwargs) -> str:
|
||||
"""执行工具(抽象方法,必须实现)"""
|
||||
pass
|
||||
|
||||
def execute_streaming(self, **kwargs):
|
||||
"""流式执行(可选实现)"""
|
||||
return None
|
||||
```
|
||||
|
||||
### 4.2 内置工具一览
|
||||
|
||||
| 工具名 | 类名 | 行号 | 功能 |
|
||||
|--------|------|------|------|
|
||||
| `read_file` | `ReadFileTool` | L5762 | 读取文件(支持 head/tail/paging) |
|
||||
| `write_file` | `WriteFileTool` | L5884 | 写入/追加文件 |
|
||||
| `list_files` | `ListFilesTool` | L5961 | 目录列表 |
|
||||
| `web` | `SimpleWebCrawlerTool` | L5637 | 网页抓取(标题/正文/链接) |
|
||||
| `memory` | `MemoryTool` | L5280 | 记忆 CRUD(search/save/delete) |
|
||||
| `memo` | `MemoTool` | L4753 | 任务 CRUD(add/complete/list/search) |
|
||||
| `run_command` | `CommandLineTool` | L6150 | 系统命令执行(支持超时) |
|
||||
| `search_skills` | `SearchSkillsTool` | L4076 | 搜索扩展技能 |
|
||||
| `reload_skills` | `ReloadSkillsTool` | L4199 | 热重载技能 |
|
||||
| `create_skill` | `CreateSkillTool` | L4409 | AI 自主创建技能 |
|
||||
| `install_skill` | `InstallSkillTool` | L4306 | 技能安装指引 |
|
||||
| `ask_user` | `AskUserTool` | L4275 | 向用户提问 |
|
||||
| `update_identity` | `UpdateIdentityTool` | L4518 | 更新用户身份 |
|
||||
| `update_soul` | `UpdateSoulTool` | L4582 | 更新 AI 灵魂 |
|
||||
| `save_memory_and_end_conversation` | `SaveMemoryAndEndConversationTool` | L4646 | 保存记忆并结束对话 |
|
||||
| `get_current_time` | `GetCurrentTimeTool` | L6055 | 获取当前时间 |
|
||||
| `calculator` | `CalculatorTool` | L6095 | 数学计算 |
|
||||
| `set_proxy` | `SetProxyTool` | L6415 | 设置 SOCKS5 代理 |
|
||||
| `get_proxy` | `GetProxyTool` | L6453 | 查看代理配置 |
|
||||
|
||||
### 4.3 工具执行流程
|
||||
|
||||
```
|
||||
AI 决定调用工具 → 生成 JSON 格式工具调用
|
||||
↓
|
||||
DeepSeekChat.process_tool_calls()
|
||||
↓
|
||||
┌─── 解析工具名 ───┬─── 解析参数 ───┬─── 注册工具 ───┐
|
||||
└─────────────────┴───────────────┴─────────────────┘
|
||||
↓
|
||||
ToolRegistry.get_tool(name)
|
||||
↓
|
||||
tool.execute(**params)
|
||||
↓
|
||||
┌─── 结果处理 ───┬─── 错误处理 ───┬─── 长结果截断 ───┐
|
||||
└─────────────────┴───────────────┴─────────────────┘
|
||||
↓
|
||||
返回结果给 AI → AI 继续生成响应
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、WebSocket 协议
|
||||
|
||||
### 5.1 客户端 → 服务器
|
||||
|
||||
| 类型 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `message` | `content` | 普通对话文本 |
|
||||
| `command` | `command` | 斜杠命令(如 `/new`, `/help`) |
|
||||
| `task` | `action`, `title`, ... | 任务操作(add/complete/list) |
|
||||
| `ping` | — | 心跳检测 |
|
||||
|
||||
### 5.2 服务器 → 客户端
|
||||
|
||||
| 类型 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `chunk` | `content` | AI 响应片段(流式) |
|
||||
| `complete` | `content` | AI 响应完成 |
|
||||
| `reasoning` | `content` | AI 思考过程 |
|
||||
| `error` | `content` | 错误信息 |
|
||||
| `command_result` | `content` | 命令执行结果 |
|
||||
| `task_status` | `task_id`, `status` | 任务状态更新 |
|
||||
| `task_process` | `task_id`, `content` | 任务执行过程 |
|
||||
| `dialogue_ended` | — | 对话结束 |
|
||||
| `dialogue_reset` | — | 对话重置完成 |
|
||||
| `info` | `content` | 系统信息 |
|
||||
| `pong` | — | Ping 响应 |
|
||||
|
||||
### 5.3 消息流示例
|
||||
|
||||
```
|
||||
// 普通对话
|
||||
Client: {type: "message", content: "你好"}
|
||||
Server: {type: "chunk", content: "你好!"}
|
||||
Server: {type: "complete", content: "你好!有什么可以帮你的吗?"}
|
||||
|
||||
// 工具调用
|
||||
Server: {type: "tool_call", tool: "web", args: {url: "..."}}
|
||||
Server: {type: "chunk", content: "网页内容:..."}
|
||||
Server: {type: "complete", content: "网页已获取"}
|
||||
|
||||
// 任务执行
|
||||
Client: {type: "task", action: "add", title: "提醒", delay: 60}
|
||||
Server: {type: "command_result", content: "任务已添加"}
|
||||
// 60 秒后
|
||||
Server: {type: "task_status", task_id: 1, status: "executing"}
|
||||
Server: {type: "task_process", task_id: "1", content: "处理中..."}
|
||||
Server: {type: "task_status", task_id: 1, status: "complete"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、任务系统
|
||||
|
||||
### 6.1 任务类型
|
||||
|
||||
| 类型 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| 一次性 | `reminder_time` | 指定执行时间 |
|
||||
| 重复(daily) | `repeat_type: "daily"` | 每天执行 |
|
||||
| 重复(weekly) | `repeat_type: "weekly"`, `weekday` | 每周指定日 |
|
||||
| 重复(monthly) | `repeat_type: "monthly"`, `day` | 每月指定日 |
|
||||
| 重复(custom) | `repeat_type: "custom"`, `interval`, `unit` | 自定义间隔 |
|
||||
| 即时 | `is_immediate: true` | 立即执行 |
|
||||
|
||||
### 6.2 任务调度流程
|
||||
|
||||
```
|
||||
MemoScheduler._run() # 后台线程
|
||||
↓
|
||||
检查所有未完成任务
|
||||
↓
|
||||
┌─── 到达时间? ───┬─── 未完成? ───┬─── 允许用户任务? ───┐
|
||||
└─────────────────┴───────────────┴─────────────────┘
|
||||
↓
|
||||
创建新的 AI 对话(将任务内容作为用户输入)
|
||||
↓
|
||||
AI 处理任务(可能调用多个工具)
|
||||
↓
|
||||
标记任务已触发
|
||||
↓
|
||||
广播结果到所有客户端
|
||||
↓
|
||||
计算下一次执行时间(重复任务)
|
||||
```
|
||||
|
||||
### 6.3 数据库表结构
|
||||
|
||||
```sql
|
||||
-- memories.db
|
||||
CREATE TABLE memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT,
|
||||
content TEXT,
|
||||
importance INTEGER DEFAULT 2,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
embedding BLOB -- 预留向量嵌入
|
||||
);
|
||||
|
||||
-- memos.db
|
||||
CREATE TABLE memos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
reminder_time TEXT,
|
||||
repeat_type TEXT,
|
||||
repeat_interval INTEGER,
|
||||
repeat_interval_unit TEXT,
|
||||
is_immediate INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending',
|
||||
is_triggered INTEGER DEFAULT 0,
|
||||
created_at TEXT,
|
||||
user_id TEXT
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、记忆系统
|
||||
|
||||
### 7.1 记忆操作
|
||||
|
||||
| 操作 | AI 调用 | 说明 |
|
||||
|------|---------|------|
|
||||
| 保存 | `memory(action="save", content=...)` | AI 主动保存重要信息 |
|
||||
| 搜索 | `memory(action="search", query=...)` | 按关键词搜索记忆 |
|
||||
| 列表 | `memory(action="list", page=...)` | 分页浏览所有记忆 |
|
||||
| 删除 | `memory(action="delete", id=...)` | 删除指定记忆 |
|
||||
|
||||
### 7.2 自动记忆
|
||||
|
||||
- AI 在对话结束时(`/exit`)自动保存关键信息
|
||||
- 可配置 `system.auto_save_memory` 开关
|
||||
- 重要度(importance)1-5 级
|
||||
|
||||
---
|
||||
|
||||
## 八、对话压缩
|
||||
|
||||
### 8.1 压缩触发条件
|
||||
|
||||
| 条件 | 阈值(默认) | 配置项 |
|
||||
|------|-------------|--------|
|
||||
| 消息数超过 | 200 条 | `compress_message_threshold` |
|
||||
| Token 数超过 | 120000 | `compress_token_threshold` |
|
||||
|
||||
### 8.2 压缩流程
|
||||
|
||||
```
|
||||
检测到阈值触发
|
||||
↓
|
||||
保留最近 8 条消息(compress_keep_recent)
|
||||
↓
|
||||
对早期消息按时间衰减加权(weight_decay_factor=0.01)
|
||||
↓
|
||||
调用 AI 生成摘要(max_input_chars=6000)
|
||||
↓
|
||||
将摘要作为系统消息插入
|
||||
↓
|
||||
压缩后继续对话
|
||||
```
|
||||
|
||||
### 8.3 压缩配置
|
||||
|
||||
```json
|
||||
{
|
||||
"compressor": {
|
||||
"min_messages_to_compress": 5,
|
||||
"weight_decay_factor": 0.01,
|
||||
"max_input_chars": 6000,
|
||||
"llm_max_tokens": 800
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、扩展技能系统
|
||||
|
||||
### 9.1 技能类型
|
||||
|
||||
| 类型 | 格式 | 存储位置 |
|
||||
|------|------|---------|
|
||||
| 可执行脚本 | Python/Shell,支持 `--help` | `~/.aibox/skills/` |
|
||||
| Markdown 技能 | `.md` 文件,首行为简介 | `~/.aibox/skills/` |
|
||||
|
||||
### 9.2 内置技能
|
||||
|
||||
| 技能名 | 文件 | 功能 |
|
||||
|--------|------|------|
|
||||
| hello-world | `skills/hello-world.py` | 示例技能 |
|
||||
| bing_search | `skills/bing_search.py` | 必应搜索 |
|
||||
| ssh-exec | `skills/ssh-exec.py` | SSH 远程执行 |
|
||||
| sftp-transfer | `skills/sftp-transfer.py` | SFTP 文件传输 |
|
||||
| facerec | `skills/facerec.py` | 人脸识别 |
|
||||
| aes-crypto | `skills/aes-crypto.py` | AES 加密/解密 |
|
||||
| gitclient | `skills/gitclient.py` | Git 操作封装 |
|
||||
|
||||
### 9.3 技能热重载
|
||||
|
||||
```python
|
||||
# 新技能放置到 ~/.aibox/skills/
|
||||
# 调用 reload_skills 工具
|
||||
# AI 下次对话时即可使用
|
||||
```
|
||||
|
||||
### 9.4 技能格式规范
|
||||
|
||||
详见 [SKILLS_FORMAT.md](file:///Users/qingsong/micro_servers/aibox/SKILLS_FORMAT.md)
|
||||
|
||||
---
|
||||
|
||||
## 十、配置参考
|
||||
|
||||
### 10.1 完整配置项
|
||||
|
||||
#### system 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `save_dir` | str | `~/.aibox` | 数据存储目录 |
|
||||
| `datetime_format` | str | `%Y-%m-%d %H:%M:%S` | 日期时间格式 |
|
||||
| `date_format` | str | `%Y%m%d_%H%M%S` | 文件名日期格式 |
|
||||
| `debug_level` | int | `1` | 调试级别 0-3 |
|
||||
| `load_history_by_default` | bool | `true` | 启动时加载历史对话 |
|
||||
| `log_file` | str | `~/.aibox/ai.log` | 日志文件路径 |
|
||||
| `log_max_size` | int | `10485760` | 日志最大字节数(10MB) |
|
||||
| `log_backup_count` | int | `5` | 日志备份数量 |
|
||||
| `websocket_host` | str | `localhost` | WebSocket 监听地址 |
|
||||
| `websocket_port` | int | `8765` | WebSocket 监听端口 |
|
||||
| `skills_dir` | str | `~/.aibox/skills` | 技能目录 |
|
||||
| `prompts_dir` | str | `~/.aibox/prompts` | 提示词目录 |
|
||||
|
||||
#### scheduler 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `enabled` | bool | `true` | 启用任务调度器 |
|
||||
| `check_interval` | float | `1.0` | 检查间隔(秒) |
|
||||
| `max_tasks_per_run` | int | `10` | 单次最大触发任务数 |
|
||||
| `broadcast_enabled` | bool | `true` | 广播任务执行结果 |
|
||||
|
||||
#### api 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `deepseek_api_url` | str | `https://api.deepseek.com/chat/completions` | API URL |
|
||||
| `deepseek_model` | str | `deepseek-v4-flash` | 模型名 |
|
||||
| `api_key_env` | str | `DEEPSEEK_API_KEY` | API Key 环境变量名 |
|
||||
| `timeout` | int | `30` | 请求超时(秒) |
|
||||
| `temperature` | float | `0.7` | 生成温度 0-1 |
|
||||
|
||||
#### context 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `max_history_messages` | int | `500` | 历史消息上限 |
|
||||
| `max_context_messages` | int | `120` | 上下文窗口 |
|
||||
| `max_tokens` | int | `8192` | 最大生成 Token |
|
||||
| `max_history_conversations` | int | `5` | 保留历史对话数 |
|
||||
| `preserve_system_prompts` | bool | `true` | 保留系统提示词 |
|
||||
| `compress_enabled` | bool | `true` | 启用对话压缩 |
|
||||
| `compress_message_threshold` | int | `200` | 压缩消息阈值 |
|
||||
| `compress_token_threshold` | int | `120000` | 压缩 Token 阈值 |
|
||||
| `compress_ratio` | float | `0.2` | 压缩保留比例 |
|
||||
| `compress_keep_recent` | int | `8` | 保留最近消息数 |
|
||||
|
||||
#### memo 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `default_reminder_minutes` | int | `60` | 默认提醒分钟数 |
|
||||
| `max_memos_per_check` | int | `10` | 单次最大触发任务数 |
|
||||
| `allow_user_tasks` | bool | `true` | 允许用户创建任务 |
|
||||
|
||||
#### proxy 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `type` | str | `""` | 代理类型(socks5/http) |
|
||||
| `host` | str | `""` | 代理主机 |
|
||||
| `port` | int | `0` | 代理端口 |
|
||||
| `username` | str | `""` | 代理用户名 |
|
||||
| `password` | str | `""` | 代理密码 |
|
||||
|
||||
### 10.2 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DEEPSEEK_API_KEY` | DeepSeek API Key(必填) |
|
||||
| `AI_CONFIG_PATH` | 自定义配置文件路径 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、命令行参数
|
||||
|
||||
```bash
|
||||
python main.py [选项]
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `--verbose`, `-v` | flag | `false` | 显示详细日志 |
|
||||
| `--debug` | int | `None` | 调试级别 0-3 |
|
||||
| `--lang` | str | `None` | 强制语言 zh/en |
|
||||
| `--host` | str | `None` | 覆盖 WebSocket 主机 |
|
||||
| `--port` | int | `None` | 覆盖 WebSocket 端口 |
|
||||
| `--no-scheduler` | flag | `false` | 禁用任务调度器 |
|
||||
| `--scheduler-interval` | float | `1.0` | 调度器检查间隔 |
|
||||
| `--config`, `-c` | str | `None` | 指定配置文件 |
|
||||
| `--check-memo` | flag | `false` | 检查到期任务并退出 |
|
||||
| `--memo-add` | str | `None` | 添加任务(CLI) |
|
||||
| `--memo-list` | flag | `false` | 列出任务 |
|
||||
| `--memo-complete` | int | `None` | 完成指定任务 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```bash
|
||||
# 启动服务器
|
||||
python main.py
|
||||
|
||||
# 详细日志 + 指定端口
|
||||
python main.py --verbose --port 9000
|
||||
|
||||
# 检查并执行到期任务(cron 场景)
|
||||
python main.py --check-memo
|
||||
|
||||
# 添加任务
|
||||
python main.py --memo-add "每日备份|执行数据库备份|+1h"
|
||||
|
||||
# 列出所有任务
|
||||
python main.py --memo-list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、前端集成
|
||||
|
||||
### 12.1 前端文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `entry.html` | 简单入口页(重定向到 frontend.html) |
|
||||
| `frontend.html` | 完整前端界面(对话/任务/设置) |
|
||||
|
||||
### 12.2 WebSocket 连接
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8765');
|
||||
|
||||
// 发送消息
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
content: '你好'
|
||||
}));
|
||||
|
||||
// 接收消息
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case 'chunk':
|
||||
appendToChat(data.content);
|
||||
break;
|
||||
case 'complete':
|
||||
finishResponse();
|
||||
break;
|
||||
case 'error':
|
||||
showError(data.content);
|
||||
break;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十三、部署
|
||||
|
||||
### 13.1 目录结构
|
||||
|
||||
```
|
||||
/opt/micro_servers/aibox/
|
||||
├── main.py
|
||||
├── client.py
|
||||
├── skills/
|
||||
├── _run.sh # core 拉起入口
|
||||
├── _set.sh # 首次安装
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### 13.2 core init.txt 配置
|
||||
|
||||
```bash
|
||||
# aibox 由 core 拉起
|
||||
bash /opt/micro_servers/aibox/_run.sh
|
||||
```
|
||||
|
||||
### 13.3 systemd 单元(可选)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=AIBOX AI Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
WorkingDirectory=/opt/micro_servers/aibox
|
||||
ExecStart=/usr/bin/python3 main.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=DEEPSEEK_API_KEY=xxx
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、已知限制
|
||||
|
||||
| ID | 描述 | 严重等级 |
|
||||
|----|------|---------|
|
||||
| L1 | 单实例运行,不支持多进程 | 🟡 Medium |
|
||||
| L2 | 记忆库无向量搜索,仅关键词匹配 | 🟡 Medium |
|
||||
| L3 | 对话压缩可能丢失早期重要信息 | 🟢 Low |
|
||||
| L4 | 工具执行无超时控制(部分工具) | 🟡 Medium |
|
||||
| L5 | 日志无远程推送,仅本地文件 | 🟢 Low |
|
||||
| L6 | 无用户认证,WebSocket 连接可被滥用 | 🔴 High |
|
||||
| L7 | API Key 以明文存储在环境变量或文件中 | 🟡 Medium |
|
||||
|
||||
### L6 详细说明
|
||||
|
||||
WebSocket 服务器**无任何认证**,任何能连接到 `ws://localhost:8765` 的客户端都可以:
|
||||
- 与 AI 对话(消耗 API 额度)
|
||||
- 执行系统命令(`run_command`)
|
||||
- 操作文件系统(`read_file`/`write_file`)
|
||||
- 创建/删除任务
|
||||
|
||||
**建议**:
|
||||
1. 仅绑定 `localhost`(默认配置已这样做)
|
||||
2. 添加 API Token 认证
|
||||
3. 限制 `run_command` 可执行的命令范围
|
||||
|
||||
---
|
||||
|
||||
## 十五、开发指南
|
||||
|
||||
### 15.1 添加新工具
|
||||
|
||||
1. 继承 `Tool` 基类
|
||||
2. 实现 `get_name()`、`get_description()`、`get_parameters()`、`execute()`
|
||||
3. 在 `DeepSeekChat._register_tools()` 中注册
|
||||
4. 更新 README 文档
|
||||
|
||||
```python
|
||||
class MyTool(Tool):
|
||||
def get_name(self) -> str:
|
||||
return "my_tool"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "我的自定义工具"
|
||||
|
||||
def get_parameters(self) -> Dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param": {"type": "string", "description": "参数说明"}
|
||||
},
|
||||
"required": ["param"]
|
||||
}
|
||||
|
||||
def execute(self, param: str, **kwargs) -> str:
|
||||
return f"执行结果: {param}"
|
||||
```
|
||||
|
||||
### 15.2 添加新命令
|
||||
|
||||
在 `WebSocketHandler.handle_command()` 中添加:
|
||||
|
||||
```python
|
||||
elif command == "/mycommand":
|
||||
result = "命令执行结果"
|
||||
await self.send_message(websocket, {
|
||||
"type": "command_result",
|
||||
"content": result
|
||||
})
|
||||
```
|
||||
|
||||
### 15.3 添加配置项
|
||||
|
||||
1. 在 `ConfigManager._get_default_config()` 中添加默认值
|
||||
2. 在 README 文档中添加说明
|
||||
3. 确保 `_merge_configs()` 能正确处理新增字段
|
||||
|
||||
---
|
||||
|
||||
## 十六、故障排查
|
||||
|
||||
### 16.1 常见问题
|
||||
|
||||
| 问题 | 排查步骤 |
|
||||
|------|---------|
|
||||
| API Key 无效 | 检查 `echo $DEEPSEEK_API_KEY` 或 `~/.cert/deepseek.key` |
|
||||
| 技能不生效 | 确认技能文件在 `~/.aibox/skills/` 且有执行权限 |
|
||||
| 任务不执行 | 检查调度器状态:`/tasks`;检查时间设置 |
|
||||
| 对话压缩丢失信息 | 调整 `compress_message_threshold` 和 `compress_keep_recent` |
|
||||
| WebSocket 断开 | 检查服务器进程:`ps aux | grep main.py`;查看日志 |
|
||||
|
||||
### 16.2 日志位置
|
||||
|
||||
```bash
|
||||
# 查看实时日志
|
||||
tail -f ~/.aibox/ai.log
|
||||
|
||||
# 查看最近错误
|
||||
grep "ERROR" ~/.aibox/ai.log | tail -20
|
||||
```
|
||||
|
||||
### 16.3 数据库检查
|
||||
|
||||
```bash
|
||||
# 检查记忆库
|
||||
sqlite3 ~/.aibox/memories.db "SELECT * FROM memories LIMIT 10;"
|
||||
|
||||
# 检查任务库
|
||||
sqlite3 ~/.aibox/memos.db "SELECT * FROM memos WHERE status='pending';"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十七、版本历史
|
||||
|
||||
| 版本 | 日期 | 主要变更 |
|
||||
|------|------|---------|
|
||||
| v2.0 | 当前 | 新增 `ask_user`、`update_soul`、`create_skill`、`install_skill`、`set_proxy`/`get_proxy` 等工具;支持 Markdown 技能;对话压缩支持 Token 阈值 |
|
||||
| v1.0 | — | 初始版本:WebSocket 服务器、文件/网页/记忆操作、任务调度、扩展技能、多语言 |
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 配置文件示例
|
||||
|
||||
完整默认配置见 [main.py:1100-1180](file:///Users/qingsong/micro_servers/aibox/main.py#L1100-L1180)
|
||||
|
||||
### B. 数据库 Schema
|
||||
|
||||
完整建表语句见:
|
||||
- 记忆库:[main.py](file:///Users/qingsong/micro_servers/aibox/main.py) 中 `MemoryManager._init_db()`
|
||||
- 任务库:[main.py](file:///Users/qingsong/micro_servers/aibox/main.py) 中 `MemoScheduler._init_db()`
|
||||
|
||||
### C. 参考文档
|
||||
|
||||
- [DeepSeek API 文档](https://platform.deepseek.com/docs)
|
||||
- [websockets 库文档](https://websockets.readthedocs.io/)
|
||||
- [SKILLS_FORMAT.md](file:///Users/qingsong/micro_servers/aibox/SKILLS_FORMAT.md)
|
||||
|
||||
---
|
||||
|
||||
*本文档由代码审查工具生成,供开发团队参考。*
|
||||
+1
-1
@@ -3,6 +3,6 @@ cd "$(dirname "$0")" || exit
|
||||
source ./_setenv.sh
|
||||
source ./venv/bin/activate
|
||||
export PATH=${PATH}:~/.aibox/skills
|
||||
exec python main.py $@
|
||||
exec python aibox.py $@
|
||||
|
||||
|
||||
@@ -871,7 +871,8 @@ update_soul content="我是...(新的自我定义)"
|
||||
},
|
||||
{
|
||||
'filename': '9_language_instruction.md',
|
||||
'content': """# 语言说明
|
||||
'content': """
|
||||
# 语言说明
|
||||
|
||||
## 语言选择
|
||||
- 根据用户的输入语言来选择回复语言
|
||||
@@ -885,11 +886,23 @@ update_soul content="我是...(新的自我定义)"
|
||||
- 重要信息可以加粗或标记
|
||||
|
||||
记住:用用户习惯的语言交流!
|
||||
|
||||
---
|
||||
|
||||
## 提问行为(正常对话时)
|
||||
当你在处理用户问题过程中遇到以下情况时,**应主动使用 `ask_user` 工具向用户提问**:
|
||||
- 信息不足,无法准确回答
|
||||
- 任务有多种可行方案,需要用户选择
|
||||
- 用户需求模糊,需要进一步澄清
|
||||
- 执行操作前需要用户确认或提供额外参数
|
||||
|
||||
**注意**:此提问行为仅适用于**用户主动发起的对话**,不适用于定时任务触发的自动对话(定时任务中禁止提问)。
|
||||
"""
|
||||
},
|
||||
{
|
||||
'filename': '10_task_handling.md',
|
||||
'content': """# 任务处理规则
|
||||
'content': """
|
||||
# 任务处理规则
|
||||
|
||||
## 定时任务
|
||||
当收到定时任务触发时:
|
||||
@@ -897,6 +910,7 @@ update_soul content="我是...(新的自我定义)"
|
||||
2. 不要偏离主题
|
||||
3. 使用相应工具执行操作
|
||||
4. 完成后使用 `save_memory_and_end_conversation` 结束对话
|
||||
5. **不要在任务执行过程中创建新的定时任务(除非任务明确要求),也不要向用户提问(因为此对话是自动触发的)**
|
||||
|
||||
## 即时任务
|
||||
当任务需要立即执行时:
|
||||
@@ -909,6 +923,7 @@ update_soul content="我是...(新的自我定义)"
|
||||
- 每次触发时专注当前执行
|
||||
- 不要重复之前已完成的工作
|
||||
- 系统会自动更新下一次执行时间
|
||||
|
||||
"""
|
||||
}
|
||||
]
|
||||
@@ -2894,9 +2909,12 @@ class TaskScheduler:
|
||||
2. 根据任务内容进行适当的操作和回答
|
||||
3. 任务处理完成后,如果不需要继续对话,可以结束对话
|
||||
4. 系统已经自动更新了任务状态(更新了执行时间和次数),你不需要手动处理任务完成逻辑
|
||||
5. 不要在任务执行过程中创建新的定时任务(除非任务明确要求),也不要向用户提问(因为此对话是自动触发的)
|
||||
|
||||
请现在开始处理这个任务。
|
||||
"""
|
||||
|
||||
|
||||
user_input = focus_prompt + "\n\n" + user_input
|
||||
|
||||
result = "任务已触发AI对话"
|
||||
@@ -4268,6 +4286,36 @@ class ReloadSkillsTool(Tool):
|
||||
output_queue.put(("complete", result))
|
||||
|
||||
|
||||
# ==================== 询问工具 ========================
|
||||
class AskUserTool(Tool):
|
||||
"""智能体遇到了麻烦,主动向用户问一个问题"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "ask_user"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "你遇到了一个问题,想向用户询问一个问题的工具。"
|
||||
|
||||
def get_parameters(self) -> Dict:
|
||||
''' TODO: 调用子代理生成提示词 '''
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"你现在遇到一个问题,你想询问用户,现在提供你的问题。"
|
||||
)
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
|
||||
def execute(self, question: str, **kwargs) -> str:
|
||||
return (
|
||||
"你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。"
|
||||
)
|
||||
|
||||
# ==================== 安装技能工具 ====================
|
||||
|
||||
class InstallSkillTool(Tool):
|
||||
@@ -6547,6 +6595,9 @@ class DeepSeekChat:
|
||||
# 增强版保存记忆并结束对话工具
|
||||
if exit_callback and self.memory_db:
|
||||
builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger))
|
||||
|
||||
# 添加询问用户的工具
|
||||
builtin_tools.append(AskUserTool(self.logger))
|
||||
|
||||
# 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具
|
||||
builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger))
|
||||
@@ -6819,7 +6870,11 @@ class DeepSeekChat:
|
||||
## 第二步:搜索相关记忆
|
||||
在每次回答用户问题前,**必须**使用 `memory search` 工具搜索记忆库中与当前话题相关的记忆,确保回答的连贯性和准确性。
|
||||
|
||||
## 第三步:执行任务或回答问题
|
||||
## 第三步:积极向用户提问
|
||||
如果你有任何无法确定的事情,那么你需要用户提供额外信息,
|
||||
请务必使用 `ask_user` 工具向用户提问,以便获得更准确的指引。
|
||||
|
||||
## 第四步:执行任务或回答问题
|
||||
根据搜索结果,选择合适的工具或直接回答。
|
||||
|
||||
【重要提醒】
|
||||
@@ -0,0 +1,384 @@
|
||||
# bot4cj — 机器人硬件脚本解析器
|
||||
|
||||
基于**仓颉(Cangjie)** 编程语言和 Linux I2C 接口的机器人动作脚本解析器。用一种矩阵式脚本来描述机器人的硬件构成与动作序列,解析后驱动真实硬件执行。
|
||||
|
||||
## 架构分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ .mt 矩阵脚本(硬件定义 + 动作序列) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ MatrixParser(P.cj) — FSM 逐字符解析 → MatrixObject 链表 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ RobotStruct(r.cj) — 从矩阵对象构建硬件变量(createHardVars) │
|
||||
│ MatrixVars_Hardware 基类 + 闭包回调: │
|
||||
│ · build() → rise 回调(打开硬件、获取资源) │
|
||||
│ · play() → things 回调(执行动作,参数/返回值均为 JsonValue) │
|
||||
│ · free() → free 回调(释放资源) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 硬件实现:hd/*.cj │
|
||||
│ PCA9685(I2C 0x40, 50Hz PWM, 角度→脉宽 0.5~2.5ms) │
|
||||
│ Servo(舵机:max/min/offset 校准 + 角度翻转) │
|
||||
│ MPU6050(陀螺+加速度,±500°/s ±8g,后台 10ms 采样线程+卡尔曼) │
|
||||
│ Speaker(正弦波发声,5ms 淡入淡出) │
|
||||
│ fb.cj(外部硬件扩展口) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 系统库:cpp/ + cuda/ │
|
||||
│ libi2c4cj.so — I2C glibc 封装 + 20 槽锁数组 │
|
||||
│ libmt4cj.so — FP32 矩阵运算(CPU / CUDA 二选一) │
|
||||
│ libfft4cj.so — 基-2 Cooley-Tukey FFT │
|
||||
│ libcnn4cj.so — Conv/Pool/GA-Pool/Flatten + Adam/Momentum │
|
||||
│ libsnd4cj.so — WaveAPI / ALSA 音频播放 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 数学/NN 库:math/*.cj │
|
||||
│ Matrix1(二维矩阵)+ Tensor + 6 种激活函数 + Xavier 初始化 │
|
||||
│ Nn1 全连接层(前向/反向/权重更新) │
|
||||
│ Af(Sigmoid/Tanh/ReLU/Leaky_ReLU/Swish/Softmax) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
|----|------|------|
|
||||
| 应用层 | **仓颉 Cangjie** | 主程序、解析器、机器人结构、数学库、HTTP 服务 |
|
||||
| 系统层 | C / C++ | I2C 驱动、矩阵运算、FFT、CNN、音频 |
|
||||
| 加速层 | CUDA(可选) | `MODE=cpu/cuda` 切换 |
|
||||
| 脚本层 | `.mt` 矩阵脚本 | 硬件定义 + 动作序列 |
|
||||
| 依赖包 | ctype4cj / pthread4cj / spi4cj / lua4cj | 仓颉官方扩展包 |
|
||||
| 构建 | Makefile(C/C++ 共享库)+ cjpm.toml(仓颉包管理) | — |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
bot4cj/
|
||||
├── src/
|
||||
│ ├── main.cj # 入口:CLI 参数解析(-i/-o/--test)+ stdin 管道执行
|
||||
│ ├── P.cj # 矩阵脚本解析器(FSM 逐字符解析)
|
||||
│ ├── r.cj # RobotStruct + MatrixVars_Hardware 基类
|
||||
│ ├── testCNN.cj # CNN 测试入口
|
||||
│ ├── testCNN1.cj # 几何 CNN 测试
|
||||
│ ├── hd/
|
||||
│ │ ├── I2C.cj # I2C 封装 + 20 槽锁数组
|
||||
│ │ ├── PCA9685.cj # PCA9685 舵机控制板(已标记由 Lua 替代)
|
||||
│ │ ├── Servo.cj # 舵机:校准 + 角度翻转
|
||||
│ │ ├── MPU6050.cj # MPU6050 陀螺+加速度+卡尔曼滤波
|
||||
│ │ ├── snd.cj # 扬声器
|
||||
│ │ ├── fb.cj # 外部硬件扩展
|
||||
│ │ └── extern/
|
||||
│ │ └── macro_ExternHardware.cj
|
||||
│ ├── math/
|
||||
│ │ ├── Matrix.cj # 二维矩阵(+运算符重载)
|
||||
│ │ ├── Tensor.cj # 张量
|
||||
│ │ ├── Af.cj # 6 种激活函数
|
||||
│ │ ├── Ini.cj # Xavier/Kaiming 初始化
|
||||
│ │ ├── nn.cj # 全连接层 Nn1
|
||||
│ │ ├── nns.cj # 旧版神经网络(XNOR 门测试)
|
||||
│ │ ├── cnn.cj # CNN 仓颉绑定
|
||||
│ │ ├── pooling.cj # 池化
|
||||
│ │ ├── flatten.cj # Flatten
|
||||
│ │ ├── fft.cj # FFT 仓颉绑定
|
||||
│ │ ├── kalman.cj # 卡尔曼滤波
|
||||
│ │ └── Adam.cj # Adam 优化器
|
||||
│ ├── cpp/ # C/C++ 系统库源
|
||||
│ │ ├── I2C.c # I2C glibc 封装
|
||||
│ │ ├── Matrix.cpp # CPU 矩阵运算
|
||||
│ │ ├── fft1.c # FFT 单实序列
|
||||
│ │ ├── fft.cpp # FFT 双实序列 + 基-2 Cooley-Tukey
|
||||
│ │ ├── kalman.c # 卡尔曼滤波
|
||||
│ │ ├── snd.cpp # 音频播放(WaveAPI/ALSA)
|
||||
│ │ └── cnn/ # 纯 C++ CNN(Conv/Pool/GA-Pool/Flatten)
|
||||
│ └── cuda/
|
||||
│ ├── matrix.cu # GPU 矩阵运算 kernel
|
||||
│ └── error.cuh # CUDA 错误检查宏
|
||||
├── modules/ # Lua 硬件模块(ADS1115/ADS1256/PCA9685/SPI)
|
||||
├── scripts/ # .mt 动作脚本(std/jump/lie-down/tilt-* 等)
|
||||
├── test/ # C/Lua 测试脚本
|
||||
├── docs/Reports/ # 深度分析报告
|
||||
├── cjpm.toml # 仓颉包配置
|
||||
├── Makefile # C/C++ 共享库构建
|
||||
├── _set.sh # 编译(cjpm update + build)
|
||||
├── _run.sh # 运行(stdin 管道)
|
||||
├── compile.ps1 # Windows 编译脚本
|
||||
├── run.ps1 # Windows 运行脚本
|
||||
├── README.md # 旧版说明
|
||||
└── README.robot.md # 本文件
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 编译 C/C++ 共享库(一次性)
|
||||
make MODE=cpu # CPU 模式(默认)
|
||||
# 或
|
||||
make MODE=cuda # GPU 模式(需要 nvcc)
|
||||
|
||||
# 2. 编译仓颉程序
|
||||
bash _set.sh
|
||||
|
||||
# 3. 运行动作脚本(stdin 管道)
|
||||
cat scripts/std.mt | bash _run.sh
|
||||
```
|
||||
|
||||
> **硬件要求**:树莓派(aarch64)或其他 Linux I2C 设备。x86_64 开发板需自行验证 I2C 参数。Windows 暂不支持(依赖 glibc)。
|
||||
|
||||
## CLI 参数
|
||||
|
||||
```
|
||||
./bot # 从 stdin 读取 .mt 脚本执行
|
||||
./bot --help # 打印帮助
|
||||
./bot --test nn # 运行神经网络与门测试
|
||||
./bot --test cnn # 运行 CNN 测试
|
||||
./bot --test cnn1 # 运行几何 CNN 测试
|
||||
./bot --test matrix_o # 解析 ss/test.mt 并保存为 ss/output.mt
|
||||
./bot --test matrix_2 # 解析 ss/output.mt → 执行 → 再次保存
|
||||
./bot -i model.json -o out.json # 加载/保存神经网络模型(DRL 预留)
|
||||
```
|
||||
|
||||
## .mt 脚本语法
|
||||
|
||||
行列式表格,每列以空格或制表符分隔,每行以分号 `;` 结尾。
|
||||
|
||||
### 块结构
|
||||
|
||||
```
|
||||
@BlockName
|
||||
column1 column2 column3;
|
||||
value1 value2 value3;
|
||||
@
|
||||
```
|
||||
|
||||
每个 `@` 块由 `@Name` 行开始,`@` 行结束。支持的块类型:
|
||||
|
||||
| 块 | 说明 |
|
||||
|----|------|
|
||||
| `@Alias` | 列名别名(如 `A` → `angle`、`F` → `frequency`) |
|
||||
| `@I2C` | 定义 I2C 设备(name + path) |
|
||||
| `@PCA9685` | 定义 PCA9685 舵机控制板(name + I2C 引用 + 地址) |
|
||||
| `@MPU6050` | 定义 MPU6050 传感器(name + I2C 引用 + 地址) |
|
||||
| `@Speaker` | 定义扬声器(channels + sample_rate + volume + max_f + min_f) |
|
||||
| `@Servo` | 定义舵机(name + max + min + offset + controller + channel) |
|
||||
| `@Action` | **动作矩阵**:列头为硬件变量的属性,行为每帧的参数 |
|
||||
|
||||
### 占位符
|
||||
|
||||
`-` 表示"沿用上一行同列的值",当前行该列不产生调用。
|
||||
|
||||
### 完整示例
|
||||
|
||||
```mt
|
||||
@Alias
|
||||
name alias;
|
||||
A angle;
|
||||
F frequency;
|
||||
@
|
||||
|
||||
@I2C
|
||||
name path;
|
||||
i2c-0 /dev/i2c-0;
|
||||
i2c-1 /dev/i2c-1;
|
||||
@
|
||||
|
||||
@PCA9685
|
||||
name I2C address;
|
||||
pca9685-0 i2c-1 0x40;
|
||||
@
|
||||
|
||||
@Speaker
|
||||
name channels sample_rate volumn max_f min_f;
|
||||
spk 2 48000 0.6 5000 -;
|
||||
@
|
||||
|
||||
@Servo
|
||||
name max min offset controller channel;
|
||||
s1 90 - - pca9685-0 0;
|
||||
s2 180 - - - 1;
|
||||
s3 -90 - - - 2;
|
||||
s4 -180 - - - 3;
|
||||
@
|
||||
|
||||
@Action
|
||||
s1.A s2.A s3.A s4.A spk.F spk.delay;
|
||||
45 - 45 - 10 200; # 站立
|
||||
90 - - - - 20; # 左转
|
||||
@
|
||||
```
|
||||
|
||||
## 执行流程
|
||||
|
||||
```
|
||||
stdin.read()
|
||||
↓
|
||||
P.MatrixParser (FSM 逐字符解析)
|
||||
↓
|
||||
LinkedList<MatrixObject> 链表
|
||||
↓
|
||||
RobotStruct.createHardVars() # 按 @I2C/@PCA9685/@Servo/@Speaker/@MPU6050 构建硬件变量
|
||||
↓
|
||||
robot.active() # build():逐个硬件调用 rise 回调(打开 I2C、初始化 PWM 等)
|
||||
↓
|
||||
robot.run() # 逐行遍历 @Action 矩阵,每列调用对应硬件的 play() 回调
|
||||
↓
|
||||
robot.deactive() # free():倒序释放资源
|
||||
```
|
||||
|
||||
`play()` 的输入输出均为 `JsonValue`,实现了硬件与脚本之间的统一通信协议。
|
||||
|
||||
## 已支持的硬件
|
||||
|
||||
### PCA9685(I2C 舵机控制板)
|
||||
- 地址:默认 `0x40`
|
||||
- PWM 频率:50Hz(舵机标准 20ms 周期)
|
||||
- 舵机脉宽映射:0°→0.5ms,180°→2.5ms
|
||||
- 角度映射到 12 位 PWM 值(0-4095)
|
||||
- **注意**:PCA9685.cj 代码已标记"由 Lua 脚本取代",保留作备用
|
||||
|
||||
### Servo(舵机)
|
||||
- 支持 `max` / `min` 角度限制与 `offset` 漂移校准
|
||||
- `max < 0` 时自动翻转角度(反向安装的舵机)
|
||||
- 通过闭包回调与实际硬件(PCA9685/Lua 模块)解耦
|
||||
|
||||
### MPU6050(6 轴传感器)
|
||||
- 陀螺仪:±500°/s 量程
|
||||
- 加速度计:±8g 量程
|
||||
- 后台 10ms 采样线程(pthread_create + pthread_cancel)
|
||||
- 卡尔曼滤波融合数据
|
||||
- **已知问题**:`ADDR_0` 与 `ADDR_1` 常量均为 `0x68`([MPU6050.cj:14-15](src/hd/MPU6050.cj#L14-L15)),第二个地址应改为 `0x69`
|
||||
|
||||
### Speaker(扬声器)
|
||||
- 支持正弦波发声(指定频率 `F` + 延时 `delay`)
|
||||
- 5ms 淡入淡出,避免"咔哒"声
|
||||
- 通道数/采样率/音量可在 `.mt` 中配置
|
||||
- **已知问题**:`play()` 仅 sleep 指定延时,真实发声依赖底层 C++ 实现
|
||||
|
||||
## 机器人型号适配
|
||||
|
||||
### Dashun 蜘蛛机器人(默认)
|
||||
10 舵机 + 2 云台 + 扬声器,建模文件见 [gopsc/Dashun](https://github.com/gopsc/Dashun):
|
||||
|
||||
| 舵机 | 关节 | 舵机 | 关节 |
|
||||
|------|------|------|------|
|
||||
| s1 | 左下水平 (rb0) | s6 | 右上垂直 (lf1) |
|
||||
| s2 | 左下垂直 (rb1) | s7 | 左上水平 (rf0) |
|
||||
| s3 | 右下水平 (lb0) | s8 | 左上垂直 (rf1) |
|
||||
| s4 | 右下垂直 (lb1) | s9 | 云台1 (cam0) |
|
||||
| s5 | 右上水平 (lf0) | s10 | 云台2 (cam1) |
|
||||
|
||||
### 可用脚本
|
||||
|
||||
`scripts/` 目录下预写了多种动作脚本:
|
||||
- `std.mt` — 站立姿态
|
||||
- `jump.mt` — 跳跃
|
||||
- `lie-down.mt` — 趴下
|
||||
- `left-hand.mt` / `rght-hand.mt` — 单手动作
|
||||
- `left-turn.mt` / `tilt-f.mt` / `tilt-b.mt` / `tilt-l.mt` / `tilt-r.mt` — 转向与倾斜
|
||||
|
||||
## 构建
|
||||
|
||||
### 依赖
|
||||
|
||||
```bash
|
||||
# 仓颉 1.1.3
|
||||
# 仓颉官方扩展包:ctype4cj / pthread4cj / spi4cj / lua4cj
|
||||
# libc / libpthread
|
||||
# liblua-5.4(可选,Lua 硬件模块支持)
|
||||
sudo ln -s /usr/lib/aarch64-linux-gnu/liblua5.4.so /usr/lib/aarch64-linux-gnu/liblua.so
|
||||
sudo ln -s /usr/lib/x86_64-linux-gnu/liblua5.4.so /usr/lib/x86_64-linux-gnu/liblua.so
|
||||
```
|
||||
|
||||
### 构建步骤
|
||||
|
||||
```bash
|
||||
# 1. 构建 C/C++ 共享库
|
||||
make MODE=cpu
|
||||
# 产出:libi2c4cj.so libmt4cj.so libfft4cj.so libsnd4cj.so
|
||||
|
||||
# 2. (可选)构建 CNN
|
||||
make libcnn4cj.so
|
||||
|
||||
# 3. (可选)CUDA 模式需 NVIDIA CUDA Toolkit
|
||||
make MODE=cuda
|
||||
|
||||
# 4. 仓颉主程序
|
||||
bash _set.sh
|
||||
```
|
||||
|
||||
[_set.sh](_set.sh) 执行 `cjpm update` + `cjpm build`,自动解析 cjpm.toml 依赖。
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
bash _run.sh scripts/std.mt
|
||||
# 或
|
||||
cat scripts/std.mt | bash _run.sh
|
||||
```
|
||||
|
||||
[_run.sh](_run.sh) 以 `cjStackSize=64MB` / `cjHeapSize=64MB` 启动。
|
||||
|
||||
## 神经网络支持
|
||||
|
||||
项目内置了全连接层 Nn1([math/nn.cj](src/math/nn.cj))与 CNN([math/cnn.cj](src/math/cnn.cj) + [cpp/cnn/](src/cpp/cnn/)):
|
||||
|
||||
- **Nn1**:多层感知机,支持前向传播、反向传播、权重更新
|
||||
- **CNN**:Conv → ReLU → Pool → GA-Pool → Flatten → FC
|
||||
- **优化器**:Adam / Momentum
|
||||
- **激活函数**:Sigmoid / Tanh / ReLU / Leaky_ReLU / Swish / Softmax
|
||||
- **初始化**:Xavier / Kaiming
|
||||
|
||||
### 测试
|
||||
|
||||
```bash
|
||||
./bot --test nn # 与门训练(2 输入 → 1 输出)
|
||||
./bot --test cnn # CNN 前向/反向测试
|
||||
./bot --test cnn1 # 几何分类 CNN 测试
|
||||
```
|
||||
|
||||
### DRL 预留(未完成)
|
||||
|
||||
main.cj 中预留了 DRL 流程(注释状态):加载预训练权重 → 执行动作 → 保存微调模型。通过 `-i` / `-o` 参数指定输入/输出路径。
|
||||
|
||||
## 安全与限制
|
||||
|
||||
### I2C 总线保护
|
||||
- [hd/I2C.cj](src/hd/I2C.cj) 内置 **20 槽锁数组**,多硬件共享 I2C 总线时自动互斥
|
||||
- 每个 I2C fd 独立加锁,避免并发写入冲突
|
||||
|
||||
### 硬件关闭时机
|
||||
- `MatrixVars_Hardware` 析构依赖仓颉 GC,**不保证**在确定性时机调用 `free()`
|
||||
- 机器人使用 `try/finally` 在 [main.cj:80-87](src/main.cj#L80) 中显式调用 `deactive()`,确保硬件资源正确释放
|
||||
|
||||
### 线程安全
|
||||
- MPU6050 采样通过 `pthread_create` 启动后台线程
|
||||
- `pthread_cancel` 依赖系统定义的取消点(sleep / 系统调用),可能在非安全点中断
|
||||
|
||||
### 平台支持
|
||||
- ✅ Linux aarch64(树莓派)/ x86_64
|
||||
- ❌ Windows(依赖 glibc)
|
||||
|
||||
## 已知问题
|
||||
|
||||
| ID | 描述 | 影响 |
|
||||
|----|------|------|
|
||||
| D1 | 仓颉 GC 不保证析构器调用,I2C fd 可能泄漏 | 长时间运行后 I2C 资源耗尽 |
|
||||
| D2 | Speaker `play()` 仅 sleep,真实发声未接入 C++ | 扬声器无实际声音 |
|
||||
| D3 | MPU6050 `pthread_cancel` 可能在非安全点中断 | 传感器数据不一致 |
|
||||
| D4 | PCA9685 初始化被标记为"由 Lua 取代",但仍保留主路径 | 代码存在双实现 |
|
||||
| D5 | `_set.sh` 会删除 `a.out` | 误删用户产物 |
|
||||
| D6 | Softmax 雅可比矩阵实现错误(math/Af.cj) | NN 训练收敛异常 |
|
||||
| D7 | Tensor 乘法仅支持 2D,3D+ 会 panic | 限制模型复杂度 |
|
||||
| D8 | Tanh 导数实现错误(使用 `1 - y^2` 而非 `1 - tanh^2(x)`) | NN 训练收敛异常 |
|
||||
| D9 | CNN `testGeometryCNN` 使用随机数据 | 测试无实际意义 |
|
||||
| D10 | MPU6050 `ADDR_0` 与 `ADDR_1` 均为 `0x68` | 无法同时使用两个 MPU6050 |
|
||||
| D11 | `@Action` 与 `@Action_Auto` 并存未合并 | 脚本语法冗余 |
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [Dashun 蜘蛛机器人建模](https://github.com/gopsc/Dashun)
|
||||
- [深度分析报告](docs/Reports/analysis_20260525_00.md)(28 章节,1280 行)
|
||||
- [仓颉编程语言](https://cangjie-lang.org/)
|
||||
- [PCA9685 数据手册](https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf)
|
||||
- [MPU6050 数据手册](https://invensense.tdk.com/wp-content/uploads/2015/02/MPU-6000-Datasheet1.pdf)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -23,6 +23,10 @@
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 自动创建 venv 并安装依赖(推荐)
|
||||
bash _set.sh
|
||||
|
||||
# 或手动安装
|
||||
pip install Pillow
|
||||
```
|
||||
|
||||
@@ -138,7 +142,9 @@ png_b64 = resp["data"]["image"]
|
||||
```
|
||||
captcha/
|
||||
├── captcha_service.py # 服务主程序(pymsg 服务 + 验证码生成与校验)
|
||||
├── _set.sh # 初始化脚本(创建 venv + 安装依赖)
|
||||
├── _run.sh # 启动脚本(cd 后 exec python3 captcha_service.py)
|
||||
├── requirements.txt # Python 依赖(pymsg、Pillow)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
# Core 模块代码审查日志
|
||||
|
||||
> 审查日期:2026-07-30 14:30:00 (Asia/Shanghai)
|
||||
> 审查人:Automated Review
|
||||
> 审查范围:`core/` 全模块(C++20,PMC/Nidus 进程托管服务)
|
||||
|
||||
---
|
||||
|
||||
## 一、模块概述
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 定位 | 微服务进程托管控制器(Process Machine Controller) |
|
||||
| 语言 | C++20 |
|
||||
| 构建 | Makefile(GCC)+ systemd 部署 |
|
||||
| 入口 | `nidus.out` |
|
||||
| 端口 | HTTP API: 5200(仅本机 `127.0.0.1`) |
|
||||
| 启动列表 | `init.txt`(9 个子服务) |
|
||||
|
||||
### 依赖项
|
||||
|
||||
| 库 | 用途 | 版本要求 |
|
||||
|----|------|---------|
|
||||
| Boost.Process | 子进程创建/管理 | — |
|
||||
| Boost.JSON | JSON 解析与序列化 | — |
|
||||
| Boost.Interprocess | POSIX 消息队列 | — |
|
||||
| Boost.Program.Options | CLI 参数解析 | — |
|
||||
| UniServe_libth | 自定义线程/HTTP 服务器封装 | 自研 |
|
||||
| OpenSSL | TLS(HTTP 服务器证书) | — |
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
core/
|
||||
├── include/
|
||||
│ ├── cn/ # 中文代码库(自研)
|
||||
│ │ ├── 中文化.hpp # 保留字映射 + 中文字符串/IO 类
|
||||
│ │ ├── 链串.hpp # 分块链表
|
||||
│ │ ├── 顺序表.hpp # 数组封装
|
||||
│ │ ├── 向量.hpp # 动态数组
|
||||
│ │ └── 异常.hpp # 异常层次
|
||||
│ ├── logs/
|
||||
│ │ └── Logger.hpp # 单例日志(控制台输出)
|
||||
│ ├── main/
|
||||
│ │ ├── HttpTask.hh # HTTP 服务器任务
|
||||
│ │ ├── PPool.hh # 进程池
|
||||
│ │ ├── ProcessTask.hh # 子进程任务
|
||||
│ │ ├── Tttask.hh # 通信任务(消息队列监听)
|
||||
│ │ ├── pmc_mtd.hh # 进程管理方法集
|
||||
│ │ └── subsys_call.hh # 子系统调用封装
|
||||
│ └── tt/
|
||||
│ ├── Mq.h # POSIX 消息队列封装
|
||||
│ └── Pipe.h # FIFO 管道封装
|
||||
├── src/
|
||||
│ ├── main.cpp # 主入口
|
||||
│ └── main/ # 实现文件
|
||||
├── init.txt # 自启动列表
|
||||
├── Makefile # 构建脚本
|
||||
├── Nidus.service # systemd 单元
|
||||
├── README.md
|
||||
├── _set.sh # 编译+安装
|
||||
└── _run.sh # 运行脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、审查发现
|
||||
|
||||
### 🔴 严重问题 (P0)
|
||||
|
||||
#### [CORE-S01] 命令注入漏洞
|
||||
|
||||
- **文件**: `src/main/HttpTask.cpp:230-247`
|
||||
- **时间**: 2026-07-30 14:32:15
|
||||
- **描述**: `exec` 路由直接将 URL 查询参数 `command` 传递给 `boost::process::child`,无任何过滤或校验。攻击者可构造恶意请求执行任意系统命令。
|
||||
|
||||
```cpp
|
||||
// 危险代码
|
||||
std::string result = execCommand(command, target);
|
||||
// command 直接来自 HTTP 请求参数
|
||||
```
|
||||
|
||||
- **风险**: 远程代码执行(RCE)
|
||||
- **严重等级**: 🔴 Critical
|
||||
- **修复建议**:
|
||||
1. 实现命令白名单机制
|
||||
2. 使用 `execvp` 而非 `system`/shell 模式
|
||||
3. 限制可执行命令的路径范围
|
||||
4. 添加参数长度和字符集校验
|
||||
|
||||
#### [CORE-S02] HTTP API 无任何鉴权
|
||||
|
||||
- **文件**: `src/main/HttpTask.cpp:130-340`
|
||||
- **时间**: 2026-07-30 14:33:00
|
||||
- **描述**: HTTP API (`/list`, `/exec`, `/kill`) 完全开放,无认证、无授权、无访问控制。任何人(本地)都可以:
|
||||
- 列出所有子进程
|
||||
- 启动任意进程
|
||||
- 终止任意进程
|
||||
|
||||
- **风险**: 权限绕过、未授权操作
|
||||
- **严重等级**: 🔴 Critical
|
||||
- **修复建议**:
|
||||
1. 添加 API Token 认证(请求头 `Authorization: Bearer <token>`)
|
||||
2. 实现 IP 白名单(仅允许 `127.0.0.1`)
|
||||
3. 添加请求速率限制
|
||||
4. 记录所有管理操作审计日志
|
||||
|
||||
#### [CORE-S03] ProcessTask 空指针解引用
|
||||
|
||||
- **文件**: `include/main/ProcessTask.hh:19,36`
|
||||
- **时间**: 2026-07-30 14:35:22
|
||||
- **描述**: `isRunning()` 和 `pid()` 方法未检查 `p` 是否为空。如果在 `start()` 之前调用这些方法,会导致空指针解引用(段错误)。
|
||||
|
||||
```cpp
|
||||
bool isRunning() override { return p && p->running(); } // 应加 p 非空检查
|
||||
pid_t pid() { return p->id(); } // 危险!p 可能为 nullptr
|
||||
```
|
||||
|
||||
- **风险**: 程序崩溃
|
||||
- **严重等级**: 🔴 Critical
|
||||
- **修复建议**:
|
||||
```cpp
|
||||
bool isRunning() override { return p != nullptr && p->running(); }
|
||||
pid_t pid() { return p ? p->id() : -1; }
|
||||
```
|
||||
|
||||
#### [CORE-S04] exec 路由路径穿越
|
||||
|
||||
- **文件**: `src/main/HttpTask.cpp:232-236`
|
||||
- **时间**: 2026-07-30 14:37:10
|
||||
- **描述**: `getExecCommand` 函数从 URL 参数提取命令,允许包含 `..` 路径穿越符,可能执行预期外的脚本。
|
||||
|
||||
- **风险**: 路径遍历
|
||||
- **严重等级**: 🔴 High
|
||||
- **修复建议**:
|
||||
- 白名单校验,仅允许执行 `init.txt` 中列出的命令
|
||||
- 规范化路径后检查是否在允许目录内
|
||||
|
||||
### 🟡 中等问题 (P1)
|
||||
|
||||
#### [CORE-S05] 进程池无线程安全保护
|
||||
|
||||
- **文件**: `src/main/PPool.cpp`
|
||||
- **时间**: 2026-07-30 14:40:05
|
||||
- **描述**: `PPool` 类使用 `std::list<std::shared_ptr<ProcessTask>>` 作为内部存储,但无互斥锁保护。`HttpTask` 和 `Tttask` 可能并发调用 `crtp()`、`kill()`、`clr()` 等方法,导致数据竞争。
|
||||
|
||||
- **风险**: 数据竞争、未定义行为
|
||||
- **严重等级**: 🟡 Medium
|
||||
- **修复建议**: 添加 `std::mutex` 保护所有公共方法
|
||||
|
||||
#### [CORE-S06] 消息队列命名冲突
|
||||
|
||||
- **文件**: `src/main/Tttask.cpp:41-42`
|
||||
- **时间**: 2026-07-30 14:42:30
|
||||
- **描述**: 使用 `getpid()` 作为消息队列名。同一台机器上启动多个 Nidus 实例时,可能发生队列名冲突。
|
||||
|
||||
```cpp
|
||||
name = std::to_string(getpid()); // 仅用 PID,无唯一化
|
||||
```
|
||||
|
||||
- **风险**: 多实例冲突
|
||||
- **严重等级**: 🟡 Medium
|
||||
- **修复建议**: 使用 `PID + 时间戳` 或 `PID + 随机后缀` 组合命名
|
||||
|
||||
#### [CORE-S07] 管道文件残留
|
||||
|
||||
- **文件**: `src/main/subsys_call.cpp`
|
||||
- **时间**: 2026-07-30 14:44:18
|
||||
- **描述**: 在异常路径中,`Mq` 和 `Pipe` 对象的析构器可能未正确执行(依赖栈展开),导致 `/tmp/pst-*` 文件残留,长期运行会占满 `/tmp` 空间。
|
||||
|
||||
- **风险**: 磁盘空间耗尽
|
||||
- **严重等级**: 🟡 Medium
|
||||
- **修复建议**:
|
||||
1. 在 `catch` 块中显式清理管道文件
|
||||
2. 添加定期清理脚本
|
||||
3. 使用 `std::filesystem::remove` 在构造器前清理旧文件
|
||||
|
||||
#### [CORE-S08] kill/exec IPC 路径已废弃
|
||||
|
||||
- **文件**: `src/main.cpp:282-296`
|
||||
- **时间**: 2026-07-30 14:46:00
|
||||
- **描述**: `parse_data_from_mq()` 中 `kill` 和 `exec` 的 IPC 处理路径被注释,仅 `list` 可用。HTTP 路由 `/exec` 和 `/kill` 虽然存在,但远端调用时会失败。
|
||||
|
||||
```cpp
|
||||
// 已注释的代码
|
||||
//else if (typ == "kill") {
|
||||
// int trg = jv.at("kill").as_int64();
|
||||
// auto flag = pmc_mtd::kill(trg);
|
||||
// ...
|
||||
//}
|
||||
```
|
||||
|
||||
- **风险**: 功能缺失
|
||||
- **严重等级**: 🟡 Medium
|
||||
- **修复建议**: 恢复被注释的代码,或在文档中明确说明限制
|
||||
|
||||
#### [CORE-S09] PPool::clr() 边界条件错误
|
||||
|
||||
- **文件**: `src/main/PPool.cpp:20-32`
|
||||
- **时间**: 2026-07-30 14:48:22
|
||||
- **描述**: `clr()` 方法从尾部反向遍历删除已终止的进程,但边界条件处理复杂:
|
||||
- 当 `pool.size() == 1` 时,循环条件 `it != pool.begin()` 直接跳过
|
||||
- 最后的 `if (pool.size() > 0 && !(*it)->isRunning())` 可能导致双删或漏删
|
||||
|
||||
```cpp
|
||||
auto it = --pool.end();
|
||||
for (; it != pool.begin(); --it)
|
||||
if (!(*it)->isRunning()) { /* erase */ }
|
||||
if (pool.size() > 0 && !(*it)->isRunning()) // 可能越界
|
||||
pool.erase(it);
|
||||
```
|
||||
|
||||
- **风险**: 迭代器失效、崩溃
|
||||
- **严重等级**: 🟡 Medium
|
||||
- **修复建议**: 简化为正向前遍历,或使用 `std::list::remove_if`
|
||||
|
||||
#### [CORE-S10] 日志仅输出到控制台
|
||||
|
||||
- **文件**: `include/logs/Logger.hpp`
|
||||
- **时间**: 2026-07-30 14:50:00
|
||||
- **描述**: `Logger` 类仅支持控制台输出,日志不持久化。服务重启后日志丢失,无法进行事后审计或问题追溯。
|
||||
|
||||
- **风险**: 可观测性缺失
|
||||
- **严重等级**: 🟡 Medium
|
||||
- **修复建议**:
|
||||
1. 实现日志文件轮转
|
||||
2. 添加远程日志推送(syslog、Elasticsearch)
|
||||
3. 集成到微服务体系的集中日志(db:8005 Logs 表)
|
||||
|
||||
### 🟢 轻微问题 (P2)
|
||||
|
||||
#### [CORE-Q01] 版本号不一致
|
||||
|
||||
- **文件**: `src/main.cpp:42` vs `Makefile:1`
|
||||
- **时间**: 2026-07-30 14:52:10
|
||||
- **描述**: `PMC_VERSION = "0.0.10"` 与 `Makefile` 中 `VERSION := 0.0.4` 不一致。
|
||||
|
||||
- **严重等级**: 🟢 Low
|
||||
- **修复建议**: 统一版本管理,或在 Makefile 中定义并编译时注入
|
||||
|
||||
#### [CORE-Q02] parse_data_from_mq 语法存疑
|
||||
|
||||
- **文件**: `src/main.cpp:267-304`
|
||||
- **时间**: 2026-07-30 14:53:45
|
||||
- **描述**: 函数声明缺少 `try` 关键字(`void parse_data_from_mq(std::string& json_str) try {`),但代码能编译通过,可能使用了 C++11 的 `function-try-block` 语法。代码可读性较差,建议显式标注。
|
||||
|
||||
- **严重等级**: 🟢 Low
|
||||
- **修复建议**: 添加注释说明使用的是函数 try-catch 语法
|
||||
|
||||
#### [CORE-Q03] PPool::kill 参数类型不匹配
|
||||
|
||||
- **文件**: `include/main/PPool.hh:35` vs `src/main/pmc_mtd.cpp:53`
|
||||
- **时间**: 2026-07-30 14:55:00
|
||||
- **描述**: `PPool::kill(int idx)` 接受下标参数,但 `pmc_mtd::kill(pid)` 传入的是 PID。需要遍历查找,O(n) 复杂度。
|
||||
|
||||
- **严重等级**: 🟢 Low
|
||||
- **修复建议**:
|
||||
1. 改为 `killByPid(pid_t pid)`
|
||||
2. 或维护 PID → 迭代器 的映射表
|
||||
|
||||
#### [CORE-Q04] 缺少配置加载能力
|
||||
|
||||
- **文件**: 全局
|
||||
- **时间**: 2026-07-30 14:56:30
|
||||
- **描述**: 当前无 `config.yaml` 或类似配置文件支持,所有参数硬编码:
|
||||
- HTTP 端口 5200
|
||||
- 消息队列大小 2048/100
|
||||
- 证书路径 `server.crt/server.key`
|
||||
|
||||
- **严重等级**: 🟢 Low
|
||||
- **修复建议**: 引入 `config.yaml` 支持,至少分离端口和证书路径配置
|
||||
|
||||
#### [CORE-Q05] 中文代码库未被实际使用
|
||||
|
||||
- **文件**: `include/cn/`
|
||||
- **时间**: 2026-07-30 14:58:00
|
||||
- **描述**: `中文化.hpp`、`链串.hpp`、`顺序表.hpp`、`向量.hpp`、`异常.hpp` 构成了一套完整的中文 API,但在 `src/` 代码中未被引用。这些文件更像是演示项目,而非生产代码。
|
||||
|
||||
- **严重等级**: 🟢 Low
|
||||
- **修复建议**:
|
||||
1. 删除未使用的中文代码库
|
||||
2. 或在 README 中明确标注为"实验性功能"
|
||||
3. 迁移到独立仓库
|
||||
|
||||
#### [CORE-Q06] 缺少单元测试
|
||||
|
||||
- **文件**: 全局
|
||||
- **时间**: 2026-07-30 14:59:30
|
||||
- **描述**: 项目无任何测试代码。核心功能(进程管理、消息队列、管道通信)完全依赖手工测试。
|
||||
|
||||
- **严重等级**: 🟢 Low
|
||||
- **修复建议**: 引入 Google Test,覆盖:
|
||||
- PPool 的增删查改
|
||||
- Pipe 的读写超时
|
||||
- Mq 的发送接收
|
||||
- 中文代码库的基础功能
|
||||
|
||||
---
|
||||
|
||||
## 三、统计汇总
|
||||
|
||||
| 等级 | 数量 | 问题 ID |
|
||||
|------|------|---------|
|
||||
| 🔴 Critical | 4 | S01, S02, S03, S04 |
|
||||
| 🟡 Medium | 6 | S05, S06, S07, S08, S09, S10 |
|
||||
| 🟢 Low | 6 | Q01, Q02, Q03, Q04, Q05, Q06 |
|
||||
| **合计** | **16** | |
|
||||
|
||||
### 按类别统计
|
||||
|
||||
| 类别 | 问题数 | 占比 |
|
||||
|------|--------|------|
|
||||
| 安全(注入、鉴权) | 3 | 19% |
|
||||
| 稳定性(崩溃、竞态) | 4 | 25% |
|
||||
| 功能缺失 | 2 | 13% |
|
||||
| 代码质量 | 5 | 31% |
|
||||
| 可观测性 | 2 | 12% |
|
||||
|
||||
---
|
||||
|
||||
## 四、修复优先级建议
|
||||
|
||||
### 第一阶段(紧急,预计 1 天)
|
||||
|
||||
1. **CORE-S03**: 修复 `ProcessTask` 空指针检查(最简单,影响启动)
|
||||
2. **CORE-S01**: 实现命令白名单 + 路径校验(核心安全)
|
||||
3. **CORE-S02**: 添加 API Token 鉴权(核心安全)
|
||||
|
||||
### 第二阶段(重要,预计 3 天)
|
||||
|
||||
4. **CORE-S05**: 添加 PPool 互斥锁(并发安全)
|
||||
5. **CORE-S08**: 恢复 kill/exec IPC 路径(功能补全)
|
||||
6. **CORE-S09**: 简化 PPool::clr() 实现(稳定性)
|
||||
7. **CORE-S10**: 添加日志文件持久化(可观测性)
|
||||
|
||||
### 第三阶段(优化,预计 1 周)
|
||||
|
||||
8. **CORE-S04**: 完善路径穿越防护
|
||||
9. **CORE-S06**: 改进消息队列命名
|
||||
10. **CORE-S07**: 添加管道清理机制
|
||||
11. **CORE-Q01 ~ Q06**: 代码质量优化
|
||||
|
||||
---
|
||||
|
||||
## 五、亮点
|
||||
|
||||
尽管问题较多,项目仍有值得肯定之处:
|
||||
|
||||
| 亮点 | 说明 |
|
||||
|------|------|
|
||||
| 清晰的模块划分 | 进程池、线程、通信、HTTP 各司其职 |
|
||||
| 双轨 IPC 设计 | 消息队列 + 管道的请求-响应模式简洁高效 |
|
||||
| 中文代码实验 | 对中文编程的探索有教育意义 |
|
||||
| systemd 集成 | 标准部署方式,便于运维 |
|
||||
| 最小依赖 | 仅依赖 Boost,无过度设计 |
|
||||
|
||||
---
|
||||
|
||||
## 六、附录
|
||||
|
||||
### 审查工具
|
||||
|
||||
- 人工静态分析
|
||||
- grep 模式匹配
|
||||
- 代码路径追踪
|
||||
|
||||
### 参考文档
|
||||
|
||||
- [Boost.Process 文档](https://www.boost.org/doc/libs/release/libs/process/)
|
||||
- [Boost.Interprocess 文档](https://www.boost.org/doc/libs/release/libs/interprocess/)
|
||||
- [POSIX 消息队列](https://man7.org/linux/man-pages/man7/mq_overview.7.html)
|
||||
- [FIFO 管道](https://man7.org/linux/man-pages/man7/fifo.7.html)
|
||||
|
||||
### 审查人
|
||||
|
||||
- 自动化审查助手
|
||||
- 审查时间:2026-07-30
|
||||
|
||||
---
|
||||
|
||||
*本日志由自动化代码审查工具生成,供开发团队参考。修复建议需结合实际场景评估。*
|
||||
+2
-1
@@ -201,9 +201,10 @@ logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
```
|
||||
|
||||
客户端特性:
|
||||
- 纯标准库实现,异步非阻塞:内存队列 + 后台线程批量推送
|
||||
- 无第三方依赖(仅依赖 pymsg 项目内置库),异步非阻塞:内存队列 + 后台线程批量推送
|
||||
- fail-safe:db 不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
- 长度保护:`message` 上限 8192 字节、`extra` 上限 4096 字节,超长自动截断
|
||||
|
||||
**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归)。
|
||||
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ cd "$(dirname "$0")" || exit
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
#nuitka --standalone main.py
|
||||
#nuitka --standalone dbv2.py
|
||||
+55
-9
@@ -37,8 +37,9 @@ from pymsg import MsgClient # noqa: E402
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
|
||||
# 管理员账号:删除/更新节点时豁免所有权校验(保留旧的 'admin' 以兼容历史数据)
|
||||
ADMIN_USERS = ('admin@qsont.me', 'qing@qsont.me')
|
||||
# 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload
|
||||
# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。
|
||||
# 旧 Token 无此字段,按非管理员处理(安全方向)。
|
||||
|
||||
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
|
||||
"""通过 pymsg 短连接调用 dbv2 数据库服务。
|
||||
@@ -85,11 +86,9 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
resp_data = resp.read().decode("utf-8")
|
||||
data = json.loads(resp_data)
|
||||
invalid: bool = data['invalid']
|
||||
return invalid == False
|
||||
#invalid = data
|
||||
#return invalid
|
||||
data = json.loads(resp_data)
|
||||
# 兼容布尔/字符串两种返回类型:仅明确判定安全才放行,其余一律拦截(fail-closed)
|
||||
return data.get('invalid') in (False, 'false')
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
# 服务端返回 4xx/5xx 错误时,尝试解析错误正文
|
||||
@@ -253,10 +252,57 @@ def get_notes():
|
||||
|
||||
result = notes['data']
|
||||
|
||||
# 解析当前用户(可选):为每个节点附带操作权标记 can_operate,
|
||||
# 前端据此显隐编辑/删除按钮。口径与 delete_note / update_note 一致:
|
||||
# 管理员(is_admin)或节点 owner 有权。
|
||||
# get_notes 保持公开:未携带 Token 或 Token 解析失败时,所有节点一律无权。
|
||||
info = None
|
||||
auth: str = request.headers.get('Authorization', '')
|
||||
if auth.startswith('Bearer '):
|
||||
try:
|
||||
info = decode_token(auth[7:])
|
||||
except Exception:
|
||||
info = None
|
||||
|
||||
for note in result:
|
||||
note['can_operate'] = bool(
|
||||
info and (info.get('is_admin') or note.get('owner') == info.get('user'))
|
||||
)
|
||||
|
||||
logs.info('dnfs', f'get_notes 查询 pid={pid} 返回{len(result)}条', extra={'ip': request.remote_addr})
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
# 当前用户身份查询:配合前端用户面板,返回 user / is_admin。
|
||||
# 与 get_notes 同款可选解析:无 Token 或畸形 Token 按未登录处理(不 500)。
|
||||
@app.route('/whoami', methods=["POST"])
|
||||
def whoami():
|
||||
info = None
|
||||
auth: str = request.headers.get('Authorization', '')
|
||||
if auth.startswith('Bearer '):
|
||||
try:
|
||||
info = decode_token(auth[7:])
|
||||
except Exception:
|
||||
info = None
|
||||
|
||||
if info is None:
|
||||
return jsonify({'login': False}), 200
|
||||
|
||||
resp = {
|
||||
'login': True,
|
||||
'user': info.get('user'),
|
||||
'is_admin': bool(info.get('is_admin'))
|
||||
}
|
||||
# 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间
|
||||
if info.get('iat'):
|
||||
resp['iat'] = info['iat']
|
||||
if info.get('created_at'):
|
||||
resp['created_at'] = info['created_at']
|
||||
if info.get('keep_time'):
|
||||
resp['keep_time'] = info['keep_time']
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# eg: {"id": 1}
|
||||
@app.route('/delete_note', methods=['POST'])
|
||||
|
||||
@@ -328,7 +374,7 @@ def delete_note():
|
||||
|
||||
data = _call_dbv2(select_payload)
|
||||
notes = data['data']
|
||||
if info['user'] not in ADMIN_USERS:
|
||||
if not info.get('is_admin'):
|
||||
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
|
||||
logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']})
|
||||
return jsonify({"error": 'It\'s not your note'}), 403
|
||||
@@ -403,7 +449,7 @@ def update_note():
|
||||
|
||||
data = _call_dbv2(select_payload)
|
||||
notes = data['data']
|
||||
if info['user'] not in ADMIN_USERS:
|
||||
if not info.get('is_admin'):
|
||||
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
|
||||
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
|
||||
return jsonify({"error": 'It\'s not your note'}), 403
|
||||
|
||||
+7
-6
@@ -9,7 +9,7 @@ UniServe Framework 的应用层网关,C++20 实现。
|
||||
- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。
|
||||
- **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
|
||||
- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
|
||||
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(60 秒有效);注册时校验,一次性消费。
|
||||
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。
|
||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
|
||||
- **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。
|
||||
|
||||
@@ -78,19 +78,19 @@ UniServe Framework 的应用层网关,C++20 实现。
|
||||
{ "username": "user@example.com", "password": "...", "email_code": "123456" }
|
||||
```
|
||||
|
||||
校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。
|
||||
校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,连续失败 5 次自动作废,fail-closed),全部通过后写入 `Users` 表。
|
||||
|
||||
错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。
|
||||
|
||||
### `POST /register/send_code`
|
||||
|
||||
向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,60 秒有效)。
|
||||
向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。
|
||||
|
||||
```json
|
||||
{ "email": "user@example.com" }
|
||||
```
|
||||
|
||||
限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`429`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。
|
||||
限流 5 次/10 分钟/IP;同一邮箱 60 秒发送冷却(由 smtp 服务执行,冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却)。错误码:`400`(邮箱格式错误)、`429`(IP 速率超限或同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。
|
||||
|
||||
### `GET/POST *`(反向代理)
|
||||
|
||||
@@ -226,14 +226,15 @@ gateway/
|
||||
|
||||
## 版本
|
||||
|
||||
`0.5.2`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印:
|
||||
`0.5.3`(见 [src/main.cpp:30](src/main.cpp#L30))。启动时打印:
|
||||
|
||||
```
|
||||
Gateway 0.5.2 started
|
||||
Gateway 0.5.3 started
|
||||
```
|
||||
|
||||
近期变更:
|
||||
|
||||
- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。
|
||||
- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。
|
||||
- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
sleep 1
|
||||
sleep 2
|
||||
exec ./build/gateway.out -i example.ini #1>/dev/null
|
||||
@@ -14,6 +14,16 @@ opened = http://127.0.0.1:9200;http://127.0.0.1:8006;
|
||||
[Token]
|
||||
# Token 有效期(秒),默认 3600 = 1 小时
|
||||
keep_time = 3600
|
||||
# Token 刷新宽限期倍数:过期后 iat + keep_time * 倍数 窗口内仍可刷新,默认 2
|
||||
refresh_grace_multiplier = 2
|
||||
# Token 生命周期内最大刷新次数,超过后需重新登录,默认 10
|
||||
max_refresh = 10
|
||||
|
||||
[Admin]
|
||||
# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true,
|
||||
# 下游服务(dnfs 等)凭该字段豁免所有权校验,无需各自维护名单。
|
||||
# 修改后需重启 gateway 生效;旧 Token 无此字段,按非管理员处理。
|
||||
users = qing@qsont.me
|
||||
|
||||
[CORS]
|
||||
# 允许跨域的 Origin 白名单(分号分隔)
|
||||
|
||||
+202
-5
@@ -5,6 +5,7 @@
|
||||
#include <us/SHA256.hpp>
|
||||
#include <us/Sqlite3.hpp>
|
||||
#include <iostream>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <typeinfo>
|
||||
#include <memory>
|
||||
@@ -27,7 +28,7 @@ using namespace qing;
|
||||
namespace json = boost::json;
|
||||
namespace po = boost::program_options;
|
||||
|
||||
constexpr const char *_VER = "0.5.2";
|
||||
constexpr const char *_VER = "0.5.3";
|
||||
|
||||
//////////////////////////////////
|
||||
#include <sstream>
|
||||
@@ -301,6 +302,10 @@ http::response<http::string_body> handleLogout(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
http::response<http::string_body> handleRefreshToken(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
|
||||
http::response<http::string_body> handleCaptchaImage(
|
||||
const http::request<http::string_body>& req,
|
||||
@@ -324,7 +329,10 @@ static std::string _DEFT_URL;
|
||||
static std::vector<std::string> _OPEN_URL;
|
||||
static std::string _DB_PATH;
|
||||
static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置
|
||||
static int _TOKEN_REFRESH_GRACE_MULTIPLIER = 2; // Token 刷新宽限期倍数,过期后 iat + keep_time*倍数 窗口内仍可刷新
|
||||
static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数,可由 [Token] max_refresh 配置
|
||||
static std::vector<std::string> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
|
||||
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin
|
||||
|
||||
/* instance items */
|
||||
static std::unique_ptr<HttpServer> _SERVE;
|
||||
@@ -333,6 +341,7 @@ static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
||||
static RateLimiter _LOGIN_RATE;
|
||||
static RateLimiter _REGISTER_RATE;
|
||||
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
|
||||
static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流
|
||||
|
||||
/* pymsg 下游调用超时(秒):
|
||||
* captcha / smtp verify_email 均为本机内存操作,5s 足够;
|
||||
@@ -497,7 +506,10 @@ int main(int argc, char **argv) {
|
||||
_OPEN_URL = split(cf.get<std::string>("Proxy.opened", ""), ';');
|
||||
_DB_PATH = cf.get<std::string>("Database.path", "");
|
||||
_TOKEN_KEEP_TIME = cf.get<int>("Token.keep_time", 3600);
|
||||
_TOKEN_REFRESH_GRACE_MULTIPLIER = cf.get<int>("Token.refresh_grace_multiplier", 2);
|
||||
_TOKEN_MAX_REFRESH = cf.get<int>("Token.max_refresh", 10);
|
||||
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
|
||||
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';');
|
||||
}
|
||||
|
||||
|
||||
@@ -618,6 +630,11 @@ void registerRoutes(HttpServer &server) {
|
||||
[](const auto& req, const auto& params) { return handleLogout(req, params); });
|
||||
});
|
||||
|
||||
server.post("/refresh_token", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleRefreshToken(req, params); });
|
||||
});
|
||||
|
||||
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
|
||||
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
@@ -867,10 +884,18 @@ http::response<http::string_body> handleLogin(
|
||||
/* 生成令牌 */
|
||||
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
|
||||
|
||||
/* 管理员判定:账号命中 [Admin] users 白名单即在 payload 中签发 is_admin,
|
||||
* 下游服务(dnfs 等)直接读取该字段豁免所有权校验,无需各自维护名单 */
|
||||
bool is_admin = std::find(_ADMIN_USERS.begin(), _ADMIN_USERS.end(), username) != _ADMIN_USERS.end();
|
||||
|
||||
json::value jv_t = {
|
||||
{"user", username},
|
||||
{"iat", LocalTime::getCurrent()},
|
||||
{"created_at", LocalTime::getCurrent()},
|
||||
{"keep_time", _TOKEN_KEEP_TIME}
|
||||
{"keep_time", _TOKEN_KEEP_TIME},
|
||||
{"is_admin", is_admin},
|
||||
{"refresh_count", 0},
|
||||
{"max_refresh", _TOKEN_MAX_REFRESH}
|
||||
};
|
||||
std::string data = json::serialize(jv_t);
|
||||
std::string auth = Base64::base64_encode(data);
|
||||
@@ -921,6 +946,159 @@ http::response<http::string_body> handleLogout(
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 刷新 Token:POST /refresh_token
|
||||
*
|
||||
* 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。
|
||||
* 仅更新 created_at 与 refresh_count;iat 保持不变以限制整条链的总生命周期。
|
||||
*
|
||||
* 宽限期:iat + keep_time * refresh_grace_multiplier
|
||||
* 例:keep_time=3600, multiplier=2 → 首次签发后 2 小时内可刷新
|
||||
* 刷新上限:refresh_count < max_refresh
|
||||
* 限流:5 次 / 5 分钟 / IP
|
||||
* Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token
|
||||
*
|
||||
* 响应:{ success, data:{ token, refresh_count, refreshes_remaining } }
|
||||
*/
|
||||
http::response<http::string_body> handleRefreshToken(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params)
|
||||
{
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
|
||||
/* IP 速率限制:5 次 / 300 秒 */
|
||||
std::string client_ip = "direct";
|
||||
auto it_host = req.base().find("X-Forwarded-For");
|
||||
if (it_host != req.base().end())
|
||||
client_ip = std::string(it_host->value());
|
||||
if (!_REFRESH_TOKEN_RATE.check_and_record(client_ip, 5, 300))
|
||||
THROW_EX_CUSTOM(HttpException, 4001, "Too many refresh attempts. Please try again later.",
|
||||
429, "/refresh_token");
|
||||
|
||||
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||
|
||||
/* 获取 Token:Authorization 头优先,回退 Cookie */
|
||||
std::string raw;
|
||||
auto it_auth = req.base().find("Authorization");
|
||||
if (it_auth != req.base().end()) {
|
||||
raw = std::string(it_auth->value());
|
||||
} else {
|
||||
auto cit = req.base().find("Cookie");
|
||||
if (cit != req.base().end()) {
|
||||
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
|
||||
if (!cookie_token.empty())
|
||||
raw = "Bearer " + cookie_token;
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.empty())
|
||||
THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token");
|
||||
|
||||
/* 解析 Bearer <base64>_<signature> */
|
||||
if (raw.substr(0, 7) != "Bearer ")
|
||||
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||
raw = raw.substr(7);
|
||||
|
||||
size_t idx = raw.find("_");
|
||||
if (idx == std::string::npos)
|
||||
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||
|
||||
std::string data = raw.substr(0, idx);
|
||||
std::string sign = raw.substr(idx + 1);
|
||||
std::string auth = Base64::base64_decode_to_string(data);
|
||||
|
||||
/* RSA 验签 */
|
||||
if (!_PUBL.Verify(auth, sign))
|
||||
THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token");
|
||||
|
||||
/* 解析 Token payload */
|
||||
json::value jv_t;
|
||||
try { jv_t = json::parse(auth); }
|
||||
catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||
}
|
||||
if (!jv_t.is_object())
|
||||
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||
|
||||
json::object& obj = jv_t.as_object();
|
||||
|
||||
/* 提取关键字段 */
|
||||
std::string username = json::value_to<std::string>(obj.at("user"));
|
||||
std::string iat_str = json::value_to<std::string>(obj.at("iat"));
|
||||
int keep_time = json::value_to<int>(obj.at("keep_time"));
|
||||
int refresh_count = json::value_to<int>(obj.at("refresh_count"));
|
||||
int max_refresh = json::value_to<int>(obj.at("max_refresh"));
|
||||
|
||||
/* 刷新次数检查 */
|
||||
if (refresh_count >= max_refresh)
|
||||
THROW_EX_CUSTOM(HttpException, 4005, "Refresh limit exceeded. Please re-login.",
|
||||
400, "/refresh_token");
|
||||
|
||||
/* 宽限期检查:iat + keep_time * grace_multiplier > now */
|
||||
auto iat_tp = LocalTime::parse(iat_str);
|
||||
if (!iat_tp)
|
||||
THROW_EX_CUSTOM(HttpException, 4004, "Bad token iat", 400, "/refresh_token");
|
||||
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto grace_expire = *iat_tp + std::chrono::seconds(keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER);
|
||||
if (now > grace_expire)
|
||||
THROW_EX_CUSTOM(HttpException, 4006, "Token expired beyond refresh window. Please re-login.",
|
||||
401, "/refresh_token");
|
||||
|
||||
/* 保留 is_admin(旧 Token 未携带时默认为 false) */
|
||||
bool is_admin = false;
|
||||
auto it_admin = obj.find("is_admin");
|
||||
if (it_admin != obj.end() && it_admin->value().is_bool())
|
||||
is_admin = it_admin->value().as_bool();
|
||||
|
||||
/* 签发新 Token:iat 不变,created_at 更新,refresh_count++ */
|
||||
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
|
||||
json::value jv_new = {
|
||||
{"user", username},
|
||||
{"iat", iat_str},
|
||||
{"created_at", LocalTime::getCurrent()},
|
||||
{"keep_time", keep_time},
|
||||
{"is_admin", is_admin},
|
||||
{"refresh_count", refresh_count + 1},
|
||||
{"max_refresh", max_refresh}
|
||||
};
|
||||
std::string new_data = json::serialize(jv_new);
|
||||
std::string new_auth = Base64::base64_encode(new_data);
|
||||
std::string new_sign = _PRIV.Sign(new_data);
|
||||
std::string new_token = new_auth + "_" + new_sign;
|
||||
|
||||
/* Set-Cookie */
|
||||
{
|
||||
std::string ck = "q4_token=" + new_token +
|
||||
"; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" +
|
||||
std::to_string(keep_time);
|
||||
res.set("Set-Cookie", ck);
|
||||
}
|
||||
|
||||
logs().info("gateway", "Token refreshed", std::nullopt,
|
||||
json::serialize(json::value{
|
||||
{"user", username},
|
||||
{"ip", client_ip},
|
||||
{"refresh_count", refresh_count + 1},
|
||||
{"refreshes_remaining", max_refresh - (refresh_count + 1)}
|
||||
}));
|
||||
|
||||
json::value jv_rr = {
|
||||
{"success", true},
|
||||
{"data", json::object{
|
||||
{"token", new_token},
|
||||
{"refresh_count", refresh_count + 1},
|
||||
{"refreshes_remaining", max_refresh - (refresh_count + 1)}
|
||||
}}
|
||||
};
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.body() = json::serialize(jv_rr);
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 代理 captcha 图形验证码:GET /captcha/image
|
||||
*
|
||||
* captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问,
|
||||
@@ -1108,6 +1286,8 @@ http::response<http::string_body> handleSendEmailCode(
|
||||
|
||||
// 调 smtp 服务(pymsg/TCP :8008)发送验证码
|
||||
bool sent = false;
|
||||
int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝
|
||||
std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端)
|
||||
try {
|
||||
pmc::net::MsgClient client("127.0.0.1", 8008, "gateway");
|
||||
json::value jv_req = {
|
||||
@@ -1117,14 +1297,31 @@ http::response<http::string_body> handleSendEmailCode(
|
||||
std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_SEND);
|
||||
auto jv_r = json::parse(resp);
|
||||
auto& obj_r = jv_r.as_object();
|
||||
if (obj_r.at("success").as_bool())
|
||||
sent = obj_r.at("data").as_object().at("sent").as_bool();
|
||||
if (obj_r.at("success").as_bool()) {
|
||||
auto& data = obj_r.at("data").as_object();
|
||||
sent = data.at("sent").as_bool();
|
||||
if (!sent) {
|
||||
auto it_ra = data.find("retry_after");
|
||||
if (it_ra != data.end() && it_ra->value().is_int64())
|
||||
retry_after = static_cast<int>(it_ra->value().as_int64());
|
||||
auto it_err = data.find("error");
|
||||
if (it_err != data.end() && it_err->value().is_string())
|
||||
smtp_err = std::string(it_err->value().as_string());
|
||||
}
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 3102, "Mail service unavailable", 502, "/register/send_code");
|
||||
}
|
||||
|
||||
if (!sent)
|
||||
if (!sent) {
|
||||
// 同邮箱发送冷却:按限流语义返回 429 并透传 smtp 提示文案;
|
||||
// 其余发送失败(含 SMTP 内部错误细节)仍用通用文案,不向外暴露
|
||||
if (retry_after > 0)
|
||||
THROW_EX_CUSTOM(HttpException, 3103,
|
||||
smtp_err.empty() ? "Too many code requests. Please try again later." : smtp_err,
|
||||
429, "/register/send_code");
|
||||
THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/register/send_code");
|
||||
}
|
||||
|
||||
logs().info("gateway", "验证码邮件发送成功: " + email, std::nullopt,
|
||||
json::serialize(json::value{{"ip", client_ip}}));
|
||||
|
||||
+199
-2
@@ -1,2 +1,199 @@
|
||||
# UniServe_libcrypt
|
||||
UniServe Framework Cryption Library
|
||||
# libcrypt — UniServe 加密库
|
||||
|
||||
C++20 轻量级加密库,基于 OpenSSL 3.x EVP API 封装。提供 AES-256-CBC 加解密、Base64 编解码、SHA-256 哈希、RSA 密钥管理与加解密/签名验证。
|
||||
|
||||
命名空间 `qing`,库文件 `libus_crypt.so`,头文件安装至 `/usr/include/us/`。被 `gateway` 的 Token 签发/验证和密码哈希直接依赖。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
qing::Crypto_Basic (基类: bytes_to_hex / hex_to_bytes / CRYPTO_CHECK)
|
||||
├── AES_CBC AES-256-CBC 对称加密(随机密钥+IV / 外部密钥)
|
||||
├── Base64 Base64 编解码(OpenSSL BIO 链)
|
||||
├── SHA256 SHA-256 哈希与验证
|
||||
└── MyRSA RSA 非对称密钥
|
||||
├── Generator 密钥对生成(2048-bit,PKCS#8 PEM 输出)
|
||||
├── Private_Key 私钥加载、OAEP 解密、PKCS1_v1_5/PSS 签名
|
||||
└── Public_Key 公钥加载、OAEP 加密、PKCS1_v1_5/PSS 验签
|
||||
```
|
||||
|
||||
所有加密类均使用 OpenSSL 3.x EVP 高层 API。`bytes_to_hex` / `hex_to_bytes` 在 `Crypto_Basic` 中实现(查表法),编译进 `libus_crypt.so`。加解密类为纯头文件(header-only),需链接时额外加 `-lssl -lcrypto`。
|
||||
|
||||
## 类与 API
|
||||
|
||||
### Crypto_Basic — 基类
|
||||
|
||||
提供两个静态工具方法和异常类型,不直接实例化。
|
||||
|
||||
| 成员 | 说明 |
|
||||
|------|------|
|
||||
| `bytes_to_hex(data)` | 字节向量 → 十六进制字符串(查表法) |
|
||||
| `hex_to_bytes(hex)` | 十六进制字符串 → 字节向量(大小写兼容) |
|
||||
| `CryptoExp` | 异常类,继承 `std::runtime_error` |
|
||||
| `CRYPTO_CHECK(cond, msg)` | 宏,条件为 false 时抛出 `std::runtime_error` |
|
||||
|
||||
### AES_CBC — AES-256-CBC 对称加密
|
||||
|
||||
```cpp
|
||||
AES_CBC aes; // 自动生成随机 32B 密钥 + 16B IV
|
||||
AES_CBC aes(key_vec, iv_vec); // 使用外部密钥和 IV
|
||||
std::string hex = aes.encrypt(text); // 加密,返回 hex 密文
|
||||
std::string raw = aes.decrypt(hex); // 解密 hex 密文,返回原文
|
||||
std::string k = aes.get_key(); // 获取密钥(hex)
|
||||
std::string v = aes.get_iv(); // 获取 IV(hex)
|
||||
```
|
||||
|
||||
加密输出为 hex 编码的密文,非原始字节。解密接受 hex 输入,返回原始字符串。
|
||||
|
||||
### Base64 — Base64 编解码
|
||||
|
||||
提供实例方法和静态工具方法两种调用方式。
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `encode(str)` / `encode(vec)` | 编码为 Base64 字符串 |
|
||||
| `decode_to_string(b64)` | 解码为原始字符串 |
|
||||
| `decode_to_bytes(b64)` | 解码为字节向量 |
|
||||
| `base64_encode(str)` (static) | 静态编码工具 |
|
||||
| `base64_decode_to_string(b64)` (static) | 静态解码工具 |
|
||||
| `is_valid_base64(str)` (static) | 验证字符集合法性(不依赖 OpenSSL) |
|
||||
|
||||
编码不添加换行符(`BIO_FLAGS_BASE64_NO_NL`)。
|
||||
|
||||
### SHA256 — SHA-256 哈希
|
||||
|
||||
```cpp
|
||||
SHA256 h;
|
||||
std::string hex = h.hash("hello"); // 字符串 → hex 哈希
|
||||
auto bytes = h.hash_bytes("hello"); // 字符串 → 字节哈希
|
||||
bool ok = h.verify("hello", expected_hex); // 验证
|
||||
|
||||
// 静态快捷方式
|
||||
std::string h = SHA256::sha256("hello");
|
||||
```
|
||||
|
||||
### MyRSA — RSA 非对称密钥
|
||||
|
||||
**Generator** — 生成 2048-bit RSA 密钥对(OpenSSL 3.x `OSSL_PARAM` API)。
|
||||
|
||||
```cpp
|
||||
MyRSA::Generator gen;
|
||||
gen.save_pri_key("private.pem", "password"); // PKCS#8 + AES-256-CBC 加密
|
||||
gen.save_pub_key("public.pem"); // SubjectPublicKeyInfo
|
||||
std::string pem = gen.get_public_key_pem(); // 导出 PEM 字符串
|
||||
```
|
||||
|
||||
**Private_Key** — 加载私钥(支持 PKCS#1/PKCS#8,加密或明文)。
|
||||
|
||||
```cpp
|
||||
auto pri = MyRSA::Private_Key("private.pem", true, "password");
|
||||
std::string raw = pri.Decrypt(cipher_hex); // OAEP 解密
|
||||
std::string sig = pri.Sign(msg); // PSS 签名(默认)
|
||||
std::string sig = pri.Sign(msg, RSASignaturePadding::PKCS1_v1_5);
|
||||
```
|
||||
|
||||
> 私有继承自 `Crypto_Basic`,内部使用其工具方法,外部不可将 `Private_Key*` 转为 `Crypto_Basic*`。
|
||||
|
||||
**Public_Key** — 加载公钥。
|
||||
|
||||
```cpp
|
||||
auto pub = MyRSA::Public_Key("public.pem");
|
||||
std::string hex = pub.Encrypt(text); // OAEP 加密
|
||||
bool ok = pub.Verify(msg, sig_hex); // 验签(默认 PSS)
|
||||
bool ok = pub.Verify(msg, sig_hex, RSASignaturePadding::PKCS1_v1_5);
|
||||
```
|
||||
|
||||
**签名填充模式**(`RSASignaturePadding` 枚举):
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `PSS` | 概率签名方案(默认,更安全) |
|
||||
| `PKCS1_v1_5` | 传统填充(兼容旧系统) |
|
||||
|
||||
## 构建与安装
|
||||
|
||||
**前置依赖**:OpenSSL 3.x(开发包 `libssl-dev`),g++ C++20。
|
||||
|
||||
```bash
|
||||
# 一键构建 + 安装(需 sudo)
|
||||
bash _set.sh
|
||||
|
||||
# 或手动
|
||||
make # 编译 libus_crypt.so
|
||||
make install # 安装头文件到 /usr/include/us/ + .so 到 /lib/
|
||||
make clear # 清理构建产物
|
||||
make uninstall # 卸载
|
||||
```
|
||||
|
||||
使用:
|
||||
|
||||
```cpp
|
||||
#include <us/Crypto_Basic.hpp>
|
||||
#include <us/AES_CBC.hpp>
|
||||
#include <us/Base64.hpp>
|
||||
#include <us/SHA256.hpp>
|
||||
#include <us/MyRSA.hpp>
|
||||
```
|
||||
|
||||
链接(OpenSSL 符号在 header-only 类中,需显式链接):
|
||||
|
||||
```bash
|
||||
g++ -std=c++20 ... -lus_crypt -lssl -lcrypto
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```cpp
|
||||
#include <us/SHA256.hpp>
|
||||
#include <us/Base64.hpp>
|
||||
#include <us/AES_CBC.hpp>
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
// SHA-256
|
||||
std::string hash = qing::SHA256::sha256("hello world");
|
||||
std::cout << "SHA256: " << hash << std::endl;
|
||||
|
||||
// Base64
|
||||
std::string b64 = qing::Base64::base64_encode("hello");
|
||||
std::cout << "Base64: " << b64 << std::endl;
|
||||
|
||||
// AES-256-CBC
|
||||
qing::AES_CBC aes;
|
||||
std::string cipher = aes.encrypt("secret message");
|
||||
std::string plain = aes.decrypt(cipher);
|
||||
std::cout << "AES decrypt: " << plain << std::endl;
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
libcrypt/
|
||||
├── inc/us/
|
||||
│ ├── Crypto_Basic.hpp # 基类:hex 转换 + 异常
|
||||
│ ├── AES_CBC.hpp # AES-256-CBC 加解密(header-only)
|
||||
│ ├── Base64.hpp # Base64 编解码(header-only)
|
||||
│ ├── SHA256.hpp # SHA-256 哈希(header-only)
|
||||
│ └── MyRSA.hpp # RSA 密钥/加解密/签名(header-only)
|
||||
├── src/
|
||||
│ └── Crypto_Basic.cpp # bytes_to_hex / hex_to_bytes 实现
|
||||
├── Makefile
|
||||
├── _set.sh
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **OpenSSL 版本**:MyRSA 的 `Generator` 使用了 `OSSL_PARAM`、`EVP_PKEY_CTX_new_from_name` 等 OpenSSL 3.x 独占 API,无法在 OpenSSL 1.1.x 下编译。AES/Base64/SHA256 兼容 1.1.x。
|
||||
- **链接要求**:`libus_crypt.so` 仅含 `bytes_to_hex` 和 `hex_to_bytes`(无 OpenSSL 调用)。所有加密类为 header-only,链接时必须添加 `-lssl -lcrypto`。
|
||||
- **RSA 密钥长度**:默认 2048-bit,可在 `generate_rsa_key(size_t key_bits)` 中调整。
|
||||
- **AES 密钥和 IV**:默认构造函数随机生成,每次创建 `AES_CBC` 对象产生不同的密钥对。需要长期存储密钥时使用参数化构造函数传入外部密钥。
|
||||
- **hex 编码**:`bytes_to_hex` 使用查表法实现,位于 `Crypto_Basic.cpp` 中编译进 `.so`。`SHA256` 不再有私有副本。
|
||||
- **`Private_Key` 私有继承**:该设计防止 `Private_Key` 被隐式转换为 `Crypto_Basic*`,是刻意为之的安全措施。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/err.h>
|
||||
#include <openssl/rand.h>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
#include <stdexcept>
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include "us/Crypto_Basic.hpp"
|
||||
#include "Crypto_Basic.hpp"
|
||||
|
||||
namespace qing {
|
||||
|
||||
@@ -99,15 +97,5 @@ namespace qing {
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
/* 辅助函数:字节向量转十六进制字符串 */
|
||||
static std::string bytes_to_hex(const std::vector<unsigned char>& bytes) {
|
||||
std::stringstream ss;
|
||||
ss << std::hex << std::setfill('0');
|
||||
for (unsigned char byte : bytes) {
|
||||
ss << std::setw(2) << static_cast<int>(byte);
|
||||
}
|
||||
return ss.str();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
# libhttp — C++20 HTTP / WebSocket 网络库
|
||||
|
||||
## 1. 模块概览
|
||||
|
||||
`libhttp` 是 `micro_servers` 项目的基础网络通信层,封装了 HTTP/HTTPS 服务器、HTTP 客户端(基于 libcurl)、WebSocket 服务器和 WebSocket 客户端四大功能。
|
||||
以 C++20 标准编写,编译为动态库 `libus_http.so` 和静态库 `libus_http_static.a`,命名空间 `pmc::net`。
|
||||
|
||||
| 属性 | 值 |
|
||||
|----------|-----------------------------|
|
||||
| 版本 | 0.0.1 |
|
||||
| 语言标准 | C++20 |
|
||||
| 许可证 | MIT |
|
||||
| 框架名 | `us` |
|
||||
| 库名 | `libus_http` |
|
||||
| 命名空间 | `pmc::net` |
|
||||
|
||||
**项目角色**:`libhttp` 被 `gateway` 模块和 `core` 模块直接依赖,承担所有入站请求的接收、路由、SSL 卸载和 WebSocket 长连接管理。
|
||||
|
||||
---
|
||||
|
||||
## 2. 文件清单
|
||||
|
||||
```
|
||||
libhttp/
|
||||
├── .gitignore # 构建产物忽略规则
|
||||
├── CMakeLists.txt # CMake 构建 (v3.12+)
|
||||
├── LICENSE # MIT License
|
||||
├── Makefile # GNU Make 构建
|
||||
├── _set.sh # 一键编译安装脚本
|
||||
├── inc/us/
|
||||
│ ├── HttpClient.hpp # HTTP 客户端 (header-only)
|
||||
│ ├── HttpServer.hpp # HTTP/HTTPS 服务器声明
|
||||
│ ├── WebSocketClient.hpp # WebSocket 客户端声明
|
||||
│ ├── WebSocketClientBase.hpp # WebSocket 客户端抽象基类
|
||||
│ └── WebSocketServer.hpp # WebSocket 服务器声明
|
||||
└── src/
|
||||
├── HttpServer.cpp # HTTP/HTTPS 服务器实现
|
||||
├── WebSocketClient.cpp # WebSocket 客户端实现
|
||||
└── WebSocketServer.cpp # WebSocket 服务器实现
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 外部依赖
|
||||
|
||||
| 依赖 | 用途 | 备注 |
|
||||
|------------------|-----------------------------------|---------------------------|
|
||||
| Boost 1.91+ | Asio (I/O)、Beast (HTTP/WS)、Algorithms | 默认 `BOOST_ROOT=C:/local/boost_1_91_0`,可通过环境变量覆盖 |
|
||||
| OpenSSL | TLS/SSL 加密传输 | 通过 Boost.Asio.SSL 调用 |
|
||||
| libcurl | HTTP 客户端请求 | 仅 `HttpClient.hpp` 依赖 |
|
||||
| pthread (Unix) | 多线程 | CMake 自动链接 |
|
||||
| ws2_32 (Windows) | Winsock | CMake 自动链接 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 构建
|
||||
|
||||
### 4.1 GNU Make
|
||||
|
||||
```bash
|
||||
# 编译 (仅 HttpServer.o → libus_http.so)
|
||||
make
|
||||
|
||||
# 清理
|
||||
make clear
|
||||
|
||||
# 安装到 /lib 和 /usr/include/us/
|
||||
make install
|
||||
|
||||
# 卸载
|
||||
make uninstall
|
||||
```
|
||||
|
||||
> **注意**:Makefile 第 17 行默认只链接 `HttpServer.o`。如需 WebSocket 支持,需取消注释 `WebSocketServer.o` 和 `WebSocketClient.o`。
|
||||
|
||||
### 4.2 CMake
|
||||
|
||||
```bash
|
||||
mkdir build && cd build
|
||||
cmake .. -DBOOST_ROOT=/path/to/boost
|
||||
cmake --build .
|
||||
cmake --install . --prefix /usr/local
|
||||
```
|
||||
|
||||
CMake 会同时产出 `libus_http.so` (动态) 和 `libus_http_static.a` (静态)。
|
||||
|
||||
### 4.3 一键脚本
|
||||
|
||||
```bash
|
||||
bash _set.sh # make clear → make → make install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 参考
|
||||
|
||||
### 5.1 HttpServer — HTTP/HTTPS 服务器
|
||||
|
||||
**头文件**: `<us/HttpServer.hpp>`
|
||||
**实现**: `src/HttpServer.cpp`
|
||||
|
||||
#### 5.1.1 类型别名
|
||||
|
||||
```cpp
|
||||
using HttpRequestHandler = std::function<http::response<http::string_body>(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params)>;
|
||||
|
||||
using MiddlewareHandler = std::function<bool(
|
||||
const http::request<http::string_body>& req,
|
||||
http::response<http::string_body>& res,
|
||||
const std::unordered_map<std::string, std::string>& params)>;
|
||||
```
|
||||
|
||||
- **`HttpRequestHandler`**: 路由处理函数,接收 Boost.Beast 请求对象和已解析的查询参数,返回 HTTP 响应。
|
||||
- **`MiddlewareHandler`**: 中间件函数,返回 `false` 表示拦截(短路后续处理),`true` 表示放行。
|
||||
|
||||
#### 5.1.2 构造函数
|
||||
|
||||
| 签名 | 说明 |
|
||||
|----------------------------------------------------------------------------------------|------------------------|
|
||||
| `HttpServer(address, port, threads = 1)` | 纯 HTTP 模式 |
|
||||
| `HttpServer(address, port, cert_file, key_file, threads = 1)` | HTTPS 模式(自动启用 SSL) |
|
||||
|
||||
- `address`: 监听地址,如 `"0.0.0.0"`
|
||||
- `port`: 监听端口
|
||||
- `threads`: 工作线程数(即 `io_context` 线程池大小)
|
||||
- `cert_file` / `key_file`: PEM 格式证书和私钥路径
|
||||
|
||||
#### 5.1.3 路由注册
|
||||
|
||||
```cpp
|
||||
void get(const std::string& path, HttpRequestHandler handler);
|
||||
void post(const std::string& path, HttpRequestHandler handler);
|
||||
void put(const std::string& path, HttpRequestHandler handler);
|
||||
void del(const std::string& path, HttpRequestHandler handler);
|
||||
void options(const std::string& path, HttpRequestHandler handler);
|
||||
void use(MiddlewareHandler middleware);
|
||||
```
|
||||
|
||||
- 路径支持通配符:`*` (任意字符) 和 `?` (单个字符),自动编译为 `std::regex`(大小写不敏感)。
|
||||
- 匹配优先级:精确匹配 > 通配符匹配。
|
||||
- 请求方法字符串对应:`"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"OPTIONS"`。
|
||||
|
||||
#### 5.1.4 中间件
|
||||
|
||||
```cpp
|
||||
void use(MiddlewareHandler middleware);
|
||||
```
|
||||
|
||||
中间件在路由匹配之前执行,按注册顺序依次调用。任意中间件返回 `false` 时,请求处理结束,响应直接返回客户端。
|
||||
|
||||
#### 5.1.5 静态文件服务
|
||||
|
||||
```cpp
|
||||
void setStaticDirectory(const std::string& path);
|
||||
```
|
||||
|
||||
设置静态资源根目录。当请求路径未匹配任何路由时,自动尝试从该目录读取文件。支持 MIME 类型自动识别:`.html`, `.css`, `.js`, `.json`, `.png`, `.jpg/.jpeg`。
|
||||
|
||||
#### 5.1.6 OPTIONS 预检请求
|
||||
|
||||
当收到 `OPTIONS` 请求时:
|
||||
1. 优先查找自定义 `OPTIONS` 路由处理器
|
||||
2. 若未注册自定义处理器且请求包含 `Origin` 头,自动生成 CORS 响应
|
||||
- 自动检测该路径下已注册的方法(GET/POST/PUT/DELETE/OPTIONS)
|
||||
- 返回 `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`, `Access-Control-Max-Age`
|
||||
|
||||
#### 5.1.7 SSL 配置
|
||||
|
||||
```cpp
|
||||
void setPrivateKeyPassword(const std::string& password);
|
||||
```
|
||||
|
||||
- SSL 上下文使用 TLS 协议,禁用 SSLv2/SSLv3
|
||||
- 仅允许 ECDHE/DHE 密钥交换的强密码套件:`ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:!aNULL:!MD5:!DSS`
|
||||
- 支持 PEM 加密私钥密码
|
||||
|
||||
#### 5.1.8 生命周期
|
||||
|
||||
```cpp
|
||||
void start(); // 启动服务器(非阻塞,线程池跑 io_context)
|
||||
void stop(); // 停止服务器(关闭所有会话,join 线程)
|
||||
void run(); // 启动并在当前线程运行 io_context(阻塞)
|
||||
bool isRunning() const;
|
||||
```
|
||||
|
||||
#### 5.1.9 调试
|
||||
|
||||
```cpp
|
||||
void showStatus() const; // 打印服务器状态和已注册路由
|
||||
```
|
||||
|
||||
内嵌类 `HttpServer::debug` 提供 `print_all_header_fields()` 静态方法,打印请求的全部头字段。
|
||||
|
||||
#### 5.1.10 内部架构
|
||||
|
||||
```
|
||||
HttpServer
|
||||
├── Listener (内部类)
|
||||
│ └── acceptor_ (tcp::acceptor)
|
||||
│ └── async_accept → Session 实例
|
||||
└── Session (内部类,enable_shared_from_this)
|
||||
├── HTTP: socket_ (tcp::socket)
|
||||
├── HTTPS: ssl_stream_ (asio::ssl::stream<tcp::socket>)
|
||||
├── strand_ 保证线程安全
|
||||
└── 读取 → handleRequest() → 写入 → [keep-alive 则继续读取]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 HttpClient — HTTP 客户端
|
||||
|
||||
**头文件**: `<us/HttpClient.hpp>` (header-only,无独立 .cpp)
|
||||
**依赖**: libcurl
|
||||
|
||||
#### 5.2.1 枚举 `HttpMethod`
|
||||
|
||||
```cpp
|
||||
enum class HttpMethod { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS };
|
||||
```
|
||||
|
||||
#### 5.2.2 公共方法
|
||||
|
||||
```cpp
|
||||
HttpClient(); // 初始化 CURL 句柄,失败抛 runtime_error
|
||||
~HttpClient(); // 清理 CURL 和 header 链表
|
||||
|
||||
void set_header(const std::string& header);
|
||||
void clear_headers();
|
||||
|
||||
const std::map<std::string, std::string>& get_response_headers() const;
|
||||
long get_response_code() const;
|
||||
```
|
||||
|
||||
流式请求(通过回调函数接收响应体,保留原始字节):
|
||||
|
||||
```cpp
|
||||
void request_stream_sync(HttpMethod method, const std::string& url,
|
||||
const std::string& data,
|
||||
std::function<void(const std::string&)> callback);
|
||||
|
||||
// 便捷方法
|
||||
void get_stream_sync(const std::string& url, std::function<void(const std::string&)> callback);
|
||||
void post_stream_sync(const std::string& url, const std::string& data,
|
||||
std::function<void(const std::string&)> callback);
|
||||
void put_stream_sync(const std::string& url, const std::string& data,
|
||||
std::function<void(const std::string&)> callback);
|
||||
void delete_stream_sync(const std::string& url, std::function<void(const std::string&)> callback);
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 同步阻塞调用
|
||||
- 响应体通过 `CURLOPT_WRITEFUNCTION` 直接追加到 `std::string`,**保留所有换行符和特殊字符**
|
||||
- 超时设置:连接超时 10 秒,总超时 30 秒
|
||||
- 每次请求调用 `curl_easy_reset` 重置状态
|
||||
|
||||
---
|
||||
|
||||
### 5.3 WebSocketServer — WebSocket 服务器
|
||||
|
||||
**头文件**: `<us/WebSocketServer.hpp>`
|
||||
**实现**: `src/WebSocketServer.cpp`
|
||||
|
||||
#### 5.3.1 回调类型
|
||||
|
||||
```cpp
|
||||
using WebSocketMessageHandler = std::function<void(
|
||||
const std::string& clientId,
|
||||
const std::string& message,
|
||||
std::function<void(const std::string&)> sendCallback)>;
|
||||
|
||||
using WebSocketConnectHandler = std::function<void(
|
||||
const std::string& clientId, const std::string& remoteAddress)>;
|
||||
|
||||
using WebSocketDisconnectHandler = std::function<void(
|
||||
const std::string& clientId)>;
|
||||
```
|
||||
|
||||
#### 5.3.2 公共接口
|
||||
|
||||
```cpp
|
||||
WebSocketServer(unsigned short port, unsigned int threads = 2);
|
||||
~WebSocketServer();
|
||||
|
||||
void start();
|
||||
void stop();
|
||||
bool isRunning() const;
|
||||
unsigned short getPort() const;
|
||||
|
||||
// 事件注册
|
||||
void onMessage(const std::string& path, WebSocketMessageHandler handler);
|
||||
void onConnect(WebSocketConnectHandler handler);
|
||||
void onDisconnect(WebSocketDisconnectHandler handler);
|
||||
|
||||
// 消息/客户端管理
|
||||
bool sendToClient(const std::string& clientId, const std::string& message);
|
||||
size_t broadcast(const std::string& message);
|
||||
void disconnectClient(const std::string& clientId);
|
||||
size_t getClientCount() const;
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- PIMPL 模式:内部实现封装于 `Impl` 结构体
|
||||
- 每个 `Session` 包含一个发送队列 (`std::deque<std::string>`),保证消息顺序
|
||||
- 客户端 ID 由全局原子计数器自动生成,格式 `"client_N"`
|
||||
- 线程安全:客户端列表受 `clientsMutex_` 保护
|
||||
- 消息处理器支持路径路由(按 `path` 键索引)
|
||||
|
||||
**内部架构**:
|
||||
|
||||
```
|
||||
WebSocketServer
|
||||
├── Impl
|
||||
│ ├── io_context
|
||||
│ ├── Listener (内部类) → tcp::acceptor
|
||||
│ └── threads_
|
||||
└── Session (内部类,enable_shared_from_this)
|
||||
├── websocket::stream<beast::tcp_stream>
|
||||
├── sendQueue_ (std::deque)
|
||||
└── 读取循环 → onMessage 回调 → sendCallback 写回
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 WebSocketClient — WebSocket 客户端
|
||||
|
||||
**头文件**: `<us/WebSocketClient.hpp>`, `<us/WebSocketClientBase.hpp>`
|
||||
**实现**: `src/WebSocketClient.cpp`
|
||||
|
||||
#### 5.4.1 抽象基类 `WebSocketClientBase`
|
||||
|
||||
定义统一接口:
|
||||
|
||||
```cpp
|
||||
using MessageCallback = std::function<void(const std::string&)>;
|
||||
using ErrorCallback = std::function<void(const std::string&)>;
|
||||
using ConnectCallback = std::function<void(bool)>;
|
||||
using CloseCallback = std::function<void()>;
|
||||
|
||||
// 回调设置
|
||||
virtual void setMessageCallback(MessageCallback) = 0;
|
||||
virtual void setErrorCallback(ErrorCallback) = 0;
|
||||
virtual void setConnectCallback(ConnectCallback) = 0;
|
||||
virtual void setCloseCallback(CloseCallback) = 0;
|
||||
|
||||
// 连接管理
|
||||
virtual bool connect(int timeout_ms = 5000) = 0;
|
||||
virtual void disconnect() = 0;
|
||||
virtual bool isConnected() const = 0;
|
||||
|
||||
// 消息发送
|
||||
virtual bool send(const std::string& message) = 0;
|
||||
virtual bool sendBinary(const void* data, size_t size) = 0;
|
||||
|
||||
// 配置
|
||||
virtual void setAutoReconnect(bool enable, int interval_ms = 3000) = 0;
|
||||
virtual void setHeartbeatInterval(int interval_ms = 30000) = 0;
|
||||
|
||||
// SSL 相关(纯虚函数,非 SSL 版本忽略参数)
|
||||
virtual bool isSecure() const = 0;
|
||||
virtual void setVerifyCertificate(bool verify) = 0;
|
||||
virtual void setCertificateFile(const std::string& cert_file) = 0;
|
||||
virtual void setPrivateKeyFile(const std::string& key_file) = 0;
|
||||
virtual void setCertificateAuthorityFile(const std::string& ca_file) = 0;
|
||||
```
|
||||
|
||||
#### 5.4.2 公共接口
|
||||
|
||||
```cpp
|
||||
WebSocketClient(const std::string& host, const std::string& port,
|
||||
const std::string& path = "/");
|
||||
~WebSocketClient();
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- PIMPL 模式:`Impl` 结构体封装所有 Boost 对象和状态
|
||||
- 构造时自动启动 IO 线程
|
||||
- 异步连接流程:`resolve → connect → WebSocket handshake`
|
||||
- 发送队列受 `std::mutex` 和 `std::condition_variable` 保护
|
||||
- 自动重连:`onError` 或 `onClose` 中若 `auto_reconnect=true` 则自动发起重连
|
||||
- 非 SSL 版本:`isSecure()` 返回 `false`,SSL 配置方法为空实现
|
||||
- `sendBinary` 将二进制数据包装为 `std::string` 后调用 `send`
|
||||
|
||||
---
|
||||
|
||||
## 6. 使用示例
|
||||
|
||||
### 6.1 基本 HTTP 服务器
|
||||
|
||||
```cpp
|
||||
#include <us/HttpServer.hpp>
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
pmc::net::HttpServer server("0.0.0.0", 8080, 4);
|
||||
|
||||
server.get("/hello", [](const auto& req, const auto& params) {
|
||||
http::response<http::string_body> res{http::status::ok, req.version()};
|
||||
res.set(http::field::content_type, "text/plain");
|
||||
res.body() = "Hello, world!";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
});
|
||||
|
||||
server.post("/echo", [](const auto& req, const auto& params) {
|
||||
http::response<http::string_body> res{http::status::ok, req.version()};
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.body() = req.body();
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
});
|
||||
|
||||
server.start(); // 非阻塞启动
|
||||
std::cout << "Server running on port " << server.getPort() << std::endl;
|
||||
server.isRunning(); // 阻塞直到手动 stop()
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 HTTPS 服务器
|
||||
|
||||
```cpp
|
||||
pmc::net::HttpServer server("0.0.0.0", 443,
|
||||
"/etc/ssl/certs/server.crt",
|
||||
"/etc/ssl/private/server.key",
|
||||
4);
|
||||
server.setPrivateKeyPassword("mysecret");
|
||||
server.start();
|
||||
```
|
||||
|
||||
### 6.3 中间件 + 静态文件
|
||||
|
||||
```cpp
|
||||
pmc::net::HttpServer server("0.0.0.0", 8080);
|
||||
|
||||
// 日志中间件
|
||||
server.use([](const auto& req, auto&, const auto&) {
|
||||
std::cout << "[" << req.method_string() << "] " << req.target() << std::endl;
|
||||
return true; // 继续处理
|
||||
});
|
||||
|
||||
// 认证中间件
|
||||
server.use([](const auto& req, auto& res, const auto&) {
|
||||
auto auth = req.find(http::field::authorization);
|
||||
if (auth == req.end()) {
|
||||
res.result(http::status::unauthorized);
|
||||
res.body() = "Unauthorized";
|
||||
res.prepare_payload();
|
||||
return false; // 拦截
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
server.setStaticDirectory("/var/www/html");
|
||||
server.start();
|
||||
```
|
||||
|
||||
### 6.4 HTTP 客户端
|
||||
|
||||
```cpp
|
||||
pmc::net::HttpClient client;
|
||||
|
||||
client.set_header("Authorization: Bearer token123");
|
||||
client.set_header("Content-Type: application/json");
|
||||
|
||||
client.post_stream_sync("https://api.example.com/data",
|
||||
R"({"key":"value"})",
|
||||
[](const std::string& body) {
|
||||
std::cout << "Response: " << body << std::endl;
|
||||
});
|
||||
|
||||
long status = client.get_response_code();
|
||||
auto& headers = client.get_response_headers();
|
||||
```
|
||||
|
||||
### 6.5 WebSocket 服务器
|
||||
|
||||
```cpp
|
||||
pmc::net::WebSocketServer ws_server(9000, 2);
|
||||
|
||||
ws_server.onConnect([](const std::string& clientId, const std::string& addr) {
|
||||
std::cout << "Client connected: " << clientId << " from " << addr << std::endl;
|
||||
});
|
||||
|
||||
ws_server.onMessage("/chat", [&ws_server](const std::string& clientId,
|
||||
const std::string& message,
|
||||
auto sendCallback) {
|
||||
std::cout << "[" << clientId << "]: " << message << std::endl;
|
||||
ws_server.broadcast(clientId + ": " + message);
|
||||
});
|
||||
|
||||
ws_server.onDisconnect([](const std::string& clientId) {
|
||||
std::cout << "Client disconnected: " << clientId << std::endl;
|
||||
});
|
||||
|
||||
ws_server.start();
|
||||
```
|
||||
|
||||
### 6.6 WebSocket 客户端
|
||||
|
||||
```cpp
|
||||
pmc::net::WebSocketClient ws_client("localhost", "9000", "/chat");
|
||||
|
||||
ws_client.setConnectCallback([](bool success) {
|
||||
std::cout << (success ? "Connected!" : "Failed!") << std::endl;
|
||||
});
|
||||
|
||||
ws_client.setMessageCallback([](const std::string& msg) {
|
||||
std::cout << "Received: " << msg << std::endl;
|
||||
});
|
||||
|
||||
ws_client.setAutoReconnect(true, 3000);
|
||||
ws_client.connect();
|
||||
|
||||
// 阻塞主线程保持连接
|
||||
while (ws_client.isConnected()) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 请求处理流程
|
||||
|
||||
```
|
||||
客户端 → TCP 连接
|
||||
├─ HTTP: Session::doRead()
|
||||
└─ HTTPS: Session::doSSLHandshake() → Session::doRead()
|
||||
↓
|
||||
HttpServer::handleRequest(req)
|
||||
├─ 解析 target_path + query params
|
||||
├─ 执行中间件链 (MiddlewareHandler[])
|
||||
│ └─ 任意中间件返回 false → 短路返回
|
||||
├─ OPTIONS 请求?
|
||||
│ ├─ 有自定义处理器 → 执行
|
||||
│ └─ 有 Origin → 自动生成 CORS 响应
|
||||
├─ 路由匹配 (精确匹配 → 通配符匹配)
|
||||
│ └─ 命中 → 执行 HttpRequestHandler
|
||||
├─ 路由未命中 + static_directory_ 非空
|
||||
│ └─ 尝试读取静态文件 → 自动设 Content-Type
|
||||
└─ 全部未命中 → 404
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 设计要点
|
||||
|
||||
### 8.1 线程模型
|
||||
|
||||
- **HttpServer**: 每个工作线程独立运行 `io_context::run()`,所有异步操作通过 `strand` 序列化,同一 Session 的读写不会并发冲突
|
||||
- **WebSocketServer**: 同 HttpServer,工作线程池跑 `io_context`
|
||||
- **WebSocketClient**: 单 IO 线程,所有异步回调在该线程中执行
|
||||
- **HttpClient**: 同步阻塞,无额外线程
|
||||
|
||||
### 8.2 PIMPL 模式
|
||||
|
||||
`WebSocketClient` 和 `WebSocketServer` 使用 PIMPL 隐藏 Boost 依赖,保持头文件轻量。
|
||||
|
||||
### 8.3 会话管理
|
||||
|
||||
- `HttpServer` 通过 `weak_ptr<Session>` 跟踪所有活跃会话,`stop()` 时遍历清理
|
||||
- `WebSocketServer` 通过 `unordered_map<string, shared_ptr<Session>>` + mutex 管理
|
||||
|
||||
### 8.4 发送队列
|
||||
|
||||
`WebSocketServer::Session` 和 `WebSocketClient::Impl` 均采用 `std::deque` 消息队列,确保消息按序发送且不会因并发写入冲突。
|
||||
|
||||
---
|
||||
|
||||
## 9. 安全特性
|
||||
|
||||
| 特性 | 实现位置 |
|
||||
|---------------------------|------------------------------|
|
||||
| SSL/TLS 加密 | HttpServer SSL 构造函数 |
|
||||
| 禁止 SSLv2/SSLv3 | `initSSLContext()` |
|
||||
| 仅强密码套件 (ECDHE/DHE) | `initSSLContext()` 密码列表 |
|
||||
| 禁用 NULL/MD5/DSS | `initSSLContext()` 密码列表 |
|
||||
| 加密私钥密码支持 | `setPrivateKeyPassword()` |
|
||||
| CORS 自动处理 | `handleRequest()` OPTIONS 分支 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 与其他模块的关系
|
||||
|
||||
```
|
||||
micro_servers/
|
||||
├── gateway/ ──依赖──→ libhttp (HttpServer 接收外部请求)
|
||||
├── core/ ──依赖──→ libhttp (HTTP 服务基础能力)
|
||||
└── libhttp/ ──依赖──→ Boost, OpenSSL, libcurl
|
||||
```
|
||||
|
||||
- **gateway** 使用 `pmc::net::HttpServer` 作为 API 网关入口,处理 JWT 认证、令牌刷新、请求转发
|
||||
- **core** 模块使用 libhttp 提供的 HTTP 服务能力
|
||||
|
||||
---
|
||||
|
||||
## 11. 已知限制
|
||||
|
||||
1. **Makefile 默认不编译 WebSocket**:第 17 行仅链接 `HttpServer.o`,需手动启用
|
||||
2. **WebSocketClient 无 SSL 实现**:当前仅有非加密版本;SSL 接口已声明但为空实现
|
||||
3. **无 HTTP/2 支持**:仅 HTTP/1.1
|
||||
4. **静态文件 MIME 有限**:仅支持 6 种常见类型
|
||||
5. **无请求体大小限制**:依赖 Boost.Beast 默认配置
|
||||
6. **HttpClient 仅同步**:无异步 HTTP 客户端实现
|
||||
```
|
||||
|
||||
+194
-9
@@ -1,14 +1,199 @@
|
||||
# UniServe_libth
|
||||
UniServe Framework Thread Library.
|
||||
# libth — UniServe 线程库
|
||||
|
||||
# Dependancy Chain
|
||||
It should be C++17.
|
||||
C++20 轻量级多线程控制库,为 UniServe Framework 提供线程抽象与状态机驱动的执行模型。由 `core`(PMC 进程管理器)的 `HttpTask` 和 `ProcessTask` 直接依赖。
|
||||
|
||||
# Install Method
|
||||
Use UniServe Generate Installation Script.
|
||||
命名空间 `qing`,库文件 `libus_th.so`,头文件安装至 `/usr/include/us/`。
|
||||
|
||||
``` bash
|
||||
bash _set.sh
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────┐ ┌───────────────────────────────┐
|
||||
│ ITask │ │ Thread │
|
||||
│ (接口) │ │ 持有 Fsm + std::thread │
|
||||
│ start() │ │ │
|
||||
│ stop() │ │ set_stop / set_wake / │
|
||||
│ isRunning│ │ set_loop / set_clear │
|
||||
└────┬─────┘ │ Activate / WaitStart / │
|
||||
│ 实现 │ WaitClose │
|
||||
▼ │ │
|
||||
HttpTask │ main(): "回字形" 执行循环 │
|
||||
ProcessTask └──────────────┬────────────────┘
|
||||
(位于 core) │ 持有
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌────────────┐ ┌──────────┐
|
||||
│ Fsm │ │ Cv_wait │ │ Thread │
|
||||
│ 状态机 │ │ 条件等待 │ │ .cpp │
|
||||
│ SHUT/STOP│ │ Wait() │ │ 实现文件 │
|
||||
│ START/ │ │ WakeCv() │ │ │
|
||||
│ RUNNING │ │ │ │ │
|
||||
└──────────┘ └────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
# ENJOY!
|
||||
## 类与 API
|
||||
|
||||
### ITask — 任务接口
|
||||
|
||||
纯虚基类,定义可运行任务的契约。下游模块(core 中的 HttpTask、ProcessTask)继承实现。
|
||||
|
||||
```cpp
|
||||
class ITask {
|
||||
virtual void start() = 0; // 启动任务
|
||||
virtual void stop() = 0; // 停止任务
|
||||
virtual bool isRunning() = 0; // 查询运行状态
|
||||
};
|
||||
```
|
||||
|
||||
### Fsm — 线程安全有限状态机
|
||||
|
||||
4 个状态:`SHUT(-1)` → `STOP(0)` → `START(1)` → `RUNNING(2)`。Thread 通过 Fsm 驱动其执行流程。
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `check()` | 线程安全地读取当前状态 |
|
||||
| `set(Stat)` | 加锁设置状态 + 唤醒阻塞线程 |
|
||||
| `set_without_lock(Stat)` | 不加锁设置(用于临界区内) |
|
||||
| `set_without_notify(Stat)` | 加锁但不唤醒条件变量 |
|
||||
| `suspend()` | 阻塞当前线程直至状态变更 |
|
||||
|
||||
初始状态为 `STOP`。
|
||||
|
||||
### Thread — 线程抽象
|
||||
|
||||
基于回调组合(composition over inheritance),持有 `Fsm` 和 `std::thread`。执行模型为"回字形"循环:
|
||||
|
||||
```
|
||||
while (状态 != SHUT) {
|
||||
① STOP: 循环调用 StopEvent 回调(空闲轮询)
|
||||
② START: 单次调用 WakeEvent 回调(启动)
|
||||
③ RUNNING:循环调用 LoopEvent 回调(工作循环)
|
||||
④ 退出后: 调用 ClearEvent 回调(清理)
|
||||
}
|
||||
异常捕获:loop 内抛出 std::exception 自动回退到 STOP,进程不崩溃。
|
||||
```
|
||||
|
||||
**生命周期:两阶段构造。** 构造函数只初始化成员,`Activate()` 才真正创建 `std::thread`——避免线程在对象构造完成前启动的竞态条件。
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `set_stop(f_t)` | 设置 STOP 状态回调 |
|
||||
| `set_wake(f_t)` | 设置 START 状态回调 |
|
||||
| `set_loop(f_t)` | 设置 RUNNING 状态回调 |
|
||||
| `set_clear(f_t)` | 设置清理回调 |
|
||||
| `Activate()` | 创建 std::thread 并启动 main() |
|
||||
| `WaitStart()` | 唤醒线程并阻塞至其进入 RUNNING |
|
||||
| `WaitClose()` | 关闭线程并 join 等待退出 |
|
||||
| `wake()` | 状态 → START(不可持有锁,否则死锁) |
|
||||
| `stop()` | 状态 → STOP |
|
||||
| `shut()` | 状态 → SHUT |
|
||||
| `run()` | 状态 → RUNNING |
|
||||
| `check()` | 查询当前状态 |
|
||||
| `suspend()` | 阻塞等待状态变更 |
|
||||
|
||||
回调类型:`using f_t = std::function<void(Thread&)>;`
|
||||
|
||||
### Cv_wait — 条件变量等待工具
|
||||
|
||||
简单的单次等待-唤醒工具,用于线程间同步。
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `Wait()` | 阻塞当前线程至 WakeCv() 被调用 |
|
||||
| `WakeCv()` | 唤醒所有等待线程 |
|
||||
|
||||
> **已知限制**:仅支持单次等待-唤醒周期,不可复用。缺少超时机制,可能永久阻塞。代码中标注建议改用 `std::future`/`std::promise`。core 的 `main.cpp` 使用此类。
|
||||
|
||||
## 构建与安装
|
||||
|
||||
```bash
|
||||
# 一键构建 + 安装(需 sudo)
|
||||
bash _set.sh
|
||||
|
||||
# 或手动执行
|
||||
make # 编译 libus_th.so
|
||||
make install # 安装头文件到 /usr/include/us/ + .so 到 /lib/
|
||||
make uninstall # 卸载
|
||||
make clear # 清理构建产物
|
||||
```
|
||||
|
||||
安装后使用:
|
||||
|
||||
```cpp
|
||||
#include <us/Thread.hpp>
|
||||
#include <us/Fsm.hpp>
|
||||
#include <us/ITask.hpp>
|
||||
#include <us/Cv_wait.hpp>
|
||||
```
|
||||
|
||||
链接:`g++ -std=c++20 ... -lus_th`
|
||||
|
||||
## 使用示例
|
||||
|
||||
```cpp
|
||||
#include <us/Thread.hpp>
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
qing::Thread worker;
|
||||
bool done = false;
|
||||
|
||||
// STOP 状态回调:空闲等待
|
||||
worker.set_stop([&](qing::Thread& t) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
});
|
||||
|
||||
// START 状态回调:一次性初始化
|
||||
worker.set_wake([&](qing::Thread& t) {
|
||||
std::cout << "worker started" << std::endl;
|
||||
t.run(); // 进入 RUNNING
|
||||
});
|
||||
|
||||
// RUNNING 状态回调:工作循环
|
||||
worker.set_loop([&](qing::Thread& t) {
|
||||
std::cout << "working..." << std::endl;
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
done = true;
|
||||
t.stop(); // 回到 STOP
|
||||
});
|
||||
|
||||
// 清理回调
|
||||
worker.set_clear([](qing::Thread& t) {
|
||||
std::cout << "cycle cleaned up" << std::endl;
|
||||
});
|
||||
|
||||
worker.Activate();
|
||||
worker.WaitStart(); // 阻塞至 RUNNING
|
||||
|
||||
while (!done) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
|
||||
worker.WaitClose();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- C++20(`g++ --std=c++20`)
|
||||
- 纯标准库,无第三方依赖
|
||||
- 线程库需链接 `-pthread`
|
||||
|
||||
## 设计要点
|
||||
|
||||
- **回调组合优于继承**。2025 年 6 月从派生改为持有 `std::function` 回调,Thread 不再需要子类化。
|
||||
- **两阶段构造**。构造与 `Activate()` 分离,防止 `std::thread` 在对象构造完成前访问未初始化的成员。
|
||||
- **异常安全**。main() 循环捕获 `std::exception` 后回退到 STOP 而非崩溃。注意:catch 后继续运行可能造成内存泄漏。
|
||||
- **wake() 不可加锁**。`WaitStart()` 先持有 `mtx_th` 再调用 `wake()`,若 `wake()` 内部也尝试获取同一锁会造成死锁。
|
||||
- **Fsm 提供两套条件变量**。Fsm 内部有一套 `cv` 用于 `suspend()`,Thread 自身另有一套 `cv` 用于 `WaitStart()`/`WaitClose()`,互不干扰。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Cv_wait 仅支持单次使用,复用时 `flag` 残留为 `true` 会导致 `Wait()` 立即返回而不阻塞。
|
||||
- Fsm 的 `suspend()` 等待的是 Fsm 内部的 `ready` 标志,由 `set()` 触发;Thread 的 `WaitStart()` 等待的是 Thread 内部的 `ready`,由 `run()`/`stop()`/`shut()` 触发。两者独立。
|
||||
- `Activate()` 不可重复调用(内部有空指针检查),但不会报错,静默返回。
|
||||
- 析构函数为虚函数,允许安全的多态删除。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -28,7 +28,7 @@ public:
|
||||
inline void WakeCv() /* 唤醒线程 */
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lcok(mtx);
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
flag = true;
|
||||
}
|
||||
cv.notify_all();
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
# libtry 模块技术手册
|
||||
|
||||
> 文档版本:2026-07-30
|
||||
> 代码版本:try.c(~741 行)
|
||||
> 语言:C(嵌入式解释器)
|
||||
> 定位:教学级脚本解释器,用于操作界面 shell
|
||||
|
||||
---
|
||||
|
||||
## 一、模块概述
|
||||
|
||||
### 1.1 定位
|
||||
|
||||
**libtry** 是一个基于 C 语言手写的**教学级解释器**,魔改自 GitHub 上的 tryC 项目。用于解析 `.try` 脚本文件,设计目标是作为**操作界面的 shell**。
|
||||
|
||||
### 1.2 特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 手写解释器 | 仅 741 行实现完整的词法/语法/执行引擎 |
|
||||
| 零依赖 | 仅依赖标准 C 库 + `console.h` 输出接口 |
|
||||
| 宽字符支持 | 使用 `wchar_t`,支持中文/多语言脚本 |
|
||||
| 递归下降 | 递归下降语法分析器,边解析边执行 |
|
||||
| 嵌入式设计 | 提供 `init_tryc`/`enqueue_tryc`/`eval_tryc`/`free_tryc` 四个核心 API |
|
||||
|
||||
### 1.3 与微服务体系的关系
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 模块关系 | **独立底层库**,不直接与任何微服务交互 |
|
||||
| 可能用途 | 作为前端 UI 的脚本解释器,或嵌入式脚本引擎 |
|
||||
| 启动方式 | 被其他模块(可能是 gateway 或 static)动态加载调用 |
|
||||
|
||||
---
|
||||
|
||||
## 二、技术架构
|
||||
|
||||
### 2.1 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 上层应用 │
|
||||
│ (gateway / static / 其他模块) │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│ 调用 API
|
||||
┌─────────────────────────▼───────────────────────────────────┐
|
||||
│ libtry 解释器 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 词法分析器 (next) │ │
|
||||
│ │ 逐字符扫描 → 识别 token → 更新符号表 │ │
|
||||
│ └──────────────────────────┬──────────────────────────────┘ │
|
||||
│ │ token 流 │
|
||||
│ ┌──────────────────────────▼──────────────────────────────┐ │
|
||||
│ │ 递归下降语法分析器 │ │
|
||||
│ │ statement() / expression() / factor() / term() │ │
|
||||
│ │ 边解析边执行(解释器模式) │ │
|
||||
│ └──────────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼──────────────────────────────┐ │
|
||||
│ │ 符号表 │ │
|
||||
│ │ symtab[128] — 符号栈(变量/函数/数组) │ │
|
||||
│ │ levelNum — 嵌套层级,支持作用域 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────▼───────────────────────────────────┐
|
||||
│ 执行结果输出 │
|
||||
│ printtovga() / 控制台输出 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 执行流程
|
||||
|
||||
```
|
||||
init_tryc() 初始化
|
||||
│
|
||||
├── memset(symtab, 0, ...) 清零符号表
|
||||
├── 预注册关键字 (array, func, else, if, return, while, print, puts, read)
|
||||
└── malloc(128) 分配源代码缓冲区
|
||||
│
|
||||
enqueue_tryc(wc) 追加字符(交互式输入)
|
||||
│
|
||||
└── src[top_src] = wc; src[++top_src] = L'\0';
|
||||
│
|
||||
eval_tryc() 执行
|
||||
│
|
||||
├── next() 获取第一个 token
|
||||
├── while (token != L'\0') { statement(); } 逐条执行
|
||||
└── 重置缓冲区
|
||||
│
|
||||
free_tryc() 释放资源
|
||||
│
|
||||
└── free(p_src);
|
||||
```
|
||||
|
||||
### 2.3 词法分析流程
|
||||
|
||||
```
|
||||
next() 函数:
|
||||
│
|
||||
├── 跳过空白符 (L' ', L'\t')
|
||||
├── 跳过注释 (# 开头到换行)
|
||||
├── 识别标识符 (字母/数字/下划线/中文)
|
||||
│ ├── 在符号表中搜索
|
||||
│ │ ├── 找到:根据 type 返回对应 token (Sym, FuncSym, ArraySym, Str)
|
||||
│ │ └── 未找到:创建新符号,类型为 Void
|
||||
├── 识别数字 (整数 + 小数)
|
||||
├── 识别字符字面量 ('a')
|
||||
├── 识别字符串字面量 ("hello")
|
||||
├── 识别运算符 (=, ==, !=, <, >, <=, >=, ||, &&)
|
||||
└── 识别标点符号 (;, ,, +, -, *, /, (, ), {, }, [, ])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、代码结构
|
||||
|
||||
### 3.1 文件结构
|
||||
|
||||
```
|
||||
libtry/
|
||||
├── try.c # 核心解释器实现(~741 行)
|
||||
├── t.try # 示例脚本(斐波那契数列)
|
||||
├── test/ # 测试目录
|
||||
│ ├── t_assign_basic.try # 赋值测试
|
||||
│ ├── t_function_basic.try # 函数测试
|
||||
│ └── t_output_basic.try # 输出测试
|
||||
├── .gitignore
|
||||
└── README.robot.md # 本文档
|
||||
```
|
||||
|
||||
### 3.2 try.c 函数索引
|
||||
|
||||
| 函数 | 行号 | 功能 |
|
||||
|------|------|------|
|
||||
| `init_tryc()` | L650 | 初始化解释器,预注册关键字 |
|
||||
| `free_tryc()` | L672 | 释放源代码缓冲区 |
|
||||
| `enqueue_tryc(wc)` | L677 | 追加一个字符到缓冲区 |
|
||||
| `eval_tryc()` | L687 | 解析并执行当前缓冲区脚本 |
|
||||
| `next()` | L85 | 词法分析,获取下一个 token |
|
||||
| `match(tk)` | L289 | 匹配并消费期望 token |
|
||||
| `statement()` | L466 | 解析并执行语句 |
|
||||
| `expression()` | L362 | 解析加减法表达式 |
|
||||
| `term()` | L311 | 解析乘除法表达式 |
|
||||
| `factor()` | L326 | 解析因子(变量/常量/函数调用/数组) |
|
||||
| `function()` | L616 | 执行函数调用 |
|
||||
| `boolexp()` | L378 | 解析布尔表达式 |
|
||||
| `boolAND()` | L423 | 解析 AND 表达式(短路) |
|
||||
| `boolOR()` | L438 | 解析 OR 表达式(短路) |
|
||||
| `clear(level)` | L269 | 清理指定层级的符号 |
|
||||
| `clearThis()` | L280 | 清理单个符号的动态内存 |
|
||||
| `skipStatments()` | L453 | 跳过 if/else 未执行分支 |
|
||||
| `skipBoolExpr()` | L413 | 跳过短路布尔表达式 |
|
||||
| `__exit()` | L702 | 错误处理(死循环挂起) |
|
||||
| `main()` | L710 | 文件执行模式(仅 `_DEBUG` 编译) |
|
||||
|
||||
### 3.3 核心数据结构
|
||||
|
||||
#### 符号表(symStruct)
|
||||
|
||||
```c
|
||||
typedef struct symStruct {
|
||||
int type; // 类型:Num, Char, Str, Array, Func
|
||||
wchar_t name[16]; // 符号名(MAXNAMESIZE)
|
||||
double value; // 值或数组/字符串长度
|
||||
union {
|
||||
wchar_t *funcp; // 函数指针或字符串内容
|
||||
struct symStruct* list; // 数组元素指针
|
||||
} pointer;
|
||||
int levelNum; // 嵌套层级(作用域)
|
||||
} symbol;
|
||||
```
|
||||
|
||||
#### Token 类型枚举
|
||||
|
||||
```c
|
||||
enum {
|
||||
Num = 128, // 数字
|
||||
Char, // 字符字面量
|
||||
Str, // 字符串
|
||||
Array, // array 关键字
|
||||
Func, // func 关键字
|
||||
Else, // else 关键字
|
||||
If, // if 关键字
|
||||
Return, // return 关键字
|
||||
While, // while 关键字
|
||||
Print, // print 关键字
|
||||
Puts, // puts 关键字
|
||||
Read, // read 关键字
|
||||
Assign, // = 赋值
|
||||
OR, // ||
|
||||
AND, // &&
|
||||
Equal, // ==
|
||||
Sym, // 变量符号
|
||||
FuncSym, // 函数符号
|
||||
ArraySym, // 数组符号
|
||||
Void, // 未初始化
|
||||
Nequal, // !=
|
||||
LessEqual, // <=
|
||||
GreatEqual // >=
|
||||
};
|
||||
```
|
||||
|
||||
#### 全局变量
|
||||
|
||||
| 变量 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `symtab[128]` | `symbol[]` | 符号表栈(SYMTABSIZE) |
|
||||
| `symPointer` | `int` | 符号表栈指针 |
|
||||
| `currentlevel` | `int` | 当前嵌套层级 |
|
||||
| `src` | `wchar_t*` | 当前扫描位置 |
|
||||
| `old_src` | `wchar_t*` | 行首位置(调试用) |
|
||||
| `p_src` | `wchar_t*` | 源代码缓冲区起始 |
|
||||
| `top_src` | `int` | 缓冲区写入位置 |
|
||||
| `token` | `int` | 当前 token 类型 |
|
||||
| `token_val` | `union tokenValue` | 当前 token 值 |
|
||||
| `return_val` | `double` | 函数返回值 |
|
||||
| `compileState` | `int` | 编译状态(debug/run) |
|
||||
|
||||
---
|
||||
|
||||
## 四、API 接口
|
||||
|
||||
### 4.1 初始化与销毁
|
||||
|
||||
#### init_tryc()
|
||||
|
||||
```c
|
||||
int init_tryc(void);
|
||||
```
|
||||
|
||||
**功能**:初始化解释器
|
||||
- 清零符号表
|
||||
- 预注册 8 个关键字(array, func, else, if, return, while, print, puts, read)
|
||||
- 分配 128 字节宽字符缓冲区
|
||||
|
||||
**返回值**:
|
||||
- `0`:成功
|
||||
- `-1`:内存分配失败
|
||||
|
||||
#### free_tryc()
|
||||
|
||||
```c
|
||||
void free_tryc(void);
|
||||
```
|
||||
|
||||
**功能**:释放源代码缓冲区
|
||||
|
||||
### 4.2 输入与执行
|
||||
|
||||
#### enqueue_tryc(wc)
|
||||
|
||||
```c
|
||||
int enqueue_tryc(wchar_t wc);
|
||||
```
|
||||
|
||||
**功能**:追加一个字符到源代码缓冲区(用于交互式输入)
|
||||
|
||||
**参数**:
|
||||
- `wc`:要追加的宽字符
|
||||
|
||||
**返回值**:
|
||||
- `0`:成功
|
||||
- `-1`:缓冲区已满
|
||||
|
||||
#### eval_tryc()
|
||||
|
||||
```c
|
||||
void eval_tryc(void);
|
||||
```
|
||||
|
||||
**功能**:解析并执行当前缓冲区中的脚本
|
||||
|
||||
**说明**:执行完成后会重置缓冲区。
|
||||
|
||||
### 4.3 典型使用示例
|
||||
|
||||
#### 交互式模式
|
||||
|
||||
```c
|
||||
#include "try.c"
|
||||
|
||||
int main() {
|
||||
init_tryc();
|
||||
|
||||
// 交互式输入
|
||||
wchar_t input;
|
||||
while (read_input(&input)) {
|
||||
enqueue_tryc(input);
|
||||
if (input == L'\n') {
|
||||
eval_tryc(); // 遇到换行执行
|
||||
}
|
||||
}
|
||||
|
||||
free_tryc();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
#### 文件执行模式
|
||||
|
||||
```c
|
||||
#include "try.c"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
init_tryc();
|
||||
|
||||
// 读取文件
|
||||
FILE *file = fopen(argv[1], "r");
|
||||
wchar_t wc;
|
||||
int i = 0;
|
||||
while ((wc = fgetwc(file)) != WEOF) {
|
||||
src[i++] = wc;
|
||||
}
|
||||
src[i] = L'\0';
|
||||
fclose(file);
|
||||
|
||||
// 执行
|
||||
eval_tryc();
|
||||
|
||||
clear(0); // 清理全局符号
|
||||
free_tryc();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、语言特性
|
||||
|
||||
### 5.1 数据类型
|
||||
|
||||
| 类型 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `Num` | 数字(整数或浮点) | `a = 5;`, `b = 3.14;` |
|
||||
| `Char` | 单个字符 | `c = 'A';` |
|
||||
| `Str` | 字符串(宽字符) | `s = "hello";` |
|
||||
| `Array` | 数组(动态分配) | `array arr(15);` |
|
||||
| `Func` | 函数 | `func name{...};` |
|
||||
|
||||
### 5.2 变量与作用域
|
||||
|
||||
#### 变量声明
|
||||
|
||||
变量通过首次赋值自动声明:
|
||||
|
||||
```
|
||||
x = 5; // 声明并初始化数字变量
|
||||
name = "hello"; // 声明并初始化字符串变量
|
||||
flag = 'A'; // 声明并初始化字符变量
|
||||
```
|
||||
|
||||
#### 作用域
|
||||
|
||||
- 全局变量:在顶层声明
|
||||
- 局部变量:在函数内声明,函数结束后自动销毁
|
||||
- 符号表使用栈结构,`levelNum` 标识嵌套层级
|
||||
|
||||
### 5.3 运算符
|
||||
|
||||
#### 算术运算符
|
||||
|
||||
| 运算符 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| `+` | 加法 | `a + b` |
|
||||
| `-` | 减法 | `a - b` |
|
||||
| `*` | 乘法 | `a * b` |
|
||||
| `/` | 除法 | `a / b` |
|
||||
|
||||
#### 比较运算符
|
||||
|
||||
| 运算符 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| `>` | 大于 | `a > b` |
|
||||
| `<` | 小于 | `a < b` |
|
||||
| `>=` | 大于等于 | `a >= b` |
|
||||
| `<=` | 小于等于 | `a <= b` |
|
||||
| `==` | 等于 | `a == b` |
|
||||
| `!=` | 不等于 | `a != b` |
|
||||
|
||||
#### 逻辑运算符
|
||||
|
||||
| 运算符 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| `\|\|` | 逻辑或(短路) | `a \|\| b` |
|
||||
| `&&` | 逻辑与(短路) | `a && b` |
|
||||
| `!` | 逻辑非 | `!a` |
|
||||
|
||||
#### 赋值运算符
|
||||
|
||||
| 运算符 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| `=` | 赋值 | `a = 5;` |
|
||||
|
||||
### 5.4 控制流
|
||||
|
||||
#### if/else 语句
|
||||
|
||||
```
|
||||
if (condition) {
|
||||
// true 分支
|
||||
}
|
||||
else {
|
||||
// false 分支
|
||||
}
|
||||
```
|
||||
|
||||
#### while 循环
|
||||
|
||||
```
|
||||
while (condition) {
|
||||
// 循环体
|
||||
}
|
||||
```
|
||||
|
||||
#### return 语句
|
||||
|
||||
```
|
||||
func name{
|
||||
return(value);
|
||||
};
|
||||
```
|
||||
|
||||
### 5.5 函数
|
||||
|
||||
#### 函数定义
|
||||
|
||||
```
|
||||
func function_name{
|
||||
// 函数体
|
||||
return(return_value);
|
||||
};
|
||||
```
|
||||
|
||||
**特性**:
|
||||
- 支持递归调用
|
||||
- 支持参数传递(通过符号表)
|
||||
- `return` 语句会终止函数执行并返回值
|
||||
|
||||
#### 函数调用
|
||||
|
||||
```
|
||||
result = function_name(arg1, arg2);
|
||||
```
|
||||
|
||||
### 5.6 数组
|
||||
|
||||
#### 数组声明
|
||||
|
||||
```
|
||||
array arr(size);
|
||||
```
|
||||
|
||||
- `size` 必须是正整数
|
||||
- 数组元素初始化为 0
|
||||
- 数组元素类型为 `Num`(double)
|
||||
|
||||
#### 数组访问与赋值
|
||||
|
||||
```
|
||||
arr[0] = 10;
|
||||
value = arr[i];
|
||||
```
|
||||
|
||||
### 5.7 输入输出
|
||||
|
||||
#### print()
|
||||
|
||||
```
|
||||
print(expression);
|
||||
```
|
||||
|
||||
**功能**:输出数字
|
||||
|
||||
**示例**:
|
||||
```
|
||||
print(42); // 输出: 42.000000
|
||||
print(3.14); // 输出: 3.140000
|
||||
print(arr[0]); // 输出数组元素
|
||||
```
|
||||
|
||||
#### puts()
|
||||
|
||||
```
|
||||
puts("string");
|
||||
```
|
||||
|
||||
**功能**:输出字符串
|
||||
|
||||
**示例**:
|
||||
```
|
||||
puts("Hello, World!");
|
||||
```
|
||||
|
||||
#### read()
|
||||
|
||||
```
|
||||
read(variable);
|
||||
```
|
||||
|
||||
**功能**:读取输入(当前实现返回 -1)
|
||||
|
||||
### 5.8 注释
|
||||
|
||||
```
|
||||
# This is a comment
|
||||
```
|
||||
|
||||
注释从 `#` 开始到行尾结束。
|
||||
|
||||
---
|
||||
|
||||
## 六、示例脚本
|
||||
|
||||
### 6.1 斐波那契数列
|
||||
|
||||
```
|
||||
# Fibonacci sequence
|
||||
func fun{
|
||||
if(x <= 2){
|
||||
return(1);
|
||||
}
|
||||
y = 0;
|
||||
x = x - 1;
|
||||
y = fun(x);
|
||||
x = x - 1;
|
||||
return(y + fun(x));
|
||||
};
|
||||
|
||||
# 计算 1-15 的斐波那契数列并存入数组
|
||||
array arr(15);
|
||||
x = 0;
|
||||
while(x<15){
|
||||
arr[x] = fun(x) + 1000;
|
||||
x = x + 1;
|
||||
}
|
||||
|
||||
puts("Fibonacci sequence:");
|
||||
i = 0;
|
||||
while(i < 15){
|
||||
print(arr[i]);
|
||||
i=i+1;
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 基础赋值
|
||||
|
||||
```
|
||||
a = 5;
|
||||
b = "ok";
|
||||
|
||||
print(5);
|
||||
puts(b);
|
||||
```
|
||||
|
||||
### 6.3 基础函数
|
||||
|
||||
```
|
||||
func f_test{
|
||||
return(0);
|
||||
};
|
||||
|
||||
r = f_test();
|
||||
print(r);
|
||||
```
|
||||
|
||||
### 6.4 基础输出
|
||||
|
||||
```
|
||||
print(1);
|
||||
puts("1");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、已知限制
|
||||
|
||||
### 🔴 严重问题
|
||||
|
||||
| ID | 位置 | 描述 | 影响 |
|
||||
|----|------|------|------|
|
||||
| L1 | [L20](file:///Users/qingsong/micro_servers/libtry/try.c#L20) | **缓冲区大小固定**:`POOLSIZE = 128` 字节,无法修改 | 长脚本将溢出 |
|
||||
| L2 | [L702](file:///Users/qingsong/micro_servers/libtry/try.c#L702) | **`__exit()` 死循环**:`while(1);` 错误时直接挂死程序 | 无法恢复 |
|
||||
| L3 | [L186](file:///Users/qingsong/micro_servers/libtry/try.c#L186) | **字符串内存泄漏**:`malloc` 分配的字符串在重新赋值时未释放 | 长期运行泄漏 |
|
||||
| L4 | [L528](file:///Users/qingsong/micro_servers/libtry/try.c#L528) | **数组越界**:无越界检查 | 未定义行为 |
|
||||
|
||||
### 🟡 中等问题
|
||||
|
||||
| ID | 位置 | 描述 |
|
||||
|----|------|------|
|
||||
| L5 | [L598](file:///Users/qingsong/micro_servers/libtry/try.c#L598) | **`puts` 实现存疑**:原 `wprintf` 被注释,改用 `printtovga` |
|
||||
| L6 | [L518](file:///Users/qingsong/micro_servers/libtry/try.c#L518) | **`clearThis()` 被注释**:作者注释说"不知为何需要注释了才能跑" |
|
||||
| L7 | [L706](file:///Users/qingsong/micro_servers/libtry/try.c#L706) | **文件模式仅限 `_DEBUG`**:正常构建时 `main()` 不编译 |
|
||||
| L8 | [L103](file:///Users/qingsong/micro_servers/libtry/try.c#L103) | **栈缓冲区溢出风险**:`nameBuffer[16]` 但标识符长度限制由 `MAXNAMESIZE=16` 控制 |
|
||||
| L9 | [L21](file:///Users/qingsong/micro_servers/libtry/try.c#L21) | **符号表大小固定**:`SYMTABSIZE = 128` 个符号 |
|
||||
|
||||
### 🟢 轻微问题
|
||||
|
||||
| ID | 描述 |
|
||||
|----|------|
|
||||
| L10 | 错误处理过于简单,`match()` 失败直接 `__exit(-1)` |
|
||||
| L11 | 大量被注释的调试代码(`//wprintf(...)`) |
|
||||
| L12 | 全局变量过多(`symtab`, `token`, `token_val`, `src`, `old_src`, `currentlevel` 等) |
|
||||
| L13 | 无并发支持,非线程安全 |
|
||||
| L14 | 无单元测试 |
|
||||
|
||||
### 修复建议
|
||||
|
||||
#### 优先级 1(安全相关)
|
||||
|
||||
1. **修复数组越界**:在 [L528](file:///Users/qingsong/micro_servers/libtry/try.c#L528) 添加检查
|
||||
```c
|
||||
if (index < 0 || index >= s->value) {
|
||||
printtovga(L"Array index out of bounds\n");
|
||||
__exit(-1);
|
||||
}
|
||||
```
|
||||
|
||||
2. **修复 `__exit()`**:改为返回错误码
|
||||
```c
|
||||
static int error_code = 0;
|
||||
static void __exit(int code) {
|
||||
error_code = code;
|
||||
// 不挂起,让调用者处理错误
|
||||
}
|
||||
```
|
||||
|
||||
3. **修复字符串内存泄漏**:在赋值前释放旧字符串
|
||||
```c
|
||||
else if (token == Str) {
|
||||
if (s->pointer.funcp != NULL) {
|
||||
free(s->pointer.funcp); // 释放旧字符串
|
||||
}
|
||||
s->pointer.funcp = (wchar_t*)token_val.ptr;
|
||||
s->type = Str;
|
||||
match(Str);
|
||||
}
|
||||
```
|
||||
|
||||
#### 优先级 2(功能增强)
|
||||
|
||||
4. **动态缓冲区**:将 `POOLSIZE` 改为可配置或动态扩容
|
||||
5. **错误回调**:添加错误回调函数,让调用者处理错误
|
||||
6. **符号表扩容**:允许 `SYMTABSIZE` 动态增长
|
||||
|
||||
---
|
||||
|
||||
## 八、开发指南
|
||||
|
||||
### 8.1 添加新关键字
|
||||
|
||||
在 [init_tryc()](file:///Users/qingsong/micro_servers/libtry/try.c#L650) 中添加:
|
||||
|
||||
```c
|
||||
// 1. 在 enum 中添加新 token 类型
|
||||
enum {
|
||||
Num = 128, Char, Str, Array, Func,
|
||||
Else, If, Return, While, Print, Puts, Read,
|
||||
Assign, OR, AND, Equal, Sym, FuncSym, ArraySym, Void,
|
||||
Nequal, LessEqual, GreatEqual,
|
||||
NewKeyword // 新增
|
||||
};
|
||||
|
||||
// 2. 在 init_tryc() 中注册
|
||||
src = L"array func else if return while print puts read new_keyword";
|
||||
for (i = Array; i <= NewKeyword; ++i) {
|
||||
next();
|
||||
symtab[symPointer - 1].type = i;
|
||||
}
|
||||
|
||||
// 3. 在 statement() 中添加处理逻辑
|
||||
else if (token == NewKeyword) {
|
||||
match(NewKeyword);
|
||||
// 处理逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 添加新运算符
|
||||
|
||||
在 [next()](file:///Users/qingsong/micro_servers/libtry/try.c#L85) 中添加识别逻辑:
|
||||
|
||||
```c
|
||||
// 例如:+= 运算符
|
||||
else if (token == L'+') {
|
||||
if (*src == L'=') {
|
||||
src++;
|
||||
token = AddAssign; // 新增 token 类型
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 [statement()](file:///Users/qingsong/micro_servers/libtry/try.c#L466) 中添加执行逻辑。
|
||||
|
||||
### 8.3 自定义输出
|
||||
|
||||
修改 `printtovga()` 的实现,或添加可配置的输出回调:
|
||||
|
||||
```c
|
||||
// 函数指针类型
|
||||
typedef void (*output_func)(const wchar_t*);
|
||||
|
||||
// 全局输出函数
|
||||
static output_func current_output = default_output;
|
||||
|
||||
// 设置输出函数
|
||||
void set_output_func(output_func func) {
|
||||
current_output = func;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 构建说明
|
||||
|
||||
#### 编译器要求
|
||||
|
||||
- C 编译器(GCC/Clang)
|
||||
- 支持 C99 标准
|
||||
- 支持宽字符(`wchar.h`)
|
||||
|
||||
#### 编译选项
|
||||
|
||||
```bash
|
||||
# 基本编译
|
||||
gcc -c try.c -o try.o -std=c99
|
||||
|
||||
# 调试模式(启用 main 函数)
|
||||
gcc -c try.c -o try.o -std=c99 -D_DEBUG
|
||||
|
||||
# 优化编译
|
||||
gcc -c try.c -o try.o -std=c99 -O2
|
||||
```
|
||||
|
||||
#### 链接使用
|
||||
|
||||
```bash
|
||||
# 创建测试程序
|
||||
cat > test_main.c << 'EOF'
|
||||
#include <stdio.h>
|
||||
#include "try.h"
|
||||
|
||||
int main() {
|
||||
init_tryc();
|
||||
|
||||
// 注入脚本
|
||||
wchar_t script[] = L"print(42);";
|
||||
for (int i = 0; script[i] != L'\0'; i++) {
|
||||
enqueue_tryc(script[i]);
|
||||
}
|
||||
eval_tryc();
|
||||
|
||||
free_tryc();
|
||||
return 0;
|
||||
}
|
||||
EOF
|
||||
|
||||
# 编译链接
|
||||
gcc test_main.c try.o -o test_try -std=c99
|
||||
./test_try
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、测试
|
||||
|
||||
### 9.1 现有测试
|
||||
|
||||
| 测试文件 | 内容 |
|
||||
|----------|------|
|
||||
| `test/t_assign_basic.try` | 变量赋值与输出 |
|
||||
| `test/t_function_basic.try` | 函数定义与调用 |
|
||||
| `test/t_output_basic.try` | print/puts 输出 |
|
||||
| `t.try` | 斐波那契数列(复杂示例) |
|
||||
|
||||
### 9.2 运行测试
|
||||
|
||||
```bash
|
||||
# 编译(调试模式)
|
||||
gcc try.c -o try_interp -std=c99 -D_DEBUG
|
||||
|
||||
# 执行测试脚本
|
||||
./try_interp test/t_assign_basic.try
|
||||
./try_interp test/t_function_basic.try
|
||||
./try_interp test/t_output_basic.try
|
||||
./try_interp t.try
|
||||
```
|
||||
|
||||
### 9.3 添加新测试
|
||||
|
||||
在 `test/` 目录下创建 `.try` 文件:
|
||||
|
||||
```
|
||||
# test/test_new_feature.try
|
||||
# 描述新功能
|
||||
|
||||
a = 10;
|
||||
b = 20;
|
||||
c = a + b;
|
||||
print(c);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、版本历史
|
||||
|
||||
| 版本 | 日期 | 主要变更 |
|
||||
|------|------|---------|
|
||||
| 当前 | 2025-08-27 | 增加宽字符支持,修复 Str 匹配 BUG,使用 longjmp 替代 exit() |
|
||||
| 初始 | — | 魔改自 tryC 项目,手写简易解释器 |
|
||||
|
||||
### 修改记录(代码注释)
|
||||
|
||||
| 日期 | 作者 | 变更 |
|
||||
|------|------|------|
|
||||
| 2025-08-27 | qing | 增加宽字符支持 |
|
||||
| 2025-08-27 | qing | 修复 Str 漏匹配的 BUG |
|
||||
| 2025-08-27 | qing | 注明内存泄漏风险 |
|
||||
| 2025-08-27 | qing | 使用 `longjmp()` 替代 `exit()` |
|
||||
| 2026-07-07 | qing | 增加 `clearThis()` 函数,用于释放变量 |
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 完整关键字列表
|
||||
|
||||
```
|
||||
array, func, else, if, return, while, print, puts, read
|
||||
```
|
||||
|
||||
### B. Token 类型完整列表
|
||||
|
||||
```
|
||||
Num(128), Char(129), Str(130), Array(131), Func(132),
|
||||
Else(133), If(134), Return(135), While(136), Print(137), Puts(138), Read(139),
|
||||
Assign(140), OR(141), AND(142), Equal(143), Sym(144), FuncSym(145), ArraySym(146), Void(147),
|
||||
Nequal(148), LessEqual(149), GreatEqual(150)
|
||||
```
|
||||
|
||||
### C. 限制常量
|
||||
|
||||
| 常量 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| `POOLSIZE` | 128 | 源代码缓冲区大小(字节) |
|
||||
| `SYMTABSIZE` | 128 | 符号表大小 |
|
||||
| `MAXNAMESIZE` | 16 | 标识符最大长度 |
|
||||
| `RETURNFLAG` | DBL_MAX | return 语句标记 |
|
||||
|
||||
### D. 参考资源
|
||||
|
||||
- [Crafting Interpreters](https://craftinginterpreters.com/) — 解释器编写经典教程
|
||||
- [Writing an Interpreter in Go](https://interpreterbook.com/) — Go 实现的解释器教程
|
||||
- [tryC (GitHub)](https://github.com/) — 原始项目(已魔改)
|
||||
|
||||
---
|
||||
|
||||
*本文档基于 try.c 源码分析生成,供开发团队参考。使用前请评估已知限制。*
|
||||
@@ -0,0 +1,764 @@
|
||||
# pymsg 模块技术手册
|
||||
|
||||
> 文档版本:2026-07-30
|
||||
> 代码版本:0.1.0
|
||||
> 语言:Python 3.9+(核心)、C++(gateway 集成)、Go(waf 集成)
|
||||
> 定位:跨语言 TCP 消息帧协议库
|
||||
|
||||
---
|
||||
|
||||
## 一、模块概述
|
||||
|
||||
### 1.1 定位
|
||||
|
||||
**pymsg** 是一个极简跨语言 TCP 消息帧协议库,基于长度前缀(4 字节大端 uint32)+ UTF-8 载荷。为微服务体系提供轻量级 RPC 通信层。
|
||||
|
||||
### 1.2 设计哲学
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| 零依赖 | 仅使用 Python stdlib,无需任何第三方包 |
|
||||
| 极简协议 | 4 字节头 + 载荷,实现只需 20 行代码 |
|
||||
| 跨语言 | Python/C++/Go 三端实现完全兼容 |
|
||||
| 二进制安全 | 长度前缀帧,JSON 中的换行符无需转义 |
|
||||
| fail-safe | 日志推送在 db 不可用时静默丢弃,不影响业务 |
|
||||
|
||||
### 1.3 与微服务体系的关系
|
||||
|
||||
pymsg 是微服务的**核心通信层**:
|
||||
|
||||
```
|
||||
┌──────────────┐ pymsg ┌──────────────┐
|
||||
│ dnfs:8006 │ ───────→│ db:8005 │
|
||||
│ (Python) │ 短连接 │ (Python) │
|
||||
└──────────────┘ RPC └──────────────┘
|
||||
┌──────────────┐ pymsg ┌──────────────┐
|
||||
│ gateway:8443 │ ───────→│ db:8005 │
|
||||
│ (C++20) │ 短连接 │ │
|
||||
└──────────────┘ RPC └──────────────┘
|
||||
┌──────────────┐ pymsg ┌──────────────┐
|
||||
│ waf:443 │ ───────→│ db:8005 │
|
||||
│ (Go) │ 短连接 │ │
|
||||
└──────────────┘ RPC └──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、技术架构
|
||||
|
||||
### 2.1 协议格式
|
||||
|
||||
```
|
||||
┌────────────────────────┬──────────────────────────┐
|
||||
│ 4 字节(大端序) │ N 字节(UTF-8) │
|
||||
│ uint32 载荷字节长度 │ JSON / 文本载荷 │
|
||||
└────────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 微服务调用方 │
|
||||
│ (dnfs / gateway / waf / static / smtp / captcha) │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│ MsgClient.call() / send()
|
||||
┌─────────────────────────▼───────────────────────────────────┐
|
||||
│ pymsg 协议层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 底层协议函数 │ │
|
||||
│ │ send(sock, data) → [4字节长度头][UTF-8载荷] │ │
|
||||
│ │ recv(sock) → 读4字节→解析长度→读对应字节→返回 │ │
|
||||
│ └──────────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼──────────────────────────────┐ │
|
||||
│ │ MsgClient 客户端 │ │
|
||||
│ │ send(msg) — 短连接,发送即忘(带 name 前缀) │ │
|
||||
│ │ call(msg) — 短连接,RPC 请求-应答 │ │
|
||||
│ │ listen(cb) — 长连接,回调模式 │ │
|
||||
│ └──────────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼──────────────────────────────┐ │
|
||||
│ │ MsgServer 服务端 │ │
|
||||
│ │ start(on_message) — 启动监听 │ │
|
||||
│ │ reply(sock, msg) — 回复客户端 │ │
|
||||
│ │ broadcast(msg) — 广播到所有客户端 │ │
|
||||
│ │ stop() — 停止服务 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ TCP
|
||||
┌─────────────────────────▼───────────────────────────────────┐
|
||||
│ db:8005 服务端 │
|
||||
│ MsgServer + 路由分发 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 消息流
|
||||
|
||||
#### RPC 请求-应答
|
||||
|
||||
```
|
||||
调用方 服务端 (db)
|
||||
────── ──────────
|
||||
MsgClient.call(json_str)
|
||||
│
|
||||
├── socket.connect(127.0.0.1:8005)
|
||||
├── send(sock, json_str)
|
||||
│ ├── payload = json_str.encode("utf-8")
|
||||
│ ├── header = struct.pack("!I", len(payload))
|
||||
│ └── sock.sendall(header + payload)
|
||||
│
|
||||
├── recv(sock) ←── on_message(sock, addr, text)
|
||||
│ ├── sock.recv(4) ├── json.loads(text)
|
||||
│ ├── length = struct.unpack("!I", header)[0]
|
||||
│ ├── sock.recv(length) ├── handler(req)
|
||||
│ └── payload.decode("utf-8") └── server.reply(sock, response)
|
||||
│ ├── send(sock, response)
|
||||
├── close(sock) │ └── sock.sendall(header + payload)
|
||||
└── return response ←── recv(sock) 返回 response
|
||||
```
|
||||
|
||||
#### 日志推送
|
||||
|
||||
```
|
||||
各服务 (gateway/static/smtp) db:8005
|
||||
───────────────────────────── ──────────
|
||||
log_client.push(entry)
|
||||
│
|
||||
├── 入内存队列 (ch, 容量 256)
|
||||
│
|
||||
├── 后台 goroutine 批量 flush
|
||||
│ ├── 满 50 条 或 2 秒超时
|
||||
│ └── 逐条 MsgClient.call(json)
|
||||
│
|
||||
└── (同 RPC 流程,fail-safe:失败静默丢弃)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、代码结构
|
||||
|
||||
### 3.1 文件结构
|
||||
|
||||
```
|
||||
pymsg/
|
||||
├── pymsg/
|
||||
│ └── __init__.py # 核心实现(~370 行)
|
||||
├── README.md # 用户文档
|
||||
├── pyproject.toml # 项目配置
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
### 3.2 跨语言实现位置
|
||||
|
||||
| 语言 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| Python | [pymsg/__init__.py](file:///Users/qingsong/micro_servers/pymsg/pymsg/__init__.py) | 主实现 |
|
||||
| C++ | [gateway/inc/Msg.hpp](file:///Users/qingsong/micro_servers/gateway/inc/Msg.hpp) | gateway 使用 |
|
||||
| Go | [waf/internal/logclient/logclient.go](file:///Users/qingsong/micro_servers/waf/internal/logclient/logclient.go) | waf 使用 |
|
||||
| Python (黑盒) | [tests/blackbox/helpers.py](file:///Users/qingsong/micro_servers/tests/blackbox/helpers.py) | 纯 stdlib 测试实现 |
|
||||
|
||||
### 3.3 代码索引
|
||||
|
||||
#### Python 实现(__init__.py)
|
||||
|
||||
| 函数/类 | 行号 | 功能 |
|
||||
|---------|------|------|
|
||||
| `send(sock, data, *, timeout=None)` | L48 | 底层发送 |
|
||||
| `recv(sock, *, timeout=None)` | L72 | 底层接收 |
|
||||
| `_recv_exact(sock, n)` | L103 | 精确读 n 字节 |
|
||||
| `_timeout(sock, seconds)` | L34 | 超时 context manager |
|
||||
| `TimeoutError` | L30 | 超时异常 |
|
||||
| `MsgClient` | L120 | TCP 客户端 |
|
||||
| `MsgClient.__init__` | L136 | 构造器 |
|
||||
| `MsgClient.connect()` | L144 | 建立长连接 |
|
||||
| `MsgClient.send(msg)` | L150 | 短连接发送(带 name 前缀) |
|
||||
| `MsgClient.call(msg, *, timeout=None)` | L163 | 短连接 RPC |
|
||||
| `MsgClient.listen(on_message)` | L193 | 长连接监听 |
|
||||
| `MsgClient.stop()` | L220 | 停止监听 |
|
||||
| `MsgServer` | L234 | TCP 服务端 |
|
||||
| `MsgServer.start(on_message)` | L259 | 启动服务 |
|
||||
| `MsgServer.broadcast(msg, except_sock)` | L296 | 广播 |
|
||||
| `MsgServer.reply(sock, msg)` | L321 | 回复 |
|
||||
| `MsgServer.stop()` | L329 | 停止服务 |
|
||||
|
||||
#### C++ 实现(Msg.hpp)
|
||||
|
||||
| 函数/类 | 行号 | 功能 |
|
||||
|---------|------|------|
|
||||
| `MsgException` | L47 | 基础异常 |
|
||||
| `MsgTimeoutException` | L52 | 超时异常 |
|
||||
| `MsgConnectionException` | L57 | 连接异常 |
|
||||
| `writeUint32BE(buf, val)` | L72 | 4 字节大端编码 |
|
||||
| `readUint32BE(buf)` | L79 | 4 字节大端解码 |
|
||||
| `setSockTimeout(sockfd, seconds)` | L88 | 设置 socket 超时 |
|
||||
| `recvExact(sockfd, buf, n)` | L99 | 精确读 n 字节 |
|
||||
| `sendAll(sockfd, data, len)` | L121 | 完整发送 |
|
||||
| `openConnection(addr, port, timeout)` | L139 | 打开短连接 |
|
||||
| `pmsg_send(sockfd, data)` | L174 | 协议发送 |
|
||||
| `pmsg_recv(sockfd)` | L188 | 协议接收 |
|
||||
| `MsgClient` | L210 | C++ 客户端 |
|
||||
| `MsgClient.connect()` | L220 | 长连接 |
|
||||
| `MsgClient.send(msg)` | L228 | 短连接发送 |
|
||||
| `MsgClient.call(msg, timeout)` | L248 | 短连接 RPC |
|
||||
| `MsgClient.listen(on_message)` | L269 | 长连接监听 |
|
||||
| `MsgClient.stop()` | L311 | 停止 |
|
||||
|
||||
#### Go 实现(logclient.go)
|
||||
|
||||
| 函数 | 行号 | 功能 |
|
||||
|------|------|------|
|
||||
| `pymsgCall(payload)` | L226 | 发送并接收一条 pymsg 消息 |
|
||||
| `Client.sendOne(entry, ts)` | L179 | 发送单条日志 |
|
||||
| `Client.send(batch)` | L171 | 批量发送 |
|
||||
| `Client.flushLoop()` | L125 | 后台 flush goroutine |
|
||||
| `Push(service, level, message, extra)` | L82 | 全局推送 |
|
||||
|
||||
---
|
||||
|
||||
## 四、API 参考
|
||||
|
||||
### 4.1 底层函数
|
||||
|
||||
#### send(sock, data, *, timeout=None)
|
||||
|
||||
```python
|
||||
def send(sock: socket.socket, data: str, *, timeout: float | None = None) -> None
|
||||
```
|
||||
|
||||
**功能**:在已连接的 socket 上发送一条长度前缀帧消息
|
||||
- 将字符串编码为 UTF-8
|
||||
- 前面加上 4 字节大端长度头
|
||||
- 调用 `sock.sendall()` 发送
|
||||
|
||||
**参数**:
|
||||
- `sock`:已连接的 socket 对象
|
||||
- `data`:要发送的字符串
|
||||
- `timeout`:可选超时(秒),超时抛出 `TimeoutError`
|
||||
|
||||
#### recv(sock, *, timeout=None)
|
||||
|
||||
```python
|
||||
def recv(sock: socket.socket, *, timeout: float | None = None) -> str
|
||||
```
|
||||
|
||||
**功能**:从已连接的 socket 接收一条长度前缀帧消息
|
||||
- 读取 4 字节头解出长度
|
||||
- 读取指定字节数
|
||||
- 返回解码后的 UTF-8 字符串
|
||||
|
||||
**参数**:
|
||||
- `sock`:已连接的 socket 对象
|
||||
- `timeout`:可选超时(秒)
|
||||
|
||||
**返回值**:解码后的字符串
|
||||
|
||||
**异常**:
|
||||
- `ConnectionError`:连接在完整消息接收前断开
|
||||
- `TimeoutError`:操作超时
|
||||
|
||||
#### TimeoutError
|
||||
|
||||
```python
|
||||
class TimeoutError(Exception)
|
||||
```
|
||||
|
||||
`send` 或 `recv` 超过指定超时时抛出的异常。
|
||||
|
||||
---
|
||||
|
||||
### 4.2 MsgClient(客户端)
|
||||
|
||||
#### 构造
|
||||
|
||||
```python
|
||||
client = MsgClient(addr: str, port: int, name: str)
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `addr`:服务端地址或 IP
|
||||
- `port`:服务端端口
|
||||
- `name`:客户端标识(`send()` 模式下以 `"name: message"` 格式发送)
|
||||
|
||||
#### 方法
|
||||
|
||||
| 方法 | 模式 | 说明 |
|
||||
|------|------|------|
|
||||
| `connect()` | 长连接 | 创建持久 TCP 连接(供 `listen()` 使用) |
|
||||
| `send(msg)` | 短连接 | 发送即忘,消息带 `name: msg` 前缀 |
|
||||
| `call(msg, timeout=10.0)` | 短连接 | RPC 模式,阻塞等待回复 |
|
||||
| `listen(on_message)` | 长连接 | 阻塞当前线程,持续接收消息 |
|
||||
| `stop()` | — | 中断 `listen()`,可从另一线程调用 |
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```python
|
||||
from pymsg import MsgClient
|
||||
|
||||
# RPC 调用(短连接)
|
||||
client = MsgClient("127.0.0.1", 8005, "my-service")
|
||||
resp = client.call('{"_route":"execute","action":"select","table":"Users"}', timeout=10.0)
|
||||
print(resp) # {"success": true, "data": [...]}
|
||||
|
||||
# 发送即忘
|
||||
client.send("hello")
|
||||
|
||||
# 长连接监听
|
||||
def on_message(text):
|
||||
print(f"收到: {text}")
|
||||
|
||||
client.connect()
|
||||
client.listen(on_message) # 阻塞,直到 stop()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 MsgServer(服务端)
|
||||
|
||||
#### 构造
|
||||
|
||||
```python
|
||||
server = MsgServer(addr: str, port: int)
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `addr`:监听地址(`0.0.0.0` 表示所有网卡)
|
||||
- `port`:监听端口
|
||||
|
||||
#### 方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `start(on_message)` | 启动服务器(阻塞),回调签名:`on_message(sock, addr, text)` |
|
||||
| `reply(sock, msg)` | 向指定客户端回复 |
|
||||
| `broadcast(msg, except_sock=None)` | 广播到所有客户端(可排除某个 socket) |
|
||||
| `stop()` | 停止服务(可从另一线程调用) |
|
||||
|
||||
#### 回调签名
|
||||
|
||||
```python
|
||||
def on_message(sock: socket.socket, addr: tuple, text: str):
|
||||
"""
|
||||
Args:
|
||||
sock: 发送消息的客户端套接字(用于 reply)
|
||||
addr: (ip, port) 元组
|
||||
text: 解码后的字符串消息
|
||||
"""
|
||||
server.reply(sock, "ack")
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```python
|
||||
from pymsg import MsgServer
|
||||
|
||||
def on_message(sock, addr, text):
|
||||
print(f"[{addr}] {text}")
|
||||
server.reply(sock, "ack")
|
||||
|
||||
server = MsgServer("0.0.0.0", 8005)
|
||||
server.start(on_message) # 阻塞
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 异常速查
|
||||
|
||||
| 异常 | 触发条件 |
|
||||
|------|----------|
|
||||
| `pymsg.TimeoutError` | `send()` 或 `recv()` 超过 `timeout` |
|
||||
| `ConnectionError`(内置) | 完整消息接收前 socket 断开 |
|
||||
| `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 |
|
||||
| `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 |
|
||||
|
||||
---
|
||||
|
||||
## 五、使用场景
|
||||
|
||||
### 5.1 场景一:数据库服务(db:8005)
|
||||
|
||||
db 服务通过 pymsg 暴露通用 CRUD 接口:
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgServer
|
||||
|
||||
class DBServer:
|
||||
def __init__(self):
|
||||
self._server = MsgServer("127.0.0.1", 8005)
|
||||
self._routes = {"execute": self._handle_execute}
|
||||
|
||||
def start(self):
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def _dispatch(self, sock, addr, text):
|
||||
req = json.loads(text)
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": False, "error": "未知路由"}))
|
||||
return
|
||||
try:
|
||||
result = handler(req)
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": True, "data": result}))
|
||||
except Exception as e:
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": False, "error": str(e)}))
|
||||
|
||||
def _handle_execute(self, data):
|
||||
# 通过 SQLAlchemy 执行 SQL,返回 dict 列表
|
||||
pass
|
||||
```
|
||||
|
||||
### 5.2 场景二:RPC 调用(dnfs → db)
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
|
||||
def _call_dbv2(payload):
|
||||
client = MsgClient("127.0.0.1", 8005, "dnfs")
|
||||
resp = client.call(json.dumps(payload), timeout=10.0)
|
||||
return json.loads(resp)
|
||||
|
||||
# 创建笔记
|
||||
result = _call_dbv2({
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Notes",
|
||||
"values": {"title": "会议记录", "content": "..."}
|
||||
})
|
||||
```
|
||||
|
||||
### 5.3 场景三:异步日志推送(log_client)
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
|
||||
def _send(batch):
|
||||
for entry in batch:
|
||||
client = MsgClient("127.0.0.1", 8005, "gateway-log")
|
||||
client.call(json.dumps({
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry["service"],
|
||||
"level": entry["level"],
|
||||
"message": entry["message"],
|
||||
}
|
||||
}), timeout=5)
|
||||
```
|
||||
|
||||
### 5.4 场景四:日志推送(Go 实现)
|
||||
|
||||
```go
|
||||
// waf/internal/logclient/logclient.go
|
||||
func (c *Client) sendOne(entry Entry, ts string) {
|
||||
req := dbV2Request{
|
||||
Route: "execute",
|
||||
Action: "insert",
|
||||
Table: "Logs",
|
||||
Values: values,
|
||||
}
|
||||
data, _ := json.Marshal(req)
|
||||
resp, err := pymsgCall(data) // 短连接 RPC
|
||||
if err != nil {
|
||||
return // fail-safe
|
||||
}
|
||||
}
|
||||
|
||||
func pymsgCall(payload []byte) ([]byte, error) {
|
||||
conn, err := net.DialTimeout("tcp", "127.0.0.1:8005", 5*time.Second)
|
||||
// ... 写 4 字节头 + 载荷,读回复
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、跨语言实现对照
|
||||
|
||||
### 6.1 协议层
|
||||
|
||||
| 功能 | Python | C++ | Go |
|
||||
|------|--------|-----|-----|
|
||||
| 4 字节编码 | `struct.Struct("!I")` | `writeUint32BE` (手动位移) | `binary.BigEndian.PutUint32` |
|
||||
| 4 字节解码 | `struct.unpack("!I", header)` | `readUint32BE` (手动位移) | `binary.BigEndian.Uint32` |
|
||||
| 超时设置 | `_timeout` context manager | `setSockTimeout` (setsockopt) | `net.DialTimeout` + `SetDeadline` |
|
||||
| 安全上限 | 无 | 64 MiB | 无 |
|
||||
|
||||
### 6.2 客户端对照
|
||||
|
||||
| 操作 | Python | C++ | Go |
|
||||
|------|--------|-----|-----|
|
||||
| 构造 | `MsgClient(addr, port, name)` | `MsgClient(addr, port, name)` | — |
|
||||
| 长连接 | `connect()` | `connect()` | — |
|
||||
| 发送即忘 | `send(msg)` 短连接 + `name:` 前缀 | `send(msg)` 短连接 + `name:` 前缀 | — |
|
||||
| RPC 调用 | `call(msg, timeout)` 短连接 | `call(msg, timeout)` 短连接 | `pymsgCall(payload)` 短连接 |
|
||||
| 长监听 | `listen(on_message)` | `listen(on_message)` | — |
|
||||
| 停止 | `stop()` | `stop()` | — |
|
||||
|
||||
### 6.3 服务端对照
|
||||
|
||||
| 操作 | Python | C++ | Go |
|
||||
|------|--------|-----|-----|
|
||||
| 启动 | `start(on_message)` | — | — |
|
||||
| 回复 | `reply(sock, msg)` | `pmsg_send(fd, msg)` | — |
|
||||
| 广播 | `broadcast(msg, except_sock)` | — | — |
|
||||
| 停止 | `stop()` | — | — |
|
||||
|
||||
### 6.4 从头实现协议
|
||||
|
||||
仅需四步:
|
||||
|
||||
```
|
||||
1. 将字符串编码为 UTF-8 字节
|
||||
2. 将字节长度打包为 4 字节大端无符号整数
|
||||
3. 写入 头 + 载荷 到 socket
|
||||
4. 接收端先读 4 字节解出长度,再读对应字节数,解码为 UTF-8
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、线程安全
|
||||
|
||||
| 场景 | 安全性 | 说明 |
|
||||
|------|--------|------|
|
||||
| `MsgServer.stop()` 跨线程调用 | ✅ 安全 | 设置原子标志并关闭监听 socket |
|
||||
| `MsgServer.reply()` 在回调内调用 | ✅ 安全 | 同一线程内调用 |
|
||||
| `MsgServer.broadcast()` 在回调内调用 | ✅ 安全 | 同一线程内调用 |
|
||||
| `MsgClient.stop()` 跨线程调用 | ✅ 安全 | 可中断 `listen()` |
|
||||
| `send()`/`recv()` 多线程共享 socket | ⚠️ 不安全 | 调用方需自行串行化 |
|
||||
| `MsgServer` 并发调用 broadcast/reply | ⚠️ 不支持 | 仅在 `on_message` 回调内(同一线程)安全 |
|
||||
|
||||
---
|
||||
|
||||
## 八、性能特性
|
||||
|
||||
### 8.1 性能特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 零拷贝 | 长度前缀帧避免数据复制 |
|
||||
| 短连接复用 | `call()` 每次新建连接,但调用完成立即释放 |
|
||||
| fail-safe | 日志推送失败静默丢弃,不阻塞业务 |
|
||||
| 批量缓冲 | 日志推送:50 条或 2 秒自动 flush |
|
||||
|
||||
### 8.2 连接模型
|
||||
|
||||
| 模式 | 连接数 | 延迟 | 适用场景 |
|
||||
|------|--------|------|---------|
|
||||
| `send()` | 每次 1 个 | 中 | 低频通知 |
|
||||
| `call()` | 每次 1 个 | 中 | RPC 调用 |
|
||||
| `listen()` | 持久 1 个 | 低 | 高频消息流 |
|
||||
|
||||
---
|
||||
|
||||
## 九、已知限制
|
||||
|
||||
| ID | 描述 | 严重等级 | 影响范围 |
|
||||
|----|------|---------|---------|
|
||||
| L1 | **无消息校验**:协议无 CRC/哈希,传输损坏无法检测 | 🟡 Medium | 所有消息 |
|
||||
| L2 | **无认证**:任何人可连接 8005 端口,无鉴权机制 | 🔴 High | db 服务 |
|
||||
| L3 | **无加密**:明文传输,无 TLS/加密保护 | 🔴 High | 所有服务 |
|
||||
| L4 | **Python 版无安全上限**:C++ 版有 64 MiB 上限,Python 版未检查 | 🟡 Medium | Python 客户端 |
|
||||
| L5 | **无重试机制**:连接失败直接抛异常,无自动重试 | 🟢 Low | 所有调用 |
|
||||
| L6 | **无版本号**:协议升级无兼容路径 | 🟢 Low | 长期维护 |
|
||||
| L7 | **无心跳**:长连接无 keep-alive,可能被防火墙切断 | 🟡 Medium | listen 模式 |
|
||||
| L8 | **全局阻塞**:`MsgServer.start()` 阻塞主线程,需另开线程 | 🟢 Low | 服务端 |
|
||||
|
||||
### 建议改进
|
||||
|
||||
1. **L2/L3**:在防火墙层面限制 8005 端口仅允许本机连接(当前部署已这样做)
|
||||
2. **L4**:在 Python `recv()` 中添加消息大小上限检查
|
||||
3. **L7**:在 `listen()` 循环中添加 ping/pong 机制
|
||||
4. **L5**:在 `call()` 中添加简单重试(1-2 次)
|
||||
|
||||
---
|
||||
|
||||
## 十、设计考量
|
||||
|
||||
### 10.1 为什么用长度前缀帧?
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| **长度前缀帧** ✅ | 二进制安全、实现简单、无需流式解析 | 无 |
|
||||
| 换行分隔 | 简单 | JSON 中换行需转义、缓冲区管理复杂 |
|
||||
| HTTP | 成熟、有工具 | 头部开销大、需额外依赖 |
|
||||
| Protobuf | 高效、类型安全 | 需额外依赖、跨语言实现复杂 |
|
||||
|
||||
### 10.2 为什么用短连接?
|
||||
|
||||
| 考虑 | 说明 |
|
||||
|------|------|
|
||||
| 简单性 | 短连接实现最简洁,无连接管理开销 |
|
||||
| 可靠性 | 每次调用都是独立连接,单个请求失败不影响其他 |
|
||||
| 性能 | 本地回环(`127.0.0.1`)下 TCP 连接建立开销很小 |
|
||||
| 可测试性 | 易于模拟各种网络故障 |
|
||||
|
||||
### 10.3 协议设计取舍
|
||||
|
||||
| 取舍 | 选择 | 原因 |
|
||||
|------|------|------|
|
||||
| 头大小 | 4 字节 | 支持最大 4 GiB 消息,足够使用 |
|
||||
| 字节序 | 大端 | 网络标准序,跨语言一致 |
|
||||
| 编码 | UTF-8 | 支持中文/多语言,与 JSON 兼容 |
|
||||
| 版本号 | 无 | 保持最简,升级时可扩展 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、开发指南
|
||||
|
||||
### 11.1 安装
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
cd pymsg
|
||||
pip install -e .
|
||||
|
||||
# 作为本地依赖
|
||||
# 在其他项目的 requirements.txt 中:
|
||||
# ../pymsg
|
||||
```
|
||||
|
||||
### 11.2 添加新功能
|
||||
|
||||
#### 添加消息校验(CRC)
|
||||
|
||||
```python
|
||||
import zlib
|
||||
|
||||
def send_with_crc(sock, data):
|
||||
payload = data.encode("utf-8")
|
||||
crc = zlib.crc32(payload) & 0xFFFFFFFF
|
||||
# [4字节长度][4字节CRC][UTF-8载荷]
|
||||
header = _HEADER.pack(len(payload) + 4)
|
||||
crc_bytes = struct.pack("!I", crc)
|
||||
sock.sendall(header + crc_bytes + payload)
|
||||
```
|
||||
|
||||
#### 添加心跳机制
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
def listen_with_heartbeat(self, on_message, interval=30):
|
||||
last_recv = time.time()
|
||||
while self._running:
|
||||
# 发送心跳
|
||||
if time.time() - last_recv > interval:
|
||||
send(self._sock, "PING")
|
||||
# 接收消息
|
||||
text = recv(self._sock, timeout=1.0)
|
||||
if text == "PONG":
|
||||
continue
|
||||
last_recv = time.time()
|
||||
on_message(text)
|
||||
```
|
||||
|
||||
### 11.3 扩展协议版本
|
||||
|
||||
```python
|
||||
# 在头中添加版本号
|
||||
_VERSION = 1 # 1 字节版本号
|
||||
|
||||
def send_v2(sock, data):
|
||||
payload = data.encode("utf-8")
|
||||
header = struct.pack("!BI", _VERSION, len(payload))
|
||||
sock.sendall(header + payload)
|
||||
|
||||
def recv_v2(sock):
|
||||
header = _recv_exact(sock, 5)
|
||||
version, length = struct.unpack("!BI", header)
|
||||
if version != _VERSION:
|
||||
raise ValueError(f"Unsupported protocol version: {version}")
|
||||
payload = _recv_exact(sock, length)
|
||||
return payload.decode("utf-8")
|
||||
```
|
||||
|
||||
### 11.4 构建与测试
|
||||
|
||||
```bash
|
||||
# Python 测试
|
||||
cd pymsg
|
||||
python -c "
|
||||
from pymsg import MsgClient, MsgServer, send, recv
|
||||
# ... 单元测试
|
||||
"
|
||||
|
||||
# 黑盒测试
|
||||
cd tests/blackbox
|
||||
python test_runner.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、版本历史
|
||||
|
||||
| 版本 | 日期 | 主要变更 |
|
||||
|------|------|---------|
|
||||
| 0.1.0 | 当前 | 初始版本:核心协议、MsgClient、MsgServer、跨语言实现 |
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 协议规范(RFC 风格)
|
||||
|
||||
```
|
||||
Protocol: pymsg v0
|
||||
|
||||
Frame Format:
|
||||
┌──────────┬──────────┬──────────────────┐
|
||||
│ 4 bytes │ N bytes │ │
|
||||
├──────────┼──────────┼──────────────────┤
|
||||
│ uint32 │ UTF-8 │ │
|
||||
│ big-end │ string │ │
|
||||
│ payload │ payload │ │
|
||||
│ length │ │ │
|
||||
└──────────┴──────────┴──────────────────┘
|
||||
|
||||
Encoding: UTF-8
|
||||
Byte Order: Big-Endian (network byte order)
|
||||
Max Message Size: 2^32 - 1 bytes (theoretical)
|
||||
Timeout: Optional, specified per operation
|
||||
|
||||
Modes:
|
||||
- Short Connection (send/call): connect → send → recv → close
|
||||
- Long Connection (listen): connect → loop(recv → callback)
|
||||
```
|
||||
|
||||
### B. 错误码映射
|
||||
|
||||
| 异常 | 来源 | 场景 |
|
||||
|------|------|------|
|
||||
| `TimeoutError` | pymsg | 操作超过指定超时 |
|
||||
| `ConnectionError` | Python | 连接在接收前断开 |
|
||||
| `ConnectionRefusedError` | Python | 目标服务不可达 |
|
||||
| `RuntimeError` | Python | 在 `connect()` 前调用 `listen()` |
|
||||
| `MsgTimeoutException` | C++ | C++ 版超时 |
|
||||
| `MsgConnectionException` | C++ | C++ 版连接异常 |
|
||||
|
||||
### C. 参考实现清单
|
||||
|
||||
| 语言 | 文件 | 类型 |
|
||||
|------|------|------|
|
||||
| Python | [pymsg/__init__.py](file:///Users/qingsong/micro_servers/pymsg/pymsg/__init__.py) | 主实现 |
|
||||
| Python | [tests/blackbox/helpers.py](file:///Users/qingsong/micro_servers/tests/blackbox/helpers.py) | 测试客户端 |
|
||||
| C++ | [gateway/inc/Msg.hpp](file:///Users/qingsong/micro_servers/gateway/inc/Msg.hpp) | gateway 客户端 |
|
||||
| Go | [waf/internal/logclient/logclient.go](file:///Users/qingsong/micro_servers/waf/internal/logclient/logclient.go) | waf 客户端 |
|
||||
|
||||
### D. 参考资源
|
||||
|
||||
- [Beej's Guide to Network Programming](https://beej.us/guide/bgnet/) — Socket 编程经典
|
||||
- [The SO_REUSEADDR socket option](https://man7.org/linux/man-pages/man2/socket.2.html)
|
||||
- [Python socket module](https://docs.python.org/3/library/socket.html)
|
||||
|
||||
---
|
||||
|
||||
*本文档基于 pymsg 源码分析生成,供开发团队参考。*
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# smtp — 邮箱验证码服务
|
||||
|
||||
基于自研 [pymsg](../pymsg/) 协议(TCP 长度前缀帧 + JSON)的邮箱验证码服务,通过 SMTP_SSL 发送 6 位数字验证码,支持发送冷却和防暴力枚举。
|
||||
|
||||
服务由 `core`(PMC)的 `init.txt` 拉起,监听 `127.0.0.1:8008`(TCP),仅允许本机调用。`gateway` 在 `/register` 流程中经 pymsg 调用本服务完成邮箱验证。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
gateway :8443 (注册流程)
|
||||
│ pymsg TCP
|
||||
▼
|
||||
smtp :8008 (MsgServer)
|
||||
│ SMTP_SSL
|
||||
▼
|
||||
外部 SMTP 服务器 (163/QQ 等)
|
||||
│
|
||||
▼
|
||||
用户邮箱(收验证码)
|
||||
```
|
||||
|
||||
## 路由
|
||||
|
||||
服务基于 pymsg 协议,每条消息为完整 JSON,`_route` 字段指定路由。
|
||||
|
||||
| _route | 说明 |
|
||||
|--------|------|
|
||||
| `send_email` | 生成 6 位数字验证码,通过 SMTP_SSL 发送至指定邮箱 |
|
||||
| `verify_email` | 校验用户输入的验证码,一次性消费 |
|
||||
| `health` | 健康检查 |
|
||||
|
||||
响应格式:
|
||||
|
||||
```json
|
||||
{"success": true, "data": {...}}
|
||||
{"success": false, "error": "..."}
|
||||
```
|
||||
|
||||
### 1. send_email — 发送验证码
|
||||
|
||||
**请求**:
|
||||
|
||||
```json
|
||||
{"_route": "send_email", "email": "user@example.com"}
|
||||
```
|
||||
|
||||
可选字段 `subject` / `content` 覆盖默认标题与正文,`content` 中 `{code}` 会被替换为验证码。
|
||||
|
||||
**响应**:
|
||||
|
||||
| 响应 data | 触发条件 |
|
||||
|-----------|----------|
|
||||
| `{"sent": true, "ttl": 600}` | 发送成功,TTL 为有效期(秒) |
|
||||
| `{"sent": false, "error": "invalid email"}` | 邮箱格式无效 |
|
||||
| `{"sent": false, "error": "SMTP 服务未配置"}` | .env 缺少 SMTP 配置 |
|
||||
| `{"sent": false, "retry_after": N, "error": "发送过于频繁,请 N 秒后重试"}` | 同一邮箱在冷却期内 |
|
||||
|
||||
**安全机制**:
|
||||
- 发送冷却:同一邮箱两次成功发送的最小间隔 `SEND_INTERVAL`(60 秒)。冷却期内请求直接拒绝,不覆盖已有验证码。
|
||||
- 发送失败回滚:SMTP 发送失败时立即删除刚写入的验证码,避免"码已存但邮件没到"的不可用状态。
|
||||
|
||||
### 2. verify_email — 校验验证码
|
||||
|
||||
**请求**:
|
||||
|
||||
```json
|
||||
{"_route": "verify_email", "email": "user@example.com", "code": "123456"}
|
||||
```
|
||||
|
||||
兼容旧字段名 `input_captcha`。
|
||||
|
||||
**响应**(均为 `success: true`,业务结果在 `data` 中区分):
|
||||
|
||||
| 响应 data | 触发条件 |
|
||||
|-----------|----------|
|
||||
| `{"valid": true}` | 验证码正确(已消费) |
|
||||
| `{"valid": false, "error": "email and code required"}` | 缺少字段 |
|
||||
| `{"valid": false, "error": "invalid or expired code"}` | 验证码错误、过期或不存在 |
|
||||
| `{"valid": false, "invalidated": true, "error": "验证码连续错误 5 次,已失效,请重新获取"}` | 连续校验失败达上限 |
|
||||
|
||||
> 验证码一经正确校验即从内存中删除(一次性消费),防重放。同一验证码连续校验失败达 `MAX_VERIFY_ATTEMPTS`(5 次)即作废,防止对 6 位数字码的在线枚举。
|
||||
|
||||
### 3. health — 健康检查
|
||||
|
||||
**请求**:`{"_route": "health"}`
|
||||
**响应**:`{"success": true, "data": {"status": "ok", "service": "smtp"}}`
|
||||
|
||||
## 配置
|
||||
|
||||
通过 `.env` 文件配置 SMTP 连接信息,支持 4 种格式:
|
||||
|
||||
```bash
|
||||
# KEY=VALUE(推荐)
|
||||
SMTP_SERVER=smtp.163.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=your@163.com
|
||||
__PASSWORD=your_smtp_password
|
||||
|
||||
# 也支持 KEY: VALUE / KEY VALUE / export KEY=...
|
||||
MAIL_FROM=dnfs@qsont.me # 可选,发件人身份;默认跟随 SMTP_USER
|
||||
```
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `SMTP_SERVER` | 是 | SMTP 服务器地址 |
|
||||
| `SMTP_PORT` | 否 | SMTP 端口,默认 465 |
|
||||
| `SMTP_USER` | 是 | 邮箱登录账号 |
|
||||
| `__PASSWORD` | 是 | 邮箱密码/授权码(兼容别名 `SMTP_PASSWORD`) |
|
||||
| `MAIL_FROM` | 否 | 发件人身份,默认同 SMTP_USER。多数服务商强制 From 头与登录账号一致 |
|
||||
|
||||
服务启动时检查必填配置项,缺失时打印警告日志但不阻止启动(`send_email` 路由会返回配置错误)。
|
||||
|
||||
## 数据模型与验证码生命周期
|
||||
|
||||
- **存储**:进程内存字典 `code_store`,键为 email,值为 `{code, expires_at, attempts}`。
|
||||
- **冷却记录**:`send_history` 字典,键为 email,值为上次成功发送的时间戳。
|
||||
- **生成**:`send_email` 成功时写入 `code_store`,记录 `expires_at = now + CODE_TTL`(600 秒)。
|
||||
- **消费**:`verify_email` 校验通过时 `del code_store[email]`,不可再次使用。
|
||||
- **过期**:超时后 `verify_email` 自动删除并返回 `invalid or expired code`。`send_email` 时惰性清理过期项。
|
||||
- **作废**:连续校验失败达 5 次后删除验证码,返回 `invalidated: true`。
|
||||
- **发送失败回滚**:SMTP 异常时立即 `pop` 刚写入的 `code_store` 条目。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 初始化(创建 venv + 安装依赖)
|
||||
bash _set.sh
|
||||
|
||||
# 启动
|
||||
bash _run.sh
|
||||
|
||||
# 或直接运行
|
||||
python3 smtp_v2.py --addr 127.0.0.1 --port 8008
|
||||
```
|
||||
|
||||
## 集成关系
|
||||
|
||||
- 由 `core` 通过 `init.txt` 拉起的 `bash _run.sh` 守护运行。
|
||||
- `gateway` 在 `/register` 时经 pymsg 调 `send_email` 发送验证码,调 `verify_email` 校验。需在 gateway 的 `example.ini` 中启用邮箱验证功能。
|
||||
- 日志通过 `log_client.py` 异步批量推送至 db:8005 的 Logs 表(缓冲 50 条 / 2 秒)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
smtp/
|
||||
├── smtp_v2.py # 服务主程序(pymsg 协议,当前有效版本)
|
||||
├── smtp.py # 旧版 Flask HTTP 实现(已废弃,仅作参考)
|
||||
├── log_client.py # 集中日志推送 SDK
|
||||
├── _set.sh # 初始化脚本(创建 venv + 安装依赖)
|
||||
├── _run.sh # 启动脚本(exec python3 smtp_v2.py)
|
||||
├── requirements.txt # Python 依赖(pymsg、nuitka)
|
||||
└── .gitignore # 忽略 __pycache__ / .env / venv
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 验证码存储在进程内存中,服务重启后全部失效;不支持多实例水平扩展。
|
||||
- 默认仅监听 `127.0.0.1`,仅供本机调用;浏览器侧不直接访问本服务。
|
||||
- 旧版 `smtp.py`(Flask HTTP 实现)已废弃,`_run.sh` 当前启动的是 `smtp_v2.py`。旧版存在无冷却、无失败回滚、无防枚举等安全问题,且 `verify_email` 在 email 不存在时有 KeyError 风险,不应在生产环境使用。
|
||||
- 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,`MAIL_FROM` 仅当服务商允许自定义发件身份时才起作用。
|
||||
- 验证码为纯 6 位数字,有效期 600 秒。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
+2
-1
@@ -2,4 +2,5 @@
|
||||
cd "$(dirname "$0")" || exit
|
||||
source ./venv/bin/activate
|
||||
#exec ./smtp.bin
|
||||
exec python3 smtp.py
|
||||
sleep 2
|
||||
exec python3 smtp_v2.py
|
||||
+1
-1
@@ -4,4 +4,4 @@ python3 -m venv venv
|
||||
#sudo chmod +x .env/bin/activate
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
#nuitka --standalone smtp.py
|
||||
#nuitka --standalone smtp_v2.py
|
||||
+66
-227
@@ -1,245 +1,84 @@
|
||||
"""
|
||||
smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。
|
||||
#!/usr/bin/env python3
|
||||
|
||||
替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
|
||||
协议:每条消息为完整 JSON,``_route`` 字段指定路由名。
|
||||
|
||||
请求示例::
|
||||
|
||||
{"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码
|
||||
{"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费)
|
||||
{"_route": "health"} → 健康检查
|
||||
|
||||
响应格式(与 dbv2 / captcha 一致)::
|
||||
|
||||
{"success": true, "data": {...}}
|
||||
{"success": false, "error": "..."}
|
||||
|
||||
SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
|
||||
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
import threading
|
||||
import time
|
||||
from flask import Flask, request, jsonify
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
# 手动加载 .env(支持 KEY=VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号)
|
||||
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
||||
_env_loaded = 0
|
||||
if os.path.isfile(_env_path):
|
||||
with open(_env_path, "r") as _f:
|
||||
for _line in _f:
|
||||
_line = _line.strip()
|
||||
if not _line or _line.startswith("#"):
|
||||
continue
|
||||
if _line.startswith("export "):
|
||||
_line = _line[len("export "):].strip()
|
||||
# 支持 KEY=VALUE 与 KEY VALUE 两种写法
|
||||
if "=" in _line:
|
||||
_key, _, _val = _line.partition("=")
|
||||
else:
|
||||
_parts = _line.split(None, 1)
|
||||
_key = _parts[0]
|
||||
_val = _parts[1] if len(_parts) > 1 else ""
|
||||
_key = _key.strip()
|
||||
_val = _val.strip().strip("\"'")
|
||||
if _key and _key not in os.environ:
|
||||
os.environ[_key] = _val
|
||||
_env_loaded += 1
|
||||
else:
|
||||
print(f"[smtp] 警告: 未找到 .env 文件: {_env_path}")
|
||||
|
||||
from email import header
|
||||
from log_client import logs
|
||||
from dotenv import load_dotenv
|
||||
import waitress
|
||||
import smtplib
|
||||
import random
|
||||
import time
|
||||
import sys, os
|
||||
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
||||
app = Flask(__name__)
|
||||
captchas: dict[str, list[str, str]] = {}
|
||||
|
||||
SMTP_SERVER = os.environ.get("SMTP_SERVER", "")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
|
||||
SMTP_USER = os.environ.get("SMTP_USER", "")
|
||||
# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名
|
||||
SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "")
|
||||
@app.route("/send_email", methods=["POST"])
|
||||
|
||||
CODE_LEN = 6
|
||||
CODE_TTL = 60 # 验证码有效期(秒)
|
||||
MAIL_FROM = "dnfs@qsont.me"
|
||||
DEFAULT_SUBJECT = "DNFS 邮箱验证"
|
||||
DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。"
|
||||
def send_email():
|
||||
def build_captcha() -> str:
|
||||
captcha: str = ""
|
||||
for i in range(0,6):
|
||||
digit: int = random.randint(0,9)
|
||||
captcha += str(digit)
|
||||
return captcha
|
||||
|
||||
code_store = {} # email -> {'code': str, 'expires_at': float}
|
||||
STORE_LOCK = threading.Lock()
|
||||
data = request.get_json()
|
||||
|
||||
recipient: str = data.get("email")
|
||||
subject: str = "DNFS 邮箱验证"
|
||||
captcha: str = build_captcha()
|
||||
content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。"
|
||||
|
||||
captchas[recipient] = ["", ""]
|
||||
captchas[recipient][0] = captcha
|
||||
captchas[recipient][1] = str(int(time.time())+60)
|
||||
|
||||
message = MIMEText(content, "plain", "UTF-8")
|
||||
message["From"] = "dnfs@qsont.me"
|
||||
message["To"] = recipient
|
||||
message["Subject"] = subject
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
|
||||
server.login(SMTP_USER, __PASSWORD)
|
||||
server.sendmail(SMTP_USER, [recipient], message.as_string())
|
||||
logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})")
|
||||
return jsonify({"success": "验证码发送成功"}), 200
|
||||
|
||||
except Exception as err:
|
||||
logs.error("smtp", f"{recipient}: 验证码发送失败({err})")
|
||||
return jsonify({"error": f"验证码发送失败:{err}"}), 400
|
||||
|
||||
finally:
|
||||
server.quit()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SmtpServer — 主服务类
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@app.route("/verify_email", methods=["POST"])
|
||||
|
||||
class SmtpServer:
|
||||
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。"""
|
||||
def verify_email():
|
||||
data = request.get_json()
|
||||
|
||||
def __init__(self, addr="127.0.0.1", port=8008):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._server = MsgServer(addr, port)
|
||||
for i in list(captchas.keys()):
|
||||
if int(captchas[i][1]) < int(time.time()):
|
||||
del captchas[i]
|
||||
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"send_email": self._handle_send_email,
|
||||
"verify_email": self._handle_verify_email,
|
||||
"health": self._handle_health,
|
||||
}
|
||||
email: str = data.get("email")
|
||||
input_captcha: str = data.get("input_captcha")
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
|
||||
# 只报缺失的 key 名,绝不报值
|
||||
missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER),
|
||||
("SMTP_USER", SMTP_USER),
|
||||
("__PASSWORD", SMTP_PASSWORD)) if not v]
|
||||
logs.error("smtp", f"SMTP 配置缺失: {','.join(missing)}"
|
||||
f"(.env 加载 {_env_loaded} 项),send_email 将始终失败")
|
||||
print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)")
|
||||
print(f"[smtp] 监听 tcp://{self.addr}:{self.port}")
|
||||
logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}")
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
logs.error("smtp", f"路由 {route} 处理异常: {e}")
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 路由处理
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_send_email(self, req):
|
||||
"""生成 6 位数字验证码,存入内存(60s),经 SMTP_SSL 发送邮件。
|
||||
|
||||
可选字段 subject / content 覆盖默认标题与正文,content 中 {code}
|
||||
会被替换为验证码。
|
||||
"""
|
||||
email = str(req.get("email", "")).strip()
|
||||
if not email or "@" not in email:
|
||||
return {"sent": False, "error": "invalid email"}
|
||||
|
||||
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
|
||||
logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送")
|
||||
return {"sent": False, "error": "SMTP 服务未配置"}
|
||||
|
||||
code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN))
|
||||
subject = str(req.get("subject") or DEFAULT_SUBJECT)
|
||||
content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code)
|
||||
|
||||
with STORE_LOCK:
|
||||
code_store[email] = {'code': code, 'expires_at': time.time() + CODE_TTL}
|
||||
# 惰性清理过期项
|
||||
now = time.time()
|
||||
expired = [k for k, v in code_store.items() if v['expires_at'] < now]
|
||||
for k in expired:
|
||||
del code_store[k]
|
||||
|
||||
message = MIMEText(content, "plain", "UTF-8")
|
||||
message["From"] = MAIL_FROM
|
||||
message["To"] = email
|
||||
message["Subject"] = subject
|
||||
|
||||
# server 作用域限定在 try 内;连接失败时不会误调 quit()
|
||||
try:
|
||||
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
|
||||
server.login(SMTP_USER, SMTP_PASSWORD)
|
||||
server.sendmail(SMTP_USER, [email], message.as_string())
|
||||
except Exception as err:
|
||||
# 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态
|
||||
with STORE_LOCK:
|
||||
code_store.pop(email, None)
|
||||
logs.error("smtp", f"{email}: 验证码发送失败({err})")
|
||||
return {"sent": False, "error": f"验证码发送失败:{err}"}
|
||||
|
||||
logs.info("smtp", f"{email}: 验证码发送成功({code})")
|
||||
return {"sent": True, "ttl": CODE_TTL}
|
||||
|
||||
def _handle_verify_email(self, req):
|
||||
"""校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。"""
|
||||
email = str(req.get("email", "")).strip()
|
||||
# 兼容旧字段 input_captcha
|
||||
code = str(req.get("code") or req.get("input_captcha") or "").strip()
|
||||
if not email or not code:
|
||||
return {"valid": False, "error": "email and code required"}
|
||||
|
||||
with STORE_LOCK:
|
||||
entry = code_store.get(email)
|
||||
if entry and entry['expires_at'] < time.time():
|
||||
del code_store[email]
|
||||
entry = None
|
||||
if entry and entry['code'] == code:
|
||||
# 验证通过:一次性消费,防重放
|
||||
del code_store[email]
|
||||
valid = True
|
||||
else:
|
||||
valid = False
|
||||
|
||||
if valid:
|
||||
logs.info("smtp", f"{email}: 验证通过")
|
||||
return {"valid": True}
|
||||
if captchas[email][0] == input_captcha:
|
||||
logs.info("smtp", f"{email}: 验证通过")
|
||||
return jsonify({"success": "验证通过"}), 200
|
||||
else:
|
||||
logs.info("smtp", f"{email}: 验证失败")
|
||||
return {"valid": False, "error": "invalid or expired code"}
|
||||
return jsonify({"error": "验证失败"}), 400
|
||||
|
||||
def _handle_health(self, _req):
|
||||
return {"status": "ok", "service": "smtp"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 入口
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务")
|
||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=8008, help="监听端口")
|
||||
args = parser.parse_args()
|
||||
|
||||
server = SmtpServer(args.addr, args.port)
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
logs.info("smtp", "开始监听http://127.0.0.1:8008")
|
||||
print("Listen at http://127.0.0.1:8008")
|
||||
waitress.serve(app, host="127.0.0.1", port=8008)
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。
|
||||
|
||||
替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
|
||||
协议:每条消息为完整 JSON,``_route`` 字段指定路由名。
|
||||
|
||||
请求示例::
|
||||
|
||||
{"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码
|
||||
{"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费)
|
||||
{"_route": "health"} → 健康检查
|
||||
|
||||
响应格式(与 dbv2 / captcha 一致)::
|
||||
|
||||
{"success": true, "data": {...}}
|
||||
{"success": false, "error": "..."}
|
||||
|
||||
SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
|
||||
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
import threading
|
||||
import time
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
# 手动加载 .env(支持 KEY=VALUE / KEY: VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号)
|
||||
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
||||
_env_loaded = 0
|
||||
if os.path.isfile(_env_path):
|
||||
with open(_env_path, "r") as _f:
|
||||
for _line in _f:
|
||||
_line = _line.strip()
|
||||
if not _line or _line.startswith("#"):
|
||||
continue
|
||||
if _line.startswith("export "):
|
||||
_line = _line[len("export "):].strip()
|
||||
# 支持 KEY=VALUE / KEY: VALUE / KEY VALUE 三种写法
|
||||
if "=" in _line:
|
||||
_key, _, _val = _line.partition("=")
|
||||
elif ":" in _line:
|
||||
_key, _, _val = _line.partition(":")
|
||||
else:
|
||||
_parts = _line.split(None, 1)
|
||||
_key = _parts[0]
|
||||
_val = _parts[1] if len(_parts) > 1 else ""
|
||||
_key = _key.strip()
|
||||
_val = _val.strip().strip("\"'")
|
||||
if _key and _key not in os.environ:
|
||||
os.environ[_key] = _val
|
||||
_env_loaded += 1
|
||||
else:
|
||||
print(f"[smtp] 警告: 未找到 .env 文件: {_env_path}")
|
||||
|
||||
from log_client import logs
|
||||
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
SMTP_SERVER = os.environ.get("SMTP_SERVER", "")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
|
||||
SMTP_USER = os.environ.get("SMTP_USER", "")
|
||||
# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名
|
||||
SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "")
|
||||
|
||||
CODE_LEN = 6
|
||||
CODE_TTL = 600 # 验证码有效期(秒)
|
||||
SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒)
|
||||
MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码)
|
||||
# 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER;
|
||||
# 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖
|
||||
MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER
|
||||
DEFAULT_SUBJECT = "DNFS 邮箱验证"
|
||||
# 有效期文案由 CODE_TTL 派生,改 TTL 不用同步改这里({{code}} 是 f-string 转义)
|
||||
DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60} 分钟内有效。"
|
||||
|
||||
code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int}
|
||||
send_history = {} # email -> 上次成功发送的时间戳(发送冷却用)
|
||||
STORE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SmtpServer — 主服务类
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class SmtpServer:
|
||||
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。"""
|
||||
|
||||
def __init__(self, addr="127.0.0.1", port=8008):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._server = MsgServer(addr, port)
|
||||
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"send_email": self._handle_send_email,
|
||||
"verify_email": self._handle_verify_email,
|
||||
"health": self._handle_health,
|
||||
}
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
|
||||
# 只报缺失的 key 名,绝不报值
|
||||
missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER),
|
||||
("SMTP_USER", SMTP_USER),
|
||||
("__PASSWORD", SMTP_PASSWORD)) if not v]
|
||||
logs.error("smtp", f"SMTP 配置缺失: {','.join(missing)}"
|
||||
f"(.env 加载 {_env_loaded} 项),send_email 将始终失败")
|
||||
print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)")
|
||||
print(f"[smtp] 监听 tcp://{self.addr}:{self.port}")
|
||||
logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}")
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
logs.error("smtp", f"路由 {route} 处理异常: {e}")
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 路由处理
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_send_email(self, req):
|
||||
"""生成 6 位数字验证码,存入内存(TTL 600s),经 SMTP_SSL 发送邮件。
|
||||
|
||||
可选字段 subject / content 覆盖默认标题与正文,content 中 {code}
|
||||
会被替换为验证码。同一邮箱 SEND_INTERVAL 秒内仅允许成功发送一次,
|
||||
冷却期内的请求直接拒绝且不覆盖已发出的有效验证码。
|
||||
"""
|
||||
email = str(req.get("email", "")).strip()
|
||||
if not email or "@" not in email:
|
||||
return {"sent": False, "error": "invalid email"}
|
||||
|
||||
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
|
||||
logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送")
|
||||
return {"sent": False, "error": "SMTP 服务未配置"}
|
||||
|
||||
# 发送冷却:同一邮箱 SEND_INTERVAL 秒内仅放行一次;发送失败不计入,
|
||||
# 用户可立即重试
|
||||
with STORE_LOCK:
|
||||
remain = SEND_INTERVAL - (time.time() - send_history.get(email, 0.0))
|
||||
if remain > 0:
|
||||
wait = int(remain) + 1
|
||||
logs.info("smtp", f"{email}: 发送冷却中,剩余 {wait}s,已拒绝")
|
||||
return {"sent": False, "retry_after": wait,
|
||||
"error": f"发送过于频繁,请 {wait} 秒后重试"}
|
||||
|
||||
code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN))
|
||||
subject = str(req.get("subject") or DEFAULT_SUBJECT)
|
||||
content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code)
|
||||
|
||||
with STORE_LOCK:
|
||||
code_store[email] = {'code': code,
|
||||
'expires_at': time.time() + CODE_TTL,
|
||||
'attempts': 0}
|
||||
# 惰性清理过期项
|
||||
now = time.time()
|
||||
expired = [k for k, v in code_store.items() if v['expires_at'] < now]
|
||||
for k in expired:
|
||||
del code_store[k]
|
||||
|
||||
message = MIMEText(content, "plain", "UTF-8")
|
||||
message["From"] = MAIL_FROM
|
||||
message["To"] = email
|
||||
message["Subject"] = subject
|
||||
|
||||
# server 作用域限定在 try 内;连接失败时不会误调 quit()
|
||||
try:
|
||||
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
|
||||
server.login(SMTP_USER, SMTP_PASSWORD)
|
||||
server.sendmail(SMTP_USER, [email], message.as_string())
|
||||
except Exception as err:
|
||||
# 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态
|
||||
with STORE_LOCK:
|
||||
code_store.pop(email, None)
|
||||
logs.error("smtp", f"{email}: 验证码发送失败({err})")
|
||||
return {"sent": False, "error": f"验证码发送失败:{err}"}
|
||||
|
||||
# 发送成功:记录冷却起点,并惰性清理已失效的冷却记录
|
||||
with STORE_LOCK:
|
||||
send_history[email] = time.time()
|
||||
stale = [k for k, v in send_history.items()
|
||||
if time.time() - v > SEND_INTERVAL]
|
||||
for k in stale:
|
||||
del send_history[k]
|
||||
|
||||
logs.info("smtp", f"{email}: 验证码发送成功({code})")
|
||||
return {"sent": True, "ttl": CODE_TTL}
|
||||
|
||||
def _handle_verify_email(self, req):
|
||||
"""校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。
|
||||
|
||||
同一验证码连续校验失败达 MAX_VERIFY_ATTEMPTS 次即作废删除(响应带
|
||||
invalidated 标记),防止对 6 位数字码的在线枚举。
|
||||
"""
|
||||
email = str(req.get("email", "")).strip()
|
||||
# 兼容旧字段 input_captcha
|
||||
code = str(req.get("code") or req.get("input_captcha") or "").strip()
|
||||
if not email or not code:
|
||||
return {"valid": False, "error": "email and code required"}
|
||||
|
||||
invalidated = False
|
||||
with STORE_LOCK:
|
||||
entry = code_store.get(email)
|
||||
if entry and entry['expires_at'] < time.time():
|
||||
del code_store[email]
|
||||
entry = None
|
||||
if entry and entry['code'] == code:
|
||||
# 验证通过:一次性消费,防重放
|
||||
del code_store[email]
|
||||
valid = True
|
||||
else:
|
||||
valid = False
|
||||
if entry:
|
||||
# 仅对已发出且未过期的验证码计失败次数;达到上限即作废
|
||||
entry['attempts'] = entry.get('attempts', 0) + 1
|
||||
if entry['attempts'] >= MAX_VERIFY_ATTEMPTS:
|
||||
del code_store[email]
|
||||
invalidated = True
|
||||
|
||||
if valid:
|
||||
logs.info("smtp", f"{email}: 验证通过")
|
||||
return {"valid": True}
|
||||
if invalidated:
|
||||
logs.info("smtp", f"{email}: 连续校验失败 {MAX_VERIFY_ATTEMPTS} 次,验证码已作废")
|
||||
return {"valid": False, "invalidated": True,
|
||||
"error": f"验证码连续错误 {MAX_VERIFY_ATTEMPTS} 次,已失效,请重新获取"}
|
||||
logs.info("smtp", f"{email}: 验证失败")
|
||||
return {"valid": False, "error": "invalid or expired code"}
|
||||
|
||||
def _handle_health(self, _req):
|
||||
return {"status": "ok", "service": "smtp"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 入口
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务")
|
||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=8008, help="监听端口")
|
||||
args = parser.parse_args()
|
||||
|
||||
server = SmtpServer(args.addr, args.port)
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
+50
-5
@@ -7,18 +7,23 @@
|
||||
## 架构
|
||||
|
||||
```
|
||||
gateway :443 (反向代理)
|
||||
waf :443 (TLS 终止 + 安全规则)
|
||||
│
|
||||
▼
|
||||
gateway :8443 (反向代理 + 认证)
|
||||
│
|
||||
▼
|
||||
static :9200 (Flask + CORS)
|
||||
│
|
||||
└── public/ 托管目录
|
||||
├── index.html 社区首页
|
||||
├── login.html 登录
|
||||
├── login.html 登录(默认首页)
|
||||
├── register.html 注册
|
||||
├── index.html 社区首页
|
||||
├── forgot.html 忘记密码
|
||||
├── dnfs.html 树状笔记(调用 dnfs:8006)
|
||||
├── __view.html 文件浏览器(系统文件,不在列表中显示)
|
||||
├── allcolor.html 调色板
|
||||
├── LICENSE.html 许可证页面
|
||||
├── css/ theme.css(统一主题)+ view.css
|
||||
├── js/ dnfs.js / login.js / register.js / validation.js / view.js
|
||||
├── games/ HTML 小游戏合集
|
||||
@@ -27,15 +32,46 @@ static :9200 (Flask + CORS)
|
||||
|
||||
## 路由
|
||||
|
||||
### 核心路由
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `GET /` | 社区首页(index.html),不存在时回退 `__view.html` |
|
||||
| `GET /` | 登录页(login.html),如不存在回退 `__view.html` |
|
||||
| `GET /<path>` | 静态文件服务;目录返回 `__view.html` 由前端拉取列表 |
|
||||
| `GET /api/filelist?path=` | 文件列表 API,过滤 `__` 前缀系统文件 |
|
||||
| `GET /video/<path>` | 视频/音频流媒体(HTTP Range 206 分段) |
|
||||
| `GET /<path>?download=true` | 强制下载 |
|
||||
| `GET /health` | 健康检查 |
|
||||
|
||||
### 内部工具路由
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `POST /api/hash` | SHA256 密码哈希 |
|
||||
| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) |
|
||||
| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) |
|
||||
|
||||
### yhdet OAuth 路由
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `GET/POST /yhdet_callback` | yhdet.top OAuth 回调,自动注册/登录并跳转 dnfs 页 |
|
||||
| `POST /api/yhdet_login` | yhdet OAuth 用户名密码登录 |
|
||||
| `POST /api/yhdet_verify` | yhdet Token 验证 |
|
||||
| `POST /api/proxy_send_code` | yhdet 验证码代理转发 |
|
||||
|
||||
### dnfs 代理路由
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `POST /get_notes` | 获取树状笔记列表(代理至 dnfs:8006) |
|
||||
| `POST /add_note` | 新增笔记(代理至 dnfs:8006) |
|
||||
| `POST /delete_note` | 删除笔记(代理至 dnfs:8006) |
|
||||
| `POST /update_note` | 更新笔记(代理至 dnfs:8006) |
|
||||
| `POST /whoami` | 当前用户身份查询(代理至 dnfs:8006) |
|
||||
|
||||
以上 5 个 dnfs 代理路由均支持 HttpOnly Cookie 模式:当请求携带 `q4_token` Cookie 时,自动转为 `Authorization: Bearer` 头转发。
|
||||
|
||||
## 功能
|
||||
|
||||
- 文件浏览:目录导航、面包屑、实时搜索(`/` 或 `Ctrl+F`)、文件统计
|
||||
@@ -43,6 +79,8 @@ static :9200 (Flask + CORS)
|
||||
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
|
||||
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
|
||||
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
|
||||
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
|
||||
- 集中日志:`log_client.py` 通过 pymsg TCP 异步批量推送日志至 db:8005 的 Logs 表
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -60,6 +98,11 @@ root = ./public
|
||||
enabled = false
|
||||
cert_file = cert.pem
|
||||
key_file = key.pem
|
||||
|
||||
[cors]
|
||||
# 允许跨域的 Origin 白名单(逗号分隔)
|
||||
# 注意:生产环境请勿包含 localhost / 127.0.0.1 / 通配符 *
|
||||
allowed_origins = https://qsont.me,https://yhdet.top
|
||||
```
|
||||
|
||||
## 依赖
|
||||
@@ -67,7 +110,9 @@ key_file = key.pem
|
||||
```
|
||||
flask>=2.3.0,<3.0.0
|
||||
flask-cors>=4.0.0,<5.0.0
|
||||
nuitka # 可选,编译为独立二进制
|
||||
waitress>=2.1.2,<4.0.0 # 生产级 WSGI 服务器(非 SSL 模式默认使用)
|
||||
pymsg # 集中日志推送(log_client 依赖)
|
||||
nuitka # 可选,编译为独立二进制
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
+17
-2
@@ -474,6 +474,22 @@ def proxy_update_note():
|
||||
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
|
||||
|
||||
|
||||
@app.route('/whoami', methods=['POST', 'OPTIONS'])
|
||||
def proxy_whoami():
|
||||
if request.method == "OPTIONS": return _cors(make_response("", 204))
|
||||
try:
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
h = {"Content-Type": "application/json"}
|
||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
|
||||
req = _urlreq.Request(f"{DNFS_URL}/whoami", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||
with _urlreq.urlopen(req, timeout=15) as r:
|
||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||
except Exception as e:
|
||||
logs.warning("static", "dnfs whoami 代理失败", extra={"error": str(e)})
|
||||
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
|
||||
|
||||
|
||||
@app.route('/api/proxy_send_code', methods=['POST'])
|
||||
def proxy_send_code():
|
||||
from flask import request, jsonify
|
||||
@@ -506,8 +522,7 @@ def serve_view():
|
||||
"""根路径:优先返回 index.html(社区首页),不存在时回退到 __view.html(文件浏览)"""
|
||||
if not FILESYSTEM_ENABLED:
|
||||
return "文件系统未启用", 403
|
||||
#index_path = os.path.join(HTML_ROOT_DIR, 'index.html')
|
||||
index_path = os.path.join(HTML_ROOT_DIR, 'login.html')
|
||||
index_path = os.path.join(HTML_ROOT_DIR, 'index.html')
|
||||
if os.path.exists(index_path):
|
||||
return send_file(index_path, mimetype='text/html')
|
||||
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
|
||||
<script src="js/validation.js"></script>
|
||||
<script src="js/view.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -365,6 +365,33 @@ input[type="button"]:disabled {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== User Panel (dnfs 左上角用户信息,镜像 minimap 样式) ===== */
|
||||
.userpanel {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(253, 246, 238, 0.72);
|
||||
border: 1px solid var(--border-light);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.userpanel {
|
||||
background: rgba(30, 23, 18, 0.72);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Minimap (dnfs) ===== */
|
||||
.minimap {
|
||||
position: fixed;
|
||||
@@ -392,8 +419,17 @@ input[type="button"]:disabled {
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
.minimap:empty { display: none; }
|
||||
.minimap .tag {
|
||||
.minimap:empty, .userpanel:empty { display: none; }
|
||||
/* userpanel 纯展示项(用户名/角色):不可点击,无手型光标与 hover 变色 */
|
||||
.userpanel .tag.no-hover {
|
||||
cursor: default;
|
||||
}
|
||||
.userpanel .tag.no-hover:hover {
|
||||
color: var(--fg-secondary);
|
||||
background: rgba(232, 133, 58, 0.08);
|
||||
border-color: transparent;
|
||||
}
|
||||
.minimap .tag, .userpanel .tag {
|
||||
display: inline-block;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
@@ -416,18 +452,18 @@ input[type="button"]:disabled {
|
||||
border-radius: 4px;
|
||||
}
|
||||
.minimap::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||||
.minimap .tag:hover {
|
||||
.minimap .tag:hover, .userpanel .tag:hover {
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.minimap .tag.back {
|
||||
.minimap .tag.back, .userpanel .tag.back {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.minimap .tag.fold-btn {
|
||||
.minimap .tag.fold-btn, .userpanel .tag.fold-btn {
|
||||
color: var(--fg-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
@@ -438,7 +474,7 @@ input[type="button"]:disabled {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.minimap .tag.fold-btn:hover {
|
||||
.minimap .tag.fold-btn:hover, .userpanel .tag.fold-btn:hover {
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
@@ -447,6 +483,16 @@ input[type="button"]:disabled {
|
||||
/* ===== Node Form (dnfs) ===== */
|
||||
.node-form { margin-top: 1rem; }
|
||||
.node-form .form-actions { margin-top: 0.75rem; }
|
||||
.node-form #input-note-title {
|
||||
padding: 0.8rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.node-form #input-note-content {
|
||||
padding: 0.9rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
min-height: 120px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ===== Markdown Content ===== */
|
||||
code {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div id="userpanel" class="userpanel"></div>
|
||||
<div id="minimap" class="minimap"></div>
|
||||
<div id="movebar" class="movebar"></div>
|
||||
<div class="recommend-list" id="recommend-list"></div>
|
||||
@@ -25,6 +26,7 @@
|
||||
<!--<script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.0.0/markdown-it.min.js"></script>-->
|
||||
<script src="js/markdown-it-12_0_0.js"></script>
|
||||
<script src="js/validation.js"></script>
|
||||
<script src="js/token_manager.js"></script>
|
||||
<script src="js/dnfs.js"></script>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
|
||||
+107
-30
@@ -19,6 +19,11 @@ async function getNode(id) {
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
// WAF 拦截:弹提示并中止,不能落入 catch(其会误跳登录页)
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) {
|
||||
alert(WafTip.MESSAGE);
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
|
||||
try {
|
||||
@@ -48,8 +53,9 @@ async function getNode(id) {
|
||||
//}
|
||||
|
||||
} catch (err) {
|
||||
alert(err.message || '接口请求失败,请检查后端服务是否启动');
|
||||
// 获取节点失败(非 JSON 响应 / HTTP 错误 / 网络异常),统一跳转登录页
|
||||
console.error('获取节点请求异常:', err);
|
||||
location.href = '/login.html';
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
@@ -77,6 +83,7 @@ async function addNode(pid, title, content) {
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
|
||||
try {
|
||||
@@ -136,6 +143,7 @@ async function deleteNode(id) {
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
|
||||
try {
|
||||
@@ -187,6 +195,7 @@ async function deleteChild(id) {
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
|
||||
try {
|
||||
@@ -245,6 +254,7 @@ async function updateNode(id, pid, title, content) {
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
|
||||
try {
|
||||
@@ -300,6 +310,7 @@ async function moveNode(id, pid, title, content) {
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
|
||||
try {
|
||||
@@ -438,7 +449,9 @@ var recommend_list = document.getElementById('recommend-list');
|
||||
var delete_note = document.getElementById('delete-note');
|
||||
var minimap = document.getElementById("minimap");
|
||||
var movebar = document.getElementById("movebar");
|
||||
var is_expand = true;
|
||||
/* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */
|
||||
var PANEL_EXPAND_MIN_WIDTH = 768;
|
||||
var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH;
|
||||
var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */
|
||||
var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */
|
||||
|
||||
@@ -559,39 +572,44 @@ function load_notes(main_node) {
|
||||
sts_div.appendChild(makeStatItem(ICON_EYE, item.visit_count));
|
||||
sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count));
|
||||
sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count));
|
||||
// 卡片操作按钮:编辑 / 删除(stopPropagation 防止触发卡片自身的点入跳转)
|
||||
let act_div = document.createElement('div');
|
||||
act_div.className = "card-actions";
|
||||
let edit_btn = document.createElement('button');
|
||||
edit_btn.className = "act-btn act-edit";
|
||||
edit_btn.innerHTML = ICON_EDIT + "编辑";
|
||||
edit_btn.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
startEdit(item);
|
||||
});
|
||||
let move_btn = document.createElement('button');
|
||||
move_btn.className = "act-btn act-move";
|
||||
move_btn.innerHTML = ICON_MOVE + "移动";
|
||||
move_btn.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
startMove(item);
|
||||
});
|
||||
let del_btn = document.createElement('button');
|
||||
del_btn.className = "act-btn act-del";
|
||||
del_btn.innerHTML = ICON_TRASH + "删除";
|
||||
del_btn.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
deleteChild(item.id);
|
||||
});
|
||||
act_div.appendChild(edit_btn);
|
||||
act_div.appendChild(move_btn);
|
||||
act_div.appendChild(del_btn);
|
||||
// 卡片操作按钮:编辑 / 移动 / 删除(stopPropagation 防止触发卡片自身的点入跳转)
|
||||
// 按钮组整体按 can_operate 显隐:操作权由后端 get_notes 按 owner / is_admin 判定,
|
||||
// 与 delete_note / update_note 鉴权口径一致(移动本质也是 update_note);
|
||||
// 未登录或无权时不渲染整个按钮组
|
||||
ttl.appendChild(wrt_div);
|
||||
ttl.appendChild(sts_div);
|
||||
let ctt = document.createElement('p');
|
||||
renderMarkdown(ctt, item.content);
|
||||
card.appendChild(ctt)
|
||||
card.appendChild(act_div);
|
||||
if (item.can_operate === true) {
|
||||
let act_div = document.createElement('div');
|
||||
act_div.className = "card-actions";
|
||||
let edit_btn = document.createElement('button');
|
||||
edit_btn.className = "act-btn act-edit";
|
||||
edit_btn.innerHTML = ICON_EDIT + "编辑";
|
||||
edit_btn.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
startEdit(item);
|
||||
});
|
||||
let move_btn = document.createElement('button');
|
||||
move_btn.className = "act-btn act-move";
|
||||
move_btn.innerHTML = ICON_MOVE + "移动";
|
||||
move_btn.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
startMove(item);
|
||||
});
|
||||
let del_btn = document.createElement('button');
|
||||
del_btn.className = "act-btn act-del";
|
||||
del_btn.innerHTML = ICON_TRASH + "删除";
|
||||
del_btn.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
deleteChild(item.id);
|
||||
});
|
||||
act_div.appendChild(edit_btn);
|
||||
act_div.appendChild(move_btn);
|
||||
act_div.appendChild(del_btn);
|
||||
card.appendChild(act_div);
|
||||
}
|
||||
recommend_list.appendChild(card)
|
||||
recommend_list.appendChild(document.createElement('br'))
|
||||
}
|
||||
@@ -648,5 +666,64 @@ function loadMap(children_json) {
|
||||
}
|
||||
}
|
||||
|
||||
/* by qing — 用户信息面板:左上角,默认折叠;展示用户名 / 角色 / 退出登录。
|
||||
* 身份来自 dnfs /whoami(HttpOnly Cookie 模式下 JS 无法读 Token,由后端解 payload 返回) */
|
||||
var userpanel = document.getElementById("userpanel");
|
||||
var userpanel_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; // 与 minimap 同策略:窄屏默认折叠
|
||||
var current_user = null;
|
||||
|
||||
async function loadUserPanel() {
|
||||
try {
|
||||
const res = await fetch('/whoami', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: '{}'
|
||||
});
|
||||
if (!res.ok) return;
|
||||
current_user = await res.json();
|
||||
renderUserPanel();
|
||||
} catch (e) {
|
||||
console.error('获取用户信息失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserPanel() {
|
||||
if (current_user == null) return;
|
||||
userpanel.replaceChildren();
|
||||
let fold = document.createElement("div");
|
||||
fold.className = "tag fold-btn";
|
||||
if (userpanel_expand) {
|
||||
fold.textContent = "v 折叠";
|
||||
} else {
|
||||
fold.textContent = "> " + (current_user.login ? current_user.user : "未登录");
|
||||
}
|
||||
fold.addEventListener("click", () => {
|
||||
userpanel_expand = !userpanel_expand;
|
||||
renderUserPanel();
|
||||
});
|
||||
userpanel.appendChild(fold);
|
||||
if (!userpanel_expand || !current_user.login) return;
|
||||
|
||||
let name_tag = document.createElement("div");
|
||||
name_tag.className = "tag no-hover";
|
||||
name_tag.textContent = current_user.user;
|
||||
userpanel.appendChild(name_tag);
|
||||
|
||||
let role_tag = document.createElement("div");
|
||||
role_tag.className = "tag no-hover";
|
||||
role_tag.textContent = current_user.is_admin ? "管理员" : "普通用户";
|
||||
userpanel.appendChild(role_tag);
|
||||
|
||||
let logout_tag = document.createElement("div");
|
||||
logout_tag.className = "tag back";
|
||||
logout_tag.textContent = "退出登录";
|
||||
logout_tag.addEventListener("click", async () => {
|
||||
try { await fetch('/logout', {method: 'POST'}); } catch (e) {}
|
||||
location.href = '/login.html';
|
||||
});
|
||||
userpanel.appendChild(logout_tag);
|
||||
}
|
||||
|
||||
getNode(0).then(load_notes);
|
||||
getNode(0).then(loadMap)
|
||||
loadUserPanel();
|
||||
@@ -22,6 +22,11 @@ async function refreshCaptcha() {
|
||||
captchaToken = '';
|
||||
try {
|
||||
const res = await fetch('/captcha/image', { cache: 'no-store' });
|
||||
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
|
||||
captchaImg.removeAttribute('src');
|
||||
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error('captcha load failed');
|
||||
captchaToken = res.headers.get('X-Captcha-Token') || '';
|
||||
const blob = await res.blob();
|
||||
@@ -126,6 +131,7 @@ form.addEventListener('submit', async function(e) {
|
||||
body: jsonBody
|
||||
});
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
|
||||
if (!res.ok) throw new Error(result.message || '登录失败');
|
||||
|
||||
@@ -10,6 +10,26 @@ const codeTip = document.getElementById('codeTip');
|
||||
const submitButton = document.getElementById('formsub');
|
||||
|
||||
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
|
||||
let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐)
|
||||
|
||||
// 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却
|
||||
function startResendCooldown(seconds) {
|
||||
clearInterval(cooldownTimer);
|
||||
let remain = seconds;
|
||||
getCodeButton.disabled = true;
|
||||
getCodeButton.value = `${remain}s 后重发`;
|
||||
cooldownTimer = setInterval(function () {
|
||||
remain -= 1;
|
||||
if (remain <= 0) {
|
||||
clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
getCodeButton.disabled = false;
|
||||
getCodeButton.value = '获取验证码';
|
||||
} else {
|
||||
getCodeButton.value = `${remain}s 后重发`;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
form.addEventListener('reset', function () {
|
||||
clearTipStyle();
|
||||
@@ -114,28 +134,33 @@ getCodeButton.addEventListener('click', async function () {
|
||||
codeTip.textContent = '验证码发送中…';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-yellow');
|
||||
let httpStatus = 0;
|
||||
try {
|
||||
const res = await fetch('/register/send_code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
httpStatus = res.status;
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
|
||||
if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败');
|
||||
|
||||
codeTarget = email;
|
||||
codeInput.value = '';
|
||||
codeTip.textContent = '验证码已发送至邮箱,请查收(60 秒内有效)';
|
||||
codeTip.textContent = '验证码已发送至邮箱,请查收(10 分钟内有效)';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-green');
|
||||
startResendCooldown(60);
|
||||
} catch (err) {
|
||||
codeTip.textContent = err.message || '验证码发送失败,请稍后重试';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-red');
|
||||
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
|
||||
} finally {
|
||||
getCodeButton.disabled = false;
|
||||
if (!cooldownTimer) getCodeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -166,6 +191,7 @@ form.addEventListener('submit', async function (e) {
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Token 生命周期管理器
|
||||
* - 定时静默刷新:在 Token 80% 生命周期时提前调 /refresh_token
|
||||
* - 全局 fetch 拦截器:收到 401 先尝试刷新一次,成功则重试原请求,失败则原样返回 401
|
||||
* - 依赖:gateway POST /refresh_token(Cookie q4_token 自动携带)
|
||||
* - 无侵入:与 dnfs.js 完全解耦
|
||||
*/
|
||||
|
||||
let _tokenExpireAt = null;
|
||||
let _refreshTimer = null;
|
||||
|
||||
const REFRESH_ADVANCE = 0.8; // 在 80% 生命周期时提前刷新
|
||||
const REFRESH_MIN_GAP = 60; // 两次刷新最少间隔(秒)
|
||||
const RETRY_COOLDOWN = 15; // 刷新失败后冷却期(秒)
|
||||
|
||||
/* ---------- 过期时间估算:通过 /whoami 获取 iat + keep_time ---------- */
|
||||
|
||||
async function estimateExpiry() {
|
||||
try {
|
||||
const res = await _rawFetch('/whoami', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
if (!data.login) return false;
|
||||
|
||||
// 优先 iat(首次签发时间,刷新不变),回退 created_at(兼容旧 Token)
|
||||
const issueStr = data.iat || data.created_at;
|
||||
const keepTime = data.keep_time;
|
||||
if (!issueStr || !keepTime) return false;
|
||||
|
||||
const issueDate = new Date(issueStr.replace(' ', 'T') + 'Z');
|
||||
if (isNaN(issueDate.getTime())) return false;
|
||||
|
||||
_tokenExpireAt = issueDate.getTime() + keepTime * 1000;
|
||||
scheduleRefresh();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('TokenManager: 过期时间估算失败', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 定时刷新 ---------- */
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(_refreshTimer);
|
||||
if (!_tokenExpireAt) return;
|
||||
|
||||
const remainingSec = (_tokenExpireAt - Date.now()) / 1000;
|
||||
if (remainingSec <= 0) {
|
||||
// Token 已过期,立即尝试刷新
|
||||
tryRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// 80% 位置触发,但至少等 REFRESH_MIN_GAP 秒
|
||||
const delaySec = Math.max(remainingSec * (1 - REFRESH_ADVANCE), REFRESH_MIN_GAP);
|
||||
_refreshTimer = setTimeout(tryRefresh, delaySec * 1000);
|
||||
}
|
||||
|
||||
async function tryRefresh() {
|
||||
try {
|
||||
const res = await _rawFetch('/refresh_token', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.message) console.warn('TokenManager: 刷新被拒 -', data.message);
|
||||
// 冷却后重新估算
|
||||
_refreshTimer = setTimeout(() => estimateExpiry(), RETRY_COOLDOWN * 1000);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
console.log(`TokenManager: 已刷新 (第${data.data.refresh_count}次, 剩余${data.data.refreshes_remaining}次)`);
|
||||
}
|
||||
await estimateExpiry();
|
||||
} catch (e) {
|
||||
console.warn('TokenManager: 刷新网络异常', e.message);
|
||||
_refreshTimer = setTimeout(() => estimateExpiry(), RETRY_COOLDOWN * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 全局 fetch 401 拦截器 ---------- */
|
||||
|
||||
const _rawFetch = window.fetch;
|
||||
|
||||
window.fetch = async function (url, options) {
|
||||
let resp = await _rawFetch(url, options);
|
||||
|
||||
// 仅处理 401;/refresh_token 自身不重试防循环
|
||||
if (resp.status !== 401 || (typeof url === 'string' && url.includes('/refresh_token'))) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshRes = await _rawFetch('/refresh_token', { method: 'POST' });
|
||||
if (!refreshRes.ok) return resp;
|
||||
|
||||
const data = await refreshRes.json();
|
||||
if (!data.success) return resp;
|
||||
|
||||
console.log('TokenManager: 401 拦截 → 刷新成功,重试原请求');
|
||||
await estimateExpiry();
|
||||
|
||||
// Cookie 已由 gateway Set-Cookie 自动更新,直接重试
|
||||
return await _rawFetch(url, options);
|
||||
} catch (e) {
|
||||
console.warn('TokenManager: 401 刷新异常', e.message);
|
||||
}
|
||||
|
||||
return resp;
|
||||
};
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
|
||||
estimateExpiry();
|
||||
@@ -68,3 +68,15 @@
|
||||
|
||||
window.FormValidators = { username, password, email };
|
||||
}());
|
||||
|
||||
/* WAF 拦截识别:waf 命中规则时返回 403 + 纯文本 "blocked by WAF"
|
||||
* (见 waf/internal/proxy/proxy.go block());gateway 的 403(Target-Url
|
||||
* 白名单拒绝)body 为 JSON 错误消息,不含此特征,不会误判。 */
|
||||
window.WafTip = {
|
||||
MESSAGE: '请求被 WAF 安全规则拦截,请检查输入内容',
|
||||
isBlocked(res, responseText) {
|
||||
return res.status === 403
|
||||
&& typeof responseText === 'string'
|
||||
&& responseText.indexOf('blocked by WAF') !== -1;
|
||||
}
|
||||
};
|
||||
@@ -138,7 +138,9 @@ async function loadData() {
|
||||
try {
|
||||
const url = `/api/filelist?path=${encodeURIComponent(currentPath)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
const data = JSON.parse(responseText);
|
||||
if (data.success) {
|
||||
items = data.items || [];
|
||||
render();
|
||||
|
||||
@@ -7,7 +7,7 @@ smtp 黑盒用户模拟测试 — 发送邮箱验证码 → 校验。
|
||||
3. 用空参数校验 → 应报错
|
||||
|
||||
注意: SMTP 服务需要配置真实的邮件服务器。若服务不可达或邮件发送失败,
|
||||
相关测试将标记为 SKIP。验证码有效期为 60 秒。
|
||||
相关测试将标记为 SKIP。验证码有效期为 10 分钟(600 秒)。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
+60
-19
@@ -22,7 +22,7 @@
|
||||
| 列出所有规则 | `./waf.bin -list` |
|
||||
| 测试输入 | `./waf.bin -check "/?id=1' OR '1'='1"` |
|
||||
| 热加载(不重启) | `kill -HUP $(pgrep waf)` |
|
||||
| 临时禁用某文件 | `mv rules/local/x.conf x.conf.bak && kill -HUP $(pgrep waf)` |
|
||||
| 临时禁用某文件 | `mv rules/local/x.conf x.conf.bak && kill -HUP $(pgrep waf)` (引擎仅加载 `.conf` 后缀) |
|
||||
|
||||
### 规则文件组织
|
||||
|
||||
@@ -32,7 +32,7 @@ rules/
|
||||
├── local/ # 本地自定义规则(ID 段 100000-199999)
|
||||
│ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA
|
||||
│ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById)
|
||||
└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取)
|
||||
└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取,可选)
|
||||
├── coraza.conf # CRS 入口(请求体处理器初始化 + tx.crs_setup_version)
|
||||
├── LICENSE # Apache-2.0
|
||||
└── rules/*.conf # CRS 规则文件
|
||||
@@ -54,8 +54,8 @@ rules/
|
||||
| ID 段 | 用途 |
|
||||
|-------|------|
|
||||
| 100000-199999 | 本地自定义规则 |
|
||||
| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义) |
|
||||
| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改) |
|
||||
| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义,仅当启用 CRS 时存在) |
|
||||
| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改,仅当启用 CRS 时存在) |
|
||||
|
||||
### 规则语法(SecLang)
|
||||
|
||||
@@ -99,7 +99,7 @@ rules_dir: "./rules"
|
||||
|------|--------|------|
|
||||
| `listen` | `:443` | 监听地址 |
|
||||
| `tls_cert` / `tls_key` | — | TLS 证书与私钥路径 |
|
||||
| `upstream` | — | gateway 地址,**必填** |
|
||||
| `upstream` | — | gateway 地址,**必填**(启动时硬性校验,缺失将导致进程退出) |
|
||||
| `rules_dir` | `./rules` | 规则根目录 |
|
||||
|
||||
## 目录结构
|
||||
@@ -112,8 +112,10 @@ waf/
|
||||
├── internal/
|
||||
│ ├── engine/
|
||||
│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测/输入检测
|
||||
│ ├── logclient/
|
||||
│ │ └── logclient.go # Go 版 pymsg 日志客户端(异步批量推送)
|
||||
│ └── proxy/
|
||||
│ └── proxy.go # ReverseProxy + WAF 事务接入
|
||||
│ └── proxy.go # ReverseProxy + WAF 事务接入 + 安全响应头注入
|
||||
├── rules/
|
||||
│ ├── waf.conf
|
||||
│ └── local/
|
||||
@@ -156,25 +158,61 @@ bash /opt/micro_servers/waf/_run.sh
|
||||
|
||||
并把 `gateway` 改为内部 HTTP 端口(不再监听 443)。
|
||||
|
||||
## 命中日志
|
||||
## 安全响应头
|
||||
|
||||
命中规则时输出 JSON 一行,便于 `jq` 分析(见 [proxy.go:120-137](internal/proxy/proxy.go#L120)):
|
||||
WAF 在边缘层统一注入安全响应头([proxy.go:54-77](internal/proxy/proxy.go#L54-L77) `setSecurityHeaders`),覆盖所有出站响应(包括正常转发、命中拦截、上游 502)。下游服务(static/dnfs 等)的同名头会被 WAF 覆盖,避免 CSP 绕过。
|
||||
|
||||
```json
|
||||
{"ts":"2026-07-25T10:00:00Z","action":"block","rule_id":100001,"ip":"1.2.3.4","method":"GET","path":"/","query":"id=1'+OR+'1'='1","ua":"curl/8.0","cost_ms":0}
|
||||
```
|
||||
| Header | 值 | 说明 |
|
||||
|--------|----|------|
|
||||
| X-Frame-Options | SAMEORIGIN | 允许本站 iframe 嵌套(登录页条例弹窗),防跨站点击劫持 |
|
||||
| X-Content-Type-Options | nosniff | — |
|
||||
| X-XSS-Protection | 1; mode=block | 现代浏览器已废弃,仅兼容旧浏览器 |
|
||||
| Referrer-Policy | strict-origin-when-cross-origin | — |
|
||||
| Permissions-Policy | camera=(), microphone=(), geolocation=() | — |
|
||||
| Strict-Transport-Security | max-age=15552000 | HSTS 半年期,**不含子域** |
|
||||
| Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline'; ... | 暂保留 `'unsafe-inline'` 以兼容 forgot.html 与 games/*.html 的内联脚本 |
|
||||
|
||||
放行请求时输出一行 pass 日志(见 [proxy.go:115](internal/proxy/proxy.go#L115)):
|
||||
## 拦截日志
|
||||
|
||||
```
|
||||
[proxy] pass ip=1.2.3.4:54321 method=GET path=/ status=200 rule=- cost=1.2ms
|
||||
命中规则时 `block()` 经 logclient(Go 版 pymsg 客户端,见 [internal/logclient/](internal/logclient/))异步批量推送到 db:8005 的 `Logs` 表(与其他服务的集中日志同链路;标准日志已静默,stdout/journalctl 中看不到拦截记录):
|
||||
|
||||
- **规则命中拦截**(block 触发):`service=waf`、`level=WARNING`
|
||||
- **规则加载/解析错误**(`LogError` 回调触发,引擎初始化/热加载时的语义错误):`service=waf`、`level=ERROR`
|
||||
|
||||
`block()` 推送的日志 `extra` 字段(JSON 字符串):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `rule_id` | int | 命中规则 ID |
|
||||
| `ip` | string | `RemoteAddr` 原始值,含端口号(如 `1.2.3.4:54321`) |
|
||||
| `method` | string | 请求方法 |
|
||||
| `path` | string | 请求路径 |
|
||||
| `query` | string | 查询字符串 |
|
||||
| `ua` | string | User-Agent |
|
||||
| `cost_ms` | int64 | 处理耗时(毫秒) |
|
||||
|
||||
fail-safe:db 不可用时日志静默丢弃,不影响拦截动作本身。
|
||||
|
||||
查询最近的拦截记录:
|
||||
|
||||
```bash
|
||||
sqlite3 /opt/micro_servers/db/database.db \
|
||||
"SELECT ts, message, extra FROM Logs WHERE service='waf' AND level='WARNING' ORDER BY id DESC LIMIT 20;"
|
||||
```
|
||||
|
||||
聚合统计命中规则:
|
||||
|
||||
```bash
|
||||
journalctl -u Nidus -o cat | grep '"action":"block"' | \
|
||||
jq -r '.rule_id' | sort | uniq -c | sort -rn
|
||||
sqlite3 /opt/micro_servers/db/database.db \
|
||||
"SELECT json_extract(extra,'\$.rule_id') AS rule, COUNT(*) FROM Logs \
|
||||
WHERE service='waf' AND level='WARNING' GROUP BY rule ORDER BY 2 DESC;"
|
||||
```
|
||||
|
||||
查看规则加载/解析错误:
|
||||
|
||||
```bash
|
||||
sqlite3 /opt/micro_servers/db/database.db \
|
||||
"SELECT ts, message, extra FROM Logs WHERE service='waf' AND level='ERROR' ORDER BY id DESC LIMIT 20;"
|
||||
```
|
||||
|
||||
## 启用 OWASP CRS(可选)
|
||||
@@ -210,26 +248,29 @@ kill -HUP $(pgrep waf)
|
||||
|
||||
## 请求处理流程
|
||||
|
||||
[proxy.go:57-117](internal/proxy/proxy.go#L57) `ServeHTTP`:
|
||||
[proxy.go:81-138](internal/proxy/proxy.go#L81) `ServeHTTP`:
|
||||
|
||||
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
|
||||
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
|
||||
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
||||
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
|
||||
5. 捕获响应头 → `ProcessResponseHeaders`
|
||||
6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头)
|
||||
|
||||
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
|
||||
|
||||
ErrorHandler([proxy.go:48-51](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,不暴露内部堆栈,仅服务端 log。
|
||||
ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
|
||||
- **响应体检测未启用**:仅 `ProcessResponseHeaders`,未 `ProcessResponseBody`(缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||
- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
||||
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
|
||||
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
|
||||
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
|
||||
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
|
||||
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
|
||||
|
||||
## 依赖
|
||||
|
||||
|
||||
Reference in new issue
Block a user