M1:镜生域后端闭环——灵胚/照心试演/启镜原子闸门/执镜关系/命台租约/魂籍签名导出/死亡冷静期(迁移 001,测试 7 用例)
This commit is contained in:
1 parent
c090893e63
commit
1cd0069d65
24 files changed
+1508
-9
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user