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

- 前端: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
+7
View File
@@ -0,0 +1,7 @@
from .auth import get_password_hash, verify_password, create_access_token, create_refresh_token, get_current_user
from .ai_service import AIService
__all__ = [
"get_password_hash", "verify_password", "create_access_token",
"create_refresh_token", "get_current_user", "AIService",
]
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
"""安全审计日志服务。
提供 log_action() 用于在任意路由中记录安全相关事件。日志写入数据库 audit_logs 表,
同时输出到 logger,便于后续接入文件/集中式日志收集。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import Request
from sqlalchemy.orm import Session
from models.audit import AuditLog
from models.user import User
logger = logging.getLogger(__name__)
def _user_label(user: Optional[User]) -> str:
"""返回用户可读标识:优先 username,其次 phone,最后 id。"""
if user is None:
return ""
for attr in ("username", "phone", "name"):
val = getattr(user, attr, None)
if val:
return str(val)
return str(getattr(user, "id", ""))
def _client_ip(request: Optional[Request]) -> str:
if request is None:
return ""
try:
fwd = request.headers.get("x-forwarded-for")
if fwd:
return fwd.split(",")[0].strip()[:64]
client = getattr(request, "client", None)
if client and client.host:
return client.host[:64]
except Exception:
pass
return ""
def _user_agent(request: Optional[Request]) -> str:
if request is None:
return ""
try:
return (request.headers.get("user-agent") or "")[:255]
except Exception:
return ""
def log_action(
db: Session,
*,
action: str,
user: Optional[User] = None,
request: Optional[Request] = None,
target_type: str = "",
target_id: Any = "",
detail: str = "",
status: str = "success",
) -> None:
"""记录一条审计日志。失败不影响主流程。"""
try:
log = AuditLog(
user_id=user.id if user else None,
username=_user_label(user),
action=action[:64],
target_type=target_type[:32] if target_type else "",
target_id=str(target_id)[:64] if target_id else "",
detail=detail[:2000] if detail else "",
ip=_client_ip(request),
user_agent=_user_agent(request),
status=status[:16] if status else "success",
)
db.add(log)
db.commit()
except Exception as exc: # pragma: no cover - 日志失败不应中断业务
logger.warning("audit log failed: %s", exc)
try:
db.rollback()
except Exception:
pass
# 同时输出到日志通道
logger.info(
"audit action=%s user=%s target=%s/%s status=%s ip=%s",
action,
_user_label(user),
target_type,
target_id,
status,
_client_ip(request),
)
+87
View File
@@ -0,0 +1,87 @@
import logging
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from config import get_settings
from database import get_db
from models.user import User
logger = logging.getLogger(__name__)
security = HTTPBearer()
optional_security = HTTPBearer(auto_error=False)
settings = get_settings()
def get_password_hash(password: str) -> str:
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def create_access_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
logger.info(f"Created token for user {user_id}")
return token
def create_refresh_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id = int(payload.get("sub"))
logger.info(f"Token decoded for user {user_id}")
except JWTError as e:
logger.error(f"JWT decode error: {e}")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭证")
except (TypeError, ValueError) as e:
logger.error(f"Invalid sub claim: {e}")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭证")
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
return user
def get_optional_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(optional_security),
db: Session = Depends(get_db),
) -> User | None:
if credentials is None:
return None
try:
payload = jwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id = int(payload.get("sub"))
except (JWTError, TypeError, ValueError):
return None
user = db.query(User).filter(User.id == user_id).first()
if user is None or not user.is_active:
return None
return user
def get_admin_user(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user
+103
View File
@@ -0,0 +1,103 @@
from fastapi import HTTPException
from sqlalchemy.orm import Session
from models.credit import CreditAccount, CreditTransaction
from models.user import User
DEFAULT_CREDIT_BALANCE = 130
DAILY_CHECKIN_CREDITS = 10
CREDIT_COSTS = {
"courseware_generate": 8,
"animation_generate": 6,
"exercise_generate": 4,
"lesson_plan_generate": 5,
"exam_generate": 5,
"essay_grade": 2,
"chat_generate": 1,
"material_parse": 1,
}
def ensure_credit_account(db: Session, user: User) -> CreditAccount:
account = db.query(CreditAccount).filter(CreditAccount.user_id == user.id).first()
if account:
return account
account = CreditAccount(
user_id=user.id,
balance=DEFAULT_CREDIT_BALANCE,
total_granted=DEFAULT_CREDIT_BALANCE,
total_used=0,
)
db.add(account)
db.flush()
return account
def spend_credits(db: Session, user: User, action: str, description: str = "") -> CreditAccount:
cost = CREDIT_COSTS.get(action, 1)
account = ensure_credit_account(db, user)
if account.balance < cost:
raise HTTPException(status_code=402, detail=f"积分不足,本次需要 {cost} 分,当前剩余 {account.balance}")
account.balance -= cost
account.total_used += cost
db.add(CreditTransaction(
user_id=user.id,
amount=-cost,
action=action,
description=description,
balance_after=account.balance,
))
db.flush()
return account
def credits_payload(account: CreditAccount, action: str) -> dict:
return {
"balance": account.balance,
"cost": CREDIT_COSTS.get(action, 1),
"action": action,
}
def grant_credits(db: Session, user: User, amount: int, action: str = "grant", description: str = "") -> CreditAccount:
if amount <= 0:
raise HTTPException(status_code=400, detail="积分数量必须大于 0")
account = ensure_credit_account(db, user)
account.balance += amount
account.total_granted += amount
db.add(CreditTransaction(
user_id=user.id,
amount=amount,
action=action,
description=description,
balance_after=account.balance,
))
db.flush()
return account
def has_credit_transaction_today(db: Session, user: User, action: str) -> bool:
from datetime import datetime, time
today = datetime.now().date()
start = datetime.combine(today, time.min)
end = datetime.combine(today, time.max)
return db.query(CreditTransaction).filter(
CreditTransaction.user_id == user.id,
CreditTransaction.action == action,
CreditTransaction.created_at >= start,
CreditTransaction.created_at <= end,
).first() is not None
def grant_daily_checkin(db: Session, user: User) -> CreditAccount:
if has_credit_transaction_today(db, user, "daily_checkin"):
raise HTTPException(status_code=409, detail="今日积分已领取")
return grant_credits(
db,
user,
DAILY_CHECKIN_CREDITS,
action="daily_checkin",
description="每日登录领取积分",
)
+4
View File
@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+27
View File
@@ -0,0 +1,27 @@
"""密码强度校验策略:拒绝常见弱密码,要求字母+数字组合。"""
import re
WEAK_PATTERNS = [
"123456", "123456789", "password", "111111", "000000", "888888",
"abc123", "qwerty", "654321", "12345678", "1234567", "admin",
]
CONSECUTIVE = ["0123456789", "9876543210", "abcdef", "qwerty", "asdfgh"]
def validate_password_strength(password: str) -> str | None:
"""返回错误提示;通过则返回 None。要求至少 6 位且含字母与数字。"""
if not password or len(password) < 6:
return "密码至少需要 6 个字符"
low = password.lower()
if low in WEAK_PATTERNS:
return "密码过于简单,请使用更复杂的密码"
if any(seq in low for seq in CONSECUTIVE):
return "密码包含连续字符,请更换"
has_letter = bool(re.search(r"[a-zA-Z]", password))
has_digit = bool(re.search(r"\d", password))
if not has_letter:
return "密码必须包含字母"
if not has_digit:
return "密码必须包含数字"
return None
+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))