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

41
router/enhance/alias.py Normal file
View File

@@ -0,0 +1,41 @@
"""为课程提供一个简短的别名,便于在空间有限的课程表图片中辨认。
别名是单独保存的,在最终输出阶段才会被装饰在原有的课表输出上。
"""
from typing import Sequence
from sqlmodel import Session, select
from njupt_api.baselib import logger
from router.enhance.model import Alias, engine
def apply_alias(courses: list[dict]) -> list[dict]:
with Session(engine) as session:
aliases: Sequence[Alias] = session.exec(select(Alias)).all()
# 否则不做任何更改
if len(aliases) == 0:
return courses
alias_count = 0
apply_count = 0
alias_dict = {}
for alias in aliases:
m = alias.model_dump()
alias_dict[m["originalName"]] = m["aliasName"]
alias_count += 1
for course in courses:
if course["name"] in alias_dict:
course["alias"] = alias_dict[course["name"]]
apply_count += 1
else:
course["alias"] = None
logger.debug(
f"课程别名 | 将 {alias_count} 个别名应用在了 {apply_count} 门输出的课程上。",
)
return courses

21
router/enhance/auth.py Normal file
View File

@@ -0,0 +1,21 @@
from pathlib import Path
from typing import Annotated
import aiofiles
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
security = HTTPBearer()
async def verify_token(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> str:
token = credentials.credentials
async with aiofiles.open(file=Path.cwd() / "data" / "token.txt", mode="r") as f:
if (await f.readline()).strip() == token:
return token
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing token. (Suan API WebUI)",
headers={"WWW-Authenticate": "Bearer"},
)

77
router/enhance/lib.py Normal file
View File

@@ -0,0 +1,77 @@
from datetime import date, timedelta
from typing import Any, Generator, Literal
from pydantic import BaseModel
from sqlmodel import Session
from njupt_api.baselib import config
from .alias import apply_alias
from .model import engine
from .screenshot import generate_img
class ScheduleQueryDto(BaseModel):
username: str | None = None
password: str | None = None
week: int = 0
img: bool = False
class TestDto(BaseModel):
username: str
password: str
scheduleType: Literal["class", "student"] # noqa: N815
class AliasDto(BaseModel):
originalName: str # noqa: N815
aliasName: str | None # noqa: N815
class ReturnDto(BaseModel):
success: bool
message: str | None = None
result: Any | None = None
img_url: str | None = None
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
async def apply_enhance(course_list: list[dict], week: int, img: bool) -> ReturnDto:
"""
在一个方法中集成了 应用别名 和 生成课表图片 功能。此为异步方法,需要 await。
Example:
return await apply_enhance(course_list, week, img)
Returns:
返回应用别名和图片完毕的 ReturnDto。
"""
final_course_list = [course for course in course_list if week in course["weeks"]] if week > 0 else course_list
final_course_list = apply_alias(final_course_list)
# 获取课表图片设置
title_template = config.get("schedule", "schedule_title_template", "芒果酸的课程表")
subtitle_template = config.get("schedule", "schedule_subtitle_template", "")
semester_start_date = date.fromisoformat(config.get("schedule", "semester_start_date", "2026-03-02"))
# 可用变量
week_start_day = semester_start_date + timedelta(weeks=week)
vars_ = {
"week": week,
"week_start_day": week_start_day.isoformat(),
"week_end_day": (week_start_day + timedelta(days=6)).isoformat(),
}
img_url = None
if img:
img_url = f"http://172.28.143.24:8000/api/schedule/img/{
await generate_img(final_course_list, title_template.format(**vars_), subtitle_template.format(**vars_))
}"
return ReturnDto(success=True, result=final_course_list, img_url=img_url)

29
router/enhance/model.py Normal file
View File

@@ -0,0 +1,29 @@
from typing import Optional
from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel, create_engine
sqlite_file_name = "data/njupt_api.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
class Course(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
teacher: Optional[str] = Field(default=None, nullable=True)
classroom: Optional[str] = Field(default=None, nullable=True)
weeks: list[int] = Field(default=[], sa_column=Column(JSON))
day: int
classes: list[int] = Field(default=[], sa_column=Column(JSON))
class Alias(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
originalName: Optional[str] = Field(default=None, nullable=True) # noqa: N815
aliasName: Optional[str] = Field(default=None, nullable=True) # noqa: N815
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)

View File

@@ -0,0 +1,49 @@
"""使用 Playwright 截图"""
from json import dumps
from pathlib import Path
from urllib.parse import urlencode
from uuid import uuid4
from playwright.async_api import ViewportSize
from njupt_api.baselib import PlayContextManager, logger
TEMP_DIR = Path.cwd() / "temp"
class ScreenShot(PlayContextManager):
def __init__(self) -> None:
super().__init__()
async def goto(self, url: str) -> bool:
await self.page.set_viewport_size(ViewportSize(width=1200, height=900))
res = await self.page.goto(url)
logger.debug(f"截图 | {res.ok=} - {url=:.{50}}...")
return res.ok
async def shot(self, save_path: str) -> None:
await self.page.mouse.move(0, 0)
await self.page.wait_for_load_state("networkidle")
await self.page.screenshot(path=save_path)
logger.debug(f"截图 | 截图已经保存在 {save_path=}")
return
async def generate_img(courses: list[dict], title: str, subtitle: str) -> str:
"""
方法将生成课程表图片并保存在临时目录中,返回图片的完整名称。图片位于 temp 工作目录。
Returns:
字符串,表明生成图片的文件名,格式为 `schedule-{uuid4()}.png`
"""
t_name = f"schedule-{uuid4()}.png"
async with ScreenShot() as ss:
await ss.goto(
f"127.0.0.1:8000/webui/schedule#/?{
urlencode({'data': dumps(courses), 'title': title, 'subtitle': subtitle})
}",
)
await ss.shot(str(TEMP_DIR / t_name))
logger.debug(f"截图 | 生成临时图片 - {t_name}")
return t_name

29
router/enhance/week.py Normal file
View File

@@ -0,0 +1,29 @@
"""学期-星期计算"""
from datetime import date, timedelta
def get_semester_week_info(start: date, target: date) -> tuple[int, int]:
"""
给定学期开始日期(第一周周一)和另一指定日期,计算指定的日期是第几周的星期几。
Args:
start: 学期开始的日期date
target: 指定日期date
Returns:
包含两个数字的元组,分别为第几周和星期几。
"""
return (target - start).days // 7 + 1, target.isoweekday()
def get_week_day_info(target: date) -> tuple[date, date]:
"""
给定一个指定日期,获取该日所在的星期的周一和周日的日期。
Args:
target: 指定日期date
Returns:
包含两个 date 的元组,分别为周一和周日。
"""
weekday_int = target.weekday()
return target - timedelta(days=weekday_int), target + timedelta(days=6 - weekday_int)