Merge pull request 'Dev qing' (#13) from dev_qing into main
Reviewed-on: qing/micro_servers#13
This commit is contained in:
commit
2a6d68386d
48 files changed
+3494
-1017
No files matched your search
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
|
||||
# nuitka 编译产物
|
||||
botops.bin
|
||||
app.build/
|
||||
app.dist/
|
||||
app.onefile-build/
|
||||
@@ -0,0 +1,46 @@
|
||||
# BotOps — aibox 智能体 TUI 前端
|
||||
|
||||
复刻并增强 [aibox/client.py](../aibox/client.py),基于 [Textual](https://textual.textualize.io/) 的现代化终端 UI,连接 aibox 微服务(WebSocket,默认 `ws://localhost:8765`)。
|
||||
|
||||
## 相比 client.py 的增强
|
||||
|
||||
| 能力 | client.py | BotOps |
|
||||
|------|-----------|--------|
|
||||
| AI 回复渲染 | 纯文本流式 / 一次性静态渲染 | **Markdown 增量流式渲染**(无闪烁) |
|
||||
| 终端管理 | 手搓光标/提示符 | Textual 部件框架(多面板布局) |
|
||||
| 任务事件 | 混在对话流里 | **独立侧栏**(task_process / task_status / task_error) |
|
||||
| 自动重连 | ✅(已移植) | ✅ 指数退避 2s→30s,`/disconnect` 抑制 |
|
||||
| 快捷键 | Ctrl+C 中断 | Ctrl+C 中断 / Ctrl+Q 退出 / Ctrl+L 清屏 |
|
||||
|
||||
## 安装与运行
|
||||
|
||||
```bash
|
||||
cd BotOps
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
python app.py # 默认 ws://localhost:8765
|
||||
python app.py ws://your-host:8765 # 指定 aibox 地址
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
- 本地命令(不发给服务器):`/connect` `/disconnect` `/interrupt` `/clear` `/status` `/help` `/quit`
|
||||
- 其余 `/` 命令透传给 aibox 服务器(与 client.py 行为一致)
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
BotOps/
|
||||
├── app.py # Textual App:布局 + 消息路由 + 快捷键
|
||||
├── ws_client.py # WebSocket 协议层(自动重连)
|
||||
├── commands.py # 斜杠命令注册表
|
||||
└── widgets/
|
||||
├── chat_view.py # 聊天区:AI 回复 Markdown 增量流式渲染
|
||||
└── status_bar.py # 底部状态栏(连接状态)
|
||||
```
|
||||
|
||||
## 协议说明
|
||||
|
||||
与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释):
|
||||
- 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}`
|
||||
- 接收:`chunk`(**增量片段**,服务端 `chunk_buffer` 发送后即清零,前端直接拼接)/ `complete`(**完整全文**,用于对齐已显示内容)/ `line`(过程行:🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏)/ `reasoning`(思考流,仅服务端 debug_level≥3 发送)/ `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong`
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# 编译 BotOps 为独立单文件(不运行):产出 ./botops.bin
|
||||
# 用法: ./_inst.sh
|
||||
cd "$(dirname "$0")" || exit
|
||||
source venv/bin/activate
|
||||
python3 -m nuitka --standalone --onefile \
|
||||
--assume-yes-for-downloads \
|
||||
--include-package=websockets \
|
||||
--include-package=textual \
|
||||
--include-package-data=textual \
|
||||
--output-filename=botops.bin \
|
||||
app.py
|
||||
echo "编译完成: ./botops.bin(运行: ./botops.bin [ws://host:port])"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
source venv/bin/activate
|
||||
exec python3 app.py $@
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BotOps — aibox 智能体的现代化 TUI 前端(Textual)。
|
||||
|
||||
复刻并增强 aibox/client.py:
|
||||
- AI 回复流式 Markdown 渲染(Markdown 部件增量追加,无闪烁)
|
||||
- 断线指数退避自动重连(/disconnect 抑制)
|
||||
- 任务事件独立侧栏(task_process / task_status / task_error)
|
||||
- Ctrl+C 中断生成、Ctrl+Q 退出、Ctrl+L 清屏
|
||||
|
||||
用法:
|
||||
python app.py [ws://host:port] # 默认 ws://localhost:8765
|
||||
"""
|
||||
import sys
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import Footer, Header, Input, RichLog
|
||||
|
||||
from commands import CommandRegistry
|
||||
from widgets.chat_view import ChatView
|
||||
from widgets.status_bar import StatusBar
|
||||
from ws_client import BotWSClient
|
||||
|
||||
DEFAULT_URI = "ws://localhost:8765"
|
||||
|
||||
|
||||
class BotOpsApp(App):
|
||||
TITLE = "BotOps"
|
||||
SUB_TITLE = "aibox TUI"
|
||||
|
||||
CSS = """
|
||||
#chat {
|
||||
width: 3fr;
|
||||
border: solid $primary;
|
||||
}
|
||||
#tasks {
|
||||
width: 1fr;
|
||||
border: solid $secondary;
|
||||
}
|
||||
.msg-user {
|
||||
color: cyan;
|
||||
padding: 0 1;
|
||||
margin-top: 1;
|
||||
}
|
||||
.msg-ai {
|
||||
padding: 0 1;
|
||||
}
|
||||
.msg-system {
|
||||
color: $text-muted;
|
||||
padding: 0 1;
|
||||
}
|
||||
.msg-error {
|
||||
color: red;
|
||||
padding: 0 1;
|
||||
}
|
||||
.msg-task {
|
||||
color: yellow;
|
||||
padding: 0 1;
|
||||
}
|
||||
#status {
|
||||
height: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "interrupt", "中断", show=True),
|
||||
Binding("ctrl+q", "quit", "退出", show=True),
|
||||
Binding("ctrl+l", "clear", "清屏", show=True),
|
||||
]
|
||||
|
||||
def __init__(self, uri: str = DEFAULT_URI):
|
||||
super().__init__()
|
||||
self.ws = BotWSClient(uri)
|
||||
self.ws.on_message = self._on_ws_message
|
||||
self.ws.on_state = self._on_ws_state
|
||||
self.registry = CommandRegistry(self)
|
||||
|
||||
# ── 布局 ─────────────────────────────────────────────────────────────
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Horizontal():
|
||||
yield ChatView(id="chat")
|
||||
yield RichLog(id="tasks", max_lines=1000)
|
||||
yield Input(placeholder="输入消息,/help 查看命令,Enter 发送", id="input")
|
||||
yield StatusBar(id="status")
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self):
|
||||
self.query_one("#input", Input).focus()
|
||||
self._refresh_status("")
|
||||
self.run_worker(self.connect_to(self.ws.uri))
|
||||
|
||||
@property
|
||||
def chat(self) -> ChatView:
|
||||
return self.query_one("#chat", ChatView)
|
||||
|
||||
@property
|
||||
def tasks(self) -> RichLog:
|
||||
return self.query_one("#tasks", RichLog)
|
||||
|
||||
# ── 连接管理 ──────────────────────────────────────────────────────────
|
||||
async def connect_to(self, uri: str):
|
||||
if self.ws.connected:
|
||||
await self.ws.disconnect()
|
||||
self.ws.uri = uri
|
||||
await self.notify_system(f"正在连接 {uri} …")
|
||||
await self.ws.connect()
|
||||
|
||||
async def disconnect(self):
|
||||
await self.ws.disconnect()
|
||||
|
||||
async def send_interrupt(self):
|
||||
if await self.ws.send_interrupt():
|
||||
await self.notify_system("🛑 已发送中断信号")
|
||||
else:
|
||||
await self.notify_system("未连接到服务器", level="error")
|
||||
|
||||
# ── UI 操作 ───────────────────────────────────────────────────────────
|
||||
async def notify_system(self, text: str, level: str = "info"):
|
||||
css = {"info": "msg-system", "error": "msg-error", "task": "msg-task"}.get(level, "msg-system")
|
||||
await self.chat.add_system(text, css)
|
||||
|
||||
async def clear_chat(self):
|
||||
await self.chat.reset()
|
||||
|
||||
def _refresh_status(self, extra: str):
|
||||
self.query_one("#status", StatusBar).set_state(
|
||||
self.ws.connected, self.ws.uri if self.ws.connected else "", extra)
|
||||
|
||||
# ── 输入处理 ──────────────────────────────────────────────────────────
|
||||
async def on_input_submitted(self, event: Input.Submitted):
|
||||
text = event.value.strip()
|
||||
event.input.value = ""
|
||||
if not text:
|
||||
return
|
||||
if text.startswith("/") and await self.registry.execute(text):
|
||||
return
|
||||
if not self.ws.connected:
|
||||
await self.notify_system("未连接到服务器,先 /connect", level="error")
|
||||
return
|
||||
await self.chat.add_user(text)
|
||||
await self.chat.start_ai() # 预建 AI 消息框,即时反馈
|
||||
if not await self.ws.send_message(text):
|
||||
await self.chat.finish_ai("") # 发送失败:移除预建的空消息框
|
||||
await self.notify_system("❌ 发送失败:连接已断开,请 /connect 重连", level="error")
|
||||
|
||||
# ── 快捷键 ────────────────────────────────────────────────────────────
|
||||
async def action_interrupt(self):
|
||||
await self.send_interrupt()
|
||||
|
||||
async def action_clear(self):
|
||||
await self.clear_chat()
|
||||
|
||||
def action_quit(self):
|
||||
self.exit()
|
||||
|
||||
# ── aibox 协议消息路由(对应 client.py 的 _handle_message)─────────────
|
||||
async def _on_ws_message(self, data: dict):
|
||||
t = data.get("type", "")
|
||||
content = data.get("content", "")
|
||||
|
||||
if t == "chunk":
|
||||
# 服务端 chunk 为增量片段(发送后即清零),直接拼接
|
||||
await self.chat.append_ai(content)
|
||||
elif t == "line":
|
||||
# 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀
|
||||
stripped = content.strip()
|
||||
if not stripped:
|
||||
pass
|
||||
elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")):
|
||||
pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示
|
||||
else:
|
||||
# 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏
|
||||
self.tasks.write(stripped)
|
||||
elif t == "reasoning":
|
||||
# 思考流(服务端 debug_level>=3 才发送),记入任务侧栏
|
||||
stripped = content.strip()
|
||||
if stripped:
|
||||
self.tasks.write(f"💭 {stripped[-200:]}")
|
||||
elif t == "complete":
|
||||
# complete 携带完整全文,用于对齐已显示内容
|
||||
await self.chat.finish_ai(content)
|
||||
elif t == "dialogue_ended":
|
||||
await self.chat.finish_ai(self.chat.ai_text)
|
||||
if content:
|
||||
await self.notify_system(f"✅ {content}")
|
||||
elif t == "task_added":
|
||||
await self.notify_system(f"➕ {content}")
|
||||
elif t == "dialogue_reset":
|
||||
if content:
|
||||
await self.notify_system(f"🔄 {content}")
|
||||
elif t == "pong":
|
||||
pass
|
||||
elif t == "task_process":
|
||||
sub = data.get("msg_type", "")
|
||||
title = data.get("task_title", "未知任务")
|
||||
if sub == "chunk":
|
||||
tail = str(data.get("content", ""))[-200:]
|
||||
if tail:
|
||||
self.tasks.write(f"⚡ {title}: {tail}")
|
||||
elif sub == "complete":
|
||||
self.tasks.write(f"✅ 任务完成: {title}")
|
||||
elif t == "task_status":
|
||||
icon = {"executing": "⚙️", "complete": "✅", "failed": "❌",
|
||||
"added": "➕", "batch_start": "🚀", "batch_complete": "🏁"
|
||||
}.get(data.get("status", ""), "📋")
|
||||
self.tasks.write(
|
||||
f"{icon} [{data.get('task_id', '?')}] {data.get('title', '')}: "
|
||||
f"{data.get('message', content)}")
|
||||
elif t == "task_error":
|
||||
self.tasks.write(
|
||||
f"❌ [{data.get('task_id', '?')}] {data.get('task_title', '?')}: "
|
||||
f"{data.get('error', '未知错误')}")
|
||||
elif t == "interrupt_ack":
|
||||
await self.notify_system(f"🛑 {content}")
|
||||
elif t == "error":
|
||||
await self.notify_system(f"❌ {content}", level="error")
|
||||
elif t == "info":
|
||||
await self.notify_system(f"ℹ️ {content}")
|
||||
elif t == "command_result":
|
||||
await self.notify_system(content)
|
||||
elif t == "connection_ack":
|
||||
await self.notify_system(f"✅ {content}")
|
||||
elif t == "notification":
|
||||
await self.notify_system(f"🔔 {content}")
|
||||
elif content:
|
||||
await self.chat.append_ai(content)
|
||||
|
||||
# ── 连接状态回调 ──────────────────────────────────────────────────────
|
||||
async def _on_ws_state(self, state: str, detail: str):
|
||||
if state == "connected":
|
||||
self._refresh_status("")
|
||||
await self.notify_system(f"✅ 已连接到 {self.ws.uri}")
|
||||
elif state == "disconnected":
|
||||
self._refresh_status("")
|
||||
await self.notify_system(
|
||||
f"❌ {detail}(中断期间的服务端输出已丢失,如回复不完整请重发)")
|
||||
elif state == "reconnecting":
|
||||
self._refresh_status(detail)
|
||||
elif state == "error":
|
||||
self._refresh_status("")
|
||||
await self.notify_system(detail, level="error")
|
||||
|
||||
|
||||
def main():
|
||||
uri = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URI
|
||||
BotOpsApp(uri).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""斜杠命令注册表:/connect /disconnect /interrupt /clear /status /help /quit。
|
||||
|
||||
本地命令在此拦截处理;未注册的 / 命令透传给 aibox 服务器(与 client.py 行为一致)。
|
||||
"""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app import BotOpsApp
|
||||
|
||||
|
||||
class CommandRegistry:
|
||||
def __init__(self, app: "BotOpsApp"):
|
||||
self.app = app
|
||||
self._commands = {
|
||||
"/connect": ("连接服务器:/connect ws://host:port", self._connect),
|
||||
"/disconnect": ("断开连接(不自动重连)", self._disconnect),
|
||||
"/interrupt": ("中断当前生成(或按 Ctrl+C)", self._interrupt),
|
||||
"/clear": ("清空聊天区", self._clear),
|
||||
"/status": ("显示连接状态", self._status),
|
||||
"/help": ("显示帮助", self._help),
|
||||
"/quit": ("退出 BotOps", self._quit),
|
||||
}
|
||||
|
||||
async def execute(self, line: str) -> bool:
|
||||
"""是本地命令则执行并返回 True;否则返回 False(调用方应透传服务器)。"""
|
||||
cmd = line.split()[0].lower()
|
||||
entry = self._commands.get(cmd)
|
||||
if entry is None:
|
||||
return False
|
||||
await entry[1](line)
|
||||
return True
|
||||
|
||||
async def _connect(self, line: str):
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
await self.app.notify_system("用法: /connect ws://host:port", level="error")
|
||||
return
|
||||
await self.app.connect_to(parts[1])
|
||||
|
||||
async def _disconnect(self, _line: str):
|
||||
await self.app.disconnect()
|
||||
|
||||
async def _interrupt(self, _line: str):
|
||||
await self.app.send_interrupt()
|
||||
|
||||
async def _clear(self, _line: str):
|
||||
await self.app.clear_chat()
|
||||
|
||||
async def _status(self, _line: str):
|
||||
if self.app.ws.connected:
|
||||
await self.app.notify_system(f"✅ 已连接: {self.app.ws.uri}")
|
||||
else:
|
||||
await self.app.notify_system("○ 未连接")
|
||||
|
||||
async def _help(self, _line: str):
|
||||
lines = ["可用命令:"] + [f" {k:<14} {v[0]}" for k, v in self._commands.items()]
|
||||
lines.append(" 其余 / 命令将透传给 aibox 服务器")
|
||||
await self.app.notify_system("\n".join(lines))
|
||||
|
||||
async def _quit(self, _line: str):
|
||||
await self.app.ws.disconnect()
|
||||
self.app.exit()
|
||||
@@ -0,0 +1,3 @@
|
||||
textual>=0.60.0
|
||||
websockets>=11.0
|
||||
nuitka
|
||||
Whitespace-only changes.
@@ -0,0 +1,90 @@
|
||||
"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。
|
||||
|
||||
服务端协议(aibox/main.py output_callback 原样透传 msg_type):
|
||||
- chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append
|
||||
- complete 完整全文,用于对齐:与已显示文本不一致时整体替换(防丢帧/乱序)
|
||||
"""
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Markdown, Static
|
||||
|
||||
|
||||
class ChatView(VerticalScroll):
|
||||
"""可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。
|
||||
|
||||
滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长
|
||||
(流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚,
|
||||
下一条新消息再重新锚定。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ai_widget: Markdown | None = None
|
||||
self._ai_text = ""
|
||||
|
||||
@property
|
||||
def ai_text(self) -> str:
|
||||
return self._ai_text
|
||||
|
||||
def _anchor_bottom(self):
|
||||
"""锚定吸底(需在布局刷新后执行,否则滚到的是旧位置)。"""
|
||||
self.call_after_refresh(self.anchor)
|
||||
|
||||
async def add_user(self, text: str):
|
||||
await self.mount(Static(f"👤 你: {text}", classes="msg-user", markup=False))
|
||||
self._anchor_bottom()
|
||||
|
||||
async def add_system(self, text: str, css_class: str = "msg-system"):
|
||||
await self.mount(Static(text, classes=css_class, markup=False))
|
||||
self._anchor_bottom()
|
||||
|
||||
async def start_ai(self):
|
||||
"""预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。"""
|
||||
if self._ai_widget is not None:
|
||||
return
|
||||
self._ai_text = ""
|
||||
self._ai_widget = Markdown("", classes="msg-ai")
|
||||
await self.mount(self._ai_widget)
|
||||
self._anchor_bottom()
|
||||
|
||||
async def append_ai(self, delta: str):
|
||||
"""追加增量片段(服务端 chunk 为增量语义,直接拼接)。"""
|
||||
if not delta:
|
||||
return
|
||||
if self._ai_widget is None:
|
||||
await self.start_ai()
|
||||
self._ai_widget.append(delta)
|
||||
self._ai_text += delta
|
||||
# anchor 状态下容器会自动吸底;保险起见刷新后再滚一次
|
||||
self.call_after_refresh(self.scroll_end, animate=False)
|
||||
|
||||
async def finish_ai(self, full_text: str = ""):
|
||||
"""收尾一条 AI 消息。
|
||||
|
||||
- full_text 为 complete 携带的完整全文:与已显示不一致时整体替换对齐;
|
||||
- full_text 为空且未收到任何内容(如发送失败):移除预建的空消息框。
|
||||
"""
|
||||
w = self._ai_widget
|
||||
self._ai_widget = None
|
||||
if w is None:
|
||||
# 流式期间丢帧,只在 complete 收到全文:补建完整消息
|
||||
if full_text:
|
||||
self._ai_text = full_text
|
||||
await self.mount(Markdown(full_text, classes="msg-ai"))
|
||||
self._anchor_bottom()
|
||||
return
|
||||
if full_text and full_text != self._ai_text:
|
||||
# 已显示内容与全文不一致(丢字/乱序/丢帧),整体替换对齐
|
||||
await w.remove()
|
||||
self._ai_text = full_text
|
||||
await self.mount(Markdown(full_text, classes="msg-ai"))
|
||||
elif not full_text and not self._ai_text:
|
||||
# 预建后没有任何输出:移除空框
|
||||
await w.remove()
|
||||
self._ai_text = ""
|
||||
self.call_after_refresh(self.scroll_end, animate=False)
|
||||
|
||||
async def reset(self):
|
||||
"""清空聊天区(/clear)。"""
|
||||
self._ai_widget = None
|
||||
self._ai_text = ""
|
||||
await self.remove_children()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""底部状态栏:连接状态 + 服务器地址 + 附加信息。"""
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class StatusBar(Static):
|
||||
def set_state(self, connected: bool, uri: str = "", extra: str = ""):
|
||||
dot = "●" if connected else "○"
|
||||
color = "green" if connected else "red"
|
||||
text = f"[{color}]{dot}[/] {'已连接' if connected else '未连接'}"
|
||||
if uri:
|
||||
text += f" {uri}"
|
||||
if extra:
|
||||
text += f" | {extra}"
|
||||
self.update(text)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""BotOps WebSocket 协议层(提炼自 aibox/client.py,增加状态回调与自动重连)。
|
||||
|
||||
协议:JSON 消息。
|
||||
发送:{"type": "message", "content": ...}
|
||||
{"type": "command", "command": "/..."}
|
||||
{"type": "interrupt", "content": ...}
|
||||
接收:chunk / line / reasoning / complete / dialogue_ended / dialogue_reset /
|
||||
task_process / task_status / task_error / task_added / interrupt_ack /
|
||||
error / info / command_result / connection_ack / notification / pong
|
||||
|
||||
说明:服务端 chunk 为**增量片段**(output_callback 发送 chunk_buffer 后即清零),
|
||||
complete 为完整全文(可用于对齐);line 为过程行(工具提示/输出/前缀)。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
import websockets
|
||||
|
||||
MessageHandler = Callable[[dict], Awaitable[None]]
|
||||
StateHandler = Callable[[str, str], Awaitable[None]] # (state, detail)
|
||||
|
||||
|
||||
class BotWSClient:
|
||||
"""aibox 协议 WebSocket 客户端:断线指数退避自动重连,手动断开则抑制。"""
|
||||
|
||||
def __init__(self, uri: str):
|
||||
self.uri = uri
|
||||
self.ws = None
|
||||
self.connected = False
|
||||
self._manual_disconnect = False
|
||||
self._listen_task: Optional[asyncio.Task] = None
|
||||
self._reconnect_task: Optional[asyncio.Task] = None
|
||||
self.on_message: Optional[MessageHandler] = None
|
||||
self.on_state: Optional[StateHandler] = None
|
||||
|
||||
async def _emit_state(self, state: str, detail: str = ""):
|
||||
if self.on_state:
|
||||
await self.on_state(state, detail)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""连接并开始监听;成功返回 True。"""
|
||||
await self._close_ws()
|
||||
try:
|
||||
self.ws = await websockets.connect(self.uri)
|
||||
except Exception as e:
|
||||
await self._emit_state("error", f"连接失败: {e}")
|
||||
return False
|
||||
self.connected = True
|
||||
self._manual_disconnect = False
|
||||
self._listen_task = asyncio.create_task(self._listen())
|
||||
await self._emit_state("connected", self.uri)
|
||||
return True
|
||||
|
||||
async def disconnect(self):
|
||||
"""手动断开(抑制自动重连)。"""
|
||||
self._manual_disconnect = True
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
self.connected = False
|
||||
await self._close_ws()
|
||||
if self._listen_task and not self._listen_task.done():
|
||||
self._listen_task.cancel()
|
||||
await self._emit_state("disconnected", "已手动断开")
|
||||
|
||||
async def _close_ws(self):
|
||||
if self.ws is not None:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
async def send_message(self, text: str) -> bool:
|
||||
"""发送用户消息(/ 开头按 command 发送,与 aibox 协议一致)。"""
|
||||
if not self.connected or not self.ws:
|
||||
return False
|
||||
data = ({"type": "command", "command": text} if text.startswith("/")
|
||||
else {"type": "message", "content": text})
|
||||
try:
|
||||
await self.ws.send(json.dumps(data, ensure_ascii=False))
|
||||
return True
|
||||
except Exception as e:
|
||||
await self._emit_state("error", f"发送失败: {e}")
|
||||
return False
|
||||
|
||||
async def send_interrupt(self) -> bool:
|
||||
"""发送中断信号(对应 client.py 的 Ctrl+C 行为)。"""
|
||||
if not self.connected or not self.ws:
|
||||
return False
|
||||
try:
|
||||
await self.ws.send(json.dumps(
|
||||
{"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"},
|
||||
ensure_ascii=False))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _listen(self):
|
||||
"""接收循环:断线时在 finally 中触发自动重连。"""
|
||||
try:
|
||||
async for raw in self.ws:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
data = {"type": "chunk", "content": raw}
|
||||
if self.on_message:
|
||||
await self.on_message(data)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
await self._emit_state("error", f"接收错误: {e}")
|
||||
finally:
|
||||
was_connected = self.connected
|
||||
self.connected = False
|
||||
if was_connected:
|
||||
await self._emit_state("disconnected", "连接已断开")
|
||||
self._schedule_reconnect()
|
||||
|
||||
def _schedule_reconnect(self):
|
||||
if self._manual_disconnect:
|
||||
return
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
return
|
||||
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
||||
|
||||
async def _reconnect_loop(self):
|
||||
"""指数退避 2s→30s 封顶,直到成功或手动断开。"""
|
||||
delay = 2
|
||||
while not self._manual_disconnect and not self.connected:
|
||||
await self._emit_state("reconnecting", f"{delay}s 后自动重连 {self.uri} …")
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if self._manual_disconnect:
|
||||
return
|
||||
try:
|
||||
self.ws = await websockets.connect(self.uri)
|
||||
except Exception:
|
||||
delay = min(delay * 2, 30)
|
||||
continue
|
||||
self.connected = True
|
||||
self._listen_task = asyncio.create_task(self._listen())
|
||||
await self._emit_state("connected", self.uri)
|
||||
return
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
1. 客户端所有请求先打到 `waf:443`(HTTPS),WAF 终止 TLS、运行 coraza 规则匹配,命中则直接返回 403。
|
||||
2. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `gateway:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。
|
||||
3. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。
|
||||
3. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制;注册需邮箱验证码,经 pymsg/TCP 调 `smtp:8008` 发送与校验),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。
|
||||
4. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。
|
||||
5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。
|
||||
6. `dnfs` 处理节点增删改查时,通过 pymsg/TCP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。
|
||||
@@ -49,7 +49,7 @@
|
||||
| [db](db/) | Python(pymsg) | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,SQLAlchemy Core 参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) |
|
||||
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
|
||||
| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 |
|
||||
| [smtp](smtp/) | Python(Flask) | 8008 | 邮件服务,发送/校验邮箱验证码(/send_email、/verify_email),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
|
||||
| [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
|
||||
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
|
||||
|
||||
### core(核心模块 · 进程托管服务)
|
||||
@@ -68,18 +68,18 @@
|
||||
- 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。
|
||||
- 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。
|
||||
|
||||
### gateway(应用层网关 · v0.4.3)
|
||||
### gateway(应用层网关 · v0.5.2)
|
||||
|
||||
- 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。
|
||||
- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。
|
||||
- **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),常数时间比较防 timing attack。
|
||||
- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite。
|
||||
- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite;登录成功即更新 `Users.last_login`(`"$now"` 哨兵)。
|
||||
- **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期,验签通过才放行;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效。
|
||||
- **Cookie 认证**:登录成功同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie;代理时无 `Authorization` 头则回退读 Cookie 验签,并向下游注入 `Authorization: Bearer`(下游零改动);`POST /logout` 清除 Cookie。公开目标(`Proxy.default`)不做凭证校验,避免旧 Cookie 影响公开页面。
|
||||
- **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用;`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由。
|
||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。
|
||||
- **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中(`closed` 仅记录用途,实际隔离靠"不在 opened 中即拒绝"),缺省走 `Proxy.default`(不需 Token,服务前端静态资源)。
|
||||
- **自动建表**:启动时若 `Users` 表不存在则自动创建,首位注册者即为管理员。
|
||||
- **用户表**:`Users` 表由 [db/create_sqlite3.sql](db/create_sqlite3.sql) 建立(gateway 不再自动建表),无默认管理员账号,首位注册者即为管理员。
|
||||
- 详见 [gateway/README.md](gateway/README.md)。
|
||||
|
||||
### static(前端托管服务)
|
||||
@@ -107,12 +107,12 @@
|
||||
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
|
||||
- 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。
|
||||
- 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。
|
||||
- **日期/时间列适配**:values 与 where 中的 DATETIME/DATE/TIME 列接受 ISO 8601 字符串(带时区偏移自动转 UTC)或哨兵值 `"$now"`(服务端当前 UTC);响应中的日期时间一律序列化为 ISO 8601(T 分隔)。
|
||||
- **内置日志子模块**:
|
||||
- 启动时自动建 `Logs` 表(service / level / message / trace_id / extra + 4 个索引)。
|
||||
- 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。
|
||||
- `_log_internal()` — db 进程内部直接写 Logs 表,无网络往返,防递归设计。
|
||||
- 后台守护线程每小时自动清理:保留 30 天 / 上限 100 万条。
|
||||
- 注:`db/log_module.py`(Flask `/log/*` Blueprint)为旧版 HTTP 接口的遗留文件,当前 dbv2 未挂载。
|
||||
- **统一日志客户端 SDK**(各服务目录下的 `log_client.py`,如 [dnfs/log_client.py](dnfs/log_client.py)):各服务复制即用,纯标准库实现,异步非阻塞(内存队列 + 后台线程,50 条/2 秒批量 flush),fail-safe 设计(db 不可用时静默丢弃,不影响业务)。
|
||||
|
||||
### ai_review(AI 内容审核服务)
|
||||
@@ -135,9 +135,9 @@
|
||||
|
||||
### smtp(邮件服务)
|
||||
|
||||
- 注册流程的邮箱验证码后端,基于 Flask + waitress,监听 127.0.0.1:8008。
|
||||
- 接口:`POST /send_email`(生成 6 位验证码并经 SMTP_SSL 发送至收件人,60 秒有效)、`POST /verify_email`(校验)。
|
||||
- 验证码存于进程内存,SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`)。
|
||||
- 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。
|
||||
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,60 秒有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。
|
||||
- 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。
|
||||
|
||||
### aibox(独立 AI 智能体系统)
|
||||
|
||||
|
||||
+179
-21
@@ -9,11 +9,20 @@ import sys
|
||||
import os
|
||||
import threading
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
import websockets
|
||||
from colorama import init, Fore, Style as ColoramaStyle
|
||||
|
||||
# rich 为可选依赖:有则 AI 回复渲染为 Markdown,无则回退纯文本流式输出
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
_RICH = True
|
||||
except ImportError:
|
||||
_RICH = False
|
||||
|
||||
init(autoreset=True)
|
||||
|
||||
class Colors:
|
||||
@@ -75,10 +84,15 @@ class WebSocketClient:
|
||||
except json.JSONDecodeError:
|
||||
self.callback("chunk", message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
self.connected = False
|
||||
self.callback("system", "❌ 连接已断开")
|
||||
pass
|
||||
except Exception as e:
|
||||
self.callback("error", f"接收消息错误: {e}")
|
||||
finally:
|
||||
# 统一在此通知断线;手动 disconnect() 已把 connected 置 False,不会误触发自动重连
|
||||
was_connected = self.connected
|
||||
self.connected = False
|
||||
if was_connected:
|
||||
self.callback("disconnected", "❌ 连接已断开")
|
||||
|
||||
async def _handle_message(self, data: dict):
|
||||
msg_type = data.get('type', '')
|
||||
@@ -134,12 +148,25 @@ class SimpleCLI:
|
||||
self.ai_full = ""
|
||||
self.ai_prefix_shown = False
|
||||
|
||||
# 已显示消息的去重
|
||||
self.displayed_messages: set = set()
|
||||
|
||||
# 已显示消息的去重(LRU 上限,防长对话内存无限增长)
|
||||
self.DISPLAYED_MAX = 500
|
||||
self.displayed_messages: OrderedDict = OrderedDict()
|
||||
|
||||
# 锁和状态
|
||||
self.lock = threading.Lock()
|
||||
self.current_input = ""
|
||||
|
||||
# 标准输入:单守护线程阻塞读 + asyncio.Queue 中转(避免 Ctrl+C 泄漏线程)
|
||||
self._input_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._stdin_thread: Optional[threading.Thread] = None
|
||||
|
||||
# 自动重连状态
|
||||
self._manual_disconnect = False # 用户主动 /disconnect、/quit、/connect 后置 True,抑制自动重连
|
||||
self._reconnect_task: Optional[asyncio.Task] = None
|
||||
|
||||
# Markdown 渲染(rich 可用时启用)
|
||||
self._console = Console() if _RICH else None
|
||||
self._md_generating_shown = False # "生成中…"状态行是否已显示
|
||||
|
||||
# 流式输出状态
|
||||
self.streaming_line = False # 是否正在流式输出行
|
||||
@@ -232,16 +259,23 @@ class SimpleCLI:
|
||||
"""流式显示增量内容"""
|
||||
if not content:
|
||||
return
|
||||
|
||||
|
||||
# 清理内容
|
||||
clean = self._clean_content(content)
|
||||
if not clean:
|
||||
return
|
||||
|
||||
|
||||
key = clean[:200]
|
||||
if key in self.displayed_messages:
|
||||
return
|
||||
|
||||
|
||||
# rich 可用:流式期间只显示状态行并累计全文,complete 时一次性渲染
|
||||
if _RICH:
|
||||
self._md_show_generating()
|
||||
self.ai_previous = clean
|
||||
self.ai_full = clean
|
||||
return
|
||||
|
||||
# 检测增量
|
||||
if self.ai_previous and clean.startswith(self.ai_previous):
|
||||
new_part = clean[len(self.ai_previous):]
|
||||
@@ -275,6 +309,39 @@ class SimpleCLI:
|
||||
self.ai_previous = clean
|
||||
self.ai_full = clean
|
||||
|
||||
# ── Markdown 渲染(rich)─────────────────────────────────────────────
|
||||
def _md_show_generating(self):
|
||||
"""流式期间显示一次性状态行(不实时渲染,避免 Live 闪烁/被卷走)。"""
|
||||
if not _RICH:
|
||||
return
|
||||
with self.lock:
|
||||
if not self._md_generating_shown:
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(f"{Colors.AI}🤖 AI助手: 生成中…{Colors.RESET}")
|
||||
sys.stdout.flush()
|
||||
self._md_generating_shown = True
|
||||
self.streaming_line = True
|
||||
self.prompt_shown = False
|
||||
|
||||
def _md_render_final(self, text: str):
|
||||
"""complete 时一次性静态渲染最终 Markdown(先擦掉"生成中…"行)。"""
|
||||
if not _RICH:
|
||||
return
|
||||
with self.lock:
|
||||
if self._md_generating_shown:
|
||||
sys.stdout.write('\r' + ' ' * 40 + '\r')
|
||||
self._md_generating_shown = False
|
||||
self.streaming_line = False
|
||||
if text:
|
||||
self._console.print(Markdown(text))
|
||||
|
||||
def _remember_displayed(self, key: str):
|
||||
"""记录已显示消息(LRU:超 DISPLAYED_MAX 淘汰最旧,防长对话内存膨胀)。"""
|
||||
self.displayed_messages[key] = None
|
||||
self.displayed_messages.move_to_end(key)
|
||||
if len(self.displayed_messages) > self.DISPLAYED_MAX:
|
||||
self.displayed_messages.popitem(last=False)
|
||||
|
||||
def display(self, msg_type: str, content: str, extra: Any = None):
|
||||
"""显示消息"""
|
||||
if msg_type == 'chunk':
|
||||
@@ -302,6 +369,11 @@ class SimpleCLI:
|
||||
elif msg_type == 'system':
|
||||
self._finish_stream()
|
||||
self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}")
|
||||
elif msg_type == 'disconnected':
|
||||
self.connected = False
|
||||
self._finish_stream()
|
||||
self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}")
|
||||
self._schedule_reconnect()
|
||||
elif msg_type == 'info':
|
||||
self._finish_stream()
|
||||
self._print_line(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}")
|
||||
@@ -323,7 +395,7 @@ class SimpleCLI:
|
||||
sys.stdout.write(f"{Colors.TASK}{content}{Colors.RESET}\n")
|
||||
sys.stdout.flush()
|
||||
return
|
||||
|
||||
|
||||
# 检查是否是工具结果
|
||||
if content.startswith(('✅ 对话记忆已保存', '💾 保存记忆', '❌', '⚠️')):
|
||||
with self.lock:
|
||||
@@ -350,6 +422,25 @@ class SimpleCLI:
|
||||
|
||||
def _handle_complete(self, content: str):
|
||||
"""处理完成事件 - 只负责结束流式输出,不重复显示内容"""
|
||||
# rich 渲染路径:擦掉"生成中…",一次性渲染最终 Markdown,恢复提示符
|
||||
if _RICH:
|
||||
clean = self._clean_content(content) if content else ""
|
||||
self._md_render_final(clean)
|
||||
if clean:
|
||||
self._remember_displayed(clean[:200])
|
||||
with self.lock:
|
||||
self.in_tool_phase = False
|
||||
self.waiting_for_response = False
|
||||
self.streaming_line = False
|
||||
self.prompt_shown = True
|
||||
current = self.current_input
|
||||
sys.stdout.write(self._get_prompt() + current)
|
||||
sys.stdout.flush()
|
||||
self.ai_previous = ""
|
||||
self.ai_full = ""
|
||||
self.ai_prefix_shown = False
|
||||
return
|
||||
|
||||
# 如果之前在工具阶段,现在退出
|
||||
if self.in_tool_phase:
|
||||
with self.lock:
|
||||
@@ -406,7 +497,7 @@ class SimpleCLI:
|
||||
return
|
||||
|
||||
# 只有当内容没有被流式显示时才显示完整消息
|
||||
self.displayed_messages.add(key)
|
||||
self._remember_displayed(key)
|
||||
# 如果之前没有流式输出,现在显示完整消息
|
||||
self._print_line(f"{Colors.AI}🤖 AI助手: {clean}")
|
||||
|
||||
@@ -466,7 +557,7 @@ class SimpleCLI:
|
||||
clean = content.strip()
|
||||
key = clean[:200]
|
||||
if key not in self.displayed_messages:
|
||||
self.displayed_messages.add(key)
|
||||
self._remember_displayed(key)
|
||||
self._print_line(f"{Colors.TASK}⚡ 任务 - {task_title}: {clean}")
|
||||
del self._task_buffers[task_title]
|
||||
self.streaming_line = False
|
||||
@@ -519,10 +610,15 @@ class SimpleCLI:
|
||||
self.display("error", "用法: /connect ws://host:port")
|
||||
return
|
||||
uri = parts[1]
|
||||
# 切换服务器:抑制旧连接的自动重连
|
||||
self._manual_disconnect = True
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
if self.client and self.client.connected:
|
||||
await self.client.disconnect()
|
||||
self.client = WebSocketClient(uri, self.display)
|
||||
if await self.client.connect():
|
||||
self._manual_disconnect = False
|
||||
self.connected = True
|
||||
self.display("system", f"✅ 已连接到 {uri}")
|
||||
asyncio.create_task(self.client.receive_messages())
|
||||
@@ -530,6 +626,9 @@ class SimpleCLI:
|
||||
self.client = None
|
||||
self.connected = False
|
||||
elif cmd == '/disconnect':
|
||||
self._manual_disconnect = True # 主动断开,不自动重连
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
if self.client:
|
||||
await self.client.disconnect()
|
||||
self.client = None
|
||||
@@ -556,6 +655,9 @@ class SimpleCLI:
|
||||
elif cmd == '/quit':
|
||||
self.display("system", "👋 再见!")
|
||||
self.running = False
|
||||
self._manual_disconnect = True
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
if self.client:
|
||||
await self.client.disconnect()
|
||||
return
|
||||
@@ -569,6 +671,35 @@ class SimpleCLI:
|
||||
self.display("error", f"未知命令: {cmd}")
|
||||
self._print_prompt(clear_input=True)
|
||||
|
||||
def _schedule_reconnect(self):
|
||||
"""启动自动重连(若未手动断开且当前无进行中的重连任务)。"""
|
||||
if self._manual_disconnect or not self.running or not self.client:
|
||||
return
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
return
|
||||
try:
|
||||
self._reconnect_task = asyncio.create_task(self._auto_reconnect_loop(self.client))
|
||||
except RuntimeError:
|
||||
pass # 事件循环正在关闭(程序退出中),放弃重连
|
||||
|
||||
async def _auto_reconnect_loop(self, client: WebSocketClient):
|
||||
"""断线自动重连:指数退避 2s→30s 封顶,直到成功、/disconnect 或退出。"""
|
||||
delay = 2
|
||||
while self.running and not self._manual_disconnect and self.client is client:
|
||||
self.display("info", f"🔄 {delay}s 后自动重连 {client.uri} …(/disconnect 取消)")
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if not self.running or self._manual_disconnect or self.client is not client:
|
||||
return
|
||||
if await client.connect():
|
||||
self.connected = True
|
||||
self.display("system", f"✅ 已自动重连到 {client.uri}")
|
||||
asyncio.create_task(client.receive_messages())
|
||||
return
|
||||
delay = min(delay * 2, 30)
|
||||
|
||||
async def _send_interrupt(self):
|
||||
if self.client and self.client.connected:
|
||||
interrupt_msg = {"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"}
|
||||
@@ -581,7 +712,7 @@ class SimpleCLI:
|
||||
if not self.client or not self.client.connected:
|
||||
self.display("error", "未连接到服务器")
|
||||
return
|
||||
|
||||
|
||||
# 重置流式状态
|
||||
with self.lock:
|
||||
if self.streaming_line:
|
||||
@@ -601,30 +732,57 @@ class SimpleCLI:
|
||||
await self.client.send_message(message)
|
||||
# 不在这里显示提示符,等待AI响应结束后再显示
|
||||
|
||||
def _stdin_reader(self, loop: asyncio.AbstractEventLoop):
|
||||
"""独立守护线程:阻塞读 stdin,经 call_soon_threadsafe 投入队列。
|
||||
|
||||
进程生命周期只启动一次——替代原 run_in_executor 每行一个线程的方案,
|
||||
避免 Ctrl+C 取消 await 后旧线程仍阻塞在 readline 上无限泄漏。
|
||||
"""
|
||||
def _put(item):
|
||||
try:
|
||||
loop.call_soon_threadsafe(self._input_queue.put_nowait, item)
|
||||
except RuntimeError:
|
||||
pass # 事件循环已关闭(程序退出中)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
except Exception:
|
||||
break
|
||||
if not line: # EOF (Ctrl+D)
|
||||
_put(None)
|
||||
break
|
||||
_put(line)
|
||||
|
||||
async def run(self):
|
||||
self.print_banner()
|
||||
self._task_buffers = {}
|
||||
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
if self._stdin_thread is None:
|
||||
self._stdin_thread = threading.Thread(
|
||||
target=self._stdin_reader, args=(loop,),
|
||||
daemon=True, name="stdin-reader")
|
||||
self._stdin_thread.start()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
line = await asyncio.get_event_loop().run_in_executor(
|
||||
None, lambda: sys.stdin.readline()
|
||||
)
|
||||
if not line:
|
||||
line = await self._input_queue.get()
|
||||
if line is None: # EOF (Ctrl+D)
|
||||
break
|
||||
|
||||
|
||||
self.current_input = line.rstrip('\n')
|
||||
line = line.strip()
|
||||
|
||||
|
||||
if not line:
|
||||
self._print_prompt(clear_input=True)
|
||||
continue
|
||||
|
||||
|
||||
if line.startswith('/'):
|
||||
await self.handle_command(line)
|
||||
else:
|
||||
await self.send_user_message(line)
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
if self.client and self.client.connected:
|
||||
await self._send_interrupt()
|
||||
|
||||
@@ -56,28 +56,34 @@ def generate_text():
|
||||
|
||||
def generate_image(text):
|
||||
width, height = 160, 50
|
||||
bg = random.choice([(255, 255, 255), (240, 240, 240), (230, 240, 250)])
|
||||
bg = (random.randint(0,256), random.randint(0,256), random.randint(0,256))
|
||||
img = Image.new('RGB', (width, height), bg)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
for i, ch in enumerate(text):
|
||||
color = random.choice([(30, 30, 30), (50, 80, 150), (150, 50, 50), (50, 130, 50)])
|
||||
color = (random.randint(0,256), random.randint(0,256), random.randint(0,256))
|
||||
y = random.randint(2, 12)
|
||||
x = 15 + i * 32 + random.randint(-3, 3) # 4 位字符在 160px 宽度内均匀分布
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
random.choice([28, 30, 32, 34]))
|
||||
random.choice(range(20,40,2)))
|
||||
except (IOError, OSError):
|
||||
font = ImageFont.load_default()
|
||||
draw.text((x, y), ch, fill=color, font=font)
|
||||
|
||||
char = Image.new("RGBA", (64, 64), (0,0,0,0))
|
||||
draw_char = ImageDraw.Draw(char)
|
||||
draw_char.text((20,20), ch, fill=color, font=font)
|
||||
rotated_char = char.rotate(random.randint(-30, 30), expand=True)
|
||||
img.paste(rotated_char, (x-32, y-32), mask=rotated_char)
|
||||
# draw.text((x, y), ch, fill=color, font=font)
|
||||
|
||||
for _ in range(4):
|
||||
draw.line([(random.randint(0, width), random.randint(0, height)),
|
||||
(random.randint(0, width), random.randint(0, height))],
|
||||
fill=random.choice([(30, 30, 30), (50, 80, 150)]), width=1)
|
||||
fill=(random.randint(0,256), random.randint(0,256), random.randint(0,256)))
|
||||
for _ in range(50):
|
||||
draw.point((random.randint(0, width - 1), random.randint(0, height - 1)),
|
||||
fill=random.choice([(30, 30, 30), (50, 80, 150)]))
|
||||
fill=(random.randint(0,256), random.randint(0,256), random.randint(0,256)))
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
|
||||
+126
-187
@@ -1,62 +1,55 @@
|
||||
# 跨数据库通用查询服务(基于 SQLAlchemy Core)
|
||||
# 通用数据库服务 db(pymsg/TCP + SQLAlchemy Core)
|
||||
|
||||
一个安全的 HTTP 服务,通过结构化 JSON 描述查询意图,后端自动转换为对应数据库的 SQL,无需手写 SQL,即可无缝切换 SQLite、PostgreSQL、MySQL 等数据库。
|
||||
一个安全的数据库访问服务。各业务服务通过自研 pymsg 协议(TCP 长度前缀帧 + JSON)发送结构化查询意图,db 翻译为 SQL 执行,全程参数绑定,业务侧无需直连数据库、无需手写 SQL。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **跨数据库**:基于 SQLAlchemy Core 表达式语言,一套接口适配多种数据库
|
||||
- **跨数据库**:基于 SQLAlchemy Core 表达式语言,一套接口适配 SQLite / PostgreSQL / MySQL
|
||||
- **零 SQL 注入**:所有条件、值均通过 SQLAlchemy 参数绑定处理
|
||||
- **统一 JSON 接口**:`action` + `table` + `conditions` 模式
|
||||
- **支持增删改查**:select, insert, update, delete 全覆盖
|
||||
- **单线程本地监听**:waitress + 127.0.0.1:8005,简单可靠
|
||||
- **统一 JSON 接口**:`_route` + `action` + `table` + `where` 模式
|
||||
- **支持增删改查**:select / insert / update / delete 全覆盖
|
||||
- **内置日志表**:启动自动建 Logs 表,供全系统集中写日志;带后台自动清理
|
||||
- **仅监听本机**:默认 `127.0.0.1:8005`,不对外暴露
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pip install flask sqlalchemy waitress
|
||||
# 根据需要安装数据库驱动,例如:
|
||||
# pip install psycopg2-binary # PostgreSQL
|
||||
# pip install pymysql # MySQL
|
||||
./_set.sh # 建 venv 并安装 requirements.txt(SQLAlchemy + 本地 ../pymsg)
|
||||
```
|
||||
|
||||
### 启动服务(默认 SQLite)
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
服务运行在 `http://127.0.0.1:8005`。
|
||||
### 启动服务
|
||||
|
||||
使用其他数据库:
|
||||
```bash
|
||||
export DATABASE_URL="postgresql://user:pass@localhost/dbname"
|
||||
python app.py
|
||||
./_run.sh # venv 中执行 python3 dbv2.py
|
||||
```
|
||||
|
||||
**注意**:新接口依赖表已存在(通过反射获取结构)。首次使用时请先手动建表:
|
||||
```bash
|
||||
sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"
|
||||
```
|
||||
服务监听 `tcp://127.0.0.1:8005`。生产环境由 core(Nidus)按 init.txt 统一拉起,守护靠 systemd。
|
||||
|
||||
## API 文档
|
||||
**注意**:`execute` 路由依赖表已存在(通过反射获取结构)。建表见 `create_sqlite3.sql`(Users / Notes;Logs 表由服务启动时自建)。
|
||||
|
||||
### 请求格式
|
||||
## 协议与 API
|
||||
|
||||
**端点**:`POST /execute`
|
||||
**Content-Type**:`application/json`
|
||||
### 传输协议(pymsg)
|
||||
|
||||
每条消息为一个完整 JSON,帧格式:`[4 字节大端长度][UTF-8 JSON]`。
|
||||
请求中的 `_route` 字段指定路由,目前仅有一个路由:**`execute`**。
|
||||
|
||||
### execute 路由 — 通用 CRUD
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "操作类型", // select | insert | update | delete
|
||||
"table": "表名",
|
||||
"columns": ["列1", "列2"], // select/insert 时可选
|
||||
"values": { // insert/update 时必填
|
||||
"列名": "值"
|
||||
},
|
||||
"where": { // 条件,支持组合操作符
|
||||
"_route": "execute",
|
||||
"action": "select", // select | insert | update | delete
|
||||
"table": "Users",
|
||||
"columns": ["id", "name"], // select 时可选(缺省全列)
|
||||
"values": {"name": "Bob"}, // insert / update 时必填
|
||||
"where": { // 可选,条件见下表
|
||||
"age": {"ge": 18, "lt": 60},
|
||||
"name": "Alice"
|
||||
},
|
||||
"order_by": [{"column": "age", "desc": true}],
|
||||
"order_by": [{"column": "id", "desc": true}],
|
||||
"limit": 10,
|
||||
"offset": 0
|
||||
}
|
||||
@@ -84,48 +77,80 @@ sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [ // 查询结果数组(insert/update/delete 时包含影响行数或 lastrowid)
|
||||
{"id": 1, "name": "Alice"}
|
||||
"data": [
|
||||
{"id": 1, "username": "Alice", "create_at": "2026-07-28T14:30:00"}
|
||||
]
|
||||
}
|
||||
```
|
||||
- select → 行对象数组
|
||||
- insert → `[{"lastrowid": N}]`
|
||||
- update / delete → `[{"rowcount": N}]`
|
||||
|
||||
失败:
|
||||
```json
|
||||
{"success": false, "error": "错误描述"}
|
||||
```
|
||||
|
||||
## 日期/时间列的处理
|
||||
|
||||
JSON 没有日期类型,db 内置了类型适配层:按反射得到的列类型自动在
|
||||
ISO 8601 字符串与数据库 DATETIME / DATE / TIME 之间转换。
|
||||
|
||||
**写入(values / where 条件值)**:
|
||||
|
||||
| 输入 | 说明 |
|
||||
|------|------|
|
||||
| `"2026-07-28T22:30:00"` | ISO 8601(T 分隔),推荐 |
|
||||
| `"2026-07-28 22:30:00"` | 空格分隔同样接受(SQLite 原生格式) |
|
||||
| `"2026-07-28T22:30:00+08:00"` | 带时区偏移:自动转 UTC 后存储 |
|
||||
| `"2026-07-28T14:30:00Z"` | Z 后缀等同 +00:00 |
|
||||
| `"$now"` | 哨兵值:服务端当前 UTC 时间(DATETIME/DATE 列可用) |
|
||||
|
||||
存储统一为 UTC 朴素时间(无时区)。非法日期字符串会返回明确的错误:
|
||||
`{"success": false, "error": "列 last_login 的日期时间值无法解析(需 ISO 8601 或 \"$now\"): ..."}`
|
||||
|
||||
**读取(select 响应)**:日期时间值一律序列化为 ISO 8601(T 分隔),
|
||||
如 `"2026-07-28T22:30:00"`,客户端可用 `datetime.fromisoformat()` 直接解析。
|
||||
|
||||
**注意**:`like` 运算符是字符串模式匹配,对日期列使用时不做转换。
|
||||
|
||||
```json
|
||||
// 示例:登录后更新 last_login 为服务端当前时间
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误描述"
|
||||
"_route": "execute",
|
||||
"action": "update",
|
||||
"table": "Users",
|
||||
"values": {"last_login": "$now"},
|
||||
"where": {"username": "alice"}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例(curl)
|
||||
## 使用示例(Python + pymsg)
|
||||
|
||||
**插入**:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8005/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"insert","table":"users","values":{"name":"Bob","age":25}}'
|
||||
```
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
|
||||
**查询**:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8005/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"select","table":"users","where":{"age":{"gt":20}},"order_by":[{"column":"id","desc":true}],"limit":5}'
|
||||
```
|
||||
client = MsgClient("127.0.0.1", 8005, "myservice")
|
||||
|
||||
**更新**:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8005/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"update","table":"users","values":{"age":26},"where":{"name":"Bob"}}'
|
||||
```
|
||||
# 查询
|
||||
resp = client.call(json.dumps({
|
||||
"_route": "execute",
|
||||
"action": "select",
|
||||
"table": "Users",
|
||||
"where": {"id": {"gt": 0}},
|
||||
"order_by": [{"column": "id", "desc": True}],
|
||||
"limit": 5,
|
||||
}))
|
||||
print(json.loads(resp))
|
||||
|
||||
**删除**:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8005/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"delete","table":"users","where":{"id":{"in":[1,2]}}}'
|
||||
# 插入
|
||||
client.call(json.dumps({
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Users",
|
||||
"values": {"username": "bob", "password_hash": "..."},
|
||||
}))
|
||||
```
|
||||
|
||||
## 安全机制
|
||||
@@ -133,17 +158,17 @@ curl -X POST http://127.0.0.1:8005/execute \
|
||||
- 强制使用 SQLAlchemy 参数绑定,杜绝 SQL 注入。
|
||||
- 仅反射已有表,无法创建或删除表(保证结构安全)。
|
||||
- 所有异常自动回滚事务。
|
||||
- 默认监听 `127.0.0.1`,仅本机可访问。
|
||||
- 仅监听 `127.0.0.1`,仅本机服务可访问。
|
||||
|
||||
## 内置日志模块
|
||||
## 内置日志能力
|
||||
|
||||
db 服务内置了统一日志收集能力,各业务服务通过 HTTP 推送日志到 `/log/*` 系列接口,db 自身的操作日志通过 `log_internal()` 直接写入(同进程,无 HTTP 往返,无循环依赖)。
|
||||
db 内置统一日志收集:全系统各服务(waf / gateway / static / dnfs / db / ai_review / captcha / core / smtp)的日志集中写入 db 的 `Logs` 表。
|
||||
|
||||
### 为什么集成在 db 而不是独立服务
|
||||
|
||||
独立 logs 服务会导致**循环调用**:db 记日志→调 logs,logs 存库→调 db,无限递归。集成在 db 内部后,db 自己记日志直接写表,彻底消除循环依赖。
|
||||
|
||||
### 日志表
|
||||
### 日志表(启动时自建)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS Logs (
|
||||
@@ -155,152 +180,66 @@ CREATE TABLE IF NOT EXISTS Logs (
|
||||
trace_id TEXT,
|
||||
extra TEXT
|
||||
);
|
||||
-- 索引: (service,ts) / (level,ts) / (ts) / (trace_id)
|
||||
```
|
||||
|
||||
- `service`:来源服务名(`waf`/`gateway`/`static`/`dnfs`/`db`/`ai_review`/`captcha`/`core`)
|
||||
- `level`:`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`
|
||||
- `level`:`DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`
|
||||
- `trace_id`:可选,请求追踪 ID
|
||||
- `extra`:可选,JSON 字符串,存放附加结构化字段
|
||||
|
||||
### 接口
|
||||
### 写入方式
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/log/ingest` | POST | 推送日志(支持批量) |
|
||||
| `/log/query` | POST | 查询日志(按服务/级别/时间/关键字过滤) |
|
||||
| `/log/stats` | POST | 统计聚合(按 service/level 分组计数) |
|
||||
| `/log/purge` | POST | 清理过期日志 |
|
||||
| `/log/health` | GET | 健康检查 |
|
||||
**外部服务**:复制 `example_log_client.py` 为各自目录下的 `log_client.py`,改 `service_name` 即用。客户端底层通过 pymsg 调 `execute` 路由逐条 INSERT 到 Logs 表:
|
||||
|
||||
所有接口的响应格式与 `/execute` 统一:`{success, data, error}`。
|
||||
```python
|
||||
from log_client import logs
|
||||
|
||||
#### POST /log/ingest
|
||||
|
||||
```json
|
||||
请求:
|
||||
{
|
||||
"logs": [
|
||||
{
|
||||
"service": "dnfs",
|
||||
"level": "INFO",
|
||||
"message": "节点新增成功 id=42",
|
||||
"ts": "2026-07-25T08:30:00.123Z",
|
||||
"trace_id": "req-abc-123",
|
||||
"extra": {"path": "/add_note", "status": 200}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
响应:
|
||||
{"success": true, "data": {"inserted": 1}}
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
```
|
||||
|
||||
- `ts` 可选,缺省自动填当前 UTC 时间
|
||||
- 单次最多 100 条
|
||||
- `message` 上限 8KB,`extra` 上限 4KB
|
||||
客户端特性:
|
||||
- 纯标准库实现,异步非阻塞:内存队列 + 后台线程批量推送
|
||||
- fail-safe:db 不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
|
||||
#### POST /log/query
|
||||
**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归)。
|
||||
|
||||
```json
|
||||
请求:
|
||||
{
|
||||
"service": "dnfs", // 可选,支持数组匹配多个
|
||||
"level": "WARNING", // 可选,支持数组匹配多个
|
||||
"since": "2026-07-25T00:00:00Z", // 可选
|
||||
"until": "2026-07-25T23:59:59Z", // 可选
|
||||
"keyword": "节点", // 可选,message 模糊搜索
|
||||
"trace_id": "req-abc-123", // 可选
|
||||
"order_by": "ts", // 默认 ts,可选 id/service/level
|
||||
"desc": true, // 默认 true(最新在前)
|
||||
"limit": 50, // 默认 100,上限 1000
|
||||
"offset": 0 // 分页偏移
|
||||
}
|
||||
### 查询日志
|
||||
|
||||
响应:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total": 128,
|
||||
"logs": [{"id":1024,"ts":"...","service":"dnfs","level":"INFO","message":"...","trace_id":"...","extra":{...}}]
|
||||
}
|
||||
}
|
||||
```
|
||||
日志查询直接走 `execute` 路由 select Logs 表,或用工具脚本 `_view_db.sqlite3.py`:
|
||||
|
||||
#### POST /log/stats
|
||||
|
||||
```json
|
||||
请求:
|
||||
{"since":"...","until":"...","group_by":"service"}
|
||||
|
||||
响应:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"groups": [{"service":"dnfs","level":"INFO","count":320}],
|
||||
"total": 325
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`group_by` 支持 `service` / `level` / `service,level`。
|
||||
|
||||
#### POST /log/purge
|
||||
|
||||
```json
|
||||
请求: {"before": "2026-07-18T00:00:00Z"}
|
||||
响应: {"success": true, "data": {"deleted": 1523}}
|
||||
```
|
||||
|
||||
#### GET /log/health
|
||||
|
||||
```json
|
||||
{"success": true, "data": {"status": "ok", "count": 10240}}
|
||||
```bash
|
||||
python3 _view_db.sqlite3.py # 列出所有表及行数
|
||||
python3 _view_db.sqlite3.py Logs -n 20 # 最近 20 条日志
|
||||
python3 _view_db.sqlite3.py Logs -w "level='ERROR'"
|
||||
python3 _view_db.sqlite3.py -s "SELECT service, COUNT(*) FROM Logs GROUP BY service"
|
||||
```
|
||||
|
||||
### 自动清理
|
||||
|
||||
- 服务启动时开启后台守护线程,每 1 小时清理一次
|
||||
- 默认保留 30 天日志,超期自动删除
|
||||
- 日志总条数超过 100 万时,按时间倒序保留最新 N 条
|
||||
|
||||
### 各服务接入方式
|
||||
|
||||
把 `db/log_client.py` 复制到各服务目录(或加入 `PYTHONPATH`),即可使用:
|
||||
|
||||
```python
|
||||
from log_client import logs
|
||||
|
||||
# 替换原来的 print
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
|
||||
# 带追踪 ID
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
```
|
||||
|
||||
特性:
|
||||
- 使用 `urllib.request`(标准库,不引入 requests)
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
|
||||
### 防递归设计
|
||||
|
||||
| 场景 | 调用路径 | 是否安全 |
|
||||
|------|---------|---------|
|
||||
| 外部服务记日志 | `服务 → POST /log/ingest → 写 Logs 表` | 安全 |
|
||||
| db 自己记日志 | `/execute → log_internal() → 直接写 Logs 表` | 安全(无 HTTP) |
|
||||
| /log/ingest 成功 | `写入成功 → 直接返回` | 安全(不再记日志) |
|
||||
| /log/ingest 失败 | `写入失败 → log_internal() → 直接写表` | 安全(log_internal 不回调 ingest) |
|
||||
- 日志总条数超过 100 万时,按时间倒序保留最新 100 万条
|
||||
|
||||
## 切换数据库
|
||||
|
||||
只需修改环境变量 `DATABASE_URL`,并安装对应驱动:
|
||||
|
||||
- SQLite:`sqlite:///database.db`
|
||||
- PostgreSQL:`postgresql://user:pass@localhost/dbname` (需 `pip install psycopg2`)
|
||||
- MySQL:`mysql+pymysql://user:pass@localhost/dbname` (需 `pip install pymysql`)
|
||||
- SQLite(默认):`sqlite:///database.db`
|
||||
- PostgreSQL:`postgresql://user:pass@localhost/dbname`(需 `pip install psycopg2`)
|
||||
- MySQL:`mysql+pymysql://user:pass@localhost/dbname`(需 `pip install pymysql`)
|
||||
|
||||
SQLAlchemy 会自动生成适配各数据库的 SQL 方言,无需修改接口调用。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dbv2.py` | 服务主程序(pymsg/TCP,唯一在跑的实现) |
|
||||
| `example_log_client.py` | 日志客户端 SDK 模板,各服务复制为 `log_client.py` 使用 |
|
||||
| `create_sqlite3.sql` | Users / Notes / Logs 建表脚本 |
|
||||
| `_view_db.sqlite3.py` | 非交互式查看 database.db 的工具脚本 |
|
||||
| `_set.sh` / `_run.sh` | 环境安装 / 启动脚本 |
|
||||
+88
-10
@@ -18,18 +18,26 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
|
||||
支持的路由::
|
||||
|
||||
execute → 通用 CRUD(select / insert / update / delete)
|
||||
|
||||
日期/时间列::
|
||||
|
||||
values 与 where 中的 DATETIME / DATE / TIME 列接受 ISO 8601 字符串
|
||||
(如 "2026-07-28T22:30:00",可带时区偏移,统一转 UTC 存储),
|
||||
或哨兵值 "$now"(服务端当前 UTC 时间)。
|
||||
响应中的日期时间一律序列化为 ISO 8601(T 分隔)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, date, time as dt_time, timezone, timedelta
|
||||
|
||||
from sqlalchemy import (
|
||||
create_engine, MetaData, Table,
|
||||
select, insert, update, delete, func, and_,
|
||||
)
|
||||
from sqlalchemy import types as sa_types
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
# pymsg 导入
|
||||
@@ -50,6 +58,16 @@ DEFAULT_RETENTION_DAYS = 30
|
||||
DEFAULT_MAX_RECORDS = 1_000_000
|
||||
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒
|
||||
|
||||
#: where/values 中的哨兵值:服务端当前 UTC 时间
|
||||
NOW_SENTINEL = "$now"
|
||||
|
||||
|
||||
def _json_default(o):
|
||||
"""json.dumps 兜底序列化:日期时间一律 ISO 8601(T 分隔),其余 str()。"""
|
||||
if isinstance(o, (datetime, date, dt_time)):
|
||||
return o.isoformat()
|
||||
return str(o)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# DBServer — 主服务类
|
||||
@@ -113,7 +131,8 @@ class DBServer:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False, default=str))
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False,
|
||||
default=_json_default))
|
||||
|
||||
# ── 表反射 ---------------------------------------------------------
|
||||
def _get_table(self, table_name):
|
||||
@@ -121,6 +140,54 @@ class DBServer:
|
||||
Table(table_name, self.metadata, autoload_with=self.engine)
|
||||
return self.metadata.tables[table_name]
|
||||
|
||||
# ── 日期/时间类型适配 ------------------------------------------------
|
||||
def _coerce_value(self, column, value):
|
||||
"""按反射列类型把 JSON 值适配为 SQLAlchemy 需要的 Python 对象。
|
||||
|
||||
JSON 没有日期类型,客户端只能传字符串;而 SQLite 的 DATETIME/DATE
|
||||
类型只接受 Python datetime/date 对象,字符串会被直接拒绝
|
||||
(TypeError: SQLite DateTime type only accepts Python datetime ...)。
|
||||
这里统一把 ISO 8601 字符串转为对应对象;带时区偏移的一律转 UTC
|
||||
并去掉 tzinfo,与 SQLite 朴素存储保持一致。
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
col_type = column.type
|
||||
if isinstance(col_type, sa_types.DateTime):
|
||||
if value == NOW_SENTINEL:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
text = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"列 {column.name} 的日期时间值无法解析"
|
||||
f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}"
|
||||
)
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
if isinstance(col_type, sa_types.Date):
|
||||
if value == NOW_SENTINEL:
|
||||
return datetime.now(timezone.utc).date()
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"列 {column.name} 的日期值无法解析"
|
||||
f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}"
|
||||
)
|
||||
if isinstance(col_type, sa_types.Time):
|
||||
try:
|
||||
return dt_time.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"列 {column.name} 的时间值无法解析"
|
||||
f"(需 ISO 8601): {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
def _build_where(self, table, conditions):
|
||||
if not conditions:
|
||||
return None
|
||||
@@ -129,23 +196,26 @@ class DBServer:
|
||||
column = table.columns[col]
|
||||
if isinstance(val, dict):
|
||||
if "eq" in val:
|
||||
clauses.append(column == val["eq"])
|
||||
clauses.append(column == self._coerce_value(column, val["eq"]))
|
||||
if "ne" in val:
|
||||
clauses.append(column != val["ne"])
|
||||
clauses.append(column != self._coerce_value(column, val["ne"]))
|
||||
if "gt" in val:
|
||||
clauses.append(column > val["gt"])
|
||||
clauses.append(column > self._coerce_value(column, val["gt"]))
|
||||
if "lt" in val:
|
||||
clauses.append(column < val["lt"])
|
||||
clauses.append(column < self._coerce_value(column, val["lt"]))
|
||||
if "ge" in val:
|
||||
clauses.append(column >= val["ge"])
|
||||
clauses.append(column >= self._coerce_value(column, val["ge"]))
|
||||
if "le" in val:
|
||||
clauses.append(column <= val["le"])
|
||||
clauses.append(column <= self._coerce_value(column, val["le"]))
|
||||
if "like" in val:
|
||||
# like 是字符串模式匹配,不做日期转换
|
||||
clauses.append(column.like(val["like"]))
|
||||
if "in" in val:
|
||||
clauses.append(column.in_(val["in"]))
|
||||
clauses.append(column.in_(
|
||||
[self._coerce_value(column, v) for v in val["in"]]
|
||||
))
|
||||
else:
|
||||
clauses.append(column == val)
|
||||
clauses.append(column == self._coerce_value(column, val))
|
||||
return and_(*clauses)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
@@ -205,6 +275,10 @@ class DBServer:
|
||||
|
||||
def _do_insert(self, conn, table, data):
|
||||
values = data.get("values", {})
|
||||
values = {
|
||||
k: self._coerce_value(table.columns[k], v)
|
||||
for k, v in values.items()
|
||||
}
|
||||
stmt = insert(table).values(**values)
|
||||
res = conn.execute(stmt)
|
||||
conn.commit()
|
||||
@@ -212,6 +286,10 @@ class DBServer:
|
||||
|
||||
def _do_update(self, conn, table, data):
|
||||
values = data.get("values", {})
|
||||
values = {
|
||||
k: self._coerce_value(table.columns[k], v)
|
||||
for k, v in values.items()
|
||||
}
|
||||
where_cond = self._build_where(table, data.get("where"))
|
||||
stmt = update(table).values(**values)
|
||||
if where_cond is not None:
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
"""
|
||||
db 服务的内置日志子模块。
|
||||
|
||||
提供两部分能力:
|
||||
1. log_internal() — db 进程内部直接写 Logs 表(无 HTTP 往返,无循环依赖)
|
||||
2. /log/* HTTP 路由 — 供外部服务推送 / 查询 / 统计 / 清理日志
|
||||
|
||||
设计要点(防递归):
|
||||
- log_internal() 直接操作数据库,不经过任何 HTTP 路由
|
||||
- /log/ingest 成功后不再调 log_internal
|
||||
- /log/ingest 失败时才调 log_internal,而 log_internal 不会回调 /log/ingest
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import (
|
||||
Table, insert, select, delete, func, and_, or_, MetaData
|
||||
)
|
||||
|
||||
bp = Blueprint('log', __name__, url_prefix='/log')
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────
|
||||
|
||||
VALID_SERVICES = {
|
||||
'waf', 'gateway', 'static', 'dnfs', 'db',
|
||||
'ai_review', 'captcha', 'core',
|
||||
}
|
||||
VALID_LEVELS = {'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'}
|
||||
|
||||
MAX_BATCH = 100 # 单次 /log/ingest 最多 100 条
|
||||
MAX_MESSAGE_LEN = 8192 # message 上限 8KB
|
||||
MAX_EXTRA_LEN = 4096 # extra 上限 4KB
|
||||
|
||||
DEFAULT_RETENTION_DAYS = 30
|
||||
DEFAULT_MAX_RECORDS = 1_000_000
|
||||
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒
|
||||
|
||||
# ── 表管理 ─────────────────────────────────────────────────────────────
|
||||
|
||||
_LOGS_TABLE_CACHE = {}
|
||||
|
||||
def _ensure_logs_table(metadata: MetaData, engine):
|
||||
"""确保 Logs 表已建好并已反射到 metadata(首次调用时建表 + 反射)。"""
|
||||
if 'Logs' in _LOGS_TABLE_CACHE:
|
||||
return _LOGS_TABLE_CACHE['Logs']
|
||||
|
||||
# 先用原生 SQL 建表(SQLAlchemy 反射要求表已存在)
|
||||
with engine.connect() as conn:
|
||||
conn.exec_driver_sql("""
|
||||
CREATE TABLE IF NOT EXISTS Logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
trace_id TEXT,
|
||||
extra TEXT
|
||||
)
|
||||
""")
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 反射到 metadata
|
||||
table = Table('Logs', metadata, autoload_with=engine)
|
||||
_LOGS_TABLE_CACHE['Logs'] = table
|
||||
return table
|
||||
|
||||
|
||||
# ── 内部日志记录(db 自己用,无 HTTP)─────────────────────────────────
|
||||
|
||||
def log_internal(engine, metadata, service, level, message,
|
||||
trace_id=None, extra=None):
|
||||
"""
|
||||
db 进程内部记录日志 — 直接操作数据库,不走 HTTP,无循环依赖。
|
||||
|
||||
供 db 的 /execute 路由内部调用。失败时静默吞掉,不影响主业务。
|
||||
"""
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
extra_json = json.dumps(extra, ensure_ascii=False) if extra else None
|
||||
if len(message) > MAX_MESSAGE_LEN:
|
||||
message = message[:MAX_MESSAGE_LEN]
|
||||
if extra_json and len(extra_json) > MAX_EXTRA_LEN:
|
||||
extra_json = extra_json[:MAX_EXTRA_LEN]
|
||||
|
||||
with engine.connect() as conn:
|
||||
conn.execute(insert(table).values(
|
||||
ts=datetime.now(timezone.utc).isoformat(),
|
||||
service=service,
|
||||
level=level,
|
||||
message=message,
|
||||
trace_id=trace_id,
|
||||
extra=extra_json,
|
||||
))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# 日志记录失败不应影响主业务
|
||||
pass
|
||||
|
||||
|
||||
# ── HTTP 路由 ──────────────────────────────────────────────────────────
|
||||
|
||||
def init_log_routes(app, engine, metadata,
|
||||
retention_days=DEFAULT_RETENTION_DAYS,
|
||||
max_records=DEFAULT_MAX_RECORDS,
|
||||
cleanup_interval=DEFAULT_CLEANUP_INTERVAL):
|
||||
"""把日志蓝图注册到 Flask app,并注入 engine/metadata。"""
|
||||
|
||||
app.config['LOG_ENGINE'] = engine
|
||||
app.config['LOG_METADATA'] = metadata
|
||||
app.config['LOG_RETENTION_DAYS'] = retention_days
|
||||
app.config['LOG_MAX_RECORDS'] = max_records
|
||||
app.config['LOG_CLEANUP_INTERVAL'] = cleanup_interval
|
||||
|
||||
# 建表 + 反射
|
||||
try:
|
||||
_ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'Logs 表初始化失败: {e}')
|
||||
|
||||
# 注册路由
|
||||
app.register_blueprint(bp)
|
||||
|
||||
# 启动后台清理线程
|
||||
_start_cleanup_thread(engine, metadata,
|
||||
retention_days, max_records, cleanup_interval)
|
||||
|
||||
|
||||
# ── 5.1 推送日志 ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/ingest', methods=['POST'])
|
||||
def ingest():
|
||||
"""接收外部日志推送(单条或批量)。"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data or 'logs' not in data:
|
||||
return jsonify({'success': False, 'error': '请求体缺少 logs 字段'}), 400
|
||||
|
||||
logs = data['logs']
|
||||
if not isinstance(logs, list) or not logs:
|
||||
return jsonify({'success': False, 'error': 'logs 必须是非空数组'}), 400
|
||||
if len(logs) > MAX_BATCH:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'单次最多 {MAX_BATCH} 条,当前 {len(logs)} 条'
|
||||
}), 400
|
||||
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500
|
||||
|
||||
# 校验 + 组装
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
rows = []
|
||||
for i, log in enumerate(logs):
|
||||
if not isinstance(log, dict):
|
||||
return jsonify({'success': False,
|
||||
'error': f'第 {i} 条日志不是有效对象'}), 400
|
||||
|
||||
service = log.get('service')
|
||||
level = log.get('level')
|
||||
message = log.get('message')
|
||||
|
||||
if not service or service not in VALID_SERVICES:
|
||||
return jsonify({'success': False,
|
||||
'error': f'第 {i} 条: service 无效或缺失'}), 400
|
||||
if not level or level not in VALID_LEVELS:
|
||||
return jsonify({'success': False,
|
||||
'error': f'第 {i} 条: level 无效或缺失'}), 400
|
||||
if not message or not isinstance(message, str):
|
||||
return jsonify({'success': False,
|
||||
'error': f'第 {i} 条: message 缺失或非字符串'}), 400
|
||||
|
||||
if len(message) > MAX_MESSAGE_LEN:
|
||||
message = message[:MAX_MESSAGE_LEN]
|
||||
|
||||
trace_id = log.get('trace_id')
|
||||
extra = log.get('extra')
|
||||
extra_json = None
|
||||
if extra is not None:
|
||||
extra_json = json.dumps(extra, ensure_ascii=False)
|
||||
if len(extra_json) > MAX_EXTRA_LEN:
|
||||
extra_json = extra_json[:MAX_EXTRA_LEN]
|
||||
|
||||
rows.append({
|
||||
'ts': log.get('ts') or now,
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra_json,
|
||||
})
|
||||
|
||||
# 写入(成功后不再调 log_internal,防递归)
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(insert(table), rows)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
# 失败时可以安全地记日志(log_internal 不回调 ingest)
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'/log/ingest 写入失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
return jsonify({'success': True, 'data': {'inserted': len(rows)}})
|
||||
|
||||
|
||||
# ── 5.2 查询日志 ───────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/query', methods=['POST'])
|
||||
def query():
|
||||
"""结构化查询日志。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500
|
||||
|
||||
clauses = []
|
||||
|
||||
# service(支持单个字符串或数组)
|
||||
svc = data.get('service')
|
||||
if svc:
|
||||
col = table.columns['service']
|
||||
if isinstance(svc, list):
|
||||
clauses.append(col.in_(svc))
|
||||
else:
|
||||
clauses.append(col == svc)
|
||||
|
||||
# level(支持单个字符串或数组)
|
||||
lvl = data.get('level')
|
||||
if lvl:
|
||||
col = table.columns['level']
|
||||
if isinstance(lvl, list):
|
||||
clauses.append(col.in_(lvl))
|
||||
else:
|
||||
clauses.append(col == lvl)
|
||||
|
||||
# trace_id
|
||||
tid = data.get('trace_id')
|
||||
if tid:
|
||||
clauses.append(table.columns['trace_id'] == tid)
|
||||
|
||||
# 时间区间
|
||||
since = data.get('since')
|
||||
until = data.get('until')
|
||||
ts_col = table.columns['ts']
|
||||
if since:
|
||||
clauses.append(ts_col >= since)
|
||||
if until:
|
||||
clauses.append(ts_col <= until)
|
||||
|
||||
# 关键字模糊搜索
|
||||
keyword = data.get('keyword')
|
||||
if keyword:
|
||||
clauses.append(table.columns['message'].like(f'%{keyword}%'))
|
||||
|
||||
where_cond = and_(*clauses) if clauses else None
|
||||
|
||||
# 排序
|
||||
order_col_name = data.get('order_by', 'ts')
|
||||
if order_col_name not in ('id', 'ts', 'service', 'level'):
|
||||
order_col_name = 'ts'
|
||||
order_col = table.columns[order_col_name]
|
||||
desc = data.get('desc', True)
|
||||
order_expr = order_col.desc() if desc else order_col.asc()
|
||||
|
||||
# 分页
|
||||
limit = min(data.get('limit', 100), 1000)
|
||||
offset = data.get('offset', 0)
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# 总数
|
||||
count_stmt = select(func.count()).select_from(table)
|
||||
if where_cond is not None:
|
||||
count_stmt = count_stmt.where(where_cond)
|
||||
total = conn.execute(count_stmt).scalar() or 0
|
||||
|
||||
# 查询
|
||||
stmt = select(table)
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
stmt = stmt.order_by(order_expr).limit(limit).offset(offset)
|
||||
|
||||
rows = conn.execute(stmt).fetchall()
|
||||
logs = []
|
||||
for row in rows:
|
||||
d = dict(row._mapping)
|
||||
if d.get('extra'):
|
||||
try:
|
||||
d['extra'] = json.loads(d['extra'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
logs.append(d)
|
||||
|
||||
return jsonify({'success': True, 'data': {'total': total, 'logs': logs}})
|
||||
|
||||
except Exception as e:
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'/log/query 失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── 5.3 统计聚合 ───────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/stats', methods=['POST'])
|
||||
def stats():
|
||||
"""日志分类统计。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500
|
||||
|
||||
group_by = data.get('group_by', 'service')
|
||||
group_fields = [g.strip() for g in group_by.split(',') if g.strip()]
|
||||
for g in group_fields:
|
||||
if g not in ('service', 'level'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'group_by 仅支持 service / level,收到: {g}'
|
||||
}), 400
|
||||
|
||||
# 时间过滤
|
||||
clauses = []
|
||||
since = data.get('since')
|
||||
until = data.get('until')
|
||||
ts_col = table.columns['ts']
|
||||
if since:
|
||||
clauses.append(ts_col >= since)
|
||||
if until:
|
||||
clauses.append(ts_col <= until)
|
||||
where_cond = and_(*clauses) if clauses else None
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
cols = [table.columns[g] for g in group_fields] + [func.count().label('count')]
|
||||
stmt = select(*cols)
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
stmt = stmt.group_by(*[table.columns[g] for g in group_fields])
|
||||
|
||||
rows = conn.execute(stmt).fetchall()
|
||||
groups = [dict(row._mapping) for row in rows]
|
||||
|
||||
# 总数
|
||||
count_stmt = select(func.count()).select_from(table)
|
||||
if where_cond is not None:
|
||||
count_stmt = count_stmt.where(where_cond)
|
||||
total = conn.execute(count_stmt).scalar() or 0
|
||||
|
||||
return jsonify({'success': True, 'data': {'groups': groups, 'total': total}})
|
||||
|
||||
except Exception as e:
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'/log/stats 失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── 5.4 清理过期 ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/purge', methods=['POST'])
|
||||
def purge():
|
||||
"""清理过期日志。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
before = data.get('before')
|
||||
if not before:
|
||||
return jsonify({'success': False, 'error': '缺少 before 字段'}), 400
|
||||
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
stmt = delete(table).where(table.columns['ts'] < before)
|
||||
result = conn.execute(stmt)
|
||||
conn.commit()
|
||||
deleted = result.rowcount
|
||||
log_internal(engine, metadata, 'db', 'INFO',
|
||||
f'/log/purge 清理 {deleted} 条 (before={before})')
|
||||
return jsonify({'success': True, 'data': {'deleted': deleted}})
|
||||
|
||||
except Exception as e:
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'/log/purge 失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── 5.5 健康检查 ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""日志模块健康检查。"""
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
with engine.connect() as conn:
|
||||
count = conn.execute(select(func.count()).select_from(table)).scalar() or 0
|
||||
return jsonify({'success': True, 'data': {'status': 'ok', 'count': count}})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── 后台自动清理线程 ──────────────────────────────────────────────────
|
||||
|
||||
def _start_cleanup_thread(engine, metadata,
|
||||
retention_days=DEFAULT_RETENTION_DAYS,
|
||||
max_records=DEFAULT_MAX_RECORDS,
|
||||
interval=DEFAULT_CLEANUP_INTERVAL):
|
||||
"""启动后台守护线程,定期清理过期日志。"""
|
||||
def _cleanup_loop():
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
_do_cleanup(engine, metadata, retention_days, max_records)
|
||||
except Exception:
|
||||
# 清理失败不影响服务运行
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=_cleanup_loop, daemon=True, name='logs-cleanup')
|
||||
t.start()
|
||||
|
||||
|
||||
def _do_cleanup(engine, metadata, retention_days, max_records):
|
||||
"""执行一次清理:删过期 + 裁剪超量。"""
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
|
||||
# 1. 删除过期日志
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).isoformat()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(delete(table).where(table.columns['ts'] < cutoff))
|
||||
conn.commit()
|
||||
expired_deleted = result.rowcount
|
||||
|
||||
# 2. 裁剪超量记录(保留最新的 max_records 条)
|
||||
count = conn.execute(select(func.count()).select_from(table)).scalar() or 0
|
||||
trimmed = 0
|
||||
if count > max_records:
|
||||
excess = count - max_records
|
||||
# 找到第 max_records 条的 ts(按时间正序的第 max_records 条)
|
||||
cutoff_row = conn.execute(
|
||||
select(table.columns['ts'])
|
||||
.order_by(table.columns['ts'].asc())
|
||||
.limit(1).offset(max_records)
|
||||
).fetchone()
|
||||
if cutoff_row:
|
||||
trim_result = conn.execute(
|
||||
delete(table).where(table.columns['ts'] < cutoff_row[0])
|
||||
)
|
||||
conn.commit()
|
||||
trimmed = trim_result.rowcount
|
||||
|
||||
if expired_deleted or trimmed:
|
||||
log_internal(engine, metadata, 'db', 'INFO',
|
||||
f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')
|
||||
@@ -1,5 +1,3 @@
|
||||
nuitka
|
||||
flask
|
||||
SQLAlchemy
|
||||
waitress
|
||||
../pymsg
|
||||
@@ -1,42 +0,0 @@
|
||||
"""
|
||||
Security headers middleware for Flask applications.
|
||||
Usage:
|
||||
from security_middleware import apply_security_headers
|
||||
apply_security_headers(app)
|
||||
"""
|
||||
import secrets
|
||||
|
||||
def apply_security_headers(app, csp_policy=None):
|
||||
"""Apply security response headers to a Flask app."""
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
# Prevent clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# XSS protection (legacy browsers)
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Prevent MIME type sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Referrer policy
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Permissions policy (disable camera, mic, geolocation)
|
||||
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
|
||||
|
||||
# Content Security Policy
|
||||
if csp_policy:
|
||||
response.headers['Content-Security-Policy'] = csp_policy
|
||||
else:
|
||||
response.headers['Content-Security-Policy'] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'self'"
|
||||
)
|
||||
|
||||
# Remove server header info
|
||||
response.headers.pop('Server', None)
|
||||
|
||||
return response
|
||||
+2
-2
@@ -44,7 +44,7 @@ Authorization: Bearer <base64_payload>_<rsa_sign>
|
||||
- payload 中取 `user` 字段作为操作者,写入 `owner`。
|
||||
- `delete_note` 与 `update_note` 校验所有权:
|
||||
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
|
||||
- **例外**:`user == 'admin'` 可删除任意节点(管理员豁免,仅 delete)。
|
||||
- **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。
|
||||
- `get_notes` 不需要鉴权。
|
||||
|
||||
## 接口文档
|
||||
@@ -154,7 +154,7 @@ curl -X POST http://localhost:8006/delete_note \
|
||||
|
||||
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
|
||||
|
||||
仅节点 owner 可更新;update 路径下 **admin 不享豁免**(与 delete 不同)。
|
||||
仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。
|
||||
|
||||
**Request**
|
||||
|
||||
|
||||
+8
-4
@@ -37,6 +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')
|
||||
|
||||
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
|
||||
"""通过 pymsg 短连接调用 dbv2 数据库服务。
|
||||
自动注入 ``_route: "execute"``。
|
||||
@@ -325,7 +328,7 @@ def delete_note():
|
||||
|
||||
data = _call_dbv2(select_payload)
|
||||
notes = data['data']
|
||||
if not info['user'] == 'admin':
|
||||
if info['user'] not in ADMIN_USERS:
|
||||
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
|
||||
logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']})
|
||||
return jsonify({"error": 'It\'s not your note'}), 403
|
||||
@@ -400,9 +403,10 @@ def update_note():
|
||||
|
||||
data = _call_dbv2(select_payload)
|
||||
notes = data['data']
|
||||
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
|
||||
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
|
||||
return jsonify({"error": 'It\'s not your note'}), 403
|
||||
if info['user'] not in ADMIN_USERS:
|
||||
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
|
||||
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
|
||||
return jsonify({"error": 'It\'s not your note'}), 403
|
||||
|
||||
# 内容审查
|
||||
review = review_content(title + '\n' + content, 10)
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ INSTL_DIR := /usr/local/bin
|
||||
CPP := g++
|
||||
INCS := -I inc/
|
||||
MY_LIBS := -L/usr/lib -lus_http -lus_crypt
|
||||
LIBS := -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options
|
||||
CPPFLAGS := --std=c++20 -pipe # -Wall -Werror
|
||||
LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options
|
||||
CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror
|
||||
BUILD_DIR = build
|
||||
$(shell mkdir -p $(BUILD_DIR))
|
||||
|
||||
|
||||
+31
-9
@@ -6,10 +6,11 @@ UniServe Framework 的应用层网关,C++20 实现。
|
||||
|
||||
## 职责
|
||||
|
||||
- **账号鉴权**:`/register` 注册、`/login` 登录,签发 RSA 签名的 Token。
|
||||
- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。
|
||||
- **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
|
||||
- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
|
||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入)。
|
||||
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(60 秒有效);注册时校验,一次性消费。
|
||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
|
||||
- **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。
|
||||
|
||||
## 安全特性
|
||||
@@ -71,9 +72,25 @@ UniServe Framework 的应用层网关,C++20 实现。
|
||||
|
||||
### `POST /register`
|
||||
|
||||
请求体与 `/login` 相同。先做密码强度校验、邮箱格式校验、CAPTCHA 校验,再写入 `Users` 表。
|
||||
请求体在 `/login` 基础上增加 `email_code`(必填,6 位数字邮箱验证码):
|
||||
|
||||
错误码:`400`(密码/邮箱/CAPTCHA 不合规)、`429`(速率超限)、`500`(数据库异常)。
|
||||
```json
|
||||
{ "username": "user@example.com", "password": "...", "email_code": "123456" }
|
||||
```
|
||||
|
||||
校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。
|
||||
|
||||
错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。
|
||||
|
||||
### `POST /register/send_code`
|
||||
|
||||
向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,60 秒有效)。
|
||||
|
||||
```json
|
||||
{ "email": "user@example.com" }
|
||||
```
|
||||
|
||||
限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`429`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。
|
||||
|
||||
### `GET/POST *`(反向代理)
|
||||
|
||||
@@ -95,18 +112,18 @@ Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default
|
||||
|
||||
## 数据库
|
||||
|
||||
SQLite3,表 `Users`:
|
||||
用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK AUTOINCREMENT | |
|
||||
| email | TEXT UNIQUE NOT NULL | 登录账号 |
|
||||
| username | TEXT UNIQUE NOT NULL | 登录账号(邮箱格式) |
|
||||
| password_hash | TEXT NOT NULL | `pbkdf2:100000:<salt_hex>:<hash_hex>` |
|
||||
| create_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
| last_login | DATETIME | 每次登录更新 |
|
||||
| is_active | BOOLEAN DEFAULT 1 | |
|
||||
|
||||
启动时若 `Users` 表不存在则自动建表(含 `idx_users_email` 索引),无默认管理员账号——首位注册者即为管理员。
|
||||
gateway 不直连数据库(早期直连本地 sqlite 的代码已注释保留),所有读写经 `DbC::call_msg` 调 db 的 `execute` 路由。无默认管理员账号——首位注册者即为管理员。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -209,12 +226,17 @@ gateway/
|
||||
|
||||
## 版本
|
||||
|
||||
`0.3.0-security`(见 [src/main.cpp:27](src/main.cpp#L27))。启动时打印:
|
||||
`0.5.2`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印:
|
||||
|
||||
```
|
||||
Gateway 0.3.0-security started (PBKDF2 + CAPTCHA + RateLimit)
|
||||
Gateway 0.5.2 started
|
||||
```
|
||||
|
||||
近期变更:
|
||||
|
||||
- `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,与其他步骤一致)。
|
||||
|
||||
## 依赖
|
||||
|
||||
- C++20(`g++`)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
sleep 1
|
||||
exec ./build/gateway.out -i example.ini #1>/dev/null
|
||||
+5
-1
@@ -6,6 +6,10 @@ namespace qing {
|
||||
namespace json = boost::json;
|
||||
class DbC {
|
||||
public:
|
||||
/* 本机 db 轻量查询的默认超时(秒);慢操作由调用方显式放宽。
|
||||
* smtp/captcha 等其他下游的超时常量见 main.cpp。 */
|
||||
static constexpr double DEFAULT_TIMEOUT = 5.0;
|
||||
|
||||
// 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。
|
||||
// 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。
|
||||
static json::value call_http(json::value& params) {
|
||||
@@ -25,7 +29,7 @@ namespace qing {
|
||||
// {"success":..., "data":[...]}。
|
||||
// 响应格式与 call_http 兼容,调用方可直接替换。
|
||||
static json::value call_msg(const json::value& params,
|
||||
double timeout = 5.0) {
|
||||
double timeout = DEFAULT_TIMEOUT) {
|
||||
json::value jv_route = params;
|
||||
jv_route.as_object()["_route"] = "execute";
|
||||
pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#pragma once
|
||||
|
||||
/** LogC — gateway 统一日志客户端(C++ 版,对应各 Python 服务的 log_client.py)
|
||||
*
|
||||
* 设计要点(与 Python 版对齐):
|
||||
* - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
* - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
* - fail-safe:db 服务不可用时静默丢弃(仅 stderr 提示),绝不影响业务
|
||||
* - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
*
|
||||
* 用法:
|
||||
* #include "LogC.hpp"
|
||||
*
|
||||
* qing::LogC::instance().info("gateway", "启动完成");
|
||||
* qing::LogC::instance().error("gateway", "数据库连接失败",
|
||||
* std::nullopt, R"({"errno":2002})");
|
||||
*
|
||||
* // 进程退出前:
|
||||
* qing::LogC::instance().shutdown(); // 停止后台线程并 flush 剩余日志(幂等)
|
||||
*/
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/json.hpp>
|
||||
|
||||
#include "Msg.hpp"
|
||||
|
||||
namespace qing {
|
||||
|
||||
namespace json = boost::json;
|
||||
|
||||
class LogC {
|
||||
public:
|
||||
struct Entry {
|
||||
std::string service;
|
||||
std::string level;
|
||||
std::string message;
|
||||
std::optional<std::string> trace_id;
|
||||
std::optional<std::string> extra; // 已序列化的 JSON 字符串(存 Logs.extra TEXT)
|
||||
};
|
||||
|
||||
static LogC& instance() {
|
||||
static LogC inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
// ---- 公开接口 --------------------------------------------------------
|
||||
|
||||
// 推送一条日志(非阻塞,立即返回)
|
||||
void push(const std::string& service, const std::string& level,
|
||||
const std::string& message,
|
||||
std::optional<std::string> trace_id = std::nullopt,
|
||||
std::optional<std::string> extra = std::nullopt) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
_queue.push_back(Entry{service, level, message,
|
||||
std::move(trace_id), std::move(extra)});
|
||||
}
|
||||
_cv.notify_one();
|
||||
}
|
||||
|
||||
void debug(const std::string& service, const std::string& message,
|
||||
std::optional<std::string> trace_id = std::nullopt,
|
||||
std::optional<std::string> extra = std::nullopt) {
|
||||
push(service, "DEBUG", message, std::move(trace_id), std::move(extra));
|
||||
}
|
||||
void info(const std::string& service, const std::string& message,
|
||||
std::optional<std::string> trace_id = std::nullopt,
|
||||
std::optional<std::string> extra = std::nullopt) {
|
||||
push(service, "INFO", message, std::move(trace_id), std::move(extra));
|
||||
}
|
||||
void warning(const std::string& service, const std::string& message,
|
||||
std::optional<std::string> trace_id = std::nullopt,
|
||||
std::optional<std::string> extra = std::nullopt) {
|
||||
push(service, "WARNING", message, std::move(trace_id), std::move(extra));
|
||||
}
|
||||
void error(const std::string& service, const std::string& message,
|
||||
std::optional<std::string> trace_id = std::nullopt,
|
||||
std::optional<std::string> extra = std::nullopt) {
|
||||
push(service, "ERROR", message, std::move(trace_id), std::move(extra));
|
||||
}
|
||||
void critical(const std::string& service, const std::string& message,
|
||||
std::optional<std::string> trace_id = std::nullopt,
|
||||
std::optional<std::string> extra = std::nullopt) {
|
||||
push(service, "CRITICAL", message, std::move(trace_id), std::move(extra));
|
||||
}
|
||||
|
||||
// 手动 flush:通知后台线程立即发送当前积攒的批次,并短暂等待其完成
|
||||
void flush() {
|
||||
_force_flush.store(true);
|
||||
_cv.notify_all();
|
||||
for (int i = 0; i < 200 && _force_flush.load(); ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
}
|
||||
|
||||
// 停止后台线程并 flush 剩余日志(进程退出前调用,幂等)
|
||||
void shutdown() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
if (_stop) return;
|
||||
_stop = true;
|
||||
}
|
||||
_cv.notify_all();
|
||||
if (_thread.joinable()) _thread.join();
|
||||
}
|
||||
|
||||
// ---- 配置(构造后、首次推送前可改)------------------------------------
|
||||
void set_endpoint(const std::string& addr, int port) {
|
||||
_db_addr = addr;
|
||||
_db_port = port;
|
||||
}
|
||||
|
||||
private:
|
||||
LogC() : _thread(&LogC::_flush_loop, this) {}
|
||||
~LogC() { shutdown(); }
|
||||
|
||||
LogC(const LogC&) = delete;
|
||||
LogC& operator=(const LogC&) = delete;
|
||||
|
||||
// 后台线程:积攒到 batch_size 或超时 flush_interval 时发送
|
||||
void _flush_loop() {
|
||||
std::vector<Entry> batch;
|
||||
auto last_flush = std::chrono::steady_clock::now();
|
||||
|
||||
for (;;) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(_mtx);
|
||||
// 最多 0.5s 轮询一次(对齐 Python 版 chk_timeout 上限)
|
||||
_cv.wait_for(lock, std::chrono::milliseconds(500),
|
||||
[&] { return _stop || !_queue.empty(); });
|
||||
while (!_queue.empty() && batch.size() < _BATCH_SIZE) {
|
||||
batch.push_back(std::move(_queue.front()));
|
||||
_queue.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const bool due = std::chrono::duration<double>(now - last_flush).count()
|
||||
>= _FLUSH_INTERVAL;
|
||||
if (!batch.empty() && (batch.size() >= _BATCH_SIZE || due
|
||||
|| _stop || _force_flush.load())) {
|
||||
_send(batch);
|
||||
batch.clear();
|
||||
last_flush = now;
|
||||
_force_flush.store(false);
|
||||
}
|
||||
|
||||
if (_stop) {
|
||||
// 退出前 flush 队列剩余
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(_mtx);
|
||||
while (!_queue.empty()) {
|
||||
batch.push_back(std::move(_queue.front()));
|
||||
_queue.pop_front();
|
||||
}
|
||||
}
|
||||
if (!batch.empty()) _send(batch);
|
||||
_force_flush.store(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表
|
||||
void _send(const std::vector<Entry>& batch) {
|
||||
const std::string now = _utc_now_iso(); // 批次共享同一时间戳(对齐 Python 版)
|
||||
try {
|
||||
pmc::net::MsgClient client(_db_addr, _db_port, "gateway-log");
|
||||
for (const auto& e : batch) {
|
||||
json::object values;
|
||||
values["ts"] = now;
|
||||
values["service"] = e.service;
|
||||
values["level"] = e.level;
|
||||
values["message"] = e.message;
|
||||
values["trace_id"] = e.trace_id ? json::value(*e.trace_id)
|
||||
: json::value(nullptr);
|
||||
values["extra"] = e.extra ? json::value(*e.extra)
|
||||
: json::value(nullptr);
|
||||
|
||||
json::object payload;
|
||||
payload["_route"] = "execute";
|
||||
payload["action"] = "insert";
|
||||
payload["table"] = "Logs";
|
||||
payload["values"] = std::move(values);
|
||||
|
||||
const std::string resp = client.call(
|
||||
json::serialize(payload), _TIMEOUT);
|
||||
auto obj = json::parse(resp).as_object();
|
||||
if (!obj.at("success").as_bool()) {
|
||||
std::string err = "unknown";
|
||||
if (auto* ev = obj.if_contains("error")) {
|
||||
if (ev->is_string()) err = ev->as_string().c_str();
|
||||
}
|
||||
std::cerr << "[LogC] INSERT 失败: " << err << std::endl;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
// fail-safe:db 不可用时静默丢弃,绝不影响业务
|
||||
std::cerr << "[LogC] 发送失败: " << e.what() << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
// UTC ISO8601 时间戳(对齐 Python datetime.now(timezone.utc).isoformat())
|
||||
// 形如 2026-07-29T08:30:00.123456+00:00,字典序即时间序,便于 db 按 ts 清理
|
||||
static std::string _utc_now_iso() {
|
||||
const auto now = std::chrono::system_clock::now();
|
||||
const auto t = std::chrono::system_clock::to_time_t(now);
|
||||
const auto us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
now.time_since_epoch()).count() % 1000000;
|
||||
std::tm tm{};
|
||||
::gmtime_r(&t, &tm);
|
||||
char buf[40];
|
||||
std::snprintf(buf, sizeof buf,
|
||||
"%04d-%02d-%02dT%02d:%02d:%02d.%06d+00:00",
|
||||
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
|
||||
tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast<int>(us));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- 配置项 -----------------------------------------------------------
|
||||
std::string _db_addr = "127.0.0.1";
|
||||
int _db_port = 8005;
|
||||
static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush
|
||||
static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush
|
||||
static constexpr double _TIMEOUT = 5.0; // socket 超时(秒)
|
||||
|
||||
// ---- 状态 -------------------------------------------------------------
|
||||
std::deque<Entry> _queue;
|
||||
std::mutex _mtx;
|
||||
std::condition_variable _cv;
|
||||
bool _stop = false;
|
||||
std::atomic<bool> _force_flush{false};
|
||||
std::thread _thread;
|
||||
};
|
||||
|
||||
} // namespace qing
|
||||
+179
-24
@@ -20,13 +20,14 @@
|
||||
#include "LocalTime.hpp"
|
||||
#include "Conf.hpp"
|
||||
#include "DbC.hpp"
|
||||
#include "LogC.hpp"
|
||||
|
||||
using namespace pmc::net;
|
||||
using namespace qing;
|
||||
namespace json = boost::json;
|
||||
namespace po = boost::program_options;
|
||||
|
||||
constexpr const char *_VER = "0.4.4";
|
||||
constexpr const char *_VER = "0.5.2";
|
||||
|
||||
//////////////////////////////////
|
||||
#include <sstream>
|
||||
@@ -287,6 +288,11 @@ http::response<http::string_body> handleRegister(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
/* 发送注册邮箱验证码:pymsg 调 smtp 服务 send_email 路由 */
|
||||
http::response<http::string_body> handleSendEmailCode(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
http::response<http::string_body> handleCaptcha(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
@@ -326,6 +332,15 @@ static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
||||
//static std::unique_ptr<Sqlite3> _SQLDB;
|
||||
static RateLimiter _LOGIN_RATE;
|
||||
static RateLimiter _REGISTER_RATE;
|
||||
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
|
||||
|
||||
/* pymsg 下游调用超时(秒):
|
||||
* captcha / smtp verify_email 均为本机内存操作,5s 足够;
|
||||
* smtp send_email 需完成一次完整外部 SMTP 会话(握手 + TLS + 投递),放宽到 20s。
|
||||
* db 调用的默认超时见 DbC.hpp 的 DbC::DEFAULT_TIMEOUT。 */
|
||||
static constexpr double _TIMEOUT_CAPTCHA = 5.0;
|
||||
static constexpr double _TIMEOUT_SMTP_VERIFY = 5.0;
|
||||
static constexpr double _TIMEOUT_SMTP_SEND = 20.0;
|
||||
|
||||
/* CAPTCHA store: token -> answer (in-memory, with expiry) */
|
||||
#include <unordered_map>
|
||||
@@ -337,6 +352,9 @@ struct CaptchaEntry {
|
||||
static std::unordered_map<std::string, CaptchaEntry> _CAPTCHA_STORE;
|
||||
static std::mutex _CAPTCHA_MTX;
|
||||
|
||||
/* 集中式日志客户端:推送 gateway 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */
|
||||
static inline LogC& logs() { return LogC::instance(); }
|
||||
|
||||
std::string generate_captcha_text() {
|
||||
static const char chars[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
// thread_local:每个线程独立 rng 实例,避免多线程并发调用 dist(rng) 时的数据竞争
|
||||
@@ -379,7 +397,27 @@ bool verify_captcha_remote(const std::string& token, const std::string& answer)
|
||||
{"token", token},
|
||||
{"answer", answer}
|
||||
};
|
||||
std::string resp = client.call(json::serialize(jv_req), 5.0);
|
||||
std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_CAPTCHA);
|
||||
auto jv = json::parse(resp);
|
||||
auto& obj = jv.as_object();
|
||||
if (!obj.at("success").as_bool()) return false;
|
||||
return obj.at("data").as_object().at("valid").as_bool();
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 调用 smtp 服务(pymsg/TCP :8008)校验邮箱验证码。
|
||||
* 验证码一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 */
|
||||
bool verify_email_code_remote(const std::string& email, const std::string& code) {
|
||||
try {
|
||||
pmc::net::MsgClient client("127.0.0.1", 8008, "gateway");
|
||||
json::value jv_req = {
|
||||
{"_route", "verify_email"},
|
||||
{"email", email},
|
||||
{"code", code}
|
||||
};
|
||||
std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_VERIFY);
|
||||
auto jv = json::parse(resp);
|
||||
auto& obj = jv.as_object();
|
||||
if (!obj.at("success").as_bool()) return false;
|
||||
@@ -501,10 +539,14 @@ int main(int argc, char **argv) {
|
||||
|
||||
|
||||
std::cout << "Gateway " << _VER << " started" << std::endl;
|
||||
logs().info("gateway", std::string("Gateway ") + _VER + " started");
|
||||
|
||||
/* 开始监听 */
|
||||
_SERVE->run();
|
||||
|
||||
/* 收到停止信号后的收尾:记录并 flush 剩余日志 */
|
||||
logs().info("gateway", "Gateway shutdown");
|
||||
logs().shutdown();
|
||||
}
|
||||
|
||||
//void db_init() {
|
||||
@@ -565,6 +607,12 @@ void registerRoutes(HttpServer &server) {
|
||||
[](const auto& req, const auto& params) { return handleRegister(req, params); });
|
||||
});
|
||||
|
||||
/* 发送注册邮箱验证码(须在 post("*") 通配符之前注册) */
|
||||
server.post("/register/send_code", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleSendEmailCode(req, params); });
|
||||
});
|
||||
|
||||
server.post("/logout", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleLogout(req, params); });
|
||||
@@ -603,7 +651,14 @@ catch (const HttpException& e) {
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
// 详细错误信息(e.what() 含源码路径/行号/C++ 模板类型)暂时以注释方式保留(同 handleLogin)
|
||||
std::cerr << "[handleRegister HttpException] " << e.what() << std::endl;
|
||||
std::cerr << "[wrapperHttp HttpException] " << e.what() << std::endl;
|
||||
// 推送集中式日志:5xx 服务端错误记 ERROR,其余(400/401/403/429 等业务错误)记 WARNING
|
||||
if (e.status() >= 500)
|
||||
logs().error("gateway", e.what(), std::nullopt,
|
||||
json::serialize(json::value{{"url", e.url()}, {"status", e.status()}}));
|
||||
else
|
||||
logs().warning("gateway", e.what(), std::nullopt,
|
||||
json::serialize(json::value{{"url", e.url()}, {"status", e.status()}}));
|
||||
json::value jv_r = {
|
||||
{"success", false},
|
||||
{"message", e.what()}
|
||||
@@ -627,6 +682,7 @@ catch (const std::exception& e) {
|
||||
// 透传到客户端会泄露 C++ 类型信息与内部路径(P0-3 遗漏点,已补全)
|
||||
// 排障:完整信息仍输出到服务端 stderr
|
||||
std::cerr << "[wrapperHttp std::exception] <" << ti.name() << "> " << e.what() << std::endl;
|
||||
logs().error("gateway", std::string("<") + ti.name() + "> " + e.what());
|
||||
json::value jv_r = {
|
||||
{"success", false},
|
||||
/* {"message", std::string("<") + ti.name() + "> " + e.what()}, */ // 泄露源码信息,已禁用
|
||||
@@ -696,7 +752,7 @@ http::response<http::string_body> handleLogin(
|
||||
client_ip = "direct";
|
||||
|
||||
if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300))
|
||||
THROW_EX_CUSTOM(HttpException, 2001, "Too many login attampts. Please try again later.", 400, "/login");
|
||||
THROW_EX_CUSTOM(HttpException, 2001, "Too many login attempts. Please try again later.", 429, "/login");
|
||||
|
||||
// parse json req body
|
||||
json::value jv;
|
||||
@@ -742,10 +798,14 @@ http::response<http::string_body> handleLogin(
|
||||
auto jv_db_r = DbC::call_msg(jv_select);
|
||||
auto obj_r = jv_db_r.as_object();
|
||||
auto success = obj_r["success"].as_bool();
|
||||
if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login (Update Step) Failed", 500, "/login");
|
||||
if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login (Query Step) Failed", 500, "/login");
|
||||
auto obj_rd = obj_r.at("data").as_array();
|
||||
auto obj_ru = obj_rd.at(0).as_object();
|
||||
stored_hash = obj_ru.at("password_hash").as_string();
|
||||
/* 用户不存在时 data 为空数组:直接 at(0) 会抛异常被 wrapper 兜底成 500,
|
||||
* 此处保持 stored_hash 为空,统一走下面的 401 分支 */
|
||||
if (!obj_rd.empty()) {
|
||||
auto obj_ru = obj_rd.at(0).as_object();
|
||||
stored_hash = obj_ru.at("password_hash").as_string();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -770,6 +830,14 @@ http::response<http::string_body> handleLogin(
|
||||
// std::cout << "Migrated password hash for " << username << " to PBKDF2" << std::endl;
|
||||
//}
|
||||
}
|
||||
else {
|
||||
/* 用户不存在:执行一次等价的 dummy PBKDF2 校验拉平响应时序,
|
||||
* 防止通过响应时间差异枚举有效用户名(用户不存在/密码错误一律 401) */
|
||||
static const std::string dummy_hash =
|
||||
"pbkdf2:100000:00000000000000000000000000000000:"
|
||||
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||
PBKDF2Hasher::verify_password(password, dummy_hash);
|
||||
}
|
||||
|
||||
if (!auth_ok) THROW_EX_CUSTOM(HttpException, 2101, "Invalid Username or Password", 401, "/login");
|
||||
|
||||
@@ -779,21 +847,21 @@ http::response<http::string_body> handleLogin(
|
||||
//stmt_upd.bind_text(1, username);
|
||||
//stmt_upd.step();
|
||||
//
|
||||
// 新版调用db模块 无法通过DB上传时间戳目前
|
||||
//json::value jv_upd = {
|
||||
// {"action", "update"},
|
||||
// {"table", "Users"},
|
||||
// {"values", json::object{
|
||||
// {"last_login", LocalTime::getCurrent()},
|
||||
// }},
|
||||
// {"where", json::object{
|
||||
// {"username", username},
|
||||
// }}
|
||||
//};
|
||||
//auto jv_db_r = DbC::call_http(jv_upd);
|
||||
//auto obj_r = jv_db_r.as_object();
|
||||
//auto success = obj_r["success"].as_bool();
|
||||
//if (!success) THROW_EX_CUSTOM(HttpException, 2102, json::serialize(jv_db_r)+"Login (Update Step) Failed", 500, "/login");
|
||||
// 新版调用db模块:values 中的 "$now" 哨兵由 db 日期适配层替换为服务端当前 UTC 时间
|
||||
//(此前因 db 无法处理日期列而注释,dbv2 增加 _coerce_value 后可用)。
|
||||
json::value jv_upd = {
|
||||
{"action", "update"},
|
||||
{"table", "Users"},
|
||||
{"values", json::object{
|
||||
{"last_login", "$now"},
|
||||
}},
|
||||
{"where", json::object{
|
||||
{"username", username},
|
||||
}}
|
||||
};
|
||||
auto jv_upd_r = DbC::call_msg(jv_upd);
|
||||
if (!jv_upd_r.as_object()["success"].as_bool())
|
||||
THROW_EX_CUSTOM(HttpException, 2102, "Login (Update Step) Failed", 500, "/login");
|
||||
|
||||
|
||||
/* 生成令牌 */
|
||||
@@ -827,6 +895,9 @@ http::response<http::string_body> handleLogin(
|
||||
res.set("Set-Cookie", ck);
|
||||
}
|
||||
|
||||
logs().info("gateway", "登录成功: " + username, std::nullopt,
|
||||
json::serialize(json::value{{"ip", client_ip}}));
|
||||
|
||||
res.result(http::status::ok);
|
||||
res.body() = json::serialize(jv_rr);
|
||||
res.prepare_payload();
|
||||
@@ -867,7 +938,7 @@ http::response<http::string_body> handleCaptchaImage(
|
||||
pmc::net::MsgClient client("127.0.0.1", 8009, "gateway");
|
||||
std::string resp;
|
||||
try {
|
||||
resp = client.call(R"({"_route":"image"})", 5.0);
|
||||
resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA);
|
||||
} catch (const std::exception& e) {
|
||||
THROW_EX_CUSTOM(HttpException, 2400, "Captcha service unavailable", 502, "/captcha/image");
|
||||
}
|
||||
@@ -915,7 +986,7 @@ http::response<http::string_body> handleRegister(
|
||||
client_ip = std::string(it_host->value());
|
||||
if (!_REGISTER_RATE.check_and_record(client_ip, 3, 600))
|
||||
THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.",
|
||||
403, "/register");
|
||||
429, "/register");
|
||||
|
||||
// parse the req body
|
||||
json::value jv;
|
||||
@@ -951,6 +1022,14 @@ http::response<http::string_body> handleRegister(
|
||||
THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register");
|
||||
}
|
||||
|
||||
// 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前)
|
||||
auto ecode_it = obj.find("email_code");
|
||||
if (ecode_it == obj.end())
|
||||
THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/register");
|
||||
std::string email_code = std::string(ecode_it->value().as_string());
|
||||
if (!verify_email_code_remote(username, email_code))
|
||||
THROW_EX_CUSTOM(HttpException, 3006, "Invalid or expired email verification code", 400, "/register");
|
||||
|
||||
// generate the hash of password
|
||||
auto password_hash = PBKDF2Hasher::hash_password(password);
|
||||
|
||||
@@ -977,12 +1056,87 @@ http::response<http::string_body> handleRegister(
|
||||
if (!success) THROW_EX_CUSTOM(HttpException, 2202, "Registe Failed", 400, "/register");
|
||||
auto jv_r = json::value{{"success", success}};
|
||||
|
||||
logs().info("gateway", "注册成功: " + username, std::nullopt,
|
||||
json::serialize(json::value{{"ip", client_ip}}));
|
||||
|
||||
res.result(http::status::ok);
|
||||
res.body() = json::serialize(jv_r);
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 处理发送注册邮箱验证码 /register/send_code
|
||||
*
|
||||
* 经 pymsg 调 smtp 服务 send_email 路由,向目标邮箱发送 6 位验证码。
|
||||
* 限流 5 次/10 分钟/IP;smtp 不可用返回 502(fail-closed)。 */
|
||||
http::response<http::string_body> handleSendEmailCode(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params)
|
||||
{
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
|
||||
// IP rate limit check
|
||||
std::string client_ip = "direct";
|
||||
auto it_host = req.base().find("X-Forwarded-For");
|
||||
if (it_host != req.base().end())
|
||||
client_ip = std::string(it_host->value());
|
||||
if (!_EMAIL_CODE_RATE.check_and_record(client_ip, 5, 600))
|
||||
THROW_EX_CUSTOM(HttpException, 3101, "Too many code requests. Please try again later.",
|
||||
429, "/register/send_code");
|
||||
|
||||
// parse the req body
|
||||
json::value jv;
|
||||
try { jv = json::parse(req.body()); }
|
||||
catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code");
|
||||
}
|
||||
|
||||
if (!jv.is_object())
|
||||
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code");
|
||||
|
||||
auto& obj = jv.as_object();
|
||||
auto email_it = obj.find("email");
|
||||
if (email_it == obj.end())
|
||||
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code");
|
||||
auto email = std::string(email_it->value().as_string());
|
||||
|
||||
// Email format check
|
||||
std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)");
|
||||
if (!std::regex_match(email, email_regex))
|
||||
THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code");
|
||||
|
||||
// 调 smtp 服务(pymsg/TCP :8008)发送验证码
|
||||
bool sent = false;
|
||||
try {
|
||||
pmc::net::MsgClient client("127.0.0.1", 8008, "gateway");
|
||||
json::value jv_req = {
|
||||
{"_route", "send_email"},
|
||||
{"email", email}
|
||||
};
|
||||
std::string resp = client.call(json::serialize(jv_req), _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();
|
||||
} catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 3102, "Mail service unavailable", 502, "/register/send_code");
|
||||
}
|
||||
|
||||
if (!sent)
|
||||
THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/register/send_code");
|
||||
|
||||
logs().info("gateway", "验证码邮件发送成功: " + email, std::nullopt,
|
||||
json::serialize(json::value{{"ip", client_ip}}));
|
||||
|
||||
auto jv_r = json::value{{"success", true}};
|
||||
res.result(http::status::ok);
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.body() = json::serialize(jv_r);
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
http::response<http::string_body> handleProxy(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params,
|
||||
@@ -1097,6 +1251,7 @@ http::response<http::string_body> handleProxy(
|
||||
}
|
||||
}
|
||||
if (!flag) {
|
||||
logs().warning("gateway", "代理目标不在白名单: " + t_url);
|
||||
throw std::runtime_error("403");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Prerequisites
|
||||
*.d
|
||||
|
||||
# Compiled Object files
|
||||
*.slo
|
||||
*.lo
|
||||
*.o
|
||||
*.obj
|
||||
|
||||
# Precompiled Headers
|
||||
*.gch
|
||||
*.pch
|
||||
|
||||
# Linker files
|
||||
*.ilk
|
||||
|
||||
# Debugger Files
|
||||
*.pdb
|
||||
|
||||
# Compiled Dynamic libraries
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
*.so.*
|
||||
|
||||
|
||||
# Fortran module files
|
||||
*.mod
|
||||
*.smod
|
||||
|
||||
# Compiled Static libraries
|
||||
*.lai
|
||||
*.la
|
||||
*.a
|
||||
*.lib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
|
||||
# Build directories
|
||||
build/
|
||||
Build/
|
||||
build-*/
|
||||
|
||||
# CMake generated files
|
||||
CMakeFiles/
|
||||
CMakeCache.txt
|
||||
cmake_install.cmake
|
||||
#Makefile
|
||||
install_manifest.txt
|
||||
compile_commands.json
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.log
|
||||
*.bak
|
||||
*.swp
|
||||
|
||||
# vcpkg
|
||||
vcpkg_installed/
|
||||
|
||||
# debug information files
|
||||
*.dwo
|
||||
|
||||
# test output & cache
|
||||
Testing/
|
||||
.cache/
|
||||
@@ -0,0 +1,27 @@
|
||||
# Fibonacci sequence
|
||||
func fun{
|
||||
if(x <= 2){
|
||||
return(1);
|
||||
}
|
||||
y = 0;
|
||||
x = x - 1;
|
||||
y = fun(x);
|
||||
x = x - 1;
|
||||
return(y + fun(x));
|
||||
};
|
||||
|
||||
# save the Fibonacci sequence of 1 to 15 in an array
|
||||
array arr(15);
|
||||
x = 0;
|
||||
while(x<15){
|
||||
arr[x] = fun(x) + 1000;
|
||||
x = x + 1;
|
||||
}
|
||||
|
||||
puts("Fibonacci sequence:");
|
||||
# print the Fibonacci sequence of 1 to 15
|
||||
i = 0;
|
||||
while(i < 15){
|
||||
print(arr[i]);
|
||||
i=i+1;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
a = 5;
|
||||
b = "ok";
|
||||
|
||||
print(5);
|
||||
puts(b);
|
||||
@@ -0,0 +1,6 @@
|
||||
func f_test{
|
||||
return(0);
|
||||
};
|
||||
|
||||
r = f_test();
|
||||
print(r);
|
||||
@@ -0,0 +1,2 @@
|
||||
print(1);
|
||||
puts("1")
|
||||
+741
@@ -0,0 +1,741 @@
|
||||
/* 魔改自github tryC项目。手写简易的解释器,我们
|
||||
* 将用它来作为操作界面的shell。
|
||||
*
|
||||
* 也许我们可以增加一些字符串赋值、拼接的能力
|
||||
* 同时未来也需要加入系统调用的能力
|
||||
*
|
||||
*
|
||||
* 20250827(qing): 增加了宽字符支持。
|
||||
* 20250827(qing): 修复了Str漏匹配的BUG
|
||||
* 20250827(qing): 该脚本在操作不当的情况下可能会发生内存泄漏
|
||||
* 20250827(qing): 使用longjmp()代替exit()
|
||||
*/
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <float.h>
|
||||
#include <string.h>
|
||||
#include <wchar.h>
|
||||
#include <wctype.h>
|
||||
#include "console.h"
|
||||
#define POOLSIZE (1 * 128) /* 任意大小 */
|
||||
#define SYMTABSIZE (1 * 128) /* !符号表栈的大小 */
|
||||
#define MAXNAMESIZE 16 /* 变量/函数名称的长度 */
|
||||
#define RETURNFLAG DBL_MAX /* 表明函数中已经出现return语句 */
|
||||
|
||||
|
||||
typedef struct symStruct { /* 该结构体表示存储在符号表中的一个符号 */
|
||||
int type; /* 符号的类型:Num, Char, Str, Array, Func */
|
||||
wchar_t name[MAXNAMESIZE]; /* 记录符号名称 */
|
||||
double value; /* 记录符号的值,或者字符串或数组的长度 */
|
||||
union {
|
||||
wchar_t *funcp; /* 指向一个数组或者一个函数 */
|
||||
struct symStruct* list;
|
||||
} pointer;
|
||||
int levelNum; /* 表明声明的嵌套层级 */
|
||||
} symbol;
|
||||
static symbol symtab[SYMTABSIZE];
|
||||
static int symPointer = 0; /* 符号表栈指针 */
|
||||
static int currentlevel = 0; /* 当前嵌套层级 */
|
||||
|
||||
static wchar_t *src, *old_src; /* 当前正处理的文本 */
|
||||
static wchar_t *p_src; /* 用于定位释放点 */
|
||||
static int top_src = 0;
|
||||
|
||||
|
||||
enum { /* 解释器状态枚举 (使用int直接接受匿名枚举) */
|
||||
debug, run
|
||||
};
|
||||
static int compileState = run; /* 当前解释器状态 */
|
||||
|
||||
static double return_val = 0; /* 用于保存函数的返回值 */
|
||||
|
||||
|
||||
enum { /* 标记和类别(运算符放最后并按优先级排序) */
|
||||
Num = 128, Char, Str, Array, Func,
|
||||
Else, If, Return, While, Print, Puts, Read,
|
||||
Assign, OR, AND, Equal, Sym, FuncSym, ArraySym, Void,
|
||||
Nequal, LessEqual, GreatEqual
|
||||
};
|
||||
static int token; /* 当前标记类型 */
|
||||
union tokenValue {
|
||||
symbol *ptr; /* 在返回字符串或符号地址用于赋值时使用 */
|
||||
double val; /* 用于字符或数字时标记值 */
|
||||
} ;
|
||||
|
||||
static union tokenValue token_val;
|
||||
|
||||
/*--------------- 函数声明 ---------------*/
|
||||
static double function();
|
||||
static double statement();
|
||||
static int boolOR(); /* 用于辅助IF和WHILE词元 */
|
||||
static int boolAND();
|
||||
static int boolexp();
|
||||
static double expression();
|
||||
static double factor();
|
||||
static double term();
|
||||
static void match(int tk);
|
||||
static void next();
|
||||
/////
|
||||
static void clear(int level);
|
||||
static void clearThis(); /* 20260707(qing): 增加一个clearThis()函数,用于释放变量 */
|
||||
static void __exit();
|
||||
/* ------------------- 词法分析 -------------------*/
|
||||
|
||||
/* 输入字符串的下一个标记 */
|
||||
static void next() {
|
||||
wchar_t *last_pos;
|
||||
while(token = *src) { /* 逐字扫描 */
|
||||
++src;
|
||||
if (token == L'\n') { /* 记录新行的起点 */
|
||||
//if (compileState == debug)
|
||||
// wprintf(L"%.*ls", (int)(src-old_src), old_src);
|
||||
old_src = src;
|
||||
}
|
||||
else if (token == L'#') { /* 跳过注释 */
|
||||
while(*src != 0 && *src != L'\n') {
|
||||
src++;
|
||||
}
|
||||
}
|
||||
/* ---- 检查到标识符 ---- */
|
||||
else if ((token >= L'a' && token <= L'z') || (token >= L'A' && token <= L'Z') || (token == L'_') || (token > 0x7F)) {
|
||||
last_pos = src -1; /* 程序标识符 */
|
||||
wchar_t nameBuffer[MAXNAMESIZE];
|
||||
nameBuffer[0] = token; /* 扫描标识符 */
|
||||
while ((*src >= L'a' && *src <= L'z') || (*src >= L'A' && *src <= L'Z') || (*src >= L'0' && *src <= L'9') || (*src == L'_') || (*src > 0x7f)) {
|
||||
nameBuffer[src-last_pos] = *src;
|
||||
src++;
|
||||
}
|
||||
nameBuffer[src-last_pos] = 0; /* 获取标识符名 */
|
||||
int i;
|
||||
for (i=symPointer-1; i>=0; --i) { /* 在符号表中搜索符号 */
|
||||
if (wcscmp(nameBuffer, symtab[i].name) == 0) { /* 如果找到符号:根据符号类型返回标记 */
|
||||
|
||||
if (symtab[i].type == Num || symtab[i].type == Char) {
|
||||
token_val.ptr = &symtab[i];
|
||||
token = Sym;
|
||||
}
|
||||
else if (symtab[i].type == Str) { /* 20250827(qing): 新增此分支,修复BUG */
|
||||
token = Str;
|
||||
token_val.ptr = (symbol *)symtab[i].pointer.funcp;
|
||||
}
|
||||
else if (symtab[i].type == FuncSym) {
|
||||
token_val.ptr = &symtab[i];
|
||||
token = symtab[i].type;
|
||||
}
|
||||
else if (symtab[i].type == ArraySym) {
|
||||
token_val.ptr = &symtab[i];
|
||||
token = symtab[i].type;
|
||||
}
|
||||
else {
|
||||
if (symtab[i].type == Void) {
|
||||
token = Sym;
|
||||
token_val.ptr = &symtab[i];
|
||||
}
|
||||
else { // ???
|
||||
token = symtab[i].type;
|
||||
token_val.ptr = NULL; /* 以防万一,为当前记号存储器赋个值!
|
||||
* 因为Array记号可能到这里来,它会在检查失败后检查释放 */
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
wcscpy(symtab[symPointer].name, nameBuffer); /* 如果标记未被发现,创建一个新的 */
|
||||
symtab[symPointer].levelNum = currentlevel;
|
||||
symtab[symPointer].type = Void;
|
||||
token_val.ptr = &symtab[symPointer];
|
||||
symPointer++;
|
||||
token = Sym;
|
||||
return;
|
||||
}
|
||||
else if (token >= L'0' && token <= L'9') {
|
||||
token_val.val = (double)token - L'0';
|
||||
while (*src >= L'0' && *src <= L'9') {
|
||||
token_val.val = token_val.val * 10.0 + *src++ - L'0';
|
||||
}
|
||||
if (*src == L'.') {
|
||||
src++;
|
||||
int countDig = 1;
|
||||
while (*src >= L'0' && *src <= L'9') {
|
||||
token_val.val = token_val.val + ((double)(*src++) - L'0') / (10.0 * countDig++);
|
||||
}
|
||||
}
|
||||
token = Num;
|
||||
return;
|
||||
}
|
||||
|
||||
/* 字符 */
|
||||
else if (token == L'\'') {
|
||||
token_val.val = *src++;
|
||||
token = Char;
|
||||
src++;
|
||||
return;
|
||||
}
|
||||
|
||||
/* 字符串 */
|
||||
else if (token == L'"') {
|
||||
last_pos = src;
|
||||
int numCount = 0;
|
||||
while (*src != 0 && *src != token) {
|
||||
src ++;
|
||||
numCount ++;
|
||||
}
|
||||
if (*src) { /* TODO: 解析出字符串就会创建动态内存,那么可能会有风险! */
|
||||
*src = 0;
|
||||
token_val.ptr = malloc(sizeof(wchar_t) * (numCount + 1));
|
||||
*(wchar_t*)token_val.ptr = L'0';
|
||||
wcscpy((wchar_t*)token_val.ptr, last_pos);
|
||||
*src = token;
|
||||
src++;
|
||||
}
|
||||
token = Str;
|
||||
return;
|
||||
}
|
||||
|
||||
/* == 和 = */
|
||||
else if (token == L'=') {
|
||||
if (*src == L'=') {
|
||||
src++;
|
||||
token = Equal;
|
||||
}
|
||||
else {token = Assign;}
|
||||
return;
|
||||
}
|
||||
|
||||
/* != */
|
||||
else if (token == L'!') {
|
||||
if (*src == L'=') {
|
||||
src++;
|
||||
token = Nequal;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* <= 或 < */
|
||||
else if (token == L'<') {
|
||||
if (*src == L'=') {
|
||||
src ++;
|
||||
token = LessEqual;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* >= 或 > */
|
||||
else if (token == L'>') {
|
||||
if (*src == L'=') {
|
||||
src++;
|
||||
token = GreatEqual;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* || */
|
||||
else if (token == L'|') {
|
||||
if (*src == L'|') {
|
||||
src++;
|
||||
token = OR;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
/* && */
|
||||
else if (token == L'&') {
|
||||
if ( *src == L'&') {
|
||||
src++;
|
||||
token=AND;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* 保持以在别处解析 */
|
||||
else if ( token == L'*' || token == L'/' || token == L';' || token == L',' || token == L'+' || token == L'-' ||
|
||||
token == L'(' || token == L')' || token == L'{' || token == L'}' || token == L'[' || token == L']') {
|
||||
return;
|
||||
}
|
||||
|
||||
/* 空格、制表符 */
|
||||
else if (token == L' ' || token == L'\t') { }
|
||||
|
||||
/* 不认识 */
|
||||
else {
|
||||
//wprintf(L"unexpected token: %d\n", token);
|
||||
printtovga(L"unexpected token\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void clear(int level) {
|
||||
for (int i=symPointer-1; i>=0; i--) {
|
||||
token_val.ptr = &symtab[i];
|
||||
if ( token_val.ptr->levelNum >= level ) {
|
||||
clearThis();
|
||||
}
|
||||
else { break; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 20250827(QING): 在这里检测pointer联合字段是否为空,不为空就表明有值先释放 */
|
||||
static void clearThis() {
|
||||
//printf("Clear a Symbol: %s\n", token_val.ptr->name);
|
||||
if (token_val.ptr->type == Str && token_val.ptr->pointer.funcp)
|
||||
free(token_val.ptr->pointer.funcp);
|
||||
else if (token_val.ptr->type == ArraySym && token_val.ptr->pointer.list)
|
||||
free(token_val.ptr->pointer.list);
|
||||
}
|
||||
|
||||
/* 检查是否匹配 */
|
||||
static void match(int tk) {
|
||||
if (token == tk) {
|
||||
if (compileState==debug) {
|
||||
if (iswprint(tk)) { /* 该字符是否能够打印 */
|
||||
//wprintf(L"match: %lc\n", tk);
|
||||
}
|
||||
else {
|
||||
//wprintf(L"match: %d\n", tk);
|
||||
;
|
||||
}
|
||||
}
|
||||
next(); /* 检查下一个标记 */
|
||||
}
|
||||
else { /* 直接让程序死亡,在单片机上似乎不妥 */
|
||||
//wprintf(L"line %.*ls:expected token: %d\n", (int)(src - old_src), old_src, tk);
|
||||
printtovga(L"unexpected token\n");
|
||||
__exit(-1);
|
||||
//clearThis();
|
||||
}
|
||||
}
|
||||
|
||||
/*-------------------------- 语法分析与执行 --------------------------*/
|
||||
static double term() {
|
||||
double temp = factor();
|
||||
while (token == L'*' || token == L'/') {
|
||||
if (token == L'*') {
|
||||
match(L'*');
|
||||
temp *= factor();
|
||||
}
|
||||
else {
|
||||
match(L'/');
|
||||
temp /= factor();
|
||||
}
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
|
||||
static double factor() {
|
||||
double temp = 0;
|
||||
if (token == L'(') {
|
||||
match(L'(');
|
||||
temp = expression();
|
||||
match(L')');
|
||||
}
|
||||
else if (token == Num || token == Char) {
|
||||
temp = token_val.val;
|
||||
match(token);
|
||||
}
|
||||
else if (token == Sym) {
|
||||
temp = token_val.ptr->value;
|
||||
match(Sym);
|
||||
}
|
||||
else if (token == ArraySym) {
|
||||
symbol *ptr = token_val.ptr;
|
||||
match(ArraySym);
|
||||
match(L'[');
|
||||
int index = (int)expression();
|
||||
if (index >= 0 && index < ptr->value) {
|
||||
temp = ptr->pointer.list[index].value;
|
||||
}
|
||||
match(L']');
|
||||
}
|
||||
else if (token == FuncSym) { /* 处理函数调用 */
|
||||
temp = function();
|
||||
}
|
||||
else {
|
||||
//printf("Unknow token: %d.\n", token);
|
||||
printtovga(L"Unknow token\n");
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
|
||||
/* 加减法表达式 */
|
||||
static double expression() {
|
||||
double temp = term();
|
||||
while (token == L'+' || token == L'-') {
|
||||
if (token == L'+') {
|
||||
match(L'+');
|
||||
temp += term();
|
||||
}
|
||||
else {
|
||||
match(L'-');
|
||||
temp -= term();
|
||||
}
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
|
||||
|
||||
static int boolexp() {
|
||||
if (token == L'(') {
|
||||
match(L'(');
|
||||
int result = boolOR();
|
||||
match(L')');
|
||||
return result;
|
||||
}
|
||||
else if (token == L'!') {
|
||||
match(L'!');
|
||||
return boolexp();
|
||||
}
|
||||
double temp = expression();
|
||||
if (token == L'>') {
|
||||
match(L'>');
|
||||
return temp > expression();
|
||||
}
|
||||
else if (token == L'<') {
|
||||
match(L'<');
|
||||
return temp < expression();
|
||||
}
|
||||
else if (token == GreatEqual) {
|
||||
match(GreatEqual);
|
||||
return temp >= expression();
|
||||
}
|
||||
else if (token == LessEqual) {
|
||||
match(LessEqual);
|
||||
return temp <= expression();
|
||||
}
|
||||
else if (token == Equal) {
|
||||
match(Equal);
|
||||
return temp == expression();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void skipBoolExpr() {
|
||||
int count = 0;
|
||||
while (token && !(token == L')' && count == 0)) {
|
||||
if (token == L'(') count++;
|
||||
if (token == L')') count--;
|
||||
token = *src++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static int boolAND() {
|
||||
int val = boolexp();
|
||||
while (token == AND) {
|
||||
match(AND);
|
||||
if (val == 0){
|
||||
skipBoolExpr();
|
||||
return 0; // short cut
|
||||
}
|
||||
val = val & boolexp();
|
||||
if (val == 0) return 0;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
|
||||
static int boolOR() {
|
||||
int val = boolAND();
|
||||
while (token == OR) {
|
||||
match(OR);
|
||||
if (val == 1){
|
||||
skipBoolExpr();
|
||||
return 1; // short cut
|
||||
}
|
||||
val = val | boolAND();
|
||||
if (val == 1) return 1;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
|
||||
static void skipStatments() {
|
||||
if(token == L'{')
|
||||
token = *src++;
|
||||
int count = 0;
|
||||
while (token && !(token == L'}' && count == 0)) {
|
||||
if (token == L'}') count++;
|
||||
if (token == L'{') count--;
|
||||
token = *src++;
|
||||
}
|
||||
match(L'}');
|
||||
}
|
||||
|
||||
/* 解析一个句子 */
|
||||
static double statement() {
|
||||
if (token == L'{') {
|
||||
match(L'{');
|
||||
while (token != L'}') {
|
||||
if (RETURNFLAG == statement())
|
||||
return RETURNFLAG;
|
||||
}
|
||||
match(L'}');
|
||||
}
|
||||
else if (token == If) {
|
||||
match(If);
|
||||
match(L'(');
|
||||
int boolresult = boolOR();
|
||||
match(L')');
|
||||
if (boolresult) {
|
||||
if (RETURNFLAG == statement())
|
||||
return RETURNFLAG;
|
||||
}
|
||||
else skipStatments();
|
||||
if (token == Else) {
|
||||
match(Else);
|
||||
if (!boolresult) {
|
||||
if (RETURNFLAG == statement())
|
||||
return RETURNFLAG;
|
||||
}
|
||||
else skipStatments();
|
||||
}
|
||||
}
|
||||
else if (token == While) {
|
||||
match(While);
|
||||
wchar_t* whileStartPos = src;
|
||||
wchar_t* whileStartOldPos = old_src;
|
||||
int boolresult;
|
||||
do {
|
||||
src = whileStartPos;
|
||||
old_src = whileStartOldPos;
|
||||
token = L'('; /* 不加行会报错 */
|
||||
match(L'(');
|
||||
boolresult = boolOR();
|
||||
match(L')');
|
||||
if (boolresult) {
|
||||
if (RETURNFLAG == statement())
|
||||
return RETURNFLAG;
|
||||
}
|
||||
else skipStatments();
|
||||
}while (boolresult);
|
||||
}
|
||||
|
||||
|
||||
/* ---- 给变量赋值 ---- */
|
||||
else if (token == Sym || token == ArraySym) {
|
||||
symbol* s = token_val.ptr;
|
||||
//clearThis(); /* 不知为何需要注释了才能跑 */
|
||||
int tktype = token;
|
||||
int index;
|
||||
match(tktype);
|
||||
if (tktype == ArraySym) {
|
||||
match(L'[');
|
||||
index = expression();
|
||||
match(L']');
|
||||
match(Assign);
|
||||
if (index >= 0 && index < s->value) {
|
||||
s->pointer.list[index].value = expression();
|
||||
}
|
||||
/* TODO: 越界异常 */
|
||||
}
|
||||
else {
|
||||
match(Assign);
|
||||
if (token == Str) {
|
||||
s->pointer.funcp = (wchar_t*)token_val.ptr;
|
||||
s->type = Str;
|
||||
match(Str);
|
||||
}
|
||||
else if (token == Char) {
|
||||
s->value = token_val.val;
|
||||
s->type = Char;
|
||||
match(Char);
|
||||
}
|
||||
else {
|
||||
s->value = expression();
|
||||
s->type = Num;
|
||||
}
|
||||
}
|
||||
match(L';');
|
||||
}
|
||||
else if (token == Array) {
|
||||
match(Array);
|
||||
symbol* s = token_val.ptr;
|
||||
//clearThis();
|
||||
match(Sym);
|
||||
match(L'(');
|
||||
int length = (int)expression();
|
||||
match(L')');
|
||||
s->pointer.list = malloc(sizeof(struct symStruct) * length + 1);
|
||||
for (int i = 0; i < length; ++i)
|
||||
s->pointer.list[i].type = Num;
|
||||
s->value = length;
|
||||
s->type = ArraySym;
|
||||
match(L';');
|
||||
}
|
||||
else if (token == Func) {
|
||||
match(Func);
|
||||
symbol* s = token_val.ptr;
|
||||
//clearThis();
|
||||
s->type = FuncSym;
|
||||
match(Sym);
|
||||
s->pointer.funcp = src;
|
||||
s->value = token;
|
||||
skipStatments();
|
||||
match(L';');
|
||||
}
|
||||
else if (token == Return) {
|
||||
match(Return);
|
||||
match(L'(');
|
||||
return_val = expression();
|
||||
match(L')');
|
||||
match(L';');
|
||||
return RETURNFLAG;
|
||||
}
|
||||
else if (token == Print || token == Read || token == Puts) {
|
||||
int func = token;
|
||||
double temp;
|
||||
match(func);
|
||||
match(L'(');
|
||||
switch (func) {
|
||||
case Print:
|
||||
temp = expression();
|
||||
wchar_t buf[16];
|
||||
swprintf(buf, 16, L"%lf\n", temp);
|
||||
printtovga(buf);
|
||||
break;
|
||||
case Puts:
|
||||
/* 这一句切换不过来 */
|
||||
//wprintf(L"%ls\n", (wchar_t*)token_val.ptr);
|
||||
printtovga((wchar_t*)token_val.ptr);
|
||||
match(Str);
|
||||
break;
|
||||
case Read: /* 单片机上等下支持 */
|
||||
//wscanf(L"%lf", &token_val.ptr->value);
|
||||
token_val.ptr->value = -1;
|
||||
token_val.ptr->type = Num;
|
||||
match(Sym);
|
||||
}
|
||||
match(L')');
|
||||
match(L';');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
static double function() {
|
||||
currentlevel++;
|
||||
return_val = 0;
|
||||
|
||||
symbol* s = token_val.ptr;
|
||||
match(FuncSym);
|
||||
match(L'(');
|
||||
while (token != L')') {
|
||||
symtab[symPointer] = *token_val.ptr;
|
||||
wcscpy(symtab[symPointer].name, token_val.ptr->name);
|
||||
symtab[symPointer].levelNum = currentlevel;
|
||||
symPointer++;
|
||||
match(Sym);
|
||||
if (token == L',')
|
||||
match(L',');
|
||||
}
|
||||
match(L')');
|
||||
wchar_t* startPos = src;
|
||||
wchar_t* startOldPos = old_src;
|
||||
int startToken = token;
|
||||
old_src = src = s->pointer.funcp;
|
||||
token = (int)s->value;
|
||||
statement();
|
||||
src = startPos;
|
||||
old_src = startOldPos;
|
||||
token = startToken;
|
||||
|
||||
while (symtab[symPointer - 1].levelNum == currentlevel) {
|
||||
symPointer--;
|
||||
}
|
||||
currentlevel--;
|
||||
return return_val;
|
||||
}
|
||||
/*----------------------------------------------------------------*/
|
||||
int init_tryc() {
|
||||
|
||||
/* 将所有字段赋值为0 */
|
||||
memset(symtab, 0, sizeof(symtab));
|
||||
|
||||
int i; /* 用于阅读定位 */
|
||||
src = L"array func else if return while print puts read";
|
||||
for (i = Array; i <= Read; ++i) {
|
||||
next();
|
||||
symtab[symPointer -1].type = i;
|
||||
}
|
||||
|
||||
if (!(src = old_src = p_src = (wchar_t*)malloc(POOLSIZE))) {
|
||||
//wprintf(L"could not malloc(%d) for source area\n", POOLSIZE);
|
||||
printtovga(L"could not malloc for source aream\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
top_src = 0;
|
||||
src[0] = L'\0'; // add EOF character
|
||||
}
|
||||
|
||||
void free_tryc() {
|
||||
|
||||
free(p_src);
|
||||
}
|
||||
|
||||
int enqueue_tryc(wchar_t wc) {
|
||||
|
||||
if (top_src >= POOLSIZE - 1) {
|
||||
return -1;
|
||||
}
|
||||
src[top_src] = wc;
|
||||
src[++top_src] = L'\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
void eval_tryc() {
|
||||
|
||||
if (*src == L'\0') return;
|
||||
|
||||
next();
|
||||
while (token != L'\0') {
|
||||
statement();
|
||||
}
|
||||
src = p_src;
|
||||
|
||||
top_src = 0;
|
||||
src[0]= L'\0';
|
||||
|
||||
}
|
||||
|
||||
void __exit() {
|
||||
while (1);
|
||||
}
|
||||
|
||||
#ifdef _DEBUG
|
||||
#include "fcntl.h"
|
||||
#include "unistd.h"
|
||||
#include "locale.h"
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
|
||||
init_tryc();
|
||||
|
||||
|
||||
int i;
|
||||
setlocale(LC_ALL, "");
|
||||
wchar_t wc;
|
||||
i = 0;
|
||||
FILE *file = fopen(argv[1], "r");
|
||||
if (!file) {
|
||||
perror("fopen");
|
||||
return 1;
|
||||
}
|
||||
|
||||
while ((wc = fgetwc(file)) != WEOF) {
|
||||
src[i++] = wc;
|
||||
}
|
||||
|
||||
|
||||
src[i] = L'\0'; // add EOF character
|
||||
fclose(file);
|
||||
|
||||
eval_tryc();
|
||||
|
||||
clear(0);
|
||||
free_tryc();
|
||||
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
# pymsg
|
||||
|
||||
基于长度前缀的可变长 socket 消息帧协议,Python 实现。
|
||||
零依赖、极简的 TCP 消息层 —— 没有 HTTP,没有 protobuf,只有 `[4字节头][UTF-8载荷]`。
|
||||
|
||||
## 协议
|
||||
|
||||
```
|
||||
┌────────────────────────┬──────────────────────────┐
|
||||
│ 4 字节(大端序) │ N 字节(UTF-8) │
|
||||
│ uint32 载荷字节长度 │ JSON / 文本载荷 │
|
||||
└────────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
消息头为 4 字节大端无符号整数,表示后续 UTF-8 载荷的字节数。接收方先读 4 字节解出长度,再精确读取对应字节数,即完成一条消息的接收。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd pymsg
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
或在其他项目中作为本地依赖引用:
|
||||
|
||||
```
|
||||
# requirements.txt
|
||||
../pymsg
|
||||
```
|
||||
|
||||
要求 Python 3.9+,无外部依赖。
|
||||
|
||||
## 快速上手
|
||||
|
||||
```python
|
||||
import socket
|
||||
from pymsg import send, recv
|
||||
|
||||
# --- 发送方 ---
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.connect(("127.0.0.1", 8005))
|
||||
send(sock, '{"action": "ping"}')
|
||||
resp = recv(sock, timeout=5.0)
|
||||
print(resp) # {"success": true, "data": "pong"}
|
||||
sock.close()
|
||||
|
||||
# --- 接收方 ---
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 8005))
|
||||
server.listen(1)
|
||||
conn, addr = server.accept()
|
||||
msg = recv(conn)
|
||||
send(conn, '{"success": true, "data": "pong"}')
|
||||
conn.close()
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### 底层函数
|
||||
|
||||
**`send(sock, data, *, timeout=None)`**
|
||||
|
||||
在已连接的 socket 上发送一条长度前缀帧消息。将字符串编码为 UTF-8,前面加上 4 字节大端长度头,调用 `sock.sendall()` 发送。
|
||||
|
||||
若指定 `timeout`,会临时调整 socket 超时,超时后抛出 `TimeoutError`,原始超时设置会被恢复。
|
||||
|
||||
**`recv(sock, *, timeout=None)`**
|
||||
|
||||
从已连接的 socket 接收一条长度前缀帧消息。读取 4 字节头解出长度,再读取指定字节数,返回解码后的 UTF-8 字符串。
|
||||
|
||||
若连接在完整消息接收前断开,抛出 `ConnectionError`。若指定了 `timeout` 且超时,抛出 `TimeoutError`。
|
||||
|
||||
**`TimeoutError`**
|
||||
|
||||
`send` 或 `recv` 操作超过指定超时时间时抛出的异常,继承自 `Exception`。
|
||||
|
||||
### `MsgClient` — TCP 客户端
|
||||
|
||||
封装了 socket 创建、连接和三种通信模式。
|
||||
|
||||
```python
|
||||
from pymsg import MsgClient
|
||||
|
||||
client = MsgClient("127.0.0.1", 8005, "my-service")
|
||||
```
|
||||
|
||||
| 参数 | 含义 |
|
||||
|------|------|
|
||||
| `addr` | 服务端地址或 IP |
|
||||
| `port` | 服务端端口 |
|
||||
| `name` | 客户端标识,在 `send()` 模式下会以 `"name: message"` 格式发送 |
|
||||
|
||||
三种使用模式:
|
||||
|
||||
**1. 发送即忘(短连接)**
|
||||
|
||||
```python
|
||||
client.send("hello")
|
||||
```
|
||||
|
||||
每次调用新建 TCP 连接,发送带 name 前缀的消息 `"my-service: hello"` 后立即关闭,不等待回复。适用于通知、日志推送等无需响应的场景。
|
||||
|
||||
**2. 请求-应答 / RPC(短连接)**
|
||||
|
||||
```python
|
||||
resp = client.call('{"action": "select", "table": "Users"}', timeout=10.0)
|
||||
```
|
||||
|
||||
新建连接,发送消息(不带 name 前缀),阻塞等待一条回复后关闭。这是 RPC 风格服务调用的主要模式。默认超时 10 秒。
|
||||
|
||||
目标不可达时抛出 `ConnectionRefusedError`,超时无回复时抛出 `TimeoutError`。
|
||||
|
||||
**3. 长连接监听(回调模式)**
|
||||
|
||||
```python
|
||||
def on_msg(text):
|
||||
print(f"收到: {text}")
|
||||
|
||||
client.connect() # 建立持久 TCP 连接
|
||||
client.listen(on_msg) # 阻塞当前线程,每收到一条消息回调一次
|
||||
```
|
||||
|
||||
先调用 `connect()` 建立长连接,再调用 `listen()` 阻塞当前线程,每收到一条消息即回调 `on_message(text)`。从另一线程调用 `client.stop()` 可退出监听循环。
|
||||
|
||||
### `MsgServer` — TCP 服务端
|
||||
|
||||
基于 pymsg 协议的多客户端 TCP 服务端。接受连接,接收帧消息,分发到用户提供的回调函数。
|
||||
|
||||
```python
|
||||
from pymsg import MsgServer
|
||||
|
||||
def on_message(sock, addr, text):
|
||||
print(f"[{addr}] {text}")
|
||||
server.reply(sock, "ack")
|
||||
|
||||
server = MsgServer("0.0.0.0", 8005)
|
||||
server.start(on_message) # 阻塞当前线程
|
||||
```
|
||||
|
||||
| 参数 | 含义 |
|
||||
|------|------|
|
||||
| `addr` | 监听地址 |
|
||||
| `port` | 监听端口 |
|
||||
|
||||
回调签名为 `on_message(sock, addr, text)`:
|
||||
- `sock` — 客户端 socket,可用 `reply()` 回复
|
||||
- `addr` — `(ip, port)` 元组
|
||||
- `text` — 解码后的 UTF-8 字符串消息
|
||||
|
||||
**主要方法:**
|
||||
|
||||
`start(on_message)` — 绑定端口、开始监听,进入 accept/分发循环,阻塞至 `stop()` 被调用。
|
||||
|
||||
`reply(sock, msg)` — 向指定客户端 socket 发送一条帧格式的回复。典型用法:服务端收到请求、处理后调用 `reply(sock, json_response)` 返回结果。
|
||||
|
||||
`broadcast(msg, except_sock=None)` — 向所有已连接客户端广播消息,可排除某个 socket(通常是发送者自身)。
|
||||
|
||||
`stop()` — 设置停止标志并关闭监听 socket,可从另一线程安全调用。
|
||||
|
||||
服务端内部管理客户端列表,透明处理断连和管道破裂,使用 `select` 实现非阻塞 I/O,轮询间隔 100ms。
|
||||
|
||||
## 线程安全
|
||||
|
||||
- `MsgServer.stop()` 可从任意线程安全调用:设置原子标志并关闭监听 socket,`select` 循环会干净退出。
|
||||
- `MsgServer.broadcast()` 和 `reply()` 在 `on_message` 回调内(同一线程)调用是安全的。服务端运行期间从其他线程并发调用不支持。
|
||||
- `MsgClient.stop()` 可从另一线程调用以中断 `listen()`。
|
||||
- `send()` 和 `recv()` 内部不做同步 —— 如果多线程共享同一 socket,调用方需自行串行化。
|
||||
|
||||
## 实际用例
|
||||
|
||||
### 服务端 — 数据库服务(dbv2)
|
||||
|
||||
db 服务通过 pymsg 暴露通用 CRUD 接口。每条请求是带 `_route` 字段的 JSON 对象,服务端按路由分发到对应处理函数,通过同一 socket 回复结果。
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgServer
|
||||
|
||||
class DBServer:
|
||||
def __init__(self):
|
||||
self._server = MsgServer("127.0.0.1", 8005)
|
||||
self._routes = {"execute": self._handle_execute}
|
||||
|
||||
def start(self):
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def _dispatch(self, sock, addr, text):
|
||||
req = json.loads(text)
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": False, "error": "未知路由"}))
|
||||
return
|
||||
try:
|
||||
result = handler(req)
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": True, "data": result}))
|
||||
except Exception as e:
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": False, "error": str(e)}))
|
||||
|
||||
def _handle_execute(self, data):
|
||||
# ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表
|
||||
pass
|
||||
```
|
||||
|
||||
### 客户端 — RPC 调用(dnfs → db)
|
||||
|
||||
dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 pymsg 调用 db。
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
|
||||
def _call_dbv2(payload):
|
||||
client = MsgClient("127.0.0.1", 8005, "dnfs")
|
||||
resp = client.call(json.dumps(payload), timeout=10.0)
|
||||
return json.loads(resp)
|
||||
|
||||
# 创建笔记
|
||||
result = _call_dbv2({
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Notes",
|
||||
"values": {"title": "会议记录", "content": "..."}
|
||||
})
|
||||
```
|
||||
|
||||
### 客户端 — 异步日志推送(log_client)
|
||||
|
||||
各服务通过 pymsg 将日志异步推送到 db。使用 `MsgClient.call()` 逐批插入。
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
|
||||
def _send(batch):
|
||||
client = MsgClient("127.0.0.1", 8005, "gateway-log")
|
||||
for entry in batch:
|
||||
client.call(json.dumps({
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now, "service": entry["service"],
|
||||
"level": entry["level"], "message": entry["message"],
|
||||
}
|
||||
}), timeout=5)
|
||||
```
|
||||
|
||||
## 跨语言互操作
|
||||
|
||||
pymsg 的线格式有意设计得极其简单,使其他语言的服务也能直接参与通信。项目中已有的实现:
|
||||
|
||||
- **C++** — `gateway/inc/Msg.hpp`(`pmc::net::MsgClient`)
|
||||
完整复刻:`pmsg_send()`、`pmsg_recv()`,以及 `MsgClient` 类(`connect` / `send` / `call` / `listen` / `stop`)。
|
||||
|
||||
- **Go** — `waf/internal/logclient/logclient.go`
|
||||
原生 `pymsgCall()` 函数:写 4 字节大端头 + 载荷,读取回复。
|
||||
|
||||
- **Python(纯 stdlib)** — `tests/blackbox/helpers.py`(`PymsgClient`)
|
||||
仅使用 `socket` 和 `struct` 的纯 Python 实现,用于黑盒测试,不依赖 pymsg 包本身。
|
||||
|
||||
从头实现该协议只需四步:
|
||||
1. 将字符串编码为 UTF-8 字节
|
||||
2. 将字节长度打包为 4 字节大端无符号整数
|
||||
3. 写入 头 + 载荷 到 socket
|
||||
4. 接收端先读 4 字节解出长度,再读对应字节数,解码为 UTF-8
|
||||
|
||||
## 错误处理速查
|
||||
|
||||
| 异常 | 触发条件 |
|
||||
|------|----------|
|
||||
| `pymsg.TimeoutError` | `send()` 或 `recv()` 超过 `timeout` 参数设定的时间 |
|
||||
| `ConnectionError`(内置) | 完整消息接收前 socket 断开 |
|
||||
| `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 |
|
||||
| `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 |
|
||||
|
||||
`MsgServer` 内部会吞掉客户端断连错误(移除该客户端,继续服务)。`broadcast()` 静默跳过已断开的 socket。
|
||||
|
||||
## 设计考量
|
||||
|
||||
为什么用长度前缀帧而不是换行分隔或 HTTP?
|
||||
|
||||
- **二进制安全。** JSON 载荷中可能包含换行符,长度前缀帧无需转义。
|
||||
- **无需流式解析器。** 接收方读完 4 字节头后就知道还要读多少字节 —— 没有缓冲、没有行分割。
|
||||
- **跨语言实现极其简单。** 整个协议用 C、Go 或 Rust 大约 20 行代码即可实现。
|
||||
- **适用于任何流式传输。** TCP、Unix socket、管道、TLS 包裹的连接 —— 任何提供有序字节传输的通道都可以。
|
||||
@@ -1,8 +1,2 @@
|
||||
../pymsg
|
||||
nuitka
|
||||
flask
|
||||
flask-cors
|
||||
flask_sqlalchemy
|
||||
#gunicorn
|
||||
waitress
|
||||
python-dotenv
|
||||
+225
-64
@@ -1,84 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from email.mime.text import MIMEText
|
||||
from email import header
|
||||
from log_client import logs
|
||||
from dotenv import load_dotenv
|
||||
import waitress
|
||||
import smtplib
|
||||
替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
|
||||
协议:每条消息为完整 JSON,``_route`` 字段指定路由名。
|
||||
|
||||
请求示例::
|
||||
|
||||
{"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码
|
||||
{"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费)
|
||||
{"_route": "health"} → 健康检查
|
||||
|
||||
响应格式(与 dbv2 / captcha 一致)::
|
||||
|
||||
{"success": true, "data": {...}}
|
||||
{"success": false, "error": "..."}
|
||||
|
||||
SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
|
||||
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
import threading
|
||||
import time
|
||||
import sys, os
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
# 手动加载 .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}")
|
||||
|
||||
app = Flask(__name__)
|
||||
captchas: dict[str, list[str, str]] = {}
|
||||
from log_client import logs
|
||||
|
||||
@app.route("/send_email", methods=["POST"])
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
|
||||
def send_email():
|
||||
def build_captcha() -> str:
|
||||
captcha: str = ""
|
||||
for i in range(0,6):
|
||||
digit: int = random.randint(0,9)
|
||||
captcha += str(digit)
|
||||
return captcha
|
||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
data = request.get_json()
|
||||
SMTP_SERVER = os.environ.get("SMTP_SERVER", "")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
|
||||
SMTP_USER = os.environ.get("SMTP_USER", "")
|
||||
# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名
|
||||
SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "")
|
||||
|
||||
recipient: str = data.get("email")
|
||||
subject: str = "DNFS 邮箱验证"
|
||||
captcha: str = build_captcha()
|
||||
content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。"
|
||||
CODE_LEN = 6
|
||||
CODE_TTL = 60 # 验证码有效期(秒)
|
||||
MAIL_FROM = "dnfs@qsont.me"
|
||||
DEFAULT_SUBJECT = "DNFS 邮箱验证"
|
||||
DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。"
|
||||
|
||||
captchas[recipient] = ["", ""]
|
||||
captchas[recipient][0] = captcha
|
||||
captchas[recipient][1] = str(int(time.time())+60)
|
||||
|
||||
message = MIMEText(content, "plain", "UTF-8")
|
||||
message["From"] = "dnfs@qsont.me"
|
||||
message["To"] = recipient
|
||||
message["Subject"] = subject
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
|
||||
server.login(SMTP_USER, __PASSWORD)
|
||||
server.sendmail(SMTP_USER, [recipient], message.as_string())
|
||||
logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})")
|
||||
return jsonify({"success": "验证码发送成功"}), 200
|
||||
|
||||
except Exception as err:
|
||||
logs.error("smtp", f"{recipient}: 验证码发送失败({err})")
|
||||
return jsonify({"error": f"验证码发送失败:{err}"}), 400
|
||||
|
||||
finally:
|
||||
server.quit()
|
||||
code_store = {} # email -> {'code': str, 'expires_at': float}
|
||||
STORE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@app.route("/verify_email", methods=["POST"])
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SmtpServer — 主服务类
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def verify_email():
|
||||
data = request.get_json()
|
||||
class SmtpServer:
|
||||
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。"""
|
||||
|
||||
for i in list(captchas.keys()):
|
||||
if int(captchas[i][1]) < int(time.time()):
|
||||
del captchas[i]
|
||||
def __init__(self, addr="127.0.0.1", port=8008):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._server = MsgServer(addr, port)
|
||||
|
||||
email: str = data.get("email")
|
||||
input_captcha: str = data.get("input_captcha")
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"send_email": self._handle_send_email,
|
||||
"verify_email": self._handle_verify_email,
|
||||
"health": self._handle_health,
|
||||
}
|
||||
|
||||
if captchas[email][0] == input_captcha:
|
||||
logs.info("smtp", f"{email}: 验证通过")
|
||||
return jsonify({"success": "验证通过"}), 200
|
||||
else:
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
|
||||
# 只报缺失的 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 jsonify({"error": "验证失败"}), 400
|
||||
return {"valid": False, "error": "invalid or expired code"}
|
||||
|
||||
def _handle_health(self, _req):
|
||||
return {"status": "ok", "service": "smtp"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 入口
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
logs.info("smtp", "开始监听http://127.0.0.1:8008")
|
||||
print("Listen at http://127.0.0.1:8008")
|
||||
waitress.serve(app, host="127.0.0.1", port=8008)
|
||||
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()
|
||||
+4
-1
@@ -582,6 +582,9 @@ if __name__ == '__main__':
|
||||
print("\n")
|
||||
|
||||
if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
|
||||
# Waitress 不支持 TLS,SSL 场景回退到 Flask 内置服务器
|
||||
logger.warning("SSL enabled: falling back to Flask built-in server (Waitress has no TLS support)")
|
||||
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
|
||||
else:
|
||||
app.run(host=host, port=port, debug=False)
|
||||
from waitress import serve
|
||||
serve(app, host=host, port=port, threads=8)
|
||||
+142
-1
@@ -212,7 +212,27 @@ input[type="button"]:disabled {
|
||||
#article-content { font-size: 0.95rem; line-height: 1.7; margin-bottom: 1.25rem; }
|
||||
|
||||
/* ===== Recommend List (dnfs) ===== */
|
||||
.recommend-list .card { margin-bottom: 0.75rem; width: 100%; max-width: none; }
|
||||
/* 卡片尺寸随内容自适应:宽度包裹内容(fit-content),上限为容器宽度;高度天然随内容伸缩 */
|
||||
.recommend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
.recommend-list br { display: none; } /* JS 追加的 <br> 分隔符,改由 flex gap 统一控制间距 */
|
||||
.recommend-list .card {
|
||||
width: fit-content;
|
||||
min-width: min(240px, 100%);
|
||||
max-width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* 防止内容中的大图撑破自适应宽度 */
|
||||
.recommend-list .card img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.recommend-list center {
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
@@ -224,6 +244,127 @@ input[type="button"]:disabled {
|
||||
.recommend-list .card h3 { transition: color 0.15s; }
|
||||
.recommend-list .card p { font-size: 0.9rem; line-height: 1.6; color: var(--fg-secondary); }
|
||||
|
||||
/* ===== Owner Chip (dnfs 发帖用户) ===== */
|
||||
.card-owner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.owner-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
border: 1px solid var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
.owner-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Card Stats (dnfs 互动统计:观看/点赞/分享) ===== */
|
||||
.card-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.1rem;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
.stat-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.stat-item svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stat-num {
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ===== Card Actions (dnfs 编辑/删除按钮) ===== */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.act-btn svg { width: 12px; height: 12px; flex-shrink: 0; }
|
||||
.act-btn:hover { border-color: var(--accent); }
|
||||
.act-del { background: var(--error-bg); color: var(--error); }
|
||||
.act-del:hover { border-color: var(--error); }
|
||||
.act-move { background: transparent; color: var(--fg-secondary); border-color: var(--border); }
|
||||
.act-move:hover { border-color: var(--accent); color: var(--accent-dark); }
|
||||
|
||||
/* ===== Move Bar (dnfs 节点移动条) ===== */
|
||||
.movebar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1.25rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 110;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(253, 246, 238, 0.85);
|
||||
border: 1px solid var(--accent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 0.82rem;
|
||||
max-width: 92vw;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.movebar { background: rgba(30, 23, 18, 0.85); }
|
||||
}
|
||||
.movebar:empty { display: none; }
|
||||
.move-title {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== Minimap (dnfs) ===== */
|
||||
.minimap {
|
||||
position: fixed;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<body>
|
||||
<div class="box">
|
||||
<div id="minimap" class="minimap"></div>
|
||||
<div id="movebar" class="movebar"></div>
|
||||
<div class="recommend-list" id="recommend-list"></div>
|
||||
<div class="card node-form">
|
||||
<h3>节点操作</h3>
|
||||
|
||||
+323
-10
@@ -168,6 +168,235 @@ async function deleteNode(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/* by qing — 卡片上的"删除":删除子节点后停留在当前层,仅刷新列表(不弹栈) */
|
||||
async function deleteChild(id) {
|
||||
if (confirm("确认删除此节点?") == false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
obj = {"id": id};
|
||||
jsonBody = JSON.stringify(obj);
|
||||
|
||||
const res = await fetch('/delete_note', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Target-Url': 'http://127.0.0.1:8006'
|
||||
},
|
||||
body: jsonBody
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch {
|
||||
console.error(`删除笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
|
||||
throw new Error('服务器响应格式异常,请稍后重试');
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// 凭证失效(过期或网关重启),跳转重新登录
|
||||
location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(result.error === "It's not your note" ? '只能删除自己的节点' : (result.error || `删除节点失败(HTTP ${res.status})`));
|
||||
}
|
||||
|
||||
// 若删掉的正是编辑中的节点,顺带退出编辑模式
|
||||
if (editing_id == id) {
|
||||
cancelEdit();
|
||||
input_note_title.value = "";
|
||||
input_note_content.value = "";
|
||||
}
|
||||
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||
getNode(cur).then(load_notes);
|
||||
getNode(cur).then(loadMap);
|
||||
|
||||
console.log(`删除节点成功`);
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
alert(err.message || '接口请求失败,请检查后端服务是否启动');
|
||||
console.error('删除节点请求异常:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/* by qing — 保存编辑:调用 /update_note,成功后刷新当前层并退出编辑模式 */
|
||||
async function updateNode(id, pid, title, content) {
|
||||
try {
|
||||
obj = {
|
||||
"id": Number(id),
|
||||
"pid": pid,
|
||||
"title": title,
|
||||
"content": content
|
||||
}
|
||||
jsonBody = JSON.stringify(obj)
|
||||
const res = await fetch('/update_note', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Target-Url': 'http://127.0.0.1:8006'
|
||||
},
|
||||
body: jsonBody
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch {
|
||||
console.error(`更新笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
|
||||
throw new Error('服务器响应格式异常,请稍后重试');
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// 凭证失效(过期或网关重启),跳转重新登录
|
||||
location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(result.error === "It's not your note" ? '只能编辑自己的节点' : (result.error || `更新节点失败(HTTP ${res.status})`));
|
||||
}
|
||||
|
||||
input_note_title.value = "";
|
||||
input_note_content.value = "";
|
||||
cancelEdit();
|
||||
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||
getNode(cur).then(load_notes);
|
||||
getNode(cur).then(loadMap);
|
||||
|
||||
alert('修改成功');
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
alert(err.message || '接口请求失败,请检查后端服务是否启动');
|
||||
console.error('更新节点请求异常:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/* by qing — 节点移动:本质是 pid 变更的 update_note,title/content 原样携带 */
|
||||
async function moveNode(id, pid, title, content) {
|
||||
try {
|
||||
obj = {
|
||||
"id": Number(id),
|
||||
"pid": pid,
|
||||
"title": title,
|
||||
"content": content
|
||||
}
|
||||
jsonBody = JSON.stringify(obj)
|
||||
const res = await fetch('/update_note', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Target-Url': 'http://127.0.0.1:8006'
|
||||
},
|
||||
body: jsonBody
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch {
|
||||
console.error(`更新笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
|
||||
throw new Error('服务器响应格式异常,请稍后重试');
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// 凭证失效(过期或网关重启),跳转重新登录
|
||||
location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(result.error === "It's not your note" ? '只能移动自己的节点' : (result.error || `移动节点失败(HTTP ${res.status})`));
|
||||
}
|
||||
|
||||
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||
getNode(cur).then(load_notes);
|
||||
getNode(cur).then(loadMap);
|
||||
|
||||
alert('移动成功');
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
alert(err.message || '接口请求失败,请检查后端服务是否启动');
|
||||
console.error('移动节点请求异常:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/* by qing — 点击卡片"移动":暂存节点信息,底部弹出移动条 */
|
||||
function startMove(item) {
|
||||
moving_node = { id: Number(item.id), pid: item.pid, title: item.title, content: item.content };
|
||||
renderMoveBar();
|
||||
}
|
||||
|
||||
/* by qing — 渲染底部移动条:显示待移动节点,提供"移动到当前层"和"取消" */
|
||||
function renderMoveBar() {
|
||||
movebar.replaceChildren();
|
||||
if (moving_node == null) {
|
||||
return;
|
||||
}
|
||||
let tip = document.createElement('span');
|
||||
tip.className = "move-title";
|
||||
tip.textContent = "移动中:" + moving_node.title;
|
||||
let ok_btn = document.createElement('button');
|
||||
ok_btn.className = "act-btn act-edit";
|
||||
ok_btn.textContent = "移动到当前层";
|
||||
ok_btn.addEventListener('click', confirmMove);
|
||||
let cancel_btn = document.createElement('button');
|
||||
cancel_btn.className = "act-btn act-del";
|
||||
cancel_btn.textContent = "取消";
|
||||
cancel_btn.addEventListener('click', function() {
|
||||
moving_node = null;
|
||||
renderMoveBar();
|
||||
});
|
||||
movebar.appendChild(tip);
|
||||
movebar.appendChild(ok_btn);
|
||||
movebar.appendChild(cancel_btn);
|
||||
}
|
||||
|
||||
/* by qing — 执行移动:把待移动节点挂到当前层(stack 顶,根为 0)。
|
||||
* 后端 update_note 会走 AI 内容审核,耗时较长,期间禁用移动条与表单按钮防止重复提交 */
|
||||
async function confirmMove() {
|
||||
if (moving_node == null) {
|
||||
return;
|
||||
}
|
||||
var target_pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||
if (target_pid == moving_node.id) {
|
||||
alert('不能移动到节点自身内部');
|
||||
return;
|
||||
}
|
||||
if (target_pid == moving_node.pid) {
|
||||
alert('该节点已在当前层');
|
||||
return;
|
||||
}
|
||||
// 防重复点击:请求进行中禁用按钮(成功/失败都在 finally 恢复)
|
||||
movebar.querySelectorAll('button').forEach(function(b) { b.disabled = true; });
|
||||
input_note_submit.disabled = true;
|
||||
input_note_clear.disabled = true;
|
||||
delete_note.disabled = true;
|
||||
try {
|
||||
var result = await moveNode(moving_node.id, target_pid, moving_node.title, moving_node.content);
|
||||
if (result != null) {
|
||||
moving_node = null;
|
||||
renderMoveBar();
|
||||
}
|
||||
} finally {
|
||||
movebar.querySelectorAll('button').forEach(function(b) { b.disabled = false; });
|
||||
input_note_submit.disabled = false;
|
||||
input_note_clear.disabled = false;
|
||||
delete_note.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* by shufeng2012 */
|
||||
function renderMarkdown(element, content) {
|
||||
/*
|
||||
@@ -177,6 +406,26 @@ function renderMarkdown(element, content) {
|
||||
element.innerHTML = md.render(content, {html: false});
|
||||
}
|
||||
|
||||
/* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */
|
||||
const ICON_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
const ICON_HEART = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
|
||||
const ICON_SHARE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>';
|
||||
const ICON_EDIT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
|
||||
const ICON_TRASH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
const ICON_MOVE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
|
||||
|
||||
/* by qing — 生成一个统计项:图标 + 数字,count 缺省显示 0 */
|
||||
function makeStatItem(iconSvg, count) {
|
||||
let span = document.createElement('span');
|
||||
span.className = "stat-item";
|
||||
span.innerHTML = iconSvg; // 上方固定常量字符串,不含用户输入
|
||||
let num = document.createElement('span');
|
||||
num.className = "stat-num";
|
||||
num.textContent = (count == null) ? "0" : String(count);
|
||||
span.appendChild(num);
|
||||
return span;
|
||||
}
|
||||
|
||||
/* ---- by qing ---- */
|
||||
const stack = []; /* 用于存储当前节点的路径,也就是经过的每一个节点的id, 否则就回不去上一层*/
|
||||
var article_title = document.getElementById('article-title');
|
||||
@@ -188,7 +437,10 @@ var input_note_clear = document.getElementById('input-note-clear');
|
||||
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;
|
||||
var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */
|
||||
var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */
|
||||
|
||||
input_note_submit.addEventListener('click', function(event) {
|
||||
if (input_note_submit.disabled) return; // 防重复点击
|
||||
@@ -201,7 +453,9 @@ input_note_submit.addEventListener('click', function(event) {
|
||||
input_note_submit.disabled = true;
|
||||
input_note_clear.disabled = true;
|
||||
delete_note.disabled = true;
|
||||
addNode(pid, title, content)
|
||||
// 编辑模式下保存修改,否则新增节点
|
||||
var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content);
|
||||
Promise.resolve(req)
|
||||
.finally(() => {
|
||||
input_note_submit.disabled = false;
|
||||
input_note_clear.disabled = false;
|
||||
@@ -212,6 +466,7 @@ input_note_submit.addEventListener('click', function(event) {
|
||||
input_note_clear.addEventListener('click', function(event) {
|
||||
input_note_title.value = "";
|
||||
input_note_content.value = "";
|
||||
cancelEdit(); // 重置时同时退出编辑模式
|
||||
})
|
||||
|
||||
delete_note.addEventListener('click', (event) => {
|
||||
@@ -229,6 +484,22 @@ delete_note.addEventListener('click', (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式 */
|
||||
function startEdit(item) {
|
||||
editing_id = item.id;
|
||||
input_note_title.value = item.title;
|
||||
input_note_content.value = item.content;
|
||||
input_note_submit.textContent = "保存修改";
|
||||
input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
input_note_title.focus();
|
||||
}
|
||||
|
||||
/* by qing — 退出编辑模式,表单恢复为新增状态 */
|
||||
function cancelEdit() {
|
||||
editing_id = null;
|
||||
input_note_submit.textContent = "提交";
|
||||
}
|
||||
|
||||
function load_notes(main_node) {
|
||||
|
||||
/* 这个不能设置为空的 */
|
||||
@@ -239,6 +510,7 @@ function load_notes(main_node) {
|
||||
if (confirm("是否放弃当前输入的内容?") == true) {
|
||||
input_note_title.value = "";
|
||||
input_note_content.value = "";
|
||||
cancelEdit();
|
||||
} else {
|
||||
if (stack.length != 0) {
|
||||
return;
|
||||
@@ -247,12 +519,14 @@ function load_notes(main_node) {
|
||||
} else {
|
||||
input_note_title.value = "";
|
||||
input_note_content.value = "";
|
||||
cancelEdit();
|
||||
}
|
||||
|
||||
|
||||
recommend_list.replaceChildren();
|
||||
for (let i in main_node) {
|
||||
let item = main_node[i]
|
||||
console.log(item)
|
||||
let card = document.createElement('div');
|
||||
card.className = "card";
|
||||
card.addEventListener('click', function(event) {
|
||||
@@ -267,18 +541,57 @@ function load_notes(main_node) {
|
||||
card.id = item.title;
|
||||
ttl.appendChild(ttl1);
|
||||
card.appendChild(ttl);
|
||||
let vit_div = document.createElement('p');
|
||||
let lke_div = document.createElement('p');
|
||||
let srd_div = document.createElement('p');
|
||||
vit_div.textContent = "观看数:" + item.visit_count;
|
||||
lke_div.textContent = "点赞数:" + item.like_count;
|
||||
srd_div.textContent = "分享数:" + item.share_count;
|
||||
ttl.appendChild(vit_div);
|
||||
ttl.appendChild(lke_div);
|
||||
ttl.appendChild(srd_div);
|
||||
let wrt_div = document.createElement('p');
|
||||
// 发帖用户:头像圆点(取昵称首字符)+ 昵称,样式见 theme.css 的 Owner Chip 段
|
||||
wrt_div.className = "card-owner";
|
||||
let owner_text = (item.owner == null || item.owner === "") ? "匿名" : String(item.owner);
|
||||
let owner_avatar = document.createElement('span');
|
||||
owner_avatar.className = "owner-avatar";
|
||||
owner_avatar.textContent = owner_text.trim().charAt(0).toUpperCase() || "?";
|
||||
let owner_name = document.createElement('span');
|
||||
owner_name.className = "owner-name";
|
||||
owner_name.textContent = owner_text;
|
||||
wrt_div.appendChild(owner_avatar);
|
||||
wrt_div.appendChild(owner_name);
|
||||
// 互动统计:眼睛=观看、爱心=点赞、转发=分享(后端 Notes 表暂无计数字段,缺省显示 0)
|
||||
let sts_div = document.createElement('div');
|
||||
sts_div.className = "card-stats";
|
||||
sts_div.appendChild(makeStatItem(ICON_EYE, item.visit_count));
|
||||
sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count));
|
||||
sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count));
|
||||
// 卡片操作按钮:编辑 / 删除(stopPropagation 防止触发卡片自身的点入跳转)
|
||||
let act_div = document.createElement('div');
|
||||
act_div.className = "card-actions";
|
||||
let edit_btn = document.createElement('button');
|
||||
edit_btn.className = "act-btn act-edit";
|
||||
edit_btn.innerHTML = ICON_EDIT + "编辑";
|
||||
edit_btn.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
startEdit(item);
|
||||
});
|
||||
let move_btn = document.createElement('button');
|
||||
move_btn.className = "act-btn act-move";
|
||||
move_btn.innerHTML = ICON_MOVE + "移动";
|
||||
move_btn.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
startMove(item);
|
||||
});
|
||||
let del_btn = document.createElement('button');
|
||||
del_btn.className = "act-btn act-del";
|
||||
del_btn.innerHTML = ICON_TRASH + "删除";
|
||||
del_btn.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
deleteChild(item.id);
|
||||
});
|
||||
act_div.appendChild(edit_btn);
|
||||
act_div.appendChild(move_btn);
|
||||
act_div.appendChild(del_btn);
|
||||
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'))
|
||||
}
|
||||
|
||||
@@ -9,12 +9,10 @@ const pwdTip = document.getElementById('pwdTip');
|
||||
const codeTip = document.getElementById('codeTip');
|
||||
const submitButton = document.getElementById('formsub');
|
||||
|
||||
let sendCode = '';
|
||||
let codeTarget = '';
|
||||
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
|
||||
|
||||
form.addEventListener('reset', function () {
|
||||
clearTipStyle();
|
||||
sendCode = '';
|
||||
codeTarget = '';
|
||||
});
|
||||
|
||||
@@ -64,7 +62,7 @@ function checkCode() {
|
||||
codeTip.innerHTML = '';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
|
||||
if (!sendCode) {
|
||||
if (!codeTarget) {
|
||||
codeTip.textContent = '请先获取验证码!';
|
||||
codeTip.classList.add('tip-red');
|
||||
return false;
|
||||
@@ -79,14 +77,7 @@ function checkCode() {
|
||||
codeTip.classList.add('tip-yellow');
|
||||
return false;
|
||||
}
|
||||
if (sendCode === 'email_sent') { /* Server-verified, skip client check */ } else if (code !== sendCode) {
|
||||
codeTip.textContent = '验证码错误,请核对!';
|
||||
codeTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
|
||||
codeTip.textContent = '验证码正确';
|
||||
codeTip.classList.add('tip-green');
|
||||
// 验证码正确性由服务端在注册提交时校验(一次性消费)
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -102,7 +93,7 @@ repeatPasswordInput.addEventListener('blur', checkPassword);
|
||||
emailInput.addEventListener('focus', checkEmail);
|
||||
emailInput.addEventListener('input', function () {
|
||||
checkEmail();
|
||||
if (sendCode) {
|
||||
if (codeTarget) {
|
||||
checkCode();
|
||||
}
|
||||
});
|
||||
@@ -112,19 +103,40 @@ codeInput.addEventListener('focus', checkCode);
|
||||
codeInput.addEventListener('input', checkCode);
|
||||
codeInput.addEventListener('blur', checkCode);
|
||||
|
||||
getCodeButton.addEventListener('click', function () {
|
||||
getCodeButton.addEventListener('click', async function () {
|
||||
if (getCodeButton.disabled) return; // 防重复点击
|
||||
const email = checkEmail();
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendCode = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
codeTarget = email;
|
||||
codeInput.value = '';
|
||||
codeTip.textContent = '验证码已发送,请填写验证码';
|
||||
getCodeButton.disabled = true;
|
||||
codeTip.textContent = '验证码发送中…';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-green');
|
||||
alert(`验证码已发送至${email},您的验证码:${sendCode}`);
|
||||
codeTip.classList.add('tip-yellow');
|
||||
try {
|
||||
const res = await fetch('/register/send_code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
const responseText = await res.text();
|
||||
let result;
|
||||
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
|
||||
if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败');
|
||||
|
||||
codeTarget = email;
|
||||
codeInput.value = '';
|
||||
codeTip.textContent = '验证码已发送至邮箱,请查收(60 秒内有效)';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-green');
|
||||
} catch (err) {
|
||||
codeTip.textContent = err.message || '验证码发送失败,请稍后重试';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-red');
|
||||
} finally {
|
||||
getCodeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function (e) {
|
||||
@@ -139,7 +151,8 @@ form.addEventListener('submit', async function (e) {
|
||||
}
|
||||
const jsonBody = JSON.stringify({
|
||||
username: email,
|
||||
password
|
||||
password,
|
||||
email_code: code
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -169,7 +182,6 @@ form.addEventListener('submit', async function (e) {
|
||||
if (result.success) {
|
||||
alert('注册成功');
|
||||
form.reset();
|
||||
sendCode = '';
|
||||
codeTarget = '';
|
||||
location.href = 'login.html';
|
||||
return;
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
nuitka
|
||||
Flask>=2.3.0,<3.0.0
|
||||
Flask-CORS>=4.0.0,<5.0.0
|
||||
waitress>=2.1.2,<4.0.0
|
||||
@@ -31,6 +31,7 @@ SERVICES = {
|
||||
"name": "db",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8005,
|
||||
"protocol": "pymsg",
|
||||
"description": "通用数据库服务 (CRUD+集中日志)",
|
||||
"test_file": "test_db.py",
|
||||
},
|
||||
|
||||
@@ -284,6 +284,21 @@ class PymsgClient:
|
||||
# 服务可用性检查
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_tcp_service(host: str, port: int, timeout: float = 5.0) -> tuple:
|
||||
"""检查纯 TCP 服务(如 pymsg 协议服务 db/captcha/smtp)是否可达。
|
||||
返回 (reachable: bool, detail: str)。
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True, "TCP 连接成功"
|
||||
except ConnectionRefusedError:
|
||||
return False, "连接被拒绝 (服务未启动或端口未监听)"
|
||||
except socket.timeout:
|
||||
return False, f"TCP 连接超时 ({timeout}s)"
|
||||
except OSError as e:
|
||||
return False, f"网络错误: {e}"
|
||||
|
||||
|
||||
def check_service(url: str, timeout: float = 8.0) -> tuple:
|
||||
"""检查服务是否可达(HTTP 请求检测,与测试实际行为一致)。
|
||||
返回 (reachable: bool, detail: str)。
|
||||
|
||||
+54
-89
@@ -1,45 +1,38 @@
|
||||
"""
|
||||
db 黑盒用户模拟测试 — 集中日志: 写入 → 查询 → 统计 → 清理。
|
||||
db 黑盒用户模拟测试 — pymsg 协议 CRUD。
|
||||
|
||||
用户旅程:
|
||||
1. 通过 pymsg 协议执行 CRUD (select/insert)
|
||||
2. 通过 HTTP /log/ingest 写入日志
|
||||
3. 通过 HTTP /log/query 查询日志
|
||||
4. 通过 HTTP /log/stats 查看统计
|
||||
5. 通过 HTTP /log/health 健康检查
|
||||
1. 通过 pymsg 协议查询 Users 表
|
||||
2. 通过 pymsg 协议查询 Logs 表
|
||||
3. 通过 pymsg 协议带 where 条件查询
|
||||
4. 无效路由被拒绝
|
||||
5. 日期列 where(ISO 字符串 / $now 哨兵 / 非法值拒绝)
|
||||
|
||||
注意: dbv2.py (pymsg 模式) 不挂载 Flask log_module Blueprint,
|
||||
因此 HTTP /log/* 路由可能不可用。测试会优雅降级。
|
||||
注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过
|
||||
log_client.py 走 execute 路由 INSERT 写入 Logs 表。
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
|
||||
from helpers import (
|
||||
http_get, http_post, try_parse_json,
|
||||
assert_eq, assert_status, assert_true, assert_json_key,
|
||||
skip, Counter, check_service, PymsgClient,
|
||||
assert_true, skip, Counter, PymsgClient, check_tcp_service,
|
||||
)
|
||||
from config import get_url
|
||||
|
||||
DB_URL = get_url("db")
|
||||
DB_HOST = "127.0.0.1"
|
||||
DB_PORT = 8005
|
||||
|
||||
|
||||
def run(url: str = None):
|
||||
"""db 用户模拟测试入口。返回 (passed, failed, skipped)。"""
|
||||
du = url or DB_URL
|
||||
"""db 用户模拟测试入口。返回 (passed, failed, skipped)。
|
||||
|
||||
url 参数仅为保持与其他测试模块的签名一致,db 固定走 127.0.0.1:8005。
|
||||
"""
|
||||
c = Counter()
|
||||
|
||||
print(f"\n--- db 数据库与日志流程 ---\n")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 0. 检查服务可用性
|
||||
# 0. 检查服务可用性(TCP 直连,db 无 HTTP 接口)
|
||||
# ------------------------------------------------------------------
|
||||
if not check_service(du)[0]:
|
||||
if not check_tcp_service(DB_HOST, DB_PORT)[0]:
|
||||
skip("全部测试", "db 服务不可达", c)
|
||||
return c.passed, c.failed, c.skipped
|
||||
|
||||
@@ -109,75 +102,47 @@ def run(url: str = None):
|
||||
"pymsg-无效路由-拒绝", c,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 第二部分: HTTP /log/* 接口 (Flask log_module)
|
||||
# ------------------------------------------------------------------
|
||||
print("\n [HTTP /log/* 接口测试]")
|
||||
# 场景 5: 日期列 where — ISO 8601 字符串(dbv2 日期适配层)
|
||||
result = client.call({
|
||||
"_route": "execute",
|
||||
"action": "select",
|
||||
"table": "Users",
|
||||
"columns": ["id", "username", "create_at"],
|
||||
"where": {"create_at": {"lt": "2999-01-01T00:00:00"}},
|
||||
"limit": 1,
|
||||
})
|
||||
assert_true(
|
||||
result.get("success") is True,
|
||||
"pymsg-日期列where-ISO字符串", c,
|
||||
f"error={result.get('error', '')}",
|
||||
)
|
||||
|
||||
# 检查 /log/health 是否可用(Flask log_module 是否挂载)
|
||||
status, body, _ = http_get(f"{du}/log/health")
|
||||
if status == 0 or status >= 500:
|
||||
skip("HTTP /log/* 测试", "log_module 未挂载 (dbv2 使用 pymsg 模式, 非 Flask)", c)
|
||||
else:
|
||||
# 场景 5: /log/health 健康检查
|
||||
assert_eq(status, 200, "log-健康检查 (200)", c)
|
||||
# 场景 6: 日期列 where — $now 哨兵
|
||||
result = client.call({
|
||||
"_route": "execute",
|
||||
"action": "select",
|
||||
"table": "Users",
|
||||
"columns": ["id", "username", "create_at"],
|
||||
"where": {"create_at": {"lt": "$now"}},
|
||||
"limit": 1,
|
||||
})
|
||||
assert_true(
|
||||
result.get("success") is True,
|
||||
"pymsg-日期列where-$now哨兵", c,
|
||||
f"error={result.get('error', '')}",
|
||||
)
|
||||
|
||||
# 场景 6: /log/ingest 写入日志
|
||||
test_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
status, body, _ = http_post(f"{du}/log/ingest", {
|
||||
"logs": [{
|
||||
"service": "dnfs",
|
||||
"level": "INFO",
|
||||
"message": f"blackbox_test_{random.randint(10000, 99999)}",
|
||||
"ts": test_ts,
|
||||
}],
|
||||
})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
status == 200 and data is not None and data.get("success") is True,
|
||||
"log-写入日志", c,
|
||||
f"status={status} body={body[:150]}",
|
||||
)
|
||||
|
||||
# 场景 7: /log/ingest — 空日志列表
|
||||
status, body, _ = http_post(f"{du}/log/ingest", {"logs": []})
|
||||
assert_eq(status, 200, "log-空日志列表 (200)", c)
|
||||
|
||||
# 场景 8: /log/ingest — 缺少 logs 字段
|
||||
status, body, _ = http_post(f"{du}/log/ingest", {})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
data is not None and not data.get("success", True),
|
||||
"log-缺少logs字段-拒绝", c,
|
||||
)
|
||||
|
||||
# 场景 9: /log/query 查询日志
|
||||
status, body, _ = http_post(f"{du}/log/query", {
|
||||
"limit": 5,
|
||||
"desc": True,
|
||||
})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
status == 200 and data is not None and data.get("success") is True,
|
||||
"log-查询日志", c,
|
||||
f"status={status}",
|
||||
)
|
||||
if data and data.get("success"):
|
||||
assert_true(
|
||||
"logs" in data.get("data", {}),
|
||||
"log-查询返回logs字段", c,
|
||||
)
|
||||
|
||||
# 场景 10: /log/stats 统计
|
||||
status, body, _ = http_post(f"{du}/log/stats", {
|
||||
"group_by": "service",
|
||||
})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
status == 200 and data is not None,
|
||||
"log-统计", c,
|
||||
f"status={status}",
|
||||
)
|
||||
# 场景 7: 日期列 where — 非法日期字符串应报错
|
||||
result = client.call({
|
||||
"_route": "execute",
|
||||
"action": "select",
|
||||
"table": "Users",
|
||||
"where": {"create_at": {"lt": "not-a-date"}},
|
||||
})
|
||||
assert_true(
|
||||
result.get("success") is False,
|
||||
"pymsg-日期列where-非法值-拒绝", c,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 汇总
|
||||
|
||||
@@ -17,7 +17,7 @@ import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from config import SERVICES, PORT_MAP, detect_service, get_url
|
||||
from helpers import check_service
|
||||
from helpers import check_service, check_tcp_service
|
||||
|
||||
TEST_MODULES = {
|
||||
"gateway": "test_gateway",
|
||||
@@ -53,7 +53,11 @@ def run_service_test(name: str, url: str) -> tuple:
|
||||
print(f" {svc['description']} ({url})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
reachable, detail = check_service(url)
|
||||
# pymsg/TCP 服务无 HTTP 接口,用 TCP 直连探测
|
||||
if svc.get("protocol") == "pymsg":
|
||||
reachable, detail = check_tcp_service(svc["host"], svc["port"])
|
||||
else:
|
||||
reachable, detail = check_service(url)
|
||||
if not reachable:
|
||||
print(f" [WARN] 服务不可达: {url}")
|
||||
if detail:
|
||||
|
||||
+13
-13
@@ -2,25 +2,25 @@
|
||||
# ID 段:100000-199999
|
||||
|
||||
# 100001: 拦截 SQL 注入特征(UNION SELECT)
|
||||
SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \
|
||||
"id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'"
|
||||
#SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \
|
||||
# "id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'"
|
||||
|
||||
# 100002: 拦截 SQL 注释符
|
||||
SecRule ARGS "@rx (?i)(--|#|/\*.*\*/)" \
|
||||
"id:100002,phase:2,deny,status:403,log,msg:'SQL injection: comment'"
|
||||
## 100002: 拦截 SQL 注释符
|
||||
#SecRule ARGS "@rx (?i)(--|#|/\*.*\*/)" \
|
||||
# "id:100002,phase:2,deny,status:403,log,msg:'SQL injection: comment'"
|
||||
|
||||
# 100003: 拦截 XSS 标签
|
||||
SecRule ARGS|REQUEST_HEADERS "@rx (?i)<script|javascript:|onerror=|onload=" \
|
||||
"id:100003,phase:2,deny,status:403,log,msg:'XSS probe'"
|
||||
#SecRule ARGS|REQUEST_HEADERS "@rx (?i)<script|javascript:|onerror=|onload=" \
|
||||
# "id:100003,phase:2,deny,status:403,log,msg:'XSS probe'"
|
||||
|
||||
# 100004: 路径遍历
|
||||
SecRule REQUEST_URI|ARGS "@rx (?i)\.\./|\.\.%2f|\.\.%5c" \
|
||||
"id:100004,phase:1,deny,status:403,log,msg:'Path traversal'"
|
||||
#SecRule REQUEST_URI|ARGS "@rx (?i)\.\./|\.\.%2f|\.\.%5c" \
|
||||
# "id:100004,phase:1,deny,status:403,log,msg:'Path traversal'"
|
||||
|
||||
# 100005: 命令注入常见关键字
|
||||
SecRule ARGS "@rx (?i)(;|\|)&?\s*(cat|ls|id|whoami|uname|wget|curl|bash|sh)\s" \
|
||||
"id:100005,phase:2,deny,status:403,log,msg:'Command injection'"
|
||||
#SecRule ARGS "@rx (?i)(;|\|)&?\s*(cat|ls|id|whoami|uname|wget|curl|bash|sh)\s" \
|
||||
# "id:100005,phase:2,deny,status:403,log,msg:'Command injection'"
|
||||
|
||||
# 100006: 已知恶意扫描器 UA
|
||||
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(nikto|sqlmap|nmap|masscan|acunetix|nessus)" \
|
||||
"id:100006,phase:1,deny,status:403,log,msg:'Malicious scanner UA'"
|
||||
#SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(nikto|sqlmap|nmap|masscan|acunetix|nessus)" \
|
||||
# "id:100006,phase:1,deny,status:403,log,msg:'Malicious scanner UA'"
|
||||
Reference in new issue
Block a user