319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""镜生域服务:灵胚生命周期、启镜事务、死亡请求与冷静期。"""
|
||
|
||
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
|