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