智教助手平台:完整初始化
- 前端: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:
@@ -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
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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="每日登录领取积分",
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user