Dev qing #13
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。
|
1. 客户端所有请求先打到 `waf:443`(HTTPS),WAF 终止 TLS、运行 coraza 规则匹配,命中则直接返回 403。
|
||||||
2. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `gateway:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。
|
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`)。
|
4. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。
|
||||||
5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。
|
5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。
|
||||||
6. `dnfs` 处理节点增删改查时,通过 pymsg/TCP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。
|
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 表 + 自动清理) |
|
| [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,支持人工反馈纠偏 |
|
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
|
||||||
| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 |
|
| [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 拉起但与主干微服务无调用关系 |
|
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
|
||||||
|
|
||||||
### core(核心模块 · 进程托管服务)
|
### core(核心模块 · 进程托管服务)
|
||||||
@@ -68,18 +68,18 @@
|
|||||||
- 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。
|
- 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。
|
||||||
- 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。
|
- 配置见 [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 终止)。
|
- 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。
|
||||||
- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。
|
- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。
|
||||||
- **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),常数时间比较防 timing attack。
|
- **密码安全**: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 全部失效。
|
- **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 影响公开页面。
|
- **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)的图形验证码代理路由。
|
- **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用;`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由。
|
||||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。
|
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。
|
||||||
- **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中(`closed` 仅记录用途,实际隔离靠"不在 opened 中即拒绝"),缺省走 `Proxy.default`(不需 Token,服务前端静态资源)。
|
- **反向代理**:`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)。
|
- 详见 [gateway/README.md](gateway/README.md)。
|
||||||
|
|
||||||
### static(前端托管服务)
|
### static(前端托管服务)
|
||||||
@@ -107,12 +107,12 @@
|
|||||||
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
|
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
|
||||||
- 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。
|
- 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。
|
||||||
- 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。
|
- 默认使用 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 个索引)。
|
- 启动时自动建 `Logs` 表(service / level / message / trace_id / extra + 4 个索引)。
|
||||||
- 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。
|
- 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。
|
||||||
- `_log_internal()` — db 进程内部直接写 Logs 表,无网络往返,防递归设计。
|
- `_log_internal()` — db 进程内部直接写 Logs 表,无网络往返,防递归设计。
|
||||||
- 后台守护线程每小时自动清理:保留 30 天 / 上限 100 万条。
|
- 后台守护线程每小时自动清理:保留 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 不可用时静默丢弃,不影响业务)。
|
- **统一日志客户端 SDK**(各服务目录下的 `log_client.py`,如 [dnfs/log_client.py](dnfs/log_client.py)):各服务复制即用,纯标准库实现,异步非阻塞(内存队列 + 后台线程,50 条/2 秒批量 flush),fail-safe 设计(db 不可用时静默丢弃,不影响业务)。
|
||||||
|
|
||||||
### ai_review(AI 内容审核服务)
|
### ai_review(AI 内容审核服务)
|
||||||
@@ -135,9 +135,9 @@
|
|||||||
|
|
||||||
### smtp(邮件服务)
|
### smtp(邮件服务)
|
||||||
|
|
||||||
- 注册流程的邮箱验证码后端,基于 Flask + waitress,监听 127.0.0.1:8008。
|
- 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。
|
||||||
- 接口:`POST /send_email`(生成 6 位验证码并经 SMTP_SSL 发送至收件人,60 秒有效)、`POST /verify_email`(校验)。
|
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,60 秒有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。
|
||||||
- 验证码存于进程内存,SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`)。
|
- 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。
|
||||||
|
|
||||||
### aibox(独立 AI 智能体系统)
|
### aibox(独立 AI 智能体系统)
|
||||||
|
|
||||||
|
|||||||
+179
-21
@@ -9,11 +9,20 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from collections import OrderedDict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
import websockets
|
import websockets
|
||||||
from colorama import init, Fore, Style as ColoramaStyle
|
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)
|
init(autoreset=True)
|
||||||
|
|
||||||
class Colors:
|
class Colors:
|
||||||
@@ -75,10 +84,15 @@ class WebSocketClient:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
self.callback("chunk", message)
|
self.callback("chunk", message)
|
||||||
except websockets.exceptions.ConnectionClosed:
|
except websockets.exceptions.ConnectionClosed:
|
||||||
self.connected = False
|
pass
|
||||||
self.callback("system", "❌ 连接已断开")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.callback("error", f"接收消息错误: {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):
|
async def _handle_message(self, data: dict):
|
||||||
msg_type = data.get('type', '')
|
msg_type = data.get('type', '')
|
||||||
@@ -134,12 +148,25 @@ class SimpleCLI:
|
|||||||
self.ai_full = ""
|
self.ai_full = ""
|
||||||
self.ai_prefix_shown = False
|
self.ai_prefix_shown = False
|
||||||
|
|
||||||
# 已显示消息的去重
|
# 已显示消息的去重(LRU 上限,防长对话内存无限增长)
|
||||||
self.displayed_messages: set = set()
|
self.DISPLAYED_MAX = 500
|
||||||
|
self.displayed_messages: OrderedDict = OrderedDict()
|
||||||
|
|
||||||
# 锁和状态
|
# 锁和状态
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.current_input = ""
|
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 # 是否正在流式输出行
|
self.streaming_line = False # 是否正在流式输出行
|
||||||
@@ -232,16 +259,23 @@ class SimpleCLI:
|
|||||||
"""流式显示增量内容"""
|
"""流式显示增量内容"""
|
||||||
if not content:
|
if not content:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 清理内容
|
# 清理内容
|
||||||
clean = self._clean_content(content)
|
clean = self._clean_content(content)
|
||||||
if not clean:
|
if not clean:
|
||||||
return
|
return
|
||||||
|
|
||||||
key = clean[:200]
|
key = clean[:200]
|
||||||
if key in self.displayed_messages:
|
if key in self.displayed_messages:
|
||||||
return
|
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):
|
if self.ai_previous and clean.startswith(self.ai_previous):
|
||||||
new_part = clean[len(self.ai_previous):]
|
new_part = clean[len(self.ai_previous):]
|
||||||
@@ -275,6 +309,39 @@ class SimpleCLI:
|
|||||||
self.ai_previous = clean
|
self.ai_previous = clean
|
||||||
self.ai_full = 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):
|
def display(self, msg_type: str, content: str, extra: Any = None):
|
||||||
"""显示消息"""
|
"""显示消息"""
|
||||||
if msg_type == 'chunk':
|
if msg_type == 'chunk':
|
||||||
@@ -302,6 +369,11 @@ class SimpleCLI:
|
|||||||
elif msg_type == 'system':
|
elif msg_type == 'system':
|
||||||
self._finish_stream()
|
self._finish_stream()
|
||||||
self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}")
|
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':
|
elif msg_type == 'info':
|
||||||
self._finish_stream()
|
self._finish_stream()
|
||||||
self._print_line(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}")
|
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.write(f"{Colors.TASK}{content}{Colors.RESET}\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
return
|
return
|
||||||
|
|
||||||
# 检查是否是工具结果
|
# 检查是否是工具结果
|
||||||
if content.startswith(('✅ 对话记忆已保存', '💾 保存记忆', '❌', '⚠️')):
|
if content.startswith(('✅ 对话记忆已保存', '💾 保存记忆', '❌', '⚠️')):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -350,6 +422,25 @@ class SimpleCLI:
|
|||||||
|
|
||||||
def _handle_complete(self, content: str):
|
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:
|
if self.in_tool_phase:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -406,7 +497,7 @@ class SimpleCLI:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 只有当内容没有被流式显示时才显示完整消息
|
# 只有当内容没有被流式显示时才显示完整消息
|
||||||
self.displayed_messages.add(key)
|
self._remember_displayed(key)
|
||||||
# 如果之前没有流式输出,现在显示完整消息
|
# 如果之前没有流式输出,现在显示完整消息
|
||||||
self._print_line(f"{Colors.AI}🤖 AI助手: {clean}")
|
self._print_line(f"{Colors.AI}🤖 AI助手: {clean}")
|
||||||
|
|
||||||
@@ -466,7 +557,7 @@ class SimpleCLI:
|
|||||||
clean = content.strip()
|
clean = content.strip()
|
||||||
key = clean[:200]
|
key = clean[:200]
|
||||||
if key not in self.displayed_messages:
|
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}")
|
self._print_line(f"{Colors.TASK}⚡ 任务 - {task_title}: {clean}")
|
||||||
del self._task_buffers[task_title]
|
del self._task_buffers[task_title]
|
||||||
self.streaming_line = False
|
self.streaming_line = False
|
||||||
@@ -519,10 +610,15 @@ class SimpleCLI:
|
|||||||
self.display("error", "用法: /connect ws://host:port")
|
self.display("error", "用法: /connect ws://host:port")
|
||||||
return
|
return
|
||||||
uri = parts[1]
|
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:
|
if self.client and self.client.connected:
|
||||||
await self.client.disconnect()
|
await self.client.disconnect()
|
||||||
self.client = WebSocketClient(uri, self.display)
|
self.client = WebSocketClient(uri, self.display)
|
||||||
if await self.client.connect():
|
if await self.client.connect():
|
||||||
|
self._manual_disconnect = False
|
||||||
self.connected = True
|
self.connected = True
|
||||||
self.display("system", f"✅ 已连接到 {uri}")
|
self.display("system", f"✅ 已连接到 {uri}")
|
||||||
asyncio.create_task(self.client.receive_messages())
|
asyncio.create_task(self.client.receive_messages())
|
||||||
@@ -530,6 +626,9 @@ class SimpleCLI:
|
|||||||
self.client = None
|
self.client = None
|
||||||
self.connected = False
|
self.connected = False
|
||||||
elif cmd == '/disconnect':
|
elif cmd == '/disconnect':
|
||||||
|
self._manual_disconnect = True # 主动断开,不自动重连
|
||||||
|
if self._reconnect_task and not self._reconnect_task.done():
|
||||||
|
self._reconnect_task.cancel()
|
||||||
if self.client:
|
if self.client:
|
||||||
await self.client.disconnect()
|
await self.client.disconnect()
|
||||||
self.client = None
|
self.client = None
|
||||||
@@ -556,6 +655,9 @@ class SimpleCLI:
|
|||||||
elif cmd == '/quit':
|
elif cmd == '/quit':
|
||||||
self.display("system", "👋 再见!")
|
self.display("system", "👋 再见!")
|
||||||
self.running = False
|
self.running = False
|
||||||
|
self._manual_disconnect = True
|
||||||
|
if self._reconnect_task and not self._reconnect_task.done():
|
||||||
|
self._reconnect_task.cancel()
|
||||||
if self.client:
|
if self.client:
|
||||||
await self.client.disconnect()
|
await self.client.disconnect()
|
||||||
return
|
return
|
||||||
@@ -569,6 +671,35 @@ class SimpleCLI:
|
|||||||
self.display("error", f"未知命令: {cmd}")
|
self.display("error", f"未知命令: {cmd}")
|
||||||
self._print_prompt(clear_input=True)
|
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):
|
async def _send_interrupt(self):
|
||||||
if self.client and self.client.connected:
|
if self.client and self.client.connected:
|
||||||
interrupt_msg = {"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"}
|
interrupt_msg = {"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"}
|
||||||
@@ -581,7 +712,7 @@ class SimpleCLI:
|
|||||||
if not self.client or not self.client.connected:
|
if not self.client or not self.client.connected:
|
||||||
self.display("error", "未连接到服务器")
|
self.display("error", "未连接到服务器")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 重置流式状态
|
# 重置流式状态
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.streaming_line:
|
if self.streaming_line:
|
||||||
@@ -601,30 +732,57 @@ class SimpleCLI:
|
|||||||
await self.client.send_message(message)
|
await self.client.send_message(message)
|
||||||
# 不在这里显示提示符,等待AI响应结束后再显示
|
# 不在这里显示提示符,等待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):
|
async def run(self):
|
||||||
self.print_banner()
|
self.print_banner()
|
||||||
self._task_buffers = {}
|
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:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
line = await asyncio.get_event_loop().run_in_executor(
|
line = await self._input_queue.get()
|
||||||
None, lambda: sys.stdin.readline()
|
if line is None: # EOF (Ctrl+D)
|
||||||
)
|
|
||||||
if not line:
|
|
||||||
break
|
break
|
||||||
|
|
||||||
self.current_input = line.rstrip('\n')
|
self.current_input = line.rstrip('\n')
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
|
|
||||||
if not line:
|
if not line:
|
||||||
self._print_prompt(clear_input=True)
|
self._print_prompt(clear_input=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if line.startswith('/'):
|
if line.startswith('/'):
|
||||||
await self.handle_command(line)
|
await self.handle_command(line)
|
||||||
else:
|
else:
|
||||||
await self.send_user_message(line)
|
await self.send_user_message(line)
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
if self.client and self.client.connected:
|
if self.client and self.client.connected:
|
||||||
await self._send_interrupt()
|
await self._send_interrupt()
|
||||||
|
|||||||
@@ -56,28 +56,34 @@ def generate_text():
|
|||||||
|
|
||||||
def generate_image(text):
|
def generate_image(text):
|
||||||
width, height = 160, 50
|
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)
|
img = Image.new('RGB', (width, height), bg)
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
for i, ch in enumerate(text):
|
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)
|
y = random.randint(2, 12)
|
||||||
x = 15 + i * 32 + random.randint(-3, 3) # 4 位字符在 160px 宽度内均匀分布
|
x = 15 + i * 32 + random.randint(-3, 3) # 4 位字符在 160px 宽度内均匀分布
|
||||||
try:
|
try:
|
||||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
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):
|
except (IOError, OSError):
|
||||||
font = ImageFont.load_default()
|
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):
|
for _ in range(4):
|
||||||
draw.line([(random.randint(0, width), random.randint(0, height)),
|
draw.line([(random.randint(0, width), random.randint(0, height)),
|
||||||
(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):
|
for _ in range(50):
|
||||||
draw.point((random.randint(0, width - 1), random.randint(0, height - 1)),
|
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()
|
buf = io.BytesIO()
|
||||||
img.save(buf, format='PNG')
|
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 参数绑定处理
|
- **零 SQL 注入**:所有条件、值均通过 SQLAlchemy 参数绑定处理
|
||||||
- **统一 JSON 接口**:`action` + `table` + `conditions` 模式
|
- **统一 JSON 接口**:`_route` + `action` + `table` + `where` 模式
|
||||||
- **支持增删改查**:select, insert, update, delete 全覆盖
|
- **支持增删改查**:select / insert / update / delete 全覆盖
|
||||||
- **单线程本地监听**:waitress + 127.0.0.1:8005,简单可靠
|
- **内置日志表**:启动自动建 Logs 表,供全系统集中写日志;带后台自动清理
|
||||||
|
- **仅监听本机**:默认 `127.0.0.1:8005`,不对外暴露
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 安装依赖
|
### 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install flask sqlalchemy waitress
|
./_set.sh # 建 venv 并安装 requirements.txt(SQLAlchemy + 本地 ../pymsg)
|
||||||
# 根据需要安装数据库驱动,例如:
|
|
||||||
# pip install psycopg2-binary # PostgreSQL
|
|
||||||
# pip install pymysql # MySQL
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 启动服务(默认 SQLite)
|
### 启动服务
|
||||||
```bash
|
|
||||||
python app.py
|
|
||||||
```
|
|
||||||
服务运行在 `http://127.0.0.1:8005`。
|
|
||||||
|
|
||||||
使用其他数据库:
|
|
||||||
```bash
|
```bash
|
||||||
export DATABASE_URL="postgresql://user:pass@localhost/dbname"
|
./_run.sh # venv 中执行 python3 dbv2.py
|
||||||
python app.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**注意**:新接口依赖表已存在(通过反射获取结构)。首次使用时请先手动建表:
|
服务监听 `tcp://127.0.0.1:8005`。生产环境由 core(Nidus)按 init.txt 统一拉起,守护靠 systemd。
|
||||||
```bash
|
|
||||||
sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 文档
|
**注意**:`execute` 路由依赖表已存在(通过反射获取结构)。建表见 `create_sqlite3.sql`(Users / Notes;Logs 表由服务启动时自建)。
|
||||||
|
|
||||||
### 请求格式
|
## 协议与 API
|
||||||
|
|
||||||
**端点**:`POST /execute`
|
### 传输协议(pymsg)
|
||||||
**Content-Type**:`application/json`
|
|
||||||
|
每条消息为一个完整 JSON,帧格式:`[4 字节大端长度][UTF-8 JSON]`。
|
||||||
|
请求中的 `_route` 字段指定路由,目前仅有一个路由:**`execute`**。
|
||||||
|
|
||||||
|
### execute 路由 — 通用 CRUD
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"action": "操作类型", // select | insert | update | delete
|
"_route": "execute",
|
||||||
"table": "表名",
|
"action": "select", // select | insert | update | delete
|
||||||
"columns": ["列1", "列2"], // select/insert 时可选
|
"table": "Users",
|
||||||
"values": { // insert/update 时必填
|
"columns": ["id", "name"], // select 时可选(缺省全列)
|
||||||
"列名": "值"
|
"values": {"name": "Bob"}, // insert / update 时必填
|
||||||
},
|
"where": { // 可选,条件见下表
|
||||||
"where": { // 条件,支持组合操作符
|
|
||||||
"age": {"ge": 18, "lt": 60},
|
"age": {"ge": 18, "lt": 60},
|
||||||
"name": "Alice"
|
"name": "Alice"
|
||||||
},
|
},
|
||||||
"order_by": [{"column": "age", "desc": true}],
|
"order_by": [{"column": "id", "desc": true}],
|
||||||
"limit": 10,
|
"limit": 10,
|
||||||
"offset": 0
|
"offset": 0
|
||||||
}
|
}
|
||||||
@@ -84,48 +77,80 @@ sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": [ // 查询结果数组(insert/update/delete 时包含影响行数或 lastrowid)
|
"data": [
|
||||||
{"id": 1, "name": "Alice"}
|
{"id": 1, "username": "Alice", "create_at": "2026-07-28T14:30:00"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- select → 行对象数组
|
||||||
|
- insert → `[{"lastrowid": N}]`
|
||||||
|
- update / delete → `[{"rowcount": N}]`
|
||||||
|
|
||||||
失败:
|
失败:
|
||||||
```json
|
```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,
|
"_route": "execute",
|
||||||
"error": "错误描述"
|
"action": "update",
|
||||||
|
"table": "Users",
|
||||||
|
"values": {"last_login": "$now"},
|
||||||
|
"where": {"username": "alice"}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 使用示例(curl)
|
## 使用示例(Python + pymsg)
|
||||||
|
|
||||||
**插入**:
|
```python
|
||||||
```bash
|
import json
|
||||||
curl -X POST http://127.0.0.1:8005/execute \
|
from pymsg import MsgClient
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"action":"insert","table":"users","values":{"name":"Bob","age":25}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**查询**:
|
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":"select","table":"users","where":{"age":{"gt":20}},"order_by":[{"column":"id","desc":true}],"limit":5}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**更新**:
|
# 查询
|
||||||
```bash
|
resp = client.call(json.dumps({
|
||||||
curl -X POST http://127.0.0.1:8005/execute \
|
"_route": "execute",
|
||||||
-H "Content-Type: application/json" \
|
"action": "select",
|
||||||
-d '{"action":"update","table":"users","values":{"age":26},"where":{"name":"Bob"}}'
|
"table": "Users",
|
||||||
```
|
"where": {"id": {"gt": 0}},
|
||||||
|
"order_by": [{"column": "id", "desc": True}],
|
||||||
|
"limit": 5,
|
||||||
|
}))
|
||||||
|
print(json.loads(resp))
|
||||||
|
|
||||||
**删除**:
|
# 插入
|
||||||
```bash
|
client.call(json.dumps({
|
||||||
curl -X POST http://127.0.0.1:8005/execute \
|
"_route": "execute",
|
||||||
-H "Content-Type: application/json" \
|
"action": "insert",
|
||||||
-d '{"action":"delete","table":"users","where":{"id":{"in":[1,2]}}}'
|
"table": "Users",
|
||||||
|
"values": {"username": "bob", "password_hash": "..."},
|
||||||
|
}))
|
||||||
```
|
```
|
||||||
|
|
||||||
## 安全机制
|
## 安全机制
|
||||||
@@ -133,17 +158,17 @@ curl -X POST http://127.0.0.1:8005/execute \
|
|||||||
- 强制使用 SQLAlchemy 参数绑定,杜绝 SQL 注入。
|
- 强制使用 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 而不是独立服务
|
### 为什么集成在 db 而不是独立服务
|
||||||
|
|
||||||
独立 logs 服务会导致**循环调用**:db 记日志→调 logs,logs 存库→调 db,无限递归。集成在 db 内部后,db 自己记日志直接写表,彻底消除循环依赖。
|
独立 logs 服务会导致**循环调用**:db 记日志→调 logs,logs 存库→调 db,无限递归。集成在 db 内部后,db 自己记日志直接写表,彻底消除循环依赖。
|
||||||
|
|
||||||
### 日志表
|
### 日志表(启动时自建)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE IF NOT EXISTS Logs (
|
CREATE TABLE IF NOT EXISTS Logs (
|
||||||
@@ -155,152 +180,66 @@ CREATE TABLE IF NOT EXISTS Logs (
|
|||||||
trace_id TEXT,
|
trace_id TEXT,
|
||||||
extra 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
|
- `trace_id`:可选,请求追踪 ID
|
||||||
- `extra`:可选,JSON 字符串,存放附加结构化字段
|
- `extra`:可选,JSON 字符串,存放附加结构化字段
|
||||||
|
|
||||||
### 接口
|
### 写入方式
|
||||||
|
|
||||||
| 接口 | 方法 | 说明 |
|
**外部服务**:复制 `example_log_client.py` 为各自目录下的 `log_client.py`,改 `service_name` 即用。客户端底层通过 pymsg 调 `execute` 路由逐条 INSERT 到 Logs 表:
|
||||||
|------|------|------|
|
|
||||||
| `/log/ingest` | POST | 推送日志(支持批量) |
|
|
||||||
| `/log/query` | POST | 查询日志(按服务/级别/时间/关键字过滤) |
|
|
||||||
| `/log/stats` | POST | 统计聚合(按 service/level 分组计数) |
|
|
||||||
| `/log/purge` | POST | 清理过期日志 |
|
|
||||||
| `/log/health` | GET | 健康检查 |
|
|
||||||
|
|
||||||
所有接口的响应格式与 `/execute` 统一:`{success, data, error}`。
|
```python
|
||||||
|
from log_client import logs
|
||||||
|
|
||||||
#### POST /log/ingest
|
logs.info("dnfs", "节点新增成功 id=42")
|
||||||
|
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||||
```json
|
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||||
请求:
|
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||||
{
|
|
||||||
"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}}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `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 // 分页偏移
|
|
||||||
}
|
|
||||||
|
|
||||||
响应:
|
日志查询直接走 `execute` 路由 select Logs 表,或用工具脚本 `_view_db.sqlite3.py`:
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"total": 128,
|
|
||||||
"logs": [{"id":1024,"ts":"...","service":"dnfs","level":"INFO","message":"...","trace_id":"...","extra":{...}}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### POST /log/stats
|
```bash
|
||||||
|
python3 _view_db.sqlite3.py # 列出所有表及行数
|
||||||
```json
|
python3 _view_db.sqlite3.py Logs -n 20 # 最近 20 条日志
|
||||||
请求:
|
python3 _view_db.sqlite3.py Logs -w "level='ERROR'"
|
||||||
{"since":"...","until":"...","group_by":"service"}
|
python3 _view_db.sqlite3.py -s "SELECT service, COUNT(*) FROM Logs 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}}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 自动清理
|
### 自动清理
|
||||||
|
|
||||||
- 服务启动时开启后台守护线程,每 1 小时清理一次
|
- 服务启动时开启后台守护线程,每 1 小时清理一次
|
||||||
- 默认保留 30 天日志,超期自动删除
|
- 默认保留 30 天日志,超期自动删除
|
||||||
- 日志总条数超过 100 万时,按时间倒序保留最新 N 条
|
- 日志总条数超过 100 万时,按时间倒序保留最新 100 万条
|
||||||
|
|
||||||
### 各服务接入方式
|
|
||||||
|
|
||||||
把 `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) |
|
|
||||||
|
|
||||||
## 切换数据库
|
## 切换数据库
|
||||||
|
|
||||||
只需修改环境变量 `DATABASE_URL`,并安装对应驱动:
|
只需修改环境变量 `DATABASE_URL`,并安装对应驱动:
|
||||||
|
|
||||||
- SQLite:`sqlite:///database.db`
|
- SQLite(默认):`sqlite:///database.db`
|
||||||
- PostgreSQL:`postgresql://user:pass@localhost/dbname` (需 `pip install psycopg2`)
|
- PostgreSQL:`postgresql://user:pass@localhost/dbname`(需 `pip install psycopg2`)
|
||||||
- MySQL:`mysql+pymysql://user:pass@localhost/dbname` (需 `pip install pymysql`)
|
- MySQL:`mysql+pymysql://user:pass@localhost/dbname`(需 `pip install pymysql`)
|
||||||
|
|
||||||
SQLAlchemy 会自动生成适配各数据库的 SQL 方言,无需修改接口调用。
|
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)
|
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 json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, date, time as dt_time, timezone, timedelta
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
create_engine, MetaData, Table,
|
create_engine, MetaData, Table,
|
||||||
select, insert, update, delete, func, and_,
|
select, insert, update, delete, func, and_,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy import types as sa_types
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
# pymsg 导入
|
# pymsg 导入
|
||||||
@@ -50,6 +58,16 @@ DEFAULT_RETENTION_DAYS = 30
|
|||||||
DEFAULT_MAX_RECORDS = 1_000_000
|
DEFAULT_MAX_RECORDS = 1_000_000
|
||||||
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒
|
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 — 主服务类
|
# DBServer — 主服务类
|
||||||
@@ -113,7 +131,8 @@ class DBServer:
|
|||||||
resp["data"] = data
|
resp["data"] = data
|
||||||
if error is not None:
|
if error is not None:
|
||||||
resp["error"] = error
|
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):
|
def _get_table(self, table_name):
|
||||||
@@ -121,6 +140,54 @@ class DBServer:
|
|||||||
Table(table_name, self.metadata, autoload_with=self.engine)
|
Table(table_name, self.metadata, autoload_with=self.engine)
|
||||||
return self.metadata.tables[table_name]
|
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):
|
def _build_where(self, table, conditions):
|
||||||
if not conditions:
|
if not conditions:
|
||||||
return None
|
return None
|
||||||
@@ -129,23 +196,26 @@ class DBServer:
|
|||||||
column = table.columns[col]
|
column = table.columns[col]
|
||||||
if isinstance(val, dict):
|
if isinstance(val, dict):
|
||||||
if "eq" in val:
|
if "eq" in val:
|
||||||
clauses.append(column == val["eq"])
|
clauses.append(column == self._coerce_value(column, val["eq"]))
|
||||||
if "ne" in val:
|
if "ne" in val:
|
||||||
clauses.append(column != val["ne"])
|
clauses.append(column != self._coerce_value(column, val["ne"]))
|
||||||
if "gt" in val:
|
if "gt" in val:
|
||||||
clauses.append(column > val["gt"])
|
clauses.append(column > self._coerce_value(column, val["gt"]))
|
||||||
if "lt" in val:
|
if "lt" in val:
|
||||||
clauses.append(column < val["lt"])
|
clauses.append(column < self._coerce_value(column, val["lt"]))
|
||||||
if "ge" in val:
|
if "ge" in val:
|
||||||
clauses.append(column >= val["ge"])
|
clauses.append(column >= self._coerce_value(column, val["ge"]))
|
||||||
if "le" in val:
|
if "le" in val:
|
||||||
clauses.append(column <= val["le"])
|
clauses.append(column <= self._coerce_value(column, val["le"]))
|
||||||
if "like" in val:
|
if "like" in val:
|
||||||
|
# like 是字符串模式匹配,不做日期转换
|
||||||
clauses.append(column.like(val["like"]))
|
clauses.append(column.like(val["like"]))
|
||||||
if "in" in val:
|
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:
|
else:
|
||||||
clauses.append(column == val)
|
clauses.append(column == self._coerce_value(column, val))
|
||||||
return and_(*clauses)
|
return and_(*clauses)
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -205,6 +275,10 @@ class DBServer:
|
|||||||
|
|
||||||
def _do_insert(self, conn, table, data):
|
def _do_insert(self, conn, table, data):
|
||||||
values = data.get("values", {})
|
values = data.get("values", {})
|
||||||
|
values = {
|
||||||
|
k: self._coerce_value(table.columns[k], v)
|
||||||
|
for k, v in values.items()
|
||||||
|
}
|
||||||
stmt = insert(table).values(**values)
|
stmt = insert(table).values(**values)
|
||||||
res = conn.execute(stmt)
|
res = conn.execute(stmt)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -212,6 +286,10 @@ class DBServer:
|
|||||||
|
|
||||||
def _do_update(self, conn, table, data):
|
def _do_update(self, conn, table, data):
|
||||||
values = data.get("values", {})
|
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"))
|
where_cond = self._build_where(table, data.get("where"))
|
||||||
stmt = update(table).values(**values)
|
stmt = update(table).values(**values)
|
||||||
if where_cond is not None:
|
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
|
nuitka
|
||||||
flask
|
|
||||||
SQLAlchemy
|
SQLAlchemy
|
||||||
waitress
|
|
||||||
../pymsg
|
../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`。
|
- payload 中取 `user` 字段作为操作者,写入 `owner`。
|
||||||
- `delete_note` 与 `update_note` 校验所有权:
|
- `delete_note` 与 `update_note` 校验所有权:
|
||||||
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
|
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
|
||||||
- **例外**:`user == 'admin'` 可删除任意节点(管理员豁免,仅 delete)。
|
- **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。
|
||||||
- `get_notes` 不需要鉴权。
|
- `get_notes` 不需要鉴权。
|
||||||
|
|
||||||
## 接口文档
|
## 接口文档
|
||||||
@@ -154,7 +154,7 @@ curl -X POST http://localhost:8006/delete_note \
|
|||||||
|
|
||||||
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
|
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
|
||||||
|
|
||||||
仅节点 owner 可更新;update 路径下 **admin 不享豁免**(与 delete 不同)。
|
仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。
|
||||||
|
|
||||||
**Request**
|
**Request**
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -37,6 +37,9 @@ from pymsg import MsgClient # noqa: E402
|
|||||||
_DBV2_ADDR = '127.0.0.1'
|
_DBV2_ADDR = '127.0.0.1'
|
||||||
_DBV2_PORT = 8005
|
_DBV2_PORT = 8005
|
||||||
|
|
||||||
|
# 管理员账号:删除/更新节点时豁免所有权校验(保留旧的 'admin' 以兼容历史数据)
|
||||||
|
ADMIN_USERS = ('admin@qsont.me', 'qing@qsont.me')
|
||||||
|
|
||||||
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
|
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
|
||||||
"""通过 pymsg 短连接调用 dbv2 数据库服务。
|
"""通过 pymsg 短连接调用 dbv2 数据库服务。
|
||||||
自动注入 ``_route: "execute"``。
|
自动注入 ``_route: "execute"``。
|
||||||
@@ -325,7 +328,7 @@ def delete_note():
|
|||||||
|
|
||||||
data = _call_dbv2(select_payload)
|
data = _call_dbv2(select_payload)
|
||||||
notes = data['data']
|
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']:
|
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']})
|
logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']})
|
||||||
return jsonify({"error": 'It\'s not your note'}), 403
|
return jsonify({"error": 'It\'s not your note'}), 403
|
||||||
@@ -400,9 +403,10 @@ def update_note():
|
|||||||
|
|
||||||
data = _call_dbv2(select_payload)
|
data = _call_dbv2(select_payload)
|
||||||
notes = data['data']
|
notes = data['data']
|
||||||
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
|
if info['user'] not in ADMIN_USERS:
|
||||||
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
|
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
|
||||||
return jsonify({"error": 'It\'s not your note'}), 403
|
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)
|
review = review_content(title + '\n' + content, 10)
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ INSTL_DIR := /usr/local/bin
|
|||||||
CPP := g++
|
CPP := g++
|
||||||
INCS := -I inc/
|
INCS := -I inc/
|
||||||
MY_LIBS := -L/usr/lib -lus_http -lus_crypt
|
MY_LIBS := -L/usr/lib -lus_http -lus_crypt
|
||||||
LIBS := -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options
|
LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options
|
||||||
CPPFLAGS := --std=c++20 -pipe # -Wall -Werror
|
CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror
|
||||||
BUILD_DIR = build
|
BUILD_DIR = build
|
||||||
$(shell mkdir -p $(BUILD_DIR))
|
$(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` 中放开的内部服务。
|
- **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
|
||||||
- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
|
- **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 头,允许跨域。
|
- **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。
|
||||||
|
|
||||||
## 安全特性
|
## 安全特性
|
||||||
@@ -71,9 +72,25 @@ UniServe Framework 的应用层网关,C++20 实现。
|
|||||||
|
|
||||||
### `POST /register`
|
### `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 *`(反向代理)
|
### `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 | |
|
| 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>` |
|
| password_hash | TEXT NOT NULL | `pbkdf2:100000:<salt_hex>:<hash_hex>` |
|
||||||
| create_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
| create_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||||
| last_login | DATETIME | 每次登录更新 |
|
| last_login | DATETIME | 每次登录更新 |
|
||||||
| is_active | BOOLEAN DEFAULT 1 | |
|
| 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++`)
|
- C++20(`g++`)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
cd "$(dirname "$0")" || exit
|
cd "$(dirname "$0")" || exit
|
||||||
|
sleep 1
|
||||||
exec ./build/gateway.out -i example.ini #1>/dev/null
|
exec ./build/gateway.out -i example.ini #1>/dev/null
|
||||||
+5
-1
@@ -6,6 +6,10 @@ namespace qing {
|
|||||||
namespace json = boost::json;
|
namespace json = boost::json;
|
||||||
class DbC {
|
class DbC {
|
||||||
public:
|
public:
|
||||||
|
/* 本机 db 轻量查询的默认超时(秒);慢操作由调用方显式放宽。
|
||||||
|
* smtp/captcha 等其他下游的超时常量见 main.cpp。 */
|
||||||
|
static constexpr double DEFAULT_TIMEOUT = 5.0;
|
||||||
|
|
||||||
// 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。
|
// 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。
|
||||||
// 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。
|
// 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。
|
||||||
static json::value call_http(json::value& params) {
|
static json::value call_http(json::value& params) {
|
||||||
@@ -25,7 +29,7 @@ namespace qing {
|
|||||||
// {"success":..., "data":[...]}。
|
// {"success":..., "data":[...]}。
|
||||||
// 响应格式与 call_http 兼容,调用方可直接替换。
|
// 响应格式与 call_http 兼容,调用方可直接替换。
|
||||||
static json::value call_msg(const json::value& params,
|
static json::value call_msg(const json::value& params,
|
||||||
double timeout = 5.0) {
|
double timeout = DEFAULT_TIMEOUT) {
|
||||||
json::value jv_route = params;
|
json::value jv_route = params;
|
||||||
jv_route.as_object()["_route"] = "execute";
|
jv_route.as_object()["_route"] = "execute";
|
||||||
pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
|
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 "LocalTime.hpp"
|
||||||
#include "Conf.hpp"
|
#include "Conf.hpp"
|
||||||
#include "DbC.hpp"
|
#include "DbC.hpp"
|
||||||
|
#include "LogC.hpp"
|
||||||
|
|
||||||
using namespace pmc::net;
|
using namespace pmc::net;
|
||||||
using namespace qing;
|
using namespace qing;
|
||||||
namespace json = boost::json;
|
namespace json = boost::json;
|
||||||
namespace po = boost::program_options;
|
namespace po = boost::program_options;
|
||||||
|
|
||||||
constexpr const char *_VER = "0.4.4";
|
constexpr const char *_VER = "0.5.2";
|
||||||
|
|
||||||
//////////////////////////////////
|
//////////////////////////////////
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
@@ -287,6 +288,11 @@ http::response<http::string_body> handleRegister(
|
|||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
const std::unordered_map<std::string, std::string>& params);
|
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(
|
http::response<http::string_body> handleCaptcha(
|
||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
const std::unordered_map<std::string, std::string>& params);
|
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 std::unique_ptr<Sqlite3> _SQLDB;
|
||||||
static RateLimiter _LOGIN_RATE;
|
static RateLimiter _LOGIN_RATE;
|
||||||
static RateLimiter _REGISTER_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) */
|
/* CAPTCHA store: token -> answer (in-memory, with expiry) */
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
@@ -337,6 +352,9 @@ struct CaptchaEntry {
|
|||||||
static std::unordered_map<std::string, CaptchaEntry> _CAPTCHA_STORE;
|
static std::unordered_map<std::string, CaptchaEntry> _CAPTCHA_STORE;
|
||||||
static std::mutex _CAPTCHA_MTX;
|
static std::mutex _CAPTCHA_MTX;
|
||||||
|
|
||||||
|
/* 集中式日志客户端:推送 gateway 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */
|
||||||
|
static inline LogC& logs() { return LogC::instance(); }
|
||||||
|
|
||||||
std::string generate_captcha_text() {
|
std::string generate_captcha_text() {
|
||||||
static const char chars[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
static const char chars[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||||
// thread_local:每个线程独立 rng 实例,避免多线程并发调用 dist(rng) 时的数据竞争
|
// thread_local:每个线程独立 rng 实例,避免多线程并发调用 dist(rng) 时的数据竞争
|
||||||
@@ -379,7 +397,27 @@ bool verify_captcha_remote(const std::string& token, const std::string& answer)
|
|||||||
{"token", token},
|
{"token", token},
|
||||||
{"answer", answer}
|
{"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 jv = json::parse(resp);
|
||||||
auto& obj = jv.as_object();
|
auto& obj = jv.as_object();
|
||||||
if (!obj.at("success").as_bool()) return false;
|
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;
|
std::cout << "Gateway " << _VER << " started" << std::endl;
|
||||||
|
logs().info("gateway", std::string("Gateway ") + _VER + " started");
|
||||||
|
|
||||||
/* 开始监听 */
|
/* 开始监听 */
|
||||||
_SERVE->run();
|
_SERVE->run();
|
||||||
|
|
||||||
|
/* 收到停止信号后的收尾:记录并 flush 剩余日志 */
|
||||||
|
logs().info("gateway", "Gateway shutdown");
|
||||||
|
logs().shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
//void db_init() {
|
//void db_init() {
|
||||||
@@ -565,6 +607,12 @@ void registerRoutes(HttpServer &server) {
|
|||||||
[](const auto& req, const auto& params) { return handleRegister(req, params); });
|
[](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> {
|
server.post("/logout", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||||
return wrapperHttp(req, params,
|
return wrapperHttp(req, params,
|
||||||
[](const auto& req, const auto& params) { return handleLogout(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;
|
http::response<http::string_body> res;
|
||||||
res.version(req.version());
|
res.version(req.version());
|
||||||
// 详细错误信息(e.what() 含源码路径/行号/C++ 模板类型)暂时以注释方式保留(同 handleLogin)
|
// 详细错误信息(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 = {
|
json::value jv_r = {
|
||||||
{"success", false},
|
{"success", false},
|
||||||
{"message", e.what()}
|
{"message", e.what()}
|
||||||
@@ -627,6 +682,7 @@ catch (const std::exception& e) {
|
|||||||
// 透传到客户端会泄露 C++ 类型信息与内部路径(P0-3 遗漏点,已补全)
|
// 透传到客户端会泄露 C++ 类型信息与内部路径(P0-3 遗漏点,已补全)
|
||||||
// 排障:完整信息仍输出到服务端 stderr
|
// 排障:完整信息仍输出到服务端 stderr
|
||||||
std::cerr << "[wrapperHttp std::exception] <" << ti.name() << "> " << e.what() << std::endl;
|
std::cerr << "[wrapperHttp std::exception] <" << ti.name() << "> " << e.what() << std::endl;
|
||||||
|
logs().error("gateway", std::string("<") + ti.name() + "> " + e.what());
|
||||||
json::value jv_r = {
|
json::value jv_r = {
|
||||||
{"success", false},
|
{"success", false},
|
||||||
/* {"message", std::string("<") + ti.name() + "> " + e.what()}, */ // 泄露源码信息,已禁用
|
/* {"message", std::string("<") + ti.name() + "> " + e.what()}, */ // 泄露源码信息,已禁用
|
||||||
@@ -696,7 +752,7 @@ http::response<http::string_body> handleLogin(
|
|||||||
client_ip = "direct";
|
client_ip = "direct";
|
||||||
|
|
||||||
if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300))
|
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
|
// parse json req body
|
||||||
json::value jv;
|
json::value jv;
|
||||||
@@ -742,10 +798,14 @@ http::response<http::string_body> handleLogin(
|
|||||||
auto jv_db_r = DbC::call_msg(jv_select);
|
auto jv_db_r = DbC::call_msg(jv_select);
|
||||||
auto obj_r = jv_db_r.as_object();
|
auto obj_r = jv_db_r.as_object();
|
||||||
auto success = obj_r["success"].as_bool();
|
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_rd = obj_r.at("data").as_array();
|
||||||
auto obj_ru = obj_rd.at(0).as_object();
|
/* 用户不存在时 data 为空数组:直接 at(0) 会抛异常被 wrapper 兜底成 500,
|
||||||
stored_hash = obj_ru.at("password_hash").as_string();
|
* 此处保持 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;
|
// 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");
|
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.bind_text(1, username);
|
||||||
//stmt_upd.step();
|
//stmt_upd.step();
|
||||||
//
|
//
|
||||||
// 新版调用db模块 无法通过DB上传时间戳目前
|
// 新版调用db模块:values 中的 "$now" 哨兵由 db 日期适配层替换为服务端当前 UTC 时间
|
||||||
//json::value jv_upd = {
|
//(此前因 db 无法处理日期列而注释,dbv2 增加 _coerce_value 后可用)。
|
||||||
// {"action", "update"},
|
json::value jv_upd = {
|
||||||
// {"table", "Users"},
|
{"action", "update"},
|
||||||
// {"values", json::object{
|
{"table", "Users"},
|
||||||
// {"last_login", LocalTime::getCurrent()},
|
{"values", json::object{
|
||||||
// }},
|
{"last_login", "$now"},
|
||||||
// {"where", json::object{
|
}},
|
||||||
// {"username", username},
|
{"where", json::object{
|
||||||
// }}
|
{"username", username},
|
||||||
//};
|
}}
|
||||||
//auto jv_db_r = DbC::call_http(jv_upd);
|
};
|
||||||
//auto obj_r = jv_db_r.as_object();
|
auto jv_upd_r = DbC::call_msg(jv_upd);
|
||||||
//auto success = obj_r["success"].as_bool();
|
if (!jv_upd_r.as_object()["success"].as_bool())
|
||||||
//if (!success) THROW_EX_CUSTOM(HttpException, 2102, json::serialize(jv_db_r)+"Login (Update Step) Failed", 500, "/login");
|
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);
|
res.set("Set-Cookie", ck);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logs().info("gateway", "登录成功: " + username, std::nullopt,
|
||||||
|
json::serialize(json::value{{"ip", client_ip}}));
|
||||||
|
|
||||||
res.result(http::status::ok);
|
res.result(http::status::ok);
|
||||||
res.body() = json::serialize(jv_rr);
|
res.body() = json::serialize(jv_rr);
|
||||||
res.prepare_payload();
|
res.prepare_payload();
|
||||||
@@ -867,7 +938,7 @@ http::response<http::string_body> handleCaptchaImage(
|
|||||||
pmc::net::MsgClient client("127.0.0.1", 8009, "gateway");
|
pmc::net::MsgClient client("127.0.0.1", 8009, "gateway");
|
||||||
std::string resp;
|
std::string resp;
|
||||||
try {
|
try {
|
||||||
resp = client.call(R"({"_route":"image"})", 5.0);
|
resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA);
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
THROW_EX_CUSTOM(HttpException, 2400, "Captcha service unavailable", 502, "/captcha/image");
|
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());
|
client_ip = std::string(it_host->value());
|
||||||
if (!_REGISTER_RATE.check_and_record(client_ip, 3, 600))
|
if (!_REGISTER_RATE.check_and_record(client_ip, 3, 600))
|
||||||
THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.",
|
THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.",
|
||||||
403, "/register");
|
429, "/register");
|
||||||
|
|
||||||
// parse the req body
|
// parse the req body
|
||||||
json::value jv;
|
json::value jv;
|
||||||
@@ -951,6 +1022,14 @@ http::response<http::string_body> handleRegister(
|
|||||||
THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register");
|
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
|
// generate the hash of password
|
||||||
auto password_hash = PBKDF2Hasher::hash_password(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");
|
if (!success) THROW_EX_CUSTOM(HttpException, 2202, "Registe Failed", 400, "/register");
|
||||||
auto jv_r = json::value{{"success", success}};
|
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.result(http::status::ok);
|
||||||
res.body() = json::serialize(jv_r);
|
res.body() = json::serialize(jv_r);
|
||||||
res.prepare_payload();
|
res.prepare_payload();
|
||||||
return res;
|
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(
|
http::response<http::string_body> handleProxy(
|
||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
const std::unordered_map<std::string, std::string>& params,
|
const std::unordered_map<std::string, std::string>& params,
|
||||||
@@ -1097,6 +1251,7 @@ http::response<http::string_body> handleProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!flag) {
|
if (!flag) {
|
||||||
|
logs().warning("gateway", "代理目标不在白名单: " + t_url);
|
||||||
throw std::runtime_error("403");
|
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
|
../pymsg
|
||||||
nuitka
|
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
|
替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
|
||||||
from email.mime.text import MIMEText
|
协议:每条消息为完整 JSON,``_route`` 字段指定路由名。
|
||||||
from email import header
|
|
||||||
from log_client import logs
|
请求示例::
|
||||||
from dotenv import load_dotenv
|
|
||||||
import waitress
|
{"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码
|
||||||
import smtplib
|
{"_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 random
|
||||||
|
import smtplib
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import sys, os
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
# 手动加载 .env(支持 KEY=VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号)
|
||||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
_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__)
|
from log_client import logs
|
||||||
captchas: dict[str, list[str, str]] = {}
|
|
||||||
|
|
||||||
@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")
|
CODE_LEN = 6
|
||||||
subject: str = "DNFS 邮箱验证"
|
CODE_TTL = 60 # 验证码有效期(秒)
|
||||||
captcha: str = build_captcha()
|
MAIL_FROM = "dnfs@qsont.me"
|
||||||
content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。"
|
DEFAULT_SUBJECT = "DNFS 邮箱验证"
|
||||||
|
DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。"
|
||||||
|
|
||||||
captchas[recipient] = ["", ""]
|
code_store = {} # email -> {'code': str, 'expires_at': float}
|
||||||
captchas[recipient][0] = captcha
|
STORE_LOCK = threading.Lock()
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/verify_email", methods=["POST"])
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# SmtpServer — 主服务类
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def verify_email():
|
class SmtpServer:
|
||||||
data = request.get_json()
|
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。"""
|
||||||
|
|
||||||
for i in list(captchas.keys()):
|
def __init__(self, addr="127.0.0.1", port=8008):
|
||||||
if int(captchas[i][1]) < int(time.time()):
|
self.addr = addr
|
||||||
del captchas[i]
|
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}: 验证通过")
|
def start(self) -> None:
|
||||||
return jsonify({"success": "验证通过"}), 200
|
"""启动服务(阻塞当前线程)。"""
|
||||||
else:
|
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}: 验证失败")
|
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__":
|
if __name__ == "__main__":
|
||||||
logs.info("smtp", "开始监听http://127.0.0.1:8008")
|
import argparse
|
||||||
print("Listen at http://127.0.0.1:8008")
|
|
||||||
waitress.serve(app, host="127.0.0.1", port=8008)
|
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")
|
print("\n")
|
||||||
|
|
||||||
if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
|
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)
|
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
|
||||||
else:
|
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; }
|
#article-content { font-size: 0.95rem; line-height: 1.7; margin-bottom: 1.25rem; }
|
||||||
|
|
||||||
/* ===== Recommend List (dnfs) ===== */
|
/* ===== 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 {
|
.recommend-list center {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
@@ -224,6 +244,127 @@ input[type="button"]:disabled {
|
|||||||
.recommend-list .card h3 { transition: color 0.15s; }
|
.recommend-list .card h3 { transition: color 0.15s; }
|
||||||
.recommend-list .card p { font-size: 0.9rem; line-height: 1.6; color: var(--fg-secondary); }
|
.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 (dnfs) ===== */
|
||||||
.minimap {
|
.minimap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div id="minimap" class="minimap"></div>
|
<div id="minimap" class="minimap"></div>
|
||||||
|
<div id="movebar" class="movebar"></div>
|
||||||
<div class="recommend-list" id="recommend-list"></div>
|
<div class="recommend-list" id="recommend-list"></div>
|
||||||
<div class="card node-form">
|
<div class="card node-form">
|
||||||
<h3>节点操作</h3>
|
<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 */
|
/* by shufeng2012 */
|
||||||
function renderMarkdown(element, content) {
|
function renderMarkdown(element, content) {
|
||||||
/*
|
/*
|
||||||
@@ -177,6 +406,26 @@ function renderMarkdown(element, content) {
|
|||||||
element.innerHTML = md.render(content, {html: false});
|
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 ---- */
|
/* ---- by qing ---- */
|
||||||
const stack = []; /* 用于存储当前节点的路径,也就是经过的每一个节点的id, 否则就回不去上一层*/
|
const stack = []; /* 用于存储当前节点的路径,也就是经过的每一个节点的id, 否则就回不去上一层*/
|
||||||
var article_title = document.getElementById('article-title');
|
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 recommend_list = document.getElementById('recommend-list');
|
||||||
var delete_note = document.getElementById('delete-note');
|
var delete_note = document.getElementById('delete-note');
|
||||||
var minimap = document.getElementById("minimap");
|
var minimap = document.getElementById("minimap");
|
||||||
|
var movebar = document.getElementById("movebar");
|
||||||
var is_expand = true;
|
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) {
|
input_note_submit.addEventListener('click', function(event) {
|
||||||
if (input_note_submit.disabled) return; // 防重复点击
|
if (input_note_submit.disabled) return; // 防重复点击
|
||||||
@@ -201,7 +453,9 @@ input_note_submit.addEventListener('click', function(event) {
|
|||||||
input_note_submit.disabled = true;
|
input_note_submit.disabled = true;
|
||||||
input_note_clear.disabled = true;
|
input_note_clear.disabled = true;
|
||||||
delete_note.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(() => {
|
.finally(() => {
|
||||||
input_note_submit.disabled = false;
|
input_note_submit.disabled = false;
|
||||||
input_note_clear.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_clear.addEventListener('click', function(event) {
|
||||||
input_note_title.value = "";
|
input_note_title.value = "";
|
||||||
input_note_content.value = "";
|
input_note_content.value = "";
|
||||||
|
cancelEdit(); // 重置时同时退出编辑模式
|
||||||
})
|
})
|
||||||
|
|
||||||
delete_note.addEventListener('click', (event) => {
|
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) {
|
function load_notes(main_node) {
|
||||||
|
|
||||||
/* 这个不能设置为空的 */
|
/* 这个不能设置为空的 */
|
||||||
@@ -239,6 +510,7 @@ function load_notes(main_node) {
|
|||||||
if (confirm("是否放弃当前输入的内容?") == true) {
|
if (confirm("是否放弃当前输入的内容?") == true) {
|
||||||
input_note_title.value = "";
|
input_note_title.value = "";
|
||||||
input_note_content.value = "";
|
input_note_content.value = "";
|
||||||
|
cancelEdit();
|
||||||
} else {
|
} else {
|
||||||
if (stack.length != 0) {
|
if (stack.length != 0) {
|
||||||
return;
|
return;
|
||||||
@@ -247,12 +519,14 @@ function load_notes(main_node) {
|
|||||||
} else {
|
} else {
|
||||||
input_note_title.value = "";
|
input_note_title.value = "";
|
||||||
input_note_content.value = "";
|
input_note_content.value = "";
|
||||||
|
cancelEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
recommend_list.replaceChildren();
|
recommend_list.replaceChildren();
|
||||||
for (let i in main_node) {
|
for (let i in main_node) {
|
||||||
let item = main_node[i]
|
let item = main_node[i]
|
||||||
|
console.log(item)
|
||||||
let card = document.createElement('div');
|
let card = document.createElement('div');
|
||||||
card.className = "card";
|
card.className = "card";
|
||||||
card.addEventListener('click', function(event) {
|
card.addEventListener('click', function(event) {
|
||||||
@@ -267,18 +541,57 @@ function load_notes(main_node) {
|
|||||||
card.id = item.title;
|
card.id = item.title;
|
||||||
ttl.appendChild(ttl1);
|
ttl.appendChild(ttl1);
|
||||||
card.appendChild(ttl);
|
card.appendChild(ttl);
|
||||||
let vit_div = document.createElement('p');
|
let wrt_div = document.createElement('p');
|
||||||
let lke_div = document.createElement('p');
|
// 发帖用户:头像圆点(取昵称首字符)+ 昵称,样式见 theme.css 的 Owner Chip 段
|
||||||
let srd_div = document.createElement('p');
|
wrt_div.className = "card-owner";
|
||||||
vit_div.textContent = "观看数:" + item.visit_count;
|
let owner_text = (item.owner == null || item.owner === "") ? "匿名" : String(item.owner);
|
||||||
lke_div.textContent = "点赞数:" + item.like_count;
|
let owner_avatar = document.createElement('span');
|
||||||
srd_div.textContent = "分享数:" + item.share_count;
|
owner_avatar.className = "owner-avatar";
|
||||||
ttl.appendChild(vit_div);
|
owner_avatar.textContent = owner_text.trim().charAt(0).toUpperCase() || "?";
|
||||||
ttl.appendChild(lke_div);
|
let owner_name = document.createElement('span');
|
||||||
ttl.appendChild(srd_div);
|
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');
|
let ctt = document.createElement('p');
|
||||||
renderMarkdown(ctt, item.content);
|
renderMarkdown(ctt, item.content);
|
||||||
card.appendChild(ctt)
|
card.appendChild(ctt)
|
||||||
|
card.appendChild(act_div);
|
||||||
recommend_list.appendChild(card)
|
recommend_list.appendChild(card)
|
||||||
recommend_list.appendChild(document.createElement('br'))
|
recommend_list.appendChild(document.createElement('br'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ const pwdTip = document.getElementById('pwdTip');
|
|||||||
const codeTip = document.getElementById('codeTip');
|
const codeTip = document.getElementById('codeTip');
|
||||||
const submitButton = document.getElementById('formsub');
|
const submitButton = document.getElementById('formsub');
|
||||||
|
|
||||||
let sendCode = '';
|
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
|
||||||
let codeTarget = '';
|
|
||||||
|
|
||||||
form.addEventListener('reset', function () {
|
form.addEventListener('reset', function () {
|
||||||
clearTipStyle();
|
clearTipStyle();
|
||||||
sendCode = '';
|
|
||||||
codeTarget = '';
|
codeTarget = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +62,7 @@ function checkCode() {
|
|||||||
codeTip.innerHTML = '';
|
codeTip.innerHTML = '';
|
||||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||||
|
|
||||||
if (!sendCode) {
|
if (!codeTarget) {
|
||||||
codeTip.textContent = '请先获取验证码!';
|
codeTip.textContent = '请先获取验证码!';
|
||||||
codeTip.classList.add('tip-red');
|
codeTip.classList.add('tip-red');
|
||||||
return false;
|
return false;
|
||||||
@@ -79,14 +77,7 @@ function checkCode() {
|
|||||||
codeTip.classList.add('tip-yellow');
|
codeTip.classList.add('tip-yellow');
|
||||||
return false;
|
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;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +93,7 @@ repeatPasswordInput.addEventListener('blur', checkPassword);
|
|||||||
emailInput.addEventListener('focus', checkEmail);
|
emailInput.addEventListener('focus', checkEmail);
|
||||||
emailInput.addEventListener('input', function () {
|
emailInput.addEventListener('input', function () {
|
||||||
checkEmail();
|
checkEmail();
|
||||||
if (sendCode) {
|
if (codeTarget) {
|
||||||
checkCode();
|
checkCode();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -112,19 +103,40 @@ codeInput.addEventListener('focus', checkCode);
|
|||||||
codeInput.addEventListener('input', checkCode);
|
codeInput.addEventListener('input', checkCode);
|
||||||
codeInput.addEventListener('blur', checkCode);
|
codeInput.addEventListener('blur', checkCode);
|
||||||
|
|
||||||
getCodeButton.addEventListener('click', function () {
|
getCodeButton.addEventListener('click', async function () {
|
||||||
|
if (getCodeButton.disabled) return; // 防重复点击
|
||||||
const email = checkEmail();
|
const email = checkEmail();
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendCode = Math.floor(100000 + Math.random() * 900000).toString();
|
getCodeButton.disabled = true;
|
||||||
codeTarget = email;
|
codeTip.textContent = '验证码发送中…';
|
||||||
codeInput.value = '';
|
|
||||||
codeTip.textContent = '验证码已发送,请填写验证码';
|
|
||||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||||
codeTip.classList.add('tip-green');
|
codeTip.classList.add('tip-yellow');
|
||||||
alert(`验证码已发送至${email},您的验证码:${sendCode}`);
|
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) {
|
form.addEventListener('submit', async function (e) {
|
||||||
@@ -139,7 +151,8 @@ form.addEventListener('submit', async function (e) {
|
|||||||
}
|
}
|
||||||
const jsonBody = JSON.stringify({
|
const jsonBody = JSON.stringify({
|
||||||
username: email,
|
username: email,
|
||||||
password
|
password,
|
||||||
|
email_code: code
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -169,7 +182,6 @@ form.addEventListener('submit', async function (e) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert('注册成功');
|
alert('注册成功');
|
||||||
form.reset();
|
form.reset();
|
||||||
sendCode = '';
|
|
||||||
codeTarget = '';
|
codeTarget = '';
|
||||||
location.href = 'login.html';
|
location.href = 'login.html';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
nuitka
|
nuitka
|
||||||
Flask>=2.3.0,<3.0.0
|
Flask>=2.3.0,<3.0.0
|
||||||
Flask-CORS>=4.0.0,<5.0.0
|
Flask-CORS>=4.0.0,<5.0.0
|
||||||
|
waitress>=2.1.2,<4.0.0
|
||||||
@@ -31,6 +31,7 @@ SERVICES = {
|
|||||||
"name": "db",
|
"name": "db",
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8005,
|
"port": 8005,
|
||||||
|
"protocol": "pymsg",
|
||||||
"description": "通用数据库服务 (CRUD+集中日志)",
|
"description": "通用数据库服务 (CRUD+集中日志)",
|
||||||
"test_file": "test_db.py",
|
"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:
|
def check_service(url: str, timeout: float = 8.0) -> tuple:
|
||||||
"""检查服务是否可达(HTTP 请求检测,与测试实际行为一致)。
|
"""检查服务是否可达(HTTP 请求检测,与测试实际行为一致)。
|
||||||
返回 (reachable: bool, detail: str)。
|
返回 (reachable: bool, detail: str)。
|
||||||
|
|||||||
+54
-89
@@ -1,45 +1,38 @@
|
|||||||
"""
|
"""
|
||||||
db 黑盒用户模拟测试 — 集中日志: 写入 → 查询 → 统计 → 清理。
|
db 黑盒用户模拟测试 — pymsg 协议 CRUD。
|
||||||
|
|
||||||
用户旅程:
|
用户旅程:
|
||||||
1. 通过 pymsg 协议执行 CRUD (select/insert)
|
1. 通过 pymsg 协议查询 Users 表
|
||||||
2. 通过 HTTP /log/ingest 写入日志
|
2. 通过 pymsg 协议查询 Logs 表
|
||||||
3. 通过 HTTP /log/query 查询日志
|
3. 通过 pymsg 协议带 where 条件查询
|
||||||
4. 通过 HTTP /log/stats 查看统计
|
4. 无效路由被拒绝
|
||||||
5. 通过 HTTP /log/health 健康检查
|
5. 日期列 where(ISO 字符串 / $now 哨兵 / 非法值拒绝)
|
||||||
|
|
||||||
注意: dbv2.py (pymsg 模式) 不挂载 Flask log_module Blueprint,
|
注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过
|
||||||
因此 HTTP /log/* 路由可能不可用。测试会优雅降级。
|
log_client.py 走 execute 路由 INSERT 写入 Logs 表。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import time
|
|
||||||
|
|
||||||
from helpers import (
|
from helpers import (
|
||||||
http_get, http_post, try_parse_json,
|
assert_true, skip, Counter, PymsgClient, check_tcp_service,
|
||||||
assert_eq, assert_status, assert_true, assert_json_key,
|
|
||||||
skip, Counter, check_service, PymsgClient,
|
|
||||||
)
|
)
|
||||||
from config import get_url
|
|
||||||
|
|
||||||
DB_URL = get_url("db")
|
|
||||||
DB_HOST = "127.0.0.1"
|
DB_HOST = "127.0.0.1"
|
||||||
DB_PORT = 8005
|
DB_PORT = 8005
|
||||||
|
|
||||||
|
|
||||||
def run(url: str = None):
|
def run(url: str = None):
|
||||||
"""db 用户模拟测试入口。返回 (passed, failed, skipped)。"""
|
"""db 用户模拟测试入口。返回 (passed, failed, skipped)。
|
||||||
du = url or DB_URL
|
|
||||||
|
url 参数仅为保持与其他测试模块的签名一致,db 固定走 127.0.0.1:8005。
|
||||||
|
"""
|
||||||
c = Counter()
|
c = Counter()
|
||||||
|
|
||||||
print(f"\n--- db 数据库与日志流程 ---\n")
|
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)
|
skip("全部测试", "db 服务不可达", c)
|
||||||
return c.passed, c.failed, c.skipped
|
return c.passed, c.failed, c.skipped
|
||||||
|
|
||||||
@@ -109,75 +102,47 @@ def run(url: str = None):
|
|||||||
"pymsg-无效路由-拒绝", c,
|
"pymsg-无效路由-拒绝", c,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# 场景 5: 日期列 where — ISO 8601 字符串(dbv2 日期适配层)
|
||||||
# 第二部分: HTTP /log/* 接口 (Flask log_module)
|
result = client.call({
|
||||||
# ------------------------------------------------------------------
|
"_route": "execute",
|
||||||
print("\n [HTTP /log/* 接口测试]")
|
"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 是否挂载)
|
# 场景 6: 日期列 where — $now 哨兵
|
||||||
status, body, _ = http_get(f"{du}/log/health")
|
result = client.call({
|
||||||
if status == 0 or status >= 500:
|
"_route": "execute",
|
||||||
skip("HTTP /log/* 测试", "log_module 未挂载 (dbv2 使用 pymsg 模式, 非 Flask)", c)
|
"action": "select",
|
||||||
else:
|
"table": "Users",
|
||||||
# 场景 5: /log/health 健康检查
|
"columns": ["id", "username", "create_at"],
|
||||||
assert_eq(status, 200, "log-健康检查 (200)", c)
|
"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 写入日志
|
# 场景 7: 日期列 where — 非法日期字符串应报错
|
||||||
test_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
result = client.call({
|
||||||
status, body, _ = http_post(f"{du}/log/ingest", {
|
"_route": "execute",
|
||||||
"logs": [{
|
"action": "select",
|
||||||
"service": "dnfs",
|
"table": "Users",
|
||||||
"level": "INFO",
|
"where": {"create_at": {"lt": "not-a-date"}},
|
||||||
"message": f"blackbox_test_{random.randint(10000, 99999)}",
|
})
|
||||||
"ts": test_ts,
|
assert_true(
|
||||||
}],
|
result.get("success") is False,
|
||||||
})
|
"pymsg-日期列where-非法值-拒绝", c,
|
||||||
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}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 汇总
|
# 汇总
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import sys
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from config import SERVICES, PORT_MAP, detect_service, get_url
|
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 = {
|
TEST_MODULES = {
|
||||||
"gateway": "test_gateway",
|
"gateway": "test_gateway",
|
||||||
@@ -53,7 +53,11 @@ def run_service_test(name: str, url: str) -> tuple:
|
|||||||
print(f" {svc['description']} ({url})")
|
print(f" {svc['description']} ({url})")
|
||||||
print(f"{'='*60}")
|
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:
|
if not reachable:
|
||||||
print(f" [WARN] 服务不可达: {url}")
|
print(f" [WARN] 服务不可达: {url}")
|
||||||
if detail:
|
if detail:
|
||||||
|
|||||||
+13
-13
@@ -2,25 +2,25 @@
|
|||||||
# ID 段:100000-199999
|
# ID 段:100000-199999
|
||||||
|
|
||||||
# 100001: 拦截 SQL 注入特征(UNION SELECT)
|
# 100001: 拦截 SQL 注入特征(UNION SELECT)
|
||||||
SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \
|
#SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \
|
||||||
"id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'"
|
# "id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'"
|
||||||
|
|
||||||
# 100002: 拦截 SQL 注释符
|
## 100002: 拦截 SQL 注释符
|
||||||
SecRule ARGS "@rx (?i)(--|#|/\*.*\*/)" \
|
#SecRule ARGS "@rx (?i)(--|#|/\*.*\*/)" \
|
||||||
"id:100002,phase:2,deny,status:403,log,msg:'SQL injection: comment'"
|
# "id:100002,phase:2,deny,status:403,log,msg:'SQL injection: comment'"
|
||||||
|
|
||||||
# 100003: 拦截 XSS 标签
|
# 100003: 拦截 XSS 标签
|
||||||
SecRule ARGS|REQUEST_HEADERS "@rx (?i)<script|javascript:|onerror=|onload=" \
|
#SecRule ARGS|REQUEST_HEADERS "@rx (?i)<script|javascript:|onerror=|onload=" \
|
||||||
"id:100003,phase:2,deny,status:403,log,msg:'XSS probe'"
|
# "id:100003,phase:2,deny,status:403,log,msg:'XSS probe'"
|
||||||
|
|
||||||
# 100004: 路径遍历
|
# 100004: 路径遍历
|
||||||
SecRule REQUEST_URI|ARGS "@rx (?i)\.\./|\.\.%2f|\.\.%5c" \
|
#SecRule REQUEST_URI|ARGS "@rx (?i)\.\./|\.\.%2f|\.\.%5c" \
|
||||||
"id:100004,phase:1,deny,status:403,log,msg:'Path traversal'"
|
# "id:100004,phase:1,deny,status:403,log,msg:'Path traversal'"
|
||||||
|
|
||||||
# 100005: 命令注入常见关键字
|
# 100005: 命令注入常见关键字
|
||||||
SecRule ARGS "@rx (?i)(;|\|)&?\s*(cat|ls|id|whoami|uname|wget|curl|bash|sh)\s" \
|
#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'"
|
# "id:100005,phase:2,deny,status:403,log,msg:'Command injection'"
|
||||||
|
|
||||||
# 100006: 已知恶意扫描器 UA
|
# 100006: 已知恶意扫描器 UA
|
||||||
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(nikto|sqlmap|nmap|masscan|acunetix|nessus)" \
|
#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'"
|
# "id:100006,phase:1,deny,status:403,log,msg:'Malicious scanner UA'"
|
||||||
Reference in new issue
Block a user