✨ 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:
1 parent
9dafb8fe72
commit
7c57d27494
7 files changed
+839
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Avatar 域:Live2D/VRM/glTF 校验与非破坏编辑。"""
|
||||
@@ -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]}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
Reference in new issue
Block a user