Files
hunjing/backend/app/domains/yuanqi/service.py
T
chenxin 7e23d6fa84 M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流
- 缘契域 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 干净
2026-08-27 20:10:11 +08:00

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,
}