"""形仪域应用服务:真形锚点、候选定相、声音档案与克隆授权治理。 铁律(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, }