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

- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
This commit is contained in:
Zhang Jing Xuan
2026-07-29 16:29:10 +08:00
commit 0841b1a103
193 changed files with 44995 additions and 0 deletions
View File
+236
View File
@@ -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
]
+297
View File
@@ -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)}"},
)
+188
View File
@@ -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()
+435
View File
@@ -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": "密码重置成功"}
+184
View File
@@ -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"),
}
+513
View File
@@ -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()
+210
View File
@@ -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()
+317
View File
@@ -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()
+83
View File
@@ -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
+198
View File
@@ -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()
+248
View File
@@ -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()
)
+245
View File
@@ -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()
+98
View File
@@ -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()
+108
View File
@@ -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
+101
View File
@@ -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
+267
View File
@@ -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()
+301
View File
@@ -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,
}