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

+37
View File
@@ -0,0 +1,37 @@
"""镜生域:灵胚、启镜、执镜关系、命台、魂籍、死亡与冷静期(data-model.md §1)。"""
from app.domains.jingsheng.identity import build_identity_record, verify_identity_record
from app.domains.jingsheng.seat import SeatConflict, acquire_lease, emit_seat_event, release_lease
from app.domains.jingsheng.service import (
abandon_embryo,
activate_embryo,
bind_event_store,
create_embryo,
death_cancel,
death_finalize,
death_request,
get_embryo,
list_embryos,
record_rehearsal,
)
from app.domains.jingsheng.service import patch_embryo
__all__ = [
"SeatConflict",
"abandon_embryo",
"acquire_lease",
"activate_embryo",
"bind_event_store",
"build_identity_record",
"create_embryo",
"death_cancel",
"death_finalize",
"death_request",
"emit_seat_event",
"get_embryo",
"list_embryos",
"patch_embryo",
"record_rehearsal",
"release_lease",
"verify_identity_record",
]
+82
View File
@@ -0,0 +1,82 @@
"""魂籍(portable_identity_record):可验证、版本化的身份与状态包。
完整性模型:
- payload 规范化序列化(排序键、紧凑分隔符)后取 SHA-256 摘要;
- 签名 = HMAC-SHA256(密钥, "{character_id}|{version}|{digest}"),绑定人物与版本;
- 校验:重算摘要与签名比对。篡改 payload 破坏摘要;把记录复制到别的人物/版本,
签名不匹配——文件相同或人物 ID 相同不足以冒充原主体(data-model.md §1)。
"""
from __future__ import annotations
import hashlib
import hmac
import json
from app.core.config import settings
from app.models.jingsheng import IdentityRecord
def _canonical(payload: dict) -> str:
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _digest(payload: dict) -> str:
return hashlib.sha256(_canonical(payload).encode("utf-8")).hexdigest()
def _signature(character_id: str, version: int, digest: str) -> str:
secret = settings.identity_record_secret
message = f"{character_id}|{version}|{digest}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
def build_identity_record(
*,
character_id: str,
version: int,
identity: dict,
runtime_state: dict,
data_state: dict | None = None,
) -> IdentityRecord:
"""构建一条魂籍版本记录(调用方负责落库)。"""
payload = {
"spec": "hunjing.portable_identity_record.v1",
"character_id": character_id,
"version": version,
"identity": identity,
"data_state": data_state or {"note": "数据状态由各域自报(M1 占位)"},
"runtime_state": runtime_state,
}
digest = _digest(payload)
return IdentityRecord(
character_id=character_id,
version=version,
payload_json=json.dumps(payload, ensure_ascii=False),
digest=digest,
signature=_signature(character_id, version, digest),
)
class IdentityVerificationError(Exception):
"""校验失败:摘要或签名不匹配。"""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
def verify_identity_record(record: IdentityRecord) -> dict:
"""校验魂籍记录,返回规范化 payload;失败抛 IdentityVerificationError。"""
try:
payload = json.loads(record.payload_json)
except json.JSONDecodeError as exc:
raise IdentityVerificationError("payload 无法解析") from exc
if _digest(payload) != record.digest:
raise IdentityVerificationError("摘要不匹配(payload 已被篡改)")
expected = _signature(record.character_id, record.version, record.digest)
if not hmac.compare_digest(expected, record.signature):
raise IdentityVerificationError("签名不匹配(非本平台签发或被移植冒用)")
if payload.get("character_id") != record.character_id or payload.get("version") != record.version:
raise IdentityVerificationError("payload 声明的身份与记录不符")
return payload
+94
View File
@@ -0,0 +1,94 @@
"""命台租约(authoritative_runtime_seat):同一人物唯一权威运行位置。
- 活跃租约(未过期、非本人)存在时,其他持有者获取被拒(SeatConflict);
- 租约过期可接管,epoch 递增(旧持有者写入按 epoch 失效——M2 运行时接入);
- 事件(seat.acquired / released / contested)由 API 层在事务提交后发射,
避免事务内跨连接写冲突;M1 为 API 级租约,Kernel 前台串行仍为进程内锁,多进程一致在 M2 收口。
"""
from __future__ import annotations
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import as_utc, utc_now
from app.models.jingsheng import RuntimeSeat
class SeatConflict(Exception):
"""命台被其他持有者占用(HJ-1101 语义);调用方须留痕 seat.contested。"""
def __init__(self, seat: RuntimeSeat) -> None:
super().__init__(f"命台被 {seat.holder} 占用至 {seat.lease_expires_at}")
self.seat = seat
async def acquire_lease(
session: AsyncSession, character_id: str, holder: str, ttl_seconds: int = 60
) -> RuntimeSeat:
"""获取或续期命台租约;冲突抛 SeatConflict(不在此发事件,避免事务内跨连接写)。"""
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
if seat is None:
seat = RuntimeSeat(character_id=character_id, epoch=0)
session.add(seat)
now = utc_now()
if (
seat.holder is not None
and seat.holder != holder
and seat.lease_expires_at is not None
and as_utc(seat.lease_expires_at) > now
):
raise SeatConflict(seat)
# 仅"从其他持有者手中接管"递增 epoch;无人持有时首次获取沿用当前纪元
takeover = seat.holder is not None and seat.holder != holder
seat.holder = holder
seat.lease_expires_at = now + timedelta(seconds=ttl_seconds)
if takeover:
seat.epoch += 1
await session.flush()
return seat
async def release_lease(session: AsyncSession, character_id: str, holder: str) -> bool:
"""释放租约;返回是否实际释放(holder 不匹配时无操作)。"""
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
if seat is None or seat.holder != holder:
return False
seat.holder = None
seat.lease_expires_at = None
await session.flush()
return True
async def emit_seat_event(
event_type: str,
character_id: str,
holder: str | None,
epoch: int | None,
*,
requester: str | None = None,
) -> None:
"""事务外发射命台事件(一炁,visibility=internal)。"""
from app.modules.runtime.events import Event
from app.services.event_store import DbEventStore
from app.domains.jingsheng import service as jingsheng_service
store: DbEventStore | None = jingsheng_service._event_store # noqa: SLF001
if store is None:
return
payload: dict = {"holder": holder, "epoch": epoch}
if requester is not None:
payload["requester"] = requester
await store.append(
Event(
type=event_type,
source="jingsheng",
payload=payload,
visibility="internal",
run_id=None,
character_id=character_id,
)
)
+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