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

+2 -2
View File
@@ -5,7 +5,6 @@ from fastapi import APIRouter
from app.api.v1.endpoints import (
admin,
auth,
avatar,
character_import,
characters,
@@ -21,6 +20,7 @@ from app.api.v1.endpoints import (
baigong,
hetu,
soul,
xingyi,
yuanqi,
mirror_trees,
mcp,
@@ -44,6 +44,7 @@ api_router.include_router(kernel_runs.router)
api_router.include_router(me.router)
api_router.include_router(hetu.world_router)
api_router.include_router(soul.soul_router)
api_router.include_router(xingyi.form_router)
api_router.include_router(yuanqi.cov_router)
for _baigong_r in baigong.routers:
api_router.include_router(_baigong_r)
@@ -62,4 +63,3 @@ api_router.include_router(imports.router)
api_router.include_router(events.router)
api_router.include_router(goals.router)
api_router.include_router(admin.router)
api_router.include_router(avatar.router)
-47
View File
@@ -1,47 +0,0 @@
"""角色头像 API。
上传与获取角色头像图片文件。
"""
from fastapi import APIRouter, Depends, File, UploadFile
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.services.avatar import (
get_avatar_response,
upload_avatar,
)
router = APIRouter(prefix="/characters", tags=["characters"])
@router.post("/{character_id}/avatar")
async def upload_character_avatar(
character_id: str,
file: UploadFile = File(..., description="头像图片,支持 JPG/PNG/WebP,最大 5MB。"),
current_user: CurrentUser = Depends(get_current_user),
) -> dict:
"""上传角色头像。
支持的格式:JPEG、PNG、WebP。最大 5MB。
新头像会替换旧头像(同角色只保留一张)。
"""
content = await file.read()
avatar_url = await upload_avatar(
character_id=character_id,
content=content,
content_type=file.content_type or "image/jpeg",
owner_scope=current_user.owner_scope,
)
return {"avatar_url": avatar_url}
@router.get("/{character_id}/avatar")
async def get_character_avatar(
character_id: str,
) -> object:
"""获取角色头像图片。
返回原始图片文件。无头像时返回 404。
"""
return await get_avatar_response(character_id)
+5
View File
@@ -0,0 +1,5 @@
"""形仪路由(域实现见 app/domains/xingyi/api.py)。"""
from app.domains.xingyi.api import form_router
__all__ = ["form_router"]
-1
View File
@@ -30,7 +30,6 @@ class AppConfig(BaseSettings):
)
# ==================== 文件存储 ====================
avatar_dir: str = "../db/avatars" # 角色头像文件存储目录(相对 backend)
# ==================== 默认管理员用户 ====================
default_admin_username: str = "admin" # 种子默认管理员用户名
@@ -81,6 +81,9 @@ class RuntimeConfig(BaseSettings):
chroma_collection: str = "hunjing_memories" # ChromaDB collection 名
chroma_persist_directory: str = "../db/chroma" # ChromaDB 持久化目录(相对 backend)
embedding_model_hint: str = "bge-m3" # embedding 模型逻辑名
# 元炁本地推理引擎(ASR 语音转写;未配置=多模态关闭,文字链路不受影响)
yuanqi_base_url: str = ""
yuanqi_asr_model: str = "iic/SenseVoiceSmall"
# ==================== 自动记忆候选 [PLANNED] ====================
enable_memory_candidates: bool = False # 是否启用自动记忆候选抽取
-7
View File
@@ -115,13 +115,6 @@ class Settings(
return example
return configured
@cached_property
def resolved_avatar_dir(self) -> Path:
"""解析角色头像存储目录为绝对路径。"""
path = self.resolve_from_backend(self.avatar_dir)
path.mkdir(parents=True, exist_ok=True)
return path
# 全局配置实例
settings = Settings()
+5
View File
@@ -0,0 +1,5 @@
"""形仪域:真形锚点、主仪/化仪候选定相、声音档案与克隆授权(rewrite/1.0 M7)。"""
from app.domains.xingyi import asr, models, service # noqa: F401
__all__ = ["asr", "models", "service"]
+157
View File
@@ -0,0 +1,157 @@
"""形仪 API:真形锚点 / 形仪候选定相 / 声音档案与克隆授权 / ASR 转写。"""
from datetime import datetime
from fastapi import APIRouter, Depends, File, UploadFile, status
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.xingyi import service
from app.schemas.auth import CurrentUser
form_router = APIRouter(prefix="/characters/{character_id}", tags=["xingyi"])
class AnchorUpsertRequest(BaseModel):
features: dict = Field(default_factory=dict, description="稳定辨识特征")
age_presentation: str = "adult"
permissions: dict = Field(default_factory=dict)
mutable_scope: dict = Field(default_factory=dict)
class FormCreateRequest(BaseModel):
kind: str = "costume"
source: str = "upload"
description: str = Field(min_length=1, max_length=4000)
media_ref: str | None = None
live2d: dict | None = None
class VoiceCreateRequest(BaseModel):
source: str
name: str = Field(min_length=1, max_length=120)
params: dict = Field(default_factory=dict)
grant_purpose: str | None = None
grant_expires_at: datetime | None = None
grant_distribution: str | None = None
# ── 真形锚点 ──
@form_router.get("/form-anchor")
async def get_anchor(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.get_anchor(character_id, user.owner_scope)
@form_router.put("/form-anchor")
async def upsert_anchor(
character_id: str, payload: AnchorUpsertRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.upsert_anchor(
character_id, user.owner_scope,
features=payload.features, age_presentation=payload.age_presentation,
permissions=payload.permissions, mutable_scope=payload.mutable_scope,
)
# ── 形仪候选与定相 ──
@form_router.get("/forms", status_code=status.HTTP_200_OK)
async def list_forms_api(
character_id: str, user: CurrentUser = Depends(get_current_user), status_filter: str | None = None
) -> list[dict]:
return await service.list_forms(character_id, user.owner_scope, status=status_filter)
@form_router.post("/forms", status_code=status.HTTP_201_CREATED)
async def create_form_api(
character_id: str, payload: FormCreateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.create_form(
character_id, user.owner_scope,
kind=payload.kind, source=payload.source, description=payload.description,
media_ref=payload.media_ref, live2d=payload.live2d,
)
@form_router.get("/forms/primary")
async def current_primary_api(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict | None:
return await service.current_primary(character_id, user.owner_scope)
@form_router.post("/forms/{form_id}/adopt")
async def adopt_form_api(
character_id: str, form_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.adopt_form(form_id, user.owner_scope)
@form_router.post("/forms/{form_id}/discard")
async def discard_form_api(
character_id: str, form_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.discard_form(form_id, user.owner_scope)
# ── 声音档案 ──
@form_router.get("/voices")
async def list_voices_api(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await service.list_voices(character_id, user.owner_scope)
@form_router.post("/voices", status_code=status.HTTP_201_CREATED)
async def create_voice_api(
character_id: str, payload: VoiceCreateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.create_voice(
character_id, user.owner_scope,
source=payload.source, name=payload.name, params=payload.params,
grant_purpose=payload.grant_purpose, grant_expires_at=payload.grant_expires_at,
grant_distribution=payload.grant_distribution,
)
@form_router.post("/voices/{voice_id}/revoke-grant")
async def revoke_voice_grant_api(
character_id: str, voice_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.revoke_voice_grant(voice_id, user.owner_scope)
@form_router.post("/voices/{voice_id}/assert-usable")
async def assert_voice_usable_api(
character_id: str, voice_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""合成前置校验(授权克隆:未撤回且未过期才可用)。"""
return await service.assert_voice_usable(voice_id, user.owner_scope)
# ── ASR 转写(元炁 /v1/audio/transcriptions 透传) ──
@form_router.post("/voice/transcriptions")
async def transcribe_audio_api(
character_id: str,
user: CurrentUser = Depends(get_current_user),
audio: UploadFile = File(...),
) -> dict:
"""语音转文本(元炁 SenseVoice)。转写文本由前端作为镜树问节内容提交。
多模态关闭(YUANQI 未启用)时本端点 503——文字相伴链路不受影响(回归由
测试钉死:关闭全部多模态后镜树/缘契照常)。
"""
from app.domains.xingyi.asr import transcribe
await service._owned(character_id, user.owner_scope)
content = await audio.read()
return await transcribe(content, audio.filename or "audio.webm")
+43
View File
@@ -0,0 +1,43 @@
"""ASR 转写客户端:灵台 → 元炁 ``/v1/audio/transcriptions``(Whisper 兼容 multipart)。
多模态关闭纪律:YUANQI_BASE_URL 未配置(或调用失败)时 503,不降级、不伪造转写;
文字相伴链路(镜树/缘契/百工)不依赖本模块——关闭全部多模态后完整可用。
"""
from __future__ import annotations
import httpx
from app.core.config import settings
from app.core.errors import AppError
_TIMEOUT_SECONDS = 120.0 # 本地推理首包较慢,给足窗口
async def transcribe(audio_bytes: bytes, filename: str) -> dict:
base_url = settings.yuanqi_base_url
if not base_url:
raise AppError(
"service_unavailable",
"语音转写未启用(YUANQI_BASE_URL 未配置,多模态已关闭)。",
status_code=503,
)
async with httpx.AsyncClient(trust_env=False, timeout=_TIMEOUT_SECONDS) as client:
response = await client.post(
f"{base_url.rstrip('/')}/v1/audio/transcriptions",
files={"file": (filename, audio_bytes)},
data={"model": settings.yuanqi_asr_model},
)
if response.status_code >= 400:
raise AppError(
"service_unavailable",
f"元炁转写失败(HTTP {response.status_code}):{response.text[:200]}",
status_code=502,
)
data = response.json()
text = data.get("text") or ""
return {
"text": text,
"engine": "yuanqi.sensevoice",
"original": text, # ASR 原转写(与正影文本并存的语义由镜树承载)
}
+88
View File
@@ -0,0 +1,88 @@
"""形仪域表模型:真形锚点 / 主仪化仪(候选定相)/ 声音档案。
设计依据 core-domains.md §5 与 PRD §8:
- 真形锚点记录稳定辨识特征、年龄表现、许可、禁忌与可变范围;
- 生成内容先为 candidate,用户定相(adopt)后才成为正式主仪或化仪;
- 声音三来源(合成预设/原创设计/授权克隆);克隆授权按身份、用途、期限与
传播范围治理,可撤回,撤回立即生效。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.core.time import utc_now
from app.db.base import Base
class FormAnchor(Base):
"""真形锚点:不可被生成内容静默覆盖的稳定辨识约束。"""
__tablename__ = "form_anchors"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("anchor"))
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
# 稳定辨识特征(发色/瞳色/身形/标志性印记等)
features_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 年龄表现约束(成人角色恒定成人表现;不得生成未成年敏感形态)
age_presentation: Mapped[str] = mapped_column(String(40), nullable=False, default="adult")
# 许可与禁忌(允许的画风/场景;禁止的表现)
permissions_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 可变范围(服装/表情/姿态可变;核心特征不可变)
mutable_scope_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class Form(Base):
"""主仪/化仪:一魂多仪。candidate → adopted(定相)/ discarded;同一时刻仅一主仪。"""
__tablename__ = "forms"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("form"))
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
# primary 主仪 / costume 化仪 / text 文字形仪(启镜最小形态)
kind: Mapped[str] = mapped_column(String(20), nullable=False, default="costume", index=True)
# candidate(候选,未定相不转正)/ adopted / discarded / retired
status: Mapped[str] = mapped_column(String(20), nullable=False, default="candidate", index=True)
# 来源:image_gen(生成)/ upload(上传)/ live2d(模型导入)/ text
source: Mapped[str] = mapped_column(String(20), nullable=False, default="upload")
# 文字描述(所有形态必填——文字形仪或图像的文字说明)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 媒体引用(图片文件路径或 Live2D 模型包引用;文字形仪为空)
media_ref: Mapped[str | None] = mapped_column(String(500))
# Live2D 动作映射(模型 motions/expressions 索引,JSON)
live2d_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
adopted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class VoiceProfile(Base):
"""声音档案:合成预设 / 原创设计 / 授权克隆。"""
__tablename__ = "voice_profiles"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("voice"))
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
# synthesis_preset / original_design / authorized_clone
source: Mapped[str] = mapped_column(String(30), nullable=False, default="synthesis_preset")
name: Mapped[str] = mapped_column(String(120), nullable=False)
# 合成参数(音色/语速/音高;预设名或设计参数)
params_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 克隆授权(authorized_clone 必填):身份、用途、期限、传播范围;可撤回
grant_purpose: Mapped[str | None] = mapped_column(String(200))
grant_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
grant_distribution: Mapped[str | None] = mapped_column(String(200))
grant_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
is_active: Mapped[bool] = mapped_column(default=True, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+324
View File
@@ -0,0 +1,324 @@
"""形仪域应用服务:真形锚点、候选定相、声音档案与克隆授权治理。
铁律(PRD §8.1/§8.2):
- 生成内容先为 candidate,用户定相(adopt)才成为正式形仪;同一时刻仅一主仪
(定相新主仪自动退役旧主仪);
- 克隆授权按身份、用途、期限与传播范围治理,可撤回,撤回立即生效;
- 未定相候选不得出现在正式形仪呈现位(头像位/主仪卡)。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from sqlalchemy import select
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.domains.xingyi.models import Form, FormAnchor, VoiceProfile
from app.models.character import Character
async def _owned(character_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。", status_code=404)
# ── 真形锚点 ──
async def get_anchor(character_id: str, owner_scope: str) -> dict:
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
row = (
await session.execute(
select(FormAnchor).where(FormAnchor.character_id == character_id)
)
).scalars().first()
if row is None:
return {"character_id": character_id, "features": {}, "age_presentation": "adult",
"permissions": {}, "mutable_scope": {}}
session.expunge(row)
return _anchor_dict(row)
async def upsert_anchor(
character_id: str, owner_scope: str, *, features: dict, age_presentation: str,
permissions: dict, mutable_scope: dict,
) -> dict:
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
row = (
await session.execute(
select(FormAnchor).where(FormAnchor.character_id == character_id)
)
).scalars().first()
if row is None:
row = FormAnchor(character_id=character_id)
session.add(row)
row.features_json = json.dumps(features or {}, ensure_ascii=False)
row.age_presentation = age_presentation or "adult"
row.permissions_json = json.dumps(permissions or {}, ensure_ascii=False)
row.mutable_scope_json = json.dumps(mutable_scope or {}, ensure_ascii=False)
await session.commit()
session.expunge(row)
return _anchor_dict(row)
# ── 主仪/化仪:候选治理 ──
async def create_form(
character_id: str,
owner_scope: str,
*,
kind: str,
source: str,
description: str,
media_ref: str | None,
live2d: dict | None,
) -> dict:
"""登记形仪候选(status=candidate;未定相不转正)。"""
await _owned(character_id, owner_scope)
if kind not in ("primary", "costume", "text"):
raise AppError("invalid_request", "kind 须为 primary / costume / text。")
if source not in ("image_gen", "upload", "live2d", "text"):
raise AppError("invalid_request", "source 须为 image_gen / upload / live2d / text。")
if not description:
raise AppError("invalid_request", "文字描述不能为空(文字形仪或图像说明)。")
async with AsyncSessionLocal() as session:
form = Form(
character_id=character_id,
kind=kind,
source=source,
description=description,
media_ref=media_ref,
live2d_json=json.dumps(live2d or {}, ensure_ascii=False),
status="candidate",
)
session.add(form)
await session.commit()
session.expunge(form)
return _form_dict(form)
async def adopt_form(form_id: str, owner_scope: str) -> dict:
"""定相:candidate → adopted;主仪唯一(同人物旧主仪自动 retired)。"""
form = await _get_owned_form(form_id, owner_scope)
if form["status"] != "candidate":
raise AppError("invalid_request", "只有候选可定相。", status_code=409)
async with AsyncSessionLocal() as session:
row = await session.get(Form, form_id)
if row.kind == "primary":
previous = (
await session.execute(
select(Form).where(
Form.character_id == row.character_id,
Form.kind == "primary",
Form.status == "adopted",
)
)
).scalars().all()
for prev in previous:
prev.status = "retired"
row.status = "adopted"
row.adopted_at = utc_now()
await session.commit()
session.expunge(row)
character_id = row.character_id
await one_qi.emit(
"form.adopted", character_id=character_id,
payload={"form_id": form_id, "kind": row.kind}, source="xingyi",
)
return _form_dict(row)
async def discard_form(form_id: str, owner_scope: str) -> dict:
form = await _get_owned_form(form_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(Form, form_id)
row.status = "discarded"
await session.commit()
return {"form_id": form_id, "status": "discarded"}
async def list_forms(character_id: str, owner_scope: str, *, status: str | None = None) -> list[dict]:
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
stmt = select(Form).where(Form.character_id == character_id).order_by(Form.created_at.desc())
if status:
stmt = stmt.where(Form.status == status)
rows = (await session.execute(stmt)).scalars().all()
return [_form_dict(r) for r in rows]
async def current_primary(character_id: str, owner_scope: str) -> dict | None:
"""当前主仪(呈现位数据源):只取 adopted,candidate 不得出现。"""
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
row = (
await session.execute(
select(Form).where(
Form.character_id == character_id,
Form.kind == "primary",
Form.status == "adopted",
).order_by(Form.adopted_at.desc())
)
).scalars().first()
if row is None:
return None
session.expunge(row)
return _form_dict(row)
# ── 声音档案与克隆授权 ──
async def create_voice(
character_id: str,
owner_scope: str,
*,
source: str,
name: str,
params: dict,
grant_purpose: str | None,
grant_expires_at: datetime | None,
grant_distribution: str | None,
) -> dict:
await _owned(character_id, owner_scope)
if source not in ("synthesis_preset", "original_design", "authorized_clone"):
raise AppError("invalid_request", "声音来源须为 synthesis_preset / original_design / authorized_clone。")
if source == "authorized_clone" and not (grant_purpose and grant_distribution):
raise AppError(
"invalid_request", "授权克隆必须声明用途(grant_purpose)与传播范围(grant_distribution)。"
)
async with AsyncSessionLocal() as session:
voice = VoiceProfile(
character_id=character_id,
source=source,
name=name,
params_json=json.dumps(params or {}, ensure_ascii=False),
grant_purpose=grant_purpose,
grant_expires_at=grant_expires_at,
grant_distribution=grant_distribution,
)
session.add(voice)
await session.commit()
session.expunge(voice)
return _voice_dict(voice)
async def list_voices(character_id: str, owner_scope: str) -> list[dict]:
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(VoiceProfile)
.where(VoiceProfile.character_id == character_id)
.order_by(VoiceProfile.created_at.desc())
)
).scalars().all()
return [_voice_dict(r) for r in rows]
async def revoke_voice_grant(voice_id: str, owner_scope: str) -> dict:
"""撤回克隆授权:立即生效(后续合成/克隆调用被拒)。"""
voice = await _get_owned_voice(voice_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(VoiceProfile, voice_id)
row.grant_revoked_at = utc_now()
row.is_active = False
await session.commit()
character_id = row.character_id
await one_qi.emit(
"voice.grant_revoked", character_id=character_id,
payload={"voice_id": voice_id}, source="xingyi",
)
return {"voice_id": voice_id, "grant_revoked": True, "effective": "immediate"}
async def assert_voice_usable(voice_id: str, owner_scope: str) -> dict:
"""合成/克隆前置校验:授权克隆必须授权有效(未撤回且未过期)。"""
voice = await _get_owned_voice(voice_id, owner_scope)
if not voice["is_active"]:
raise AppError("invalid_request", "声音档案已停用(授权已撤回)。", status_code=409)
if voice["source"] == "authorized_clone":
if voice.get("grant_revoked_at"):
raise AppError("invalid_request", "克隆授权已撤回,不可再用于合成。", status_code=403)
expires = voice.get("grant_expires_at")
if expires:
exp = datetime.fromisoformat(expires)
if exp.tzinfo is None:
exp = exp.replace(tzinfo=UTC)
if exp <= datetime.now(UTC):
raise AppError("invalid_request", "克隆授权已过期。", status_code=403)
return voice
async def _get_owned_form(form_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
row = await session.get(Form, form_id)
if row is None:
raise AppError("not_found", "形仪不存在。", status_code=404)
character = await session.get(Character, row.character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "形仪不存在。", status_code=404)
session.expunge(row)
return _form_dict(row)
async def _get_owned_voice(voice_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
row = await session.get(VoiceProfile, voice_id)
if row is None:
raise AppError("not_found", "声音档案不存在。", status_code=404)
character = await session.get(Character, row.character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "声音档案不存在。", status_code=404)
session.expunge(row)
return _voice_dict(row)
def _anchor_dict(a: FormAnchor) -> dict:
return {
"character_id": a.character_id,
"features": json.loads(a.features_json or "{}"),
"age_presentation": a.age_presentation,
"permissions": json.loads(a.permissions_json or "{}"),
"mutable_scope": json.loads(a.mutable_scope_json or "{}"),
}
def _form_dict(f: Form) -> dict:
return {
"id": f.id,
"character_id": f.character_id,
"kind": f.kind,
"status": f.status,
"source": f.source,
"description": f.description,
"media_ref": f.media_ref,
"live2d": json.loads(f.live2d_json or "{}"),
"created_at": f.created_at.isoformat() if f.created_at else None,
"adopted_at": f.adopted_at.isoformat() if f.adopted_at else None,
}
def _voice_dict(v: VoiceProfile) -> dict:
return {
"id": v.id,
"character_id": v.character_id,
"source": v.source,
"name": v.name,
"params": json.loads(v.params_json or "{}"),
"grant_purpose": v.grant_purpose,
"grant_expires_at": v.grant_expires_at.isoformat() if v.grant_expires_at else None,
"grant_distribution": v.grant_distribution,
"grant_revoked_at": v.grant_revoked_at.isoformat() if v.grant_revoked_at else None,
"is_active": v.is_active,
}
+4
View File
@@ -14,6 +14,7 @@ from app.models.idempotency import IdempotencyKey
from app.models.imports import CharacterImportJob, ImportItem
from app.domains.baigong.models import Capability, Mandate, ToolCall
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
from app.domains.xingyi.models import Form, FormAnchor, VoiceProfile
from app.domains.yuanqi.models import Covenant, CovenantEvent
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat, SoulForging
from app.models.lorebook import Lorebook, LorebookEntry
@@ -33,6 +34,9 @@ from app.models.user import User
__all__ = [
"Capability",
"Form",
"FormAnchor",
"VoiceProfile",
"Covenant",
"CovenantEvent",
"PersistentWorld",
-3
View File
@@ -41,9 +41,6 @@ class CharacterResponse(BaseModel):
source_template_id: str | None = Field(
description="来源系统模板人物 ID;非模板复制人物通常有值。"
)
avatar_url: str | None = Field(
default=None, description="角色头像 URL。无头像时为 null。"
)
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
updated_at: str = Field(description="更新时间,UTC ISO 8601 字符串。")
-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 "",
)