M7:形仪与语音——真形锚点、候选定相、声音档案与克隆授权、ASR 接入

- 形仪域 domains/xingyi:form_anchors(稳定辨识特征/年龄表现/许可禁忌/可变范围——生成内容不可静默覆盖锚点);forms(主仪/化仪/文字形仪,candidate→adopt 定相治理:未定相不进呈现位、主仪唯一、新主仪自动退役旧主仪);voice_profiles(三来源,授权克隆必填用途与传播范围,revoke-grant 撤回立即生效,assert-usable 合成前置校验拒绝撤回/过期)
- ASR:元炁 SenseVoice /v1/audio/transcriptions 透传(Whisper 兼容 multipart);YUANQI_BASE_URL 未配置时 503 不降级不伪造——多模态关闭纪律
- 前端形仪工坊(人物详情页):真形锚点编辑(特征逐行解析)/候选登记定相废弃/声音档案建立与授权撤回;头像呈现位改走定相主仪(forms/primary);xingyi api 库 + mock + 组件 4 用例
- 退役:旧 avatar 模块(services/endpoints/avatar、avatar_dir 配置、avatar_url 字段、前端直传组件与测试)
- 验收(test_m7_xingyi 三用例):未定相不成为正式形仪(含主仪唯一与旧主仪 retired 断言);克隆授权可撤回(缺用途拒/撤回即拒/过期同理/预设不受约束);关闭全部多模态后文字相伴完整可用(ASR 503 时镜树缘契百工照常——回归)
- 四套件:后端 376、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 008 净库+±1 干净
This commit is contained in:
chenxin committed 2026-08-27 20:26:52 +08:00
1 parent 7e23d6fa84
commit b419e0afde
30 files changed
+1876 -468

No files matched your search

-131
View File
@@ -1,131 +0,0 @@
"""角色头像文件服务。
职责:
- 上传头像文件到本地文件系统
- 提供头像文件流
- 清理头像文件
"""
from __future__ import annotations
from pathlib import Path
from fastapi.responses import FileResponse
from app.core.config import settings
from app.core.errors import AppError
from app.services.characters import get_owned_character
_ALLOWED_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".webp"})
_MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def _avatar_path(character_id: str) -> Path:
"""获取角色头像文件路径(无扩展名,需遍历查找实际文件)。"""
return settings.resolved_avatar_dir / character_id
def _find_avatar_file(character_id: str) -> Path | None:
"""查找角色已有的头像文件(任一格式)。"""
base = _avatar_path(character_id)
for ext in _ALLOWED_EXTENSIONS:
f = base.with_suffix(ext)
if f.exists():
return f
return None
def _allowed(content_type: str) -> bool:
return content_type in {
"image/jpeg",
"image/png",
"image/webp",
}
async def upload_avatar(
character_id: str,
content: bytes,
content_type: str,
owner_scope: str = settings.default_owner_scope,
) -> str:
"""上传角色头像。
Args:
character_id: 角色 ID
content: 图片文件二进制内容
content_type: MIME 类型
owner_scope: 归属范围
Returns:
头像访问的相对 URL 路径
Raises:
AppError: 文件类型不允许、大小超限、角色不存在
"""
from app.db.session import AsyncSessionLocal
# 校验角色存在性
async with AsyncSessionLocal() as session:
await get_owned_character(session, character_id, owner_scope=owner_scope)
if not _allowed(content_type):
raise AppError(
"invalid_input",
f"不支持的文件类型 '{content_type}',仅支持 JPG/PNG/WebP。",
)
if len(content) > _MAX_FILE_SIZE:
raise AppError(
"invalid_input",
"头像文件过大,最大允许 5MB。",
)
# 扩展名映射
ext_map = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
ext = ext_map[content_type]
# 删除旧头像
old = _find_avatar_file(character_id)
if old:
old.unlink(missing_ok=True)
# 写入新文件
dest = _avatar_path(character_id).with_suffix(ext)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(content)
return f"/api/v1/characters/{character_id}/avatar"
async def get_avatar_response(character_id: str) -> FileResponse:
"""获取角色头像文件响应。
Args:
character_id: 角色 ID
Returns:
FileResponse 或 None(无头像时抛出 not_found)
"""
avatar_file = _find_avatar_file(character_id)
if avatar_file is None:
raise AppError("not_found", "该角色未设置头像。")
# 推测 media_type
ext = avatar_file.suffix.lower()
media_type = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}.get(ext, "application/octet-stream")
return FileResponse(str(avatar_file), media_type=media_type)
async def delete_avatar(character_id: str) -> None:
"""删除角色头像文件。"""
avatar_file = _find_avatar_file(character_id)
if avatar_file:
avatar_file.unlink(missing_ok=True)
-9
View File
@@ -31,14 +31,6 @@ from app.schemas.preferences import GenerationPreferencesResponse
from app.schemas.runtime import EventResponse, RunResponse
def _check_avatar(character_id: str) -> str | None:
"""检查角色是否有头像文件,有则返回 URL。"""
from app.services.avatar import _find_avatar_file
if _find_avatar_file(character_id):
return f"/api/v1/characters/{character_id}/avatar"
return None
def character_to_response(character: Character) -> CharacterResponse:
return CharacterResponse(
@@ -53,7 +45,6 @@ def character_to_response(character: Character) -> CharacterResponse:
owner_scope=character.owner_scope,
is_system_template=character.is_system_template,
source_template_id=character.source_template_id,
avatar_url=_check_avatar(character.id),
created_at=to_iso(character.created_at) or "",
updated_at=to_iso(character.updated_at) or "",
)