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

@@ -0,0 +1,108 @@
"""镜生域:灵胚、执镜关系、命台、魂籍表与人物增量列
Revision ID: 001
Revises: 000
Create Date: 2026-08-21 22:23:35.864890
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: Union[str, Sequence[str], None] = '000'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('embryo_drafts',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('identity_baseline_json', sa.Text(), nullable=False),
sa.Column('rehearsals_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_embryo_drafts_owner_scope'), 'embryo_drafts', ['owner_scope'], unique=False)
op.create_index(op.f('ix_embryo_drafts_status'), 'embryo_drafts', ['status'], unique=False)
op.create_table('custodianships',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('custodian_scope', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_custodianships_character_id'), 'custodianships', ['character_id'], unique=True)
op.create_index(op.f('ix_custodianships_custodian_scope'), 'custodianships', ['custodian_scope'], unique=False)
op.create_table('identity_records',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('payload_json', sa.Text(), nullable=False),
sa.Column('digest', sa.String(length=64), nullable=False),
sa.Column('signature', sa.String(length=128), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('character_id', 'version', name='uq_identity_char_version')
)
op.create_index(op.f('ix_identity_records_character_id'), 'identity_records', ['character_id'], unique=False)
op.create_table('runtime_seats',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('epoch', sa.Integer(), nullable=False),
sa.Column('holder', sa.String(length=120), nullable=True),
sa.Column('lease_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_runtime_seats_character_id'), 'runtime_seats', ['character_id'], unique=True)
op.add_column('characters', sa.Column('source', sa.String(length=40), server_default='original', nullable=False))
op.add_column('characters', sa.Column('identity_baseline_json', sa.Text(), server_default='{}', nullable=False))
op.add_column('characters', sa.Column('death_requested_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('characters', sa.Column('calm_period_until', sa.DateTime(timezone=True), nullable=True))
op.add_column('characters', sa.Column('died_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_characters_source'), 'characters', ['source'], unique=False)
op.add_column('runs', sa.Column('kind', sa.String(length=40), server_default='foreground', nullable=False))
# SQLite 不支持 ALTER COLUMN,用 batch 模式跨方言改 runs.character_id 可空(试演 Run 无人物)
with op.batch_alter_table('runs') as batch_op:
batch_op.alter_column('character_id', existing_type=sa.String(length=80), nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('runs') as batch_op:
batch_op.alter_column('character_id', existing_type=sa.String(length=80), nullable=False)
op.drop_column('runs', 'kind')
op.drop_index(op.f('ix_characters_source'), table_name='characters')
op.drop_column('characters', 'died_at')
op.drop_column('characters', 'calm_period_until')
op.drop_column('characters', 'death_requested_at')
op.drop_column('characters', 'identity_baseline_json')
op.drop_column('characters', 'source')
op.drop_index(op.f('ix_runtime_seats_character_id'), table_name='runtime_seats')
op.drop_table('runtime_seats')
op.drop_index(op.f('ix_identity_records_character_id'), table_name='identity_records')
op.drop_table('identity_records')
op.drop_index(op.f('ix_custodianships_custodian_scope'), table_name='custodianships')
op.drop_index(op.f('ix_custodianships_character_id'), table_name='custodianships')
op.drop_table('custodianships')
op.drop_index(op.f('ix_embryo_drafts_status'), table_name='embryo_drafts')
op.drop_index(op.f('ix_embryo_drafts_owner_scope'), table_name='embryo_drafts')
op.drop_table('embryo_drafts')
# ### end Alembic commands ###
+4
View File
@@ -17,6 +17,8 @@ from app.api.v1.endpoints import (
goals,
health,
imports,
embryo_drafts,
jingsheng_characters,
kernel_runs,
lorebooks,
mcp,
@@ -40,6 +42,8 @@ api_router.include_router(character_import.router)
api_router.include_router(chat.router)
api_router.include_router(runs.router)
api_router.include_router(kernel_runs.router)
api_router.include_router(embryo_drafts.router)
api_router.include_router(jingsheng_characters.router)
api_router.include_router(memory.router)
api_router.include_router(lorebooks.router)
api_router.include_router(personality.router)
@@ -0,0 +1,141 @@
"""灵胚 API(镜生域,rewrite/1.0 M1)。对应 api-contracts.md §3.2。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains import jingsheng
from app.kernel import Kernel
from app.schemas.auth import CurrentUser
router = APIRouter(prefix="/embryo-drafts", tags=["jingsheng"])
class EmbryoCreate(BaseModel):
source: str = Field(default="original", description="original/adopted/imported/serendipity")
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=8000)
identity_baseline: dict = Field(default_factory=dict, description="道体锚点草稿")
class EmbryoPatch(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=8000)
identity_baseline: dict | None = None
class RehearseRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=8000)
def _embryo_view(embryo) -> dict: # noqa: ANN001 — ORM 行
return {
"id": embryo.id,
"status": embryo.status,
"source": embryo.source,
"name": embryo.name,
"description": embryo.description,
"identity_baseline": json.loads(embryo.identity_baseline_json or "{}"),
"rehearsals": json.loads(embryo.rehearsals_json or "[]"),
"created_at": embryo.created_at.isoformat(),
"updated_at": embryo.updated_at.isoformat(),
}
@router.get("")
async def list_embryo_drafts(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return [_embryo_view(e) for e in await jingsheng.list_embryos(user.owner_scope)]
@router.post("", status_code=201)
async def create_embryo_draft(
payload: EmbryoCreate, user: CurrentUser = Depends(get_current_user)
) -> dict:
embryo = await jingsheng.create_embryo(
owner_scope=user.owner_scope,
source=payload.source,
name=payload.name,
description=payload.description,
identity_baseline=payload.identity_baseline,
)
return _embryo_view(embryo)
@router.get("/{embryo_id}")
async def get_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return _embryo_view(await jingsheng.get_embryo(embryo_id, user.owner_scope))
@router.patch("/{embryo_id}")
async def patch_embryo_draft(
embryo_id: str, payload: EmbryoPatch, user: CurrentUser = Depends(get_current_user)
) -> dict:
embryo = await jingsheng.patch_embryo(
embryo_id,
user.owner_scope,
name=payload.name,
description=payload.description,
identity_baseline=payload.identity_baseline,
)
return _embryo_view(embryo)
@router.post("/{embryo_id}/rehearse")
async def rehearse_embryo(
embryo_id: str,
payload: RehearseRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> dict:
"""照心试演:幻境 Run,不产生人物经历;事件流经 /runs/{run_id}/stream。"""
embryo = await jingsheng.get_embryo(embryo_id, user.owner_scope)
if embryo.status == "activated":
from app.core.errors import AppError
raise AppError("HJ-1103", "灵胚已启镜,无需再试演。", status_code=409)
kernel: Kernel | None = getattr(request.app.state, "kernel", None)
if kernel is None:
from app.core.errors import AppError
raise AppError("service_unavailable", "Kernel 运行时未就绪。", status_code=503)
baseline = json.loads(embryo.identity_baseline_json or "{}")
system_contract = (
f"你是一位尚未降生的灵胚「{embryo.name}」,正在幻境中试演。"
f"身份锚点:{json.dumps(baseline, ensure_ascii=False)}"
)
run = await kernel.start_rehearsal_run(
embryo_id=embryo_id, prompt=payload.prompt, system_contract=system_contract
)
await jingsheng.record_rehearsal(
embryo_id, user.owner_scope, run_id=run.id, prompt=payload.prompt
)
return {"run_id": run.id, "kind": "rehearsal"}
@router.post("/{embryo_id}/activate")
async def activate_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""启镜:签发正式人物 ID、魂籍、执镜关系与命台。"""
character = await jingsheng.activate_embryo(embryo_id, user.owner_scope)
return {
"character_id": character.id,
"name": character.name,
"status": character.status,
"source": character.source,
"identity_record_version": 1,
}
@router.post("/{embryo_id}/abandon", status_code=204)
async def abandon_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> None:
await jingsheng.abandon_embryo(embryo_id, user.owner_scope)
@@ -0,0 +1,220 @@
"""镜生域人物端点:魂籍导出与校验、死亡请求/撤销/落定、命台租约(api-contracts.md §3.2)。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.api.v1.deps import get_current_user
from app.core.config import settings
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains import jingsheng
from app.domains.jingsheng.identity import IdentityVerificationError, verify_identity_record
from app.domains.jingsheng.seat import SeatConflict, acquire_lease, emit_seat_event, release_lease
from app.models.character import Character
from app.models.jingsheng import IdentityRecord
from app.schemas.auth import CurrentUser
router = APIRouter(prefix="/characters", tags=["jingsheng"])
class DeathFinalizeRequest(BaseModel):
confirm_phrase: str = Field(description="须填入人物名以强确认")
class SeatLeaseRequest(BaseModel):
holder: str = Field(min_length=1, max_length=120, description="租约持有者标识(设备/实例)")
async def _owned_character(character_id: str, owner_scope: str) -> Character:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
# ── 魂籍 ──────────────────────────────────────────────
@router.get("/{character_id}/identity-records")
async def list_identity_records(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
result = await session.execute(
select(IdentityRecord)
.where(IdentityRecord.character_id == character_id)
.order_by(IdentityRecord.version.desc())
)
records = result.scalars().all()
return [
{
"id": r.id,
"version": r.version,
"digest": r.digest,
"created_at": r.created_at.isoformat(),
}
for r in records
]
@router.get("/{character_id}/identity-records/{record_id}")
async def get_identity_record(
character_id: str, record_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
record = await session.get(IdentityRecord, record_id)
if record is None or record.character_id != character_id:
raise AppError("not_found", "魂籍记录不存在。")
return {
"id": record.id,
"version": record.version,
"digest": record.digest,
"payload": json.loads(record.payload_json),
"created_at": record.created_at.isoformat(),
}
@router.post("/{character_id}/identity-records")
async def export_identity_record(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""导出新版本魂籍:身份/数据/运行状态三段分离。"""
character = await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
latest = await session.scalar(
select(IdentityRecord.version)
.where(IdentityRecord.character_id == character_id)
.order_by(IdentityRecord.version.desc())
.limit(1)
)
next_version = (latest or 0) + 1
record = jingsheng.build_identity_record(
character_id=character_id,
version=next_version,
identity={
"name": character.name,
"source": character.source,
"identity_baseline": json.loads(character.identity_baseline_json or "{}"),
"activated_at": character.activated_at.isoformat() if character.activated_at else None,
},
runtime_state={"status": character.status},
)
session.add(record)
await session.commit()
await session.refresh(record)
return {
"id": record.id,
"version": record.version,
"digest": record.digest,
"created_at": record.created_at.isoformat(),
}
@router.post("/{character_id}/identity-records/{record_id}/verify")
async def verify_record(
character_id: str, record_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
record = await session.get(IdentityRecord, record_id)
if record is None or record.character_id != character_id:
raise AppError("not_found", "魂籍记录不存在。")
try:
verify_identity_record(record)
except IdentityVerificationError as exc:
raise AppError("HJ-9000", f"魂籍校验失败:{exc.reason}", status_code=422) from exc
return {"valid": True, "version": record.version, "digest": record.digest}
# ── 死亡与冷静期 ──────────────────────────────────────────────
@router.post("/{character_id}/death-request")
async def request_death(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
character = await jingsheng.death_request(
character_id, user.owner_scope, calm_period_hours=settings.death_calm_period_hours
)
return {
"character_id": character.id,
"status": character.status,
"calm_period_until": character.calm_period_until.isoformat(),
}
@router.post("/{character_id}/death-cancel")
async def cancel_death(character_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
character = await jingsheng.death_cancel(character_id, user.owner_scope)
return {"character_id": character.id, "status": character.status}
@router.post("/{character_id}/death-finalize")
async def finalize_death(
character_id: str,
payload: DeathFinalizeRequest,
user: CurrentUser = Depends(get_current_user),
) -> dict:
character = await _owned_character(character_id, user.owner_scope)
if payload.confirm_phrase != character.name:
raise AppError("invalid_request", "确认短语与人物名不一致,拒绝落定。")
deceased = await jingsheng.death_finalize(character_id, user.owner_scope)
return {
"character_id": deceased.id,
"status": deceased.status,
"died_at": deceased.died_at.isoformat() if deceased.died_at else None,
}
# ── 命台租约 ──────────────────────────────────────────────
@router.post("/{character_id}/seat/acquire")
async def acquire_seat(
character_id: str, payload: SeatLeaseRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
try:
seat = await acquire_lease(
session,
character_id,
payload.holder,
ttl_seconds=settings.seat_lease_ttl_seconds,
)
except SeatConflict as exc:
current_holder = exc.seat.holder
await session.rollback()
# 留痕在事务回滚后发射(避免 SQLite 跨连接写锁冲突)
await emit_seat_event(
"seat.contested",
character_id,
current_holder,
None,
requester=payload.holder,
)
raise AppError("HJ-1101", f"命台被占用:{exc}", status_code=429) from exc
epoch, holder = seat.epoch, seat.holder
await session.commit()
await emit_seat_event("seat.acquired", character_id, holder, epoch)
return {"epoch": epoch, "holder": holder}
@router.post("/{character_id}/seat/release", status_code=204)
async def release_seat(
character_id: str, payload: SeatLeaseRequest, user: CurrentUser = Depends(get_current_user)
) -> None:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
released = await release_lease(session, character_id, payload.holder)
await session.commit()
if released:
await emit_seat_event("seat.released", character_id, payload.holder, None)
@@ -19,6 +19,11 @@ class RuntimeConfig(BaseSettings):
shenshi_base_url: str = "" # 神识服务地址;为空时 Kernel 使用进程内回声桩(M0 骨架)
shenshi_service_token: str = "" # 灵台→神识服务令牌(开发档可空)
shenshi_timeout_seconds: float = 120.0 # 神识流式调用超时(秒)
# ==================== 镜生域(rewrite/1.0 M1) ====================
identity_record_secret: str = "dev-identity-secret" # 魂籍签名密钥;正式部署必须改掉默认值
death_calm_period_hours: int = 72 # 死亡冷静期时长(小时),期内可撤销
seat_lease_ttl_seconds: int = 60 # 命台租约 TTL(秒)
# Agent 循环最大轮数:模型每轮仍带工具调用则继续,超限截断返回(见 agent.runtime.DEFAULT_MAX_ITERATIONS)
agent_max_iterations: int = 8
# 单次高风险工具确认挂起超时(秒):须小于 run_timeout_seconds,超时按拒绝处理;
+16
View File
@@ -115,6 +115,22 @@ def new_user_id() -> str:
return new_id("user_")
def new_embryo_id() -> str:
return new_id("embryo_")
def new_custodianship_id() -> str:
return new_id("cust_")
def new_seat_id() -> str:
return new_id("seat_")
def new_identity_record_id() -> str:
return new_id("identity_")
def new_role_id() -> str:
return new_id("rl_")
+5
View File
@@ -13,3 +13,8 @@ def to_iso(value: datetime | None) -> str | None:
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def as_utc(value: datetime) -> datetime:
"""SQLite 读回 naive datetime 时补 UTC;PG(timezone=True) 读回 aware 则原样返回。"""
return value if value.tzinfo else value.replace(tzinfo=UTC)
+1
View File
@@ -0,0 +1 @@
"""六域领域层(rewrite/1.0)。镜生域先行,其余域随里程碑落地。"""
+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
+5
View File
@@ -98,6 +98,11 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
timeout_seconds=float(settings.run_timeout_seconds),
)
# 镜生域事件写入绑定一炁(灵胚/启镜/命台/死亡事件落 events 表)
from app.domains.jingsheng import bind_event_store
bind_event_store(_app.state.event_store)
# ── MCP 进程管理器(stdio 子进程生命周期管理) ──────────────
from app.integrations.mcp.process_manager import (
ProcessManager,
+1
View File
@@ -16,6 +16,7 @@ from app.models.governance import (
)
from app.models.idempotency import IdempotencyKey
from app.models.imports import CharacterImportJob, ImportItem
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.models.lorebook import Lorebook, LorebookEntry
from app.models.mcp_servers import McpServer
from app.models.memory import Memory, MemoryLink
+10
View File
@@ -41,3 +41,13 @@ class Character(Base):
)
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# ── rewrite/1.0 镜生域增量列(data-model.md §1):来源、道体锚点、死亡与冷静期 ──
source: Mapped[str] = mapped_column(
String(40), nullable=False, default="original", server_default="original", index=True
)
identity_baseline_json: Mapped[str] = mapped_column(
Text, nullable=False, default="{}", server_default="{}"
)
death_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
calm_period_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
died_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+84
View File
@@ -0,0 +1,84 @@
"""镜生域模型:灵胚、执镜关系、命台、魂籍(data-model.md §1)。
- 灵胚只有预备身份,启镜(activate)才产生正式人物与执镜关系;
- 命台人物粒度唯一(UNIQUE(character_id)),租约到期可被接管,epoch 递增;
- 魂籍版本不可变(UNIQUE(character_id, version)),签名绑定人物与版本。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_custodianship_id, new_embryo_id, new_identity_record_id, new_seat_id
from app.core.time import utc_now
from app.db.base import Base
class EmbryoDraft(Base):
"""灵胚:启镜前的可审阅草稿。放弃灵胚是删除草稿,不是人物死亡。"""
__tablename__ = "embryo_drafts"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_embryo_id)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# drafting / previewing / confirming / activated / abandoned
status: Mapped[str] = mapped_column(String(40), nullable=False, default="drafting", index=True)
# original / adopted / imported / serendipity
source: Mapped[str] = mapped_column(String(40), nullable=False, default="original")
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
identity_baseline_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 照心试演引用:[{run_id, prompt, performed_at}],内容在事件日志,不入正式经历
rehearsals_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class Custodianship(Base):
"""执镜关系:治理责任(1.0 一对一),与缘契(covenants)严格分离。"""
__tablename__ = "custodianships"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_custodianship_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, unique=True, index=True
)
custodian_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class RuntimeSeat(Base):
"""命台:人物唯一权威运行位置;holder 持有租约,过期可接管,epoch 每次获取递增。"""
__tablename__ = "runtime_seats"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_seat_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, unique=True, index=True
)
epoch: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
holder: Mapped[str | None] = mapped_column(String(120))
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class IdentityRecord(Base):
"""魂籍:可验证、版本化的身份与状态包。版本不可变;复制不能凭相同 ID 冒充原人物。"""
__tablename__ = "identity_records"
__table_args__ = (UniqueConstraint("character_id", "version", name="uq_identity_char_version"),)
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_identity_record_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
digest: Mapped[str] = mapped_column(String(64), nullable=False)
signature: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+8 -1
View File
@@ -14,11 +14,18 @@ class Run(Base):
__tablename__ = "runs"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_run_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
# rewrite/1.0:照心试演(kind=rehearsal)无正式人物,character_id 允许为空
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), index=True, nullable=True
)
session_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("chat_sessions.id"), index=True
)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True)
# run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2)
kind: Mapped[str] = mapped_column(
String(40), nullable=False, default="foreground", server_default="foreground"
)
goal: Mapped[str | None] = mapped_column(String(300))
origin: Mapped[str] = mapped_column(String(40), nullable=False, default="chat")
model_hint: Mapped[str | None] = mapped_column(String(120))
+8 -2
View File
@@ -66,11 +66,17 @@ class RunResponse(BaseModel):
)
id: str = Field(description="运行 ID,前缀为 run_。")
character_id: str = Field(description="所属人物 ID。")
session_id: str | None = Field(description="关联会话 ID。")
character_id: str | None = Field(
default=None, description="所属人物 ID;照心试演(kind=rehearsal)无正式人物。"
)
session_id: str | None = Field(default=None, description="关联会话 ID。")
status: str = Field(
description="运行状态:queued / running / completed / failed / cancelled / interrupted。"
)
kind: str = Field(
default="foreground",
description="运行种类:foreground / background_task / awakening / rehearsal(契约词典 §8.2)。",
)
goal: str | None = Field(description="运行目标。")
origin: str = Field(description="运行来源,例如 chat。")
model_hint: str | None = Field(description="本次运行使用的逻辑模型名或能力槽位。")
+1
View File
@@ -187,6 +187,7 @@ def run_to_response(run: Run) -> RunResponse:
character_id=run.character_id,
session_id=run.session_id,
status=run.status,
kind=run.kind,
goal=run.goal,
origin=run.origin,
model_hint=run.model_hint,
+344
View File
@@ -0,0 +1,344 @@
"""镜生域 M1 测试。
对应 rewrite-plan M1 验收:
- 启镜前无正式人物 ID(灵胚/试演阶段零 character 行、零 character.activated 事件);
- 并发启镜只有一个赢家(原子闸门 409);命台租约冲突被拒且留痕(seat.contested);
- 魂籍可验证、篡改与移植冒充均被拒(摘要+签名双校验)。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncGenerator
from datetime import timedelta
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.domains.jingsheng import bind_event_store
from app.kernel import Kernel, KernelEventLog
from app.models.character import Character
from app.models.jingsheng import Custodianship, IdentityRecord, RuntimeSeat
from app.services.event_store import DbEventStore
from app.shenshi_client import EchoShenshi
@pytest.fixture
async def jingsheng_client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
from app.main import app
event_store = DbEventStore(AsyncSessionLocal)
bind_event_store(event_store)
app.state.kernel = Kernel(
shenshi=EchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(event_store),
timeout_seconds=10.0,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
app.state.kernel = None
async def _create_embryo(client: AsyncClient, name: str = "试灵") -> dict:
resp = await client.post(
"/api/v1/embryo-drafts",
json={"source": "original", "name": name, "identity_baseline": {"锚点": "温润"}},
)
assert resp.status_code == 201
return resp.json()
async def _characters_count() -> int:
from sqlalchemy import func, select
async with AsyncSessionLocal() as session:
return int(await session.scalar(select(func.count(Character.id))))
async def _events_of_type(event_type: str) -> list[dict]:
store = DbEventStore(AsyncSessionLocal)
all_events = []
async with AsyncSessionLocal() as session:
from sqlalchemy import select
from app.models.runtime import Event
rows = (
(await session.execute(select(Event).where(Event.type == event_type))).scalars().all()
)
all_events = [json.loads(r.payload_json) for r in rows]
_ = store
return all_events
async def _wait_run(client: AsyncClient, run_id: str) -> str:
for _ in range(300):
resp = await client.get(f"/api/v1/runs/{run_id}")
status = resp.json()["status"]
if status in ("completed", "failed", "interrupted", "cancelled"):
return status
await asyncio.sleep(0.02)
raise AssertionError("Run 未到达终态")
# ── 启镜前无正式人物 ──────────────────────────────────────────────
async def test_embryo_and_rehearsal_create_no_character(jingsheng_client: AsyncClient) -> None:
before = await _characters_count()
embryo = await _create_embryo(jingsheng_client)
patched = await jingsheng_client.patch(
f"/api/v1/embryo-drafts/{embryo['id']}", json={"description": "照心共塑"}
)
assert patched.status_code == 200
rehearsed = await jingsheng_client.post(
f"/api/v1/embryo-drafts/{embryo['id']}/rehearse", json={"prompt": "你是谁?"}
)
assert rehearsed.status_code == 200
run_id = rehearsed.json()["run_id"]
assert await _wait_run(jingsheng_client, run_id) == "completed"
run_detail = (await jingsheng_client.get(f"/api/v1/runs/{run_id}")).json()
assert run_detail["kind"] == "rehearsal"
embryo_now = (await jingsheng_client.get(f"/api/v1/embryo-drafts/{embryo['id']}")).json()
assert embryo_now["status"] == "previewing"
assert embryo_now["rehearsals"][0]["run_id"] == run_id
# 验收:启镜前零人物行、零 character.activated 事件
assert await _characters_count() == before
assert await _events_of_type("character.activated") == []
# ── 启镜与并发防护 ──────────────────────────────────────────────
async def test_activate_issues_identity_and_guards(jingsheng_client: AsyncClient) -> None:
embryo = await _create_embryo(jingsheng_client, name="镜心")
activated = await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")
assert activated.status_code == 200
character_id = activated.json()["character_id"]
assert activated.json()["source"] == "original"
async with AsyncSessionLocal() as session:
from sqlalchemy import select
character = await session.get(Character, character_id)
assert character is not None and character.status == "active"
assert character.identity_baseline_json == json.dumps({"锚点": "温润"}, ensure_ascii=False)
cust = (
await session.execute(
select(Custodianship).where(Custodianship.character_id == character_id)
)
).scalar_one()
assert cust.custodian_scope == character.owner_scope
seat = (
await session.execute(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
).scalar_one()
assert seat.epoch == 1 and seat.holder is None
record = (
await session.execute(
select(IdentityRecord).where(IdentityRecord.character_id == character_id)
)
).scalar_one()
assert record.version == 1
events = await _events_of_type("character.activated")
assert any(e.get("embryo_id") == embryo["id"] for e in events)
# 二次启镜被拒
again = await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")
assert again.status_code == 409
assert "HJ-1103" in again.json()["error"]["code"]
async def test_concurrent_activation_single_winner(jingsheng_client: AsyncClient) -> None:
embryo = await _create_embryo(jingsheng_client, name="并发灵")
results = await asyncio.gather(
jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate"),
jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate"),
)
statuses = sorted(r.status_code for r in results)
assert statuses == [200, 409]
winner = next(r for r in results if r.status_code == 200)
loser = next(r for r in results if r.status_code == 409)
assert winner.json()["character_id"]
assert "HJ-1103" in loser.json()["error"]["code"]
# ── 魂籍:验证、篡改与冒充 ──────────────────────────────────────────────
async def test_identity_record_verify_and_tamper(jingsheng_client: AsyncClient) -> None:
embryo = await _create_embryo(jingsheng_client, name="魂籍灵")
character_id = (
(await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()[
"character_id"
]
)
exported = await jingsheng_client.post(f"/api/v1/characters/{character_id}/identity-records")
assert exported.status_code == 200
record_id = exported.json()["id"]
ok = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/identity-records/{record_id}/verify"
)
assert ok.status_code == 200 and ok.json()["valid"] is True
# 篡改 payload:摘要不匹配
async with AsyncSessionLocal() as session:
record = await session.get(IdentityRecord, record_id)
payload = json.loads(record.payload_json)
payload["identity"]["name"] = "冒充者"
record.payload_json = json.dumps(payload, ensure_ascii=False)
await session.commit()
tampered = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/identity-records/{record_id}/verify"
)
assert tampered.status_code == 422
# 移植冒充:把整条记录复制到另一人物名下,签名不匹配
other = await _create_embryo(jingsheng_client, name="另一灵")
other_character = (
(await jingsheng_client.post(f"/api/v1/embryo-drafts/{other['id']}/activate")).json()[
"character_id"
]
)
async with AsyncSessionLocal() as session:
original = await session.get(IdentityRecord, record_id)
session.add(
IdentityRecord(
character_id=other_character,
version=9, # 避开另一人物已有的 v1;冒充场景本身与版本号无关
payload_json=original.payload_json,
digest=original.digest,
signature=original.signature, # 原样复制——文件复制不能冒充
)
)
await session.commit()
async with AsyncSessionLocal() as session:
from sqlalchemy import select
forged = (
await session.execute(
select(IdentityRecord).where(
IdentityRecord.character_id == other_character,
IdentityRecord.version == 9,
)
)
).scalar_one()
forged_id = forged.id
forged_check = await jingsheng_client.post(
f"/api/v1/characters/{other_character}/identity-records/{forged_id}/verify"
)
assert forged_check.status_code == 422
# ── 命台租约:冲突留痕、过期接管 ──────────────────────────────────────────────
async def _activated_character(client: AsyncClient, name: str) -> str:
embryo = await _create_embryo(client, name=name)
return (
(await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"]
)
async def test_seat_lease_conflict_and_takeover(jingsheng_client: AsyncClient) -> None:
character_id = await _activated_character(jingsheng_client, "命台灵")
first = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-A"}
)
assert first.status_code == 200 and first.json()["epoch"] == 1
conflict = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-B"}
)
assert conflict.status_code == 429
assert conflict.json()["error"]["code"] == "HJ-1101"
# 验收:抢占被拒且留痕
async with AsyncSessionLocal() as session:
from sqlalchemy import select
from app.models.runtime import Event
rows = (
await session.execute(select(Event).where(Event.type == "seat.contested"))
).scalars().all()
assert any(json.loads(r.payload_json).get("requester") == "device-B" for r in rows)
# 同持有者续期:不冲突
renew = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-A"}
)
assert renew.status_code == 200
# 租约过期后接管:epoch 递增
async with AsyncSessionLocal() as session:
seat = (
await session.execute(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
).scalar_one()
seat.lease_expires_at = seat.lease_expires_at - timedelta(seconds=9999)
await session.commit()
takeover = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-B"}
)
assert takeover.status_code == 200 and takeover.json()["epoch"] == 2
# ── 死亡:冷静期、撤销与落定 ──────────────────────────────────────────────
async def test_death_calm_period_flow(jingsheng_client: AsyncClient) -> None:
character_id = await _activated_character(jingsheng_client, "将别灵")
requested = await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-request")
assert requested.status_code == 200
assert requested.json()["status"] == "death_pending"
early = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "将别灵"}
)
assert early.status_code == 422 # 冷静期未满
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
character.calm_period_until = character.calm_period_until - timedelta(hours=100)
await session.commit()
wrong_phrase = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "错误名"}
)
assert wrong_phrase.status_code == 400
finalized = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "将别灵"}
)
assert finalized.status_code == 200
assert finalized.json()["status"] == "deceased"
# 死亡是身份闭合,不是数据删除:行仍在
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
assert character is not None and character.status == "deceased"
assert character.died_at is not None
events = await _events_of_type("character.deceased")
assert events
async def test_death_cancel_restores_active(jingsheng_client: AsyncClient) -> None:
character_id = await _activated_character(jingsheng_client, "回心灵")
await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-request")
cancelled = await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-cancel")
assert cancelled.status_code == 200 and cancelled.json()["status"] == "active"
events = await _events_of_type("character.death_cancelled")
assert events