Files
hunjing/backend/app/avatars/routes.py
T
chenxin 7c57d27494 ✨ feat: WP-09 Live2D/3D——模型导入校验与非破坏命令历史
- 迁移 18:avatar_model(live2d/vrm/gltf、validated/rejected、校验报告、来源许可、当前命令指针)+ avatar_command(十类命令检查约束、(avatar,sequence) 唯一、undone 标记)
- GLB/glTF 解析器(纯 Python):GLB magic/版本/chunk 结构校验、JSON 场景图引用一致性(scene→node→mesh→material 逐层索引越界检测)、VRM 0.x/1.0 扩展检测与 meta 提取
- 资源上限(恶意模型文件防线):文件 200MB、节点 10 万、网格 1 万、纹理 2 千、材质 5 千、动画 1 千——超限入库 rejected 并保留报告
- Cubism model3.json 校验:Version/FileReferences 必需、moc3 引用、纹理列表、表情/动作/HitArea 计数
- 上传 MIME 白名单扩展 model/ 与 application/json
- 命令历史:十类命令(param_map/expression/physics/motion/material/texture/bone/attachment/lighting/camera)追加(sequence 单调)、撤销(标记 undone 非破坏)、rejected 模型不接受编辑(422 前置校验)
- 测试 111 项全绿(+9):合法 GLB/VRM 检测/损坏文件/引用越界/超限/Cubism 正反例/命令历史与撤销幂等/rejected 拒编
2026-09-14 10:34:52 +08:00

120 lines
3.6 KiB
Python

"""Avatar 域路由(WP-09)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.avatars import service
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import AvatarModel, User
router = APIRouter(prefix="/v1/avatars", tags=["avatars"])
def _model_out(m: AvatarModel) -> dict[str, Any]:
return {
"id": m.id, "name": m.name, "format": m.format, "status": m.status,
"file_id": m.file_id, "validation_report": m.validation_report,
"character_id": m.character_id, "current_command_id": m.current_command_id,
"created_at": m.created_at.isoformat(),
}
def _command_out(c: Any) -> dict[str, Any]:
return {
"id": c.id, "avatar_id": c.avatar_id, "sequence": c.sequence,
"command_type": c.command_type, "payload": c.payload, "undone": c.undone,
"created_at": c.created_at.isoformat(),
}
@router.post("/imports", status_code=201)
async def import_avatar(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
model = await service.import_avatar(
db, domain.id, body.get("name", ""), body.get("format", ""),
body.get("file_id", ""), getattr(request.state, "trace_id", None),
)
return _model_out(model)
@router.get("")
async def list_avatars(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
rows = list(
(
await db.scalars(
select(AvatarModel)
.where(AvatarModel.domain_id == domain.id)
.order_by(AvatarModel.created_at.asc())
)
).all()
)
return {"data": [_model_out(m) for m in rows]}
@router.post("/{avatar_id}/commands", status_code=201)
async def append_command(
avatar_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
command = await service.append_command(
db, domain.id, avatar_id, body.get("command_type", ""),
body.get("payload", {}), getattr(request.state, "trace_id", None),
)
return _command_out(command)
@router.post("/{avatar_id}/commands/{command_id}/undoings")
async def undo_command(
avatar_id: str,
command_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
command = await service.undo_command(
db, domain.id, avatar_id, command_id, getattr(request.state, "trace_id", None)
)
return _command_out(command)
@router.get("/{avatar_id}/commands")
async def list_commands(
avatar_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
from app.models import AvatarCommand
domain = await _domain_of(db, user)
model = await db.get(AvatarModel, avatar_id)
if model is None or model.domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型不存在")
rows = list(
(
await db.scalars(
select(AvatarCommand)
.where(AvatarCommand.avatar_id == avatar_id)
.order_by(AvatarCommand.sequence.asc())
)
).all()
)
return {"data": [_command_out(c) for c in rows]}