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,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)
|
||||
Reference in new issue
Block a user