✨ 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 拒编
This commit is contained in:
chenxin committed 2026-09-14 10:34:52 +08:00
1 parent 9dafb8fe72
commit 7c57d27494
7 files changed
+839

No files matched your search

@@ -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 ###
+1
View File
@@ -0,0 +1 @@
"""Avatar 域:Live2D/VRM/glTF 校验与非破坏编辑。"""
+119
View File
@@ -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]}
+357
View File
@@ -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("<III", data, 0)
if magic != GLB_MAGIC:
report.errors.append("magic 不符:不是 GLB 文件")
return report
if version != 2:
report.errors.append(f"glTF 容器版本 {version},仅支持 2")
return report
if length != len(data):
report.errors.append(f"声明长度 {length} 与实际 {len(data)} 不符")
return report
# JSON chunk
if len(data) < 20:
report.errors.append("缺少 JSON chunk 头")
return report
json_len, json_type = struct.unpack_from("<II", data, 12)
if json_type != CHUNK_TYPE_JSON:
report.errors.append("首 chunk 不是 JSON 类型")
return report
json_bytes = data[20 : 20 + json_len]
if len(json_bytes) < json_len:
report.errors.append("JSON chunk 截断")
return report
try:
gltf = json.loads(json_bytes.rstrip(b"\x00 \t\r\n"))
except json.JSONDecodeError as exc:
report.errors.append(f"JSON 解析失败:{exc}")
return report
# VRM 扩展检测
extensions_used = gltf.get("extensionsUsed", [])
extensions = gltf.get("extensions", {})
if "VRMC_vrm" in extensions_used or "VRMC_vrm" in extensions:
report.format = "vrm"
report.vrm_version = "1.0"
report.vrm_meta = dict(extensions.get("VRMC_vrm", {}).get("meta", {}))
elif "VRM" in extensions_used or "VRM" in extensions:
report.format = "vrm"
report.vrm_version = "0.x"
report.vrm_meta = dict(extensions.get("VRM", {}).get("meta", {}))
# 结构与上限
ok = _check_references(report, gltf) and _limit_check(report, gltf)
report.valid = ok and not report.errors
return report
def validate_gltf_json(data: bytes) -> 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
+2
View File
@@ -3,6 +3,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.auth.routes import router as auth_router 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.characters.routes import router as characters_router
from app.community.routes import router as community_router from app.community.routes import router as community_router
from app.conversations.routes import message_router, run_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(inst_router)
app.include_router(invoke_router) app.include_router(invoke_router)
app.include_router(media_router) app.include_router(media_router)
app.include_router(avatars_router)
app.include_router(ops_router) app.include_router(ops_router)
app.include_router(admin_router) app.include_router(admin_router)
+69
View File
@@ -1333,3 +1333,72 @@ class PluginExecution(Base):
name="status_values", 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),
)
+219
View File
@@ -1916,3 +1916,222 @@ class TestSandbox:
finally: finally:
os.environ.pop("HUNJING_SANDBOX_ENABLED", None) os.environ.pop("HUNJING_SANDBOX_ENABLED", None)
get_settings.cache_clear() 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("<II", len(json_bytes), 0x4E4F534A) + json_bytes
if bin_data:
pad = (-len(bin_data)) % 4
b = bin_data + b"\x00" * pad
chunks += _struct.pack("<II", len(b), 0x004E4942) + b
total = 12 + len(chunks)
return _struct.pack("<III", 0x46546C67, 2, total) + chunks
VALID_GLTF = {
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0, "name": "root"}],
"meshes": [{"primitives": [{"material": 0}]}],
"materials": [{"name": "skin"}],
"textures": [],
}
@pytest.mark.usefixtures("client")
class TestAvatarImport:
async def _upload(self, client, headers, data: bytes, mime: str) -> 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 模型不接受编辑