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"""
{title}
{body}
"""
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()