Files
hunjing/backend/app/domains/xingyi/service.py
T
chenxin b419e0afde 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 干净
2026-08-27 20:26:52 +08:00

325 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""形仪域应用服务:真形锚点、候选定相、声音档案与克隆授权治理。
铁律(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,
}