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:
1 parent
7e23d6fa84
commit
b419e0afde
30 files changed
+1876
-468
No files matched your search
@@ -0,0 +1,5 @@
|
||||
"""形仪域:真形锚点、主仪/化仪候选定相、声音档案与克隆授权(rewrite/1.0 M7)。"""
|
||||
|
||||
from app.domains.xingyi import asr, models, service # noqa: F401
|
||||
|
||||
__all__ = ["asr", "models", "service"]
|
||||
@@ -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")
|
||||
@@ -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 原转写(与正影文本并存的语义由镜树承载)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user