From 11993e178bb463fe9cab67c3cd373432065d8587 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 18:22:38 +0800 Subject: [PATCH 01/38] smtp dev --- smtp/_run.sh | 2 +- smtp/smtp.py | 293 ++++++++++---------------------------------- smtp/smtp.py.remote | 245 ++++++++++++++++++++++++++++++++++++ 3 files changed, 312 insertions(+), 228 deletions(-) create mode 100644 smtp/smtp.py.remote diff --git a/smtp/_run.sh b/smtp/_run.sh index 3133126..0134b6f 100755 --- a/smtp/_run.sh +++ b/smtp/_run.sh @@ -2,4 +2,4 @@ cd "$(dirname "$0")" || exit source ./venv/bin/activate #exec ./smtp.bin -exec python3 smtp.py +exec python3 smtp.py.remote diff --git a/smtp/smtp.py b/smtp/smtp.py index 2028ab3..ea45cd6 100644 --- a/smtp/smtp.py +++ b/smtp/smtp.py @@ -1,245 +1,84 @@ -""" -smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。 +#!/usr/bin/env python3 -替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。 -协议:每条消息为完整 JSON,``_route`` 字段指定路由名。 - -请求示例:: - - {"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码 - {"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费) - {"_route": "health"} → 健康检查 - -响应格式(与 dbv2 / captcha 一致):: - - {"success": true, "data": {...}} - {"success": false, "error": "..."} - -SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD -(__PASSWORD 兼容别名 SMTP_PASSWORD)。 -""" - -import json -import os -import random -import smtplib -import threading -import time +from flask import Flask, request, jsonify from email.mime.text import MIMEText - -# 手动加载 .env(支持 KEY=VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号) -_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") -_env_loaded = 0 -if os.path.isfile(_env_path): - with open(_env_path, "r") as _f: - for _line in _f: - _line = _line.strip() - if not _line or _line.startswith("#"): - continue - if _line.startswith("export "): - _line = _line[len("export "):].strip() - # 支持 KEY=VALUE 与 KEY VALUE 两种写法 - if "=" in _line: - _key, _, _val = _line.partition("=") - else: - _parts = _line.split(None, 1) - _key = _parts[0] - _val = _parts[1] if len(_parts) > 1 else "" - _key = _key.strip() - _val = _val.strip().strip("\"'") - if _key and _key not in os.environ: - os.environ[_key] = _val - _env_loaded += 1 -else: - print(f"[smtp] 警告: 未找到 .env 文件: {_env_path}") - +from email import header from log_client import logs +from dotenv import load_dotenv +import waitress +import smtplib +import random +import time +import sys, os -# pymsg 导入 -import sys as _sys -_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from pymsg import MsgServer # noqa: E402 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) -# ── 配置 ──────────────────────────────────────────────────────────────── +app = Flask(__name__) +captchas: dict[str, list[str, str]] = {} -SMTP_SERVER = os.environ.get("SMTP_SERVER", "") -SMTP_PORT = int(os.environ.get("SMTP_PORT", "465")) -SMTP_USER = os.environ.get("SMTP_USER", "") -# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名 -SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "") +@app.route("/send_email", methods=["POST"]) -CODE_LEN = 6 -CODE_TTL = 60 # 验证码有效期(秒) -MAIL_FROM = "dnfs@qsont.me" -DEFAULT_SUBJECT = "DNFS 邮箱验证" -DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。" +def send_email(): + def build_captcha() -> str: + captcha: str = "" + for i in range(0,6): + digit: int = random.randint(0,9) + captcha += str(digit) + return captcha -code_store = {} # email -> {'code': str, 'expires_at': float} -STORE_LOCK = threading.Lock() + data = request.get_json() + + recipient: str = data.get("email") + subject: str = "DNFS 邮箱验证" + captcha: str = build_captcha() + content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。" + + captchas[recipient] = ["", ""] + captchas[recipient][0] = captcha + captchas[recipient][1] = str(int(time.time())+60) + + message = MIMEText(content, "plain", "UTF-8") + message["From"] = "dnfs@qsont.me" + message["To"] = recipient + message["Subject"] = subject + + try: + server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) + server.login(SMTP_USER, __PASSWORD) + server.sendmail(SMTP_USER, [recipient], message.as_string()) + logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})") + return jsonify({"success": "验证码发送成功"}), 200 + + except Exception as err: + logs.error("smtp", f"{recipient}: 验证码发送失败({err})") + return jsonify({"error": f"验证码发送失败:{err}"}), 400 + + finally: + server.quit() -# ═══════════════════════════════════════════════════════════════════════ -# SmtpServer — 主服务类 -# ═══════════════════════════════════════════════════════════════════════ +@app.route("/verify_email", methods=["POST"]) -class SmtpServer: - """基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。""" +def verify_email(): + data = request.get_json() - def __init__(self, addr="127.0.0.1", port=8008): - self.addr = addr - self.port = port - self._server = MsgServer(addr, port) + for i in list(captchas.keys()): + if int(captchas[i][1]) < int(time.time()): + del captchas[i] - # 路由表 - self._routes = { - "send_email": self._handle_send_email, - "verify_email": self._handle_verify_email, - "health": self._handle_health, - } + email: str = data.get("email") + input_captcha: str = data.get("input_captcha") - # ── 启动 / 停止 ────────────────────────────────────────────────── - def start(self) -> None: - """启动服务(阻塞当前线程)。""" - if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): - # 只报缺失的 key 名,绝不报值 - missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER), - ("SMTP_USER", SMTP_USER), - ("__PASSWORD", SMTP_PASSWORD)) if not v] - logs.error("smtp", f"SMTP 配置缺失: {','.join(missing)}" - f"(.env 加载 {_env_loaded} 项),send_email 将始终失败") - print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)") - print(f"[smtp] 监听 tcp://{self.addr}:{self.port}") - logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}") - self._server.start(self._dispatch) - - def stop(self) -> None: - """从另一线程停止服务。""" - self._server.stop() - - # ── 消息调度 ------------------------------------------------------- - def _dispatch(self, sock, addr, text): - """解析 JSON 中的 _route,分发给对应的 handler,并回复结果。""" - try: - req = json.loads(text) - except json.JSONDecodeError as e: - self._reply(sock, False, error=f"JSON 解析失败: {e}") - return - - route = req.pop("_route", "") - handler = self._routes.get(route) - if handler is None: - self._reply(sock, False, error=f"未知路由: {route}") - return - - try: - result = handler(req) - self._reply(sock, True, data=result) - except Exception as e: - logs.error("smtp", f"路由 {route} 处理异常: {e}") - self._reply(sock, False, error=str(e)) - - def _reply(self, sock, success, data=None, error=None): - resp = {"success": success} - if data is not None: - resp["data"] = data - if error is not None: - resp["error"] = error - self._server.reply(sock, json.dumps(resp, ensure_ascii=False)) - - # ═══════════════════════════════════════════════════════════════════ - # 路由处理 - # ═══════════════════════════════════════════════════════════════════ - - def _handle_send_email(self, req): - """生成 6 位数字验证码,存入内存(60s),经 SMTP_SSL 发送邮件。 - - 可选字段 subject / content 覆盖默认标题与正文,content 中 {code} - 会被替换为验证码。 - """ - email = str(req.get("email", "")).strip() - if not email or "@" not in email: - return {"sent": False, "error": "invalid email"} - - if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): - logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送") - return {"sent": False, "error": "SMTP 服务未配置"} - - code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN)) - subject = str(req.get("subject") or DEFAULT_SUBJECT) - content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code) - - with STORE_LOCK: - code_store[email] = {'code': code, 'expires_at': time.time() + CODE_TTL} - # 惰性清理过期项 - now = time.time() - expired = [k for k, v in code_store.items() if v['expires_at'] < now] - for k in expired: - del code_store[k] - - message = MIMEText(content, "plain", "UTF-8") - message["From"] = MAIL_FROM - message["To"] = email - message["Subject"] = subject - - # server 作用域限定在 try 内;连接失败时不会误调 quit() - try: - with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server: - server.login(SMTP_USER, SMTP_PASSWORD) - server.sendmail(SMTP_USER, [email], message.as_string()) - except Exception as err: - # 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态 - with STORE_LOCK: - code_store.pop(email, None) - logs.error("smtp", f"{email}: 验证码发送失败({err})") - return {"sent": False, "error": f"验证码发送失败:{err}"} - - logs.info("smtp", f"{email}: 验证码发送成功({code})") - return {"sent": True, "ttl": CODE_TTL} - - def _handle_verify_email(self, req): - """校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。""" - email = str(req.get("email", "")).strip() - # 兼容旧字段 input_captcha - code = str(req.get("code") or req.get("input_captcha") or "").strip() - if not email or not code: - return {"valid": False, "error": "email and code required"} - - with STORE_LOCK: - entry = code_store.get(email) - if entry and entry['expires_at'] < time.time(): - del code_store[email] - entry = None - if entry and entry['code'] == code: - # 验证通过:一次性消费,防重放 - del code_store[email] - valid = True - else: - valid = False - - if valid: - logs.info("smtp", f"{email}: 验证通过") - return {"valid": True} + if captchas[email][0] == input_captcha: + logs.info("smtp", f"{email}: 验证通过") + return jsonify({"success": "验证通过"}), 200 + else: logs.info("smtp", f"{email}: 验证失败") - return {"valid": False, "error": "invalid or expired code"} + return jsonify({"error": "验证失败"}), 400 - def _handle_health(self, _req): - return {"status": "ok", "service": "smtp"} - - -# ═══════════════════════════════════════════════════════════════════════ -# 入口 -# ═══════════════════════════════════════════════════════════════════════ if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务") - parser.add_argument("--addr", default="127.0.0.1", help="监听地址") - parser.add_argument("--port", type=int, default=8008, help="监听端口") - args = parser.parse_args() - - server = SmtpServer(args.addr, args.port) - try: - server.start() - except KeyboardInterrupt: - server.stop() + logs.info("smtp", "开始监听http://127.0.0.1:8008") + print("Listen at http://127.0.0.1:8008") + waitress.serve(app, host="127.0.0.1", port=8008) \ No newline at end of file diff --git a/smtp/smtp.py.remote b/smtp/smtp.py.remote new file mode 100644 index 0000000..2028ab3 --- /dev/null +++ b/smtp/smtp.py.remote @@ -0,0 +1,245 @@ +""" +smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。 + +替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。 +协议:每条消息为完整 JSON,``_route`` 字段指定路由名。 + +请求示例:: + + {"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码 + {"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费) + {"_route": "health"} → 健康检查 + +响应格式(与 dbv2 / captcha 一致):: + + {"success": true, "data": {...}} + {"success": false, "error": "..."} + +SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD +(__PASSWORD 兼容别名 SMTP_PASSWORD)。 +""" + +import json +import os +import random +import smtplib +import threading +import time +from email.mime.text import MIMEText + +# 手动加载 .env(支持 KEY=VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号) +_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") +_env_loaded = 0 +if os.path.isfile(_env_path): + with open(_env_path, "r") as _f: + for _line in _f: + _line = _line.strip() + if not _line or _line.startswith("#"): + continue + if _line.startswith("export "): + _line = _line[len("export "):].strip() + # 支持 KEY=VALUE 与 KEY VALUE 两种写法 + if "=" in _line: + _key, _, _val = _line.partition("=") + else: + _parts = _line.split(None, 1) + _key = _parts[0] + _val = _parts[1] if len(_parts) > 1 else "" + _key = _key.strip() + _val = _val.strip().strip("\"'") + if _key and _key not in os.environ: + os.environ[_key] = _val + _env_loaded += 1 +else: + print(f"[smtp] 警告: 未找到 .env 文件: {_env_path}") + +from log_client import logs + +# pymsg 导入 +import sys as _sys +_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from pymsg import MsgServer # noqa: E402 + +# ── 配置 ──────────────────────────────────────────────────────────────── + +SMTP_SERVER = os.environ.get("SMTP_SERVER", "") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "465")) +SMTP_USER = os.environ.get("SMTP_USER", "") +# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名 +SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "") + +CODE_LEN = 6 +CODE_TTL = 60 # 验证码有效期(秒) +MAIL_FROM = "dnfs@qsont.me" +DEFAULT_SUBJECT = "DNFS 邮箱验证" +DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。" + +code_store = {} # email -> {'code': str, 'expires_at': float} +STORE_LOCK = threading.Lock() + + +# ═══════════════════════════════════════════════════════════════════════ +# SmtpServer — 主服务类 +# ═══════════════════════════════════════════════════════════════════════ + +class SmtpServer: + """基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。""" + + def __init__(self, addr="127.0.0.1", port=8008): + self.addr = addr + self.port = port + self._server = MsgServer(addr, port) + + # 路由表 + self._routes = { + "send_email": self._handle_send_email, + "verify_email": self._handle_verify_email, + "health": self._handle_health, + } + + # ── 启动 / 停止 ────────────────────────────────────────────────── + def start(self) -> None: + """启动服务(阻塞当前线程)。""" + if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): + # 只报缺失的 key 名,绝不报值 + missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER), + ("SMTP_USER", SMTP_USER), + ("__PASSWORD", SMTP_PASSWORD)) if not v] + logs.error("smtp", f"SMTP 配置缺失: {','.join(missing)}" + f"(.env 加载 {_env_loaded} 项),send_email 将始终失败") + print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)") + print(f"[smtp] 监听 tcp://{self.addr}:{self.port}") + logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}") + self._server.start(self._dispatch) + + def stop(self) -> None: + """从另一线程停止服务。""" + self._server.stop() + + # ── 消息调度 ------------------------------------------------------- + def _dispatch(self, sock, addr, text): + """解析 JSON 中的 _route,分发给对应的 handler,并回复结果。""" + try: + req = json.loads(text) + except json.JSONDecodeError as e: + self._reply(sock, False, error=f"JSON 解析失败: {e}") + return + + route = req.pop("_route", "") + handler = self._routes.get(route) + if handler is None: + self._reply(sock, False, error=f"未知路由: {route}") + return + + try: + result = handler(req) + self._reply(sock, True, data=result) + except Exception as e: + logs.error("smtp", f"路由 {route} 处理异常: {e}") + self._reply(sock, False, error=str(e)) + + def _reply(self, sock, success, data=None, error=None): + resp = {"success": success} + if data is not None: + resp["data"] = data + if error is not None: + resp["error"] = error + self._server.reply(sock, json.dumps(resp, ensure_ascii=False)) + + # ═══════════════════════════════════════════════════════════════════ + # 路由处理 + # ═══════════════════════════════════════════════════════════════════ + + def _handle_send_email(self, req): + """生成 6 位数字验证码,存入内存(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 {"valid": False, "error": "invalid or expired code"} + + def _handle_health(self, _req): + return {"status": "ok", "service": "smtp"} + + +# ═══════════════════════════════════════════════════════════════════════ +# 入口 +# ═══════════════════════════════════════════════════════════════════════ + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务") + parser.add_argument("--addr", default="127.0.0.1", help="监听地址") + parser.add_argument("--port", type=int, default=8008, help="监听端口") + args = parser.parse_args() + + server = SmtpServer(args.addr, args.port) + try: + server.start() + except KeyboardInterrupt: + server.stop() From 53c742c26ea08faa451429d59962bf6efd763d7b Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 18:36:21 +0800 Subject: [PATCH 02/38] =?UTF-8?q?refactor:=20smtp.py.remote=20=E6=9B=B4?= =?UTF-8?q?=E5=90=8D=E4=B8=BA=20smtp=5Fv2.py=EF=BC=8C=E6=81=A2=E5=A4=8D=20?= =?UTF-8?q?.py=20=E5=90=8E=E7=BC=80=E4=BB=A5=E4=BE=BF=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=93=BE=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _run.sh 同步指向新文件名;start() 新增启动时凭证明文打印(仅 stdout 不落日志库),用于排查 .env 加载问题,调试完成后需移除。 --- smtp/_run.sh | 2 +- smtp/{smtp.py.remote => smtp_v2.py} | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) rename smtp/{smtp.py.remote => smtp_v2.py} (97%) diff --git a/smtp/_run.sh b/smtp/_run.sh index 0134b6f..9a6fdd4 100755 --- a/smtp/_run.sh +++ b/smtp/_run.sh @@ -2,4 +2,4 @@ cd "$(dirname "$0")" || exit source ./venv/bin/activate #exec ./smtp.bin -exec python3 smtp.py.remote +exec python3 smtp_v2.py diff --git a/smtp/smtp.py.remote b/smtp/smtp_v2.py similarity index 97% rename from smtp/smtp.py.remote rename to smtp/smtp_v2.py index 2028ab3..cb68261 100644 --- a/smtp/smtp.py.remote +++ b/smtp/smtp_v2.py @@ -100,6 +100,10 @@ class SmtpServer: # ── 启动 / 停止 ────────────────────────────────────────────────── def start(self) -> None: """启动服务(阻塞当前线程)。""" + # 调试:打印加载到的凭证(明文,含密码;仅 print 不落日志库,调完请删) + print(f"[smtp] 凭证加载(.env 贡献 {_env_loaded} 项): " + f"SMTP_SERVER={SMTP_SERVER!r} SMTP_PORT={SMTP_PORT} " + f"SMTP_USER={SMTP_USER!r} __PASSWORD={SMTP_PASSWORD!r}") if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): # 只报缺失的 key 名,绝不报值 missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER), From f5b69d0b4c8146b6f00182cdb7a26f0f83f57fe0 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 19:08:13 +0800 Subject: [PATCH 03/38] =?UTF-8?q?fix:=20smtp=20.env=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=20KEY:=20VALUE=20=E5=86=92=E5=8F=B7=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=EF=BC=8CMAIL=5FFROM=20=E9=BB=98=E8=AE=A4=E8=B7=9F?= =?UTF-8?q?=E9=9A=8F=20SMTP=5FUSER?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务器 .env 误用 YAML 冒号格式导致凭证全部加载为空;163 等服务商强制 From 头与登录账号一致,写死的 MAIL_FROM 会触发 553,现默认取 SMTP_USER、可用 .env 显式覆盖。 --- smtp/smtp_v2.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py index cb68261..e889bc8 100644 --- a/smtp/smtp_v2.py +++ b/smtp/smtp_v2.py @@ -27,7 +27,7 @@ import threading import time from email.mime.text import MIMEText -# 手动加载 .env(支持 KEY=VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号) +# 手动加载 .env(支持 KEY=VALUE / KEY: VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号) _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") _env_loaded = 0 if os.path.isfile(_env_path): @@ -38,9 +38,11 @@ if os.path.isfile(_env_path): continue if _line.startswith("export "): _line = _line[len("export "):].strip() - # 支持 KEY=VALUE 与 KEY VALUE 两种写法 + # 支持 KEY=VALUE / KEY: VALUE / KEY VALUE 三种写法 if "=" in _line: _key, _, _val = _line.partition("=") + elif ":" in _line: + _key, _, _val = _line.partition(":") else: _parts = _line.split(None, 1) _key = _parts[0] @@ -70,7 +72,9 @@ SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", CODE_LEN = 6 CODE_TTL = 60 # 验证码有效期(秒) -MAIL_FROM = "dnfs@qsont.me" +# 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER; +# 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 +MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER DEFAULT_SUBJECT = "DNFS 邮箱验证" DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。" From f4a7aca2fab8c4b5892e24dcab2a1b98284859b4 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 19:32:44 +0800 Subject: [PATCH 04/38] =?UTF-8?q?update:=20smtp=20=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E6=9C=89=E6=95=88=E6=9C=9F=2060s=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E4=B8=BA=2010=20=E5=88=86=E9=92=9F=EF=BC=8C=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E4=BB=8E=20CODE=5FTTL=20=E6=B4=BE=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 60 秒对邮件送达延迟过于紧张;正文有效期文案改为按 TTL 自动生成避免漂移,前端提示与 README 同步。同时移除 .env 排查用的凭证明文调试打印。 --- README.md | 2 +- gateway/README.md | 4 ++-- smtp/smtp_v2.py | 9 +++------ static/public/js/register.js | 2 +- tests/blackbox/test_smtp.py | 2 +- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 79bf3fa..e614140 100755 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ ### smtp(邮件服务) - 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。 -- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,60 秒有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。 +- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。 - 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。 ### aibox(独立 AI 智能体系统) diff --git a/gateway/README.md b/gateway/README.md index dee5208..61e9d84 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -9,7 +9,7 @@ UniServe Framework 的应用层网关,C++20 实现。 - **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。 - **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 - **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 -- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(60 秒有效);注册时校验,一次性消费。 +- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。 - **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 - **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。 @@ -84,7 +84,7 @@ UniServe Framework 的应用层网关,C++20 实现。 ### `POST /register/send_code` -向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,60 秒有效)。 +向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。 ```json { "email": "user@example.com" } diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py index e889bc8..dc9840d 100644 --- a/smtp/smtp_v2.py +++ b/smtp/smtp_v2.py @@ -71,12 +71,13 @@ SMTP_USER = os.environ.get("SMTP_USER", "") SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "") CODE_LEN = 6 -CODE_TTL = 60 # 验证码有效期(秒) +CODE_TTL = 600 # 验证码有效期(秒) # 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER; # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER DEFAULT_SUBJECT = "DNFS 邮箱验证" -DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。" +# 有效期文案由 CODE_TTL 派生,改 TTL 不用同步改这里({{code}} 是 f-string 转义) +DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60} 分钟内有效。" code_store = {} # email -> {'code': str, 'expires_at': float} STORE_LOCK = threading.Lock() @@ -104,10 +105,6 @@ class SmtpServer: # ── 启动 / 停止 ────────────────────────────────────────────────── def start(self) -> None: """启动服务(阻塞当前线程)。""" - # 调试:打印加载到的凭证(明文,含密码;仅 print 不落日志库,调完请删) - print(f"[smtp] 凭证加载(.env 贡献 {_env_loaded} 项): " - f"SMTP_SERVER={SMTP_SERVER!r} SMTP_PORT={SMTP_PORT} " - f"SMTP_USER={SMTP_USER!r} __PASSWORD={SMTP_PASSWORD!r}") if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): # 只报缺失的 key 名,绝不报值 missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER), diff --git a/static/public/js/register.js b/static/public/js/register.js index 58fa28d..b54a460 100644 --- a/static/public/js/register.js +++ b/static/public/js/register.js @@ -127,7 +127,7 @@ getCodeButton.addEventListener('click', async function () { codeTarget = email; codeInput.value = ''; - codeTip.textContent = '验证码已发送至邮箱,请查收(60 秒内有效)'; + codeTip.textContent = '验证码已发送至邮箱,请查收(10 分钟内有效)'; codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); codeTip.classList.add('tip-green'); } catch (err) { diff --git a/tests/blackbox/test_smtp.py b/tests/blackbox/test_smtp.py index ada18fa..c696a7b 100644 --- a/tests/blackbox/test_smtp.py +++ b/tests/blackbox/test_smtp.py @@ -7,7 +7,7 @@ smtp 黑盒用户模拟测试 — 发送邮箱验证码 → 校验。 3. 用空参数校验 → 应报错 注意: SMTP 服务需要配置真实的邮件服务器。若服务不可达或邮件发送失败, -相关测试将标记为 SKIP。验证码有效期为 60 秒。 +相关测试将标记为 SKIP。验证码有效期为 10 分钟(600 秒)。 """ import json From c5dd8d198db603e7e2ff2f7520fcff32e0f68de7 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 20:54:56 +0800 Subject: [PATCH 05/38] =?UTF-8?q?feat(smtp):=20=E5=90=8C=E4=B8=80=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=2060=20=E7=A7=92=E5=8F=91=E9=80=81=E5=86=B7=E5=8D=B4?= =?UTF-8?q?=EF=BC=8Cgateway=20429=20=E9=80=8F=E4=BC=A0=20+=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=87=8D=E5=8F=91=E5=80=92=E8=AE=A1=E6=97=B6=EF=BC=88?= =?UTF-8?q?gateway=20v0.5.3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- gateway/README.md | 7 ++++--- gateway/src/main.cpp | 27 +++++++++++++++++++++++---- smtp/smtp_v2.py | 25 +++++++++++++++++++++++-- static/public/js/register.js | 26 +++++++++++++++++++++++++- 5 files changed, 77 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e614140..a232bc5 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.5.2) +### gateway(应用层网关 · v0.5.3) - 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。 - 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。 @@ -136,7 +136,7 @@ ### smtp(邮件服务) - 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。 -- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。 +- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。 - 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。 ### aibox(独立 AI 智能体系统) diff --git a/gateway/README.md b/gateway/README.md index 61e9d84..055e110 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -90,7 +90,7 @@ UniServe Framework 的应用层网关,C++20 实现。 { "email": "user@example.com" } ``` -限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`429`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。 +限流 5 次/10 分钟/IP;同一邮箱 60 秒发送冷却(由 smtp 服务执行,冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却)。错误码:`400`(邮箱格式错误)、`429`(IP 速率超限或同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。 ### `GET/POST *`(反向代理) @@ -226,14 +226,15 @@ gateway/ ## 版本 -`0.5.2`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印: +`0.5.3`(见 [src/main.cpp:30](src/main.cpp#L30))。启动时打印: ``` -Gateway 0.5.2 started +Gateway 0.5.3 started ``` 近期变更: +- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。 - `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。 - `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。 diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index e6b1989..391deb7 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -27,7 +27,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.5.2"; +constexpr const char *_VER = "0.5.3"; ////////////////////////////////// #include @@ -1108,6 +1108,8 @@ http::response handleSendEmailCode( // 调 smtp 服务(pymsg/TCP :8008)发送验证码 bool sent = false; + int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 + std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) try { pmc::net::MsgClient client("127.0.0.1", 8008, "gateway"); json::value jv_req = { @@ -1117,14 +1119,31 @@ http::response handleSendEmailCode( std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_SEND); auto jv_r = json::parse(resp); auto& obj_r = jv_r.as_object(); - if (obj_r.at("success").as_bool()) - sent = obj_r.at("data").as_object().at("sent").as_bool(); + if (obj_r.at("success").as_bool()) { + auto& data = obj_r.at("data").as_object(); + sent = data.at("sent").as_bool(); + if (!sent) { + auto it_ra = data.find("retry_after"); + if (it_ra != data.end() && it_ra->value().is_int64()) + retry_after = static_cast(it_ra->value().as_int64()); + auto it_err = data.find("error"); + if (it_err != data.end() && it_err->value().is_string()) + smtp_err = std::string(it_err->value().as_string()); + } + } } catch (const std::exception&) { THROW_EX_CUSTOM(HttpException, 3102, "Mail service unavailable", 502, "/register/send_code"); } - if (!sent) + if (!sent) { + // 同邮箱发送冷却:按限流语义返回 429 并透传 smtp 提示文案; + // 其余发送失败(含 SMTP 内部错误细节)仍用通用文案,不向外暴露 + if (retry_after > 0) + THROW_EX_CUSTOM(HttpException, 3103, + smtp_err.empty() ? "Too many code requests. Please try again later." : smtp_err, + 429, "/register/send_code"); THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/register/send_code"); + } logs().info("gateway", "验证码邮件发送成功: " + email, std::nullopt, json::serialize(json::value{{"ip", client_ip}})); diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py index dc9840d..3007fa1 100644 --- a/smtp/smtp_v2.py +++ b/smtp/smtp_v2.py @@ -72,6 +72,7 @@ SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", CODE_LEN = 6 CODE_TTL = 600 # 验证码有效期(秒) +SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒) # 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER; # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER @@ -80,6 +81,7 @@ DEFAULT_SUBJECT = "DNFS 邮箱验证" DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60} 分钟内有效。" code_store = {} # email -> {'code': str, 'expires_at': float} +send_history = {} # email -> 上次成功发送的时间戳(发送冷却用) STORE_LOCK = threading.Lock() @@ -156,10 +158,11 @@ class SmtpServer: # ═══════════════════════════════════════════════════════════════════ def _handle_send_email(self, req): - """生成 6 位数字验证码,存入内存(60s),经 SMTP_SSL 发送邮件。 + """生成 6 位数字验证码,存入内存(TTL 600s),经 SMTP_SSL 发送邮件。 可选字段 subject / content 覆盖默认标题与正文,content 中 {code} - 会被替换为验证码。 + 会被替换为验证码。同一邮箱 SEND_INTERVAL 秒内仅允许成功发送一次, + 冷却期内的请求直接拒绝且不覆盖已发出的有效验证码。 """ email = str(req.get("email", "")).strip() if not email or "@" not in email: @@ -169,6 +172,16 @@ class SmtpServer: logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送") return {"sent": False, "error": "SMTP 服务未配置"} + # 发送冷却:同一邮箱 SEND_INTERVAL 秒内仅放行一次;发送失败不计入, + # 用户可立即重试 + with STORE_LOCK: + remain = SEND_INTERVAL - (time.time() - send_history.get(email, 0.0)) + if remain > 0: + wait = int(remain) + 1 + logs.info("smtp", f"{email}: 发送冷却中,剩余 {wait}s,已拒绝") + return {"sent": False, "retry_after": wait, + "error": f"发送过于频繁,请 {wait} 秒后重试"} + code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN)) subject = str(req.get("subject") or DEFAULT_SUBJECT) content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code) @@ -198,6 +211,14 @@ class SmtpServer: logs.error("smtp", f"{email}: 验证码发送失败({err})") return {"sent": False, "error": f"验证码发送失败:{err}"} + # 发送成功:记录冷却起点,并惰性清理已失效的冷却记录 + with STORE_LOCK: + send_history[email] = time.time() + stale = [k for k, v in send_history.items() + if time.time() - v > SEND_INTERVAL] + for k in stale: + del send_history[k] + logs.info("smtp", f"{email}: 验证码发送成功({code})") return {"sent": True, "ttl": CODE_TTL} diff --git a/static/public/js/register.js b/static/public/js/register.js index b54a460..86380f0 100644 --- a/static/public/js/register.js +++ b/static/public/js/register.js @@ -10,6 +10,26 @@ const codeTip = document.getElementById('codeTip'); const submitButton = document.getElementById('formsub'); let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手) +let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐) + +// 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却 +function startResendCooldown(seconds) { + clearInterval(cooldownTimer); + let remain = seconds; + getCodeButton.disabled = true; + getCodeButton.value = `${remain}s 后重发`; + cooldownTimer = setInterval(function () { + remain -= 1; + if (remain <= 0) { + clearInterval(cooldownTimer); + cooldownTimer = null; + getCodeButton.disabled = false; + getCodeButton.value = '获取验证码'; + } else { + getCodeButton.value = `${remain}s 后重发`; + } + }, 1000); +} form.addEventListener('reset', function () { clearTipStyle(); @@ -114,12 +134,14 @@ getCodeButton.addEventListener('click', async function () { codeTip.textContent = '验证码发送中…'; codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); codeTip.classList.add('tip-yellow'); + let httpStatus = 0; try { const res = await fetch('/register/send_code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) }); + httpStatus = res.status; const responseText = await res.text(); let result; try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); } @@ -130,12 +152,14 @@ getCodeButton.addEventListener('click', async function () { codeTip.textContent = '验证码已发送至邮箱,请查收(10 分钟内有效)'; codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); codeTip.classList.add('tip-green'); + startResendCooldown(60); } catch (err) { codeTip.textContent = err.message || '验证码发送失败,请稍后重试'; codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); codeTip.classList.add('tip-red'); + if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐 } finally { - getCodeButton.disabled = false; + if (!cooldownTimer) getCodeButton.disabled = false; } }); From 3c02ba43adaea969ee4feb06d7e1749f52bd7bc6 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 21:23:04 +0800 Subject: [PATCH 06/38] =?UTF-8?q?AskUserTool=20=E5=86=85=E7=BD=AE=E6=8A=80?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aibox/main.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/aibox/main.py b/aibox/main.py index 469b245..5ba567d 100644 --- a/aibox/main.py +++ b/aibox/main.py @@ -4268,6 +4268,36 @@ class ReloadSkillsTool(Tool): output_queue.put(("complete", result)) +# ==================== 询问工具 ======================== +class AskUserTool(Tool): + """智能体遇到了麻烦,主动向用户问一个问题""" + + def get_name(self) -> str: + return "ask_user" + + def get_description(self) -> str: + return "你遇到了一个问题,想向用户询问一个问题的工具。" + + def get_parameters(self) -> Dict: + ''' TODO: 调用子代理生成提示词 ''' + return { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": ( + "你现在遇到一个问题,你想询问用户,现在提供你的问题。" + ) + } + }, + "required": [] + } + + def execute(self, question: str, **kwargs) -> str: + return ( + "你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。" + ) + # ==================== 安装技能工具 ==================== class InstallSkillTool(Tool): @@ -6547,6 +6577,9 @@ class DeepSeekChat: # 增强版保存记忆并结束对话工具 if exit_callback and self.memory_db: builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger)) + + # 添加询问用户的工具 + builtin_tools.append(AskUserTool(self.logger)) # 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具 builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger)) From e6e5dab4cdd292506aa2ea1dbb5414a21dfb611a Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 21:54:22 +0800 Subject: [PATCH 07/38] =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E6=8F=90?= =?UTF-8?q?=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aibox/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/aibox/main.py b/aibox/main.py index 5ba567d..eae4ce1 100644 --- a/aibox/main.py +++ b/aibox/main.py @@ -2894,9 +2894,12 @@ class TaskScheduler: 2. 根据任务内容进行适当的操作和回答 3. 任务处理完成后,如果不需要继续对话,可以结束对话 4. 系统已经自动更新了任务状态(更新了执行时间和次数),你不需要手动处理任务完成逻辑 + 5. 不要在任务执行过程中创建新的定时任务(除非任务明确要求),也不要向用户提问(因为此对话是自动触发的) 请现在开始处理这个任务。 """ + + user_input = focus_prompt + "\n\n" + user_input result = "任务已触发AI对话" @@ -6852,7 +6855,11 @@ class DeepSeekChat: ## 第二步:搜索相关记忆 在每次回答用户问题前,**必须**使用 `memory search` 工具搜索记忆库中与当前话题相关的记忆,确保回答的连贯性和准确性。 - ## 第三步:执行任务或回答问题 + ## 第三步:积极向用户提问 + 如果你有任何无法确定的事情,那么你需要用户提供额外信息, + 请务必使用 `ask_user` 工具向用户提问,以便获得更准确的指引。 + + ## 第四步:执行任务或回答问题 根据搜索结果,选择合适的工具或直接回答。 【重要提醒】 From 5275b8bf752eec1094ec9efbcc50c60b8d49646a Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 21:57:16 +0800 Subject: [PATCH 08/38] =?UTF-8?q?feat(smtp):=20=E9=82=AE=E7=AE=B1=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E8=BF=9E=E7=BB=AD=E6=A0=A1=E9=AA=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=205=20=E6=AC=A1=E8=87=AA=E5=8A=A8=E4=BD=9C=E5=BA=9F?= =?UTF-8?q?=EF=BC=8C=E9=98=B2=E6=9E=9A=E4=B8=BE=206=20=E4=BD=8D=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- gateway/README.md | 2 +- smtp/smtp_v2.py | 24 +++++++++++++++++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a232bc5..e3e8c91 100755 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ ### smtp(邮件服务) - 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。 -- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。 +- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,连续失败 5 次自动作废并返回 `invalidated`,兼容旧字段 `input_captcha`)、`health`。 - 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。 ### aibox(独立 AI 智能体系统) diff --git a/gateway/README.md b/gateway/README.md index 055e110..66b643d 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -78,7 +78,7 @@ UniServe Framework 的应用层网关,C++20 实现。 { "username": "user@example.com", "password": "...", "email_code": "123456" } ``` -校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。 +校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,连续失败 5 次自动作废,fail-closed),全部通过后写入 `Users` 表。 错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。 diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py index 3007fa1..de9edde 100644 --- a/smtp/smtp_v2.py +++ b/smtp/smtp_v2.py @@ -73,6 +73,7 @@ SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", CODE_LEN = 6 CODE_TTL = 600 # 验证码有效期(秒) SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒) +MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码) # 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER; # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER @@ -80,7 +81,7 @@ DEFAULT_SUBJECT = "DNFS 邮箱验证" # 有效期文案由 CODE_TTL 派生,改 TTL 不用同步改这里({{code}} 是 f-string 转义) DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60} 分钟内有效。" -code_store = {} # email -> {'code': str, 'expires_at': float} +code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int} send_history = {} # email -> 上次成功发送的时间戳(发送冷却用) STORE_LOCK = threading.Lock() @@ -187,7 +188,9 @@ class SmtpServer: 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} + code_store[email] = {'code': code, + 'expires_at': time.time() + CODE_TTL, + 'attempts': 0} # 惰性清理过期项 now = time.time() expired = [k for k, v in code_store.items() if v['expires_at'] < now] @@ -223,13 +226,18 @@ class SmtpServer: return {"sent": True, "ttl": CODE_TTL} def _handle_verify_email(self, req): - """校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。""" + """校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。 + + 同一验证码连续校验失败达 MAX_VERIFY_ATTEMPTS 次即作废删除(响应带 + invalidated 标记),防止对 6 位数字码的在线枚举。 + """ email = str(req.get("email", "")).strip() # 兼容旧字段 input_captcha code = str(req.get("code") or req.get("input_captcha") or "").strip() if not email or not code: return {"valid": False, "error": "email and code required"} + invalidated = False with STORE_LOCK: entry = code_store.get(email) if entry and entry['expires_at'] < time.time(): @@ -241,10 +249,20 @@ class SmtpServer: valid = True else: valid = False + if entry: + # 仅对已发出且未过期的验证码计失败次数;达到上限即作废 + entry['attempts'] = entry.get('attempts', 0) + 1 + if entry['attempts'] >= MAX_VERIFY_ATTEMPTS: + del code_store[email] + invalidated = True if valid: logs.info("smtp", f"{email}: 验证通过") return {"valid": True} + if invalidated: + logs.info("smtp", f"{email}: 连续校验失败 {MAX_VERIFY_ATTEMPTS} 次,验证码已作废") + return {"valid": False, "invalidated": True, + "error": f"验证码连续错误 {MAX_VERIFY_ATTEMPTS} 次,已失效,请重新获取"} logs.info("smtp", f"{email}: 验证失败") return {"valid": False, "error": "invalid or expired code"} From 949d274c22b4da8b9d6c6be25d49a31dfb43eb8a Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 08:22:53 +0800 Subject: [PATCH 09/38] =?UTF-8?q?=E8=8E=B7=E5=8F=96=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=90=8E=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/public/js/dnfs.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 2225a36..f5fcfa2 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -48,8 +48,9 @@ async function getNode(id) { //} } catch (err) { - alert(err.message || '接口请求失败,请检查后端服务是否启动'); + // 获取节点失败(非 JSON 响应 / HTTP 错误 / 网络异常),统一跳转登录页 console.error('获取节点请求异常:', err); + location.href = '/login.html'; } finally { } } From e9f9860fe177f6c9c5ced32f3cc2e86179bfd00c Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 08:38:14 +0800 Subject: [PATCH 10/38] =?UTF-8?q?fix(dnfs):=20review=5Fcontent=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E5=B8=83=E5=B0=94/=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E7=BB=93=E6=9E=9C=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=86=85=E5=AE=B9=E8=AF=AF=E6=8B=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅明确判定安全(布尔 false 或字符串 "false")才放行,其余一律拦截, 保持 fail-closed;同时消除 invalid 字段缺失时的 KeyError。 --- dnfs/dnfs.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index d317e2c..cf10c37 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -85,11 +85,9 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]: try: with urllib.request.urlopen(req, timeout=timeout) as resp: resp_data = resp.read().decode("utf-8") - data = json.loads(resp_data) - invalid: bool = data['invalid'] - return invalid == False - #invalid = data - #return invalid + data = json.loads(resp_data) + # 兼容布尔/字符串两种返回类型:仅明确判定安全才放行,其余一律拦截(fail-closed) + return data.get('invalid') in (False, 'false') except urllib.error.HTTPError as e: # 服务端返回 4xx/5xx 错误时,尝试解析错误正文 From 46c3a197be2bd16289a22737abbdfabdf041d347 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 09:08:33 +0800 Subject: [PATCH 11/38] =?UTF-8?q?refactor(auth):=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=88=A4=E5=AE=9A=E4=B8=8A=E7=A7=BB=E8=87=B3=20gatewa?= =?UTF-8?q?y=EF=BC=8C=E7=BB=8F=20Token=20payload=20=E4=B8=8B=E5=8F=91=20is?= =?UTF-8?q?=5Fadmin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gateway 登录签发 Token 时,账号命中 [Admin] users 白名单即在 payload 写入 is_admin=true(随 RSA 签名保护);dnfs 移除本地 ADMIN_USERS 硬编码, delete_note/update_note 豁免判断改读 payload 的 is_admin 字段。 旧 Token 无此字段按非管理员处理(安全方向)。 --- dnfs/dnfs.py | 9 +++++---- gateway/example.ini | 6 ++++++ gateway/src/main.cpp | 10 +++++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index cf10c37..6fdd957 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -37,8 +37,9 @@ from pymsg import MsgClient # noqa: E402 _DBV2_ADDR = '127.0.0.1' _DBV2_PORT = 8005 -# 管理员账号:删除/更新节点时豁免所有权校验(保留旧的 'admin' 以兼容历史数据) -ADMIN_USERS = ('admin@qsont.me', 'qing@qsont.me') +# 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload +# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。 +# 旧 Token 无此字段,按非管理员处理(安全方向)。 def _call_dbv2(payload: dict, timeout: int = 10) -> dict: """通过 pymsg 短连接调用 dbv2 数据库服务。 @@ -326,7 +327,7 @@ def delete_note(): data = _call_dbv2(select_payload) notes = data['data'] - if info['user'] not in ADMIN_USERS: + if not info.get('is_admin'): if not len(notes) == 1 or not notes[0]['owner'] == info['user']: logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']}) return jsonify({"error": 'It\'s not your note'}), 403 @@ -401,7 +402,7 @@ def update_note(): data = _call_dbv2(select_payload) notes = data['data'] - if info['user'] not in ADMIN_USERS: + if not info.get('is_admin'): if not len(notes) == 1 or not notes[0]['owner'] == info['user']: logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']}) return jsonify({"error": 'It\'s not your note'}), 403 diff --git a/gateway/example.ini b/gateway/example.ini index f6fbd24..f26f27d 100644 --- a/gateway/example.ini +++ b/gateway/example.ini @@ -15,6 +15,12 @@ opened = http://127.0.0.1:9200;http://127.0.0.1:8006; # Token 有效期(秒),默认 3600 = 1 小时 keep_time = 3600 +[Admin] +# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true, +# 下游服务(dnfs 等)凭该字段豁免所有权校验,无需各自维护名单。 +# 修改后需重启 gateway 生效;旧 Token 无此字段,按非管理员处理。 +users = qing@qsont.me + [CORS] # 允许跨域的 Origin 白名单(分号分隔) # 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 391deb7..eae3dcf 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -325,6 +326,7 @@ static std::vector _OPEN_URL; static std::string _DB_PATH; static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置 static std::vector _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置 +static std::vector _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin /* instance items */ static std::unique_ptr _SERVE; @@ -498,6 +500,7 @@ int main(int argc, char **argv) { _DB_PATH = cf.get("Database.path", ""); _TOKEN_KEEP_TIME = cf.get("Token.keep_time", 3600); _CORS_ORIGINS = split(cf.get("CORS.allowed_origins", ""), ';'); + _ADMIN_USERS = split(cf.get("Admin.users", ""), ';'); } @@ -867,10 +870,15 @@ http::response handleLogin( /* 生成令牌 */ auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem()); + /* 管理员判定:账号命中 [Admin] users 白名单即在 payload 中签发 is_admin, + * 下游服务(dnfs 等)直接读取该字段豁免所有权校验,无需各自维护名单 */ + bool is_admin = std::find(_ADMIN_USERS.begin(), _ADMIN_USERS.end(), username) != _ADMIN_USERS.end(); + json::value jv_t = { {"user", username}, {"created_at", LocalTime::getCurrent()}, - {"keep_time", _TOKEN_KEEP_TIME} + {"keep_time", _TOKEN_KEEP_TIME}, + {"is_admin", is_admin} }; std::string data = json::serialize(jv_t); std::string auth = Base64::base64_encode(data); From b4e1cf462a99e3dec63f17890ad36aab7094369b Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 09:17:28 +0800 Subject: [PATCH 12/38] =?UTF-8?q?feat(dnfs):=20get=5Fnotes=20=E6=8C=89?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E7=94=A8=E6=88=B7=E6=B3=A8=E5=85=A5=20can=5F?= =?UTF-8?q?operate=EF=BC=8C=E5=89=8D=E7=AB=AF=E6=8C=89=E6=9D=83=E6=98=BE?= =?UTF-8?q?=E9=9A=90=E7=BC=96=E8=BE=91/=E5=88=A0=E9=99=A4=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dnfs get_notes 可选解析 Authorization(畸形 Token try 兜底),为每个节点 附带 can_operate 标记(is_admin 或 owner,口径与 delete/update 一致); dnfs.js 卡片编辑/删除按钮仅 can_operate === true 时渲染,未登录或无权隐藏。 --- dnfs/dnfs.py | 17 +++++++++++++++++ static/public/js/dnfs.js | 40 +++++++++++++++++++++++----------------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 6fdd957..c9733fb 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -252,6 +252,23 @@ def get_notes(): result = notes['data'] + # 解析当前用户(可选):为每个节点附带操作权标记 can_operate, + # 前端据此显隐编辑/删除按钮。口径与 delete_note / update_note 一致: + # 管理员(is_admin)或节点 owner 有权。 + # get_notes 保持公开:未携带 Token 或 Token 解析失败时,所有节点一律无权。 + info = None + auth: str = request.headers.get('Authorization', '') + if auth.startswith('Bearer '): + try: + info = decode_token(auth[7:]) + except Exception: + info = None + + for note in result: + note['can_operate'] = bool( + info and (info.get('is_admin') or note.get('owner') == info.get('user')) + ) + logs.info('dnfs', f'get_notes 查询 pid={pid} 返回{len(result)}条', extra={'ip': request.remote_addr}) return jsonify(result), 200 diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index f5fcfa2..bd52ec1 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -560,16 +560,21 @@ 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 防止触发卡片自身的点入跳转) + // 卡片操作按钮:编辑 / 移动 / 删除(stopPropagation 防止触发卡片自身的点入跳转) + // 编辑与删除按 can_operate 显隐:操作权由后端 get_notes 按 owner / is_admin 判定, + // 与 delete_note / update_note 鉴权口径一致;未登录或无权时隐藏 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); - }); + if (item.can_operate === true) { + 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); + }); + act_div.appendChild(edit_btn); + } let move_btn = document.createElement('button'); move_btn.className = "act-btn act-move"; move_btn.innerHTML = ICON_MOVE + "移动"; @@ -577,16 +582,17 @@ function load_notes(main_node) { event.stopPropagation(); startMove(item); }); - let del_btn = document.createElement('button'); - del_btn.className = "act-btn act-del"; - del_btn.innerHTML = ICON_TRASH + "删除"; - del_btn.addEventListener('click', function(event) { - event.stopPropagation(); - deleteChild(item.id); - }); - act_div.appendChild(edit_btn); act_div.appendChild(move_btn); - act_div.appendChild(del_btn); + if (item.can_operate === true) { + 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(del_btn); + } ttl.appendChild(wrt_div); ttl.appendChild(sts_div); let ctt = document.createElement('p'); From 1388fcb72b7b639a4bac9e4c75faf77a0b996fd0 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 09:21:15 +0800 Subject: [PATCH 13/38] =?UTF-8?q?feat(dnfs):=20=E5=8D=A1=E7=89=87=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E6=8C=89=E9=92=AE=E4=B8=80=E5=B9=B6=E7=BA=B3=E5=85=A5?= =?UTF-8?q?=20can=5Foperate=20=E6=9D=83=E9=99=90=E6=98=BE=E9=9A=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编辑/移动/删除三个按钮合并为单次权限判断(移动本质也是 update_note), 未登录或无权时不渲染整个按钮组。 --- static/public/js/dnfs.js | 41 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index bd52ec1..a713838 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -561,11 +561,17 @@ function load_notes(main_node) { sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count)); sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count)); // 卡片操作按钮:编辑 / 移动 / 删除(stopPropagation 防止触发卡片自身的点入跳转) - // 编辑与删除按 can_operate 显隐:操作权由后端 get_notes 按 owner / is_admin 判定, - // 与 delete_note / update_note 鉴权口径一致;未登录或无权时隐藏 - let act_div = document.createElement('div'); - act_div.className = "card-actions"; + // 按钮组整体按 can_operate 显隐:操作权由后端 get_notes 按 owner / is_admin 判定, + // 与 delete_note / update_note 鉴权口径一致(移动本质也是 update_note); + // 未登录或无权时不渲染整个按钮组 + ttl.appendChild(wrt_div); + ttl.appendChild(sts_div); + let ctt = document.createElement('p'); + renderMarkdown(ctt, item.content); + card.appendChild(ctt) if (item.can_operate === true) { + let act_div = document.createElement('div'); + act_div.className = "card-actions"; let edit_btn = document.createElement('button'); edit_btn.className = "act-btn act-edit"; edit_btn.innerHTML = ICON_EDIT + "编辑"; @@ -573,17 +579,13 @@ function load_notes(main_node) { event.stopPropagation(); startEdit(item); }); - act_div.appendChild(edit_btn); - } - let move_btn = document.createElement('button'); - move_btn.className = "act-btn act-move"; - move_btn.innerHTML = ICON_MOVE + "移动"; - move_btn.addEventListener('click', function(event) { - event.stopPropagation(); - startMove(item); - }); - act_div.appendChild(move_btn); - if (item.can_operate === true) { + let move_btn = document.createElement('button'); + move_btn.className = "act-btn act-move"; + move_btn.innerHTML = ICON_MOVE + "移动"; + move_btn.addEventListener('click', function(event) { + event.stopPropagation(); + startMove(item); + }); let del_btn = document.createElement('button'); del_btn.className = "act-btn act-del"; del_btn.innerHTML = ICON_TRASH + "删除"; @@ -591,14 +593,11 @@ function load_notes(main_node) { event.stopPropagation(); deleteChild(item.id); }); + act_div.appendChild(edit_btn); + act_div.appendChild(move_btn); act_div.appendChild(del_btn); + card.appendChild(act_div); } - 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 8c1956cdebbde7e4585190fc832b3a3f722f2ad8 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 09:31:28 +0800 Subject: [PATCH 14/38] =?UTF-8?q?feat(dnfs):=20=E5=B7=A6=E4=B8=8A=E8=A7=92?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BF=A1=E6=81=AF=E9=9D=A2=E6=9D=BF=EF=BC=88?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=8A=98=E5=8F=A0=EF=BC=89=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20/whoami=20=E8=BA=AB=E4=BB=BD=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dnfs 新增 POST /whoami:解 Token payload 返回 user/is_admin,无或畸形 Token 返回 login=false(HttpOnly Cookie 模式下 JS 无法读 Token,身份 必须由后端下发);static 加 /whoami 代理(Cookie 转 Bearer);前端 dnfs.html 左上新增 userpanel,镜像 minimap 样式默认折叠,展开显示 用户名/角色/退出登录(POST /logout 清 Cookie 后跳登录页)。 --- dnfs/dnfs.py | 22 ++++++++++++++ static/fsys.py | 16 ++++++++++ static/public/css/theme.css | 39 ++++++++++++++++++++---- static/public/dnfs.html | 1 + static/public/js/dnfs.js | 59 +++++++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 6 deletions(-) diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index c9733fb..60c49c8 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -273,6 +273,28 @@ def get_notes(): return jsonify(result), 200 +# 当前用户身份查询:配合前端用户面板,返回 user / is_admin。 +# 与 get_notes 同款可选解析:无 Token 或畸形 Token 按未登录处理(不 500)。 +@app.route('/whoami', methods=["POST"]) +def whoami(): + info = None + auth: str = request.headers.get('Authorization', '') + if auth.startswith('Bearer '): + try: + info = decode_token(auth[7:]) + except Exception: + info = None + + if info is None: + return jsonify({'login': False}), 200 + + return jsonify({ + 'login': True, + 'user': info.get('user'), + 'is_admin': bool(info.get('is_admin')) + }), 200 + + # eg: {"id": 1} @app.route('/delete_note', methods=['POST']) diff --git a/static/fsys.py b/static/fsys.py index 4ced9a8..dbbf611 100644 --- a/static/fsys.py +++ b/static/fsys.py @@ -474,6 +474,22 @@ def proxy_update_note(): return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"})) +@app.route('/whoami', methods=['POST', 'OPTIONS']) +def proxy_whoami(): + if request.method == "OPTIONS": return _cors(make_response("", 204)) + try: + data = request.get_json(force=True, silent=True) or {} + h = {"Content-Type": "application/json"} + if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"] + elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer + req = _urlreq.Request(f"{DNFS_URL}/whoami", data=_json.dumps(data).encode(), headers=h, method="POST") + with _urlreq.urlopen(req, timeout=15) as r: + return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"})) + except Exception as e: + logs.warning("static", "dnfs whoami 代理失败", extra={"error": str(e)}) + return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"})) + + @app.route('/api/proxy_send_code', methods=['POST']) def proxy_send_code(): from flask import request, jsonify diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 1e4ccfe..092dab6 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -365,6 +365,33 @@ input[type="button"]:disabled { font-weight: 600; } +/* ===== User Panel (dnfs 左上角用户信息,镜像 minimap 样式) ===== */ +.userpanel { + position: fixed; + left: 1rem; + top: 1rem; + z-index: 100; + display: flex; + flex-direction: column; + gap: 0.4rem; + max-height: 80vh; + overflow-y: auto; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-md); + background: rgba(253, 246, 238, 0.72); + border: 1px solid var(--border-light); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: var(--shadow-md); + transition: background 0.2s, border-color 0.2s; +} +@media (prefers-color-scheme: dark) { + .userpanel { + background: rgba(30, 23, 18, 0.72); + border-color: var(--border); + } +} + /* ===== Minimap (dnfs) ===== */ .minimap { position: fixed; @@ -392,8 +419,8 @@ input[type="button"]:disabled { border-color: var(--border); } } -.minimap:empty { display: none; } -.minimap .tag { +.minimap:empty, .userpanel:empty { display: none; } +.minimap .tag, .userpanel .tag { display: inline-block; max-width: 140px; overflow: hidden; @@ -416,18 +443,18 @@ input[type="button"]:disabled { border-radius: 4px; } .minimap::-webkit-scrollbar-thumb:hover { background: var(--muted); } -.minimap .tag:hover { +.minimap .tag:hover, .userpanel .tag:hover { color: var(--accent-dark); background: var(--accent-soft); border-color: var(--accent); } -.minimap .tag.back { +.minimap .tag.back, .userpanel .tag.back { color: var(--accent); background: var(--accent-soft); border-color: var(--accent); margin-bottom: 0.25rem; } -.minimap .tag.fold-btn { +.minimap .tag.fold-btn, .userpanel .tag.fold-btn { color: var(--fg-secondary); background: transparent; border: 1px solid var(--border); @@ -438,7 +465,7 @@ input[type="button"]:disabled { cursor: pointer; transition: color 0.15s, background 0.15s, border-color 0.15s; } -.minimap .tag.fold-btn:hover { +.minimap .tag.fold-btn:hover, .userpanel .tag.fold-btn:hover { color: var(--accent-dark); background: var(--accent-soft); border-color: var(--accent); diff --git a/static/public/dnfs.html b/static/public/dnfs.html index 56d0f4e..31286b3 100644 --- a/static/public/dnfs.html +++ b/static/public/dnfs.html @@ -9,6 +9,7 @@
+
diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index a713838..d52f8ef 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -654,5 +654,64 @@ function loadMap(children_json) { } } +/* by qing — 用户信息面板:左上角,默认折叠;展示用户名 / 角色 / 退出登录。 + * 身份来自 dnfs /whoami(HttpOnly Cookie 模式下 JS 无法读 Token,由后端解 payload 返回) */ +var userpanel = document.getElementById("userpanel"); +var userpanel_expand = false; +var current_user = null; + +async function loadUserPanel() { + try { + const res = await fetch('/whoami', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: '{}' + }); + if (!res.ok) return; + current_user = await res.json(); + renderUserPanel(); + } catch (e) { + console.error('获取用户信息失败:', e); + } +} + +function renderUserPanel() { + if (current_user == null) return; + userpanel.replaceChildren(); + let fold = document.createElement("div"); + fold.className = "tag fold-btn"; + if (userpanel_expand) { + fold.textContent = "v 折叠"; + } else { + fold.textContent = "> " + (current_user.login ? current_user.user : "未登录"); + } + fold.addEventListener("click", () => { + userpanel_expand = !userpanel_expand; + renderUserPanel(); + }); + userpanel.appendChild(fold); + if (!userpanel_expand || !current_user.login) return; + + let name_tag = document.createElement("div"); + name_tag.className = "tag"; + name_tag.textContent = current_user.user; + userpanel.appendChild(name_tag); + + let role_tag = document.createElement("div"); + role_tag.className = "tag"; + role_tag.textContent = current_user.is_admin ? "管理员" : "普通用户"; + userpanel.appendChild(role_tag); + + let logout_tag = document.createElement("div"); + logout_tag.className = "tag back"; + logout_tag.textContent = "退出登录"; + logout_tag.addEventListener("click", async () => { + try { await fetch('/logout', {method: 'POST'}); } catch (e) {} + location.href = '/login.html'; + }); + userpanel.appendChild(logout_tag); +} + getNode(0).then(load_notes); getNode(0).then(loadMap) +loadUserPanel(); From 55b127747a1d9a072313dc28216d277664199a1c Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 09:34:53 +0800 Subject: [PATCH 15/38] =?UTF-8?q?fix(dnfs):=20=E7=94=A8=E6=88=B7=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=94=A8=E6=88=B7=E5=90=8D/=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=BA=AF=E5=B1=95=E7=A4=BA=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E9=99=A4=E6=89=8B=E5=9E=8B=E5=85=89=E6=A0=87=E4=B8=8E=20hover?= =?UTF-8?q?=20=E5=8F=98=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 .tag.no-hover 修饰类还原交互态;折叠按钮与退出登录保持可点样式。 --- static/public/css/theme.css | 9 +++++++++ static/public/js/dnfs.js | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 092dab6..be578fa 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -420,6 +420,15 @@ input[type="button"]:disabled { } } .minimap:empty, .userpanel:empty { display: none; } +/* userpanel 纯展示项(用户名/角色):不可点击,无手型光标与 hover 变色 */ +.userpanel .tag.no-hover { + cursor: default; +} +.userpanel .tag.no-hover:hover { + color: var(--fg-secondary); + background: rgba(232, 133, 58, 0.08); + border-color: transparent; +} .minimap .tag, .userpanel .tag { display: inline-block; max-width: 140px; diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index d52f8ef..50a2285 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -693,12 +693,12 @@ function renderUserPanel() { if (!userpanel_expand || !current_user.login) return; let name_tag = document.createElement("div"); - name_tag.className = "tag"; + name_tag.className = "tag no-hover"; name_tag.textContent = current_user.user; userpanel.appendChild(name_tag); let role_tag = document.createElement("div"); - role_tag.className = "tag"; + role_tag.className = "tag no-hover"; role_tag.textContent = current_user.is_admin ? "管理员" : "普通用户"; userpanel.appendChild(role_tag); From a58a0f97e3919b3f3d01569c78218f3db2219a78 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 09:40:58 +0800 Subject: [PATCH 16/38] =?UTF-8?q?feat(dnfs):=20minimap=20=E4=B8=8E?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=9D=A2=E6=9D=BF=E6=8C=89=E8=A7=86=E5=8F=A3?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6=E5=86=B3=E5=AE=9A=E5=88=9D=E5=A7=8B=E6=8A=98?= =?UTF-8?q?=E5=8F=A0=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 PANEL_EXPAND_MIN_WIDTH=768 断点:宽屏默认展开、窄屏默认折叠避免 遮挡内容;仅决定初始状态,用户手动折叠/展开不被 resize 覆盖。 --- static/public/js/dnfs.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 50a2285..1282fe6 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -439,7 +439,9 @@ var recommend_list = document.getElementById('recommend-list'); var delete_note = document.getElementById('delete-note'); var minimap = document.getElementById("minimap"); var movebar = document.getElementById("movebar"); -var is_expand = true; +/* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */ +var PANEL_EXPAND_MIN_WIDTH = 768; +var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */ var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */ @@ -657,7 +659,7 @@ function loadMap(children_json) { /* by qing — 用户信息面板:左上角,默认折叠;展示用户名 / 角色 / 退出登录。 * 身份来自 dnfs /whoami(HttpOnly Cookie 模式下 JS 无法读 Token,由后端解 payload 返回) */ var userpanel = document.getElementById("userpanel"); -var userpanel_expand = false; +var userpanel_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; // 与 minimap 同策略:窄屏默认折叠 var current_user = null; async function loadUserPanel() { From 5c33dbe0276368a1a2e3a86d49c55a833644e42a Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 09:44:15 +0800 Subject: [PATCH 17/38] =?UTF-8?q?style(dnfs):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E9=9D=A2=E6=9D=BF=E7=A7=BB=E5=88=B0=E5=B7=A6?= =?UTF-8?q?=E8=BE=B9=E4=B8=AD=E9=97=B4=EF=BC=8C=E4=B8=8E=E5=8F=B3=E4=BE=A7?= =?UTF-8?q?=20minimap=20=E9=95=9C=E5=83=8F=E5=AF=B9=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/public/css/theme.css | 5 +++-- static/public/js/dnfs.js | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index be578fa..5a30d4f 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -365,11 +365,12 @@ input[type="button"]:disabled { font-weight: 600; } -/* ===== User Panel (dnfs 左上角用户信息,镜像 minimap 样式) ===== */ +/* ===== User Panel (dnfs 左边中间用户信息,镜像 minimap 样式) ===== */ .userpanel { position: fixed; left: 1rem; - top: 1rem; + top: 50%; + transform: translateY(-50%); z-index: 100; display: flex; flex-direction: column; diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 1282fe6..25a2207 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -656,7 +656,7 @@ function loadMap(children_json) { } } -/* by qing — 用户信息面板:左上角,默认折叠;展示用户名 / 角色 / 退出登录。 +/* by qing — 用户信息面板:左边中间,默认折叠;展示用户名 / 角色 / 退出登录。 * 身份来自 dnfs /whoami(HttpOnly Cookie 模式下 JS 无法读 Token,由后端解 payload 返回) */ var userpanel = document.getElementById("userpanel"); var userpanel_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; // 与 minimap 同策略:窄屏默认折叠 From 40175eddb5329f4f51c4d8e4ec687d002cc9933f Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 09:52:08 +0800 Subject: [PATCH 18/38] =?UTF-8?q?Revert=20"style(dnfs):=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BF=A1=E6=81=AF=E9=9D=A2=E6=9D=BF=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E5=B7=A6=E8=BE=B9=E4=B8=AD=E9=97=B4=EF=BC=8C=E4=B8=8E=E5=8F=B3?= =?UTF-8?q?=E4=BE=A7=20minimap=20=E9=95=9C=E5=83=8F=E5=AF=B9=E7=A7=B0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 5c33dbe0276368a1a2e3a86d49c55a833644e42a. --- static/public/css/theme.css | 5 ++--- static/public/js/dnfs.js | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 5a30d4f..be578fa 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -365,12 +365,11 @@ input[type="button"]:disabled { font-weight: 600; } -/* ===== User Panel (dnfs 左边中间用户信息,镜像 minimap 样式) ===== */ +/* ===== User Panel (dnfs 左上角用户信息,镜像 minimap 样式) ===== */ .userpanel { position: fixed; left: 1rem; - top: 50%; - transform: translateY(-50%); + top: 1rem; z-index: 100; display: flex; flex-direction: column; diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 25a2207..1282fe6 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -656,7 +656,7 @@ function loadMap(children_json) { } } -/* by qing — 用户信息面板:左边中间,默认折叠;展示用户名 / 角色 / 退出登录。 +/* by qing — 用户信息面板:左上角,默认折叠;展示用户名 / 角色 / 退出登录。 * 身份来自 dnfs /whoami(HttpOnly Cookie 模式下 JS 无法读 Token,由后端解 payload 返回) */ var userpanel = document.getElementById("userpanel"); var userpanel_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; // 与 minimap 同策略:窄屏默认折叠 From 9765edac9b0f8404b4509746383cd7db11b7c6b2 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 11:01:34 +0800 Subject: [PATCH 19/38] =?UTF-8?q?fix(static):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E8=AF=86=E5=88=AB=20WAF=20=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=B9=B6=E6=AD=A3=E7=A1=AE=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validation.js 新增 WafTip 公共检测:403 + 响应体含 blocked by WAF 双重特征, 与 gateway 白名单拒绝的 403(JSON 错误消息)精确区分 - login/register/dnfs/view 各请求点在 JSON 解析前识别拦截,统一提示 「请求被 WAF 安全规则拦截,请检查输入内容」 - 修复 dnfs getNode 被 WAF 拦截时落入 catch 误跳登录页的问题 - 验证码图片加载识别 403 拦截,alt 提示点击重试 - __view.html 补充引入 validation.js --- static/public/__view.html | 1 + static/public/js/dnfs.js | 10 ++++++++++ static/public/js/login.js | 6 ++++++ static/public/js/register.js | 2 ++ static/public/js/validation.js | 12 ++++++++++++ static/public/js/view.js | 4 +++- 6 files changed, 34 insertions(+), 1 deletion(-) diff --git a/static/public/__view.html b/static/public/__view.html index 1690cd7..c29f08e 100644 --- a/static/public/__view.html +++ b/static/public/__view.html @@ -47,6 +47,7 @@ 蜀ICP备2024107064号-2 + diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 1282fe6..b5e4e91 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -19,6 +19,11 @@ async function getNode(id) { }); const responseText = await res.text(); + // WAF 拦截:弹提示并中止,不能落入 catch(其会误跳登录页) + if (window.WafTip && WafTip.isBlocked(res, responseText)) { + alert(WafTip.MESSAGE); + return; + } let result; try { @@ -78,6 +83,7 @@ async function addNode(pid, title, content) { }); const responseText = await res.text(); + if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { @@ -137,6 +143,7 @@ async function deleteNode(id) { }); const responseText = await res.text(); + if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { @@ -188,6 +195,7 @@ async function deleteChild(id) { }); const responseText = await res.text(); + if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { @@ -246,6 +254,7 @@ async function updateNode(id, pid, title, content) { }); const responseText = await res.text(); + if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { @@ -301,6 +310,7 @@ async function moveNode(id, pid, title, content) { }); const responseText = await res.text(); + if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { diff --git a/static/public/js/login.js b/static/public/js/login.js index fc3b7dc..1012ff6 100644 --- a/static/public/js/login.js +++ b/static/public/js/login.js @@ -22,6 +22,11 @@ async function refreshCaptcha() { captchaToken = ''; try { const res = await fetch('/captcha/image', { cache: 'no-store' }); + if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试 + captchaImg.removeAttribute('src'); + captchaImg.alt = '验证码加载被安全规则拦截,点击重试'; + return; + } if (!res.ok) throw new Error('captcha load failed'); captchaToken = res.headers.get('X-Captcha-Token') || ''; const blob = await res.blob(); @@ -126,6 +131,7 @@ form.addEventListener('submit', async function(e) { body: jsonBody }); const responseText = await res.text(); + if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); } if (!res.ok) throw new Error(result.message || '登录失败'); diff --git a/static/public/js/register.js b/static/public/js/register.js index 86380f0..298a93c 100644 --- a/static/public/js/register.js +++ b/static/public/js/register.js @@ -143,6 +143,7 @@ getCodeButton.addEventListener('click', async function () { }); httpStatus = res.status; const responseText = await res.text(); + if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); } if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败'); @@ -190,6 +191,7 @@ form.addEventListener('submit', async function (e) { }); const responseText = await res.text(); + if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { diff --git a/static/public/js/validation.js b/static/public/js/validation.js index 03ecc5f..1779c61 100644 --- a/static/public/js/validation.js +++ b/static/public/js/validation.js @@ -68,3 +68,15 @@ window.FormValidators = { username, password, email }; }()); + +/* WAF 拦截识别:waf 命中规则时返回 403 + 纯文本 "blocked by WAF" + * (见 waf/internal/proxy/proxy.go block());gateway 的 403(Target-Url + * 白名单拒绝)body 为 JSON 错误消息,不含此特征,不会误判。 */ +window.WafTip = { + MESSAGE: '请求被 WAF 安全规则拦截,请检查输入内容', + isBlocked(res, responseText) { + return res.status === 403 + && typeof responseText === 'string' + && responseText.indexOf('blocked by WAF') !== -1; + } +}; diff --git a/static/public/js/view.js b/static/public/js/view.js index f42723e..0c06be8 100644 --- a/static/public/js/view.js +++ b/static/public/js/view.js @@ -138,7 +138,9 @@ async function loadData() { try { const url = `/api/filelist?path=${encodeURIComponent(currentPath)}`; const res = await fetch(url); - const data = await res.json(); + const responseText = await res.text(); + if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); + const data = JSON.parse(responseText); if (data.success) { items = data.items || []; render(); From 91e6659daceb0a33ccea468ac1070235b53a359d Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 11:01:34 +0800 Subject: [PATCH 20/38] =?UTF-8?q?docs(waf):=20=E6=8B=A6=E6=88=AA=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=AB=A0=E8=8A=82=E6=9B=B4=E6=96=B0=E4=B8=BA=20logcli?= =?UTF-8?q?ent=20=E6=8E=A8=E9=80=81=20db=20Logs=20=E8=A1=A8=E7=9A=84?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- waf/README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/waf/README.md b/waf/README.md index 4620fbe..b30108d 100644 --- a/waf/README.md +++ b/waf/README.md @@ -156,25 +156,29 @@ bash /opt/micro_servers/waf/_run.sh 并把 `gateway` 改为内部 HTTP 端口(不再监听 443)。 -## 命中日志 +## 拦截日志 -命中规则时输出 JSON 一行,便于 `jq` 分析(见 [proxy.go:120-137](internal/proxy/proxy.go#L120)): +命中规则时 `block()` 经 logclient(Go 版 pymsg 客户端,见 [internal/logclient/](internal/logclient/))异步批量推送到 db:8005 的 `Logs` 表(与其他服务的集中日志同链路;标准日志已静默,stdout/journalctl 中看不到拦截记录): -```json -{"ts":"2026-07-25T10:00:00Z","action":"block","rule_id":100001,"ip":"1.2.3.4","method":"GET","path":"/","query":"id=1'+OR+'1'='1","ua":"curl/8.0","cost_ms":0} -``` +- `service=waf`、`level=WARNING` +- `message`:`block rule_id=100001 GET /path` +- `extra`(JSON 字符串):`rule_id` / `ip` / `method` / `path` / `query` / `ua` / `cost_ms` -放行请求时输出一行 pass 日志(见 [proxy.go:115](internal/proxy/proxy.go#L115)): +fail-safe:db 不可用时日志静默丢弃,不影响拦截动作本身。 -``` -[proxy] pass ip=1.2.3.4:54321 method=GET path=/ status=200 rule=- cost=1.2ms +查询最近的拦截记录: + +```bash +sqlite3 /opt/micro_servers/db/database.db \ + "SELECT ts, message, extra FROM Logs WHERE service='waf' AND level='WARNING' ORDER BY id DESC LIMIT 20;" ``` 聚合统计命中规则: ```bash -journalctl -u Nidus -o cat | grep '"action":"block"' | \ - jq -r '.rule_id' | sort | uniq -c | sort -rn +sqlite3 /opt/micro_servers/db/database.db \ + "SELECT json_extract(extra,'\$.rule_id') AS rule, COUNT(*) FROM Logs \ + WHERE service='waf' AND level='WARNING' GROUP BY rule ORDER BY 2 DESC;" ``` ## 启用 OWASP CRS(可选) From 62007e784893f1992d4d5848b490bce9b00345b0 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 11:48:54 +0800 Subject: [PATCH 21/38] =?UTF-8?q?docs(static):=20README=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E8=A1=A8=E3=80=81=E6=9E=B6=E6=9E=84=E5=9B=BE=E3=80=81?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E3=80=81=E4=BE=9D=E8=B5=96=E4=B8=8E=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E4=BB=A3=E7=A0=81=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正根路径 / 描述:index.html → login.html - 补全全部 19 个路由(原仅 6 个),分组为核心/内部工具/OAuth/代理 - 架构图补全 waf:443 → gateway:8443 → static:9200 完整链路 - 配置示例新增 [cors] 段 - 依赖列表补全 waitress 和 pymsg - 页面和功能列表补全遗漏项 --- static/README.md | 55 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/static/README.md b/static/README.md index fea5bfc..003ea13 100644 --- a/static/README.md +++ b/static/README.md @@ -7,18 +7,23 @@ ## 架构 ``` -gateway :443 (反向代理) +waf :443 (TLS 终止 + 安全规则) + │ + ▼ +gateway :8443 (反向代理 + 认证) │ ▼ static :9200 (Flask + CORS) │ └── public/ 托管目录 - ├── index.html 社区首页 - ├── login.html 登录 + ├── login.html 登录(默认首页) ├── register.html 注册 + ├── index.html 社区首页 + ├── forgot.html 忘记密码 ├── dnfs.html 树状笔记(调用 dnfs:8006) ├── __view.html 文件浏览器(系统文件,不在列表中显示) ├── allcolor.html 调色板 + ├── LICENSE.html 许可证页面 ├── css/ theme.css(统一主题)+ view.css ├── js/ dnfs.js / login.js / register.js / validation.js / view.js ├── games/ HTML 小游戏合集 @@ -27,15 +32,46 @@ static :9200 (Flask + CORS) ## 路由 +### 核心路由 + | 路径 | 说明 | |------|------| -| `GET /` | 社区首页(index.html),不存在时回退 `__view.html` | +| `GET /` | 登录页(login.html),如不存在回退 `__view.html` | | `GET /` | 静态文件服务;目录返回 `__view.html` 由前端拉取列表 | | `GET /api/filelist?path=` | 文件列表 API,过滤 `__` 前缀系统文件 | | `GET /video/` | 视频/音频流媒体(HTTP Range 206 分段) | | `GET /?download=true` | 强制下载 | | `GET /health` | 健康检查 | +### 内部工具路由 + +| 路径 | 说明 | +|------|------| +| `POST /api/hash` | SHA256 密码哈希 | +| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) | +| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) | + +### yhdet OAuth 路由 + +| 路径 | 说明 | +|------|------| +| `GET/POST /yhdet_callback` | yhdet.top OAuth 回调,自动注册/登录并跳转 dnfs 页 | +| `POST /api/yhdet_login` | yhdet OAuth 用户名密码登录 | +| `POST /api/yhdet_verify` | yhdet Token 验证 | +| `POST /api/proxy_send_code` | yhdet 验证码代理转发 | + +### dnfs 代理路由 + +| 路径 | 说明 | +|------|------| +| `POST /get_notes` | 获取树状笔记列表(代理至 dnfs:8006) | +| `POST /add_note` | 新增笔记(代理至 dnfs:8006) | +| `POST /delete_note` | 删除笔记(代理至 dnfs:8006) | +| `POST /update_note` | 更新笔记(代理至 dnfs:8006) | +| `POST /whoami` | 当前用户身份查询(代理至 dnfs:8006) | + +以上 5 个 dnfs 代理路由均支持 HttpOnly Cookie 模式:当请求携带 `q4_token` Cookie 时,自动转为 `Authorization: Bearer` 头转发。 + ## 功能 - 文件浏览:目录导航、面包屑、实时搜索(`/` 或 `Ctrl+F`)、文件统计 @@ -43,6 +79,8 @@ static :9200 (Flask + CORS) - 深色模式:`css/theme.css` 统一主题,自动适配系统偏好 - 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内 - Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定) +- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头 +- 集中日志:`log_client.py` 通过 pymsg TCP 异步批量推送日志至 db:8005 的 Logs 表 ## 配置 @@ -60,6 +98,11 @@ root = ./public enabled = false cert_file = cert.pem key_file = key.pem + +[cors] +# 允许跨域的 Origin 白名单(逗号分隔) +# 注意:生产环境请勿包含 localhost / 127.0.0.1 / 通配符 * +allowed_origins = https://qsont.me,https://yhdet.top ``` ## 依赖 @@ -67,7 +110,9 @@ key_file = key.pem ``` flask>=2.3.0,<3.0.0 flask-cors>=4.0.0,<5.0.0 -nuitka # 可选,编译为独立二进制 +waitress>=2.1.2,<4.0.0 # 生产级 WSGI 服务器(非 SSL 模式默认使用) +pymsg # 集中日志推送(log_client 依赖) +nuitka # 可选,编译为独立二进制 ``` ## 运行 From dcfdbcb9f292df38d1c734a2b3ddae13f14312a3 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 11:49:10 +0800 Subject: [PATCH 22/38] =?UTF-8?q?docs(waf):=20=E6=9B=B4=E6=96=B0=20README?= =?UTF-8?q?=20=E6=96=87=E6=A1=A3=E4=B8=8E=E4=BB=A3=E7=A0=81=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「安全响应头」章节,列出 7 个 WAF 注入的响应头及说明 - 修正「已知限制」中响应体检测描述,明确响应头检测已启用 - 澄清拦截日志等级区分(WARNING 命中拦截 / ERROR 加载错误)及 extra 字段类型 - 补充规则 ID 分段表的 CRS 可选说明 - 更新目录结构列表补充 logclient 子包 - 请求处理流程新增 ModifyResponse 步骤 --- waf/README.md | 61 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/waf/README.md b/waf/README.md index b30108d..803dcd0 100644 --- a/waf/README.md +++ b/waf/README.md @@ -22,7 +22,7 @@ | 列出所有规则 | `./waf.bin -list` | | 测试输入 | `./waf.bin -check "/?id=1' OR '1'='1"` | | 热加载(不重启) | `kill -HUP $(pgrep waf)` | -| 临时禁用某文件 | `mv rules/local/x.conf x.conf.bak && kill -HUP $(pgrep waf)` | +| 临时禁用某文件 | `mv rules/local/x.conf x.conf.bak && kill -HUP $(pgrep waf)` (引擎仅加载 `.conf` 后缀) | ### 规则文件组织 @@ -32,7 +32,7 @@ rules/ ├── local/ # 本地自定义规则(ID 段 100000-199999) │ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA │ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById) -└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取) +└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取,可选) ├── coraza.conf # CRS 入口(请求体处理器初始化 + tx.crs_setup_version) ├── LICENSE # Apache-2.0 └── rules/*.conf # CRS 规则文件 @@ -54,8 +54,8 @@ rules/ | ID 段 | 用途 | |-------|------| | 100000-199999 | 本地自定义规则 | -| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义) | -| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改) | +| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义,仅当启用 CRS 时存在) | +| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改,仅当启用 CRS 时存在) | ### 规则语法(SecLang) @@ -99,7 +99,7 @@ rules_dir: "./rules" |------|--------|------| | `listen` | `:443` | 监听地址 | | `tls_cert` / `tls_key` | — | TLS 证书与私钥路径 | -| `upstream` | — | gateway 地址,**必填** | +| `upstream` | — | gateway 地址,**必填**(启动时硬性校验,缺失将导致进程退出) | | `rules_dir` | `./rules` | 规则根目录 | ## 目录结构 @@ -112,8 +112,10 @@ waf/ ├── internal/ │ ├── engine/ │ │ └── engine.go # coraza 封装:加载/热加载/列举/单测/输入检测 +│ ├── logclient/ +│ │ └── logclient.go # Go 版 pymsg 日志客户端(异步批量推送) │ └── proxy/ -│ └── proxy.go # ReverseProxy + WAF 事务接入 +│ └── proxy.go # ReverseProxy + WAF 事务接入 + 安全响应头注入 ├── rules/ │ ├── waf.conf │ └── local/ @@ -156,13 +158,38 @@ bash /opt/micro_servers/waf/_run.sh 并把 `gateway` 改为内部 HTTP 端口(不再监听 443)。 +## 安全响应头 + +WAF 在边缘层统一注入安全响应头([proxy.go:54-77](internal/proxy/proxy.go#L54-L77) `setSecurityHeaders`),覆盖所有出站响应(包括正常转发、命中拦截、上游 502)。下游服务(static/dnfs 等)的同名头会被 WAF 覆盖,避免 CSP 绕过。 + +| Header | 值 | 说明 | +|--------|----|------| +| X-Frame-Options | SAMEORIGIN | 允许本站 iframe 嵌套(登录页条例弹窗),防跨站点击劫持 | +| X-Content-Type-Options | nosniff | — | +| X-XSS-Protection | 1; mode=block | 现代浏览器已废弃,仅兼容旧浏览器 | +| Referrer-Policy | strict-origin-when-cross-origin | — | +| Permissions-Policy | camera=(), microphone=(), geolocation=() | — | +| Strict-Transport-Security | max-age=15552000 | HSTS 半年期,**不含子域** | +| Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline'; ... | 暂保留 `'unsafe-inline'` 以兼容 forgot.html 与 games/*.html 的内联脚本 | + ## 拦截日志 命中规则时 `block()` 经 logclient(Go 版 pymsg 客户端,见 [internal/logclient/](internal/logclient/))异步批量推送到 db:8005 的 `Logs` 表(与其他服务的集中日志同链路;标准日志已静默,stdout/journalctl 中看不到拦截记录): -- `service=waf`、`level=WARNING` -- `message`:`block rule_id=100001 GET /path` -- `extra`(JSON 字符串):`rule_id` / `ip` / `method` / `path` / `query` / `ua` / `cost_ms` +- **规则命中拦截**(block 触发):`service=waf`、`level=WARNING` +- **规则加载/解析错误**(`LogError` 回调触发,引擎初始化/热加载时的语义错误):`service=waf`、`level=ERROR` + +`block()` 推送的日志 `extra` 字段(JSON 字符串): + +| 字段 | 类型 | 说明 | +|------|------|------| +| `rule_id` | int | 命中规则 ID | +| `ip` | string | `RemoteAddr` 原始值,含端口号(如 `1.2.3.4:54321`) | +| `method` | string | 请求方法 | +| `path` | string | 请求路径 | +| `query` | string | 查询字符串 | +| `ua` | string | User-Agent | +| `cost_ms` | int64 | 处理耗时(毫秒) | fail-safe:db 不可用时日志静默丢弃,不影响拦截动作本身。 @@ -181,6 +208,13 @@ sqlite3 /opt/micro_servers/db/database.db \ WHERE service='waf' AND level='WARNING' GROUP BY rule ORDER BY 2 DESC;" ``` +查看规则加载/解析错误: + +```bash +sqlite3 /opt/micro_servers/db/database.db \ + "SELECT ts, message, extra FROM Logs WHERE service='waf' AND level='ERROR' ORDER BY id DESC LIMIT 20;" +``` + ## 启用 OWASP CRS(可选) 通过 Go module proxy 拉取(不依赖 GitHub 直连,国内可用): @@ -214,26 +248,29 @@ kill -HUP $(pgrep waf) ## 请求处理流程 -[proxy.go:57-117](internal/proxy/proxy.go#L57) `ServeHTTP`: +[proxy.go:81-138](internal/proxy/proxy.go#L81) `ServeHTTP`: 1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host) 2. `ProcessRequestHeaders` → 命中即 `block` 返回 403 3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block 4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway 5. 捕获响应头 → `ProcessResponseHeaders` +6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头) Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。 -ErrorHandler([proxy.go:48-51](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,不暴露内部堆栈,仅服务端 log。 +ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。 ## 已知限制 - 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。 -- **响应体检测未启用**:仅 `ProcessResponseHeaders`,未 `ProcessResponseBody`(缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。 +- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。 - 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。 - `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。 - TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。 - `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。 +- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。 +- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。 ## 依赖 From aa3a99825cbb4c3fdd6cbe4ec07753ec040bb4d9 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 11:53:30 +0800 Subject: [PATCH 23/38] =?UTF-8?q?docs(captcha):=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E6=A0=91=E5=92=8C=E5=AE=89=E8=A3=85=E6=8C=87?= =?UTF-8?q?=E5=BC=95=E8=A1=A5=E5=85=A8=E9=81=97=E6=BC=8F=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安装章节新增 bash _set.sh 初始化步骤 - 项目结构树补全 _set.sh 和 requirements.txt --- captcha/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/captcha/README.md b/captcha/README.md index 0b388f4..674e16c 100644 --- a/captcha/README.md +++ b/captcha/README.md @@ -23,6 +23,10 @@ ## 安装 ```bash +# 自动创建 venv 并安装依赖(推荐) +bash _set.sh + +# 或手动安装 pip install Pillow ``` @@ -138,7 +142,9 @@ png_b64 = resp["data"]["image"] ``` captcha/ ├── captcha_service.py # 服务主程序(pymsg 服务 + 验证码生成与校验) +├── _set.sh # 初始化脚本(创建 venv + 安装依赖) ├── _run.sh # 启动脚本(cd 后 exec python3 captcha_service.py) +├── requirements.txt # Python 依赖(pymsg、Pillow) └── README.md ``` From a8a190571c5d2e618dbb392e63b9348e8fdd58cd Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 11:54:31 +0800 Subject: [PATCH 24/38] feat(gateway): add /refresh_token API with grace window + refresh limit - POST /refresh_token: accept current Bearer token (or Cookie fallback), verify RSA signature, check grace window (iat + keep_time * multiplier) and refresh count limit, then issue a new token. - Token payload extended with iat, refresh_count, max_refresh fields. - iat preserves original issue time; created_at updates on each refresh. - Rate limited: 5 req / 5 min / IP via RateLimiter. - Config: Token.refresh_grace_multiplier (default 2), Token.max_refresh (default 10). - Backward compatible: old tokens without new fields still pass proxy checks. --- gateway/example.ini | 4 + gateway/src/main.cpp | 172 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/gateway/example.ini b/gateway/example.ini index f26f27d..9221437 100644 --- a/gateway/example.ini +++ b/gateway/example.ini @@ -14,6 +14,10 @@ opened = http://127.0.0.1:9200;http://127.0.0.1:8006; [Token] # Token 有效期(秒),默认 3600 = 1 小时 keep_time = 3600 +# Token 刷新宽限期倍数:过期后 iat + keep_time * 倍数 窗口内仍可刷新,默认 2 +refresh_grace_multiplier = 2 +# Token 生命周期内最大刷新次数,超过后需重新登录,默认 10 +max_refresh = 10 [Admin] # 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true, diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index eae3dcf..70a5be1 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -302,6 +302,10 @@ http::response handleLogout( const http::request& req, const std::unordered_map& params); +http::response handleRefreshToken( + const http::request& req, + const std::unordered_map& params); + /* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */ http::response handleCaptchaImage( const http::request& req, @@ -325,6 +329,8 @@ static std::string _DEFT_URL; static std::vector _OPEN_URL; static std::string _DB_PATH; static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置 +static int _TOKEN_REFRESH_GRACE_MULTIPLIER = 2; // Token 刷新宽限期倍数,过期后 iat + keep_time*倍数 窗口内仍可刷新 +static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数,可由 [Token] max_refresh 配置 static std::vector _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置 static std::vector _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin @@ -335,6 +341,7 @@ static std::unique_ptr _KEYGEN; static RateLimiter _LOGIN_RATE; static RateLimiter _REGISTER_RATE; static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流 +static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流 /* pymsg 下游调用超时(秒): * captcha / smtp verify_email 均为本机内存操作,5s 足够; @@ -499,6 +506,8 @@ int main(int argc, char **argv) { _OPEN_URL = split(cf.get("Proxy.opened", ""), ';'); _DB_PATH = cf.get("Database.path", ""); _TOKEN_KEEP_TIME = cf.get("Token.keep_time", 3600); + _TOKEN_REFRESH_GRACE_MULTIPLIER = cf.get("Token.refresh_grace_multiplier", 2); + _TOKEN_MAX_REFRESH = cf.get("Token.max_refresh", 10); _CORS_ORIGINS = split(cf.get("CORS.allowed_origins", ""), ';'); _ADMIN_USERS = split(cf.get("Admin.users", ""), ';'); } @@ -621,6 +630,11 @@ void registerRoutes(HttpServer &server) { [](const auto& req, const auto& params) { return handleLogout(req, params); }); }); + server.post("/refresh_token", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleRefreshToken(req, params); }); + }); + /* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */ server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response { return wrapperHttp(req, params, @@ -876,9 +890,12 @@ http::response handleLogin( json::value jv_t = { {"user", username}, + {"iat", LocalTime::getCurrent()}, {"created_at", LocalTime::getCurrent()}, {"keep_time", _TOKEN_KEEP_TIME}, - {"is_admin", is_admin} + {"is_admin", is_admin}, + {"refresh_count", 0}, + {"max_refresh", _TOKEN_MAX_REFRESH} }; std::string data = json::serialize(jv_t); std::string auth = Base64::base64_encode(data); @@ -929,6 +946,159 @@ http::response handleLogout( return res; } +/* 刷新 Token:POST /refresh_token + * + * 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。 + * 仅更新 created_at 与 refresh_count;iat 保持不变以限制整条链的总生命周期。 + * + * 宽限期:iat + keep_time * refresh_grace_multiplier + * 例:keep_time=3600, multiplier=2 → 首次签发后 2 小时内可刷新 + * 刷新上限:refresh_count < max_refresh + * 限流:5 次 / 5 分钟 / IP + * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token + * + * 响应:{ success, data:{ token, refresh_count, refreshes_remaining } } + */ +http::response handleRefreshToken( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + /* IP 速率限制:5 次 / 300 秒 */ + std::string client_ip = "direct"; + auto it_host = req.base().find("X-Forwarded-For"); + if (it_host != req.base().end()) + client_ip = std::string(it_host->value()); + if (!_REFRESH_TOKEN_RATE.check_and_record(client_ip, 5, 300)) + THROW_EX_CUSTOM(HttpException, 4001, "Too many refresh attempts. Please try again later.", + 429, "/refresh_token"); + + auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); + + /* 获取 Token:Authorization 头优先,回退 Cookie */ + std::string raw; + auto it_auth = req.base().find("Authorization"); + if (it_auth != req.base().end()) { + raw = std::string(it_auth->value()); + } else { + auto cit = req.base().find("Cookie"); + if (cit != req.base().end()) { + std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token"); + if (!cookie_token.empty()) + raw = "Bearer " + cookie_token; + } + } + + if (raw.empty()) + THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token"); + + /* 解析 Bearer _ */ + if (raw.substr(0, 7) != "Bearer ") + THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); + raw = raw.substr(7); + + size_t idx = raw.find("_"); + if (idx == std::string::npos) + THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); + + std::string data = raw.substr(0, idx); + std::string sign = raw.substr(idx + 1); + std::string auth = Base64::base64_decode_to_string(data); + + /* RSA 验签 */ + if (!_PUBL.Verify(auth, sign)) + THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token"); + + /* 解析 Token payload */ + json::value jv_t; + try { jv_t = json::parse(auth); } + catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); + } + if (!jv_t.is_object()) + THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); + + json::object& obj = jv_t.as_object(); + + /* 提取关键字段 */ + std::string username = json::value_to(obj.at("user")); + std::string iat_str = json::value_to(obj.at("iat")); + int keep_time = json::value_to(obj.at("keep_time")); + int refresh_count = json::value_to(obj.at("refresh_count")); + int max_refresh = json::value_to(obj.at("max_refresh")); + + /* 刷新次数检查 */ + if (refresh_count >= max_refresh) + THROW_EX_CUSTOM(HttpException, 4005, "Refresh limit exceeded. Please re-login.", + 400, "/refresh_token"); + + /* 宽限期检查:iat + keep_time * grace_multiplier > now */ + auto iat_tp = LocalTime::parse(iat_str); + if (!iat_tp) + THROW_EX_CUSTOM(HttpException, 4004, "Bad token iat", 400, "/refresh_token"); + + auto now = std::chrono::system_clock::now(); + auto grace_expire = *iat_tp + std::chrono::seconds(keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER); + if (now > grace_expire) + THROW_EX_CUSTOM(HttpException, 4006, "Token expired beyond refresh window. Please re-login.", + 401, "/refresh_token"); + + /* 保留 is_admin(旧 Token 未携带时默认为 false) */ + bool is_admin = false; + auto it_admin = obj.find("is_admin"); + if (it_admin != obj.end() && it_admin->value().is_bool()) + is_admin = it_admin->value().as_bool(); + + /* 签发新 Token:iat 不变,created_at 更新,refresh_count++ */ + auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem()); + json::value jv_new = { + {"user", username}, + {"iat", iat_str}, + {"created_at", LocalTime::getCurrent()}, + {"keep_time", keep_time}, + {"is_admin", is_admin}, + {"refresh_count", refresh_count + 1}, + {"max_refresh", max_refresh} + }; + std::string new_data = json::serialize(jv_new); + std::string new_auth = Base64::base64_encode(new_data); + std::string new_sign = _PRIV.Sign(new_data); + std::string new_token = new_auth + "_" + new_sign; + + /* Set-Cookie */ + { + std::string ck = "q4_token=" + new_token + + "; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" + + std::to_string(keep_time); + res.set("Set-Cookie", ck); + } + + logs().info("gateway", "Token refreshed", std::nullopt, + json::serialize(json::value{ + {"user", username}, + {"ip", client_ip}, + {"refresh_count", refresh_count + 1}, + {"refreshes_remaining", max_refresh - (refresh_count + 1)} + })); + + json::value jv_rr = { + {"success", true}, + {"data", json::object{ + {"token", new_token}, + {"refresh_count", refresh_count + 1}, + {"refreshes_remaining", max_refresh - (refresh_count + 1)} + }} + }; + res.set("Connection", "close"); + res.result(http::status::ok); + res.set(http::field::content_type, "application/json"); + res.body() = json::serialize(jv_rr); + res.prepare_payload(); + return res; +} + /* 代理 captcha 图形验证码:GET /captcha/image * * captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问, From f214c862d3990a8626659b468e8a6e3fd1b131fb Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 11:54:51 +0800 Subject: [PATCH 25/38] =?UTF-8?q?docs(bot4cj):=20=E6=96=B0=E5=A2=9E=20READ?= =?UTF-8?q?ME.robot.md=20=E5=AE=8C=E6=95=B4=E6=8A=80=E6=9C=AF=E6=89=8B?= =?UTF-8?q?=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补充 5 层架构分层图(脚本→解析器→结构构建→硬件实现→系统库) - 详解 .mt 脚本语法、块类型、占位符规则 - 补充 PCA9685/Servo/MPU6050/Speaker 各硬件寄存器与校准参数 - 说明执行流程:stdin→FSM 解析→链表→build/play/free - 列出 I2C 总线保护、GC 析构、pthread_cancel 等安全限制 - 补充 11 条已知缺陷清单(D1-D11) - 新增 NN/CNN 测试命令与 DRL 预留接口说明 --- bot4cj/README.robot.md | 384 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 bot4cj/README.robot.md diff --git a/bot4cj/README.robot.md b/bot4cj/README.robot.md new file mode 100644 index 0000000..7ce9f1b --- /dev/null +++ b/bot4cj/README.robot.md @@ -0,0 +1,384 @@ +# bot4cj — 机器人硬件脚本解析器 + +基于**仓颉(Cangjie)** 编程语言和 Linux I2C 接口的机器人动作脚本解析器。用一种矩阵式脚本来描述机器人的硬件构成与动作序列,解析后驱动真实硬件执行。 + +## 架构分层 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ .mt 矩阵脚本(硬件定义 + 动作序列) │ +├──────────────────────────────────────────────────────────────┤ +│ MatrixParser(P.cj) — FSM 逐字符解析 → MatrixObject 链表 │ +├──────────────────────────────────────────────────────────────┤ +│ RobotStruct(r.cj) — 从矩阵对象构建硬件变量(createHardVars) │ +│ MatrixVars_Hardware 基类 + 闭包回调: │ +│ · build() → rise 回调(打开硬件、获取资源) │ +│ · play() → things 回调(执行动作,参数/返回值均为 JsonValue) │ +│ · free() → free 回调(释放资源) │ +├──────────────────────────────────────────────────────────────┤ +│ 硬件实现:hd/*.cj │ +│ PCA9685(I2C 0x40, 50Hz PWM, 角度→脉宽 0.5~2.5ms) │ +│ Servo(舵机:max/min/offset 校准 + 角度翻转) │ +│ MPU6050(陀螺+加速度,±500°/s ±8g,后台 10ms 采样线程+卡尔曼) │ +│ Speaker(正弦波发声,5ms 淡入淡出) │ +│ fb.cj(外部硬件扩展口) │ +├──────────────────────────────────────────────────────────────┤ +│ 系统库:cpp/ + cuda/ │ +│ libi2c4cj.so — I2C glibc 封装 + 20 槽锁数组 │ +│ libmt4cj.so — FP32 矩阵运算(CPU / CUDA 二选一) │ +│ libfft4cj.so — 基-2 Cooley-Tukey FFT │ +│ libcnn4cj.so — Conv/Pool/GA-Pool/Flatten + Adam/Momentum │ +│ libsnd4cj.so — WaveAPI / ALSA 音频播放 │ +├──────────────────────────────────────────────────────────────┤ +│ 数学/NN 库:math/*.cj │ +│ Matrix1(二维矩阵)+ Tensor + 6 种激活函数 + Xavier 初始化 │ +│ Nn1 全连接层(前向/反向/权重更新) │ +│ Af(Sigmoid/Tanh/ReLU/Leaky_ReLU/Swish/Softmax) │ +└──────────────────────────────────────────────────────────────┘ +``` + +## 技术栈 + +| 层 | 技术 | 说明 | +|----|------|------| +| 应用层 | **仓颉 Cangjie** | 主程序、解析器、机器人结构、数学库、HTTP 服务 | +| 系统层 | C / C++ | I2C 驱动、矩阵运算、FFT、CNN、音频 | +| 加速层 | CUDA(可选) | `MODE=cpu/cuda` 切换 | +| 脚本层 | `.mt` 矩阵脚本 | 硬件定义 + 动作序列 | +| 依赖包 | ctype4cj / pthread4cj / spi4cj / lua4cj | 仓颉官方扩展包 | +| 构建 | Makefile(C/C++ 共享库)+ cjpm.toml(仓颉包管理) | — | + +## 目录结构 + +``` +bot4cj/ +├── src/ +│ ├── main.cj # 入口:CLI 参数解析(-i/-o/--test)+ stdin 管道执行 +│ ├── P.cj # 矩阵脚本解析器(FSM 逐字符解析) +│ ├── r.cj # RobotStruct + MatrixVars_Hardware 基类 +│ ├── testCNN.cj # CNN 测试入口 +│ ├── testCNN1.cj # 几何 CNN 测试 +│ ├── hd/ +│ │ ├── I2C.cj # I2C 封装 + 20 槽锁数组 +│ │ ├── PCA9685.cj # PCA9685 舵机控制板(已标记由 Lua 替代) +│ │ ├── Servo.cj # 舵机:校准 + 角度翻转 +│ │ ├── MPU6050.cj # MPU6050 陀螺+加速度+卡尔曼滤波 +│ │ ├── snd.cj # 扬声器 +│ │ ├── fb.cj # 外部硬件扩展 +│ │ └── extern/ +│ │ └── macro_ExternHardware.cj +│ ├── math/ +│ │ ├── Matrix.cj # 二维矩阵(+运算符重载) +│ │ ├── Tensor.cj # 张量 +│ │ ├── Af.cj # 6 种激活函数 +│ │ ├── Ini.cj # Xavier/Kaiming 初始化 +│ │ ├── nn.cj # 全连接层 Nn1 +│ │ ├── nns.cj # 旧版神经网络(XNOR 门测试) +│ │ ├── cnn.cj # CNN 仓颉绑定 +│ │ ├── pooling.cj # 池化 +│ │ ├── flatten.cj # Flatten +│ │ ├── fft.cj # FFT 仓颉绑定 +│ │ ├── kalman.cj # 卡尔曼滤波 +│ │ └── Adam.cj # Adam 优化器 +│ ├── cpp/ # C/C++ 系统库源 +│ │ ├── I2C.c # I2C glibc 封装 +│ │ ├── Matrix.cpp # CPU 矩阵运算 +│ │ ├── fft1.c # FFT 单实序列 +│ │ ├── fft.cpp # FFT 双实序列 + 基-2 Cooley-Tukey +│ │ ├── kalman.c # 卡尔曼滤波 +│ │ ├── snd.cpp # 音频播放(WaveAPI/ALSA) +│ │ └── cnn/ # 纯 C++ CNN(Conv/Pool/GA-Pool/Flatten) +│ └── cuda/ +│ ├── matrix.cu # GPU 矩阵运算 kernel +│ └── error.cuh # CUDA 错误检查宏 +├── modules/ # Lua 硬件模块(ADS1115/ADS1256/PCA9685/SPI) +├── scripts/ # .mt 动作脚本(std/jump/lie-down/tilt-* 等) +├── test/ # C/Lua 测试脚本 +├── docs/Reports/ # 深度分析报告 +├── cjpm.toml # 仓颉包配置 +├── Makefile # C/C++ 共享库构建 +├── _set.sh # 编译(cjpm update + build) +├── _run.sh # 运行(stdin 管道) +├── compile.ps1 # Windows 编译脚本 +├── run.ps1 # Windows 运行脚本 +├── README.md # 旧版说明 +└── README.robot.md # 本文件 +``` + +## 快速开始 + +```bash +# 1. 编译 C/C++ 共享库(一次性) +make MODE=cpu # CPU 模式(默认) +# 或 +make MODE=cuda # GPU 模式(需要 nvcc) + +# 2. 编译仓颉程序 +bash _set.sh + +# 3. 运行动作脚本(stdin 管道) +cat scripts/std.mt | bash _run.sh +``` + +> **硬件要求**:树莓派(aarch64)或其他 Linux I2C 设备。x86_64 开发板需自行验证 I2C 参数。Windows 暂不支持(依赖 glibc)。 + +## CLI 参数 + +``` +./bot # 从 stdin 读取 .mt 脚本执行 +./bot --help # 打印帮助 +./bot --test nn # 运行神经网络与门测试 +./bot --test cnn # 运行 CNN 测试 +./bot --test cnn1 # 运行几何 CNN 测试 +./bot --test matrix_o # 解析 ss/test.mt 并保存为 ss/output.mt +./bot --test matrix_2 # 解析 ss/output.mt → 执行 → 再次保存 +./bot -i model.json -o out.json # 加载/保存神经网络模型(DRL 预留) +``` + +## .mt 脚本语法 + +行列式表格,每列以空格或制表符分隔,每行以分号 `;` 结尾。 + +### 块结构 + +``` +@BlockName +column1 column2 column3; +value1 value2 value3; +@ +``` + +每个 `@` 块由 `@Name` 行开始,`@` 行结束。支持的块类型: + +| 块 | 说明 | +|----|------| +| `@Alias` | 列名别名(如 `A` → `angle`、`F` → `frequency`) | +| `@I2C` | 定义 I2C 设备(name + path) | +| `@PCA9685` | 定义 PCA9685 舵机控制板(name + I2C 引用 + 地址) | +| `@MPU6050` | 定义 MPU6050 传感器(name + I2C 引用 + 地址) | +| `@Speaker` | 定义扬声器(channels + sample_rate + volume + max_f + min_f) | +| `@Servo` | 定义舵机(name + max + min + offset + controller + channel) | +| `@Action` | **动作矩阵**:列头为硬件变量的属性,行为每帧的参数 | + +### 占位符 + +`-` 表示"沿用上一行同列的值",当前行该列不产生调用。 + +### 完整示例 + +```mt +@Alias +name alias; + A angle; + F frequency; +@ + +@I2C + name path; +i2c-0 /dev/i2c-0; +i2c-1 /dev/i2c-1; +@ + +@PCA9685 + name I2C address; +pca9685-0 i2c-1 0x40; +@ + +@Speaker +name channels sample_rate volumn max_f min_f; + spk 2 48000 0.6 5000 -; +@ + +@Servo + name max min offset controller channel; + s1 90 - - pca9685-0 0; + s2 180 - - - 1; + s3 -90 - - - 2; + s4 -180 - - - 3; +@ + +@Action +s1.A s2.A s3.A s4.A spk.F spk.delay; + 45 - 45 - 10 200; # 站立 + 90 - - - - 20; # 左转 +@ +``` + +## 执行流程 + +``` +stdin.read() + ↓ +P.MatrixParser (FSM 逐字符解析) + ↓ +LinkedList 链表 + ↓ +RobotStruct.createHardVars() # 按 @I2C/@PCA9685/@Servo/@Speaker/@MPU6050 构建硬件变量 + ↓ +robot.active() # build():逐个硬件调用 rise 回调(打开 I2C、初始化 PWM 等) + ↓ +robot.run() # 逐行遍历 @Action 矩阵,每列调用对应硬件的 play() 回调 + ↓ +robot.deactive() # free():倒序释放资源 +``` + +`play()` 的输入输出均为 `JsonValue`,实现了硬件与脚本之间的统一通信协议。 + +## 已支持的硬件 + +### PCA9685(I2C 舵机控制板) +- 地址:默认 `0x40` +- PWM 频率:50Hz(舵机标准 20ms 周期) +- 舵机脉宽映射:0°→0.5ms,180°→2.5ms +- 角度映射到 12 位 PWM 值(0-4095) +- **注意**:PCA9685.cj 代码已标记"由 Lua 脚本取代",保留作备用 + +### Servo(舵机) +- 支持 `max` / `min` 角度限制与 `offset` 漂移校准 +- `max < 0` 时自动翻转角度(反向安装的舵机) +- 通过闭包回调与实际硬件(PCA9685/Lua 模块)解耦 + +### MPU6050(6 轴传感器) +- 陀螺仪:±500°/s 量程 +- 加速度计:±8g 量程 +- 后台 10ms 采样线程(pthread_create + pthread_cancel) +- 卡尔曼滤波融合数据 +- **已知问题**:`ADDR_0` 与 `ADDR_1` 常量均为 `0x68`([MPU6050.cj:14-15](src/hd/MPU6050.cj#L14-L15)),第二个地址应改为 `0x69` + +### Speaker(扬声器) +- 支持正弦波发声(指定频率 `F` + 延时 `delay`) +- 5ms 淡入淡出,避免"咔哒"声 +- 通道数/采样率/音量可在 `.mt` 中配置 +- **已知问题**:`play()` 仅 sleep 指定延时,真实发声依赖底层 C++ 实现 + +## 机器人型号适配 + +### Dashun 蜘蛛机器人(默认) +10 舵机 + 2 云台 + 扬声器,建模文件见 [gopsc/Dashun](https://github.com/gopsc/Dashun): + +| 舵机 | 关节 | 舵机 | 关节 | +|------|------|------|------| +| s1 | 左下水平 (rb0) | s6 | 右上垂直 (lf1) | +| s2 | 左下垂直 (rb1) | s7 | 左上水平 (rf0) | +| s3 | 右下水平 (lb0) | s8 | 左上垂直 (rf1) | +| s4 | 右下垂直 (lb1) | s9 | 云台1 (cam0) | +| s5 | 右上水平 (lf0) | s10 | 云台2 (cam1) | + +### 可用脚本 + +`scripts/` 目录下预写了多种动作脚本: +- `std.mt` — 站立姿态 +- `jump.mt` — 跳跃 +- `lie-down.mt` — 趴下 +- `left-hand.mt` / `rght-hand.mt` — 单手动作 +- `left-turn.mt` / `tilt-f.mt` / `tilt-b.mt` / `tilt-l.mt` / `tilt-r.mt` — 转向与倾斜 + +## 构建 + +### 依赖 + +```bash +# 仓颉 1.1.3 +# 仓颉官方扩展包:ctype4cj / pthread4cj / spi4cj / lua4cj +# libc / libpthread +# liblua-5.4(可选,Lua 硬件模块支持) +sudo ln -s /usr/lib/aarch64-linux-gnu/liblua5.4.so /usr/lib/aarch64-linux-gnu/liblua.so +sudo ln -s /usr/lib/x86_64-linux-gnu/liblua5.4.so /usr/lib/x86_64-linux-gnu/liblua.so +``` + +### 构建步骤 + +```bash +# 1. 构建 C/C++ 共享库 +make MODE=cpu +# 产出:libi2c4cj.so libmt4cj.so libfft4cj.so libsnd4cj.so + +# 2. (可选)构建 CNN +make libcnn4cj.so + +# 3. (可选)CUDA 模式需 NVIDIA CUDA Toolkit +make MODE=cuda + +# 4. 仓颉主程序 +bash _set.sh +``` + +[_set.sh](_set.sh) 执行 `cjpm update` + `cjpm build`,自动解析 cjpm.toml 依赖。 + +### 运行 + +```bash +bash _run.sh scripts/std.mt +# 或 +cat scripts/std.mt | bash _run.sh +``` + +[_run.sh](_run.sh) 以 `cjStackSize=64MB` / `cjHeapSize=64MB` 启动。 + +## 神经网络支持 + +项目内置了全连接层 Nn1([math/nn.cj](src/math/nn.cj))与 CNN([math/cnn.cj](src/math/cnn.cj) + [cpp/cnn/](src/cpp/cnn/)): + +- **Nn1**:多层感知机,支持前向传播、反向传播、权重更新 +- **CNN**:Conv → ReLU → Pool → GA-Pool → Flatten → FC +- **优化器**:Adam / Momentum +- **激活函数**:Sigmoid / Tanh / ReLU / Leaky_ReLU / Swish / Softmax +- **初始化**:Xavier / Kaiming + +### 测试 + +```bash +./bot --test nn # 与门训练(2 输入 → 1 输出) +./bot --test cnn # CNN 前向/反向测试 +./bot --test cnn1 # 几何分类 CNN 测试 +``` + +### DRL 预留(未完成) + +main.cj 中预留了 DRL 流程(注释状态):加载预训练权重 → 执行动作 → 保存微调模型。通过 `-i` / `-o` 参数指定输入/输出路径。 + +## 安全与限制 + +### I2C 总线保护 +- [hd/I2C.cj](src/hd/I2C.cj) 内置 **20 槽锁数组**,多硬件共享 I2C 总线时自动互斥 +- 每个 I2C fd 独立加锁,避免并发写入冲突 + +### 硬件关闭时机 +- `MatrixVars_Hardware` 析构依赖仓颉 GC,**不保证**在确定性时机调用 `free()` +- 机器人使用 `try/finally` 在 [main.cj:80-87](src/main.cj#L80) 中显式调用 `deactive()`,确保硬件资源正确释放 + +### 线程安全 +- MPU6050 采样通过 `pthread_create` 启动后台线程 +- `pthread_cancel` 依赖系统定义的取消点(sleep / 系统调用),可能在非安全点中断 + +### 平台支持 +- ✅ Linux aarch64(树莓派)/ x86_64 +- ❌ Windows(依赖 glibc) + +## 已知问题 + +| ID | 描述 | 影响 | +|----|------|------| +| D1 | 仓颉 GC 不保证析构器调用,I2C fd 可能泄漏 | 长时间运行后 I2C 资源耗尽 | +| D2 | Speaker `play()` 仅 sleep,真实发声未接入 C++ | 扬声器无实际声音 | +| D3 | MPU6050 `pthread_cancel` 可能在非安全点中断 | 传感器数据不一致 | +| D4 | PCA9685 初始化被标记为"由 Lua 取代",但仍保留主路径 | 代码存在双实现 | +| D5 | `_set.sh` 会删除 `a.out` | 误删用户产物 | +| D6 | Softmax 雅可比矩阵实现错误(math/Af.cj) | NN 训练收敛异常 | +| D7 | Tensor 乘法仅支持 2D,3D+ 会 panic | 限制模型复杂度 | +| D8 | Tanh 导数实现错误(使用 `1 - y^2` 而非 `1 - tanh^2(x)`) | NN 训练收敛异常 | +| D9 | CNN `testGeometryCNN` 使用随机数据 | 测试无实际意义 | +| D10 | MPU6050 `ADDR_0` 与 `ADDR_1` 均为 `0x68` | 无法同时使用两个 MPU6050 | +| D11 | `@Action` 与 `@Action_Auto` 并存未合并 | 脚本语法冗余 | + +## 相关资源 + +- [Dashun 蜘蛛机器人建模](https://github.com/gopsc/Dashun) +- [深度分析报告](docs/Reports/analysis_20260525_00.md)(28 章节,1280 行) +- [仓颉编程语言](https://cangjie-lang.org/) +- [PCA9685 数据手册](https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf) +- [MPU6050 数据手册](https://invensense.tdk.com/wp-content/uploads/2015/02/MPU-6000-Datasheet1.pdf) + +## 许可证 + +MIT License From 45066017734919e27312f3ff5f4b1dd03e2b747a Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 11:58:26 +0800 Subject: [PATCH 26/38] =?UTF-8?q?docs(libth):=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=9A=84=20README=20=E6=96=87=E6=A1=A3=EF=BC=8C=E8=A6=86?= =?UTF-8?q?=E7=9B=96=20API=E3=80=81=E6=9E=B6=E6=9E=84=E3=80=81=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E4=B8=8E=E4=BD=BF=E7=94=A8=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 4 个类的完整 API 说明(Thread/Fsm/ITask/Cv_wait) - "回字形"执行循环和两阶段构造的设计说明 - 构建、安装、链接、卸载的完整指引 - 可运行的使用示例代码 - 设计要点与注意事项(死锁风险、单次使用限制等) - 修正 C++ 标准版本为 C++20(与 Makefile 一致) fix(libth): Cv_wait.hpp 中 lcok 拼写错误修正为 lock --- libth/README.md | 203 +++++++++++++++++++++++++++++++++++++-- libth/inc/us/Cv_wait.hpp | 2 +- 2 files changed, 195 insertions(+), 10 deletions(-) diff --git a/libth/README.md b/libth/README.md index e087cbe..33a0cc0 100644 --- a/libth/README.md +++ b/libth/README.md @@ -1,14 +1,199 @@ -# UniServe_libth -UniServe Framework Thread Library. +# libth — UniServe 线程库 -# Dependancy Chain -It should be C++17. +C++20 轻量级多线程控制库,为 UniServe Framework 提供线程抽象与状态机驱动的执行模型。由 `core`(PMC 进程管理器)的 `HttpTask` 和 `ProcessTask` 直接依赖。 -# Install Method -Use UniServe Generate Installation Script. +命名空间 `qing`,库文件 `libus_th.so`,头文件安装至 `/usr/include/us/`。 -``` bash -bash _set.sh +## 架构 + +``` +┌──────────┐ ┌───────────────────────────────┐ +│ ITask │ │ Thread │ +│ (接口) │ │ 持有 Fsm + std::thread │ +│ start() │ │ │ +│ stop() │ │ set_stop / set_wake / │ +│ isRunning│ │ set_loop / set_clear │ +└────┬─────┘ │ Activate / WaitStart / │ + │ 实现 │ WaitClose │ + ▼ │ │ + HttpTask │ main(): "回字形" 执行循环 │ + ProcessTask └──────────────┬────────────────┘ + (位于 core) │ 持有 + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌────────────┐ ┌──────────┐ + │ Fsm │ │ Cv_wait │ │ Thread │ + │ 状态机 │ │ 条件等待 │ │ .cpp │ + │ SHUT/STOP│ │ Wait() │ │ 实现文件 │ + │ START/ │ │ WakeCv() │ │ │ + │ RUNNING │ │ │ │ │ + └──────────┘ └────────────┘ └──────────┘ ``` -# ENJOY! +## 类与 API + +### ITask — 任务接口 + +纯虚基类,定义可运行任务的契约。下游模块(core 中的 HttpTask、ProcessTask)继承实现。 + +```cpp +class ITask { + virtual void start() = 0; // 启动任务 + virtual void stop() = 0; // 停止任务 + virtual bool isRunning() = 0; // 查询运行状态 +}; +``` + +### Fsm — 线程安全有限状态机 + +4 个状态:`SHUT(-1)` → `STOP(0)` → `START(1)` → `RUNNING(2)`。Thread 通过 Fsm 驱动其执行流程。 + +| 方法 | 说明 | +|------|------| +| `check()` | 线程安全地读取当前状态 | +| `set(Stat)` | 加锁设置状态 + 唤醒阻塞线程 | +| `set_without_lock(Stat)` | 不加锁设置(用于临界区内) | +| `set_without_notify(Stat)` | 加锁但不唤醒条件变量 | +| `suspend()` | 阻塞当前线程直至状态变更 | + +初始状态为 `STOP`。 + +### Thread — 线程抽象 + +基于回调组合(composition over inheritance),持有 `Fsm` 和 `std::thread`。执行模型为"回字形"循环: + +``` +while (状态 != SHUT) { + ① STOP: 循环调用 StopEvent 回调(空闲轮询) + ② START: 单次调用 WakeEvent 回调(启动) + ③ RUNNING:循环调用 LoopEvent 回调(工作循环) + ④ 退出后: 调用 ClearEvent 回调(清理) +} +异常捕获:loop 内抛出 std::exception 自动回退到 STOP,进程不崩溃。 +``` + +**生命周期:两阶段构造。** 构造函数只初始化成员,`Activate()` 才真正创建 `std::thread`——避免线程在对象构造完成前启动的竞态条件。 + +| 方法 | 说明 | +|------|------| +| `set_stop(f_t)` | 设置 STOP 状态回调 | +| `set_wake(f_t)` | 设置 START 状态回调 | +| `set_loop(f_t)` | 设置 RUNNING 状态回调 | +| `set_clear(f_t)` | 设置清理回调 | +| `Activate()` | 创建 std::thread 并启动 main() | +| `WaitStart()` | 唤醒线程并阻塞至其进入 RUNNING | +| `WaitClose()` | 关闭线程并 join 等待退出 | +| `wake()` | 状态 → START(不可持有锁,否则死锁) | +| `stop()` | 状态 → STOP | +| `shut()` | 状态 → SHUT | +| `run()` | 状态 → RUNNING | +| `check()` | 查询当前状态 | +| `suspend()` | 阻塞等待状态变更 | + +回调类型:`using f_t = std::function;` + +### Cv_wait — 条件变量等待工具 + +简单的单次等待-唤醒工具,用于线程间同步。 + +| 方法 | 说明 | +|------|------| +| `Wait()` | 阻塞当前线程至 WakeCv() 被调用 | +| `WakeCv()` | 唤醒所有等待线程 | + +> **已知限制**:仅支持单次等待-唤醒周期,不可复用。缺少超时机制,可能永久阻塞。代码中标注建议改用 `std::future`/`std::promise`。core 的 `main.cpp` 使用此类。 + +## 构建与安装 + +```bash +# 一键构建 + 安装(需 sudo) +bash _set.sh + +# 或手动执行 +make # 编译 libus_th.so +make install # 安装头文件到 /usr/include/us/ + .so 到 /lib/ +make uninstall # 卸载 +make clear # 清理构建产物 +``` + +安装后使用: + +```cpp +#include +#include +#include +#include +``` + +链接:`g++ -std=c++20 ... -lus_th` + +## 使用示例 + +```cpp +#include +#include + +int main() { + qing::Thread worker; + bool done = false; + + // STOP 状态回调:空闲等待 + worker.set_stop([&](qing::Thread& t) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + }); + + // START 状态回调:一次性初始化 + worker.set_wake([&](qing::Thread& t) { + std::cout << "worker started" << std::endl; + t.run(); // 进入 RUNNING + }); + + // RUNNING 状态回调:工作循环 + worker.set_loop([&](qing::Thread& t) { + std::cout << "working..." << std::endl; + std::this_thread::sleep_for(std::chrono::seconds(1)); + done = true; + t.stop(); // 回到 STOP + }); + + // 清理回调 + worker.set_clear([](qing::Thread& t) { + std::cout << "cycle cleaned up" << std::endl; + }); + + worker.Activate(); + worker.WaitStart(); // 阻塞至 RUNNING + + while (!done) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + worker.WaitClose(); + return 0; +} +``` + +## 依赖 + +- C++20(`g++ --std=c++20`) +- 纯标准库,无第三方依赖 +- 线程库需链接 `-pthread` + +## 设计要点 + +- **回调组合优于继承**。2025 年 6 月从派生改为持有 `std::function` 回调,Thread 不再需要子类化。 +- **两阶段构造**。构造与 `Activate()` 分离,防止 `std::thread` 在对象构造完成前访问未初始化的成员。 +- **异常安全**。main() 循环捕获 `std::exception` 后回退到 STOP 而非崩溃。注意:catch 后继续运行可能造成内存泄漏。 +- **wake() 不可加锁**。`WaitStart()` 先持有 `mtx_th` 再调用 `wake()`,若 `wake()` 内部也尝试获取同一锁会造成死锁。 +- **Fsm 提供两套条件变量**。Fsm 内部有一套 `cv` 用于 `suspend()`,Thread 自身另有一套 `cv` 用于 `WaitStart()`/`WaitClose()`,互不干扰。 + +## 注意事项 + +- Cv_wait 仅支持单次使用,复用时 `flag` 残留为 `true` 会导致 `Wait()` 立即返回而不阻塞。 +- Fsm 的 `suspend()` 等待的是 Fsm 内部的 `ready` 标志,由 `set()` 触发;Thread 的 `WaitStart()` 等待的是 Thread 内部的 `ready`,由 `run()`/`stop()`/`shut()` 触发。两者独立。 +- `Activate()` 不可重复调用(内部有空指针检查),但不会报错,静默返回。 +- 析构函数为虚函数,允许安全的多态删除。 + +## 许可证 + +MIT License diff --git a/libth/inc/us/Cv_wait.hpp b/libth/inc/us/Cv_wait.hpp index 40886f6..1dad774 100644 --- a/libth/inc/us/Cv_wait.hpp +++ b/libth/inc/us/Cv_wait.hpp @@ -28,7 +28,7 @@ public: inline void WakeCv() /* 唤醒线程 */ { { - std::lock_guard lcok(mtx); + std::lock_guard lock(mtx); flag = true; } cv.notify_all(); From 63a80694ae99a05eaa2b6d1b8122ebd644bddd78 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 12:01:04 +0800 Subject: [PATCH 27/38] =?UTF-8?q?docs(core):=20=E6=96=B0=E5=A2=9E=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A5=E6=97=A5=E5=BF=97=20CODE=5FREVIEW?= =?UTF-8?q?=5FLOG=5F20260730.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 审查日期:2026-07-30 14:30:00 (Asia/Shanghai) - 发现 16 个问题(4 Critical / 6 Medium / 6 Low) - 核心风险:命令注入、无鉴权、空指针解引用 - 包含分阶段修复计划与优先级建议 --- core/docs/CODE_REVIEW_LOG_20260730.md | 381 ++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 core/docs/CODE_REVIEW_LOG_20260730.md diff --git a/core/docs/CODE_REVIEW_LOG_20260730.md b/core/docs/CODE_REVIEW_LOG_20260730.md new file mode 100644 index 0000000..6f4b252 --- /dev/null +++ b/core/docs/CODE_REVIEW_LOG_20260730.md @@ -0,0 +1,381 @@ +# Core 模块代码审查日志 + +> 审查日期:2026-07-30 14:30:00 (Asia/Shanghai) +> 审查人:Automated Review +> 审查范围:`core/` 全模块(C++20,PMC/Nidus 进程托管服务) + +--- + +## 一、模块概述 + +| 属性 | 值 | +|------|-----| +| 定位 | 微服务进程托管控制器(Process Machine Controller) | +| 语言 | C++20 | +| 构建 | Makefile(GCC)+ systemd 部署 | +| 入口 | `nidus.out` | +| 端口 | HTTP API: 5200(仅本机 `127.0.0.1`) | +| 启动列表 | `init.txt`(9 个子服务) | + +### 依赖项 + +| 库 | 用途 | 版本要求 | +|----|------|---------| +| Boost.Process | 子进程创建/管理 | — | +| Boost.JSON | JSON 解析与序列化 | — | +| Boost.Interprocess | POSIX 消息队列 | — | +| Boost.Program.Options | CLI 参数解析 | — | +| UniServe_libth | 自定义线程/HTTP 服务器封装 | 自研 | +| OpenSSL | TLS(HTTP 服务器证书) | — | + +### 目录结构 + +``` +core/ +├── include/ +│ ├── cn/ # 中文代码库(自研) +│ │ ├── 中文化.hpp # 保留字映射 + 中文字符串/IO 类 +│ │ ├── 链串.hpp # 分块链表 +│ │ ├── 顺序表.hpp # 数组封装 +│ │ ├── 向量.hpp # 动态数组 +│ │ └── 异常.hpp # 异常层次 +│ ├── logs/ +│ │ └── Logger.hpp # 单例日志(控制台输出) +│ ├── main/ +│ │ ├── HttpTask.hh # HTTP 服务器任务 +│ │ ├── PPool.hh # 进程池 +│ │ ├── ProcessTask.hh # 子进程任务 +│ │ ├── Tttask.hh # 通信任务(消息队列监听) +│ │ ├── pmc_mtd.hh # 进程管理方法集 +│ │ └── subsys_call.hh # 子系统调用封装 +│ └── tt/ +│ ├── Mq.h # POSIX 消息队列封装 +│ └── Pipe.h # FIFO 管道封装 +├── src/ +│ ├── main.cpp # 主入口 +│ └── main/ # 实现文件 +├── init.txt # 自启动列表 +├── Makefile # 构建脚本 +├── Nidus.service # systemd 单元 +├── README.md +├── _set.sh # 编译+安装 +└── _run.sh # 运行脚本 +``` + +--- + +## 二、审查发现 + +### 🔴 严重问题 (P0) + +#### [CORE-S01] 命令注入漏洞 + +- **文件**: `src/main/HttpTask.cpp:230-247` +- **时间**: 2026-07-30 14:32:15 +- **描述**: `exec` 路由直接将 URL 查询参数 `command` 传递给 `boost::process::child`,无任何过滤或校验。攻击者可构造恶意请求执行任意系统命令。 + +```cpp +// 危险代码 +std::string result = execCommand(command, target); +// command 直接来自 HTTP 请求参数 +``` + +- **风险**: 远程代码执行(RCE) +- **严重等级**: 🔴 Critical +- **修复建议**: + 1. 实现命令白名单机制 + 2. 使用 `execvp` 而非 `system`/shell 模式 + 3. 限制可执行命令的路径范围 + 4. 添加参数长度和字符集校验 + +#### [CORE-S02] HTTP API 无任何鉴权 + +- **文件**: `src/main/HttpTask.cpp:130-340` +- **时间**: 2026-07-30 14:33:00 +- **描述**: HTTP API (`/list`, `/exec`, `/kill`) 完全开放,无认证、无授权、无访问控制。任何人(本地)都可以: + - 列出所有子进程 + - 启动任意进程 + - 终止任意进程 + +- **风险**: 权限绕过、未授权操作 +- **严重等级**: 🔴 Critical +- **修复建议**: + 1. 添加 API Token 认证(请求头 `Authorization: Bearer `) + 2. 实现 IP 白名单(仅允许 `127.0.0.1`) + 3. 添加请求速率限制 + 4. 记录所有管理操作审计日志 + +#### [CORE-S03] ProcessTask 空指针解引用 + +- **文件**: `include/main/ProcessTask.hh:19,36` +- **时间**: 2026-07-30 14:35:22 +- **描述**: `isRunning()` 和 `pid()` 方法未检查 `p` 是否为空。如果在 `start()` 之前调用这些方法,会导致空指针解引用(段错误)。 + +```cpp +bool isRunning() override { return p && p->running(); } // 应加 p 非空检查 +pid_t pid() { return p->id(); } // 危险!p 可能为 nullptr +``` + +- **风险**: 程序崩溃 +- **严重等级**: 🔴 Critical +- **修复建议**: + ```cpp + bool isRunning() override { return p != nullptr && p->running(); } + pid_t pid() { return p ? p->id() : -1; } + ``` + +#### [CORE-S04] exec 路由路径穿越 + +- **文件**: `src/main/HttpTask.cpp:232-236` +- **时间**: 2026-07-30 14:37:10 +- **描述**: `getExecCommand` 函数从 URL 参数提取命令,允许包含 `..` 路径穿越符,可能执行预期外的脚本。 + +- **风险**: 路径遍历 +- **严重等级**: 🔴 High +- **修复建议**: + - 白名单校验,仅允许执行 `init.txt` 中列出的命令 + - 规范化路径后检查是否在允许目录内 + +### 🟡 中等问题 (P1) + +#### [CORE-S05] 进程池无线程安全保护 + +- **文件**: `src/main/PPool.cpp` +- **时间**: 2026-07-30 14:40:05 +- **描述**: `PPool` 类使用 `std::list>` 作为内部存储,但无互斥锁保护。`HttpTask` 和 `Tttask` 可能并发调用 `crtp()`、`kill()`、`clr()` 等方法,导致数据竞争。 + +- **风险**: 数据竞争、未定义行为 +- **严重等级**: 🟡 Medium +- **修复建议**: 添加 `std::mutex` 保护所有公共方法 + +#### [CORE-S06] 消息队列命名冲突 + +- **文件**: `src/main/Tttask.cpp:41-42` +- **时间**: 2026-07-30 14:42:30 +- **描述**: 使用 `getpid()` 作为消息队列名。同一台机器上启动多个 Nidus 实例时,可能发生队列名冲突。 + +```cpp +name = std::to_string(getpid()); // 仅用 PID,无唯一化 +``` + +- **风险**: 多实例冲突 +- **严重等级**: 🟡 Medium +- **修复建议**: 使用 `PID + 时间戳` 或 `PID + 随机后缀` 组合命名 + +#### [CORE-S07] 管道文件残留 + +- **文件**: `src/main/subsys_call.cpp` +- **时间**: 2026-07-30 14:44:18 +- **描述**: 在异常路径中,`Mq` 和 `Pipe` 对象的析构器可能未正确执行(依赖栈展开),导致 `/tmp/pst-*` 文件残留,长期运行会占满 `/tmp` 空间。 + +- **风险**: 磁盘空间耗尽 +- **严重等级**: 🟡 Medium +- **修复建议**: + 1. 在 `catch` 块中显式清理管道文件 + 2. 添加定期清理脚本 + 3. 使用 `std::filesystem::remove` 在构造器前清理旧文件 + +#### [CORE-S08] kill/exec IPC 路径已废弃 + +- **文件**: `src/main.cpp:282-296` +- **时间**: 2026-07-30 14:46:00 +- **描述**: `parse_data_from_mq()` 中 `kill` 和 `exec` 的 IPC 处理路径被注释,仅 `list` 可用。HTTP 路由 `/exec` 和 `/kill` 虽然存在,但远端调用时会失败。 + +```cpp +// 已注释的代码 +//else if (typ == "kill") { +// int trg = jv.at("kill").as_int64(); +// auto flag = pmc_mtd::kill(trg); +// ... +//} +``` + +- **风险**: 功能缺失 +- **严重等级**: 🟡 Medium +- **修复建议**: 恢复被注释的代码,或在文档中明确说明限制 + +#### [CORE-S09] PPool::clr() 边界条件错误 + +- **文件**: `src/main/PPool.cpp:20-32` +- **时间**: 2026-07-30 14:48:22 +- **描述**: `clr()` 方法从尾部反向遍历删除已终止的进程,但边界条件处理复杂: + - 当 `pool.size() == 1` 时,循环条件 `it != pool.begin()` 直接跳过 + - 最后的 `if (pool.size() > 0 && !(*it)->isRunning())` 可能导致双删或漏删 + +```cpp +auto it = --pool.end(); +for (; it != pool.begin(); --it) + if (!(*it)->isRunning()) { /* erase */ } +if (pool.size() > 0 && !(*it)->isRunning()) // 可能越界 + pool.erase(it); +``` + +- **风险**: 迭代器失效、崩溃 +- **严重等级**: 🟡 Medium +- **修复建议**: 简化为正向前遍历,或使用 `std::list::remove_if` + +#### [CORE-S10] 日志仅输出到控制台 + +- **文件**: `include/logs/Logger.hpp` +- **时间**: 2026-07-30 14:50:00 +- **描述**: `Logger` 类仅支持控制台输出,日志不持久化。服务重启后日志丢失,无法进行事后审计或问题追溯。 + +- **风险**: 可观测性缺失 +- **严重等级**: 🟡 Medium +- **修复建议**: + 1. 实现日志文件轮转 + 2. 添加远程日志推送(syslog、Elasticsearch) + 3. 集成到微服务体系的集中日志(db:8005 Logs 表) + +### 🟢 轻微问题 (P2) + +#### [CORE-Q01] 版本号不一致 + +- **文件**: `src/main.cpp:42` vs `Makefile:1` +- **时间**: 2026-07-30 14:52:10 +- **描述**: `PMC_VERSION = "0.0.10"` 与 `Makefile` 中 `VERSION := 0.0.4` 不一致。 + +- **严重等级**: 🟢 Low +- **修复建议**: 统一版本管理,或在 Makefile 中定义并编译时注入 + +#### [CORE-Q02] parse_data_from_mq 语法存疑 + +- **文件**: `src/main.cpp:267-304` +- **时间**: 2026-07-30 14:53:45 +- **描述**: 函数声明缺少 `try` 关键字(`void parse_data_from_mq(std::string& json_str) try {`),但代码能编译通过,可能使用了 C++11 的 `function-try-block` 语法。代码可读性较差,建议显式标注。 + +- **严重等级**: 🟢 Low +- **修复建议**: 添加注释说明使用的是函数 try-catch 语法 + +#### [CORE-Q03] PPool::kill 参数类型不匹配 + +- **文件**: `include/main/PPool.hh:35` vs `src/main/pmc_mtd.cpp:53` +- **时间**: 2026-07-30 14:55:00 +- **描述**: `PPool::kill(int idx)` 接受下标参数,但 `pmc_mtd::kill(pid)` 传入的是 PID。需要遍历查找,O(n) 复杂度。 + +- **严重等级**: 🟢 Low +- **修复建议**: + 1. 改为 `killByPid(pid_t pid)` + 2. 或维护 PID → 迭代器 的映射表 + +#### [CORE-Q04] 缺少配置加载能力 + +- **文件**: 全局 +- **时间**: 2026-07-30 14:56:30 +- **描述**: 当前无 `config.yaml` 或类似配置文件支持,所有参数硬编码: + - HTTP 端口 5200 + - 消息队列大小 2048/100 + - 证书路径 `server.crt/server.key` + +- **严重等级**: 🟢 Low +- **修复建议**: 引入 `config.yaml` 支持,至少分离端口和证书路径配置 + +#### [CORE-Q05] 中文代码库未被实际使用 + +- **文件**: `include/cn/` +- **时间**: 2026-07-30 14:58:00 +- **描述**: `中文化.hpp`、`链串.hpp`、`顺序表.hpp`、`向量.hpp`、`异常.hpp` 构成了一套完整的中文 API,但在 `src/` 代码中未被引用。这些文件更像是演示项目,而非生产代码。 + +- **严重等级**: 🟢 Low +- **修复建议**: + 1. 删除未使用的中文代码库 + 2. 或在 README 中明确标注为"实验性功能" + 3. 迁移到独立仓库 + +#### [CORE-Q06] 缺少单元测试 + +- **文件**: 全局 +- **时间**: 2026-07-30 14:59:30 +- **描述**: 项目无任何测试代码。核心功能(进程管理、消息队列、管道通信)完全依赖手工测试。 + +- **严重等级**: 🟢 Low +- **修复建议**: 引入 Google Test,覆盖: + - PPool 的增删查改 + - Pipe 的读写超时 + - Mq 的发送接收 + - 中文代码库的基础功能 + +--- + +## 三、统计汇总 + +| 等级 | 数量 | 问题 ID | +|------|------|---------| +| 🔴 Critical | 4 | S01, S02, S03, S04 | +| 🟡 Medium | 6 | S05, S06, S07, S08, S09, S10 | +| 🟢 Low | 6 | Q01, Q02, Q03, Q04, Q05, Q06 | +| **合计** | **16** | | + +### 按类别统计 + +| 类别 | 问题数 | 占比 | +|------|--------|------| +| 安全(注入、鉴权) | 3 | 19% | +| 稳定性(崩溃、竞态) | 4 | 25% | +| 功能缺失 | 2 | 13% | +| 代码质量 | 5 | 31% | +| 可观测性 | 2 | 12% | + +--- + +## 四、修复优先级建议 + +### 第一阶段(紧急,预计 1 天) + +1. **CORE-S03**: 修复 `ProcessTask` 空指针检查(最简单,影响启动) +2. **CORE-S01**: 实现命令白名单 + 路径校验(核心安全) +3. **CORE-S02**: 添加 API Token 鉴权(核心安全) + +### 第二阶段(重要,预计 3 天) + +4. **CORE-S05**: 添加 PPool 互斥锁(并发安全) +5. **CORE-S08**: 恢复 kill/exec IPC 路径(功能补全) +6. **CORE-S09**: 简化 PPool::clr() 实现(稳定性) +7. **CORE-S10**: 添加日志文件持久化(可观测性) + +### 第三阶段(优化,预计 1 周) + +8. **CORE-S04**: 完善路径穿越防护 +9. **CORE-S06**: 改进消息队列命名 +10. **CORE-S07**: 添加管道清理机制 +11. **CORE-Q01 ~ Q06**: 代码质量优化 + +--- + +## 五、亮点 + +尽管问题较多,项目仍有值得肯定之处: + +| 亮点 | 说明 | +|------|------| +| 清晰的模块划分 | 进程池、线程、通信、HTTP 各司其职 | +| 双轨 IPC 设计 | 消息队列 + 管道的请求-响应模式简洁高效 | +| 中文代码实验 | 对中文编程的探索有教育意义 | +| systemd 集成 | 标准部署方式,便于运维 | +| 最小依赖 | 仅依赖 Boost,无过度设计 | + +--- + +## 六、附录 + +### 审查工具 + +- 人工静态分析 +- grep 模式匹配 +- 代码路径追踪 + +### 参考文档 + +- [Boost.Process 文档](https://www.boost.org/doc/libs/release/libs/process/) +- [Boost.Interprocess 文档](https://www.boost.org/doc/libs/release/libs/interprocess/) +- [POSIX 消息队列](https://man7.org/linux/man-pages/man7/mq_overview.7.html) +- [FIFO 管道](https://man7.org/linux/man-pages/man7/fifo.7.html) + +### 审查人 + +- 自动化审查助手 +- 审查时间:2026-07-30 + +--- + +*本日志由自动化代码审查工具生成,供开发团队参考。修复建议需结合实际场景评估。* From 931e134796639e26c0c202534af751843be8e09f Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 12:02:25 +0800 Subject: [PATCH 28/38] =?UTF-8?q?docs(smtp):=20=E6=96=B0=E5=BB=BA=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=20README=EF=BC=8C=E8=A6=86=E7=9B=96=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E3=80=81=E8=B7=AF=E7=94=B1=E3=80=81=E9=85=8D=E7=BD=AE=E4=B8=8E?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3 个路由的完整请求/响应文档(send_email / verify_email / health) - .env 配置项说明(4 种格式支持、必填/可选字段) - 验证码生命周期:生成→冷却→消费→过期→作废→失败回滚 - 安全机制:发送冷却、失败次数上限防枚举、SMTP 失败回滚 - 集成关系:gateway 调用流程、core 守护、log_client 日志推送 - 旧版 smtp.py 的安全风险标注 fix(smtp): _set.sh 中 nuitka 编译目标 smtp.py → smtp_v2.py --- smtp/README.md | 165 +++++++++++++++++++++++++++++++++++++++++++++++++ smtp/_set.sh | 2 +- 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 smtp/README.md diff --git a/smtp/README.md b/smtp/README.md new file mode 100644 index 0000000..c94a2d6 --- /dev/null +++ b/smtp/README.md @@ -0,0 +1,165 @@ +# smtp — 邮箱验证码服务 + +基于自研 [pymsg](../pymsg/) 协议(TCP 长度前缀帧 + JSON)的邮箱验证码服务,通过 SMTP_SSL 发送 6 位数字验证码,支持发送冷却和防暴力枚举。 + +服务由 `core`(PMC)的 `init.txt` 拉起,监听 `127.0.0.1:8008`(TCP),仅允许本机调用。`gateway` 在 `/register` 流程中经 pymsg 调用本服务完成邮箱验证。 + +## 架构 + +``` +gateway :8443 (注册流程) + │ pymsg TCP + ▼ +smtp :8008 (MsgServer) + │ SMTP_SSL + ▼ +外部 SMTP 服务器 (163/QQ 等) + │ + ▼ +用户邮箱(收验证码) +``` + +## 路由 + +服务基于 pymsg 协议,每条消息为完整 JSON,`_route` 字段指定路由。 + +| _route | 说明 | +|--------|------| +| `send_email` | 生成 6 位数字验证码,通过 SMTP_SSL 发送至指定邮箱 | +| `verify_email` | 校验用户输入的验证码,一次性消费 | +| `health` | 健康检查 | + +响应格式: + +```json +{"success": true, "data": {...}} +{"success": false, "error": "..."} +``` + +### 1. send_email — 发送验证码 + +**请求**: + +```json +{"_route": "send_email", "email": "user@example.com"} +``` + +可选字段 `subject` / `content` 覆盖默认标题与正文,`content` 中 `{code}` 会被替换为验证码。 + +**响应**: + +| 响应 data | 触发条件 | +|-----------|----------| +| `{"sent": true, "ttl": 600}` | 发送成功,TTL 为有效期(秒) | +| `{"sent": false, "error": "invalid email"}` | 邮箱格式无效 | +| `{"sent": false, "error": "SMTP 服务未配置"}` | .env 缺少 SMTP 配置 | +| `{"sent": false, "retry_after": N, "error": "发送过于频繁,请 N 秒后重试"}` | 同一邮箱在冷却期内 | + +**安全机制**: +- 发送冷却:同一邮箱两次成功发送的最小间隔 `SEND_INTERVAL`(60 秒)。冷却期内请求直接拒绝,不覆盖已有验证码。 +- 发送失败回滚:SMTP 发送失败时立即删除刚写入的验证码,避免"码已存但邮件没到"的不可用状态。 + +### 2. verify_email — 校验验证码 + +**请求**: + +```json +{"_route": "verify_email", "email": "user@example.com", "code": "123456"} +``` + +兼容旧字段名 `input_captcha`。 + +**响应**(均为 `success: true`,业务结果在 `data` 中区分): + +| 响应 data | 触发条件 | +|-----------|----------| +| `{"valid": true}` | 验证码正确(已消费) | +| `{"valid": false, "error": "email and code required"}` | 缺少字段 | +| `{"valid": false, "error": "invalid or expired code"}` | 验证码错误、过期或不存在 | +| `{"valid": false, "invalidated": true, "error": "验证码连续错误 5 次,已失效,请重新获取"}` | 连续校验失败达上限 | + +> 验证码一经正确校验即从内存中删除(一次性消费),防重放。同一验证码连续校验失败达 `MAX_VERIFY_ATTEMPTS`(5 次)即作废,防止对 6 位数字码的在线枚举。 + +### 3. health — 健康检查 + +**请求**:`{"_route": "health"}` +**响应**:`{"success": true, "data": {"status": "ok", "service": "smtp"}}` + +## 配置 + +通过 `.env` 文件配置 SMTP 连接信息,支持 4 种格式: + +```bash +# KEY=VALUE(推荐) +SMTP_SERVER=smtp.163.com +SMTP_PORT=465 +SMTP_USER=your@163.com +__PASSWORD=your_smtp_password + +# 也支持 KEY: VALUE / KEY VALUE / export KEY=... +MAIL_FROM=dnfs@qsont.me # 可选,发件人身份;默认跟随 SMTP_USER +``` + +| 变量 | 必填 | 说明 | +|------|------|------| +| `SMTP_SERVER` | 是 | SMTP 服务器地址 | +| `SMTP_PORT` | 否 | SMTP 端口,默认 465 | +| `SMTP_USER` | 是 | 邮箱登录账号 | +| `__PASSWORD` | 是 | 邮箱密码/授权码(兼容别名 `SMTP_PASSWORD`) | +| `MAIL_FROM` | 否 | 发件人身份,默认同 SMTP_USER。多数服务商强制 From 头与登录账号一致 | + +服务启动时检查必填配置项,缺失时打印警告日志但不阻止启动(`send_email` 路由会返回配置错误)。 + +## 数据模型与验证码生命周期 + +- **存储**:进程内存字典 `code_store`,键为 email,值为 `{code, expires_at, attempts}`。 +- **冷却记录**:`send_history` 字典,键为 email,值为上次成功发送的时间戳。 +- **生成**:`send_email` 成功时写入 `code_store`,记录 `expires_at = now + CODE_TTL`(600 秒)。 +- **消费**:`verify_email` 校验通过时 `del code_store[email]`,不可再次使用。 +- **过期**:超时后 `verify_email` 自动删除并返回 `invalid or expired code`。`send_email` 时惰性清理过期项。 +- **作废**:连续校验失败达 5 次后删除验证码,返回 `invalidated: true`。 +- **发送失败回滚**:SMTP 异常时立即 `pop` 刚写入的 `code_store` 条目。 + +## 运行 + +```bash +# 初始化(创建 venv + 安装依赖) +bash _set.sh + +# 启动 +bash _run.sh + +# 或直接运行 +python3 smtp_v2.py --addr 127.0.0.1 --port 8008 +``` + +## 集成关系 + +- 由 `core` 通过 `init.txt` 拉起的 `bash _run.sh` 守护运行。 +- `gateway` 在 `/register` 时经 pymsg 调 `send_email` 发送验证码,调 `verify_email` 校验。需在 gateway 的 `example.ini` 中启用邮箱验证功能。 +- 日志通过 `log_client.py` 异步批量推送至 db:8005 的 Logs 表(缓冲 50 条 / 2 秒)。 + +## 项目结构 + +``` +smtp/ +├── smtp_v2.py # 服务主程序(pymsg 协议,当前有效版本) +├── smtp.py # 旧版 Flask HTTP 实现(已废弃,仅作参考) +├── log_client.py # 集中日志推送 SDK +├── _set.sh # 初始化脚本(创建 venv + 安装依赖) +├── _run.sh # 启动脚本(exec python3 smtp_v2.py) +├── requirements.txt # Python 依赖(pymsg、nuitka) +└── .gitignore # 忽略 __pycache__ / .env / venv +``` + +## 注意事项 + +- 验证码存储在进程内存中,服务重启后全部失效;不支持多实例水平扩展。 +- 默认仅监听 `127.0.0.1`,仅供本机调用;浏览器侧不直接访问本服务。 +- 旧版 `smtp.py`(Flask HTTP 实现)已废弃,`_run.sh` 当前启动的是 `smtp_v2.py`。旧版存在无冷却、无失败回滚、无防枚举等安全问题,且 `verify_email` 在 email 不存在时有 KeyError 风险,不应在生产环境使用。 +- 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,`MAIL_FROM` 仅当服务商允许自定义发件身份时才起作用。 +- 验证码为纯 6 位数字,有效期 600 秒。 + +## 许可证 + +MIT License diff --git a/smtp/_set.sh b/smtp/_set.sh index 5c79af3..1eb7202 100755 --- a/smtp/_set.sh +++ b/smtp/_set.sh @@ -4,4 +4,4 @@ python3 -m venv venv #sudo chmod +x .env/bin/activate source venv/bin/activate pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple -#nuitka --standalone smtp.py +#nuitka --standalone smtp_v2.py From 254fbf46289297a551da22d4d683d9ebc7b60b0f Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 12:08:17 +0800 Subject: [PATCH 29/38] =?UTF-8?q?docs(aibox):=20=E6=9B=B4=E6=96=B0=20READM?= =?UTF-8?q?E.md=20=E5=B9=B6=E6=96=B0=E5=A2=9E=20README.robot.md=20?= =?UTF-8?q?=E6=8A=80=E6=9C=AF=E6=89=8B=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md 修复: - 新增 ask_user 工具到工具列表 - 补充配置表中缺失的 17 个字段(system/scheduler/api/context/memo/files/commands/web/memory/conversation/tools/compressor/proxy) - 补充对话压缩完整配置项 README.robot.md 新增: - 17 章节完整技术手册(~500 行) - 架构图、数据流、协议详情 - 19 个工具的完整实现信息 - 配置参考(含所有字段说明) - 已知限制与安全建议 - 部署与故障排查指南 --- aibox/README.md | 77 +++- aibox/README.robot.md | 823 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 888 insertions(+), 12 deletions(-) create mode 100644 aibox/README.robot.md diff --git a/aibox/README.md b/aibox/README.md index 9ae1e2e..3b3e313 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -115,6 +115,7 @@ AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生, | `reload_skills` | 热重载扩展技能 | | `create_skill` | 创建新技能(AI 自主编写 Python 脚本) | | `install_skill` | 技能安装指引 | +| `ask_user` | 向用户提问(AI 主动交互) | | `update_identity` | 更新用户身份信息(`00_IDENTITY.md`) | | `update_soul` | 更新 AI 自我定义(`99_SOUL.md`,智能体进化) | | `save_memory_and_end_conversation` | 保存记忆并结束对话 | @@ -374,36 +375,88 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i { "system": { "save_dir": "~/.aibox", + "datetime_format": "%Y-%m-%d %H:%M:%S", + "date_format": "%Y%m%d_%H%M%S", + "debug_level": 1, + "load_history_by_default": true, + "log_file": "~/.aibox/ai.log", + "log_max_size": 10485760, + "log_backup_count": 5, "websocket_host": "localhost", "websocket_port": 8765, - "debug_level": 1, - "log_file": "~/.aibox/ai.log", "skills_dir": "~/.aibox/skills", "prompts_dir": "~/.aibox/prompts" }, + "scheduler": { + "enabled": true, + "check_interval": 1.0, + "max_tasks_per_run": 10, + "broadcast_enabled": true + }, + "api": { + "deepseek_api_url": "https://api.deepseek.com/chat/completions", + "deepseek_model": "deepseek-v4-flash", + "api_key_env": "DEEPSEEK_API_KEY", + "timeout": 30, + "temperature": 0.7 + }, "context": { "max_history_messages": 500, + "max_context_messages": 120, + "max_tokens": 8192, + "max_history_conversations": 5, + "preserve_system_prompts": true, "compress_enabled": true, "compress_message_threshold": 200, "compress_token_threshold": 120000, "compress_ratio": 0.2, "compress_keep_recent": 8 }, - "scheduler": { - "enabled": true, - "check_interval": 1.0, - "max_tasks_per_run": 10 + "memo": { + "default_reminder_minutes": 60, + "max_memos_per_check": 10, + "allow_user_tasks": true }, - "api": { - "deepseek_api_url": "https://api.deepseek.com/chat/completions", - "deepseek_model": "deepseek-v4-flash", - "api_key_env": "DEEPSEEK_API_KEY" + "files": { + "max_file_size": 10485710 + }, + "commands": { + "enabled": true + }, + "web": { + "timeout": 10, + "delay": 0, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "respect_robots": false, + "max_download_size": 104857600 }, "memory": { - "max_search_results": 100 + "db_name": "memories.db", + "max_search_results": 100, + "default_importance": 2 + }, + "conversation": { + "current_file": "current_conversation.json", + "history_file": "conversation_history.json", + "archive_dir": "archive" }, "tools": { - "max_result_length": 10000 + "max_result_length": 10000, + "save_result_to_file": false, + "tool_results_dir": "tool_results" + }, + "compressor": { + "min_messages_to_compress": 5, + "weight_decay_factor": 0.01, + "max_input_chars": 6000, + "llm_max_tokens": 800 + }, + "proxy": { + "type": "", + "host": "", + "port": 0, + "username": "", + "password": "" } } ``` diff --git a/aibox/README.robot.md b/aibox/README.robot.md new file mode 100644 index 0000000..278cece --- /dev/null +++ b/aibox/README.robot.md @@ -0,0 +1,823 @@ +# AIBOX 模块技术手册 + +> 文档版本:2026-07-30 +> 代码版本:v2.0 +> 语言:Python 3.8+ +> 定位:单 AI 智能体系统(独立于微服务体系) + +--- + +## 一、模块概述 + +### 1.1 定位 + +**AIBOX** 是一个基于 DeepSeek API 的单 AI 智能体系统,通过 WebSocket 与客户端双向通信。所有任务(文件操作、网页访问、记忆管理、任务调度等)都由 AI 对话驱动执行。 + +### 1.2 与微服务体系的关系 + +| 属性 | 说明 | +|------|------| +| 启动方式 | `core`(PMC) 通过 `init.txt` 拉起 | +| 监听端口 | 8765(WebSocket) | +| 与主干关系 | **完全独立**,不调用 waf/gateway/dnfs 等服务 | +| 数据存储 | 本地 `~/.aibox/`(不使用 db:8005) | +| 前端 | 独立 HTML 页面 | + +### 1.3 核心特性 + +| 特性 | 说明 | +|------|------| +| AI 对话 | DeepSeek API + 思考模式(reasoning 流式输出) | +| 实时通信 | WebSocket 双向通信 + 流式输出 | +| 工具系统 | 19 个内置工具(文件/网页/记忆/任务/命令等) | +| 技能扩展 | 热重载 `.py` 脚本和 `.md` 技能 | +| 记忆管理 | 自动保存/检索对话记忆 | +| 任务调度 | 一次性/重复/即时任务 | +| 自我进化 | AI 动态更新角色与用户画像 | +| 对话压缩 | 自动压缩长对话历史 | +| 多语言 | 中英自动检测 | + +--- + +## 二、技术架构 + +### 2.1 技术栈 + +| 层 | 技术 | 用途 | +|----|------|------| +| 语言 | Python 3.8+ | 主程序 | +| 通信 | websockets | WebSocket 服务器 | +| AI | DeepSeek API | 智能对话、工具调用、流式输出 | +| 存储 | SQLite | 记忆数据库、备忘录任务 | +| HTTP | requests | 网页访问、API 调用 | +| HTML 解析 | beautifulsoup4 | 网页内容提取 | +| SSH | paramiko | SSH 远程执行(技能) | +| 加密 | cryptography | AES 加密(技能) | + +### 2.2 架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ WebSocket 客户端 │ +│ (entry.html / frontend.html / client.py / voice_client.py) │ +└────────────────────────┬────────────────────────────────────┘ + │ ws://localhost:8765 +┌────────────────────────▼────────────────────────────────────┐ +│ WebSocket 服务器 (main.py) │ +├─────────────────────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ 消息处理器 │ │ 命令处理器 │ │ 任务调度器 │ │ +│ │ (handle_msg) │ │ (handle_cmd) │ │ (MemoScheduler) │ │ +│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │ +└─────────┼─────────────────┼───────────────────┼─────────────┘ + │ │ │ +┌─────────▼─────────────────▼───────────────────▼─────────────┐ +│ AI 对话引擎 (DeepSeekChat) │ +├─────────────────────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ 提示词管理器 │ │ 工具注册器 │ │ 对话压缩器 │ │ +│ │ (PromptMgr) │ │ (ToolRegistry)│ │ (ConversationComp)│ │ +│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │ +└─────────┼─────────────────┼───────────────────┼─────────────┘ + │ │ │ +┌─────────▼─────────────────▼───────────────────▼─────────────┐ +│ 数据持久层 │ +├─────────────────────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ 记忆数据库 │ │ 备忘录 DB │ │ 对话历史管理器 │ │ +│ │ (memories.db) │ │ (memos.db) │ │ (JSON files) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.3 核心数据流 + +``` +客户端请求 → WebSocketHandler.handle_message() + ↓ +┌─── 消息类型判断 ───┬─── 命令类型判断 ───┬─── 任务操作判断 ───┐ +│ type="message" │ type="command" │ type="task" │ +└─────────┬─────────┴─────────┬─────────┴─────────┬─────────┘ + │ │ │ + ▼ ▼ ▼ + AI 对话引擎 命令处理器 任务管理器 + (DeepSeekChat) (handle_command) (handle_task) + │ │ │ + ▼ ▼ ▼ + 工具调用循环 系统命令执行 任务 CRUD 操作 + (ToolRegistry) (/new, /list...) (add, complete...) + │ │ │ + ▼ ▼ ▼ + 流式响应生成 命令结果返回 任务状态广播 + (chunk → complete) (command_result) (task_status) +``` + +--- + +## 三、代码结构 + +### 3.1 文件结构 + +``` +aibox/ +├── main.py # 核心(~8000 行) +│ ├── WebSocketHandler # WebSocket 消息处理 +│ ├── DeepSeekChat # AI 对话引擎 +│ ├── PromptManager # 提示词管理 +│ ├── Tool + 19 个工具类 # 工具系统 +│ ├── MemoScheduler # 任务调度器 +│ ├── MemoryManager # 记忆管理 +│ └── ConfigManager # 配置管理 +├── client.py # CLI WebSocket 客户端 +├── voice_client.py # 语音客户端(语音输入/输出) +├── entry.html # Web 前端入口 +├── frontend.html # Web 主页面 +├── skills/ # 内置扩展技能 +│ ├── hello-world.py # 示例技能 +│ ├── bing_search.py # 必应搜索 +│ ├── ssh-exec.py # SSH 远程执行 +│ ├── sftp-transfer.py # SFTP 传输 +│ ├── facerec.py # 人脸识别 +│ ├── aes-crypto.py # AES 加密 +│ ├── gitclient.py # Git 操作 +│ └── *.md # Markdown 技能(prompt) +├── tasks/ITIL.md # ITIL 任务模板 +├── SKILLS_FORMAT.md # 技能格式规范 +├── requirements.txt +├── _run.sh # core 拉起的启动脚本 +├── _runcli.sh # CLI 启动 +├── _set.sh # 依赖安装 +├── _setenv.sh # 环境变量设置 +└── README.md # 用户文档 +``` + +### 3.2 main.py 核心类 + +| 类名 | 行号 | 功能 | +|------|------|------| +| `Tool` | L3642 | 工具基类(抽象方法:get_name/execute) | +| `WebSocketHandler` | ~L1900 | WebSocket 消息处理 | +| `DeepSeekChat` | ~L2400 | AI 对话引擎 | +| `PromptManager` | ~L1050 | 提示词文件管理 | +| `ConfigManager` | ~L1080 | 配置加载/合并 | +| `MemoryManager` | ~L5200 | 记忆 CRUD | +| `MemoScheduler` | ~L4700 | 任务调度器 | +| `ConversationCompressor` | ~L6480 | 对话压缩 | + +### 3.3 运行期数据 + +``` +~/.aibox/ +├── config.json # 配置(自动生成默认值) +├── prompts/ +│ ├── 00_IDENTITY.md # 用户身份(AI 可更新) +│ ├── 1_*.md # 基础技能提示词 +│ ├── 99_SOUL.md # AI 灵魂定义(AI 可更新) +│ └── 100_STATUS.md # AI 状态记录 +├── skills/ # 用户扩展技能 +├── memories.db # 记忆库(SQLite) +├── memos.db # 备忘录任务(SQLite) +├── current_conversation.json # 当前对话上下文 +├── conversation_history.json # 对话索引 +├── archive/ # 历史对话归档 +├── tool_results/ # 工具执行结果缓存 +└── ai.log # 日志(自动轮转) +``` + +--- + +## 四、工具系统 + +### 4.1 工具基类 + +```python +class Tool: + """工具基类,所有工具必须继承此类""" + + def get_name(self) -> str: + """返回工具名称(抽象方法,必须实现)""" + pass + + def get_description(self) -> str: + """返回工具描述""" + return "" + + def get_parameters(self) -> Dict: + """返回工具参数(JSON Schema 格式)""" + return {"type": "object", "properties": {}} + + def execute(self, **kwargs) -> str: + """执行工具(抽象方法,必须实现)""" + pass + + def execute_streaming(self, **kwargs): + """流式执行(可选实现)""" + return None +``` + +### 4.2 内置工具一览 + +| 工具名 | 类名 | 行号 | 功能 | +|--------|------|------|------| +| `read_file` | `ReadFileTool` | L5762 | 读取文件(支持 head/tail/paging) | +| `write_file` | `WriteFileTool` | L5884 | 写入/追加文件 | +| `list_files` | `ListFilesTool` | L5961 | 目录列表 | +| `web` | `SimpleWebCrawlerTool` | L5637 | 网页抓取(标题/正文/链接) | +| `memory` | `MemoryTool` | L5280 | 记忆 CRUD(search/save/delete) | +| `memo` | `MemoTool` | L4753 | 任务 CRUD(add/complete/list/search) | +| `run_command` | `CommandLineTool` | L6150 | 系统命令执行(支持超时) | +| `search_skills` | `SearchSkillsTool` | L4076 | 搜索扩展技能 | +| `reload_skills` | `ReloadSkillsTool` | L4199 | 热重载技能 | +| `create_skill` | `CreateSkillTool` | L4409 | AI 自主创建技能 | +| `install_skill` | `InstallSkillTool` | L4306 | 技能安装指引 | +| `ask_user` | `AskUserTool` | L4275 | 向用户提问 | +| `update_identity` | `UpdateIdentityTool` | L4518 | 更新用户身份 | +| `update_soul` | `UpdateSoulTool` | L4582 | 更新 AI 灵魂 | +| `save_memory_and_end_conversation` | `SaveMemoryAndEndConversationTool` | L4646 | 保存记忆并结束对话 | +| `get_current_time` | `GetCurrentTimeTool` | L6055 | 获取当前时间 | +| `calculator` | `CalculatorTool` | L6095 | 数学计算 | +| `set_proxy` | `SetProxyTool` | L6415 | 设置 SOCKS5 代理 | +| `get_proxy` | `GetProxyTool` | L6453 | 查看代理配置 | + +### 4.3 工具执行流程 + +``` +AI 决定调用工具 → 生成 JSON 格式工具调用 + ↓ +DeepSeekChat.process_tool_calls() + ↓ +┌─── 解析工具名 ───┬─── 解析参数 ───┬─── 注册工具 ───┐ +└─────────────────┴───────────────┴─────────────────┘ + ↓ +ToolRegistry.get_tool(name) + ↓ +tool.execute(**params) + ↓ +┌─── 结果处理 ───┬─── 错误处理 ───┬─── 长结果截断 ───┐ +└─────────────────┴───────────────┴─────────────────┘ + ↓ +返回结果给 AI → AI 继续生成响应 +``` + +--- + +## 五、WebSocket 协议 + +### 5.1 客户端 → 服务器 + +| 类型 | 字段 | 说明 | +|------|------|------| +| `message` | `content` | 普通对话文本 | +| `command` | `command` | 斜杠命令(如 `/new`, `/help`) | +| `task` | `action`, `title`, ... | 任务操作(add/complete/list) | +| `ping` | — | 心跳检测 | + +### 5.2 服务器 → 客户端 + +| 类型 | 字段 | 说明 | +|------|------|------| +| `chunk` | `content` | AI 响应片段(流式) | +| `complete` | `content` | AI 响应完成 | +| `reasoning` | `content` | AI 思考过程 | +| `error` | `content` | 错误信息 | +| `command_result` | `content` | 命令执行结果 | +| `task_status` | `task_id`, `status` | 任务状态更新 | +| `task_process` | `task_id`, `content` | 任务执行过程 | +| `dialogue_ended` | — | 对话结束 | +| `dialogue_reset` | — | 对话重置完成 | +| `info` | `content` | 系统信息 | +| `pong` | — | Ping 响应 | + +### 5.3 消息流示例 + +``` +// 普通对话 +Client: {type: "message", content: "你好"} +Server: {type: "chunk", content: "你好!"} +Server: {type: "complete", content: "你好!有什么可以帮你的吗?"} + +// 工具调用 +Server: {type: "tool_call", tool: "web", args: {url: "..."}} +Server: {type: "chunk", content: "网页内容:..."} +Server: {type: "complete", content: "网页已获取"} + +// 任务执行 +Client: {type: "task", action: "add", title: "提醒", delay: 60} +Server: {type: "command_result", content: "任务已添加"} +// 60 秒后 +Server: {type: "task_status", task_id: 1, status: "executing"} +Server: {type: "task_process", task_id: "1", content: "处理中..."} +Server: {type: "task_status", task_id: 1, status: "complete"} +``` + +--- + +## 六、任务系统 + +### 6.1 任务类型 + +| 类型 | 字段 | 说明 | +|------|------|------| +| 一次性 | `reminder_time` | 指定执行时间 | +| 重复(daily) | `repeat_type: "daily"` | 每天执行 | +| 重复(weekly) | `repeat_type: "weekly"`, `weekday` | 每周指定日 | +| 重复(monthly) | `repeat_type: "monthly"`, `day` | 每月指定日 | +| 重复(custom) | `repeat_type: "custom"`, `interval`, `unit` | 自定义间隔 | +| 即时 | `is_immediate: true` | 立即执行 | + +### 6.2 任务调度流程 + +``` +MemoScheduler._run() # 后台线程 + ↓ +检查所有未完成任务 + ↓ +┌─── 到达时间? ───┬─── 未完成? ───┬─── 允许用户任务? ───┐ +└─────────────────┴───────────────┴─────────────────┘ + ↓ +创建新的 AI 对话(将任务内容作为用户输入) + ↓ +AI 处理任务(可能调用多个工具) + ↓ +标记任务已触发 + ↓ +广播结果到所有客户端 + ↓ +计算下一次执行时间(重复任务) +``` + +### 6.3 数据库表结构 + +```sql +-- memories.db +CREATE TABLE memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, + content TEXT, + importance INTEGER DEFAULT 2, + created_at TEXT, + updated_at TEXT, + embedding BLOB -- 预留向量嵌入 +); + +-- memos.db +CREATE TABLE memos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + content TEXT, + reminder_time TEXT, + repeat_type TEXT, + repeat_interval INTEGER, + repeat_interval_unit TEXT, + is_immediate INTEGER DEFAULT 0, + status TEXT DEFAULT 'pending', + is_triggered INTEGER DEFAULT 0, + created_at TEXT, + user_id TEXT +); +``` + +--- + +## 七、记忆系统 + +### 7.1 记忆操作 + +| 操作 | AI 调用 | 说明 | +|------|---------|------| +| 保存 | `memory(action="save", content=...)` | AI 主动保存重要信息 | +| 搜索 | `memory(action="search", query=...)` | 按关键词搜索记忆 | +| 列表 | `memory(action="list", page=...)` | 分页浏览所有记忆 | +| 删除 | `memory(action="delete", id=...)` | 删除指定记忆 | + +### 7.2 自动记忆 + +- AI 在对话结束时(`/exit`)自动保存关键信息 +- 可配置 `system.auto_save_memory` 开关 +- 重要度(importance)1-5 级 + +--- + +## 八、对话压缩 + +### 8.1 压缩触发条件 + +| 条件 | 阈值(默认) | 配置项 | +|------|-------------|--------| +| 消息数超过 | 200 条 | `compress_message_threshold` | +| Token 数超过 | 120000 | `compress_token_threshold` | + +### 8.2 压缩流程 + +``` +检测到阈值触发 + ↓ +保留最近 8 条消息(compress_keep_recent) + ↓ +对早期消息按时间衰减加权(weight_decay_factor=0.01) + ↓ +调用 AI 生成摘要(max_input_chars=6000) + ↓ +将摘要作为系统消息插入 + ↓ +压缩后继续对话 +``` + +### 8.3 压缩配置 + +```json +{ + "compressor": { + "min_messages_to_compress": 5, + "weight_decay_factor": 0.01, + "max_input_chars": 6000, + "llm_max_tokens": 800 + } +} +``` + +--- + +## 九、扩展技能系统 + +### 9.1 技能类型 + +| 类型 | 格式 | 存储位置 | +|------|------|---------| +| 可执行脚本 | Python/Shell,支持 `--help` | `~/.aibox/skills/` | +| Markdown 技能 | `.md` 文件,首行为简介 | `~/.aibox/skills/` | + +### 9.2 内置技能 + +| 技能名 | 文件 | 功能 | +|--------|------|------| +| hello-world | `skills/hello-world.py` | 示例技能 | +| bing_search | `skills/bing_search.py` | 必应搜索 | +| ssh-exec | `skills/ssh-exec.py` | SSH 远程执行 | +| sftp-transfer | `skills/sftp-transfer.py` | SFTP 文件传输 | +| facerec | `skills/facerec.py` | 人脸识别 | +| aes-crypto | `skills/aes-crypto.py` | AES 加密/解密 | +| gitclient | `skills/gitclient.py` | Git 操作封装 | + +### 9.3 技能热重载 + +```python +# 新技能放置到 ~/.aibox/skills/ +# 调用 reload_skills 工具 +# AI 下次对话时即可使用 +``` + +### 9.4 技能格式规范 + +详见 [SKILLS_FORMAT.md](file:///Users/qingsong/micro_servers/aibox/SKILLS_FORMAT.md) + +--- + +## 十、配置参考 + +### 10.1 完整配置项 + +#### system 段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `save_dir` | str | `~/.aibox` | 数据存储目录 | +| `datetime_format` | str | `%Y-%m-%d %H:%M:%S` | 日期时间格式 | +| `date_format` | str | `%Y%m%d_%H%M%S` | 文件名日期格式 | +| `debug_level` | int | `1` | 调试级别 0-3 | +| `load_history_by_default` | bool | `true` | 启动时加载历史对话 | +| `log_file` | str | `~/.aibox/ai.log` | 日志文件路径 | +| `log_max_size` | int | `10485760` | 日志最大字节数(10MB) | +| `log_backup_count` | int | `5` | 日志备份数量 | +| `websocket_host` | str | `localhost` | WebSocket 监听地址 | +| `websocket_port` | int | `8765` | WebSocket 监听端口 | +| `skills_dir` | str | `~/.aibox/skills` | 技能目录 | +| `prompts_dir` | str | `~/.aibox/prompts` | 提示词目录 | + +#### scheduler 段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `enabled` | bool | `true` | 启用任务调度器 | +| `check_interval` | float | `1.0` | 检查间隔(秒) | +| `max_tasks_per_run` | int | `10` | 单次最大触发任务数 | +| `broadcast_enabled` | bool | `true` | 广播任务执行结果 | + +#### api 段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `deepseek_api_url` | str | `https://api.deepseek.com/chat/completions` | API URL | +| `deepseek_model` | str | `deepseek-v4-flash` | 模型名 | +| `api_key_env` | str | `DEEPSEEK_API_KEY` | API Key 环境变量名 | +| `timeout` | int | `30` | 请求超时(秒) | +| `temperature` | float | `0.7` | 生成温度 0-1 | + +#### context 段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `max_history_messages` | int | `500` | 历史消息上限 | +| `max_context_messages` | int | `120` | 上下文窗口 | +| `max_tokens` | int | `8192` | 最大生成 Token | +| `max_history_conversations` | int | `5` | 保留历史对话数 | +| `preserve_system_prompts` | bool | `true` | 保留系统提示词 | +| `compress_enabled` | bool | `true` | 启用对话压缩 | +| `compress_message_threshold` | int | `200` | 压缩消息阈值 | +| `compress_token_threshold` | int | `120000` | 压缩 Token 阈值 | +| `compress_ratio` | float | `0.2` | 压缩保留比例 | +| `compress_keep_recent` | int | `8` | 保留最近消息数 | + +#### memo 段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `default_reminder_minutes` | int | `60` | 默认提醒分钟数 | +| `max_memos_per_check` | int | `10` | 单次最大触发任务数 | +| `allow_user_tasks` | bool | `true` | 允许用户创建任务 | + +#### proxy 段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `type` | str | `""` | 代理类型(socks5/http) | +| `host` | str | `""` | 代理主机 | +| `port` | int | `0` | 代理端口 | +| `username` | str | `""` | 代理用户名 | +| `password` | str | `""` | 代理密码 | + +### 10.2 环境变量 + +| 变量 | 说明 | +|------|------| +| `DEEPSEEK_API_KEY` | DeepSeek API Key(必填) | +| `AI_CONFIG_PATH` | 自定义配置文件路径 | + +--- + +## 十一、命令行参数 + +```bash +python main.py [选项] +``` + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `--verbose`, `-v` | flag | `false` | 显示详细日志 | +| `--debug` | int | `None` | 调试级别 0-3 | +| `--lang` | str | `None` | 强制语言 zh/en | +| `--host` | str | `None` | 覆盖 WebSocket 主机 | +| `--port` | int | `None` | 覆盖 WebSocket 端口 | +| `--no-scheduler` | flag | `false` | 禁用任务调度器 | +| `--scheduler-interval` | float | `1.0` | 调度器检查间隔 | +| `--config`, `-c` | str | `None` | 指定配置文件 | +| `--check-memo` | flag | `false` | 检查到期任务并退出 | +| `--memo-add` | str | `None` | 添加任务(CLI) | +| `--memo-list` | flag | `false` | 列出任务 | +| `--memo-complete` | int | `None` | 完成指定任务 | + +### 使用示例 + +```bash +# 启动服务器 +python main.py + +# 详细日志 + 指定端口 +python main.py --verbose --port 9000 + +# 检查并执行到期任务(cron 场景) +python main.py --check-memo + +# 添加任务 +python main.py --memo-add "每日备份|执行数据库备份|+1h" + +# 列出所有任务 +python main.py --memo-list +``` + +--- + +## 十二、前端集成 + +### 12.1 前端文件 + +| 文件 | 说明 | +|------|------| +| `entry.html` | 简单入口页(重定向到 frontend.html) | +| `frontend.html` | 完整前端界面(对话/任务/设置) | + +### 12.2 WebSocket 连接 + +```javascript +const ws = new WebSocket('ws://localhost:8765'); + +// 发送消息 +ws.send(JSON.stringify({ + type: 'message', + content: '你好' +})); + +// 接收消息 +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + switch (data.type) { + case 'chunk': + appendToChat(data.content); + break; + case 'complete': + finishResponse(); + break; + case 'error': + showError(data.content); + break; + } +}; +``` + +--- + +## 十三、部署 + +### 13.1 目录结构 + +``` +/opt/micro_servers/aibox/ +├── main.py +├── client.py +├── skills/ +├── _run.sh # core 拉起入口 +├── _set.sh # 首次安装 +└── requirements.txt +``` + +### 13.2 core init.txt 配置 + +```bash +# aibox 由 core 拉起 +bash /opt/micro_servers/aibox/_run.sh +``` + +### 13.3 systemd 单元(可选) + +```ini +[Unit] +Description=AIBOX AI Agent +After=network.target + +[Service] +Type=simple +User=deploy +WorkingDirectory=/opt/micro_servers/aibox +ExecStart=/usr/bin/python3 main.py +Restart=always +RestartSec=5 +Environment=DEEPSEEK_API_KEY=xxx + +[Install] +WantedBy=multi-user.target +``` + +--- + +## 十四、已知限制 + +| ID | 描述 | 严重等级 | +|----|------|---------| +| L1 | 单实例运行,不支持多进程 | 🟡 Medium | +| L2 | 记忆库无向量搜索,仅关键词匹配 | 🟡 Medium | +| L3 | 对话压缩可能丢失早期重要信息 | 🟢 Low | +| L4 | 工具执行无超时控制(部分工具) | 🟡 Medium | +| L5 | 日志无远程推送,仅本地文件 | 🟢 Low | +| L6 | 无用户认证,WebSocket 连接可被滥用 | 🔴 High | +| L7 | API Key 以明文存储在环境变量或文件中 | 🟡 Medium | + +### L6 详细说明 + +WebSocket 服务器**无任何认证**,任何能连接到 `ws://localhost:8765` 的客户端都可以: +- 与 AI 对话(消耗 API 额度) +- 执行系统命令(`run_command`) +- 操作文件系统(`read_file`/`write_file`) +- 创建/删除任务 + +**建议**: +1. 仅绑定 `localhost`(默认配置已这样做) +2. 添加 API Token 认证 +3. 限制 `run_command` 可执行的命令范围 + +--- + +## 十五、开发指南 + +### 15.1 添加新工具 + +1. 继承 `Tool` 基类 +2. 实现 `get_name()`、`get_description()`、`get_parameters()`、`execute()` +3. 在 `DeepSeekChat._register_tools()` 中注册 +4. 更新 README 文档 + +```python +class MyTool(Tool): + def get_name(self) -> str: + return "my_tool" + + def get_description(self) -> str: + return "我的自定义工具" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "param": {"type": "string", "description": "参数说明"} + }, + "required": ["param"] + } + + def execute(self, param: str, **kwargs) -> str: + return f"执行结果: {param}" +``` + +### 15.2 添加新命令 + +在 `WebSocketHandler.handle_command()` 中添加: + +```python +elif command == "/mycommand": + result = "命令执行结果" + await self.send_message(websocket, { + "type": "command_result", + "content": result + }) +``` + +### 15.3 添加配置项 + +1. 在 `ConfigManager._get_default_config()` 中添加默认值 +2. 在 README 文档中添加说明 +3. 确保 `_merge_configs()` 能正确处理新增字段 + +--- + +## 十六、故障排查 + +### 16.1 常见问题 + +| 问题 | 排查步骤 | +|------|---------| +| API Key 无效 | 检查 `echo $DEEPSEEK_API_KEY` 或 `~/.cert/deepseek.key` | +| 技能不生效 | 确认技能文件在 `~/.aibox/skills/` 且有执行权限 | +| 任务不执行 | 检查调度器状态:`/tasks`;检查时间设置 | +| 对话压缩丢失信息 | 调整 `compress_message_threshold` 和 `compress_keep_recent` | +| WebSocket 断开 | 检查服务器进程:`ps aux | grep main.py`;查看日志 | + +### 16.2 日志位置 + +```bash +# 查看实时日志 +tail -f ~/.aibox/ai.log + +# 查看最近错误 +grep "ERROR" ~/.aibox/ai.log | tail -20 +``` + +### 16.3 数据库检查 + +```bash +# 检查记忆库 +sqlite3 ~/.aibox/memories.db "SELECT * FROM memories LIMIT 10;" + +# 检查任务库 +sqlite3 ~/.aibox/memos.db "SELECT * FROM memos WHERE status='pending';" +``` + +--- + +## 十七、版本历史 + +| 版本 | 日期 | 主要变更 | +|------|------|---------| +| v2.0 | 当前 | 新增 `ask_user`、`update_soul`、`create_skill`、`install_skill`、`set_proxy`/`get_proxy` 等工具;支持 Markdown 技能;对话压缩支持 Token 阈值 | +| v1.0 | — | 初始版本:WebSocket 服务器、文件/网页/记忆操作、任务调度、扩展技能、多语言 | + +--- + +## 附录 + +### A. 配置文件示例 + +完整默认配置见 [main.py:1100-1180](file:///Users/qingsong/micro_servers/aibox/main.py#L1100-L1180) + +### B. 数据库 Schema + +完整建表语句见: +- 记忆库:[main.py](file:///Users/qingsong/micro_servers/aibox/main.py) 中 `MemoryManager._init_db()` +- 任务库:[main.py](file:///Users/qingsong/micro_servers/aibox/main.py) 中 `MemoScheduler._init_db()` + +### C. 参考文档 + +- [DeepSeek API 文档](https://platform.deepseek.com/docs) +- [websockets 库文档](https://websockets.readthedocs.io/) +- [SKILLS_FORMAT.md](file:///Users/qingsong/micro_servers/aibox/SKILLS_FORMAT.md) + +--- + +*本文档由代码审查工具生成,供开发团队参考。* From 6649b31e17e9925b90d51e2a485a9371eb0ec342 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 30 Jul 2026 12:25:11 +0800 Subject: [PATCH 30/38] feat(static): add frontend token auto-refresh for dnfs page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New token_manager.js: silent refresh timer (80% lifetime) + global fetch 401 interceptor with one-shot retry. - dnfs.py /whoami now returns iat, created_at, keep_time for expiry estimation. - Dnfs.html loads token_manager.js before dnfs.js (zero change to dnfs.js). - Graceful fallback: stale tokens without new fields, refresh failures, and /refresh_token 401 itself all degrade to the existing 401→login flow. --- dnfs/dnfs.py | 12 ++- static/public/dnfs.html | 1 + static/public/js/token_manager.js | 118 ++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 static/public/js/token_manager.js diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 60c49c8..c06f7c4 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -288,11 +288,19 @@ def whoami(): if info is None: return jsonify({'login': False}), 200 - return jsonify({ + resp = { 'login': True, 'user': info.get('user'), 'is_admin': bool(info.get('is_admin')) - }), 200 + } + # 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间 + if info.get('iat'): + resp['iat'] = info['iat'] + if info.get('created_at'): + resp['created_at'] = info['created_at'] + if info.get('keep_time'): + resp['keep_time'] = info['keep_time'] + return jsonify(resp), 200 # eg: {"id": 1} diff --git a/static/public/dnfs.html b/static/public/dnfs.html index 31286b3..bc4c659 100644 --- a/static/public/dnfs.html +++ b/static/public/dnfs.html @@ -26,6 +26,7 @@ +