From 7c57d27494c415111704c290e7cbf35bbc822c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Mon, 14 Sep 2026 10:34:52 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20WP-09=20Live2D/3D=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=A8=A1=E5=9E=8B=E5=AF=BC=E5=85=A5=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E4=B8=8E=E9=9D=9E=E7=A0=B4=E5=9D=8F=E5=91=BD=E4=BB=A4=E5=8E=86?= =?UTF-8?q?=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 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 拒编 --- ...66d791926f2_avatar_模型与非破坏命令历史.py | 72 ++++ backend/app/avatars/__init__.py | 1 + backend/app/avatars/routes.py | 119 ++++++ backend/app/avatars/service.py | 357 ++++++++++++++++++ backend/app/main.py | 2 + backend/app/models.py | 69 ++++ backend/tests/test_conversations.py | 219 +++++++++++ 7 files changed, 839 insertions(+) create mode 100644 backend/alembic/versions/e66d791926f2_avatar_模型与非破坏命令历史.py create mode 100644 backend/app/avatars/__init__.py create mode 100644 backend/app/avatars/routes.py create mode 100644 backend/app/avatars/service.py diff --git a/backend/alembic/versions/e66d791926f2_avatar_模型与非破坏命令历史.py b/backend/alembic/versions/e66d791926f2_avatar_模型与非破坏命令历史.py new file mode 100644 index 0000000..1b8c828 --- /dev/null +++ b/backend/alembic/versions/e66d791926f2_avatar_模型与非破坏命令历史.py @@ -0,0 +1,72 @@ +"""Avatar 模型与非破坏命令历史 + +Revision ID: e66d791926f2 +Revises: 4e679f11da46 +Create Date: 2026-09-14 10:24:23.502856 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'e66d791926f2' +down_revision: str | Sequence[str] | None = '4e679f11da46' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('avatar_model', + sa.Column('id', sa.String(), nullable=False), + sa.Column('domain_id', sa.String(), nullable=False), + sa.Column('character_id', sa.String(), nullable=True), + sa.Column('name', sa.String(), nullable=False), + sa.Column('format', sa.String(), nullable=False), + sa.Column('file_id', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('validation_report', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('source_info', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('license_terms', sa.String(), nullable=True), + sa.Column('current_command_id', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("format IN ('live2d', 'vrm', 'gltf')", name=op.f('ck_avatar_model_format_values')), + sa.CheckConstraint("status IN ('pending', 'validated', 'rejected')", name=op.f('ck_avatar_model_status_values')), + sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_avatar_model_character'), ondelete='set null'), + sa.ForeignKeyConstraint(['current_command_id'], ['avatar_command.id'], name=op.f('fk_avatar_model_avatar_command'), ondelete='set null', use_alter=True), + sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_avatar_model_personal_domain'), ondelete='cascade'), + sa.ForeignKeyConstraint(['file_id'], ['file_asset.id'], name=op.f('fk_avatar_model_file_asset'), ondelete='restrict'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_avatar_model')) + ) + op.create_index(op.f('ix_avatar_model_domain_id'), 'avatar_model', ['domain_id'], unique=False) + op.create_table('avatar_command', + sa.Column('id', sa.String(), nullable=False), + sa.Column('avatar_id', sa.String(), nullable=False), + sa.Column('sequence', sa.Integer(), nullable=False), + sa.Column('command_type', sa.String(), nullable=False), + sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('undone', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("command_type IN ('param_map', 'expression', 'physics', 'motion', 'material', 'texture', 'bone', 'attachment', 'lighting', 'camera')", name=op.f('ck_avatar_command_command_type_values')), + sa.ForeignKeyConstraint(['avatar_id'], ['avatar_model.id'], name=op.f('fk_avatar_command_avatar_model'), ondelete='cascade'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_avatar_command')) + ) + op.create_index(op.f('ix_avatar_command_avatar_id'), 'avatar_command', ['avatar_id'], unique=False) + op.create_index('uq_avatar_command_seq', 'avatar_command', ['avatar_id', 'sequence'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('uq_avatar_command_seq', table_name='avatar_command') + op.drop_index(op.f('ix_avatar_command_avatar_id'), table_name='avatar_command') + op.drop_table('avatar_command') + op.drop_index(op.f('ix_avatar_model_domain_id'), table_name='avatar_model') + op.drop_table('avatar_model') + # ### end Alembic commands ### diff --git a/backend/app/avatars/__init__.py b/backend/app/avatars/__init__.py new file mode 100644 index 0000000..db3b99c --- /dev/null +++ b/backend/app/avatars/__init__.py @@ -0,0 +1 @@ +"""Avatar 域:Live2D/VRM/glTF 校验与非破坏编辑。""" diff --git a/backend/app/avatars/routes.py b/backend/app/avatars/routes.py new file mode 100644 index 0000000..6334e5c --- /dev/null +++ b/backend/app/avatars/routes.py @@ -0,0 +1,119 @@ +"""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]} diff --git a/backend/app/avatars/service.py b/backend/app/avatars/service.py new file mode 100644 index 0000000..f4d8e6c --- /dev/null +++ b/backend/app/avatars/service.py @@ -0,0 +1,357 @@ +"""Avatar 域:Live2D/VRM/glTF 导入校验与非破坏命令历史(WP-09)。 + +校验器纯 Python 实现,输入为文件字节;全部按不可信输入处理 +(恶意模型文件防线:magic/结构/引用/资源计数硬上限)。 +""" + +import json +import struct +from dataclasses import dataclass, field +from typing import Any + +from fastapi import HTTPException, status + +# 资源硬上限(契约第 7 节:限额由配置发布但 Schema 有硬上限;此处取工程默认) +MAX_FILE_BYTES = 200 * 1024 * 1024 +MAX_NODES = 100_000 +MAX_MESHES = 10_000 +MAX_TEXTURES = 2_000 +MAX_MATERIALS = 5_000 +MAX_ANIMATIONS = 1_000 + +GLB_MAGIC = 0x46546C67 # 'glTF' +CHUNK_TYPE_JSON = 0x4E4F534A +CHUNK_TYPE_BIN = 0x004E4942 + + +@dataclass +class ValidationReport: + format: str = "" + valid: bool = False + errors: list[str] = field(default_factory=list) + stats: dict[str, int] = field(default_factory=dict) + vrm_version: str | None = None + vrm_meta: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = { + "format": self.format, "valid": self.valid, "errors": self.errors, + "stats": self.stats, + } + if self.vrm_version: + out["vrm_version"] = self.vrm_version + if self.vrm_meta: + out["vrm_meta"] = self.vrm_meta + return out + + +def _limit_check(report: ValidationReport, gltf: dict[str, Any]) -> bool: + stats = { + "nodes": len(gltf.get("nodes", [])), + "meshes": len(gltf.get("meshes", [])), + "textures": len(gltf.get("textures", [])), + "materials": len(gltf.get("materials", [])), + "animations": len(gltf.get("animations", [])), + } + report.stats = stats + limits = { + "nodes": MAX_NODES, "meshes": MAX_MESHES, "textures": MAX_TEXTURES, + "materials": MAX_MATERIALS, "animations": MAX_ANIMATIONS, + } + ok = True + for key, cap in limits.items(): + if stats[key] > cap: + report.errors.append(f"{key} 数 {stats[key]} 超过上限 {cap}") + ok = False + return ok + + +def _check_references(report: ValidationReport, gltf: dict[str, Any]) -> bool: + """场景图引用一致性:node.mesh/mesh.material/material 指向有效索引。""" + meshes = len(gltf.get("meshes", [])) + materials = len(gltf.get("materials", [])) + scenes = gltf.get("scenes", []) + if not scenes: + report.errors.append("缺少 scenes") + return False + for i, scene in enumerate(scenes): + for node_idx in scene.get("nodes", []): + if node_idx >= len(gltf.get("nodes", [])): + report.errors.append(f"scene[{i}] 引用不存在的 node {node_idx}") + return False + for i, node in enumerate(gltf.get("nodes", [])): + mesh_idx = node.get("mesh") + if mesh_idx is not None and mesh_idx >= meshes: + report.errors.append(f"node[{i}] 引用不存在的 mesh {mesh_idx}") + return False + for i, mesh in enumerate(gltf.get("meshes", [])): + for prim in mesh.get("primitives", []): + mat_idx = prim.get("material") + if mat_idx is not None and mat_idx >= materials: + report.errors.append(f"mesh[{i}] 引用不存在的 material {mat_idx}") + return False + return True + + +def validate_glb(data: bytes) -> ValidationReport: + """GLB 二进制格式校验:magic、版本、chunk 结构、JSON 场景图。""" + report = ValidationReport(format="gltf") + if len(data) > MAX_FILE_BYTES: + report.errors.append(f"文件超过 {MAX_FILE_BYTES} 字节上限") + return report + if len(data) < 12: + report.errors.append("文件不足 GLB 头部长度(12 字节)") + return report + magic, version, length = struct.unpack_from(" ValidationReport: + """纯 .gltf(JSON)校验。""" + report = ValidationReport(format="gltf") + if len(data) > MAX_FILE_BYTES: + report.errors.append(f"文件超过 {MAX_FILE_BYTES} 字节上限") + return report + try: + gltf = json.loads(data) + except json.JSONDecodeError as exc: + report.errors.append(f"JSON 解析失败:{exc}") + return report + if not isinstance(gltf, dict) or "asset" not in gltf: + report.errors.append("缺少 asset 段(glTF 2.0 必需)") + return report + ok = _check_references(report, gltf) and _limit_check(report, gltf) + report.valid = ok and not report.errors + return report + + +# Cubism model3.json 必需字段与引用文件类别 +_CUBISM_REQUIRED = {"Version", "FileReferences"} +_CUBISM_FILE_KEYS = {" moc3": "moc", ".model.json": "physics", } + + +def validate_cubism(data: bytes) -> ValidationReport: + """Cubism model3.json 校验:必需字段、 moc3 引用、组引用一致性。""" + report = ValidationReport(format="live2d") + if len(data) > MAX_FILE_BYTES: + report.errors.append(f"文件超过 {MAX_FILE_BYTES} 字节上限") + return report + try: + model = json.loads(data) + except json.JSONDecodeError as exc: + report.errors.append(f"JSON 解析失败:{exc}") + return report + if not isinstance(model, dict): + report.errors.append("根不是对象") + return report + for key in _CUBISM_REQUIRED: + if key not in model: + report.errors.append(f"缺少必需字段 {key}") + refs = model.get("FileReferences", {}) + if not isinstance(refs, dict): + report.errors.append("FileReferences 不是对象") + refs = {} + moc = refs.get("moc") + if not moc or not str(moc).endswith(".moc3"): + report.errors.append("缺少 moc3 模型文件引用") + textures = refs.get("Textures", []) + if not isinstance(textures, list) or not textures: + report.errors.append("缺少纹理引用列表") + # 参数区间引用(Expressions/Physics/Motions 仅做存在性结构检查) + stats = { + "textures": len(textures) if isinstance(textures, list) else 0, + "expressions": len(refs.get("Expressions", []) or []), + "motions": sum( + len(v) if isinstance(v, list) else 0 + for v in (refs.get("Motions", {}) or {}).values() + ), + "hit_areas": len(model.get("HitAreas", []) or []), + "parameters": len(model.get("Parameters", []) or []), + } + report.stats = stats + if stats["textures"] > MAX_TEXTURES: + report.errors.append(f"纹理数超过上限 {MAX_TEXTURES}") + report.valid = not report.errors + return report + + +def detect_and_validate(data: bytes, declared_format: str) -> ValidationReport: + """格式探测 + 校验入口:magic 优先,声明辅助;二进制非 GLB 一律 magic 拒绝。""" + if data[:4] == b"glTF": + report = validate_glb(data) + elif declared_format == "live2d": + report = validate_cubism(data) + elif data[:1] not in (b"{", b"["): + # 声明 gltf 但内容是二进制且无 GLB magic:magic 不符 + report = ValidationReport(format="gltf") + report.errors.append("magic 不符:不是 GLB/glTF 文件") + else: + report = validate_gltf_json(data) + if report.format != declared_format and declared_format != "": + report.errors.append( + f"声明格式 {declared_format} 与检测结果 {report.format} 不符" + ) + report.valid = False + return report + + +async def import_avatar( + db: Any, domain_id: str, name: str, declared_format: str, file_id: str, + trace_id: str | None, +) -> Any: + """导入 Avatar 模型:文件须 ready;校验失败入库 rejected(保留报告供排查)。""" + from app.media.service import require_file + from app.models import AuditEvent, AvatarModel + + asset = await require_file(db, domain_id, file_id) + if asset.status != "ready": + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "文件未就绪") + if declared_format not in {"live2d", "vrm", "gltf"}: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "format 取值不合法") + from pathlib import Path + + from app.media.service import STORAGE_ROOT + + path = Path(STORAGE_ROOT) / asset.storage_key + if not path.exists(): + raise HTTPException(status.HTTP_404_NOT_FOUND, "存储对象缺失") + data = path.read_bytes() + report = detect_and_validate(data, declared_format) + model = AvatarModel( + domain_id=domain_id, + name=name if name.strip() else asset.filename, + format=declared_format, + file_id=file_id, + status="validated" if report.valid else "rejected", + validation_report=report.to_dict(), + ) + db.add(model) + await db.flush() + db.add( + AuditEvent( + actor_type="user", actor_id=domain_id, action="avatar.imported", + aggregate_type="avatar_model", aggregate_id=model.id, + payload={"valid": report.valid, "errors": report.errors[:10]}, + trace_id=trace_id, + ) + ) + await db.flush() + return model + + +async def append_command( + db: Any, domain_id: str, avatar_id: str, command_type: str, payload: dict[str, Any], + trace_id: str | None, +) -> Any: + """追加非破坏编辑命令:sequence 单调递增;rejected 模型不接受编辑。""" + from sqlalchemy import select + + from app.models import AuditEvent, AvatarCommand, AvatarModel + + model = await db.get(AvatarModel, avatar_id) + if model is None or model.domain_id != domain_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "模型不存在") + if model.status != "validated": + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅已验证模型可编辑") + valid_types = { + "param_map", "expression", "physics", "motion", "material", "texture", + "bone", "attachment", "lighting", "camera", + } + if command_type not in valid_types: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + f"command_type 取值不合法:{command_type}", + ) + last = ( + await db.execute( + select(AvatarCommand.sequence) + .where(AvatarCommand.avatar_id == avatar_id) + .order_by(AvatarCommand.sequence.desc()) + .limit(1) + ) + ).scalar_one_or_none() + command = AvatarCommand( + avatar_id=avatar_id, + sequence=(last + 1) if last is not None else 1, + command_type=command_type, + payload=payload, + ) + db.add(command) + await db.flush() + model.current_command_id = command.id + db.add( + AuditEvent( + actor_type="user", actor_id=domain_id, action="avatar.command_appended", + aggregate_type="avatar_model", aggregate_id=avatar_id, + payload={"sequence": command.sequence, "type": command_type}, + trace_id=trace_id, + ) + ) + await db.flush() + return command + + +async def undo_command( + db: Any, domain_id: str, avatar_id: str, command_id: str, trace_id: str | None, +) -> Any: + """撤销最近命令(标记 undone,不物理删除——非破坏历史)。""" + from app.models import AuditEvent, AvatarCommand, AvatarModel + + model = await db.get(AvatarModel, avatar_id) + if model is None or model.domain_id != domain_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "模型不存在") + command = await db.get(AvatarCommand, command_id) + if command is None or command.avatar_id != avatar_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "命令不存在") + if command.undone: + return command # 幂等 + command.undone = True + db.add( + AuditEvent( + actor_type="user", actor_id=domain_id, action="avatar.command_undone", + aggregate_type="avatar_model", aggregate_id=avatar_id, + payload={"sequence": command.sequence}, trace_id=trace_id, + ) + ) + await db.flush() + return command diff --git a/backend/app/main.py b/backend/app/main.py index c3ee7b9..e9d74f5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from app.auth.routes import router as auth_router +from app.avatars.routes import router as avatars_router from app.characters.routes import router as characters_router from app.community.routes import router as community_router from app.conversations.routes import message_router, run_router @@ -54,6 +55,7 @@ def create_app() -> FastAPI: app.include_router(inst_router) app.include_router(invoke_router) app.include_router(media_router) + app.include_router(avatars_router) app.include_router(ops_router) app.include_router(admin_router) diff --git a/backend/app/models.py b/backend/app/models.py index 1e3de03..3ca9c44 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1333,3 +1333,72 @@ class PluginExecution(Base): name="status_values", ), ) + + +class AvatarModel(Base): + """Avatar 模型资产(WP-09):live2d/vrm/gltf;校验结果与来源许可。""" + + __tablename__ = "avatar_model" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: new_id("avatar_") + ) + domain_id: Mapped[str] = mapped_column( + ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True + ) + character_id: Mapped[str | None] = mapped_column( + ForeignKey("character.id", ondelete="set null"), nullable=True + ) + name: Mapped[str] = mapped_column(String, nullable=False) + format: Mapped[str] = mapped_column(String, nullable=False) + file_id: Mapped[str] = mapped_column( + ForeignKey("file_asset.id", ondelete="restrict"), nullable=False + ) + status: Mapped[str] = mapped_column(String, nullable=False, default="pending") + validation_report: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, default=dict + ) + source_info: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + license_terms: Mapped[str | None] = mapped_column(String, nullable=True) + current_command_id: Mapped[str | None] = mapped_column( + ForeignKey("avatar_command.id", ondelete="set null", use_alter=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint("format IN ('live2d', 'vrm', 'gltf')", name="format_values"), + CheckConstraint( + "status IN ('pending', 'validated', 'rejected')", name="status_values" + ), + ) + + +class AvatarCommand(Base): + """非破坏编辑命令历史(WP-09/ADR-006):命令与版本可扩展到 2.0 LLM 改模。""" + + __tablename__ = "avatar_command" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: new_id("command_") + ) + avatar_id: Mapped[str] = mapped_column( + ForeignKey("avatar_model.id", ondelete="cascade"), nullable=False, index=True + ) + sequence: Mapped[int] = mapped_column(nullable=False) + command_type: Mapped[str] = mapped_column(String, nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + undone: Mapped[bool] = mapped_column(nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint( + "command_type IN ('param_map', 'expression', 'physics', 'motion', " + "'material', 'texture', 'bone', 'attachment', 'lighting', 'camera')", + name="command_type_values", + ), + Index("uq_avatar_command_seq", "avatar_id", "sequence", unique=True), + ) diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py index d924f57..d13fd79 100644 --- a/backend/tests/test_conversations.py +++ b/backend/tests/test_conversations.py @@ -1916,3 +1916,222 @@ class TestSandbox: finally: os.environ.pop("HUNJING_SANDBOX_ENABLED", None) get_settings.cache_clear() + + +def _make_glb(gltf_dict, bin_data: bytes = b"") -> bytes: + import json as _json + import struct as _struct + + json_bytes = _json.dumps(gltf_dict).encode() + b" " + while len(json_bytes) % 4: + json_bytes += b" " + chunks = _struct.pack(" str: + up = await client.post( + "/v1/uploads", json={"filename": "m.bin", "media_type": mime}, headers=headers + ) + assert up.status_code == 201, up.text + await client.request( + "PUT", f"/v1/uploads/{up.json()['id']}/data", content=data, headers=headers + ) + done = await client.post(f"/v1/uploads/{up.json()['id']}/completions", headers=headers) + return done.json()["id"] + + async def test_valid_gltf_glb_import(self, client) -> None: + headers = await login(client, "av1@example.com") + fid = await self._upload(client, headers, _make_glb(VALID_GLTF), "model/gltf-binary") + resp = await client.post( + "/v1/avatars/imports", + json={"name": "测试模型", "format": "gltf", "file_id": fid}, headers=headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["status"] == "validated" + assert body["validation_report"]["stats"]["nodes"] == 1 + + async def test_vrm_detection(self, client) -> None: + headers = await login(client, "av2@example.com") + vrm = {**VALID_GLTF, "extensionsUsed": ["VRMC_vrm"], + "extensions": {"VRMC_vrm": {"meta": {"title": "测试 VRM"}}}} + fid = await self._upload(client, headers, _make_glb(vrm), "model/gltf-binary") + resp = await client.post( + "/v1/avatars/imports", + json={"format": "vrm", "file_id": fid}, headers=headers, + ) + assert resp.json()["status"] == "validated" + report = resp.json()["validation_report"] + assert report["vrm_version"] == "1.0" + assert report["vrm_meta"]["title"] == "测试 VRM" + + async def test_corrupt_glb_rejected_with_report(self, client) -> None: + headers = await login(client, "av3@example.com") + fid = await self._upload(client, headers, b"\x00" * 64, "model/gltf-binary") + resp = await client.post( + "/v1/avatars/imports", + json={"format": "gltf", "file_id": fid}, headers=headers, + ) + assert resp.json()["status"] == "rejected" + assert any("magic" in e for e in resp.json()["validation_report"]["errors"]) + + async def test_broken_scene_reference_rejected(self, client) -> None: + headers = await login(client, "av4@example.com") + bad = {"asset": {"version": "2.0"}, "scenes": [{"nodes": [99]}], "nodes": []} + fid = await self._upload(client, headers, _make_glb(bad), "model/gltf-binary") + resp = await client.post( + "/v1/avatars/imports", + json={"format": "gltf", "file_id": fid}, headers=headers, + ) + assert resp.json()["status"] == "rejected" + assert any("node" in e for e in resp.json()["validation_report"]["errors"]) + + async def test_resource_limit_rejection(self, client) -> None: + headers = await login(client, "av5@example.com") + huge = { + "asset": {"version": "2.0"}, + "scenes": [{"nodes": []}], + "nodes": [{"name": f"n{i}"} for i in range(100_001)], + } + fid = await self._upload(client, headers, _make_glb(huge), "model/gltf-binary") + resp = await client.post( + "/v1/avatars/imports", + json={"format": "gltf", "file_id": fid}, headers=headers, + ) + assert resp.json()["status"] == "rejected" + assert any("上限" in e for e in resp.json()["validation_report"]["errors"]) + + async def test_cubism_model3_import(self, client) -> None: + import json as _json + + headers = await login(client, "av6@example.com") + cubism = { + "Version": 3, + "FileReferences": { + "moc": "model.moc3", + "Textures": ["tex0.png", "tex1.png"], + "Physics": "physics.json", + }, + "HitAreas": [{"Id": "HitAreaHead", "Name": "头"}], + } + fid = await self._upload( + client, headers, _json.dumps(cubism).encode(), "application/json" + ) + resp = await client.post( + "/v1/avatars/imports", + json={"format": "live2d", "file_id": fid}, headers=headers, + ) + assert resp.json()["status"] == "validated" + stats = resp.json()["validation_report"]["stats"] + assert stats["textures"] == 2 and stats["hit_areas"] == 1 + + async def test_cubism_missing_moc_rejected(self, client) -> None: + import json as _json + + headers = await login(client, "av7@example.com") + fid = await self._upload( + client, headers, _json.dumps({"Version": 3, "FileReferences": {}}).encode(), + "application/json", + ) + resp = await client.post( + "/v1/avatars/imports", + json={"format": "live2d", "file_id": fid}, headers=headers, + ) + assert resp.json()["status"] == "rejected" + assert any("moc3" in e for e in resp.json()["validation_report"]["errors"]) + + +@pytest.mark.usefixtures("client") +class TestAvatarCommands: + async def _import_valid(self, client, headers) -> str: + up = await client.post( + "/v1/uploads", + json={"filename": "m.glb", "media_type": "model/gltf-binary"}, headers=headers + ) + await client.request( + "PUT", f"/v1/uploads/{up.json()['id']}/data", + content=_make_glb(VALID_GLTF), headers=headers, + ) + done = await client.post( + f"/v1/uploads/{up.json()['id']}/completions", headers=headers + ) + resp = await client.post( + "/v1/avatars/imports", + json={"format": "gltf", "file_id": done.json()["id"]}, headers=headers, + ) + return resp.json()["id"] + + async def test_command_history_and_undo(self, client) -> None: + headers = await login(client, "av8@example.com") + aid = await self._import_valid(client, headers) + c1 = await client.post( + f"/v1/avatars/{aid}/commands", + json={"command_type": "param_map", + "payload": {"ParamAngleX": {"min": -30, "max": 30}}}, headers=headers, + ) + assert c1.json()["sequence"] == 1 + c2 = await client.post( + f"/v1/avatars/{aid}/commands", + json={"command_type": "expression", + "payload": {"name": "smile", "params": {}}}, headers=headers, + ) + assert c2.json()["sequence"] == 2 + # 非法命令类型 + bad = await client.post( + f"/v1/avatars/{aid}/commands", + json={"command_type": "delete_file", "payload": {}}, headers=headers, + ) + assert bad.status_code == 422 + # 撤销:幂等 + 历史保留 + undo = await client.post( + f"/v1/avatars/{aid}/commands/{c2.json()['id']}/undoings", headers=headers + ) + assert undo.json()["undone"] is True + again = await client.post( + f"/v1/avatars/{aid}/commands/{c2.json()['id']}/undoings", headers=headers + ) + assert again.json()["undone"] is True # 幂等 + history = await client.get(f"/v1/avatars/{aid}/commands", headers=headers) + seqs = [c["sequence"] for c in history.json()["data"]] + assert seqs == [1, 2] # 非破坏:撤销不删除 + + async def test_rejected_model_not_editable(self, client) -> None: + headers = await login(client, "av9@example.com") + up = await client.post( + "/v1/uploads", + json={"filename": "bad.glb", "media_type": "model/gltf-binary"}, headers=headers + ) + await client.request( + "PUT", f"/v1/uploads/{up.json()['id']}/data", + content=b"garbage", headers=headers, + ) + done = await client.post( + f"/v1/uploads/{up.json()['id']}/completions", headers=headers + ) + model = await client.post( + "/v1/avatars/imports", + json={"format": "gltf", "file_id": done.json()["id"]}, headers=headers, + ) + cmd = await client.post( + f"/v1/avatars/{model.json()['id']}/commands", + json={"command_type": "param_map", "payload": {}}, headers=headers, + ) + assert cmd.status_code == 422 # rejected 模型不接受编辑