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]*?", " ", text, flags=re.IGNORECASE) text = re.sub(r"", "\n", text, flags=re.IGNORECASE) text = re.sub(r"", "\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()