- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位 - 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起) - 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数 - 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离 - 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换 - 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库 - 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id - 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继 - 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""缘契域应用服务:提议—问心—铭契、撤回与边界变更。
|
|
|
|
铁律(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,
|
|
}
|