Python 后端提交

Python 后端(FastAPI + FastMCP + ...)的初始版本号设定为 0.1.0,这是 uv 在 pypriject.toml
里给我自动设置的,我觉得有道理。
This commit is contained in:
2026-04-21 13:38:46 +08:00
parent 14eadaab86
commit b284c3c260
27 changed files with 1691 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
from .config import config
from .logger import LogRecord, log_buffer, log_record_serialize, logger
from .mcploggingmiddleware import LoggingMiddleware
from .playcontextmanager import PlayContextManager
__all__ = [
config,
LogRecord,
log_buffer,
log_record_serialize,
logger,
LoggingMiddleware,
PlayContextManager,
]

115
njupt_api/baselib/config.py Normal file
View File

@@ -0,0 +1,115 @@
from json import dumps, loads
from pathlib import Path
from typing import TypeVar
import aiofiles
from .logger import logger
CONFIG_PATH = Path.cwd() / "data" / "config.json"
T = TypeVar("T")
class Config:
def __init__(self) -> None:
self._doc = {}
async def load_json(self) -> None:
"""
从 Toml 配置文件中读取配置。
"""
logger.debug("异步读取配置文件。")
async with aiofiles.open(file=CONFIG_PATH, mode="r") as f:
self._doc = loads(await f.read())
def sync_load_json(self) -> None:
"""
同步读取配置文件,仅限于 main.py 中启动时。
Raises:
FileNotFoundError: 配置文件不存在。
"""
logger.debug("同步读取配置文件。")
try:
with open(file=CONFIG_PATH, mode="r") as f:
self._doc = loads(f.read())
except FileNotFoundError:
logger.warning("FileNotFoundError - 配置文件不存在。")
raise
def sync_create_json(self) -> None:
"""
同步创建配置文件。
"""
logger.debug("同步创建配置文件。")
with open(file=CONFIG_PATH, mode="w") as f:
f.write(dumps(self._doc))
def init_config(self) -> None:
"""
重新初始化 Toml 配置文件。这会重置所有配置。
"""
logger.warning("初始化配置文件,这会重置所有配置。")
self._doc.clear()
doc_system = {}
doc_schedule = {}
doc_log = {}
doc_system["host"] = "0.0.0.0"
doc_system["port"] = 8000
doc_system["reload"] = True
doc_schedule["jwxt_login_method"] = "sso"
doc_schedule["semester_start_date"] = "2026-03-02"
doc_schedule["schedule_title_template"] = "芒果酸的第 {title} 周课程表"
doc_schedule["schedule_subtitle_template"] = "我也要上吗?"
doc_log["log_api_request_details"] = False
doc_log["log_mcp_request_details"] = False
doc_log["log_assets_request"] = False
self._doc["system"] = doc_system
self._doc["schedule"] = doc_schedule
self._doc["log"] = doc_log
async def save_json(self) -> None:
"""
异步保存 Toml 配置文件。
"""
logger.debug("异步保存配置文件。")
async with aiofiles.open(file=CONFIG_PATH, mode="w") as f:
await f.write(dumps(self._doc, indent=4))
def get(self, group: str, option: str, default: T) -> T:
"""
获取配置项的值。
Args:
group: Table
option: Key
default: 默认值
Returns:
Any与 default 参数类型相同。
"""
try:
return self._doc.get(group).get(option)
except AttributeError:
return default
def to_dict(self) -> dict:
return self._doc
def from_dict(self, data: dict) -> None:
self._doc.clear()
for key, value in data.items():
if isinstance(value, dict):
t_table = {}
for k, v in value.items():
t_table[k] = v
self._doc[key] = t_table
config = Config()

View File

@@ -0,0 +1,42 @@
import sys
from collections import deque
from loguru import logger
logger.remove()
logger.add(
sys.stdout,
level="DEBUG",
colorize=True,
)
logger.add("data/app.log", rotation="10 MB", retention="7 days") # 文件日志
log_buffer = deque(maxlen=1000)
class LogRecord:
log_counter = 0
def __init__(self, message: str) -> None:
self.id = LogRecord.log_counter
self.message = message
LogRecord.log_counter += 1
def log_record_serialize(record: LogRecord) -> dict:
return {
"id": record.id,
"message": record.message,
}
def memory_sink(message: str) -> None:
"""向自定义缓冲区写入日志,供 WebUI 获取
:param message: 'loguru._handler.Message'
"""
log_entry = LogRecord(message=message)
log_buffer.append(log_entry)
logger.add(sink=memory_sink, level="DEBUG", colorize=True)

View File

@@ -0,0 +1,36 @@
import time
from fastmcp.server.middleware import CallNext, MiddlewareContext
from fastmcp.server.middleware.middleware import Middleware
from fastmcp.tools import ToolResult
from mcp import types as mt
from . import config
from .logger import logger
class LoggingMiddleware(Middleware):
async def on_call_tool(
self,
context: MiddlewareContext[mt.CallToolRequestParams],
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
) -> ToolResult:
tool_name = context.message.name
args = context.message.arguments
start_time = time.time()
logger.debug(f"MCP → 调用工具: {tool_name}")
if config.get("log", "log_mcp_request_details", False):
logger.debug(f"调用参数 - {args=}")
try:
result = await call_next(context)
elapsed = time.time() - start_time
logger.info(f"MCP ← 工具 {tool_name} 完成, 耗时: {elapsed:.3f}s")
return result
except Exception as e:
elapsed = time.time() - start_time
logger.error(
f"MCP ✗ 工具 {tool_name} 失败, 耗时: {elapsed:.3f}s, 错误: {e}",
)
raise

View File

@@ -0,0 +1,56 @@
from playwright.async_api import (
Browser,
BrowserContext,
Page,
Playwright,
async_playwright,
)
class PlayContextManager:
def __init__(
self,
playwright: Playwright = None,
browser: Browser = None,
context: BrowserContext = None,
page: Page = None,
) -> None:
self.playwright = playwright
self.browser = browser
self.context = context
self.page = page
self.isLogin = False
async def start(self) -> None:
"""手动启动"""
self.playwright = await async_playwright().start() # 不是 __enter__
self.browser = await self.playwright.chromium.launch(
headless=False,
args=[
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--no-proxy-server",
],
)
self.context = await self.browser.new_context()
self.page = await self.context.new_page()
async def __aenter__(self) -> "PlayContextManager":
await self.start()
return self
async def close(self) -> None:
"""手动关闭"""
if self.context:
await self.context.close()
if self.browser:
await self.browser.close()
if self.playwright:
await self.playwright.stop() # 不是 __exit__
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # noqa: ANN001
await self.close()