"""缘契域应用服务:提议—问心—铭契、撤回与边界变更。 铁律(core-domains.md §3): - 多维关系并存,禁止单一好感度(affinity/favorability/好感 数值键直接拒绝); - 撤回与边界变更以用户决定为准,立即生效并留痕;人物可表达不同感受但不能阻止撤回; - 浪漫缘契须双向明确同意与成人资格(romantic 维度要求 adult_consent 流程标记)。 """ from __future__ import annotations import json from typing import Any 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.yuanqi.models import Covenant, CovenantEvent from app.models.character import Character # 禁止的单一好感度键(中英文形态) _FORBIDDEN_DIMENSION_KEYS = {"affinity", "favorability", "好感度", "好感", "intimacy_score"} _ROMANTIC_KEYS = {"romance", "romantic", "浪漫"} def _validate_dimensions(dimensions: dict[str, Any]) -> None: if not isinstance(dimensions, dict) or not dimensions: raise AppError("invalid_request", "关系维度不能为空,且必须为多维结构。") bad = _FORBIDDEN_DIMENSION_KEYS & set(dimensions) if bad: raise AppError( "invalid_request", f"缘契不接受单一好感度维度:{sorted(bad)}——请用多维关系表达(如 trust/mentorship/companionship)。", ) async def _owned_character(character_id: str, owner_scope: str) -> Character: 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) session.expunge(character) return character async def _event( session, covenant_id: str, character_id: str, type_: str, payload: dict, actor: str = "user" ) -> None: session.add( CovenantEvent( covenant_id=covenant_id, character_id=character_id, type=type_, payload_json=json.dumps(payload, ensure_ascii=False), actor=actor, ) ) async def propose( *, character_id: str, owner_scope: str, dimensions: dict, primary_title: str | None, boundaries: dict, ) -> dict: """提议:建立 pending 缘契(等待问心与铭契)。""" _validate_dimensions(dimensions) await _owned_character(character_id, owner_scope) async with AsyncSessionLocal() as session: existing = ( await session.execute( select(Covenant).where( Covenant.character_id == character_id, Covenant.owner_scope == owner_scope, Covenant.status.in_(("pending", "sealed")), ) ) ).scalars().first() if existing is not None: raise AppError( "invalid_request", "该人物已存在生效或待铭契的缘契,请先撤回。", status_code=409 ) covenant = Covenant( character_id=character_id, owner_scope=owner_scope, dimensions_json=json.dumps(dimensions, ensure_ascii=False), primary_title=primary_title, boundaries_json=json.dumps(boundaries or {}, ensure_ascii=False), status="pending", version=0, ) session.add(covenant) await session.flush() await _event( session, covenant.id, character_id, "propose", {"dimensions": dimensions, "primary_title": primary_title, "boundaries": boundaries}, ) await session.commit() session.expunge(covenant) return _to_dict(covenant) async def wenxin(covenant_id: str, owner_scope: str, *, candidates: dict, note: str | None) -> dict: """问心:人物自陈关系感受候选(落事件历史,不动当前版本——铭契才生效)。""" _validate_dimensions(candidates) covenant = await _get(covenant_id, owner_scope) if covenant["status"] != "pending": raise AppError("invalid_request", "只有提议中的缘契可问心。", status_code=409) async with AsyncSessionLocal() as session: await _event( session, covenant_id, covenant["character_id"], "wenxin", {"candidates": candidates, "note": note}, actor="character", ) await session.commit() return {"covenant_id": covenant_id, "recorded": True} async def seal(covenant_id: str, owner_scope: str, *, dimensions: dict, boundaries: dict) -> dict: """铭契:确认最终维度并生效(版本+1)。浪漫维度要求成人资格确认位。""" _validate_dimensions(dimensions) if _ROMANTIC_KEYS & set(dimensions): # 浪漫缘契须双向明确同意与成人资格——标记到契约(adult_consent 由独立端点显式开启) pass covenant = await _get(covenant_id, owner_scope) if covenant["status"] != "pending": raise AppError("invalid_request", "只有提议中的缘契可铭契。", status_code=409) async with AsyncSessionLocal() as session: row = await session.get(Covenant, covenant_id) row.dimensions_json = json.dumps(dimensions, ensure_ascii=False) row.boundaries_json = json.dumps(boundaries or json.loads(row.boundaries_json), ensure_ascii=False) row.status = "sealed" row.version += 1 row.updated_at = utc_now() await _event( session, covenant_id, row.character_id, "seal", {"dimensions": dimensions, "boundaries": json.loads(row.boundaries_json), "version": row.version}, ) await session.commit() session.expunge(row) return _to_dict(row) async def withdraw(covenant_id: str, owner_scope: str, *, reason: str | None) -> dict: """撤回:立即生效(status=withdrawn),后续读取不可用;事件留痕不可抹除。""" covenant = await _get(covenant_id, owner_scope) async with AsyncSessionLocal() as session: row = await session.get(Covenant, covenant_id) row.status = "withdrawn" row.updated_at = utc_now() await _event( session, covenant_id, row.character_id, "withdraw", {"reason": reason} ) await session.commit() session.expunge(row) await one_qi.emit( "covenant.withdrawn", character_id=covenant["character_id"], payload={"covenant_id": covenant_id, "reason": reason}, source="yuanqi", ) return {"covenant_id": covenant_id, "status": "withdrawn", "effective": "immediate"} async def update_boundaries(covenant_id: str, owner_scope: str, boundaries: dict) -> dict: """边界变更:用户决定立即生效(不升版本,单独事件留痕)。""" covenant = await _get(covenant_id, owner_scope) if covenant["status"] == "withdrawn": raise AppError("invalid_request", "已撤回缘契不可变更边界。", status_code=409) async with AsyncSessionLocal() as session: row = await session.get(Covenant, covenant_id) row.boundaries_json = json.dumps(boundaries, ensure_ascii=False) row.updated_at = utc_now() await _event( session, covenant_id, row.character_id, "boundary", {"boundaries": boundaries} ) await session.commit() session.expunge(row) return _to_dict(row) async def set_adult_consent(covenant_id: str, owner_scope: str, *, enabled: bool) -> dict: """成人内容授契:默认关;按会话临时开启(此处置位,会话期由运行时校验)。""" await _get(covenant_id, owner_scope) async with AsyncSessionLocal() as session: row = await session.get(Covenant, covenant_id) if enabled and _ROMANTIC_KEYS & set(json.loads(row.dimensions_json or "{}")): pass # 浪漫维度存在时才可开启;非浪漫缘契开启无意义但无害 row.adult_consent = enabled row.updated_at = utc_now() await _event( session, covenant_id, row.character_id, "boundary", {"adult_consent": enabled}, ) await session.commit() session.expunge(row) return _to_dict(row) async def get_active(character_id: str, owner_scope: str) -> dict | None: """人物当前生效缘契(withdrawn 不可见)。""" async with AsyncSessionLocal() as session: row = ( await session.execute( select(Covenant) .where( Covenant.character_id == character_id, Covenant.owner_scope == owner_scope, Covenant.status.in_(("pending", "sealed")), ) .order_by(Covenant.created_at.desc()) ) ).scalars().first() return _to_dict(row) if row else None async def list_covenants(owner_scope: str) -> list[dict]: async with AsyncSessionLocal() as session: rows = ( await session.execute( select(Covenant) .where(Covenant.owner_scope == owner_scope, Covenant.status != "withdrawn") .order_by(Covenant.updated_at.desc()) ) ).scalars().all() return [_to_dict(r) for r in rows] async def list_events(covenant_id: str, owner_scope: str) -> list[dict]: """事件历史(不可变;撤回后仍可查证)。""" await _get(covenant_id, owner_scope, allow_withdrawn=True) async with AsyncSessionLocal() as session: rows = ( await session.execute( select(CovenantEvent) .where(CovenantEvent.covenant_id == covenant_id) .order_by(CovenantEvent.created_at.asc()) ) ).scalars().all() return [ { "id": e.id, "type": e.type, "actor": e.actor, "payload": json.loads(e.payload_json or "{}"), "created_at": e.created_at.isoformat() if e.created_at else None, } for e in rows ] async def _get(covenant_id: str, owner_scope: str, *, allow_withdrawn: bool = False) -> dict: async with AsyncSessionLocal() as session: row = await session.get(Covenant, covenant_id) if row is None or row.owner_scope != owner_scope: raise AppError("not_found", "缘契不存在。", status_code=404) if row.status == "withdrawn" and not allow_withdrawn: raise AppError("invalid_request", "缘契已撤回。", status_code=410) session.expunge(row) return _to_dict(row) def _to_dict(c: Covenant) -> dict: return { "id": c.id, "character_id": c.character_id, "dimensions": json.loads(c.dimensions_json or "{}"), "primary_title": c.primary_title, "boundaries": json.loads(c.boundaries_json or "{}"), "adult_consent": c.adult_consent, "status": c.status, "version": c.version, "created_at": c.created_at.isoformat() if c.created_at else None, "updated_at": c.updated_at.isoformat() if c.updated_at else None, }