From 9078c4d05159faefe34dd2c5e1ba3f5fb2e2f155 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 17:12:45 +0800 Subject: [PATCH 01/33] =?UTF-8?q?refactor:=20smtp=20=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E4=B8=BA=20pymsg=20TCP=20=E6=9C=8D=E5=8A=A1=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20NameError/finally/KeyError=20=E7=AD=89=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- smtp/requirements.txt | 7 +- smtp/smtp.py | 265 ++++++++++++++++++++++++++++++++---------- 3 files changed, 206 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 17d5953..dcf2175 100755 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ | [db](db/) | Python(pymsg) | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,SQLAlchemy Core 参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) | | [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 | | [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 | -| [smtp](smtp/) | Python(Flask) | 8008 | 邮件服务,发送/校验邮箱验证码(/send_email、/verify_email),注册流程的邮件验证码后端,SMTP 账号配置在 .env | +| [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env | | [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 | ### core(核心模块 · 进程托管服务) @@ -135,9 +135,9 @@ ### smtp(邮件服务) -- 注册流程的邮箱验证码后端,基于 Flask + waitress,监听 127.0.0.1:8008。 -- 接口:`POST /send_email`(生成 6 位验证码并经 SMTP_SSL 发送至收件人,60 秒有效)、`POST /verify_email`(校验)。 -- 验证码存于进程内存,SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`)。 +- 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。 +- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,60 秒有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。 +- 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。 ### aibox(独立 AI 智能体系统) diff --git a/smtp/requirements.txt b/smtp/requirements.txt index 645be6a..a74b3c5 100644 --- a/smtp/requirements.txt +++ b/smtp/requirements.txt @@ -1,8 +1,3 @@ ../pymsg -nuitka -flask -flask-cors -flask_sqlalchemy -#gunicorn -waitress python-dotenv +nuitka diff --git a/smtp/smtp.py b/smtp/smtp.py index ea45cd6..51e313d 100644 --- a/smtp/smtp.py +++ b/smtp/smtp.py @@ -1,84 +1,221 @@ -#!/usr/bin/env python3 +""" +smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。 -from flask import Flask, request, jsonify -from email.mime.text import MIMEText -from email import header -from log_client import logs -from dotenv import load_dotenv -import waitress -import smtplib +替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。 +协议:每条消息为完整 JSON,``_route`` 字段指定路由名。 + +请求示例:: + + {"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码 + {"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费) + {"_route": "health"} → 健康检查 + +响应格式(与 dbv2 / captcha 一致):: + + {"success": true, "data": {...}} + {"success": false, "error": "..."} + +SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD +(__PASSWORD 兼容别名 SMTP_PASSWORD)。 +""" + +import json +import os import random +import smtplib +import threading import time -import sys, os +from email.mime.text import MIMEText -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) +try: + from dotenv import load_dotenv + load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) +except ImportError: + pass # 无 dotenv 时直接读进程环境变量 -app = Flask(__name__) -captchas: dict[str, list[str, str]] = {} +from log_client import logs -@app.route("/send_email", methods=["POST"]) +# pymsg 导入 +import sys as _sys +_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from pymsg import MsgServer # noqa: E402 -def send_email(): - def build_captcha() -> str: - captcha: str = "" - for i in range(0,6): - digit: int = random.randint(0,9) - captcha += str(digit) - return captcha +# ── 配置 ──────────────────────────────────────────────────────────────── - data = request.get_json() +SMTP_SERVER = os.environ.get("SMTP_SERVER", "") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "465")) +SMTP_USER = os.environ.get("SMTP_USER", "") +# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名 +SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "") - recipient: str = data.get("email") - subject: str = "DNFS 邮箱验证" - captcha: str = build_captcha() - content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。" +CODE_LEN = 6 +CODE_TTL = 60 # 验证码有效期(秒) +MAIL_FROM = "dnfs@qsont.me" +DEFAULT_SUBJECT = "DNFS 邮箱验证" +DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。" - captchas[recipient] = ["", ""] - captchas[recipient][0] = captcha - captchas[recipient][1] = str(int(time.time())+60) - - message = MIMEText(content, "plain", "UTF-8") - message["From"] = "dnfs@qsont.me" - message["To"] = recipient - message["Subject"] = subject - - try: - server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) - server.login(SMTP_USER, __PASSWORD) - server.sendmail(SMTP_USER, [recipient], message.as_string()) - logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})") - return jsonify({"success": "验证码发送成功"}), 200 - - except Exception as err: - logs.error("smtp", f"{recipient}: 验证码发送失败({err})") - return jsonify({"error": f"验证码发送失败:{err}"}), 400 - - finally: - server.quit() +code_store = {} # email -> {'code': str, 'expires_at': float} +STORE_LOCK = threading.Lock() -@app.route("/verify_email", methods=["POST"]) +# ═══════════════════════════════════════════════════════════════════════ +# SmtpServer — 主服务类 +# ═══════════════════════════════════════════════════════════════════════ -def verify_email(): - data = request.get_json() +class SmtpServer: + """基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。""" - for i in list(captchas.keys()): - if int(captchas[i][1]) < int(time.time()): - del captchas[i] + def __init__(self, addr="127.0.0.1", port=8008): + self.addr = addr + self.port = port + self._server = MsgServer(addr, port) - email: str = data.get("email") - input_captcha: str = data.get("input_captcha") + # 路由表 + self._routes = { + "send_email": self._handle_send_email, + "verify_email": self._handle_verify_email, + "health": self._handle_health, + } - if captchas[email][0] == input_captcha: - logs.info("smtp", f"{email}: 验证通过") - return jsonify({"success": "验证通过"}), 200 - else: + # ── 启动 / 停止 ────────────────────────────────────────────────── + def start(self) -> None: + """启动服务(阻塞当前线程)。""" + if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): + logs.error("smtp", "SMTP 配置不完整(.env 缺 SMTP_SERVER/SMTP_USER/__PASSWORD)," + "send_email 将始终失败") + print("[smtp] 警告: SMTP 配置不完整,send_email 将失败") + print(f"[smtp] 监听 tcp://{self.addr}:{self.port}") + logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}") + self._server.start(self._dispatch) + + def stop(self) -> None: + """从另一线程停止服务。""" + self._server.stop() + + # ── 消息调度 ------------------------------------------------------- + def _dispatch(self, sock, addr, text): + """解析 JSON 中的 _route,分发给对应的 handler,并回复结果。""" + try: + req = json.loads(text) + except json.JSONDecodeError as e: + self._reply(sock, False, error=f"JSON 解析失败: {e}") + return + + route = req.pop("_route", "") + handler = self._routes.get(route) + if handler is None: + self._reply(sock, False, error=f"未知路由: {route}") + return + + try: + result = handler(req) + self._reply(sock, True, data=result) + except Exception as e: + logs.error("smtp", f"路由 {route} 处理异常: {e}") + self._reply(sock, False, error=str(e)) + + def _reply(self, sock, success, data=None, error=None): + resp = {"success": success} + if data is not None: + resp["data"] = data + if error is not None: + resp["error"] = error + self._server.reply(sock, json.dumps(resp, ensure_ascii=False)) + + # ═══════════════════════════════════════════════════════════════════ + # 路由处理 + # ═══════════════════════════════════════════════════════════════════ + + def _handle_send_email(self, req): + """生成 6 位数字验证码,存入内存(60s),经 SMTP_SSL 发送邮件。 + + 可选字段 subject / content 覆盖默认标题与正文,content 中 {code} + 会被替换为验证码。 + """ + email = str(req.get("email", "")).strip() + if not email or "@" not in email: + return {"sent": False, "error": "invalid email"} + + if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): + logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送") + return {"sent": False, "error": "SMTP 服务未配置"} + + code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN)) + subject = str(req.get("subject") or DEFAULT_SUBJECT) + content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code) + + with STORE_LOCK: + code_store[email] = {'code': code, 'expires_at': time.time() + CODE_TTL} + # 惰性清理过期项 + now = time.time() + expired = [k for k, v in code_store.items() if v['expires_at'] < now] + for k in expired: + del code_store[k] + + message = MIMEText(content, "plain", "UTF-8") + message["From"] = MAIL_FROM + message["To"] = email + message["Subject"] = subject + + # server 作用域限定在 try 内;连接失败时不会误调 quit() + try: + with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server: + server.login(SMTP_USER, SMTP_PASSWORD) + server.sendmail(SMTP_USER, [email], message.as_string()) + except Exception as err: + # 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态 + with STORE_LOCK: + code_store.pop(email, None) + logs.error("smtp", f"{email}: 验证码发送失败({err})") + return {"sent": False, "error": f"验证码发送失败:{err}"} + + logs.info("smtp", f"{email}: 验证码发送成功({code})") + return {"sent": True, "ttl": CODE_TTL} + + def _handle_verify_email(self, req): + """校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。""" + email = str(req.get("email", "")).strip() + # 兼容旧字段 input_captcha + code = str(req.get("code") or req.get("input_captcha") or "").strip() + if not email or not code: + return {"valid": False, "error": "email and code required"} + + with STORE_LOCK: + entry = code_store.get(email) + if entry and entry['expires_at'] < time.time(): + del code_store[email] + entry = None + if entry and entry['code'] == code: + # 验证通过:一次性消费,防重放 + del code_store[email] + valid = True + else: + valid = False + + if valid: + logs.info("smtp", f"{email}: 验证通过") + return {"valid": True} logs.info("smtp", f"{email}: 验证失败") - return jsonify({"error": "验证失败"}), 400 + return {"valid": False, "error": "invalid or expired code"} + def _handle_health(self, _req): + return {"status": "ok", "service": "smtp"} + + +# ═══════════════════════════════════════════════════════════════════════ +# 入口 +# ═══════════════════════════════════════════════════════════════════════ if __name__ == "__main__": - logs.info("smtp", "开始监听http://127.0.0.1:8008") - print("Listen at http://127.0.0.1:8008") - waitress.serve(app, host="127.0.0.1", port=8008) \ No newline at end of file + import argparse + + parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务") + parser.add_argument("--addr", default="127.0.0.1", help="监听地址") + parser.add_argument("--port", type=int, default=8008, help="监听端口") + args = parser.parse_args() + + server = SmtpServer(args.addr, args.port) + try: + server.start() + except KeyboardInterrupt: + server.stop() From df518d1adb086ef90a9e8317af5b2749af76c19b Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 17:29:52 +0800 Subject: [PATCH 02/33] =?UTF-8?q?feat:=20=E6=B3=A8=E5=86=8C=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E9=82=AE=E7=AE=B1=E9=AA=8C=E8=AF=81=E7=A0=81=EF=BC=88?= =?UTF-8?q?gateway=20v0.5.0=20=E6=96=B0=E5=A2=9E=20/register/send=5Fcode?= =?UTF-8?q?=EF=BC=8Cpymsg=20=E8=B0=83=20smtp=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- gateway/README.md | 25 ++++++-- gateway/src/main.cpp | 111 ++++++++++++++++++++++++++++++++++- static/public/js/register.js | 56 +++++++++++------- 4 files changed, 166 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index dcf2175..5d9ebca 100755 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ 1. 客户端所有请求先打到 `waf:443`(HTTPS),WAF 终止 TLS、运行 coraza 规则匹配,命中则直接返回 403。 2. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `gateway:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。 -3. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。 +3. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制;注册需邮箱验证码,经 pymsg/TCP 调 `smtp:8008` 发送与校验),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。 4. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。 5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。 6. `dnfs` 处理节点增删改查时,通过 pymsg/TCP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。 diff --git a/gateway/README.md b/gateway/README.md index 6544096..44504e1 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -6,10 +6,11 @@ UniServe Framework 的应用层网关,C++20 实现。 ## 职责 -- **账号鉴权**:`/register` 注册、`/login` 登录,签发 RSA 签名的 Token。 +- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。 - **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 - **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 -- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入)。 +- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(60 秒有效);注册时校验,一次性消费。 +- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入)。 - **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。 ## 安全特性 @@ -71,9 +72,25 @@ UniServe Framework 的应用层网关,C++20 实现。 ### `POST /register` -请求体与 `/login` 相同。先做密码强度校验、邮箱格式校验、CAPTCHA 校验,再写入 `Users` 表。 +请求体在 `/login` 基础上增加 `email_code`(必填,6 位数字邮箱验证码): -错误码:`400`(密码/邮箱/CAPTCHA 不合规)、`429`(速率超限)、`500`(数据库异常)。 +```json +{ "username": "user@example.com", "password": "...", "email_code": "123456" } +``` + +校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。 + +错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`403`(速率超限)、`500`(数据库异常)。 + +### `POST /register/send_code` + +向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,60 秒有效)。 + +```json +{ "email": "user@example.com" } +``` + +限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`403`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。 ### `GET/POST *`(反向代理) diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index e5e5530..0e88a60 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -26,7 +26,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.4.4"; +constexpr const char *_VER = "0.5.0"; ////////////////////////////////// #include @@ -287,6 +287,11 @@ http::response handleRegister( const http::request& req, const std::unordered_map& params); +/* 发送注册邮箱验证码:pymsg 调 smtp 服务 send_email 路由 */ +http::response handleSendEmailCode( + const http::request& req, + const std::unordered_map& params); + http::response handleCaptcha( const http::request& req, const std::unordered_map& params); @@ -326,6 +331,7 @@ static std::unique_ptr _KEYGEN; //static std::unique_ptr _SQLDB; static RateLimiter _LOGIN_RATE; static RateLimiter _REGISTER_RATE; +static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流 /* CAPTCHA store: token -> answer (in-memory, with expiry) */ #include @@ -389,6 +395,26 @@ bool verify_captcha_remote(const std::string& token, const std::string& answer) } } +/* 调用 smtp 服务(pymsg/TCP :8008)校验邮箱验证码。 + * 验证码一次性消费;服务不可用或响应异常一律视为校验失败(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"); + json::value jv_req = { + {"_route", "verify_email"}, + {"email", email}, + {"code", code} + }; + std::string resp = client.call(json::serialize(jv_req), 5.0); + auto jv = json::parse(resp); + auto& obj = jv.as_object(); + if (!obj.at("success").as_bool()) return false; + return obj.at("data").as_object().at("valid").as_bool(); + } catch (const std::exception&) { + return false; + } +} + /* -------- 信号处理回调函数 -------- */ void signalHandler(int signum) { @@ -565,6 +591,12 @@ void registerRoutes(HttpServer &server) { [](const auto& req, const auto& params) { return handleRegister(req, params); }); }); + /* 发送注册邮箱验证码(须在 post("*") 通配符之前注册) */ + server.post("/register/send_code", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleSendEmailCode(req, params); }); + }); + server.post("/logout", [](const auto& req, const auto& params) -> http::response { return wrapperHttp(req, params, [](const auto& req, const auto& params) { return handleLogout(req, params); }); @@ -951,6 +983,14 @@ http::response handleRegister( THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register"); } + // 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前) + auto ecode_it = obj.find("email_code"); + if (ecode_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/register"); + std::string email_code = std::string(ecode_it->value().as_string()); + if (!verify_email_code_remote(username, email_code)) + THROW_EX_CUSTOM(HttpException, 3006, "Invalid or expired email verification code", 400, "/register"); + // generate the hash of password auto password_hash = PBKDF2Hasher::hash_password(password); @@ -983,6 +1023,75 @@ http::response handleRegister( return res; } +/* 处理发送注册邮箱验证码 /register/send_code + * + * 经 pymsg 调 smtp 服务 send_email 路由,向目标邮箱发送 6 位验证码。 + * 限流 5 次/10 分钟/IP;smtp 不可用返回 502(fail-closed)。 */ +http::response handleSendEmailCode( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + // IP rate limit check + std::string client_ip = "direct"; + auto it_host = req.base().find("X-Forwarded-For"); + if (it_host != req.base().end()) + client_ip = std::string(it_host->value()); + if (!_EMAIL_CODE_RATE.check_and_record(client_ip, 5, 600)) + THROW_EX_CUSTOM(HttpException, 3101, "Too many code requests. Please try again later.", + 403, "/register/send_code"); + + // parse the req body + json::value jv; + try { jv = json::parse(req.body()); } + catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code"); + } + + if (!jv.is_object()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code"); + + auto& obj = jv.as_object(); + auto email_it = obj.find("email"); + if (email_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code"); + auto email = std::string(email_it->value().as_string()); + + // Email format check + std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); + if (!std::regex_match(email, email_regex)) + THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code"); + + // 调 smtp 服务(pymsg/TCP :8008)发送验证码 + bool sent = false; + try { + pmc::net::MsgClient client("127.0.0.1", 8008, "gateway"); + json::value jv_req = { + {"_route", "send_email"}, + {"email", email} + }; + std::string resp = client.call(json::serialize(jv_req), 20.0); + auto jv_r = json::parse(resp); + auto& obj_r = jv_r.as_object(); + if (obj_r.at("success").as_bool()) + sent = obj_r.at("data").as_object().at("sent").as_bool(); + } catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3102, "Mail service unavailable", 502, "/register/send_code"); + } + + if (!sent) + THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/register/send_code"); + + auto jv_r = json::value{{"success", true}}; + res.result(http::status::ok); + res.set(http::field::content_type, "application/json"); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + http::response handleProxy( const http::request& req, const std::unordered_map& params, diff --git a/static/public/js/register.js b/static/public/js/register.js index 7ec5c34..58fa28d 100644 --- a/static/public/js/register.js +++ b/static/public/js/register.js @@ -9,12 +9,10 @@ const pwdTip = document.getElementById('pwdTip'); const codeTip = document.getElementById('codeTip'); const submitButton = document.getElementById('formsub'); -let sendCode = ''; -let codeTarget = ''; +let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手) form.addEventListener('reset', function () { clearTipStyle(); - sendCode = ''; codeTarget = ''; }); @@ -64,7 +62,7 @@ function checkCode() { codeTip.innerHTML = ''; codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); - if (!sendCode) { + if (!codeTarget) { codeTip.textContent = '请先获取验证码!'; codeTip.classList.add('tip-red'); return false; @@ -79,14 +77,7 @@ function checkCode() { codeTip.classList.add('tip-yellow'); return false; } - if (sendCode === 'email_sent') { /* Server-verified, skip client check */ } else if (code !== sendCode) { - codeTip.textContent = '验证码错误,请核对!'; - codeTip.classList.add('tip-red'); - return false; - } - - codeTip.textContent = '验证码正确'; - codeTip.classList.add('tip-green'); + // 验证码正确性由服务端在注册提交时校验(一次性消费) return code; } @@ -102,7 +93,7 @@ repeatPasswordInput.addEventListener('blur', checkPassword); emailInput.addEventListener('focus', checkEmail); emailInput.addEventListener('input', function () { checkEmail(); - if (sendCode) { + if (codeTarget) { checkCode(); } }); @@ -112,19 +103,40 @@ codeInput.addEventListener('focus', checkCode); codeInput.addEventListener('input', checkCode); codeInput.addEventListener('blur', checkCode); -getCodeButton.addEventListener('click', function () { +getCodeButton.addEventListener('click', async function () { + if (getCodeButton.disabled) return; // 防重复点击 const email = checkEmail(); if (!email) { return; } - sendCode = Math.floor(100000 + Math.random() * 900000).toString(); - codeTarget = email; - codeInput.value = ''; - codeTip.textContent = '验证码已发送,请填写验证码'; + getCodeButton.disabled = true; + codeTip.textContent = '验证码发送中…'; codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); - codeTip.classList.add('tip-green'); - alert(`验证码已发送至${email},您的验证码:${sendCode}`); + codeTip.classList.add('tip-yellow'); + try { + const res = await fetch('/register/send_code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + const responseText = await res.text(); + let result; + try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); } + if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败'); + + codeTarget = email; + codeInput.value = ''; + codeTip.textContent = '验证码已发送至邮箱,请查收(60 秒内有效)'; + codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); + codeTip.classList.add('tip-green'); + } catch (err) { + codeTip.textContent = err.message || '验证码发送失败,请稍后重试'; + codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); + codeTip.classList.add('tip-red'); + } finally { + getCodeButton.disabled = false; + } }); form.addEventListener('submit', async function (e) { @@ -139,7 +151,8 @@ form.addEventListener('submit', async function (e) { } const jsonBody = JSON.stringify({ username: email, - password + password, + email_code: code }); try { @@ -169,7 +182,6 @@ form.addEventListener('submit', async function (e) { if (result.success) { alert('注册成功'); form.reset(); - sendCode = ''; codeTarget = ''; location.href = 'login.html'; return; From 6330e04474532a56d5bade42028ad7103fa17904 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 17:34:55 +0800 Subject: [PATCH 03/33] =?UTF-8?q?fix:=20smtp=20.env=20=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E6=9B=BF=E4=BB=A3=20dotenv=EF=BC=8C=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E6=94=AF=E6=8C=81=20KEY=3DVALUE=20=E5=92=8C=20KEY=20V?= =?UTF-8?q?ALUE=20=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smtp/requirements.txt | 1 - smtp/smtp.py | 24 +++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/smtp/requirements.txt b/smtp/requirements.txt index a74b3c5..4ccd809 100644 --- a/smtp/requirements.txt +++ b/smtp/requirements.txt @@ -1,3 +1,2 @@ ../pymsg -python-dotenv nuitka diff --git a/smtp/smtp.py b/smtp/smtp.py index 51e313d..4c5d08b 100644 --- a/smtp/smtp.py +++ b/smtp/smtp.py @@ -27,11 +27,25 @@ import threading import time from email.mime.text import MIMEText -try: - from dotenv import load_dotenv - load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) -except ImportError: - pass # 无 dotenv 时直接读进程环境变量 +# 手动加载 .env(支持 KEY=VALUE 和 KEY VALUE 两种格式,容忍空行/注释/引号) +_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") +if os.path.isfile(_env_path): + with open(_env_path, "r") as _f: + for _line in _f: + _line = _line.strip() + if not _line or _line.startswith("#"): + continue + # 支持 KEY=VALUE 与 KEY VALUE 两种写法 + if "=" in _line: + _key, _, _val = _line.partition("=") + else: + _parts = _line.split(None, 1) + _key = _parts[0] + _val = _parts[1] if len(_parts) > 1 else "" + _key = _key.strip() + _val = _val.strip().strip("\"'") + if _key and _key not in os.environ: + os.environ[_key] = _val from log_client import logs From d5a67ce77edf29db9b4ad32c3bb1422a7d51ea52 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 17:42:37 +0800 Subject: [PATCH 04/33] =?UTF-8?q?fix:=20smtp=20.env=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=20export=20=E5=89=8D=E7=BC=80=E5=B9=B6=E5=9C=A8=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=E6=8A=A5=E5=91=8A=E7=BC=BA=E5=A4=B1=E7=9A=84?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B9=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smtp/smtp.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/smtp/smtp.py b/smtp/smtp.py index 4c5d08b..2028ab3 100644 --- a/smtp/smtp.py +++ b/smtp/smtp.py @@ -27,14 +27,17 @@ import threading import time from email.mime.text import MIMEText -# 手动加载 .env(支持 KEY=VALUE 和 KEY VALUE 两种格式,容忍空行/注释/引号) +# 手动加载 .env(支持 KEY=VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号) _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") +_env_loaded = 0 if os.path.isfile(_env_path): with open(_env_path, "r") as _f: for _line in _f: _line = _line.strip() if not _line or _line.startswith("#"): continue + if _line.startswith("export "): + _line = _line[len("export "):].strip() # 支持 KEY=VALUE 与 KEY VALUE 两种写法 if "=" in _line: _key, _, _val = _line.partition("=") @@ -46,6 +49,9 @@ if os.path.isfile(_env_path): _val = _val.strip().strip("\"'") if _key and _key not in os.environ: os.environ[_key] = _val + _env_loaded += 1 +else: + print(f"[smtp] 警告: 未找到 .env 文件: {_env_path}") from log_client import logs @@ -95,9 +101,13 @@ class SmtpServer: def start(self) -> None: """启动服务(阻塞当前线程)。""" if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): - logs.error("smtp", "SMTP 配置不完整(.env 缺 SMTP_SERVER/SMTP_USER/__PASSWORD)," - "send_email 将始终失败") - print("[smtp] 警告: SMTP 配置不完整,send_email 将失败") + # 只报缺失的 key 名,绝不报值 + missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER), + ("SMTP_USER", SMTP_USER), + ("__PASSWORD", SMTP_PASSWORD)) if not v] + logs.error("smtp", f"SMTP 配置缺失: {','.join(missing)}" + f"(.env 加载 {_env_loaded} 项),send_email 将始终失败") + print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)") print(f"[smtp] 监听 tcp://{self.addr}:{self.port}") logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}") self._server.start(self._dispatch) From c592b0263647ef15f6e47f567dbea1f6d0fc912c Mon Sep 17 00:00:00 2001 From: shufeng2012 <277665039@qq.com> Date: Tue, 28 Jul 2026 20:17:28 +0800 Subject: [PATCH 05/33] =?UTF-8?q?=F0=9F=A6=BA=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E6=B7=B7=E6=B7=86=E7=A8=8B=E5=BA=A6?= =?UTF-8?q?=EF=BC=88=E6=88=91=E8=87=AA=E5=B7=B1=E9=83=BD=E8=BF=87=E4=B8=8D?= =?UTF-8?q?=E4=BA=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- captcha/captcha_service.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/captcha/captcha_service.py b/captcha/captcha_service.py index 054749b..c602303 100644 --- a/captcha/captcha_service.py +++ b/captcha/captcha_service.py @@ -56,28 +56,34 @@ def generate_text(): def generate_image(text): width, height = 160, 50 - bg = random.choice([(255, 255, 255), (240, 240, 240), (230, 240, 250)]) + bg = (random.randint(1,256), random.randint(1,256), random.randint(1,256)) img = Image.new('RGB', (width, height), bg) draw = ImageDraw.Draw(img) for i, ch in enumerate(text): - color = random.choice([(30, 30, 30), (50, 80, 150), (150, 50, 50), (50, 130, 50)]) + color = (random.randint(1,256), random.randint(1,256), random.randint(1,256)) y = random.randint(2, 12) x = 15 + i * 32 + random.randint(-3, 3) # 4 位字符在 160px 宽度内均匀分布 try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - random.choice([28, 30, 32, 34])) + random.choice(range(20,40,2))) except (IOError, OSError): font = ImageFont.load_default() - draw.text((x, y), ch, fill=color, font=font) + + char = Image.new("RGBA", (64, 64), (0,0,0,0)) + draw_char = ImageDraw.Draw(char) + draw_char.text((20,20), ch, fill=color, font=font) + rotated_char = char.rotate(random.randint(-30, 30), expand=True) + img.paste(rotated_char, (x-32, y-32), mask=rotated_char) + # draw.text((x, y), ch, fill=color, font=font) for _ in range(4): draw.line([(random.randint(0, width), random.randint(0, height)), (random.randint(0, width), random.randint(0, height))], - fill=random.choice([(30, 30, 30), (50, 80, 150)]), width=1) + fill=(random.randint(1,256), random.randint(1,256), random.randint(1,256))) for _ in range(50): draw.point((random.randint(0, width - 1), random.randint(0, height - 1)), - fill=random.choice([(30, 30, 30), (50, 80, 150)])) + fill=(random.randint(1,256), random.randint(1,256), random.randint(1,256))) buf = io.BytesIO() img.save(buf, format='PNG') From 9e2667d6046485acc8393e3f4e398a418e69465d Mon Sep 17 00:00:00 2001 From: shufeng2012 <277665039@qq.com> Date: Tue, 28 Jul 2026 20:38:43 +0800 Subject: [PATCH 06/33] =?UTF-8?q?=F0=9F=A6=BA=20=E6=89=A9=E5=A4=A7?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E9=A2=9C=E8=89=B2=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- captcha/captcha_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/captcha/captcha_service.py b/captcha/captcha_service.py index c602303..6eb7953 100644 --- a/captcha/captcha_service.py +++ b/captcha/captcha_service.py @@ -56,12 +56,12 @@ def generate_text(): def generate_image(text): width, height = 160, 50 - bg = (random.randint(1,256), random.randint(1,256), random.randint(1,256)) + bg = (random.randint(0,256), random.randint(0,256), random.randint(0,256)) img = Image.new('RGB', (width, height), bg) draw = ImageDraw.Draw(img) for i, ch in enumerate(text): - color = (random.randint(1,256), random.randint(1,256), random.randint(1,256)) + color = (random.randint(0,256), random.randint(0,256), random.randint(0,256)) y = random.randint(2, 12) x = 15 + i * 32 + random.randint(-3, 3) # 4 位字符在 160px 宽度内均匀分布 try: @@ -80,10 +80,10 @@ def generate_image(text): for _ in range(4): draw.line([(random.randint(0, width), random.randint(0, height)), (random.randint(0, width), random.randint(0, height))], - fill=(random.randint(1,256), random.randint(1,256), random.randint(1,256))) + fill=(random.randint(0,256), random.randint(0,256), random.randint(0,256))) for _ in range(50): draw.point((random.randint(0, width - 1), random.randint(0, height - 1)), - fill=(random.randint(1,256), random.randint(1,256), random.randint(1,256))) + fill=(random.randint(0,256), random.randint(0,256), random.randint(0,256))) buf = io.BytesIO() img.save(buf, format='PNG') From aaeac343d36e42399e4fd407485118c1d0d6bf77 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 21:44:23 +0800 Subject: [PATCH 07/33] =?UTF-8?q?fix(aibox/client):=20=E4=BF=AE4=E5=A4=84?= =?UTF-8?q?=E2=80=94=E2=80=94stdin=E5=8D=95=E7=BA=BF=E7=A8=8B=E9=98=B2?= =?UTF-8?q?=E6=B3=84=E6=BC=8F=E3=80=81LRU=E5=8E=BB=E9=87=8D=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E3=80=81=E8=87=AA=E5=8A=A8=E9=87=8D=E8=BF=9E=E3=80=81?= =?UTF-8?q?=E5=BC=83=E7=94=A8get=5Fevent=5Floop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aibox/client.py | 126 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 110 insertions(+), 16 deletions(-) diff --git a/aibox/client.py b/aibox/client.py index 881a366..f109ed3 100644 --- a/aibox/client.py +++ b/aibox/client.py @@ -9,6 +9,7 @@ import sys import os import threading import hashlib +from collections import OrderedDict from datetime import datetime from typing import Optional, Dict, Any, List import websockets @@ -75,10 +76,15 @@ class WebSocketClient: except json.JSONDecodeError: self.callback("chunk", message) except websockets.exceptions.ConnectionClosed: - self.connected = False - self.callback("system", "❌ 连接已断开") + pass except Exception as e: self.callback("error", f"接收消息错误: {e}") + finally: + # 统一在此通知断线;手动 disconnect() 已把 connected 置 False,不会误触发自动重连 + was_connected = self.connected + self.connected = False + if was_connected: + self.callback("disconnected", "❌ 连接已断开") async def _handle_message(self, data: dict): msg_type = data.get('type', '') @@ -134,12 +140,21 @@ class SimpleCLI: self.ai_full = "" self.ai_prefix_shown = False - # 已显示消息的去重 - self.displayed_messages: set = set() - + # 已显示消息的去重(LRU 上限,防长对话内存无限增长) + self.DISPLAYED_MAX = 500 + self.displayed_messages: OrderedDict = OrderedDict() + # 锁和状态 self.lock = threading.Lock() self.current_input = "" + + # 标准输入:单守护线程阻塞读 + asyncio.Queue 中转(避免 Ctrl+C 泄漏线程) + self._input_queue: asyncio.Queue = asyncio.Queue() + self._stdin_thread: Optional[threading.Thread] = None + + # 自动重连状态 + self._manual_disconnect = False # 用户主动 /disconnect、/quit、/connect 后置 True,抑制自动重连 + self._reconnect_task: Optional[asyncio.Task] = None # 流式输出状态 self.streaming_line = False # 是否正在流式输出行 @@ -275,6 +290,13 @@ class SimpleCLI: self.ai_previous = clean self.ai_full = clean + def _remember_displayed(self, key: str): + """记录已显示消息(LRU:超 DISPLAYED_MAX 淘汰最旧,防长对话内存膨胀)。""" + self.displayed_messages[key] = None + self.displayed_messages.move_to_end(key) + if len(self.displayed_messages) > self.DISPLAYED_MAX: + self.displayed_messages.popitem(last=False) + def display(self, msg_type: str, content: str, extra: Any = None): """显示消息""" if msg_type == 'chunk': @@ -302,6 +324,11 @@ class SimpleCLI: elif msg_type == 'system': self._finish_stream() self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}") + elif msg_type == 'disconnected': + self.connected = False + self._finish_stream() + self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}") + self._schedule_reconnect() elif msg_type == 'info': self._finish_stream() self._print_line(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}") @@ -406,7 +433,7 @@ class SimpleCLI: return # 只有当内容没有被流式显示时才显示完整消息 - self.displayed_messages.add(key) + self._remember_displayed(key) # 如果之前没有流式输出,现在显示完整消息 self._print_line(f"{Colors.AI}🤖 AI助手: {clean}") @@ -466,7 +493,7 @@ class SimpleCLI: clean = content.strip() key = clean[:200] if key not in self.displayed_messages: - self.displayed_messages.add(key) + self._remember_displayed(key) self._print_line(f"{Colors.TASK}⚡ 任务 - {task_title}: {clean}") del self._task_buffers[task_title] self.streaming_line = False @@ -519,10 +546,15 @@ class SimpleCLI: self.display("error", "用法: /connect ws://host:port") return uri = parts[1] + # 切换服务器:抑制旧连接的自动重连 + self._manual_disconnect = True + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() if self.client and self.client.connected: await self.client.disconnect() self.client = WebSocketClient(uri, self.display) if await self.client.connect(): + self._manual_disconnect = False self.connected = True self.display("system", f"✅ 已连接到 {uri}") asyncio.create_task(self.client.receive_messages()) @@ -530,6 +562,9 @@ class SimpleCLI: self.client = None self.connected = False elif cmd == '/disconnect': + self._manual_disconnect = True # 主动断开,不自动重连 + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() if self.client: await self.client.disconnect() self.client = None @@ -556,6 +591,9 @@ class SimpleCLI: elif cmd == '/quit': self.display("system", "👋 再见!") self.running = False + self._manual_disconnect = True + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() if self.client: await self.client.disconnect() return @@ -569,6 +607,35 @@ class SimpleCLI: self.display("error", f"未知命令: {cmd}") self._print_prompt(clear_input=True) + def _schedule_reconnect(self): + """启动自动重连(若未手动断开且当前无进行中的重连任务)。""" + if self._manual_disconnect or not self.running or not self.client: + return + if self._reconnect_task and not self._reconnect_task.done(): + return + try: + self._reconnect_task = asyncio.create_task(self._auto_reconnect_loop(self.client)) + except RuntimeError: + pass # 事件循环正在关闭(程序退出中),放弃重连 + + async def _auto_reconnect_loop(self, client: WebSocketClient): + """断线自动重连:指数退避 2s→30s 封顶,直到成功、/disconnect 或退出。""" + delay = 2 + while self.running and not self._manual_disconnect and self.client is client: + self.display("info", f"🔄 {delay}s 后自动重连 {client.uri} …(/disconnect 取消)") + try: + await asyncio.sleep(delay) + except asyncio.CancelledError: + return + if not self.running or self._manual_disconnect or self.client is not client: + return + if await client.connect(): + self.connected = True + self.display("system", f"✅ 已自动重连到 {client.uri}") + asyncio.create_task(client.receive_messages()) + return + delay = min(delay * 2, 30) + async def _send_interrupt(self): if self.client and self.client.connected: interrupt_msg = {"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"} @@ -601,30 +668,57 @@ class SimpleCLI: await self.client.send_message(message) # 不在这里显示提示符,等待AI响应结束后再显示 + def _stdin_reader(self, loop: asyncio.AbstractEventLoop): + """独立守护线程:阻塞读 stdin,经 call_soon_threadsafe 投入队列。 + + 进程生命周期只启动一次——替代原 run_in_executor 每行一个线程的方案, + 避免 Ctrl+C 取消 await 后旧线程仍阻塞在 readline 上无限泄漏。 + """ + def _put(item): + try: + loop.call_soon_threadsafe(self._input_queue.put_nowait, item) + except RuntimeError: + pass # 事件循环已关闭(程序退出中) + + while self.running: + try: + line = sys.stdin.readline() + except Exception: + break + if not line: # EOF (Ctrl+D) + _put(None) + break + _put(line) + async def run(self): self.print_banner() self._task_buffers = {} - + + loop = asyncio.get_running_loop() + if self._stdin_thread is None: + self._stdin_thread = threading.Thread( + target=self._stdin_reader, args=(loop,), + daemon=True, name="stdin-reader") + self._stdin_thread.start() + while self.running: try: - line = await asyncio.get_event_loop().run_in_executor( - None, lambda: sys.stdin.readline() - ) - if not line: + line = await self._input_queue.get() + if line is None: # EOF (Ctrl+D) break - + self.current_input = line.rstrip('\n') line = line.strip() - + if not line: self._print_prompt(clear_input=True) continue - + if line.startswith('/'): await self.handle_command(line) else: await self.send_user_message(line) - + except KeyboardInterrupt: if self.client and self.client.connected: await self._send_interrupt() From 2982bc30a4d442deda7c9700795060b33ab517ee Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 21:51:53 +0800 Subject: [PATCH 08/33] =?UTF-8?q?feat(aibox/client):=20AI=20=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=B5=81=E5=BC=8F=E6=B8=B2=E6=9F=93=20Markdown?= =?UTF-8?q?=EF=BC=88rich.Live=EF=BC=8C=E6=97=A0=20rich=20=E6=97=B6?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E7=BA=AF=E6=96=87=E6=9C=AC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aibox/client.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/aibox/client.py b/aibox/client.py index f109ed3..ceb002d 100644 --- a/aibox/client.py +++ b/aibox/client.py @@ -15,6 +15,15 @@ from typing import Optional, Dict, Any, List import websockets from colorama import init, Fore, Style as ColoramaStyle +# rich 为可选依赖:有则流式渲染 Markdown,无则回退纯文本流式输出 +try: + from rich.console import Console + from rich.live import Live + from rich.markdown import Markdown + _RICH = True +except ImportError: + _RICH = False + init(autoreset=True) class Colors: @@ -155,6 +164,11 @@ class SimpleCLI: # 自动重连状态 self._manual_disconnect = False # 用户主动 /disconnect、/quit、/connect 后置 True,抑制自动重连 self._reconnect_task: Optional[asyncio.Task] = None + + # Markdown 流式渲染状态(rich 可用时启用) + self._console = Console() if _RICH else None + self._md_live = None # rich.Live 实例(渲染进行中) + self._md_buffer = "" # 当前累计的 AI 回复全文 # 流式输出状态 self.streaming_line = False # 是否正在流式输出行 @@ -180,6 +194,7 @@ class SimpleCLI: def _print_line(self, line: str = "", end: str = "\n"): """打印一行,并恢复提示符""" + self._md_stream_stop() # 打印普通行前,先定格进行中的 Markdown 渲染 with self.lock: current = self.current_input sys.stdout.write('\r' + ' ' * 120 + '\r') @@ -247,16 +262,27 @@ class SimpleCLI: """流式显示增量内容""" if not content: return - + # 清理内容 clean = self._clean_content(content) if not clean: return - + key = clean[:200] if key in self.displayed_messages: return - + + # rich 可用:累计文本直接整体渲染为 Markdown(服务端 chunk 为累计全文) + if _RICH: + with self.lock: + self.waiting_for_response = True + self.prompt_shown = False + self.streaming_line = True + self._md_stream_update(clean) + self.ai_previous = clean + self.ai_full = clean + return + # 检测增量 if self.ai_previous and clean.startswith(self.ai_previous): new_part = clean[len(self.ai_previous):] @@ -290,6 +316,37 @@ class SimpleCLI: self.ai_previous = clean self.ai_full = clean + # ── Markdown 流式渲染(rich)───────────────────────────────────────── + def _md_stream_start(self): + """开启 Live 渲染区域(若 rich 不可用或已开启则忽略)。""" + if not _RICH or self._md_live is not None: + return + self._md_buffer = "" + # transient=False:结束后保留最终渲染结果;vertical_overflow=visible 不裁剪长文 + self._md_live = Live(Markdown(""), console=self._console, + refresh_per_second=8, vertical_overflow="visible") + self._md_live.start() + + def _md_stream_update(self, full_text: str): + """用累计全文刷新渲染(服务端 chunk 即累计文本)。""" + if not _RICH: + return + self._md_stream_start() + self._md_buffer = full_text + self._md_live.update(Markdown(full_text)) + + def _md_stream_stop(self): + """结束渲染:最终刷新一次后停住(内容定格保留在终端)。""" + if self._md_live is not None: + try: + if self._md_buffer: + self._md_live.update(Markdown(self._md_buffer)) + self._md_live.stop() + except Exception: + pass + finally: + self._md_live = None + def _remember_displayed(self, key: str): """记录已显示消息(LRU:超 DISPLAYED_MAX 淘汰最旧,防长对话内存膨胀)。""" self.displayed_messages[key] = None @@ -308,6 +365,7 @@ class SimpleCLI: elif msg_type == 'task_complete': self._handle_task_complete(extra) elif msg_type == 'dialogue_ended': + self._md_stream_stop() with self.lock: if self.streaming_line: sys.stdout.write('\n') @@ -340,6 +398,7 @@ class SimpleCLI: """处理流式块""" # 检查是否是工具调用相关的消息 if content.startswith('🔧 调用工具'): + self._md_stream_stop() # 工具消息与 Markdown 渲染区域交替,先定格 with self.lock: self.in_tool_phase = True self.prompt_shown = False @@ -350,9 +409,10 @@ class SimpleCLI: sys.stdout.write(f"{Colors.TASK}{content}{Colors.RESET}\n") sys.stdout.flush() return - + # 检查是否是工具结果 if content.startswith(('✅ 对话记忆已保存', '💾 保存记忆', '❌', '⚠️')): + self._md_stream_stop() with self.lock: # 打印工具结果 sys.stdout.write(f"{Colors.TASK}{content}{Colors.RESET}\n") @@ -377,6 +437,27 @@ class SimpleCLI: def _handle_complete(self, content: str): """处理完成事件 - 只负责结束流式输出,不重复显示内容""" + # rich 渲染路径:最终全文已在 Live 区域定格,只需收尾并恢复提示符 + if _RICH: + clean = self._clean_content(content) if content else "" + if clean: + self._md_stream_update(clean) + self._remember_displayed(clean[:200]) + self._md_stream_stop() + with self.lock: + self.in_tool_phase = False + self.waiting_for_response = False + self.streaming_line = False + self.prompt_shown = True + current = self.current_input + sys.stdout.write('\r' + ' ' * 120 + '\r') + sys.stdout.write(self._get_prompt() + current) + sys.stdout.flush() + self.ai_previous = "" + self.ai_full = "" + self.ai_prefix_shown = False + return + # 如果之前在工具阶段,现在退出 if self.in_tool_phase: with self.lock: @@ -578,6 +659,7 @@ class SimpleCLI: else: self.display("system", "未连接") elif cmd == '/clear': + self._md_stream_stop() os.system('clear' if os.name == 'posix' else 'cls') self.displayed_messages.clear() self.current_input = "" @@ -648,7 +730,8 @@ class SimpleCLI: if not self.client or not self.client.connected: self.display("error", "未连接到服务器") return - + + self._md_stream_stop() # 发送新消息前定格上一段渲染 # 重置流式状态 with self.lock: if self.streaming_line: From dabd892601a4f272caca05dae20a1a38bf94b07f Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:01:21 +0800 Subject: [PATCH 09/33] =?UTF-8?q?fix(aibox/client):=20=E6=94=BE=E5=BC=83?= =?UTF-8?q?=20rich.Live=20=E6=94=B9=E4=B8=BA=20complete=20=E6=97=B6?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=80=A7=E9=9D=99=E6=80=81=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=86=85=E5=AE=B9=E4=B8=80=E9=97=AA?= =?UTF-8?q?=E8=80=8C=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aibox/client.py | 77 +++++++++++++++++++------------------------------ 1 file changed, 29 insertions(+), 48 deletions(-) diff --git a/aibox/client.py b/aibox/client.py index ceb002d..f65c22f 100644 --- a/aibox/client.py +++ b/aibox/client.py @@ -15,10 +15,9 @@ from typing import Optional, Dict, Any, List import websockets from colorama import init, Fore, Style as ColoramaStyle -# rich 为可选依赖:有则流式渲染 Markdown,无则回退纯文本流式输出 +# rich 为可选依赖:有则 AI 回复渲染为 Markdown,无则回退纯文本流式输出 try: from rich.console import Console - from rich.live import Live from rich.markdown import Markdown _RICH = True except ImportError: @@ -165,10 +164,9 @@ class SimpleCLI: self._manual_disconnect = False # 用户主动 /disconnect、/quit、/connect 后置 True,抑制自动重连 self._reconnect_task: Optional[asyncio.Task] = None - # Markdown 流式渲染状态(rich 可用时启用) + # Markdown 渲染(rich 可用时启用) self._console = Console() if _RICH else None - self._md_live = None # rich.Live 实例(渲染进行中) - self._md_buffer = "" # 当前累计的 AI 回复全文 + self._md_generating_shown = False # "生成中…"状态行是否已显示 # 流式输出状态 self.streaming_line = False # 是否正在流式输出行 @@ -194,7 +192,6 @@ class SimpleCLI: def _print_line(self, line: str = "", end: str = "\n"): """打印一行,并恢复提示符""" - self._md_stream_stop() # 打印普通行前,先定格进行中的 Markdown 渲染 with self.lock: current = self.current_input sys.stdout.write('\r' + ' ' * 120 + '\r') @@ -272,13 +269,9 @@ class SimpleCLI: if key in self.displayed_messages: return - # rich 可用:累计文本直接整体渲染为 Markdown(服务端 chunk 为累计全文) + # rich 可用:流式期间只显示状态行并累计全文,complete 时一次性渲染 if _RICH: - with self.lock: - self.waiting_for_response = True - self.prompt_shown = False - self.streaming_line = True - self._md_stream_update(clean) + self._md_show_generating() self.ai_previous = clean self.ai_full = clean return @@ -316,36 +309,31 @@ class SimpleCLI: self.ai_previous = clean self.ai_full = clean - # ── Markdown 流式渲染(rich)───────────────────────────────────────── - def _md_stream_start(self): - """开启 Live 渲染区域(若 rich 不可用或已开启则忽略)。""" - if not _RICH or self._md_live is not None: - return - self._md_buffer = "" - # transient=False:结束后保留最终渲染结果;vertical_overflow=visible 不裁剪长文 - self._md_live = Live(Markdown(""), console=self._console, - refresh_per_second=8, vertical_overflow="visible") - self._md_live.start() - - def _md_stream_update(self, full_text: str): - """用累计全文刷新渲染(服务端 chunk 即累计文本)。""" + # ── Markdown 渲染(rich)───────────────────────────────────────────── + def _md_show_generating(self): + """流式期间显示一次性状态行(不实时渲染,避免 Live 闪烁/被卷走)。""" if not _RICH: return - self._md_stream_start() - self._md_buffer = full_text - self._md_live.update(Markdown(full_text)) + with self.lock: + if not self._md_generating_shown: + sys.stdout.write('\n') + sys.stdout.write(f"{Colors.AI}🤖 AI助手: 生成中…{Colors.RESET}") + sys.stdout.flush() + self._md_generating_shown = True + self.streaming_line = True + self.prompt_shown = False - def _md_stream_stop(self): - """结束渲染:最终刷新一次后停住(内容定格保留在终端)。""" - if self._md_live is not None: - try: - if self._md_buffer: - self._md_live.update(Markdown(self._md_buffer)) - self._md_live.stop() - except Exception: - pass - finally: - self._md_live = None + def _md_render_final(self, text: str): + """complete 时一次性静态渲染最终 Markdown(先擦掉"生成中…"行)。""" + if not _RICH: + return + with self.lock: + if self._md_generating_shown: + sys.stdout.write('\r' + ' ' * 40 + '\r') + self._md_generating_shown = False + self.streaming_line = False + if text: + self._console.print(Markdown(text)) def _remember_displayed(self, key: str): """记录已显示消息(LRU:超 DISPLAYED_MAX 淘汰最旧,防长对话内存膨胀)。""" @@ -365,7 +353,6 @@ class SimpleCLI: elif msg_type == 'task_complete': self._handle_task_complete(extra) elif msg_type == 'dialogue_ended': - self._md_stream_stop() with self.lock: if self.streaming_line: sys.stdout.write('\n') @@ -398,7 +385,6 @@ class SimpleCLI: """处理流式块""" # 检查是否是工具调用相关的消息 if content.startswith('🔧 调用工具'): - self._md_stream_stop() # 工具消息与 Markdown 渲染区域交替,先定格 with self.lock: self.in_tool_phase = True self.prompt_shown = False @@ -412,7 +398,6 @@ class SimpleCLI: # 检查是否是工具结果 if content.startswith(('✅ 对话记忆已保存', '💾 保存记忆', '❌', '⚠️')): - self._md_stream_stop() with self.lock: # 打印工具结果 sys.stdout.write(f"{Colors.TASK}{content}{Colors.RESET}\n") @@ -437,20 +422,18 @@ class SimpleCLI: def _handle_complete(self, content: str): """处理完成事件 - 只负责结束流式输出,不重复显示内容""" - # rich 渲染路径:最终全文已在 Live 区域定格,只需收尾并恢复提示符 + # rich 渲染路径:擦掉"生成中…",一次性渲染最终 Markdown,恢复提示符 if _RICH: clean = self._clean_content(content) if content else "" + self._md_render_final(clean) if clean: - self._md_stream_update(clean) self._remember_displayed(clean[:200]) - self._md_stream_stop() with self.lock: self.in_tool_phase = False self.waiting_for_response = False self.streaming_line = False self.prompt_shown = True current = self.current_input - sys.stdout.write('\r' + ' ' * 120 + '\r') sys.stdout.write(self._get_prompt() + current) sys.stdout.flush() self.ai_previous = "" @@ -659,7 +642,6 @@ class SimpleCLI: else: self.display("system", "未连接") elif cmd == '/clear': - self._md_stream_stop() os.system('clear' if os.name == 'posix' else 'cls') self.displayed_messages.clear() self.current_input = "" @@ -731,7 +713,6 @@ class SimpleCLI: self.display("error", "未连接到服务器") return - self._md_stream_stop() # 发送新消息前定格上一段渲染 # 重置流式状态 with self.lock: if self.streaming_line: From 6692c9c4eed1d9d880e8ca2cf8feaffa726288b5 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:21:36 +0800 Subject: [PATCH 10/33] =?UTF-8?q?refactor(db):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E9=81=97=E7=95=99=E6=AD=BB=E4=BB=A3=E7=A0=81=E5=B9=B6=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E6=96=87=E6=A1=A3=E4=B8=8E=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 dbv2 从未挂载的 log_module.py(Flask /log/* 遗留)与纯 TCP 服务 用不到的 security_middleware.py 副本;db/README.md 按 pymsg 实现重写, requirements 去掉 flask/waitress;黑盒测试对 pymsg 服务改用 TCP 探测, 修复 check_service HTTP 探测导致的 db 测试整体假 skip。 --- README.md | 1 - db/README.md | 283 +++++++------------- db/log_module.py | 490 ---------------------------------- db/requirements.txt | 2 - db/security_middleware.py | 42 --- tests/blackbox/config.py | 1 + tests/blackbox/helpers.py | 15 ++ tests/blackbox/test_db.py | 106 +------- tests/blackbox/test_runner.py | 8 +- 9 files changed, 130 insertions(+), 818 deletions(-) delete mode 100644 db/log_module.py delete mode 100644 db/security_middleware.py diff --git a/README.md b/README.md index 5d9ebca..c5faffd 100755 --- a/README.md +++ b/README.md @@ -112,7 +112,6 @@ - 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。 - `_log_internal()` — db 进程内部直接写 Logs 表,无网络往返,防递归设计。 - 后台守护线程每小时自动清理:保留 30 天 / 上限 100 万条。 - - 注:`db/log_module.py`(Flask `/log/*` Blueprint)为旧版 HTTP 接口的遗留文件,当前 dbv2 未挂载。 - **统一日志客户端 SDK**(各服务目录下的 `log_client.py`,如 [dnfs/log_client.py](dnfs/log_client.py)):各服务复制即用,纯标准库实现,异步非阻塞(内存队列 + 后台线程,50 条/2 秒批量 flush),fail-safe 设计(db 不可用时静默丢弃,不影响业务)。 ### ai_review(AI 内容审核服务) diff --git a/db/README.md b/db/README.md index 6e1a73c..303817a 100644 --- a/db/README.md +++ b/db/README.md @@ -1,62 +1,55 @@ -# 跨数据库通用查询服务(基于 SQLAlchemy Core) +# 通用数据库服务 db(pymsg/TCP + SQLAlchemy Core) -一个安全的 HTTP 服务,通过结构化 JSON 描述查询意图,后端自动转换为对应数据库的 SQL,无需手写 SQL,即可无缝切换 SQLite、PostgreSQL、MySQL 等数据库。 +一个安全的数据库访问服务。各业务服务通过自研 pymsg 协议(TCP 长度前缀帧 + JSON)发送结构化查询意图,db 翻译为 SQL 执行,全程参数绑定,业务侧无需直连数据库、无需手写 SQL。 ## 功能特点 -- **跨数据库**:基于 SQLAlchemy Core 表达式语言,一套接口适配多种数据库 +- **跨数据库**:基于 SQLAlchemy Core 表达式语言,一套接口适配 SQLite / PostgreSQL / MySQL - **零 SQL 注入**:所有条件、值均通过 SQLAlchemy 参数绑定处理 -- **统一 JSON 接口**:`action` + `table` + `conditions` 模式 -- **支持增删改查**:select, insert, update, delete 全覆盖 -- **单线程本地监听**:waitress + 127.0.0.1:8005,简单可靠 +- **统一 JSON 接口**:`_route` + `action` + `table` + `where` 模式 +- **支持增删改查**:select / insert / update / delete 全覆盖 +- **内置日志表**:启动自动建 Logs 表,供全系统集中写日志;带后台自动清理 +- **仅监听本机**:默认 `127.0.0.1:8005`,不对外暴露 ## 快速开始 ### 安装依赖 + ```bash -pip install flask sqlalchemy waitress -# 根据需要安装数据库驱动,例如: -# pip install psycopg2-binary # PostgreSQL -# pip install pymysql # MySQL +./_set.sh # 建 venv 并安装 requirements.txt(SQLAlchemy + 本地 ../pymsg) ``` -### 启动服务(默认 SQLite) -```bash -python app.py -``` -服务运行在 `http://127.0.0.1:8005`。 +### 启动服务 -使用其他数据库: ```bash -export DATABASE_URL="postgresql://user:pass@localhost/dbname" -python app.py +./_run.sh # venv 中执行 python3 dbv2.py ``` -**注意**:新接口依赖表已存在(通过反射获取结构)。首次使用时请先手动建表: -```bash -sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)" -``` +服务监听 `tcp://127.0.0.1:8005`。生产环境由 core(Nidus)按 init.txt 统一拉起,守护靠 systemd。 -## API 文档 +**注意**:`execute` 路由依赖表已存在(通过反射获取结构)。建表见 `create_sqlite3.sql`(Users / Notes;Logs 表由服务启动时自建)。 -### 请求格式 +## 协议与 API -**端点**:`POST /execute` -**Content-Type**:`application/json` +### 传输协议(pymsg) + +每条消息为一个完整 JSON,帧格式:`[4 字节大端长度][UTF-8 JSON]`。 +请求中的 `_route` 字段指定路由,目前仅有一个路由:**`execute`**。 + +### execute 路由 — 通用 CRUD ```json { - "action": "操作类型", // select | insert | update | delete - "table": "表名", - "columns": ["列1", "列2"], // select/insert 时可选 - "values": { // insert/update 时必填 - "列名": "值" - }, - "where": { // 条件,支持组合操作符 + "_route": "execute", + "action": "select", // select | insert | update | delete + "table": "Users", + "columns": ["id", "name"], // select 时可选(缺省全列) + "values": {"name": "Bob"}, // insert / update 时必填 + "where": { // 可选,条件见下表 "age": {"ge": 18, "lt": 60}, "name": "Alice" }, - "order_by": [{"column": "age", "desc": true}], + "order_by": [{"column": "id", "desc": true}], "limit": 10, "offset": 0 } @@ -84,48 +77,46 @@ sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age ```json { "success": true, - "data": [ // 查询结果数组(insert/update/delete 时包含影响行数或 lastrowid) - {"id": 1, "name": "Alice"} + "data": [ + {"id": 1, "username": "Alice"} ] } ``` +- select → 行对象数组 +- insert → `[{"lastrowid": N}]` +- update / delete → `[{"rowcount": N}]` 失败: ```json -{ - "success": false, - "error": "错误描述" -} +{"success": false, "error": "错误描述"} ``` -## 使用示例(curl) +## 使用示例(Python + pymsg) -**插入**: -```bash -curl -X POST http://127.0.0.1:8005/execute \ - -H "Content-Type: application/json" \ - -d '{"action":"insert","table":"users","values":{"name":"Bob","age":25}}' -``` +```python +import json +from pymsg import MsgClient -**查询**: -```bash -curl -X POST http://127.0.0.1:8005/execute \ - -H "Content-Type: application/json" \ - -d '{"action":"select","table":"users","where":{"age":{"gt":20}},"order_by":[{"column":"id","desc":true}],"limit":5}' -``` +client = MsgClient("127.0.0.1", 8005, "myservice") -**更新**: -```bash -curl -X POST http://127.0.0.1:8005/execute \ - -H "Content-Type: application/json" \ - -d '{"action":"update","table":"users","values":{"age":26},"where":{"name":"Bob"}}' -``` +# 查询 +resp = client.call(json.dumps({ + "_route": "execute", + "action": "select", + "table": "Users", + "where": {"id": {"gt": 0}}, + "order_by": [{"column": "id", "desc": True}], + "limit": 5, +})) +print(json.loads(resp)) -**删除**: -```bash -curl -X POST http://127.0.0.1:8005/execute \ - -H "Content-Type: application/json" \ - -d '{"action":"delete","table":"users","where":{"id":{"in":[1,2]}}}' +# 插入 +client.call(json.dumps({ + "_route": "execute", + "action": "insert", + "table": "Users", + "values": {"username": "bob", "password_hash": "..."}, +})) ``` ## 安全机制 @@ -133,17 +124,17 @@ curl -X POST http://127.0.0.1:8005/execute \ - 强制使用 SQLAlchemy 参数绑定,杜绝 SQL 注入。 - 仅反射已有表,无法创建或删除表(保证结构安全)。 - 所有异常自动回滚事务。 -- 默认监听 `127.0.0.1`,仅本机可访问。 +- 仅监听 `127.0.0.1`,仅本机服务可访问。 -## 内置日志模块 +## 内置日志能力 -db 服务内置了统一日志收集能力,各业务服务通过 HTTP 推送日志到 `/log/*` 系列接口,db 自身的操作日志通过 `log_internal()` 直接写入(同进程,无 HTTP 往返,无循环依赖)。 +db 内置统一日志收集:全系统各服务(waf / gateway / static / dnfs / db / ai_review / captcha / core / smtp)的日志集中写入 db 的 `Logs` 表。 ### 为什么集成在 db 而不是独立服务 独立 logs 服务会导致**循环调用**:db 记日志→调 logs,logs 存库→调 db,无限递归。集成在 db 内部后,db 自己记日志直接写表,彻底消除循环依赖。 -### 日志表 +### 日志表(启动时自建) ```sql CREATE TABLE IF NOT EXISTS Logs ( @@ -155,152 +146,66 @@ CREATE TABLE IF NOT EXISTS Logs ( trace_id TEXT, extra TEXT ); +-- 索引: (service,ts) / (level,ts) / (ts) / (trace_id) ``` -- `service`:来源服务名(`waf`/`gateway`/`static`/`dnfs`/`db`/`ai_review`/`captcha`/`core`) -- `level`:`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL` +- `level`:`DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL` - `trace_id`:可选,请求追踪 ID - `extra`:可选,JSON 字符串,存放附加结构化字段 -### 接口 +### 写入方式 -| 接口 | 方法 | 说明 | -|------|------|------| -| `/log/ingest` | POST | 推送日志(支持批量) | -| `/log/query` | POST | 查询日志(按服务/级别/时间/关键字过滤) | -| `/log/stats` | POST | 统计聚合(按 service/level 分组计数) | -| `/log/purge` | POST | 清理过期日志 | -| `/log/health` | GET | 健康检查 | +**外部服务**:复制 `example_log_client.py` 为各自目录下的 `log_client.py`,改 `service_name` 即用。客户端底层通过 pymsg 调 `execute` 路由逐条 INSERT 到 Logs 表: -所有接口的响应格式与 `/execute` 统一:`{success, data, error}`。 +```python +from log_client import logs -#### POST /log/ingest - -```json -请求: -{ - "logs": [ - { - "service": "dnfs", - "level": "INFO", - "message": "节点新增成功 id=42", - "ts": "2026-07-25T08:30:00.123Z", - "trace_id": "req-abc-123", - "extra": {"path": "/add_note", "status": 200} - } - ] -} - -响应: -{"success": true, "data": {"inserted": 1}} +logs.info("dnfs", "节点新增成功 id=42") +logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) +logs.push("gateway", "WARNING", "登录失败次数超限", + trace_id="req-abc", extra={"ip": "1.2.3.4"}) ``` -- `ts` 可选,缺省自动填当前 UTC 时间 -- 单次最多 100 条 -- `message` 上限 8KB,`extra` 上限 4KB +客户端特性: +- 纯标准库实现,异步非阻塞:内存队列 + 后台线程批量推送 +- fail-safe:db 不可用时静默丢弃,绝不影响业务 +- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush -#### POST /log/query +**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归)。 -```json -请求: -{ - "service": "dnfs", // 可选,支持数组匹配多个 - "level": "WARNING", // 可选,支持数组匹配多个 - "since": "2026-07-25T00:00:00Z", // 可选 - "until": "2026-07-25T23:59:59Z", // 可选 - "keyword": "节点", // 可选,message 模糊搜索 - "trace_id": "req-abc-123", // 可选 - "order_by": "ts", // 默认 ts,可选 id/service/level - "desc": true, // 默认 true(最新在前) - "limit": 50, // 默认 100,上限 1000 - "offset": 0 // 分页偏移 -} +### 查询日志 -响应: -{ - "success": true, - "data": { - "total": 128, - "logs": [{"id":1024,"ts":"...","service":"dnfs","level":"INFO","message":"...","trace_id":"...","extra":{...}}] - } -} -``` +日志查询直接走 `execute` 路由 select Logs 表,或用工具脚本 `_view_db.sqlite3.py`: -#### POST /log/stats - -```json -请求: -{"since":"...","until":"...","group_by":"service"} - -响应: -{ - "success": true, - "data": { - "groups": [{"service":"dnfs","level":"INFO","count":320}], - "total": 325 - } -} -``` - -`group_by` 支持 `service` / `level` / `service,level`。 - -#### POST /log/purge - -```json -请求: {"before": "2026-07-18T00:00:00Z"} -响应: {"success": true, "data": {"deleted": 1523}} -``` - -#### GET /log/health - -```json -{"success": true, "data": {"status": "ok", "count": 10240}} +```bash +python3 _view_db.sqlite3.py # 列出所有表及行数 +python3 _view_db.sqlite3.py Logs -n 20 # 最近 20 条日志 +python3 _view_db.sqlite3.py Logs -w "level='ERROR'" +python3 _view_db.sqlite3.py -s "SELECT service, COUNT(*) FROM Logs GROUP BY service" ``` ### 自动清理 - 服务启动时开启后台守护线程,每 1 小时清理一次 - 默认保留 30 天日志,超期自动删除 -- 日志总条数超过 100 万时,按时间倒序保留最新 N 条 - -### 各服务接入方式 - -把 `db/log_client.py` 复制到各服务目录(或加入 `PYTHONPATH`),即可使用: - -```python -from log_client import logs - -# 替换原来的 print -logs.info("dnfs", "节点新增成功 id=42") -logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) - -# 带追踪 ID -logs.push("gateway", "WARNING", "登录失败次数超限", - trace_id="req-abc", extra={"ip": "1.2.3.4"}) -``` - -特性: -- 使用 `urllib.request`(标准库,不引入 requests) -- 异步非阻塞:内存队列 + 后台线程批量推送 -- fail-safe:db 服务不可用时静默丢弃,绝不影响业务 -- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - -### 防递归设计 - -| 场景 | 调用路径 | 是否安全 | -|------|---------|---------| -| 外部服务记日志 | `服务 → POST /log/ingest → 写 Logs 表` | 安全 | -| db 自己记日志 | `/execute → log_internal() → 直接写 Logs 表` | 安全(无 HTTP) | -| /log/ingest 成功 | `写入成功 → 直接返回` | 安全(不再记日志) | -| /log/ingest 失败 | `写入失败 → log_internal() → 直接写表` | 安全(log_internal 不回调 ingest) | +- 日志总条数超过 100 万时,按时间倒序保留最新 100 万条 ## 切换数据库 只需修改环境变量 `DATABASE_URL`,并安装对应驱动: -- SQLite:`sqlite:///database.db` -- PostgreSQL:`postgresql://user:pass@localhost/dbname` (需 `pip install psycopg2`) -- MySQL:`mysql+pymysql://user:pass@localhost/dbname` (需 `pip install pymysql`) +- SQLite(默认):`sqlite:///database.db` +- PostgreSQL:`postgresql://user:pass@localhost/dbname`(需 `pip install psycopg2`) +- MySQL:`mysql+pymysql://user:pass@localhost/dbname`(需 `pip install pymysql`) SQLAlchemy 会自动生成适配各数据库的 SQL 方言,无需修改接口调用。 +## 文件说明 + +| 文件 | 说明 | +|------|------| +| `dbv2.py` | 服务主程序(pymsg/TCP,唯一在跑的实现) | +| `example_log_client.py` | 日志客户端 SDK 模板,各服务复制为 `log_client.py` 使用 | +| `create_sqlite3.sql` | Users / Notes / Logs 建表脚本 | +| `_view_db.sqlite3.py` | 非交互式查看 database.db 的工具脚本 | +| `_set.sh` / `_run.sh` | 环境安装 / 启动脚本 | diff --git a/db/log_module.py b/db/log_module.py deleted file mode 100644 index 385c387..0000000 --- a/db/log_module.py +++ /dev/null @@ -1,490 +0,0 @@ -""" -db 服务的内置日志子模块。 - -提供两部分能力: - 1. log_internal() — db 进程内部直接写 Logs 表(无 HTTP 往返,无循环依赖) - 2. /log/* HTTP 路由 — 供外部服务推送 / 查询 / 统计 / 清理日志 - -设计要点(防递归): - - log_internal() 直接操作数据库,不经过任何 HTTP 路由 - - /log/ingest 成功后不再调 log_internal - - /log/ingest 失败时才调 log_internal,而 log_internal 不会回调 /log/ingest -""" - -import json -import threading -import time -from datetime import datetime, timezone, timedelta - -from flask import Blueprint, request, jsonify, current_app -from sqlalchemy import ( - Table, insert, select, delete, func, and_, or_, MetaData -) - -bp = Blueprint('log', __name__, url_prefix='/log') - -# ── 常量 ────────────────────────────────────────────────────────────── - -VALID_SERVICES = { - 'waf', 'gateway', 'static', 'dnfs', 'db', - 'ai_review', 'captcha', 'core', -} -VALID_LEVELS = {'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'} - -MAX_BATCH = 100 # 单次 /log/ingest 最多 100 条 -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 # 秒 - -# ── 表管理 ───────────────────────────────────────────────────────────── - -_LOGS_TABLE_CACHE = {} - -def _ensure_logs_table(metadata: MetaData, engine): - """确保 Logs 表已建好并已反射到 metadata(首次调用时建表 + 反射)。""" - if 'Logs' in _LOGS_TABLE_CACHE: - return _LOGS_TABLE_CACHE['Logs'] - - # 先用原生 SQL 建表(SQLAlchemy 反射要求表已存在) - with engine.connect() as conn: - conn.exec_driver_sql(""" - CREATE TABLE IF NOT EXISTS Logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL, - service TEXT NOT NULL, - level TEXT NOT NULL, - message TEXT NOT NULL, - trace_id TEXT, - extra TEXT - ) - """) - conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)" - ) - conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)" - ) - conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)" - ) - conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)" - ) - conn.commit() - - # 反射到 metadata - table = Table('Logs', metadata, autoload_with=engine) - _LOGS_TABLE_CACHE['Logs'] = table - return table - - -# ── 内部日志记录(db 自己用,无 HTTP)───────────────────────────────── - -def log_internal(engine, metadata, service, level, message, - trace_id=None, extra=None): - """ - db 进程内部记录日志 — 直接操作数据库,不走 HTTP,无循环依赖。 - - 供 db 的 /execute 路由内部调用。失败时静默吞掉,不影响主业务。 - """ - try: - table = _ensure_logs_table(metadata, engine) - extra_json = json.dumps(extra, ensure_ascii=False) if extra else None - if len(message) > MAX_MESSAGE_LEN: - message = message[:MAX_MESSAGE_LEN] - if extra_json and len(extra_json) > MAX_EXTRA_LEN: - extra_json = extra_json[:MAX_EXTRA_LEN] - - with engine.connect() as conn: - conn.execute(insert(table).values( - ts=datetime.now(timezone.utc).isoformat(), - service=service, - level=level, - message=message, - trace_id=trace_id, - extra=extra_json, - )) - conn.commit() - except Exception: - # 日志记录失败不应影响主业务 - pass - - -# ── HTTP 路由 ────────────────────────────────────────────────────────── - -def init_log_routes(app, engine, metadata, - retention_days=DEFAULT_RETENTION_DAYS, - max_records=DEFAULT_MAX_RECORDS, - cleanup_interval=DEFAULT_CLEANUP_INTERVAL): - """把日志蓝图注册到 Flask app,并注入 engine/metadata。""" - - app.config['LOG_ENGINE'] = engine - app.config['LOG_METADATA'] = metadata - app.config['LOG_RETENTION_DAYS'] = retention_days - app.config['LOG_MAX_RECORDS'] = max_records - app.config['LOG_CLEANUP_INTERVAL'] = cleanup_interval - - # 建表 + 反射 - try: - _ensure_logs_table(metadata, engine) - except Exception as e: - log_internal(engine, metadata, 'db', 'ERROR', - f'Logs 表初始化失败: {e}') - - # 注册路由 - app.register_blueprint(bp) - - # 启动后台清理线程 - _start_cleanup_thread(engine, metadata, - retention_days, max_records, cleanup_interval) - - -# ── 5.1 推送日志 ────────────────────────────────────────────────────── - -@bp.route('/ingest', methods=['POST']) -def ingest(): - """接收外部日志推送(单条或批量)。""" - data = request.get_json(silent=True) - if not data or 'logs' not in data: - return jsonify({'success': False, 'error': '请求体缺少 logs 字段'}), 400 - - logs = data['logs'] - if not isinstance(logs, list) or not logs: - return jsonify({'success': False, 'error': 'logs 必须是非空数组'}), 400 - if len(logs) > MAX_BATCH: - return jsonify({ - 'success': False, - 'error': f'单次最多 {MAX_BATCH} 条,当前 {len(logs)} 条' - }), 400 - - engine = current_app.config['LOG_ENGINE'] - metadata = current_app.config['LOG_METADATA'] - - try: - table = _ensure_logs_table(metadata, engine) - except Exception as e: - return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500 - - # 校验 + 组装 - now = datetime.now(timezone.utc).isoformat() - rows = [] - for i, log in enumerate(logs): - if not isinstance(log, dict): - return jsonify({'success': False, - 'error': f'第 {i} 条日志不是有效对象'}), 400 - - service = log.get('service') - level = log.get('level') - message = log.get('message') - - if not service or service not in VALID_SERVICES: - return jsonify({'success': False, - 'error': f'第 {i} 条: service 无效或缺失'}), 400 - if not level or level not in VALID_LEVELS: - return jsonify({'success': False, - 'error': f'第 {i} 条: level 无效或缺失'}), 400 - if not message or not isinstance(message, str): - return jsonify({'success': False, - 'error': f'第 {i} 条: message 缺失或非字符串'}), 400 - - if len(message) > MAX_MESSAGE_LEN: - message = message[:MAX_MESSAGE_LEN] - - trace_id = log.get('trace_id') - extra = log.get('extra') - extra_json = None - if extra is not None: - extra_json = json.dumps(extra, ensure_ascii=False) - if len(extra_json) > MAX_EXTRA_LEN: - extra_json = extra_json[:MAX_EXTRA_LEN] - - rows.append({ - 'ts': log.get('ts') or now, - 'service': service, - 'level': level, - 'message': message, - 'trace_id': trace_id, - 'extra': extra_json, - }) - - # 写入(成功后不再调 log_internal,防递归) - try: - with engine.connect() as conn: - conn.execute(insert(table), rows) - conn.commit() - except Exception as e: - # 失败时可以安全地记日志(log_internal 不回调 ingest) - log_internal(engine, metadata, 'db', 'ERROR', - f'/log/ingest 写入失败: {e}') - return jsonify({'success': False, 'error': str(e)}), 500 - - return jsonify({'success': True, 'data': {'inserted': len(rows)}}) - - -# ── 5.2 查询日志 ─────────────────────────────────────────────────────── - -@bp.route('/query', methods=['POST']) -def query(): - """结构化查询日志。""" - data = request.get_json(silent=True) or {} - - engine = current_app.config['LOG_ENGINE'] - metadata = current_app.config['LOG_METADATA'] - - try: - table = _ensure_logs_table(metadata, engine) - except Exception as e: - return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500 - - clauses = [] - - # service(支持单个字符串或数组) - svc = data.get('service') - if svc: - col = table.columns['service'] - if isinstance(svc, list): - clauses.append(col.in_(svc)) - else: - clauses.append(col == svc) - - # level(支持单个字符串或数组) - lvl = data.get('level') - if lvl: - col = table.columns['level'] - if isinstance(lvl, list): - clauses.append(col.in_(lvl)) - else: - clauses.append(col == lvl) - - # trace_id - tid = data.get('trace_id') - if tid: - clauses.append(table.columns['trace_id'] == tid) - - # 时间区间 - since = data.get('since') - until = data.get('until') - ts_col = table.columns['ts'] - if since: - clauses.append(ts_col >= since) - if until: - clauses.append(ts_col <= until) - - # 关键字模糊搜索 - keyword = data.get('keyword') - if keyword: - clauses.append(table.columns['message'].like(f'%{keyword}%')) - - where_cond = and_(*clauses) if clauses else None - - # 排序 - order_col_name = data.get('order_by', 'ts') - if order_col_name not in ('id', 'ts', 'service', 'level'): - order_col_name = 'ts' - order_col = table.columns[order_col_name] - desc = data.get('desc', True) - order_expr = order_col.desc() if desc else order_col.asc() - - # 分页 - limit = min(data.get('limit', 100), 1000) - offset = data.get('offset', 0) - - try: - with engine.connect() as conn: - # 总数 - count_stmt = select(func.count()).select_from(table) - if where_cond is not None: - count_stmt = count_stmt.where(where_cond) - total = conn.execute(count_stmt).scalar() or 0 - - # 查询 - stmt = select(table) - if where_cond is not None: - stmt = stmt.where(where_cond) - stmt = stmt.order_by(order_expr).limit(limit).offset(offset) - - rows = conn.execute(stmt).fetchall() - logs = [] - for row in rows: - d = dict(row._mapping) - if d.get('extra'): - try: - d['extra'] = json.loads(d['extra']) - except (json.JSONDecodeError, TypeError): - pass - logs.append(d) - - return jsonify({'success': True, 'data': {'total': total, 'logs': logs}}) - - except Exception as e: - log_internal(engine, metadata, 'db', 'ERROR', - f'/log/query 失败: {e}') - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ── 5.3 统计聚合 ─────────────────────────────────────────────────────── - -@bp.route('/stats', methods=['POST']) -def stats(): - """日志分类统计。""" - data = request.get_json(silent=True) or {} - - engine = current_app.config['LOG_ENGINE'] - metadata = current_app.config['LOG_METADATA'] - - try: - table = _ensure_logs_table(metadata, engine) - except Exception as e: - return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500 - - group_by = data.get('group_by', 'service') - group_fields = [g.strip() for g in group_by.split(',') if g.strip()] - for g in group_fields: - if g not in ('service', 'level'): - return jsonify({ - 'success': False, - 'error': f'group_by 仅支持 service / level,收到: {g}' - }), 400 - - # 时间过滤 - clauses = [] - since = data.get('since') - until = data.get('until') - ts_col = table.columns['ts'] - if since: - clauses.append(ts_col >= since) - if until: - clauses.append(ts_col <= until) - where_cond = and_(*clauses) if clauses else None - - try: - with engine.connect() as conn: - cols = [table.columns[g] for g in group_fields] + [func.count().label('count')] - stmt = select(*cols) - if where_cond is not None: - stmt = stmt.where(where_cond) - stmt = stmt.group_by(*[table.columns[g] for g in group_fields]) - - rows = conn.execute(stmt).fetchall() - groups = [dict(row._mapping) for row in rows] - - # 总数 - count_stmt = select(func.count()).select_from(table) - if where_cond is not None: - count_stmt = count_stmt.where(where_cond) - total = conn.execute(count_stmt).scalar() or 0 - - return jsonify({'success': True, 'data': {'groups': groups, 'total': total}}) - - except Exception as e: - log_internal(engine, metadata, 'db', 'ERROR', - f'/log/stats 失败: {e}') - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ── 5.4 清理过期 ────────────────────────────────────────────────────── - -@bp.route('/purge', methods=['POST']) -def purge(): - """清理过期日志。""" - data = request.get_json(silent=True) or {} - before = data.get('before') - if not before: - return jsonify({'success': False, 'error': '缺少 before 字段'}), 400 - - engine = current_app.config['LOG_ENGINE'] - metadata = current_app.config['LOG_METADATA'] - - try: - table = _ensure_logs_table(metadata, engine) - except Exception as e: - return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500 - - try: - with engine.connect() as conn: - stmt = delete(table).where(table.columns['ts'] < before) - result = conn.execute(stmt) - conn.commit() - deleted = result.rowcount - log_internal(engine, metadata, 'db', 'INFO', - f'/log/purge 清理 {deleted} 条 (before={before})') - return jsonify({'success': True, 'data': {'deleted': deleted}}) - - except Exception as e: - log_internal(engine, metadata, 'db', 'ERROR', - f'/log/purge 失败: {e}') - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ── 5.5 健康检查 ────────────────────────────────────────────────────── - -@bp.route('/health', methods=['GET']) -def health(): - """日志模块健康检查。""" - engine = current_app.config['LOG_ENGINE'] - metadata = current_app.config['LOG_METADATA'] - - try: - table = _ensure_logs_table(metadata, engine) - with engine.connect() as conn: - count = conn.execute(select(func.count()).select_from(table)).scalar() or 0 - return jsonify({'success': True, 'data': {'status': 'ok', 'count': count}}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ── 后台自动清理线程 ────────────────────────────────────────────────── - -def _start_cleanup_thread(engine, metadata, - retention_days=DEFAULT_RETENTION_DAYS, - max_records=DEFAULT_MAX_RECORDS, - interval=DEFAULT_CLEANUP_INTERVAL): - """启动后台守护线程,定期清理过期日志。""" - def _cleanup_loop(): - while True: - time.sleep(interval) - try: - _do_cleanup(engine, metadata, retention_days, max_records) - except Exception: - # 清理失败不影响服务运行 - pass - - t = threading.Thread(target=_cleanup_loop, daemon=True, name='logs-cleanup') - t.start() - - -def _do_cleanup(engine, metadata, retention_days, max_records): - """执行一次清理:删过期 + 裁剪超量。""" - table = _ensure_logs_table(metadata, engine) - - # 1. 删除过期日志 - cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).isoformat() - with engine.connect() as conn: - result = conn.execute(delete(table).where(table.columns['ts'] < cutoff)) - conn.commit() - expired_deleted = result.rowcount - - # 2. 裁剪超量记录(保留最新的 max_records 条) - count = conn.execute(select(func.count()).select_from(table)).scalar() or 0 - trimmed = 0 - if count > max_records: - excess = count - max_records - # 找到第 max_records 条的 ts(按时间正序的第 max_records 条) - cutoff_row = conn.execute( - select(table.columns['ts']) - .order_by(table.columns['ts'].asc()) - .limit(1).offset(max_records) - ).fetchone() - if cutoff_row: - trim_result = conn.execute( - delete(table).where(table.columns['ts'] < cutoff_row[0]) - ) - conn.commit() - trimmed = trim_result.rowcount - - if expired_deleted or trimmed: - log_internal(engine, metadata, 'db', 'INFO', - f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条') diff --git a/db/requirements.txt b/db/requirements.txt index be8d7ed..390bf2e 100644 --- a/db/requirements.txt +++ b/db/requirements.txt @@ -1,5 +1,3 @@ nuitka -flask SQLAlchemy -waitress ../pymsg \ No newline at end of file diff --git a/db/security_middleware.py b/db/security_middleware.py deleted file mode 100644 index 8f69800..0000000 --- a/db/security_middleware.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -Security headers middleware for Flask applications. -Usage: - from security_middleware import apply_security_headers - apply_security_headers(app) -""" -import secrets - -def apply_security_headers(app, csp_policy=None): - """Apply security response headers to a Flask app.""" - - @app.after_request - def set_security_headers(response): - # Prevent clickjacking - response.headers['X-Frame-Options'] = 'SAMEORIGIN' - # XSS protection (legacy browsers) - response.headers['X-XSS-Protection'] = '1; mode=block' - # Prevent MIME type sniffing - response.headers['X-Content-Type-Options'] = 'nosniff' - # Referrer policy - response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' - # Permissions policy (disable camera, mic, geolocation) - response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()' - - # Content Security Policy - if csp_policy: - response.headers['Content-Security-Policy'] = csp_policy - else: - response.headers['Content-Security-Policy'] = ( - "default-src 'self'; " - "script-src 'self' 'unsafe-inline'; " - "style-src 'self' 'unsafe-inline'; " - "img-src 'self' data: blob:; " - "font-src 'self'; " - "connect-src 'self'; " - "frame-ancestors 'self'" - ) - - # Remove server header info - response.headers.pop('Server', None) - - return response diff --git a/tests/blackbox/config.py b/tests/blackbox/config.py index 1854538..22e5450 100644 --- a/tests/blackbox/config.py +++ b/tests/blackbox/config.py @@ -31,6 +31,7 @@ SERVICES = { "name": "db", "host": "127.0.0.1", "port": 8005, + "protocol": "pymsg", "description": "通用数据库服务 (CRUD+集中日志)", "test_file": "test_db.py", }, diff --git a/tests/blackbox/helpers.py b/tests/blackbox/helpers.py index 6509873..b4ba71e 100644 --- a/tests/blackbox/helpers.py +++ b/tests/blackbox/helpers.py @@ -284,6 +284,21 @@ class PymsgClient: # 服务可用性检查 # --------------------------------------------------------------------------- +def check_tcp_service(host: str, port: int, timeout: float = 5.0) -> tuple: + """检查纯 TCP 服务(如 pymsg 协议服务 db/captcha/smtp)是否可达。 + 返回 (reachable: bool, detail: str)。 + """ + try: + with socket.create_connection((host, port), timeout=timeout): + return True, "TCP 连接成功" + except ConnectionRefusedError: + return False, "连接被拒绝 (服务未启动或端口未监听)" + except socket.timeout: + return False, f"TCP 连接超时 ({timeout}s)" + except OSError as e: + return False, f"网络错误: {e}" + + def check_service(url: str, timeout: float = 8.0) -> tuple: """检查服务是否可达(HTTP 请求检测,与测试实际行为一致)。 返回 (reachable: bool, detail: str)。 diff --git a/tests/blackbox/test_db.py b/tests/blackbox/test_db.py index ca922b2..4be4a96 100644 --- a/tests/blackbox/test_db.py +++ b/tests/blackbox/test_db.py @@ -1,45 +1,37 @@ """ -db 黑盒用户模拟测试 — 集中日志: 写入 → 查询 → 统计 → 清理。 +db 黑盒用户模拟测试 — pymsg 协议 CRUD。 用户旅程: - 1. 通过 pymsg 协议执行 CRUD (select/insert) - 2. 通过 HTTP /log/ingest 写入日志 - 3. 通过 HTTP /log/query 查询日志 - 4. 通过 HTTP /log/stats 查看统计 - 5. 通过 HTTP /log/health 健康检查 + 1. 通过 pymsg 协议查询 Users 表 + 2. 通过 pymsg 协议查询 Logs 表 + 3. 通过 pymsg 协议带 where 条件查询 + 4. 无效路由被拒绝 -注意: dbv2.py (pymsg 模式) 不挂载 Flask log_module Blueprint, -因此 HTTP /log/* 路由可能不可用。测试会优雅降级。 +注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过 +log_client.py 走 execute 路由 INSERT 写入 Logs 表。 """ -import json -import random -import string -import time - from helpers import ( - http_get, http_post, try_parse_json, - assert_eq, assert_status, assert_true, assert_json_key, - skip, Counter, check_service, PymsgClient, + assert_true, skip, Counter, PymsgClient, check_tcp_service, ) -from config import get_url -DB_URL = get_url("db") DB_HOST = "127.0.0.1" DB_PORT = 8005 def run(url: str = None): - """db 用户模拟测试入口。返回 (passed, failed, skipped)。""" - du = url or DB_URL + """db 用户模拟测试入口。返回 (passed, failed, skipped)。 + + url 参数仅为保持与其他测试模块的签名一致,db 固定走 127.0.0.1:8005。 + """ c = Counter() print(f"\n--- db 数据库与日志流程 ---\n") # ------------------------------------------------------------------ - # 0. 检查服务可用性 + # 0. 检查服务可用性(TCP 直连,db 无 HTTP 接口) # ------------------------------------------------------------------ - if not check_service(du)[0]: + if not check_tcp_service(DB_HOST, DB_PORT)[0]: skip("全部测试", "db 服务不可达", c) return c.passed, c.failed, c.skipped @@ -109,76 +101,6 @@ def run(url: str = None): "pymsg-无效路由-拒绝", c, ) - # ------------------------------------------------------------------ - # 第二部分: HTTP /log/* 接口 (Flask log_module) - # ------------------------------------------------------------------ - print("\n [HTTP /log/* 接口测试]") - - # 检查 /log/health 是否可用(Flask log_module 是否挂载) - status, body, _ = http_get(f"{du}/log/health") - if status == 0 or status >= 500: - skip("HTTP /log/* 测试", "log_module 未挂载 (dbv2 使用 pymsg 模式, 非 Flask)", c) - else: - # 场景 5: /log/health 健康检查 - assert_eq(status, 200, "log-健康检查 (200)", c) - - # 场景 6: /log/ingest 写入日志 - test_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - status, body, _ = http_post(f"{du}/log/ingest", { - "logs": [{ - "service": "dnfs", - "level": "INFO", - "message": f"blackbox_test_{random.randint(10000, 99999)}", - "ts": test_ts, - }], - }) - data = try_parse_json(body) - assert_true( - status == 200 and data is not None and data.get("success") is True, - "log-写入日志", c, - f"status={status} body={body[:150]}", - ) - - # 场景 7: /log/ingest — 空日志列表 - status, body, _ = http_post(f"{du}/log/ingest", {"logs": []}) - assert_eq(status, 200, "log-空日志列表 (200)", c) - - # 场景 8: /log/ingest — 缺少 logs 字段 - status, body, _ = http_post(f"{du}/log/ingest", {}) - data = try_parse_json(body) - assert_true( - data is not None and not data.get("success", True), - "log-缺少logs字段-拒绝", c, - ) - - # 场景 9: /log/query 查询日志 - status, body, _ = http_post(f"{du}/log/query", { - "limit": 5, - "desc": True, - }) - data = try_parse_json(body) - assert_true( - status == 200 and data is not None and data.get("success") is True, - "log-查询日志", c, - f"status={status}", - ) - if data and data.get("success"): - assert_true( - "logs" in data.get("data", {}), - "log-查询返回logs字段", c, - ) - - # 场景 10: /log/stats 统计 - status, body, _ = http_post(f"{du}/log/stats", { - "group_by": "service", - }) - data = try_parse_json(body) - assert_true( - status == 200 and data is not None, - "log-统计", c, - f"status={status}", - ) - # ------------------------------------------------------------------ # 汇总 # ------------------------------------------------------------------ diff --git a/tests/blackbox/test_runner.py b/tests/blackbox/test_runner.py index 8729763..8f1ff84 100644 --- a/tests/blackbox/test_runner.py +++ b/tests/blackbox/test_runner.py @@ -17,7 +17,7 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from config import SERVICES, PORT_MAP, detect_service, get_url -from helpers import check_service +from helpers import check_service, check_tcp_service TEST_MODULES = { "gateway": "test_gateway", @@ -53,7 +53,11 @@ def run_service_test(name: str, url: str) -> tuple: print(f" {svc['description']} ({url})") print(f"{'='*60}") - reachable, detail = check_service(url) + # pymsg/TCP 服务无 HTTP 接口,用 TCP 直连探测 + if svc.get("protocol") == "pymsg": + reachable, detail = check_tcp_service(svc["host"], svc["port"]) + else: + reachable, detail = check_service(url) if not reachable: print(f" [WARN] 服务不可达: {url}") if detail: From afb55494e581d1772c43fbb56155add33a8423b0 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:25:01 +0800 Subject: [PATCH 11/33] interpreter project --- libtry/.gitignore | 69 +++ libtry/t.try | 27 ++ libtry/test/t_assign_basic.try | 5 + libtry/test/t_function_basic.try | 6 + libtry/test/t_output_basic.try | 2 + libtry/try.c | 741 +++++++++++++++++++++++++++++++ 6 files changed, 850 insertions(+) create mode 100644 libtry/.gitignore create mode 100644 libtry/t.try create mode 100644 libtry/test/t_assign_basic.try create mode 100644 libtry/test/t_function_basic.try create mode 100644 libtry/test/t_output_basic.try create mode 100644 libtry/try.c diff --git a/libtry/.gitignore b/libtry/.gitignore new file mode 100644 index 0000000..f152158 --- /dev/null +++ b/libtry/.gitignore @@ -0,0 +1,69 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Linker files +*.ilk + +# Debugger Files +*.pdb + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll +*.so.* + + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +# Build directories +build/ +Build/ +build-*/ + +# CMake generated files +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +#Makefile +install_manifest.txt +compile_commands.json + +# Temporary files +*.tmp +*.log +*.bak +*.swp + +# vcpkg +vcpkg_installed/ + +# debug information files +*.dwo + +# test output & cache +Testing/ +.cache/ diff --git a/libtry/t.try b/libtry/t.try new file mode 100644 index 0000000..4125279 --- /dev/null +++ b/libtry/t.try @@ -0,0 +1,27 @@ +# Fibonacci sequence +func fun{ + if(x <= 2){ + return(1); + } + y = 0; + x = x - 1; + y = fun(x); + x = x - 1; + return(y + fun(x)); +}; + +# save the Fibonacci sequence of 1 to 15 in an array +array arr(15); +x = 0; +while(x<15){ + arr[x] = fun(x) + 1000; + x = x + 1; +} + +puts("Fibonacci sequence:"); +# print the Fibonacci sequence of 1 to 15 +i = 0; +while(i < 15){ + print(arr[i]); + i=i+1; +} diff --git a/libtry/test/t_assign_basic.try b/libtry/test/t_assign_basic.try new file mode 100644 index 0000000..acf209a --- /dev/null +++ b/libtry/test/t_assign_basic.try @@ -0,0 +1,5 @@ +a = 5; +b = "ok"; + +print(5); +puts(b); diff --git a/libtry/test/t_function_basic.try b/libtry/test/t_function_basic.try new file mode 100644 index 0000000..0a21c21 --- /dev/null +++ b/libtry/test/t_function_basic.try @@ -0,0 +1,6 @@ +func f_test{ + return(0); +}; + +r = f_test(); +print(r); diff --git a/libtry/test/t_output_basic.try b/libtry/test/t_output_basic.try new file mode 100644 index 0000000..bcdc20b --- /dev/null +++ b/libtry/test/t_output_basic.try @@ -0,0 +1,2 @@ +print(1); +puts("1") diff --git a/libtry/try.c b/libtry/try.c new file mode 100644 index 0000000..17d910d --- /dev/null +++ b/libtry/try.c @@ -0,0 +1,741 @@ +/* 魔改自github tryC项目。手写简易的解释器,我们 + * 将用它来作为操作界面的shell。 + * + * 也许我们可以增加一些字符串赋值、拼接的能力 + * 同时未来也需要加入系统调用的能力 + * + * + * 20250827(qing): 增加了宽字符支持。 + * 20250827(qing): 修复了Str漏匹配的BUG + * 20250827(qing): 该脚本在操作不当的情况下可能会发生内存泄漏 + * 20250827(qing): 使用longjmp()代替exit() + */ + +#include +#include +#include +#include +#include +#include "console.h" +#define POOLSIZE (1 * 128) /* 任意大小 */ +#define SYMTABSIZE (1 * 128) /* !符号表栈的大小 */ +#define MAXNAMESIZE 16 /* 变量/函数名称的长度 */ +#define RETURNFLAG DBL_MAX /* 表明函数中已经出现return语句 */ + + +typedef struct symStruct { /* 该结构体表示存储在符号表中的一个符号 */ + int type; /* 符号的类型:Num, Char, Str, Array, Func */ + wchar_t name[MAXNAMESIZE]; /* 记录符号名称 */ + double value; /* 记录符号的值,或者字符串或数组的长度 */ + union { + wchar_t *funcp; /* 指向一个数组或者一个函数 */ + struct symStruct* list; + } pointer; + int levelNum; /* 表明声明的嵌套层级 */ +} symbol; +static symbol symtab[SYMTABSIZE]; +static int symPointer = 0; /* 符号表栈指针 */ +static int currentlevel = 0; /* 当前嵌套层级 */ + +static wchar_t *src, *old_src; /* 当前正处理的文本 */ +static wchar_t *p_src; /* 用于定位释放点 */ +static int top_src = 0; + + +enum { /* 解释器状态枚举 (使用int直接接受匿名枚举) */ + debug, run +}; +static int compileState = run; /* 当前解释器状态 */ + +static double return_val = 0; /* 用于保存函数的返回值 */ + + +enum { /* 标记和类别(运算符放最后并按优先级排序) */ + Num = 128, Char, Str, Array, Func, + Else, If, Return, While, Print, Puts, Read, + Assign, OR, AND, Equal, Sym, FuncSym, ArraySym, Void, + Nequal, LessEqual, GreatEqual +}; +static int token; /* 当前标记类型 */ +union tokenValue { + symbol *ptr; /* 在返回字符串或符号地址用于赋值时使用 */ + double val; /* 用于字符或数字时标记值 */ +} ; + +static union tokenValue token_val; + +/*--------------- 函数声明 ---------------*/ +static double function(); +static double statement(); +static int boolOR(); /* 用于辅助IF和WHILE词元 */ +static int boolAND(); +static int boolexp(); +static double expression(); +static double factor(); +static double term(); +static void match(int tk); +static void next(); +///// +static void clear(int level); +static void clearThis(); /* 20260707(qing): 增加一个clearThis()函数,用于释放变量 */ +static void __exit(); +/* ------------------- 词法分析 -------------------*/ + +/* 输入字符串的下一个标记 */ +static void next() { + wchar_t *last_pos; + while(token = *src) { /* 逐字扫描 */ + ++src; + if (token == L'\n') { /* 记录新行的起点 */ + //if (compileState == debug) + // wprintf(L"%.*ls", (int)(src-old_src), old_src); + old_src = src; + } + else if (token == L'#') { /* 跳过注释 */ + while(*src != 0 && *src != L'\n') { + src++; + } + } + /* ---- 检查到标识符 ---- */ + else if ((token >= L'a' && token <= L'z') || (token >= L'A' && token <= L'Z') || (token == L'_') || (token > 0x7F)) { + last_pos = src -1; /* 程序标识符 */ + wchar_t nameBuffer[MAXNAMESIZE]; + nameBuffer[0] = token; /* 扫描标识符 */ + while ((*src >= L'a' && *src <= L'z') || (*src >= L'A' && *src <= L'Z') || (*src >= L'0' && *src <= L'9') || (*src == L'_') || (*src > 0x7f)) { + nameBuffer[src-last_pos] = *src; + src++; + } + nameBuffer[src-last_pos] = 0; /* 获取标识符名 */ + int i; + for (i=symPointer-1; i>=0; --i) { /* 在符号表中搜索符号 */ + if (wcscmp(nameBuffer, symtab[i].name) == 0) { /* 如果找到符号:根据符号类型返回标记 */ + + if (symtab[i].type == Num || symtab[i].type == Char) { + token_val.ptr = &symtab[i]; + token = Sym; + } + else if (symtab[i].type == Str) { /* 20250827(qing): 新增此分支,修复BUG */ + token = Str; + token_val.ptr = (symbol *)symtab[i].pointer.funcp; + } + else if (symtab[i].type == FuncSym) { + token_val.ptr = &symtab[i]; + token = symtab[i].type; + } + else if (symtab[i].type == ArraySym) { + token_val.ptr = &symtab[i]; + token = symtab[i].type; + } + else { + if (symtab[i].type == Void) { + token = Sym; + token_val.ptr = &symtab[i]; + } + else { // ??? + token = symtab[i].type; + token_val.ptr = NULL; /* 以防万一,为当前记号存储器赋个值! + * 因为Array记号可能到这里来,它会在检查失败后检查释放 */ + } + } + + return; + } + } + wcscpy(symtab[symPointer].name, nameBuffer); /* 如果标记未被发现,创建一个新的 */ + symtab[symPointer].levelNum = currentlevel; + symtab[symPointer].type = Void; + token_val.ptr = &symtab[symPointer]; + symPointer++; + token = Sym; + return; + } + else if (token >= L'0' && token <= L'9') { + token_val.val = (double)token - L'0'; + while (*src >= L'0' && *src <= L'9') { + token_val.val = token_val.val * 10.0 + *src++ - L'0'; + } + if (*src == L'.') { + src++; + int countDig = 1; + while (*src >= L'0' && *src <= L'9') { + token_val.val = token_val.val + ((double)(*src++) - L'0') / (10.0 * countDig++); + } + } + token = Num; + return; + } + + /* 字符 */ + else if (token == L'\'') { + token_val.val = *src++; + token = Char; + src++; + return; + } + + /* 字符串 */ + else if (token == L'"') { + last_pos = src; + int numCount = 0; + while (*src != 0 && *src != token) { + src ++; + numCount ++; + } + if (*src) { /* TODO: 解析出字符串就会创建动态内存,那么可能会有风险! */ + *src = 0; + token_val.ptr = malloc(sizeof(wchar_t) * (numCount + 1)); + *(wchar_t*)token_val.ptr = L'0'; + wcscpy((wchar_t*)token_val.ptr, last_pos); + *src = token; + src++; + } + token = Str; + return; + } + + /* == 和 = */ + else if (token == L'=') { + if (*src == L'=') { + src++; + token = Equal; + } + else {token = Assign;} + return; + } + + /* != */ + else if (token == L'!') { + if (*src == L'=') { + src++; + token = Nequal; + } + return; + } + + /* <= 或 < */ + else if (token == L'<') { + if (*src == L'=') { + src ++; + token = LessEqual; + } + return; + } + + /* >= 或 > */ + else if (token == L'>') { + if (*src == L'=') { + src++; + token = GreatEqual; + } + return; + } + + /* || */ + else if (token == L'|') { + if (*src == L'|') { + src++; + token = OR; + } + return; + } + + + /* && */ + else if (token == L'&') { + if ( *src == L'&') { + src++; + token=AND; + } + return; + } + + /* 保持以在别处解析 */ + else if ( token == L'*' || token == L'/' || token == L';' || token == L',' || token == L'+' || token == L'-' || + token == L'(' || token == L')' || token == L'{' || token == L'}' || token == L'[' || token == L']') { + return; + } + + /* 空格、制表符 */ + else if (token == L' ' || token == L'\t') { } + + /* 不认识 */ + else { + //wprintf(L"unexpected token: %d\n", token); + printtovga(L"unexpected token\n"); + } + } +} + +static void clear(int level) { + for (int i=symPointer-1; i>=0; i--) { + token_val.ptr = &symtab[i]; + if ( token_val.ptr->levelNum >= level ) { + clearThis(); + } + else { break; } + } +} + +/* 20250827(QING): 在这里检测pointer联合字段是否为空,不为空就表明有值先释放 */ +static void clearThis() { + //printf("Clear a Symbol: %s\n", token_val.ptr->name); + if (token_val.ptr->type == Str && token_val.ptr->pointer.funcp) + free(token_val.ptr->pointer.funcp); + else if (token_val.ptr->type == ArraySym && token_val.ptr->pointer.list) + free(token_val.ptr->pointer.list); +} + +/* 检查是否匹配 */ +static void match(int tk) { + if (token == tk) { + if (compileState==debug) { + if (iswprint(tk)) { /* 该字符是否能够打印 */ + //wprintf(L"match: %lc\n", tk); + } + else { + //wprintf(L"match: %d\n", tk); + ; + } + } + next(); /* 检查下一个标记 */ + } + else { /* 直接让程序死亡,在单片机上似乎不妥 */ + //wprintf(L"line %.*ls:expected token: %d\n", (int)(src - old_src), old_src, tk); + printtovga(L"unexpected token\n"); + __exit(-1); + //clearThis(); + } +} + +/*-------------------------- 语法分析与执行 --------------------------*/ +static double term() { + double temp = factor(); + while (token == L'*' || token == L'/') { + if (token == L'*') { + match(L'*'); + temp *= factor(); + } + else { + match(L'/'); + temp /= factor(); + } + } + return temp; +} + +static double factor() { + double temp = 0; + if (token == L'(') { + match(L'('); + temp = expression(); + match(L')'); + } + else if (token == Num || token == Char) { + temp = token_val.val; + match(token); + } + else if (token == Sym) { + temp = token_val.ptr->value; + match(Sym); + } + else if (token == ArraySym) { + symbol *ptr = token_val.ptr; + match(ArraySym); + match(L'['); + int index = (int)expression(); + if (index >= 0 && index < ptr->value) { + temp = ptr->pointer.list[index].value; + } + match(L']'); + } + else if (token == FuncSym) { /* 处理函数调用 */ + temp = function(); + } + else { + //printf("Unknow token: %d.\n", token); + printtovga(L"Unknow token\n"); + } + return temp; +} + +/* 加减法表达式 */ +static double expression() { + double temp = term(); + while (token == L'+' || token == L'-') { + if (token == L'+') { + match(L'+'); + temp += term(); + } + else { + match(L'-'); + temp -= term(); + } + } + return temp; +} + + +static int boolexp() { + if (token == L'(') { + match(L'('); + int result = boolOR(); + match(L')'); + return result; + } + else if (token == L'!') { + match(L'!'); + return boolexp(); + } + double temp = expression(); + if (token == L'>') { + match(L'>'); + return temp > expression(); + } + else if (token == L'<') { + match(L'<'); + return temp < expression(); + } + else if (token == GreatEqual) { + match(GreatEqual); + return temp >= expression(); + } + else if (token == LessEqual) { + match(LessEqual); + return temp <= expression(); + } + else if (token == Equal) { + match(Equal); + return temp == expression(); + } + return 0; +} + +static void skipBoolExpr() { + int count = 0; + while (token && !(token == L')' && count == 0)) { + if (token == L'(') count++; + if (token == L')') count--; + token = *src++; + } +} + + +static int boolAND() { + int val = boolexp(); + while (token == AND) { + match(AND); + if (val == 0){ + skipBoolExpr(); + return 0; // short cut + } + val = val & boolexp(); + if (val == 0) return 0; + } + return val; +} + + +static int boolOR() { + int val = boolAND(); + while (token == OR) { + match(OR); + if (val == 1){ + skipBoolExpr(); + return 1; // short cut + } + val = val | boolAND(); + if (val == 1) return 1; + } + return val; +} + + +static void skipStatments() { + if(token == L'{') + token = *src++; + int count = 0; + while (token && !(token == L'}' && count == 0)) { + if (token == L'}') count++; + if (token == L'{') count--; + token = *src++; + } + match(L'}'); +} + +/* 解析一个句子 */ +static double statement() { + if (token == L'{') { + match(L'{'); + while (token != L'}') { + if (RETURNFLAG == statement()) + return RETURNFLAG; + } + match(L'}'); + } + else if (token == If) { + match(If); + match(L'('); + int boolresult = boolOR(); + match(L')'); + if (boolresult) { + if (RETURNFLAG == statement()) + return RETURNFLAG; + } + else skipStatments(); + if (token == Else) { + match(Else); + if (!boolresult) { + if (RETURNFLAG == statement()) + return RETURNFLAG; + } + else skipStatments(); + } + } + else if (token == While) { + match(While); + wchar_t* whileStartPos = src; + wchar_t* whileStartOldPos = old_src; + int boolresult; + do { + src = whileStartPos; + old_src = whileStartOldPos; + token = L'('; /* 不加行会报错 */ + match(L'('); + boolresult = boolOR(); + match(L')'); + if (boolresult) { + if (RETURNFLAG == statement()) + return RETURNFLAG; + } + else skipStatments(); + }while (boolresult); + } + + + /* ---- 给变量赋值 ---- */ + else if (token == Sym || token == ArraySym) { + symbol* s = token_val.ptr; + //clearThis(); /* 不知为何需要注释了才能跑 */ + int tktype = token; + int index; + match(tktype); + if (tktype == ArraySym) { + match(L'['); + index = expression(); + match(L']'); + match(Assign); + if (index >= 0 && index < s->value) { + s->pointer.list[index].value = expression(); + } + /* TODO: 越界异常 */ + } + else { + match(Assign); + if (token == Str) { + s->pointer.funcp = (wchar_t*)token_val.ptr; + s->type = Str; + match(Str); + } + else if (token == Char) { + s->value = token_val.val; + s->type = Char; + match(Char); + } + else { + s->value = expression(); + s->type = Num; + } + } + match(L';'); + } + else if (token == Array) { + match(Array); + symbol* s = token_val.ptr; + //clearThis(); + match(Sym); + match(L'('); + int length = (int)expression(); + match(L')'); + s->pointer.list = malloc(sizeof(struct symStruct) * length + 1); + for (int i = 0; i < length; ++i) + s->pointer.list[i].type = Num; + s->value = length; + s->type = ArraySym; + match(L';'); + } + else if (token == Func) { + match(Func); + symbol* s = token_val.ptr; + //clearThis(); + s->type = FuncSym; + match(Sym); + s->pointer.funcp = src; + s->value = token; + skipStatments(); + match(L';'); + } + else if (token == Return) { + match(Return); + match(L'('); + return_val = expression(); + match(L')'); + match(L';'); + return RETURNFLAG; + } + else if (token == Print || token == Read || token == Puts) { + int func = token; + double temp; + match(func); + match(L'('); + switch (func) { + case Print: + temp = expression(); + wchar_t buf[16]; + swprintf(buf, 16, L"%lf\n", temp); + printtovga(buf); + break; + case Puts: + /* 这一句切换不过来 */ + //wprintf(L"%ls\n", (wchar_t*)token_val.ptr); + printtovga((wchar_t*)token_val.ptr); + match(Str); + break; + case Read: /* 单片机上等下支持 */ + //wscanf(L"%lf", &token_val.ptr->value); + token_val.ptr->value = -1; + token_val.ptr->type = Num; + match(Sym); + } + match(L')'); + match(L';'); + } + return 0; +} + + +static double function() { + currentlevel++; + return_val = 0; + + symbol* s = token_val.ptr; + match(FuncSym); + match(L'('); + while (token != L')') { + symtab[symPointer] = *token_val.ptr; + wcscpy(symtab[symPointer].name, token_val.ptr->name); + symtab[symPointer].levelNum = currentlevel; + symPointer++; + match(Sym); + if (token == L',') + match(L','); + } + match(L')'); + wchar_t* startPos = src; + wchar_t* startOldPos = old_src; + int startToken = token; + old_src = src = s->pointer.funcp; + token = (int)s->value; + statement(); + src = startPos; + old_src = startOldPos; + token = startToken; + + while (symtab[symPointer - 1].levelNum == currentlevel) { + symPointer--; + } + currentlevel--; + return return_val; +} +/*----------------------------------------------------------------*/ +int init_tryc() { + + /* 将所有字段赋值为0 */ + memset(symtab, 0, sizeof(symtab)); + + int i; /* 用于阅读定位 */ + src = L"array func else if return while print puts read"; + for (i = Array; i <= Read; ++i) { + next(); + symtab[symPointer -1].type = i; + } + + if (!(src = old_src = p_src = (wchar_t*)malloc(POOLSIZE))) { + //wprintf(L"could not malloc(%d) for source area\n", POOLSIZE); + printtovga(L"could not malloc for source aream\n"); + return -1; + } + + top_src = 0; + src[0] = L'\0'; // add EOF character +} + +void free_tryc() { + + free(p_src); +} + +int enqueue_tryc(wchar_t wc) { + + if (top_src >= POOLSIZE - 1) { + return -1; + } + src[top_src] = wc; + src[++top_src] = L'\0'; + return 0; +} + +void eval_tryc() { + + if (*src == L'\0') return; + + next(); + while (token != L'\0') { + statement(); + } + src = p_src; + + top_src = 0; + src[0]= L'\0'; + +} + +void __exit() { + while (1); +} + +#ifdef _DEBUG +#include "fcntl.h" +#include "unistd.h" +#include "locale.h" +int main(int argc, char **argv) +{ + + init_tryc(); + + + int i; + setlocale(LC_ALL, ""); + wchar_t wc; + i = 0; + FILE *file = fopen(argv[1], "r"); + if (!file) { + perror("fopen"); + return 1; + } + + while ((wc = fgetwc(file)) != WEOF) { + src[i++] = wc; + } + + + src[i] = L'\0'; // add EOF character + fclose(file); + + eval_tryc(); + + clear(0); + free_tryc(); + + return 0; +} +#endif From f91d4166d6e5b562018f61034b08e6ee2254e4e7 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:41:15 +0800 Subject: [PATCH 12/33] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20BotOps=20?= =?UTF-8?q?=E5=AD=90=E6=A8=A1=E5=9D=97=E2=80=94=E2=80=94aibox=20=E7=9A=84?= =?UTF-8?q?=20Textual=20TUI=20=E5=89=8D=E7=AB=AF=EF=BC=88=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=20Markdown=20=E6=B8=B2=E6=9F=93+=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E8=BF=9E+=E4=BB=BB=E5=8A=A1=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotOps/README.md | 46 ++++ BotOps/__pycache__/app.cpython-312.pyc | Bin 0 -> 14011 bytes BotOps/__pycache__/commands.cpython-312.pyc | Bin 0 -> 5067 bytes BotOps/__pycache__/ws_client.cpython-312.pyc | Bin 0 -> 9315 bytes BotOps/app.py | 230 ++++++++++++++++++ BotOps/commands.py | 62 +++++ BotOps/requirements.txt | 2 + BotOps/widgets/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 116 bytes .../__pycache__/chat_view.cpython-312.pyc | Bin 0 -> 4469 bytes .../__pycache__/status_bar.cpython-312.pyc | Bin 0 -> 978 bytes BotOps/widgets/chat_view.py | 69 ++++++ BotOps/widgets/status_bar.py | 14 ++ BotOps/ws_client.py | 146 +++++++++++ 14 files changed, 569 insertions(+) create mode 100644 BotOps/README.md create mode 100644 BotOps/__pycache__/app.cpython-312.pyc create mode 100644 BotOps/__pycache__/commands.cpython-312.pyc create mode 100644 BotOps/__pycache__/ws_client.cpython-312.pyc create mode 100644 BotOps/app.py create mode 100644 BotOps/commands.py create mode 100644 BotOps/requirements.txt create mode 100644 BotOps/widgets/__init__.py create mode 100644 BotOps/widgets/__pycache__/__init__.cpython-312.pyc create mode 100644 BotOps/widgets/__pycache__/chat_view.cpython-312.pyc create mode 100644 BotOps/widgets/__pycache__/status_bar.cpython-312.pyc create mode 100644 BotOps/widgets/chat_view.py create mode 100644 BotOps/widgets/status_bar.py create mode 100644 BotOps/ws_client.py diff --git a/BotOps/README.md b/BotOps/README.md new file mode 100644 index 0000000..ff6303f --- /dev/null +++ b/BotOps/README.md @@ -0,0 +1,46 @@ +# BotOps — aibox 智能体 TUI 前端 + +复刻并增强 [aibox/client.py](../aibox/client.py),基于 [Textual](https://textual.textualize.io/) 的现代化终端 UI,连接 aibox 微服务(WebSocket,默认 `ws://localhost:8765`)。 + +## 相比 client.py 的增强 + +| 能力 | client.py | BotOps | +|------|-----------|--------| +| AI 回复渲染 | 纯文本流式 / 一次性静态渲染 | **Markdown 增量流式渲染**(无闪烁) | +| 终端管理 | 手搓光标/提示符 | Textual 部件框架(多面板布局) | +| 任务事件 | 混在对话流里 | **独立侧栏**(task_process / task_status / task_error) | +| 自动重连 | ✅(已移植) | ✅ 指数退避 2s→30s,`/disconnect` 抑制 | +| 快捷键 | Ctrl+C 中断 | Ctrl+C 中断 / Ctrl+Q 退出 / Ctrl+L 清屏 | + +## 安装与运行 + +```bash +cd BotOps +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple +python app.py # 默认 ws://localhost:8765 +python app.py ws://your-host:8765 # 指定 aibox 地址 +``` + +## 命令 + +- 本地命令(不发给服务器):`/connect` `/disconnect` `/interrupt` `/clear` `/status` `/help` `/quit` +- 其余 `/` 命令透传给 aibox 服务器(与 client.py 行为一致) + +## 结构 + +``` +BotOps/ +├── app.py # Textual App:布局 + 消息路由 + 快捷键 +├── ws_client.py # WebSocket 协议层(自动重连) +├── commands.py # 斜杠命令注册表 +└── widgets/ + ├── chat_view.py # 聊天区:AI 回复 Markdown 增量流式渲染 + └── status_bar.py # 底部状态栏(连接状态) +``` + +## 协议说明 + +与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释): +- 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}` +- 接收:`chunk`(**累计全文**,增量由前端计算)/ `complete` / `dialogue_ended` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` diff --git a/BotOps/__pycache__/app.cpython-312.pyc b/BotOps/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dca1f4d4d0d6e633913b8d39896906927ee0ff50 GIT binary patch literal 14011 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 literal 0 HcmV?d00001 diff --git a/BotOps/__pycache__/ws_client.cpython-312.pyc b/BotOps/__pycache__/ws_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14441e9afd67bd8c8138b1d6c79c17d6928146e4 GIT binary patch literal 9315 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 ComposeResult: + yield Header() + with Horizontal(): + yield ChatView(id="chat") + yield RichLog(id="tasks", max_lines=1000) + yield Input(placeholder="输入消息,/help 查看命令,Enter 发送", id="input") + yield StatusBar(id="status") + yield Footer() + + async def on_mount(self): + self.query_one("#input", Input).focus() + self._refresh_status("") + self.run_worker(self.connect_to(self.ws.uri)) + + @property + def chat(self) -> ChatView: + return self.query_one("#chat", ChatView) + + @property + def tasks(self) -> RichLog: + return self.query_one("#tasks", RichLog) + + # ── 连接管理 ────────────────────────────────────────────────────────── + async def connect_to(self, uri: str): + if self.ws.connected: + await self.ws.disconnect() + self.ws.uri = uri + await self.notify_system(f"正在连接 {uri} …") + await self.ws.connect() + + async def disconnect(self): + await self.ws.disconnect() + + async def send_interrupt(self): + if await self.ws.send_interrupt(): + await self.notify_system("🛑 已发送中断信号") + else: + await self.notify_system("未连接到服务器", level="error") + + # ── UI 操作 ─────────────────────────────────────────────────────────── + async def notify_system(self, text: str, level: str = "info"): + css = {"info": "msg-system", "error": "msg-error", "task": "msg-task"}.get(level, "msg-system") + await self.chat.add_system(text, css) + + async def clear_chat(self): + await self.chat.reset() + + def _refresh_status(self, extra: str): + self.query_one("#status", StatusBar).set_state( + self.ws.connected, self.ws.uri if self.ws.connected else "", extra) + + # ── 输入处理 ────────────────────────────────────────────────────────── + async def on_input_submitted(self, event: Input.Submitted): + text = event.value.strip() + event.input.value = "" + if not text: + return + if text.startswith("/") and await self.registry.execute(text): + return + if not self.ws.connected: + await self.notify_system("未连接到服务器,先 /connect", level="error") + return + await self.chat.add_user(text) + await self.chat.start_ai() # 预建 AI 消息框,即时反馈 + await self.ws.send_message(text) + + # ── 快捷键 ──────────────────────────────────────────────────────────── + async def action_interrupt(self): + await self.send_interrupt() + + async def action_clear(self): + await self.clear_chat() + + def action_quit(self): + self.exit() + + # ── aibox 协议消息路由(对应 client.py 的 _handle_message)───────────── + async def _on_ws_message(self, data: dict): + t = data.get("type", "") + content = data.get("content", "") + + if t == "chunk": + if content.startswith("🔧 调用工具"): + await self.notify_system(content, level="task") + elif content.startswith(("✅ 对话记忆已保存", "💾 保存记忆", "❌", "⚠️")): + await self.notify_system(content, level="task") + else: + await self.chat.append_ai(content) + elif t == "complete": + await self.chat.finish_ai(content or self.chat.ai_text) + elif t == "dialogue_ended": + await self.chat.finish_ai(self.chat.ai_text) + if content: + await self.notify_system(f"✅ {content}") + elif t == "task_process": + sub = data.get("msg_type", "") + title = data.get("task_title", "未知任务") + if sub == "chunk": + tail = str(data.get("content", ""))[-200:] + if tail: + self.tasks.write(f"⚡ {title}: {tail}") + elif sub == "complete": + self.tasks.write(f"✅ 任务完成: {title}") + elif t == "task_status": + icon = {"executing": "⚙️", "complete": "✅", "failed": "❌", + "added": "➕", "batch_start": "🚀", "batch_complete": "🏁" + }.get(data.get("status", ""), "📋") + self.tasks.write( + f"{icon} [{data.get('task_id', '?')}] {data.get('title', '')}: " + f"{data.get('message', content)}") + elif t == "task_error": + self.tasks.write( + f"❌ [{data.get('task_id', '?')}] {data.get('task_title', '?')}: " + f"{data.get('error', '未知错误')}") + elif t == "interrupt_ack": + await self.notify_system(f"🛑 {content}") + elif t == "error": + await self.notify_system(f"❌ {content}", level="error") + elif t == "info": + await self.notify_system(f"ℹ️ {content}") + elif t == "command_result": + await self.notify_system(content) + elif t == "connection_ack": + await self.notify_system(f"✅ {content}") + elif t == "notification": + await self.notify_system(f"🔔 {content}") + elif content: + await self.chat.append_ai(content) + + # ── 连接状态回调 ────────────────────────────────────────────────────── + async def _on_ws_state(self, state: str, detail: str): + if state == "connected": + self._refresh_status("") + await self.notify_system(f"✅ 已连接到 {self.ws.uri}") + elif state == "disconnected": + self._refresh_status("") + await self.notify_system(f"❌ {detail}") + elif state == "reconnecting": + self._refresh_status(detail) + elif state == "error": + self._refresh_status("") + await self.notify_system(detail, level="error") + + +def main(): + uri = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URI + BotOpsApp(uri).run() + + +if __name__ == "__main__": + main() diff --git a/BotOps/commands.py b/BotOps/commands.py new file mode 100644 index 0000000..02599d0 --- /dev/null +++ b/BotOps/commands.py @@ -0,0 +1,62 @@ +"""斜杠命令注册表:/connect /disconnect /interrupt /clear /status /help /quit。 + +本地命令在此拦截处理;未注册的 / 命令透传给 aibox 服务器(与 client.py 行为一致)。 +""" +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app import BotOpsApp + + +class CommandRegistry: + def __init__(self, app: "BotOpsApp"): + self.app = app + self._commands = { + "/connect": ("连接服务器:/connect ws://host:port", self._connect), + "/disconnect": ("断开连接(不自动重连)", self._disconnect), + "/interrupt": ("中断当前生成(或按 Ctrl+C)", self._interrupt), + "/clear": ("清空聊天区", self._clear), + "/status": ("显示连接状态", self._status), + "/help": ("显示帮助", self._help), + "/quit": ("退出 BotOps", self._quit), + } + + async def execute(self, line: str) -> bool: + """是本地命令则执行并返回 True;否则返回 False(调用方应透传服务器)。""" + cmd = line.split()[0].lower() + entry = self._commands.get(cmd) + if entry is None: + return False + await entry[1](line) + return True + + async def _connect(self, line: str): + parts = line.split() + if len(parts) < 2: + await self.app.notify_system("用法: /connect ws://host:port", level="error") + return + await self.app.connect_to(parts[1]) + + async def _disconnect(self, _line: str): + await self.app.disconnect() + + async def _interrupt(self, _line: str): + await self.app.send_interrupt() + + async def _clear(self, _line: str): + await self.app.clear_chat() + + async def _status(self, _line: str): + if self.app.ws.connected: + await self.app.notify_system(f"✅ 已连接: {self.app.ws.uri}") + else: + await self.app.notify_system("○ 未连接") + + async def _help(self, _line: str): + lines = ["可用命令:"] + [f" {k:<14} {v[0]}" for k, v in self._commands.items()] + lines.append(" 其余 / 命令将透传给 aibox 服务器") + await self.app.notify_system("\n".join(lines)) + + async def _quit(self, _line: str): + await self.app.ws.disconnect() + self.app.exit() diff --git a/BotOps/requirements.txt b/BotOps/requirements.txt new file mode 100644 index 0000000..f89aaf5 --- /dev/null +++ b/BotOps/requirements.txt @@ -0,0 +1,2 @@ +textual>=0.60.0 +websockets>=11.0 diff --git a/BotOps/widgets/__init__.py b/BotOps/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/BotOps/widgets/__pycache__/__init__.cpython-312.pyc b/BotOps/widgets/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d10886c249b308805ea18f38a26856a5ea9c672 GIT binary patch literal 116 zcmX@j%ge<81X24kvOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!5-!h7Nlz^)){l?R p%*!l^kJl@xyv1RYo1apelWJGQ3RJ@g#Kj=SM`lJw#v*1Q3jpCP7X$zR literal 0 HcmV?d00001 diff --git a/BotOps/widgets/__pycache__/chat_view.cpython-312.pyc b/BotOps/widgets/__pycache__/chat_view.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5501fc8416f2d3406d1d4f923c49d4dd0e25c9b GIT binary patch literal 4469 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 u5_*jBUG)Ax(VBmu)d_gqr2ZGp6QRri literal 0 HcmV?d00001 diff --git a/BotOps/widgets/__pycache__/status_bar.cpython-312.pyc b/BotOps/widgets/__pycache__/status_bar.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b41b6597370ca1e4617eac7b9945acee4e878eb GIT binary patch literal 978 zcmZ8gO-vI(6rTOrXwpvIUO4+Nvpq#V4JkWJYc5K_80yBnohlA=8n zj8YGVMZlDUNtJ^?2Q|iscQ0@-p_6zKTjeCCcrhNF*{w)?hxfksy_xrBzS;dyTU!ID z^}f40@|y!-gC&1Ws)EXK3T6QWF#tKpC%92Q#&duN7XTD80E&oT7W}m_VO)yHb~nvR zZ|-m2Dtw)OSx!%u^E2PyKKQosuzdHC&!`=0dEriJdZ{#5C@nlIEv6~6IhQF-=fA8h zl_#HWq;GDro)M17SFE@-tn2;=-v_EmrImtNfIy6+=i{LaIV3D zN{%!SpzMg$2fT_xE5UiNC{M@^j}=FTH4aa6K2-|<q@U)_-1~I!eX3}DXIwC;BB9d+-3`|6eie$2y>f5iT>%(veS!S2%39Ajn zap%}L37W{#7===eJCb+qkZgp>o-z%J&FU1&qpVOoc=TSN(4Ljn8}{Z-<&L=x?g_Wu zwVt0@y8OOlwJo|@*P9iKp*`#2`rLrq=t{XuYvIG$o?@t;QOG^-p3e2Ih1;?{>q>Yg zG!@GAd{hop=ok=<@C4xf6h&mBXj(EpiZqSXXxgX&?F7SNO}l2t6V-}1U>FG^m=?zS z=&r{AH_?C_nPb0%=NK>#QI`QfctvW-pWFg86m&1VHn&;kZ)8iO5s?Hm4H`NfSI4BJ z^E#^nV)m|nYu54KIHH{O&v*_Wp#}Cen9bB}2^_~2!NpIYbz2T{EjjropuyYt2eC&M AV*mgE literal 0 HcmV?d00001 diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py new file mode 100644 index 0000000..59b8517 --- /dev/null +++ b/BotOps/widgets/chat_view.py @@ -0,0 +1,69 @@ +"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。""" +from textual.containers import VerticalScroll +from textual.widgets import Markdown, Static + + +def _delta(prev: str, new: str) -> str: + """计算累计文本的增量(服务端 chunk 为累计全文;前缀失配时取最长公共前缀后的部分)。""" + if new.startswith(prev): + return new[len(prev):] + i = 0 + for a, b in zip(prev, new): + if a != b: + break + i += 1 + return new[i:] + + +class ChatView(VerticalScroll): + """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._ai_widget: Markdown | None = None + self._ai_prev = "" + + @property + def ai_text(self) -> str: + return self._ai_prev + + async def add_user(self, text: str): + await self.mount(Static(f"👤 你: {text}", classes="msg-user", markup=False)) + + async def add_system(self, text: str, css_class: str = "msg-system"): + await self.mount(Static(text, classes=css_class, markup=False)) + + async def start_ai(self): + """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。""" + if self._ai_widget is not None: + return + self._ai_prev = "" + self._ai_widget = Markdown("", classes="msg-ai") + await self.mount(self._ai_widget) + self.scroll_end(animate=False) + + async def append_ai(self, full_text: str): + """以累计全文增量渲染(服务端 chunk 为累计文本)。""" + if self._ai_widget is None: + await self.start_ai() + d = _delta(self._ai_prev, full_text) + if d: + self._ai_widget.append(d) + self._ai_prev = full_text + self.scroll_end(animate=False) + + async def finish_ai(self, full_text: str = ""): + """收尾:用最终全文对齐一次(防中途丢帧),随后关闭本条 AI 消息。""" + if self._ai_widget is None and full_text: + await self.start_ai() + if full_text and full_text != self._ai_prev: + await self.append_ai(full_text) + self._ai_widget = None + self._ai_prev = "" + self.scroll_end(animate=False) + + async def reset(self): + """清空聊天区(/clear)。""" + self._ai_widget = None + self._ai_prev = "" + await self.remove_children() diff --git a/BotOps/widgets/status_bar.py b/BotOps/widgets/status_bar.py new file mode 100644 index 0000000..04ddd2a --- /dev/null +++ b/BotOps/widgets/status_bar.py @@ -0,0 +1,14 @@ +"""底部状态栏:连接状态 + 服务器地址 + 附加信息。""" +from textual.widgets import Static + + +class StatusBar(Static): + def set_state(self, connected: bool, uri: str = "", extra: str = ""): + dot = "●" if connected else "○" + color = "green" if connected else "red" + text = f"[{color}]{dot}[/] {'已连接' if connected else '未连接'}" + if uri: + text += f" {uri}" + if extra: + text += f" | {extra}" + self.update(text) diff --git a/BotOps/ws_client.py b/BotOps/ws_client.py new file mode 100644 index 0000000..01b3d83 --- /dev/null +++ b/BotOps/ws_client.py @@ -0,0 +1,146 @@ +"""BotOps WebSocket 协议层(提炼自 aibox/client.py,增加状态回调与自动重连)。 + +协议:JSON 消息。 + 发送:{"type": "message", "content": ...} + {"type": "command", "command": "/..."} + {"type": "interrupt", "content": ...} + 接收:chunk / complete / dialogue_ended / task_process / task_status / task_error / + interrupt_ack / error / info / command_result / connection_ack / notification + +说明:服务端 chunk 为**累计全文**(与 aibox/client.py 前缀匹配逻辑一致)。 +""" +import asyncio +import json +from typing import Awaitable, Callable, Optional + +import websockets + +MessageHandler = Callable[[dict], Awaitable[None]] +StateHandler = Callable[[str, str], Awaitable[None]] # (state, detail) + + +class BotWSClient: + """aibox 协议 WebSocket 客户端:断线指数退避自动重连,手动断开则抑制。""" + + def __init__(self, uri: str): + self.uri = uri + self.ws = None + self.connected = False + self._manual_disconnect = False + self._listen_task: Optional[asyncio.Task] = None + self._reconnect_task: Optional[asyncio.Task] = None + self.on_message: Optional[MessageHandler] = None + self.on_state: Optional[StateHandler] = None + + async def _emit_state(self, state: str, detail: str = ""): + if self.on_state: + await self.on_state(state, detail) + + async def connect(self) -> bool: + """连接并开始监听;成功返回 True。""" + await self._close_ws() + try: + self.ws = await websockets.connect(self.uri) + except Exception as e: + await self._emit_state("error", f"连接失败: {e}") + return False + self.connected = True + self._manual_disconnect = False + self._listen_task = asyncio.create_task(self._listen()) + await self._emit_state("connected", self.uri) + return True + + async def disconnect(self): + """手动断开(抑制自动重连)。""" + self._manual_disconnect = True + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() + self.connected = False + await self._close_ws() + if self._listen_task and not self._listen_task.done(): + self._listen_task.cancel() + await self._emit_state("disconnected", "已手动断开") + + async def _close_ws(self): + if self.ws is not None: + try: + await self.ws.close() + except Exception: + pass + self.ws = None + + async def send_message(self, text: str) -> bool: + """发送用户消息(/ 开头按 command 发送,与 aibox 协议一致)。""" + if not self.connected or not self.ws: + return False + data = ({"type": "command", "command": text} if text.startswith("/") + else {"type": "message", "content": text}) + try: + await self.ws.send(json.dumps(data, ensure_ascii=False)) + return True + except Exception as e: + await self._emit_state("error", f"发送失败: {e}") + return False + + async def send_interrupt(self) -> bool: + """发送中断信号(对应 client.py 的 Ctrl+C 行为)。""" + if not self.connected or not self.ws: + return False + try: + await self.ws.send(json.dumps( + {"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"}, + ensure_ascii=False)) + return True + except Exception: + return False + + async def _listen(self): + """接收循环:断线时在 finally 中触发自动重连。""" + try: + async for raw in self.ws: + try: + data = json.loads(raw) + except json.JSONDecodeError: + data = {"type": "chunk", "content": raw} + if self.on_message: + await self.on_message(data) + except websockets.exceptions.ConnectionClosed: + pass + except asyncio.CancelledError: + raise + except Exception as e: + await self._emit_state("error", f"接收错误: {e}") + finally: + was_connected = self.connected + self.connected = False + if was_connected: + await self._emit_state("disconnected", "连接已断开") + self._schedule_reconnect() + + def _schedule_reconnect(self): + if self._manual_disconnect: + return + if self._reconnect_task and not self._reconnect_task.done(): + return + self._reconnect_task = asyncio.create_task(self._reconnect_loop()) + + async def _reconnect_loop(self): + """指数退避 2s→30s 封顶,直到成功或手动断开。""" + delay = 2 + while not self._manual_disconnect and not self.connected: + await self._emit_state("reconnecting", f"{delay}s 后自动重连 {self.uri} …") + try: + await asyncio.sleep(delay) + except asyncio.CancelledError: + return + if self._manual_disconnect: + return + try: + self.ws = await websockets.connect(self.uri) + except Exception: + delay = min(delay * 2, 30) + continue + self.connected = True + self._listen_task = asyncio.create_task(self._listen()) + await self._emit_state("connected", self.uri) + return From c064caa3ecb7fbc82d3fecdc458e838ff6d4379a Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:41:34 +0800 Subject: [PATCH 13/33] =?UTF-8?q?chore:=20BotOps=20=E6=B7=BB=E5=8A=A0=20.g?= =?UTF-8?q?itignore=20=E5=B9=B6=E7=A7=BB=E9=99=A4=E8=AF=AF=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E7=9A=84=20=5F=5Fpycache=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotOps/.gitignore | 3 +++ BotOps/__pycache__/app.cpython-312.pyc | Bin 14011 -> 0 bytes BotOps/__pycache__/commands.cpython-312.pyc | Bin 5067 -> 0 bytes BotOps/__pycache__/ws_client.cpython-312.pyc | Bin 9315 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 116 -> 0 bytes .../__pycache__/chat_view.cpython-312.pyc | Bin 4469 -> 0 bytes .../__pycache__/status_bar.cpython-312.pyc | Bin 978 -> 0 bytes 7 files changed, 3 insertions(+) create mode 100644 BotOps/.gitignore delete mode 100644 BotOps/__pycache__/app.cpython-312.pyc delete mode 100644 BotOps/__pycache__/commands.cpython-312.pyc delete mode 100644 BotOps/__pycache__/ws_client.cpython-312.pyc delete mode 100644 BotOps/widgets/__pycache__/__init__.cpython-312.pyc delete mode 100644 BotOps/widgets/__pycache__/chat_view.cpython-312.pyc delete mode 100644 BotOps/widgets/__pycache__/status_bar.cpython-312.pyc diff --git a/BotOps/.gitignore b/BotOps/.gitignore new file mode 100644 index 0000000..9b5f630 --- /dev/null +++ b/BotOps/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +venv/ diff --git a/BotOps/__pycache__/app.cpython-312.pyc b/BotOps/__pycache__/app.cpython-312.pyc deleted file mode 100644 index dca1f4d4d0d6e633913b8d39896906927ee0ff50..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14011 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 diff --git a/BotOps/__pycache__/ws_client.cpython-312.pyc b/BotOps/__pycache__/ws_client.cpython-312.pyc deleted file mode 100644 index 14441e9afd67bd8c8138b1d6c79c17d6928146e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9315 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 diff --git a/BotOps/widgets/__pycache__/chat_view.cpython-312.pyc b/BotOps/widgets/__pycache__/chat_view.cpython-312.pyc deleted file mode 100644 index b5501fc8416f2d3406d1d4f923c49d4dd0e25c9b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4469 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 u5_*jBUG)Ax(VBmu)d_gqr2ZGp6QRri diff --git a/BotOps/widgets/__pycache__/status_bar.cpython-312.pyc b/BotOps/widgets/__pycache__/status_bar.cpython-312.pyc deleted file mode 100644 index 2b41b6597370ca1e4617eac7b9945acee4e878eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 978 zcmZ8gO-vI(6rTOrXwpvIUO4+Nvpq#V4JkWJYc5K_80yBnohlA=8n zj8YGVMZlDUNtJ^?2Q|iscQ0@-p_6zKTjeCCcrhNF*{w)?hxfksy_xrBzS;dyTU!ID z^}f40@|y!-gC&1Ws)EXK3T6QWF#tKpC%92Q#&duN7XTD80E&oT7W}m_VO)yHb~nvR zZ|-m2Dtw)OSx!%u^E2PyKKQosuzdHC&!`=0dEriJdZ{#5C@nlIEv6~6IhQF-=fA8h zl_#HWq;GDro)M17SFE@-tn2;=-v_EmrImtNfIy6+=i{LaIV3D zN{%!SpzMg$2fT_xE5UiNC{M@^j}=FTH4aa6K2-|<q@U)_-1~I!eX3}DXIwC;BB9d+-3`|6eie$2y>f5iT>%(veS!S2%39Ajn zap%}L37W{#7===eJCb+qkZgp>o-z%J&FU1&qpVOoc=TSN(4Ljn8}{Z-<&L=x?g_Wu zwVt0@y8OOlwJo|@*P9iKp*`#2`rLrq=t{XuYvIG$o?@t;QOG^-p3e2Ih1;?{>q>Yg zG!@GAd{hop=ok=<@C4xf6h&mBXj(EpiZqSXXxgX&?F7SNO}l2t6V-}1U>FG^m=?zS z=&r{AH_?C_nPb0%=NK>#QI`QfctvW-pWFg86m&1VHn&;kZ)8iO5s?Hm4H`NfSI4BJ z^E#^nV)m|nYu54KIHH{O&v*_Wp#}Cen9bB}2^_~2!NpIYbz2T{EjjropuyYt2eC&M AV*mgE From 422ca798dac91dd1e0a5cc4c98c1194a9fa7cd0c Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:44:24 +0800 Subject: [PATCH 14/33] =?UTF-8?q?chore:=20BotOps=20=E6=B7=BB=E5=8A=A0=20?= =?UTF-8?q?=5Fset.sh/=5Frun.sh=20=E5=90=AF=E5=8A=A8=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=EF=BC=88=E4=B8=8E=20aibox=20=E5=90=8C=E6=AC=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotOps/_run.sh | 4 ++++ BotOps/_set.sh | 5 +++++ 2 files changed, 9 insertions(+) create mode 100755 BotOps/_run.sh create mode 100755 BotOps/_set.sh diff --git a/BotOps/_run.sh b/BotOps/_run.sh new file mode 100755 index 0000000..07b7b90 --- /dev/null +++ b/BotOps/_run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +source venv/bin/activate +exec python3 app.py $@ diff --git a/BotOps/_set.sh b/BotOps/_set.sh new file mode 100755 index 0000000..c7a534e --- /dev/null +++ b/BotOps/_set.sh @@ -0,0 +1,5 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple From c93023bd5df1ea1e64991797392d952dca32f6e9 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:48:45 +0800 Subject: [PATCH 15/33] =?UTF-8?q?fix(BotOps):=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=8C=BA=E6=94=B9=E7=94=A8=20anchor=20=E9=94=9A=E5=AE=9A?= =?UTF-8?q?=E5=90=B8=E5=BA=95=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E6=97=B6=E6=BB=9A=E5=8A=A8=E6=9D=A1=E4=B8=8D=E8=B7=9F=E9=9A=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotOps/widgets/chat_view.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py index 59b8517..5b83857 100644 --- a/BotOps/widgets/chat_view.py +++ b/BotOps/widgets/chat_view.py @@ -16,7 +16,12 @@ def _delta(prev: str, new: str) -> str: class ChatView(VerticalScroll): - """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。""" + """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。 + + 滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长 + (流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚, + 下一条新消息再重新锚定。 + """ def __init__(self, **kwargs): super().__init__(**kwargs) @@ -27,11 +32,17 @@ class ChatView(VerticalScroll): def ai_text(self) -> str: return self._ai_prev + def _anchor_bottom(self): + """锚定吸底(需在布局刷新后执行,否则滚到的是旧位置)。""" + self.call_after_refresh(self.anchor) + async def add_user(self, text: str): await self.mount(Static(f"👤 你: {text}", classes="msg-user", markup=False)) + self._anchor_bottom() async def add_system(self, text: str, css_class: str = "msg-system"): await self.mount(Static(text, classes=css_class, markup=False)) + self._anchor_bottom() async def start_ai(self): """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。""" @@ -40,7 +51,7 @@ class ChatView(VerticalScroll): self._ai_prev = "" self._ai_widget = Markdown("", classes="msg-ai") await self.mount(self._ai_widget) - self.scroll_end(animate=False) + self._anchor_bottom() async def append_ai(self, full_text: str): """以累计全文增量渲染(服务端 chunk 为累计文本)。""" @@ -50,7 +61,8 @@ class ChatView(VerticalScroll): if d: self._ai_widget.append(d) self._ai_prev = full_text - self.scroll_end(animate=False) + # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次 + self.call_after_refresh(self.scroll_end, animate=False) async def finish_ai(self, full_text: str = ""): """收尾:用最终全文对齐一次(防中途丢帧),随后关闭本条 AI 消息。""" @@ -60,7 +72,7 @@ class ChatView(VerticalScroll): await self.append_ai(full_text) self._ai_widget = None self._ai_prev = "" - self.scroll_end(animate=False) + self.call_after_refresh(self.scroll_end, animate=False) async def reset(self): """清空聊天区(/clear)。""" From 8be62683dc133a78e06586d3329bc55acc1f092f Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:53:35 +0800 Subject: [PATCH 16/33] =?UTF-8?q?chore:=20BotOps=20=E6=B7=BB=E5=8A=A0=20?= =?UTF-8?q?=5Finst.sh=20=E5=8D=95=E6=96=87=E4=BB=B6=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=EF=BC=88nuitka=20--onefile=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotOps/_inst.sh | 11 +++++++++++ BotOps/requirements.txt | 1 + 2 files changed, 12 insertions(+) create mode 100755 BotOps/_inst.sh diff --git a/BotOps/_inst.sh b/BotOps/_inst.sh new file mode 100755 index 0000000..8cd264c --- /dev/null +++ b/BotOps/_inst.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# 编译 BotOps 为独立单文件(不运行):产出 ./botops.bin +# 用法: ./_inst.sh +cd "$(dirname "$0")" || exit +source venv/bin/activate +python3 -m nuitka --standalone --onefile \ + --assume-yes-for-downloads \ + --include-package-data=textual \ + --output-filename=botops.bin \ + app.py +echo "编译完成: ./botops.bin(运行: ./botops.bin [ws://host:port])" diff --git a/BotOps/requirements.txt b/BotOps/requirements.txt index f89aaf5..7616ac0 100644 --- a/BotOps/requirements.txt +++ b/BotOps/requirements.txt @@ -1,2 +1,3 @@ textual>=0.60.0 websockets>=11.0 +nuitka From 12e736d26494160db776fcca5f9246e77bee5a41 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 22:54:06 +0800 Subject: [PATCH 17/33] =?UTF-8?q?chore:=20BotOps=20.gitignore=20=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=20nuitka=20=E7=BC=96=E8=AF=91=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotOps/.gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/BotOps/.gitignore b/BotOps/.gitignore index 9b5f630..318ba3c 100644 --- a/BotOps/.gitignore +++ b/BotOps/.gitignore @@ -1,3 +1,9 @@ __pycache__/ *.pyc venv/ + +# nuitka 编译产物 +botops.bin +app.build/ +app.dist/ +app.onefile-build/ From 19d5b65625f3cb4cf3996fb995c3e9193e2273d5 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 23:16:17 +0800 Subject: [PATCH 18/33] =?UTF-8?q?fix(db):=20=E6=96=B0=E5=A2=9E=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E5=88=97=E7=B1=BB=E5=9E=8B=E9=80=82=E9=85=8D=E5=B1=82?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95=E7=BB=8F=20db=20?= =?UTF-8?q?=E5=A4=84=E7=90=86=20DATETIME=20=E5=88=97=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON 无日期类型,而 SQLite DATETIME 只接受 Python datetime 对象, 导致各服务对日期列的写入与 where 比较全部失败。_coerce_value 按反射 列类型把 ISO 8601 字符串转为 datetime/date/time(带时区偏移统一转 UTC),并支持 "$now" 哨兵取服务端当前时间;响应中日期时间改为一律 序列化为 ISO 8601(T 分隔)。黑盒测试补 3 个日期列场景。 --- README.md | 1 + db/README.md | 36 +++++++++++++- db/dbv2.py | 98 +++++++++++++++++++++++++++++++++++---- tests/blackbox/test_db.py | 43 +++++++++++++++++ 4 files changed, 167 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c5faffd..19d3ef6 100755 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ - 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。 - 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。 - 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。 +- **日期/时间列适配**:values 与 where 中的 DATETIME/DATE/TIME 列接受 ISO 8601 字符串(带时区偏移自动转 UTC)或哨兵值 `"$now"`(服务端当前 UTC);响应中的日期时间一律序列化为 ISO 8601(T 分隔)。 - **内置日志子模块**: - 启动时自动建 `Logs` 表(service / level / message / trace_id / extra + 4 个索引)。 - 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。 diff --git a/db/README.md b/db/README.md index 303817a..3f52b8e 100644 --- a/db/README.md +++ b/db/README.md @@ -78,7 +78,7 @@ { "success": true, "data": [ - {"id": 1, "username": "Alice"} + {"id": 1, "username": "Alice", "create_at": "2026-07-28T14:30:00"} ] } ``` @@ -91,6 +91,40 @@ {"success": false, "error": "错误描述"} ``` +## 日期/时间列的处理 + +JSON 没有日期类型,db 内置了类型适配层:按反射得到的列类型自动在 +ISO 8601 字符串与数据库 DATETIME / DATE / TIME 之间转换。 + +**写入(values / where 条件值)**: + +| 输入 | 说明 | +|------|------| +| `"2026-07-28T22:30:00"` | ISO 8601(T 分隔),推荐 | +| `"2026-07-28 22:30:00"` | 空格分隔同样接受(SQLite 原生格式) | +| `"2026-07-28T22:30:00+08:00"` | 带时区偏移:自动转 UTC 后存储 | +| `"2026-07-28T14:30:00Z"` | Z 后缀等同 +00:00 | +| `"$now"` | 哨兵值:服务端当前 UTC 时间(DATETIME/DATE 列可用) | + +存储统一为 UTC 朴素时间(无时区)。非法日期字符串会返回明确的错误: +`{"success": false, "error": "列 last_login 的日期时间值无法解析(需 ISO 8601 或 \"$now\"): ..."}` + +**读取(select 响应)**:日期时间值一律序列化为 ISO 8601(T 分隔), +如 `"2026-07-28T22:30:00"`,客户端可用 `datetime.fromisoformat()` 直接解析。 + +**注意**:`like` 运算符是字符串模式匹配,对日期列使用时不做转换。 + +```json +// 示例:登录后更新 last_login 为服务端当前时间 +{ + "_route": "execute", + "action": "update", + "table": "Users", + "values": {"last_login": "$now"}, + "where": {"username": "alice"} +} +``` + ## 使用示例(Python + pymsg) ```python diff --git a/db/dbv2.py b/db/dbv2.py index 00cd52b..e940015 100644 --- a/db/dbv2.py +++ b/db/dbv2.py @@ -18,18 +18,26 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。 支持的路由:: execute → 通用 CRUD(select / insert / update / delete) + +日期/时间列:: + + values 与 where 中的 DATETIME / DATE / TIME 列接受 ISO 8601 字符串 + (如 "2026-07-28T22:30:00",可带时区偏移,统一转 UTC 存储), + 或哨兵值 "$now"(服务端当前 UTC 时间)。 + 响应中的日期时间一律序列化为 ISO 8601(T 分隔)。 """ import json import os import time import traceback -from datetime import datetime, timezone, timedelta +from datetime import datetime, date, time as dt_time, timezone, timedelta from sqlalchemy import ( create_engine, MetaData, Table, select, insert, update, delete, func, and_, ) +from sqlalchemy import types as sa_types from sqlalchemy.exc import SQLAlchemyError # pymsg 导入 @@ -50,6 +58,16 @@ DEFAULT_RETENTION_DAYS = 30 DEFAULT_MAX_RECORDS = 1_000_000 DEFAULT_CLEANUP_INTERVAL = 3600 # 秒 +#: where/values 中的哨兵值:服务端当前 UTC 时间 +NOW_SENTINEL = "$now" + + +def _json_default(o): + """json.dumps 兜底序列化:日期时间一律 ISO 8601(T 分隔),其余 str()。""" + if isinstance(o, (datetime, date, dt_time)): + return o.isoformat() + return str(o) + # ═══════════════════════════════════════════════════════════════════════ # DBServer — 主服务类 @@ -113,7 +131,8 @@ class DBServer: resp["data"] = data if error is not None: resp["error"] = error - self._server.reply(sock, json.dumps(resp, ensure_ascii=False, default=str)) + self._server.reply(sock, json.dumps(resp, ensure_ascii=False, + default=_json_default)) # ── 表反射 --------------------------------------------------------- def _get_table(self, table_name): @@ -121,6 +140,54 @@ class DBServer: Table(table_name, self.metadata, autoload_with=self.engine) return self.metadata.tables[table_name] + # ── 日期/时间类型适配 ------------------------------------------------ + def _coerce_value(self, column, value): + """按反射列类型把 JSON 值适配为 SQLAlchemy 需要的 Python 对象。 + + JSON 没有日期类型,客户端只能传字符串;而 SQLite 的 DATETIME/DATE + 类型只接受 Python datetime/date 对象,字符串会被直接拒绝 + (TypeError: SQLite DateTime type only accepts Python datetime ...)。 + 这里统一把 ISO 8601 字符串转为对应对象;带时区偏移的一律转 UTC + 并去掉 tzinfo,与 SQLite 朴素存储保持一致。 + """ + if not isinstance(value, str): + return value + + col_type = column.type + if isinstance(col_type, sa_types.DateTime): + if value == NOW_SENTINEL: + return datetime.now(timezone.utc).replace(tzinfo=None) + text = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + dt = datetime.fromisoformat(text) + except ValueError: + raise ValueError( + f"列 {column.name} 的日期时间值无法解析" + f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}" + ) + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + if isinstance(col_type, sa_types.Date): + if value == NOW_SENTINEL: + return datetime.now(timezone.utc).date() + try: + return date.fromisoformat(value) + except ValueError: + raise ValueError( + f"列 {column.name} 的日期值无法解析" + f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}" + ) + if isinstance(col_type, sa_types.Time): + try: + return dt_time.fromisoformat(value) + except ValueError: + raise ValueError( + f"列 {column.name} 的时间值无法解析" + f"(需 ISO 8601): {value!r}" + ) + return value + def _build_where(self, table, conditions): if not conditions: return None @@ -129,23 +196,26 @@ class DBServer: column = table.columns[col] if isinstance(val, dict): if "eq" in val: - clauses.append(column == val["eq"]) + clauses.append(column == self._coerce_value(column, val["eq"])) if "ne" in val: - clauses.append(column != val["ne"]) + clauses.append(column != self._coerce_value(column, val["ne"])) if "gt" in val: - clauses.append(column > val["gt"]) + clauses.append(column > self._coerce_value(column, val["gt"])) if "lt" in val: - clauses.append(column < val["lt"]) + clauses.append(column < self._coerce_value(column, val["lt"])) if "ge" in val: - clauses.append(column >= val["ge"]) + clauses.append(column >= self._coerce_value(column, val["ge"])) if "le" in val: - clauses.append(column <= val["le"]) + clauses.append(column <= self._coerce_value(column, val["le"])) if "like" in val: + # like 是字符串模式匹配,不做日期转换 clauses.append(column.like(val["like"])) if "in" in val: - clauses.append(column.in_(val["in"])) + clauses.append(column.in_( + [self._coerce_value(column, v) for v in val["in"]] + )) else: - clauses.append(column == val) + clauses.append(column == self._coerce_value(column, val)) return and_(*clauses) # ═══════════════════════════════════════════════════════════════════ @@ -205,6 +275,10 @@ class DBServer: def _do_insert(self, conn, table, data): values = data.get("values", {}) + values = { + k: self._coerce_value(table.columns[k], v) + for k, v in values.items() + } stmt = insert(table).values(**values) res = conn.execute(stmt) conn.commit() @@ -212,6 +286,10 @@ class DBServer: def _do_update(self, conn, table, data): values = data.get("values", {}) + values = { + k: self._coerce_value(table.columns[k], v) + for k, v in values.items() + } where_cond = self._build_where(table, data.get("where")) stmt = update(table).values(**values) if where_cond is not None: diff --git a/tests/blackbox/test_db.py b/tests/blackbox/test_db.py index 4be4a96..0e8461f 100644 --- a/tests/blackbox/test_db.py +++ b/tests/blackbox/test_db.py @@ -6,6 +6,7 @@ db 黑盒用户模拟测试 — pymsg 协议 CRUD。 2. 通过 pymsg 协议查询 Logs 表 3. 通过 pymsg 协议带 where 条件查询 4. 无效路由被拒绝 + 5. 日期列 where(ISO 字符串 / $now 哨兵 / 非法值拒绝) 注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过 log_client.py 走 execute 路由 INSERT 写入 Logs 表。 @@ -101,6 +102,48 @@ def run(url: str = None): "pymsg-无效路由-拒绝", c, ) + # 场景 5: 日期列 where — ISO 8601 字符串(dbv2 日期适配层) + result = client.call({ + "_route": "execute", + "action": "select", + "table": "Users", + "columns": ["id", "username", "create_at"], + "where": {"create_at": {"lt": "2999-01-01T00:00:00"}}, + "limit": 1, + }) + assert_true( + result.get("success") is True, + "pymsg-日期列where-ISO字符串", c, + f"error={result.get('error', '')}", + ) + + # 场景 6: 日期列 where — $now 哨兵 + result = client.call({ + "_route": "execute", + "action": "select", + "table": "Users", + "columns": ["id", "username", "create_at"], + "where": {"create_at": {"lt": "$now"}}, + "limit": 1, + }) + assert_true( + result.get("success") is True, + "pymsg-日期列where-$now哨兵", c, + f"error={result.get('error', '')}", + ) + + # 场景 7: 日期列 where — 非法日期字符串应报错 + result = client.call({ + "_route": "execute", + "action": "select", + "table": "Users", + "where": {"create_at": {"lt": "not-a-date"}}, + }) + assert_true( + result.get("success") is False, + "pymsg-日期列where-非法值-拒绝", c, + ) + # ------------------------------------------------------------------ # 汇总 # ------------------------------------------------------------------ From b07dbf2da5f894f5a533e380de2721770763f94d Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 23:31:20 +0800 Subject: [PATCH 19/33] =?UTF-8?q?feat(gateway):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E5=90=8E=E6=9B=B4=E6=96=B0=20Users.last=5Flo?= =?UTF-8?q?gin=EF=BC=88v0.5.1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启用 handleLogin 中被注释的 last_login 更新:经 DbC::call_msg 以 "$now" 哨兵(db 日期适配层转为服务端 UTC 时间)写入,失败按惯例 THROW_EX_CUSTOM 抛 500 由 wrapper 统一处理。README 版本节同步 (原仍停留在 0.3.0-security)。 --- README.md | 2 +- gateway/README.md | 6 ++++-- gateway/src/main.cpp | 32 ++++++++++++++++---------------- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 19d3ef6..f6b426c 100755 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ - 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。 - 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。 - **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),常数时间比较防 timing attack。 -- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite。 +- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite;登录成功即更新 `Users.last_login`(`"$now"` 哨兵)。 - **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期,验签通过才放行;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效。 - **Cookie 认证**:登录成功同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie;代理时无 `Authorization` 头则回退读 Cookie 验签,并向下游注入 `Authorization: Bearer`(下游零改动);`POST /logout` 清除 Cookie。公开目标(`Proxy.default`)不做凭证校验,避免旧 Cookie 影响公开页面。 - **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用;`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由。 diff --git a/gateway/README.md b/gateway/README.md index 44504e1..c4d999c 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -226,12 +226,14 @@ gateway/ ## 版本 -`0.3.0-security`(见 [src/main.cpp:27](src/main.cpp#L27))。启动时打印: +`0.5.1`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印: ``` -Gateway 0.3.0-security started (PBKDF2 + CAPTCHA + RateLimit) +Gateway 0.5.1 started ``` +近期变更:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。 + ## 依赖 - C++20(`g++`) diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 0e88a60..fbf8ce2 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -26,7 +26,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.5.0"; +constexpr const char *_VER = "0.5.1"; ////////////////////////////////// #include @@ -811,21 +811,21 @@ http::response handleLogin( //stmt_upd.bind_text(1, username); //stmt_upd.step(); // - // 新版调用db模块 无法通过DB上传时间戳目前 - //json::value jv_upd = { - // {"action", "update"}, - // {"table", "Users"}, - // {"values", json::object{ - // {"last_login", LocalTime::getCurrent()}, - // }}, - // {"where", json::object{ - // {"username", username}, - // }} - //}; - //auto jv_db_r = DbC::call_http(jv_upd); - //auto obj_r = jv_db_r.as_object(); - //auto success = obj_r["success"].as_bool(); - //if (!success) THROW_EX_CUSTOM(HttpException, 2102, json::serialize(jv_db_r)+"Login (Update Step) Failed", 500, "/login"); + // 新版调用db模块:values 中的 "$now" 哨兵由 db 日期适配层替换为服务端当前 UTC 时间 + //(此前因 db 无法处理日期列而注释,dbv2 增加 _coerce_value 后可用)。 + json::value jv_upd = { + {"action", "update"}, + {"table", "Users"}, + {"values", json::object{ + {"last_login", "$now"}, + }}, + {"where", json::object{ + {"username", username}, + }} + }; + auto jv_upd_r = DbC::call_msg(jv_upd); + if (!jv_upd_r.as_object()["success"].as_bool()) + THROW_EX_CUSTOM(HttpException, 2102, "Login (Update Step) Failed", 500, "/login"); /* 生成令牌 */ From c2e9cce6a75f4f474c9aeefc37378521f976c62b Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 23:38:16 +0800 Subject: [PATCH 20/33] =?UTF-8?q?fix(BotOps):=20=5Finst.sh=20=E8=A1=A5=20-?= =?UTF-8?q?-include-package=3Dwebsockets/textual=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=89=93=E5=8C=85=E5=90=8E=E6=89=BE=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=20websockets.asyncio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotOps/_inst.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BotOps/_inst.sh b/BotOps/_inst.sh index 8cd264c..1ed1c95 100755 --- a/BotOps/_inst.sh +++ b/BotOps/_inst.sh @@ -5,6 +5,8 @@ cd "$(dirname "$0")" || exit source venv/bin/activate python3 -m nuitka --standalone --onefile \ --assume-yes-for-downloads \ + --include-package=websockets \ + --include-package=textual \ --include-package-data=textual \ --output-filename=botops.bin \ app.py From 6a0551ab3457436b559fc7a8c3e1c12203c07213 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 00:21:13 +0800 Subject: [PATCH 21/33] =?UTF-8?q?fix(gateway):=20=E9=99=90=E6=B5=81?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81=E7=BB=9F=E4=B8=80429=E3=80=81?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E7=94=A8=E6=88=B7=E4=B8=8D=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E6=94=B9401=E5=B9=B6=E6=8B=89=E5=B9=B3=E6=97=B6=E5=BA=8F?= =?UTF-8?q?=E3=80=81pymsg=E8=B6=85=E6=97=B6=E5=B8=B8=E9=87=8F=E5=8C=96?= =?UTF-8?q?=EF=BC=88v0.5.2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 三处限流错误码统一为 429(login 400→429 并修 attampts 拼写;register/send_code 403→429) - 登录用户不存在不再兜底 500:空结果统一走 401,dummy PBKDF2 校验拉平时序防用户枚举 - db 查询失败消息误名修正:Login (Update Step) Failed → (Query Step) - 超时常量化:DbC::DEFAULT_TIMEOUT=5.0;_TIMEOUT_CAPTCHA/_SMTP_VERIFY=5.0、_SMTP_SEND=20.0 - 文档:gateway/README 数据库节 email→username 重写、建表归 db/create_sqlite3.sql;根 README 版本号与自动建表描述同步 --- README.md | 4 ++-- gateway/README.md | 21 ++++++++++++--------- gateway/inc/DbC.hpp | 6 +++++- gateway/src/main.cpp | 42 +++++++++++++++++++++++++++++++----------- 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f6b426c..79bf3fa 100755 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ - 极客操作:`kill -HUP ` 热加载规则;`waf -list` 列规则;`waf -test ` 校验语法;`waf -check ""` 测试单条输入。 - 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。 -### gateway(应用层网关 · v0.4.3) +### gateway(应用层网关 · v0.5.2) - 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。 - 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。 @@ -79,7 +79,7 @@ - **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用;`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由。 - **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。 - **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中(`closed` 仅记录用途,实际隔离靠"不在 opened 中即拒绝"),缺省走 `Proxy.default`(不需 Token,服务前端静态资源)。 -- **自动建表**:启动时若 `Users` 表不存在则自动创建,首位注册者即为管理员。 +- **用户表**:`Users` 表由 [db/create_sqlite3.sql](db/create_sqlite3.sql) 建立(gateway 不再自动建表),无默认管理员账号,首位注册者即为管理员。 - 详见 [gateway/README.md](gateway/README.md)。 ### static(前端托管服务) diff --git a/gateway/README.md b/gateway/README.md index c4d999c..dee5208 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -10,7 +10,7 @@ UniServe Framework 的应用层网关,C++20 实现。 - **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 - **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 - **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(60 秒有效);注册时校验,一次性消费。 -- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入)。 +- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 - **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。 ## 安全特性 @@ -80,7 +80,7 @@ UniServe Framework 的应用层网关,C++20 实现。 校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。 -错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`403`(速率超限)、`500`(数据库异常)。 +错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。 ### `POST /register/send_code` @@ -90,7 +90,7 @@ UniServe Framework 的应用层网关,C++20 实现。 { "email": "user@example.com" } ``` -限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`403`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。 +限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`429`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。 ### `GET/POST *`(反向代理) @@ -112,18 +112,18 @@ Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default ## 数据库 -SQLite3,表 `Users`: +用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表): | 字段 | 类型 | 说明 | |------|------|------| | id | INTEGER PK AUTOINCREMENT | | -| email | TEXT UNIQUE NOT NULL | 登录账号 | +| username | TEXT UNIQUE NOT NULL | 登录账号(邮箱格式) | | password_hash | TEXT NOT NULL | `pbkdf2:100000::` | | create_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | | last_login | DATETIME | 每次登录更新 | | is_active | BOOLEAN DEFAULT 1 | | -启动时若 `Users` 表不存在则自动建表(含 `idx_users_email` 索引),无默认管理员账号——首位注册者即为管理员。 +gateway 不直连数据库(早期直连本地 sqlite 的代码已注释保留),所有读写经 `DbC::call_msg` 调 db 的 `execute` 路由。无默认管理员账号——首位注册者即为管理员。 ## 配置 @@ -226,13 +226,16 @@ gateway/ ## 版本 -`0.5.1`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印: +`0.5.2`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印: ``` -Gateway 0.5.1 started +Gateway 0.5.2 started ``` -近期变更:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。 +近期变更: + +- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。 +- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。 ## 依赖 diff --git a/gateway/inc/DbC.hpp b/gateway/inc/DbC.hpp index 7ca99c9..00ded14 100644 --- a/gateway/inc/DbC.hpp +++ b/gateway/inc/DbC.hpp @@ -6,6 +6,10 @@ namespace qing { namespace json = boost::json; class DbC { public: + /* 本机 db 轻量查询的默认超时(秒);慢操作由调用方显式放宽。 + * smtp/captcha 等其他下游的超时常量见 main.cpp。 */ + static constexpr double DEFAULT_TIMEOUT = 5.0; + // 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。 // 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。 static json::value call_http(json::value& params) { @@ -25,7 +29,7 @@ namespace qing { // {"success":..., "data":[...]}。 // 响应格式与 call_http 兼容,调用方可直接替换。 static json::value call_msg(const json::value& params, - double timeout = 5.0) { + 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"); diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index fbf8ce2..9bb713e 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -26,7 +26,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.5.1"; +constexpr const char *_VER = "0.5.2"; ////////////////////////////////// #include @@ -333,6 +333,14 @@ static RateLimiter _LOGIN_RATE; static RateLimiter _REGISTER_RATE; static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流 +/* pymsg 下游调用超时(秒): + * captcha / smtp verify_email 均为本机内存操作,5s 足够; + * smtp send_email 需完成一次完整外部 SMTP 会话(握手 + TLS + 投递),放宽到 20s。 + * db 调用的默认超时见 DbC.hpp 的 DbC::DEFAULT_TIMEOUT。 */ +static constexpr double _TIMEOUT_CAPTCHA = 5.0; +static constexpr double _TIMEOUT_SMTP_VERIFY = 5.0; +static constexpr double _TIMEOUT_SMTP_SEND = 20.0; + /* CAPTCHA store: token -> answer (in-memory, with expiry) */ #include #include @@ -385,7 +393,7 @@ bool verify_captcha_remote(const std::string& token, const std::string& answer) {"token", token}, {"answer", answer} }; - std::string resp = client.call(json::serialize(jv_req), 5.0); + std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_CAPTCHA); auto jv = json::parse(resp); auto& obj = jv.as_object(); if (!obj.at("success").as_bool()) return false; @@ -405,7 +413,7 @@ bool verify_email_code_remote(const std::string& email, const std::string& code) {"email", email}, {"code", code} }; - std::string resp = client.call(json::serialize(jv_req), 5.0); + std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_VERIFY); auto jv = json::parse(resp); auto& obj = jv.as_object(); if (!obj.at("success").as_bool()) return false; @@ -728,7 +736,7 @@ http::response handleLogin( client_ip = "direct"; if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300)) - THROW_EX_CUSTOM(HttpException, 2001, "Too many login attampts. Please try again later.", 400, "/login"); + THROW_EX_CUSTOM(HttpException, 2001, "Too many login attempts. Please try again later.", 429, "/login"); // parse json req body json::value jv; @@ -774,10 +782,14 @@ http::response handleLogin( auto jv_db_r = DbC::call_msg(jv_select); auto obj_r = jv_db_r.as_object(); auto success = obj_r["success"].as_bool(); - if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login (Update Step) Failed", 500, "/login"); + if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login (Query Step) Failed", 500, "/login"); auto obj_rd = obj_r.at("data").as_array(); - auto obj_ru = obj_rd.at(0).as_object(); - stored_hash = obj_ru.at("password_hash").as_string(); + /* 用户不存在时 data 为空数组:直接 at(0) 会抛异常被 wrapper 兜底成 500, + * 此处保持 stored_hash 为空,统一走下面的 401 分支 */ + if (!obj_rd.empty()) { + auto obj_ru = obj_rd.at(0).as_object(); + stored_hash = obj_ru.at("password_hash").as_string(); + } } @@ -802,6 +814,14 @@ http::response handleLogin( // std::cout << "Migrated password hash for " << username << " to PBKDF2" << std::endl; //} } + else { + /* 用户不存在:执行一次等价的 dummy PBKDF2 校验拉平响应时序, + * 防止通过响应时间差异枚举有效用户名(用户不存在/密码错误一律 401) */ + static const std::string dummy_hash = + "pbkdf2:100000:00000000000000000000000000000000:" + "0000000000000000000000000000000000000000000000000000000000000000"; + PBKDF2Hasher::verify_password(password, dummy_hash); + } if (!auth_ok) THROW_EX_CUSTOM(HttpException, 2101, "Invalid Username or Password", 401, "/login"); @@ -899,7 +919,7 @@ http::response handleCaptchaImage( pmc::net::MsgClient client("127.0.0.1", 8009, "gateway"); std::string resp; try { - resp = client.call(R"({"_route":"image"})", 5.0); + resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA); } catch (const std::exception& e) { THROW_EX_CUSTOM(HttpException, 2400, "Captcha service unavailable", 502, "/captcha/image"); } @@ -947,7 +967,7 @@ http::response handleRegister( client_ip = std::string(it_host->value()); if (!_REGISTER_RATE.check_and_record(client_ip, 3, 600)) THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.", - 403, "/register"); + 429, "/register"); // parse the req body json::value jv; @@ -1041,7 +1061,7 @@ http::response handleSendEmailCode( client_ip = std::string(it_host->value()); if (!_EMAIL_CODE_RATE.check_and_record(client_ip, 5, 600)) THROW_EX_CUSTOM(HttpException, 3101, "Too many code requests. Please try again later.", - 403, "/register/send_code"); + 429, "/register/send_code"); // parse the req body json::value jv; @@ -1072,7 +1092,7 @@ http::response handleSendEmailCode( {"_route", "send_email"}, {"email", email} }; - std::string resp = client.call(json::serialize(jv_req), 20.0); + std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_SEND); auto jv_r = json::parse(resp); auto& obj_r = jv_r.as_object(); if (obj_r.at("success").as_bool()) From f0cdaacd854ac4f4d8a24e81b0243f73edae3ef9 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 08:34:14 +0800 Subject: [PATCH 22/33] =?UTF-8?q?feat(gateway):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=8E=A5=E5=85=A5=20db=20=E9=9B=86=E4=B8=AD?= =?UTF-8?q?=E5=BC=8F=E6=97=A5=E5=BF=97=EF=BC=88=E6=96=B0=E5=A2=9E=20C++=20?= =?UTF-8?q?=E7=89=88=20LogC=EF=BC=8Cpymsg=20=E6=8E=A8=E9=80=81=20Logs=20?= =?UTF-8?q?=E8=A1=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 gateway/inc/LogC.hpp:异步批量日志客户端(内存队列+后台线程,50条/2秒 flush), 对齐 Python 版 log_client.py,fail-safe 设计(db 不可用静默丢弃) - main.cpp 全部日志输出迁移:wrapperHttp 双异常分支(>=500 ERROR / 其余 WARNING)、 启动/关闭、登录成功、注册成功、验证码邮件发送成功、代理白名单拒绝 - Makefile 增加 -pthread(LogC 使用 std::thread) --- gateway/Makefile | 4 +- gateway/inc/LogC.hpp | 248 +++++++++++++++++++++++++++++++++++++++++++ gateway/src/main.cpp | 28 ++++- 3 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 gateway/inc/LogC.hpp diff --git a/gateway/Makefile b/gateway/Makefile index a6c49ef..e144517 100644 --- a/gateway/Makefile +++ b/gateway/Makefile @@ -4,8 +4,8 @@ INSTL_DIR := /usr/local/bin CPP := g++ INCS := -I inc/ MY_LIBS := -L/usr/lib -lus_http -lus_crypt -LIBS := -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options -CPPFLAGS := --std=c++20 -pipe # -Wall -Werror +LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options +CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror BUILD_DIR = build $(shell mkdir -p $(BUILD_DIR)) diff --git a/gateway/inc/LogC.hpp b/gateway/inc/LogC.hpp new file mode 100644 index 0000000..a685d9e --- /dev/null +++ b/gateway/inc/LogC.hpp @@ -0,0 +1,248 @@ +#pragma once + +/** LogC — gateway 统一日志客户端(C++ 版,对应各 Python 服务的 log_client.py) + * + * 设计要点(与 Python 版对齐): + * - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 + * - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 + * - fail-safe:db 服务不可用时静默丢弃(仅 stderr 提示),绝不影响业务 + * - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush + * + * 用法: + * #include "LogC.hpp" + * + * qing::LogC::instance().info("gateway", "启动完成"); + * qing::LogC::instance().error("gateway", "数据库连接失败", + * std::nullopt, R"({"errno":2002})"); + * + * // 进程退出前: + * qing::LogC::instance().shutdown(); // 停止后台线程并 flush 剩余日志(幂等) + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "Msg.hpp" + +namespace qing { + +namespace json = boost::json; + +class LogC { +public: + struct Entry { + std::string service; + std::string level; + std::string message; + std::optional trace_id; + std::optional extra; // 已序列化的 JSON 字符串(存 Logs.extra TEXT) + }; + + static LogC& instance() { + static LogC inst; + return inst; + } + + // ---- 公开接口 -------------------------------------------------------- + + // 推送一条日志(非阻塞,立即返回) + void push(const std::string& service, const std::string& level, + const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + { + std::lock_guard lock(_mtx); + _queue.push_back(Entry{service, level, message, + std::move(trace_id), std::move(extra)}); + } + _cv.notify_one(); + } + + void debug(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "DEBUG", message, std::move(trace_id), std::move(extra)); + } + void info(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "INFO", message, std::move(trace_id), std::move(extra)); + } + void warning(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "WARNING", message, std::move(trace_id), std::move(extra)); + } + void error(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "ERROR", message, std::move(trace_id), std::move(extra)); + } + void critical(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "CRITICAL", message, std::move(trace_id), std::move(extra)); + } + + // 手动 flush:通知后台线程立即发送当前积攒的批次,并短暂等待其完成 + void flush() { + _force_flush.store(true); + _cv.notify_all(); + for (int i = 0; i < 200 && _force_flush.load(); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + + // 停止后台线程并 flush 剩余日志(进程退出前调用,幂等) + void shutdown() { + { + std::lock_guard lock(_mtx); + if (_stop) return; + _stop = true; + } + _cv.notify_all(); + if (_thread.joinable()) _thread.join(); + } + + // ---- 配置(构造后、首次推送前可改)------------------------------------ + void set_endpoint(const std::string& addr, int port) { + _db_addr = addr; + _db_port = port; + } + +private: + LogC() : _thread(&LogC::_flush_loop, this) {} + ~LogC() { shutdown(); } + + LogC(const LogC&) = delete; + LogC& operator=(const LogC&) = delete; + + // 后台线程:积攒到 batch_size 或超时 flush_interval 时发送 + void _flush_loop() { + std::vector batch; + auto last_flush = std::chrono::steady_clock::now(); + + for (;;) { + { + std::unique_lock lock(_mtx); + // 最多 0.5s 轮询一次(对齐 Python 版 chk_timeout 上限) + _cv.wait_for(lock, std::chrono::milliseconds(500), + [&] { return _stop || !_queue.empty(); }); + while (!_queue.empty() && batch.size() < _BATCH_SIZE) { + batch.push_back(std::move(_queue.front())); + _queue.pop_front(); + } + } + + const auto now = std::chrono::steady_clock::now(); + const bool due = std::chrono::duration(now - last_flush).count() + >= _FLUSH_INTERVAL; + if (!batch.empty() && (batch.size() >= _BATCH_SIZE || due + || _stop || _force_flush.load())) { + _send(batch); + batch.clear(); + last_flush = now; + _force_flush.store(false); + } + + if (_stop) { + // 退出前 flush 队列剩余 + { + std::unique_lock lock(_mtx); + while (!_queue.empty()) { + batch.push_back(std::move(_queue.front())); + _queue.pop_front(); + } + } + if (!batch.empty()) _send(batch); + _force_flush.store(false); + return; + } + } + } + + // 通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表 + void _send(const std::vector& batch) { + const std::string now = _utc_now_iso(); // 批次共享同一时间戳(对齐 Python 版) + try { + pmc::net::MsgClient client(_db_addr, _db_port, "gateway-log"); + for (const auto& e : batch) { + json::object values; + values["ts"] = now; + values["service"] = e.service; + values["level"] = e.level; + values["message"] = e.message; + values["trace_id"] = e.trace_id ? json::value(*e.trace_id) + : json::value(nullptr); + values["extra"] = e.extra ? json::value(*e.extra) + : json::value(nullptr); + + json::object payload; + payload["_route"] = "execute"; + payload["action"] = "insert"; + payload["table"] = "Logs"; + payload["values"] = std::move(values); + + const std::string resp = client.call( + json::serialize(payload), _TIMEOUT); + auto obj = json::parse(resp).as_object(); + if (!obj.at("success").as_bool()) { + std::string err = "unknown"; + if (auto* ev = obj.if_contains("error")) { + if (ev->is_string()) err = ev->as_string().c_str(); + } + std::cerr << "[LogC] INSERT 失败: " << err << std::endl; + } + } + } catch (const std::exception& e) { + // fail-safe:db 不可用时静默丢弃,绝不影响业务 + std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; + } + } + + // UTC ISO8601 时间戳(对齐 Python datetime.now(timezone.utc).isoformat()) + // 形如 2026-07-29T08:30:00.123456+00:00,字典序即时间序,便于 db 按 ts 清理 + static std::string _utc_now_iso() { + const auto now = std::chrono::system_clock::now(); + const auto t = std::chrono::system_clock::to_time_t(now); + const auto us = std::chrono::duration_cast( + now.time_since_epoch()).count() % 1000000; + std::tm tm{}; + ::gmtime_r(&t, &tm); + char buf[40]; + std::snprintf(buf, sizeof buf, + "%04d-%02d-%02dT%02d:%02d:%02d.%06d+00:00", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast(us)); + return buf; + } + + // ---- 配置项 ----------------------------------------------------------- + std::string _db_addr = "127.0.0.1"; + int _db_port = 8005; + static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush + static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush + static constexpr double _TIMEOUT = 5.0; // socket 超时(秒) + + // ---- 状态 ------------------------------------------------------------- + std::deque _queue; + std::mutex _mtx; + std::condition_variable _cv; + bool _stop = false; + std::atomic _force_flush{false}; + std::thread _thread; +}; + +} // namespace qing diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 9bb713e..e6b1989 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -20,6 +20,7 @@ #include "LocalTime.hpp" #include "Conf.hpp" #include "DbC.hpp" +#include "LogC.hpp" using namespace pmc::net; using namespace qing; @@ -351,6 +352,9 @@ struct CaptchaEntry { static std::unordered_map _CAPTCHA_STORE; static std::mutex _CAPTCHA_MTX; +/* 集中式日志客户端:推送 gateway 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */ +static inline LogC& logs() { return LogC::instance(); } + std::string generate_captcha_text() { static const char chars[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; // thread_local:每个线程独立 rng 实例,避免多线程并发调用 dist(rng) 时的数据竞争 @@ -535,10 +539,14 @@ int main(int argc, char **argv) { std::cout << "Gateway " << _VER << " started" << std::endl; + logs().info("gateway", std::string("Gateway ") + _VER + " started"); /* 开始监听 */ _SERVE->run(); + /* 收到停止信号后的收尾:记录并 flush 剩余日志 */ + logs().info("gateway", "Gateway shutdown"); + logs().shutdown(); } //void db_init() { @@ -643,7 +651,14 @@ catch (const HttpException& e) { http::response res; res.version(req.version()); // 详细错误信息(e.what() 含源码路径/行号/C++ 模板类型)暂时以注释方式保留(同 handleLogin) - std::cerr << "[handleRegister HttpException] " << e.what() << std::endl; + std::cerr << "[wrapperHttp HttpException] " << e.what() << std::endl; + // 推送集中式日志:5xx 服务端错误记 ERROR,其余(400/401/403/429 等业务错误)记 WARNING + if (e.status() >= 500) + logs().error("gateway", e.what(), std::nullopt, + json::serialize(json::value{{"url", e.url()}, {"status", e.status()}})); + else + logs().warning("gateway", e.what(), std::nullopt, + json::serialize(json::value{{"url", e.url()}, {"status", e.status()}})); json::value jv_r = { {"success", false}, {"message", e.what()} @@ -667,6 +682,7 @@ catch (const std::exception& e) { // 透传到客户端会泄露 C++ 类型信息与内部路径(P0-3 遗漏点,已补全) // 排障:完整信息仍输出到服务端 stderr std::cerr << "[wrapperHttp std::exception] <" << ti.name() << "> " << e.what() << std::endl; + logs().error("gateway", std::string("<") + ti.name() + "> " + e.what()); json::value jv_r = { {"success", false}, /* {"message", std::string("<") + ti.name() + "> " + e.what()}, */ // 泄露源码信息,已禁用 @@ -879,6 +895,9 @@ http::response handleLogin( res.set("Set-Cookie", ck); } + logs().info("gateway", "登录成功: " + username, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + res.result(http::status::ok); res.body() = json::serialize(jv_rr); res.prepare_payload(); @@ -1037,6 +1056,9 @@ http::response handleRegister( if (!success) THROW_EX_CUSTOM(HttpException, 2202, "Registe Failed", 400, "/register"); auto jv_r = json::value{{"success", success}}; + logs().info("gateway", "注册成功: " + username, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + res.result(http::status::ok); res.body() = json::serialize(jv_r); res.prepare_payload(); @@ -1104,6 +1126,9 @@ http::response handleSendEmailCode( if (!sent) THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/register/send_code"); + logs().info("gateway", "验证码邮件发送成功: " + email, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + auto jv_r = json::value{{"success", true}}; res.result(http::status::ok); res.set(http::field::content_type, "application/json"); @@ -1226,6 +1251,7 @@ http::response handleProxy( } } if (!flag) { + logs().warning("gateway", "代理目标不在白名单: " + t_url); throw std::runtime_error("403"); } } From f9cf408c82cd291ce1a2a90a8873d892da225f01 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 09:10:09 +0800 Subject: [PATCH 23/33] =?UTF-8?q?static:=20=E5=88=87=E6=8D=A2=20fsys.py=20?= =?UTF-8?q?=E5=88=B0=20Waitress=20WSGI=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HTTP 场景使用 waitress.serve(threads=8) 替代 Flask 开发服务器 - SSL 场景回退 Flask 内置服务器(Waitress 不支持 TLS) - requirements.txt 新增 waitress 依赖 --- static/fsys.py | 5 ++++- static/requirements.txt | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/static/fsys.py b/static/fsys.py index bb8db56..4ced9a8 100644 --- a/static/fsys.py +++ b/static/fsys.py @@ -582,6 +582,9 @@ if __name__ == '__main__': print("\n") if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE): + # Waitress 不支持 TLS,SSL 场景回退到 Flask 内置服务器 + logger.warning("SSL enabled: falling back to Flask built-in server (Waitress has no TLS support)") app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False) else: - app.run(host=host, port=port, debug=False) + from waitress import serve + serve(app, host=host, port=port, threads=8) diff --git a/static/requirements.txt b/static/requirements.txt index 9535a30..f043a50 100644 --- a/static/requirements.txt +++ b/static/requirements.txt @@ -2,3 +2,4 @@ nuitka Flask>=2.3.0,<3.0.0 Flask-CORS>=4.0.0,<5.0.0 +waitress>=2.1.2,<4.0.0 From b86b089634319bd306e7a0775fccfdc47aa76a4e Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 09:32:30 +0800 Subject: [PATCH 24/33] =?UTF-8?q?fix(BotOps):=20=E4=BF=AE=E6=AD=A3=20aibox?= =?UTF-8?q?=20=E5=8D=8F=E8=AE=AE=E8=AF=AD=E4=B9=89=E9=94=99=E9=85=8D?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=E8=BE=93=E5=87=BA=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端 chunk 为增量片段(chunk_buffer 发送后即清零),原 _delta 按累计全文假设计算,相邻 chunk 前缀撞字时静默丢字符;改为直接拼接 - complete 携带完整全文,finish_ai 改为全文对齐(不一致时整体替换, 可自愈丢帧/乱序),修复结尾全文重复 append - 新增 line/reasoning/task_added/dialogue_reset/pong 消息路由: line(🔧 工具调用提示、工具输出)不再落入兜底污染聊天区,改投任务侧栏 - 发送失败时移除预建的空 AI 消息框并提示,避免空等回复 - 断线提示补充重连窗口期服务端输出丢失说明 - 同步修正 ws_client/README 中的协议描述 --- BotOps/README.md | 2 +- BotOps/app.py | 38 +++++++++++++++----- BotOps/widgets/chat_view.py | 71 +++++++++++++++++++++---------------- BotOps/ws_client.py | 8 +++-- 4 files changed, 76 insertions(+), 43 deletions(-) diff --git a/BotOps/README.md b/BotOps/README.md index ff6303f..6b1c119 100644 --- a/BotOps/README.md +++ b/BotOps/README.md @@ -43,4 +43,4 @@ BotOps/ 与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释): - 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}` -- 接收:`chunk`(**累计全文**,增量由前端计算)/ `complete` / `dialogue_ended` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` +- 接收:`chunk`(**增量片段**,服务端 `chunk_buffer` 发送后即清零,前端直接拼接)/ `complete`(**完整全文**,用于对齐已显示内容)/ `line`(过程行:🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏)/ `reasoning`(思考流,仅服务端 debug_level≥3 发送)/ `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong` diff --git a/BotOps/app.py b/BotOps/app.py index 62fdf10..645528d 100644 --- a/BotOps/app.py +++ b/BotOps/app.py @@ -141,7 +141,9 @@ class BotOpsApp(App): return await self.chat.add_user(text) await self.chat.start_ai() # 预建 AI 消息框,即时反馈 - await self.ws.send_message(text) + if not await self.ws.send_message(text): + await self.chat.finish_ai("") # 发送失败:移除预建的空消息框 + await self.notify_system("❌ 发送失败:连接已断开,请 /connect 重连", level="error") # ── 快捷键 ──────────────────────────────────────────────────────────── async def action_interrupt(self): @@ -159,18 +161,37 @@ class BotOpsApp(App): content = data.get("content", "") if t == "chunk": - if content.startswith("🔧 调用工具"): - await self.notify_system(content, level="task") - elif content.startswith(("✅ 对话记忆已保存", "💾 保存记忆", "❌", "⚠️")): - await self.notify_system(content, level="task") + # 服务端 chunk 为增量片段(发送后即清零),直接拼接 + await self.chat.append_ai(content) + elif t == "line": + # 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀 + stripped = content.strip() + if not stripped: + pass + elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")): + pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示 else: - await self.chat.append_ai(content) + # 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏 + self.tasks.write(stripped) + elif t == "reasoning": + # 思考流(服务端 debug_level>=3 才发送),记入任务侧栏 + stripped = content.strip() + if stripped: + self.tasks.write(f"💭 {stripped[-200:]}") elif t == "complete": - await self.chat.finish_ai(content or self.chat.ai_text) + # complete 携带完整全文,用于对齐已显示内容 + await self.chat.finish_ai(content) elif t == "dialogue_ended": await self.chat.finish_ai(self.chat.ai_text) if content: await self.notify_system(f"✅ {content}") + elif t == "task_added": + await self.notify_system(f"➕ {content}") + elif t == "dialogue_reset": + if content: + await self.notify_system(f"🔄 {content}") + elif t == "pong": + pass elif t == "task_process": sub = data.get("msg_type", "") title = data.get("task_title", "未知任务") @@ -213,7 +234,8 @@ class BotOpsApp(App): await self.notify_system(f"✅ 已连接到 {self.ws.uri}") elif state == "disconnected": self._refresh_status("") - await self.notify_system(f"❌ {detail}") + await self.notify_system( + f"❌ {detail}(中断期间的服务端输出已丢失,如回复不完整请重发)") elif state == "reconnecting": self._refresh_status(detail) elif state == "error": diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py index 5b83857..718ef2c 100644 --- a/BotOps/widgets/chat_view.py +++ b/BotOps/widgets/chat_view.py @@ -1,20 +1,13 @@ -"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。""" +"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。 + +服务端协议(aibox/main.py output_callback 原样透传 msg_type): + - chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append + - complete 完整全文,用于对齐:与已显示文本不一致时整体替换(防丢帧/乱序) +""" from textual.containers import VerticalScroll from textual.widgets import Markdown, Static -def _delta(prev: str, new: str) -> str: - """计算累计文本的增量(服务端 chunk 为累计全文;前缀失配时取最长公共前缀后的部分)。""" - if new.startswith(prev): - return new[len(prev):] - i = 0 - for a, b in zip(prev, new): - if a != b: - break - i += 1 - return new[i:] - - class ChatView(VerticalScroll): """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。 @@ -26,11 +19,11 @@ class ChatView(VerticalScroll): def __init__(self, **kwargs): super().__init__(**kwargs) self._ai_widget: Markdown | None = None - self._ai_prev = "" + self._ai_text = "" @property def ai_text(self) -> str: - return self._ai_prev + return self._ai_text def _anchor_bottom(self): """锚定吸底(需在布局刷新后执行,否则滚到的是旧位置)。""" @@ -48,34 +41,50 @@ class ChatView(VerticalScroll): """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。""" if self._ai_widget is not None: return - self._ai_prev = "" + self._ai_text = "" self._ai_widget = Markdown("", classes="msg-ai") await self.mount(self._ai_widget) self._anchor_bottom() - async def append_ai(self, full_text: str): - """以累计全文增量渲染(服务端 chunk 为累计文本)。""" + async def append_ai(self, delta: str): + """追加增量片段(服务端 chunk 为增量语义,直接拼接)。""" + if not delta: + return if self._ai_widget is None: await self.start_ai() - d = _delta(self._ai_prev, full_text) - if d: - self._ai_widget.append(d) - self._ai_prev = full_text - # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次 - self.call_after_refresh(self.scroll_end, animate=False) + self._ai_widget.append(delta) + self._ai_text += delta + # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次 + self.call_after_refresh(self.scroll_end, animate=False) async def finish_ai(self, full_text: str = ""): - """收尾:用最终全文对齐一次(防中途丢帧),随后关闭本条 AI 消息。""" - if self._ai_widget is None and full_text: - await self.start_ai() - if full_text and full_text != self._ai_prev: - await self.append_ai(full_text) + """收尾一条 AI 消息。 + + - full_text 为 complete 携带的完整全文:与已显示不一致时整体替换对齐; + - full_text 为空且未收到任何内容(如发送失败):移除预建的空消息框。 + """ + w = self._ai_widget self._ai_widget = None - self._ai_prev = "" + if w is None: + # 流式期间丢帧,只在 complete 收到全文:补建完整消息 + if full_text: + self._ai_text = full_text + await self.mount(Markdown(full_text, classes="msg-ai")) + self._anchor_bottom() + return + if full_text and full_text != self._ai_text: + # 已显示内容与全文不一致(丢字/乱序/丢帧),整体替换对齐 + await w.remove() + self._ai_text = full_text + await self.mount(Markdown(full_text, classes="msg-ai")) + elif not full_text and not self._ai_text: + # 预建后没有任何输出:移除空框 + await w.remove() + self._ai_text = "" self.call_after_refresh(self.scroll_end, animate=False) async def reset(self): """清空聊天区(/clear)。""" self._ai_widget = None - self._ai_prev = "" + self._ai_text = "" await self.remove_children() diff --git a/BotOps/ws_client.py b/BotOps/ws_client.py index 01b3d83..61719e0 100644 --- a/BotOps/ws_client.py +++ b/BotOps/ws_client.py @@ -4,10 +4,12 @@ 发送:{"type": "message", "content": ...} {"type": "command", "command": "/..."} {"type": "interrupt", "content": ...} - 接收:chunk / complete / dialogue_ended / task_process / task_status / task_error / - interrupt_ack / error / info / command_result / connection_ack / notification + 接收:chunk / line / reasoning / complete / dialogue_ended / dialogue_reset / + task_process / task_status / task_error / task_added / interrupt_ack / + error / info / command_result / connection_ack / notification / pong -说明:服务端 chunk 为**累计全文**(与 aibox/client.py 前缀匹配逻辑一致)。 +说明:服务端 chunk 为**增量片段**(output_callback 发送 chunk_buffer 后即清零), + complete 为完整全文(可用于对齐);line 为过程行(工具提示/输出/前缀)。 """ import asyncio import json From b8ec752a12698764249af41968e56c1a752091ec Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 09:39:49 +0800 Subject: [PATCH 25/33] =?UTF-8?q?docs(pymsg):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=B8=AD=E6=96=87=20README=20=E6=96=87=E6=A1=A3=EF=BC=8C?= =?UTF-8?q?=E6=B6=B5=E7=9B=96=E5=8D=8F=E8=AE=AE=E3=80=81API=20=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=92=8C=E5=AE=9E=E9=99=85=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pymsg/README.md | 289 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 pymsg/README.md diff --git a/pymsg/README.md b/pymsg/README.md new file mode 100644 index 0000000..b6cd64a --- /dev/null +++ b/pymsg/README.md @@ -0,0 +1,289 @@ +# pymsg + +基于长度前缀的可变长 socket 消息帧协议,Python 实现。 +零依赖、极简的 TCP 消息层 —— 没有 HTTP,没有 protobuf,只有 `[4字节头][UTF-8载荷]`。 + +## 协议 + +``` +┌────────────────────────┬──────────────────────────┐ +│ 4 字节(大端序) │ N 字节(UTF-8) │ +│ uint32 载荷字节长度 │ JSON / 文本载荷 │ +└────────────────────────┴──────────────────────────┘ +``` + +消息头为 4 字节大端无符号整数,表示后续 UTF-8 载荷的字节数。接收方先读 4 字节解出长度,再精确读取对应字节数,即完成一条消息的接收。 + +## 安装 + +```bash +cd pymsg +pip install -e . +``` + +或在其他项目中作为本地依赖引用: + +``` +# requirements.txt +../pymsg +``` + +要求 Python 3.9+,无外部依赖。 + +## 快速上手 + +```python +import socket +from pymsg import send, recv + +# --- 发送方 --- +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.connect(("127.0.0.1", 8005)) +send(sock, '{"action": "ping"}') +resp = recv(sock, timeout=5.0) +print(resp) # {"success": true, "data": "pong"} +sock.close() + +# --- 接收方 --- +server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +server.bind(("127.0.0.1", 8005)) +server.listen(1) +conn, addr = server.accept() +msg = recv(conn) +send(conn, '{"success": true, "data": "pong"}') +conn.close() +``` + +## API 参考 + +### 底层函数 + +**`send(sock, data, *, timeout=None)`** + +在已连接的 socket 上发送一条长度前缀帧消息。将字符串编码为 UTF-8,前面加上 4 字节大端长度头,调用 `sock.sendall()` 发送。 + +若指定 `timeout`,会临时调整 socket 超时,超时后抛出 `TimeoutError`,原始超时设置会被恢复。 + +**`recv(sock, *, timeout=None)`** + +从已连接的 socket 接收一条长度前缀帧消息。读取 4 字节头解出长度,再读取指定字节数,返回解码后的 UTF-8 字符串。 + +若连接在完整消息接收前断开,抛出 `ConnectionError`。若指定了 `timeout` 且超时,抛出 `TimeoutError`。 + +**`TimeoutError`** + +`send` 或 `recv` 操作超过指定超时时间时抛出的异常,继承自 `Exception`。 + +### `MsgClient` — TCP 客户端 + +封装了 socket 创建、连接和三种通信模式。 + +```python +from pymsg import MsgClient + +client = MsgClient("127.0.0.1", 8005, "my-service") +``` + +| 参数 | 含义 | +|------|------| +| `addr` | 服务端地址或 IP | +| `port` | 服务端端口 | +| `name` | 客户端标识,在 `send()` 模式下会以 `"name: message"` 格式发送 | + +三种使用模式: + +**1. 发送即忘(短连接)** + +```python +client.send("hello") +``` + +每次调用新建 TCP 连接,发送带 name 前缀的消息 `"my-service: hello"` 后立即关闭,不等待回复。适用于通知、日志推送等无需响应的场景。 + +**2. 请求-应答 / RPC(短连接)** + +```python +resp = client.call('{"action": "select", "table": "Users"}', timeout=10.0) +``` + +新建连接,发送消息(不带 name 前缀),阻塞等待一条回复后关闭。这是 RPC 风格服务调用的主要模式。默认超时 10 秒。 + +目标不可达时抛出 `ConnectionRefusedError`,超时无回复时抛出 `TimeoutError`。 + +**3. 长连接监听(回调模式)** + +```python +def on_msg(text): + print(f"收到: {text}") + +client.connect() # 建立持久 TCP 连接 +client.listen(on_msg) # 阻塞当前线程,每收到一条消息回调一次 +``` + +先调用 `connect()` 建立长连接,再调用 `listen()` 阻塞当前线程,每收到一条消息即回调 `on_message(text)`。从另一线程调用 `client.stop()` 可退出监听循环。 + +### `MsgServer` — TCP 服务端 + +基于 pymsg 协议的多客户端 TCP 服务端。接受连接,接收帧消息,分发到用户提供的回调函数。 + +```python +from pymsg import MsgServer + +def on_message(sock, addr, text): + print(f"[{addr}] {text}") + server.reply(sock, "ack") + +server = MsgServer("0.0.0.0", 8005) +server.start(on_message) # 阻塞当前线程 +``` + +| 参数 | 含义 | +|------|------| +| `addr` | 监听地址 | +| `port` | 监听端口 | + +回调签名为 `on_message(sock, addr, text)`: +- `sock` — 客户端 socket,可用 `reply()` 回复 +- `addr` — `(ip, port)` 元组 +- `text` — 解码后的 UTF-8 字符串消息 + +**主要方法:** + +`start(on_message)` — 绑定端口、开始监听,进入 accept/分发循环,阻塞至 `stop()` 被调用。 + +`reply(sock, msg)` — 向指定客户端 socket 发送一条帧格式的回复。典型用法:服务端收到请求、处理后调用 `reply(sock, json_response)` 返回结果。 + +`broadcast(msg, except_sock=None)` — 向所有已连接客户端广播消息,可排除某个 socket(通常是发送者自身)。 + +`stop()` — 设置停止标志并关闭监听 socket,可从另一线程安全调用。 + +服务端内部管理客户端列表,透明处理断连和管道破裂,使用 `select` 实现非阻塞 I/O,轮询间隔 100ms。 + +## 线程安全 + +- `MsgServer.stop()` 可从任意线程安全调用:设置原子标志并关闭监听 socket,`select` 循环会干净退出。 +- `MsgServer.broadcast()` 和 `reply()` 在 `on_message` 回调内(同一线程)调用是安全的。服务端运行期间从其他线程并发调用不支持。 +- `MsgClient.stop()` 可从另一线程调用以中断 `listen()`。 +- `send()` 和 `recv()` 内部不做同步 —— 如果多线程共享同一 socket,调用方需自行串行化。 + +## 实际用例 + +### 服务端 — 数据库服务(dbv2) + +db 服务通过 pymsg 暴露通用 CRUD 接口。每条请求是带 `_route` 字段的 JSON 对象,服务端按路由分发到对应处理函数,通过同一 socket 回复结果。 + +```python +import json +from pymsg import MsgServer + +class DBServer: + def __init__(self): + self._server = MsgServer("127.0.0.1", 8005) + self._routes = {"execute": self._handle_execute} + + def start(self): + self._server.start(self._dispatch) + + def _dispatch(self, sock, addr, text): + req = json.loads(text) + route = req.pop("_route", "") + handler = self._routes.get(route) + if handler is None: + self._server.reply(sock, + json.dumps({"success": False, "error": "未知路由"})) + return + try: + result = handler(req) + self._server.reply(sock, + json.dumps({"success": True, "data": result})) + except Exception as e: + self._server.reply(sock, + json.dumps({"success": False, "error": str(e)})) + + def _handle_execute(self, data): + # ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表 + pass +``` + +### 客户端 — RPC 调用(dnfs → db) + +dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 pymsg 调用 db。 + +```python +import json +from pymsg import MsgClient + +def _call_dbv2(payload): + client = MsgClient("127.0.0.1", 8005, "dnfs") + resp = client.call(json.dumps(payload), timeout=10.0) + return json.loads(resp) + +# 创建笔记 +result = _call_dbv2({ + "_route": "execute", + "action": "insert", + "table": "Notes", + "values": {"title": "会议记录", "content": "..."} +}) +``` + +### 客户端 — 异步日志推送(log_client) + +各服务通过 pymsg 将日志异步推送到 db。使用 `MsgClient.call()` 逐批插入。 + +```python +import json +from pymsg import MsgClient + +def _send(batch): + client = MsgClient("127.0.0.1", 8005, "gateway-log") + for entry in batch: + client.call(json.dumps({ + "_route": "execute", + "action": "insert", + "table": "Logs", + "values": { + "ts": now, "service": entry["service"], + "level": entry["level"], "message": entry["message"], + } + }), timeout=5) +``` + +## 跨语言互操作 + +pymsg 的线格式有意设计得极其简单,使其他语言的服务也能直接参与通信。项目中已有的实现: + +- **C++** — `gateway/inc/Msg.hpp`(`pmc::net::MsgClient`) + 完整复刻:`pmsg_send()`、`pmsg_recv()`,以及 `MsgClient` 类(`connect` / `send` / `call` / `listen` / `stop`)。 + +- **Go** — `waf/internal/logclient/logclient.go` + 原生 `pymsgCall()` 函数:写 4 字节大端头 + 载荷,读取回复。 + +- **Python(纯 stdlib)** — `tests/blackbox/helpers.py`(`PymsgClient`) + 仅使用 `socket` 和 `struct` 的纯 Python 实现,用于黑盒测试,不依赖 pymsg 包本身。 + +从头实现该协议只需四步: +1. 将字符串编码为 UTF-8 字节 +2. 将字节长度打包为 4 字节大端无符号整数 +3. 写入 头 + 载荷 到 socket +4. 接收端先读 4 字节解出长度,再读对应字节数,解码为 UTF-8 + +## 错误处理速查 + +| 异常 | 触发条件 | +|------|----------| +| `pymsg.TimeoutError` | `send()` 或 `recv()` 超过 `timeout` 参数设定的时间 | +| `ConnectionError`(内置) | 完整消息接收前 socket 断开 | +| `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 | +| `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 | + +`MsgServer` 内部会吞掉客户端断连错误(移除该客户端,继续服务)。`broadcast()` 静默跳过已断开的 socket。 + +## 设计考量 + +为什么用长度前缀帧而不是换行分隔或 HTTP? + +- **二进制安全。** JSON 载荷中可能包含换行符,长度前缀帧无需转义。 +- **无需流式解析器。** 接收方读完 4 字节头后就知道还要读多少字节 —— 没有缓冲、没有行分割。 +- **跨语言实现极其简单。** 整个协议用 C、Go 或 Rust 大约 20 行代码即可实现。 +- **适用于任何流式传输。** TCP、Unix socket、管道、TLS 包裹的连接 —— 任何提供有序字节传输的通道都可以。 From a905dac00dc36a8406ab35fc037c739737334140 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 10:47:20 +0800 Subject: [PATCH 26/33] display note ownerx --- static/public/js/dnfs.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index c5eb984..afbdbec 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -253,6 +253,7 @@ function load_notes(main_node) { recommend_list.replaceChildren(); for (let i in main_node) { let item = main_node[i] + console.log(item) let card = document.createElement('div'); card.className = "card"; card.addEventListener('click', function(event) { @@ -267,12 +268,15 @@ function load_notes(main_node) { card.id = item.title; ttl.appendChild(ttl1); card.appendChild(ttl); + let wrt_div = document.createElement('p'); let vit_div = document.createElement('p'); let lke_div = document.createElement('p'); let srd_div = document.createElement('p'); + wrt_div.textContent = "用户:" + item.owner; vit_div.textContent = "观看数:" + item.visit_count; lke_div.textContent = "点赞数:" + item.like_count; srd_div.textContent = "分享数:" + item.share_count; + ttl.appendChild(wrt_div); ttl.appendChild(vit_div); ttl.appendChild(lke_div); ttl.appendChild(srd_div); From dae81ba4a18190060d569acf9a002ce47928c358 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 10:53:29 +0800 Subject: [PATCH 27/33] =?UTF-8?q?style(dnfs):=20=E7=BE=8E=E5=8C=96?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=8F=91=E5=B8=96=E7=94=A8=E6=88=B7=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=B8=BA=E5=A4=B4=E5=83=8F+=E6=98=B5=E7=A7=B0?= =?UTF-8?q?=E8=83=B6=E5=9B=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dnfs.js: owner 纯文本改为首字符圆形头像 + 昵称,空值兜底匿名 - theme.css: 新增 Owner Chip 样式段,复用主题变量自动适配深色模式 --- static/public/css/theme.css | 32 ++++++++++++++++++++++++++++++++ static/public/js/dnfs.js | 30 ++++++++++++++++++++---------- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 786dcf3..04fe2ce 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -224,6 +224,38 @@ input[type="button"]:disabled { .recommend-list .card h3 { transition: color 0.15s; } .recommend-list .card p { font-size: 0.9rem; line-height: 1.6; color: var(--fg-secondary); } +/* ===== Owner Chip (dnfs 发帖用户) ===== */ +.card-owner { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.2rem; +} +.owner-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent-dark); + border: 1px solid var(--accent); + font-size: 0.7rem; + font-weight: 700; + flex-shrink: 0; + user-select: none; +} +.owner-name { + font-size: 0.8rem; + font-weight: 600; + color: var(--fg-secondary); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ===== Minimap (dnfs) ===== */ .minimap { position: fixed; diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index afbdbec..563761e 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -269,17 +269,27 @@ function load_notes(main_node) { ttl.appendChild(ttl1); card.appendChild(ttl); let wrt_div = document.createElement('p'); - let vit_div = document.createElement('p'); - let lke_div = document.createElement('p'); - let srd_div = document.createElement('p'); - wrt_div.textContent = "用户:" + item.owner; - vit_div.textContent = "观看数:" + item.visit_count; - lke_div.textContent = "点赞数:" + item.like_count; - srd_div.textContent = "分享数:" + item.share_count; + //let vit_div = document.createElement('p'); + //let lke_div = document.createElement('p'); + //let srd_div = document.createElement('p'); + // 发帖用户:头像圆点(取昵称首字符)+ 昵称,样式见 theme.css 的 Owner Chip 段 + wrt_div.className = "card-owner"; + let owner_text = (item.owner == null || item.owner === "") ? "匿名" : String(item.owner); + let owner_avatar = document.createElement('span'); + owner_avatar.className = "owner-avatar"; + owner_avatar.textContent = owner_text.trim().charAt(0).toUpperCase() || "?"; + let owner_name = document.createElement('span'); + owner_name.className = "owner-name"; + owner_name.textContent = owner_text; + wrt_div.appendChild(owner_avatar); + wrt_div.appendChild(owner_name); + //vit_div.textContent = "观看数:" + item.visit_count; + //lke_div.textContent = "点赞数:" + item.like_count; + //srd_div.textContent = "分享数:" + item.share_count; ttl.appendChild(wrt_div); - ttl.appendChild(vit_div); - ttl.appendChild(lke_div); - ttl.appendChild(srd_div); + //ttl.appendChild(vit_div); + //ttl.appendChild(lke_div); + //ttl.appendChild(srd_div); let ctt = document.createElement('p'); renderMarkdown(ctt, item.content); card.appendChild(ctt) From 2fc12e7b000ba027861dac014626d445250873a0 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 10:59:40 +0800 Subject: [PATCH 28/33] =?UTF-8?q?feat(dnfs):=20=E5=8D=A1=E7=89=87=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=A7=82=E7=9C=8B/=E7=82=B9=E8=B5=9E/=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E4=BA=92=E5=8A=A8=E7=BB=9F=E8=AE=A1=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dnfs.js: 新增眼睛/爱心/转发 SVG 图标常量与 makeStatItem 构造函数, 统计行挂载在 owner 胶囊下方,计数缺省显示 0(后端暂无该字段) - theme.css: 新增 card-stats/stat-item/stat-num 样式 --- static/public/css/theme.css | 26 ++++++++++++++++++++++++++ static/public/js/dnfs.js | 33 ++++++++++++++++++++++++--------- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 04fe2ce..3627224 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -256,6 +256,32 @@ input[type="button"]:disabled { white-space: nowrap; } +/* ===== Card Stats (dnfs 互动统计:观看/点赞/分享) ===== */ +.card-stats { + display: flex; + align-items: center; + justify-content: center; + gap: 1.1rem; + margin-top: 0.45rem; +} +.stat-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.78rem; + color: var(--muted); +} +.stat-item svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} +.stat-num { + font-weight: 600; + color: var(--fg-secondary); + font-variant-numeric: tabular-nums; +} + /* ===== Minimap (dnfs) ===== */ .minimap { position: fixed; diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 563761e..6f617d5 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -177,6 +177,23 @@ function renderMarkdown(element, content) { element.innerHTML = md.render(content, {html: false}); } +/* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */ +const ICON_EYE = ''; +const ICON_HEART = ''; +const ICON_SHARE = ''; + +/* by qing — 生成一个统计项:图标 + 数字,count 缺省显示 0 */ +function makeStatItem(iconSvg, count) { + let span = document.createElement('span'); + span.className = "stat-item"; + span.innerHTML = iconSvg; // 上方固定常量字符串,不含用户输入 + let num = document.createElement('span'); + num.className = "stat-num"; + num.textContent = (count == null) ? "0" : String(count); + span.appendChild(num); + return span; +} + /* ---- by qing ---- */ const stack = []; /* 用于存储当前节点的路径,也就是经过的每一个节点的id, 否则就回不去上一层*/ var article_title = document.getElementById('article-title'); @@ -269,9 +286,6 @@ function load_notes(main_node) { ttl.appendChild(ttl1); card.appendChild(ttl); let wrt_div = document.createElement('p'); - //let vit_div = document.createElement('p'); - //let lke_div = document.createElement('p'); - //let srd_div = document.createElement('p'); // 发帖用户:头像圆点(取昵称首字符)+ 昵称,样式见 theme.css 的 Owner Chip 段 wrt_div.className = "card-owner"; let owner_text = (item.owner == null || item.owner === "") ? "匿名" : String(item.owner); @@ -283,13 +297,14 @@ function load_notes(main_node) { owner_name.textContent = owner_text; wrt_div.appendChild(owner_avatar); wrt_div.appendChild(owner_name); - //vit_div.textContent = "观看数:" + item.visit_count; - //lke_div.textContent = "点赞数:" + item.like_count; - //srd_div.textContent = "分享数:" + item.share_count; + // 互动统计:眼睛=观看、爱心=点赞、转发=分享(后端 Notes 表暂无计数字段,缺省显示 0) + let sts_div = document.createElement('div'); + sts_div.className = "card-stats"; + sts_div.appendChild(makeStatItem(ICON_EYE, item.visit_count)); + sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count)); + sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count)); ttl.appendChild(wrt_div); - //ttl.appendChild(vit_div); - //ttl.appendChild(lke_div); - //ttl.appendChild(srd_div); + ttl.appendChild(sts_div); let ctt = document.createElement('p'); renderMarkdown(ctt, item.content); card.appendChild(ctt) From a0f6a14b5dfda9282815d05d1113c076402c5e60 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 11:05:20 +0800 Subject: [PATCH 29/33] =?UTF-8?q?style(dnfs):=20=E7=AC=94=E8=AE=B0?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=B0=BA=E5=AF=B8=E9=9A=8F=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E8=87=AA=E9=80=82=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recommend-list 改为 flex 纵向布局,gap 统一间距,隐藏 JS 遗留
分隔符 - 卡片宽度 fit-content 包裹内容,min-width 240px 保底、max-width 100% 封顶 - 内容图片限宽防撑破自适应布局 --- static/public/css/theme.css | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 3627224..e6c6dfb 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -212,7 +212,27 @@ input[type="button"]:disabled { #article-content { font-size: 0.95rem; line-height: 1.7; margin-bottom: 1.25rem; } /* ===== Recommend List (dnfs) ===== */ -.recommend-list .card { margin-bottom: 0.75rem; width: 100%; max-width: none; } +/* 卡片尺寸随内容自适应:宽度包裹内容(fit-content),上限为容器宽度;高度天然随内容伸缩 */ +.recommend-list { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + width: 100%; +} +.recommend-list br { display: none; } /* JS 追加的
分隔符,改由 flex gap 统一控制间距 */ +.recommend-list .card { + width: fit-content; + min-width: min(240px, 100%); + max-width: 100%; + margin-bottom: 0; +} +/* 防止内容中的大图撑破自适应宽度 */ +.recommend-list .card img { + max-width: 100%; + height: auto; + border-radius: var(--radius-sm); +} .recommend-list center { cursor: pointer; padding: 0.4rem; From ace98caff66f32c8628035721b5d47dd7ba34f63 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 11:22:30 +0800 Subject: [PATCH 30/33] =?UTF-8?q?feat:=20=E5=8D=A1=E7=89=87=E7=BC=96?= =?UTF-8?q?=E8=BE=91/=E5=88=A0=E9=99=A4=E6=8C=89=E9=92=AE=20+=20dnfs=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E8=B1=81=E5=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dnfs.js: 卡片底部新增编辑/删除按钮,复用下方表单支持编辑模式 (startEdit/cancelEdit/updateNode/deleteChild),stopPropagation 防误触 - theme.css: 新增 card-actions/act-btn 按钮样式,编辑橙色/删除红色软底 - dnfs.py: ADMIN_USERS 统一管理豁免名单(admin, admin@qsont.me), update_note 新增管理员豁免,delete_note 同步改用名单 - dnfs/README.md: 同步更新所有权豁免说明 --- dnfs/README.md | 4 +- dnfs/dnfs.py | 12 ++- static/public/css/theme.css | 26 ++++++ static/public/js/dnfs.js | 158 +++++++++++++++++++++++++++++++++++- 4 files changed, 193 insertions(+), 7 deletions(-) diff --git a/dnfs/README.md b/dnfs/README.md index 923c0f0..f0ad6ef 100644 --- a/dnfs/README.md +++ b/dnfs/README.md @@ -44,7 +44,7 @@ Authorization: Bearer _ - payload 中取 `user` 字段作为操作者,写入 `owner`。 - `delete_note` 与 `update_note` 校验所有权: - 节点 `owner` 必须等于当前 `user`,否则 `403`; - - **例外**:`user == 'admin'` 可删除任意节点(管理员豁免,仅 delete)。 + - **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。 - `get_notes` 不需要鉴权。 ## 接口文档 @@ -154,7 +154,7 @@ curl -X POST http://localhost:8006/delete_note \ 更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。 -仅节点 owner 可更新;update 路径下 **admin 不享豁免**(与 delete 不同)。 +仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。 **Request** diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 45b54d5..8a559c8 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -37,6 +37,9 @@ from pymsg import MsgClient # noqa: E402 _DBV2_ADDR = '127.0.0.1' _DBV2_PORT = 8005 +# 管理员账号:删除/更新节点时豁免所有权校验(保留旧的 'admin' 以兼容历史数据) +ADMIN_USERS = ('admin', 'admin@qsont.me') + def _call_dbv2(payload: dict, timeout: int = 10) -> dict: """通过 pymsg 短连接调用 dbv2 数据库服务。 自动注入 ``_route: "execute"``。 @@ -325,7 +328,7 @@ def delete_note(): data = _call_dbv2(select_payload) notes = data['data'] - if not info['user'] == 'admin': + if info['user'] not in ADMIN_USERS: if not len(notes) == 1 or not notes[0]['owner'] == info['user']: logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']}) return jsonify({"error": 'It\'s not your note'}), 403 @@ -400,9 +403,10 @@ def update_note(): data = _call_dbv2(select_payload) notes = data['data'] - if not len(notes) == 1 or not notes[0]['owner'] == info['user']: - logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']}) - return jsonify({"error": 'It\'s not your note'}), 403 + if info['user'] not in ADMIN_USERS: + if not len(notes) == 1 or not notes[0]['owner'] == info['user']: + logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']}) + return jsonify({"error": 'It\'s not your note'}), 403 # 内容审查 review = review_content(title + '\n' + content, 10) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index e6c6dfb..0ace46c 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -302,6 +302,32 @@ input[type="button"]:disabled { font-variant-numeric: tabular-nums; } +/* ===== Card Actions (dnfs 编辑/删除按钮) ===== */ +.card-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.6rem; +} +.act-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.7rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: var(--radius-sm); + border: 1px solid transparent; + background: var(--accent-soft); + color: var(--accent-dark); + cursor: pointer; + transition: border-color 0.15s, opacity 0.15s, transform 0.1s; +} +.act-btn svg { width: 12px; height: 12px; flex-shrink: 0; } +.act-btn:hover { border-color: var(--accent); } +.act-del { background: var(--error-bg); color: var(--error); } +.act-del:hover { border-color: var(--error); } + /* ===== Minimap (dnfs) ===== */ .minimap { position: fixed; diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 6f617d5..aa27232 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -168,6 +168,118 @@ async function deleteNode(id) { } } +/* by qing — 卡片上的"删除":删除子节点后停留在当前层,仅刷新列表(不弹栈) */ +async function deleteChild(id) { + if (confirm("确认删除此节点?") == false) { + return; + } + try { + obj = {"id": id}; + jsonBody = JSON.stringify(obj); + + const res = await fetch('/delete_note', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Target-Url': 'http://127.0.0.1:8006' + }, + body: jsonBody + }); + + const responseText = await res.text(); + let result; + + try { + result = JSON.parse(responseText); + } catch { + console.error(`删除笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText); + throw new Error('服务器响应格式异常,请稍后重试'); + } + + if (res.status === 401) { + // 凭证失效(过期或网关重启),跳转重新登录 + location.href = '/login.html'; + return; + } + + if (!res.ok) { + throw new Error(result.error === "It's not your note" ? '只能删除自己的节点' : (result.error || `删除节点失败(HTTP ${res.status})`)); + } + + // 若删掉的正是编辑中的节点,顺带退出编辑模式 + if (editing_id == id) { + cancelEdit(); + input_note_title.value = ""; + input_note_content.value = ""; + } + var cur = (stack.length == 0) ? 0 : stack[stack.length - 1]; + getNode(cur).then(load_notes); + getNode(cur).then(loadMap); + + console.log(`删除节点成功`); + return result; + + } catch (err) { + alert(err.message || '接口请求失败,请检查后端服务是否启动'); + console.error('删除节点请求异常:', err); + } +} + +/* by qing — 保存编辑:调用 /update_note,成功后刷新当前层并退出编辑模式 */ +async function updateNode(id, pid, title, content) { + try { + obj = { + "id": Number(id), + "pid": pid, + "title": title, + "content": content + } + jsonBody = JSON.stringify(obj) + const res = await fetch('/update_note', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Target-Url': 'http://127.0.0.1:8006' + }, + body: jsonBody + }); + + const responseText = await res.text(); + let result; + + try { + result = JSON.parse(responseText); + } catch { + console.error(`更新笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText); + throw new Error('服务器响应格式异常,请稍后重试'); + } + + if (res.status === 401) { + // 凭证失效(过期或网关重启),跳转重新登录 + location.href = '/login.html'; + return; + } + + if (!res.ok) { + throw new Error(result.error === "It's not your note" ? '只能编辑自己的节点' : (result.error || `更新节点失败(HTTP ${res.status})`)); + } + + input_note_title.value = ""; + input_note_content.value = ""; + cancelEdit(); + var cur = (stack.length == 0) ? 0 : stack[stack.length - 1]; + getNode(cur).then(load_notes); + getNode(cur).then(loadMap); + + alert('修改成功'); + return result; + + } catch (err) { + alert(err.message || '接口请求失败,请检查后端服务是否启动'); + console.error('更新节点请求异常:', err); + } +} + /* by shufeng2012 */ function renderMarkdown(element, content) { /* @@ -181,6 +293,8 @@ function renderMarkdown(element, content) { const ICON_EYE = ''; const ICON_HEART = ''; const ICON_SHARE = ''; +const ICON_EDIT = ''; +const ICON_TRASH = ''; /* by qing — 生成一个统计项:图标 + 数字,count 缺省显示 0 */ function makeStatItem(iconSvg, count) { @@ -206,6 +320,7 @@ var recommend_list = document.getElementById('recommend-list'); var delete_note = document.getElementById('delete-note'); var minimap = document.getElementById("minimap"); var is_expand = true; +var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */ input_note_submit.addEventListener('click', function(event) { if (input_note_submit.disabled) return; // 防重复点击 @@ -218,7 +333,9 @@ input_note_submit.addEventListener('click', function(event) { input_note_submit.disabled = true; input_note_clear.disabled = true; delete_note.disabled = true; - addNode(pid, title, content) + // 编辑模式下保存修改,否则新增节点 + var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content); + Promise.resolve(req) .finally(() => { input_note_submit.disabled = false; input_note_clear.disabled = false; @@ -229,6 +346,7 @@ input_note_submit.addEventListener('click', function(event) { input_note_clear.addEventListener('click', function(event) { input_note_title.value = ""; input_note_content.value = ""; + cancelEdit(); // 重置时同时退出编辑模式 }) delete_note.addEventListener('click', (event) => { @@ -246,6 +364,22 @@ delete_note.addEventListener('click', (event) => { } }) +/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式 */ +function startEdit(item) { + editing_id = item.id; + input_note_title.value = item.title; + input_note_content.value = item.content; + input_note_submit.textContent = "保存修改"; + input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' }); + input_note_title.focus(); +} + +/* by qing — 退出编辑模式,表单恢复为新增状态 */ +function cancelEdit() { + editing_id = null; + input_note_submit.textContent = "提交"; +} + function load_notes(main_node) { /* 这个不能设置为空的 */ @@ -256,6 +390,7 @@ function load_notes(main_node) { if (confirm("是否放弃当前输入的内容?") == true) { input_note_title.value = ""; input_note_content.value = ""; + cancelEdit(); } else { if (stack.length != 0) { return; @@ -264,6 +399,7 @@ function load_notes(main_node) { } else { input_note_title.value = ""; input_note_content.value = ""; + cancelEdit(); } @@ -303,11 +439,31 @@ function load_notes(main_node) { sts_div.appendChild(makeStatItem(ICON_EYE, item.visit_count)); sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count)); sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count)); + // 卡片操作按钮:编辑 / 删除(stopPropagation 防止触发卡片自身的点入跳转) + let act_div = document.createElement('div'); + act_div.className = "card-actions"; + let edit_btn = document.createElement('button'); + edit_btn.className = "act-btn act-edit"; + edit_btn.innerHTML = ICON_EDIT + "编辑"; + edit_btn.addEventListener('click', function(event) { + event.stopPropagation(); + startEdit(item); + }); + let del_btn = document.createElement('button'); + del_btn.className = "act-btn act-del"; + del_btn.innerHTML = ICON_TRASH + "删除"; + del_btn.addEventListener('click', function(event) { + event.stopPropagation(); + deleteChild(item.id); + }); + act_div.appendChild(edit_btn); + act_div.appendChild(del_btn); ttl.appendChild(wrt_div); ttl.appendChild(sts_div); let ctt = document.createElement('p'); renderMarkdown(ctt, item.content); card.appendChild(ctt) + card.appendChild(act_div); recommend_list.appendChild(card) recommend_list.appendChild(document.createElement('br')) } From 3f9bffb9eae08247779bb8cde046444f76856884 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 11:40:35 +0800 Subject: [PATCH 31/33] =?UTF-8?q?feat(dnfs):=20=E8=8A=82=E7=82=B9=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dnfs.js: 卡片新增移动按钮,startMove 暂存节点,底部悬浮移动条 提供移动到当前层/取消;confirmMove 经 update_note 改 pid 完成迁移; 拦截移动到自身与已在当前层的情况 - dnfs.html: 新增 #movebar 容器 - theme.css: act-move 描边按钮与 movebar 底部悬浮条样式 --- static/public/css/theme.css | 37 ++++++++++++ static/public/dnfs.html | 1 + static/public/js/dnfs.js | 115 ++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 0ace46c..1e4ccfe 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -327,6 +327,43 @@ input[type="button"]:disabled { .act-btn:hover { border-color: var(--accent); } .act-del { background: var(--error-bg); color: var(--error); } .act-del:hover { border-color: var(--error); } +.act-move { background: transparent; color: var(--fg-secondary); border-color: var(--border); } +.act-move:hover { border-color: var(--accent); color: var(--accent-dark); } + +/* ===== Move Bar (dnfs 节点移动条) ===== */ +.movebar { + position: fixed; + left: 50%; + bottom: 1.25rem; + transform: translateX(-50%); + z-index: 110; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0.8rem; + border-radius: var(--radius-md); + background: rgba(253, 246, 238, 0.85); + border: 1px solid var(--accent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: var(--shadow-md); + font-size: 0.82rem; + max-width: 92vw; +} +@media (prefers-color-scheme: dark) { + .movebar { background: rgba(30, 23, 18, 0.85); } +} +.movebar:empty { display: none; } +.move-title { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-secondary); + font-weight: 600; +} /* ===== Minimap (dnfs) ===== */ .minimap { diff --git a/static/public/dnfs.html b/static/public/dnfs.html index 5ea84ae..56d0f4e 100644 --- a/static/public/dnfs.html +++ b/static/public/dnfs.html @@ -10,6 +10,7 @@
+

节点操作

diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index aa27232..5336449 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -280,6 +280,110 @@ async function updateNode(id, pid, title, content) { } } +/* by qing — 节点移动:本质是 pid 变更的 update_note,title/content 原样携带 */ +async function moveNode(id, pid, title, content) { + try { + obj = { + "id": Number(id), + "pid": pid, + "title": title, + "content": content + } + jsonBody = JSON.stringify(obj) + const res = await fetch('/update_note', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Target-Url': 'http://127.0.0.1:8006' + }, + body: jsonBody + }); + + const responseText = await res.text(); + let result; + + try { + result = JSON.parse(responseText); + } catch { + console.error(`更新笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText); + throw new Error('服务器响应格式异常,请稍后重试'); + } + + if (res.status === 401) { + // 凭证失效(过期或网关重启),跳转重新登录 + location.href = '/login.html'; + return; + } + + if (!res.ok) { + throw new Error(result.error === "It's not your note" ? '只能移动自己的节点' : (result.error || `移动节点失败(HTTP ${res.status})`)); + } + + var cur = (stack.length == 0) ? 0 : stack[stack.length - 1]; + getNode(cur).then(load_notes); + getNode(cur).then(loadMap); + + alert('移动成功'); + return result; + + } catch (err) { + alert(err.message || '接口请求失败,请检查后端服务是否启动'); + console.error('移动节点请求异常:', err); + } +} + +/* by qing — 点击卡片"移动":暂存节点信息,底部弹出移动条 */ +function startMove(item) { + moving_node = { id: Number(item.id), pid: item.pid, title: item.title, content: item.content }; + renderMoveBar(); +} + +/* by qing — 渲染底部移动条:显示待移动节点,提供"移动到当前层"和"取消" */ +function renderMoveBar() { + movebar.replaceChildren(); + if (moving_node == null) { + return; + } + let tip = document.createElement('span'); + tip.className = "move-title"; + tip.textContent = "移动中:" + moving_node.title; + let ok_btn = document.createElement('button'); + ok_btn.className = "act-btn act-edit"; + ok_btn.textContent = "移动到当前层"; + ok_btn.addEventListener('click', confirmMove); + let cancel_btn = document.createElement('button'); + cancel_btn.className = "act-btn act-del"; + cancel_btn.textContent = "取消"; + cancel_btn.addEventListener('click', function() { + moving_node = null; + renderMoveBar(); + }); + movebar.appendChild(tip); + movebar.appendChild(ok_btn); + movebar.appendChild(cancel_btn); +} + +/* by qing — 执行移动:把待移动节点挂到当前层(stack 顶,根为 0) */ +async function confirmMove() { + if (moving_node == null) { + return; + } + var target_pid = (stack.length == 0) ? 0 : stack[stack.length - 1]; + if (target_pid == moving_node.id) { + alert('不能移动到节点自身内部'); + return; + } + if (target_pid == moving_node.pid) { + alert('该节点已在当前层'); + return; + } + var result = await moveNode(moving_node.id, target_pid, moving_node.title, moving_node.content); + if (result != null) { + moving_node = null; + renderMoveBar(); + } +} + /* by shufeng2012 */ function renderMarkdown(element, content) { /* @@ -295,6 +399,7 @@ const ICON_HEART = ' Date: Wed, 29 Jul 2026 11:52:46 +0800 Subject: [PATCH 32/33] refix --- dnfs/dnfs.py | 2 +- gateway/_run.sh | 1 + waf/rules/local/custom.conf | 26 +++++++++++++------------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 8a559c8..d317e2c 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -38,7 +38,7 @@ _DBV2_ADDR = '127.0.0.1' _DBV2_PORT = 8005 # 管理员账号:删除/更新节点时豁免所有权校验(保留旧的 'admin' 以兼容历史数据) -ADMIN_USERS = ('admin', 'admin@qsont.me') +ADMIN_USERS = ('admin@qsont.me', 'qing@qsont.me') def _call_dbv2(payload: dict, timeout: int = 10) -> dict: """通过 pymsg 短连接调用 dbv2 数据库服务。 diff --git a/gateway/_run.sh b/gateway/_run.sh index df8a225..172a051 100644 --- a/gateway/_run.sh +++ b/gateway/_run.sh @@ -1,3 +1,4 @@ #!/bin/bash cd "$(dirname "$0")" || exit +sleep 1 exec ./build/gateway.out -i example.ini #1>/dev/null diff --git a/waf/rules/local/custom.conf b/waf/rules/local/custom.conf index 4f57cd9..9c858de 100644 --- a/waf/rules/local/custom.conf +++ b/waf/rules/local/custom.conf @@ -2,25 +2,25 @@ # ID 段:100000-199999 # 100001: 拦截 SQL 注入特征(UNION SELECT) -SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \ - "id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'" +#SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \ +# "id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'" -# 100002: 拦截 SQL 注释符 -SecRule ARGS "@rx (?i)(--|#|/\*.*\*/)" \ - "id:100002,phase:2,deny,status:403,log,msg:'SQL injection: comment'" +## 100002: 拦截 SQL 注释符 +#SecRule ARGS "@rx (?i)(--|#|/\*.*\*/)" \ +# "id:100002,phase:2,deny,status:403,log,msg:'SQL injection: comment'" # 100003: 拦截 XSS 标签 -SecRule ARGS|REQUEST_HEADERS "@rx (?i) Date: Wed, 29 Jul 2026 14:37:15 +0800 Subject: [PATCH 33/33] =?UTF-8?q?fix(dnfs):=20=E7=A7=BB=E5=8A=A8=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=9C=9F=E9=97=B4=E7=A6=81=E7=94=A8=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=E9=87=8D=E5=A4=8D=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 update_note 走 AI 审核耗时较长,confirmMove 期间禁用移动条 与表单按钮,finally 统一恢复 --- static/public/js/dnfs.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 5336449..2225a36 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -363,7 +363,8 @@ function renderMoveBar() { movebar.appendChild(cancel_btn); } -/* by qing — 执行移动:把待移动节点挂到当前层(stack 顶,根为 0) */ +/* by qing — 执行移动:把待移动节点挂到当前层(stack 顶,根为 0)。 + * 后端 update_note 会走 AI 内容审核,耗时较长,期间禁用移动条与表单按钮防止重复提交 */ async function confirmMove() { if (moving_node == null) { return; @@ -377,10 +378,22 @@ async function confirmMove() { alert('该节点已在当前层'); return; } - var result = await moveNode(moving_node.id, target_pid, moving_node.title, moving_node.content); - if (result != null) { - moving_node = null; - renderMoveBar(); + // 防重复点击:请求进行中禁用按钮(成功/失败都在 finally 恢复) + movebar.querySelectorAll('button').forEach(function(b) { b.disabled = true; }); + input_note_submit.disabled = true; + input_note_clear.disabled = true; + delete_note.disabled = true; + try { + var result = await moveNode(moving_node.id, target_pid, moving_node.title, moving_node.content); + if (result != null) { + moving_node = null; + renderMoveBar(); + } + } finally { + movebar.querySelectorAll('button').forEach(function(b) { b.disabled = false; }); + input_note_submit.disabled = false; + input_note_clear.disabled = false; + delete_note.disabled = false; } }