221 lines
8.5 KiB
Python
221 lines
8.5 KiB
Python
"""镜生域人物端点:魂籍导出与校验、死亡请求/撤销/落定、命台租约(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)
|