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 @@
|
||||
"""六域领域层(rewrite/1.0)。镜生域先行,其余域随里程碑落地。"""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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