From c06eb19f64a3063b341c92620d0b037274cb10aa Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 11:47:14 +0800 Subject: [PATCH 01/22] refix --- aibox/aibox.py | 2 +- confs/_run.sh | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/aibox/aibox.py b/aibox/aibox.py index 2ee8098..26270e0 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -1168,7 +1168,7 @@ class ConfigManager: "api": { "deepseek_api_url": "https://api.deepseek.com/chat/completions", "deepseek_model": "deepseek-v4-flash", - "reasoning_enabled": false, + "reasoning_enabled": False, "api_key_env": "DEEPSEEK_API_KEY", "timeout": 120, "temperature": 0.7 diff --git a/confs/_run.sh b/confs/_run.sh index f8815d1..32c010d 100755 --- a/confs/_run.sh +++ b/confs/_run.sh @@ -2,5 +2,4 @@ cd "$(dirname "$0")" || exit source ./venv/bin/activate #exec ./smtp.bin -sleep 2 exec python3 confs.py From 6ebd5446196bb2636bc41d5abe993c85a975ce51 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 12:23:21 +0800 Subject: [PATCH 02/22] =?UTF-8?q?feat(db):=20=E8=BF=81=E7=A7=BB=20db=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=88=B0=20confs=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 confs/configs/db.ini 集中托管 db 服务配置(server/database/logs/confs) - dbv2.py 采用三层 fail-safe 加载模式:内置默认值 → confs(db.ini,优先)→ 环境变量覆盖(最高优先级,Docker 兼容) - _merge_ini() 白名单合并,仅覆盖已知 section/key,ConfigParser(interpolation=None) 禁用插值 - db 先于 confs 启动时连接失败静默 fail-safe,启动时打印配置来源 - db_tools.py 运维工具保持独立,直接读 DATABASE_URL 环境变量,不依赖 confs - 更新 confs/README.md 与 db/README.md 文档 --- confs/README.md | 13 ++++++-- confs/configs/db.ini | 16 +++++++++ db/README.md | 58 +++++++++++++++++++++++++++++--- db/dbv2.py | 78 +++++++++++++++++++++++++++++++++++++++----- 4 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 confs/configs/db.ini diff --git a/confs/README.md b/confs/README.md index e1b232f..686d31d 100644 --- a/confs/README.md +++ b/confs/README.md @@ -237,6 +237,7 @@ except RpcError as e: |------|---------|---------| | [static](../static/) | [static.ini](configs/static.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 本地 `config.ini` 回退 | | [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 环境变量覆盖 | +| [db](../db/) | [db.ini](configs/db.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 环境变量覆盖(最高优先级,Docker 兼容) | static 的接入实现见 [static/fsys.py](../static/fsys.py): - `_fetch_from_confs()` 通过 `RpcClient` 调 `get` 路由取 `static.ini`,3 秒超时 @@ -249,7 +250,14 @@ dnfs 的接入实现见 [dnfs/dnfs.py](../dnfs/dnfs.py): - 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`REVIEW_URL`/`CONFS_HOST`/`CONFS_PORT` - Docker 环境通过环境变量传入服务名(db/ai_review),覆盖 confs 中的 127.0.0.1 默认值 -其他服务(gateway / waf / ai_review / smtp / db)尚未接入,仍读各自本地配置。 +db 的接入实现见 [db/dbv2.py](../db/dbv2.py): +- 三层加载顺序:内置默认值 → confs(db.ini)→ 环境变量覆盖(优先级最高) +- 配置项涵盖:`[server]` host/port/workers、`[database]` url、`[logs]` retention_days/max_records/cleanup_interval、`[confs]` host/port +- 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`WORKERS`/`DATABASE_URL`/`CONFS_HOST`/`CONFS_PORT` +- 注意:db 先于 confs 启动(启动顺序 db → confs → ai_review/...),db 启动时若 confs 不可用会静默 fail-safe 使用默认值,配置来源会打印到控制台 +- 独立运维工具 `db_tools.py`(backup/export/import 等)仍直接读 `DATABASE_URL` 环境变量,不依赖 confs,避免运维脚本依赖服务就绪 + +其他服务(gateway / waf / ai_review / smtp)尚未接入,仍读各自本地配置。 ## 项目结构 @@ -260,7 +268,8 @@ confs/ ├── configs/ # 配置文件存放目录(被分发对象) │ ├── example.ini # 示例配置 │ ├── static.ini # static 服务配置(已接入分发) -│ └── dnfs.ini # dnfs 服务配置(已接入分发) +│ ├── dnfs.ini # dnfs 服务配置(已接入分发) +│ └── db.ini # db 服务配置(已接入分发) ├── docs/ │ └── SECURITY_AUDIT_20260801.md # 安全审计报告 ├── _set.sh # 初始化脚本(建 venv + 安装依赖) diff --git a/confs/configs/db.ini b/confs/configs/db.ini new file mode 100644 index 0000000..bd2a2d3 --- /dev/null +++ b/confs/configs/db.ini @@ -0,0 +1,16 @@ +[server] +host = 0.0.0.0 +port = 8005 +workers = 4 + +[database] +url = sqlite:///database.db + +[logs] +retention_days = 30 +max_records = 1000000 +cleanup_interval = 3600 + +[confs] +host = 127.0.0.1 +port = 8010 diff --git a/db/README.md b/db/README.md index 67b6baa..0397c2f 100644 --- a/db/README.md +++ b/db/README.md @@ -25,10 +25,60 @@ ./_run.sh # venv 中执行 python3 dbv2.py ``` -服务监听 `tcp://127.0.0.1:8005`。生产环境由 core(Nidus)按 init.txt 统一拉起,守护靠 systemd。 +服务监听 `tcp://0.0.0.0:8005`(默认)。生产环境由 core(Nidus)按 init.txt 统一拉起,守护靠 systemd。 **注意**:`execute` 路由依赖表已存在(通过反射获取结构)。建表见 `create_sqlite3.sql`(Users / Notes;Logs 表由服务启动时自建)。 +## 配置管理 + +db 已接入 [confs](../confs/) 统一配置服务,采用三层 fail-safe 加载模式(与 static/dnfs 一致): + +1. **内置默认值** — 硬编码在 `dbv2.py` 中,保证服务在任何环境都能启动 +2. **confs 统一配置服务**(优先)— 通过 pymsg RpcClient 从 `confs` 拉取 `db.ini` +3. **环境变量覆盖**(最高优先级)— Docker/部署环境通过环境变量覆盖,保持向后兼容 + +### confs 配置文件(db.ini) + +配置文件托管于 [confs/configs/db.ini](../confs/configs/db.ini): + +```ini +[server] +host = 0.0.0.0 +port = 8005 +workers = 4 + +[database] +url = sqlite:///database.db + +[logs] +retention_days = 30 +max_records = 1000000 +cleanup_interval = 3600 + +[confs] +host = 127.0.0.1 +port = 8010 +``` + +### 环境变量(最高优先级) + +| 环境变量 | 说明 | 对应 confs 配置项 | +|---------|------|------------------| +| `LISTEN_ADDR` | 监听地址 | `[server] host` | +| `LISTEN_PORT` | 监听端口 | `[server] port` | +| `WORKERS` | 回调并发线程数 | `[server] workers` | +| `DATABASE_URL` | 数据库连接 URL | `[database] url` | +| `CONFS_HOST` | confs 服务地址 | `[confs] host` | +| `CONFS_PORT` | confs 服务端口 | `[confs] port` | + +### 启动顺序注意 + +根据 micro_servers 的启动顺序:**db → confs → ai_review/...**,db 先于 confs 启动。因此: +- db 启动时若 confs 尚未就绪,`_fetch_from_confs()` 连接失败会静默 fail-safe,使用内置默认值 + 环境变量正常启动 +- 启动时控制台会打印配置来源:`配置来源: confs 统一配置服务 (db.ini)` 或 `警告: confs 不可用,使用内置默认值`,最后 `start()` 前会再次打印最终来源 +- 独立运维工具 `db_tools.py`(backup/export/import/vacuum 等)仍直接读 `DATABASE_URL` 环境变量,不依赖 confs 服务就绪 +- Docker 环境通过 `docker-entrypoint.sh` 传入环境变量(见 [Dockerfile](Dockerfile)),保持与之前完全兼容,无需额外配置 + ## 协议与 API ### 传输协议(pymsg) @@ -232,10 +282,10 @@ python3 _view_db.sqlite3.py -s "SELECT service, COUNT(*) FROM Logs GROUP BY serv ## 切换数据库 -只需修改环境变量 `DATABASE_URL`,并安装对应驱动: +推荐方式:修改 confs 的 [db.ini](../confs/configs/db.ini) 中 `[database] url`,然后通过 confs `reload` 路由重载(或重启 db)。也可通过环境变量 `DATABASE_URL` 覆盖(Docker 部署时常用): -- SQLite(默认):`sqlite:///database.db` -- PostgreSQL:`postgresql://user:pass@localhost/dbname`(需 `pip install psycopg2`) +- SQLite(默认):`sqlite:///database.db`(相对路径)或 `sqlite:////app/data/database.db`(绝对路径,Docker 中使用) +- PostgreSQL:`postgresql://user:pass@localhost/dbname`(需 `pip install psycopg2-binary`) - MySQL:`mysql+pymysql://user:pass@localhost/dbname`(需 `pip install pymysql`) SQLAlchemy 会自动生成适配各数据库的 SQL 方言,无需修改接口调用。 diff --git a/db/dbv2.py b/db/dbv2.py index 771b206..ccd2121 100644 --- a/db/dbv2.py +++ b/db/dbv2.py @@ -27,6 +27,7 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。 响应中的日期时间一律序列化为 ISO 8601(T 分隔)。 """ +import configparser import json import os import threading @@ -47,9 +48,70 @@ import sys as _sys _sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from pymsg import RpcServer # noqa: E402 -# ── 数据库配置 ────────────────────────────────────────────────────────── +# ── 配置加载(三层 fail-safe:内置默认值 → confs → 环境变量覆盖)────── +# 加载顺序:内置默认值 → confs 统一配置服务(db.ini,优先); +# confs 不可用时 fail-safe 使用默认值;环境变量优先级最高(Docker 兼容)。 -DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db") +_db_config = { + 'server': {'host': '0.0.0.0', 'port': '8005', 'workers': '4'}, + 'database': {'url': 'sqlite:///database.db'}, + 'logs': { + 'retention_days': '30', + 'max_records': '1000000', + 'cleanup_interval': '3600', + }, + 'confs': {'host': '127.0.0.1', 'port': '8010'}, +} + + +def _merge_ini(text): + """把 ini 文本合并进 _db_config(仅覆盖已知的 section/key)。""" + ini = configparser.ConfigParser(interpolation=None) + ini.read_string(text) + for section in _db_config: + if ini.has_section(section): + for key, value in ini[section].items(): + if key in _db_config[section]: + _db_config[section][key] = value + + +def _fetch_from_confs(): + """从 confs 统一配置服务拉取 db.ini 全文,失败返回 None(fail-safe)。""" + try: + from pymsg import RpcClient + host = os.environ.get('CONFS_HOST', _db_config['confs']['host']) + port = int(os.environ.get('CONFS_PORT', _db_config['confs']['port'])) + data = RpcClient(host, port, 'db', timeout=3).call( + 'get', {'name': 'db.ini'}) + return data.get('content') + except Exception: + return None + + +_confs_text = _fetch_from_confs() +_config_source = 'built-in defaults' +if _confs_text is not None: + _merge_ini(_confs_text) + _config_source = 'confs (db.ini)' + print(f'[dbv2] 配置来源: confs 统一配置服务 (db.ini)') +else: + print(f'[dbv2] 警告: confs 不可用,使用内置默认值') + +# 环境变量覆盖(最高优先级,保持 Docker 兼容) +if os.environ.get('LISTEN_ADDR'): + _db_config['server']['host'] = os.environ['LISTEN_ADDR'] +if os.environ.get('LISTEN_PORT'): + _db_config['server']['port'] = os.environ['LISTEN_PORT'] +if os.environ.get('WORKERS'): + _db_config['server']['workers'] = os.environ['WORKERS'] +if os.environ.get('DATABASE_URL'): + _db_config['database']['url'] = os.environ['DATABASE_URL'] + +# 解析为最终常量 +DATABASE_URL = _db_config['database']['url'] +DEFAULT_RETENTION_DAYS = int(_db_config['logs']['retention_days']) +DEFAULT_MAX_RECORDS = int(_db_config['logs']['max_records']) +DEFAULT_CLEANUP_INTERVAL = int(_db_config['logs']['cleanup_interval']) # ── SQLite 连接配置(针对 Docker/多线程环境优化)────────────────────── @@ -76,10 +138,6 @@ def _configure_sqlite(dbapi_connection, connection_record): MAX_MESSAGE_LEN = 8192 # message 上限 8KB MAX_EXTRA_LEN = 4096 # extra 上限 4KB -DEFAULT_RETENTION_DAYS = 30 -DEFAULT_MAX_RECORDS = 1_000_000 -DEFAULT_CLEANUP_INTERVAL = 3600 # 秒 - #: where/values 中的哨兵值:服务端当前 UTC 时间 NOW_SENTINEL = "$now" @@ -456,17 +514,19 @@ class DBServer: if __name__ == "__main__": import argparse - default_addr = os.environ.get("LISTEN_ADDR", "0.0.0.0") - default_port = int(os.environ.get("LISTEN_PORT", "8005")) + default_addr = _db_config['server']['host'] + default_port = int(_db_config['server']['port']) + default_workers = int(_db_config['server']['workers']) parser = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务") parser.add_argument("--addr", default=default_addr, help="监听地址") parser.add_argument("--port", type=int, default=default_port, help="监听端口") - parser.add_argument("--workers", type=int, default=4, + parser.add_argument("--workers", type=int, default=default_workers, help="回调并发线程数(0=同步串行模式)") args = parser.parse_args() server = DBServer(args.addr, args.port, workers=args.workers) + print(f'[dbv2] 配置来源: {_config_source}') try: server.start() except KeyboardInterrupt: From 2cbb93b3b72a3832aeb5a72902a8dc819d3e6faa Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 12:29:55 +0800 Subject: [PATCH 03/22] =?UTF-8?q?refactor(db):=20=E5=88=A0=E9=99=A4=20conf?= =?UTF-8?q?s=20=E8=BF=9E=E6=8E=A5=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE=E7=9A=84=20fail-s?= =?UTF-8?q?afe=20=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - confs 改为 db 启动的强依赖:连接失败直接 sys.exit(1),不再静默使用默认值 - 配置加载从三层(默认值→confs→环境变量)改为两层(confs→环境变量覆盖) - _db_config 初始值改为空字符串白名单模板,移除内置默认值 - 新增配置完整性校验:confs 返回后检查所有必需 section/key 非空,缺失则报错退出 - 区分错误类型:连接拒绝/RpcError/通用异常分别打印明确的错误信息到 stderr - confs 连接地址改为引导配置(环境变量优先,默认 127.0.0.1:8010),不再从 db.ini 读取 - 更新 db/README.md 与 confs/README.md,说明启动顺序调整为 confs → db → ... --- confs/README.md | 12 ++++---- db/README.md | 38 +++++++++++++------------ db/dbv2.py | 73 +++++++++++++++++++++++++++++++++---------------- 3 files changed, 77 insertions(+), 46 deletions(-) diff --git a/confs/README.md b/confs/README.md index 686d31d..3e3e74a 100644 --- a/confs/README.md +++ b/confs/README.md @@ -227,7 +227,7 @@ except RpcError as e: ## 集成关系 -- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/confs/_run.sh` 拉起并守护(在 db 之后、ai_review 之前启动) +- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/confs/_run.sh` 拉起并守护(需先于 db 启动,db 对 confs 有强依赖) - 各内部服务通过 pymsg `RpcClient` 调 `get` 路由按需获取配置,替代本地散落的 ini/json/yaml - 自身日志通过 `log_client.py` 异步推送至 `db:8005` 的 Logs 表(与其他服务一致) @@ -237,7 +237,7 @@ except RpcError as e: |------|---------|---------| | [static](../static/) | [static.ini](configs/static.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 本地 `config.ini` 回退 | | [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 环境变量覆盖 | -| [db](../db/) | [db.ini](configs/db.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 环境变量覆盖(最高优先级,Docker 兼容) | +| [db](../db/) | [db.ini](configs/db.ini) | confs 强依赖(confs 不可用时 db 直接退出)→ 环境变量覆盖(最高优先级,Docker 兼容) | static 的接入实现见 [static/fsys.py](../static/fsys.py): - `_fetch_from_confs()` 通过 `RpcClient` 调 `get` 路由取 `static.ini`,3 秒超时 @@ -251,10 +251,12 @@ dnfs 的接入实现见 [dnfs/dnfs.py](../dnfs/dnfs.py): - Docker 环境通过环境变量传入服务名(db/ai_review),覆盖 confs 中的 127.0.0.1 默认值 db 的接入实现见 [db/dbv2.py](../db/dbv2.py): -- 三层加载顺序:内置默认值 → confs(db.ini)→ 环境变量覆盖(优先级最高) -- 配置项涵盖:`[server]` host/port/workers、`[database]` url、`[logs]` retention_days/max_records/cleanup_interval、`[confs]` host/port +- 两层加载顺序:confs(db.ini,强依赖)→ 环境变量覆盖(优先级最高) +- confs 连接失败或 db.ini 缺失必需配置项时,db 直接 `sys.exit(1)` 终止,不使用默认值 +- 配置项涵盖:`[server]` host/port/workers、`[database]` url、`[logs]` retention_days/max_records/cleanup_interval +- confs 连接地址为引导配置:环境变量 `CONFS_HOST`/`CONFS_PORT` 优先,默认 `127.0.0.1:8010` - 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`WORKERS`/`DATABASE_URL`/`CONFS_HOST`/`CONFS_PORT` -- 注意:db 先于 confs 启动(启动顺序 db → confs → ai_review/...),db 启动时若 confs 不可用会静默 fail-safe 使用默认值,配置来源会打印到控制台 +- 启动顺序:confs 必须先于 db 启动(confs → db → ai_review/...) - 独立运维工具 `db_tools.py`(backup/export/import 等)仍直接读 `DATABASE_URL` 环境变量,不依赖 confs,避免运维脚本依赖服务就绪 其他服务(gateway / waf / ai_review / smtp)尚未接入,仍读各自本地配置。 diff --git a/db/README.md b/db/README.md index 0397c2f..cc3da2d 100644 --- a/db/README.md +++ b/db/README.md @@ -31,11 +31,10 @@ ## 配置管理 -db 已接入 [confs](../confs/) 统一配置服务,采用三层 fail-safe 加载模式(与 static/dnfs 一致): +db 已接入 [confs](../confs/) 统一配置服务,confs 是 db 启动的**强依赖**: -1. **内置默认值** — 硬编码在 `dbv2.py` 中,保证服务在任何环境都能启动 -2. **confs 统一配置服务**(优先)— 通过 pymsg RpcClient 从 `confs` 拉取 `db.ini` -3. **环境变量覆盖**(最高优先级)— Docker/部署环境通过环境变量覆盖,保持向后兼容 +1. **confs 统一配置服务** — 必须成功从 confs 拉取 `db.ini`,否则 db 启动失败直接退出(exit code 1) +2. **环境变量覆盖**(最高优先级)— Docker/部署环境通过环境变量覆盖 confs 中的值 ### confs 配置文件(db.ini) @@ -54,13 +53,15 @@ url = sqlite:///database.db retention_days = 30 max_records = 1000000 cleanup_interval = 3600 - -[confs] -host = 127.0.0.1 -port = 8010 ``` -### 环境变量(最高优先级) +### 引导配置(confs 连接地址) + +confs 自身的连接地址不通过 confs 获取(避免循环依赖),通过以下方式确定(优先级从高到低): +- 环境变量 `CONFS_HOST` / `CONFS_PORT` +- 默认值 `127.0.0.1:8010` + +### 环境变量(最高优先级,覆盖 confs 配置值) | 环境变量 | 说明 | 对应 confs 配置项 | |---------|------|------------------| @@ -68,16 +69,19 @@ port = 8010 | `LISTEN_PORT` | 监听端口 | `[server] port` | | `WORKERS` | 回调并发线程数 | `[server] workers` | | `DATABASE_URL` | 数据库连接 URL | `[database] url` | -| `CONFS_HOST` | confs 服务地址 | `[confs] host` | -| `CONFS_PORT` | confs 服务端口 | `[confs] port` | +| `CONFS_HOST` | confs 服务地址(引导配置) | — | +| `CONFS_PORT` | confs 服务端口(引导配置) | — | -### 启动顺序注意 +### 启动顺序要求 -根据 micro_servers 的启动顺序:**db → confs → ai_review/...**,db 先于 confs 启动。因此: -- db 启动时若 confs 尚未就绪,`_fetch_from_confs()` 连接失败会静默 fail-safe,使用内置默认值 + 环境变量正常启动 -- 启动时控制台会打印配置来源:`配置来源: confs 统一配置服务 (db.ini)` 或 `警告: confs 不可用,使用内置默认值`,最后 `start()` 前会再次打印最终来源 -- 独立运维工具 `db_tools.py`(backup/export/import/vacuum 等)仍直接读 `DATABASE_URL` 环境变量,不依赖 confs 服务就绪 -- Docker 环境通过 `docker-entrypoint.sh` 传入环境变量(见 [Dockerfile](Dockerfile)),保持与之前完全兼容,无需额外配置 +**confs 必须先于 db 启动**(启动顺序调整为:confs → db → ai_review/...)。若 confs 不可用: +- db 打印致命错误到 stderr(包含 confs 地址和具体错误原因),以 exit code 1 终止 +- 不会静默使用默认值启动 +- 配置完整性校验:若 db.ini 中缺少必需的 section/key 且无对应环境变量覆盖,同样报错退出 +- 启动成功时控制台打印:`配置来源: confs 统一配置服务 (db.ini @ host:port)` + +> 独立运维工具 `db_tools.py`(backup/export/import/vacuum 等)仍直接读 `DATABASE_URL` 环境变量,不依赖 confs 服务就绪。 +> Docker 环境通过 `docker-entrypoint.sh` 传入环境变量(见 [Dockerfile](Dockerfile)),需确保 `CONFS_HOST`/`CONFS_PORT` 指向 confs 服务地址。 ## 协议与 API diff --git a/db/dbv2.py b/db/dbv2.py index ccd2121..b0123af 100644 --- a/db/dbv2.py +++ b/db/dbv2.py @@ -48,19 +48,25 @@ import sys as _sys _sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from pymsg import RpcServer # noqa: E402 -# ── 配置加载(三层 fail-safe:内置默认值 → confs → 环境变量覆盖)────── -# 加载顺序:内置默认值 → confs 统一配置服务(db.ini,优先); -# confs 不可用时 fail-safe 使用默认值;环境变量优先级最高(Docker 兼容)。 +# ── 配置加载(两层:confs 强依赖 → 环境变量覆盖)────────────────────── +# confs 是 db 启动的强依赖:必须成功从 confs 拉取 db.ini,否则直接退出。 +# 环境变量优先级最高(Docker 兼容),可覆盖 confs 中的配置值。 +# confs 自身的连接地址(host/port)通过引导配置确定:环境变量 CONFS_HOST/CONFS_PORT +# 优先,缺省使用 127.0.0.1:8010(与其他服务一致的约定)。 +# 引导配置:confs 连接地址(不依赖 confs 本身,可通过环境变量覆盖) +_confs_host = os.environ.get('CONFS_HOST', '127.0.0.1') +_confs_port = int(os.environ.get('CONFS_PORT', '8010')) + +# 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段 _db_config = { - 'server': {'host': '0.0.0.0', 'port': '8005', 'workers': '4'}, - 'database': {'url': 'sqlite:///database.db'}, + 'server': {'host': '', 'port': '', 'workers': ''}, + 'database': {'url': ''}, 'logs': { - 'retention_days': '30', - 'max_records': '1000000', - 'cleanup_interval': '3600', + 'retention_days': '', + 'max_records': '', + 'cleanup_interval': '', }, - 'confs': {'host': '127.0.0.1', 'port': '8010'}, } @@ -76,26 +82,32 @@ def _merge_ini(text): def _fetch_from_confs(): - """从 confs 统一配置服务拉取 db.ini 全文,失败返回 None(fail-safe)。""" + """从 confs 统一配置服务拉取 db.ini 全文,失败直接终止进程。""" + from pymsg import RpcClient, RpcError # noqa: E402 try: - from pymsg import RpcClient - host = os.environ.get('CONFS_HOST', _db_config['confs']['host']) - port = int(os.environ.get('CONFS_PORT', _db_config['confs']['port'])) - data = RpcClient(host, port, 'db', timeout=3).call( - 'get', {'name': 'db.ini'}) + client = RpcClient(_confs_host, _confs_port, 'db', timeout=5) + data = client.call('get', {'name': 'db.ini'}) return data.get('content') - except Exception: - return None + except (ConnectionRefusedError, ConnectionError, OSError) as e: + print(f'[dbv2] 致命错误: 无法连接 confs 服务 ({_confs_host}:{_confs_port}): {e}', + file=_sys.stderr) + print(f'[dbv2] 请确认 confs 服务已启动并监听 {_confs_host}:{_confs_port}', + file=_sys.stderr) + _sys.exit(1) + except RpcError as e: + print(f'[dbv2] 致命错误: 从 confs 获取 db.ini 失败: {e}', + file=_sys.stderr) + _sys.exit(1) + except Exception as e: + print(f'[dbv2] 致命错误: confs 配置拉取异常: {e}', + file=_sys.stderr) + _sys.exit(1) _confs_text = _fetch_from_confs() -_config_source = 'built-in defaults' -if _confs_text is not None: - _merge_ini(_confs_text) - _config_source = 'confs (db.ini)' - print(f'[dbv2] 配置来源: confs 统一配置服务 (db.ini)') -else: - print(f'[dbv2] 警告: confs 不可用,使用内置默认值') +_merge_ini(_confs_text) +_config_source = 'confs (db.ini)' +print(f'[dbv2] 配置来源: confs 统一配置服务 (db.ini @ {_confs_host}:{_confs_port})') # 环境变量覆盖(最高优先级,保持 Docker 兼容) if os.environ.get('LISTEN_ADDR'): @@ -107,6 +119,19 @@ if os.environ.get('WORKERS'): if os.environ.get('DATABASE_URL'): _db_config['database']['url'] = os.environ['DATABASE_URL'] +# 配置完整性校验 +_required = { + 'server': ['host', 'port', 'workers'], + 'database': ['url'], + 'logs': ['retention_days', 'max_records', 'cleanup_interval'], +} +for section, keys in _required.items(): + for key in keys: + if not _db_config[section][key]: + print(f'[dbv2] 致命错误: 配置项 [{section}] {key} 缺失(db.ini 中未配置且无环境变量覆盖)', + file=_sys.stderr) + _sys.exit(1) + # 解析为最终常量 DATABASE_URL = _db_config['database']['url'] DEFAULT_RETENTION_DAYS = int(_db_config['logs']['retention_days']) From 84adec47224bc3d556af3a560d84357d7464892f Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 12:33:51 +0800 Subject: [PATCH 04/22] =?UTF-8?q?refactor(dnfs):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?confs=20=E6=94=B9=E4=B8=BA=E5=BC=BA=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配置加载从三层(默认值→confs→环境变量)改为两层(confs 强依赖→环境变量覆盖) - confs 连接失败直接 sys.exit(1),不再静默使用内置默认值 - _dnfs_config 初始值改为空字符串白名单模板,移除内置默认值 - 新增配置完整性校验:confs 返回后检查所有必需 section/key 非空 - 区分错误类型:连接拒绝/RpcError/通用异常分别打印明确错误信息到 stderr - confs 连接地址改为引导配置(环境变量优先,默认 127.0.0.1:8010),移除 dnfs.ini 中 [confs] 段 - 启动顺序调整为 confs → db → dnfs → ai_review/...(dnfs 同时依赖 confs 和 db) - 更新 dnfs/README.md 与 confs/README.md 文档 --- confs/README.md | 9 ++++-- confs/configs/dnfs.ini | 4 --- dnfs/README.md | 45 +++++++++++++++++---------- dnfs/dnfs.py | 70 ++++++++++++++++++++++++++++-------------- 4 files changed, 81 insertions(+), 47 deletions(-) diff --git a/confs/README.md b/confs/README.md index 3e3e74a..f9295fb 100644 --- a/confs/README.md +++ b/confs/README.md @@ -236,7 +236,7 @@ except RpcError as e: | 服务 | 配置文件 | 接入方式 | |------|---------|---------| | [static](../static/) | [static.ini](configs/static.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 本地 `config.ini` 回退 | -| [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 环境变量覆盖 | +| [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | confs 强依赖(confs 不可用时 dnfs 直接退出)→ 环境变量覆盖(最高优先级,Docker 兼容) | | [db](../db/) | [db.ini](configs/db.ini) | confs 强依赖(confs 不可用时 db 直接退出)→ 环境变量覆盖(最高优先级,Docker 兼容) | static 的接入实现见 [static/fsys.py](../static/fsys.py): @@ -246,9 +246,12 @@ static 的接入实现见 [static/fsys.py](../static/fsys.py): - 配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层 dnfs 的接入实现见 [dnfs/dnfs.py](../dnfs/dnfs.py): -- 三层加载顺序:内置默认值 → confs(dnfs.ini)→ 环境变量覆盖(优先级最高) +- 两层加载顺序:confs(dnfs.ini,强依赖)→ 环境变量覆盖(优先级最高) +- confs 连接失败或 dnfs.ini 缺失必需配置项时,dnfs 直接 `sys.exit(1)` 终止,不使用默认值 +- 配置项涵盖:`[server]` host/port、`[database]` host/port/timeout、`[ai_review]` url/timeout +- confs 连接地址为引导配置:环境变量 `CONFS_HOST`/`CONFS_PORT` 优先,默认 `127.0.0.1:8010` - 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`REVIEW_URL`/`CONFS_HOST`/`CONFS_PORT` -- Docker 环境通过环境变量传入服务名(db/ai_review),覆盖 confs 中的 127.0.0.1 默认值 +- 启动顺序:confs → db → dnfs → ai_review/...(dnfs 同时依赖 confs 和 db) db 的接入实现见 [db/dbv2.py](../db/dbv2.py): - 两层加载顺序:confs(db.ini,强依赖)→ 环境变量覆盖(优先级最高) diff --git a/confs/configs/dnfs.ini b/confs/configs/dnfs.ini index 8288444..fd47bbd 100644 --- a/confs/configs/dnfs.ini +++ b/confs/configs/dnfs.ini @@ -10,7 +10,3 @@ timeout = 10 [ai_review] url = http://127.0.0.1:8007/review timeout = 10 - -[confs] -host = 127.0.0.1 -port = 8010 diff --git a/dnfs/README.md b/dnfs/README.md index 1413830..2201130 100644 --- a/dnfs/README.md +++ b/dnfs/README.md @@ -215,27 +215,32 @@ curl -X POST http://localhost:8006/update_note \ ## 配置 -dnfs 已接入 confs 统一配置服务,采用**三层 fail-safe** 加载模式(与 static 服务一致): +dnfs 已接入 confs 统一配置服务,confs 是 dnfs 启动的**强依赖**: -**加载顺序**:内置默认值 → confs 统一配置服务(dnfs.ini,优先)→ 环境变量覆盖(最高优先级) +1. **confs 统一配置服务** — 必须成功从 confs 拉取 `dnfs.ini`,否则 dnfs 启动失败直接退出(exit code 1) +2. **环境变量覆盖**(最高优先级)— Docker/部署环境通过环境变量覆盖 confs 中的值 ### 配置文件 集中托管在 [confs/configs/dnfs.ini](../confs/configs/dnfs.ini),由 confs 服务分发: -| Section | Key | 默认值 | 说明 | -|---------|-----|--------|------| -| `[server]` | `host` | `127.0.0.1` | 监听地址 | -| `[server]` | `port` | `8006` | 监听端口 | -| `[database]` | `host` | `127.0.0.1` | db 服务地址 | -| `[database]` | `port` | `8005` | db 服务端口 | -| `[database]` | `timeout` | `10` | RPC 调用超时(秒) | -| `[ai_review]` | `url` | `http://127.0.0.1:8007/review` | AI 审核接口地址 | -| `[ai_review]` | `timeout` | `10` | AI 审核请求超时(秒) | -| `[confs]` | `host` | `127.0.0.1` | confs 服务地址 | -| `[confs]` | `port` | `8010` | confs 服务端口 | +| Section | Key | 说明 | +|---------|-----|------| +| `[server]` | `host` | 监听地址 | +| `[server]` | `port` | 监听端口 | +| `[database]` | `host` | db 服务地址 | +| `[database]` | `port` | db 服务端口 | +| `[database]` | `timeout` | RPC 调用超时(秒) | +| `[ai_review]` | `url` | AI 审核接口地址 | +| `[ai_review]` | `timeout` | AI 审核请求超时(秒) | -### 环境变量(Docker 环境必需,优先级最高) +### 引导配置(confs 连接地址) + +confs 自身的连接地址不通过 confs 获取(避免循环依赖),通过以下方式确定(优先级从高到低): +- 环境变量 `CONFS_HOST` / `CONFS_PORT` +- 默认值 `127.0.0.1:8010` + +### 环境变量(最高优先级,覆盖 confs 配置值) 环境变量覆盖配置文件值,容器化部署时通过 docker-compose.yml 传入服务名: @@ -246,10 +251,16 @@ dnfs 已接入 confs 统一配置服务,采用**三层 fail-safe** 加载模 | `DB_HOST` | `db` | db 服务主机名(docker-compose 服务名) | | `DB_PORT` | `8005` | db 服务端口 | | `REVIEW_URL` | `http://ai_review:8007/review` | ai_review 服务地址 | -| `CONFS_HOST` | - | 可选,confs 容器化后使用 | -| `CONFS_PORT` | - | 可选,confs 容器化后使用 | +| `CONFS_HOST` | `confs` | confs 服务主机名(引导配置) | +| `CONFS_PORT` | `8010` | confs 服务端口(引导配置) | -配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层。本地裸机运行时 confs 不可用会自动回退到内置默认值 + 环境变量。 +### 启动顺序要求 + +**confs 必须先于 dnfs 启动**(启动顺序:confs → db → dnfs → ai_review/...)。若 confs 不可用: +- dnfs 打印致命错误到 stderr(包含 confs 地址和具体错误原因),以 exit code 1 终止 +- 不会静默使用默认值启动 +- 配置完整性校验:若 dnfs.ini 中缺少必需的 section/key 且无对应环境变量覆盖,同样报错退出 +- 启动成功时控制台打印:`配置来源: confs (dnfs.ini @ host:port)` ## 前端 diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index ff78c71..177e271 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -33,15 +33,21 @@ from log_client import logs sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from pymsg import RpcClient, RpcError # noqa: E402 -# ==================== 配置加载(三层 fail-safe) ==================== -# 加载顺序:内置默认值 → confs 统一配置服务(dnfs.ini) → 环境变量覆盖 -# 环境变量优先级最高(Docker 环境使用服务名 db/ai_review 而非 127.0.0.1) +# ==================== 配置加载(两层:confs 强依赖 → 环境变量覆盖) ==================== +# confs 是 dnfs 启动的强依赖:必须成功从 confs 拉取 dnfs.ini,否则直接退出。 +# 环境变量优先级最高(Docker 环境使用服务名 db/ai_review 而非 127.0.0.1)。 +# confs 自身的连接地址通过引导配置确定:环境变量 CONFS_HOST/CONFS_PORT 优先, +# 缺省使用 127.0.0.1:8010(与其他服务一致的约定)。 +# 引导配置:confs 连接地址(不依赖 confs 本身) +_confs_host = os.environ.get('CONFS_HOST', '127.0.0.1') +_confs_port = int(os.environ.get('CONFS_PORT', '8010')) + +# 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段 _dnfs_config = { - 'server': {'host': '127.0.0.1', 'port': '8006'}, - 'database': {'host': '127.0.0.1', 'port': '8005', 'timeout': '10'}, - 'ai_review': {'url': 'http://127.0.0.1:8007/review', 'timeout': '10'}, - 'confs': {'host': '127.0.0.1', 'port': '8010'}, + 'server': {'host': '', 'port': ''}, + 'database': {'host': '', 'port': '', 'timeout': ''}, + 'ai_review': {'url': '', 'timeout': ''}, } @@ -57,23 +63,29 @@ def _merge_ini(text): def _fetch_from_confs(): - """从 confs 统一配置服务拉取 dnfs.ini 全文,失败返回 None(fail-safe)。""" + """从 confs 统一配置服务拉取 dnfs.ini 全文,失败直接终止进程。""" try: - host = os.environ.get('CONFS_HOST', _dnfs_config['confs']['host']) - port = int(os.environ.get('CONFS_PORT', _dnfs_config['confs']['port'])) - data = RpcClient(host, port, 'dnfs', timeout=3).call( + data = RpcClient(_confs_host, _confs_port, 'dnfs', timeout=5).call( 'get', {'name': 'dnfs.ini'}) return data.get('content') - except Exception: - return None + except (ConnectionRefusedError, ConnectionError, OSError) as e: + print(f'[dnfs] 致命错误: 无法连接 confs 服务 ({_confs_host}:{_confs_port}): {e}', + file=sys.stderr) + print(f'[dnfs] 请确认 confs 服务已启动并监听 {_confs_host}:{_confs_port}', + file=sys.stderr) + sys.exit(1) + except RpcError as e: + print(f'[dnfs] 致命错误: 从 confs 获取 dnfs.ini 失败: {e}', + file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f'[dnfs] 致命错误: confs 配置拉取异常: {e}', + file=sys.stderr) + sys.exit(1) _confs_text = _fetch_from_confs() -if _confs_text is not None: - _merge_ini(_confs_text) - logs.info("dnfs", "配置来源: confs 统一配置服务 (dnfs.ini)") -else: - logs.warning("dnfs", "confs 不可用,使用内置默认值 + 环境变量") +_merge_ini(_confs_text) # 环境变量覆盖(优先级最高,Docker 环境需要) if os.environ.get('LISTEN_ADDR'): @@ -86,20 +98,32 @@ if os.environ.get('DB_PORT'): _dnfs_config['database']['port'] = os.environ['DB_PORT'] if os.environ.get('REVIEW_URL'): _dnfs_config['ai_review']['url'] = os.environ['REVIEW_URL'] -if os.environ.get('CONFS_HOST'): - _dnfs_config['confs']['host'] = os.environ['CONFS_HOST'] -if os.environ.get('CONFS_PORT'): - _dnfs_config['confs']['port'] = os.environ['CONFS_PORT'] + +# 配置完整性校验 +_required = { + 'server': ['host', 'port'], + 'database': ['host', 'port', 'timeout'], + 'ai_review': ['url', 'timeout'], +} +for section, keys in _required.items(): + for key in keys: + if not _dnfs_config[section][key]: + print(f'[dnfs] 致命错误: 配置项 [{section}] {key} 缺失(dnfs.ini 中未配置且无环境变量覆盖)', + file=sys.stderr) + sys.exit(1) # 提取配置常量 +_DB_TIMEOUT = int(_dnfs_config['database']['timeout']) _DBV2_ADDR = _dnfs_config['database']['host'] _DBV2_PORT = int(_dnfs_config['database']['port']) -_DB_TIMEOUT = int(_dnfs_config['database']['timeout']) _REVIEW_SERVER_URL = _dnfs_config['ai_review']['url'] _REVIEW_TIMEOUT = int(_dnfs_config['ai_review']['timeout']) _ADDR = _dnfs_config['server']['host'] _PORT = int(_dnfs_config['server']['port']) +_config_source = f'confs (dnfs.ini @ {_confs_host}:{_confs_port})' +print(f'[dnfs] 配置来源: {_config_source}') + # 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload # 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。 # 旧 Token 无此字段,按非管理员处理(安全方向)。 From 1a5e9f7064898c27444ffed8b4795a0ac286635c Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 12:34:53 +0800 Subject: [PATCH 05/22] =?UTF-8?q?feat(static):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=99=BB=E5=BD=95=E6=80=81=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E4=B8=8E=E8=BF=9B=E5=85=A5=E7=AC=94=E8=AE=B0=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.html: lang 改为 zh-CN,新增"关于本站"介绍段落,按钮改为有 id 的登录/注册/进入/退出四按钮(默认隐藏进入/退出) - 新增 index.js: POST /whoami 检测登录态(HttpOnly Cookie 自动携带),已登录则显示用户名+进入笔记/退出按钮,未登录显示登录/注册 - 退出按钮调用 POST /logout 后刷新页面 --- static/public/index.html | 24 +++++++++++++++++----- static/public/js/index.js | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/static/public/index.html b/static/public/index.html index 608b0f5..d86131d 100644 --- a/static/public/index.html +++ b/static/public/index.html @@ -1,5 +1,5 @@ - + @@ -24,6 +24,13 @@ margin-left: 5px; margin-right: 5px; } + .site-intro { + max-width: 640px; + margin: 0 auto; + font-size: 0.95rem; + line-height: 1.9; + color: var(--fg-secondary); + } DNFS 欢迎来到树状笔记 @@ -33,16 +40,23 @@
头像加载失败 DNFS - - + + + +
-

推荐节点

- +

关于本站

+

+ DNFS 树状笔记是一个以树状结构组织内容的笔记社区:每个节点都可以生长出子节点, + 像树枝一样层层延展,让知识自然成体系。支持 Markdown 文本、链接卡片、图片与文件分享, + 注册登录后即可创建属于你自己的知识树。 +

+ \ No newline at end of file diff --git a/static/public/js/index.js b/static/public/js/index.js index e69de29..15c6706 100644 --- a/static/public/js/index.js +++ b/static/public/js/index.js @@ -0,0 +1,42 @@ +/* index.js — 社区首页逻辑 + * + * 登录态检测:POST /whoami(HttpOnly q4_token Cookie 自动携带,JS 不经手 Token) + * 已登录:显示用户名与"进入笔记/退出"按钮;未登录:显示"登录/注册" + */ +(function () { + 'use strict'; + + const nameEl = document.getElementById('name'); + const enterBtn = document.getElementById('enterBtn'); + const logoutBtn = document.getElementById('logoutBtn'); + const loginBtn = document.getElementById('loginBtn'); + const registerBtn = document.getElementById('registerBtn'); + + async function checkLogin() { + try { + const res = await fetch('/whoami', { method: 'POST' }); + if (!res.ok) return; + const data = await res.json(); + if (data && data.login && data.user) { + nameEl.textContent = String(data.user); + enterBtn.style.display = ''; + logoutBtn.style.display = ''; + loginBtn.style.display = 'none'; + registerBtn.style.display = 'none'; + } + } catch (e) { + console.warn('index: 登录态检测失败', e.message); + } + } + + logoutBtn.addEventListener('click', async function () { + try { + await fetch('/logout', { method: 'POST' }); + } catch (e) { + console.warn('index: 退出登录请求异常', e.message); + } + location.reload(); + }); + + checkLogin(); +})(); From de0e4168b28d0014035e4317d2fadf96fac0a60b Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 12:37:19 +0800 Subject: [PATCH 06/22] =?UTF-8?q?refactor(static):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E5=86=85=E8=81=94=E6=A0=B7=E5=BC=8F=E8=A7=A3=E8=80=A6=E8=87=B3?= =?UTF-8?q?=20css/index.css?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增页面级样式文件 css/index.css(与 view.css/aibox.css 惯例一致) - index.html 移除内联 + DNFS 欢迎来到树状笔记

DNFS 树状笔记

- 头像加载失败 + 头像加载失败 DNFS From 0ae107f9539f966a9d84d50b963803fc224003d0 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 12:38:51 +0800 Subject: [PATCH 07/22] =?UTF-8?q?refactor(static):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E9=80=BB=E8=BE=91=EF=BC=8Cconfs=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=BC=BA=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配置加载从三层(内置默认值→confs→本地config.ini回退)改为两层(confs 强依赖→环境变量覆盖) - confs 连接失败直接 sys.exit(1),不再静默使用内置默认值或回退本地 config.ini - file_config 初始值改为空字符串白名单模板,移除所有内置默认值 - 新增配置完整性校验:server.host/port、directory.root、cors.allowed_origins 必须非空 - ssl 段保留合理默认(enabled=false、cert/key 默认文件名),不强制配置 - 区分错误类型:连接拒绝/RpcError/通用异常分别打印明确错误信息到 stderr - confs 连接地址改为引导配置(环境变量 CONFS_HOST/CONFS_PORT 优先,默认 127.0.0.1:8010) - 移除 --config 命令行参数(本地 config.ini 回退已删除) - 删除 static/config.ini 本地回退文件 - 更新 _run.sh 移除 --config 参数 - 更新 static/README.md 与 confs/README.md 文档 - confs 集成关系描述更新为"confs 最先启动" --- confs/README.md | 14 ++++--- static/README.md | 58 +++++++++++++++++++-------- static/_run.sh | 4 +- static/config.ini | 17 -------- static/fsys.py | 100 ++++++++++++++++++++++++++++++++-------------- 5 files changed, 121 insertions(+), 72 deletions(-) delete mode 100644 static/config.ini diff --git a/confs/README.md b/confs/README.md index f9295fb..1a393d0 100644 --- a/confs/README.md +++ b/confs/README.md @@ -227,7 +227,7 @@ except RpcError as e: ## 集成关系 -- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/confs/_run.sh` 拉起并守护(需先于 db 启动,db 对 confs 有强依赖) +- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/confs/_run.sh` 拉起并守护(confs 最先启动,所有依赖 confs 的服务必须在 confs 就绪后启动;confs 日志推送 db 为异步 fail-safe,db 不可用时静默丢弃,不阻塞 confs 启动) - 各内部服务通过 pymsg `RpcClient` 调 `get` 路由按需获取配置,替代本地散落的 ini/json/yaml - 自身日志通过 `log_client.py` 异步推送至 `db:8005` 的 Logs 表(与其他服务一致) @@ -235,15 +235,17 @@ except RpcError as e: | 服务 | 配置文件 | 接入方式 | |------|---------|---------| -| [static](../static/) | [static.ini](configs/static.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 本地 `config.ini` 回退 | +| [static](../static/) | [static.ini](configs/static.ini) | confs 强依赖(confs 不可用时 static 直接退出)→ 环境变量覆盖(最高优先级,Docker 兼容) | | [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | confs 强依赖(confs 不可用时 dnfs 直接退出)→ 环境变量覆盖(最高优先级,Docker 兼容) | | [db](../db/) | [db.ini](configs/db.ini) | confs 强依赖(confs 不可用时 db 直接退出)→ 环境变量覆盖(最高优先级,Docker 兼容) | static 的接入实现见 [static/fsys.py](../static/fsys.py): -- `_fetch_from_confs()` 通过 `RpcClient` 调 `get` 路由取 `static.ini`,3 秒超时 -- `CONFS_HOST` / `CONFS_PORT` 环境变量定位 confs(默认 `127.0.0.1:8010`) -- `_merge_ini()` 仅覆盖已知的 section/key(白名单合并,不引入未预期字段) -- 配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层 +- 两层加载顺序:confs(static.ini,强依赖)→ 环境变量覆盖(优先级最高) +- confs 连接失败或 static.ini 缺失必需配置项时,static 直接 `sys.exit(1)` 终止,不使用默认值或回退本地文件 +- 配置项涵盖:`[server]` host/port、`[directory]` root、`[ssl]` enabled/cert_file/key_file(ssl 有默认值)、`[cors]` allowed_origins +- confs 连接地址为引导配置:环境变量 `CONFS_HOST`/`CONFS_PORT` 优先,默认 `127.0.0.1:8010` +- 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`STATIC_ROOT`/`CONFS_HOST`/`CONFS_PORT` +- 启动顺序:confs 必须先于 static 启动;已删除 `--config` 命令行参数和本地 `config.ini` 回退逻辑 dnfs 的接入实现见 [dnfs/dnfs.py](../dnfs/dnfs.py): - 两层加载顺序:confs(dnfs.ini,强依赖)→ 环境变量覆盖(优先级最高) diff --git a/static/README.md b/static/README.md index 06ac0d7..ec49623 100644 --- a/static/README.md +++ b/static/README.md @@ -90,26 +90,52 @@ static :9200 (Flask + CORS) ## 配置 -`config.ini`: +static 已接入 confs 统一配置服务,confs 是 static 启动的**强依赖**: -```ini -[server] -host = 127.0.0.1 -port = 9200 +1. **confs 统一配置服务** — 必须成功从 confs 拉取 `static.ini`,否则 static 启动失败直接退出(exit code 1) +2. **环境变量覆盖**(最高优先级)— Docker/部署环境通过环境变量覆盖 confs 中的值 -[directory] -root = ./public +### 配置文件 -[ssl] -enabled = false -cert_file = cert.pem -key_file = key.pem +集中托管在 [confs/configs/static.ini](../confs/configs/static.ini),由 confs 服务分发: -[cors] -# 允许跨域的 Origin 白名单(逗号分隔) -# 注意:生产环境请勿包含 localhost / 127.0.0.1 / 通配符 * -allowed_origins = https://qsont.me,https://yhdet.top -``` +| Section | Key | 说明 | +|---------|-----|------| +| `[server]` | `host` | 监听地址 | +| `[server]` | `port` | 监听端口 | +| `[directory]` | `root` | 静态文件根目录(相对 BASE_DIR) | +| `[ssl]` | `enabled` | 是否启用 TLS(`true`/`false`,默认 `false`) | +| `[ssl]` | `cert_file` | SSL 证书路径(默认 `cert.pem`) | +| `[ssl]` | `key_file` | SSL 私钥路径(默认 `key.pem`) | +| `[cors]` | `allowed_origins` | CORS 白名单,逗号分隔 | + +### 引导配置(confs 连接地址) + +confs 自身的连接地址不通过 confs 获取(避免循环依赖),通过以下方式确定(优先级从高到低): +- 环境变量 `CONFS_HOST` / `CONFS_PORT` +- 默认值 `127.0.0.1:8010` + +### 环境变量(最高优先级,覆盖 confs 配置值) + +| 变量 | 说明 | +|------|------| +| `LISTEN_ADDR` | 监听地址(容器内需绑定 `0.0.0.0`) | +| `LISTEN_PORT` | 监听端口 | +| `STATIC_ROOT` | 静态文件根目录 | +| `CONFS_HOST` | confs 服务主机名(引导配置,Docker 环境使用服务名) | +| `CONFS_PORT` | confs 服务端口(引导配置) | + +### 启动顺序要求 + +**confs 必须先于 static 启动**。若 confs 不可用: +- static 打印致命错误到 stderr(包含 confs 地址和具体错误原因),以 exit code 1 终止 +- 不会静默使用默认值或回退本地配置文件启动 +- 配置完整性校验:若 static.ini 中缺少必需的 section/key(server.host/port、directory.root、cors.allowed_origins)且无对应环境变量覆盖,同样报错退出 +- ssl 段为可选项,未配置时默认 `enabled=false`、cert/key 使用默认文件名 + +### 历史遗留:本地 config.ini 已删除 + +旧版本的 `static/config.ini` 本地回退逻辑已移除,配置统一由 confs 服务分发。 ## 依赖 diff --git a/static/_run.sh b/static/_run.sh index 1f44b63..dc49454 100644 --- a/static/_run.sh +++ b/static/_run.sh @@ -1,5 +1,5 @@ #!/bin/bash cd "$(dirname "$0")" || exit source ./venv/bin/activate -#exec ./fsys.bin --config config.ini 1>/dev/null -exec python3 ./fsys.py --config config.ini 1>/dev/null +#exec ./fsys.bin 1>/dev/null +exec python3 ./fsys.py 1>/dev/null diff --git a/static/config.ini b/static/config.ini deleted file mode 100644 index fbccc07..0000000 --- a/static/config.ini +++ /dev/null @@ -1,17 +0,0 @@ -[server] -host = 127.0.0.1 -port = 9200 - -[directory] -root = ./public - -[ssl] -enabled = false -cert_file = cert.pem -key_file = key.pem - -[cors] -# 允许跨域的 Origin 白名单(逗号分隔) -# 注意:生产环境请勿包含 localhost / 127.0.0.1 / 通配符 * -# 本地开发联调时可临时追加 http://localhost:9200,调试完务必移除 -allowed_origins = https://qsont.me,https://yhdet.top diff --git a/static/fsys.py b/static/fsys.py index f2542e2..ae56e99 100644 --- a/static/fsys.py +++ b/static/fsys.py @@ -36,33 +36,35 @@ file_handler.setLevel(logging.INFO) logger.addHandler(file_handler) parser = argparse.ArgumentParser(description='File Server') -parser.add_argument('--config', type=str, default='config.ini', help='Configuration file path') args = parser.parse_args() app = Flask(__name__) apply_security_headers(app) -# ==================== 配置文件加载 ==================== -# 加载顺序:内置默认值 → confs 统一配置服务(static.ini); -# confs 不可用时 fail-safe 回退本地 config.ini -CONFIG_FILE = args.config -if not os.path.isabs(CONFIG_FILE): - CONFIG_FILE = os.path.join(BASE_DIR, CONFIG_FILE) +# ==================== 配置加载(两层:confs 强依赖 → 环境变量覆盖) ==================== +# confs 是 static 启动的强依赖:必须成功从 confs 拉取 static.ini,否则直接退出。 +# 环境变量优先级最高(Docker 兼容),可覆盖 confs 中的配置值。 +# confs 自身的连接地址通过引导配置确定:环境变量 CONFS_HOST/CONFS_PORT 优先, +# 缺省使用 127.0.0.1:8010(与其他服务一致的约定)。 +# 引导配置:confs 连接地址(不依赖 confs 本身) +_confs_host = os.environ.get('CONFS_HOST', '127.0.0.1') +_confs_port = int(os.environ.get('CONFS_PORT', '8010')) + +# 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段 file_config = { - 'server': {'host': '0.0.0.0', 'port': '5000'}, - 'directory': {'root': './public'}, - 'ssl': {'enabled': 'false', 'cert_file': 'cert.pem', 'key_file': 'key.pem'}, - # CORS 白名单(逗号分隔),生产环境默认值;localhost 仅限本地开发时显式写入 config.ini - 'cors': {'allowed_origins': 'https://qsont.me,https://yhdet.top'}, + 'server': {'host': '', 'port': ''}, + 'directory': {'root': ''}, + 'ssl': {'enabled': '', 'cert_file': '', 'key_file': ''}, + 'cors': {'allowed_origins': ''}, } def _merge_ini(text): """把 ini 文本合并进 file_config(仅覆盖已知的 section/key)。""" - ini = configparser.ConfigParser() + ini = configparser.ConfigParser(interpolation=None) ini.read_string(text) - for section in ['server', 'directory', 'ssl', 'cors']: + for section in file_config: if ini.has_section(section): for key, value in ini[section].items(): if key in file_config[section]: @@ -70,28 +72,64 @@ def _merge_ini(text): def _fetch_from_confs(): - """从 confs 统一配置服务拉取 static.ini 全文,失败返回 None(fail-safe)。""" + """从 confs 统一配置服务拉取 static.ini 全文,失败直接终止进程。""" + from pymsg import RpcClient, RpcError # noqa: E402 try: - from pymsg import RpcClient - host = os.environ.get('CONFS_HOST', '127.0.0.1') - port = int(os.environ.get('CONFS_PORT', '8010')) - data = RpcClient(host, port, 'static', timeout=3).call( - 'get', {'name': 'static.ini'}) + client = RpcClient(_confs_host, _confs_port, 'static', timeout=5) + data = client.call('get', {'name': 'static.ini'}) return data.get('content') - except Exception: - return None + except (ConnectionRefusedError, ConnectionError, OSError) as e: + print(f'[static] 致命错误: 无法连接 confs 服务 ({_confs_host}:{_confs_port}): {e}', + file=sys.stderr) + print(f'[static] 请确认 confs 服务已启动并监听 {_confs_host}:{_confs_port}', + file=sys.stderr) + sys.exit(1) + except RpcError as e: + print(f'[static] 致命错误: 从 confs 获取 static.ini 失败: {e}', + file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f'[static] 致命错误: confs 配置拉取异常: {e}', + file=sys.stderr) + sys.exit(1) _confs_text = _fetch_from_confs() -if _confs_text is not None: - _merge_ini(_confs_text) - logger.info("配置来源: confs 统一配置服务 (static.ini)") - logs.info("static", "配置来源: confs 统一配置服务 (static.ini)") -elif os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, encoding='utf-8') as f: - _merge_ini(f.read()) - logger.warning(f"confs 不可用,回退本地配置文件 {CONFIG_FILE}") - logs.warning("static", f"confs 不可用,回退本地配置文件 {CONFIG_FILE}") +_merge_ini(_confs_text) + +# 环境变量覆盖(优先级最高,Docker 兼容) +if os.environ.get('LISTEN_ADDR'): + file_config['server']['host'] = os.environ['LISTEN_ADDR'] +if os.environ.get('LISTEN_PORT'): + file_config['server']['port'] = os.environ['LISTEN_PORT'] +if os.environ.get('STATIC_ROOT'): + file_config['directory']['root'] = os.environ['STATIC_ROOT'] + +# 配置完整性校验 +_required = { + 'server': ['host', 'port'], + 'directory': ['root'], + 'cors': ['allowed_origins'], +} +for section, keys in _required.items(): + for key in keys: + if not file_config[section][key]: + print(f'[static] 致命错误: 配置项 [{section}] {key} 缺失(static.ini 中未配置且无环境变量覆盖)', + file=sys.stderr) + sys.exit(1) + +# ssl 段有合理默认(enabled=false,cert/key 有默认文件名),不强制要求配置 +if not file_config['ssl']['enabled']: + file_config['ssl']['enabled'] = 'false' +if not file_config['ssl']['cert_file']: + file_config['ssl']['cert_file'] = 'cert.pem' +if not file_config['ssl']['key_file']: + file_config['ssl']['key_file'] = 'key.pem' + +_config_source = f'confs (static.ini @ {_confs_host}:{_confs_port})' +print(f'[static] 配置来源: {_config_source}') +logger.info(f"配置来源: {_config_source}") +logs.info("static", f"配置来源: {_config_source}") # ==================== CORS(配置加载完成后初始化) ==================== _cors_origins = [o.strip() for o in file_config['cors']['allowed_origins'].split(',') if o.strip()] From 101aed48be5bc123ac0ecb23c49677fe1d43fadc Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 12:47:08 +0800 Subject: [PATCH 08/22] =?UTF-8?q?feat(pymsg):=20RpcClient=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20retry=5Fon=5Frefused=20=E9=98=BB=E5=A1=9E=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E6=A8=A1=E5=BC=8F=EF=BC=8C=E8=A7=A3=E5=86=B3=E5=BE=AE?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=90=AF=E5=8A=A8=E9=A1=BA=E5=BA=8F=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pymsg 客户端: - MsgClient.call() 新增 retry_on_refused/retry_interval 参数 - retry_on_refused=True 时,ConnectionRefusedError 不抛出,按 retry_interval(默认1s)阻塞重试 - 重试期间每5秒向 stderr 打印等待提示,避免日志刷屏 - 仅对 ConnectionRefusedError 重试;TimeoutError/ConnectionError/RpcError 仍立即抛出 - 默认 retry_on_refused=False,完全向后兼容 - RpcClient 构造函数新增同名关键字参数,call() 支持单次调用覆盖 - 新增 time 模块导入 - 更新 README 文档(新增"启动等待模式"章节和参数表) 各服务(db/dnfs/static): - _fetch_from_confs() 改用 retry_on_refused=True,移除手动 ConnectionRefusedError 捕获和 sys.exit - 保留 RpcError(如配置文件不存在)的友好错误处理和 sys.exit(1) - 启动时不再因 confs 未就绪而退出:pymsg 自动阻塞等待 confs 启动后自动连接成功 - 启动顺序从"刚性要求 confs 先启动"变为"不严格要求启动顺序" - log_client 保持默认 retry_on_refused=False,后台线程不阻塞(fail-safe 丢弃日志) - 更新各服务 README 和 confs README 的启动顺序描述 测试验证: - 基本 call() 正常工作 - retry_on_refused=False 连接拒绝立即抛 ConnectionRefusedError - retry_on_refused=True 阻塞等待(延迟1.5s启动服务后成功连接,重试日志正常输出) --- confs/README.md | 19 ++++++++------- db/README.md | 12 +++++----- db/dbv2.py | 22 ++++++++---------- dnfs/README.md | 12 ++++++---- dnfs/dnfs.py | 23 ++++++++----------- pymsg/README.md | 32 +++++++++++++++++++++++++- pymsg/pymsg/__init__.py | 51 ++++++++++++++++++++++++++++++----------- pymsg/pymsg/rpc.py | 22 +++++++++++++++--- static/README.md | 10 ++++---- static/fsys.py | 22 ++++++++---------- 10 files changed, 141 insertions(+), 84 deletions(-) diff --git a/confs/README.md b/confs/README.md index 1a393d0..7a6a4be 100644 --- a/confs/README.md +++ b/confs/README.md @@ -227,7 +227,7 @@ except RpcError as e: ## 集成关系 -- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/confs/_run.sh` 拉起并守护(confs 最先启动,所有依赖 confs 的服务必须在 confs 就绪后启动;confs 日志推送 db 为异步 fail-safe,db 不可用时静默丢弃,不阻塞 confs 启动) +- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/confs/_run.sh` 拉起并守护(confs 最先启动即可;各业务服务通过 pymsg `retry_on_refused=True` 在拉取配置时自动阻塞等待 confs 就绪,不严格要求启动顺序;confs 日志推送 db 为异步 fail-safe,db 不可用时静默丢弃,不阻塞 confs 启动) - 各内部服务通过 pymsg `RpcClient` 调 `get` 路由按需获取配置,替代本地散落的 ini/json/yaml - 自身日志通过 `log_client.py` 异步推送至 `db:8005` 的 Logs 表(与其他服务一致) @@ -235,33 +235,32 @@ except RpcError as e: | 服务 | 配置文件 | 接入方式 | |------|---------|---------| -| [static](../static/) | [static.ini](configs/static.ini) | confs 强依赖(confs 不可用时 static 直接退出)→ 环境变量覆盖(最高优先级,Docker 兼容) | -| [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | confs 强依赖(confs 不可用时 dnfs 直接退出)→ 环境变量覆盖(最高优先级,Docker 兼容) | -| [db](../db/) | [db.ini](configs/db.ini) | confs 强依赖(confs 不可用时 db 直接退出)→ 环境变量覆盖(最高优先级,Docker 兼容) | +| [static](../static/) | [static.ini](configs/static.ini) | confs 强依赖(pymsg 自动阻塞等待 confs 就绪)→ 环境变量覆盖(最高优先级,Docker 兼容) | +| [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | confs 强依赖(pymsg 自动阻塞等待 confs 就绪)→ 环境变量覆盖(最高优先级,Docker 兼容) | +| [db](../db/) | [db.ini](configs/db.ini) | confs 强依赖(pymsg 自动阻塞等待 confs 就绪)→ 环境变量覆盖(最高优先级,Docker 兼容) | static 的接入实现见 [static/fsys.py](../static/fsys.py): - 两层加载顺序:confs(static.ini,强依赖)→ 环境变量覆盖(优先级最高) -- confs 连接失败或 static.ini 缺失必需配置项时,static 直接 `sys.exit(1)` 终止,不使用默认值或回退本地文件 +- pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪(每秒重试,每 5 秒打印等待提示),不会因启动顺序导致进程退出 +- static.ini 缺失必需配置项时仍然报错退出(配置错误,非启动时序问题) - 配置项涵盖:`[server]` host/port、`[directory]` root、`[ssl]` enabled/cert_file/key_file(ssl 有默认值)、`[cors]` allowed_origins - confs 连接地址为引导配置:环境变量 `CONFS_HOST`/`CONFS_PORT` 优先,默认 `127.0.0.1:8010` - 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`STATIC_ROOT`/`CONFS_HOST`/`CONFS_PORT` -- 启动顺序:confs 必须先于 static 启动;已删除 `--config` 命令行参数和本地 `config.ini` 回退逻辑 +- 已删除 `--config` 命令行参数和本地 `config.ini` 回退逻辑 dnfs 的接入实现见 [dnfs/dnfs.py](../dnfs/dnfs.py): - 两层加载顺序:confs(dnfs.ini,强依赖)→ 环境变量覆盖(优先级最高) -- confs 连接失败或 dnfs.ini 缺失必需配置项时,dnfs 直接 `sys.exit(1)` 终止,不使用默认值 +- pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪,不严格要求启动顺序 - 配置项涵盖:`[server]` host/port、`[database]` host/port/timeout、`[ai_review]` url/timeout - confs 连接地址为引导配置:环境变量 `CONFS_HOST`/`CONFS_PORT` 优先,默认 `127.0.0.1:8010` - 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`REVIEW_URL`/`CONFS_HOST`/`CONFS_PORT` -- 启动顺序:confs → db → dnfs → ai_review/...(dnfs 同时依赖 confs 和 db) db 的接入实现见 [db/dbv2.py](../db/dbv2.py): - 两层加载顺序:confs(db.ini,强依赖)→ 环境变量覆盖(优先级最高) -- confs 连接失败或 db.ini 缺失必需配置项时,db 直接 `sys.exit(1)` 终止,不使用默认值 +- pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪,不严格要求启动顺序 - 配置项涵盖:`[server]` host/port/workers、`[database]` url、`[logs]` retention_days/max_records/cleanup_interval - confs 连接地址为引导配置:环境变量 `CONFS_HOST`/`CONFS_PORT` 优先,默认 `127.0.0.1:8010` - 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`WORKERS`/`DATABASE_URL`/`CONFS_HOST`/`CONFS_PORT` -- 启动顺序:confs 必须先于 db 启动(confs → db → ai_review/...) - 独立运维工具 `db_tools.py`(backup/export/import 等)仍直接读 `DATABASE_URL` 环境变量,不依赖 confs,避免运维脚本依赖服务就绪 其他服务(gateway / waf / ai_review / smtp)尚未接入,仍读各自本地配置。 diff --git a/db/README.md b/db/README.md index cc3da2d..4cd8898 100644 --- a/db/README.md +++ b/db/README.md @@ -72,13 +72,13 @@ confs 自身的连接地址不通过 confs 获取(避免循环依赖),通 | `CONFS_HOST` | confs 服务地址(引导配置) | — | | `CONFS_PORT` | confs 服务端口(引导配置) | — | -### 启动顺序要求 +### 启动顺序 -**confs 必须先于 db 启动**(启动顺序调整为:confs → db → ai_review/...)。若 confs 不可用: -- db 打印致命错误到 stderr(包含 confs 地址和具体错误原因),以 exit code 1 终止 -- 不会静默使用默认值启动 -- 配置完整性校验:若 db.ini 中缺少必需的 section/key 且无对应环境变量覆盖,同样报错退出 -- 启动成功时控制台打印:`配置来源: confs 统一配置服务 (db.ini @ host:port)` +db 通过 pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪,**不严格要求启动顺序**: +- confs 先启动 → db 立即拉取配置,正常启动 +- db 先启动 → pymsg 自动重试(每秒一次,每 5 秒打印等待提示),confs 启动后自动连接成功 +- 配置完整性校验:若 db.ini 中缺少必需的 section/key 且无对应环境变量覆盖,仍然报错退出 +- 启动成功时控制台打印:`配置来源: confs (db.ini @ host:port)` > 独立运维工具 `db_tools.py`(backup/export/import/vacuum 等)仍直接读 `DATABASE_URL` 环境变量,不依赖 confs 服务就绪。 > Docker 环境通过 `docker-entrypoint.sh` 传入环境变量(见 [Dockerfile](Dockerfile)),需确保 `CONFS_HOST`/`CONFS_PORT` 指向 confs 服务地址。 diff --git a/db/dbv2.py b/db/dbv2.py index b0123af..efd124f 100644 --- a/db/dbv2.py +++ b/db/dbv2.py @@ -82,26 +82,22 @@ def _merge_ini(text): def _fetch_from_confs(): - """从 confs 统一配置服务拉取 db.ini 全文,失败直接终止进程。""" + """从 confs 统一配置服务拉取 db.ini 全文。 + + 使用 retry_on_refused=True:confs 未就绪时 pymsg 自动阻塞重试(每秒一次, + 每 5 秒打印一次等待提示),直到 confs 启动后自动连接成功。 + RpcError(如配置文件不存在)仍直接报错退出——这是配置错误而非启动时序问题。 + """ from pymsg import RpcClient, RpcError # noqa: E402 + client = RpcClient(_confs_host, _confs_port, 'db', timeout=5, + retry_on_refused=True) try: - client = RpcClient(_confs_host, _confs_port, 'db', timeout=5) data = client.call('get', {'name': 'db.ini'}) - return data.get('content') - except (ConnectionRefusedError, ConnectionError, OSError) as e: - print(f'[dbv2] 致命错误: 无法连接 confs 服务 ({_confs_host}:{_confs_port}): {e}', - file=_sys.stderr) - print(f'[dbv2] 请确认 confs 服务已启动并监听 {_confs_host}:{_confs_port}', - file=_sys.stderr) - _sys.exit(1) except RpcError as e: print(f'[dbv2] 致命错误: 从 confs 获取 db.ini 失败: {e}', file=_sys.stderr) _sys.exit(1) - except Exception as e: - print(f'[dbv2] 致命错误: confs 配置拉取异常: {e}', - file=_sys.stderr) - _sys.exit(1) + return data.get('content') _confs_text = _fetch_from_confs() diff --git a/dnfs/README.md b/dnfs/README.md index 2201130..c7de774 100644 --- a/dnfs/README.md +++ b/dnfs/README.md @@ -254,14 +254,16 @@ confs 自身的连接地址不通过 confs 获取(避免循环依赖),通 | `CONFS_HOST` | `confs` | confs 服务主机名(引导配置) | | `CONFS_PORT` | `8010` | confs 服务端口(引导配置) | -### 启动顺序要求 +### 启动顺序 -**confs 必须先于 dnfs 启动**(启动顺序:confs → db → dnfs → ai_review/...)。若 confs 不可用: -- dnfs 打印致命错误到 stderr(包含 confs 地址和具体错误原因),以 exit code 1 终止 -- 不会静默使用默认值启动 -- 配置完整性校验:若 dnfs.ini 中缺少必需的 section/key 且无对应环境变量覆盖,同样报错退出 +dnfs 通过 pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪,**拉取配置阶段不严格要求启动顺序**: +- confs 先启动 → dnfs 立即拉取配置 +- dnfs 先启动 → pymsg 自动重试(每秒一次,每 5 秒打印等待提示),confs 启动后自动连接成功 +- 配置完整性校验:若 dnfs.ini 中缺少必需的 section/key 且无对应环境变量覆盖,仍然报错退出 - 启动成功时控制台打印:`配置来源: confs (dnfs.ini @ host:port)` +> 注意:dnfs 对 db/ai_review 的 RPC 调用是运行时按需连接(首次请求时才建立 TCP),db/ai_review 未就绪时相关 API 返回 502 但进程不退出;待依赖服务启动后自动恢复。 + ## 前端 三文件架构: diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 177e271..9d19daa 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -63,25 +63,20 @@ def _merge_ini(text): def _fetch_from_confs(): - """从 confs 统一配置服务拉取 dnfs.ini 全文,失败直接终止进程。""" + """从 confs 统一配置服务拉取 dnfs.ini 全文。 + + 使用 retry_on_refused=True:confs 未就绪时 pymsg 自动阻塞重试(每秒一次, + 每 5 秒打印一次等待提示),直到 confs 启动后自动连接成功。 + RpcError(如配置文件不存在)仍直接报错退出——这是配置错误而非启动时序问题。 + """ try: - data = RpcClient(_confs_host, _confs_port, 'dnfs', timeout=5).call( - 'get', {'name': 'dnfs.ini'}) - return data.get('content') - except (ConnectionRefusedError, ConnectionError, OSError) as e: - print(f'[dnfs] 致命错误: 无法连接 confs 服务 ({_confs_host}:{_confs_port}): {e}', - file=sys.stderr) - print(f'[dnfs] 请确认 confs 服务已启动并监听 {_confs_host}:{_confs_port}', - file=sys.stderr) - sys.exit(1) + data = RpcClient(_confs_host, _confs_port, 'dnfs', timeout=5, + retry_on_refused=True).call('get', {'name': 'dnfs.ini'}) except RpcError as e: print(f'[dnfs] 致命错误: 从 confs 获取 dnfs.ini 失败: {e}', file=sys.stderr) sys.exit(1) - except Exception as e: - print(f'[dnfs] 致命错误: confs 配置拉取异常: {e}', - file=sys.stderr) - sys.exit(1) + return data.get('content') _confs_text = _fetch_from_confs() diff --git a/pymsg/README.md b/pymsg/README.md index ad76e58..3e84706 100644 --- a/pymsg/README.md +++ b/pymsg/README.md @@ -110,6 +110,20 @@ resp = client.call('{"action": "select", "table": "Users"}', timeout=10.0) 目标不可达时抛出 `ConnectionRefusedError`,超时无回复时抛出 `TimeoutError`。 +**启动等待模式(retry_on_refused)**: + +```python +# 服务启动时阻塞等待下游服务就绪,连接被拒绝时自动重试 +resp = client.call('{"_route": "get", "name": "db.ini"}', + timeout=5.0, + retry_on_refused=True, # 连接拒绝时阻塞重试 + retry_interval=1.0) # 重试间隔 1 秒(默认) +``` + +`retry_on_refused=True` 时,`ConnectionRefusedError` 不会抛出,而是按 `retry_interval`(默认 1.0 秒)间隔不断重试,直到连接成功。重试期间每 5 秒向 stderr 打印一次等待提示(如 `[pymsg] 等待服务 127.0.0.1:8010 就绪... (已重试 N 次)`),避免日志刷屏。仅对 `ConnectionRefusedError` 重试;`TimeoutError`、`ConnectionError`、业务错误等仍立即抛出。 + +适用场景:微服务启动顺序不确定时,服务进程可在不依赖外部进程管理器的情况下自动等待下游依赖就绪,无需手动 sys.exit + 外部重启。`log_client` 等 fail-safe 场景应保持默认 `retry_on_refused=False`,避免后台线程阻塞。 + **3. 长连接监听(回调模式)** ```python @@ -226,7 +240,23 @@ except RpcError as e: ... # 服务端业务错误(success=false) ``` -`call(route, payload=None, *, timeout=None)`:自动注入 `_route`、JSON 编解码、检查 `success`,返回 `data` 字段;业务错误抛 `RpcError`,网络故障仍抛 `ConnectionRefusedError` / `TimeoutError`(来自 `MsgClient`)。 +| 构造参数 | 含义 | +|----------|------| +| `addr` / `port` | 服务端地址与端口 | +| `name` | 客户端标识(透传 `MsgClient`) | +| `timeout` | 默认调用超时秒数(默认 `10.0`) | +| `retry_on_refused` | 连接被拒绝时是否阻塞重试(关键字参数,默认 `False`) | +| `retry_interval` | 重试间隔秒数(关键字参数,默认 `1.0`) | + +`call(route, payload=None, *, timeout=None, retry_on_refused=None, retry_interval=None)`:自动注入 `_route`、JSON 编解码、检查 `success`,返回 `data` 字段;业务错误抛 `RpcError`,网络故障仍抛 `ConnectionRefusedError` / `TimeoutError`(来自 `MsgClient`)。`retry_on_refused` 和 `retry_interval` 参数可在单次调用时覆盖构造时的设置。 + +**启动等待示例**(解决微服务启动顺序问题): + +```python +# confs 客户端:启动时阻塞等待 confs 就绪,不依赖启动顺序 +rpc = RpcClient("127.0.0.1", 8010, "db", timeout=5, retry_on_refused=True) +data = rpc.call("get", {"name": "db.ini"}) # confs 未启动时自动阻塞等待 +``` ## 实际用例 diff --git a/pymsg/pymsg/__init__.py b/pymsg/pymsg/__init__.py index 48d4e7e..6a9830e 100644 --- a/pymsg/pymsg/__init__.py +++ b/pymsg/pymsg/__init__.py @@ -22,6 +22,7 @@ import select import socket as _socket import struct import threading +import time import traceback from concurrent.futures import ThreadPoolExecutor @@ -200,34 +201,56 @@ class MsgClient: ) # ---- 请求-应答(短连接)------------------------------------------------ - def call(self, msg: str, *, timeout: "float | None" = None) -> str: + def call(self, msg: str, *, timeout: "float | None" = None, + retry_on_refused: bool = False, + retry_interval: float = 1.0, + _log_every: float = 5.0) -> str: """发送一条消息并阻塞等待单条回复(短连接:connect → send → recv → close)。 适合请求-应答模式,如 RPC 调用。不会给消息添加 name 前缀。 Args: - msg: 要发送的原始字符串。 - timeout: 超时秒数,默认 10 秒。 + msg: 要发送的原始字符串。 + timeout: 超时秒数,默认 10 秒。 + retry_on_refused: 目标服务未启动(ConnectionRefusedError)时是否阻塞重试, + 直到连接成功。适用于启动顺序不确定的服务发现场景。 + 默认 False(连接拒绝立即抛异常)。 + retry_interval: 重试间隔秒数,默认 1.0。 + _log_every: 重试期间打印等待提示的间隔(秒),默认每 5 秒打印一次,避免刷屏。 Returns: str: 服务器返回的字符串。 Raises: - ConnectionRefusedError: 目标服务不可达。 + ConnectionRefusedError: 目标服务不可达且 ``retry_on_refused=False``。 TimeoutError: 等待响应超时。 ConnectionError: 连接在收全数据前断开。 """ _tout = timeout if timeout is not None else 10.0 - try: - with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s: - s.settimeout(_tout) - s.connect((self.addr, self.port)) - send(s, msg) - return recv(s, timeout=_tout) - except ConnectionRefusedError: - raise ConnectionRefusedError( - f"Could not connect to {self.addr}:{self.port}" - ) + _last_log = 0.0 + _attempt = 0 + while True: + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s: + s.settimeout(_tout) + s.connect((self.addr, self.port)) + send(s, msg) + return recv(s, timeout=_tout) + except ConnectionRefusedError: + if not retry_on_refused: + raise ConnectionRefusedError( + f"Could not connect to {self.addr}:{self.port}" + ) + _attempt += 1 + now = time.monotonic() + if now - _last_log >= _log_every or _attempt == 1: + print( + f"[pymsg] 等待服务 {self.addr}:{self.port} 就绪... " + f"(已重试 {_attempt} 次)", + file=__import__('sys').stderr, + ) + _last_log = now + time.sleep(retry_interval) # ---- 持续监听(阻塞当前线程,回调模式)------------------------------- def listen(self, on_message: "callable") -> None: diff --git a/pymsg/pymsg/rpc.py b/pymsg/pymsg/rpc.py index 74b2977..127e76a 100644 --- a/pymsg/pymsg/rpc.py +++ b/pymsg/pymsg/rpc.py @@ -36,21 +36,33 @@ class RpcClient: rpc = RpcClient("127.0.0.1", 8005, "dnfs") data = rpc.call("execute", {"action": "select", "table": "Users"}) + + # 启动时阻塞等待服务就绪(连接被拒绝时自动重试) + rpc = RpcClient("127.0.0.1", 8010, "static", retry_on_refused=True) """ def __init__(self, addr: str, port: int, name: str = "rpc", - timeout: float = 10.0) -> None: + timeout: float = 10.0, + *, retry_on_refused: bool = False, + retry_interval: float = 1.0) -> None: self._client = MsgClient(addr, port, name) self._timeout = timeout + self._retry_on_refused = retry_on_refused + self._retry_interval = retry_interval def call(self, route: str, payload: "dict | None" = None, *, - timeout: "float | None" = None): + timeout: "float | None" = None, + retry_on_refused: "bool | None" = None, + retry_interval: "float | None" = None): """调用指定路由,返回响应中的 ``data`` 字段。 Args: route: 路由名(注入为 ``_route`` 字段)。 payload: 业务字段 dict(``_route`` 键会被覆盖)。 timeout: 本次调用的超时秒数,缺省用构造时的值。 + retry_on_refused: 本次调用是否在连接被拒绝时阻塞重试, + None 表示使用构造时的值(默认 False)。 + retry_interval: 本次调用的重试间隔,None 表示使用构造时的值。 Returns: 服务端返回的 ``data``(可能为 None)。 @@ -58,13 +70,17 @@ class RpcClient: Raises: RpcError: 服务端返回 success=false,或响应非法。 pymsg.TimeoutError: 等待响应超时。 - ConnectionRefusedError: 目标服务不可达。 + ConnectionRefusedError: 目标服务不可达且未开启重试。 """ req = dict(payload or {}) req["_route"] = route + _retry = self._retry_on_refused if retry_on_refused is None else retry_on_refused + _interval = self._retry_interval if retry_interval is None else retry_interval resp = self._client.call( json.dumps(req, ensure_ascii=False), timeout=self._timeout if timeout is None else timeout, + retry_on_refused=_retry, + retry_interval=_interval, ) try: obj = json.loads(resp) diff --git a/static/README.md b/static/README.md index ec49623..162c6ad 100644 --- a/static/README.md +++ b/static/README.md @@ -125,12 +125,12 @@ confs 自身的连接地址不通过 confs 获取(避免循环依赖),通 | `CONFS_HOST` | confs 服务主机名(引导配置,Docker 环境使用服务名) | | `CONFS_PORT` | confs 服务端口(引导配置) | -### 启动顺序要求 +### 启动顺序 -**confs 必须先于 static 启动**。若 confs 不可用: -- static 打印致命错误到 stderr(包含 confs 地址和具体错误原因),以 exit code 1 终止 -- 不会静默使用默认值或回退本地配置文件启动 -- 配置完整性校验:若 static.ini 中缺少必需的 section/key(server.host/port、directory.root、cors.allowed_origins)且无对应环境变量覆盖,同样报错退出 +static 通过 pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪,**拉取配置阶段不严格要求启动顺序**: +- confs 先启动 → static 立即拉取配置 +- static 先启动 → pymsg 自动重试(每秒一次,每 5 秒打印等待提示),confs 启动后自动连接成功 +- 配置完整性校验:若 static.ini 中缺少必需的 section/key(server.host/port、directory.root、cors.allowed_origins)且无对应环境变量覆盖,仍然报错退出 - ssl 段为可选项,未配置时默认 `enabled=false`、cert/key 使用默认文件名 ### 历史遗留:本地 config.ini 已删除 diff --git a/static/fsys.py b/static/fsys.py index ae56e99..9a9b201 100644 --- a/static/fsys.py +++ b/static/fsys.py @@ -72,26 +72,22 @@ def _merge_ini(text): def _fetch_from_confs(): - """从 confs 统一配置服务拉取 static.ini 全文,失败直接终止进程。""" + """从 confs 统一配置服务拉取 static.ini 全文。 + + 使用 retry_on_refused=True:confs 未就绪时 pymsg 自动阻塞重试(每秒一次, + 每 5 秒打印一次等待提示),直到 confs 启动后自动连接成功。 + RpcError(如配置文件不存在)仍直接报错退出——这是配置错误而非启动时序问题。 + """ from pymsg import RpcClient, RpcError # noqa: E402 + client = RpcClient(_confs_host, _confs_port, 'static', timeout=5, + retry_on_refused=True) try: - client = RpcClient(_confs_host, _confs_port, 'static', timeout=5) data = client.call('get', {'name': 'static.ini'}) - return data.get('content') - except (ConnectionRefusedError, ConnectionError, OSError) as e: - print(f'[static] 致命错误: 无法连接 confs 服务 ({_confs_host}:{_confs_port}): {e}', - file=sys.stderr) - print(f'[static] 请确认 confs 服务已启动并监听 {_confs_host}:{_confs_port}', - file=sys.stderr) - sys.exit(1) except RpcError as e: print(f'[static] 致命错误: 从 confs 获取 static.ini 失败: {e}', file=sys.stderr) sys.exit(1) - except Exception as e: - print(f'[static] 致命错误: confs 配置拉取异常: {e}', - file=sys.stderr) - sys.exit(1) + return data.get('content') _confs_text = _fetch_from_confs() From d883dc620e2113d33b5065e88a118b3666958841 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 12:59:33 +0800 Subject: [PATCH 09/22] =?UTF-8?q?refactor(pymsg):=20=E6=96=B0=E5=A2=9EAsyn?= =?UTF-8?q?cRpcQueue=E9=80=9A=E7=94=A8=E5=BC=82=E6=AD=A5=E9=98=9F=E5=88=97?= =?UTF-8?q?=EF=BC=8Clog=5Fclient=E5=9F=BA=E4=BA=8E=E6=AD=A4=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pymsg新增AsyncRpcQueue:提供内存队列+后台线程+攒批处理的通用异步基础设施, 支持两种模式:简单RPC模式(client+method+serialize)和自定义handler模式 - confs/dnfs/static的log_client.py重构为基于AsyncRpcQueue的薄封装, 保持API完全向后兼容(push/info/error/warning/debug/critical + 全局logs单例) - 消除了三份log_client副本中重复的队列/线程/攒批逻辑(约150行/份), 代码量净减少约50行 - LogClient的行为与旧版一致:逐条INSERT到db、fail-fast网络错误、队列满丢最旧 --- confs/log_client.py | 204 +++++++++++-------------------------- dnfs/log_client.py | 204 +++++++++++-------------------------- pymsg/pymsg/__init__.py | 3 +- pymsg/pymsg/async_queue.py | 202 ++++++++++++++++++++++++++++++++++++ static/log_client.py | 204 +++++++++++-------------------------- 5 files changed, 384 insertions(+), 433 deletions(-) create mode 100644 pymsg/pymsg/async_queue.py diff --git a/confs/log_client.py b/confs/log_client.py index 0dd516f..515f1d6 100644 --- a/confs/log_client.py +++ b/confs/log_client.py @@ -1,13 +1,15 @@ -"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。 +""" +统一日志客户端 — 各服务共用(基于 pymsg.AsyncRpcQueue)。 设计要点: - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃 + - 异步非阻塞:内存队列 + 后台线程攒批推送,不拖慢业务 + - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - - 时间戳:日志入队时即记录 ts,反映事件真实发生时间 + - 时间戳:日志入队时即记录 ts(UTC ISO 格式),反映事件真实发生时间 + - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 -用法: +用法(与旧版完全兼容): from log_client import logs logs.info("dnfs", "节点新增成功 id=42") @@ -19,28 +21,25 @@ import json import os as _os import sys as _sys -import threading -import queue -import time import atexit from datetime import datetime, timezone _sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import RpcClient, RpcError # noqa: E402 +from pymsg import RpcClient, RpcError, AsyncRpcQueue # noqa: E402 # ── 配置 ────────────────────────────────────────────────────────────── _DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') _DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) -_BATCH_SIZE = 50 # 队列满 50 条时 flush -_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush -_TIMEOUT = 5 # socket 超时(秒) -_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目 +_BATCH_SIZE = 50 +_FLUSH_INTERVAL = 2.0 +_TIMEOUT = 5 +_QUEUE_MAXSIZE = 10000 def _safe_json(obj): - """安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。""" + """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" try: return json.dumps(obj, ensure_ascii=False, default=str) except Exception: @@ -48,163 +47,80 @@ def _safe_json(obj): def _now_iso(): - """生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。""" + """生成当前 UTC 时间 ISO 字符串(入队时调用,保证时间戳准确)。""" return datetime.now(timezone.utc).isoformat() class LogClient: - """异步批量日志推送客户端。""" + """异步批量日志推送客户端(API 与旧版完全兼容)。""" - def __init__(self, service_name="log", - batch_size=_BATCH_SIZE, - flush_interval=_FLUSH_INTERVAL): + def __init__(self, service_name: str = "log", + batch_size: int = _BATCH_SIZE, + flush_interval: float = _FLUSH_INTERVAL): self._service_name = service_name - self._batch_size = batch_size - self._flush_interval = flush_interval - self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE) - self._stop = threading.Event() - self._flush_signal = threading.Event() - self._flush_done = threading.Event() - self._thread = threading.Thread(target=self._flush_loop, - daemon=True, name='log-client') - self._thread.start() - atexit.register(self._shutdown) + self._client = RpcClient( + _DBV2_ADDR, _DBV2_PORT, + service_name + "-log", + timeout=_TIMEOUT, + ) + self._queue = AsyncRpcQueue( + handler=self._send_batch, + batch_size=batch_size, + flush_interval=flush_interval, + max_queue_size=_QUEUE_MAXSIZE, + on_error=self._on_error, + ) + self._queue.start() + atexit.register(self.close) # ── 公开接口 ──────────────────────────────────────────────────────── - def push(self, service, level, message, trace_id=None, extra=None): + def push(self, service: str, level: str, message: str, + trace_id: str = None, extra=None): """推送一条日志(非阻塞,立即返回)。 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 """ entry = { - 'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间 + 'ts': _now_iso(), 'service': service, 'level': level, 'message': message, 'trace_id': trace_id, 'extra': extra, } - try: - self._queue.put_nowait(entry) - except queue.Full: - # 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略) - try: - self._queue.get_nowait() - except queue.Empty: - pass - try: - self._queue.put_nowait(entry) - except queue.Full: - pass # 极端竞争下仍失败则静默丢弃 + self._queue.put(entry) - def debug(self, service, message, **kw): + def debug(self, service: str, message: str, **kw): self.push(service, 'DEBUG', message, **kw) - def info(self, service, message, **kw): + def info(self, service: str, message: str, **kw): self.push(service, 'INFO', message, **kw) - def warning(self, service, message, **kw): + def warning(self, service: str, message: str, **kw): self.push(service, 'WARNING', message, **kw) - def error(self, service, message, **kw): + def warn(self, service: str, message: str, **kw): + self.push(service, 'WARNING', message, **kw) + + def error(self, service: str, message: str, **kw): self.push(service, 'ERROR', message, **kw) - def critical(self, service, message, **kw): + def critical(self, service: str, message: str, **kw): self.push(service, 'CRITICAL', message, **kw) def flush(self): - """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" - self._flush_done.clear() - self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch - # 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量) - self._flush_done.wait(timeout=self._flush_interval + 2.0) - # 主线程排空队列中剩余条目 - self._drain_queue_to_db() + """手动 flush 队列中剩余的日志。""" + self._queue.flush() - def _drain_queue_to_db(self): - """排空主线程可见的队列中剩余条目,直接发送到 db。""" - batch = [] - while True: - try: - batch.append(self._queue.get_nowait()) - if len(batch) >= self._batch_size: - self._send(batch) - batch = [] - except queue.Empty: - break - if batch: - self._send(batch) + def close(self): + """关闭客户端,flush 剩余日志。""" + self._queue.close() # ── 内部实现 ──────────────────────────────────────────────────────── - def _flush_loop(self): - """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。 - - 外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等) - 都不会死亡,避免日志队列无限增长导致 OOM。 - """ - batch = [] - last_flush = time.monotonic() - - while not self._stop.is_set(): - try: - timeout = self._flush_interval - (time.monotonic() - last_flush) - chk_timeout = max(0.1, min(timeout, 0.5)) - try: - item = self._queue.get(timeout=chk_timeout) - batch.append(item) - while len(batch) < self._batch_size: - try: - batch.append(self._queue.get_nowait()) - except queue.Empty: - break - except queue.Empty: - pass - - should_flush = ( - len(batch) >= self._batch_size or - self._flush_signal.is_set() or - (batch and time.monotonic() - last_flush >= self._flush_interval) - ) - if should_flush and batch: - try: - self._send(batch) - except Exception as e: - # _send 内部已有细粒度异常处理,此处是最终安全网 - print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - batch = [] - last_flush = time.monotonic() - if self._flush_signal.is_set(): - self._flush_signal.clear() - self._flush_done.set() - except Exception as e: - # 最外层安全网:任何意外异常都不应杀死日志线程 - print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - time.sleep(0.5) # 防止异常风暴导致 CPU 满载 - - # 退出前 flush 剩余 - if batch: - try: - self._send(batch) - except Exception as e: - print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr) - self._flush_done.set() - - def _send(self, batch): - """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。 - - 整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe), - 不抛异常给调用方。 - """ - try: - client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") - except Exception as e: - print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr) - return - + def _send_batch(self, batch: list): + """后台线程回调:逐条 INSERT 到 db(与旧版行为一致:网络错误放弃本批剩余)。""" for entry in batch: extra_val = entry.get("extra") try: @@ -224,21 +140,21 @@ class LogClient: }, } try: - client.call("execute", payload, timeout=_TIMEOUT) + self._client.call("execute", payload, timeout=_TIMEOUT) except RpcError as e: - # 业务错误(如表结构不符):打印后继续下一条 print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) except Exception as e: - # 网络错误:db 不可用,放弃本批剩余(fail-safe) - print(f"[log_client] 发送失败: {e}", file=_sys.stderr) + print(f"[log_client] 发送失败(放弃本批剩余): {e}", file=_sys.stderr) break - def _shutdown(self): - """进程退出时 flush 剩余日志。""" - self._stop.set() - self.flush() + def _on_error(self, e: Exception, batch: list): + """异常安全网:handler 外的异常也不会崩溃线程。""" + print(f"[log_client] _send_batch 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) # ── 全局单例 ────────────────────────────────────────────────────────── +# 各服务的 log_client.py 副本需将 service_name 改为自己的服务名。 +# 此文件(confs/log_client.py)的 service_name 为 "confs"。 -logs = LogClient() +logs = LogClient(service_name="confs") diff --git a/dnfs/log_client.py b/dnfs/log_client.py index 0dd516f..e61f68a 100644 --- a/dnfs/log_client.py +++ b/dnfs/log_client.py @@ -1,13 +1,15 @@ -"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。 +""" +统一日志客户端 — 各服务共用(基于 pymsg.AsyncRpcQueue)。 设计要点: - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃 + - 异步非阻塞:内存队列 + 后台线程攒批推送,不拖慢业务 + - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - - 时间戳:日志入队时即记录 ts,反映事件真实发生时间 + - 时间戳:日志入队时即记录 ts(UTC ISO 格式),反映事件真实发生时间 + - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 -用法: +用法(与旧版完全兼容): from log_client import logs logs.info("dnfs", "节点新增成功 id=42") @@ -19,28 +21,25 @@ import json import os as _os import sys as _sys -import threading -import queue -import time import atexit from datetime import datetime, timezone _sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import RpcClient, RpcError # noqa: E402 +from pymsg import RpcClient, RpcError, AsyncRpcQueue # noqa: E402 # ── 配置 ────────────────────────────────────────────────────────────── _DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') _DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) -_BATCH_SIZE = 50 # 队列满 50 条时 flush -_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush -_TIMEOUT = 5 # socket 超时(秒) -_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目 +_BATCH_SIZE = 50 +_FLUSH_INTERVAL = 2.0 +_TIMEOUT = 5 +_QUEUE_MAXSIZE = 10000 def _safe_json(obj): - """安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。""" + """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" try: return json.dumps(obj, ensure_ascii=False, default=str) except Exception: @@ -48,163 +47,80 @@ def _safe_json(obj): def _now_iso(): - """生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。""" + """生成当前 UTC 时间 ISO 字符串(入队时调用,保证时间戳准确)。""" return datetime.now(timezone.utc).isoformat() class LogClient: - """异步批量日志推送客户端。""" + """异步批量日志推送客户端(API 与旧版完全兼容)。""" - def __init__(self, service_name="log", - batch_size=_BATCH_SIZE, - flush_interval=_FLUSH_INTERVAL): + def __init__(self, service_name: str = "log", + batch_size: int = _BATCH_SIZE, + flush_interval: float = _FLUSH_INTERVAL): self._service_name = service_name - self._batch_size = batch_size - self._flush_interval = flush_interval - self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE) - self._stop = threading.Event() - self._flush_signal = threading.Event() - self._flush_done = threading.Event() - self._thread = threading.Thread(target=self._flush_loop, - daemon=True, name='log-client') - self._thread.start() - atexit.register(self._shutdown) + self._client = RpcClient( + _DBV2_ADDR, _DBV2_PORT, + service_name + "-log", + timeout=_TIMEOUT, + ) + self._queue = AsyncRpcQueue( + handler=self._send_batch, + batch_size=batch_size, + flush_interval=flush_interval, + max_queue_size=_QUEUE_MAXSIZE, + on_error=self._on_error, + ) + self._queue.start() + atexit.register(self.close) # ── 公开接口 ──────────────────────────────────────────────────────── - def push(self, service, level, message, trace_id=None, extra=None): + def push(self, service: str, level: str, message: str, + trace_id: str = None, extra=None): """推送一条日志(非阻塞,立即返回)。 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 """ entry = { - 'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间 + 'ts': _now_iso(), 'service': service, 'level': level, 'message': message, 'trace_id': trace_id, 'extra': extra, } - try: - self._queue.put_nowait(entry) - except queue.Full: - # 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略) - try: - self._queue.get_nowait() - except queue.Empty: - pass - try: - self._queue.put_nowait(entry) - except queue.Full: - pass # 极端竞争下仍失败则静默丢弃 + self._queue.put(entry) - def debug(self, service, message, **kw): + def debug(self, service: str, message: str, **kw): self.push(service, 'DEBUG', message, **kw) - def info(self, service, message, **kw): + def info(self, service: str, message: str, **kw): self.push(service, 'INFO', message, **kw) - def warning(self, service, message, **kw): + def warning(self, service: str, message: str, **kw): self.push(service, 'WARNING', message, **kw) - def error(self, service, message, **kw): + def warn(self, service: str, message: str, **kw): + self.push(service, 'WARNING', message, **kw) + + def error(self, service: str, message: str, **kw): self.push(service, 'ERROR', message, **kw) - def critical(self, service, message, **kw): + def critical(self, service: str, message: str, **kw): self.push(service, 'CRITICAL', message, **kw) def flush(self): - """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" - self._flush_done.clear() - self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch - # 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量) - self._flush_done.wait(timeout=self._flush_interval + 2.0) - # 主线程排空队列中剩余条目 - self._drain_queue_to_db() + """手动 flush 队列中剩余的日志。""" + self._queue.flush() - def _drain_queue_to_db(self): - """排空主线程可见的队列中剩余条目,直接发送到 db。""" - batch = [] - while True: - try: - batch.append(self._queue.get_nowait()) - if len(batch) >= self._batch_size: - self._send(batch) - batch = [] - except queue.Empty: - break - if batch: - self._send(batch) + def close(self): + """关闭客户端,flush 剩余日志。""" + self._queue.close() # ── 内部实现 ──────────────────────────────────────────────────────── - def _flush_loop(self): - """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。 - - 外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等) - 都不会死亡,避免日志队列无限增长导致 OOM。 - """ - batch = [] - last_flush = time.monotonic() - - while not self._stop.is_set(): - try: - timeout = self._flush_interval - (time.monotonic() - last_flush) - chk_timeout = max(0.1, min(timeout, 0.5)) - try: - item = self._queue.get(timeout=chk_timeout) - batch.append(item) - while len(batch) < self._batch_size: - try: - batch.append(self._queue.get_nowait()) - except queue.Empty: - break - except queue.Empty: - pass - - should_flush = ( - len(batch) >= self._batch_size or - self._flush_signal.is_set() or - (batch and time.monotonic() - last_flush >= self._flush_interval) - ) - if should_flush and batch: - try: - self._send(batch) - except Exception as e: - # _send 内部已有细粒度异常处理,此处是最终安全网 - print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - batch = [] - last_flush = time.monotonic() - if self._flush_signal.is_set(): - self._flush_signal.clear() - self._flush_done.set() - except Exception as e: - # 最外层安全网:任何意外异常都不应杀死日志线程 - print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - time.sleep(0.5) # 防止异常风暴导致 CPU 满载 - - # 退出前 flush 剩余 - if batch: - try: - self._send(batch) - except Exception as e: - print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr) - self._flush_done.set() - - def _send(self, batch): - """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。 - - 整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe), - 不抛异常给调用方。 - """ - try: - client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") - except Exception as e: - print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr) - return - + def _send_batch(self, batch: list): + """后台线程回调:逐条 INSERT 到 db(与旧版行为一致:网络错误放弃本批剩余)。""" for entry in batch: extra_val = entry.get("extra") try: @@ -224,21 +140,21 @@ class LogClient: }, } try: - client.call("execute", payload, timeout=_TIMEOUT) + self._client.call("execute", payload, timeout=_TIMEOUT) except RpcError as e: - # 业务错误(如表结构不符):打印后继续下一条 print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) except Exception as e: - # 网络错误:db 不可用,放弃本批剩余(fail-safe) - print(f"[log_client] 发送失败: {e}", file=_sys.stderr) + print(f"[log_client] 发送失败(放弃本批剩余): {e}", file=_sys.stderr) break - def _shutdown(self): - """进程退出时 flush 剩余日志。""" - self._stop.set() - self.flush() + def _on_error(self, e: Exception, batch: list): + """异常安全网:handler 外的异常也不会崩溃线程。""" + print(f"[log_client] _send_batch 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) # ── 全局单例 ────────────────────────────────────────────────────────── +# 各服务的 log_client.py 副本需将 service_name 改为自己的服务名。 +# 此文件(confs/log_client.py)的 service_name 为 "confs"。 -logs = LogClient() +logs = LogClient(service_name="dnfs") diff --git a/pymsg/pymsg/__init__.py b/pymsg/pymsg/__init__.py index 6a9830e..502d5b3 100644 --- a/pymsg/pymsg/__init__.py +++ b/pymsg/pymsg/__init__.py @@ -525,5 +525,6 @@ class MsgServer: # ── JSON RPC 约定层(依赖上面的 MsgClient / MsgServer,故放在末尾导入)── from .rpc import RpcClient, RpcError, RpcServer # noqa: E402 +from .async_queue import AsyncRpcQueue # noqa: E402 -__all__ += ["RpcClient", "RpcServer", "RpcError"] +__all__ += ["RpcClient", "RpcServer", "RpcError", "AsyncRpcQueue"] diff --git a/pymsg/pymsg/async_queue.py b/pymsg/pymsg/async_queue.py new file mode 100644 index 0000000..e8e8893 --- /dev/null +++ b/pymsg/pymsg/async_queue.py @@ -0,0 +1,202 @@ +""" +AsyncRpcQueue - 通用异步批量发送队列 + +提供内存队列 + 后台线程的异步批量处理机制,适用于日志上报、指标采集等 +非关键路径场景。业务线程put操作非阻塞,后台线程攒批处理,失败时通过错误回调 +通知,不影响主业务流程。 + +两种使用模式: + +1. 简单RPC模式(指定client + method + serialize): + queue = AsyncRpcQueue( + client=rpc_client, + method='log_api.push', + batch_size=50, + flush_interval=2.0, + serialize=lambda items: {'logs': json.dumps(items)}, + ) + +2. 自定义处理模式(指定handler回调): + def send_batch(batch): + for item in batch: + rpc_client.call('execute', item) + queue = AsyncRpcQueue( + handler=send_batch, + batch_size=50, + flush_interval=2.0, + ) +""" + +import threading +import time +from collections import deque +from typing import Any, Callable, Optional + + +class AsyncRpcQueue: + """通用异步批量发送队列""" + + def __init__( + self, + client: Any = None, + method: str = None, + *, + handler: Optional[Callable[[list], None]] = None, + batch_size: int = 100, + flush_interval: float = 2.0, + max_queue_size: int = 10000, + serialize: Optional[Callable[[list], dict]] = None, + on_error: Optional[Callable[[Exception, list], None]] = None, + retry_on_refused: bool = False, + retry_interval: float = 1.0, + ): + """ + 初始化异步队列 + + Args: + client: RpcClient实例(RPC模式必填) + method: RPC方法名(RPC模式必填) + handler: 自定义批量处理回调,接收一批items列表。提供此参数时 + 忽略client/method/serialize/retry_on_refused。 + batch_size: 批量发送的阈值,达到此数量立即发送 + flush_interval: 定时flush间隔(秒) + max_queue_size: 队列最大长度,超过则丢弃最旧数据 + serialize: 序列化函数,接收items列表返回RPC参数字典 + 默认: lambda items: {'items': items} + on_error: 错误回调,接收(exception, batch_items) + retry_on_refused: 是否在连接被拒绝时重试(RPC模式,透传给client.call) + retry_interval: 重试间隔(秒)(RPC模式,透传给client.call) + """ + if handler is None: + if client is None or method is None: + raise ValueError("必须提供 handler 或 (client + method)") + self._handler = self._make_rpc_handler( + client, method, + serialize or (lambda items: {'items': items}), + retry_on_refused, retry_interval, + ) + else: + self._handler = handler + + self._batch_size = batch_size + self._flush_interval = flush_interval + self._max_queue_size = max_queue_size + self._on_error = on_error + + self._queue: deque = deque() + self._lock = threading.Lock() + self._wakeup = threading.Event() + self._thread: Optional[threading.Thread] = None + self._closed = False + self._dropped_count = 0 + + @staticmethod + def _make_rpc_handler(client, method, serialize, retry_on_refused, retry_interval): + """创建RPC模式的默认handler""" + def handler(batch: list): + params = serialize(batch) + client.call( + method, params, + retry_on_refused=retry_on_refused, + retry_interval=retry_interval, + ) + return handler + + def start(self) -> None: + """启动后台发送线程""" + if self._thread is not None and self._thread.is_alive(): + return + self._closed = False + self._thread = threading.Thread( + target=self._run, + name=f'AsyncRpcQueue', + daemon=True, + ) + self._thread.start() + + def put(self, item: Any) -> bool: + """ + 放入一条数据到队列(非阻塞) + + Args: + item: 要发送的数据项 + + Returns: + bool: True表示放入成功,False表示队列已关闭 + """ + if self._closed: + return False + with self._lock: + if len(self._queue) >= self._max_queue_size: + self._queue.popleft() + self._dropped_count += 1 + self._queue.append(item) + self._wakeup.set() + return True + + def flush(self) -> None: + """立即触发一次发送(会阻塞直到处理完成,主要用于关闭时)""" + self._send_batch() + + def close(self) -> None: + """关闭队列,flush剩余数据后停止后台线程""" + if self._closed: + return + self._closed = True + self._wakeup.set() + if self._thread is not None: + self._thread.join(timeout=5.0) + self._send_batch() + + @property + def pending_count(self) -> int: + """当前队列中待发送的数据量""" + with self._lock: + return len(self._queue) + + @property + def dropped_count(self) -> int: + """因队列满而丢弃的数据量""" + return self._dropped_count + + def _run(self) -> None: + """后台线程主循环""" + last_flush = time.time() + while not self._closed: + self._wakeup.wait(timeout=0.1) + self._wakeup.clear() + + now = time.time() + should_flush = False + with self._lock: + if len(self._queue) >= self._batch_size: + should_flush = True + elif now - last_flush >= self._flush_interval and self._queue: + should_flush = True + if should_flush: + self._send_batch() + last_flush = time.time() + + def _send_batch(self) -> None: + """取一批数据处理""" + with self._lock: + if not self._queue: + return + batch = list(self._queue) + self._queue.clear() + try: + self._handler(batch) + except Exception as e: + if self._on_error: + try: + self._on_error(e, batch) + except Exception: + pass + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False diff --git a/static/log_client.py b/static/log_client.py index 0dd516f..f0b95b6 100644 --- a/static/log_client.py +++ b/static/log_client.py @@ -1,13 +1,15 @@ -"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。 +""" +统一日志客户端 — 各服务共用(基于 pymsg.AsyncRpcQueue)。 设计要点: - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃 + - 异步非阻塞:内存队列 + 后台线程攒批推送,不拖慢业务 + - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - - 时间戳:日志入队时即记录 ts,反映事件真实发生时间 + - 时间戳:日志入队时即记录 ts(UTC ISO 格式),反映事件真实发生时间 + - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 -用法: +用法(与旧版完全兼容): from log_client import logs logs.info("dnfs", "节点新增成功 id=42") @@ -19,28 +21,25 @@ import json import os as _os import sys as _sys -import threading -import queue -import time import atexit from datetime import datetime, timezone _sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import RpcClient, RpcError # noqa: E402 +from pymsg import RpcClient, RpcError, AsyncRpcQueue # noqa: E402 # ── 配置 ────────────────────────────────────────────────────────────── _DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') _DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) -_BATCH_SIZE = 50 # 队列满 50 条时 flush -_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush -_TIMEOUT = 5 # socket 超时(秒) -_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目 +_BATCH_SIZE = 50 +_FLUSH_INTERVAL = 2.0 +_TIMEOUT = 5 +_QUEUE_MAXSIZE = 10000 def _safe_json(obj): - """安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。""" + """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" try: return json.dumps(obj, ensure_ascii=False, default=str) except Exception: @@ -48,163 +47,80 @@ def _safe_json(obj): def _now_iso(): - """生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。""" + """生成当前 UTC 时间 ISO 字符串(入队时调用,保证时间戳准确)。""" return datetime.now(timezone.utc).isoformat() class LogClient: - """异步批量日志推送客户端。""" + """异步批量日志推送客户端(API 与旧版完全兼容)。""" - def __init__(self, service_name="log", - batch_size=_BATCH_SIZE, - flush_interval=_FLUSH_INTERVAL): + def __init__(self, service_name: str = "log", + batch_size: int = _BATCH_SIZE, + flush_interval: float = _FLUSH_INTERVAL): self._service_name = service_name - self._batch_size = batch_size - self._flush_interval = flush_interval - self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE) - self._stop = threading.Event() - self._flush_signal = threading.Event() - self._flush_done = threading.Event() - self._thread = threading.Thread(target=self._flush_loop, - daemon=True, name='log-client') - self._thread.start() - atexit.register(self._shutdown) + self._client = RpcClient( + _DBV2_ADDR, _DBV2_PORT, + service_name + "-log", + timeout=_TIMEOUT, + ) + self._queue = AsyncRpcQueue( + handler=self._send_batch, + batch_size=batch_size, + flush_interval=flush_interval, + max_queue_size=_QUEUE_MAXSIZE, + on_error=self._on_error, + ) + self._queue.start() + atexit.register(self.close) # ── 公开接口 ──────────────────────────────────────────────────────── - def push(self, service, level, message, trace_id=None, extra=None): + def push(self, service: str, level: str, message: str, + trace_id: str = None, extra=None): """推送一条日志(非阻塞,立即返回)。 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 """ entry = { - 'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间 + 'ts': _now_iso(), 'service': service, 'level': level, 'message': message, 'trace_id': trace_id, 'extra': extra, } - try: - self._queue.put_nowait(entry) - except queue.Full: - # 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略) - try: - self._queue.get_nowait() - except queue.Empty: - pass - try: - self._queue.put_nowait(entry) - except queue.Full: - pass # 极端竞争下仍失败则静默丢弃 + self._queue.put(entry) - def debug(self, service, message, **kw): + def debug(self, service: str, message: str, **kw): self.push(service, 'DEBUG', message, **kw) - def info(self, service, message, **kw): + def info(self, service: str, message: str, **kw): self.push(service, 'INFO', message, **kw) - def warning(self, service, message, **kw): + def warning(self, service: str, message: str, **kw): self.push(service, 'WARNING', message, **kw) - def error(self, service, message, **kw): + def warn(self, service: str, message: str, **kw): + self.push(service, 'WARNING', message, **kw) + + def error(self, service: str, message: str, **kw): self.push(service, 'ERROR', message, **kw) - def critical(self, service, message, **kw): + def critical(self, service: str, message: str, **kw): self.push(service, 'CRITICAL', message, **kw) def flush(self): - """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" - self._flush_done.clear() - self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch - # 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量) - self._flush_done.wait(timeout=self._flush_interval + 2.0) - # 主线程排空队列中剩余条目 - self._drain_queue_to_db() + """手动 flush 队列中剩余的日志。""" + self._queue.flush() - def _drain_queue_to_db(self): - """排空主线程可见的队列中剩余条目,直接发送到 db。""" - batch = [] - while True: - try: - batch.append(self._queue.get_nowait()) - if len(batch) >= self._batch_size: - self._send(batch) - batch = [] - except queue.Empty: - break - if batch: - self._send(batch) + def close(self): + """关闭客户端,flush 剩余日志。""" + self._queue.close() # ── 内部实现 ──────────────────────────────────────────────────────── - def _flush_loop(self): - """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。 - - 外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等) - 都不会死亡,避免日志队列无限增长导致 OOM。 - """ - batch = [] - last_flush = time.monotonic() - - while not self._stop.is_set(): - try: - timeout = self._flush_interval - (time.monotonic() - last_flush) - chk_timeout = max(0.1, min(timeout, 0.5)) - try: - item = self._queue.get(timeout=chk_timeout) - batch.append(item) - while len(batch) < self._batch_size: - try: - batch.append(self._queue.get_nowait()) - except queue.Empty: - break - except queue.Empty: - pass - - should_flush = ( - len(batch) >= self._batch_size or - self._flush_signal.is_set() or - (batch and time.monotonic() - last_flush >= self._flush_interval) - ) - if should_flush and batch: - try: - self._send(batch) - except Exception as e: - # _send 内部已有细粒度异常处理,此处是最终安全网 - print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - batch = [] - last_flush = time.monotonic() - if self._flush_signal.is_set(): - self._flush_signal.clear() - self._flush_done.set() - except Exception as e: - # 最外层安全网:任何意外异常都不应杀死日志线程 - print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - time.sleep(0.5) # 防止异常风暴导致 CPU 满载 - - # 退出前 flush 剩余 - if batch: - try: - self._send(batch) - except Exception as e: - print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr) - self._flush_done.set() - - def _send(self, batch): - """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。 - - 整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe), - 不抛异常给调用方。 - """ - try: - client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") - except Exception as e: - print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr) - return - + def _send_batch(self, batch: list): + """后台线程回调:逐条 INSERT 到 db(与旧版行为一致:网络错误放弃本批剩余)。""" for entry in batch: extra_val = entry.get("extra") try: @@ -224,21 +140,21 @@ class LogClient: }, } try: - client.call("execute", payload, timeout=_TIMEOUT) + self._client.call("execute", payload, timeout=_TIMEOUT) except RpcError as e: - # 业务错误(如表结构不符):打印后继续下一条 print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) except Exception as e: - # 网络错误:db 不可用,放弃本批剩余(fail-safe) - print(f"[log_client] 发送失败: {e}", file=_sys.stderr) + print(f"[log_client] 发送失败(放弃本批剩余): {e}", file=_sys.stderr) break - def _shutdown(self): - """进程退出时 flush 剩余日志。""" - self._stop.set() - self.flush() + def _on_error(self, e: Exception, batch: list): + """异常安全网:handler 外的异常也不会崩溃线程。""" + print(f"[log_client] _send_batch 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) # ── 全局单例 ────────────────────────────────────────────────────────── +# 各服务的 log_client.py 副本需将 service_name 改为自己的服务名。 +# 此文件(confs/log_client.py)的 service_name 为 "confs"。 -logs = LogClient() +logs = LogClient(service_name="static") From bd1d7ca4d08362a10c1134f5ccf394f57c40fa1c Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 13:15:52 +0800 Subject: [PATCH 10/22] refactor(libhttp): split HttpServer into modular TUs for faster incremental builds Split the 1400-line HttpServer.cpp into 6 focused translation units by functional area: - HttpServer.cpp: core lifecycle, SSL init, session management, debug - HttpServer_Listener.cpp: TCP connection acceptor - HttpServer_Routes.cpp: route registration, matching, wildcard/query parsing - HttpServer_Request.cpp: request dispatch, static files, CORS, HTTP I/O - HttpServer_Ws.cpp: WS route registry, client management, broadcast APIs - HttpServer_WsSession.cpp: per-session WS frames + bidirectional WS proxy Add inc/us/detail/HttpServerImpl.hpp for internal shared definitions (exception classes, full Session/Listener declarations) installed under /usr/include/us/detail/. Update Makefile with proper dependency tracking. Update README with Source Layout section and v3.3.0 changelog entry. Modifying one module now only requires recompiling its corresponding .o instead of the entire monolithic translation unit. --- libhttp/Makefile | 38 +- libhttp/README.md | 18 + libhttp/inc/us/detail/HttpServerImpl.hpp | 180 ++++ libhttp/src/HttpServer.cpp | 1247 +--------------------- libhttp/src/HttpServer_Listener.cpp | 51 + libhttp/src/HttpServer_Request.cpp | 329 ++++++ libhttp/src/HttpServer_Routes.cpp | 125 +++ libhttp/src/HttpServer_Ws.cpp | 106 ++ libhttp/src/HttpServer_WsSession.cpp | 506 +++++++++ 9 files changed, 1373 insertions(+), 1227 deletions(-) create mode 100644 libhttp/inc/us/detail/HttpServerImpl.hpp create mode 100644 libhttp/src/HttpServer_Listener.cpp create mode 100644 libhttp/src/HttpServer_Request.cpp create mode 100644 libhttp/src/HttpServer_Routes.cpp create mode 100644 libhttp/src/HttpServer_Ws.cpp create mode 100644 libhttp/src/HttpServer_WsSession.cpp diff --git a/libhttp/Makefile b/libhttp/Makefile index 41a1bb0..eaaa3ec 100644 --- a/libhttp/Makefile +++ b/libhttp/Makefile @@ -13,15 +13,38 @@ CLIENT_LIBS := -L/opt/homebrew/lib -lssl -lcrypto -lcurl -lpthread BUILD_TDIR := build $(shell mkdir -p $(BUILD_TDIR)) +# Server object files (HttpServer 拆分后) +SERVER_OBJS := $(BUILD_TDIR)/HttpServer.o \ + $(BUILD_TDIR)/HttpServer_Listener.o \ + $(BUILD_TDIR)/HttpServer_Routes.o \ + $(BUILD_TDIR)/HttpServer_Request.o \ + $(BUILD_TDIR)/HttpServer_Ws.o \ + $(BUILD_TDIR)/HttpServer_WsSession.o + #-------- all: $(BUILD_TDIR)/$(LIB_NAME).so $(BUILD_TDIR)/$(LIBC_NAME).so # ---- Server library (HttpServer, no curl) ---- -$(BUILD_TDIR)/$(LIB_NAME).so: $(BUILD_TDIR)/HttpServer.o +$(BUILD_TDIR)/$(LIB_NAME).so: $(SERVER_OBJS) $(CPP) -shared $^ $(SERVER_LIBS) -o $@ -$(BUILD_TDIR)/HttpServer.o: src/HttpServer.cpp - $(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@ +$(BUILD_TDIR)/HttpServer.o: src/HttpServer.cpp inc/us/detail/HttpServerImpl.hpp + $(CPP) $(CPPFLAGS) -fPIC -c $< $(INCS) -o $@ + +$(BUILD_TDIR)/HttpServer_Listener.o: src/HttpServer_Listener.cpp inc/us/detail/HttpServerImpl.hpp + $(CPP) $(CPPFLAGS) -fPIC -c $< $(INCS) -o $@ + +$(BUILD_TDIR)/HttpServer_Routes.o: src/HttpServer_Routes.cpp inc/us/detail/HttpServerImpl.hpp + $(CPP) $(CPPFLAGS) -fPIC -c $< $(INCS) -o $@ + +$(BUILD_TDIR)/HttpServer_Request.o: src/HttpServer_Request.cpp inc/us/detail/HttpServerImpl.hpp + $(CPP) $(CPPFLAGS) -fPIC -c $< $(INCS) -o $@ + +$(BUILD_TDIR)/HttpServer_Ws.o: src/HttpServer_Ws.cpp inc/us/detail/HttpServerImpl.hpp + $(CPP) $(CPPFLAGS) -fPIC -c $< $(INCS) -o $@ + +$(BUILD_TDIR)/HttpServer_WsSession.o: src/HttpServer_WsSession.cpp inc/us/detail/HttpServerImpl.hpp + $(CPP) $(CPPFLAGS) -fPIC -c $< $(INCS) -o $@ # ---- Client library (HttpClient, depends on curl + beast websocket) ---- $(BUILD_TDIR)/$(LIBC_NAME).so: $(BUILD_TDIR)/HttpClient.o @@ -36,8 +59,13 @@ clear: install: all @echo "install UniServe_libhttp..." @mkdir -vp $(INCLU_TDIR)/$(FRAMEWORK_NAME) + @mkdir -vp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/detail @cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/HttpServer.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/ @cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/HttpClient.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/ + @cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/WebSocketServer.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/ + @cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/WebSocketClient.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/ + @cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/WebSocketClientBase.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/ + @cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/detail/HttpServerImpl.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/detail/ @cp -vf $(BUILD_TDIR)/$(LIB_NAME).so $(INSTL_TDIR)/ @cp -vf $(BUILD_TDIR)/$(LIBC_NAME).so $(INSTL_TDIR)/ @echo "done." @@ -46,6 +74,10 @@ uninstall: @echo "uninstall UniServe_libhttp..." @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/HttpServer.hpp @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/HttpClient.hpp + @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/WebSocketServer.hpp + @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/WebSocketClient.hpp + @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/WebSocketClientBase.hpp + @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/detail/HttpServerImpl.hpp @rm -vf $(INSTL_TDIR)/$(LIB_NAME).so @rm -vf $(INSTL_TDIR)/$(LIBC_NAME).so @echo "done." diff --git a/libhttp/README.md b/libhttp/README.md index 4813da3..f96a371 100644 --- a/libhttp/README.md +++ b/libhttp/README.md @@ -46,6 +46,23 @@ Two shared libraries are produced: `libus_http.so` does **not** depend on libcurl. Link against `libus_httpclient.so` only when you need the client, and link both when you need both server and client (e.g. a proxy/gateway). +### Source Layout + +The server implementation is split into modular source files for faster incremental builds: + +| Source File | Responsibility | +|---|---| +| `src/HttpServer.cpp` | Core lifecycle (construct/destruct/start/stop/run), SSL init, session management, debug utilities | +| `src/HttpServer_Listener.cpp` | TCP connection acceptor (`Listener` class) | +| `src/HttpServer_Routes.cpp` | HTTP route registration, route matching, wildcard→regex conversion, query parameter parsing | +| `src/HttpServer_Request.cpp` | Request dispatch pipeline, static file serving, CORS preflight, Session HTTP read/write | +| `src/HttpServer_Ws.cpp` | WebSocket route registration, client registry, broadcast/send APIs | +| `src/HttpServer_WsSession.cpp` | Session WebSocket handling (regular WS frames + bidirectional WS proxy bridging) | + +Internal shared definitions (exception classes, full `Session`/`Listener` declarations) live in `inc/us/detail/HttpServerImpl.hpp` (installed to `/usr/include/us/detail/`). + +Modifying one module only requires recompiling its corresponding `.o` file — no need to rebuild the entire 1400-line translation unit. + ## Quick Start ### HTTP + WebSocket Server @@ -260,6 +277,7 @@ using WebSocketClientBase = HttpClient; ## Version History +- **v3.3.0**: Split HttpServer into 6 modular translation units by functional area (core/lifecycle, listener, routes, request dispatch, WS management, WS session/proxy) for dramatically faster incremental builds. Added internal detail header `inc/us/detail/HttpServerImpl.hpp` for shared definitions across TU boundaries. - **v3.2.0**: Split into two shared libraries — `libus_http.so` (server only, no curl) and `libus_httpclient.so` (HttpClient, depends on curl). HttpClient implementation moved to `src/HttpClient.cpp`; `HttpClient.hpp` is declaration-only. - **v3.1.0**: Added WebSocket reverse proxy (`ws_proxy()`) with bidirectional async bridging, ws:// and wss:// backend support, handshake-time authorization callback. - **v3.0.0**: Merged WebSocketServer into HttpServer and WebSocketClient into HttpClient. Single-port HTTP+WebSocket support. diff --git a/libhttp/inc/us/detail/HttpServerImpl.hpp b/libhttp/inc/us/detail/HttpServerImpl.hpp new file mode 100644 index 0000000..16393b0 --- /dev/null +++ b/libhttp/inc/us/detail/HttpServerImpl.hpp @@ -0,0 +1,180 @@ +#ifndef HTTPSERVER_IMPL_HPP +#define HTTPSERVER_IMPL_HPP + +#include "us/HttpServer.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace pmc { +namespace net { + +// ===================== 自定义异常 ===================== +class HttpServerException : public std::runtime_error { +public: + explicit HttpServerException(const std::string& msg) : runtime_error(msg) {} +}; +class HttpListenerException : public HttpServerException { +public: + explicit HttpListenerException(const std::string& msg) : HttpServerException(msg) {} +}; +class SSLInitException : public HttpServerException { +public: + explicit SSLInitException(const std::string& msg) : HttpServerException(msg) {} +}; +class CreateObjectFailed : public HttpServerException { +public: + explicit CreateObjectFailed(const std::string& msg) : HttpServerException(msg) {} +}; + +// ===================== Session (完整定义) ===================== +class HttpServer::Session : public std::enable_shared_from_this { + using tcp_stream = beast::tcp_stream; + using ssl_stream = asio::ssl::stream; + using ws_stream = websocket::stream; + using wss_stream = websocket::stream; + + // Proxy outbound types + using proxy_ws_stream = websocket::stream; + using proxy_wss_stream = websocket::stream&>; + +public: + Session(tcp::socket&& socket, HttpServer* server) + : stream_(std::move(socket)), server_(server), ssl_enabled_(false), + is_websocket_(false), is_ws_proxy_(false), client_id_("") {} + + Session(tcp::socket&& socket, asio::ssl::context& ssl_ctx, HttpServer* server) + : stream_(std::move(socket)), server_(server), ssl_enabled_(true), + is_websocket_(false), is_ws_proxy_(false), client_id_("") { + ssl_stream_ = std::make_unique(stream_, ssl_ctx); + } + + ~Session() { close(); } + + void run(); + void setRemoteInfo(const std::string& ip, unsigned short port); + void wsSend(const std::string& message); + std::string getClientId() const { return client_id_; } + std::string getRemoteAddress() const { return remote_ip_ + ":" + std::to_string(remote_port_); } + bool isWebSocket() const { return is_websocket_; } + +private: + void close(); + void doSSLHandshake(); + void doRead(); + void handleRead(beast::error_code ec); + bool isWebSocketUpgrade(const http::request& req); + void doWsUpgrade(); + void doWssUpgrade(); + void onWsAccept(beast::error_code ec); + + // Regular WebSocket + void doWsRead(); + void onWsRead(beast::error_code ec, std::size_t bytes_transferred); + void doWsWrite(); + void onWsClose(); + + // WebSocket Proxy + static void parseProxyUrl(const std::string& url, bool& ssl, std::string& host, + std::string& port, std::string& path); + void doProxyConnect(const std::string& target_url); + void onProxyResolve(beast::error_code ec, tcp::resolver::results_type results); + void onProxyConnect(beast::error_code ec, tcp::endpoint ep); + void onProxySslHandshake(beast::error_code ec); + void onProxyHandshake(beast::error_code ec); + + // Client → Backend + void doProxyReadClient(); + void onProxyReadClient(beast::error_code ec, std::size_t bytes_transferred); + void doProxyWriteBackend(); + + // Backend → Client + void doProxyReadBackend(); + void onProxyReadBackend(beast::error_code ec, std::size_t bytes_transferred); + void doProxyWriteClient(); + + void closeProxy(websocket::close_code code); + + // Regular HTTP + void processRequest(http::request req); + void doWrite(std::shared_ptr> response); + + // Client stream (inbound) + tcp_stream stream_; + std::unique_ptr ssl_stream_; + std::unique_ptr ws_stream_; + std::unique_ptr wss_stream_; + + beast::flat_buffer buffer_; + beast::flat_buffer ws_buffer_; + http::request request_; + + HttpServer* server_; + bool ssl_enabled_; + + std::string remote_ip_ = "unknown"; + unsigned short remote_port_ = 0; + + bool is_websocket_; + bool is_ws_proxy_; + std::string client_id_; + std::string ws_path_; + WebSocketMessageHandler ws_handler_; + std::deque ws_send_queue_; + + // Proxy state (outbound to backend) + std::string ws_proxy_target_; + bool proxy_ssl_ = false; + bool proxy_connected_ = false; + bool proxy_closed_ = false; + std::string proxy_host_, proxy_port_, proxy_path_; + + std::unique_ptr proxy_resolver_; + std::unique_ptr proxy_socket_; + std::unique_ptr proxy_tcp_stream_; + std::unique_ptr proxy_ssl_ctx_; + std::unique_ptr> proxy_ssl_stream_; + std::unique_ptr proxy_ws_stream_; + std::unique_ptr proxy_wss_stream_; + + beast::flat_buffer proxy_client_buffer_; + beast::flat_buffer proxy_backend_buffer_; + std::deque> proxy_to_backend_queue_; + std::deque> proxy_to_client_queue_; +}; + +// ===================== Listener (完整定义) ===================== +class HttpServer::Listener : public std::enable_shared_from_this { +public: + Listener(asio::io_context& ioc, tcp::endpoint endpoint, HttpServer* server) + : ioc_(ioc), acceptor_(ioc), server_(server), ssl_enabled_(false) { + initAcceptor(endpoint); + } + + Listener(asio::io_context& ioc, tcp::endpoint endpoint, + asio::ssl::context& ssl_ctx, HttpServer* server) + : ioc_(ioc), acceptor_(ioc), ssl_ctx_(&ssl_ctx), server_(server), ssl_enabled_(true) { + initAcceptor(endpoint); + } + + void run(); + +private: + void initAcceptor(tcp::endpoint ep); + void doAccept(); + + asio::io_context& ioc_; + tcp::acceptor acceptor_; + asio::ssl::context* ssl_ctx_ = nullptr; + HttpServer* server_; + bool ssl_enabled_; +}; + +} // namespace net +} // namespace pmc + +#endif // HTTPSERVER_IMPL_HPP diff --git a/libhttp/src/HttpServer.cpp b/libhttp/src/HttpServer.cpp index aaa9580..e16811e 100644 --- a/libhttp/src/HttpServer.cpp +++ b/libhttp/src/HttpServer.cpp @@ -1,860 +1,9 @@ -#include "us/HttpServer.hpp" -#include -#include -#include -#include -#include -#include -#include +#include "us/detail/HttpServerImpl.hpp" namespace pmc { namespace net { -// ===================== 自定义异常 ===================== -class HttpServerException : public std::runtime_error { -public: - explicit HttpServerException(const std::string& msg) : runtime_error(msg) {} -}; -class HttpListenerException : public HttpServerException { -public: - explicit HttpListenerException(const std::string& msg) : HttpServerException(msg) {} -}; -class SSLInitException : public HttpServerException { -public: - explicit SSLInitException(const std::string& msg) : HttpServerException(msg) {} -}; -class CreateObjectFailed : public HttpServerException { -public: - explicit CreateObjectFailed(const std::string& msg) : HttpServerException(msg) {} -}; - -// ===================== Session ===================== -class HttpServer::Session : public std::enable_shared_from_this { - using tcp_stream = beast::tcp_stream; - using ssl_stream = asio::ssl::stream; - using ws_stream = websocket::stream; - using wss_stream = websocket::stream; - - // Proxy outbound types - using proxy_ws_stream = websocket::stream; - using proxy_wss_stream = websocket::stream&>; - -public: - Session(tcp::socket&& socket, HttpServer* server) - : stream_(std::move(socket)), server_(server), ssl_enabled_(false), - is_websocket_(false), is_ws_proxy_(false), client_id_("") {} - - Session(tcp::socket&& socket, asio::ssl::context& ssl_ctx, HttpServer* server) - : stream_(std::move(socket)), server_(server), ssl_enabled_(true), - is_websocket_(false), is_ws_proxy_(false), client_id_("") { - ssl_stream_ = std::make_unique(stream_, ssl_ctx); - } - - ~Session() { close(); } - - void run() { - if (ssl_enabled_) { - doSSLHandshake(); - } else { - doRead(); - } - } - - void setRemoteInfo(const std::string& ip, unsigned short port) { - remote_ip_ = ip; - remote_port_ = port; - } - - void wsSend(const std::string& message) { - auto exec = stream_.get_executor(); - asio::post(exec, [self = shared_from_this(), message]() { - self->ws_send_queue_.push_back(message); - if (self->ws_send_queue_.size() == 1) { - self->doWsWrite(); - } - }); - } - - std::string getClientId() const { return client_id_; } - std::string getRemoteAddress() const { return remote_ip_ + ":" + std::to_string(remote_port_); } - bool isWebSocket() const { return is_websocket_; } - -private: - void close() { - beast::error_code ec; - closeProxy(websocket::close_code::going_away); - if (is_websocket_) { - if (ssl_enabled_ && wss_stream_) { - wss_stream_->close(websocket::close_code::normal, ec); - } else if (ws_stream_) { - ws_stream_->close(websocket::close_code::normal, ec); - } - } - if (ssl_stream_) { - ssl_stream_->shutdown(ec); - } - stream_.socket().shutdown(tcp::socket::shutdown_send, ec); - stream_.close(); - } - - void doSSLHandshake() { - auto self = shared_from_this(); - std::cout << "[SSL] Starting handshake for " - << remote_ip_ << ":" << remote_port_ << std::endl; - - ssl_stream_->async_handshake( - asio::ssl::stream_base::server, - [self](beast::error_code ec) { - if (ec) { - std::cerr << "[SSL] Handshake error: " - << self->remote_ip_ << ":" << self->remote_port_ - << " - " << ec.message() - << " (code=" << ec.value() << ")" << std::endl; - ERR_print_errors_fp(stderr); - return; - } - std::cout << "[SSL] Handshake succeeded: " - << self->remote_ip_ << ":" << self->remote_port_ << std::endl; - self->doRead(); - }); - } - - void doRead() { - auto self = shared_from_this(); - request_ = {}; - buffer_.consume(buffer_.size()); - - if (ssl_enabled_ && ssl_stream_) { - http::async_read(*ssl_stream_, buffer_, request_, - [self](beast::error_code ec, std::size_t) { - self->handleRead(ec); - }); - } else { - http::async_read(stream_, buffer_, request_, - [self](beast::error_code ec, std::size_t) { - self->handleRead(ec); - }); - } - } - - void handleRead(beast::error_code ec) { - if (!ec) { - if (isWebSocketUpgrade(request_)) { - std::string path = std::string(request_.target()); - auto qpos = path.find('?'); - if (qpos != std::string::npos) path = path.substr(0, qpos); - - // Check proxy routes first (higher priority) - auto proxy_handshake = server_->findWsProxyRoute(path); - if (proxy_handshake) { - std::string target = proxy_handshake(request_); - if (target.empty()) { - // Rejected by handshake callback → 403 - auto resp = std::make_shared>(); - resp->version(request_.version()); - resp->result(http::status::forbidden); - resp->set(http::field::content_type, "text/plain"); - resp->set(http::field::connection, "close"); - resp->body() = "403 Forbidden\n"; - resp->prepare_payload(); - doWrite(resp); - return; - } - ws_path_ = path; - is_ws_proxy_ = true; - ws_proxy_target_ = target; - if (ssl_enabled_) { - doWssUpgrade(); - } else { - doWsUpgrade(); - } - return; - } - - auto ws_handler = server_->findWsRoute(path); - if (ws_handler) { - ws_handler_ = ws_handler; - ws_path_ = path; - if (ssl_enabled_) { - doWssUpgrade(); - } else { - doWsUpgrade(); - } - return; - } - } - - auto req_copy = std::move(request_); - processRequest(std::move(req_copy)); - } else if (ec != http::error::end_of_stream) { - std::cerr << "[Read] Error from " << remote_ip_ << ":" << remote_port_ - << ": " << ec.message() << std::endl; - } - } - - bool isWebSocketUpgrade(const http::request& req) { - if (req.method() != http::verb::get) return false; - - auto upgrade = req.find(http::field::upgrade); - if (upgrade == req.end()) return false; - - std::string upgrade_val(upgrade->value()); - boost::to_lower(upgrade_val); - boost::trim(upgrade_val); - if (upgrade_val != "websocket") return false; - - auto connection = req.find(http::field::connection); - if (connection == req.end()) return false; - - std::string conn_val(connection->value()); - boost::to_lower(conn_val); - return conn_val.find("upgrade") != std::string::npos; - } - - void doWsUpgrade() { - auto self = shared_from_this(); - ws_stream_ = std::make_unique(stream_); - ws_stream_->text(true); - - ws_stream_->async_accept( - request_, - [self](beast::error_code ec) { - self->onWsAccept(ec); - }); - } - - void doWssUpgrade() { - auto self = shared_from_this(); - wss_stream_ = std::make_unique(*ssl_stream_); - wss_stream_->text(true); - - wss_stream_->async_accept( - request_, - [self](beast::error_code ec) { - self->onWsAccept(ec); - }); - } - - void onWsAccept(beast::error_code ec) { - if (ec) { - std::cerr << "[WS] Accept error from " << remote_ip_ << ":" << remote_port_ - << ": " << ec.message() << std::endl; - return; - } - - is_websocket_ = true; - client_id_ = server_->generateWsClientId(); - server_->addWsClient(client_id_, shared_from_this()); - - std::cout << "[WS] Client " << client_id_ << " connected from " - << remote_ip_ << ":" << remote_port_ << " at " << ws_path_ << std::endl; - - if (is_ws_proxy_) { - std::cout << "[WS-Proxy] Proxying " << ws_path_ << " -> " << ws_proxy_target_ << std::endl; - doProxyConnect(ws_proxy_target_); - return; - } - - if (server_->ws_connect_handler_) { - server_->ws_connect_handler_(client_id_, getRemoteAddress()); - } - - doWsRead(); - } - - // ======== Regular WebSocket message handling ======== - - void doWsRead() { - auto self = shared_from_this(); - ws_buffer_.consume(ws_buffer_.size()); - - if (ssl_enabled_ && wss_stream_) { - wss_stream_->async_read( - ws_buffer_, - [self](beast::error_code ec, std::size_t bytes_transferred) { - self->onWsRead(ec, bytes_transferred); - }); - } else if (ws_stream_) { - ws_stream_->async_read( - ws_buffer_, - [self](beast::error_code ec, std::size_t bytes_transferred) { - self->onWsRead(ec, bytes_transferred); - }); - } - } - - void onWsRead(beast::error_code ec, std::size_t bytes_transferred) { - if (ec == websocket::error::closed) { - onWsClose(); - return; - } - if (ec) { - std::cerr << "[WS] Read error from " << client_id_ << ": " << ec.message() << std::endl; - onWsClose(); - return; - } - - std::string message = beast::buffers_to_string(ws_buffer_.data()); - ws_buffer_.consume(bytes_transferred); - - if (ws_handler_) { - auto self = shared_from_this(); - ws_handler_(client_id_, message, - [self](const std::string& response) { - self->wsSend(response); - }); - } - - doWsRead(); - } - - void doWsWrite() { - if (ws_send_queue_.empty()) return; - - auto self = shared_from_this(); - std::string& message = ws_send_queue_.front(); - - auto write_handler = [self](beast::error_code ec, std::size_t) { - if (ec) { - std::cerr << "[WS] Write error to " << self->client_id_ << ": " << ec.message() << std::endl; - self->onWsClose(); - return; - } - self->ws_send_queue_.pop_front(); - if (!self->ws_send_queue_.empty()) { - self->doWsWrite(); - } - }; - - if (ssl_enabled_ && wss_stream_) { - wss_stream_->text(true); - wss_stream_->async_write(asio::buffer(message), write_handler); - } else if (ws_stream_) { - ws_stream_->text(true); - ws_stream_->async_write(asio::buffer(message), write_handler); - } - } - - void onWsClose() { - if (client_id_.empty()) return; - std::cout << "[WS] Client " << client_id_ << " disconnected" << std::endl; - - closeProxy(websocket::close_code::normal); - server_->removeWsClient(client_id_); - - if (server_->ws_disconnect_handler_) { - server_->ws_disconnect_handler_(client_id_); - } - client_id_.clear(); - is_websocket_ = false; - is_ws_proxy_ = false; - } - - // ======== WebSocket Proxy (bidirectional bridge) ======== - - static void parseProxyUrl(const std::string& url, bool& ssl, std::string& host, - std::string& port, std::string& path) { - std::string rest; - if (url.size() >= 6 && url.substr(0, 6) == "wss://") { - ssl = true; - rest = url.substr(6); - } else if (url.size() >= 5 && url.substr(0, 5) == "ws://") { - ssl = false; - rest = url.substr(5); - } else { - ssl = false; - rest = url; - } - - size_t path_pos = rest.find('/'); - std::string host_port; - if (path_pos != std::string::npos) { - host_port = rest.substr(0, path_pos); - path = rest.substr(path_pos); - } else { - host_port = rest; - path = "/"; - } - - size_t port_pos = host_port.find(':'); - if (port_pos != std::string::npos) { - host = host_port.substr(0, port_pos); - port = host_port.substr(port_pos + 1); - } else { - host = host_port; - port = ssl ? "443" : "80"; - } - } - - void doProxyConnect(const std::string& target_url) { - auto self = shared_from_this(); - bool ssl; - std::string host, port, path; - parseProxyUrl(target_url, ssl, host, port, path); - proxy_ssl_ = ssl; - proxy_host_ = host; - proxy_port_ = port; - proxy_path_ = path; - - auto exec = stream_.get_executor(); - proxy_resolver_ = std::make_unique(asio::make_strand(exec)); - - proxy_resolver_->async_resolve(host, port, - asio::bind_executor(exec, - [self](beast::error_code ec, tcp::resolver::results_type results) { - self->onProxyResolve(ec, results); - })); - } - - void onProxyResolve(beast::error_code ec, tcp::resolver::results_type results) { - if (ec) { - std::cerr << "[WS-Proxy] Resolve error: " << ec.message() << std::endl; - close(); - return; - } - - auto self = shared_from_this(); - auto exec = stream_.get_executor(); - - proxy_socket_ = std::make_unique(exec); - proxy_tcp_stream_ = std::make_unique(std::move(*proxy_socket_)); - proxy_socket_.reset(); - - proxy_tcp_stream_->expires_after(std::chrono::seconds(10)); - asio::async_connect(proxy_tcp_stream_->socket(), results, - asio::bind_executor(exec, - [self](beast::error_code ec, tcp::endpoint ep) { - self->onProxyConnect(ec, ep); - })); - } - - void onProxyConnect(beast::error_code ec, tcp::endpoint /*ep*/) { - if (ec) { - std::cerr << "[WS-Proxy] Connect error: " << ec.message() << std::endl; - close(); - return; - } - - auto self = shared_from_this(); - if (proxy_ssl_) { - proxy_ssl_ctx_ = std::make_unique(asio::ssl::context::tlsv12_client); - proxy_ssl_ctx_->set_verify_mode(asio::ssl::verify_none); - proxy_ssl_stream_ = std::make_unique>( - proxy_tcp_stream_->socket(), *proxy_ssl_ctx_); - - if (!SSL_set_tlsext_host_name(proxy_ssl_stream_->native_handle(), proxy_host_.c_str())) { - std::cerr << "[WS-Proxy] Failed to set SNI" << std::endl; - close(); - return; - } - - proxy_tcp_stream_->expires_after(std::chrono::seconds(10)); - auto exec = stream_.get_executor(); - proxy_ssl_stream_->async_handshake(asio::ssl::stream_base::client, - asio::bind_executor(exec, - [self](beast::error_code ec) { - self->onProxySslHandshake(ec); - })); - } else { - proxy_ws_stream_ = std::make_unique(proxy_tcp_stream_->socket()); - proxy_tcp_stream_->expires_never(); - - proxy_ws_stream_->set_option(websocket::stream_base::decorator( - [](websocket::request_type& req) { - req.set(http::field::user_agent, "UniServe-WsProxy"); - })); - - auto exec = self->stream_.get_executor(); - proxy_ws_stream_->async_handshake(proxy_host_, proxy_path_, - asio::bind_executor(exec, - [self](beast::error_code ec) { - self->onProxyHandshake(ec); - })); - } - } - - void onProxySslHandshake(beast::error_code ec) { - if (ec) { - std::cerr << "[WS-Proxy] SSL handshake error: " << ec.message() << std::endl; - close(); - return; - } - - auto self = shared_from_this(); - proxy_wss_stream_ = std::make_unique(*proxy_ssl_stream_); - proxy_tcp_stream_->expires_never(); - - proxy_wss_stream_->set_option(websocket::stream_base::decorator( - [](websocket::request_type& req) { - req.set(http::field::user_agent, "UniServe-WsProxy"); - })); - - auto exec = stream_.get_executor(); - proxy_wss_stream_->async_handshake(proxy_host_, proxy_path_, - asio::bind_executor(exec, - [self](beast::error_code ec) { - self->onProxyHandshake(ec); - })); - } - - void onProxyHandshake(beast::error_code ec) { - if (ec) { - std::cerr << "[WS-Proxy] Backend handshake error: " << ec.message() << std::endl; - close(); - return; - } - - proxy_connected_ = true; - proxy_closed_ = false; - std::cout << "[WS-Proxy] Connected to backend " << proxy_host_ << ":" << proxy_port_ - << proxy_path_ << std::endl; - - if (server_->ws_connect_handler_) { - server_->ws_connect_handler_(client_id_, getRemoteAddress()); - } - - // Start bidirectional bridging - doProxyReadClient(); - doProxyReadBackend(); - } - - // ---- Client → Backend direction ---- - - void doProxyReadClient() { - if (proxy_closed_) return; - auto self = shared_from_this(); - proxy_client_buffer_.consume(proxy_client_buffer_.size()); - - auto read_handler = [self](beast::error_code ec, std::size_t bytes_transferred) { - self->onProxyReadClient(ec, bytes_transferred); - }; - - if (ssl_enabled_ && wss_stream_) { - wss_stream_->async_read(proxy_client_buffer_, read_handler); - } else if (ws_stream_) { - ws_stream_->async_read(proxy_client_buffer_, read_handler); - } - } - - void onProxyReadClient(beast::error_code ec, std::size_t bytes_transferred) { - if (ec) { - if (ec != websocket::error::closed) { - std::cerr << "[WS-Proxy] Client read error: " << ec.message() << std::endl; - } - closeProxy(websocket::close_code::normal); - return; - } - - bool is_binary = false; - if (ssl_enabled_ && wss_stream_) { - is_binary = wss_stream_->got_binary(); - } else if (ws_stream_) { - is_binary = ws_stream_->got_binary(); - } - - std::string data = beast::buffers_to_string(proxy_client_buffer_.data()); - proxy_client_buffer_.consume(bytes_transferred); - - // Queue to backend - auto exec = stream_.get_executor(); - asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() { - self->proxy_to_backend_queue_.emplace_back(std::move(data), is_binary); - if (self->proxy_to_backend_queue_.size() == 1) { - self->doProxyWriteBackend(); - } - }); - - doProxyReadClient(); - } - - void doProxyWriteBackend() { - if (proxy_to_backend_queue_.empty() || !proxy_connected_ || proxy_closed_) return; - - auto self = shared_from_this(); - auto& [msg, is_binary] = proxy_to_backend_queue_.front(); - - auto write_handler = [self](beast::error_code ec, std::size_t) { - if (ec) { - std::cerr << "[WS-Proxy] Backend write error: " << ec.message() << std::endl; - self->closeProxy(websocket::close_code::internal_error); - return; - } - self->proxy_to_backend_queue_.pop_front(); - if (!self->proxy_to_backend_queue_.empty()) { - self->doProxyWriteBackend(); - } - }; - - if (proxy_ssl_ && proxy_wss_stream_) { - proxy_wss_stream_->binary(is_binary); - proxy_wss_stream_->async_write(asio::buffer(msg), write_handler); - } else if (proxy_ws_stream_) { - proxy_ws_stream_->binary(is_binary); - proxy_ws_stream_->async_write(asio::buffer(msg), write_handler); - } - } - - // ---- Backend → Client direction ---- - - void doProxyReadBackend() { - if (proxy_closed_) return; - auto self = shared_from_this(); - proxy_backend_buffer_.consume(proxy_backend_buffer_.size()); - - auto read_handler = [self](beast::error_code ec, std::size_t bytes_transferred) { - self->onProxyReadBackend(ec, bytes_transferred); - }; - - if (proxy_ssl_ && proxy_wss_stream_) { - proxy_wss_stream_->async_read(proxy_backend_buffer_, read_handler); - } else if (proxy_ws_stream_) { - proxy_ws_stream_->async_read(proxy_backend_buffer_, read_handler); - } - } - - void onProxyReadBackend(beast::error_code ec, std::size_t bytes_transferred) { - if (ec) { - if (ec != websocket::error::closed) { - std::cerr << "[WS-Proxy] Backend read error: " << ec.message() << std::endl; - } - closeProxy(websocket::close_code::normal); - return; - } - - bool is_binary = false; - if (proxy_ssl_ && proxy_wss_stream_) { - is_binary = proxy_wss_stream_->got_binary(); - } else if (proxy_ws_stream_) { - is_binary = proxy_ws_stream_->got_binary(); - } - - std::string data = beast::buffers_to_string(proxy_backend_buffer_.data()); - proxy_backend_buffer_.consume(bytes_transferred); - - // Queue to client - auto exec = stream_.get_executor(); - asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() { - self->proxy_to_client_queue_.emplace_back(std::move(data), is_binary); - if (self->proxy_to_client_queue_.size() == 1) { - self->doProxyWriteClient(); - } - }); - - doProxyReadBackend(); - } - - void doProxyWriteClient() { - if (proxy_to_client_queue_.empty() || !proxy_connected_ || proxy_closed_) return; - - auto self = shared_from_this(); - auto& [msg, is_binary] = proxy_to_client_queue_.front(); - - auto write_handler = [self](beast::error_code ec, std::size_t) { - if (ec) { - std::cerr << "[WS-Proxy] Client write error: " << ec.message() << std::endl; - self->closeProxy(websocket::close_code::internal_error); - return; - } - self->proxy_to_client_queue_.pop_front(); - if (!self->proxy_to_client_queue_.empty()) { - self->doProxyWriteClient(); - } - }; - - if (ssl_enabled_ && wss_stream_) { - wss_stream_->binary(is_binary); - wss_stream_->async_write(asio::buffer(msg), write_handler); - } else if (ws_stream_) { - ws_stream_->binary(is_binary); - ws_stream_->async_write(asio::buffer(msg), write_handler); - } - } - - void closeProxy(websocket::close_code code) { - if (proxy_closed_) return; - proxy_closed_ = true; - proxy_connected_ = false; - - beast::error_code ec; - // Close backend connection - if (proxy_ssl_ && proxy_wss_stream_) { - proxy_wss_stream_->close(code, ec); - } else if (proxy_ws_stream_) { - proxy_ws_stream_->close(code, ec); - } - if (proxy_ssl_stream_) { - proxy_ssl_stream_->shutdown(ec); - } - if (proxy_tcp_stream_) { - proxy_tcp_stream_->socket().shutdown(tcp::socket::shutdown_send, ec); - proxy_tcp_stream_->close(); - } - - proxy_to_backend_queue_.clear(); - proxy_to_client_queue_.clear(); - proxy_ws_stream_.reset(); - proxy_wss_stream_.reset(); - proxy_ssl_stream_.reset(); - proxy_ssl_ctx_.reset(); - proxy_tcp_stream_.reset(); - proxy_resolver_.reset(); - } - - // ======== Regular HTTP ======== - - void processRequest(http::request req) { - try { - auto resp = std::make_shared>( - server_->handleRequest(req)); - doWrite(resp); - } catch (const std::exception& e) { - std::cerr << "[Request] Handler exception: " << e.what() << std::endl; - auto err_resp = std::make_shared>(); - err_resp->version(req.version()); - err_resp->result(http::status::internal_server_error); - err_resp->set(http::field::content_type, "text/plain"); - err_resp->body() = "500 Internal Server Error\n"; - err_resp->prepare_payload(); - doWrite(err_resp); - } - } - - void doWrite(std::shared_ptr> response) { - auto self = shared_from_this(); - if (ssl_enabled_ && ssl_stream_) { - http::async_write(*ssl_stream_, *response, - [self, response](beast::error_code ec, std::size_t) { - if (ec) { - std::cerr << "[Write] Error to " << self->remote_ip_ << ":" << self->remote_port_ - << ": " << ec.message() << std::endl; - return; - } - if (!response->keep_alive()) self->close(); - else self->doRead(); - }); - } else { - http::async_write(stream_, *response, - [self, response](beast::error_code ec, std::size_t) { - if (ec) { - std::cerr << "[Write] Error to " << self->remote_ip_ << ":" << self->remote_port_ - << ": " << ec.message() << std::endl; - return; - } - if (!response->keep_alive()) self->close(); - else self->doRead(); - }); - } - } - - // ---- Client stream (inbound) ---- - tcp_stream stream_; - std::unique_ptr ssl_stream_; - std::unique_ptr ws_stream_; - std::unique_ptr wss_stream_; - - beast::flat_buffer buffer_; - beast::flat_buffer ws_buffer_; - http::request request_; - - HttpServer* server_; - bool ssl_enabled_; - - std::string remote_ip_ = "unknown"; - unsigned short remote_port_ = 0; - - bool is_websocket_; - bool is_ws_proxy_; - std::string client_id_; - std::string ws_path_; - WebSocketMessageHandler ws_handler_; - std::deque ws_send_queue_; - - // ---- Proxy state (outbound to backend) ---- - std::string ws_proxy_target_; - bool proxy_ssl_ = false; - bool proxy_connected_ = false; - bool proxy_closed_ = false; - std::string proxy_host_, proxy_port_, proxy_path_; - - std::unique_ptr proxy_resolver_; - std::unique_ptr proxy_socket_; - std::unique_ptr proxy_tcp_stream_; - std::unique_ptr proxy_ssl_ctx_; - std::unique_ptr> proxy_ssl_stream_; - std::unique_ptr proxy_ws_stream_; - std::unique_ptr proxy_wss_stream_; - - beast::flat_buffer proxy_client_buffer_; - beast::flat_buffer proxy_backend_buffer_; - std::deque> proxy_to_backend_queue_; - std::deque> proxy_to_client_queue_; -}; - -// ===================== Listener ===================== -class HttpServer::Listener : public std::enable_shared_from_this { -public: - Listener(asio::io_context& ioc, tcp::endpoint endpoint, HttpServer* server) - : ioc_(ioc), acceptor_(ioc), server_(server), ssl_enabled_(false) { - initAcceptor(endpoint); - } - - Listener(asio::io_context& ioc, tcp::endpoint endpoint, - asio::ssl::context& ssl_ctx, HttpServer* server) - : ioc_(ioc), acceptor_(ioc), ssl_ctx_(&ssl_ctx), server_(server), ssl_enabled_(true) { - initAcceptor(endpoint); - } - - void run() { - if (acceptor_.is_open()) doAccept(); - } - -private: - void initAcceptor(tcp::endpoint ep) { - beast::error_code ec; - acceptor_.open(ep.protocol(), ec); - if (!ec) { - acceptor_.set_option(asio::socket_base::reuse_address(true), ec); - acceptor_.bind(ep, ec); - if (!ec) acceptor_.listen(asio::socket_base::max_listen_connections, ec); - } - if (ec) throw HttpListenerException("Listener error: " + ec.message()); - } - - void doAccept() { - auto self = shared_from_this(); - acceptor_.async_accept( - asio::make_strand(ioc_), - [self](beast::error_code ec, tcp::socket socket) { - if (!ec) { - auto ep = socket.remote_endpoint(ec); - std::string ip = ec ? "unknown" : ep.address().to_string(); - unsigned short port = ec ? 0 : ep.port(); - std::cout << "[Listener] New connection from " << ip << ":" << port << std::endl; - - std::shared_ptr session; - if (self->ssl_enabled_ && self->ssl_ctx_) { - session = std::make_shared(std::move(socket), *self->ssl_ctx_, self->server_); - } else { - session = std::make_shared(std::move(socket), self->server_); - } - session->setRemoteInfo(ip, port); - self->server_->addSession(session); - session->run(); - } else { - std::cerr << "[Listener] Accept error: " << ec.message() << std::endl; - } - self->doAccept(); - }); - } - - asio::io_context& ioc_; - tcp::acceptor acceptor_; - asio::ssl::context* ssl_ctx_ = nullptr; - HttpServer* server_; - bool ssl_enabled_; -}; - -// ===================== HttpServer 实现 ===================== +// ===================== HttpServer 核心生命周期 ===================== HttpServer::HttpServer(const std::string& address, unsigned short port, unsigned int threads) : address_(address), port_(port), threads_(threads), ssl_enabled_(false), @@ -973,354 +122,34 @@ unsigned short HttpServer::getPort() const { return port_; } std::string HttpServer::getAddress() const { return address_; } bool HttpServer::isSSLEnabled() const { return ssl_enabled_; } -// ---- HTTP 路由注册 ---- -void HttpServer::get(const std::string& path, HttpRequestHandler handler) { - RouteEntry entry; - entry.path = path; - entry.handler = handler; - entry.is_wildcard = (path.find('*') != std::string::npos || - path.find('?') != std::string::npos); - if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); - get_handlers_.push_back(entry); +// ---- 会话管理 ---- +void HttpServer::addSession(const std::shared_ptr& session) { + std::lock_guard lock(sessions_mutex_); + sessions_.erase(std::remove_if(sessions_.begin(), sessions_.end(), + [](const std::weak_ptr& wp) { return wp.expired(); }), sessions_.end()); + sessions_.push_back(session); } -void HttpServer::post(const std::string& path, HttpRequestHandler handler) { - RouteEntry entry; - entry.path = path; - entry.handler = handler; - entry.is_wildcard = (path.find('*') != std::string::npos || - path.find('?') != std::string::npos); - if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); - post_handlers_.push_back(entry); +void HttpServer::removeSession(const Session* session) { + std::lock_guard lock(sessions_mutex_); + sessions_.erase(std::remove_if(sessions_.begin(), sessions_.end(), + [session](const std::weak_ptr& wp) { + auto sp = wp.lock(); + return !sp || sp.get() == session; + }), sessions_.end()); } -void HttpServer::put(const std::string& path, HttpRequestHandler handler) { - RouteEntry entry; - entry.path = path; - entry.handler = handler; - entry.is_wildcard = (path.find('*') != std::string::npos || - path.find('?') != std::string::npos); - if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); - put_handlers_.push_back(entry); +void HttpServer::closeAllSessions() { + std::lock_guard lock(sessions_mutex_); + sessions_.clear(); } -void HttpServer::del(const std::string& path, HttpRequestHandler handler) { - RouteEntry entry; - entry.path = path; - entry.handler = handler; - entry.is_wildcard = (path.find('*') != std::string::npos || - path.find('?') != std::string::npos); - if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); - delete_handlers_.push_back(entry); -} - -void HttpServer::options(const std::string& path, HttpRequestHandler handler) { - RouteEntry entry; - entry.path = path; - entry.handler = handler; - entry.is_wildcard = (path.find('*') != std::string::npos || - path.find('?') != std::string::npos); - if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); - options_handlers_.push_back(entry); -} - -void HttpServer::use(MiddlewareHandler m) { - middlewares_.push_back(m); -} - -void HttpServer::setStaticDirectory(const std::string& p) { - static_directory_ = p; -} - -// ---- WebSocket 路由注册 ---- -void HttpServer::ws(const std::string& path, WebSocketMessageHandler handler) { - WsRouteEntry entry; - entry.path = path; - entry.handler = handler; - ws_handlers_.push_back(entry); -} - -void HttpServer::ws_proxy(const std::string& path, WsProxyHandshakeHandler handshake) { - WsProxyRouteEntry entry; - entry.path = path; - entry.handshake = handshake; - ws_proxy_handlers_.push_back(entry); -} - -void HttpServer::onWsConnect(WebSocketConnectHandler handler) { - ws_connect_handler_ = handler; -} - -void HttpServer::onWsDisconnect(WebSocketDisconnectHandler handler) { - ws_disconnect_handler_ = handler; -} - -bool HttpServer::sendToWsClient(const std::string& clientId, const std::string& message) { - std::shared_ptr session; - { - std::lock_guard lock(ws_clients_mutex_); - auto it = ws_clients_.find(clientId); - if (it != ws_clients_.end()) { - session = it->second; - } +// ---- 调试工具 ---- +void HttpServer::debug::print_all_header_fields(const http::request& req) { + for (auto const& field : req) { + std::cout << "Name: " << field.name_string() + << ", Value: " << field.value() << std::endl; } - if (session) { - session->wsSend(message); - return true; - } - return false; -} - -size_t HttpServer::broadcastWs(const std::string& message) { - std::vector> clients; - { - std::lock_guard lock(ws_clients_mutex_); - for (auto& pair : ws_clients_) { - clients.push_back(pair.second); - } - } - for (auto& session : clients) { - session->wsSend(message); - } - return clients.size(); -} - -void HttpServer::disconnectWsClient(const std::string& clientId) { - std::lock_guard lock(ws_clients_mutex_); - ws_clients_.erase(clientId); -} - -size_t HttpServer::getWsClientCount() const { - std::lock_guard lock(ws_clients_mutex_); - return ws_clients_.size(); -} - -// ---- 通配符转换 ---- -std::regex HttpServer::wildcardToRegex(const std::string& pattern) { - std::string r; - r.reserve(pattern.size() * 2); - for (char c : pattern) { - if (c == '*') r += ".*"; - else if (c == '?') r += "."; - else if (c == '.' || c == '+' || c == '(' || c == ')' || c == '[' || c == ']' || - c == '{' || c == '}' || c == '^' || c == '$' || c == '|' || c == '\\') { - r += '\\'; - r += c; - } else r += c; - } - return std::regex(r, std::regex::icase); -} - -bool HttpServer::matchPath(const std::string& path, const std::regex& pattern) { - return std::regex_match(path, pattern); -} - -// ---- 查询参数解析 ---- -std::unordered_map HttpServer::parseQueryParams(const std::string& query) { - std::unordered_map params; - if (query.empty()) return params; - - std::vector pairs; - boost::split(pairs, query, boost::is_any_of("&")); - for (const auto& pair : pairs) { - std::vector kv; - boost::split(kv, pair, boost::is_any_of("=")); - if (kv.size() == 2) params[kv[0]] = kv[1]; - else if (kv.size() == 1) params[kv[0]] = ""; - } - return params; -} - -// ---- 路由查找 ---- -HttpRequestHandler HttpServer::findRoute(const std::string& method, const std::string& path) { - const std::vector* handlers = nullptr; - if (method == "GET") handlers = &get_handlers_; - else if (method == "POST") handlers = &post_handlers_; - else if (method == "PUT") handlers = &put_handlers_; - else if (method == "DELETE") handlers = &delete_handlers_; - else if (method == "OPTIONS") handlers = &options_handlers_; - else return nullptr; - - for (const auto& e : *handlers) { - if (!e.is_wildcard && e.path == path) return e.handler; - } - for (const auto& e : *handlers) { - if (e.is_wildcard && matchPath(path, e.pattern)) return e.handler; - } - return nullptr; -} - -bool HttpServer::routeExists(const std::string& method, const std::string& path) { - return findRoute(method, path) != nullptr; -} - -WebSocketMessageHandler HttpServer::findWsRoute(const std::string& path) { - for (const auto& e : ws_handlers_) { - if (e.path == path) return e.handler; - } - return nullptr; -} - -WsProxyHandshakeHandler HttpServer::findWsProxyRoute(const std::string& path) { - for (const auto& e : ws_proxy_handlers_) { - if (e.path == path) return e.handshake; - } - return nullptr; -} - -// ---- WebSocket 客户端管理 ---- -std::string HttpServer::generateWsClientId() { - return "ws_client_" + std::to_string(++ws_client_counter_); -} - -void HttpServer::addWsClient(const std::string& clientId, std::shared_ptr session) { - std::lock_guard lock(ws_clients_mutex_); - ws_clients_[clientId] = session; -} - -void HttpServer::removeWsClient(const std::string& clientId) { - std::lock_guard lock(ws_clients_mutex_); - ws_clients_.erase(clientId); -} - -std::shared_ptr HttpServer::getWsClient(const std::string& clientId) { - std::lock_guard lock(ws_clients_mutex_); - auto it = ws_clients_.find(clientId); - if (it != ws_clients_.end()) return it->second; - return nullptr; -} - -// ---- 请求处理核心 ---- -http::response HttpServer::handleRequest(const http::request& req) { - std::string target_path(req.target().data(), req.target().size()); - std::string query_string; - auto pos = target_path.find('?'); - if (pos != std::string::npos) { - query_string = target_path.substr(pos + 1); - target_path = target_path.substr(0, pos); - } - auto params = parseQueryParams(query_string); - - http::response res; - res.version(req.version()); - res.set(http::field::server, "Boost.Beast HttpServer"); - res.set(http::field::content_type, "text/plain"); - res.keep_alive(req.keep_alive()); - - bool middleware_handled = false; - for (auto& m : middlewares_) { - if (!m(req, res, params)) { - middleware_handled = true; - break; - } - } - if (middleware_handled) { - res.prepare_payload(); - return res; - } - - std::string method = http::to_string(req.method()); - - if (method == "OPTIONS") { - auto handler = findRoute("OPTIONS", target_path); - if (handler) { - try { - return handler(req, params); - } catch (const std::exception& e) { - std::cerr << "[Handler] Exception: " << e.what() << std::endl; - res.result(http::status::internal_server_error); - res.body() = "500 Internal Server Error\n"; - res.prepare_payload(); - return res; - } - } - - auto origin = req.find(http::field::origin); - if (origin != req.end()) { - std::vector all_methods = {"GET", "POST", "PUT", "DELETE", "OPTIONS"}; - std::vector allowed_methods; - - for (const auto& m : all_methods) { - if (m == "OPTIONS" || routeExists(m, target_path)) { - allowed_methods.push_back(m); - } - } - - if (!allowed_methods.empty() || findWsRoute(target_path)) { - if (allowed_methods.empty()) allowed_methods.push_back("GET"); - - std::string allow_methods; - for (size_t i = 0; i < allowed_methods.size(); ++i) { - if (i > 0) allow_methods += ", "; - allow_methods += allowed_methods[i]; - } - - res.result(http::status::ok); - res.set(http::field::allow, allow_methods); - res.set(http::field::access_control_allow_origin, - std::string(origin->value())); - res.set(http::field::access_control_allow_methods, allow_methods); - res.set(http::field::access_control_allow_headers, - "Content-Type, Authorization, X-Requested-With, Accept, Origin"); - res.set(http::field::access_control_max_age, "86400"); - res.body() = ""; - res.content_length(0); - res.prepare_payload(); - return res; - } - } - - res.result(http::status::not_found); - res.body() = "404 Not Found\n"; - res.prepare_payload(); - return res; - } - - auto handler = findRoute(method, target_path); - - if (!handler && !static_directory_.empty()) { - std::string file_path = static_directory_ + target_path; - std::ifstream file(file_path, std::ios::binary); - if (file.is_open()) { - std::stringstream buffer; - buffer << file.rdbuf(); - res.result(http::status::ok); - res.body() = buffer.str(); - - if (boost::algorithm::ends_with(file_path, ".html")) - res.set(http::field::content_type, "text/html"); - else if (boost::algorithm::ends_with(file_path, ".css")) - res.set(http::field::content_type, "text/css"); - else if (boost::algorithm::ends_with(file_path, ".js")) - res.set(http::field::content_type, "application/javascript"); - else if (boost::algorithm::ends_with(file_path, ".json")) - res.set(http::field::content_type, "application/json"); - else if (boost::algorithm::ends_with(file_path, ".png")) - res.set(http::field::content_type, "image/png"); - else if (boost::algorithm::ends_with(file_path, ".jpg") || - boost::algorithm::ends_with(file_path, ".jpeg")) - res.set(http::field::content_type, "image/jpeg"); - res.prepare_payload(); - return res; - } - } - - if (handler) { - try { - return handler(req, params); - } catch (const std::exception& e) { - std::cerr << "[Handler] Exception: " << e.what() << std::endl; - res.result(http::status::internal_server_error); - res.body() = "500 Internal Server Error\n"; - res.prepare_payload(); - return res; - } - } - - res.result(http::status::not_found); - res.body() = "404 Not Found\n"; - res.prepare_payload(); - return res; } void HttpServer::showStatus() const { @@ -1362,35 +191,5 @@ void HttpServer::showStatus() const { std::cout << "==========================" << std::endl; } -// ---- 会话管理 ---- -void HttpServer::addSession(const std::shared_ptr& session) { - std::lock_guard lock(sessions_mutex_); - sessions_.erase(std::remove_if(sessions_.begin(), sessions_.end(), - [](const std::weak_ptr& wp) { return wp.expired(); }), sessions_.end()); - sessions_.push_back(session); -} - -void HttpServer::removeSession(const Session* session) { - std::lock_guard lock(sessions_mutex_); - sessions_.erase(std::remove_if(sessions_.begin(), sessions_.end(), - [session](const std::weak_ptr& wp) { - auto sp = wp.lock(); - return !sp || sp.get() == session; - }), sessions_.end()); -} - -void HttpServer::closeAllSessions() { - std::lock_guard lock(sessions_mutex_); - sessions_.clear(); -} - -// ---- 调试工具 ---- -void HttpServer::debug::print_all_header_fields(const http::request& req) { - for (auto const& field : req) { - std::cout << "Name: " << field.name_string() - << ", Value: " << field.value() << std::endl; - } -} - } // namespace net } // namespace pmc diff --git a/libhttp/src/HttpServer_Listener.cpp b/libhttp/src/HttpServer_Listener.cpp new file mode 100644 index 0000000..6db6b0c --- /dev/null +++ b/libhttp/src/HttpServer_Listener.cpp @@ -0,0 +1,51 @@ +#include "us/detail/HttpServerImpl.hpp" + +namespace pmc { +namespace net { + +// ===================== Listener 实现 ===================== + +void HttpServer::Listener::run() { + if (acceptor_.is_open()) doAccept(); +} + +void HttpServer::Listener::initAcceptor(tcp::endpoint ep) { + beast::error_code ec; + acceptor_.open(ep.protocol(), ec); + if (!ec) { + acceptor_.set_option(asio::socket_base::reuse_address(true), ec); + acceptor_.bind(ep, ec); + if (!ec) acceptor_.listen(asio::socket_base::max_listen_connections, ec); + } + if (ec) throw HttpListenerException("Listener error: " + ec.message()); +} + +void HttpServer::Listener::doAccept() { + auto self = shared_from_this(); + acceptor_.async_accept( + asio::make_strand(ioc_), + [self](beast::error_code ec, tcp::socket socket) { + if (!ec) { + auto ep = socket.remote_endpoint(ec); + std::string ip = ec ? "unknown" : ep.address().to_string(); + unsigned short port = ec ? 0 : ep.port(); + std::cout << "[Listener] New connection from " << ip << ":" << port << std::endl; + + std::shared_ptr session; + if (self->ssl_enabled_ && self->ssl_ctx_) { + session = std::make_shared(std::move(socket), *self->ssl_ctx_, self->server_); + } else { + session = std::make_shared(std::move(socket), self->server_); + } + session->setRemoteInfo(ip, port); + self->server_->addSession(session); + session->run(); + } else { + std::cerr << "[Listener] Accept error: " << ec.message() << std::endl; + } + self->doAccept(); + }); +} + +} // namespace net +} // namespace pmc diff --git a/libhttp/src/HttpServer_Request.cpp b/libhttp/src/HttpServer_Request.cpp new file mode 100644 index 0000000..f5e5aa7 --- /dev/null +++ b/libhttp/src/HttpServer_Request.cpp @@ -0,0 +1,329 @@ +#include "us/detail/HttpServerImpl.hpp" + +namespace pmc { +namespace net { + +// ---- 请求处理核心 ---- +http::response HttpServer::handleRequest(const http::request& req) { + std::string target_path(req.target().data(), req.target().size()); + std::string query_string; + auto pos = target_path.find('?'); + if (pos != std::string::npos) { + query_string = target_path.substr(pos + 1); + target_path = target_path.substr(0, pos); + } + auto params = parseQueryParams(query_string); + + http::response res; + res.version(req.version()); + res.set(http::field::server, "Boost.Beast HttpServer"); + res.set(http::field::content_type, "text/plain"); + res.keep_alive(req.keep_alive()); + + bool middleware_handled = false; + for (auto& m : middlewares_) { + if (!m(req, res, params)) { + middleware_handled = true; + break; + } + } + if (middleware_handled) { + res.prepare_payload(); + return res; + } + + std::string method = http::to_string(req.method()); + + if (method == "OPTIONS") { + auto handler = findRoute("OPTIONS", target_path); + if (handler) { + try { + return handler(req, params); + } catch (const std::exception& e) { + std::cerr << "[Handler] Exception: " << e.what() << std::endl; + res.result(http::status::internal_server_error); + res.body() = "500 Internal Server Error\n"; + res.prepare_payload(); + return res; + } + } + + auto origin = req.find(http::field::origin); + if (origin != req.end()) { + std::vector all_methods = {"GET", "POST", "PUT", "DELETE", "OPTIONS"}; + std::vector allowed_methods; + + for (const auto& m : all_methods) { + if (m == "OPTIONS" || routeExists(m, target_path)) { + allowed_methods.push_back(m); + } + } + + if (!allowed_methods.empty() || findWsRoute(target_path)) { + if (allowed_methods.empty()) allowed_methods.push_back("GET"); + + std::string allow_methods; + for (size_t i = 0; i < allowed_methods.size(); ++i) { + if (i > 0) allow_methods += ", "; + allow_methods += allowed_methods[i]; + } + + res.result(http::status::ok); + res.set(http::field::allow, allow_methods); + res.set(http::field::access_control_allow_origin, + std::string(origin->value())); + res.set(http::field::access_control_allow_methods, allow_methods); + res.set(http::field::access_control_allow_headers, + "Content-Type, Authorization, X-Requested-With, Accept, Origin"); + res.set(http::field::access_control_max_age, "86400"); + res.body() = ""; + res.content_length(0); + res.prepare_payload(); + return res; + } + } + + res.result(http::status::not_found); + res.body() = "404 Not Found\n"; + res.prepare_payload(); + return res; + } + + auto handler = findRoute(method, target_path); + + if (!handler && !static_directory_.empty()) { + std::string file_path = static_directory_ + target_path; + std::ifstream file(file_path, std::ios::binary); + if (file.is_open()) { + std::stringstream buffer; + buffer << file.rdbuf(); + res.result(http::status::ok); + res.body() = buffer.str(); + + if (boost::algorithm::ends_with(file_path, ".html")) + res.set(http::field::content_type, "text/html"); + else if (boost::algorithm::ends_with(file_path, ".css")) + res.set(http::field::content_type, "text/css"); + else if (boost::algorithm::ends_with(file_path, ".js")) + res.set(http::field::content_type, "application/javascript"); + else if (boost::algorithm::ends_with(file_path, ".json")) + res.set(http::field::content_type, "application/json"); + else if (boost::algorithm::ends_with(file_path, ".png")) + res.set(http::field::content_type, "image/png"); + else if (boost::algorithm::ends_with(file_path, ".jpg") || + boost::algorithm::ends_with(file_path, ".jpeg")) + res.set(http::field::content_type, "image/jpeg"); + res.prepare_payload(); + return res; + } + } + + if (handler) { + try { + return handler(req, params); + } catch (const std::exception& e) { + std::cerr << "[Handler] Exception: " << e.what() << std::endl; + res.result(http::status::internal_server_error); + res.body() = "500 Internal Server Error\n"; + res.prepare_payload(); + return res; + } + } + + res.result(http::status::not_found); + res.body() = "404 Not Found\n"; + res.prepare_payload(); + return res; +} + +// ---- Session HTTP 处理 ---- +void HttpServer::Session::setRemoteInfo(const std::string& ip, unsigned short port) { + remote_ip_ = ip; + remote_port_ = port; +} + +void HttpServer::Session::doSSLHandshake() { + auto self = shared_from_this(); + std::cout << "[SSL] Starting handshake for " + << remote_ip_ << ":" << remote_port_ << std::endl; + + ssl_stream_->async_handshake( + asio::ssl::stream_base::server, + [self](beast::error_code ec) { + if (ec) { + std::cerr << "[SSL] Handshake error: " + << self->remote_ip_ << ":" << self->remote_port_ + << " - " << ec.message() + << " (code=" << ec.value() << ")" << std::endl; + ERR_print_errors_fp(stderr); + return; + } + std::cout << "[SSL] Handshake succeeded: " + << self->remote_ip_ << ":" << self->remote_port_ << std::endl; + self->doRead(); + }); +} + +void HttpServer::Session::doRead() { + auto self = shared_from_this(); + request_ = {}; + buffer_.consume(buffer_.size()); + + if (ssl_enabled_ && ssl_stream_) { + http::async_read(*ssl_stream_, buffer_, request_, + [self](beast::error_code ec, std::size_t) { + self->handleRead(ec); + }); + } else { + http::async_read(stream_, buffer_, request_, + [self](beast::error_code ec, std::size_t) { + self->handleRead(ec); + }); + } +} + +bool HttpServer::Session::isWebSocketUpgrade(const http::request& req) { + if (req.method() != http::verb::get) return false; + + auto upgrade = req.find(http::field::upgrade); + if (upgrade == req.end()) return false; + + std::string upgrade_val(upgrade->value()); + boost::to_lower(upgrade_val); + boost::trim(upgrade_val); + if (upgrade_val != "websocket") return false; + + auto connection = req.find(http::field::connection); + if (connection == req.end()) return false; + + std::string conn_val(connection->value()); + boost::to_lower(conn_val); + return conn_val.find("upgrade") != std::string::npos; +} + +void HttpServer::Session::handleRead(beast::error_code ec) { + if (!ec) { + if (isWebSocketUpgrade(request_)) { + std::string path = std::string(request_.target()); + auto qpos = path.find('?'); + if (qpos != std::string::npos) path = path.substr(0, qpos); + + // Check proxy routes first (higher priority) + auto proxy_handshake = server_->findWsProxyRoute(path); + if (proxy_handshake) { + std::string target = proxy_handshake(request_); + if (target.empty()) { + // Rejected by handshake callback → 403 + auto resp = std::make_shared>(); + resp->version(request_.version()); + resp->result(http::status::forbidden); + resp->set(http::field::content_type, "text/plain"); + resp->set(http::field::connection, "close"); + resp->body() = "403 Forbidden\n"; + resp->prepare_payload(); + doWrite(resp); + return; + } + ws_path_ = path; + is_ws_proxy_ = true; + ws_proxy_target_ = target; + if (ssl_enabled_) { + doWssUpgrade(); + } else { + doWsUpgrade(); + } + return; + } + + auto ws_handler = server_->findWsRoute(path); + if (ws_handler) { + ws_handler_ = ws_handler; + ws_path_ = path; + if (ssl_enabled_) { + doWssUpgrade(); + } else { + doWsUpgrade(); + } + return; + } + } + + auto req_copy = std::move(request_); + processRequest(std::move(req_copy)); + } else if (ec != http::error::end_of_stream) { + std::cerr << "[Read] Error from " << remote_ip_ << ":" << remote_port_ + << ": " << ec.message() << std::endl; + } +} + +void HttpServer::Session::processRequest(http::request req) { + try { + auto resp = std::make_shared>( + server_->handleRequest(req)); + doWrite(resp); + } catch (const std::exception& e) { + std::cerr << "[Request] Handler exception: " << e.what() << std::endl; + auto err_resp = std::make_shared>(); + err_resp->version(req.version()); + err_resp->result(http::status::internal_server_error); + err_resp->set(http::field::content_type, "text/plain"); + err_resp->body() = "500 Internal Server Error\n"; + err_resp->prepare_payload(); + doWrite(err_resp); + } +} + +void HttpServer::Session::doWrite(std::shared_ptr> response) { + auto self = shared_from_this(); + if (ssl_enabled_ && ssl_stream_) { + http::async_write(*ssl_stream_, *response, + [self, response](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[Write] Error to " << self->remote_ip_ << ":" << self->remote_port_ + << ": " << ec.message() << std::endl; + return; + } + if (!response->keep_alive()) self->close(); + else self->doRead(); + }); + } else { + http::async_write(stream_, *response, + [self, response](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[Write] Error to " << self->remote_ip_ << ":" << self->remote_port_ + << ": " << ec.message() << std::endl; + return; + } + if (!response->keep_alive()) self->close(); + else self->doRead(); + }); + } +} + +void HttpServer::Session::close() { + beast::error_code ec; + closeProxy(websocket::close_code::going_away); + if (is_websocket_) { + if (ssl_enabled_ && wss_stream_) { + wss_stream_->close(websocket::close_code::normal, ec); + } else if (ws_stream_) { + ws_stream_->close(websocket::close_code::normal, ec); + } + } + if (ssl_stream_) { + ssl_stream_->shutdown(ec); + } + stream_.socket().shutdown(tcp::socket::shutdown_send, ec); + stream_.close(); +} + +void HttpServer::Session::run() { + if (ssl_enabled_) { + doSSLHandshake(); + } else { + doRead(); + } +} + +} // namespace net +} // namespace pmc diff --git a/libhttp/src/HttpServer_Routes.cpp b/libhttp/src/HttpServer_Routes.cpp new file mode 100644 index 0000000..6683a46 --- /dev/null +++ b/libhttp/src/HttpServer_Routes.cpp @@ -0,0 +1,125 @@ +#include "us/detail/HttpServerImpl.hpp" + +namespace pmc { +namespace net { + +// ---- HTTP 路由注册 ---- +void HttpServer::get(const std::string& path, HttpRequestHandler handler) { + RouteEntry entry; + entry.path = path; + entry.handler = handler; + entry.is_wildcard = (path.find('*') != std::string::npos || + path.find('?') != std::string::npos); + if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); + get_handlers_.push_back(entry); +} + +void HttpServer::post(const std::string& path, HttpRequestHandler handler) { + RouteEntry entry; + entry.path = path; + entry.handler = handler; + entry.is_wildcard = (path.find('*') != std::string::npos || + path.find('?') != std::string::npos); + if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); + post_handlers_.push_back(entry); +} + +void HttpServer::put(const std::string& path, HttpRequestHandler handler) { + RouteEntry entry; + entry.path = path; + entry.handler = handler; + entry.is_wildcard = (path.find('*') != std::string::npos || + path.find('?') != std::string::npos); + if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); + put_handlers_.push_back(entry); +} + +void HttpServer::del(const std::string& path, HttpRequestHandler handler) { + RouteEntry entry; + entry.path = path; + entry.handler = handler; + entry.is_wildcard = (path.find('*') != std::string::npos || + path.find('?') != std::string::npos); + if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); + delete_handlers_.push_back(entry); +} + +void HttpServer::options(const std::string& path, HttpRequestHandler handler) { + RouteEntry entry; + entry.path = path; + entry.handler = handler; + entry.is_wildcard = (path.find('*') != std::string::npos || + path.find('?') != std::string::npos); + if (entry.is_wildcard) entry.pattern = wildcardToRegex(path); + options_handlers_.push_back(entry); +} + +void HttpServer::use(MiddlewareHandler m) { + middlewares_.push_back(m); +} + +void HttpServer::setStaticDirectory(const std::string& p) { + static_directory_ = p; +} + +// ---- 通配符转换 ---- +std::regex HttpServer::wildcardToRegex(const std::string& pattern) { + std::string r; + r.reserve(pattern.size() * 2); + for (char c : pattern) { + if (c == '*') r += ".*"; + else if (c == '?') r += "."; + else if (c == '.' || c == '+' || c == '(' || c == ')' || c == '[' || c == ']' || + c == '{' || c == '}' || c == '^' || c == '$' || c == '|' || c == '\\') { + r += '\\'; + r += c; + } else r += c; + } + return std::regex(r, std::regex::icase); +} + +bool HttpServer::matchPath(const std::string& path, const std::regex& pattern) { + return std::regex_match(path, pattern); +} + +// ---- 查询参数解析 ---- +std::unordered_map HttpServer::parseQueryParams(const std::string& query) { + std::unordered_map params; + if (query.empty()) return params; + + std::vector pairs; + boost::split(pairs, query, boost::is_any_of("&")); + for (const auto& pair : pairs) { + std::vector kv; + boost::split(kv, pair, boost::is_any_of("=")); + if (kv.size() == 2) params[kv[0]] = kv[1]; + else if (kv.size() == 1) params[kv[0]] = ""; + } + return params; +} + +// ---- 路由查找 ---- +HttpRequestHandler HttpServer::findRoute(const std::string& method, const std::string& path) { + const std::vector* handlers = nullptr; + if (method == "GET") handlers = &get_handlers_; + else if (method == "POST") handlers = &post_handlers_; + else if (method == "PUT") handlers = &put_handlers_; + else if (method == "DELETE") handlers = &delete_handlers_; + else if (method == "OPTIONS") handlers = &options_handlers_; + else return nullptr; + + for (const auto& e : *handlers) { + if (!e.is_wildcard && e.path == path) return e.handler; + } + for (const auto& e : *handlers) { + if (e.is_wildcard && matchPath(path, e.pattern)) return e.handler; + } + return nullptr; +} + +bool HttpServer::routeExists(const std::string& method, const std::string& path) { + return findRoute(method, path) != nullptr; +} + +} // namespace net +} // namespace pmc diff --git a/libhttp/src/HttpServer_Ws.cpp b/libhttp/src/HttpServer_Ws.cpp new file mode 100644 index 0000000..d037a97 --- /dev/null +++ b/libhttp/src/HttpServer_Ws.cpp @@ -0,0 +1,106 @@ +#include "us/detail/HttpServerImpl.hpp" + +namespace pmc { +namespace net { + +// ---- WebSocket 路由注册 ---- +void HttpServer::ws(const std::string& path, WebSocketMessageHandler handler) { + WsRouteEntry entry; + entry.path = path; + entry.handler = handler; + ws_handlers_.push_back(entry); +} + +void HttpServer::ws_proxy(const std::string& path, WsProxyHandshakeHandler handshake) { + WsProxyRouteEntry entry; + entry.path = path; + entry.handshake = handshake; + ws_proxy_handlers_.push_back(entry); +} + +void HttpServer::onWsConnect(WebSocketConnectHandler handler) { + ws_connect_handler_ = handler; +} + +void HttpServer::onWsDisconnect(WebSocketDisconnectHandler handler) { + ws_disconnect_handler_ = handler; +} + +WebSocketMessageHandler HttpServer::findWsRoute(const std::string& path) { + for (const auto& e : ws_handlers_) { + if (e.path == path) return e.handler; + } + return nullptr; +} + +WsProxyHandshakeHandler HttpServer::findWsProxyRoute(const std::string& path) { + for (const auto& e : ws_proxy_handlers_) { + if (e.path == path) return e.handshake; + } + return nullptr; +} + +// ---- WebSocket 客户端管理 ---- +std::string HttpServer::generateWsClientId() { + return "ws_client_" + std::to_string(++ws_client_counter_); +} + +void HttpServer::addWsClient(const std::string& clientId, std::shared_ptr session) { + std::lock_guard lock(ws_clients_mutex_); + ws_clients_[clientId] = session; +} + +void HttpServer::removeWsClient(const std::string& clientId) { + std::lock_guard lock(ws_clients_mutex_); + ws_clients_.erase(clientId); +} + +std::shared_ptr HttpServer::getWsClient(const std::string& clientId) { + std::lock_guard lock(ws_clients_mutex_); + auto it = ws_clients_.find(clientId); + if (it != ws_clients_.end()) return it->second; + return nullptr; +} + +bool HttpServer::sendToWsClient(const std::string& clientId, const std::string& message) { + std::shared_ptr session; + { + std::lock_guard lock(ws_clients_mutex_); + auto it = ws_clients_.find(clientId); + if (it != ws_clients_.end()) { + session = it->second; + } + } + if (session) { + session->wsSend(message); + return true; + } + return false; +} + +size_t HttpServer::broadcastWs(const std::string& message) { + std::vector> clients; + { + std::lock_guard lock(ws_clients_mutex_); + for (auto& pair : ws_clients_) { + clients.push_back(pair.second); + } + } + for (auto& session : clients) { + session->wsSend(message); + } + return clients.size(); +} + +void HttpServer::disconnectWsClient(const std::string& clientId) { + std::lock_guard lock(ws_clients_mutex_); + ws_clients_.erase(clientId); +} + +size_t HttpServer::getWsClientCount() const { + std::lock_guard lock(ws_clients_mutex_); + return ws_clients_.size(); +} + +} // namespace net +} // namespace pmc diff --git a/libhttp/src/HttpServer_WsSession.cpp b/libhttp/src/HttpServer_WsSession.cpp new file mode 100644 index 0000000..25eb7c9 --- /dev/null +++ b/libhttp/src/HttpServer_WsSession.cpp @@ -0,0 +1,506 @@ +#include "us/detail/HttpServerImpl.hpp" + +namespace pmc { +namespace net { + +// ===================== Session WebSocket 处理 ===================== + +void HttpServer::Session::wsSend(const std::string& message) { + auto exec = stream_.get_executor(); + asio::post(exec, [self = shared_from_this(), message]() { + self->ws_send_queue_.push_back(message); + if (self->ws_send_queue_.size() == 1) { + self->doWsWrite(); + } + }); +} + +void HttpServer::Session::doWsUpgrade() { + auto self = shared_from_this(); + ws_stream_ = std::make_unique(stream_); + ws_stream_->text(true); + + ws_stream_->async_accept( + request_, + [self](beast::error_code ec) { + self->onWsAccept(ec); + }); +} + +void HttpServer::Session::doWssUpgrade() { + auto self = shared_from_this(); + wss_stream_ = std::make_unique(*ssl_stream_); + wss_stream_->text(true); + + wss_stream_->async_accept( + request_, + [self](beast::error_code ec) { + self->onWsAccept(ec); + }); +} + +void HttpServer::Session::onWsAccept(beast::error_code ec) { + if (ec) { + std::cerr << "[WS] Accept error from " << remote_ip_ << ":" << remote_port_ + << ": " << ec.message() << std::endl; + return; + } + + is_websocket_ = true; + client_id_ = server_->generateWsClientId(); + server_->addWsClient(client_id_, shared_from_this()); + + std::cout << "[WS] Client " << client_id_ << " connected from " + << remote_ip_ << ":" << remote_port_ << " at " << ws_path_ << std::endl; + + if (is_ws_proxy_) { + std::cout << "[WS-Proxy] Proxying " << ws_path_ << " -> " << ws_proxy_target_ << std::endl; + doProxyConnect(ws_proxy_target_); + return; + } + + if (server_->ws_connect_handler_) { + server_->ws_connect_handler_(client_id_, getRemoteAddress()); + } + + doWsRead(); +} + +void HttpServer::Session::doWsRead() { + auto self = shared_from_this(); + ws_buffer_.consume(ws_buffer_.size()); + + if (ssl_enabled_ && wss_stream_) { + wss_stream_->async_read( + ws_buffer_, + [self](beast::error_code ec, std::size_t bytes_transferred) { + self->onWsRead(ec, bytes_transferred); + }); + } else if (ws_stream_) { + ws_stream_->async_read( + ws_buffer_, + [self](beast::error_code ec, std::size_t bytes_transferred) { + self->onWsRead(ec, bytes_transferred); + }); + } +} + +void HttpServer::Session::onWsRead(beast::error_code ec, std::size_t bytes_transferred) { + if (ec == websocket::error::closed) { + onWsClose(); + return; + } + if (ec) { + std::cerr << "[WS] Read error from " << client_id_ << ": " << ec.message() << std::endl; + onWsClose(); + return; + } + + std::string message = beast::buffers_to_string(ws_buffer_.data()); + ws_buffer_.consume(bytes_transferred); + + if (ws_handler_) { + auto self = shared_from_this(); + ws_handler_(client_id_, message, + [self](const std::string& response) { + self->wsSend(response); + }); + } + + doWsRead(); +} + +void HttpServer::Session::doWsWrite() { + if (ws_send_queue_.empty()) return; + + auto self = shared_from_this(); + std::string& message = ws_send_queue_.front(); + + auto write_handler = [self](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[WS] Write error to " << self->client_id_ << ": " << ec.message() << std::endl; + self->onWsClose(); + return; + } + self->ws_send_queue_.pop_front(); + if (!self->ws_send_queue_.empty()) { + self->doWsWrite(); + } + }; + + if (ssl_enabled_ && wss_stream_) { + wss_stream_->text(true); + wss_stream_->async_write(asio::buffer(message), write_handler); + } else if (ws_stream_) { + ws_stream_->text(true); + ws_stream_->async_write(asio::buffer(message), write_handler); + } +} + +void HttpServer::Session::onWsClose() { + if (client_id_.empty()) return; + std::cout << "[WS] Client " << client_id_ << " disconnected" << std::endl; + + closeProxy(websocket::close_code::normal); + server_->removeWsClient(client_id_); + + if (server_->ws_disconnect_handler_) { + server_->ws_disconnect_handler_(client_id_); + } + client_id_.clear(); + is_websocket_ = false; + is_ws_proxy_ = false; +} + +// ===================== WebSocket Proxy (双向桥接) ===================== + +void HttpServer::Session::parseProxyUrl(const std::string& url, bool& ssl, std::string& host, + std::string& port, std::string& path) { + std::string rest; + if (url.size() >= 6 && url.substr(0, 6) == "wss://") { + ssl = true; + rest = url.substr(6); + } else if (url.size() >= 5 && url.substr(0, 5) == "ws://") { + ssl = false; + rest = url.substr(5); + } else { + ssl = false; + rest = url; + } + + size_t path_pos = rest.find('/'); + std::string host_port; + if (path_pos != std::string::npos) { + host_port = rest.substr(0, path_pos); + path = rest.substr(path_pos); + } else { + host_port = rest; + path = "/"; + } + + size_t port_pos = host_port.find(':'); + if (port_pos != std::string::npos) { + host = host_port.substr(0, port_pos); + port = host_port.substr(port_pos + 1); + } else { + host = host_port; + port = ssl ? "443" : "80"; + } +} + +void HttpServer::Session::doProxyConnect(const std::string& target_url) { + auto self = shared_from_this(); + bool ssl; + std::string host, port, path; + parseProxyUrl(target_url, ssl, host, port, path); + proxy_ssl_ = ssl; + proxy_host_ = host; + proxy_port_ = port; + proxy_path_ = path; + + auto exec = stream_.get_executor(); + proxy_resolver_ = std::make_unique(asio::make_strand(exec)); + + proxy_resolver_->async_resolve(host, port, + asio::bind_executor(exec, + [self](beast::error_code ec, tcp::resolver::results_type results) { + self->onProxyResolve(ec, results); + })); +} + +void HttpServer::Session::onProxyResolve(beast::error_code ec, tcp::resolver::results_type results) { + if (ec) { + std::cerr << "[WS-Proxy] Resolve error: " << ec.message() << std::endl; + close(); + return; + } + + auto self = shared_from_this(); + auto exec = stream_.get_executor(); + + proxy_socket_ = std::make_unique(exec); + proxy_tcp_stream_ = std::make_unique(std::move(*proxy_socket_)); + proxy_socket_.reset(); + + proxy_tcp_stream_->expires_after(std::chrono::seconds(10)); + asio::async_connect(proxy_tcp_stream_->socket(), results, + asio::bind_executor(exec, + [self](beast::error_code ec, tcp::endpoint ep) { + self->onProxyConnect(ec, ep); + })); +} + +void HttpServer::Session::onProxyConnect(beast::error_code ec, tcp::endpoint /*ep*/) { + if (ec) { + std::cerr << "[WS-Proxy] Connect error: " << ec.message() << std::endl; + close(); + return; + } + + auto self = shared_from_this(); + if (proxy_ssl_) { + proxy_ssl_ctx_ = std::make_unique(asio::ssl::context::tlsv12_client); + proxy_ssl_ctx_->set_verify_mode(asio::ssl::verify_none); + proxy_ssl_stream_ = std::make_unique>( + proxy_tcp_stream_->socket(), *proxy_ssl_ctx_); + + if (!SSL_set_tlsext_host_name(proxy_ssl_stream_->native_handle(), proxy_host_.c_str())) { + std::cerr << "[WS-Proxy] Failed to set SNI" << std::endl; + close(); + return; + } + + proxy_tcp_stream_->expires_after(std::chrono::seconds(10)); + auto exec = stream_.get_executor(); + proxy_ssl_stream_->async_handshake(asio::ssl::stream_base::client, + asio::bind_executor(exec, + [self](beast::error_code ec) { + self->onProxySslHandshake(ec); + })); + } else { + proxy_ws_stream_ = std::make_unique(proxy_tcp_stream_->socket()); + proxy_tcp_stream_->expires_never(); + + proxy_ws_stream_->set_option(websocket::stream_base::decorator( + [](websocket::request_type& req) { + req.set(http::field::user_agent, "UniServe-WsProxy"); + })); + + auto exec = self->stream_.get_executor(); + proxy_ws_stream_->async_handshake(proxy_host_, proxy_path_, + asio::bind_executor(exec, + [self](beast::error_code ec) { + self->onProxyHandshake(ec); + })); + } +} + +void HttpServer::Session::onProxySslHandshake(beast::error_code ec) { + if (ec) { + std::cerr << "[WS-Proxy] SSL handshake error: " << ec.message() << std::endl; + close(); + return; + } + + auto self = shared_from_this(); + proxy_wss_stream_ = std::make_unique(*proxy_ssl_stream_); + proxy_tcp_stream_->expires_never(); + + proxy_wss_stream_->set_option(websocket::stream_base::decorator( + [](websocket::request_type& req) { + req.set(http::field::user_agent, "UniServe-WsProxy"); + })); + + auto exec = stream_.get_executor(); + proxy_wss_stream_->async_handshake(proxy_host_, proxy_path_, + asio::bind_executor(exec, + [self](beast::error_code ec) { + self->onProxyHandshake(ec); + })); +} + +void HttpServer::Session::onProxyHandshake(beast::error_code ec) { + if (ec) { + std::cerr << "[WS-Proxy] Backend handshake error: " << ec.message() << std::endl; + close(); + return; + } + + proxy_connected_ = true; + proxy_closed_ = false; + std::cout << "[WS-Proxy] Connected to backend " << proxy_host_ << ":" << proxy_port_ + << proxy_path_ << std::endl; + + if (server_->ws_connect_handler_) { + server_->ws_connect_handler_(client_id_, getRemoteAddress()); + } + + // Start bidirectional bridging + doProxyReadClient(); + doProxyReadBackend(); +} + +// ---- Client → Backend direction ---- + +void HttpServer::Session::doProxyReadClient() { + if (proxy_closed_) return; + auto self = shared_from_this(); + proxy_client_buffer_.consume(proxy_client_buffer_.size()); + + auto read_handler = [self](beast::error_code ec, std::size_t bytes_transferred) { + self->onProxyReadClient(ec, bytes_transferred); + }; + + if (ssl_enabled_ && wss_stream_) { + wss_stream_->async_read(proxy_client_buffer_, read_handler); + } else if (ws_stream_) { + ws_stream_->async_read(proxy_client_buffer_, read_handler); + } +} + +void HttpServer::Session::onProxyReadClient(beast::error_code ec, std::size_t bytes_transferred) { + if (ec) { + if (ec != websocket::error::closed) { + std::cerr << "[WS-Proxy] Client read error: " << ec.message() << std::endl; + } + closeProxy(websocket::close_code::normal); + return; + } + + bool is_binary = false; + if (ssl_enabled_ && wss_stream_) { + is_binary = wss_stream_->got_binary(); + } else if (ws_stream_) { + is_binary = ws_stream_->got_binary(); + } + + std::string data = beast::buffers_to_string(proxy_client_buffer_.data()); + proxy_client_buffer_.consume(bytes_transferred); + + // Queue to backend + auto exec = stream_.get_executor(); + asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() { + self->proxy_to_backend_queue_.emplace_back(std::move(data), is_binary); + if (self->proxy_to_backend_queue_.size() == 1) { + self->doProxyWriteBackend(); + } + }); + + doProxyReadClient(); +} + +void HttpServer::Session::doProxyWriteBackend() { + if (proxy_to_backend_queue_.empty() || !proxy_connected_ || proxy_closed_) return; + + auto self = shared_from_this(); + auto& [msg, is_binary] = proxy_to_backend_queue_.front(); + + auto write_handler = [self](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[WS-Proxy] Backend write error: " << ec.message() << std::endl; + self->closeProxy(websocket::close_code::internal_error); + return; + } + self->proxy_to_backend_queue_.pop_front(); + if (!self->proxy_to_backend_queue_.empty()) { + self->doProxyWriteBackend(); + } + }; + + if (proxy_ssl_ && proxy_wss_stream_) { + proxy_wss_stream_->binary(is_binary); + proxy_wss_stream_->async_write(asio::buffer(msg), write_handler); + } else if (proxy_ws_stream_) { + proxy_ws_stream_->binary(is_binary); + proxy_ws_stream_->async_write(asio::buffer(msg), write_handler); + } +} + +// ---- Backend → Client direction ---- + +void HttpServer::Session::doProxyReadBackend() { + if (proxy_closed_) return; + auto self = shared_from_this(); + proxy_backend_buffer_.consume(proxy_backend_buffer_.size()); + + auto read_handler = [self](beast::error_code ec, std::size_t bytes_transferred) { + self->onProxyReadBackend(ec, bytes_transferred); + }; + + if (proxy_ssl_ && proxy_wss_stream_) { + proxy_wss_stream_->async_read(proxy_backend_buffer_, read_handler); + } else if (proxy_ws_stream_) { + proxy_ws_stream_->async_read(proxy_backend_buffer_, read_handler); + } +} + +void HttpServer::Session::onProxyReadBackend(beast::error_code ec, std::size_t bytes_transferred) { + if (ec) { + if (ec != websocket::error::closed) { + std::cerr << "[WS-Proxy] Backend read error: " << ec.message() << std::endl; + } + closeProxy(websocket::close_code::normal); + return; + } + + bool is_binary = false; + if (proxy_ssl_ && proxy_wss_stream_) { + is_binary = proxy_wss_stream_->got_binary(); + } else if (proxy_ws_stream_) { + is_binary = proxy_ws_stream_->got_binary(); + } + + std::string data = beast::buffers_to_string(proxy_backend_buffer_.data()); + proxy_backend_buffer_.consume(bytes_transferred); + + // Queue to client + auto exec = stream_.get_executor(); + asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() { + self->proxy_to_client_queue_.emplace_back(std::move(data), is_binary); + if (self->proxy_to_client_queue_.size() == 1) { + self->doProxyWriteClient(); + } + }); + + doProxyReadBackend(); +} + +void HttpServer::Session::doProxyWriteClient() { + if (proxy_to_client_queue_.empty() || !proxy_connected_ || proxy_closed_) return; + + auto self = shared_from_this(); + auto& [msg, is_binary] = proxy_to_client_queue_.front(); + + auto write_handler = [self](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[WS-Proxy] Client write error: " << ec.message() << std::endl; + self->closeProxy(websocket::close_code::internal_error); + return; + } + self->proxy_to_client_queue_.pop_front(); + if (!self->proxy_to_client_queue_.empty()) { + self->doProxyWriteClient(); + } + }; + + if (ssl_enabled_ && wss_stream_) { + wss_stream_->binary(is_binary); + wss_stream_->async_write(asio::buffer(msg), write_handler); + } else if (ws_stream_) { + ws_stream_->binary(is_binary); + ws_stream_->async_write(asio::buffer(msg), write_handler); + } +} + +void HttpServer::Session::closeProxy(websocket::close_code code) { + if (proxy_closed_) return; + proxy_closed_ = true; + proxy_connected_ = false; + + beast::error_code ec; + // Close backend connection + if (proxy_ssl_ && proxy_wss_stream_) { + proxy_wss_stream_->close(code, ec); + } else if (proxy_ws_stream_) { + proxy_ws_stream_->close(code, ec); + } + if (proxy_ssl_stream_) { + proxy_ssl_stream_->shutdown(ec); + } + if (proxy_tcp_stream_) { + proxy_tcp_stream_->socket().shutdown(tcp::socket::shutdown_send, ec); + proxy_tcp_stream_->close(); + } + + proxy_to_backend_queue_.clear(); + proxy_to_client_queue_.clear(); + proxy_ws_stream_.reset(); + proxy_wss_stream_.reset(); + proxy_ssl_stream_.reset(); + proxy_ssl_ctx_.reset(); + proxy_tcp_stream_.reset(); + proxy_resolver_.reset(); +} + +} // namespace net +} // namespace pmc From 531358919cbe72e0495af7994f910064fc3010b7 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 13:16:45 +0800 Subject: [PATCH 11/22] fix conf --- gateway/example.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/example.ini b/gateway/example.ini index 495f72f..bdb0dc2 100644 --- a/gateway/example.ini +++ b/gateway/example.ini @@ -23,7 +23,7 @@ max_refresh = 10 # 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true, # 下游服务(dnfs 等)凭该字段豁免所有权校验,无需各自维护名单。 # 修改后需重启 gateway 生效;旧 Token 无此字段,按非管理员处理。 -users = qing@qsont.me +users = qing@qsont.me;shufeng@qsont.me [CORS] # 允许跨域的 Origin 白名单(分号分隔) From b30ce1424b2ff1464fe44a0e0eabb83393bcb9ef Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 13:28:41 +0800 Subject: [PATCH 12/22] docs(dnfs): update README with security audit findings and architecture corrections - Fix architecture diagram to reflect pymsg TCP RPC (not HTTP), add confs dependency - Update security headers to match actual security_middleware.py values - Add complete P0/P1/P2 security audit findings (16 issues total): - P0: Token without RSA signature verification (forgery risk) - P0: review_content() fail-closed broken due to exception handling gaps - P0: delete_note/update_note missing @_handle_exp decorator - P0: Parent nodes with children can be deleted (no DB foreign key + TOCTOU race) - P1: No token expiry check, no request body size limit, log_client config mismatch - P2: Error message info leakage, overly permissive CORS, legacy commented code - Correct file paths for frontend (static/public/), dependencies (pymsg, not nuitka) - Fix outdated code line references --- dnfs/README.md | 86 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 21 deletions(-) diff --git a/dnfs/README.md b/dnfs/README.md index c7de774..39afce2 100644 --- a/dnfs/README.md +++ b/dnfs/README.md @@ -14,13 +14,16 @@ Dendrachy Network File-System,基于父子关系(pid)组织的树状节点 ▼ dnfs :8006 (Flask + Waitress) │ - ├──► db :8005 数据持久化(SQL-over-HTTP,/execute) - └──► ai_review :8007 内容审核(仅 add_note / update_note 时调用) + ├──► confs :8010 启动时拉取配置(dnfs.ini,强依赖,retry_on_refused 等待就绪) + ├──► db :8005 数据持久化(pymsg TCP RPC,execute 路由) + └──► ai_review :8007 内容审核(HTTP POST /review,仅 add_note/update_note 时调用) ``` -- 不直连数据库,所有数据操作通过 HTTP 调用 `db` 服务(`http://127.0.0.1:8005/execute`)。 -- 新增/更新节点前调用 `ai_review` 服务(`http://127.0.0.1:8007/review`)进行内容审核,审核不通过则拒绝写入。 -- 通常部署在 gateway 之后(`Proxy.opened` 白名单中),由 gateway 完成 Token 验签后转发,dnfs 仅做所有权校验。 +- 启动时通过 pymsg 从 confs 拉取 `dnfs.ini` 配置,拉取失败直接退出(强依赖);使用 `retry_on_refused=True` 自动等待 confs 就绪。 +- 不直连数据库,所有数据操作通过 pymsg TCP RPC 调用 `db` 服务(`_route: "execute"`),规避 SQL 注入风险。 +- 新增/更新节点前调用 `ai_review` 服务进行内容审核(HTTP POST `{content: "..."}`),审核不通过则拒绝写入。 +- 日志通过 pymsg `AsyncRpcQueue` 异步批量写入 db 的 `Logs` 表,fail-safe 不阻塞业务。 +- 通常部署在 gateway/WAF 之后(`Proxy.opened` 白名单中),由 gateway 完成 Token RSA 验签后转发。 ## 数据模型 @@ -42,7 +45,7 @@ Dendrachy Network File-System,基于父子关系(pid)组织的树状节点 Authorization: Bearer _ ``` -- Token 由 gateway 在 `/login` 签发,dnfs 通过 [dnfs.py:163](dnfs.py#L163) `decode_token` 仅解 Base64 取 payload,**不验签**(验签由 gateway 完成)。 +- Token 由 gateway 在 `/login` 签发,dnfs 通过 [dnfs.py:304](dnfs.py#L304) `decode_token` 仅解 Base64 取 payload,**当前代码不验签**(代码中标注 `# FIXME: 验证数字签名`,依赖部署上经 gateway 转发的网络隔离)。 - payload 中取 `user` 字段作为操作者,写入 `owner`。 - `delete_note` 与 `update_note` 校验所有权: - 节点 `owner` 必须等于当前 `user`,否则 `403`; @@ -208,10 +211,13 @@ curl -X POST http://localhost:8006/update_note \ ## 安全特性 -- **安全响应头**([security_middleware.py](security_middleware.py)):`X-Frame-Options: DENY`、`X-Content-Type-Options: nosniff`、`Referrer-Policy`、`Permissions-Policy`、CSP(`default-src 'self'`、`frame-ancestors 'none'`),并移除 `Server` 头。 +- **安全响应头**([security_middleware.py](security_middleware.py)):`X-Frame-Options: SAMEORIGIN`、`X-XSS-Protection`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: strict-origin-when-cross-origin`、`Permissions-Policy`(禁用摄像头/麦克风/地理位置)、CSP(`default-src 'self'` 等),并移除 `Server` 头。 - **Token 鉴权**:写操作强制校验,payload 取 `user` 字段。 - **所有权隔离**:普通用户只能改/删自己的节点。 -- **内容审核**:写入与更新前调用 `ai_review`,色情/违禁/赌博/诈骗/暴力等内容会被拒绝。 +- **特殊节点管控**:link/html/image/file 类型有格式校验与大小限制(URL 前缀、文件名长度、内容大小上限)。 +- **内容审核**:写入与更新前调用 `ai_review`,非法内容会被拒绝(fail-closed 设计,见已知问题)。 +- **异步日志**:通过 pymsg `AsyncRpcQueue` 异步批量写日志,fail-safe 不阻塞业务线程。 +- **非 root 容器化运行**:Dockerfile 中使用 appuser 非特权用户运行服务。 ## 配置 @@ -266,17 +272,17 @@ dnfs 通过 pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪,** ## 前端 -三文件架构: +前端页面由 `static` 服务承载,不在 dnfs 模块内: ``` -templates/index.html 页面结构(Font Awesome 图标) -static/css/style.css 样式 -static/js/script.js 交互逻辑 +static/public/dnfs.html 页面结构 +static/public/css/ 样式(index.css / aibox.css 等) +static/public/js/dnfs.js 交互逻辑 ``` 功能:节点树浏览、新增/编辑/删除节点、文件 Base64 上传与下载、节点迁移(移动到其他父节点下)。 -> 注:首页路由 `@app.route('/')` 当前已注释,前端页面实际由 `static` 服务的 `dnfs.html` 承载。 +> 注:首页路由 `@app.route('/')` 和 `render_template` 相关代码已注释,dnfs 仅提供 JSON API,不直接提供 HTML 页面。 ## 依赖 @@ -284,12 +290,12 @@ static/js/script.js 交互逻辑 flask flask-cors waitress -nuitka # 可选,编译为独立二进制 +pymsg # 内部 RPC 通信库(../pymsg/) ``` -> `requirements.txt` 仍列有 `flask_sqlalchemy`,但代码已不使用(SQLAlchemy 直连代码已注释),属遗留依赖。 +运行时需要 `confs`(:8010,启动强依赖)、`db`(:8005)和 `ai_review`(:8007)服务可达;对外访问需经 gateway 转发并验签。 -运行时需要 `db`(:8005)和 `ai_review`(:8007)服务已启动;对外访问需经 gateway 转发并验签。 +> `requirements.txt` 中曾列有 `flask_sqlalchemy`,Dockerfile 中已添加 `pip uninstall -y flask_sqlalchemy` 清理遗留依赖;代码中的 SQLAlchemy 直连逻辑已全部注释,通过 pymsg RPC 调用 db 服务。 ## 运行 @@ -300,8 +306,46 @@ bash _run.sh # 启动服务(waitress) ## 已知问题与风险 -- **`decode_token` 未验签**([dnfs.py:167](dnfs.py#L167)):仅解 Base64 取 payload,存在 `# FIXME: 验证数字签名` 标记。当前安全性依赖"必须经 gateway 转发"这一部署假设;若 dnfs 被直接访问(绕过 gateway),任意伪造的 Token 都能通过。建议补 RSA 验签或在部署上确保仅 gateway 可达。 -- **`review_content` 返回类型不一致**([dnfs.py:73-119](dnfs.py#L73)):正常路径返回 `bool`([L105](dnfs.py#L105)),HTTPError 路径返回 `dict`([L113](dnfs.py#L113))。调用处用 `True != review` 判断,HTTPError 时 `dict != True` 恒为真,会一律判为审核失败并吞掉具体错误信息。建议统一返回类型或在调用处 catch 异常。 -- **CORS 全开**:`CORS(app)` 未配置白名单,允许任意 Origin 跨域访问。考虑到写操作需 Token,风险可控,但仍建议按 gateway 的 `[CORS] allowed_origins` 方式收紧。 -- **`call_db_service` 异常未捕获**:调用处([L208](dnfs.py#L208)、[L261](dnfs.py#L261)、[L318](dnfs.py#L318)、[L347](dnfs.py#L347)、[L440](dnfs.py#L440))直接取 `result['success']` / `notes['data']`,若 db 服务返回非预期结构或网络异常会抛 500。 -- **`get_notes` 不鉴权**:任何能访问 dnfs 的人都能枚举所有节点内容。若内容含敏感 Base64 文件,需在 gateway 层做路径级鉴权。 +审计日期: 2026-08-01 + +### 🔴 P0 — 严重问题(需立即修复) + +1. **Token 无 RSA 签名验证,可伪造任意用户身份(含管理员)** — [dnfs.py:304-311](dnfs.py#L304-L311) + - `decode_token()` 直接 base64 解码 payload,完全跳过 RSA 签名验证(代码中有 `# FIXME: 验证数字签名` 注释) + - 若 dnfs 被直接访问(绕过 gateway),攻击者可构造 `{"user": "任意邮箱", "is_admin": true}` 的 payload,base64 编码后拼接下划线+任意字符串即可伪造管理员身份,绕过所有权限校验 + - **参考实现**: gateway `verifyWsAdminToken` 展示了正确流程:分割 `_` → base64 解码 → RSA 公钥验签 → 检查过期时间 + - **修复建议**: 实现 RSA 签名验证(持有 gateway 公钥)+ Token 过期校验(`iat`/`created_at` + `keep_time`) + +2. **`review_content()` 错误处理断裂,fail-closed 失效** — [dnfs.py:149-195](dnfs.py#L149-L195) + - 成功路径返回 `bool`,但 HTTPError/URLError/其他异常直接 `raise e`,没有返回 `False` 实现 fail-closed + - 内层 `except json.JSONDecodeError as e` 变量名遮蔽外层 `HTTPError as e` + - ai_review 不可用时异常向上传播导致 500,而非拒绝内容(违反 fail-closed 设计意图) + - **修复建议**: 所有异常路径返回 `False` 或抛 DnfsExp 拒绝写入 + +3. **`delete_note` 和 `update_note` 缺少 `@_handle_exp` 装饰器** — [dnfs.py:428](dnfs.py#L428), [dnfs.py:531](dnfs.py#L531) + - `add_note`/`get_notes` 使用了 `@_handle_exp` 统一异常处理,但 `delete_note`/`update_note` 没有 + - 抛出 `DnfsExp` 或其他异常时返回 Flask 默认 500 HTML 错误页,与其他接口 JSON 响应不一致 + +4. **有子节点的父节点可能被删除(树结构完整性保护缺陷)** + - **数据库层缺失外键约束**: 虽然 dbv2 启用了 `PRAGMA foreign_keys=ON`,但 Notes 表建表 SQL([db/create_sqlite3.sql:13-19](../db/create_sqlite3.sql#L13-L19))中 `pid` 列只是普通 INTEGER,没有定义 `FOREIGN KEY (pid) REFERENCES Notes(id) ON DELETE RESTRICT` + - **应用层 TOCTOU 竞态**: 子节点查询(SELECT)和删除(DELETE)是两个独立 RPC 调用,无事务保护,检查与删除之间可插入子节点导致父节点被删后子节点成为孤儿 + - **错误处理缺陷**: `_call_dbv2` 返回 `success=False` 时响应中无 `data` 键,直接访问 `notes['data']` 会抛 KeyError 而非正确处理 + - **修复建议**: (a) 添加数据库外键约束; (b) 应用层检查返回值 `success` 状态; (c) 使用数据库事务将检查+删除原子化 + +### 🟠 P1 — 高危问题 + +5. **Token 无过期校验** — payload 中包含 `iat`/`created_at`/`keep_time` 字段,但 `decode_token()` 完全不检查,泄露的 Token 可永久使用 +6. **请求体大小无限制** — Waitress 未配置 `max_request_body_size`,可能被大请求体 DoS;建议设置 10MB 上限与 WAF 对齐 +7. **`log_client.py` 配置与主模块不一致** — [log_client.py:32-33](log_client.py#L32-L33) 直接读 `DB_HOST`/`DB_PORT` 环境变量,不从 confs 拉取配置,可能连错地址 +8. **`update_note` 修改 pid 时未验证目标父节点存在** — `add_note` 会检查 pid≠0 时父节点是否存在,但 `update_note` 直接更新不检查,可将节点移动到不存在的父节点下 +9. **db RPC 客户端未启用启动等待** — `_rpc = RpcClient(...)` 未设置 `retry_on_refused=True`,db 未就绪时首次请求失败(虽然 confs 连接启用了重试) +10. **`delete_note`/`update_note` 中 `decode_token` 无 try-except** — `get_notes` 中包裹了异常处理,但这两个函数没有,base64 解码/JSON 解析错误直接抛 500 +11. **`note_id` 缺少强制类型转换** — `note_id: int = data.get('id')` 只是类型提示,不会实际转换;传入字符串 `"5"` 依赖 SQLite 类型亲和性隐式匹配 + +### 🟡 P2 — 中危问题 + +12. **错误响应泄露内部信息** — 多处错误消息拼接 `request.remote_addr` 和用户名返回给客户端(如"未授权访问,从xxx"),应仅记录到服务端日志 +13. **CORS 配置过于宽松** — `CORS(app)` 默认允许所有来源(`*`),建议限制为实际前端域名 +14. **`_call_dbv2` 返回值访问前未统一检查 success** — 多处直接访问 `notes['data']` 而不先确认 `success==True` +15. **`pid` 参数判断逻辑不严谨** — `if not pid: pid = 0` 会将空字符串/None 等非法输入默默转为 0,掩盖参数错误 +16. **遗留注释代码** — 文件中有大量注释掉的旧版 SQLAlchemy 代码和已废弃接口,影响可读性 From 7d6ba6de9331274c4b27706fa942674d16b0e368 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 13:42:24 +0800 Subject: [PATCH 13/22] feat(gateway): add retry_on_refused for pymsg MsgClient (v0.6.1) - Add MsgConnectionRefusedException to distinguish ECONNREFUSED from other connection errors; openConnection() now throws it when connect() returns ECONNREFUSED. - MsgClient::call() adds retry_on_refused/retry_interval parameters (defaults false/1.0s), matching Python pymsg.MsgClient.call(retry_on_refused=True). - Retry loop: only retries on ECONNREFUSED; other exceptions (timeout, connection reset, business errors) propagate immediately. Throttled logging: first failure prints immediately, subsequent logs every 5 seconds. - DbC::call_msg() enables retry_on_refused=true (db is a hard dependency; gateway blocks waiting for db on startup instead of crashing). - LogC::_send() explicitly passes retry_on_refused=false (fail-safe; never block the background log thread when db is unavailable). - captcha/smtp auxiliary services keep default retry_on_refused=false (fail-fast; blocking in request threads would exhaust the thread pool). - Update README with pymsg protocol documentation, downstream retry strategy table, and v0.6.1 changelog entry. --- gateway/README.md | 31 ++++++++++++++- gateway/inc/DbC.hpp | 26 +++++++------ gateway/inc/LogC.hpp | 3 +- gateway/inc/Msg.hpp | 91 ++++++++++++++++++++++++++++++++++++-------- gateway/src/main.cpp | 2 +- 5 files changed, 121 insertions(+), 32 deletions(-) diff --git a/gateway/README.md b/gateway/README.md index e3236db..df0ecef 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -28,6 +28,29 @@ UniServe Framework 的应用层网关,C++20 实现。 | CAPTCHA | 一次性、5 分钟过期,登录与注册均可选启用 | | 速率限制 | 内存计数器,自动清理超过 1 万条的老条目 | | TLS 可选 | `Server.tls = off` 时纯 HTTP(配合 WAF),`on` 时用 `full_chain.crt` / `server.key` | +| 下游服务容错 | pymsg 客户端 `retry_on_refused` 机制:db 等强依赖服务未启动时自动阻塞重试,避免启动顺序问题;日志客户端 fail-safe 静默丢弃 | + +## pymsg 内部通信协议 + +gateway 通过项目内的 [pymsg](../pymsg/)(长度前缀帧 TCP 协议)与内部微服务通信,C++ 实现在 `inc/Msg.hpp`,与 Python 端字节级兼容。 + +**线协议**:`[4字节大端uint32长度][UTF-8载荷]`,最大 64 MiB。 + +**JSON RPC 约定**:请求形如 `{"_route": "<路由名>", ...}`,响应形如 `{"success": true, "data": ...}` 或 `{"success": false, "error": "..."}`。 + +**下游服务调用方式**: + +| 下游 | 端口 | `_route` | 调用位置 | 重试策略 | +|------|------|----------|---------|---------| +| db | 8005 | `execute` | `DbC::call_msg()`(所有数据库 CRUD) | `retry_on_refused=true`,db 未就绪时阻塞等待 | +| db | 8005 | `execute` | `LogC::_send()`(日志异步批量推送) | `retry_on_refused=false`,fail-safe 静默丢弃 | +| captcha | 8009 | `verify` / `image` | `verify_captcha_remote()`、`/captcha/image` 代理 | 默认不重试,连接失败 fail-closed 返回 502/false | +| smtp | 8008 | `verify_email` / `send_email` | `verify_email_code_remote()`、`/register/send_code` | 默认不重试,连接失败 fail-closed 返回 502/false | + +**retry_on_refused 机制**(`MsgClient::call()`,对应 Python 端 `MsgClient.call(retry_on_refused=True)`): +- 仅对 `ECONNREFUSED`(目标端口无服务监听)重试,超时、连接重置、业务错误立即抛出 +- 固定 1 秒间隔重试;首次失败立即打印 stderr 提示,之后每 5 秒打印一次等待日志(节制日志,避免刷屏) +- 用于解决微服务启动顺序不确定问题:gateway 启动时 db 未就绪,首次 db 调用自动阻塞等待直到 db 上线 ## 接口 @@ -265,6 +288,9 @@ gateway/ ├── src/ │ └── main.cpp # 全部实现:PBKDF2 / PasswordValidator / RateLimiter / 路由 / handler ├── inc/ +│ ├── Msg.hpp # pymsg C++ 实现:长度前缀帧协议、MsgClient(含 retry_on_refused 重试)、MsgException 族 +│ ├── DbC.hpp # db 服务客户端:通过 pymsg 调 dbv2 execute 路由(retry_on_refused=true) +│ ├── LogC.hpp # 统一日志客户端:异步批量队列 + pymsg INSERT(fail-safe,不重试) │ ├── Conf.hpp # ConfIni:基于 boost::property_tree 的 ini 解析 │ ├── ExpBase.hpp # 异常基类(带 source_location) │ └── LocalTime.hpp # 本地时间工具(getCurrent / parse) @@ -276,14 +302,15 @@ gateway/ ## 版本 -`0.6.0`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印: +`0.6.1`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印: ``` -Gateway 0.6.0 started +Gateway 0.6.1 started ``` 近期变更: +- `0.6.1`:pymsg 客户端增加 `retry_on_refused` 重试机制,与 Python 端 `pymsg.MsgClient.call(retry_on_refused=True)` 行为对齐——新增 `MsgConnectionRefusedException` 异常类(区分 ECONNREFUSED 与其他连接错误);`MsgClient::call()` 新增 `retry_on_refused`/`retry_interval` 参数,仅对连接拒绝重试,固定间隔 1s,节制日志(首次立即打印 + 每 5s 一次),其他异常(超时/连接重置/业务错误)立即抛出;`DbC::call_msg()` 启用 `retry_on_refused=true`(db 是硬依赖,启动顺序不确定时自动阻塞等待);`LogC::_send()` 显式传 `retry_on_refused=false`(日志是 fail-safe,db 不可用时静默丢弃,绝不阻塞后台线程);captcha/smtp 辅助服务保持默认不重试(fail-fast,请求线程中永久阻塞会耗尽线程池)。 - `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段,支持 `routes = /path=ws://host:port/target;...` 精确路径映射;握手阶段校验 Token 中 `is_admin=true`(基于 `Admin.users` 白名单),通过后双向透明桥接文本/二进制帧到后端,支持 `ws://` 和 `wss://`;后端连接错误/鉴权失败均优雅关闭。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。顺带修复 `LocalTime.hpp` 在 Apple clang 下 C++20 tzdb 不可用的编译问题(改用 `strftime`/`get_time`/`mktime`)。 - `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。 - `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。 diff --git a/gateway/inc/DbC.hpp b/gateway/inc/DbC.hpp index 00ded14..dbbb559 100644 --- a/gateway/inc/DbC.hpp +++ b/gateway/inc/DbC.hpp @@ -25,18 +25,20 @@ namespace qing { } // 新版 pymsg 接口:通过 MsgClient(TCP 长度前缀帧协议)调用 dbv2。 - // 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应 - // {"success":..., "data":[...]}。 - // 响应格式与 call_http 兼容,调用方可直接替换。 - static json::value call_msg(const json::value& params, - double timeout = DEFAULT_TIMEOUT) { - json::value jv_route = params; - jv_route.as_object()["_route"] = "execute"; - pmc::net::MsgClient client("127.0.0.1", 8005, "gateway"); - std::string resp = client.call( - json::serialize(jv_route), timeout); - return json::parse(resp); - } + // 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应 + // {"success":..., "data":[...]}。 + // 响应格式与 call_http 兼容,调用方可直接替换。 + // db 是强依赖,启用 retry_on_refused:db 未启动时阻塞等待,避免启动顺序问题。 + static json::value call_msg(const json::value& params, + double timeout = DEFAULT_TIMEOUT) { + json::value jv_route = params; + jv_route.as_object()["_route"] = "execute"; + pmc::net::MsgClient client("127.0.0.1", 8005, "gateway"); + std::string resp = client.call( + json::serialize(jv_route), timeout, + /*retry_on_refused=*/true); + return json::parse(resp); + } private: static constexpr const char* _DBURL = "http://127.0.0.1:8005/execute"; diff --git a/gateway/inc/LogC.hpp b/gateway/inc/LogC.hpp index a685d9e..66eaa88 100644 --- a/gateway/inc/LogC.hpp +++ b/gateway/inc/LogC.hpp @@ -196,7 +196,8 @@ private: payload["values"] = std::move(values); const std::string resp = client.call( - json::serialize(payload), _TIMEOUT); + json::serialize(payload), _TIMEOUT, + /*retry_on_refused=*/false); // 日志是 fail-safe,db 不可用时静默丢弃,绝不阻塞重试 auto obj = json::parse(resp).as_object(); if (!obj.at("success").as_bool()) { std::string err = "unknown"; diff --git a/gateway/inc/Msg.hpp b/gateway/inc/Msg.hpp index fd7a956..90c1151 100644 --- a/gateway/inc/Msg.hpp +++ b/gateway/inc/Msg.hpp @@ -17,11 +17,14 @@ */ #include +#include #include +#include #include #include #include #include +#include #include #include @@ -59,6 +62,13 @@ public: explicit MsgConnectionException(const std::string &msg) : MsgException(msg) {} }; +/// 目标服务未启动(connect 返回 ECONNREFUSED),用于 retry_on_refused 重试机制 +class MsgConnectionRefusedException : public MsgConnectionException { +public: + explicit MsgConnectionRefusedException(const std::string &msg) + : MsgConnectionException(msg) {} +}; + // ============================================================================ // Low-level protocol: 4-byte big-endian length prefix + UTF-8 payload // ============================================================================ @@ -155,10 +165,14 @@ inline int openConnection(const std::string& addr, int port, double timeout) { if (::connect(fd, reinterpret_cast(&serv_addr), sizeof(serv_addr)) < 0) { + int saved_errno = errno; ::close(fd); - throw MsgConnectionException( - "could not connect to " + addr + ":" + std::to_string(port) + - " - " + ::strerror(errno)); + std::string err_msg = "could not connect to " + addr + ":" + + std::to_string(port) + " - " + ::strerror(saved_errno); + if (saved_errno == ECONNREFUSED) { + throw MsgConnectionRefusedException(err_msg); + } + throw MsgConnectionException(err_msg); } return fd; @@ -242,19 +256,64 @@ public: /// /// 不会给消息添加 ``name:`` 前缀(适用于 RPC/API 调用)。 /// - /// @param msg 要发送的原始字符串。 - /// @param timeout 超时秒数,默认 10.0。 - /// @returns 服务器返回的字符串。 - std::string call(const std::string &msg, double timeout = 10.0) { - int fd = detail::openConnection(_addr, _port, timeout); - try { - pmsg_send(fd, msg); - std::string resp = pmsg_recv(fd); - ::close(fd); - return resp; - } catch (...) { - ::close(fd); - throw; + /// @param msg 要发送的原始字符串。 + /// @param timeout 超时秒数,默认 10.0。 + /// @param retry_on_refused 目标服务未启动(ECONNREFUSED)时是否阻塞重试, + /// 直到连接成功。适用于启动顺序不确定的服务发现场景。 + /// 默认 false(连接拒绝立即抛异常)。 + /// @param retry_interval 重试间隔秒数,默认 1.0。 + /// @returns 服务器返回的字符串。 + std::string call(const std::string &msg, double timeout = 10.0, + bool retry_on_refused = false, + double retry_interval = 1.0) { + if (!retry_on_refused) { + // 快速路径:不重试,直接执行一次,保持原有行为 + int fd = detail::openConnection(_addr, _port, timeout); + try { + pmsg_send(fd, msg); + std::string resp = pmsg_recv(fd); + ::close(fd); + return resp; + } catch (...) { + ::close(fd); + throw; + } + } + + // 重试路径:仅对 ECONNREFUSED 重试,其他错误立即抛出 + constexpr double kLogEvery = 5.0; // 重试期间打印等待提示的间隔(秒) + int attempt = 0; + auto last_log = std::chrono::steady_clock::now() - + std::chrono::duration_cast( + std::chrono::duration(kLogEvery)); + + while (true) { + try { + int fd = detail::openConnection(_addr, _port, timeout); + try { + pmsg_send(fd, msg); + std::string resp = pmsg_recv(fd); + ::close(fd); + return resp; + } catch (...) { + ::close(fd); + throw; + } + } catch (const MsgConnectionRefusedException &) { + ++attempt; + auto now = std::chrono::steady_clock::now(); + double elapsed = std::chrono::duration(now - last_log).count(); + if (elapsed >= kLogEvery || attempt == 1) { + std::fprintf(stderr, + "[pymsg] 等待服务 %s:%d 就绪... (已重试 %d 次)\n", + _addr.c_str(), _port, attempt); + std::fflush(stderr); + last_log = now; + } + std::this_thread::sleep_for( + std::chrono::duration(retry_interval)); + } + // 其他异常(MsgTimeoutException、普通MsgConnectionException等)不捕获,直接向上抛出 } } diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 2437b17..dec0a30 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -28,7 +28,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.6.0"; +constexpr const char *_VER = "0.6.1"; ////////////////////////////////// #include From e7a287742247ee573daf3d2545f96f3a8edfdb47 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 13:42:59 +0800 Subject: [PATCH 14/22] feat(dnfs): delegate Token verification to gateway /whoami Replace the insecure local base64 decode (with FIXME comment about missing signature verification) with proper remote RSA signature verification through gateway's /whoami endpoint: - Add [gateway] config section (url, timeout) to dnfs.ini with defaults. - Support GATEWAY_URL environment variable override for Docker. - decode_token() now POSTs to gateway /whoami with Authorization: Bearer ; gateway verifies RSA signature and expiry, returns login/user/ is_admin. Network/gateway errors fail-closed with 503. - Add @_handle_exp decorators to delete_note() and update_note() which were missing exception handling wrappers (consistent with other routes). - docker-compose.yml: add GATEWAY_URL, CONFS_HOST, CONFS_PORT env vars plus host.docker.internal extra_hosts so dnfs container can reach host-side gateway and confs services. --- confs/configs/dnfs.ini | 4 +++ dnfs/dnfs.py | 60 ++++++++++++++++++++++++++++++++++-------- docker-compose.yml | 5 ++++ 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/confs/configs/dnfs.ini b/confs/configs/dnfs.ini index fd47bbd..f327890 100644 --- a/confs/configs/dnfs.ini +++ b/confs/configs/dnfs.ini @@ -10,3 +10,7 @@ timeout = 10 [ai_review] url = http://127.0.0.1:8007/review timeout = 10 + +[gateway] +url = http://127.0.0.1:8443/whoami +timeout = 5 diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 9d19daa..1e83ad7 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -19,7 +19,6 @@ from waitress import serve import os import sys import json -import base64 import configparser import urllib.request import urllib.error @@ -48,6 +47,7 @@ _dnfs_config = { 'server': {'host': '', 'port': ''}, 'database': {'host': '', 'port': '', 'timeout': ''}, 'ai_review': {'url': '', 'timeout': ''}, + 'gateway': {'url': '', 'timeout': ''}, } @@ -93,12 +93,15 @@ if os.environ.get('DB_PORT'): _dnfs_config['database']['port'] = os.environ['DB_PORT'] if os.environ.get('REVIEW_URL'): _dnfs_config['ai_review']['url'] = os.environ['REVIEW_URL'] +if os.environ.get('GATEWAY_URL'): + _dnfs_config['gateway']['url'] = os.environ['GATEWAY_URL'] # 配置完整性校验 _required = { 'server': ['host', 'port'], 'database': ['host', 'port', 'timeout'], 'ai_review': ['url', 'timeout'], + 'gateway': ['url', 'timeout'], } for section, keys in _required.items(): for key in keys: @@ -113,11 +116,14 @@ _DBV2_ADDR = _dnfs_config['database']['host'] _DBV2_PORT = int(_dnfs_config['database']['port']) _REVIEW_SERVER_URL = _dnfs_config['ai_review']['url'] _REVIEW_TIMEOUT = int(_dnfs_config['ai_review']['timeout']) +_GATEWAY_URL = _dnfs_config['gateway']['url'] +_GATEWAY_TIMEOUT = int(_dnfs_config['gateway']['timeout']) _ADDR = _dnfs_config['server']['host'] _PORT = int(_dnfs_config['server']['port']) _config_source = f'confs (dnfs.ini @ {_confs_host}:{_confs_port})' print(f'[dnfs] 配置来源: {_config_source}') +print(f'[dnfs] Token 验签: gateway {_GATEWAY_URL}') # 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload # 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。 @@ -301,14 +307,46 @@ apply_security_headers(app) CORS(app) -def decode_token(token: str): - idx = token.find('_') - if idx != -1: - raw = token[:idx] - # FIXME: 验证数字签名 - decoded = base64.b64decode(raw) - jj = decoded.decode('utf-8') - return json.loads(jj) +def decode_token(token: str): + """通过 gateway /whoami 接口验证 Token 并返回用户信息。 + + gateway /whoami 内部完成 RSA 签名验证和过期检查,返回: + - 成功: {"login": true, "user": "...", "is_admin": true/false, ...} + - 失败: {"login": false} + 网络异常(gateway 不可达)按 fail-closed 处理,抛 DnfsExp 拒绝请求。 + """ + if not token: + return None + + req = urllib.request.Request( + _GATEWAY_URL, + data=b'{}', + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=_GATEWAY_TIMEOUT) as resp: + data = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as e: + # gateway 不可达或返回异常:fail-closed,拒绝请求 + logs.warning('dnfs', f'Token 验签失败(gateway 异常): {e}', extra={}) + raise DnfsExp('认证服务暂时不可用', 503) + except Exception as e: + logs.warning('dnfs', f'Token 验签未知异常: {e}', extra={}) + raise DnfsExp('认证失败', 401) + + if not data.get('login'): + return None + + # gateway 返回 login=true 时 user 字段必定存在 + return { + 'user': data.get('user', ''), + 'is_admin': bool(data.get('is_admin', False)), + } @app.route('/add_note', methods=['POST']) @@ -426,7 +464,7 @@ def get_notes(): # eg: {"id": 1} @app.route('/delete_note', methods=['POST']) - +@_handle_exp def delete_note(): data = request.get_json() @@ -529,7 +567,7 @@ def delete_note(): # "content": "hello" #} @app.route('/update_note', methods=['POST']) - +@_handle_exp def update_note(): data = request.get_json() diff --git a/docker-compose.yml b/docker-compose.yml index b1be30d..6401c09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,6 +83,11 @@ services: - DB_HOST=db - DB_PORT=8005 - REVIEW_URL=http://ai_review:8007/review + - GATEWAY_URL=http://host.docker.internal:8443/whoami + - CONFS_HOST=host.docker.internal + - CONFS_PORT=8010 + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: db: condition: service_healthy From de58c0487adfcbf5704e6cf3debe1ff89b3af913 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 13:49:28 +0800 Subject: [PATCH 15/22] fix(dnfs): validate token via gateway /whoami with RSA signature verification - Replace unsafe base64-only decode_token() with HTTP POST to gateway /whoami, which performs RSA signature verification and expiry checks internally - Fail-closed on gateway unreachable: return 503 instead of allowing access - Add [gateway] config section (url/timeout) with GATEWAY_URL env override - Add missing @_handle_exp decorator to delete_note and update_note views - Fix Flask endpoint name collision using @functools.wraps on _handle_exp - Remove unused base64 import - Update dnfs.ini with gateway config (http://127.0.0.1:8443/whoami, timeout=5) - Update docker-compose.yml with GATEWAY_URL and host.docker.internal for containers --- dnfs/dnfs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 1e83ad7..636d59b 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -22,6 +22,7 @@ import json import configparser import urllib.request import urllib.error +import functools from typing import Dict, Any from security_middleware import apply_security_headers from log_client import logs @@ -292,6 +293,7 @@ def _sp_review_text(title, sp): def _handle_exp(func): '''处理视图函数中抛出的异常,并在日志模块中进行记录''' + @functools.wraps(func) def wrapper(): try: return func() except DnfsExp as e: From 9ccb9cbdcafff93f446ac12368d399021617ea0a Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 13:55:18 +0800 Subject: [PATCH 16/22] feat(gateway): migrate configuration to confs service (v0.7.0) Migrate gateway from local -i ini file to the unified confs configuration service, following the same two-layer pattern as Python services (dnfs/db/static): - Add confs/configs/gateway.ini with all existing settings plus new [Downstream] section for db/captcha/smtp host:port (previously hardcoded). - Conf.hpp: add fromString() to parse ini from text (confs response), env()/env_int() static helpers for environment variables, public default constructor, and empty-filename guard in file constructor. - DbC.hpp: add init(host, port) static method and static _host/_port members, replacing hardcoded "127.0.0.1":8005. Defaults preserved for backward compat. - main.cpp: * Rewrite config loading: bootstrap CONFS_HOST/CONFS_PORT from env (default 127.0.0.1:8010), fetch gateway.ini via pymsg from confs (retry_on_refused=true), parse with ConfIni::fromString(). * -i flag is now optional; when provided, reads local ini (dev/debug mode). * Add downstream host/port globals (_DB_HOST/_DB_PORT, _CAPTCHA_HOST/_CAPTCHA_PORT, _SMTP_HOST/_SMTP_PORT, _CONFS_HOST/_CONFS_PORT), populated from [Downstream]. * Environment variable overrides (highest priority): LISTEN_ADDR/LISTEN_PORT, DB_HOST/DB_PORT, CAPTCHA_HOST/CAPTCHA_PORT, SMTP_HOST/SMTP_PORT. * Config integrity validation: required fields (Server.address/port, Proxy.default, Downstream.db_host/db_port) cause startup failure if missing. * Initialize DbC::init() and logs().set_endpoint() after config load. * Replace all hardcoded "127.0.0.1":8005/8008/8009 in captcha/smtp/db calls with configurable globals. * Add downstream service info to startup log output. * Fix: -v/--version flag now actually prints version and exits. * Bump version to 0.7.0. - _run.sh: remove -i example.ini default; now starts without args (confs mode), passes "$@" through for optional -i override. - Update README with new config loading docs, Downstream config section, updated directory structure, and v0.7.0 changelog. --- confs/configs/gateway.ini | 53 ++++++++++++ gateway/README.md | 88 +++++++------------- gateway/_run.sh | 8 +- gateway/inc/Conf.hpp | 39 ++++++++- gateway/inc/DbC.hpp | 43 ++++++---- gateway/src/main.cpp | 166 ++++++++++++++++++++++++++++++-------- 6 files changed, 289 insertions(+), 108 deletions(-) create mode 100644 confs/configs/gateway.ini diff --git a/confs/configs/gateway.ini b/confs/configs/gateway.ini new file mode 100644 index 0000000..8f3ead1 --- /dev/null +++ b/confs/configs/gateway.ini @@ -0,0 +1,53 @@ +[Server] +# 当 WAF 在前面终止 TLS 时,gateway 退化为内部 HTTP 服务: +# - tls = off 启用 HTTP 模式(配合 waf 模块使用) +# - tls = on 启用 HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) +address = 127.0.0.1 +port = 8443 +tls = off + +[Proxy] +default = http://127.0.0.1:9200 +closed = http://127.0.0.1:8005;http://127.0.0.1:8007; +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;shufeng@qsont.me + +[CORS] +# 允许跨域的 Origin 白名单(分号分隔) +# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) +allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 + +[Database] +path = sqlite3.db + +[WsProxy] +# WebSocket 反向代理路由(仅管理员可访问) +# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2 +# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx), +# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。 +# 支持 ws:// 和 wss:// 后端 +# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws +routes = /ws/aibox=ws://127.0.0.1:8765; + +[Downstream] +# 下游 pymsg 服务地址(其他微服务的 host:port) +# 环境变量可覆盖:DB_HOST/DB_PORT, CAPTCHA_HOST/CAPTCHA_PORT, SMTP_HOST/SMTP_PORT, CONFS_HOST/CONFS_PORT +db_host = 127.0.0.1 +db_port = 8005 +captcha_host = 127.0.0.1 +captcha_port = 8009 +smtp_host = 127.0.0.1 +smtp_port = 8008 diff --git a/gateway/README.md b/gateway/README.md index df0ecef..0993710 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -40,12 +40,13 @@ gateway 通过项目内的 [pymsg](../pymsg/)(长度前缀帧 TCP 协议)与 **下游服务调用方式**: -| 下游 | 端口 | `_route` | 调用位置 | 重试策略 | -|------|------|----------|---------|---------| +| 下游 | 默认端口 | `_route` | 调用位置 | 重试策略 | +|------|---------|----------|---------|---------| | db | 8005 | `execute` | `DbC::call_msg()`(所有数据库 CRUD) | `retry_on_refused=true`,db 未就绪时阻塞等待 | | db | 8005 | `execute` | `LogC::_send()`(日志异步批量推送) | `retry_on_refused=false`,fail-safe 静默丢弃 | | captcha | 8009 | `verify` / `image` | `verify_captcha_remote()`、`/captcha/image` 代理 | 默认不重试,连接失败 fail-closed 返回 502/false | | smtp | 8008 | `verify_email` / `send_email` | `verify_email_code_remote()`、`/register/send_code` | 默认不重试,连接失败 fail-closed 返回 502/false | +| confs | 8010 | `get` | 启动时拉取 `gateway.ini` 配置 | `retry_on_refused=true`,confs 未就绪时阻塞等待 | **retry_on_refused 机制**(`MsgClient::call()`,对应 Python 端 `MsgClient.call(retry_on_refused=True)`): - 仅对 `ECONNREFUSED`(目标端口无服务监听)重试,超时、连接重置、业务错误立即抛出 @@ -186,53 +187,25 @@ gateway 不直连数据库(早期直连本地 sqlite 的代码已注释保留 ## 配置 -[example.ini](example.ini): +gateway 配置采用**两层加载模式**,与其他 Python 服务(dnfs/db/static)保持一致: -```ini -[Server] -# tls = off HTTP 模式(配合 waf 模块) -# tls = on HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) -address = 127.0.0.1 -port = 8443 -tls = off +1. **confs 统一配置服务**(默认模式):启动时从 confs(`127.0.0.1:8010`,可通过 `CONFS_HOST`/`CONFS_PORT` 环境变量覆盖)通过 pymsg `get` 路由拉取 `gateway.ini`,`retry_on_refused=true` 阻塞等待 confs 就绪。 +2. **本地文件覆盖**(开发/调试模式):启动时传 `-i ` 则跳过 confs,直接读取本地 ini 文件。 +3. **环境变量覆盖**(最高优先级,Docker 兼容):`LISTEN_ADDR`/`LISTEN_PORT`、`DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 覆盖 ini 中对应值。 +4. **完整性校验**:必需配置项缺失则启动失败。 -[Proxy] -default = http://127.0.0.1:9200 -closed = http://127.0.0.1:8005;http://127.0.0.1:8007; -opened = http://127.0.0.1:9200;http://127.0.0.1:8006; +配置文件存放于 [confs/configs/gateway.ini](../confs/configs/gateway.ini)([example.ini](example.ini) 为本地开发参考副本,内容一致,新增 `[Downstream]` 段)。 -[Token] -# Token 有效期(秒),默认 3600 = 1 小时 -keep_time = 3600 - -[Admin] -# 管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 is_admin=true -# WebSocket 代理仅允许管理员访问,其他需管理员权限的下游同样凭此字段判断 -users = admin@example.com - -[CORS] -# 允许跨域的 Origin 白名单(分号分隔) -# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) -allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 - -[Database] -path = sqlite3.db - -[WsProxy] -# WebSocket 反向代理路由(仅管理员) -# 格式:/path=ws://host:port/target;/path2=wss://host2/target2 -# 支持 ws:// 和 wss:// 后端,握手阶段校验管理员 Token -# routes = /ws/aibox=ws://127.0.0.1:8765;/ws/log=wss://example.com/log -routes = -``` - -- `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token,主要服务前端静态资源)。 +配置项说明: +- `Server.address`/`port`/`tls`:监听地址、端口、TLS 开关;`address`/`port` 可被 `LISTEN_ADDR`/`LISTEN_PORT` 环境变量覆盖。 +- `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token)。 - `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。 -- `Proxy.closed`:仅记录用途,实际靠"不在 opened 中即拒绝"实现隔离。 -- `Token.keep_time`:登录签发 Token 的有效期(秒),缺省 3600。 -- `Admin.users`:管理员白名单(分号分隔邮箱),登录时命中即在 Token 中写入 `is_admin=true`。 -- `CORS.allowed_origins`:OPTIONS 预检允许跨域的 Origin 白名单(分号分隔),缺省为空(不回填 ACAO)。 -- `WsProxy.routes`:WebSocket 代理路由映射,格式 `/path=ws://host:port/target`,多个用分号分隔;留空则不开启 WS 代理。 +- `Proxy.closed`:仅文档记录用途,实际靠"不在 opened 中即拒绝"实现隔离。 +- `Token.*`:Token 有效期(`keep_time`)、刷新宽限期倍数(`refresh_grace_multiplier`)、最大刷新次数(`max_refresh`)。 +- `Admin.users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。 +- `CORS.allowed_origins`:允许跨域的 Origin 白名单(分号分隔)。 +- `WsProxy.routes`:WebSocket 代理路由,格式 `/path=ws://host:port/target`,分号分隔。 +- `Downstream.*`:下游 pymsg 服务地址(db/captcha/smtp),可被 `DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 环境变量覆盖。 ## 部署模式 @@ -275,42 +248,43 @@ tls = on - 项目内库:`libus_http`、`libus_crypt`(由 [libhttp](../libhttp/)、[libcrypt](../libcrypt/) 编译安装) ```bash -bash _set.sh # make clear && make -bash _run.sh # ./build/gateway.out -i example.ini +bash _set.sh # make clean && make +bash _run.sh # 默认从 confs 拉取配置;开发模式可用 _run.sh -i example.ini ``` -`gateway.out` 需以 `-i ` 传配置文件路径。其他参数:`-h/--help`、`-v/--version`。 +默认无需传参(从 confs 拉取配置);`-i ` 为本地文件模式(开发/调试用)。其他参数:`-h/--help`、`-v/--version`。 ## 目录结构 ``` gateway/ ├── src/ -│ └── main.cpp # 全部实现:PBKDF2 / PasswordValidator / RateLimiter / 路由 / handler +│ └── main.cpp # 全部实现:配置加载(confs/本地文件/环境变量)、PBKDF2、RateLimiter、路由/handler ├── inc/ -│ ├── Msg.hpp # pymsg C++ 实现:长度前缀帧协议、MsgClient(含 retry_on_refused 重试)、MsgException 族 -│ ├── DbC.hpp # db 服务客户端:通过 pymsg 调 dbv2 execute 路由(retry_on_refused=true) -│ ├── LogC.hpp # 统一日志客户端:异步批量队列 + pymsg INSERT(fail-safe,不重试) -│ ├── Conf.hpp # ConfIni:基于 boost::property_tree 的 ini 解析 +│ ├── Msg.hpp # pymsg C++ 实现:长度前缀帧协议、MsgClient(含 retry_on_refused)、MsgException 族 +│ ├── DbC.hpp # db 服务客户端:通过 pymsg 调 dbv2(地址可配置,retry_on_refused=true) +│ ├── LogC.hpp # 统一日志客户端:异步批量队列 + pymsg INSERT(fail-safe) +│ ├── Conf.hpp # ConfIni:boost::property_tree ini 解析,支持 fromString/env/env_int │ ├── ExpBase.hpp # 异常基类(带 source_location) │ └── LocalTime.hpp # 本地时间工具(getCurrent / parse) +├── example.ini # 本地开发配置参考副本(与 confs/configs/gateway.ini 一致) ├── Makefile -├── example.ini ├── _set.sh / _run.sh └── README.md ``` ## 版本 -`0.6.1`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印: +`0.7.0`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印: ``` -Gateway 0.6.1 started +Gateway 0.7.0 started ``` 近期变更: -- `0.6.1`:pymsg 客户端增加 `retry_on_refused` 重试机制,与 Python 端 `pymsg.MsgClient.call(retry_on_refused=True)` 行为对齐——新增 `MsgConnectionRefusedException` 异常类(区分 ECONNREFUSED 与其他连接错误);`MsgClient::call()` 新增 `retry_on_refused`/`retry_interval` 参数,仅对连接拒绝重试,固定间隔 1s,节制日志(首次立即打印 + 每 5s 一次),其他异常(超时/连接重置/业务错误)立即抛出;`DbC::call_msg()` 启用 `retry_on_refused=true`(db 是硬依赖,启动顺序不确定时自动阻塞等待);`LogC::_send()` 显式传 `retry_on_refused=false`(日志是 fail-safe,db 不可用时静默丢弃,绝不阻塞后台线程);captcha/smtp 辅助服务保持默认不重试(fail-fast,请求线程中永久阻塞会耗尽线程池)。 +- `0.7.0`:配置体系迁移到 confs 统一配置服务——新增 `confs/configs/gateway.ini`(原 example.ini 内容 + `[Downstream]` 段配置下游服务地址);启动时默认从 confs(`CONFS_HOST`/`CONFS_PORT`,默认 `127.0.0.1:8010`)通过 pymsg `get` 路由拉取配置(`retry_on_refused=true`),`-i ` 保留为本地文件开发模式;`ConfIni` 增加 `fromString()`(从 ini 文本解析)、`env()`/`env_int()`(环境变量读取)、公开默认构造函数;环境变量覆盖支持 `LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`CAPTCHA_HOST`/`CAPTCHA_PORT`/`SMTP_HOST`/`SMTP_PORT`;配置完整性校验(`Server.address`/`port`、`Proxy.default`、`Downstream.db_host`/`db_port` 缺失则启动失败);`DbC` 增加 `init(host, port)` 支持可配置 db 地址(默认 127.0.0.1:8005),main.cpp 中所有硬编码 captcha/smtp 地址改为配置变量,`logs().set_endpoint()` 在启动时初始化;`_run.sh` 默认不传 `-i`,`--version/-v` 选项补全实现。 +- `0.6.1`:pymsg 客户端增加 `retry_on_refused` 重试机制——新增 `MsgConnectionRefusedException`;`MsgClient::call()` 新增 `retry_on_refused`/`retry_interval` 参数;`DbC::call_msg()` 启用重试等待 db 就绪;`LogC::_send()` 显式不重试(fail-safe)。 - `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段,支持 `routes = /path=ws://host:port/target;...` 精确路径映射;握手阶段校验 Token 中 `is_admin=true`(基于 `Admin.users` 白名单),通过后双向透明桥接文本/二进制帧到后端,支持 `ws://` 和 `wss://`;后端连接错误/鉴权失败均优雅关闭。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。顺带修复 `LocalTime.hpp` 在 Apple clang 下 C++20 tzdb 不可用的编译问题(改用 `strftime`/`get_time`/`mktime`)。 - `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。 - `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。 diff --git a/gateway/_run.sh b/gateway/_run.sh index dcbe665..e5363c1 100644 --- a/gateway/_run.sh +++ b/gateway/_run.sh @@ -1,4 +1,10 @@ #!/bin/bash +# gateway 启动脚本 +# 默认从 confs 统一配置服务拉取配置(confs 需已启动)。 +# 若需使用本地 ini 文件(开发/调试),传入 -i ,例如:./_run.sh -i example.ini cd "$(dirname "$0")" || exit + +# 等待依赖服务启动(confs/db) sleep 2 -exec ./build/gateway.out -i example.ini #1>/dev/null + +exec ./build/gateway.out "$@" diff --git a/gateway/inc/Conf.hpp b/gateway/inc/Conf.hpp index 6e8ce6c..a0b7d82 100644 --- a/gateway/inc/Conf.hpp +++ b/gateway/inc/Conf.hpp @@ -1,6 +1,9 @@ #pragma once #include #include +#include +#include +#include #include #include namespace property_tree = boost::property_tree; @@ -9,22 +12,54 @@ namespace qing { class ConfIni { public: + /// 默认构造(空配置,后续通过赋值覆盖,仅供 confs 加载流程内部使用) + ConfIni() = default; + + /// 从文件路径解析 ini(保留用于开发/调试模式) ConfIni(const std::string &filename) { + if (filename.empty()) return; // 空文件名不报错,后续会通过赋值覆盖 try { - std::cout << "open and parse ini file: " << filename << std::endl; + std::cout << "[conf] 从文件加载配置: " << filename << std::endl; property_tree::ini_parser::read_ini(filename, pt); } catch (const boost::property_tree::ini_parser_error& e) { - std::cerr << "INI parsing error: " << e.what() << std::endl; + std::cerr << "[conf] INI 解析错误: " << e.what() << std::endl; throw IniConfigureFileParseException("INI parsing error!"); } } + /// 从 ini 文本字符串解析(用于从 confs 拉取后解析) + static ConfIni fromString(const std::string &ini_text) { + ConfIni c; + try { + std::istringstream iss(ini_text); + property_tree::ini_parser::read_ini(iss, c.pt); + } + catch (const boost::property_tree::ini_parser_error& e) { + std::cerr << "[conf] INI 文本解析错误: " << e.what() << std::endl; + throw IniConfigureFileParseException("INI parsing error!"); + } + return c; + } + template T get(const std::string &path, const T &_defaut) { return pt.get(path, _defaut); } + /// 读取环境变量,不存在则返回默认值 + static std::string env(const char *name, const std::string &default_val = "") { + const char *v = std::getenv(name); + return v ? std::string(v) : default_val; + } + + static int env_int(const char *name, int default_val) { + const char *v = std::getenv(name); + if (!v || !*v) return default_val; + try { return std::stoi(v); } + catch (...) { return default_val; } + } + class IniConfigureFileParseException: public std::exception { public: explicit IniConfigureFileParseException(const std::string &msg) diff --git a/gateway/inc/DbC.hpp b/gateway/inc/DbC.hpp index dbbb559..796c468 100644 --- a/gateway/inc/DbC.hpp +++ b/gateway/inc/DbC.hpp @@ -10,6 +10,12 @@ namespace qing { * smtp/captcha 等其他下游的超时常量见 main.cpp。 */ static constexpr double DEFAULT_TIMEOUT = 5.0; + /// 初始化 db 服务地址(启动时从配置设置,默认 127.0.0.1:8005) + static void init(const std::string& host, int port) { + _host = host; + _port = port; + } + // 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。 // 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。 static json::value call_http(json::value& params) { @@ -18,29 +24,34 @@ namespace qing { client.set_header("Content-Type: application/json"); std::string payload = json::serialize(params); std::string res = ""; - client.post_stream_sync(_DBURL, payload, [&res](const auto& ret) { + std::string url = "http://" + _host + ":" + std::to_string(_port) + "/execute"; + client.post_stream_sync(url, payload, [&res](const auto& ret) { res += ret; }); return json::parse(res); } // 新版 pymsg 接口:通过 MsgClient(TCP 长度前缀帧协议)调用 dbv2。 - // 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应 - // {"success":..., "data":[...]}。 - // 响应格式与 call_http 兼容,调用方可直接替换。 - // db 是强依赖,启用 retry_on_refused:db 未启动时阻塞等待,避免启动顺序问题。 - static json::value call_msg(const json::value& params, - double timeout = DEFAULT_TIMEOUT) { - json::value jv_route = params; - jv_route.as_object()["_route"] = "execute"; - pmc::net::MsgClient client("127.0.0.1", 8005, "gateway"); - std::string resp = client.call( - json::serialize(jv_route), timeout, - /*retry_on_refused=*/true); - return json::parse(resp); - } + // 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应 + // {"success":..., "data":[...]}。 + // 响应格式与 call_http 兼容,调用方可直接替换。 + // db 是强依赖,启用 retry_on_refused:db 未启动时阻塞等待,避免启动顺序问题。 + static json::value call_msg(const json::value& params, + double timeout = DEFAULT_TIMEOUT) { + json::value jv_route = params; + jv_route.as_object()["_route"] = "execute"; + pmc::net::MsgClient client(_host, _port, "gateway"); + std::string resp = client.call( + json::serialize(jv_route), timeout, + /*retry_on_refused=*/true); + return json::parse(resp); + } + + static const std::string& host() { return _host; } + static int port() { return _port; } private: - static constexpr const char* _DBURL = "http://127.0.0.1:8005/execute"; + static inline std::string _host = "127.0.0.1"; + static inline int _port = 8005; }; } diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index dec0a30..0acaabe 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -28,7 +28,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.6.1"; +constexpr const char *_VER = "0.7.0"; ////////////////////////////////// #include @@ -339,6 +339,17 @@ static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数 static std::vector _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置 static std::vector _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin +/* 下游 pymsg 服务地址(从 confs [Downstream] 段加载,可被环境变量覆盖) */ +static std::string _DB_HOST = "127.0.0.1"; +static int _DB_PORT = 8005; +static std::string _CAPTCHA_HOST = "127.0.0.1"; +static int _CAPTCHA_PORT = 8009; +static std::string _SMTP_HOST = "127.0.0.1"; +static int _SMTP_PORT = 8008; +/* confs 引导配置(不依赖 confs 本身,环境变量 CONFS_HOST/CONFS_PORT 覆盖,默认 127.0.0.1:8010) */ +static std::string _CONFS_HOST = "127.0.0.1"; +static int _CONFS_PORT = 8010; + /* WebSocket 代理路由:path -> ws://backend-host:port/target * 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */ static std::unordered_map _WS_PROXY_ROUTES; @@ -414,7 +425,7 @@ bool verify_captcha(const std::string& token, const std::string& answer) { * 与内置 verify_captcha 并存:登录/注册可任选其一接入。 */ bool verify_captcha_remote(const std::string& token, const std::string& answer) { try { - pmc::net::MsgClient client("127.0.0.1", 8009, "gateway"); + pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "gateway"); json::value jv_req = { {"_route", "verify"}, {"token", token}, @@ -430,11 +441,11 @@ bool verify_captcha_remote(const std::string& token, const std::string& answer) } } -/* 调用 smtp 服务(pymsg/TCP :8008)校验邮箱验证码。 +/* 调用 smtp 服务(pymsg/TCP)校验邮箱验证码。 * 验证码一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 */ bool verify_email_code_remote(const std::string& email, const std::string& code) { try { - pmc::net::MsgClient client("127.0.0.1", 8008, "gateway"); + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); json::value jv_req = { {"_route", "verify_email"}, {"email", email}, @@ -475,7 +486,7 @@ int main(int argc, char **argv) { desc.add_options() ("help,h", "output help message") ("version,v", "output version message") - ("input,i", po::value(), "input a configure text file") + ("input,i", po::value(), "(可选) 指定本地 ini 配置文件路径,跳过 confs 拉取(开发/调试用)") ; @@ -501,21 +512,75 @@ int main(int argc, char **argv) { return 0; } - - - /* 异常:没输入配置文件 */ - if (!vm.count("input")) { - std::cerr << "Please input configure file via -i" << std::endl; - return 1; + /* 分支:输出版本号 */ + if (vm.count("version")) { + std::cout << "Gateway " << _VER << std::endl; + return 0; } - { - try { + /* ==================== 配置加载(两层:confs 强依赖 → 环境变量覆盖) ==================== + * 与 Python 服务(dnfs/db/static)保持一致的加载模式: + * 1. 引导配置:confs 地址从 CONFS_HOST/CONFS_PORT 环境变量读取,缺省 127.0.0.1:8010 + * 2. 从 confs 拉取 gateway.ini 全文(retry_on_refused=true 等待 confs 就绪) + * - 若指定了 -i 参数,则跳过 confs,直接读取本地 ini 文件(开发/调试模式) + * 3. 用 boost::property_tree 解析 ini + * 4. 环境变量覆盖(最高优先级,Docker 环境需要服务名而非 127.0.0.1) + * 5. 配置完整性校验:必需字段缺失则启动失败 + * ==================================================================================== */ - ConfIni cf(vm["input"].as()); + { + ConfIni cf; // 默认空构造,后续根据模式赋值 + + /* 1. 引导配置:confs 连接地址(不依赖 confs 本身,避免循环依赖) */ + _CONFS_HOST = ConfIni::env("CONFS_HOST", "127.0.0.1"); + _CONFS_PORT = ConfIni::env_int("CONFS_PORT", 8010); + + try { + if (vm.count("input")) { + /* 开发模式:-i 指定本地文件,跳过 confs */ + std::string ini_path = vm["input"].as(); + cf = ConfIni(ini_path); + std::cout << "[gateway] 配置来源: 本地文件 " << ini_path << std::endl; + } else { + /* 生产模式:从 confs 拉取 gateway.ini */ + std::cout << "[gateway] 从 confs (" << _CONFS_HOST << ":" << _CONFS_PORT + << ") 拉取 gateway.ini ..." << std::endl; + pmc::net::MsgClient confs_client(_CONFS_HOST, _CONFS_PORT, "gateway"); + json::object req; + req["_route"] = "get"; + req["name"] = "gateway.ini"; + std::string resp_str = confs_client.call( + json::serialize(req), /*timeout=*/5.0, + /*retry_on_refused=*/true); + auto resp = json::parse(resp_str).as_object(); + if (!resp.at("success").as_bool()) { + std::string err = "unknown"; + if (auto* ev = resp.if_contains("error")) { + if (ev->is_string()) err = ev->as_string().c_str(); + } + std::cerr << "[gateway] 致命错误: 从 confs 获取 gateway.ini 失败: " << err << std::endl; + return 1; + } + std::string ini_content = resp.at("data").as_object().at("content").as_string().c_str(); + cf = ConfIni::fromString(ini_content); + std::cout << "[gateway] 配置来源: confs (gateway.ini @ " + << _CONFS_HOST << ":" << _CONFS_PORT << ")" << std::endl; + } + } + catch (ConfIni::IniConfigureFileParseException &e) { + std::cerr << "ERROR: " << e.what() << std::endl; + std::cerr << "ini parse error" << std::endl; + return 1; + } + catch (const std::exception &e) { + std::cerr << "[gateway] 致命错误: 配置加载失败: " << e.what() << std::endl; + return 1; + } + + /* 2. 从 ini 读取配置 */ _ADDR = cf.get("Server.address", ""); - _PORT = cf.get ("Server.port", -1); - _TLS = cf.get ("Server.tls", false); + _PORT = cf.get("Server.port", -1); + _TLS = cf.get("Server.tls", false); _DEFT_URL = cf.get("Proxy.default", ""); _OPEN_URL = split(cf.get("Proxy.opened", ""), ';'); _DB_PATH = cf.get("Database.path", ""); @@ -525,8 +590,15 @@ int main(int argc, char **argv) { _CORS_ORIGINS = split(cf.get("CORS.allowed_origins", ""), ';'); _ADMIN_USERS = split(cf.get("Admin.users", ""), ';'); - /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... - * 仅管理员(Token 中 is_admin=true)可访问,用于 aibox 等内部 WS 服务的网关暴露 */ + /* 下游服务地址 */ + _DB_HOST = cf.get("Downstream.db_host", "127.0.0.1"); + _DB_PORT = cf.get("Downstream.db_port", 8005); + _CAPTCHA_HOST = cf.get("Downstream.captcha_host", "127.0.0.1"); + _CAPTCHA_PORT = cf.get("Downstream.captcha_port", 8009); + _SMTP_HOST = cf.get("Downstream.smtp_host", "127.0.0.1"); + _SMTP_PORT = cf.get("Downstream.smtp_port", 8008); + + /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... */ { std::string ws_cfg = cf.get("WsProxy.routes", ""); if (!ws_cfg.empty()) { @@ -536,7 +608,6 @@ int main(int argc, char **argv) { if (eq != std::string::npos) { std::string path = entry.substr(0, eq); std::string url = entry.substr(eq + 1); - // 去除首尾空白 auto trim = [](std::string& s) { while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin()); while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); @@ -549,22 +620,53 @@ int main(int argc, char **argv) { } } } + + /* 3. 环境变量覆盖(优先级最高,Docker 环境使用服务名而非 127.0.0.1) */ + std::string env_listen_addr = ConfIni::env("LISTEN_ADDR"); + if (!env_listen_addr.empty()) _ADDR = env_listen_addr; + int env_listen_port = ConfIni::env_int("LISTEN_PORT", -1); + if (env_listen_port > 0) _PORT = env_listen_port; + + std::string env_db_host = ConfIni::env("DB_HOST"); + if (!env_db_host.empty()) _DB_HOST = env_db_host; + int env_db_port = ConfIni::env_int("DB_PORT", -1); + if (env_db_port > 0) _DB_PORT = env_db_port; + + std::string env_captcha_host = ConfIni::env("CAPTCHA_HOST"); + if (!env_captcha_host.empty()) _CAPTCHA_HOST = env_captcha_host; + int env_captcha_port = ConfIni::env_int("CAPTCHA_PORT", -1); + if (env_captcha_port > 0) _CAPTCHA_PORT = env_captcha_port; + + std::string env_smtp_host = ConfIni::env("SMTP_HOST"); + if (!env_smtp_host.empty()) _SMTP_HOST = env_smtp_host; + int env_smtp_port = ConfIni::env_int("SMTP_PORT", -1); + if (env_smtp_port > 0) _SMTP_PORT = env_smtp_port; + + /* 4. 配置完整性校验 */ + if (_ADDR.empty() || _PORT == -1) { + std::cerr << "[gateway] 致命错误: 配置项 [Server] address/port 缺失" + "(ini 中未配置且无 LISTEN_ADDR/LISTEN_PORT 环境变量覆盖)" << std::endl; + return 1; } - - - /* 常规输出 */ - catch (ConfIni::IniConfigureFileParseException &e) { - std::cerr << "ERROR: " << e.what() << std::endl; - std::cerr << "ini format input file error" << std::endl; + if (_DEFT_URL.empty()) { + std::cerr << "[gateway] 致命错误: 配置项 [Proxy] default 缺失" << std::endl; + return 1; + } + if (_DB_HOST.empty() || _DB_PORT <= 0) { + std::cerr << "[gateway] 致命错误: 配置项 [Downstream] db_host/db_port 缺失" << std::endl; return 1; } } - if (_ADDR == "" || _PORT == -1) { - std::cerr << "Please setup addree and port in Serve section in configure file"; - std::cerr << std::endl; - return 0; - } + /* 初始化下游客户端地址 */ + DbC::init(_DB_HOST, _DB_PORT); + logs().set_endpoint(_DB_HOST, _DB_PORT); + + std::cout << "[gateway] 监听: " << _ADDR << ":" << _PORT + << " (TLS: " << (_TLS ? "on" : "off") << ")" << std::endl; + std::cout << "[gateway] 下游服务: db=" << _DB_HOST << ":" << _DB_PORT + << ", captcha=" << _CAPTCHA_HOST << ":" << _CAPTCHA_PORT + << ", smtp=" << _SMTP_HOST << ":" << _SMTP_PORT << std::endl; // 根据 Server.tls 选择 HTTPS(带证书)或 HTTP(无证书)构造函数 // HTTP 模式用于 WAF 终止 TLS 后的内部转发链路 @@ -1373,7 +1475,7 @@ http::response handleCaptchaImage( res.version(req.version()); /* 调 captcha 服务(pymsg/TCP),连接失败/超时视为 502 */ - pmc::net::MsgClient client("127.0.0.1", 8009, "gateway"); + pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "gateway"); std::string resp; try { resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA); @@ -1571,7 +1673,7 @@ http::response handleSendEmailCode( int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) try { - pmc::net::MsgClient client("127.0.0.1", 8008, "gateway"); + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); json::value jv_req = { {"_route", "send_email"}, {"email", email} From b935fefb828cf22862563a1bc96ede37979763f6 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 14:00:26 +0800 Subject: [PATCH 17/22] refactor(gateway): remove local ini fallback, configs only via confs Follow the same convention as the static module: remove local config.ini fallback and --config/-i command-line argument entirely. All configuration is now managed exclusively through confs service. - Delete gateway/example.ini (moved to confs/configs/gateway.ini). - Remove -i/--input option from boost::program_options; help (-h) and version (-v) are the only CLI flags remaining. - Simplify main.cpp config loading: remove local-file branch, always fetch gateway.ini from confs with retry_on_refused=true waiting for confs ready. - Update _run.sh: no longer passes any arguments; config comes from confs. - Update README: remove example.ini references, -i flag docs, local file mode description, and example.ini from directory structure tree. - Clarify changelog entry for v0.7.0. --- gateway/README.md | 20 ++++++++-------- gateway/_run.sh | 4 ++-- gateway/example.ini | 43 ---------------------------------- gateway/src/main.cpp | 55 ++++++++++++++++++-------------------------- 4 files changed, 35 insertions(+), 87 deletions(-) delete mode 100644 gateway/example.ini diff --git a/gateway/README.md b/gateway/README.md index 0993710..844dafa 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -189,12 +189,11 @@ gateway 不直连数据库(早期直连本地 sqlite 的代码已注释保留 gateway 配置采用**两层加载模式**,与其他 Python 服务(dnfs/db/static)保持一致: -1. **confs 统一配置服务**(默认模式):启动时从 confs(`127.0.0.1:8010`,可通过 `CONFS_HOST`/`CONFS_PORT` 环境变量覆盖)通过 pymsg `get` 路由拉取 `gateway.ini`,`retry_on_refused=true` 阻塞等待 confs 就绪。 -2. **本地文件覆盖**(开发/调试模式):启动时传 `-i ` 则跳过 confs,直接读取本地 ini 文件。 -3. **环境变量覆盖**(最高优先级,Docker 兼容):`LISTEN_ADDR`/`LISTEN_PORT`、`DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 覆盖 ini 中对应值。 -4. **完整性校验**:必需配置项缺失则启动失败。 +1. **confs 统一配置服务**:启动时从 confs(`127.0.0.1:8010`,可通过 `CONFS_HOST`/`CONFS_PORT` 环境变量覆盖)通过 pymsg `get` 路由拉取 `gateway.ini`,`retry_on_refused=true` 阻塞等待 confs 就绪。 +2. **环境变量覆盖**(最高优先级,Docker 兼容):`LISTEN_ADDR`/`LISTEN_PORT`、`DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 覆盖 ini 中对应值。 +3. **完整性校验**:必需配置项缺失则启动失败。 -配置文件存放于 [confs/configs/gateway.ini](../confs/configs/gateway.ini)([example.ini](example.ini) 为本地开发参考副本,内容一致,新增 `[Downstream]` 段)。 +配置文件统一存放于 [confs/configs/gateway.ini](../confs/configs/gateway.ini),gateway 目录下不再保留本地配置文件。 配置项说明: - `Server.address`/`port`/`tls`:监听地址、端口、TLS 开关;`address`/`port` 可被 `LISTEN_ADDR`/`LISTEN_PORT` 环境变量覆盖。 @@ -249,17 +248,17 @@ tls = on ```bash bash _set.sh # make clean && make -bash _run.sh # 默认从 confs 拉取配置;开发模式可用 _run.sh -i example.ini +bash _run.sh # 从 confs 拉取配置启动 ``` -默认无需传参(从 confs 拉取配置);`-i ` 为本地文件模式(开发/调试用)。其他参数:`-h/--help`、`-v/--version`。 +无需传参,配置统一由 confs 管理。命令行参数:`-h/--help`、`-v/--version`。 ## 目录结构 ``` gateway/ ├── src/ -│ └── main.cpp # 全部实现:配置加载(confs/本地文件/环境变量)、PBKDF2、RateLimiter、路由/handler +│ └── main.cpp # 全部实现:配置加载(confs → 环境变量覆盖)、PBKDF2、RateLimiter、路由/handler ├── inc/ │ ├── Msg.hpp # pymsg C++ 实现:长度前缀帧协议、MsgClient(含 retry_on_refused)、MsgException 族 │ ├── DbC.hpp # db 服务客户端:通过 pymsg 调 dbv2(地址可配置,retry_on_refused=true) @@ -267,12 +266,13 @@ gateway/ │ ├── Conf.hpp # ConfIni:boost::property_tree ini 解析,支持 fromString/env/env_int │ ├── ExpBase.hpp # 异常基类(带 source_location) │ └── LocalTime.hpp # 本地时间工具(getCurrent / parse) -├── example.ini # 本地开发配置参考副本(与 confs/configs/gateway.ini 一致) ├── Makefile ├── _set.sh / _run.sh └── README.md ``` +配置文件统一在 [confs/configs/gateway.ini](../confs/configs/gateway.ini),gateway 目录无本地配置文件。 + ## 版本 `0.7.0`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印: @@ -283,7 +283,7 @@ Gateway 0.7.0 started 近期变更: -- `0.7.0`:配置体系迁移到 confs 统一配置服务——新增 `confs/configs/gateway.ini`(原 example.ini 内容 + `[Downstream]` 段配置下游服务地址);启动时默认从 confs(`CONFS_HOST`/`CONFS_PORT`,默认 `127.0.0.1:8010`)通过 pymsg `get` 路由拉取配置(`retry_on_refused=true`),`-i ` 保留为本地文件开发模式;`ConfIni` 增加 `fromString()`(从 ini 文本解析)、`env()`/`env_int()`(环境变量读取)、公开默认构造函数;环境变量覆盖支持 `LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`CAPTCHA_HOST`/`CAPTCHA_PORT`/`SMTP_HOST`/`SMTP_PORT`;配置完整性校验(`Server.address`/`port`、`Proxy.default`、`Downstream.db_host`/`db_port` 缺失则启动失败);`DbC` 增加 `init(host, port)` 支持可配置 db 地址(默认 127.0.0.1:8005),main.cpp 中所有硬编码 captcha/smtp 地址改为配置变量,`logs().set_endpoint()` 在启动时初始化;`_run.sh` 默认不传 `-i`,`--version/-v` 选项补全实现。 +- `0.7.0`:配置体系完全迁移到 confs 统一配置服务——配置文件统一存放于 `confs/configs/gateway.ini`(原 example.ini 内容 + `[Downstream]` 段),删除本地 example.ini,移除 `-i/--input` 命令行参数(不再支持本地 ini 文件回退,与 static 模块一致);启动时从 confs(`CONFS_HOST`/`CONFS_PORT`,默认 `127.0.0.1:8010`)通过 pymsg `get` 路由拉取配置(`retry_on_refused=true`);`ConfIni` 增加 `fromString()`/`env()`/`env_int()`;环境变量覆盖支持 `LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`CAPTCHA_HOST`/`CAPTCHA_PORT`/`SMTP_HOST`/`SMTP_PORT`;配置完整性校验;`DbC` 增加 `init(host, port)`,main.cpp 所有硬编码 captcha/smtp/db 地址改为配置变量,`logs().set_endpoint()` 启动时初始化;`--version/-v` 选项补全实现;版本升至 0.7.0。 - `0.6.1`:pymsg 客户端增加 `retry_on_refused` 重试机制——新增 `MsgConnectionRefusedException`;`MsgClient::call()` 新增 `retry_on_refused`/`retry_interval` 参数;`DbC::call_msg()` 启用重试等待 db 就绪;`LogC::_send()` 显式不重试(fail-safe)。 - `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段,支持 `routes = /path=ws://host:port/target;...` 精确路径映射;握手阶段校验 Token 中 `is_admin=true`(基于 `Admin.users` 白名单),通过后双向透明桥接文本/二进制帧到后端,支持 `ws://` 和 `wss://`;后端连接错误/鉴权失败均优雅关闭。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。顺带修复 `LocalTime.hpp` 在 Apple clang 下 C++20 tzdb 不可用的编译问题(改用 `strftime`/`get_time`/`mktime`)。 - `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。 diff --git a/gateway/_run.sh b/gateway/_run.sh index e5363c1..9c34359 100644 --- a/gateway/_run.sh +++ b/gateway/_run.sh @@ -1,7 +1,7 @@ #!/bin/bash # gateway 启动脚本 -# 默认从 confs 统一配置服务拉取配置(confs 需已启动)。 -# 若需使用本地 ini 文件(开发/调试),传入 -i ,例如:./_run.sh -i example.ini +# 配置统一由 confs 服务管理(confs/configs/gateway.ini), +# 启动时从 confs(CONFS_HOST:CONFS_PORT,默认 127.0.0.1:8010)拉取配置。 cd "$(dirname "$0")" || exit # 等待依赖服务启动(confs/db) diff --git a/gateway/example.ini b/gateway/example.ini deleted file mode 100644 index bdb0dc2..0000000 --- a/gateway/example.ini +++ /dev/null @@ -1,43 +0,0 @@ -[Server] -# 当 WAF 在前面终止 TLS 时,gateway 退化为内部 HTTP 服务: -# - tls = off 启用 HTTP 模式(配合 waf 模块使用) -# - tls = on 启用 HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) -address = 127.0.0.1 -port = 8443 -tls = off - -[Proxy] -default = http://127.0.0.1:9200 -closed = http://127.0.0.1:8005;http://127.0.0.1:8007; -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;shufeng@qsont.me - -[CORS] -# 允许跨域的 Origin 白名单(分号分隔) -# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) -allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 - -[Database] -path = sqlite3.db - -[WsProxy] -# WebSocket 反向代理路由(仅管理员可访问) -# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2 -# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx), -# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。 -# 支持 ws:// 和 wss:// 后端 -# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws -routes = /ws/aibox=ws://127.0.0.1:8765; diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 0acaabe..0ad53da 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -486,7 +486,6 @@ int main(int argc, char **argv) { desc.add_options() ("help,h", "output help message") ("version,v", "output version message") - ("input,i", po::value(), "(可选) 指定本地 ini 配置文件路径,跳过 confs 拉取(开发/调试用)") ; @@ -522,50 +521,42 @@ int main(int argc, char **argv) { * 与 Python 服务(dnfs/db/static)保持一致的加载模式: * 1. 引导配置:confs 地址从 CONFS_HOST/CONFS_PORT 环境变量读取,缺省 127.0.0.1:8010 * 2. 从 confs 拉取 gateway.ini 全文(retry_on_refused=true 等待 confs 就绪) - * - 若指定了 -i 参数,则跳过 confs,直接读取本地 ini 文件(开发/调试模式) * 3. 用 boost::property_tree 解析 ini * 4. 环境变量覆盖(最高优先级,Docker 环境需要服务名而非 127.0.0.1) * 5. 配置完整性校验:必需字段缺失则启动失败 + * 不再支持本地 ini 文件回退(与 static 模块一致,配置统一由 confs 管理)。 * ==================================================================================== */ { - ConfIni cf; // 默认空构造,后续根据模式赋值 - /* 1. 引导配置:confs 连接地址(不依赖 confs 本身,避免循环依赖) */ _CONFS_HOST = ConfIni::env("CONFS_HOST", "127.0.0.1"); _CONFS_PORT = ConfIni::env_int("CONFS_PORT", 8010); + ConfIni cf; try { - if (vm.count("input")) { - /* 开发模式:-i 指定本地文件,跳过 confs */ - std::string ini_path = vm["input"].as(); - cf = ConfIni(ini_path); - std::cout << "[gateway] 配置来源: 本地文件 " << ini_path << std::endl; - } else { - /* 生产模式:从 confs 拉取 gateway.ini */ - std::cout << "[gateway] 从 confs (" << _CONFS_HOST << ":" << _CONFS_PORT - << ") 拉取 gateway.ini ..." << std::endl; - pmc::net::MsgClient confs_client(_CONFS_HOST, _CONFS_PORT, "gateway"); - json::object req; - req["_route"] = "get"; - req["name"] = "gateway.ini"; - std::string resp_str = confs_client.call( - json::serialize(req), /*timeout=*/5.0, - /*retry_on_refused=*/true); - auto resp = json::parse(resp_str).as_object(); - if (!resp.at("success").as_bool()) { - std::string err = "unknown"; - if (auto* ev = resp.if_contains("error")) { - if (ev->is_string()) err = ev->as_string().c_str(); - } - std::cerr << "[gateway] 致命错误: 从 confs 获取 gateway.ini 失败: " << err << std::endl; - return 1; + /* 从 confs 拉取 gateway.ini */ + std::cout << "[gateway] 从 confs (" << _CONFS_HOST << ":" << _CONFS_PORT + << ") 拉取 gateway.ini ..." << std::endl; + pmc::net::MsgClient confs_client(_CONFS_HOST, _CONFS_PORT, "gateway"); + json::object req; + req["_route"] = "get"; + req["name"] = "gateway.ini"; + std::string resp_str = confs_client.call( + json::serialize(req), /*timeout=*/5.0, + /*retry_on_refused=*/true); + auto resp = json::parse(resp_str).as_object(); + if (!resp.at("success").as_bool()) { + std::string err = "unknown"; + if (auto* ev = resp.if_contains("error")) { + if (ev->is_string()) err = ev->as_string().c_str(); } - std::string ini_content = resp.at("data").as_object().at("content").as_string().c_str(); - cf = ConfIni::fromString(ini_content); - std::cout << "[gateway] 配置来源: confs (gateway.ini @ " - << _CONFS_HOST << ":" << _CONFS_PORT << ")" << std::endl; + std::cerr << "[gateway] 致命错误: 从 confs 获取 gateway.ini 失败: " << err << std::endl; + return 1; } + std::string ini_content = resp.at("data").as_object().at("content").as_string().c_str(); + cf = ConfIni::fromString(ini_content); + std::cout << "[gateway] 配置来源: confs (gateway.ini @ " + << _CONFS_HOST << ":" << _CONFS_PORT << ")" << std::endl; } catch (ConfIni::IniConfigureFileParseException &e) { std::cerr << "ERROR: " << e.what() << std::endl; From 9d737897df04628a6f40bdd0563deaa536cfa6ef Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 14:09:24 +0800 Subject: [PATCH 18/22] =?UTF-8?q?fix(aibox):=20v2.6=20-=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8DCtrl+C=E4=B8=AD=E6=96=AD=E6=97=A0=E6=B3=95=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题根因: 1. WebSocketHandler.handle_message 完全没有处理 interrupt 消息类型(前端Ctrl+C发送的消息被丢弃) 2. DeepSeekChat 缺少取消机制,阻塞的 API 请求(iter_lines)和子进程无法被中断 修复方案 - 三层取消机制: 1. threading.Event 取消标志:流式响应循环每轮检查 _is_cancelled(),工具调用前检查 2. HTTP连接关闭:保存 _current_response 引用,cancel() 时调用 response.close() 中断 iter_lines() 3. 子进程终止:SubprocessCommandExecutor 添加 cancel() 方法,保存 _current_process,cancel时kill 修改内容: - DeepSeekChat: 添加 _cancel_event/_current_response/_command_executor 字段,cancel()/_is_cancelled()/_check_cancelled() 方法 - SubprocessCommandExecutor: 添加 __init__/_process_lock/cancel(),execute()/execute_streaming() 中设置和清除进程引用 - WebSocketHandler: 添加 handle_interrupt() 处理 interrupt 消息,回复 interrupt_ack 后调用 ai_manager.cancel() - EnhancedAIManager: 添加 cancel() 统一入口,同时取消AI和命令执行器 - think_and_respond: 首轮清除cancel标志,流式循环检查取消,API/JSON/未知异常中均检查取消标志 - _handle_tool_calls: 每个工具调用前检查取消信号 - 更新README v2.6日志和API文档 --- aibox/README.md | 23 ++++++++ aibox/aibox.py | 140 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/aibox/README.md b/aibox/README.md index a1105a8..2283157 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -21,6 +21,7 @@ - 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量 - 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM - 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换) +- 🛑 **可靠中断机制**:支持 Ctrl+C 实时中断,通过 `threading.Event` + HTTP 连接关闭 + 子进程 kill 三层取消,流式响应和长时间运行命令均可立即终止 ## 🚀 快速开始 @@ -192,6 +193,15 @@ const ws = new WebSocket('ws://localhost:8765'); } ``` +#### 5. 中断(Ctrl+C) +```json +{ + "type": "interrupt", + "content": "用户按下了 Ctrl+C 中断当前操作" +} +``` +服务端收到后立即回复 `interrupt_ack`,并通过三层取消机制(Event标志 + HTTP连接关闭 + 子进程kill)终止正在进行的AI思考和工具执行。 + ### 支持的命令 > 注意:记忆搜索通过 `memory` 工具(在对话中由 AI 调用)完成,本系统**不存在** `/search` 斜杠命令。 @@ -706,6 +716,19 @@ elif command == "/mycommand": ## 📄 更新日志 +### v2.6 +- ✅ **修复 Ctrl+C 中断无法停止服务器的问题**: + - 新增 `interrupt` WebSocket 消息类型处理(BotOps 前端 Ctrl+C 发送此消息),立即回复 `interrupt_ack` 确认 + - `DeepSeekChat` 新增 `cancel()` 方法和 `_cancel_event`(threading.Event),线程安全可跨线程调用 + - **三层取消机制**: + 1. 设置取消标志,流式响应循环每轮检查,发现取消立即 break + 2. 关闭当前 HTTP response 对象,中断 `iter_lines()` 阻塞读取 + 3. 终止正在运行的子进程(`SubprocessCommandExecutor.cancel()` → `process.kill()`) + - 工具调用循环中每个工具执行前检查取消信号,中断后不再继续后续工具 + - API 请求异常/JSON解析异常/未知异常中均检查取消标志,若因取消导致异常则按中断处理而非报错 + - 中断后自动回滚本轮消息,防止污染对话历史 + - 新增 `EnhancedAIManager.cancel()` 统一入口,同时终止 AI 和命令执行器 + ### v2.5 - ✅ **新增 DeepSeek 思考模式支持**(Reasoning Mode): - 新增 `api.reasoning_enabled` 配置项(默认 `false`),启用后在请求中添加 `thinking: {type: "enabled"}` 参数 diff --git a/aibox/aibox.py b/aibox/aibox.py index 26270e0..ef034a2 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -3532,11 +3532,28 @@ class CommandExecutor(ABC): class SubprocessCommandExecutor(CommandExecutor): - """基于subprocess的命令执行器 - 支持超时、工作目录和输出大小限制""" + """基于subprocess的命令执行器 - 支持超时、工作目录、输出大小限制和取消""" # 命令输出最大字节数(1MB),超出截断 MAX_OUTPUT_BYTES = 1 * 1024 * 1024 + def __init__(self, logger=None): + super().__init__(logger) + self._current_process = None + self._process_lock = threading.Lock() + + def cancel(self): + """取消当前正在运行的命令(kill子进程)。线程安全。""" + with self._process_lock: + proc = self._current_process + if proc is not None: + try: + if proc.poll() is None: + proc.kill() + self.logger.info("已终止正在运行的子进程") + except Exception: + pass + def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]: """从进程读取输出,限制总大小和超时。 返回 (output_str, timeout_occurred, truncated) @@ -3612,6 +3629,9 @@ class SubprocessCommandExecutor(CommandExecutor): env=exec_env, cwd=os.path.expanduser(work_dir) ) + # 保存进程引用以便cancel()可以kill它 + with self._process_lock: + self._current_process = process start_time = time.time() output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time) @@ -3654,6 +3674,9 @@ class SubprocessCommandExecutor(CommandExecutor): finally: if process and process.poll() is None: process.kill() + # 清除当前进程引用 + with self._process_lock: + self._current_process = None def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, work_dir: str = None, env: Dict = None, **kwargs): @@ -3694,6 +3717,9 @@ class SubprocessCommandExecutor(CommandExecutor): cwd=os.path.expanduser(work_dir) ) self.process = process + # 保存进程引用以便cancel()可以kill它 + with self._process_lock: + self._current_process = process start_time = time.time() output_lines = [] @@ -3788,6 +3814,8 @@ class SubprocessCommandExecutor(CommandExecutor): except: process.kill() self.process = None + with self._process_lock: + self._current_process = None # ==================== 工具基类 ==================== @@ -6750,11 +6778,42 @@ class DeepSeekChat: self.preserve_system = constants.PRESERVE_SYSTEM_PROMPTS self.stats = {"api_calls": 0, "tool_calls": 0, "errors": []} + # 取消机制:threading.Event 用于跨线程信号,_current_response 用于中断HTTP请求 + self._cancel_event = threading.Event() + self._current_response = None + self._command_executor = command_executor # 保存命令执行器引用,用于取消时kill子进程 + self._init_system_prompts(system_prompt) self.tool_registry = ToolRegistry(self.logger) self._register_tools(exit_callback, command_executor) + def cancel(self): + """中断当前正在进行的AI思考/工具执行。线程安全,可从任意线程调用。""" + self.logger.info("收到中断信号,正在取消当前操作...") + self._cancel_event.set() + # 关闭当前HTTP响应,中断 iter_lines() 阻塞 + if self._current_response is not None: + try: + self._current_response.close() + except Exception: + pass + # 终止正在运行的子进程 + if self._command_executor is not None and hasattr(self._command_executor, 'cancel'): + try: + self._command_executor.cancel() + except Exception: + pass + + def _is_cancelled(self) -> bool: + """检查是否已被取消。""" + return self._cancel_event.is_set() + + def _check_cancelled(self): + """如果已取消,抛出异常中断当前流程。""" + if self._cancel_event.is_set(): + raise InterruptedError(i18n.get('user_interrupted', '用户中断')) + def _init_system_prompts(self, system_prompt: str = None): """初始化系统提示词 - 从 PromptManager 加载(按 identity → 技能 → soul → status 顺序)""" if system_prompt: @@ -6965,6 +7024,11 @@ class DeepSeekChat: MAX_RESULT_LENGTH = constants.TOOL_MAX_RESULT_LENGTH for tool_call in tool_calls: + # 每个工具调用前检查取消信号 + if self._is_cancelled(): + self.logger.info("工具调用被用户中断") + break + function = tool_call.get("function", {}) tool_name = function.get("name", "unknown") arguments = function.get("arguments", "{}") @@ -7076,6 +7140,13 @@ class DeepSeekChat: is_continuation: 是否为工具调用后的递归继续;为 True 时不注入冗长的工作流指令, 因为 system prompt 中已有完整行为规范,继续时只需传递工具结果 """ + # 首轮开始时清除取消标志 + if not is_continuation: + self._cancel_event.clear() + + # 立即检查是否已被取消 + self._check_cancelled() + if not self.conversation_active: self.logger.info("对话已结束,重置状态") self.conversation_active = True @@ -7217,6 +7288,7 @@ class DeepSeekChat: stream=True, proxies=proxies # 动态代理 ) + self._current_response = response # 保存引用以便cancel()关闭连接 if response.status_code != 200: error_msg = f"\n错误状态码: {response.status_code}\n错误响应: {response.text}" @@ -7225,6 +7297,9 @@ class DeepSeekChat: response.raise_for_status() for line in response.iter_lines(): + # 流式循环中检查取消信号 + if self._is_cancelled(): + break if line: line = line.decode('utf-8') if line.startswith('data: '): @@ -7315,6 +7390,16 @@ class DeepSeekChat: self.logger.error(f"JSON解析错误: {e}, 数据: {data}") continue + # 流式循环结束,检查是否被取消 + if self._is_cancelled(): + self._rollback_messages(msgs_before_turn) + interrupt_msg = i18n.get('user_interrupted', '用户中断') + if output_callback: + output_callback("line", "\n" + interrupt_msg + "\n") + output_callback("complete", interrupt_msg) + self.logger.info("AI响应被用户中断") + return interrupt_msg + # 处理工具调用 if current_tool_calls: tool_calls_buffer = list(current_tool_calls.values()) @@ -7382,7 +7467,24 @@ class DeepSeekChat: return full_response if full_response else "" + except InterruptedError: + # 用户主动中断:回滚消息,通知前端 + self._rollback_messages(msgs_before_turn) + interrupt_msg = i18n.get('user_interrupted', '用户中断') + if output_callback: + output_callback("line", "\n" + interrupt_msg + "\n") + output_callback("complete", interrupt_msg) + self.logger.info("AI响应被用户中断") + return interrupt_msg except requests.exceptions.RequestException as e: + # 如果是因为取消导致的连接关闭,按中断处理 + if self._is_cancelled(): + self._rollback_messages(msgs_before_turn) + interrupt_msg = i18n.get('user_interrupted', '用户中断') + if output_callback: + output_callback("line", "\n" + interrupt_msg + "\n") + output_callback("complete", interrupt_msg) + return interrupt_msg # 网络/HTTP 错误:回滚本轮添加的消息(user 消息 + 可能的 assistant/tool 消息), # 防止格式错误的消息污染历史导致后续请求持续失败 self._rollback_messages(msgs_before_turn) @@ -7402,6 +7504,14 @@ class DeepSeekChat: self.logger.error(f"API响应解析失败,已回滚消息到位置 {msgs_before_turn}: {e}") return error_msg except Exception as e: + # 如果是因为取消导致的异常,按中断处理 + if self._is_cancelled(): + self._rollback_messages(msgs_before_turn) + interrupt_msg = i18n.get('user_interrupted', '用户中断') + if output_callback: + output_callback("line", "\n" + interrupt_msg + "\n") + output_callback("complete", interrupt_msg) + return interrupt_msg # 未知错误:回滚消息,防止对话状态被破坏 self._rollback_messages(msgs_before_turn) error_msg = f"\n{i18n.get('error_prefix')}{str(e)}" @@ -7410,6 +7520,9 @@ class DeepSeekChat: self.stats["errors"].append(str(e)) self.logger.error(f"未知错误,已回滚消息到位置 {msgs_before_turn}: {e}") return error_msg + finally: + # 清除当前响应引用 + self._current_response = None def _rollback_messages(self, to_length: int): """回滚 self.messages 到指定长度,用于 API 失败时恢复对话状态。""" @@ -7623,6 +7736,8 @@ class WebSocketHandler: await self.handle_command(websocket, data) elif msg_type == "message": await self.handle_user_message(websocket, data) + elif msg_type == "interrupt": + await self.handle_interrupt(websocket, data) elif msg_type == "task": await self.handle_task_message(websocket, data) elif msg_type == "ping": @@ -7648,6 +7763,18 @@ class WebSocketHandler: "content": f"处理失败: {str(e)}" }) + async def handle_interrupt(self, websocket, data): + """处理中断信号:取消正在进行的AI思考和工具执行""" + self.logger.info("收到客户端中断信号") + # 先发送确认,让前端立即得到反馈 + await self.send_message(websocket, { + "type": "interrupt_ack", + "content": "已收到中断信号,正在停止..." + }) + # 在事件循环线程外执行cancel(cancel是线程安全的,会设置Event并关闭response/kill进程) + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self.ai_manager.cancel) + async def handle_task_message(self, websocket, data): """处理任务相关消息 - 简化版,所有任务都是对话任务""" action = data.get("action", "").strip() @@ -8109,6 +8236,17 @@ class EnhancedAIManager: self.logger.info("AI已初始化") return ai + def cancel(self): + """中断当前AI操作(线程安全,可从任意线程调用)""" + if self.ai: + self.ai.cancel() + # 同时确保命令执行器中的进程也被终止 + if self.command_executor and hasattr(self.command_executor, 'cancel'): + try: + self.command_executor.cancel() + except Exception: + pass + def show_welcome_message(self): """显示欢迎信息""" print("\n" + "="*70) From c7ef7e715c1d35da66cd8f2dc3b67c9eda59b6f2 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 14:12:27 +0800 Subject: [PATCH 19/22] =?UTF-8?q?fix(botops):=20v1.3=20-=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=B8=AD=E6=96=AD=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=EF=BC=8CCtrl+C=E7=AB=8B=E5=8D=B3=E5=8F=AF=E9=9D=A0?= =?UTF-8?q?=E5=81=9C=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:收到interrupt_ack时仅显示系统通知,没有立即收尾前端状态: - 思考区域一直停留在"💭 思考中..."状态,不会更新 - AI回复状态未重置,后续残留chunk可能继续追加 - 服务端不发送reasoning_end,思考区域无法正常结束 修复: - ChatView新增abort_reasoning()方法:中断时将思考区域标签更新为"🛑 思考已中断"(保留已展示内容) - append_reasoning()增加_ai_finalized检查,中断后丢弃残留思考片段,防止重新创建思考区域 - app.py收到interrupt_ack时:先abort_reasoning()更新思考区域,再finish_ai()收尾AI状态,最后显示系统通知 - 中断后服务端发来的残留chunk/reasoning/complete均被安全忽略(append_ai已有_finalized检查,finish_ai幂等) - 更新README v1.3日志 --- BotOps/README.md | 7 +++++++ BotOps/app.py | 3 +++ BotOps/widgets/chat_view.py | 14 ++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/BotOps/README.md b/BotOps/README.md index 938f9c6..54e5948 100644 --- a/BotOps/README.md +++ b/BotOps/README.md @@ -71,6 +71,13 @@ BotOps/ ## 更新日志 +### v1.3 +- ✅ **适配服务端中断机制(Ctrl+C可靠停止)**: + - 收到 `interrupt_ack` 时立即收尾前端状态:调用 `abort_reasoning()` 将思考区域更新为"🛑 思考已中断"标签,调用 `finish_ai()` 结束AI回复状态 + - 新增 `ChatView.abort_reasoning()` 方法:中断时更新思考区域标签(保留已展示的思考内容) + - `append_reasoning()` 增加 `_ai_finalized` 检查,中断后丢弃残留的思考片段防止重新创建思考区域 + - 中断后服务端发来的残留 `chunk`/`reasoning`/`complete` 消息均被安全忽略,不会污染UI + ### v1.2 - ✅ **彻底修复思考区域顺序问题**:移除预创建空 AI widget 的逻辑,改为第一次收到 `chunk` 时才创建 widget。按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),从根本上保证思考区域始终在正式回答之前 - ✅ 简化 `complete` 处理逻辑:仅作为收尾重置状态信号,不再进行补建逻辑 diff --git a/BotOps/app.py b/BotOps/app.py index 5ea6049..ca3170f 100644 --- a/BotOps/app.py +++ b/BotOps/app.py @@ -226,6 +226,9 @@ class BotOpsApp(App): f"❌ [{data.get('task_id', '?')}] {data.get('task_title', '?')}: " f"{data.get('error', '未知错误')}") elif t == "interrupt_ack": + # 服务端确认中断:立即收尾前端状态(更新思考区域为已中断、结束AI回复) + await self.chat.abort_reasoning() + await self.chat.finish_ai("") await self.notify_system(f"🛑 {content}") elif t == "error": await self.notify_system(f"❌ {content}", level="error") diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py index 2dcf3a4..09c0bf8 100644 --- a/BotOps/widgets/chat_view.py +++ b/BotOps/widgets/chat_view.py @@ -75,6 +75,9 @@ class ChatView(VerticalScroll): """追加思考内容增量。""" if not delta: return + if self._ai_finalized: + # 已收尾(中断/完成),丢弃残留的思考片段 + return if self._reasoning_widget is None: await self.start_reasoning() self._reasoning_text += delta @@ -95,6 +98,17 @@ class ChatView(VerticalScroll): self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}") # 思考区域保留显示,不删除 + async def abort_reasoning(self): + """中断思考区域(用户Ctrl+C),更新提示文字为已中断。""" + if self._reasoning_widget is not None: + if self._reasoning_text: + display = self._reasoning_text + if len(display) > 3000: + display = "...(前略)\n" + display[-3000:] + self._reasoning_widget.update(f"🛑 思考已中断({len(self._reasoning_text)}字符)\n{display}") + else: + self._reasoning_widget.update("🛑 思考已中断") + async def _ensure_ai_widget(self): """确保AI widget存在(第一次chunk到来时创建)。""" if self._ai_widget is None: From 08f737ba2c6bfce269585dbf059b073d469d7ee1 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 14:14:53 +0800 Subject: [PATCH 20/22] =?UTF-8?q?fix(static):=20aibox=20Web=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E9=80=82=E9=85=8D=E4=B8=AD=E6=96=AD=E6=9C=BA?= =?UTF-8?q?=E5=88=B6(Ctrl+C=E5=8F=AF=E9=9D=A0=E5=81=9C=E6=AD=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(与BotOps v1.3修复一致): - interrupt_ack只显示系统消息,没有立即收尾前端状态 - 思考区域一直停留在"💭 思考中..."状态(服务端中断不发reasoning_end) - AI状态未重置,残留reasoning片段可能重新创建思考区域 修复: - 新增abortReasoning()函数:中断时更新思考区域标签为"🛑 思考已中断"(保留已展示内容) - appendReasoning()增加aiActive===false检查,中断后丢弃残留片段 - interrupt_ack处理改为:先abortReasoning()更新思考区域,再finishAi()收尾AI状态,最后显示通知 - 中断后残留chunk/reasoning/complete均被安全忽略(appendAi已有aiActive检查,finishAi幂等) --- static/public/js/aibox.js | 112 ++++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 52 deletions(-) diff --git a/static/public/js/aibox.js b/static/public/js/aibox.js index 056676a..5b72e15 100644 --- a/static/public/js/aibox.js +++ b/static/public/js/aibox.js @@ -1,6 +1,6 @@ 'use strict'; /* ============================================================ - * aibox Web 客户端(经 gateway WS 反向代理,仅管理员可用) + * aibox Web 客户端 · 移动端版(经 gateway WS 反向代理,仅管理员可用) * 协议与 BotOps/aibox client.py 完全兼容: * 发送:{"type":"message","content":...} / {"type":"command","command":"/..."} / {"type":"interrupt",...} * 接收:chunk(增量片段) / reasoning_start / reasoning / reasoning_end / complete / @@ -12,19 +12,20 @@ /* gateway [WsProxy] routes 中配置的路径(HttpOnly q4_token Cookie 随握手自动携带,JS 不经手 Token)。 * 需与 gateway.ini 的 [WsProxy] routes 条目一致,如:routes = /ws/aibox=ws://127.0.0.1:8765; */ const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/aibox'; -const RECONNECT_MIN = 2000; /* 指数退避起点(与 BotOps 一致:2s→30s 封顶) */ +const RECONNECT_MIN = 2000; /* 指数退避起点(2s→30s 封顶) */ const RECONNECT_MAX = 30000; const REASONING_TAIL = 3000; /* 思考区域超长时仅显示末尾字符数 */ +/* ---------- Markdown 渲染(链接一律新标签打开且不带 referrer) ---------- */ const md = window.markdownit({ html: false, linkify: true, breaks: true }); -/* 链接一律新标签打开且不带 referrer(CSP connect-src 'self' 允许 WS 同源) */ -const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; -md.renderer.rules.link_open = function(tokens, idx, options, env, self) { +const defaultLinkOpen = md.renderer.rules.link_open || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options)); +md.renderer.rules.link_open = (tokens, idx, options, env, self) => { tokens[idx].attrSet('target', '_blank'); tokens[idx].attrSet('rel', 'noopener noreferrer'); return defaultLinkOpen(tokens, idx, options, env, self); }; +/* ---------- DOM ---------- */ const chatEl = document.getElementById('chat'); const inputEl = document.getElementById('input'); const sendBtn = document.getElementById('send-btn'); @@ -38,26 +39,26 @@ const taskToggle = document.getElementById('task-toggle'); const taskBadge = document.getElementById('task-badge'); const backdrop = document.getElementById('backdrop'); +/* ---------- 状态 ---------- */ let ws = null; let manualClose = false; /* 手动断开则抑制自动重连 */ let reconnectDelay = RECONNECT_MIN; let reconnectTimer = null; -/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前,与 BotOps 同策略) */ +/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前) */ let aiBubble = null, aiText = '', aiActive = false; let reasoningBox = null, reasoningBody = null, reasoningHead = null, reasoningText = ''; -let streaming = false; let unreadTasks = 0; -/* ---------- 工具 ---------- */ +/* ---------- 滚动(距底 80px 内才自动跟随,用户上翻阅读不打断) ---------- */ function nearBottom() { return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80; } function scrollToBottom(force) { if (force || nearBottom()) chatEl.scrollTop = chatEl.scrollHeight; } -function escapeText(s) { return String(s == null ? '' : s); } +/* ---------- 系统消息 / 任务面板行 ---------- */ function addSystem(text, isError) { const div = document.createElement('div'); div.className = isError ? 'msg msg-error' : 'msg msg-system'; @@ -130,7 +131,7 @@ function startReasoning(hint) { reasoningHead.textContent = '💭 ' + (hint || '思考中...'); reasoningBody = document.createElement('div'); reasoningBody.className = 'reasoning-body'; - reasoningHead.addEventListener('click', function() { reasoningBody.classList.toggle('folded'); }); + reasoningHead.addEventListener('click', () => reasoningBody.classList.toggle('folded')); reasoningBox.appendChild(reasoningHead); reasoningBox.appendChild(reasoningBody); chatEl.appendChild(reasoningBox); @@ -138,12 +139,12 @@ function startReasoning(hint) { } function appendReasoning(delta) { if (!delta) return; + if (aiActive === false) return; /* 已收尾(中断/完成),丢弃残留片段 */ if (!reasoningBox) startReasoning(); reasoningText += delta; - const display = reasoningText.length > REASONING_TAIL + reasoningBody.textContent = reasoningText.length > REASONING_TAIL ? '...(前略)\n' + reasoningText.slice(-REASONING_TAIL) : reasoningText; - reasoningBody.textContent = display; scrollToBottom(); } function endReasoning() { @@ -152,6 +153,16 @@ function endReasoning() { reasoningBody.classList.add('folded'); /* 结束后默认折叠,避免占屏 */ } } +function abortReasoning() { + /* 用户中断:更新思考区域标签为已中断,保留已展示内容 */ + if (reasoningHead) { + if (reasoningText) { + reasoningHead.textContent = '🛑 思考已中断(' + reasoningText.length + ' 字符)'; + } else { + reasoningHead.textContent = '🛑 思考已中断'; + } + } +} /* ---------- 连接状态 ---------- */ function setStatus(cls, text) { @@ -159,7 +170,6 @@ function setStatus(cls, text) { statusText.textContent = text; } function setStreaming(on) { - streaming = on; interruptBtn.style.display = on ? '' : 'none'; sendBtn.disabled = false; /* 流式期间仍可发送(服务端排队),仅中断按钮显隐 */ } @@ -177,19 +187,19 @@ function connect() { scheduleReconnect(); return; } - ws.onopen = function() { + ws.onopen = () => { reconnectDelay = RECONNECT_MIN; setStatus('connected', '已连接'); connBtn.textContent = '断开'; connBtn.classList.add('on'); }; - ws.onmessage = function(ev) { + ws.onmessage = (ev) => { let data; try { data = JSON.parse(ev.data); } catch (e) { data = { type: 'chunk', content: ev.data }; } routeMessage(data); }; - ws.onclose = function() { + ws.onclose = () => { setStatus('disconnected', '已断开'); connBtn.textContent = '连接'; connBtn.classList.remove('on'); @@ -197,14 +207,14 @@ function connect() { ws = null; if (!manualClose) scheduleReconnect(); }; - ws.onerror = function() { /* onclose 随后触发,统一在 onclose 处理 */ }; + ws.onerror = () => { /* onclose 随后触发,统一在 onclose 处理 */ }; } function scheduleReconnect() { if (manualClose) return; setStatus('connecting', Math.round(reconnectDelay / 1000) + 's 后重连'); clearTimeout(reconnectTimer); - reconnectTimer = setTimeout(function() { + reconnectTimer = setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX); connect(); }, reconnectDelay); @@ -219,7 +229,7 @@ function disconnect(manual) { connBtn.classList.remove('on'); } -/* ---------- aibox 协议消息路由(对应 BotOps app.py 的 _on_ws_message) ---------- */ +/* ---------- aibox 协议消息路由 ---------- */ function routeMessage(data) { const t = data.type || ''; const content = typeof data.content === 'string' ? data.content : (data.content == null ? '' : String(data.content)); @@ -264,6 +274,9 @@ function routeMessage(data) { } else if (t === 'task_error') { addTaskLine('❌ [' + (data.task_id || '?') + '] ' + (data.task_title || '?') + ': ' + (data.error || '未知错误')); } else if (t === 'interrupt_ack') { + /* 服务端确认中断:立即收尾前端状态 */ + abortReasoning(); + finishAi(''); addSystem('🛑 ' + (content || '已中断')); } else if (t === 'error') { addSystem('❌ ' + content, true); @@ -291,38 +304,31 @@ function wsSend(obj) { return true; } -const LOCAL_COMMANDS = ['/clear', '/help', '/connect', '/disconnect', '/interrupt']; - -function handleLocalCommand(cmd) { - switch (cmd) { - case '/clear': +/* 本地命令表:命中则本地处理,其余 / 命令透传服务器 */ +const LOCAL_COMMANDS = { + '/clear'() { chatEl.replaceChildren(); finishAi(''); - return true; - case '/help': + }, + '/help'() { addSystem('本地命令:/clear 清屏 · /connect 连接 · /disconnect 断开 · /interrupt 中断 · 其余 / 命令透传服务器'); - return true; - case '/connect': - connect(); - return true; - case '/disconnect': - disconnect(true); - return true; - case '/interrupt': - sendInterrupt(); - return true; - default: - return false; - } -} + }, + '/connect'() { connect(); }, + '/disconnect'() { disconnect(true); }, + '/interrupt'() { sendInterrupt(); }, +}; function sendCurrentInput() { const text = inputEl.value.trim(); if (!text) return; - if (text.startsWith('/') && handleLocalCommand(text.split(/\s/)[0])) { - inputEl.value = ''; - autoResize(); - return; + if (text.startsWith('/')) { + const local = LOCAL_COMMANDS[text.split(/\s/)[0]]; + if (local) { + local(); + inputEl.value = ''; + autoResize(); + return; + } } /* 与 BotOps 一致:/ 开头按 command 发送,否则 message */ const data = text.startsWith('/') @@ -347,18 +353,20 @@ function autoResize() { inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px'; } inputEl.addEventListener('input', autoResize); -inputEl.addEventListener('keydown', function(e) { - /* Enter 发送,Shift+Enter 换行(移动端依赖发送按钮,不影响) */ +inputEl.addEventListener('keydown', (e) => { + /* Enter 发送,Shift+Enter 换行;isComposing 防中文输入法选词误发 */ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendCurrentInput(); } }); +/* 软键盘弹出动画结束后保持聊天区停留底部 */ +inputEl.addEventListener('focus', () => setTimeout(() => scrollToBottom(true), 300)); sendBtn.addEventListener('click', sendCurrentInput); interruptBtn.addEventListener('click', sendInterrupt); -/* ---------- 连接按钮与任务面板 ---------- */ -connBtn.addEventListener('click', function() { +/* ---------- 连接按钮与任务面板(抽屉开关) ---------- */ +connBtn.addEventListener('click', () => { if (ws && ws.readyState === WebSocket.OPEN) disconnect(true); else connect(); }); @@ -371,12 +379,12 @@ function openTaskPanel(open) { taskBadge.style.display = 'none'; } } -taskToggle.addEventListener('click', function() { openTaskPanel(!taskPanel.classList.contains('open')); }); -document.getElementById('task-close').addEventListener('click', function() { openTaskPanel(false); }); -backdrop.addEventListener('click', function() { openTaskPanel(false); }); +taskToggle.addEventListener('click', () => openTaskPanel(!taskPanel.classList.contains('open'))); +document.getElementById('task-close').addEventListener('click', () => openTaskPanel(false)); +backdrop.addEventListener('click', () => openTaskPanel(false)); /* ---------- 启动:管理员预检(WS 握手由 gateway 强制 is_admin,此处仅提示) ---------- */ -(async function init() { +(async () => { try { const res = await fetch('/whoami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); const data = res.ok ? await res.json() : null; From f319686571f1ca755351ea554ebed8f6b6ed2481 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 14:17:36 +0800 Subject: [PATCH 21/22] =?UTF-8?q?feat(static):=20aibox=20Web=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E9=87=8D=E5=86=99=E4=B8=BA=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E4=B8=93=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除桌面端媒体查询与限宽居中,任务面板固定为抽屉形态 - 新增 enterkeyhint=send、overscroll-behavior、tap-highlight 等移动端触控优化 - 配合已提交的中断机制改动,协议与 BotOps 保持兼容 --- static/public/aibox.html | 4 +++- static/public/css/aibox.css | 22 +++++++--------------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/static/public/aibox.html b/static/public/aibox.html index 89fb608..e8b01da 100644 --- a/static/public/aibox.html +++ b/static/public/aibox.html @@ -20,10 +20,12 @@
- + +
+