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

+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,
}