- 迁移 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 拒编
120 lines
3.6 KiB
Python
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]}
|