M1:镜生域后端闭环——灵胚/照心试演/启镜原子闸门/执镜关系/命台租约/魂籍签名导出/死亡冷静期(迁移 001,测试 7 用例)

This commit is contained in:
chenxin committed 2026-08-21 22:39:07 +08:00
1 parent c090893e63
commit 1cd0069d65
24 files changed
+1508 -9

No files matched your search

+318
View File
@@ -0,0 +1,318 @@
"""镜生域服务:灵胚生命周期、启镜事务、死亡请求与冷静期。"""
from __future__ import annotations
import json
from datetime import datetime, UTC
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.time import as_utc, utc_now
from app.db.session import AsyncSessionLocal
from app.domains.jingsheng.identity import build_identity_record
from app.models.character import Character
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.services.event_store import DbEventStore
# 事件由服务层经一炁落库(DbEventStore 由调用方注入;默认取进程级装配)
_event_store: DbEventStore | None = None
def bind_event_store(store: DbEventStore) -> None:
"""lifespan 装配时注入事件存储。"""
global _event_store
_event_store = store
async def _emit(
event_type: str,
*,
character_id: str | None,
owner_scope: str,
payload: dict,
visibility: str = "public",
) -> None:
from app.modules.runtime.events import Event
if _event_store is None:
return
await _event_store.append(
Event(
type=event_type,
source="jingsheng",
payload=payload,
visibility=visibility, # type: ignore[arg-type]
run_id=None,
character_id=character_id,
)
)
def _owned(embryo: EmbryoDraft, owner_scope: str) -> EmbryoDraft:
if embryo.owner_scope != owner_scope:
raise AppError("not_found", "灵胚不存在。")
return embryo
async def list_embryos(owner_scope: str) -> list[EmbryoDraft]:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(EmbryoDraft)
.where(EmbryoDraft.owner_scope == owner_scope, EmbryoDraft.status != "abandoned")
.order_by(EmbryoDraft.created_at.desc())
)
return list(result.scalars().all())
async def create_embryo(
*,
owner_scope: str,
source: str,
name: str,
description: str | None = None,
identity_baseline: dict | None = None,
) -> EmbryoDraft:
if source not in ("original", "adopted", "imported", "serendipity"):
raise AppError("invalid_request", "灵胚来源取值不合法。")
embryo = EmbryoDraft(
owner_scope=owner_scope,
source=source,
name=name,
description=description,
identity_baseline_json=json.dumps(identity_baseline or {}, ensure_ascii=False),
)
async with AsyncSessionLocal() as session:
session.add(embryo)
await session.commit()
await session.refresh(embryo)
await _emit(
"embryo.created",
character_id=None,
owner_scope=owner_scope,
payload={"embryo_id": embryo.id, "source": source},
)
return embryo
async def get_embryo(embryo_id: str, owner_scope: str) -> EmbryoDraft:
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
return _owned(embryo, owner_scope)
async def patch_embryo(
embryo_id: str,
owner_scope: str,
*,
name: str | None = None,
description: str | None = None,
identity_baseline: dict | None = None,
) -> EmbryoDraft:
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
if embryo.status == "activated":
raise AppError(
"HJ-1103", "灵胚已启镜,草稿不可再编辑。", status_code=409
)
if name is not None:
embryo.name = name
if description is not None:
embryo.description = description
if identity_baseline is not None:
embryo.identity_baseline_json = json.dumps(identity_baseline, ensure_ascii=False)
await session.commit()
await session.refresh(embryo)
return embryo
async def record_rehearsal(embryo_id: str, owner_scope: str, *, run_id: str, prompt: str) -> None:
"""照心试演引用落灵胚(内容在事件日志,不构成人物经历)。"""
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
rehearsals = json.loads(embryo.rehearsals_json)
rehearsals.append({"run_id": run_id, "prompt": prompt, "performed_at": utc_now().isoformat()})
embryo.rehearsals_json = json.dumps(rehearsals, ensure_ascii=False)
if embryo.status == "drafting":
embryo.status = "previewing"
await session.commit()
async def abandon_embryo(embryo_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None:
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
if embryo.status == "activated":
raise AppError("HJ-1103", "灵胚已启镜,不能放弃;请走人物死亡流程。", status_code=409)
embryo.status = "abandoned"
await session.commit()
await _emit(
"embryo.abandoned",
character_id=None,
owner_scope=owner_scope,
payload={"embryo_id": embryo_id},
)
async def activate_embryo(embryo_id: str, owner_scope: str) -> Character:
"""启镜:单事务内签发人物、执镜关系、命台与魂籍 v1。
并发防护:以 ``UPDATE embryo_drafts SET status='confirming' WHERE status IN (可启镜态)``
的原子行数作为闸门——第二个并发启镜拿不到闸门即 409,不产生第二个人物。
"""
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
gate = await session.execute(
update(EmbryoDraft)
.where(EmbryoDraft.id == embryo_id, EmbryoDraft.status.in_(("drafting", "previewing", "confirming")))
.values(status="confirming")
)
if gate.rowcount != 1:
raise AppError(
"HJ-1103", "灵胚当前状态不允许启镜(可能已被并发启镜)。", status_code=409
)
baseline = json.loads(embryo.identity_baseline_json or "{}")
character = Character(
name=embryo.name,
owner_scope=owner_scope,
status="active",
source=embryo.source,
identity_baseline_json=embryo.identity_baseline_json,
profile_json=json.dumps({"description": embryo.description or ""}, ensure_ascii=False),
activated_at=utc_now(),
)
session.add(character)
await session.flush() # 取 character.id(commit 后属性过期,异步下不可再访问)
character_id = character.id
session.add(Custodianship(character_id=character.id, custodian_scope=owner_scope))
session.add(RuntimeSeat(character_id=character.id, epoch=1))
record = build_identity_record(
character_id=character.id,
version=1,
identity={
"name": character.name,
"source": character.source,
"identity_baseline": baseline,
"activated_at": character.activated_at.isoformat(),
},
runtime_state={"status": "active", "seat_epoch": 1},
)
session.add(record)
await session.flush() # 取魂籍 ID
record_id = record.id
source = embryo.source
embryo.status = "activated"
await session.commit()
await _emit(
"character.activated",
character_id=character_id,
owner_scope=owner_scope,
payload={"embryo_id": embryo_id, "source": source, "identity_record": record_id},
)
async with AsyncSessionLocal() as session:
created = await session.get(Character, character_id)
assert created is not None
return created
async def death_request(
character_id: str, owner_scope: str, *, calm_period_hours: int
) -> Character:
"""死亡请求:进入冷静期(death_pending),可撤销;冷静期内人物默认静修。"""
async with AsyncSessionLocal() as session:
character = await _owned_character(session, character_id, owner_scope)
if character.status == "deceased":
raise AppError("HJ-1103", "人物已死亡。", status_code=409)
now = utc_now()
character.status = "death_pending"
character.death_requested_at = now
character.calm_period_until = datetime.fromtimestamp(
now.timestamp() + calm_period_hours * 3600, tz=UTC
)
await session.commit()
await session.refresh(character)
await _emit(
"character.death_requested",
character_id=character_id,
owner_scope=owner_scope,
payload={"calm_period_until": character.calm_period_until.isoformat()},
)
return character
async def death_cancel(character_id: str, owner_scope: str) -> Character:
"""冷静期撤销:恢复 active。人物不得阻挠撤销(宪章)。"""
async with AsyncSessionLocal() as session:
character = await _owned_character(session, character_id, owner_scope)
if character.status != "death_pending":
raise AppError("HJ-1103", "人物不在死亡冷静期。", status_code=409)
character.status = "active"
character.death_requested_at = None
character.calm_period_until = None
await session.commit()
await session.refresh(character)
await _emit(
"character.death_cancelled",
character_id=character_id,
owner_scope=owner_scope,
payload={},
)
return character
async def death_finalize(character_id: str, owner_scope: str) -> Character:
"""死亡落定:身份时间线闭合(deceased),数据保留——数据清除是独立操作。"""
async with AsyncSessionLocal() as session:
character = await _owned_character(session, character_id, owner_scope)
if character.status != "death_pending":
raise AppError("HJ-1103", "人物不在死亡冷静期。", status_code=409)
assert character.calm_period_until is not None
if utc_now() < as_utc(character.calm_period_until):
raise AppError(
"HJ-1103",
f"冷静期未满(至 {character.calm_period_until.isoformat()}),不能落定死亡。",
status_code=422,
)
character.status = "deceased"
character.died_at = utc_now()
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
if seat is not None:
seat.holder = None
seat.lease_expires_at = None
await session.commit()
await session.refresh(character)
await _emit(
"character.deceased",
character_id=character_id,
owner_scope=owner_scope,
payload={"died_at": character.died_at.isoformat() if character.died_at else None},
)
return character
async def _owned_character(
session: AsyncSession, character_id: str, owner_scope: str
) -> Character:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character