This commit is contained in:
qing committed 2026-07-30 21:57:04 +08:00
1 parent f935448389
commit 3f97dd92ef
26 files changed
+1730 -316

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
# Git
.git
.gitignore
.gitattributes
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
.env
.env/
.venv/
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Build artifacts
*.bin
*.o
*.a
*.out
# Documentation (exclude READMEs)
*.md
!README.md
docs/
# Logs
*.log
logs/
# Data files (handled via volumes)
*.db
*.sqlite
*.sqlite3
data/
chroma/
# Other modules not needed for build
bot4cj/
core/
gateway/
lib*/
waf/
captcha/
smtp/
static/
tests/
3d/
BotOps/
pymsg/tests/
+11
View File
@@ -0,0 +1,11 @@
# AI Review 服务配置
# 复制此文件为 ai_review/.env 并填入你的 API Key
# DeepSeek/OpenAI API 密钥
OPENAI_API_KEY=your_api_key_here
# API Base URL(默认使用 DeepSeek)
OPENAI_BASE_URL=https://api.deepseek.com/v1
# 使用的模型名称
MODEL_NAME=deepseek-chat
+18
View File
@@ -0,0 +1,18 @@
.env
.env/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
.git
.gitignore
*.md
!README.md
.envrc
.venv/
venv/
ENV/
chroma/
.DS_Store
+43
View File
@@ -0,0 +1,43 @@
FROM python:3.11-slim
LABEL maintainer="qingsong"
LABEL service="ai_review"
LABEL description="AI内容审核服务 - DeepSeek + RAG"
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# 安装依赖(ai_review不依赖pymsg)
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 复制应用代码
COPY . /app/
# 创建ChromaDB持久化目录
RUN mkdir -p /app/chroma
VOLUME ["/app/chroma"]
# 创建非root用户
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8007
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8007/health', timeout=5)" || exit 1
# 默认环境变量
ENV AI_REVIEW_HOST=0.0.0.0 \
AI_REVIEW_PORT=8007 \
CHROMA_PERSIST_DIR=/app/chroma
CMD ["python", "ai_review.py"]
+2
View File
@@ -0,0 +1,2 @@
# 数据库备份输出目录
# 容器内备份会输出到此目录,可直接复制走
+6 -40
View File
@@ -21,7 +21,6 @@ captcha — 基于 pymsg 协议(MsgServer)的图形验证码服务。
import base64
import io
import json
import os
import random
import secrets
@@ -34,7 +33,7 @@ from PIL import Image, ImageDraw, ImageFont
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgServer # noqa: E402
from pymsg import RpcServer # noqa: E402
# ── 配置 ────────────────────────────────────────────────────────────────
@@ -100,54 +99,21 @@ class CaptchaServer:
def __init__(self, addr="127.0.0.1", port=8009, workers=4):
self.addr = addr
self.port = port
self._server = MsgServer(addr, port, workers=workers)
# 路由表
self._routes = {
"image": self._handle_image,
"verify": self._handle_verify,
"health": self._handle_health,
}
self._server = RpcServer(addr, port, workers=workers)
self._server.add_route("image", self._handle_image)
self._server.add_route("verify", self._handle_verify)
self._server.add_route("health", self._handle_health)
# ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None:
"""启动服务(阻塞当前线程)。"""
print(f"[captcha] 监听 tcp://{self.addr}:{self.port}")
self._server.start(self._dispatch)
self._server.start()
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:
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))
# ═══════════════════════════════════════════════════════════════════
# 路由处理
# ═══════════════════════════════════════════════════════════════════
+20
View File
@@ -0,0 +1,20 @@
.env
.env/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
.git
.gitignore
*.md
!README.md
.envrc
.venv/
venv/
ENV/
*.db
*.sqlite
*.sqlite3
.DS_Store
+53
View File
@@ -0,0 +1,53 @@
FROM python:3.11-slim
LABEL maintainer="qingsong"
LABEL service="db"
LABEL description="dbv2 - pymsg协议数据库服务"
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# 安装pymsg共享库
COPY pymsg/ /app/pymsg/
RUN pip install /app/pymsg/
# 安装依赖
COPY db/requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 复制应用代码
COPY db/ /app/db/
# 入口脚本
RUN chmod +x /app/db/docker-entrypoint.sh
# 创建数据持久化目录并设置权限
RUN mkdir -p /app/data && chown -R 1000:1000 /app/data
VOLUME ["/app/data"]
# 创建非root用户
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
WORKDIR /app/db
EXPOSE 8005
# 健康检查:尝试连接TCP端口
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost', 8005)); s.close()" || exit 1
# 默认使用SQLite,数据持久化到/app/data/database.db
ENV DATABASE_URL=sqlite:////app/data/database.db \
LISTEN_ADDR=0.0.0.0 \
LISTEN_PORT=8005 \
WORKERS=4
ENTRYPOINT ["/app/db/docker-entrypoint.sh"]
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""
db_tools — SQLite 数据库备份、导出、导入工具
用法:
python db_tools.py backup <output_path> # 安全备份数据库(使用 sqlite3 backup API)
python db_tools.py export <output.json> # 导出所有表数据为 JSON
python db_tools.py import <input.json> # 从 JSON 导入数据
python db_tools.py vacuum # 执行 VACUUM 压缩数据库
python db_tools.py wal-checkpoint # 强制 WAL 检查点(确保数据写入主库)
python db_tools.py info # 显示数据库信息和表统计
"""
import json
import os
import shutil
import sys
import sqlite3
from datetime import datetime, timezone
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db")
# 从 sqlite:///path 提取文件路径
def _get_db_path() -> str:
if DATABASE_URL.startswith("sqlite:////"):
return DATABASE_URL[10:] # 绝对路径 sqlite:////app/data/xxx.db
elif DATABASE_URL.startswith("sqlite:///"):
return DATABASE_URL[9:] # 相对路径
else:
return "database.db"
def _connect() -> sqlite3.Connection:
"""创建数据库连接(配置 PRAGMA)。"""
conn = sqlite3.connect(_get_db_path())
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.row_factory = sqlite3.Row
return conn
def backup(output_path: str):
"""
使用 SQLite 官方 backup API 进行在线热备份(不需要停机)。
这是最安全的备份方式,支持读写并发。
"""
src = _get_db_path()
print(f"[backup] 正在备份数据库: {src} -> {output_path}")
if not os.path.exists(src):
print(f"[backup] 错误: 数据库文件不存在: {src}", file=sys.stderr)
sys.exit(1)
start = datetime.now(timezone.utc)
# 使用 sqlite3 backup API(支持在线备份)
src_conn = sqlite3.connect(src)
dst_conn = sqlite3.connect(output_path)
try:
src_conn.backup(dst_conn)
dst_conn.commit()
finally:
dst_conn.close()
src_conn.close()
size_mb = os.path.getsize(output_path) / (1024 * 1024)
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
print(f"[backup] 备份完成: {output_path} ({size_mb:.2f} MB, 耗时 {elapsed:.2f}s)")
def export_json(output_path: str):
"""导出所有表数据为 JSON 格式(便于跨平台传输/迁移)。"""
print(f"[export] 正在导出数据到: {output_path}")
conn = _connect()
result = {
"_meta": {
"exported_at": datetime.now(timezone.utc).isoformat(),
"db_path": _get_db_path(),
"sqlite_version": sqlite3.sqlite_version,
},
"tables": {}
}
try:
# 获取所有用户表
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
for (table_name,) in tables:
# 获取列信息
columns = [desc[1] for desc in conn.execute(f"PRAGMA table_info({table_name})").fetchall()]
# 获取行数
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0]
# 获取所有数据
rows = conn.execute(f"SELECT * FROM [{table_name}]").fetchall()
data = [dict(row) for row in rows]
result["tables"][table_name] = {
"columns": columns,
"row_count": count,
"data": data,
}
print(f"[export] 表 {table_name}: {count} 行")
# 同时导出 schema
schema = []
for (table_name,) in tables:
ddl = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
(table_name,)
).fetchone()[0]
if ddl:
schema.append(ddl + ";")
result["_meta"]["schema"] = schema
finally:
conn.close()
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
size_mb = os.path.getsize(output_path) / (1024 * 1024)
print(f"[export] 导出完成: {output_path} ({size_mb:.2f} MB)")
def import_json(input_path: str):
"""从 JSON 文件导入数据(注意:会清空现有数据!)。"""
print(f"[import] 正在从 {input_path} 导入数据")
if not os.path.exists(input_path):
print(f"[import] 错误: 文件不存在: {input_path}", file=sys.stderr)
sys.exit(1)
with open(input_path, "r", encoding="utf-8") as f:
data = json.load(f)
conn = _connect()
try:
# 是否清空现有表(需要确认)
tables = data.get("tables", {})
if tables:
print(f"[import] 警告: 即将导入 {len(tables)} 个表,现有数据将被覆盖!")
# 为安全起见,不自动清空,由用户先备份
# 按外键依赖顺序导入(先主表后子表,这里简单按名字排序)
for table_name in sorted(tables.keys()):
table_data = tables[table_name]
rows = table_data.get("data", [])
columns = table_data.get("columns", [])
if not rows or not columns:
print(f"[import] 跳表 {table_name}: 无数据或列定义")
continue
# 先清空表
conn.execute(f"DELETE FROM [{table_name}]")
# 批量插入
placeholders = ", ".join(["?"] * len(columns))
col_names = ", ".join(f"[{c}]" for c in columns)
insert_sql = f"INSERT INTO [{table_name}] ({col_names}) VALUES ({placeholders})"
conn.executemany(insert_sql, [tuple(row.get(col) for col in columns) for row in rows])
print(f"[import] 表 {table_name}: 导入 {len(rows)} 行")
conn.commit()
print("[import] 导入完成")
else:
print("[import] 没有可导入的表数据")
finally:
conn.close()
def vacuum():
"""执行 VACUUM 压缩数据库,回收空间、重建索引。"""
print("[vacuum] 正在执行 VACUUM...")
db_path = _get_db_path()
before_size = os.path.getsize(db_path) / (1024 * 1024)
conn = _connect()
try:
# WAL 检查点先将所有数据写入主库
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("VACUUM")
conn.execute("ANALYZE")
finally:
conn.close()
after_size = os.path.getsize(db_path) / (1024 * 1024)
saved = before_size - after_size
print(f"[vacuum] 完成: {before_size:.2f} MB -> {after_size:.2f} MB (节省 {saved:.2f} MB)")
def wal_checkpoint():
"""强制 WAL 检查点,确保所有写入都持久化到主数据库文件。"""
print("[wal-checkpoint] 执行 WAL 检查点...")
conn = _connect()
try:
result = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
# busy, log, checkpointed
print(f"[wal-checkpoint] 结果: busy={result[0]}, log_pages={result[1]}, checkpointed={result[2]}")
finally:
conn.close()
print("[wal-checkpoint] 完成")
def show_info():
"""显示数据库信息和表统计。"""
db_path = _get_db_path()
print("=" * 50)
print("SQLite 数据库信息")
print("=" * 50)
print(f"文件路径: {os.path.abspath(db_path)}")
if os.path.exists(db_path):
size_mb = os.path.getsize(db_path) / (1024 * 1024)
mtime = datetime.fromtimestamp(os.path.getmtime(db_path), tz=timezone.utc)
print(f"文件大小: {size_mb:.2f} MB")
print(f"最后修改: {mtime.isoformat()}")
else:
print("文件大小: (不存在)")
# WAL 文件
wal_path = db_path + "-wal"
shm_path = db_path + "-shm"
if os.path.exists(wal_path):
wal_size = os.path.getsize(wal_path) / 1024
print(f"WAL 文件: {wal_size:.2f} KB")
if os.path.exists(shm_path):
shm_size = os.path.getsize(shm_path) / 1024
print(f"SHM 文件: {shm_size:.2f} KB")
conn = _connect()
try:
print(f"SQLite 版本: {sqlite3.sqlite_version}")
# PRAGMA 信息
journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
synchronous = conn.execute("PRAGMA synchronous").fetchone()[0]
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
page_count = conn.execute("PRAGMA page_count").fetchone()[0]
free_pages = conn.execute("PRAGMA freelist_count").fetchone()[0]
print(f"Journal 模式: {journal_mode}")
print(f"同步级别: {synchronous}")
print(f"页大小: {page_size} bytes")
# 表统计
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
print(f"\n表统计 ({len(tables)} 个表):")
print("-" * 40)
total_rows = 0
for (table_name,) in tables:
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0]
total_rows += count
print(f" {table_name:20s} {count:>8d} 行")
print("-" * 40)
print(f" {'总计':20s} {total_rows:>8d} 行")
finally:
conn.close()
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
cmd = sys.argv[1]
if cmd == "backup":
output = sys.argv[2] if len(sys.argv) > 2 else f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
backup(output)
elif cmd == "export":
output = sys.argv[2] if len(sys.argv) > 2 else f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
export_json(output)
elif cmd == "import":
if len(sys.argv) < 3:
print("用法: python db_tools.py import <input.json>", file=sys.stderr)
sys.exit(1)
import_json(sys.argv[2])
elif cmd == "vacuum":
vacuum()
elif cmd == "wal-checkpoint":
wal_checkpoint()
elif cmd == "info":
show_info()
else:
print(f"未知命令: {cmd}", file=sys.stderr)
print(__doc__)
sys.exit(1)
if __name__ == "__main__":
main()
+43 -44
View File
@@ -37,6 +37,7 @@ from datetime import datetime, date, time as dt_time, timezone, timedelta
from sqlalchemy import (
create_engine, MetaData, Table,
select, insert, update, delete, func, and_,
event,
)
from sqlalchemy import types as sa_types
from sqlalchemy.exc import SQLAlchemyError
@@ -44,12 +45,32 @@ from sqlalchemy.exc import SQLAlchemyError
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgServer # noqa: E402
from pymsg import RpcServer # noqa: E402
# ── 数据库配置 ──────────────────────────────────────────────────────────
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db")
# ── SQLite 连接配置(针对 Docker/多线程环境优化)──────────────────────
def _configure_sqlite(dbapi_connection, connection_record):
"""为每个 SQLite 连接设置 PRAGMA,优化并发与持久化性能。
- WAL 模式:读写不互斥,并发性能大幅提升,Docker 卷挂载下更稳定
- NORMAL 同步级别:在断电时可能丢失最近提交的事务(但不会损坏数据库)
配合 WAL checkpoint 足够安全,性能显著提升
- foreign_keys=ON:启用外键约束
- busy_timeout:锁等待超时 5 秒,避免 SQLITE_BUSY 错误
- mmap_size:使用内存映射 I/O 提升读取性能
"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA mmap_size=268435456") # 256MB
cursor.close()
# ── 日志模块常量 ────────────────────────────────────────────────────────
MAX_MESSAGE_LEN = 8192 # message 上限 8KB
@@ -80,9 +101,20 @@ class DBServer:
def __init__(self, addr="127.0.0.1", port=8005, workers=4):
self.addr = addr
self.port = port
self.engine = create_engine(DATABASE_URL)
self.engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
)
# 为 SQLite 连接应用 PRAGMA 优化
if DATABASE_URL.startswith("sqlite"):
event.listen(self.engine, "connect", _configure_sqlite)
self.metadata = MetaData()
self._server = MsgServer(addr, port, workers=workers)
self._server = RpcServer(
addr, port, workers=workers,
json_default=_json_default,
on_error=lambda route, e: traceback.print_exc(),
)
self._server.add_route("execute", self._handle_execute)
# 日志表缓存(避免重复反射)
self._logs_table_cache = {}
@@ -98,54 +130,18 @@ class DBServer:
# 抛 InvalidRequestError),反射操作需串行化
self._reflect_lock = threading.Lock()
# 路由表
self._routes = {
"execute": self._handle_execute,
}
# ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None:
"""启动服务(阻塞当前线程)。"""
print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}")
self._ensure_logs_table() # 确保 Logs 表已建好(外部 MsgClient 写日志前)
self._ensure_logs_table() # 确保 Logs 表已建好(外部写日志前)
self._start_cleanup_thread()
self._server.start(self._dispatch)
self._server.start()
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, sock=sock)
self._reply(sock, True, data=result)
except Exception as e:
traceback.print_exc()
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,
default=_json_default))
# ── 表反射 ---------------------------------------------------------
def _get_table(self, table_name):
# double-check:_tables_cache 只在反射完整完成后写入(dict 读写 GIL
@@ -245,7 +241,7 @@ class DBServer:
# 路由处理 — execute
# ═══════════════════════════════════════════════════════════════════
def _handle_execute(self, data, **kw):
def _handle_execute(self, data):
action = data.get("action", "select")
table_name = data.get("table")
if not table_name:
@@ -460,9 +456,12 @@ class DBServer:
if __name__ == "__main__":
import argparse
default_addr = os.environ.get("LISTEN_ADDR", "0.0.0.0")
default_port = int(os.environ.get("LISTEN_PORT", "8005"))
parser = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
parser.add_argument("--port", type=int, default=8005, help="监听端口")
parser.add_argument("--addr", default=default_addr, help="监听地址")
parser.add_argument("--port", type=int, default=default_port, help="监听端口")
parser.add_argument("--workers", type=int, default=4,
help="回调并发线程数(0=同步串行模式)")
args = parser.parse_args()
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
DATA_DIR="/app/data"
DB_PATH="${DATABASE_URL#sqlite:///}"
if [ "$DB_PATH" = "$DATABASE_URL" ]; then
# URL 不是 sqlite:/// 格式,使用默认路径
DB_PATH="$DATA_DIR/database.db"
fi
echo "数据目录: $DATA_DIR"
echo "数据库路径: $DB_PATH"
# 确保数据目录存在并可写
mkdir -p "$(dirname "$DB_PATH")"
# 初始化数据库表(如果不存在)
if [[ "$DATABASE_URL" == sqlite:* ]]; then
echo "初始化数据库 schema..."
python -c "
import os
from sqlalchemy import create_engine, text
db_url = os.environ.get('DATABASE_URL', 'sqlite:////app/data/database.db')
engine = create_engine(
db_url,
connect_args={'check_same_thread': False} if db_url.startswith('sqlite') else {},
)
# 应用 SQLite PRAGMA 优化
from sqlalchemy import event
@event.listens_for(engine, 'connect')
def _configure_sqlite(dbapi_conn, _):
cur = dbapi_conn.cursor()
cur.execute('PRAGMA journal_mode=WAL')
cur.execute('PRAGMA synchronous=NORMAL')
cur.execute('PRAGMA foreign_keys=ON')
cur.execute('PRAGMA busy_timeout=5000')
cur.close()
with open('create_sqlite3.sql', 'r') as f:
schema_sql = f.read()
with engine.connect() as conn:
for stmt in schema_sql.split(';'):
stmt = stmt.strip()
if stmt:
try:
conn.execute(text(stmt))
except Exception as e:
pass
conn.commit()
print('Schema 初始化完成.')
"
fi
echo "启动 dbv2 服务 ${LISTEN_ADDR:-0.0.0.0}:${LISTEN_PORT:-8005}..."
exec python dbv2.py --addr "${LISTEN_ADDR:-0.0.0.0}" --port "${LISTEN_PORT:-8005}" --workers "${WORKERS:-4}"
+18
View File
@@ -0,0 +1,18 @@
.env
.env/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
.git
.gitignore
*.md
!README.md
.envrc
.venv/
venv/
ENV/
*.bin
.DS_Store
+48
View File
@@ -0,0 +1,48 @@
FROM python:3.11-slim
LABEL maintainer="qingsong"
LABEL service="dnfs"
LABEL description="Dendrachy Network File-System - 树状网络文件系统"
# 设置工作目录
WORKDIR /app
# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# 安装系统依赖(如果有需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# 先复制pymsg共享库并安装
COPY pymsg/ /app/pymsg/
RUN pip install /app/pymsg/
# 复制requirements并安装依赖
COPY dnfs/requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 移除flask_sqlalchemy遗留依赖(已不使用)
RUN pip uninstall -y flask_sqlalchemy 2>/dev/null || true
# 复制应用代码
COPY dnfs/ /app/dnfs/
# 创建非root用户运行(安全最佳实践)
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
WORKDIR /app/dnfs
# 暴露端口
EXPOSE 8006
# 健康检查
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8006/get_notes', data=b'{\"pid\":0}', timeout=3)" || exit 1
# 启动服务
CMD ["python", "dnfs.py"]
+19 -14
View File
@@ -32,30 +32,36 @@ from log_client import logs
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgClient # noqa: E402
from pymsg import RpcClient, RpcError # noqa: E402
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
_DBV2_ADDR = os.environ.get('DB_HOST', '127.0.0.1')
_DBV2_PORT = int(os.environ.get('DB_PORT', '8005'))
# 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload
# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。
# 旧 Token 无此字段,按非管理员处理(安全方向)。
_rpc = RpcClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
"""通过 pymsg 短连接调用 dbv2 数据库服务。
自动注入 ``_route: "execute"``。
"""通过 pymsg RPC 调用 dbv2 数据库服务(自动注入 ``_route: "execute"``)。
返回完整响应 dict(``{"success": ..., "data"/"error": ...}``),
与历史行为一致,调用方无需改动。服务端业务错误返回 success=False;
网络层故障抛 ConnectionError。
"""
payload = {**payload, "_route": "execute"}
try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
resp_text = client.call(json.dumps(payload), timeout=timeout)
return json.loads(resp_text)
data = _rpc.call("execute", payload, timeout=timeout)
return {"success": True, "data": data}
except RpcError as e:
return {"success": False, "error": str(e)}
except Exception as e:
raise ConnectionError(
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
) from None
REVIEW_SERVER_URL = 'http://127.0.0.1:8007/review'
REVIEW_SERVER_URL = os.environ.get('REVIEW_URL', 'http://127.0.0.1:8007/review')
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
"""
调用 AI 内容审核服务的 /review 接口,对给定文本进行审核。
@@ -545,10 +551,9 @@ def get_note():
# """返回应用主页"""
# return render_template('index.html')
ADDR='127.0.0.1'
PORT=8006
ADDR = os.environ.get('LISTEN_ADDR', '0.0.0.0')
PORT = int(os.environ.get('LISTEN_PORT', '8006'))
if __name__ == "__main__":
logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}')
print(f'Listen at http://{ADDR}:{PORT}')
#app.run(host="127.0.0.1", port=8006)
serve(app, host='127.0.0.1', port=8006)
serve(app, host=ADDR, port=PORT)
+34 -35
View File
@@ -1,8 +1,7 @@
"""
统一日志客户端 — 各服务共用。
"""统一日志客户端 — 各服务共用(dnfs / smtp / static 三份副本内容一致,改动请同步)。
设计要点:
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
@@ -26,12 +25,12 @@ import atexit
from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
from pymsg import MsgClient # noqa: E402
from pymsg import RpcClient, RpcError # noqa: E402
# ── 配置 ──────────────────────────────────────────────────────────────
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
_BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
@@ -41,8 +40,10 @@ _TIMEOUT = 5 # socket 超时(秒)
class LogClient:
"""异步批量日志推送客户端。"""
def __init__(self, batch_size=_BATCH_SIZE,
def __init__(self, service_name="log",
batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL):
self._service_name = service_name
self._batch_size = batch_size
self._flush_interval = flush_interval
self._queue = queue.Queue()
@@ -133,35 +134,33 @@ class LogClient:
self._send(batch)
def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
"""通过 pymsg RPC(execute 路由)逐条 INSERT 到 dbv2 Logs 表。"""
now = datetime.now(timezone.utc).isoformat()
try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"_route": "execute",
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
resp_obj = json.loads(resp)
if not resp_obj.get("success"):
import sys
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
file=sys.stderr)
except Exception as e:
import sys
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
try:
client.call("execute", payload, timeout=_TIMEOUT)
except RpcError as e:
# 业务错误(如表结构不符):打印后继续下一条
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
except Exception as e:
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
break
def _shutdown(self):
"""进程退出时 flush 剩余日志。"""
+109
View File
@@ -0,0 +1,109 @@
version: '3.8'
# ── 持久化说明 ──────────────────────────────────────────────────────
# 默认使用 Docker named volume(适合生产,性能好)。
# 开发调试或需要直接访问数据库文件时,可改为 bind mount:
# volumes:
# - ./data/db:/app/data # SQLite 数据库文件
# - ./backups:/app/backups # 备份输出目录
#
# 注意:bind mount 在 macOS/Windows 上文件系统性能比 named volume 差,
# 但方便直接复制/查看数据库文件。
services:
# ── 数据库服务 (pymsg TCP协议) ──────────────────────────────────
db:
build:
context: .
dockerfile: db/Dockerfile
container_name: micro-db
restart: unless-stopped
ports:
- "127.0.0.1:8005:8005"
volumes:
# 方式1: Docker named volume(推荐生产)
- db_data:/app/data
# 方式2: Bind mount(开发/需要直接访问文件时取消注释)
# - ./data/db:/app/data
# 备份输出目录(bind mount 方便取备份文件)
- ./backups:/app/backups
environment:
- LISTEN_ADDR=0.0.0.0
- LISTEN_PORT=8005
- DATABASE_URL=sqlite:////app/data/database.db
- WORKERS=4
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost', 8005)); s.close()"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
networks:
- micro_net
# ── AI内容审核服务 ──────────────────────────────────────────────
ai_review:
build:
context: ./ai_review
dockerfile: Dockerfile
container_name: micro-ai-review
restart: unless-stopped
ports:
- "127.0.0.1:8007:8007"
volumes:
- chroma_data:/app/chroma
# - ./data/chroma:/app/chroma # bind mount 选项
environment:
- AI_REVIEW_HOST=0.0.0.0
- AI_REVIEW_PORT=8007
- CHROMA_PERSIST_DIR=/app/chroma
env_file:
- ./ai_review/.env
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8007/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- micro_net
# ── DNFS 树状文件系统服务 ──────────────────────────────────────
dnfs:
build:
context: .
dockerfile: dnfs/Dockerfile
container_name: micro-dnfs
restart: unless-stopped
ports:
- "127.0.0.1:8006:8006"
environment:
- LISTEN_ADDR=0.0.0.0
- LISTEN_PORT=8006
- DB_HOST=db
- DB_PORT=8005
- REVIEW_URL=http://ai_review:8007/review
depends_on:
db:
condition: service_healthy
ai_review:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8006/get_notes', data=b'{\"pid\":0}', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- micro_net
networks:
micro_net:
driver: bridge
name: micro-network
volumes:
db_data:
name: micro-db-data
chroma_data:
name: micro-chroma-data
Executable
+386
View File
@@ -0,0 +1,386 @@
#!/bin/bash
#
# 微服务容器化管理脚本
# 用法: ./docker.sh [command]
#
set -e
cd "$(dirname "$0")"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
check_docker() {
if ! command -v docker &> /dev/null; then
error "Docker 未安装,请先安装 Docker Desktop"
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
error "Docker Compose 未安装"
fi
}
# 使用 docker compose 或 docker-compose(兼容旧版本)
docker_compose() {
if docker compose version &> /dev/null; then
docker compose "$@"
else
docker-compose "$@"
fi
}
cmd_build() {
info "构建所有服务镜像..."
docker_compose build --progress=plain "$@"
info "构建完成"
}
cmd_up() {
info "启动服务(后台运行)..."
docker_compose up -d "$@"
info "服务已启动"
echo ""
cmd_ps
}
cmd_down() {
info "停止并移除容器..."
docker_compose down "$@"
info "服务已停止"
}
cmd_start() {
info "启动已存在的容器..."
docker_compose start "$@"
}
cmd_stop() {
info "停止运行中的容器..."
docker_compose stop "$@"
}
cmd_restart() {
info "重启服务..."
docker_compose restart "$@"
}
cmd_ps() {
info "服务状态:"
docker_compose ps
}
cmd_logs() {
if [ -z "$1" ]; then
docker_compose logs -f --tail=100
else
docker_compose logs -f --tail=100 "$@"
fi
}
cmd_shell() {
local service=${1:-dnfs}
info "进入 $service 容器 shell..."
docker_compose exec "$service" /bin/bash || docker_compose exec "$service" /bin/sh
}
cmd_rebuild() {
info "重新构建并启动服务..."
docker_compose build --no-cache "$@"
docker_compose up -d "$@"
}
cmd_clean() {
warn "此操作将停止所有容器并删除数据卷(数据库和ChromaDB数据将丢失!)"
read -p "确认继续? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
docker_compose down -v
info "已清理所有容器和数据卷"
else
info "已取消"
fi
}
cmd_pull() {
info "拉取基础镜像..."
docker pull python:3.11-slim
}
cmd_health() {
info "检查服务健康状态..."
local services=("db:8005" "ai_review:8007" "dnfs:8006")
for svc in "${services[@]}"; do
IFS=':' read -r name port <<< "$svc"
if nc -z 127.0.0.1 "$port" 2>/dev/null; then
echo -e " ${GREEN}✓${NC} $name :$port 健康"
else
echo -e " ${RED}✗${NC} $name :$port 不可达"
fi
done
}
# ── 数据库管理命令 ──────────────────────────────────────────────
DB_CONTAINER="micro-db"
_check_db_running() {
if ! docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER}$"; then
error "db 容器未运行,请先启动服务: ./docker.sh up"
fi
}
cmd_db_info() {
_check_db_running
info "数据库信息:"
docker_compose exec db python db_tools.py info
}
cmd_db_backup() {
_check_db_running
local backup_file="${1:-}"
if [ -z "$backup_file" ]; then
backup_file="backup_$(date +%Y%m%d_%H%M%S).db"
fi
info "执行数据库备份..."
# 先做 WAL checkpoint 确保数据完整
docker_compose exec db python db_tools.py wal-checkpoint
# 备份到容器内的 /app/backups 目录(映射到宿主机 ./backups)
docker_compose exec db python db_tools.py backup "/app/backups/$backup_file"
echo ""
info "备份文件: ./backups/$backup_file"
ls -lh "./backups/$backup_file" 2>/dev/null || info "(文件已写入容器 /app/backups/) "
}
cmd_db_export() {
_check_db_running
local export_file="${1:-}"
if [ -z "$export_file" ]; then
export_file="export_$(date +%Y%m%d_%H%M%S).json"
fi
info "导出数据库为 JSON..."
docker_compose exec db python db_tools.py export "/app/backups/$export_file"
echo ""
info "导出文件: ./backups/$export_file"
}
cmd_db_import() {
local import_file="$1"
if [ -z "$import_file" ]; then
error "用法: ./docker.sh db-import <json文件路径>"
fi
if [ ! -f "./backups/$import_file" ] && [ ! -f "$import_file" ]; then
# 检查是否在backups目录
error "导入文件不存在: $import_file (请放在 ./backups/ 目录下或提供绝对路径)"
fi
_check_db_running
local container_path="/app/backups/$(basename "$import_file")"
# 如果是宿主机路径且不在backups下,先复制到backups
if [ -f "$import_file" ] && [ ! -f "./backups/$(basename "$import_file")" ]; then
warn "复制文件到 backups 目录..."
cp "$import_file" "./backups/"
fi
warn "即将导入数据,现有数据将被覆盖!"
read -p "确认继续? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
# 先备份当前数据
cmd_db_backup "before_import_$(date +%Y%m%d_%H%M%S).db"
info "开始导入..."
docker_compose exec db python db_tools.py import "$container_path"
else
info "已取消"
fi
}
cmd_db_vacuum() {
_check_db_running
info "执行 VACUUM 压缩数据库..."
docker_compose exec db python db_tools.py vacuum
}
cmd_db_restore() {
local backup_file="$1"
if [ -z "$backup_file" ]; then
error "用法: ./docker.sh db-restore <备份文件路径> (从 ./backups/ 目录)"
error "示例: ./docker.sh db-restore backup_20260730_120000.db"
fi
local backup_path="./backups/$backup_file"
if [ ! -f "$backup_path" ]; then
# 尝试绝对路径
if [ -f "$backup_file" ]; then
backup_path="$backup_file"
else
error "备份文件不存在: $backup_file"
fi
fi
_check_db_running
warn "即将从备份恢复数据库,当前数据将被覆盖!"
warn "备份文件: $backup_path"
read -p "确认继续? [y/N] 输入 YES 确认: " -r
echo
if [[ "$REPLY" == "YES" ]]; then
info "停止 dnfs 和 ai_review 服务..."
docker_compose stop dnfs ai_review
info "复制备份文件到容器..."
docker cp "$backup_path" "$DB_CONTAINER:/app/data/database.db"
info "重启 db 服务..."
docker_compose restart db
# 等待db健康
info "等待 db 服务恢复..."
sleep 5
info "启动其他服务..."
docker_compose start dnfs ai_review
info "恢复完成!"
else
info "已取消"
fi
}
cmd_db_list_backups() {
info "备份文件列表 (./backups/):"
if [ -d "./backups" ]; then
ls -lhS ./backups/ 2>/dev/null | grep -v "total" | grep -v ".gitkeep" || echo " (无备份文件)"
else
echo " backups 目录不存在"
fi
}
usage() {
echo "微服务容器化管理脚本"
echo ""
echo "用法: ./docker.sh <command> [options]"
echo ""
echo "── 服务管理 ──"
echo " build 构建所有服务镜像"
echo " up 启动所有服务(后台运行)"
echo " down 停止并移除所有容器"
echo " start 启动已停止的容器"
echo " stop 停止运行中的容器"
echo " restart 重启服务"
echo " ps 查看服务状态"
echo " logs [svc] 查看日志(默认所有服务,可指定服务名)"
echo " shell [svc] 进入容器shell(默认dnfs)"
echo " rebuild 重新构建(无缓存)并启动"
echo " health 检查服务健康状态"
echo " pull 拉取最新基础镜像"
echo " clean 停止并删除所有容器+数据卷(危险!)"
echo ""
echo "── 数据库管理 (SQLite) ──"
echo " db-info 查看数据库信息和表统计"
echo " db-backup [file] 备份数据库(.db 二进制文件,输出到 ./backups/)"
echo " db-export [file] 导出数据为 JSON(跨平台传输,输出到 ./backups/)"
echo " db-import <file> 从 JSON 导入数据(文件放在 ./backups/ 下)"
echo " db-restore <file> 从 .db 备份文件恢复"
echo " db-vacuum VACUUM 压缩数据库"
echo " db-ls 列出备份文件"
echo ""
echo "示例:"
echo " ./docker.sh build dnfs # 仅构建dnfs服务"
echo " ./docker.sh logs dnfs # 查看dnfs日志"
echo " ./docker.sh shell db # 进入db容器"
echo " ./docker.sh db-backup # 立即备份"
echo " ./docker.sh db-export # 导出JSON用于迁移"
echo " ./docker.sh db-ls # 查看现有备份"
echo ""
}
# 主逻辑
check_docker
case "${1:-}" in
build)
shift
cmd_build "$@"
;;
up)
shift
cmd_up "$@"
;;
down)
shift
cmd_down "$@"
;;
start)
shift
cmd_start "$@"
;;
stop)
shift
cmd_stop "$@"
;;
restart)
shift
cmd_restart "$@"
;;
ps|status)
cmd_ps
;;
logs)
shift
cmd_logs "$@"
;;
shell)
shift
cmd_shell "$@"
;;
rebuild)
shift
cmd_rebuild "$@"
;;
health)
cmd_health
;;
pull)
cmd_pull
;;
clean)
cmd_clean
;;
# ── 数据库管理命令 ──
db-info|dbinfo)
cmd_db_info
;;
db-backup|dbbackup)
shift
cmd_db_backup "$@"
;;
db-export|dbexport)
shift
cmd_db_export "$@"
;;
db-import|dbimport)
shift
cmd_db_import "$@"
;;
db-restore|dbrestore)
shift
cmd_db_restore "$@"
;;
db-vacuum|dbvacuum)
cmd_db_vacuum
;;
db-ls|dbls)
cmd_db_list_backups
;;
*)
usage
exit 1
;;
esac
+69 -49
View File
@@ -179,61 +179,85 @@ server = MsgServer("0.0.0.0", 8005, workers=4)
- `MsgClient.stop()` 可从另一线程调用以中断 `listen()`。
- `send()` 和 `recv()` 内部不做同步 —— 如果多线程共享同一 socket,调用方需自行串行化。
## JSON RPC 约定层(v0.3.0)
项目内所有 pymsg 通信都遵循同一 JSON RPC 约定(与 C++/Go 客户端字节级兼容):
```
请求: {"_route": "<路由名>", ...业务字段}
响应: {"success": true, "data": ...} | {"success": false, "error": "..."}
```
`RpcServer` / `RpcClient` 把这套约定的编解码、路由分发、错误包装收敛为开箱即用的类(`pymsg/rpc.py`),各服务无需再手写 `_dispatch` / `_reply`。
### `RpcServer` — JSON RPC 服务端
```python
from pymsg import RpcServer
rpc = RpcServer("127.0.0.1", 8005, workers=4)
@rpc.route("execute")
def _execute(req): # handler(req: dict) -> data
return {"rows": []} # 返回值自动包成 {"success": true, "data": ...}
# 抛异常自动包成 {"success": false, "error": str(e)}
rpc.start() # 阻塞当前线程
```
| 构造参数 | 含义 |
|----------|------|
| `addr` / `port` | 监听地址与端口 |
| `workers` | 回调并发线程数(透传 `MsgServer`,默认 0) |
| `json_default` | 响应 JSON 序列化兜底函数(如日期转 ISO 8601) |
| `on_error` | 可选钩子 `on_error(route, exc)`,handler 异常时调用(记日志用) |
### `RpcClient` — JSON RPC 客户端
```python
from pymsg import RpcClient, RpcError
rpc = RpcClient("127.0.0.1", 8005, "dnfs", timeout=10.0)
try:
data = rpc.call("execute", {"action": "select", "table": "Users"})
except RpcError as e:
... # 服务端业务错误(success=false)
```
`call(route, payload=None, *, timeout=None)`:自动注入 `_route`、JSON 编解码、检查 `success`,返回 `data` 字段;业务错误抛 `RpcError`,网络故障仍抛 `ConnectionRefusedError` / `TimeoutError`(来自 `MsgClient`)。
## 实际用例
### 服务端 — 数据库服务(dbv2)
db 服务通过 pymsg 暴露通用 CRUD 接口。每条请求是带 `_route` 字段的 JSON 对象,服务端按路由分发到对应处理函数,通过同一 socket 回复结果。
db 服务基于 `RpcServer` 暴露通用 CRUD 接口(路由 `execute`):
```python
import json
from pymsg import MsgServer
from pymsg import RpcServer
class DBServer:
def __init__(self):
self._server = MsgServer("127.0.0.1", 8005)
self._routes = {"execute": self._handle_execute}
def __init__(self, addr="127.0.0.1", port=8005, workers=4):
self._server = RpcServer(addr, port, workers=workers,
json_default=_json_default)
self._server.add_route("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)}))
self._server.start()
def _handle_execute(self, data):
# ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表
# ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表;异常自动包装为错误响应
pass
```
### 客户端 — RPC 调用(dnfs → db)
dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 pymsg 调用 db。
dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 `RpcClient` 调用 db:
```python
import json
from pymsg import MsgClient
from pymsg import RpcClient
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)
_rpc = RpcClient("127.0.0.1", 8005, "dnfs")
# 创建笔记
result = _call_dbv2({
"_route": "execute",
data = _rpc.call("execute", {
"action": "insert",
"table": "Notes",
"values": {"title": "会议记录", "content": "..."}
@@ -242,24 +266,19 @@ result = _call_dbv2({
### 客户端 — 异步日志推送(log_client)
各服务通过 pymsg 将日志异步推送到 db。使用 `MsgClient.call()` 逐批插入。
各服务通过统一 `log_client.py` 将日志异步推送到 db,底层为 `RpcClient`:
```python
import json
from pymsg import MsgClient
from pymsg import RpcClient
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)
client = RpcClient("127.0.0.1", 8005, "log-client")
for entry in batch:
client.call("execute", {
"action": "insert",
"table": "Logs",
"values": {"ts": now, "service": entry["service"],
"level": entry["level"], "message": entry["message"]},
}, timeout=5)
```
## 跨语言互操作
@@ -289,6 +308,7 @@ pymsg 的线格式有意设计得极其简单,使其他语言的服务也能
| `ConnectionError`(内置) | 完整消息接收前 socket 断开 |
| `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 |
| `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 |
| `pymsg.RpcError` | `RpcClient.call()` 收到 `success=false` 业务错误或非法 RPC 响应 |
`MsgServer` 内部会吞掉客户端断连错误(移除该客户端,继续服务)。`broadcast()` 静默跳过已断开的 socket。
+19 -3
View File
@@ -1,7 +1,7 @@
# pymsg 模块技术手册
> 文档版本:2026-07-30
> 代码版本:0.2.0
> 代码版本:0.3.0
> 语言:Python 3.9+(核心)、C++(gateway 集成)、Go(waf 集成)
> 定位:跨语言 TCP 消息帧协议库
@@ -144,9 +144,11 @@ log_client.push(entry)
```
pymsg/
├── pymsg/
│ └── __init__.py # 核心实现(~460 行)
│ ├── __init__.py # 核心协议与 MsgClient/MsgServer(~470 行)
│ └── rpc.py # JSON RPC 约定层 RpcClient/RpcServer(~170 行,v0.3.0)
├── tests/
│ └── test_threadpool.py # 线程池/同步模式单元测试(纯 stdlib)
│ ├── test_threadpool.py # 线程池/同步模式单元测试(纯 stdlib)
│ └── test_rpc.py # RPC 约定层单元测试(纯 stdlib)
├── README.md # 用户文档
├── pyproject.toml # 项目配置
└── .gitignore
@@ -189,6 +191,19 @@ pymsg/
| `MsgServer._run_task(...)` | L413 | 工作线程:执行回调、失败打标记 |
| `MsgServer._reap_failed(socketlist)` | L434 | 主循环统一移除失败连接 |
#### RPC 约定层(rpc.py,v0.3.0)
| 函数/类 | 行号 | 功能 |
|---------|------|------|
| `RpcError` | L28 | 业务错误异常(success=false / 非法响应) |
| `RpcClient` | L32 | JSON RPC 客户端 |
| `RpcClient.call(route, payload, *, timeout)` | L46 | 注入 _route、编解码、检查 success,返回 data |
| `RpcServer` | L80 | JSON RPC 服务端(基于 MsgServer) |
| `RpcServer.route(name)` / `add_route` | L119/L126 | 注册路由 handler(`fn(req) -> data`) |
| `RpcServer.start()` / `stop()` | L131/L135 | 启动(阻塞)/ 停止 |
| `RpcServer._dispatch` | L140 | 路由分发与异常包装 |
| `RpcServer._reply` | L164 | 响应包装(支持 json_default) |
#### C++ 实现(Msg.hpp)
| 函数/类 | 行号 | 功能 |
@@ -736,6 +751,7 @@ python test_runner.py
|------|------|---------|
| 0.1.0 | — | 初始版本:核心协议、MsgClient、MsgServer、跨语言实现 |
| 0.2.0 | 2026-07-30 | `MsgServer` 新增 `workers` 参数:回调可提交线程池并发执行;同连接串行(busy 标记)、每连接发送锁防帧交错、回调异常故障隔离;`tests/test_threadpool.py` 单元测试 |
| 0.3.0 | 2026-07-30 | 新增 `rpc.py` JSON RPC 约定层:`RpcClient`(_route 注入 + 编解码 + success 检查 + `RpcError`)、`RpcServer`(路由分发 + 响应包装 + `json_default`/`on_error` 钩子);db/smtp/captcha 服务端与 dnfs/log_client 客户端完成迁移;`tests/test_rpc.py` 单元测试 |
---
+6
View File
@@ -456,3 +456,9 @@ class MsgServer:
pass
self._clients.clear()
self._send_locks.clear()
# ── JSON RPC 约定层(依赖上面的 MsgClient / MsgServer,故放在末尾导入)──
from .rpc import RpcClient, RpcError, RpcServer # noqa: E402
__all__ += ["RpcClient", "RpcServer", "RpcError"]
+171
View File
@@ -0,0 +1,171 @@
"""pymsg.rpc — pymsg 线协议之上的 JSON RPC 约定层。
约定(与 C++/Go 客户端字节级兼容)::
请求: {"_route": "<路由名>", ...业务字段}
响应: {"success": true, "data": ...}
{"success": false, "error": "..."}
把各服务(db / smtp / captcha)重复的 ``_dispatch`` / ``_reply`` 服务端
分发逻辑,以及各调用方(dnfs / log_client)重复的 ``_route`` 注入 +
JSON 编解码客户端逻辑,收敛为 ``RpcServer`` / ``RpcClient`` 两个类。
异常分层::
RpcError — 远端返回的业务错误(success=false)或非法响应
pymsg.TimeoutError — 网络层超时(来自 MsgClient)
ConnectionRefusedError — 目标不可达(来自 MsgClient)
ConnectionError — 连接中断(来自 MsgClient)
"""
import json
from . import MsgClient, MsgServer
__all__ = ["RpcClient", "RpcServer", "RpcError"]
class RpcError(Exception):
"""远端返回 ``success=false`` 的业务错误,或响应不符合 RPC 约定。"""
class RpcClient:
"""JSON RPC 客户端(短连接,每次调用独立 TCP 连接)。
Usage::
rpc = RpcClient("127.0.0.1", 8005, "dnfs")
data = rpc.call("execute", {"action": "select", "table": "Users"})
"""
def __init__(self, addr: str, port: int, name: str = "rpc",
timeout: float = 10.0) -> None:
self._client = MsgClient(addr, port, name)
self._timeout = timeout
def call(self, route: str, payload: "dict | None" = None, *,
timeout: "float | None" = None):
"""调用指定路由,返回响应中的 ``data`` 字段。
Args:
route: 路由名(注入为 ``_route`` 字段)。
payload: 业务字段 dict(``_route`` 键会被覆盖)。
timeout: 本次调用的超时秒数,缺省用构造时的值。
Returns:
服务端返回的 ``data``(可能为 None)。
Raises:
RpcError: 服务端返回 success=false,或响应非法。
pymsg.TimeoutError: 等待响应超时。
ConnectionRefusedError: 目标服务不可达。
"""
req = dict(payload or {})
req["_route"] = route
resp = self._client.call(
json.dumps(req, ensure_ascii=False),
timeout=self._timeout if timeout is None else timeout,
)
try:
obj = json.loads(resp)
except json.JSONDecodeError as e:
raise RpcError(f"响应不是合法 JSON: {e}") from None
if not isinstance(obj, dict) or "success" not in obj:
raise RpcError(f"非法 RPC 响应: {resp[:200]!r}")
if not obj["success"]:
raise RpcError(obj.get("error", "未知错误"))
return obj.get("data")
class RpcServer:
"""JSON RPC 服务端:基于 MsgServer,自带路由分发与响应包装。
handler 签名为 ``handler(req: dict) -> data``:
- 返回值放入 ``{"success": true, "data": ...}`` 回复;
- 抛出的异常自动包装为 ``{"success": false, "error": str(e)}``,
服务不会因此崩溃。
Usage::
rpc = RpcServer("127.0.0.1", 8005, workers=4)
@rpc.route("execute")
def _execute(req):
return {"rows": []}
rpc.start() # 阻塞当前线程
"""
def __init__(self, addr: str, port: int, *, workers: int = 0,
json_default: "callable | None" = None,
on_error: "callable | None" = None) -> None:
"""
Args:
addr: 监听地址。
port: 监听端口。
workers: 回调并发线程数(透传 MsgServer,0=同步串行)。
json_default: 响应 JSON 序列化的兜底函数(如日期转 ISO),
透传 ``json.dumps(default=...)``。
on_error: 可选钩子 ``on_error(route, exc)``,handler 抛异常
时调用(用于记日志);None 表示静默。
"""
self._server = MsgServer(addr, port, workers=workers)
self._routes: "dict" = {}
self._json_default = json_default
self._on_error = on_error
# ── 路由注册 -------------------------------------------------------
def route(self, name: str):
"""装饰器:注册路由处理函数。"""
def deco(fn):
self._routes[name] = fn
return fn
return deco
def add_route(self, name: str, fn: "callable") -> None:
"""直接注册路由处理函数(等价于 route 装饰器)。"""
self._routes[name] = fn
# ── 启动 / 停止 ----------------------------------------------------
def start(self) -> None:
"""启动服务(阻塞当前线程)。"""
self._server.start(self._dispatch)
def stop(self) -> None:
"""从另一线程停止服务。"""
self._server.stop()
# ── 分发与响应包装 --------------------------------------------------
def _dispatch(self, sock, addr, text):
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:
if self._on_error is not None:
try:
self._on_error(route, e)
except Exception:
pass
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, default=self._json_default))
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pymsg"
version = "0.2.0"
version = "0.3.0"
description = "Length-prefixed variable-length socket message framing"
requires-python = ">=3.9"
license = "MIT"
+145
View File
@@ -0,0 +1,145 @@
"""RpcClient / RpcServer(JSON RPC 约定层)单元测试。
纯标准库,直接运行:
cd pymsg
python -m unittest discover -s tests -v
"""
import json
import socket
import threading
import time
import unittest
from datetime import datetime, timezone
from pymsg import RpcClient, RpcError, RpcServer, send
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class _RpcFixture:
"""后台线程运行 RpcServer;routes: {name: fn}。"""
def __init__(self, routes, workers=0, json_default=None):
self.port = _free_port()
self.server = RpcServer("127.0.0.1", self.port, workers=workers,
json_default=json_default)
for name, fn in routes.items():
self.server.add_route(name, fn)
self.thread = threading.Thread(target=self.server.start, daemon=True)
def __enter__(self):
self.thread.start()
deadline = time.time() + 5
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
return self
except OSError:
time.sleep(0.02)
raise RuntimeError("server did not start in time")
def __exit__(self, *exc):
self.server.stop()
self.thread.join(timeout=5)
return False
class TestRpc(unittest.TestCase):
def test_call_returns_data(self):
with _RpcFixture({"echo": lambda req: {"got": req["msg"]}}) as fx:
rpc = RpcClient("127.0.0.1", fx.port, "t")
self.assertEqual(rpc.call("echo", {"msg": "hi"}), {"got": "hi"})
def test_route_field_is_injected(self):
seen = {}
def handler(req):
seen.update(req)
return None
with _RpcFixture({"r": handler}) as fx:
RpcClient("127.0.0.1", fx.port).call("r", {"a": 1})
# _route 已被服务端 pop,handler 只看到业务字段
self.assertEqual(seen, {"a": 1})
def test_business_error_raises_rpcerror(self):
def handler(req):
raise ValueError("bad input")
with _RpcFixture({"x": handler}) as fx:
with self.assertRaises(RpcError) as cm:
RpcClient("127.0.0.1", fx.port).call("x")
self.assertIn("bad input", str(cm.exception))
def test_unknown_route_raises_rpcerror(self):
with _RpcFixture({}) as fx:
with self.assertRaises(RpcError) as cm:
RpcClient("127.0.0.1", fx.port).call("nope")
self.assertIn("未知路由", str(cm.exception))
def test_malformed_json_gets_error_response(self):
"""服务端收到非法 JSON:返回 success=false 且不崩溃,后续请求正常。"""
with _RpcFixture({"ok": lambda req: "fine"}) as fx:
with socket.create_connection(("127.0.0.1", fx.port), timeout=5) as s:
send(s, "not-json{{{")
from pymsg import recv
resp = json.loads(recv(s, timeout=5))
self.assertFalse(resp["success"])
self.assertIn("JSON 解析失败", resp["error"])
# 服务仍然存活
self.assertEqual(RpcClient("127.0.0.1", fx.port).call("ok"), "fine")
def test_data_none_when_absent(self):
with _RpcFixture({"n": lambda req: None}) as fx:
self.assertIsNone(RpcClient("127.0.0.1", fx.port).call("n"))
def test_json_default_serialization(self):
def default(o):
if isinstance(o, datetime):
return o.isoformat()
return str(o)
now = datetime(2026, 7, 30, 12, 0, tzinfo=timezone.utc)
with _RpcFixture({"t": lambda req: {"ts": now}}, json_default=default) as fx:
data = RpcClient("127.0.0.1", fx.port).call("t")
self.assertEqual(data["ts"], now.isoformat())
def test_concurrent_rpc_with_workers(self):
def handler(req):
time.sleep(0.3)
return req["i"] * 2
with _RpcFixture({"dbl": handler}, workers=4) as fx:
results = [None] * 6
def w(i):
results[i] = RpcClient("127.0.0.1", fx.port).call("dbl", {"i": i})
t0 = time.monotonic()
ts = [threading.Thread(target=w, args=(i,)) for i in range(6)]
for t in ts:
t.start()
for t in ts:
t.join(10)
elapsed = time.monotonic() - t0
self.assertEqual(results, [i * 2 for i in range(6)])
self.assertLess(elapsed, 1.2, f"workers=4 应并发,实际 {elapsed:.2f}s")
def test_per_call_timeout_override(self):
with _RpcFixture({"slow": lambda req: time.sleep(2)}) as fx:
rpc = RpcClient("127.0.0.1", fx.port, timeout=10.0)
from pymsg import TimeoutError
with self.assertRaises(TimeoutError):
rpc.call("slow", timeout=0.3)
if __name__ == "__main__":
unittest.main()
+38 -47
View File
@@ -1,8 +1,7 @@
"""
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
"""统一日志客户端 — 各服务共用(dnfs / smtp / static 三份副本内容一致,改动请同步)。
设计要点:
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
@@ -10,9 +9,9 @@
用法:
from log_client import logs
logs.info("smtp", "验证码发送成功")
logs.error("smtp", "验证码发送失败", extra={"errno": 2002})
logs.push("smtp", "WARNING", "登录失败次数超限",
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"})
"""
@@ -26,12 +25,12 @@ import atexit
from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
from pymsg import MsgClient # noqa: E402
from pymsg import RpcClient, RpcError # noqa: E402
# ── 配置 ──────────────────────────────────────────────────────────────
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
_BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
@@ -39,12 +38,9 @@ _TIMEOUT = 5 # socket 超时(秒)
class LogClient:
"""异步批量日志推送客户端。
"""异步批量日志推送客户端。"""
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
"""
def __init__(self, service_name="smtp",
def __init__(self, service_name="log",
batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL):
self._service_name = service_name
@@ -88,9 +84,7 @@ class LogClient:
def flush(self):
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
# 等待后台线程响应(queue.get 超时最长 0.5s)
time.sleep(0.6)
# 排空主队列中尚未被线程取走的条目
time.sleep(0.6) # 等待后台线程响应
batch = []
while True:
try:
@@ -113,7 +107,6 @@ class LogClient:
while not self._stop.is_set():
timeout = self._flush_interval - (time.monotonic() - last_flush)
# 限制最长阻塞时间,以便及时响应 flush() 信号
chk_timeout = max(0.1, min(timeout, 0.5))
try:
item = self._queue.get(timeout=chk_timeout)
@@ -141,35 +134,33 @@ class LogClient:
self._send(batch)
def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
"""通过 pymsg RPC(execute 路由)逐条 INSERT 到 dbv2 Logs 表。"""
now = datetime.now(timezone.utc).isoformat()
try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"_route": "execute",
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
resp_obj = json.loads(resp)
if not resp_obj.get("success"):
import sys
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
file=sys.stderr)
except Exception as e:
import sys
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
try:
client.call("execute", payload, timeout=_TIMEOUT)
except RpcError as e:
# 业务错误(如表结构不符):打印后继续下一条
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
except Exception as e:
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
break
def _shutdown(self):
"""进程退出时 flush 剩余日志。"""
@@ -179,4 +170,4 @@ class LogClient:
# ── 全局单例 ──────────────────────────────────────────────────────────
logs = LogClient(service_name="smtp")
logs = LogClient()
+10 -41
View File
@@ -19,7 +19,6 @@ SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
"""
import json
import os
import random
import smtplib
@@ -60,7 +59,7 @@ from log_client import logs
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgServer # noqa: E402
from pymsg import RpcServer # noqa: E402
# ── 配置 ────────────────────────────────────────────────────────────────
@@ -96,14 +95,14 @@ class SmtpServer:
def __init__(self, addr="127.0.0.1", port=8008, workers=4):
self.addr = addr
self.port = port
self._server = MsgServer(addr, port, workers=workers)
# 路由表
self._routes = {
"send_email": self._handle_send_email,
"verify_email": self._handle_verify_email,
"health": self._handle_health,
}
self._server = RpcServer(
addr, port, workers=workers,
on_error=lambda route, e: logs.error(
"smtp", f"路由 {route} 处理异常: {e}"),
)
self._server.add_route("send_email", self._handle_send_email)
self._server.add_route("verify_email", self._handle_verify_email)
self._server.add_route("health", self._handle_health)
# ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None:
@@ -118,42 +117,12 @@ class SmtpServer:
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)
self._server.start()
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))
# ═══════════════════════════════════════════════════════════════════
# 路由处理
# ═══════════════════════════════════════════════════════════════════
+38 -42
View File
@@ -1,8 +1,7 @@
"""
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
"""统一日志客户端 — 各服务共用(dnfs / smtp / static 三份副本内容一致,改动请同步)。
设计要点:
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
@@ -10,8 +9,10 @@
用法:
from log_client import logs
logs.info("static", "服务启动成功")
logs.error("static", "文件系统未启用", extra={"path": "/data"})
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"})
"""
import json
@@ -24,12 +25,12 @@ import atexit
from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
from pymsg import MsgClient # noqa: E402
from pymsg import RpcClient, RpcError # noqa: E402
# ── 配置 ──────────────────────────────────────────────────────────────
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
_BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
@@ -37,12 +38,9 @@ _TIMEOUT = 5 # socket 超时(秒)
class LogClient:
"""异步批量日志推送客户端。
"""异步批量日志推送客户端。"""
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
"""
def __init__(self, service_name="static",
def __init__(self, service_name="log",
batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL):
self._service_name = service_name
@@ -136,35 +134,33 @@ class LogClient:
self._send(batch)
def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
"""通过 pymsg RPC(execute 路由)逐条 INSERT 到 dbv2 Logs 表。"""
now = datetime.now(timezone.utc).isoformat()
try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"_route": "execute",
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
resp_obj = json.loads(resp)
if not resp_obj.get("success"):
import sys
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
file=sys.stderr)
except Exception as e:
import sys
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
for entry in batch:
extra_val = entry.get("extra")
payload = {
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry.get("service"),
"level": entry.get("level"),
"message": entry.get("message"),
"trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False)
if extra_val is not None else None,
},
}
try:
client.call("execute", payload, timeout=_TIMEOUT)
except RpcError as e:
# 业务错误(如表结构不符):打印后继续下一条
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
except Exception as e:
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
break
def _shutdown(self):
"""进程退出时 flush 剩余日志。"""
@@ -174,4 +170,4 @@ class LogClient:
# ── 全局单例 ──────────────────────────────────────────────────────────
logs = LogClient(service_name="static")
logs = LogClient()