智教助手平台:完整初始化
- 前端: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,236 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.animation import Animation
|
||||
from models.courseware import Courseware
|
||||
from models.credit import CreditAccount, CreditTransaction
|
||||
from models.exercise import Exercise
|
||||
from models.lesson_plan import LessonPlan
|
||||
from models.material import Material
|
||||
from models.resource import Resource, ResourceFavorite
|
||||
from models.user import User
|
||||
from models.audit import AuditLog
|
||||
from schemas.admin import AdminDashboardOut, AdminGrantCredits, AdminUserOut, AdminUserUpdate
|
||||
from schemas.resource import ResourceOut, ResourceAuthorOut
|
||||
from services.auth import get_admin_user
|
||||
from services.audit import log_action
|
||||
from services.credits import grant_credits
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["管理后台"])
|
||||
|
||||
|
||||
def _user_with_credits(user: User, db: Session) -> dict:
|
||||
account = db.query(CreditAccount).filter(CreditAccount.user_id == user.id).first()
|
||||
data = AdminUserOut.model_validate(user).model_dump()
|
||||
data["credits"] = account.balance if account else 0
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=AdminDashboardOut)
|
||||
def dashboard(current_user: User = Depends(get_admin_user), db: Session = Depends(get_db)):
|
||||
today = datetime.now().date()
|
||||
today_start = datetime.combine(today, time.min)
|
||||
|
||||
total_users = db.query(func.count(User.id)).scalar() or 0
|
||||
active_users = db.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
|
||||
admin_users = db.query(func.count(User.id)).filter(User.role == "admin").scalar() or 0
|
||||
new_users_today = db.query(func.count(User.id)).filter(User.created_at >= today_start).scalar() or 0
|
||||
|
||||
total_resources = db.query(func.count(Resource.id)).filter(Resource.status == "active").scalar() or 0
|
||||
public_resources = db.query(func.count(Resource.id)).filter(Resource.status == "active", Resource.is_public == 1).scalar() or 0
|
||||
|
||||
total_coursewares = db.query(func.count(Courseware.id)).filter(Courseware.status != "archived").scalar() or 0
|
||||
total_animations = db.query(func.count(Animation.id)).filter(Animation.status != "archived").scalar() or 0
|
||||
total_exercises = db.query(func.count(Exercise.id)).scalar() or 0
|
||||
total_lesson_plans = db.query(func.count(LessonPlan.id)).scalar() or 0
|
||||
|
||||
total_views = db.query(func.coalesce(func.sum(Resource.views), 0)).scalar() or 0
|
||||
total_downloads = db.query(func.coalesce(func.sum(Resource.downloads), 0)).scalar() or 0
|
||||
total_favorites = db.query(func.count(ResourceFavorite.id)).scalar() or 0
|
||||
total_credits_used = db.query(func.coalesce(func.sum(CreditAccount.total_used), 0)).scalar() or 0
|
||||
|
||||
exam_count = 0
|
||||
try:
|
||||
from models.exam import Exam
|
||||
exam_count = db.query(func.count(Exam.id)).scalar() or 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return AdminDashboardOut(
|
||||
total_users=total_users,
|
||||
active_users=active_users,
|
||||
admin_users=admin_users,
|
||||
new_users_today=new_users_today,
|
||||
total_resources=total_resources,
|
||||
public_resources=public_resources,
|
||||
total_coursewares=total_coursewares,
|
||||
total_animations=total_animations,
|
||||
total_exercises=total_exercises,
|
||||
total_lesson_plans=total_lesson_plans,
|
||||
total_exams=exam_count,
|
||||
total_views=total_views,
|
||||
total_downloads=total_downloads,
|
||||
total_favorites=total_favorites,
|
||||
total_credits_used=total_credits_used,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserOut])
|
||||
def list_users(
|
||||
keyword: str = Query("", description="搜索手机号或姓名"),
|
||||
role: str = Query("", description="按角色筛选"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(User)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.filter((User.phone.contains(like)) | (User.name.contains(like)))
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
users = query.order_by(User.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return [_user_with_credits(u, db) for u in users]
|
||||
|
||||
|
||||
@router.put("/users/{user_id}", response_model=AdminUserOut)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
data: AdminUserUpdate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
changes = []
|
||||
if data.role is not None:
|
||||
if data.role not in {"teacher", "admin"}:
|
||||
raise HTTPException(status_code=400, detail="角色无效")
|
||||
changes.append(f"role:{user.role}->{data.role}")
|
||||
user.role = data.role
|
||||
if data.is_active is not None:
|
||||
changes.append(f"active:{user.is_active}->{data.is_active}")
|
||||
user.is_active = data.is_active
|
||||
if data.name is not None:
|
||||
changes.append(f"name:{user.name}->{data.name}")
|
||||
user.name = data.name
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_action(db, action="admin_update_user", user=current_user, request=request, target_type="user", target_id=user.id, detail=f"修改用户 {user.name or user.phone or user.id}:{', '.join(changes) or '无变更'}")
|
||||
return _user_with_credits(user, db)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/grant-credits", response_model=AdminUserOut)
|
||||
def grant_user_credits(
|
||||
user_id: int,
|
||||
data: AdminGrantCredits,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
grant_credits(db, user, data.amount, action="admin_grant", description=f"管理员 {current_user.name} 发放 {data.amount} 积分")
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_action(db, action="admin_grant_credits", user=current_user, request=request, target_type="user", target_id=user.id, detail=f"向 {user.name or user.phone or user.id} 发放 {data.amount} 积分")
|
||||
return _user_with_credits(user, db)
|
||||
|
||||
|
||||
@router.get("/resources", response_model=list[ResourceOut])
|
||||
def list_all_resources(
|
||||
keyword: str = Query(""),
|
||||
resource_type: str = Query(""),
|
||||
status: str = Query("active"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from sqlalchemy import or_
|
||||
|
||||
query = db.query(Resource)
|
||||
if status:
|
||||
query = query.filter(Resource.status == status)
|
||||
if resource_type:
|
||||
query = query.filter(Resource.resource_type == resource_type)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.filter(or_(Resource.title.contains(like), Resource.description.contains(like)))
|
||||
resources = query.order_by(Resource.created_at.desc()).offset(skip).limit(limit).all()
|
||||
result = []
|
||||
for res in resources:
|
||||
out = ResourceOut.model_validate(res)
|
||||
out.is_favorited = False
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/resources/{resource_id}/moderate", response_model=dict)
|
||||
def moderate_resource(
|
||||
resource_id: int,
|
||||
request: Request,
|
||||
status: str = Query(..., description="active 或 archived"),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if status not in {"active", "archived"}:
|
||||
raise HTTPException(status_code=400, detail="状态无效")
|
||||
res = db.query(Resource).filter(Resource.id == resource_id).first()
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="资源不存在")
|
||||
prev = res.status
|
||||
res.status = status
|
||||
db.commit()
|
||||
log_action(db, action="admin_moderate_resource", user=current_user, request=request, target_type="resource", target_id=res.id, detail=f"资源《{res.title}》状态 {prev} -> {status}")
|
||||
return {"success": True, "status": res.status}
|
||||
|
||||
|
||||
@router.get("/audit-logs", response_model=list[dict])
|
||||
def list_audit_logs(
|
||||
action: str | None = None,
|
||||
status: str | None = None,
|
||||
user_id: int | None = None,
|
||||
keyword: str | None = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""查询安全审计日志(仅管理员)。"""
|
||||
from sqlalchemy import or_
|
||||
q = db.query(AuditLog)
|
||||
if action:
|
||||
q = q.filter(AuditLog.action == action)
|
||||
if status:
|
||||
q = q.filter(AuditLog.status == status)
|
||||
if user_id:
|
||||
q = q.filter(AuditLog.user_id == user_id)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(or_(AuditLog.username.contains(like), AuditLog.detail.contains(like), AuditLog.ip.contains(like)))
|
||||
rows = q.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"user_id": r.user_id,
|
||||
"username": r.username,
|
||||
"action": r.action,
|
||||
"target_type": r.target_type,
|
||||
"target_id": r.target_id,
|
||||
"detail": r.detail,
|
||||
"ip": r.ip,
|
||||
"user_agent": r.user_agent,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,297 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
from docx import Document
|
||||
from pptx import Presentation
|
||||
from PIL import Image
|
||||
from pypdf import PdfReader
|
||||
from openpyxl import load_workbook
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from models.material import Material
|
||||
from models.user import User
|
||||
from schemas.ai import EssayGradeRequest, ExamExportRequest, ExamGenerateRequest, HtmlExportRequest
|
||||
from services.limiter import limiter
|
||||
from services.auth import get_current_user
|
||||
from services.audit import log_action
|
||||
from services.upload_security import validate_upload, sanitize_filename
|
||||
from services.ai_service import AIService
|
||||
from services.credits import credits_payload, spend_credits
|
||||
from database import get_db
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter(prefix="/api/ai", tags=["AI能力"])
|
||||
ai_service = AIService()
|
||||
|
||||
|
||||
@router.post("/essay-grade", response_model=dict)
|
||||
@limiter.limit("10/minute")
|
||||
async def grade_essay(
|
||||
request: Request,
|
||||
data: EssayGradeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = spend_credits(db, current_user, "essay_grade", "作文批改")
|
||||
db.commit()
|
||||
result = await ai_service.grade_essay(
|
||||
essay_text=data.essay_text, grade_level=data.grade_level,
|
||||
essay_type=data.essay_type, total_score=data.total_score,
|
||||
)
|
||||
return {"success": True, "data": result, "credits": credits_payload(account, "essay_grade")}
|
||||
|
||||
|
||||
@router.post("/exam-generate", response_model=dict)
|
||||
@limiter.limit("10/minute")
|
||||
async def generate_exam(
|
||||
request: Request,
|
||||
data: ExamGenerateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = spend_credits(db, current_user, "exam_generate", f"智能命题:{data.subject}")
|
||||
db.commit()
|
||||
result = await ai_service.generate_exam(
|
||||
subject=data.subject, grade=data.grade,
|
||||
knowledge_points=data.knowledge_points, difficulty=data.difficulty,
|
||||
question_types=data.question_types, count=data.count, total_score=data.total_score,
|
||||
)
|
||||
return {"success": True, "data": result, "credits": credits_payload(account, "exam_generate")}
|
||||
|
||||
|
||||
def _safe_filename(name: str, suffix: str) -> str:
|
||||
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "export"
|
||||
return f"{stem[:80]}{suffix}"
|
||||
|
||||
|
||||
def _summarize_text(text: str, limit: int = 4000) -> str:
|
||||
normalized = re.sub(r"[ \t]+", " ", text.replace("\r\n", "\n")).strip()
|
||||
normalized = re.sub(r"\n{3,}", "\n\n", normalized)
|
||||
return normalized[:limit]
|
||||
|
||||
|
||||
def _title_from_filename(filename: str) -> str:
|
||||
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
||||
title = re.sub(r"[_-]+", " ", stem).strip() or "教学素材"
|
||||
return title[:200]
|
||||
|
||||
|
||||
def _decode_text(content: bytes) -> str:
|
||||
for encoding in ("utf-8-sig", "utf-8", "gb18030", "gbk"):
|
||||
try:
|
||||
return content.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return content.decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def _extract_docx(content: bytes) -> str:
|
||||
document = Document(BytesIO(content))
|
||||
paragraphs = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()]
|
||||
table_lines = []
|
||||
for table in document.tables:
|
||||
for row in table.rows:
|
||||
cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
|
||||
if cells:
|
||||
table_lines.append(" | ".join(cells))
|
||||
return "\n".join([*paragraphs, *table_lines])
|
||||
|
||||
|
||||
def _extract_pptx(content: bytes) -> str:
|
||||
presentation = Presentation(BytesIO(content))
|
||||
lines = []
|
||||
for index, slide in enumerate(presentation.slides, start=1):
|
||||
slide_lines = []
|
||||
for shape in slide.shapes:
|
||||
text = getattr(shape, "text", "").strip()
|
||||
if text:
|
||||
slide_lines.append(text)
|
||||
if slide_lines:
|
||||
lines.append(f"第 {index} 页:\n" + "\n".join(slide_lines))
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _extract_pdf(content: bytes) -> str:
|
||||
reader = PdfReader(BytesIO(content))
|
||||
pages = []
|
||||
for index, page in enumerate(reader.pages, start=1):
|
||||
text = (page.extract_text() or "").strip()
|
||||
if text:
|
||||
pages.append(f"第 {index} 页:\n{text}")
|
||||
return "\n\n".join(pages)
|
||||
|
||||
|
||||
def _extract_xlsx(content: bytes) -> str:
|
||||
workbook = load_workbook(BytesIO(content), data_only=True, read_only=True)
|
||||
lines = []
|
||||
for sheet in workbook.worksheets:
|
||||
row_lines = []
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
cells = [str(cell).strip() for cell in row if cell is not None and str(cell).strip()]
|
||||
if cells:
|
||||
row_lines.append(" | ".join(cells))
|
||||
if row_lines:
|
||||
lines.append(f"工作表 {sheet.title}:\n" + "\n".join(row_lines[:200]))
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
async def _describe_image(content: bytes, filename: str) -> str:
|
||||
try:
|
||||
with Image.open(BytesIO(content)) as image:
|
||||
width, height = image.size
|
||||
mode = image.mode
|
||||
except Exception:
|
||||
width, height, mode = 0, 0, ""
|
||||
meta = f"图片文件:{filename}"
|
||||
if width and height:
|
||||
meta += f";尺寸:{width}×{height}px"
|
||||
if mode:
|
||||
meta += f";色彩模式:{mode}"
|
||||
|
||||
# Try AI vision OCR first; fall back to metadata-only description
|
||||
try:
|
||||
ocr_text = await ai_service.ocr_image(content, filename)
|
||||
if ocr_text and "[无法识别文字内容]" not in ocr_text:
|
||||
return f"{meta}\n\n【图片文字识别结果】\n{ocr_text}"
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("图片OCR失败,使用元数据描述: %s", exc)
|
||||
|
||||
return (
|
||||
f"{meta}。\n"
|
||||
"这是一份拍照/图片教学素材,可能包含教材页、题目、板书、图表、实验现象或课堂场景。"
|
||||
"当前系统已保存图片素材类型,可将其带入课件、动画、练习、教案或命题流程;"
|
||||
"生成时请教师在提示词中补充图片中的关键文字、题干或知识点,以便产出更准确。"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/materials/parse", response_model=dict)
|
||||
@limiter.limit("20/minute")
|
||||
async def parse_material(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
content = await file.read()
|
||||
check = validate_upload(file, content=content, max_size=50 * 1024 * 1024)
|
||||
filename = check.filename
|
||||
suffix = check.suffix
|
||||
media_type = check.media_type
|
||||
|
||||
try:
|
||||
if media_type.startswith("image/"):
|
||||
text = await _describe_image(content, filename)
|
||||
material_type = "image"
|
||||
elif suffix == "docx":
|
||||
text = _extract_docx(content)
|
||||
material_type = "docx"
|
||||
elif suffix == "pptx":
|
||||
text = _extract_pptx(content)
|
||||
material_type = "pptx"
|
||||
elif suffix == "pdf":
|
||||
text = _extract_pdf(content)
|
||||
material_type = "pdf"
|
||||
elif suffix in {"xlsx", "xls"}:
|
||||
text = _extract_xlsx(content)
|
||||
material_type = "xlsx"
|
||||
elif suffix in {"txt", "md", "csv", "json"} or media_type.startswith("text/"):
|
||||
text = _decode_text(content)
|
||||
material_type = suffix or "text"
|
||||
else:
|
||||
raise HTTPException(status_code=415, detail="暂不支持该文件类型,请上传 txt、md、csv、json、docx、pptx、pdf、xlsx 或图片")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"材料解析失败:{exc}") from exc
|
||||
|
||||
summary = _summarize_text(text)
|
||||
account = spend_credits(db, current_user, "material_parse", f"解析材料:{filename}")
|
||||
material = Material(
|
||||
user_id=current_user.id,
|
||||
filename=filename,
|
||||
title=_title_from_filename(filename),
|
||||
material_type=material_type,
|
||||
summary=summary,
|
||||
char_count=len(text),
|
||||
size=len(content),
|
||||
source="upload",
|
||||
)
|
||||
db.add(material)
|
||||
db.commit()
|
||||
db.refresh(material)
|
||||
log_action(db, action="material_parse", user=current_user, request=request, target_type="material", target_id=material.id, detail=f"解析 {filename}({material_type}, {len(content)}B)")
|
||||
return {
|
||||
"success": True,
|
||||
"credits": credits_payload(account, "material_parse"),
|
||||
"data": {
|
||||
"material_id": material.id,
|
||||
"filename": filename,
|
||||
"title": material.title,
|
||||
"material_type": material_type,
|
||||
"size": len(content),
|
||||
"summary": summary,
|
||||
"char_count": len(text),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/export/html")
|
||||
def export_html(data: HtmlExportRequest, current_user: User = Depends(get_current_user)):
|
||||
content = f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{data.title}</title>
|
||||
</head>
|
||||
<body>
|
||||
{data.html}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
filename = _safe_filename(data.title, ".html")
|
||||
return StreamingResponse(
|
||||
BytesIO(content.encode("utf-8")),
|
||||
media_type="text/html; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export/exam-docx")
|
||||
def export_exam_docx(data: ExamExportRequest, current_user: User = Depends(get_current_user)):
|
||||
document = Document()
|
||||
document.add_heading(data.title or "试卷", level=1)
|
||||
meta = " / ".join(part for part in [data.subject, data.grade] if part)
|
||||
if meta:
|
||||
document.add_paragraph(meta)
|
||||
|
||||
for idx, question in enumerate(data.questions, start=1):
|
||||
q_type = question.get("type") or question.get("question_type") or "题目"
|
||||
score = question.get("score")
|
||||
heading = f"{idx}. [{q_type}]"
|
||||
if score:
|
||||
heading += f"({score}分)"
|
||||
document.add_paragraph(heading)
|
||||
document.add_paragraph(str(question.get("content") or question.get("question") or ""))
|
||||
options = question.get("options") or []
|
||||
if isinstance(options, list):
|
||||
for option in options:
|
||||
document.add_paragraph(str(option), style=None)
|
||||
|
||||
if data.answers:
|
||||
document.add_page_break()
|
||||
document.add_heading("参考答案", level=1)
|
||||
for idx, answer in enumerate(data.answers, start=1):
|
||||
document.add_paragraph(f"{idx}. {answer}")
|
||||
|
||||
buffer = BytesIO()
|
||||
document.save(buffer)
|
||||
buffer.seek(0)
|
||||
filename = _safe_filename(data.title, ".docx")
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
import re
|
||||
from html import escape
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from database import get_db
|
||||
from models.animation import Animation
|
||||
from models.resource import Resource
|
||||
from models.user import User
|
||||
from schemas.animation import AnimationGenerate, AnimationCreate, AnimationUpdate, AnimationOut
|
||||
from services.credits import credits_payload, spend_credits
|
||||
from services.limiter import limiter
|
||||
from services.auth import get_current_user, get_optional_current_user
|
||||
from services.ai_service import AIService
|
||||
|
||||
router = APIRouter(prefix="/api/animations", tags=["教学动画"])
|
||||
ai_service = AIService()
|
||||
|
||||
|
||||
def _safe_filename(name: str, suffix: str) -> str:
|
||||
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "教学动画"
|
||||
return f"{stem[:80]}{suffix}"
|
||||
|
||||
|
||||
def _animation_or_404(anim_id: int, current_user: User | None, db: Session) -> Animation:
|
||||
anim = db.query(Animation).filter(Animation.id == anim_id).first()
|
||||
if not anim:
|
||||
raise HTTPException(status_code=404, detail="动画不存在")
|
||||
if (not current_user or anim.user_id != current_user.id) and not has_public_resource(db, anim_id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该动画")
|
||||
return anim
|
||||
|
||||
|
||||
def _build_animation_html(anim: Animation) -> str:
|
||||
config = anim.config or {}
|
||||
body = config.get("html") or config.get("content") or ""
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
raise HTTPException(status_code=400, detail="当前动画没有可导出的 HTML")
|
||||
title = escape(anim.title or config.get("title") or "教学动画")
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
html, body {{ margin: 0; width: 100%; min-height: 100%; background: #f6faf7; }}
|
||||
body {{ display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; }}
|
||||
.animation-stage {{ width: min(1120px, 100%); aspect-ratio: 16 / 9; min-height: 540px; background: #fff; border: 1px solid #dce8de; border-radius: 12px; overflow: hidden; box-shadow: 0 18px 40px rgba(17, 69, 52, .10); }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="animation-stage">
|
||||
{body}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def has_public_resource(db: Session, anim_id: int) -> bool:
|
||||
return db.query(Resource).filter(
|
||||
Resource.content_ref == f"animation:{anim_id}",
|
||||
Resource.status == "active",
|
||||
Resource.is_public == 1,
|
||||
).first() is not None
|
||||
|
||||
|
||||
@limiter.limit("10/minute")
|
||||
@router.post("/ai-generate", response_model=dict)
|
||||
async def ai_generate_animation(
|
||||
request: Request,
|
||||
data: AnimationGenerate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = spend_credits(db, current_user, "animation_generate", f"生成教学动画:{data.prompt[:80]}")
|
||||
db.commit()
|
||||
result = await ai_service.generate_animation(prompt=data.prompt, anim_type=data.anim_type)
|
||||
return {"success": True, "data": result, "credits": credits_payload(account, "animation_generate")}
|
||||
|
||||
|
||||
@router.post("/", response_model=AnimationOut, status_code=201)
|
||||
def create_animation(data: AnimationCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
anim = Animation(user_id=current_user.id, **data.model_dump())
|
||||
db.add(anim)
|
||||
db.commit()
|
||||
db.refresh(anim)
|
||||
return anim
|
||||
|
||||
|
||||
@router.get("/", response_model=list[AnimationOut])
|
||||
def list_animations(
|
||||
anim_type: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Animation).filter(Animation.user_id == current_user.id)
|
||||
if anim_type:
|
||||
query = query.filter(Animation.anim_type == anim_type)
|
||||
return query.order_by(Animation.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{anim_id}", response_model=AnimationOut)
|
||||
def get_animation(
|
||||
anim_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return _animation_or_404(anim_id, current_user, db)
|
||||
|
||||
|
||||
@router.get("/{anim_id}/export-html")
|
||||
def export_animation_html(
|
||||
anim_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
anim = _animation_or_404(anim_id, current_user, db)
|
||||
content = _build_animation_html(anim)
|
||||
filename = _safe_filename(f"{anim.title or '教学动画'}-动画", ".html")
|
||||
return StreamingResponse(
|
||||
BytesIO(content.encode("utf-8")),
|
||||
media_type="text/html; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{anim_id}", response_model=AnimationOut)
|
||||
def update_animation(
|
||||
anim_id: int, data: AnimationUpdate,
|
||||
current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
|
||||
):
|
||||
anim = db.query(Animation).filter(Animation.id == anim_id, Animation.user_id == current_user.id).first()
|
||||
if not anim:
|
||||
raise HTTPException(status_code=404, detail="动画不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(anim, key, value)
|
||||
db.commit()
|
||||
db.refresh(anim)
|
||||
return anim
|
||||
|
||||
|
||||
@router.post("/{anim_id}/remix", response_model=AnimationOut, status_code=201)
|
||||
def remix_animation(
|
||||
anim_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
source = db.query(Animation).filter(Animation.id == anim_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="动画不存在")
|
||||
if source.user_id != current_user.id and not has_public_resource(db, anim_id):
|
||||
raise HTTPException(status_code=403, detail="无权改编该动画")
|
||||
|
||||
clone = Animation(
|
||||
user_id=current_user.id,
|
||||
title=f"{source.title}(改编)",
|
||||
anim_type=source.anim_type or "general",
|
||||
description=source.description or "",
|
||||
config=source.config or {},
|
||||
thumbnail=source.thumbnail or "",
|
||||
status="draft",
|
||||
)
|
||||
db.add(clone)
|
||||
db.commit()
|
||||
db.refresh(clone)
|
||||
return clone
|
||||
|
||||
|
||||
@router.delete("/{anim_id}", status_code=204)
|
||||
def delete_animation(anim_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
anim = db.query(Animation).filter(Animation.id == anim_id, Animation.user_id == current_user.id).first()
|
||||
if not anim:
|
||||
raise HTTPException(status_code=404, detail="动画不存在")
|
||||
db.query(Resource).filter(
|
||||
Resource.user_id == current_user.id,
|
||||
Resource.content_ref == f"animation:{anim_id}",
|
||||
Resource.status == "active",
|
||||
).update({"status": "archived"}, synchronize_session=False)
|
||||
db.delete(anim)
|
||||
db.commit()
|
||||
@@ -0,0 +1,435 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import secrets
|
||||
from config import get_settings
|
||||
from database import get_db
|
||||
from services.upload_security import validate_upload
|
||||
from services.password_policy import validate_password_strength
|
||||
from models.animation import Animation
|
||||
from models.classroom import ClassroomActivity
|
||||
from models.community import Comment, Post, PostFavorite
|
||||
from models.courseware import Courseware
|
||||
from models.credit import CreditTransaction
|
||||
from models.exam import Exam
|
||||
from models.essay import EssayGrade
|
||||
from models.exercise import Exercise
|
||||
from models.lesson_plan import LessonPlan
|
||||
from models.mindmap import MindMap
|
||||
from models.reset_code import ResetCode
|
||||
from models.resource import Resource, ResourceFavorite
|
||||
from models.user import User
|
||||
from schemas.user import CreditBenefitOut, UserLogin, UserRegister, UserUpdate, UserOut, Token, ProfileStatsOut, PasswordChange, SendCodeRequest, ResetPasswordRequest
|
||||
from services.credits import DAILY_CHECKIN_CREDITS, grant_daily_checkin, has_credit_transaction_today, ensure_credit_account
|
||||
from services.limiter import limiter
|
||||
from services.auth import (
|
||||
get_password_hash, verify_password,
|
||||
create_access_token, create_refresh_token,
|
||||
get_current_user,
|
||||
)
|
||||
from services.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
|
||||
def serialize_user(user: User, db: Session) -> dict:
|
||||
account = ensure_credit_account(db, user)
|
||||
data = UserOut.model_validate(user).model_dump()
|
||||
data.update({
|
||||
"credits": account.balance,
|
||||
"total_credits_granted": account.total_granted,
|
||||
"total_credits_used": account.total_used,
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("5/minute")
|
||||
def register(request: Request, data: UserRegister, db: Session = Depends(get_db)):
|
||||
if db.query(User).filter(User.phone == data.phone).first():
|
||||
raise HTTPException(status_code=400, detail="该手机号已注册")
|
||||
pwd_err = validate_password_strength(data.password)
|
||||
if pwd_err:
|
||||
raise HTTPException(status_code=400, detail=pwd_err)
|
||||
user = User(
|
||||
phone=data.phone,
|
||||
password_hash=get_password_hash(data.password),
|
||||
name=data.name,
|
||||
subject=data.subject,
|
||||
school=data.school,
|
||||
grade=data.grade,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_action(db, action="register", user=user, request=request, target_type="user", target_id=user.id, detail=f"注册手机号 {user.phone}")
|
||||
return Token(
|
||||
access_token=create_access_token(user.id),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
@limiter.limit("10/minute")
|
||||
def login(request: Request, data: UserLogin, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.phone == data.phone).first()
|
||||
if not user or not verify_password(data.password, user.password_hash):
|
||||
log_action(db, action="login", request=request, target_type="user", detail=f"登录失败:{data.phone}", status="failed")
|
||||
raise HTTPException(status_code=401, detail="手机号或密码错误")
|
||||
if not user.is_active:
|
||||
log_action(db, action="login", user=user, request=request, target_type="user", target_id=user.id, detail="账号已禁用", status="denied")
|
||||
raise HTTPException(status_code=403, detail="账号已被禁用")
|
||||
log_action(db, action="login", user=user, request=request, target_type="user", target_id=user.id, detail="登录成功")
|
||||
return Token(
|
||||
access_token=create_access_token(user.id),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
@limiter.limit("30/minute")
|
||||
def refresh_token(request: Request, refresh_token: str, db: Session = Depends(get_db)):
|
||||
"""用 refresh_token 换取新的 access_token(与新的 refresh_token)。"""
|
||||
from jose import jwt, JWTError
|
||||
from config import get_settings as _gs
|
||||
_s = _gs()
|
||||
try:
|
||||
payload = jwt.decode(refresh_token, _s.SECRET_KEY, algorithms=[_s.ALGORITHM])
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="无效的刷新凭证")
|
||||
user_id = int(payload.get("sub"))
|
||||
except (JWTError, TypeError, ValueError):
|
||||
log_action(db, action="refresh_token", request=request, detail="刷新令牌无效或过期", status="failed")
|
||||
raise HTTPException(status_code=401, detail="刷新凭证无效或已过期,请重新登录")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="用户不存在")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="账号已被禁用")
|
||||
|
||||
log_action(db, action="refresh_token", user=user, request=request, target_type="user", target_id=user.id, detail="刷新访问令牌")
|
||||
return Token(
|
||||
access_token=create_access_token(user.id),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def get_profile(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
return serialize_user(current_user, db)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserOut)
|
||||
def update_profile(
|
||||
data: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(current_user, key, value)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return serialize_user(current_user, db)
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=dict)
|
||||
def change_password(
|
||||
data: PasswordChange,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not verify_password(data.old_password, current_user.password_hash):
|
||||
log_action(db, action="change_password", user=current_user, request=request, target_type="user", target_id=current_user.id, detail="旧密码错误", status="failed")
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
if data.old_password == data.new_password:
|
||||
raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
|
||||
new_pwd_err = validate_password_strength(data.new_password)
|
||||
if new_pwd_err:
|
||||
raise HTTPException(status_code=400, detail=new_pwd_err)
|
||||
current_user.password_hash = get_password_hash(data.new_password)
|
||||
db.commit()
|
||||
log_action(db, action="change_password", user=current_user, request=request, target_type="user", target_id=current_user.id, detail="修改密码成功")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/avatar", response_model=dict)
|
||||
async def upload_avatar(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""上传用户头像,返回可访问的 URL。"""
|
||||
content = await file.read()
|
||||
settings = get_settings()
|
||||
check = validate_upload(file, content=content, max_size=5 * 1024 * 1024)
|
||||
# 仅允许图片
|
||||
if check.suffix not in {"png", "jpg", "jpeg", "gif", "webp", "bmp"}:
|
||||
raise HTTPException(status_code=415, detail="头像仅支持 png/jpg/jpeg/gif/webp/bmp 格式")
|
||||
# 保存到 UPLOAD_DIR/avatars/
|
||||
avatars_dir = os.path.join(settings.UPLOAD_DIR, "avatars")
|
||||
os.makedirs(avatars_dir, exist_ok=True)
|
||||
filename = f"user_{current_user.id}_{int(__import__('time').time())}.{check.suffix}"
|
||||
save_path = os.path.join(avatars_dir, filename)
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(content)
|
||||
avatar_url = f"/uploads/avatars/{filename}"
|
||||
current_user.avatar = avatar_url
|
||||
db.commit()
|
||||
log_action(db, action="upload_avatar", user=current_user, request=request, target_type="user", target_id=current_user.id, detail=f"上传头像 {filename}")
|
||||
return {"success": True, "avatar": avatar_url}
|
||||
|
||||
|
||||
def credit_benefit_payload(user: User, db: Session) -> dict:
|
||||
account = ensure_credit_account(db, user)
|
||||
daily_claimed = has_credit_transaction_today(db, user, "daily_checkin")
|
||||
recent_checkins = (
|
||||
db.query(CreditTransaction)
|
||||
.filter(CreditTransaction.user_id == user.id, CreditTransaction.action == "daily_checkin")
|
||||
.order_by(CreditTransaction.created_at.desc())
|
||||
.limit(7)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"credits": account.balance,
|
||||
"daily_amount": DAILY_CHECKIN_CREDITS,
|
||||
"daily_claimed": daily_claimed,
|
||||
"next_claim_text": "明日可继续领取" if daily_claimed else "今日可领取",
|
||||
"recent_checkins": recent_checkins,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/credits/benefits", response_model=CreditBenefitOut)
|
||||
def get_credit_benefits(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return credit_benefit_payload(current_user, db)
|
||||
|
||||
|
||||
@router.post("/credits/daily-checkin", response_model=CreditBenefitOut)
|
||||
def claim_daily_checkin(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
grant_daily_checkin(db, current_user)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return credit_benefit_payload(current_user, db)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ProfileStatsOut)
|
||||
def get_profile_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = current_user.id
|
||||
account = ensure_credit_account(db, current_user)
|
||||
|
||||
coursewares = db.query(Courseware).filter(Courseware.user_id == user_id, Courseware.status != "archived").all()
|
||||
animations = db.query(Animation).filter(Animation.user_id == user_id).all()
|
||||
exercises = db.query(Exercise).filter(Exercise.user_id == user_id).all()
|
||||
lesson_plans = db.query(LessonPlan).filter(LessonPlan.user_id == user_id).all()
|
||||
essay_grades = db.query(EssayGrade).filter(EssayGrade.user_id == user_id).all()
|
||||
exams = db.query(Exam).filter(Exam.user_id == user_id).all()
|
||||
mind_maps = db.query(MindMap).filter(MindMap.user_id == user_id).all()
|
||||
resources = db.query(Resource).filter(Resource.user_id == user_id, Resource.status == "active").all()
|
||||
classroom_activities = db.query(ClassroomActivity).filter(ClassroomActivity.user_id == user_id).all()
|
||||
community_posts = db.query(Post).filter(Post.user_id == user_id).all()
|
||||
|
||||
works_by_type = {
|
||||
"courseware": len(coursewares),
|
||||
"animation": len(animations),
|
||||
"exercise": len(exercises),
|
||||
"lesson_plan": len(lesson_plans),
|
||||
"essay_grade": len(essay_grades),
|
||||
"exam": len(exams),
|
||||
"mindmap": len(mind_maps),
|
||||
}
|
||||
resources_by_type = {key: 0 for key in ["courseware", "animation", "exercise", "lesson_plan", "exam", "other"]}
|
||||
for resource in resources:
|
||||
resources_by_type[resource.resource_type or "other"] = resources_by_type.get(resource.resource_type or "other", 0) + 1
|
||||
|
||||
published_refs = {item.content_ref for item in resources if item.content_ref}
|
||||
published_refs.update(f"courseware:{item.id}" for item in coursewares if item.status == "published")
|
||||
published_refs.update(f"animation:{item.id}" for item in animations if item.status == "published")
|
||||
published_works = len(published_refs)
|
||||
favorite_count = db.query(ResourceFavorite).filter(ResourceFavorite.user_id == user_id).count()
|
||||
community_favorite_count = db.query(PostFavorite).filter(PostFavorite.user_id == user_id).count()
|
||||
community_comment_count = db.query(Comment).filter(Comment.user_id == user_id).count()
|
||||
|
||||
def source_status(item, item_type: str, fallback: str = "") -> str:
|
||||
if f"{item_type}:{item.id}" in published_refs:
|
||||
return "published"
|
||||
return fallback
|
||||
|
||||
def recent_work(item, item_type: str, subtitle: str = "", status: str = "") -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"item_type": item_type,
|
||||
"title": item.title,
|
||||
"subtitle": subtitle,
|
||||
"status": status,
|
||||
"updated_at": item.updated_at,
|
||||
}
|
||||
|
||||
classroom_type_labels = {
|
||||
"poll": "课堂投票",
|
||||
"qa": "即时问答",
|
||||
"quiz": "随堂测验",
|
||||
"checkin": "课堂签到",
|
||||
"feedback": "学习反馈",
|
||||
}
|
||||
community_type_labels = {
|
||||
"discussion": "案例",
|
||||
"share": "模板",
|
||||
"help": "需求",
|
||||
}
|
||||
|
||||
recent_works = [
|
||||
*[recent_work(item, "courseware", "课件", source_status(item, "courseware", item.status or "draft")) for item in coursewares],
|
||||
*[recent_work(item, "animation", "动画", source_status(item, "animation", item.status or "draft")) for item in animations],
|
||||
*[recent_work(item, "exercise", "互动练习", source_status(item, "exercise", "draft")) for item in exercises],
|
||||
*[recent_work(item, "lesson_plan", "教案", source_status(item, "lesson_plan", "draft")) for item in lesson_plans],
|
||||
*[recent_work(item, "essay_grade", "作文批改", "已批改") for item in essay_grades],
|
||||
*[recent_work(item, "exam", "试卷", source_status(item, "exam", "draft")) for item in exams],
|
||||
*[recent_work(item, "mindmap", "思维导图", "draft") for item in mind_maps],
|
||||
]
|
||||
recent_works.sort(key=lambda item: item["updated_at"] or datetime.min, reverse=True)
|
||||
|
||||
recent_resources = [
|
||||
{
|
||||
"id": item.id,
|
||||
"item_type": item.resource_type or "other",
|
||||
"title": item.title,
|
||||
"subtitle": "公开" if item.is_public else "私密",
|
||||
"status": item.status or "active",
|
||||
"updated_at": item.updated_at,
|
||||
}
|
||||
for item in sorted(resources, key=lambda resource: resource.updated_at or datetime.min, reverse=True)[:5]
|
||||
]
|
||||
|
||||
recent_interactions = [
|
||||
*[
|
||||
{
|
||||
"id": item.id,
|
||||
"item_type": "classroom_activity",
|
||||
"title": item.title,
|
||||
"subtitle": f"{classroom_type_labels.get(item.activity_type or '', '课堂活动')} · {len(item.responses or [])} 条提交",
|
||||
"status": item.status or "draft",
|
||||
"updated_at": item.updated_at,
|
||||
}
|
||||
for item in classroom_activities
|
||||
],
|
||||
*[
|
||||
{
|
||||
"id": item.id,
|
||||
"item_type": "community_post",
|
||||
"title": item.title,
|
||||
"subtitle": f"{community_type_labels.get(item.post_type or '', '社区内容')} · {item.likes or 0} 收藏 · {item.comments_count or 0} 评论",
|
||||
"status": "published",
|
||||
"updated_at": item.updated_at,
|
||||
}
|
||||
for item in community_posts
|
||||
],
|
||||
]
|
||||
recent_interactions.sort(key=lambda item: item["updated_at"] or datetime.min, reverse=True)
|
||||
|
||||
total_favorites = db.query(func.count(ResourceFavorite.id)).join(Resource).filter(
|
||||
Resource.user_id == user_id,
|
||||
Resource.status == "active",
|
||||
).scalar() or 0
|
||||
recent_credit_transactions = sorted(
|
||||
list(current_user.credit_transactions or []),
|
||||
key=lambda item: item.created_at or datetime.min,
|
||||
reverse=True,
|
||||
)[:8]
|
||||
|
||||
return {
|
||||
"credits": account.balance,
|
||||
"total_credits_granted": account.total_granted,
|
||||
"total_credits_used": account.total_used,
|
||||
"total_works": sum(works_by_type.values()),
|
||||
"draft_works": max(sum(works_by_type.values()) - published_works, 0),
|
||||
"published_works": published_works,
|
||||
"published_resources": len(resources),
|
||||
"public_resources": sum(1 for item in resources if item.is_public),
|
||||
"private_resources": sum(1 for item in resources if not item.is_public),
|
||||
"favorite_resources": favorite_count,
|
||||
"total_views": sum(item.views or 0 for item in resources),
|
||||
"total_downloads": sum(item.downloads or 0 for item in resources),
|
||||
"total_favorites": total_favorites,
|
||||
"classroom_activities": len(classroom_activities),
|
||||
"active_classroom_activities": sum(1 for item in classroom_activities if item.status == "active"),
|
||||
"classroom_responses": sum(len(item.responses or []) for item in classroom_activities),
|
||||
"community_posts": len(community_posts),
|
||||
"community_favorites": community_favorite_count,
|
||||
"community_comments": community_comment_count,
|
||||
"works_by_type": works_by_type,
|
||||
"resources_by_type": resources_by_type,
|
||||
"recent_works": recent_works[:5],
|
||||
"recent_resources": recent_resources,
|
||||
"recent_interactions": recent_interactions[:6],
|
||||
"recent_credit_transactions": recent_credit_transactions,
|
||||
}
|
||||
|
||||
|
||||
@limiter.limit("3/minute")
|
||||
@router.post("/send-code", response_model=dict)
|
||||
def send_reset_code(
|
||||
request: Request,
|
||||
data: SendCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""发送密码重置验证码。生产环境对接短信网关;开发环境返回 code 便于调试。"""
|
||||
user = db.query(User).filter(User.phone == data.phone).first()
|
||||
if not user:
|
||||
# 不暴露手机号是否注册,统一返回成功
|
||||
return {"success": True, "message": "验证码已发送"}
|
||||
code = f"{secrets.randbelow(1000000):06d}"
|
||||
expires = datetime.now() + timedelta(minutes=5)
|
||||
# 使该手机号之前未消费的验证码失效
|
||||
db.query(ResetCode).filter(
|
||||
ResetCode.phone == data.phone, ResetCode.consumed == 0
|
||||
).update({"consumed": 1}, synchronize_session=False)
|
||||
db.add(ResetCode(phone=data.phone, code=code, expires_at=expires, consumed=0))
|
||||
db.commit()
|
||||
# 开发环境返回验证码;生产环境去掉 dev_code 字段
|
||||
return {"success": True, "message": "验证码已发送", "dev_code": code}
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=dict)
|
||||
def reset_password(
|
||||
request: Request,
|
||||
data: ResetPasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""通过验证码重置密码。"""
|
||||
pwd_err = validate_password_strength(data.new_password)
|
||||
if pwd_err:
|
||||
raise HTTPException(status_code=400, detail=pwd_err)
|
||||
record = db.query(ResetCode).filter(
|
||||
ResetCode.phone == data.phone,
|
||||
ResetCode.code == data.code,
|
||||
ResetCode.consumed == 0,
|
||||
).order_by(ResetCode.created_at.desc()).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="验证码无效或已过期")
|
||||
if record.expires_at < datetime.now():
|
||||
record.consumed = 1
|
||||
db.commit()
|
||||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||||
user = db.query(User).filter(User.phone == data.phone).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="账号不存在")
|
||||
user.password_hash = get_password_hash(data.new_password)
|
||||
record.consumed = 1
|
||||
db.commit()
|
||||
log_action(db, action="reset_password", user=user, request=request, target_type="user", target_id=user.id, detail="通过验证码重置密码")
|
||||
return {"success": True, "message": "密码重置成功"}
|
||||
@@ -0,0 +1,184 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from models.chat import ChatConversation, ChatMessage
|
||||
from models.user import User
|
||||
from schemas.chat import ConversationCreate, ConversationRename, ChatSend, MessageOut, ConversationOut
|
||||
from services.credits import credits_payload, spend_credits
|
||||
from services.limiter import limiter
|
||||
from services.auth import get_current_user
|
||||
from services.audit import log_action
|
||||
from services.ai_service import AIService
|
||||
|
||||
router = APIRouter(prefix="/api/chat", tags=["AI教学助手"])
|
||||
ai_service = AIService()
|
||||
|
||||
# 历史上下文窗口:最近 N 轮(user+assistant 计为 2 条),避免 token 超限
|
||||
MAX_HISTORY_TURNS = 12
|
||||
MAX_TITLE_LEN = 60
|
||||
|
||||
|
||||
def _ensure_owned(db: Session, conversation_id: int, user: User) -> ChatConversation:
|
||||
item = db.query(ChatConversation).filter(
|
||||
ChatConversation.id == conversation_id,
|
||||
ChatConversation.user_id == user.id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="对话不存在")
|
||||
return item
|
||||
|
||||
|
||||
def _derive_title(text: str) -> str:
|
||||
cleaned = " ".join(text.split())
|
||||
return (cleaned[:MAX_TITLE_LEN] + "…") if len(cleaned) > MAX_TITLE_LEN else (cleaned or "新对话")
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=list[ConversationOut])
|
||||
def list_conversations(
|
||||
keyword: str | None = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(30, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(ChatConversation).filter(ChatConversation.user_id == current_user.id)
|
||||
if keyword:
|
||||
query = query.filter(ChatConversation.title.contains(keyword))
|
||||
return (
|
||||
query.order_by(ChatConversation.pinned.desc(), ChatConversation.updated_at.desc())
|
||||
.offset(skip).limit(limit).all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/conversations", response_model=ConversationOut, status_code=201)
|
||||
def create_conversation(
|
||||
data: ConversationCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = ChatConversation(
|
||||
user_id=current_user.id,
|
||||
title=(data.title or "新对话").strip() or "新对话",
|
||||
subject=data.subject,
|
||||
grade=data.grade,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}", response_model=ConversationOut)
|
||||
def get_conversation(
|
||||
conversation_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return _ensure_owned(db, conversation_id, current_user)
|
||||
|
||||
|
||||
@router.put("/conversations/{conversation_id}", response_model=ConversationOut)
|
||||
def rename_conversation(
|
||||
conversation_id: int,
|
||||
data: ConversationRename,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = _ensure_owned(db, conversation_id, current_user)
|
||||
item.title = data.title.strip() or item.title
|
||||
if data.pinned is not None:
|
||||
item.pinned = data.pinned
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}", status_code=204)
|
||||
def delete_conversation(
|
||||
conversation_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = _ensure_owned(db, conversation_id, current_user)
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/messages", response_model=dict)
|
||||
@limiter.limit("20/minute")
|
||||
async def send_message(
|
||||
request: Request,
|
||||
conversation_id: int,
|
||||
data: ChatSend,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
conversation = _ensure_owned(db, conversation_id, current_user)
|
||||
account = spend_credits(db, current_user, "chat_generate", "AI教学助手对话")
|
||||
db.add(ChatMessage(
|
||||
conversation_id=conversation.id,
|
||||
role="user",
|
||||
content=data.content,
|
||||
))
|
||||
db.flush()
|
||||
|
||||
history = (
|
||||
db.query(ChatMessage)
|
||||
.filter(ChatMessage.conversation_id == conversation.id)
|
||||
.order_by(ChatMessage.id.desc())
|
||||
.limit(MAX_HISTORY_TURNS)
|
||||
.all()
|
||||
)
|
||||
history = list(reversed(history))
|
||||
history_payload = [{"role": m.role, "content": m.content} for m in history[:-1]] if history else []
|
||||
|
||||
try:
|
||||
reply = await ai_service.chat_teaching(
|
||||
history=history_payload,
|
||||
user_message=data.content,
|
||||
subject=data.subject or conversation.subject,
|
||||
grade=data.grade or conversation.grade,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
msg = str(exc)
|
||||
if "CONNECT" in msg or "TIMEOUT" in msg:
|
||||
reply = (
|
||||
"无法连接到 AI 服务,请检查网络或稍后重试。\n"
|
||||
"你的问题我已记录,服务恢复后重新发送即可继续对话。"
|
||||
)
|
||||
elif "EMPTY" in msg:
|
||||
reply = (
|
||||
"AI 模型本次未返回内容(推理模型可能因思考超时导致空回复),请稍后重试或换一种问法。"
|
||||
)
|
||||
else:
|
||||
reply = (
|
||||
"当前 AI 服务暂时不可用,请稍后重试。我已经记录了你的问题,"
|
||||
"服务恢复后再次发送即可继续对话。"
|
||||
)
|
||||
|
||||
assistant_msg = ChatMessage(
|
||||
conversation_id=conversation.id,
|
||||
role="assistant",
|
||||
content=reply,
|
||||
)
|
||||
db.add(assistant_msg)
|
||||
|
||||
first_message = len(conversation.messages) <= 2
|
||||
if conversation.title == "新对话" or first_message:
|
||||
conversation.title = _derive_title(data.content)
|
||||
db.commit()
|
||||
db.refresh(assistant_msg)
|
||||
|
||||
log_action(
|
||||
db, action="chat_send", user=current_user, request=request,
|
||||
target_type="chat_conversation", target_id=conversation.id,
|
||||
detail=f"AI助手对话({len(data.content)}字)",
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": MessageOut.model_validate(assistant_msg).model_dump(),
|
||||
"title": conversation.title,
|
||||
"credits": credits_payload(account, "chat_generate"),
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from html import escape
|
||||
from urllib.parse import quote
|
||||
|
||||
from docx import Document
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.classroom import ClassroomActivity
|
||||
from models.user import User
|
||||
from schemas.classroom import (
|
||||
ClassroomActivityCreate,
|
||||
ClassroomAnalysisOut,
|
||||
ClassroomActivityOut,
|
||||
ClassroomActivityUpdate,
|
||||
ClassroomResponseCreate,
|
||||
)
|
||||
from services.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/classroom", tags=["课堂工具"])
|
||||
|
||||
|
||||
STOP_WORDS = {
|
||||
"这个", "我们", "老师", "学生", "因为", "所以", "可以", "需要", "没有", "已经",
|
||||
"the", "and", "that", "with", "this", "from", "have", "will",
|
||||
}
|
||||
|
||||
|
||||
def _format_answer(answer) -> str:
|
||||
if isinstance(answer, str):
|
||||
return answer.strip()
|
||||
if answer is None:
|
||||
return ""
|
||||
return str(answer)
|
||||
|
||||
|
||||
def _percent(count: int, total: int) -> int:
|
||||
return round((count / total) * 100) if total else 0
|
||||
|
||||
|
||||
def _extract_keywords(text: str) -> list[dict]:
|
||||
words = re.findall(r"[\u4e00-\u9fa5]{2,}|[a-zA-Z0-9]{2,}", text)
|
||||
counter = Counter(word for word in words if word.lower() not in STOP_WORDS)
|
||||
return [{"keyword": word, "count": count} for word, count in counter.most_common(10)]
|
||||
|
||||
|
||||
def _infer_correct_answer(activity: ClassroomActivity) -> str:
|
||||
config = activity.config or {}
|
||||
for key in ("answer", "correct_answer", "correctAnswer"):
|
||||
value = config.get(key)
|
||||
if value:
|
||||
return str(value).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _build_mastery_level(activity_type: str, response_count: int, accuracy: int | None, option_stats: list[dict]) -> str:
|
||||
if response_count <= 0:
|
||||
return "暂无数据"
|
||||
if accuracy is not None:
|
||||
if accuracy >= 85:
|
||||
return "整体掌握较好"
|
||||
if accuracy >= 60:
|
||||
return "基础掌握,仍需巩固"
|
||||
return "薄弱点明显,建议重点讲评"
|
||||
if option_stats:
|
||||
top = max(option_stats, key=lambda item: item["count"])
|
||||
if top["percent"] >= 70:
|
||||
return f"倾向集中:{top['option']}"
|
||||
if top["percent"] >= 45:
|
||||
return f"主要倾向:{top['option']}"
|
||||
return "分布分散,需要追问原因"
|
||||
if activity_type in {"qa", "feedback"}:
|
||||
return "已收集开放反馈"
|
||||
return "已收集课堂数据"
|
||||
|
||||
|
||||
def _build_diagnosis(
|
||||
activity: ClassroomActivity,
|
||||
response_count: int,
|
||||
participant_count: int,
|
||||
option_stats: list[dict],
|
||||
keywords: list[dict],
|
||||
accuracy: int | None,
|
||||
) -> list[str]:
|
||||
if response_count <= 0:
|
||||
return ["尚未收到学生提交,建议先发布活动并提醒学生扫码参与。"]
|
||||
diagnosis = [f"本次共收到 {response_count} 条提交,覆盖 {participant_count} 名学生。"]
|
||||
if accuracy is not None:
|
||||
diagnosis.append(f"按正确答案统计,当前正确率为 {accuracy}%。")
|
||||
if option_stats:
|
||||
top = max(option_stats, key=lambda item: item["count"])
|
||||
diagnosis.append(f"选择最多的是“{top['option']}”,占比 {top['percent']}%。")
|
||||
weak_options = [item for item in option_stats if item["count"] and item["percent"] >= 20 and item is not top]
|
||||
if weak_options:
|
||||
names = "、".join(item["option"] for item in weak_options[:3])
|
||||
diagnosis.append(f"仍有较多学生选择“{names}”,建议追问选择原因。")
|
||||
if keywords:
|
||||
diagnosis.append("开放回答中高频词包括:" + "、".join(item["keyword"] for item in keywords[:5]) + "。")
|
||||
if activity.activity_type == "checkin":
|
||||
diagnosis.append("该活动更适合作为参与记录,可结合后续测验或反馈判断掌握情况。")
|
||||
return diagnosis
|
||||
|
||||
|
||||
def _build_teaching_suggestions(
|
||||
activity: ClassroomActivity,
|
||||
response_count: int,
|
||||
option_stats: list[dict],
|
||||
accuracy: int | None,
|
||||
keywords: list[dict],
|
||||
) -> list[str]:
|
||||
if response_count <= 0:
|
||||
return ["先用投放链接收集至少 5 条反馈,再生成更可靠的讲评建议。"]
|
||||
suggestions = []
|
||||
if accuracy is not None and accuracy < 60:
|
||||
suggestions.append("用 5 分钟回到概念源头,先讲清关键定义或公式来源,再做同类例题。")
|
||||
suggestions.append("安排 3 道低门槛变式题,确认学生能独立完成基本步骤。")
|
||||
elif accuracy is not None and accuracy < 85:
|
||||
suggestions.append("挑选一个典型错选项进行对比讲评,让学生说出错误思路。")
|
||||
suggestions.append("追加 2 到 3 道变式练习,覆盖易错条件和表达规范。")
|
||||
elif accuracy is not None:
|
||||
suggestions.append("整体掌握较好,可进入拓展任务或让学生互相解释解题依据。")
|
||||
elif option_stats:
|
||||
top = max(option_stats, key=lambda item: item["count"])
|
||||
suggestions.append(f"围绕“{top['option']}”这个主流反馈追问原因,确认学生是理解到位还是凭感觉选择。")
|
||||
suggestions.append("让不同选择的学生各举一个理由,形成板书对比后再归纳共识。")
|
||||
else:
|
||||
suggestions.append("把高频回答聚类成 2 到 3 类,在下一轮讲评中分别回应。")
|
||||
if keywords:
|
||||
suggestions.append(f"优先处理“{keywords[0]['keyword']}”相关问题,并设计一个即时追问。")
|
||||
suggestions.append("课末再投放一次简短反馈,比较讲评前后的变化。")
|
||||
return suggestions[:5]
|
||||
|
||||
|
||||
def _build_followup_prompt(activity: ClassroomActivity, analysis: dict) -> str:
|
||||
question = analysis.get("question") or activity.title
|
||||
diagnosis = ";".join(analysis.get("diagnosis") or [])
|
||||
material_context = str((activity.config or {}).get("material_context") or "").strip()
|
||||
material_part = f"参考素材:{material_context}。" if material_context else ""
|
||||
return (
|
||||
f"基于课堂数据回收《{activity.title}》生成一套二次巩固练习。"
|
||||
f"回收题目:{question}。"
|
||||
f"{material_part}"
|
||||
f"学情诊断:{diagnosis}。"
|
||||
"要求题目有梯度,覆盖高频错误点,附答案和解析。"
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(name: str, suffix: str) -> str:
|
||||
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课堂诊断报告"
|
||||
return f"{stem[:80]}{suffix}"
|
||||
|
||||
|
||||
def _excel_cell(value, cell_type: str = "String") -> str:
|
||||
if value is None:
|
||||
value = ""
|
||||
if isinstance(value, datetime):
|
||||
value = value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
text = escape(str(value), quote=False)
|
||||
return f"<Cell><Data ss:Type=\"{cell_type}\">{text}</Data></Cell>"
|
||||
|
||||
|
||||
def _excel_row(values: list) -> str:
|
||||
return "<Row>" + "".join(_excel_cell(value) for value in values) + "</Row>"
|
||||
|
||||
|
||||
def _excel_sheet(name: str, rows: list[list]) -> str:
|
||||
safe_name = escape(name[:31] or "Sheet", quote=True)
|
||||
body = "\n".join(_excel_row(row) for row in rows)
|
||||
return f"<Worksheet ss:Name=\"{safe_name}\"><Table>{body}</Table></Worksheet>"
|
||||
|
||||
|
||||
def _build_activity_workbook(activity: ClassroomActivity, analysis: dict) -> bytes:
|
||||
responses = list(activity.responses or [])
|
||||
detail_rows = [
|
||||
["活动ID", "活动标题", "活动类型", "学生姓名", "回答", "提交时间", "来源"],
|
||||
*[
|
||||
[
|
||||
activity.id,
|
||||
activity.title,
|
||||
activity.activity_type,
|
||||
res.get("student_name") or "匿名",
|
||||
_format_answer(res.get("answer")),
|
||||
res.get("submitted_at") or "",
|
||||
(res.get("meta") or {}).get("source", ""),
|
||||
]
|
||||
for res in responses
|
||||
],
|
||||
]
|
||||
stats_rows = [
|
||||
["指标", "数值"],
|
||||
["提交数", analysis["response_count"]],
|
||||
["参与学生数", analysis["participant_count"]],
|
||||
["掌握水平", analysis["mastery_level"]],
|
||||
["正确率", "" if analysis["accuracy"] is None else f"{analysis['accuracy']}%"],
|
||||
["回收题目", analysis["question"]],
|
||||
]
|
||||
if analysis["option_stats"]:
|
||||
stats_rows.extend([[], ["选项", "人数", "占比"]])
|
||||
stats_rows.extend([[item["option"], item["count"], f"{item['percent']}%"] for item in analysis["option_stats"]])
|
||||
if analysis["keywords"]:
|
||||
stats_rows.extend([[], ["关键词", "次数"]])
|
||||
stats_rows.extend([[item["keyword"], item["count"]] for item in analysis["keywords"]])
|
||||
|
||||
diagnosis_rows = [
|
||||
["诊断结论"],
|
||||
*[[item] for item in analysis["diagnosis"]],
|
||||
[],
|
||||
["讲评建议"],
|
||||
*[[item] for item in analysis["teaching_suggestions"]],
|
||||
[],
|
||||
["二次练习提示词"],
|
||||
[analysis["followup_prompt"]],
|
||||
]
|
||||
workbook = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?mso-application progid="Excel.Sheet"?>
|
||||
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
xmlns:x="urn:schemas-microsoft-com:office:excel"
|
||||
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
||||
{_excel_sheet("提交明细", detail_rows)}
|
||||
{_excel_sheet("统计汇总", stats_rows)}
|
||||
{_excel_sheet("诊断建议", diagnosis_rows)}
|
||||
</Workbook>
|
||||
"""
|
||||
return workbook.encode("utf-8")
|
||||
|
||||
|
||||
def _analysis_payload(activity: ClassroomActivity) -> dict:
|
||||
responses = list(activity.responses or [])
|
||||
response_count = len(responses)
|
||||
participants = {
|
||||
str(res.get("student_name") or "匿名").strip() or "匿名"
|
||||
for res in responses
|
||||
}
|
||||
participant_count = len(participants)
|
||||
answers = [_format_answer(res.get("answer")) for res in responses]
|
||||
answer_text = " ".join(answers)
|
||||
config = activity.config or {}
|
||||
options = [str(option).strip() for option in config.get("options", []) if str(option).strip()]
|
||||
option_stats = []
|
||||
if options:
|
||||
counter = Counter(answers)
|
||||
option_stats = [
|
||||
{"option": option, "count": counter.get(option, 0), "percent": _percent(counter.get(option, 0), response_count)}
|
||||
for option in options
|
||||
]
|
||||
|
||||
correct_answer = _infer_correct_answer(activity)
|
||||
accuracy = None
|
||||
if correct_answer and response_count:
|
||||
accuracy = _percent(sum(1 for answer in answers if answer == correct_answer), response_count)
|
||||
|
||||
common_answers = [answer for answer, _ in Counter(answer for answer in answers if answer).most_common(6)]
|
||||
keywords = _extract_keywords(answer_text)
|
||||
mastery_level = _build_mastery_level(activity.activity_type, response_count, accuracy, option_stats)
|
||||
diagnosis = _build_diagnosis(activity, response_count, participant_count, option_stats, keywords, accuracy)
|
||||
teaching_suggestions = _build_teaching_suggestions(activity, response_count, option_stats, accuracy, keywords)
|
||||
payload = {
|
||||
"activity_id": activity.id,
|
||||
"title": activity.title,
|
||||
"activity_type": activity.activity_type,
|
||||
"question": config.get("question") or activity.description or activity.title,
|
||||
"response_count": response_count,
|
||||
"participant_count": participant_count,
|
||||
"option_stats": option_stats,
|
||||
"keywords": keywords,
|
||||
"common_answers": common_answers,
|
||||
"mastery_level": mastery_level,
|
||||
"accuracy": accuracy,
|
||||
"diagnosis": diagnosis,
|
||||
"teaching_suggestions": teaching_suggestions,
|
||||
"generated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
payload["followup_prompt"] = _build_followup_prompt(activity, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _public_activity_payload(activity: ClassroomActivity) -> ClassroomActivity:
|
||||
config = dict(activity.config or {})
|
||||
for key in ("answer", "correct_answer", "correctAnswer", "material_context"):
|
||||
config.pop(key, None)
|
||||
setattr(activity, "config", config)
|
||||
return activity
|
||||
|
||||
|
||||
def _add_bullets(document: Document, items: list[str]) -> None:
|
||||
for item in items:
|
||||
document.add_paragraph(str(item), style="List Bullet")
|
||||
|
||||
|
||||
@router.post("/activities", response_model=ClassroomActivityOut, status_code=201)
|
||||
def create_activity(
|
||||
data: ClassroomActivityCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
activity = ClassroomActivity(user_id=current_user.id, **data.model_dump())
|
||||
db.add(activity)
|
||||
db.commit()
|
||||
db.refresh(activity)
|
||||
return activity
|
||||
|
||||
|
||||
@router.get("/activities", response_model=list[ClassroomActivityOut])
|
||||
def list_activities(
|
||||
activity_type: str = "",
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(ClassroomActivity).filter(ClassroomActivity.user_id == current_user.id)
|
||||
if activity_type:
|
||||
query = query.filter(ClassroomActivity.activity_type == activity_type)
|
||||
return query.order_by(ClassroomActivity.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}", response_model=ClassroomActivityOut)
|
||||
def get_activity(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
activity = db.query(ClassroomActivity).filter(
|
||||
ClassroomActivity.id == activity_id,
|
||||
ClassroomActivity.user_id == current_user.id,
|
||||
).first()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="课堂活动不存在")
|
||||
return activity
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}/analysis", response_model=ClassroomAnalysisOut)
|
||||
def analyze_activity(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
activity = db.query(ClassroomActivity).filter(
|
||||
ClassroomActivity.id == activity_id,
|
||||
ClassroomActivity.user_id == current_user.id,
|
||||
).first()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="课堂活动不存在")
|
||||
return _analysis_payload(activity)
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}/analysis/export")
|
||||
def export_activity_analysis(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
activity = db.query(ClassroomActivity).filter(
|
||||
ClassroomActivity.id == activity_id,
|
||||
ClassroomActivity.user_id == current_user.id,
|
||||
).first()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="课堂活动不存在")
|
||||
|
||||
analysis = _analysis_payload(activity)
|
||||
document = Document()
|
||||
document.add_heading(f"课堂学情诊断报告:{activity.title}", level=1)
|
||||
document.add_paragraph(f"活动类型:{activity.activity_type}")
|
||||
document.add_paragraph(f"回收题目:{analysis['question']}")
|
||||
document.add_paragraph(f"生成时间:{analysis['generated_at'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
|
||||
document.add_heading("核心指标", level=2)
|
||||
document.add_paragraph(f"提交数:{analysis['response_count']}")
|
||||
document.add_paragraph(f"参与学生数:{analysis['participant_count']}")
|
||||
document.add_paragraph(f"掌握水平:{analysis['mastery_level']}")
|
||||
if analysis["accuracy"] is not None:
|
||||
document.add_paragraph(f"正确率:{analysis['accuracy']}%")
|
||||
|
||||
if analysis["option_stats"]:
|
||||
document.add_heading("选项分布", level=2)
|
||||
table = document.add_table(rows=1, cols=3)
|
||||
table.style = "Table Grid"
|
||||
hdr = table.rows[0].cells
|
||||
hdr[0].text = "选项"
|
||||
hdr[1].text = "人数"
|
||||
hdr[2].text = "占比"
|
||||
for item in analysis["option_stats"]:
|
||||
row = table.add_row().cells
|
||||
row[0].text = item["option"]
|
||||
row[1].text = str(item["count"])
|
||||
row[2].text = f"{item['percent']}%"
|
||||
|
||||
if analysis["keywords"]:
|
||||
document.add_heading("高频关键词", level=2)
|
||||
document.add_paragraph("、".join(f"{item['keyword']}({item['count']})" for item in analysis["keywords"]))
|
||||
|
||||
if analysis["common_answers"]:
|
||||
document.add_heading("常见回答", level=2)
|
||||
_add_bullets(document, analysis["common_answers"])
|
||||
|
||||
document.add_heading("诊断结论", level=2)
|
||||
_add_bullets(document, analysis["diagnosis"])
|
||||
|
||||
document.add_heading("讲评建议", level=2)
|
||||
_add_bullets(document, analysis["teaching_suggestions"])
|
||||
|
||||
document.add_heading("二次练习提示词", level=2)
|
||||
document.add_paragraph(analysis["followup_prompt"])
|
||||
|
||||
buffer = BytesIO()
|
||||
document.save(buffer)
|
||||
buffer.seek(0)
|
||||
filename = _safe_filename(f"{activity.title}-学情诊断报告", ".docx")
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}/responses/export")
|
||||
def export_activity_responses(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
activity = db.query(ClassroomActivity).filter(
|
||||
ClassroomActivity.id == activity_id,
|
||||
ClassroomActivity.user_id == current_user.id,
|
||||
).first()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="课堂活动不存在")
|
||||
|
||||
analysis = _analysis_payload(activity)
|
||||
buffer = BytesIO(_build_activity_workbook(activity, analysis))
|
||||
filename = _safe_filename(f"{activity.title}-课堂数据回收", ".xls")
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.ms-excel",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public/activities/{activity_id}", response_model=ClassroomActivityOut)
|
||||
def get_public_activity(
|
||||
activity_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
activity = db.query(ClassroomActivity).filter(ClassroomActivity.id == activity_id).first()
|
||||
if not activity or activity.status not in {"active", "closed"}:
|
||||
raise HTTPException(status_code=404, detail="课堂活动不存在")
|
||||
return _public_activity_payload(activity)
|
||||
|
||||
|
||||
@router.put("/activities/{activity_id}", response_model=ClassroomActivityOut)
|
||||
def update_activity(
|
||||
activity_id: int,
|
||||
data: ClassroomActivityUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
activity = db.query(ClassroomActivity).filter(
|
||||
ClassroomActivity.id == activity_id,
|
||||
ClassroomActivity.user_id == current_user.id,
|
||||
).first()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="课堂活动不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(activity, key, value)
|
||||
db.commit()
|
||||
db.refresh(activity)
|
||||
return activity
|
||||
|
||||
|
||||
@router.post("/activities/{activity_id}/responses", response_model=dict, status_code=201)
|
||||
def submit_response(
|
||||
activity_id: int,
|
||||
data: ClassroomResponseCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
activity = db.query(ClassroomActivity).filter(ClassroomActivity.id == activity_id).first()
|
||||
if not activity or activity.status == "archived":
|
||||
raise HTTPException(status_code=404, detail="课堂活动不存在")
|
||||
if activity.status != "active":
|
||||
raise HTTPException(status_code=409, detail="课堂活动未发布或已结束")
|
||||
|
||||
responses = list(activity.responses or [])
|
||||
responses.append({
|
||||
"student_name": data.student_name,
|
||||
"answer": data.answer,
|
||||
"meta": data.meta,
|
||||
"submitted_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
activity.responses = responses
|
||||
db.commit()
|
||||
return {"success": True, "count": len(responses)}
|
||||
|
||||
|
||||
@router.delete("/activities/{activity_id}", status_code=204)
|
||||
def delete_activity(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
activity = db.query(ClassroomActivity).filter(
|
||||
ClassroomActivity.id == activity_id,
|
||||
ClassroomActivity.user_id == current_user.id,
|
||||
).first()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="课堂活动不存在")
|
||||
db.delete(activity)
|
||||
db.commit()
|
||||
@@ -0,0 +1,210 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from database import get_db
|
||||
from models.community import Post, Comment, PostFavorite
|
||||
from models.user import User
|
||||
from schemas.community import PostCreate, PostUpdate, PostOut, CommentCreate, CommentOut
|
||||
from services.auth import get_current_user, get_optional_current_user
|
||||
from routers.notification import push_notification
|
||||
|
||||
router = APIRouter(prefix="/api/community", tags=["教师社区"])
|
||||
|
||||
|
||||
def _sync_post_favorite_count(post: Post, db: Session) -> None:
|
||||
post.likes = db.query(PostFavorite).filter(PostFavorite.post_id == post.id).count()
|
||||
|
||||
|
||||
def _serialize_post(post: Post, current_user: User | None, db: Session) -> Post:
|
||||
is_favorited = False
|
||||
if current_user:
|
||||
is_favorited = db.query(PostFavorite).filter(
|
||||
PostFavorite.user_id == current_user.id,
|
||||
PostFavorite.post_id == post.id,
|
||||
).first() is not None
|
||||
setattr(post, "is_favorited", is_favorited)
|
||||
setattr(post, "author", post.author)
|
||||
return post
|
||||
|
||||
|
||||
@router.post("/posts", response_model=PostOut, status_code=201)
|
||||
def create_post(data: PostCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
post = Post(user_id=current_user.id, **data.model_dump())
|
||||
db.add(post)
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return _serialize_post(post, current_user, db)
|
||||
|
||||
|
||||
@router.get("/posts", response_model=list[PostOut])
|
||||
def list_posts(
|
||||
post_type: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Post)
|
||||
if scope in {"mine", "favorite"} and not current_user:
|
||||
raise HTTPException(status_code=401, detail="请先登录")
|
||||
if scope == "mine":
|
||||
query = query.filter(Post.user_id == current_user.id)
|
||||
elif scope == "favorite":
|
||||
query = query.join(PostFavorite, PostFavorite.post_id == Post.id).filter(
|
||||
PostFavorite.user_id == current_user.id,
|
||||
)
|
||||
if post_type:
|
||||
query = query.filter(Post.post_type == post_type)
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
Post.title.contains(keyword),
|
||||
Post.content.contains(keyword),
|
||||
))
|
||||
posts = query.order_by(Post.is_pinned.desc(), Post.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return [_serialize_post(post, current_user, db) for post in posts]
|
||||
|
||||
|
||||
@router.get("/posts/ranking", response_model=list[PostOut])
|
||||
def ranking_posts(
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
score = Post.views + Post.likes * 10 + Post.comments_count * 3
|
||||
posts = (
|
||||
db.query(Post)
|
||||
.order_by(Post.is_pinned.desc(), score.desc(), Post.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [_serialize_post(post, current_user, db) for post in posts]
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}", response_model=PostOut)
|
||||
def get_post(
|
||||
post_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||
post.views = (post.views or 0) + 1
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return _serialize_post(post, current_user, db)
|
||||
|
||||
|
||||
@router.put("/posts/{post_id}", response_model=PostOut)
|
||||
def update_post(
|
||||
post_id: int,
|
||||
data: PostUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(post, key, value)
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return _serialize_post(post, current_user, db)
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/like", response_model=dict)
|
||||
def like_post(
|
||||
post_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||
favorite = db.query(PostFavorite).filter(
|
||||
PostFavorite.user_id == current_user.id,
|
||||
PostFavorite.post_id == post_id,
|
||||
).first()
|
||||
if not favorite:
|
||||
db.add(PostFavorite(user_id=current_user.id, post_id=post_id))
|
||||
db.flush()
|
||||
push_notification(db, user_id=post.user_id, actor_id=current_user.id, ntype="like", title=f"{current_user.name} 收藏了你的帖子", content=post.title[:60], link=f"/community/{post_id}")
|
||||
_sync_post_favorite_count(post, db)
|
||||
db.commit()
|
||||
return {"success": True, "likes": post.likes, "is_favorited": True}
|
||||
|
||||
|
||||
@router.delete("/posts/{post_id}/like", response_model=dict)
|
||||
def unlike_post(
|
||||
post_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||
favorite = db.query(PostFavorite).filter(
|
||||
PostFavorite.user_id == current_user.id,
|
||||
PostFavorite.post_id == post_id,
|
||||
).first()
|
||||
if favorite:
|
||||
db.delete(favorite)
|
||||
db.flush()
|
||||
_sync_post_favorite_count(post, db)
|
||||
db.commit()
|
||||
return {"success": True, "likes": post.likes, "is_favorited": False}
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}/comments", response_model=list[CommentOut])
|
||||
def list_comments(post_id: int, skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db)):
|
||||
return db.query(Comment).filter(Comment.post_id == post_id).order_by(Comment.created_at).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/comments", response_model=CommentOut, status_code=201)
|
||||
def create_comment(
|
||||
post_id: int, data: CommentCreate,
|
||||
current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
|
||||
):
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||
comment = Comment(post_id=post_id, user_id=current_user.id, **data.model_dump())
|
||||
db.add(comment)
|
||||
post.comments_count = (post.comments_count or 0) + 1
|
||||
push_notification(db, user_id=post.user_id, actor_id=current_user.id, ntype="comment", title=f"{current_user.name} 评论了你的帖子", content=post.title[:60], link=f"/community/{post_id}")
|
||||
db.commit()
|
||||
db.refresh(comment)
|
||||
return comment
|
||||
|
||||
|
||||
@router.delete("/posts/{post_id}/comments/{comment_id}", status_code=204)
|
||||
def delete_comment(
|
||||
post_id: int,
|
||||
comment_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||
comment = db.query(Comment).filter(Comment.id == comment_id, Comment.post_id == post_id).first()
|
||||
if not comment:
|
||||
raise HTTPException(status_code=404, detail="评论不存在")
|
||||
if comment.user_id != current_user.id and post.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权删除该评论")
|
||||
db.delete(comment)
|
||||
post.comments_count = max((post.comments_count or 0) - 1, 0)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.delete("/posts/{post_id}", status_code=204)
|
||||
def delete_post(post_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||
db.delete(post)
|
||||
db.commit()
|
||||
@@ -0,0 +1,317 @@
|
||||
import html
|
||||
import re
|
||||
import secrets
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
from docx import Document
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from database import get_db
|
||||
from models.courseware import Courseware, CoursewareShare
|
||||
from models.resource import Resource
|
||||
from models.user import User
|
||||
from schemas.courseware import CoursewareCreate, CoursewareAIGenerate, CoursewareShareOut, CoursewareUpdate, CoursewareOut
|
||||
from services.credits import credits_payload, spend_credits
|
||||
from services.limiter import limiter
|
||||
from services.auth import get_current_user, get_optional_current_user
|
||||
from services.ai_service import AIService
|
||||
|
||||
router = APIRouter(prefix="/api/coursewares", tags=["课件管理"])
|
||||
ai_service = AIService()
|
||||
|
||||
|
||||
def _safe_filename(name: str, suffix: str) -> str:
|
||||
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课件"
|
||||
return f"{stem[:80]}{suffix}"
|
||||
|
||||
|
||||
def _plain_text_from_html(raw_html: str) -> str:
|
||||
text = str(raw_html or "")
|
||||
text = re.sub(r"<(script|style)[\s\S]*?</\1>", " ", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"</(p|div|section|article|li|h[1-6]|tr)>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
text = html.unescape(text)
|
||||
text = re.sub(r"[ \t\u00a0]+", " ", text)
|
||||
text = re.sub(r"\n\s+", "\n", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _courseware_or_404(
|
||||
courseware_id: int,
|
||||
current_user: User | None,
|
||||
db: Session,
|
||||
) -> Courseware:
|
||||
cw = db.query(Courseware).filter(Courseware.id == courseware_id).first()
|
||||
if not cw or cw.status == "archived":
|
||||
raise HTTPException(status_code=404, detail="课件不存在")
|
||||
if cw.status != "published" and (not current_user or cw.user_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该课件")
|
||||
return cw
|
||||
|
||||
|
||||
def _add_if_present(document: Document, label: str, value: str | None) -> None:
|
||||
if value:
|
||||
document.add_paragraph(f"{label}:{value}")
|
||||
|
||||
|
||||
def _share_payload(share: CoursewareShare, request: Request | None = None) -> dict:
|
||||
data = CoursewareShareOut.model_validate(share).model_dump()
|
||||
data["share_url"] = f"/preview/share/{share.token}"
|
||||
return data
|
||||
|
||||
|
||||
@limiter.limit("10/minute")
|
||||
@router.post("/ai-generate", response_model=dict)
|
||||
async def ai_generate_courseware(
|
||||
request: Request,
|
||||
data: CoursewareAIGenerate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = spend_credits(db, current_user, "courseware_generate", f"生成互动课件:{data.prompt[:80]}")
|
||||
db.commit()
|
||||
result = await ai_service.generate_courseware(
|
||||
prompt=data.prompt, subject=data.subject, grade=data.grade, page_count=data.page_count,
|
||||
aspect_ratio=data.aspect_ratio,
|
||||
)
|
||||
return {"success": True, "data": result, "credits": credits_payload(account, "courseware_generate")}
|
||||
|
||||
|
||||
@router.post("/", response_model=CoursewareOut, status_code=201)
|
||||
def create_courseware(data: CoursewareCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
cw = Courseware(user_id=current_user.id, **data.model_dump())
|
||||
db.add(cw)
|
||||
db.commit()
|
||||
db.refresh(cw)
|
||||
return cw
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CoursewareOut])
|
||||
def list_coursewares(
|
||||
subject: Optional[str] = None,
|
||||
grade: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Courseware).filter(Courseware.user_id == current_user.id)
|
||||
if subject:
|
||||
query = query.filter(Courseware.subject == subject)
|
||||
if grade:
|
||||
query = query.filter(Courseware.grade == grade)
|
||||
if status:
|
||||
query = query.filter(Courseware.status == status)
|
||||
else:
|
||||
query = query.filter(Courseware.status != "archived")
|
||||
return query.order_by(Courseware.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/shares/mine/list", response_model=list[CoursewareShareOut])
|
||||
def list_my_courseware_shares(
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
shares = (
|
||||
db.query(CoursewareShare)
|
||||
.filter(CoursewareShare.user_id == current_user.id, CoursewareShare.status == "active")
|
||||
.order_by(CoursewareShare.updated_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [_share_payload(share, request) for share in shares]
|
||||
|
||||
|
||||
@router.delete("/shares/mine/{share_id}", status_code=204)
|
||||
def revoke_courseware_share(
|
||||
share_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
share = db.query(CoursewareShare).filter(
|
||||
CoursewareShare.id == share_id,
|
||||
CoursewareShare.user_id == current_user.id,
|
||||
CoursewareShare.status == "active",
|
||||
).first()
|
||||
if not share:
|
||||
raise HTTPException(status_code=404, detail="分享不存在")
|
||||
share.status = "revoked"
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/shares/{token}", response_model=CoursewareShareOut)
|
||||
def get_courseware_share(
|
||||
token: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
share = db.query(CoursewareShare).filter(
|
||||
CoursewareShare.token == token,
|
||||
CoursewareShare.status == "active",
|
||||
).first()
|
||||
if not share:
|
||||
raise HTTPException(status_code=404, detail="分享不存在或已失效")
|
||||
share.views = (share.views or 0) + 1
|
||||
db.commit()
|
||||
db.refresh(share)
|
||||
return _share_payload(share, request)
|
||||
|
||||
|
||||
@router.get("/{courseware_id}", response_model=CoursewareOut)
|
||||
def get_courseware(
|
||||
courseware_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return _courseware_or_404(courseware_id, current_user, db)
|
||||
|
||||
|
||||
@router.post("/{courseware_id}/share", response_model=CoursewareShareOut, status_code=201)
|
||||
def create_courseware_share(
|
||||
courseware_id: int,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
cw = db.query(Courseware).filter(
|
||||
Courseware.id == courseware_id,
|
||||
Courseware.user_id == current_user.id,
|
||||
Courseware.status != "archived",
|
||||
).first()
|
||||
if not cw:
|
||||
raise HTTPException(status_code=404, detail="课件不存在")
|
||||
share = db.query(CoursewareShare).filter(
|
||||
CoursewareShare.courseware_id == courseware_id,
|
||||
CoursewareShare.user_id == current_user.id,
|
||||
CoursewareShare.status == "active",
|
||||
).first()
|
||||
if not share:
|
||||
share = CoursewareShare(
|
||||
courseware_id=courseware_id,
|
||||
user_id=current_user.id,
|
||||
token=secrets.token_urlsafe(18),
|
||||
)
|
||||
db.add(share)
|
||||
share.title = cw.title
|
||||
share.subject = cw.subject or ""
|
||||
share.grade = cw.grade or ""
|
||||
share.description = cw.description or ""
|
||||
share.content = cw.content or []
|
||||
share.tags = list(cw.tags or [])
|
||||
share.status = "active"
|
||||
db.commit()
|
||||
db.refresh(share)
|
||||
return _share_payload(share, request)
|
||||
|
||||
|
||||
@router.get("/{courseware_id}/export-docx")
|
||||
def export_courseware_docx(
|
||||
courseware_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
cw = _courseware_or_404(courseware_id, current_user, db)
|
||||
document = Document()
|
||||
document.add_heading(cw.title or "互动课件讲义", level=1)
|
||||
|
||||
meta_parts = [part for part in [cw.subject, cw.grade] if part]
|
||||
if meta_parts:
|
||||
document.add_paragraph(" / ".join(meta_parts))
|
||||
_add_if_present(document, "课件说明", cw.description)
|
||||
if cw.tags:
|
||||
document.add_paragraph("标签:" + "、".join(str(tag) for tag in cw.tags if str(tag).strip()))
|
||||
|
||||
pages = cw.content or []
|
||||
document.add_paragraph(f"页数:{len(pages)}")
|
||||
|
||||
for index, page in enumerate(pages, start=1):
|
||||
if not isinstance(page, dict):
|
||||
continue
|
||||
page_title = str(page.get("title") or f"第 {index} 页").strip()
|
||||
document.add_heading(f"第 {index} 页:{page_title}", level=2)
|
||||
page_type = page.get("type")
|
||||
if page_type:
|
||||
document.add_paragraph(f"页面类型:{page_type}")
|
||||
text = _plain_text_from_html(page.get("content") or "")
|
||||
document.add_paragraph(text or "该页暂无可提取文本。")
|
||||
notes = str(page.get("notes") or "").strip()
|
||||
if notes:
|
||||
document.add_paragraph(f"教师备注:{notes}")
|
||||
|
||||
buffer = BytesIO()
|
||||
document.save(buffer)
|
||||
buffer.seek(0)
|
||||
filename = _safe_filename(f"{cw.title or '课件'}-讲义", ".docx")
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{courseware_id}", response_model=CoursewareOut)
|
||||
def update_courseware(
|
||||
courseware_id: int, data: CoursewareUpdate,
|
||||
current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
|
||||
):
|
||||
cw = db.query(Courseware).filter(Courseware.id == courseware_id, Courseware.user_id == current_user.id).first()
|
||||
if not cw:
|
||||
raise HTTPException(status_code=404, detail="课件不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(cw, key, value)
|
||||
db.commit()
|
||||
db.refresh(cw)
|
||||
return cw
|
||||
|
||||
|
||||
@router.post("/{courseware_id}/remix", response_model=CoursewareOut, status_code=201)
|
||||
def remix_courseware(
|
||||
courseware_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
source = db.query(Courseware).filter(Courseware.id == courseware_id).first()
|
||||
if not source or source.status == "archived":
|
||||
raise HTTPException(status_code=404, detail="课件不存在")
|
||||
if source.status != "published" and source.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权改编该课件")
|
||||
|
||||
clone = Courseware(
|
||||
user_id=current_user.id,
|
||||
title=f"{source.title}(改编)",
|
||||
subject=source.subject or "",
|
||||
grade=source.grade or "",
|
||||
description=source.description or "",
|
||||
content=source.content or [],
|
||||
cover_image=source.cover_image or "",
|
||||
tags=list(source.tags or []),
|
||||
status="draft",
|
||||
)
|
||||
db.add(clone)
|
||||
db.commit()
|
||||
db.refresh(clone)
|
||||
return clone
|
||||
|
||||
|
||||
@router.delete("/{courseware_id}", status_code=204)
|
||||
def delete_courseware(courseware_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
cw = db.query(Courseware).filter(Courseware.id == courseware_id, Courseware.user_id == current_user.id).first()
|
||||
if not cw:
|
||||
raise HTTPException(status_code=404, detail="课件不存在")
|
||||
cw.status = "archived"
|
||||
db.query(Resource).filter(
|
||||
Resource.user_id == current_user.id,
|
||||
Resource.content_ref == f"courseware:{courseware_id}",
|
||||
Resource.status == "active",
|
||||
).update({"status": "archived"}, synchronize_session=False)
|
||||
db.commit()
|
||||
@@ -0,0 +1,83 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from models.essay import EssayGrade
|
||||
from models.user import User
|
||||
from schemas.essay import EssayGradeCreate, EssayGradeOut
|
||||
from services.auth import get_current_user
|
||||
from services.ai_service import AIService
|
||||
from services.credits import spend_credits
|
||||
|
||||
router = APIRouter(prefix="/api/essay-grades", tags=["作文批改"])
|
||||
ai_service = AIService()
|
||||
|
||||
|
||||
@router.post("/", response_model=EssayGradeOut, status_code=201)
|
||||
async def create_essay_grade(
|
||||
data: EssayGradeCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Submit an essay for AI grading; the result is persisted automatically."""
|
||||
account = spend_credits(db, current_user, "essay_grade", f"作文批改:{data.title[:40]}")
|
||||
db.commit()
|
||||
|
||||
result = await ai_service.grade_essay(
|
||||
essay_text=data.essay_text, grade_level=data.grade_level,
|
||||
essay_type=data.essay_type, total_score=data.total_score,
|
||||
)
|
||||
|
||||
item = EssayGrade(
|
||||
user_id=current_user.id,
|
||||
title=data.title,
|
||||
essay_text=data.essay_text,
|
||||
grade_level=data.grade_level,
|
||||
essay_type=data.essay_type,
|
||||
total_score=data.total_score,
|
||||
result=result,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/", response_model=list[EssayGradeOut])
|
||||
def list_essay_grades(
|
||||
keyword: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(EssayGrade).filter(EssayGrade.user_id == current_user.id)
|
||||
if keyword:
|
||||
query = query.filter(EssayGrade.title.contains(keyword))
|
||||
return query.order_by(EssayGrade.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{grade_id}", response_model=EssayGradeOut)
|
||||
def get_essay_grade(
|
||||
grade_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = db.query(EssayGrade).filter(EssayGrade.id == grade_id, EssayGrade.user_id == current_user.id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="批改记录不存在")
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{grade_id}", status_code=204)
|
||||
def delete_essay_grade(
|
||||
grade_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = db.query(EssayGrade).filter(EssayGrade.id == grade_id, EssayGrade.user_id == current_user.id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="批改记录不存在")
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -0,0 +1,198 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
from docx import Document
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.exam import Exam
|
||||
from models.resource import Resource
|
||||
from models.user import User
|
||||
from schemas.exam import ExamCreate, ExamOut, ExamUpdate
|
||||
from services.auth import get_current_user, get_optional_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/exams", tags=["智能命题"])
|
||||
|
||||
|
||||
def _safe_filename(name: str, suffix: str) -> str:
|
||||
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "试卷"
|
||||
return f"{stem[:80]}{suffix}"
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if value in (None, ""):
|
||||
return []
|
||||
return [value]
|
||||
|
||||
|
||||
def _exam_or_404(exam_id: int, current_user: User | None, db: Session) -> Exam:
|
||||
exam = db.query(Exam).filter(Exam.id == exam_id).first()
|
||||
if not exam:
|
||||
raise HTTPException(status_code=404, detail="试卷不存在")
|
||||
if (not current_user or exam.user_id != current_user.id) and not has_public_resource(db, exam_id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该试卷")
|
||||
return exam
|
||||
|
||||
|
||||
def has_public_resource(db: Session, exam_id: int) -> bool:
|
||||
return db.query(Resource).filter(
|
||||
Resource.content_ref == f"exam:{exam_id}",
|
||||
Resource.status == "active",
|
||||
Resource.is_public == 1,
|
||||
).first() is not None
|
||||
|
||||
|
||||
@router.post("/", response_model=ExamOut, status_code=201)
|
||||
def create_exam(data: ExamCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
exam = Exam(user_id=current_user.id, **data.model_dump())
|
||||
db.add(exam)
|
||||
db.commit()
|
||||
db.refresh(exam)
|
||||
return exam
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ExamOut])
|
||||
def list_exams(
|
||||
subject: str = "",
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Exam).filter(Exam.user_id == current_user.id)
|
||||
if subject:
|
||||
query = query.filter(Exam.subject == subject)
|
||||
return query.order_by(Exam.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{exam_id}", response_model=ExamOut)
|
||||
def get_exam(
|
||||
exam_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return _exam_or_404(exam_id, current_user, db)
|
||||
|
||||
|
||||
@router.get("/{exam_id}/export-docx")
|
||||
def export_exam_docx(
|
||||
exam_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
exam = _exam_or_404(exam_id, current_user, db)
|
||||
document = Document()
|
||||
document.add_heading(exam.title or "智能试卷", level=1)
|
||||
meta = [
|
||||
exam.subject,
|
||||
exam.grade,
|
||||
f"{exam.duration or 90}分钟",
|
||||
f"总分{exam.total_score or 100}分",
|
||||
f"难度:{exam.difficulty or 'medium'}",
|
||||
]
|
||||
if exam.knowledge_points:
|
||||
meta.append("知识点:" + "、".join(str(item) for item in exam.knowledge_points if str(item).strip()))
|
||||
document.add_paragraph(" / ".join(str(part) for part in meta if part))
|
||||
|
||||
for index, question in enumerate(exam.questions or [], start=1):
|
||||
if not isinstance(question, dict):
|
||||
document.add_paragraph(f"{index}. {question}")
|
||||
continue
|
||||
q_type = question.get("type") or question.get("question_type") or "题目"
|
||||
score = question.get("score")
|
||||
heading = f"{index}. [{q_type}]"
|
||||
if score:
|
||||
heading += f"({score}分)"
|
||||
document.add_paragraph(heading)
|
||||
document.add_paragraph(str(question.get("content") or question.get("question") or question.get("stem") or ""))
|
||||
options = _as_list(question.get("options"))
|
||||
for option_index, option in enumerate(options):
|
||||
prefix = chr(65 + option_index) if option_index < 26 else str(option_index + 1)
|
||||
document.add_paragraph(f"{prefix}. {option}")
|
||||
|
||||
answers = exam.answers or [question.get("answer") for question in exam.questions or [] if isinstance(question, dict)]
|
||||
if answers:
|
||||
document.add_page_break()
|
||||
document.add_heading("参考答案与解析", level=1)
|
||||
for index, question in enumerate(exam.questions or [], start=1):
|
||||
answer = answers[index - 1] if index - 1 < len(answers) else ""
|
||||
analysis = question.get("analysis") or question.get("explanation") if isinstance(question, dict) else ""
|
||||
document.add_paragraph(f"{index}. 答案:{answer}")
|
||||
if analysis:
|
||||
document.add_paragraph(f"解析:{analysis}")
|
||||
|
||||
buffer = BytesIO()
|
||||
document.save(buffer)
|
||||
buffer.seek(0)
|
||||
filename = _safe_filename(f"{exam.title or '智能试卷'}-试卷", ".docx")
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{exam_id}", response_model=ExamOut)
|
||||
def update_exam(
|
||||
exam_id: int,
|
||||
data: ExamUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
exam = db.query(Exam).filter(Exam.id == exam_id, Exam.user_id == current_user.id).first()
|
||||
if not exam:
|
||||
raise HTTPException(status_code=404, detail="试卷不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(exam, key, value)
|
||||
db.commit()
|
||||
db.refresh(exam)
|
||||
return exam
|
||||
|
||||
|
||||
@router.post("/{exam_id}/remix", response_model=ExamOut, status_code=201)
|
||||
def remix_exam(
|
||||
exam_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
source = db.query(Exam).filter(Exam.id == exam_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="试卷不存在")
|
||||
if source.user_id != current_user.id and not has_public_resource(db, exam_id):
|
||||
raise HTTPException(status_code=403, detail="无权改编该试卷")
|
||||
|
||||
clone = Exam(
|
||||
user_id=current_user.id,
|
||||
title=f"{source.title}(改编)",
|
||||
subject=source.subject or "",
|
||||
grade=source.grade or "",
|
||||
questions=list(source.questions or []),
|
||||
answers=list(source.answers or []),
|
||||
duration=source.duration or 90,
|
||||
total_score=source.total_score or 100,
|
||||
difficulty=source.difficulty or "medium",
|
||||
knowledge_points=list(source.knowledge_points or []),
|
||||
)
|
||||
db.add(clone)
|
||||
db.commit()
|
||||
db.refresh(clone)
|
||||
return clone
|
||||
|
||||
|
||||
@router.delete("/{exam_id}", status_code=204)
|
||||
def delete_exam(exam_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
exam = db.query(Exam).filter(Exam.id == exam_id, Exam.user_id == current_user.id).first()
|
||||
if not exam:
|
||||
raise HTTPException(status_code=404, detail="试卷不存在")
|
||||
db.query(Resource).filter(
|
||||
Resource.user_id == current_user.id,
|
||||
Resource.content_ref == f"exam:{exam_id}",
|
||||
Resource.status == "active",
|
||||
).update({"status": "archived"}, synchronize_session=False)
|
||||
db.delete(exam)
|
||||
db.commit()
|
||||
@@ -0,0 +1,248 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
from docx import Document
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from database import get_db
|
||||
from models.exercise import Exercise, ExerciseAttempt
|
||||
from models.resource import Resource
|
||||
from models.user import User
|
||||
from schemas.exercise import ExerciseCreate, ExerciseAIGenerate, ExerciseAttemptCreate, ExerciseAttemptOut, ExerciseOut, ExerciseUpdate
|
||||
from services.credits import credits_payload, spend_credits
|
||||
from services.limiter import limiter
|
||||
from services.auth import get_current_user, get_optional_current_user
|
||||
from services.ai_service import AIService
|
||||
|
||||
router = APIRouter(prefix="/api/exercises", tags=["互动练习"])
|
||||
ai_service = AIService()
|
||||
|
||||
|
||||
def _safe_filename(name: str, suffix: str) -> str:
|
||||
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课堂练习"
|
||||
return f"{stem[:80]}{suffix}"
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if value in (None, ""):
|
||||
return []
|
||||
return [value]
|
||||
|
||||
|
||||
def _exercise_or_404(exercise_id: int, current_user: User | None, db: Session) -> Exercise:
|
||||
ex = db.query(Exercise).filter(Exercise.id == exercise_id).first()
|
||||
if not ex:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
if (not current_user or ex.user_id != current_user.id) and not has_public_resource(db, exercise_id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该练习")
|
||||
return ex
|
||||
|
||||
|
||||
def has_public_resource(db: Session, exercise_id: int) -> bool:
|
||||
return db.query(Resource).filter(
|
||||
Resource.content_ref == f"exercise:{exercise_id}",
|
||||
Resource.status == "active",
|
||||
Resource.is_public == 1,
|
||||
).first() is not None
|
||||
|
||||
|
||||
@limiter.limit("10/minute")
|
||||
@router.post("/ai-generate", response_model=dict)
|
||||
async def ai_generate_exercise(
|
||||
request: Request,
|
||||
data: ExerciseAIGenerate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = spend_credits(db, current_user, "exercise_generate", f"生成互动练习:{data.prompt[:80]}")
|
||||
db.commit()
|
||||
result = await ai_service.generate_exercise(
|
||||
prompt=data.prompt, exercise_type=data.exercise_type,
|
||||
subject=data.subject, knowledge_points=data.knowledge_points, count=data.count,
|
||||
)
|
||||
return {"success": True, "data": result, "credits": credits_payload(account, "exercise_generate")}
|
||||
|
||||
|
||||
@router.post("/", response_model=ExerciseOut, status_code=201)
|
||||
def create_exercise(data: ExerciseCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
ex = Exercise(user_id=current_user.id, **data.model_dump())
|
||||
db.add(ex)
|
||||
db.commit()
|
||||
db.refresh(ex)
|
||||
return ex
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ExerciseOut])
|
||||
def list_exercises(
|
||||
exercise_type: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Exercise).filter(Exercise.user_id == current_user.id)
|
||||
if exercise_type:
|
||||
query = query.filter(Exercise.exercise_type == exercise_type)
|
||||
if subject:
|
||||
query = query.filter(Exercise.subject == subject)
|
||||
return query.order_by(Exercise.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{exercise_id}", response_model=ExerciseOut)
|
||||
def get_exercise(
|
||||
exercise_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return _exercise_or_404(exercise_id, current_user, db)
|
||||
|
||||
|
||||
@router.get("/{exercise_id}/export-docx")
|
||||
def export_exercise_docx(
|
||||
exercise_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ex = _exercise_or_404(exercise_id, current_user, db)
|
||||
document = Document()
|
||||
document.add_heading(ex.title or "课堂练习", level=1)
|
||||
meta_parts = [ex.subject, ex.exercise_type, f"{len(ex.questions or [])}题"]
|
||||
if ex.knowledge_points:
|
||||
meta_parts.append("知识点:" + "、".join(str(item) for item in ex.knowledge_points if str(item).strip()))
|
||||
document.add_paragraph(" / ".join(str(part) for part in meta_parts if part))
|
||||
|
||||
for index, question in enumerate(ex.questions or [], start=1):
|
||||
if not isinstance(question, dict):
|
||||
document.add_paragraph(f"{index}. {question}")
|
||||
continue
|
||||
q_type = question.get("type") or ex.exercise_type or "练习"
|
||||
difficulty = question.get("difficulty")
|
||||
heading = f"{index}. [{q_type}]"
|
||||
if difficulty:
|
||||
heading += f"({difficulty})"
|
||||
document.add_paragraph(heading)
|
||||
document.add_paragraph(str(question.get("question") or question.get("content") or question.get("stem") or ""))
|
||||
options = _as_list(question.get("options"))
|
||||
for option_index, option in enumerate(options):
|
||||
prefix = chr(65 + option_index) if option_index < 26 else str(option_index + 1)
|
||||
document.add_paragraph(f"{prefix}. {option}")
|
||||
answer = question.get("answer")
|
||||
if answer not in (None, ""):
|
||||
document.add_paragraph(f"答案:{answer}")
|
||||
explanation = question.get("explanation") or question.get("analysis")
|
||||
if explanation:
|
||||
document.add_paragraph(f"解析:{explanation}")
|
||||
|
||||
buffer = BytesIO()
|
||||
document.save(buffer)
|
||||
buffer.seek(0)
|
||||
filename = _safe_filename(f"{ex.title or '课堂练习'}-练习", ".docx")
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{exercise_id}", response_model=ExerciseOut)
|
||||
def update_exercise(
|
||||
exercise_id: int,
|
||||
data: ExerciseUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ex = db.query(Exercise).filter(Exercise.id == exercise_id, Exercise.user_id == current_user.id).first()
|
||||
if not ex:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(ex, key, value)
|
||||
db.commit()
|
||||
db.refresh(ex)
|
||||
return ex
|
||||
|
||||
|
||||
@router.post("/{exercise_id}/remix", response_model=ExerciseOut, status_code=201)
|
||||
def remix_exercise(
|
||||
exercise_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
source = db.query(Exercise).filter(Exercise.id == exercise_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
if source.user_id != current_user.id and not has_public_resource(db, exercise_id):
|
||||
raise HTTPException(status_code=403, detail="无权改编该练习")
|
||||
|
||||
clone = Exercise(
|
||||
user_id=current_user.id,
|
||||
title=f"{source.title}(改编)",
|
||||
exercise_type=source.exercise_type or "choice",
|
||||
subject=source.subject or "",
|
||||
knowledge_points=list(source.knowledge_points or []),
|
||||
questions=list(source.questions or []),
|
||||
settings=dict(source.settings or {}),
|
||||
)
|
||||
db.add(clone)
|
||||
db.commit()
|
||||
db.refresh(clone)
|
||||
return clone
|
||||
|
||||
|
||||
@router.delete("/{exercise_id}", status_code=204)
|
||||
def delete_exercise(exercise_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
ex = db.query(Exercise).filter(Exercise.id == exercise_id, Exercise.user_id == current_user.id).first()
|
||||
if not ex:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
db.query(Resource).filter(
|
||||
Resource.user_id == current_user.id,
|
||||
Resource.content_ref == f"exercise:{exercise_id}",
|
||||
Resource.status == "active",
|
||||
).update({"status": "archived"}, synchronize_session=False)
|
||||
db.delete(ex)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{exercise_id}/attempt", response_model=dict, status_code=201)
|
||||
def submit_attempt(exercise_id: int, data: ExerciseAttemptCreate, db: Session = Depends(get_db)):
|
||||
exercise = db.query(Exercise).filter(Exercise.id == exercise_id).first()
|
||||
if not exercise:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
attempt = ExerciseAttempt(
|
||||
exercise_id=exercise_id,
|
||||
student_name=data.student_name,
|
||||
answers=data.answers,
|
||||
score=data.score,
|
||||
total=data.total,
|
||||
duration=data.duration,
|
||||
)
|
||||
db.add(attempt)
|
||||
db.commit()
|
||||
db.refresh(attempt)
|
||||
return {"success": True, "id": attempt.id}
|
||||
|
||||
|
||||
@router.get("/{exercise_id}/attempts", response_model=list[ExerciseAttemptOut])
|
||||
def list_attempts(
|
||||
exercise_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
exercise = db.query(Exercise).filter(
|
||||
Exercise.id == exercise_id,
|
||||
Exercise.user_id == current_user.id,
|
||||
).first()
|
||||
if not exercise:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
return (
|
||||
db.query(ExerciseAttempt)
|
||||
.filter(ExerciseAttempt.exercise_id == exercise_id)
|
||||
.order_by(ExerciseAttempt.created_at.desc())
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
@@ -0,0 +1,245 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
from docx import Document
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from models.lesson_plan import LessonPlan
|
||||
from models.resource import Resource
|
||||
from models.user import User
|
||||
from schemas.lesson_plan import LessonPlanGenerate, LessonPlanCreate, LessonPlanOut, LessonPlanUpdate
|
||||
from services.credits import credits_payload, spend_credits
|
||||
from services.limiter import limiter
|
||||
from services.auth import get_current_user, get_optional_current_user
|
||||
from services.ai_service import AIService
|
||||
|
||||
router = APIRouter(prefix="/api/lesson-plans", tags=["教案管理"])
|
||||
ai_service = AIService()
|
||||
|
||||
|
||||
def _safe_filename(name: str, suffix: str) -> str:
|
||||
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "教案"
|
||||
return f"{stem[:80]}{suffix}"
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if value in (None, ""):
|
||||
return []
|
||||
return [value]
|
||||
|
||||
|
||||
def _add_bullets(document: Document, items: list) -> None:
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
text = ";".join(f"{key}:{value}" for key, value in item.items() if value not in (None, "", []))
|
||||
else:
|
||||
text = str(item)
|
||||
if text.strip():
|
||||
document.add_paragraph(text.strip(), style="List Bullet")
|
||||
|
||||
|
||||
def _lesson_plan_or_404(plan_id: int, current_user: User | None, db: Session) -> LessonPlan:
|
||||
lp = db.query(LessonPlan).filter(LessonPlan.id == plan_id).first()
|
||||
if not lp:
|
||||
raise HTTPException(status_code=404, detail="教案不存在")
|
||||
if (not current_user or lp.user_id != current_user.id) and not has_public_resource(db, plan_id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该教案")
|
||||
return lp
|
||||
|
||||
|
||||
def has_public_resource(db: Session, plan_id: int) -> bool:
|
||||
return db.query(Resource).filter(
|
||||
Resource.content_ref == f"lesson_plan:{plan_id}",
|
||||
Resource.status == "active",
|
||||
Resource.is_public == 1,
|
||||
).first() is not None
|
||||
|
||||
|
||||
@limiter.limit("10/minute")
|
||||
@router.post("/ai-generate", response_model=dict)
|
||||
async def ai_generate_lesson_plan(
|
||||
request: Request,
|
||||
data: LessonPlanGenerate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = spend_credits(db, current_user, "lesson_plan_generate", f"生成教案:{data.title[:80]}")
|
||||
db.commit()
|
||||
result = await ai_service.generate_lesson_plan(
|
||||
title=data.title, subject=data.subject, grade=data.grade,
|
||||
objectives=data.objectives, duration=data.duration,
|
||||
extra_requirements=data.extra_requirements,
|
||||
)
|
||||
return {"success": True, "data": result, "credits": credits_payload(account, "lesson_plan_generate")}
|
||||
|
||||
|
||||
@router.post("/", response_model=LessonPlanOut, status_code=201)
|
||||
def create_lesson_plan(data: LessonPlanCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
lp = LessonPlan(user_id=current_user.id, **data.model_dump())
|
||||
db.add(lp)
|
||||
db.commit()
|
||||
db.refresh(lp)
|
||||
return lp
|
||||
|
||||
|
||||
@router.get("/", response_model=list[LessonPlanOut])
|
||||
def list_lesson_plans(
|
||||
subject: str = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(LessonPlan).filter(LessonPlan.user_id == current_user.id)
|
||||
if subject:
|
||||
query = query.filter(LessonPlan.subject == subject)
|
||||
return query.order_by(LessonPlan.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=LessonPlanOut)
|
||||
def get_lesson_plan(
|
||||
plan_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return _lesson_plan_or_404(plan_id, current_user, db)
|
||||
|
||||
|
||||
@router.get("/{plan_id}/export-docx")
|
||||
def export_lesson_plan_docx(
|
||||
plan_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
lp = _lesson_plan_or_404(plan_id, current_user, db)
|
||||
content = lp.content or {}
|
||||
|
||||
document = Document()
|
||||
document.add_heading(lp.title or content.get("title") or "大单元教案", level=1)
|
||||
meta = " / ".join(str(part) for part in [lp.subject, lp.grade, f"{lp.duration or 45}分钟"] if part)
|
||||
if meta:
|
||||
document.add_paragraph(meta)
|
||||
|
||||
sections = [
|
||||
("教学目标", lp.objectives or content.get("objectives")),
|
||||
("教学重点", lp.key_points or content.get("key_points")),
|
||||
("教学难点", lp.difficulties or content.get("difficulties")),
|
||||
("教学材料", lp.materials or content.get("materials")),
|
||||
]
|
||||
for title, items in sections:
|
||||
items = _as_list(items)
|
||||
if items:
|
||||
document.add_heading(title, level=2)
|
||||
_add_bullets(document, items)
|
||||
|
||||
phases = _as_list(content.get("phases") or content.get("steps"))
|
||||
if phases:
|
||||
document.add_heading("教学过程", level=2)
|
||||
for index, phase in enumerate(phases, start=1):
|
||||
if not isinstance(phase, dict):
|
||||
document.add_paragraph(f"{index}. {phase}")
|
||||
continue
|
||||
name = phase.get("name") or phase.get("title") or f"教学环节 {index}"
|
||||
duration = phase.get("duration")
|
||||
heading = f"{index}. {name}"
|
||||
if duration:
|
||||
heading += f"({duration}分钟)"
|
||||
document.add_heading(heading, level=3)
|
||||
for label, key in [
|
||||
("教学活动", "activities"),
|
||||
("教师行为", "teacher_actions"),
|
||||
("学生行为", "student_actions"),
|
||||
("资源材料", "resources"),
|
||||
("评价方式", "assessment"),
|
||||
]:
|
||||
items = _as_list(phase.get(key))
|
||||
if items:
|
||||
document.add_paragraph(label)
|
||||
_add_bullets(document, items)
|
||||
|
||||
homework = _as_list(lp.homework or content.get("homework"))
|
||||
if homework:
|
||||
document.add_heading("课后作业", level=2)
|
||||
_add_bullets(document, homework)
|
||||
|
||||
reflection = content.get("reflection")
|
||||
if reflection:
|
||||
document.add_heading("教学反思", level=2)
|
||||
document.add_paragraph(str(reflection))
|
||||
|
||||
buffer = BytesIO()
|
||||
document.save(buffer)
|
||||
buffer.seek(0)
|
||||
filename = _safe_filename(f"{lp.title or '大单元教案'}-教案", ".docx")
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{plan_id}", response_model=LessonPlanOut)
|
||||
def update_lesson_plan(
|
||||
plan_id: int,
|
||||
data: LessonPlanUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
lp = db.query(LessonPlan).filter(LessonPlan.id == plan_id, LessonPlan.user_id == current_user.id).first()
|
||||
if not lp:
|
||||
raise HTTPException(status_code=404, detail="教案不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(lp, key, value)
|
||||
db.commit()
|
||||
db.refresh(lp)
|
||||
return lp
|
||||
|
||||
|
||||
@router.post("/{plan_id}/remix", response_model=LessonPlanOut, status_code=201)
|
||||
def remix_lesson_plan(
|
||||
plan_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
source = db.query(LessonPlan).filter(LessonPlan.id == plan_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="教案不存在")
|
||||
if source.user_id != current_user.id and not has_public_resource(db, plan_id):
|
||||
raise HTTPException(status_code=403, detail="无权改编该教案")
|
||||
|
||||
clone = LessonPlan(
|
||||
user_id=current_user.id,
|
||||
title=f"{source.title}(改编)",
|
||||
subject=source.subject or "",
|
||||
grade=source.grade or "",
|
||||
objectives=list(source.objectives or []),
|
||||
key_points=list(source.key_points or []),
|
||||
difficulties=list(source.difficulties or []),
|
||||
content=dict(source.content or {}),
|
||||
duration=source.duration or 45,
|
||||
materials=list(source.materials or []),
|
||||
homework=list(source.homework or []),
|
||||
)
|
||||
db.add(clone)
|
||||
db.commit()
|
||||
db.refresh(clone)
|
||||
return clone
|
||||
|
||||
|
||||
@router.delete("/{plan_id}", status_code=204)
|
||||
def delete_lesson_plan(plan_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
lp = db.query(LessonPlan).filter(LessonPlan.id == plan_id, LessonPlan.user_id == current_user.id).first()
|
||||
if not lp:
|
||||
raise HTTPException(status_code=404, detail="教案不存在")
|
||||
db.query(Resource).filter(
|
||||
Resource.user_id == current_user.id,
|
||||
Resource.content_ref == f"lesson_plan:{plan_id}",
|
||||
Resource.status == "active",
|
||||
).update({"status": "archived"}, synchronize_session=False)
|
||||
db.delete(lp)
|
||||
db.commit()
|
||||
@@ -0,0 +1,98 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.material import Material
|
||||
from models.user import User
|
||||
from schemas.material import MaterialCreate, MaterialOut, MaterialUpdate
|
||||
from services.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/materials", tags=["素材库"])
|
||||
|
||||
|
||||
@router.post("/", response_model=MaterialOut, status_code=201)
|
||||
def create_material(
|
||||
data: MaterialCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
material = Material(user_id=current_user.id, **data.model_dump())
|
||||
db.add(material)
|
||||
db.commit()
|
||||
db.refresh(material)
|
||||
return material
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MaterialOut])
|
||||
def list_materials(
|
||||
material_type: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Material).filter(Material.user_id == current_user.id, Material.status == "active")
|
||||
if material_type:
|
||||
query = query.filter(Material.material_type == material_type)
|
||||
if subject:
|
||||
query = query.filter(Material.subject == subject)
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
Material.title.contains(keyword),
|
||||
Material.filename.contains(keyword),
|
||||
Material.summary.contains(keyword),
|
||||
Material.subject.contains(keyword),
|
||||
Material.grade.contains(keyword),
|
||||
))
|
||||
return query.order_by(Material.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{material_id}", response_model=MaterialOut)
|
||||
def get_material(
|
||||
material_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
material = db.query(Material).filter(
|
||||
Material.id == material_id,
|
||||
Material.user_id == current_user.id,
|
||||
Material.status == "active",
|
||||
).first()
|
||||
if not material:
|
||||
raise HTTPException(status_code=404, detail="素材不存在")
|
||||
return material
|
||||
|
||||
|
||||
@router.put("/{material_id}", response_model=MaterialOut)
|
||||
def update_material(
|
||||
material_id: int,
|
||||
data: MaterialUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
material = db.query(Material).filter(Material.id == material_id, Material.user_id == current_user.id).first()
|
||||
if not material or material.status == "archived":
|
||||
raise HTTPException(status_code=404, detail="素材不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(material, key, value)
|
||||
db.commit()
|
||||
db.refresh(material)
|
||||
return material
|
||||
|
||||
|
||||
@router.delete("/{material_id}", status_code=204)
|
||||
def delete_material(
|
||||
material_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
material = db.query(Material).filter(Material.id == material_id, Material.user_id == current_user.id).first()
|
||||
if not material or material.status == "archived":
|
||||
raise HTTPException(status_code=404, detail="素材不存在")
|
||||
material.status = "archived"
|
||||
db.commit()
|
||||
@@ -0,0 +1,108 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from models.mindmap import MindMap
|
||||
from models.user import User
|
||||
from schemas.mindmap import (
|
||||
MindMapGenerate, MindMapCreate, MindMapUpdate, MindMapOut,
|
||||
)
|
||||
from services.credits import credits_payload, spend_credits
|
||||
from services.limiter import limiter
|
||||
from services.auth import get_current_user
|
||||
from services.ai_service import AIService
|
||||
|
||||
router = APIRouter(prefix="/api/mind-maps", tags=["思维导图管理"])
|
||||
ai_service = AIService()
|
||||
|
||||
|
||||
@limiter.limit("10/minute")
|
||||
@router.post("/ai-generate", response_model=dict)
|
||||
async def ai_generate_mind_map(
|
||||
request: Request,
|
||||
data: MindMapGenerate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = spend_credits(db, current_user, "mindmap_generate", f"生成思维导图:{data.topic[:80]}")
|
||||
db.commit()
|
||||
result = await ai_service.generate_mindmap(
|
||||
topic=data.topic, subject=data.subject, grade=data.grade,
|
||||
)
|
||||
return {"success": True, "data": result, "credits": credits_payload(account, "mindmap_generate")}
|
||||
|
||||
|
||||
@router.post("/", response_model=MindMapOut, status_code=201)
|
||||
def create_mind_map(
|
||||
data: MindMapCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = MindMap(user_id=current_user.id, **data.model_dump())
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MindMapOut])
|
||||
def list_mind_maps(
|
||||
keyword: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(MindMap).filter(MindMap.user_id == current_user.id)
|
||||
if keyword:
|
||||
query = query.filter(MindMap.title.contains(keyword))
|
||||
return query.order_by(MindMap.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{map_id}", response_model=MindMapOut)
|
||||
def get_mind_map(
|
||||
map_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = db.query(MindMap).filter(
|
||||
MindMap.id == map_id, MindMap.user_id == current_user.id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="思维导图不存在")
|
||||
return item
|
||||
|
||||
|
||||
@router.put("/{map_id}", response_model=MindMapOut)
|
||||
def update_mind_map(
|
||||
map_id: int,
|
||||
data: MindMapUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = db.query(MindMap).filter(
|
||||
MindMap.id == map_id, MindMap.user_id == current_user.id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="思维导图不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(item, key, value)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{map_id}", status_code=204)
|
||||
def delete_mind_map(
|
||||
map_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = db.query(MindMap).filter(
|
||||
MindMap.id == map_id, MindMap.user_id == current_user.id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="思维导图不存在")
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -0,0 +1,101 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from models.notification import Notification
|
||||
from models.user import User
|
||||
from schemas.notification import NotificationOut
|
||||
from services.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/notifications", tags=["通知"])
|
||||
|
||||
|
||||
def push_notification(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
actor_id: Optional[int],
|
||||
ntype: str,
|
||||
title: str,
|
||||
content: str = "",
|
||||
link: str = "",
|
||||
) -> None:
|
||||
"""创建一条通知,不通知自己。"""
|
||||
if actor_id and actor_id == user_id:
|
||||
return
|
||||
db.add(Notification(
|
||||
user_id=user_id, actor_id=actor_id, ntype=ntype,
|
||||
title=title, content=content, link=link, is_read=False,
|
||||
))
|
||||
|
||||
|
||||
@router.get("", response_model=list[NotificationOut])
|
||||
def list_notifications(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
unread_only: bool = Query(False),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Notification).filter(Notification.user_id == current_user.id)
|
||||
if unread_only:
|
||||
query = query.filter(Notification.is_read == False)
|
||||
return query.order_by(Notification.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
def unread_count(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
count = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False,
|
||||
).count()
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read", response_model=dict)
|
||||
def mark_read(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = db.query(Notification).filter(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == current_user.id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="通知不存在")
|
||||
item.is_read = True
|
||||
db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.put("/read-all", response_model=dict)
|
||||
def mark_all_read(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False,
|
||||
).update({"is_read": True}, synchronize_session=False)
|
||||
db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}", status_code=204)
|
||||
def delete_notification(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = db.query(Notification).filter(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == current_user.id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="通知不存在")
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -0,0 +1,267 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from database import get_db
|
||||
from models.resource import Resource, ResourceFavorite
|
||||
from models.user import User
|
||||
from schemas.resource import ResourceCreate, ResourceOut, ResourceUpdate
|
||||
from services.auth import get_current_user, get_optional_current_user
|
||||
from routers.notification import push_notification
|
||||
from services.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/resources", tags=["资源库"])
|
||||
|
||||
|
||||
def _sync_favorite_count(res: Resource, db: Session) -> None:
|
||||
res.likes = db.query(ResourceFavorite).filter(ResourceFavorite.resource_id == res.id).count()
|
||||
|
||||
|
||||
def _serialize_resource(res: Resource, current_user: User | None, db: Session) -> Resource:
|
||||
is_favorited = False
|
||||
if current_user:
|
||||
is_favorited = db.query(ResourceFavorite).filter(
|
||||
ResourceFavorite.user_id == current_user.id,
|
||||
ResourceFavorite.resource_id == res.id,
|
||||
).first() is not None
|
||||
setattr(res, "is_favorited", is_favorited)
|
||||
setattr(res, "author", res.author)
|
||||
return res
|
||||
|
||||
|
||||
|
||||
def _apply_sort(query, sort: str | None):
|
||||
sort = (sort or "latest").lower()
|
||||
if sort == "popular":
|
||||
return query.order_by(Resource.views.desc(), Resource.created_at.desc())
|
||||
if sort in {"downloads", "download"}:
|
||||
return query.order_by(Resource.downloads.desc(), Resource.created_at.desc())
|
||||
if sort in {"likes", "like"}:
|
||||
return query.order_by(Resource.likes.desc(), Resource.created_at.desc())
|
||||
return query.order_by(Resource.created_at.desc())
|
||||
|
||||
@router.post("/", response_model=ResourceOut, status_code=201)
|
||||
def create_resource(data: ResourceCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
res = Resource(user_id=current_user.id, **data.model_dump())
|
||||
db.add(res)
|
||||
db.commit()
|
||||
db.refresh(res)
|
||||
return _serialize_resource(res, current_user, db)
|
||||
|
||||
|
||||
@router.post("/publish", response_model=ResourceOut, status_code=201)
|
||||
def publish_resource(request: Request, data: ResourceCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
query = db.query(Resource).filter(Resource.user_id == current_user.id)
|
||||
if data.content_ref:
|
||||
query = query.filter(Resource.content_ref == data.content_ref)
|
||||
else:
|
||||
query = query.filter(Resource.title == data.title, Resource.resource_type == data.resource_type)
|
||||
res = query.first()
|
||||
is_new = res is None
|
||||
if res:
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(res, key, value)
|
||||
res.status = "active"
|
||||
else:
|
||||
res = Resource(user_id=current_user.id, status="active", **data.model_dump())
|
||||
db.add(res)
|
||||
db.commit()
|
||||
db.refresh(res)
|
||||
log_action(db, action="publish_resource", user=current_user, request=request, target_type="resource", target_id=res.id, detail=f"{'新建' if is_new else '更新'}资源《{res.title}》({res.resource_type})")
|
||||
return _serialize_resource(res, current_user, db)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ResourceOut])
|
||||
def list_resources(
|
||||
resource_type: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
sort: Optional[str] = Query("latest"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Resource).filter(Resource.status == "active")
|
||||
if resource_type:
|
||||
query = query.filter(Resource.resource_type == resource_type)
|
||||
if subject:
|
||||
query = query.filter(Resource.subject == subject)
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
Resource.title.contains(keyword),
|
||||
Resource.description.contains(keyword),
|
||||
Resource.subject.contains(keyword),
|
||||
Resource.grade.contains(keyword),
|
||||
))
|
||||
if current_user:
|
||||
query = query.filter((Resource.user_id == current_user.id) | (Resource.is_public == 1))
|
||||
else:
|
||||
query = query.filter(Resource.is_public == 1)
|
||||
resources = _apply_sort(query, sort).offset(skip).limit(limit).all()
|
||||
return [_serialize_resource(res, current_user, db) for res in resources]
|
||||
|
||||
|
||||
@router.put("/{resource_id}", response_model=ResourceOut)
|
||||
def update_resource(
|
||||
resource_id: int,
|
||||
data: ResourceUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
res = db.query(Resource).filter(Resource.id == resource_id, Resource.user_id == current_user.id).first()
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="资源不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(res, key, value)
|
||||
db.commit()
|
||||
db.refresh(res)
|
||||
return _serialize_resource(res, current_user, db)
|
||||
|
||||
|
||||
@router.get("/favorites/me", response_model=list[ResourceOut])
|
||||
def list_favorite_resources(
|
||||
resource_type: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
sort: Optional[str] = Query("latest"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = (
|
||||
db.query(Resource)
|
||||
.join(ResourceFavorite, ResourceFavorite.resource_id == Resource.id)
|
||||
.filter(ResourceFavorite.user_id == current_user.id, Resource.status == "active")
|
||||
)
|
||||
if resource_type:
|
||||
query = query.filter(Resource.resource_type == resource_type)
|
||||
if subject:
|
||||
query = query.filter(Resource.subject == subject)
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
Resource.title.contains(keyword),
|
||||
Resource.description.contains(keyword),
|
||||
Resource.subject.contains(keyword),
|
||||
Resource.grade.contains(keyword),
|
||||
))
|
||||
order = ResourceFavorite.created_at.desc() if (sort or "latest") == "latest" else None
|
||||
resources = (query.order_by(order) if order is not None else _apply_sort(query, sort)).offset(skip).limit(limit).all()
|
||||
return [_serialize_resource(res, current_user, db) for res in resources]
|
||||
|
||||
|
||||
@router.get("/mine", response_model=list[ResourceOut])
|
||||
def list_my_resources(
|
||||
resource_type: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
sort: Optional[str] = Query("latest"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Resource).filter(Resource.user_id == current_user.id, Resource.status == "active")
|
||||
if resource_type:
|
||||
query = query.filter(Resource.resource_type == resource_type)
|
||||
if subject:
|
||||
query = query.filter(Resource.subject == subject)
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
Resource.title.contains(keyword),
|
||||
Resource.description.contains(keyword),
|
||||
Resource.subject.contains(keyword),
|
||||
Resource.grade.contains(keyword),
|
||||
))
|
||||
resources = (_apply_sort(query, sort) if (sort or "latest") != "latest" else query.order_by(Resource.updated_at.desc())).offset(skip).limit(limit).all()
|
||||
return [_serialize_resource(res, current_user, db) for res in resources]
|
||||
|
||||
|
||||
@router.get("/{resource_id}", response_model=ResourceOut)
|
||||
def get_resource(
|
||||
resource_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
res = db.query(Resource).filter(Resource.id == resource_id).first()
|
||||
if not res or res.status == "archived":
|
||||
raise HTTPException(status_code=404, detail="资源不存在")
|
||||
if not res.is_public and (not current_user or res.user_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该资源")
|
||||
res.views = (res.views or 0) + 1
|
||||
db.commit()
|
||||
db.refresh(res)
|
||||
return _serialize_resource(res, current_user, db)
|
||||
|
||||
|
||||
@router.post("/{resource_id}/download", response_model=dict)
|
||||
def download_resource(
|
||||
resource_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
res = db.query(Resource).filter(Resource.id == resource_id, Resource.status == "active").first()
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="资源不存在")
|
||||
if not res.is_public and (not current_user or res.user_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该资源")
|
||||
res.downloads = (res.downloads or 0) + 1
|
||||
db.commit()
|
||||
return {"success": True, "downloads": res.downloads, "file_url": res.file_url}
|
||||
|
||||
|
||||
@router.post("/{resource_id}/like", response_model=dict)
|
||||
def like_resource(
|
||||
resource_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
res = db.query(Resource).filter(Resource.id == resource_id, Resource.status == "active").first()
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="资源不存在")
|
||||
if not res.is_public and res.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该资源")
|
||||
favorite = db.query(ResourceFavorite).filter(
|
||||
ResourceFavorite.user_id == current_user.id,
|
||||
ResourceFavorite.resource_id == resource_id,
|
||||
).first()
|
||||
if not favorite:
|
||||
db.add(ResourceFavorite(user_id=current_user.id, resource_id=resource_id))
|
||||
db.flush()
|
||||
push_notification(db, user_id=res.user_id, actor_id=current_user.id, ntype="like", title=f"{current_user.name} 收藏了你的资源", content=res.title[:60], link=f"/resources/{res.id}")
|
||||
_sync_favorite_count(res, db)
|
||||
db.commit()
|
||||
return {"success": True, "likes": res.likes, "is_favorited": True}
|
||||
|
||||
|
||||
@router.delete("/{resource_id}/like", response_model=dict)
|
||||
def unlike_resource(
|
||||
resource_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
res = db.query(Resource).filter(Resource.id == resource_id, Resource.status == "active").first()
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="资源不存在")
|
||||
if not res.is_public and res.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该资源")
|
||||
favorite = db.query(ResourceFavorite).filter(
|
||||
ResourceFavorite.user_id == current_user.id,
|
||||
ResourceFavorite.resource_id == resource_id,
|
||||
).first()
|
||||
if favorite:
|
||||
db.delete(favorite)
|
||||
db.flush()
|
||||
_sync_favorite_count(res, db)
|
||||
db.commit()
|
||||
return {"success": True, "likes": res.likes, "is_favorited": False}
|
||||
|
||||
|
||||
@router.delete("/{resource_id}", status_code=204)
|
||||
def delete_resource(resource_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
res = db.query(Resource).filter(Resource.id == resource_id, Resource.user_id == current_user.id).first()
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="资源不存在")
|
||||
res.status = "archived"
|
||||
db.commit()
|
||||
@@ -0,0 +1,301 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.animation import Animation
|
||||
from models.classroom import ClassroomActivity
|
||||
from models.community import Post
|
||||
from models.courseware import Courseware
|
||||
from models.exam import Exam
|
||||
from models.exercise import Exercise
|
||||
from models.lesson_plan import LessonPlan
|
||||
from models.material import Material
|
||||
from models.mindmap import MindMap
|
||||
from models.resource import Resource
|
||||
from models.user import User
|
||||
from schemas.search import SearchResponse
|
||||
from services.auth import get_optional_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["全局搜索"])
|
||||
|
||||
|
||||
def _has_keyword(*values, keyword: str) -> bool:
|
||||
if not keyword:
|
||||
return True
|
||||
haystack = " ".join(str(value or "") for value in values).lower()
|
||||
return keyword.lower() in haystack
|
||||
|
||||
|
||||
def _tag_list(value) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item) for item in value if str(item).strip()][:6]
|
||||
|
||||
|
||||
def _item(
|
||||
*,
|
||||
id,
|
||||
source: str,
|
||||
source_label: str,
|
||||
title: str,
|
||||
route: str,
|
||||
description: str = "",
|
||||
subject: str = "",
|
||||
grade: str = "",
|
||||
tags=None,
|
||||
action_route: str = "",
|
||||
is_public: bool = False,
|
||||
updated_at=None,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": id,
|
||||
"source": source,
|
||||
"source_label": source_label,
|
||||
"title": title or "未命名",
|
||||
"description": description or "",
|
||||
"subject": subject or "",
|
||||
"grade": grade or "",
|
||||
"tags": _tag_list(tags),
|
||||
"route": route,
|
||||
"action_route": action_route,
|
||||
"is_public": bool(is_public),
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _sort_key(item: dict):
|
||||
return item["updated_at"] or ""
|
||||
|
||||
|
||||
def _resource_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
|
||||
query = db.query(Resource).filter(Resource.status == "active")
|
||||
if current_user:
|
||||
query = query.filter((Resource.user_id == current_user.id) | (Resource.is_public == 1))
|
||||
else:
|
||||
query = query.filter(Resource.is_public == 1)
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
Resource.title.contains(keyword),
|
||||
Resource.description.contains(keyword),
|
||||
Resource.subject.contains(keyword),
|
||||
Resource.grade.contains(keyword),
|
||||
))
|
||||
return [
|
||||
_item(
|
||||
id=res.id,
|
||||
source="resource",
|
||||
source_label="资源",
|
||||
title=res.title,
|
||||
description=res.description,
|
||||
subject=res.subject,
|
||||
grade=res.grade,
|
||||
tags=res.tags,
|
||||
route=f"/resources/{res.id}",
|
||||
action_route=f"/resources/{res.id}",
|
||||
is_public=bool(res.is_public),
|
||||
updated_at=res.updated_at,
|
||||
)
|
||||
for res in query.order_by(Resource.updated_at.desc()).limit(limit).all()
|
||||
]
|
||||
|
||||
|
||||
def _community_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
|
||||
query = db.query(Post)
|
||||
if keyword:
|
||||
query = query.filter(or_(Post.title.contains(keyword), Post.content.contains(keyword)))
|
||||
return [
|
||||
_item(
|
||||
id=post.id,
|
||||
source="community",
|
||||
source_label="模板社区",
|
||||
title=post.title,
|
||||
description=post.content,
|
||||
tags=post.tags,
|
||||
route=f"/community/{post.id}",
|
||||
action_route=f"/community/{post.id}",
|
||||
is_public=True,
|
||||
updated_at=post.updated_at,
|
||||
)
|
||||
for post in query.order_by(Post.updated_at.desc()).limit(limit).all()
|
||||
]
|
||||
|
||||
|
||||
def _my_work_queries(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
|
||||
if not current_user:
|
||||
return []
|
||||
user_id = current_user.id
|
||||
items = []
|
||||
|
||||
coursewares = db.query(Courseware).filter(Courseware.user_id == user_id, Courseware.status != "archived").all()
|
||||
for work in coursewares:
|
||||
if _has_keyword(work.title, work.description, work.subject, work.grade, " ".join(_tag_list(work.tags)), keyword=keyword):
|
||||
items.append(_item(
|
||||
id=work.id,
|
||||
source="courseware",
|
||||
source_label="我的课件",
|
||||
title=work.title,
|
||||
description=work.description,
|
||||
subject=work.subject,
|
||||
grade=work.grade,
|
||||
tags=work.tags,
|
||||
route=f"/courseware/{work.id}",
|
||||
action_route=f"/courseware/{work.id}",
|
||||
updated_at=work.updated_at,
|
||||
))
|
||||
|
||||
animations = db.query(Animation).filter(Animation.user_id == user_id).all()
|
||||
for work in animations:
|
||||
if _has_keyword(work.title, work.description, work.anim_type, keyword=keyword):
|
||||
items.append(_item(
|
||||
id=work.id,
|
||||
source="animation",
|
||||
source_label="我的动画",
|
||||
title=work.title,
|
||||
description=work.description,
|
||||
subject=str(work.anim_type or ""),
|
||||
route=f"/animation?open={work.id}",
|
||||
action_route=f"/animation?open={work.id}",
|
||||
updated_at=work.updated_at,
|
||||
))
|
||||
|
||||
exercises = db.query(Exercise).filter(Exercise.user_id == user_id).all()
|
||||
for work in exercises:
|
||||
if _has_keyword(work.title, work.subject, " ".join(_tag_list(work.knowledge_points)), keyword=keyword):
|
||||
items.append(_item(
|
||||
id=work.id,
|
||||
source="exercise",
|
||||
source_label="我的练习",
|
||||
title=work.title,
|
||||
subject=work.subject,
|
||||
tags=work.knowledge_points,
|
||||
route=f"/exercise?open={work.id}",
|
||||
action_route=f"/exercise?open={work.id}",
|
||||
updated_at=work.updated_at,
|
||||
))
|
||||
|
||||
lesson_plans = db.query(LessonPlan).filter(LessonPlan.user_id == user_id).all()
|
||||
for work in lesson_plans:
|
||||
if _has_keyword(work.title, work.subject, work.grade, " ".join(_tag_list(work.objectives)), keyword=keyword):
|
||||
items.append(_item(
|
||||
id=work.id,
|
||||
source="lesson_plan",
|
||||
source_label="我的教案",
|
||||
title=work.title,
|
||||
subject=work.subject,
|
||||
grade=work.grade,
|
||||
tags=work.objectives,
|
||||
route=f"/lesson-plan?open={work.id}",
|
||||
action_route=f"/lesson-plan?open={work.id}",
|
||||
updated_at=work.updated_at,
|
||||
))
|
||||
|
||||
mind_maps = db.query(MindMap).filter(MindMap.user_id == user_id).all()
|
||||
for work in mind_maps:
|
||||
if _has_keyword(work.title, work.subject, keyword=keyword):
|
||||
items.append(_item(
|
||||
id=work.id,
|
||||
source="mindmap",
|
||||
source_label="我的导图",
|
||||
title=work.title,
|
||||
subject=work.subject,
|
||||
route=f"/mindmap?open={work.id}",
|
||||
action_route=f"/mindmap?open={work.id}",
|
||||
updated_at=work.updated_at,
|
||||
))
|
||||
|
||||
exams = db.query(Exam).filter(Exam.user_id == user_id).all()
|
||||
for work in exams:
|
||||
if _has_keyword(work.title, work.subject, work.grade, " ".join(_tag_list(work.knowledge_points)), keyword=keyword):
|
||||
items.append(_item(
|
||||
id=work.id,
|
||||
source="exam",
|
||||
source_label="我的试卷",
|
||||
title=work.title,
|
||||
subject=work.subject,
|
||||
grade=work.grade,
|
||||
tags=work.knowledge_points,
|
||||
route=f"/exam?open={work.id}",
|
||||
action_route=f"/exam?open={work.id}",
|
||||
updated_at=work.updated_at,
|
||||
))
|
||||
|
||||
items.sort(key=_sort_key, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
|
||||
def _material_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
|
||||
if not current_user:
|
||||
return []
|
||||
query = db.query(Material).filter(Material.user_id == current_user.id, Material.status == "active")
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
Material.title.contains(keyword),
|
||||
Material.filename.contains(keyword),
|
||||
Material.summary.contains(keyword),
|
||||
Material.subject.contains(keyword),
|
||||
Material.grade.contains(keyword),
|
||||
))
|
||||
return [
|
||||
_item(
|
||||
id=item.id,
|
||||
source="material",
|
||||
source_label="素材",
|
||||
title=item.title,
|
||||
description=item.summary,
|
||||
subject=item.subject,
|
||||
grade=item.grade,
|
||||
tags=item.tags,
|
||||
route="/materials",
|
||||
action_route=f"/courseware/create",
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
for item in query.order_by(Material.updated_at.desc()).limit(limit).all()
|
||||
]
|
||||
|
||||
|
||||
def _classroom_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
|
||||
if not current_user:
|
||||
return []
|
||||
activities = db.query(ClassroomActivity).filter(ClassroomActivity.user_id == current_user.id).all()
|
||||
items = []
|
||||
for activity in activities:
|
||||
config = activity.config or {}
|
||||
if _has_keyword(activity.title, activity.description, config.get("question"), activity.activity_type, keyword=keyword):
|
||||
items.append(_item(
|
||||
id=activity.id,
|
||||
source="classroom",
|
||||
source_label="课堂活动",
|
||||
title=activity.title,
|
||||
description=activity.description or config.get("question") or "",
|
||||
subject=activity.activity_type or "",
|
||||
route=f"/classroom?open={activity.id}",
|
||||
action_route=f"/classroom?open={activity.id}",
|
||||
updated_at=activity.updated_at,
|
||||
))
|
||||
items.sort(key=_sort_key, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
|
||||
@router.get("", response_model=SearchResponse)
|
||||
def global_search(
|
||||
keyword: str = Query("", max_length=100),
|
||||
limit: int = Query(6, ge=1, le=20),
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
keyword = keyword.strip()
|
||||
resources = _resource_query(keyword, current_user, db, limit)
|
||||
community = _community_query(keyword, current_user, db, limit)
|
||||
works = _my_work_queries(keyword, current_user, db, limit)
|
||||
materials = _material_query(keyword, current_user, db, limit)
|
||||
classroom = _classroom_query(keyword, current_user, db, limit)
|
||||
return {
|
||||
"keyword": keyword,
|
||||
"total": len(resources) + len(community) + len(works) + len(materials) + len(classroom),
|
||||
"resources": resources,
|
||||
"community": community,
|
||||
"works": works,
|
||||
"materials": materials,
|
||||
"classroom": classroom,
|
||||
}
|
||||
Reference in New Issue
Block a user