Files
hunjing/backend/app/characters/routes.py
T
chenxin 40221170ae ✨ feat: WP-02 角色域——完整生命周期、铸魂、内驱力与随缘
- 迁移 6:character(生命周期六态检查约束、起源、现实人物治理标记、道体基线、内驱力)+ character_version(baseline/first_statement/drive_revision/reforging/persona_candidate 五类不可变版本与审核态)
- 创建:手动与随缘(确定性词库组合,同种子同候选、一次一个——不是抽卡);现实人物治理(已故拒绝、未声明授权拒绝)
- 启镜(draft→active + 首个 baseline 版本)与初照自陈(候选→确认归真并入道体)
- 生命周期端点:静修/归藏/复照(归档可恢复、复照不抹历史)+ 死亡两步确认(7 天冷静期可撤销、未满确认 422);非法状态迁移 422
- 铸魂(显式 confirm 强确认,直接更新道体)与人格候选(pending→审核批准才并入道体,不静默覆盖)
- 内驱力编辑:写当前值并落 drive_revision 版本(可追溯,ADR-018)
- 测试 52 项全绿(+8):状态机全路径、随缘确定性、现实人物三例、铸魂确认、候选审核与重复审核 409、跨域 fail-closed
2026-09-10 18:11:15 +08:00

240 lines
7.2 KiB
Python

"""角色域路由(WP-02)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.characters import service
from app.conversations.routes import _domain_of # 复用域解析
from app.db.session import DbSession
from app.models import Character, CharacterVersion, User
router = APIRouter(prefix="/v1/characters", tags=["characters"])
class DriveIn(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
description: str
intensity: int = Field(default=1, ge=1, le=5)
class DrivesUpdateIn(BaseModel):
model_config = ConfigDict(extra="forbid")
drives: list[DriveIn] = Field(max_length=16)
def _character_out(c: Character) -> dict[str, Any]:
return {
"id": c.id,
"name": c.name,
"status": c.status,
"origin": c.origin,
"based_on_real_person": c.based_on_real_person,
"identity_baseline": c.identity_baseline,
"drives": c.drives,
"current_version_id": c.current_version_id,
"created_at": c.created_at.isoformat(),
}
def _version_out(v: CharacterVersion) -> dict[str, Any]:
return {
"id": v.id,
"character_id": v.character_id,
"kind": v.kind,
"status": v.status,
"payload": v.payload,
"created_at": v.created_at.isoformat(),
}
@router.get("")
async def list_characters(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
limit: int = 50,
cursor: str | None = None,
) -> dict[str, Any]:
if not 1 <= limit <= 200:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-200")
domain = await _domain_of(db, user)
stmt = select(Character).where(Character.personal_domain_id == domain.id)
stmt = stmt.order_by(Character.created_at.asc(), Character.id.asc()).limit(limit + 1)
rows = list((await db.scalars(stmt)).all())
has_more = len(rows) > limit
return {
"data": [_character_out(c) for c in rows[:limit]],
"next_cursor": rows[limit].id if has_more else None,
"has_more": has_more,
}
@router.post("", status_code=201)
async def create_character(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
character = await service.create_character(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
return _character_out(character)
@router.get("/{character_id}")
async def get_character(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
character = await service.require_character(db, domain.id, character_id)
return _character_out(character)
@router.post("/{character_id}/activations", status_code=201)
async def activate(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
character = await service.activate_character(
db, domain.id, character_id, getattr(request.state, "trace_id", None)
)
return _character_out(character)
@router.post("/{character_id}/first-statement", status_code=201)
async def submit_first_statement(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.submit_first_statement(
db, domain.id, character_id, body.get("text", ""),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
@router.post("/{character_id}/first-statement/confirmations", status_code=201)
async def confirm_first_statement(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.confirm_first_statement(
db, domain.id, character_id, body.get("version_id", ""),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
LIFECYCLE_ACTIONS = {
"rests": "rest",
"archives": "archive",
"revivals": "revive",
"death-requests": "request_death",
"death-cancellations": "cancel_death",
"death-confirmations": "confirm_death",
}
@router.post("/{character_id}/reforgings", status_code=201)
async def reforge(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.reforge_character(
db, domain.id, character_id, body, getattr(request.state, "trace_id", None)
)
return _version_out(version)
@router.patch("/{character_id}/drives")
async def update_drives(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: DrivesUpdateIn,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
await service.update_drives(
db, domain.id, character_id,
[d.model_dump() for d in body.drives], getattr(request.state, "trace_id", None),
)
character = await service.require_character(db, domain.id, character_id)
return _character_out(character)
@router.post("/{character_id}/persona-candidates", status_code=201)
async def submit_persona_candidate(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.submit_persona_candidate(
db, domain.id, character_id, body, getattr(request.state, "trace_id", None)
)
return _version_out(version)
@router.post("/{character_id}/versions/{version_id}/decisions", status_code=201)
async def review_version(
character_id: str,
version_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, bool],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.review_version(
db, domain.id, character_id, version_id, bool(body.get("approve")),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
@router.post("/{character_id}/{action}", status_code=200)
async def lifecycle(
character_id: str,
action: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
if action not in LIFECYCLE_ACTIONS:
raise HTTPException(status.HTTP_404_NOT_FOUND, "未知生命周期动作")
domain = await _domain_of(db, user)
character = await service.lifecycle_action(
db, domain.id, character_id, LIFECYCLE_ACTIONS[action],
getattr(request.state, "trace_id", None),
)
return _character_out(character)