智教助手平台:完整初始化

- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
This commit is contained in:
Zhang Jing Xuan
2026-07-29 16:29:10 +08:00
commit 0841b1a103
193 changed files with 44995 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
"""文件上传安全校验。
提供 validate_upload() 对上传文件做:
1. 文件名清洗(去除路径、危险字符、控制长度)
2. 扩展名白名单校验
3. MIME 与扩展名一致性校验
4. 文件魔数(magic bytes)校验,防止伪装
5. 文件大小校验
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
from fastapi import HTTPException, UploadFile
# 允许的扩展名 → 期望的 MIME 前缀(; 前部分)
ALLOWED_TYPES: dict[str, str] = {
"txt": "text/",
"md": "text/",
"csv": "text/",
"json": "application/json",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"pdf": "application/pdf",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"xls": "application/vnd.ms-excel",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"bmp": "image/bmp",
}
# 魔数签名(前若干字节)
MAGIC_SIGNATURES: dict[str, list[bytes]] = {
"pdf": [b"%PDF-"],
"png": [b"\x89PNG\r\n\x1a\n"],
"jpg": [b"\xff\xd8\xff"],
"jpeg": [b"\xff\xd8\xff"],
"gif": [b"GIF87a", b"GIF89a"],
"bmp": [b"BM"],
"webp": [b"RIFF"],
# Office 文件均为 ZIP 容器(PK\x03\x04
"docx": [b"PK\x03\x04"],
"pptx": [b"PK\x03\x04"],
"xlsx": [b"PK\x03\x04"],
"xls": [b"\xd0\xcf\x11\xe0"], # OLE 复合文档
}
MAX_FILENAME_LEN = 200
@dataclass
class UploadCheck:
filename: str
suffix: str
media_type: str
size: int
_FILENAME_BAD = re.compile("[\x00-\x1f<>:\"/\\|?*]")
def sanitize_filename(name: str) -> str:
"""清洗文件名:去路径、去控制/危险字符、限长。"""
# 仅保留文件名部分
name = re.split(r"[\\/]", name)[-1].strip()
name = _FILENAME_BAD.sub("_", name)
name = name.strip(". ")
if not name:
name = "upload"
if len(name) > MAX_FILENAME_LEN:
stem, dot, ext = name.rpartition(".")
if dot:
name = stem[: MAX_FILENAME_LEN - len(ext) - 1] + "." + ext
else:
name = name[:MAX_FILENAME_LEN]
return name
def _check_magic(suffix: str, head: bytes) -> bool:
sigs = MAGIC_SIGNATURES.get(suffix)
if not sigs:
return True # 无签名要求(文本类)默认放行
return any(head.startswith(sig) for sig in sigs)
def validate_upload(
file: UploadFile,
*,
content: bytes,
max_size: Optional[int] = None,
) -> UploadCheck:
"""校验上传文件,失败抛出 HTTPException。
参数 content 为已读取的字节内容(用于魔数校验)。
"""
if max_size is not None and len(content) > max_size:
raise HTTPException(status_code=413, detail=f"文件不能超过 {max_size // (1024 * 1024)}MB")
if not content:
raise HTTPException(status_code=400, detail="上传文件为空")
raw_name = file.filename or "material"
filename = sanitize_filename(raw_name)
suffix = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
media_type = (file.content_type or "").split(";")[0].strip().lower()
if suffix not in ALLOWED_TYPES:
raise HTTPException(
status_code=415,
detail="暂不支持该文件类型,请上传 txt、md、csv、json、docx、pptx、pdf、xlsx 或图片",
)
# MIME 一致性:图片/office/pdf 必须匹配;文本类型放宽
expected_mime = ALLOWED_TYPES[suffix]
if expected_mime != "text/" and media_type and not media_type.startswith(expected_mime.split("/")[0]):
# 宽松:只校验主类型一致(image/* application/* 等)
if media_type.split("/")[0] != expected_mime.split("/")[0]:
raise HTTPException(status_code=415, detail="文件类型与扩展名不一致")
# 魔数校验
head = content[:16]
if not _check_magic(suffix, head):
raise HTTPException(status_code=415, detail="文件内容与声明类型不符")
return UploadCheck(filename=filename, suffix=suffix, media_type=media_type or expected_mime, size=len(content))