Merge pull request 'Dev qing' (#13) from dev_qing into main

Reviewed-on: qing/micro_servers#13
This commit is contained in:
qing committed 2026-07-29 14:39:15 +08:00
commit 2a6d68386d
48 files changed
+3494 -1017

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
venv/
# nuitka 编译产物
botops.bin
app.build/
app.dist/
app.onefile-build/
+46
View File
@@ -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`
+13
View File
@@ -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
View File
@@ -0,0 +1,4 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
source venv/bin/activate
exec python3 app.py $@
Executable
+5
View File
@@ -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
View File
@@ -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()
+62
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
textual>=0.60.0
websockets>=11.0
nuitka
View File
Whitespace-only changes.
+90
View File
@@ -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()
+14
View File
@@ -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)
+148
View File
@@ -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
+9 -9
View File
@@ -31,7 +31,7 @@
1. 客户端所有请求先打到 `waf:443`(HTTPS),WAF 终止 TLS、运行 coraza 规则匹配,命中则直接返回 403。
2. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `gateway:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。
3. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。
3. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制;注册需邮箱验证码,经 pymsg/TCP 调 `smtp:8008` 发送与校验),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。
4. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。
5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。
6. `dnfs` 处理节点增删改查时,通过 pymsg/TCP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。
@@ -49,7 +49,7 @@
| [db](db/) | Python(pymsg) | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,SQLAlchemy Core 参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) |
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 |
| [smtp](smtp/) | Python(Flask) | 8008 | 邮件服务,发送/校验邮箱验证码(/send_email、/verify_email),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
| [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
### core(核心模块 · 进程托管服务)
@@ -68,18 +68,18 @@
- 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。
- 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。
### gateway(应用层网关 · v0.4.3)
### gateway(应用层网关 · v0.5.2)
- 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。
- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。
- **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),常数时间比较防 timing attack。
- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite。
- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite;登录成功即更新 `Users.last_login`(`"$now"` 哨兵)。
- **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期,验签通过才放行;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效。
- **Cookie 认证**:登录成功同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie;代理时无 `Authorization` 头则回退读 Cookie 验签,并向下游注入 `Authorization: Bearer`(下游零改动);`POST /logout` 清除 Cookie。公开目标(`Proxy.default`)不做凭证校验,避免旧 Cookie 影响公开页面。
- **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用;`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由。
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。
- **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中(`closed` 仅记录用途,实际隔离靠"不在 opened 中即拒绝"),缺省走 `Proxy.default`(不需 Token,服务前端静态资源)。
- **自动建表**:启动时若 `Users` 表不存在则自动创建,首位注册者即为管理员。
- **用户表**:`Users` 表由 [db/create_sqlite3.sql](db/create_sqlite3.sql) 建立(gateway 不再自动建表),无默认管理员账号,首位注册者即为管理员。
- 详见 [gateway/README.md](gateway/README.md)。
### static(前端托管服务)
@@ -107,12 +107,12 @@
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
- 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。
- 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。
- **日期/时间列适配**:values 与 where 中的 DATETIME/DATE/TIME 列接受 ISO 8601 字符串(带时区偏移自动转 UTC)或哨兵值 `"$now"`(服务端当前 UTC);响应中的日期时间一律序列化为 ISO 8601(T 分隔)。
- **内置日志子模块**:
- 启动时自动建 `Logs` 表(service / level / message / trace_id / extra + 4 个索引)。
- 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。
- `_log_internal()` — db 进程内部直接写 Logs 表,无网络往返,防递归设计。
- 后台守护线程每小时自动清理:保留 30 天 / 上限 100 万条。
- 注:`db/log_module.py`(Flask `/log/*` Blueprint)为旧版 HTTP 接口的遗留文件,当前 dbv2 未挂载。
- **统一日志客户端 SDK**(各服务目录下的 `log_client.py`,如 [dnfs/log_client.py](dnfs/log_client.py)):各服务复制即用,纯标准库实现,异步非阻塞(内存队列 + 后台线程,50 条/2 秒批量 flush),fail-safe 设计(db 不可用时静默丢弃,不影响业务)。
### ai_review(AI 内容审核服务)
@@ -135,9 +135,9 @@
### smtp(邮件服务)
- 注册流程的邮箱验证码后端,基于 Flask + waitress,监听 127.0.0.1:8008。
- 接口:`POST /send_email`(生成 6 位验证码并经 SMTP_SSL 发送至收件人,60 秒有效)、`POST /verify_email`(校验)。
- 验证码存于进程内存,SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`)。
- 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,60 秒有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。
- 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。
### aibox(独立 AI 智能体系统)
+179 -21
View File
@@ -9,11 +9,20 @@ import sys
import os
import threading
import hashlib
from collections import OrderedDict
from datetime import datetime
from typing import Optional, Dict, Any, List
import websockets
from colorama import init, Fore, Style as ColoramaStyle
# rich 为可选依赖:有则 AI 回复渲染为 Markdown,无则回退纯文本流式输出
try:
from rich.console import Console
from rich.markdown import Markdown
_RICH = True
except ImportError:
_RICH = False
init(autoreset=True)
class Colors:
@@ -75,10 +84,15 @@ class WebSocketClient:
except json.JSONDecodeError:
self.callback("chunk", message)
except websockets.exceptions.ConnectionClosed:
self.connected = False
self.callback("system", "❌ 连接已断开")
pass
except Exception as e:
self.callback("error", f"接收消息错误: {e}")
finally:
# 统一在此通知断线;手动 disconnect() 已把 connected 置 False,不会误触发自动重连
was_connected = self.connected
self.connected = False
if was_connected:
self.callback("disconnected", "❌ 连接已断开")
async def _handle_message(self, data: dict):
msg_type = data.get('type', '')
@@ -134,12 +148,25 @@ class SimpleCLI:
self.ai_full = ""
self.ai_prefix_shown = False
# 已显示消息的去重
self.displayed_messages: set = set()
# 已显示消息的去重(LRU 上限,防长对话内存无限增长)
self.DISPLAYED_MAX = 500
self.displayed_messages: OrderedDict = OrderedDict()
# 锁和状态
self.lock = threading.Lock()
self.current_input = ""
# 标准输入:单守护线程阻塞读 + asyncio.Queue 中转(避免 Ctrl+C 泄漏线程)
self._input_queue: asyncio.Queue = asyncio.Queue()
self._stdin_thread: Optional[threading.Thread] = None
# 自动重连状态
self._manual_disconnect = False # 用户主动 /disconnect、/quit、/connect 后置 True,抑制自动重连
self._reconnect_task: Optional[asyncio.Task] = None
# Markdown 渲染(rich 可用时启用)
self._console = Console() if _RICH else None
self._md_generating_shown = False # "生成中…"状态行是否已显示
# 流式输出状态
self.streaming_line = False # 是否正在流式输出行
@@ -232,16 +259,23 @@ class SimpleCLI:
"""流式显示增量内容"""
if not content:
return
# 清理内容
clean = self._clean_content(content)
if not clean:
return
key = clean[:200]
if key in self.displayed_messages:
return
# rich 可用:流式期间只显示状态行并累计全文,complete 时一次性渲染
if _RICH:
self._md_show_generating()
self.ai_previous = clean
self.ai_full = clean
return
# 检测增量
if self.ai_previous and clean.startswith(self.ai_previous):
new_part = clean[len(self.ai_previous):]
@@ -275,6 +309,39 @@ class SimpleCLI:
self.ai_previous = clean
self.ai_full = clean
# ── Markdown 渲染(rich)─────────────────────────────────────────────
def _md_show_generating(self):
"""流式期间显示一次性状态行(不实时渲染,避免 Live 闪烁/被卷走)。"""
if not _RICH:
return
with self.lock:
if not self._md_generating_shown:
sys.stdout.write('\n')
sys.stdout.write(f"{Colors.AI}🤖 AI助手: 生成中…{Colors.RESET}")
sys.stdout.flush()
self._md_generating_shown = True
self.streaming_line = True
self.prompt_shown = False
def _md_render_final(self, text: str):
"""complete 时一次性静态渲染最终 Markdown(先擦掉"生成中…"行)。"""
if not _RICH:
return
with self.lock:
if self._md_generating_shown:
sys.stdout.write('\r' + ' ' * 40 + '\r')
self._md_generating_shown = False
self.streaming_line = False
if text:
self._console.print(Markdown(text))
def _remember_displayed(self, key: str):
"""记录已显示消息(LRU:超 DISPLAYED_MAX 淘汰最旧,防长对话内存膨胀)。"""
self.displayed_messages[key] = None
self.displayed_messages.move_to_end(key)
if len(self.displayed_messages) > self.DISPLAYED_MAX:
self.displayed_messages.popitem(last=False)
def display(self, msg_type: str, content: str, extra: Any = None):
"""显示消息"""
if msg_type == 'chunk':
@@ -302,6 +369,11 @@ class SimpleCLI:
elif msg_type == 'system':
self._finish_stream()
self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}")
elif msg_type == 'disconnected':
self.connected = False
self._finish_stream()
self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}")
self._schedule_reconnect()
elif msg_type == 'info':
self._finish_stream()
self._print_line(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}")
@@ -323,7 +395,7 @@ class SimpleCLI:
sys.stdout.write(f"{Colors.TASK}{content}{Colors.RESET}\n")
sys.stdout.flush()
return
# 检查是否是工具结果
if content.startswith(('✅ 对话记忆已保存', '💾 保存记忆', '❌', '⚠️')):
with self.lock:
@@ -350,6 +422,25 @@ class SimpleCLI:
def _handle_complete(self, content: str):
"""处理完成事件 - 只负责结束流式输出,不重复显示内容"""
# rich 渲染路径:擦掉"生成中…",一次性渲染最终 Markdown,恢复提示符
if _RICH:
clean = self._clean_content(content) if content else ""
self._md_render_final(clean)
if clean:
self._remember_displayed(clean[:200])
with self.lock:
self.in_tool_phase = False
self.waiting_for_response = False
self.streaming_line = False
self.prompt_shown = True
current = self.current_input
sys.stdout.write(self._get_prompt() + current)
sys.stdout.flush()
self.ai_previous = ""
self.ai_full = ""
self.ai_prefix_shown = False
return
# 如果之前在工具阶段,现在退出
if self.in_tool_phase:
with self.lock:
@@ -406,7 +497,7 @@ class SimpleCLI:
return
# 只有当内容没有被流式显示时才显示完整消息
self.displayed_messages.add(key)
self._remember_displayed(key)
# 如果之前没有流式输出,现在显示完整消息
self._print_line(f"{Colors.AI}🤖 AI助手: {clean}")
@@ -466,7 +557,7 @@ class SimpleCLI:
clean = content.strip()
key = clean[:200]
if key not in self.displayed_messages:
self.displayed_messages.add(key)
self._remember_displayed(key)
self._print_line(f"{Colors.TASK}⚡ 任务 - {task_title}: {clean}")
del self._task_buffers[task_title]
self.streaming_line = False
@@ -519,10 +610,15 @@ class SimpleCLI:
self.display("error", "用法: /connect ws://host:port")
return
uri = parts[1]
# 切换服务器:抑制旧连接的自动重连
self._manual_disconnect = True
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
if self.client and self.client.connected:
await self.client.disconnect()
self.client = WebSocketClient(uri, self.display)
if await self.client.connect():
self._manual_disconnect = False
self.connected = True
self.display("system", f"✅ 已连接到 {uri}")
asyncio.create_task(self.client.receive_messages())
@@ -530,6 +626,9 @@ class SimpleCLI:
self.client = None
self.connected = False
elif cmd == '/disconnect':
self._manual_disconnect = True # 主动断开,不自动重连
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
if self.client:
await self.client.disconnect()
self.client = None
@@ -556,6 +655,9 @@ class SimpleCLI:
elif cmd == '/quit':
self.display("system", "👋 再见!")
self.running = False
self._manual_disconnect = True
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
if self.client:
await self.client.disconnect()
return
@@ -569,6 +671,35 @@ class SimpleCLI:
self.display("error", f"未知命令: {cmd}")
self._print_prompt(clear_input=True)
def _schedule_reconnect(self):
"""启动自动重连(若未手动断开且当前无进行中的重连任务)。"""
if self._manual_disconnect or not self.running or not self.client:
return
if self._reconnect_task and not self._reconnect_task.done():
return
try:
self._reconnect_task = asyncio.create_task(self._auto_reconnect_loop(self.client))
except RuntimeError:
pass # 事件循环正在关闭(程序退出中),放弃重连
async def _auto_reconnect_loop(self, client: WebSocketClient):
"""断线自动重连:指数退避 2s→30s 封顶,直到成功、/disconnect 或退出。"""
delay = 2
while self.running and not self._manual_disconnect and self.client is client:
self.display("info", f"🔄 {delay}s 后自动重连 {client.uri} …(/disconnect 取消)")
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
if not self.running or self._manual_disconnect or self.client is not client:
return
if await client.connect():
self.connected = True
self.display("system", f"✅ 已自动重连到 {client.uri}")
asyncio.create_task(client.receive_messages())
return
delay = min(delay * 2, 30)
async def _send_interrupt(self):
if self.client and self.client.connected:
interrupt_msg = {"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"}
@@ -581,7 +712,7 @@ class SimpleCLI:
if not self.client or not self.client.connected:
self.display("error", "未连接到服务器")
return
# 重置流式状态
with self.lock:
if self.streaming_line:
@@ -601,30 +732,57 @@ class SimpleCLI:
await self.client.send_message(message)
# 不在这里显示提示符,等待AI响应结束后再显示
def _stdin_reader(self, loop: asyncio.AbstractEventLoop):
"""独立守护线程:阻塞读 stdin,经 call_soon_threadsafe 投入队列。
进程生命周期只启动一次——替代原 run_in_executor 每行一个线程的方案,
避免 Ctrl+C 取消 await 后旧线程仍阻塞在 readline 上无限泄漏。
"""
def _put(item):
try:
loop.call_soon_threadsafe(self._input_queue.put_nowait, item)
except RuntimeError:
pass # 事件循环已关闭(程序退出中)
while self.running:
try:
line = sys.stdin.readline()
except Exception:
break
if not line: # EOF (Ctrl+D)
_put(None)
break
_put(line)
async def run(self):
self.print_banner()
self._task_buffers = {}
loop = asyncio.get_running_loop()
if self._stdin_thread is None:
self._stdin_thread = threading.Thread(
target=self._stdin_reader, args=(loop,),
daemon=True, name="stdin-reader")
self._stdin_thread.start()
while self.running:
try:
line = await asyncio.get_event_loop().run_in_executor(
None, lambda: sys.stdin.readline()
)
if not line:
line = await self._input_queue.get()
if line is None: # EOF (Ctrl+D)
break
self.current_input = line.rstrip('\n')
line = line.strip()
if not line:
self._print_prompt(clear_input=True)
continue
if line.startswith('/'):
await self.handle_command(line)
else:
await self.send_user_message(line)
except KeyboardInterrupt:
if self.client and self.client.connected:
await self._send_interrupt()
+12 -6
View File
@@ -56,28 +56,34 @@ def generate_text():
def generate_image(text):
width, height = 160, 50
bg = random.choice([(255, 255, 255), (240, 240, 240), (230, 240, 250)])
bg = (random.randint(0,256), random.randint(0,256), random.randint(0,256))
img = Image.new('RGB', (width, height), bg)
draw = ImageDraw.Draw(img)
for i, ch in enumerate(text):
color = random.choice([(30, 30, 30), (50, 80, 150), (150, 50, 50), (50, 130, 50)])
color = (random.randint(0,256), random.randint(0,256), random.randint(0,256))
y = random.randint(2, 12)
x = 15 + i * 32 + random.randint(-3, 3) # 4 位字符在 160px 宽度内均匀分布
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
random.choice([28, 30, 32, 34]))
random.choice(range(20,40,2)))
except (IOError, OSError):
font = ImageFont.load_default()
draw.text((x, y), ch, fill=color, font=font)
char = Image.new("RGBA", (64, 64), (0,0,0,0))
draw_char = ImageDraw.Draw(char)
draw_char.text((20,20), ch, fill=color, font=font)
rotated_char = char.rotate(random.randint(-30, 30), expand=True)
img.paste(rotated_char, (x-32, y-32), mask=rotated_char)
# draw.text((x, y), ch, fill=color, font=font)
for _ in range(4):
draw.line([(random.randint(0, width), random.randint(0, height)),
(random.randint(0, width), random.randint(0, height))],
fill=random.choice([(30, 30, 30), (50, 80, 150)]), width=1)
fill=(random.randint(0,256), random.randint(0,256), random.randint(0,256)))
for _ in range(50):
draw.point((random.randint(0, width - 1), random.randint(0, height - 1)),
fill=random.choice([(30, 30, 30), (50, 80, 150)]))
fill=(random.randint(0,256), random.randint(0,256), random.randint(0,256)))
buf = io.BytesIO()
img.save(buf, format='PNG')
+126 -187
View File
@@ -1,62 +1,55 @@
# 跨数据库通用查询服务(基于 SQLAlchemy Core)
# 通用数据库服务 db(pymsg/TCP + SQLAlchemy Core)
一个安全的 HTTP 服务,通过结构化 JSON 描述查询意图,后端自动转换为对应数据库的 SQL,无需手写 SQL,即可无缝切换 SQLite、PostgreSQL、MySQL 等数据库。
一个安全的数据库访问服务。各业务服务通过自研 pymsg 协议(TCP 长度前缀帧 + JSON)发送结构化查询意图,db 翻译为 SQL 执行,全程参数绑定,业务侧无需直连数据库、无需手写 SQL。
## 功能特点
- **跨数据库**:基于 SQLAlchemy Core 表达式语言,一套接口适配多种数据库
- **跨数据库**:基于 SQLAlchemy Core 表达式语言,一套接口适配 SQLite / PostgreSQL / MySQL
- **零 SQL 注入**:所有条件、值均通过 SQLAlchemy 参数绑定处理
- **统一 JSON 接口**:`action` + `table` + `conditions` 模式
- **支持增删改查**:select, insert, update, delete 全覆盖
- **单线程本地监听**:waitress + 127.0.0.1:8005,简单可靠
- **统一 JSON 接口**:`_route` + `action` + `table` + `where` 模式
- **支持增删改查**:select / insert / update / delete 全覆盖
- **内置日志表**:启动自动建 Logs 表,供全系统集中写日志;带后台自动清理
- **仅监听本机**:默认 `127.0.0.1:8005`,不对外暴露
## 快速开始
### 安装依赖
```bash
pip install flask sqlalchemy waitress
# 根据需要安装数据库驱动,例如:
# pip install psycopg2-binary # PostgreSQL
# pip install pymysql # MySQL
./_set.sh # 建 venv 并安装 requirements.txt(SQLAlchemy + 本地 ../pymsg)
```
### 启动服务(默认 SQLite)
```bash
python app.py
```
服务运行在 `http://127.0.0.1:8005`。
### 启动服务
使用其他数据库:
```bash
export DATABASE_URL="postgresql://user:pass@localhost/dbname"
python app.py
./_run.sh # venv 中执行 python3 dbv2.py
```
**注意**:新接口依赖表已存在(通过反射获取结构)。首次使用时请先手动建表:
```bash
sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"
```
服务监听 `tcp://127.0.0.1:8005`。生产环境由 core(Nidus)按 init.txt 统一拉起,守护靠 systemd。
## API 文档
**注意**:`execute` 路由依赖表已存在(通过反射获取结构)。建表见 `create_sqlite3.sql`(Users / Notes;Logs 表由服务启动时自建)。
### 请求格式
## 协议与 API
**端点**:`POST /execute`
**Content-Type**:`application/json`
### 传输协议(pymsg)
每条消息为一个完整 JSON,帧格式:`[4 字节大端长度][UTF-8 JSON]`。
请求中的 `_route` 字段指定路由,目前仅有一个路由:**`execute`**。
### execute 路由 — 通用 CRUD
```json
{
"action": "操作类型", // select | insert | update | delete
"table": "表名",
"columns": ["列1", "列2"], // select/insert 时可选
"values": { // insert/update 时必填
"列名": "值"
},
"where": { // 条件,支持组合操作符
"_route": "execute",
"action": "select", // select | insert | update | delete
"table": "Users",
"columns": ["id", "name"], // select 时可选(缺省全列)
"values": {"name": "Bob"}, // insert / update 时必填
"where": { // 可选,条件见下表
"age": {"ge": 18, "lt": 60},
"name": "Alice"
},
"order_by": [{"column": "age", "desc": true}],
"order_by": [{"column": "id", "desc": true}],
"limit": 10,
"offset": 0
}
@@ -84,48 +77,80 @@ sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age
```json
{
"success": true,
"data": [ // 查询结果数组(insert/update/delete 时包含影响行数或 lastrowid)
{"id": 1, "name": "Alice"}
"data": [
{"id": 1, "username": "Alice", "create_at": "2026-07-28T14:30:00"}
]
}
```
- select → 行对象数组
- insert → `[{"lastrowid": N}]`
- update / delete → `[{"rowcount": N}]`
失败:
```json
{"success": false, "error": "错误描述"}
```
## 日期/时间列的处理
JSON 没有日期类型,db 内置了类型适配层:按反射得到的列类型自动在
ISO 8601 字符串与数据库 DATETIME / DATE / TIME 之间转换。
**写入(values / where 条件值)**:
| 输入 | 说明 |
|------|------|
| `"2026-07-28T22:30:00"` | ISO 8601(T 分隔),推荐 |
| `"2026-07-28 22:30:00"` | 空格分隔同样接受(SQLite 原生格式) |
| `"2026-07-28T22:30:00+08:00"` | 带时区偏移:自动转 UTC 后存储 |
| `"2026-07-28T14:30:00Z"` | Z 后缀等同 +00:00 |
| `"$now"` | 哨兵值:服务端当前 UTC 时间(DATETIME/DATE 列可用) |
存储统一为 UTC 朴素时间(无时区)。非法日期字符串会返回明确的错误:
`{"success": false, "error": "列 last_login 的日期时间值无法解析(需 ISO 8601 或 \"$now\"): ..."}`
**读取(select 响应)**:日期时间值一律序列化为 ISO 8601(T 分隔),
如 `"2026-07-28T22:30:00"`,客户端可用 `datetime.fromisoformat()` 直接解析。
**注意**:`like` 运算符是字符串模式匹配,对日期列使用时不做转换。
```json
// 示例:登录后更新 last_login 为服务端当前时间
{
"success": false,
"error": "错误描述"
"_route": "execute",
"action": "update",
"table": "Users",
"values": {"last_login": "$now"},
"where": {"username": "alice"}
}
```
## 使用示例(curl)
## 使用示例(Python + pymsg)
**插入**:
```bash
curl -X POST http://127.0.0.1:8005/execute \
-H "Content-Type: application/json" \
-d '{"action":"insert","table":"users","values":{"name":"Bob","age":25}}'
```
```python
import json
from pymsg import MsgClient
**查询**:
```bash
curl -X POST http://127.0.0.1:8005/execute \
-H "Content-Type: application/json" \
-d '{"action":"select","table":"users","where":{"age":{"gt":20}},"order_by":[{"column":"id","desc":true}],"limit":5}'
```
client = MsgClient("127.0.0.1", 8005, "myservice")
**更新**:
```bash
curl -X POST http://127.0.0.1:8005/execute \
-H "Content-Type: application/json" \
-d '{"action":"update","table":"users","values":{"age":26},"where":{"name":"Bob"}}'
```
# 查询
resp = client.call(json.dumps({
"_route": "execute",
"action": "select",
"table": "Users",
"where": {"id": {"gt": 0}},
"order_by": [{"column": "id", "desc": True}],
"limit": 5,
}))
print(json.loads(resp))
**删除**:
```bash
curl -X POST http://127.0.0.1:8005/execute \
-H "Content-Type: application/json" \
-d '{"action":"delete","table":"users","where":{"id":{"in":[1,2]}}}'
# 插入
client.call(json.dumps({
"_route": "execute",
"action": "insert",
"table": "Users",
"values": {"username": "bob", "password_hash": "..."},
}))
```
## 安全机制
@@ -133,17 +158,17 @@ curl -X POST http://127.0.0.1:8005/execute \
- 强制使用 SQLAlchemy 参数绑定,杜绝 SQL 注入。
- 仅反射已有表,无法创建或删除表(保证结构安全)。
- 所有异常自动回滚事务。
- 默认监听 `127.0.0.1`,仅本机可访问。
- 仅监听 `127.0.0.1`,仅本机服务可访问。
## 内置日志模块
## 内置日志能力
db 服务内置了统一日志收集能力,各业务服务通过 HTTP 推送日志到 `/log/*` 系列接口,db 自身的操作日志通过 `log_internal()` 直接写入(同进程,无 HTTP 往返,无循环依赖)。
db 内置统一日志收集:全系统各服务(waf / gateway / static / dnfs / db / ai_review / captcha / core / smtp)的日志集中写入 db 的 `Logs` 表。
### 为什么集成在 db 而不是独立服务
独立 logs 服务会导致**循环调用**:db 记日志→调 logs,logs 存库→调 db,无限递归。集成在 db 内部后,db 自己记日志直接写表,彻底消除循环依赖。
### 日志表
### 日志表(启动时自建)
```sql
CREATE TABLE IF NOT EXISTS Logs (
@@ -155,152 +180,66 @@ CREATE TABLE IF NOT EXISTS Logs (
trace_id TEXT,
extra TEXT
);
-- 索引: (service,ts) / (level,ts) / (ts) / (trace_id)
```
- `service`:来源服务名(`waf`/`gateway`/`static`/`dnfs`/`db`/`ai_review`/`captcha`/`core`)
- `level`:`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`
- `level`:`DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`
- `trace_id`:可选,请求追踪 ID
- `extra`:可选,JSON 字符串,存放附加结构化字段
### 接口
### 写入方式
| 接口 | 方法 | 说明 |
|------|------|------|
| `/log/ingest` | POST | 推送日志(支持批量) |
| `/log/query` | POST | 查询日志(按服务/级别/时间/关键字过滤) |
| `/log/stats` | POST | 统计聚合(按 service/level 分组计数) |
| `/log/purge` | POST | 清理过期日志 |
| `/log/health` | GET | 健康检查 |
**外部服务**:复制 `example_log_client.py` 为各自目录下的 `log_client.py`,改 `service_name` 即用。客户端底层通过 pymsg 调 `execute` 路由逐条 INSERT 到 Logs 表:
所有接口的响应格式与 `/execute` 统一:`{success, data, error}`。
```python
from log_client import logs
#### POST /log/ingest
```json
请求:
{
"logs": [
{
"service": "dnfs",
"level": "INFO",
"message": "节点新增成功 id=42",
"ts": "2026-07-25T08:30:00.123Z",
"trace_id": "req-abc-123",
"extra": {"path": "/add_note", "status": 200}
}
]
}
响应:
{"success": true, "data": {"inserted": 1}}
logs.info("dnfs", "节点新增成功 id=42")
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
logs.push("gateway", "WARNING", "登录失败次数超限",
trace_id="req-abc", extra={"ip": "1.2.3.4"})
```
- `ts` 可选,缺省自动填当前 UTC 时间
- 单次最多 100 条
- `message` 上限 8KB,`extra` 上限 4KB
客户端特性:
- 纯标准库实现,异步非阻塞:内存队列 + 后台线程批量推送
- fail-safe:db 不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
#### POST /log/query
**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归)。
```json
请求:
{
"service": "dnfs", // 可选,支持数组匹配多个
"level": "WARNING", // 可选,支持数组匹配多个
"since": "2026-07-25T00:00:00Z", // 可选
"until": "2026-07-25T23:59:59Z", // 可选
"keyword": "节点", // 可选,message 模糊搜索
"trace_id": "req-abc-123", // 可选
"order_by": "ts", // 默认 ts,可选 id/service/level
"desc": true, // 默认 true(最新在前)
"limit": 50, // 默认 100,上限 1000
"offset": 0 // 分页偏移
}
### 查询日志
响应:
{
"success": true,
"data": {
"total": 128,
"logs": [{"id":1024,"ts":"...","service":"dnfs","level":"INFO","message":"...","trace_id":"...","extra":{...}}]
}
}
```
日志查询直接走 `execute` 路由 select Logs 表,或用工具脚本 `_view_db.sqlite3.py`:
#### POST /log/stats
```json
请求:
{"since":"...","until":"...","group_by":"service"}
响应:
{
"success": true,
"data": {
"groups": [{"service":"dnfs","level":"INFO","count":320}],
"total": 325
}
}
```
`group_by` 支持 `service` / `level` / `service,level`。
#### POST /log/purge
```json
请求: {"before": "2026-07-18T00:00:00Z"}
响应: {"success": true, "data": {"deleted": 1523}}
```
#### GET /log/health
```json
{"success": true, "data": {"status": "ok", "count": 10240}}
```bash
python3 _view_db.sqlite3.py # 列出所有表及行数
python3 _view_db.sqlite3.py Logs -n 20 # 最近 20 条日志
python3 _view_db.sqlite3.py Logs -w "level='ERROR'"
python3 _view_db.sqlite3.py -s "SELECT service, COUNT(*) FROM Logs GROUP BY service"
```
### 自动清理
- 服务启动时开启后台守护线程,每 1 小时清理一次
- 默认保留 30 天日志,超期自动删除
- 日志总条数超过 100 万时,按时间倒序保留最新 N 条
### 各服务接入方式
把 `db/log_client.py` 复制到各服务目录(或加入 `PYTHONPATH`),即可使用:
```python
from log_client import logs
# 替换原来的 print
logs.info("dnfs", "节点新增成功 id=42")
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
# 带追踪 ID
logs.push("gateway", "WARNING", "登录失败次数超限",
trace_id="req-abc", extra={"ip": "1.2.3.4"})
```
特性:
- 使用 `urllib.request`(标准库,不引入 requests)
- 异步非阻塞:内存队列 + 后台线程批量推送
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
### 防递归设计
| 场景 | 调用路径 | 是否安全 |
|------|---------|---------|
| 外部服务记日志 | `服务 → POST /log/ingest → 写 Logs 表` | 安全 |
| db 自己记日志 | `/execute → log_internal() → 直接写 Logs 表` | 安全(无 HTTP) |
| /log/ingest 成功 | `写入成功 → 直接返回` | 安全(不再记日志) |
| /log/ingest 失败 | `写入失败 → log_internal() → 直接写表` | 安全(log_internal 不回调 ingest) |
- 日志总条数超过 100 万时,按时间倒序保留最新 100 万条
## 切换数据库
只需修改环境变量 `DATABASE_URL`,并安装对应驱动:
- SQLite:`sqlite:///database.db`
- PostgreSQL:`postgresql://user:pass@localhost/dbname` (需 `pip install psycopg2`)
- MySQL:`mysql+pymysql://user:pass@localhost/dbname` (需 `pip install pymysql`)
- SQLite(默认):`sqlite:///database.db`
- PostgreSQL:`postgresql://user:pass@localhost/dbname`(需 `pip install psycopg2`)
- MySQL:`mysql+pymysql://user:pass@localhost/dbname`(需 `pip install pymysql`)
SQLAlchemy 会自动生成适配各数据库的 SQL 方言,无需修改接口调用。
## 文件说明
| 文件 | 说明 |
|------|------|
| `dbv2.py` | 服务主程序(pymsg/TCP,唯一在跑的实现) |
| `example_log_client.py` | 日志客户端 SDK 模板,各服务复制为 `log_client.py` 使用 |
| `create_sqlite3.sql` | Users / Notes / Logs 建表脚本 |
| `_view_db.sqlite3.py` | 非交互式查看 database.db 的工具脚本 |
| `_set.sh` / `_run.sh` | 环境安装 / 启动脚本 |
+88 -10
View File
@@ -18,18 +18,26 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
支持的路由::
execute → 通用 CRUD(select / insert / update / delete)
日期/时间列::
values 与 where 中的 DATETIME / DATE / TIME 列接受 ISO 8601 字符串
(如 "2026-07-28T22:30:00",可带时区偏移,统一转 UTC 存储),
或哨兵值 "$now"(服务端当前 UTC 时间)。
响应中的日期时间一律序列化为 ISO 8601(T 分隔)。
"""
import json
import os
import time
import traceback
from datetime import datetime, timezone, timedelta
from datetime import datetime, date, time as dt_time, timezone, timedelta
from sqlalchemy import (
create_engine, MetaData, Table,
select, insert, update, delete, func, and_,
)
from sqlalchemy import types as sa_types
from sqlalchemy.exc import SQLAlchemyError
# pymsg 导入
@@ -50,6 +58,16 @@ DEFAULT_RETENTION_DAYS = 30
DEFAULT_MAX_RECORDS = 1_000_000
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒
#: where/values 中的哨兵值:服务端当前 UTC 时间
NOW_SENTINEL = "$now"
def _json_default(o):
"""json.dumps 兜底序列化:日期时间一律 ISO 8601(T 分隔),其余 str()。"""
if isinstance(o, (datetime, date, dt_time)):
return o.isoformat()
return str(o)
# ═══════════════════════════════════════════════════════════════════════
# DBServer — 主服务类
@@ -113,7 +131,8 @@ class DBServer:
resp["data"] = data
if error is not None:
resp["error"] = error
self._server.reply(sock, json.dumps(resp, ensure_ascii=False, default=str))
self._server.reply(sock, json.dumps(resp, ensure_ascii=False,
default=_json_default))
# ── 表反射 ---------------------------------------------------------
def _get_table(self, table_name):
@@ -121,6 +140,54 @@ class DBServer:
Table(table_name, self.metadata, autoload_with=self.engine)
return self.metadata.tables[table_name]
# ── 日期/时间类型适配 ------------------------------------------------
def _coerce_value(self, column, value):
"""按反射列类型把 JSON 值适配为 SQLAlchemy 需要的 Python 对象。
JSON 没有日期类型,客户端只能传字符串;而 SQLite 的 DATETIME/DATE
类型只接受 Python datetime/date 对象,字符串会被直接拒绝
(TypeError: SQLite DateTime type only accepts Python datetime ...)。
这里统一把 ISO 8601 字符串转为对应对象;带时区偏移的一律转 UTC
并去掉 tzinfo,与 SQLite 朴素存储保持一致。
"""
if not isinstance(value, str):
return value
col_type = column.type
if isinstance(col_type, sa_types.DateTime):
if value == NOW_SENTINEL:
return datetime.now(timezone.utc).replace(tzinfo=None)
text = value[:-1] + "+00:00" if value.endswith("Z") else value
try:
dt = datetime.fromisoformat(text)
except ValueError:
raise ValueError(
f"列 {column.name} 的日期时间值无法解析"
f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}"
)
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
if isinstance(col_type, sa_types.Date):
if value == NOW_SENTINEL:
return datetime.now(timezone.utc).date()
try:
return date.fromisoformat(value)
except ValueError:
raise ValueError(
f"列 {column.name} 的日期值无法解析"
f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}"
)
if isinstance(col_type, sa_types.Time):
try:
return dt_time.fromisoformat(value)
except ValueError:
raise ValueError(
f"列 {column.name} 的时间值无法解析"
f"(需 ISO 8601): {value!r}"
)
return value
def _build_where(self, table, conditions):
if not conditions:
return None
@@ -129,23 +196,26 @@ class DBServer:
column = table.columns[col]
if isinstance(val, dict):
if "eq" in val:
clauses.append(column == val["eq"])
clauses.append(column == self._coerce_value(column, val["eq"]))
if "ne" in val:
clauses.append(column != val["ne"])
clauses.append(column != self._coerce_value(column, val["ne"]))
if "gt" in val:
clauses.append(column > val["gt"])
clauses.append(column > self._coerce_value(column, val["gt"]))
if "lt" in val:
clauses.append(column < val["lt"])
clauses.append(column < self._coerce_value(column, val["lt"]))
if "ge" in val:
clauses.append(column >= val["ge"])
clauses.append(column >= self._coerce_value(column, val["ge"]))
if "le" in val:
clauses.append(column <= val["le"])
clauses.append(column <= self._coerce_value(column, val["le"]))
if "like" in val:
# like 是字符串模式匹配,不做日期转换
clauses.append(column.like(val["like"]))
if "in" in val:
clauses.append(column.in_(val["in"]))
clauses.append(column.in_(
[self._coerce_value(column, v) for v in val["in"]]
))
else:
clauses.append(column == val)
clauses.append(column == self._coerce_value(column, val))
return and_(*clauses)
# ═══════════════════════════════════════════════════════════════════
@@ -205,6 +275,10 @@ class DBServer:
def _do_insert(self, conn, table, data):
values = data.get("values", {})
values = {
k: self._coerce_value(table.columns[k], v)
for k, v in values.items()
}
stmt = insert(table).values(**values)
res = conn.execute(stmt)
conn.commit()
@@ -212,6 +286,10 @@ class DBServer:
def _do_update(self, conn, table, data):
values = data.get("values", {})
values = {
k: self._coerce_value(table.columns[k], v)
for k, v in values.items()
}
where_cond = self._build_where(table, data.get("where"))
stmt = update(table).values(**values)
if where_cond is not None:
-490
View File
@@ -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} 条')
-2
View File
@@ -1,5 +1,3 @@
nuitka
flask
SQLAlchemy
waitress
../pymsg
-42
View File
@@ -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
View File
@@ -44,7 +44,7 @@ Authorization: Bearer <base64_payload>_<rsa_sign>
- payload 中取 `user` 字段作为操作者,写入 `owner`。
- `delete_note` 与 `update_note` 校验所有权:
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
- **例外**:`user == 'admin'` 可删除任意节点(管理员豁免,仅 delete)。
- **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。
- `get_notes` 不需要鉴权。
## 接口文档
@@ -154,7 +154,7 @@ curl -X POST http://localhost:8006/delete_note \
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
仅节点 owner 可更新;update 路径下 **admin 不享豁免**(与 delete 不同)。
仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。
**Request**
+8 -4
View File
@@ -37,6 +37,9 @@ from pymsg import MsgClient # noqa: E402
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
# 管理员账号:删除/更新节点时豁免所有权校验(保留旧的 'admin' 以兼容历史数据)
ADMIN_USERS = ('admin@qsont.me', 'qing@qsont.me')
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
"""通过 pymsg 短连接调用 dbv2 数据库服务。
自动注入 ``_route: "execute"``。
@@ -325,7 +328,7 @@ def delete_note():
data = _call_dbv2(select_payload)
notes = data['data']
if not info['user'] == 'admin':
if info['user'] not in ADMIN_USERS:
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']})
return jsonify({"error": 'It\'s not your note'}), 403
@@ -400,9 +403,10 @@ def update_note():
data = _call_dbv2(select_payload)
notes = data['data']
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
return jsonify({"error": 'It\'s not your note'}), 403
if info['user'] not in ADMIN_USERS:
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
return jsonify({"error": 'It\'s not your note'}), 403
# 内容审查
review = review_content(title + '\n' + content, 10)
+2 -2
View File
@@ -4,8 +4,8 @@ INSTL_DIR := /usr/local/bin
CPP := g++
INCS := -I inc/
MY_LIBS := -L/usr/lib -lus_http -lus_crypt
LIBS := -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options
CPPFLAGS := --std=c++20 -pipe # -Wall -Werror
LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options
CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror
BUILD_DIR = build
$(shell mkdir -p $(BUILD_DIR))
+31 -9
View File
@@ -6,10 +6,11 @@ UniServe Framework 的应用层网关,C++20 实现。
## 职责
- **账号鉴权**:`/register` 注册、`/login` 登录,签发 RSA 签名的 Token。
- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。
- **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入)。
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(60 秒有效);注册时校验,一次性消费。
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
- **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。
## 安全特性
@@ -71,9 +72,25 @@ UniServe Framework 的应用层网关,C++20 实现。
### `POST /register`
请求体与 `/login` 相同。先做密码强度校验、邮箱格式校验、CAPTCHA 校验,再写入 `Users` 表。
请求体在 `/login` 基础上增加 `email_code`(必填,6 位数字邮箱验证码):
错误码:`400`(密码/邮箱/CAPTCHA 不合规)、`429`(速率超限)、`500`(数据库异常)。
```json
{ "username": "user@example.com", "password": "...", "email_code": "123456" }
```
校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。
错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。
### `POST /register/send_code`
向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,60 秒有效)。
```json
{ "email": "user@example.com" }
```
限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`429`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。
### `GET/POST *`(反向代理)
@@ -95,18 +112,18 @@ Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default
## 数据库
SQLite3,表 `Users`:
用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表):
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER PK AUTOINCREMENT | |
| email | TEXT UNIQUE NOT NULL | 登录账号 |
| username | TEXT UNIQUE NOT NULL | 登录账号(邮箱格式) |
| password_hash | TEXT NOT NULL | `pbkdf2:100000:<salt_hex>:<hash_hex>` |
| create_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| last_login | DATETIME | 每次登录更新 |
| is_active | BOOLEAN DEFAULT 1 | |
启动时若 `Users` 表不存在则自动建表(含 `idx_users_email` 索引),无默认管理员账号——首位注册者即为管理员。
gateway 不直连数据库(早期直连本地 sqlite 的代码已注释保留),所有读写经 `DbC::call_msg` 调 db 的 `execute` 路由。无默认管理员账号——首位注册者即为管理员。
## 配置
@@ -209,12 +226,17 @@ gateway/
## 版本
`0.3.0-security`(见 [src/main.cpp:27](src/main.cpp#L27))。启动时打印:
`0.5.2`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印:
```
Gateway 0.3.0-security started (PBKDF2 + CAPTCHA + RateLimit)
Gateway 0.5.2 started
```
近期变更:
- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。
- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。
## 依赖
- C++20(`g++`)
+1
View File
@@ -1,3 +1,4 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
sleep 1
exec ./build/gateway.out -i example.ini #1>/dev/null
+5 -1
View File
@@ -6,6 +6,10 @@ namespace qing {
namespace json = boost::json;
class DbC {
public:
/* 本机 db 轻量查询的默认超时(秒);慢操作由调用方显式放宽。
* smtp/captcha 等其他下游的超时常量见 main.cpp。 */
static constexpr double DEFAULT_TIMEOUT = 5.0;
// 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。
// 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。
static json::value call_http(json::value& params) {
@@ -25,7 +29,7 @@ namespace qing {
// {"success":..., "data":[...]}。
// 响应格式与 call_http 兼容,调用方可直接替换。
static json::value call_msg(const json::value& params,
double timeout = 5.0) {
double timeout = DEFAULT_TIMEOUT) {
json::value jv_route = params;
jv_route.as_object()["_route"] = "execute";
pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
+248
View File
@@ -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
View File
@@ -20,13 +20,14 @@
#include "LocalTime.hpp"
#include "Conf.hpp"
#include "DbC.hpp"
#include "LogC.hpp"
using namespace pmc::net;
using namespace qing;
namespace json = boost::json;
namespace po = boost::program_options;
constexpr const char *_VER = "0.4.4";
constexpr const char *_VER = "0.5.2";
//////////////////////////////////
#include <sstream>
@@ -287,6 +288,11 @@ http::response<http::string_body> handleRegister(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params);
/* 发送注册邮箱验证码:pymsg 调 smtp 服务 send_email 路由 */
http::response<http::string_body> handleSendEmailCode(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params);
http::response<http::string_body> handleCaptcha(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params);
@@ -326,6 +332,15 @@ static std::unique_ptr<MyRSA::Generator> _KEYGEN;
//static std::unique_ptr<Sqlite3> _SQLDB;
static RateLimiter _LOGIN_RATE;
static RateLimiter _REGISTER_RATE;
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
/* pymsg 下游调用超时(秒):
* captcha / smtp verify_email 均为本机内存操作,5s 足够;
* smtp send_email 需完成一次完整外部 SMTP 会话(握手 + TLS + 投递),放宽到 20s。
* db 调用的默认超时见 DbC.hpp 的 DbC::DEFAULT_TIMEOUT。 */
static constexpr double _TIMEOUT_CAPTCHA = 5.0;
static constexpr double _TIMEOUT_SMTP_VERIFY = 5.0;
static constexpr double _TIMEOUT_SMTP_SEND = 20.0;
/* CAPTCHA store: token -> answer (in-memory, with expiry) */
#include <unordered_map>
@@ -337,6 +352,9 @@ struct CaptchaEntry {
static std::unordered_map<std::string, CaptchaEntry> _CAPTCHA_STORE;
static std::mutex _CAPTCHA_MTX;
/* 集中式日志客户端:推送 gateway 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */
static inline LogC& logs() { return LogC::instance(); }
std::string generate_captcha_text() {
static const char chars[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
// thread_local:每个线程独立 rng 实例,避免多线程并发调用 dist(rng) 时的数据竞争
@@ -379,7 +397,27 @@ bool verify_captcha_remote(const std::string& token, const std::string& answer)
{"token", token},
{"answer", answer}
};
std::string resp = client.call(json::serialize(jv_req), 5.0);
std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_CAPTCHA);
auto jv = json::parse(resp);
auto& obj = jv.as_object();
if (!obj.at("success").as_bool()) return false;
return obj.at("data").as_object().at("valid").as_bool();
} catch (const std::exception&) {
return false;
}
}
/* 调用 smtp 服务(pymsg/TCP :8008)校验邮箱验证码。
* 验证码一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 */
bool verify_email_code_remote(const std::string& email, const std::string& code) {
try {
pmc::net::MsgClient client("127.0.0.1", 8008, "gateway");
json::value jv_req = {
{"_route", "verify_email"},
{"email", email},
{"code", code}
};
std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_VERIFY);
auto jv = json::parse(resp);
auto& obj = jv.as_object();
if (!obj.at("success").as_bool()) return false;
@@ -501,10 +539,14 @@ int main(int argc, char **argv) {
std::cout << "Gateway " << _VER << " started" << std::endl;
logs().info("gateway", std::string("Gateway ") + _VER + " started");
/* 开始监听 */
_SERVE->run();
/* 收到停止信号后的收尾:记录并 flush 剩余日志 */
logs().info("gateway", "Gateway shutdown");
logs().shutdown();
}
//void db_init() {
@@ -565,6 +607,12 @@ void registerRoutes(HttpServer &server) {
[](const auto& req, const auto& params) { return handleRegister(req, params); });
});
/* 发送注册邮箱验证码(须在 post("*") 通配符之前注册) */
server.post("/register/send_code", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params,
[](const auto& req, const auto& params) { return handleSendEmailCode(req, params); });
});
server.post("/logout", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params,
[](const auto& req, const auto& params) { return handleLogout(req, params); });
@@ -603,7 +651,14 @@ catch (const HttpException& e) {
http::response<http::string_body> res;
res.version(req.version());
// 详细错误信息(e.what() 含源码路径/行号/C++ 模板类型)暂时以注释方式保留(同 handleLogin)
std::cerr << "[handleRegister HttpException] " << e.what() << std::endl;
std::cerr << "[wrapperHttp HttpException] " << e.what() << std::endl;
// 推送集中式日志:5xx 服务端错误记 ERROR,其余(400/401/403/429 等业务错误)记 WARNING
if (e.status() >= 500)
logs().error("gateway", e.what(), std::nullopt,
json::serialize(json::value{{"url", e.url()}, {"status", e.status()}}));
else
logs().warning("gateway", e.what(), std::nullopt,
json::serialize(json::value{{"url", e.url()}, {"status", e.status()}}));
json::value jv_r = {
{"success", false},
{"message", e.what()}
@@ -627,6 +682,7 @@ catch (const std::exception& e) {
// 透传到客户端会泄露 C++ 类型信息与内部路径(P0-3 遗漏点,已补全)
// 排障:完整信息仍输出到服务端 stderr
std::cerr << "[wrapperHttp std::exception] <" << ti.name() << "> " << e.what() << std::endl;
logs().error("gateway", std::string("<") + ti.name() + "> " + e.what());
json::value jv_r = {
{"success", false},
/* {"message", std::string("<") + ti.name() + "> " + e.what()}, */ // 泄露源码信息,已禁用
@@ -696,7 +752,7 @@ http::response<http::string_body> handleLogin(
client_ip = "direct";
if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300))
THROW_EX_CUSTOM(HttpException, 2001, "Too many login attampts. Please try again later.", 400, "/login");
THROW_EX_CUSTOM(HttpException, 2001, "Too many login attempts. Please try again later.", 429, "/login");
// parse json req body
json::value jv;
@@ -742,10 +798,14 @@ http::response<http::string_body> handleLogin(
auto jv_db_r = DbC::call_msg(jv_select);
auto obj_r = jv_db_r.as_object();
auto success = obj_r["success"].as_bool();
if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login (Update Step) Failed", 500, "/login");
if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login (Query Step) Failed", 500, "/login");
auto obj_rd = obj_r.at("data").as_array();
auto obj_ru = obj_rd.at(0).as_object();
stored_hash = obj_ru.at("password_hash").as_string();
/* 用户不存在时 data 为空数组:直接 at(0) 会抛异常被 wrapper 兜底成 500,
* 此处保持 stored_hash 为空,统一走下面的 401 分支 */
if (!obj_rd.empty()) {
auto obj_ru = obj_rd.at(0).as_object();
stored_hash = obj_ru.at("password_hash").as_string();
}
}
@@ -770,6 +830,14 @@ http::response<http::string_body> handleLogin(
// std::cout << "Migrated password hash for " << username << " to PBKDF2" << std::endl;
//}
}
else {
/* 用户不存在:执行一次等价的 dummy PBKDF2 校验拉平响应时序,
* 防止通过响应时间差异枚举有效用户名(用户不存在/密码错误一律 401) */
static const std::string dummy_hash =
"pbkdf2:100000:00000000000000000000000000000000:"
"0000000000000000000000000000000000000000000000000000000000000000";
PBKDF2Hasher::verify_password(password, dummy_hash);
}
if (!auth_ok) THROW_EX_CUSTOM(HttpException, 2101, "Invalid Username or Password", 401, "/login");
@@ -779,21 +847,21 @@ http::response<http::string_body> handleLogin(
//stmt_upd.bind_text(1, username);
//stmt_upd.step();
//
// 新版调用db模块 无法通过DB上传时间戳目前
//json::value jv_upd = {
// {"action", "update"},
// {"table", "Users"},
// {"values", json::object{
// {"last_login", LocalTime::getCurrent()},
// }},
// {"where", json::object{
// {"username", username},
// }}
//};
//auto jv_db_r = DbC::call_http(jv_upd);
//auto obj_r = jv_db_r.as_object();
//auto success = obj_r["success"].as_bool();
//if (!success) THROW_EX_CUSTOM(HttpException, 2102, json::serialize(jv_db_r)+"Login (Update Step) Failed", 500, "/login");
// 新版调用db模块:values 中的 "$now" 哨兵由 db 日期适配层替换为服务端当前 UTC 时间
//(此前因 db 无法处理日期列而注释,dbv2 增加 _coerce_value 后可用)。
json::value jv_upd = {
{"action", "update"},
{"table", "Users"},
{"values", json::object{
{"last_login", "$now"},
}},
{"where", json::object{
{"username", username},
}}
};
auto jv_upd_r = DbC::call_msg(jv_upd);
if (!jv_upd_r.as_object()["success"].as_bool())
THROW_EX_CUSTOM(HttpException, 2102, "Login (Update Step) Failed", 500, "/login");
/* 生成令牌 */
@@ -827,6 +895,9 @@ http::response<http::string_body> handleLogin(
res.set("Set-Cookie", ck);
}
logs().info("gateway", "登录成功: " + username, std::nullopt,
json::serialize(json::value{{"ip", client_ip}}));
res.result(http::status::ok);
res.body() = json::serialize(jv_rr);
res.prepare_payload();
@@ -867,7 +938,7 @@ http::response<http::string_body> handleCaptchaImage(
pmc::net::MsgClient client("127.0.0.1", 8009, "gateway");
std::string resp;
try {
resp = client.call(R"({"_route":"image"})", 5.0);
resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA);
} catch (const std::exception& e) {
THROW_EX_CUSTOM(HttpException, 2400, "Captcha service unavailable", 502, "/captcha/image");
}
@@ -915,7 +986,7 @@ http::response<http::string_body> handleRegister(
client_ip = std::string(it_host->value());
if (!_REGISTER_RATE.check_and_record(client_ip, 3, 600))
THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.",
403, "/register");
429, "/register");
// parse the req body
json::value jv;
@@ -951,6 +1022,14 @@ http::response<http::string_body> handleRegister(
THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register");
}
// 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前)
auto ecode_it = obj.find("email_code");
if (ecode_it == obj.end())
THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/register");
std::string email_code = std::string(ecode_it->value().as_string());
if (!verify_email_code_remote(username, email_code))
THROW_EX_CUSTOM(HttpException, 3006, "Invalid or expired email verification code", 400, "/register");
// generate the hash of password
auto password_hash = PBKDF2Hasher::hash_password(password);
@@ -977,12 +1056,87 @@ http::response<http::string_body> handleRegister(
if (!success) THROW_EX_CUSTOM(HttpException, 2202, "Registe Failed", 400, "/register");
auto jv_r = json::value{{"success", success}};
logs().info("gateway", "注册成功: " + username, std::nullopt,
json::serialize(json::value{{"ip", client_ip}}));
res.result(http::status::ok);
res.body() = json::serialize(jv_r);
res.prepare_payload();
return res;
}
/* 处理发送注册邮箱验证码 /register/send_code
*
* 经 pymsg 调 smtp 服务 send_email 路由,向目标邮箱发送 6 位验证码。
* 限流 5 次/10 分钟/IP;smtp 不可用返回 502(fail-closed)。 */
http::response<http::string_body> handleSendEmailCode(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params)
{
http::response<http::string_body> res;
res.version(req.version());
// IP rate limit check
std::string client_ip = "direct";
auto it_host = req.base().find("X-Forwarded-For");
if (it_host != req.base().end())
client_ip = std::string(it_host->value());
if (!_EMAIL_CODE_RATE.check_and_record(client_ip, 5, 600))
THROW_EX_CUSTOM(HttpException, 3101, "Too many code requests. Please try again later.",
429, "/register/send_code");
// parse the req body
json::value jv;
try { jv = json::parse(req.body()); }
catch (const std::exception&) {
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code");
}
if (!jv.is_object())
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code");
auto& obj = jv.as_object();
auto email_it = obj.find("email");
if (email_it == obj.end())
THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code");
auto email = std::string(email_it->value().as_string());
// Email format check
std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)");
if (!std::regex_match(email, email_regex))
THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code");
// 调 smtp 服务(pymsg/TCP :8008)发送验证码
bool sent = false;
try {
pmc::net::MsgClient client("127.0.0.1", 8008, "gateway");
json::value jv_req = {
{"_route", "send_email"},
{"email", email}
};
std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_SEND);
auto jv_r = json::parse(resp);
auto& obj_r = jv_r.as_object();
if (obj_r.at("success").as_bool())
sent = obj_r.at("data").as_object().at("sent").as_bool();
} catch (const std::exception&) {
THROW_EX_CUSTOM(HttpException, 3102, "Mail service unavailable", 502, "/register/send_code");
}
if (!sent)
THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/register/send_code");
logs().info("gateway", "验证码邮件发送成功: " + email, std::nullopt,
json::serialize(json::value{{"ip", client_ip}}));
auto jv_r = json::value{{"success", true}};
res.result(http::status::ok);
res.set(http::field::content_type, "application/json");
res.body() = json::serialize(jv_r);
res.prepare_payload();
return res;
}
http::response<http::string_body> handleProxy(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params,
@@ -1097,6 +1251,7 @@ http::response<http::string_body> handleProxy(
}
}
if (!flag) {
logs().warning("gateway", "代理目标不在白名单: " + t_url);
throw std::runtime_error("403");
}
}
+69
View File
@@ -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/
+27
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
a = 5;
b = "ok";
print(5);
puts(b);
+6
View File
@@ -0,0 +1,6 @@
func f_test{
return(0);
};
r = f_test();
print(r);
+2
View File
@@ -0,0 +1,2 @@
print(1);
puts("1")
+741
View File
@@ -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
View File
@@ -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 包裹的连接 —— 任何提供有序字节传输的通道都可以。
-6
View File
@@ -1,8 +1,2 @@
../pymsg
nuitka
flask
flask-cors
flask_sqlalchemy
#gunicorn
waitress
python-dotenv
+225 -64
View File
@@ -1,84 +1,245 @@
#!/usr/bin/env python3
"""
smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。
from flask import Flask, request, jsonify
from email.mime.text import MIMEText
from email import header
from log_client import logs
from dotenv import load_dotenv
import waitress
import smtplib
替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
协议:每条消息为完整 JSON,``_route`` 字段指定路由名。
请求示例::
{"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码
{"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费)
{"_route": "health"} → 健康检查
响应格式(与 dbv2 / captcha 一致)::
{"success": true, "data": {...}}
{"success": false, "error": "..."}
SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
"""
import json
import os
import random
import smtplib
import threading
import time
import sys, os
from email.mime.text import MIMEText
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
# 手动加载 .env(支持 KEY=VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号)
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
_env_loaded = 0
if os.path.isfile(_env_path):
with open(_env_path, "r") as _f:
for _line in _f:
_line = _line.strip()
if not _line or _line.startswith("#"):
continue
if _line.startswith("export "):
_line = _line[len("export "):].strip()
# 支持 KEY=VALUE 与 KEY VALUE 两种写法
if "=" in _line:
_key, _, _val = _line.partition("=")
else:
_parts = _line.split(None, 1)
_key = _parts[0]
_val = _parts[1] if len(_parts) > 1 else ""
_key = _key.strip()
_val = _val.strip().strip("\"'")
if _key and _key not in os.environ:
os.environ[_key] = _val
_env_loaded += 1
else:
print(f"[smtp] 警告: 未找到 .env 文件: {_env_path}")
app = Flask(__name__)
captchas: dict[str, list[str, str]] = {}
from log_client import logs
@app.route("/send_email", methods=["POST"])
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgServer # noqa: E402
def send_email():
def build_captcha() -> str:
captcha: str = ""
for i in range(0,6):
digit: int = random.randint(0,9)
captcha += str(digit)
return captcha
# ── 配置 ────────────────────────────────────────────────────────────────
data = request.get_json()
SMTP_SERVER = os.environ.get("SMTP_SERVER", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
SMTP_USER = os.environ.get("SMTP_USER", "")
# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名
SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "")
recipient: str = data.get("email")
subject: str = "DNFS 邮箱验证"
captcha: str = build_captcha()
content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。"
CODE_LEN = 6
CODE_TTL = 60 # 验证码有效期(秒)
MAIL_FROM = "dnfs@qsont.me"
DEFAULT_SUBJECT = "DNFS 邮箱验证"
DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。"
captchas[recipient] = ["", ""]
captchas[recipient][0] = captcha
captchas[recipient][1] = str(int(time.time())+60)
message = MIMEText(content, "plain", "UTF-8")
message["From"] = "dnfs@qsont.me"
message["To"] = recipient
message["Subject"] = subject
try:
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
server.login(SMTP_USER, __PASSWORD)
server.sendmail(SMTP_USER, [recipient], message.as_string())
logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})")
return jsonify({"success": "验证码发送成功"}), 200
except Exception as err:
logs.error("smtp", f"{recipient}: 验证码发送失败({err})")
return jsonify({"error": f"验证码发送失败:{err}"}), 400
finally:
server.quit()
code_store = {} # email -> {'code': str, 'expires_at': float}
STORE_LOCK = threading.Lock()
@app.route("/verify_email", methods=["POST"])
# ═══════════════════════════════════════════════════════════════════════
# SmtpServer — 主服务类
# ═══════════════════════════════════════════════════════════════════════
def verify_email():
data = request.get_json()
class SmtpServer:
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。"""
for i in list(captchas.keys()):
if int(captchas[i][1]) < int(time.time()):
del captchas[i]
def __init__(self, addr="127.0.0.1", port=8008):
self.addr = addr
self.port = port
self._server = MsgServer(addr, port)
email: str = data.get("email")
input_captcha: str = data.get("input_captcha")
# 路由表
self._routes = {
"send_email": self._handle_send_email,
"verify_email": self._handle_verify_email,
"health": self._handle_health,
}
if captchas[email][0] == input_captcha:
logs.info("smtp", f"{email}: 验证通过")
return jsonify({"success": "验证通过"}), 200
else:
# ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None:
"""启动服务(阻塞当前线程)。"""
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
# 只报缺失的 key 名,绝不报值
missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER),
("SMTP_USER", SMTP_USER),
("__PASSWORD", SMTP_PASSWORD)) if not v]
logs.error("smtp", f"SMTP 配置缺失: {','.join(missing)}"
f"(.env 加载 {_env_loaded} 项),send_email 将始终失败")
print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)")
print(f"[smtp] 监听 tcp://{self.addr}:{self.port}")
logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}")
self._server.start(self._dispatch)
def stop(self) -> None:
"""从另一线程停止服务。"""
self._server.stop()
# ── 消息调度 -------------------------------------------------------
def _dispatch(self, sock, addr, text):
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
try:
req = json.loads(text)
except json.JSONDecodeError as e:
self._reply(sock, False, error=f"JSON 解析失败: {e}")
return
route = req.pop("_route", "")
handler = self._routes.get(route)
if handler is None:
self._reply(sock, False, error=f"未知路由: {route}")
return
try:
result = handler(req)
self._reply(sock, True, data=result)
except Exception as e:
logs.error("smtp", f"路由 {route} 处理异常: {e}")
self._reply(sock, False, error=str(e))
def _reply(self, sock, success, data=None, error=None):
resp = {"success": success}
if data is not None:
resp["data"] = data
if error is not None:
resp["error"] = error
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
# ═══════════════════════════════════════════════════════════════════
# 路由处理
# ═══════════════════════════════════════════════════════════════════
def _handle_send_email(self, req):
"""生成 6 位数字验证码,存入内存(60s),经 SMTP_SSL 发送邮件。
可选字段 subject / content 覆盖默认标题与正文,content 中 {code}
会被替换为验证码。
"""
email = str(req.get("email", "")).strip()
if not email or "@" not in email:
return {"sent": False, "error": "invalid email"}
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送")
return {"sent": False, "error": "SMTP 服务未配置"}
code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN))
subject = str(req.get("subject") or DEFAULT_SUBJECT)
content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code)
with STORE_LOCK:
code_store[email] = {'code': code, 'expires_at': time.time() + CODE_TTL}
# 惰性清理过期项
now = time.time()
expired = [k for k, v in code_store.items() if v['expires_at'] < now]
for k in expired:
del code_store[k]
message = MIMEText(content, "plain", "UTF-8")
message["From"] = MAIL_FROM
message["To"] = email
message["Subject"] = subject
# server 作用域限定在 try 内;连接失败时不会误调 quit()
try:
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(SMTP_USER, [email], message.as_string())
except Exception as err:
# 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态
with STORE_LOCK:
code_store.pop(email, None)
logs.error("smtp", f"{email}: 验证码发送失败({err})")
return {"sent": False, "error": f"验证码发送失败:{err}"}
logs.info("smtp", f"{email}: 验证码发送成功({code})")
return {"sent": True, "ttl": CODE_TTL}
def _handle_verify_email(self, req):
"""校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。"""
email = str(req.get("email", "")).strip()
# 兼容旧字段 input_captcha
code = str(req.get("code") or req.get("input_captcha") or "").strip()
if not email or not code:
return {"valid": False, "error": "email and code required"}
with STORE_LOCK:
entry = code_store.get(email)
if entry and entry['expires_at'] < time.time():
del code_store[email]
entry = None
if entry and entry['code'] == code:
# 验证通过:一次性消费,防重放
del code_store[email]
valid = True
else:
valid = False
if valid:
logs.info("smtp", f"{email}: 验证通过")
return {"valid": True}
logs.info("smtp", f"{email}: 验证失败")
return jsonify({"error": "验证失败"}), 400
return {"valid": False, "error": "invalid or expired code"}
def _handle_health(self, _req):
return {"status": "ok", "service": "smtp"}
# ═══════════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
logs.info("smtp", "开始监听http://127.0.0.1:8008")
print("Listen at http://127.0.0.1:8008")
waitress.serve(app, host="127.0.0.1", port=8008)
import argparse
parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
parser.add_argument("--port", type=int, default=8008, help="监听端口")
args = parser.parse_args()
server = SmtpServer(args.addr, args.port)
try:
server.start()
except KeyboardInterrupt:
server.stop()
+4 -1
View File
@@ -582,6 +582,9 @@ if __name__ == '__main__':
print("\n")
if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
# Waitress 不支持 TLS,SSL 场景回退到 Flask 内置服务器
logger.warning("SSL enabled: falling back to Flask built-in server (Waitress has no TLS support)")
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
else:
app.run(host=host, port=port, debug=False)
from waitress import serve
serve(app, host=host, port=port, threads=8)
+142 -1
View File
@@ -212,7 +212,27 @@ input[type="button"]:disabled {
#article-content { font-size: 0.95rem; line-height: 1.7; margin-bottom: 1.25rem; }
/* ===== Recommend List (dnfs) ===== */
.recommend-list .card { margin-bottom: 0.75rem; width: 100%; max-width: none; }
/* 卡片尺寸随内容自适应:宽度包裹内容(fit-content),上限为容器宽度;高度天然随内容伸缩 */
.recommend-list {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
}
.recommend-list br { display: none; } /* JS 追加的 <br> 分隔符,改由 flex gap 统一控制间距 */
.recommend-list .card {
width: fit-content;
min-width: min(240px, 100%);
max-width: 100%;
margin-bottom: 0;
}
/* 防止内容中的大图撑破自适应宽度 */
.recommend-list .card img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
}
.recommend-list center {
cursor: pointer;
padding: 0.4rem;
@@ -224,6 +244,127 @@ input[type="button"]:disabled {
.recommend-list .card h3 { transition: color 0.15s; }
.recommend-list .card p { font-size: 0.9rem; line-height: 1.6; color: var(--fg-secondary); }
/* ===== Owner Chip (dnfs 发帖用户) ===== */
.card-owner {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.2rem;
}
.owner-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent-dark);
border: 1px solid var(--accent);
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
user-select: none;
}
.owner-name {
font-size: 0.8rem;
font-weight: 600;
color: var(--fg-secondary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===== Card Stats (dnfs 互动统计:观看/点赞/分享) ===== */
.card-stats {
display: flex;
align-items: center;
justify-content: center;
gap: 1.1rem;
margin-top: 0.45rem;
}
.stat-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--muted);
}
.stat-item svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.stat-num {
font-weight: 600;
color: var(--fg-secondary);
font-variant-numeric: tabular-nums;
}
/* ===== Card Actions (dnfs 编辑/删除按钮) ===== */
.card-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.6rem;
}
.act-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.7rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: var(--radius-sm);
border: 1px solid transparent;
background: var(--accent-soft);
color: var(--accent-dark);
cursor: pointer;
transition: border-color 0.15s, opacity 0.15s, transform 0.1s;
}
.act-btn svg { width: 12px; height: 12px; flex-shrink: 0; }
.act-btn:hover { border-color: var(--accent); }
.act-del { background: var(--error-bg); color: var(--error); }
.act-del:hover { border-color: var(--error); }
.act-move { background: transparent; color: var(--fg-secondary); border-color: var(--border); }
.act-move:hover { border-color: var(--accent); color: var(--accent-dark); }
/* ===== Move Bar (dnfs 节点移动条) ===== */
.movebar {
position: fixed;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%);
z-index: 110;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0.8rem;
border-radius: var(--radius-md);
background: rgba(253, 246, 238, 0.85);
border: 1px solid var(--accent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-md);
font-size: 0.82rem;
max-width: 92vw;
}
@media (prefers-color-scheme: dark) {
.movebar { background: rgba(30, 23, 18, 0.85); }
}
.movebar:empty { display: none; }
.move-title {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-secondary);
font-weight: 600;
}
/* ===== Minimap (dnfs) ===== */
.minimap {
position: fixed;
+1
View File
@@ -10,6 +10,7 @@
<body>
<div class="box">
<div id="minimap" class="minimap"></div>
<div id="movebar" class="movebar"></div>
<div class="recommend-list" id="recommend-list"></div>
<div class="card node-form">
<h3>节点操作</h3>
+323 -10
View File
@@ -168,6 +168,235 @@ async function deleteNode(id) {
}
}
/* by qing — 卡片上的"删除":删除子节点后停留在当前层,仅刷新列表(不弹栈) */
async function deleteChild(id) {
if (confirm("确认删除此节点?") == false) {
return;
}
try {
obj = {"id": id};
jsonBody = JSON.stringify(obj);
const res = await fetch('/delete_note', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Target-Url': 'http://127.0.0.1:8006'
},
body: jsonBody
});
const responseText = await res.text();
let result;
try {
result = JSON.parse(responseText);
} catch {
console.error(`删除笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
throw new Error('服务器响应格式异常,请稍后重试');
}
if (res.status === 401) {
// 凭证失效(过期或网关重启),跳转重新登录
location.href = '/login.html';
return;
}
if (!res.ok) {
throw new Error(result.error === "It's not your note" ? '只能删除自己的节点' : (result.error || `删除节点失败(HTTP ${res.status})`));
}
// 若删掉的正是编辑中的节点,顺带退出编辑模式
if (editing_id == id) {
cancelEdit();
input_note_title.value = "";
input_note_content.value = "";
}
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
getNode(cur).then(load_notes);
getNode(cur).then(loadMap);
console.log(`删除节点成功`);
return result;
} catch (err) {
alert(err.message || '接口请求失败,请检查后端服务是否启动');
console.error('删除节点请求异常:', err);
}
}
/* by qing — 保存编辑:调用 /update_note,成功后刷新当前层并退出编辑模式 */
async function updateNode(id, pid, title, content) {
try {
obj = {
"id": Number(id),
"pid": pid,
"title": title,
"content": content
}
jsonBody = JSON.stringify(obj)
const res = await fetch('/update_note', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Target-Url': 'http://127.0.0.1:8006'
},
body: jsonBody
});
const responseText = await res.text();
let result;
try {
result = JSON.parse(responseText);
} catch {
console.error(`更新笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
throw new Error('服务器响应格式异常,请稍后重试');
}
if (res.status === 401) {
// 凭证失效(过期或网关重启),跳转重新登录
location.href = '/login.html';
return;
}
if (!res.ok) {
throw new Error(result.error === "It's not your note" ? '只能编辑自己的节点' : (result.error || `更新节点失败(HTTP ${res.status})`));
}
input_note_title.value = "";
input_note_content.value = "";
cancelEdit();
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
getNode(cur).then(load_notes);
getNode(cur).then(loadMap);
alert('修改成功');
return result;
} catch (err) {
alert(err.message || '接口请求失败,请检查后端服务是否启动');
console.error('更新节点请求异常:', err);
}
}
/* by qing — 节点移动:本质是 pid 变更的 update_note,title/content 原样携带 */
async function moveNode(id, pid, title, content) {
try {
obj = {
"id": Number(id),
"pid": pid,
"title": title,
"content": content
}
jsonBody = JSON.stringify(obj)
const res = await fetch('/update_note', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Target-Url': 'http://127.0.0.1:8006'
},
body: jsonBody
});
const responseText = await res.text();
let result;
try {
result = JSON.parse(responseText);
} catch {
console.error(`更新笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
throw new Error('服务器响应格式异常,请稍后重试');
}
if (res.status === 401) {
// 凭证失效(过期或网关重启),跳转重新登录
location.href = '/login.html';
return;
}
if (!res.ok) {
throw new Error(result.error === "It's not your note" ? '只能移动自己的节点' : (result.error || `移动节点失败(HTTP ${res.status})`));
}
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
getNode(cur).then(load_notes);
getNode(cur).then(loadMap);
alert('移动成功');
return result;
} catch (err) {
alert(err.message || '接口请求失败,请检查后端服务是否启动');
console.error('移动节点请求异常:', err);
}
}
/* by qing — 点击卡片"移动":暂存节点信息,底部弹出移动条 */
function startMove(item) {
moving_node = { id: Number(item.id), pid: item.pid, title: item.title, content: item.content };
renderMoveBar();
}
/* by qing — 渲染底部移动条:显示待移动节点,提供"移动到当前层"和"取消" */
function renderMoveBar() {
movebar.replaceChildren();
if (moving_node == null) {
return;
}
let tip = document.createElement('span');
tip.className = "move-title";
tip.textContent = "移动中:" + moving_node.title;
let ok_btn = document.createElement('button');
ok_btn.className = "act-btn act-edit";
ok_btn.textContent = "移动到当前层";
ok_btn.addEventListener('click', confirmMove);
let cancel_btn = document.createElement('button');
cancel_btn.className = "act-btn act-del";
cancel_btn.textContent = "取消";
cancel_btn.addEventListener('click', function() {
moving_node = null;
renderMoveBar();
});
movebar.appendChild(tip);
movebar.appendChild(ok_btn);
movebar.appendChild(cancel_btn);
}
/* by qing — 执行移动:把待移动节点挂到当前层(stack 顶,根为 0)。
* 后端 update_note 会走 AI 内容审核,耗时较长,期间禁用移动条与表单按钮防止重复提交 */
async function confirmMove() {
if (moving_node == null) {
return;
}
var target_pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
if (target_pid == moving_node.id) {
alert('不能移动到节点自身内部');
return;
}
if (target_pid == moving_node.pid) {
alert('该节点已在当前层');
return;
}
// 防重复点击:请求进行中禁用按钮(成功/失败都在 finally 恢复)
movebar.querySelectorAll('button').forEach(function(b) { b.disabled = true; });
input_note_submit.disabled = true;
input_note_clear.disabled = true;
delete_note.disabled = true;
try {
var result = await moveNode(moving_node.id, target_pid, moving_node.title, moving_node.content);
if (result != null) {
moving_node = null;
renderMoveBar();
}
} finally {
movebar.querySelectorAll('button').forEach(function(b) { b.disabled = false; });
input_note_submit.disabled = false;
input_note_clear.disabled = false;
delete_note.disabled = false;
}
}
/* by shufeng2012 */
function renderMarkdown(element, content) {
/*
@@ -177,6 +406,26 @@ function renderMarkdown(element, content) {
element.innerHTML = md.render(content, {html: false});
}
/* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */
const ICON_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
const ICON_HEART = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
const ICON_SHARE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>';
const ICON_EDIT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
const ICON_TRASH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
const ICON_MOVE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
/* by qing — 生成一个统计项:图标 + 数字,count 缺省显示 0 */
function makeStatItem(iconSvg, count) {
let span = document.createElement('span');
span.className = "stat-item";
span.innerHTML = iconSvg; // 上方固定常量字符串,不含用户输入
let num = document.createElement('span');
num.className = "stat-num";
num.textContent = (count == null) ? "0" : String(count);
span.appendChild(num);
return span;
}
/* ---- by qing ---- */
const stack = []; /* 用于存储当前节点的路径,也就是经过的每一个节点的id, 否则就回不去上一层*/
var article_title = document.getElementById('article-title');
@@ -188,7 +437,10 @@ var input_note_clear = document.getElementById('input-note-clear');
var recommend_list = document.getElementById('recommend-list');
var delete_note = document.getElementById('delete-note');
var minimap = document.getElementById("minimap");
var movebar = document.getElementById("movebar");
var is_expand = true;
var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */
var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */
input_note_submit.addEventListener('click', function(event) {
if (input_note_submit.disabled) return; // 防重复点击
@@ -201,7 +453,9 @@ input_note_submit.addEventListener('click', function(event) {
input_note_submit.disabled = true;
input_note_clear.disabled = true;
delete_note.disabled = true;
addNode(pid, title, content)
// 编辑模式下保存修改,否则新增节点
var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content);
Promise.resolve(req)
.finally(() => {
input_note_submit.disabled = false;
input_note_clear.disabled = false;
@@ -212,6 +466,7 @@ input_note_submit.addEventListener('click', function(event) {
input_note_clear.addEventListener('click', function(event) {
input_note_title.value = "";
input_note_content.value = "";
cancelEdit(); // 重置时同时退出编辑模式
})
delete_note.addEventListener('click', (event) => {
@@ -229,6 +484,22 @@ delete_note.addEventListener('click', (event) => {
}
})
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式 */
function startEdit(item) {
editing_id = item.id;
input_note_title.value = item.title;
input_note_content.value = item.content;
input_note_submit.textContent = "保存修改";
input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' });
input_note_title.focus();
}
/* by qing — 退出编辑模式,表单恢复为新增状态 */
function cancelEdit() {
editing_id = null;
input_note_submit.textContent = "提交";
}
function load_notes(main_node) {
/* 这个不能设置为空的 */
@@ -239,6 +510,7 @@ function load_notes(main_node) {
if (confirm("是否放弃当前输入的内容?") == true) {
input_note_title.value = "";
input_note_content.value = "";
cancelEdit();
} else {
if (stack.length != 0) {
return;
@@ -247,12 +519,14 @@ function load_notes(main_node) {
} else {
input_note_title.value = "";
input_note_content.value = "";
cancelEdit();
}
recommend_list.replaceChildren();
for (let i in main_node) {
let item = main_node[i]
console.log(item)
let card = document.createElement('div');
card.className = "card";
card.addEventListener('click', function(event) {
@@ -267,18 +541,57 @@ function load_notes(main_node) {
card.id = item.title;
ttl.appendChild(ttl1);
card.appendChild(ttl);
let vit_div = document.createElement('p');
let lke_div = document.createElement('p');
let srd_div = document.createElement('p');
vit_div.textContent = "观看数:" + item.visit_count;
lke_div.textContent = "点赞数:" + item.like_count;
srd_div.textContent = "分享数:" + item.share_count;
ttl.appendChild(vit_div);
ttl.appendChild(lke_div);
ttl.appendChild(srd_div);
let wrt_div = document.createElement('p');
// 发帖用户:头像圆点(取昵称首字符)+ 昵称,样式见 theme.css 的 Owner Chip 段
wrt_div.className = "card-owner";
let owner_text = (item.owner == null || item.owner === "") ? "匿名" : String(item.owner);
let owner_avatar = document.createElement('span');
owner_avatar.className = "owner-avatar";
owner_avatar.textContent = owner_text.trim().charAt(0).toUpperCase() || "?";
let owner_name = document.createElement('span');
owner_name.className = "owner-name";
owner_name.textContent = owner_text;
wrt_div.appendChild(owner_avatar);
wrt_div.appendChild(owner_name);
// 互动统计:眼睛=观看、爱心=点赞、转发=分享(后端 Notes 表暂无计数字段,缺省显示 0)
let sts_div = document.createElement('div');
sts_div.className = "card-stats";
sts_div.appendChild(makeStatItem(ICON_EYE, item.visit_count));
sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count));
sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count));
// 卡片操作按钮:编辑 / 删除(stopPropagation 防止触发卡片自身的点入跳转)
let act_div = document.createElement('div');
act_div.className = "card-actions";
let edit_btn = document.createElement('button');
edit_btn.className = "act-btn act-edit";
edit_btn.innerHTML = ICON_EDIT + "编辑";
edit_btn.addEventListener('click', function(event) {
event.stopPropagation();
startEdit(item);
});
let move_btn = document.createElement('button');
move_btn.className = "act-btn act-move";
move_btn.innerHTML = ICON_MOVE + "移动";
move_btn.addEventListener('click', function(event) {
event.stopPropagation();
startMove(item);
});
let del_btn = document.createElement('button');
del_btn.className = "act-btn act-del";
del_btn.innerHTML = ICON_TRASH + "删除";
del_btn.addEventListener('click', function(event) {
event.stopPropagation();
deleteChild(item.id);
});
act_div.appendChild(edit_btn);
act_div.appendChild(move_btn);
act_div.appendChild(del_btn);
ttl.appendChild(wrt_div);
ttl.appendChild(sts_div);
let ctt = document.createElement('p');
renderMarkdown(ctt, item.content);
card.appendChild(ctt)
card.appendChild(act_div);
recommend_list.appendChild(card)
recommend_list.appendChild(document.createElement('br'))
}
+34 -22
View File
@@ -9,12 +9,10 @@ const pwdTip = document.getElementById('pwdTip');
const codeTip = document.getElementById('codeTip');
const submitButton = document.getElementById('formsub');
let sendCode = '';
let codeTarget = '';
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
form.addEventListener('reset', function () {
clearTipStyle();
sendCode = '';
codeTarget = '';
});
@@ -64,7 +62,7 @@ function checkCode() {
codeTip.innerHTML = '';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
if (!sendCode) {
if (!codeTarget) {
codeTip.textContent = '请先获取验证码!';
codeTip.classList.add('tip-red');
return false;
@@ -79,14 +77,7 @@ function checkCode() {
codeTip.classList.add('tip-yellow');
return false;
}
if (sendCode === 'email_sent') { /* Server-verified, skip client check */ } else if (code !== sendCode) {
codeTip.textContent = '验证码错误,请核对!';
codeTip.classList.add('tip-red');
return false;
}
codeTip.textContent = '验证码正确';
codeTip.classList.add('tip-green');
// 验证码正确性由服务端在注册提交时校验(一次性消费)
return code;
}
@@ -102,7 +93,7 @@ repeatPasswordInput.addEventListener('blur', checkPassword);
emailInput.addEventListener('focus', checkEmail);
emailInput.addEventListener('input', function () {
checkEmail();
if (sendCode) {
if (codeTarget) {
checkCode();
}
});
@@ -112,19 +103,40 @@ codeInput.addEventListener('focus', checkCode);
codeInput.addEventListener('input', checkCode);
codeInput.addEventListener('blur', checkCode);
getCodeButton.addEventListener('click', function () {
getCodeButton.addEventListener('click', async function () {
if (getCodeButton.disabled) return; // 防重复点击
const email = checkEmail();
if (!email) {
return;
}
sendCode = Math.floor(100000 + Math.random() * 900000).toString();
codeTarget = email;
codeInput.value = '';
codeTip.textContent = '验证码已发送,请填写验证码';
getCodeButton.disabled = true;
codeTip.textContent = '验证码发送中…';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-green');
alert(`验证码已发送至${email},您的验证码:${sendCode}`);
codeTip.classList.add('tip-yellow');
try {
const res = await fetch('/register/send_code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const responseText = await res.text();
let result;
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败');
codeTarget = email;
codeInput.value = '';
codeTip.textContent = '验证码已发送至邮箱,请查收(60 秒内有效)';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-green');
} catch (err) {
codeTip.textContent = err.message || '验证码发送失败,请稍后重试';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-red');
} finally {
getCodeButton.disabled = false;
}
});
form.addEventListener('submit', async function (e) {
@@ -139,7 +151,8 @@ form.addEventListener('submit', async function (e) {
}
const jsonBody = JSON.stringify({
username: email,
password
password,
email_code: code
});
try {
@@ -169,7 +182,6 @@ form.addEventListener('submit', async function (e) {
if (result.success) {
alert('注册成功');
form.reset();
sendCode = '';
codeTarget = '';
location.href = 'login.html';
return;
+1
View File
@@ -2,3 +2,4 @@
nuitka
Flask>=2.3.0,<3.0.0
Flask-CORS>=4.0.0,<5.0.0
waitress>=2.1.2,<4.0.0
+1
View File
@@ -31,6 +31,7 @@ SERVICES = {
"name": "db",
"host": "127.0.0.1",
"port": 8005,
"protocol": "pymsg",
"description": "通用数据库服务 (CRUD+集中日志)",
"test_file": "test_db.py",
},
+15
View File
@@ -284,6 +284,21 @@ class PymsgClient:
# 服务可用性检查
# ---------------------------------------------------------------------------
def check_tcp_service(host: str, port: int, timeout: float = 5.0) -> tuple:
"""检查纯 TCP 服务(如 pymsg 协议服务 db/captcha/smtp)是否可达。
返回 (reachable: bool, detail: str)。
"""
try:
with socket.create_connection((host, port), timeout=timeout):
return True, "TCP 连接成功"
except ConnectionRefusedError:
return False, "连接被拒绝 (服务未启动或端口未监听)"
except socket.timeout:
return False, f"TCP 连接超时 ({timeout}s)"
except OSError as e:
return False, f"网络错误: {e}"
def check_service(url: str, timeout: float = 8.0) -> tuple:
"""检查服务是否可达(HTTP 请求检测,与测试实际行为一致)。
返回 (reachable: bool, detail: str)。
+54 -89
View File
@@ -1,45 +1,38 @@
"""
db 黑盒用户模拟测试 — 集中日志: 写入 → 查询 → 统计 → 清理。
db 黑盒用户模拟测试 — pymsg 协议 CRUD。
用户旅程:
1. 通过 pymsg 协议执行 CRUD (select/insert)
2. 通过 HTTP /log/ingest 写入日志
3. 通过 HTTP /log/query 查询日志
4. 通过 HTTP /log/stats 查看统计
5. 通过 HTTP /log/health 健康检查
1. 通过 pymsg 协议查询 Users 表
2. 通过 pymsg 协议查询 Logs 表
3. 通过 pymsg 协议带 where 条件查询
4. 无效路由被拒绝
5. 日期列 where(ISO 字符串 / $now 哨兵 / 非法值拒绝)
注意: dbv2.py (pymsg 模式) 不挂载 Flask log_module Blueprint,
因此 HTTP /log/* 路由可能不可用。测试会优雅降级。
注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过
log_client.py 走 execute 路由 INSERT 写入 Logs 表。
"""
import json
import random
import string
import time
from helpers import (
http_get, http_post, try_parse_json,
assert_eq, assert_status, assert_true, assert_json_key,
skip, Counter, check_service, PymsgClient,
assert_true, skip, Counter, PymsgClient, check_tcp_service,
)
from config import get_url
DB_URL = get_url("db")
DB_HOST = "127.0.0.1"
DB_PORT = 8005
def run(url: str = None):
"""db 用户模拟测试入口。返回 (passed, failed, skipped)。"""
du = url or DB_URL
"""db 用户模拟测试入口。返回 (passed, failed, skipped)。
url 参数仅为保持与其他测试模块的签名一致,db 固定走 127.0.0.1:8005。
"""
c = Counter()
print(f"\n--- db 数据库与日志流程 ---\n")
# ------------------------------------------------------------------
# 0. 检查服务可用性
# 0. 检查服务可用性(TCP 直连,db 无 HTTP 接口)
# ------------------------------------------------------------------
if not check_service(du)[0]:
if not check_tcp_service(DB_HOST, DB_PORT)[0]:
skip("全部测试", "db 服务不可达", c)
return c.passed, c.failed, c.skipped
@@ -109,75 +102,47 @@ def run(url: str = None):
"pymsg-无效路由-拒绝", c,
)
# ------------------------------------------------------------------
# 第二部分: HTTP /log/* 接口 (Flask log_module)
# ------------------------------------------------------------------
print("\n [HTTP /log/* 接口测试]")
# 场景 5: 日期列 where — ISO 8601 字符串(dbv2 日期适配层)
result = client.call({
"_route": "execute",
"action": "select",
"table": "Users",
"columns": ["id", "username", "create_at"],
"where": {"create_at": {"lt": "2999-01-01T00:00:00"}},
"limit": 1,
})
assert_true(
result.get("success") is True,
"pymsg-日期列where-ISO字符串", c,
f"error={result.get('error', '')}",
)
# 检查 /log/health 是否可用(Flask log_module 是否挂载)
status, body, _ = http_get(f"{du}/log/health")
if status == 0 or status >= 500:
skip("HTTP /log/* 测试", "log_module 未挂载 (dbv2 使用 pymsg 模式, 非 Flask)", c)
else:
# 场景 5: /log/health 健康检查
assert_eq(status, 200, "log-健康检查 (200)", c)
# 场景 6: 日期列 where — $now 哨兵
result = client.call({
"_route": "execute",
"action": "select",
"table": "Users",
"columns": ["id", "username", "create_at"],
"where": {"create_at": {"lt": "$now"}},
"limit": 1,
})
assert_true(
result.get("success") is True,
"pymsg-日期列where-$now哨兵", c,
f"error={result.get('error', '')}",
)
# 场景 6: /log/ingest 写入日志
test_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
status, body, _ = http_post(f"{du}/log/ingest", {
"logs": [{
"service": "dnfs",
"level": "INFO",
"message": f"blackbox_test_{random.randint(10000, 99999)}",
"ts": test_ts,
}],
})
data = try_parse_json(body)
assert_true(
status == 200 and data is not None and data.get("success") is True,
"log-写入日志", c,
f"status={status} body={body[:150]}",
)
# 场景 7: /log/ingest — 空日志列表
status, body, _ = http_post(f"{du}/log/ingest", {"logs": []})
assert_eq(status, 200, "log-空日志列表 (200)", c)
# 场景 8: /log/ingest — 缺少 logs 字段
status, body, _ = http_post(f"{du}/log/ingest", {})
data = try_parse_json(body)
assert_true(
data is not None and not data.get("success", True),
"log-缺少logs字段-拒绝", c,
)
# 场景 9: /log/query 查询日志
status, body, _ = http_post(f"{du}/log/query", {
"limit": 5,
"desc": True,
})
data = try_parse_json(body)
assert_true(
status == 200 and data is not None and data.get("success") is True,
"log-查询日志", c,
f"status={status}",
)
if data and data.get("success"):
assert_true(
"logs" in data.get("data", {}),
"log-查询返回logs字段", c,
)
# 场景 10: /log/stats 统计
status, body, _ = http_post(f"{du}/log/stats", {
"group_by": "service",
})
data = try_parse_json(body)
assert_true(
status == 200 and data is not None,
"log-统计", c,
f"status={status}",
)
# 场景 7: 日期列 where — 非法日期字符串应报错
result = client.call({
"_route": "execute",
"action": "select",
"table": "Users",
"where": {"create_at": {"lt": "not-a-date"}},
})
assert_true(
result.get("success") is False,
"pymsg-日期列where-非法值-拒绝", c,
)
# ------------------------------------------------------------------
# 汇总
+6 -2
View File
@@ -17,7 +17,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import SERVICES, PORT_MAP, detect_service, get_url
from helpers import check_service
from helpers import check_service, check_tcp_service
TEST_MODULES = {
"gateway": "test_gateway",
@@ -53,7 +53,11 @@ def run_service_test(name: str, url: str) -> tuple:
print(f" {svc['description']} ({url})")
print(f"{'='*60}")
reachable, detail = check_service(url)
# pymsg/TCP 服务无 HTTP 接口,用 TCP 直连探测
if svc.get("protocol") == "pymsg":
reachable, detail = check_tcp_service(svc["host"], svc["port"])
else:
reachable, detail = check_service(url)
if not reachable:
print(f" [WARN] 服务不可达: {url}")
if detail:
+13 -13
View File
@@ -2,25 +2,25 @@
# ID 段:100000-199999
# 100001: 拦截 SQL 注入特征(UNION SELECT)
SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \
"id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'"
#SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \
# "id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'"
# 100002: 拦截 SQL 注释符
SecRule ARGS "@rx (?i)(--|#|/\*.*\*/)" \
"id:100002,phase:2,deny,status:403,log,msg:'SQL injection: comment'"
## 100002: 拦截 SQL 注释符
#SecRule ARGS "@rx (?i)(--|#|/\*.*\*/)" \
# "id:100002,phase:2,deny,status:403,log,msg:'SQL injection: comment'"
# 100003: 拦截 XSS 标签
SecRule ARGS|REQUEST_HEADERS "@rx (?i)<script|javascript:|onerror=|onload=" \
"id:100003,phase:2,deny,status:403,log,msg:'XSS probe'"
#SecRule ARGS|REQUEST_HEADERS "@rx (?i)<script|javascript:|onerror=|onload=" \
# "id:100003,phase:2,deny,status:403,log,msg:'XSS probe'"
# 100004: 路径遍历
SecRule REQUEST_URI|ARGS "@rx (?i)\.\./|\.\.%2f|\.\.%5c" \
"id:100004,phase:1,deny,status:403,log,msg:'Path traversal'"
#SecRule REQUEST_URI|ARGS "@rx (?i)\.\./|\.\.%2f|\.\.%5c" \
# "id:100004,phase:1,deny,status:403,log,msg:'Path traversal'"
# 100005: 命令注入常见关键字
SecRule ARGS "@rx (?i)(;|\|)&?\s*(cat|ls|id|whoami|uname|wget|curl|bash|sh)\s" \
"id:100005,phase:2,deny,status:403,log,msg:'Command injection'"
#SecRule ARGS "@rx (?i)(;|\|)&?\s*(cat|ls|id|whoami|uname|wget|curl|bash|sh)\s" \
# "id:100005,phase:2,deny,status:403,log,msg:'Command injection'"
# 100006: 已知恶意扫描器 UA
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(nikto|sqlmap|nmap|masscan|acunetix|nessus)" \
"id:100006,phase:1,deny,status:403,log,msg:'Malicious scanner UA'"
#SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(nikto|sqlmap|nmap|masscan|acunetix|nessus)" \
# "id:100006,phase:1,deny,status:403,log,msg:'Malicious scanner UA'"