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()