"""司命台管理 API(api-spec §19)。 聚合九枢治理视图:九枢健康度 / 朱批待办 / 起居注审计(§19.1–§19.3) + 五组治理对象 CRUD(§19.4)。治理写操作由 service 层记审计并入起居注。 路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不持有 session; ``ToolRuntime`` / ``ConfirmationStore`` 等运行时对象经 ``deps`` 从 ``app.state`` 注入。 """ import json from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from app.api.v1.deps import ( get_confirmation_store, get_current_user, get_optional_tool_runtime, require_admin, ) from app.application.tool_runtime.runtime import ToolRuntime from app.models.runtime import Event from app.modules.safety.confirmation import ConfirmationStore from app.schemas.admin import ( AdminCreateUserRequest, AdminUpdateUserRequest, AdminUserResponse, AgentRosterResponse, AuditEntry, CreateAgentRequest, CreateGoalRequest, CreatePerceptionChannelRequest, CreatePlanRequest, CreateWorldEntityRequest, GoalNode, PendingApproval, PerceptionChannelResponse, PivotHealthReport, PlanSummaryResponse, TimeEventListResponse, TimeEventResponse, TraceDetail, TraceListResponse, UpdateAgentRequest, UpdateGoalRequest, UpdatePerceptionChannelRequest, UpdatePlanRequest, UpdateWorldEntityRequest, WorldEntityResponse, ) from app.schemas.admin_character import ( AdminCreateTemplateRequest, AdminTemplateSummaryResponse, AdminUpdateTemplateRequest, ) from app.schemas.auth import CurrentUser from app.schemas.common import Page from app.services import governance as governance_service from app.services.admin import ( get_audit_timeline, get_pending_approvals, get_pivots_health, get_trace, list_time_events, list_traces, ) from app.services.admin_characters import ( admin_create_template, admin_delete_template, admin_get_template, admin_list_templates, admin_update_template, ) router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]) # —— §19.1 九枢健康度聚合 —— @router.get("/pivots/health", response_model=PivotHealthReport) async def pivots_health( confirmation_store: ConfirmationStore = Depends(get_confirmation_store), tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime), current_user: CurrentUser = Depends(get_current_user), ) -> PivotHealthReport: return await get_pivots_health( confirmation_store=confirmation_store, tool_runtime=tool_runtime, owner_scope=current_user.owner_scope, ) # —— §19.2 朱批待办 —— @router.get("/safety/pending", response_model=Page[PendingApproval]) async def safety_pending( confirmation_store: ConfirmationStore = Depends(get_confirmation_store), tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime), ) -> Page[PendingApproval]: items = await get_pending_approvals( confirmation_store=confirmation_store, tool_runtime=tool_runtime ) return Page(items=items, page=1, page_size=len(items), total=len(items)) # —— §19.3 起居注审计 —— @router.get("/audit/timeline", response_model=Page[AuditEntry]) async def audit_timeline( pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), ) -> Page[AuditEntry]: return await get_audit_timeline(pivot=pivot, page=page, page_size=page_size) # —— §19.4 太一枢 · 目标 —— @router.get("/goals", response_model=list[GoalNode]) async def list_goals( character_id: str | None = Query(default=None, description="按人物过滤。"), ) -> list[GoalNode]: if character_id: return await governance_service.list_goals_by_character(character_id) return await governance_service.list_goals() @router.post("/goals", response_model=GoalNode) async def create_goal(payload: CreateGoalRequest) -> GoalNode: return await governance_service.create_goal(payload) @router.patch("/goals/{goal_id}", response_model=GoalNode) async def update_goal( goal_id: str, payload: UpdateGoalRequest, ) -> GoalNode: return await governance_service.update_goal(goal_id, payload) @router.delete("/goals/{goal_id}", status_code=204) async def delete_goal(goal_id: str) -> None: await governance_service.delete_goal(goal_id) # —— §19.4 烛照枢 · 感知通道 —— @router.get("/perception/channels", response_model=list[PerceptionChannelResponse]) async def list_perception_channels() -> list[PerceptionChannelResponse]: return await governance_service.list_perception_channels() @router.post("/perception/channels", response_model=PerceptionChannelResponse) async def create_perception_channel( payload: CreatePerceptionChannelRequest, ) -> PerceptionChannelResponse: return await governance_service.create_perception_channel(payload) @router.patch("/perception/channels/{channel}", response_model=PerceptionChannelResponse) async def update_perception_channel( channel: str, payload: UpdatePerceptionChannelRequest, ) -> PerceptionChannelResponse: return await governance_service.update_perception_channel(channel, payload) @router.delete("/perception/channels/{channel}", status_code=204) async def delete_perception_channel(channel: str) -> None: await governance_service.delete_perception_channel(channel) # —— §19.4 河图枢 · 世界实体 —— @router.get("/world/entities", response_model=list[WorldEntityResponse]) async def list_world_entities() -> list[WorldEntityResponse]: return await governance_service.list_world_entities() @router.post("/world/entities", response_model=WorldEntityResponse) async def create_world_entity( payload: CreateWorldEntityRequest, ) -> WorldEntityResponse: return await governance_service.create_world_entity(payload) @router.patch("/world/entities/{entity_id}", response_model=WorldEntityResponse) async def update_world_entity( entity_id: str, payload: UpdateWorldEntityRequest, ) -> WorldEntityResponse: return await governance_service.update_world_entity(entity_id, payload) @router.delete("/world/entities/{entity_id}", status_code=204) async def delete_world_entity(entity_id: str) -> None: await governance_service.delete_world_entity(entity_id) # —— §19.4 洛书枢 · 计划 —— @router.get("/plans", response_model=list[PlanSummaryResponse]) async def list_plans() -> list[PlanSummaryResponse]: return await governance_service.list_plans() @router.post("/plans", response_model=PlanSummaryResponse) async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse: return await governance_service.create_plan(payload) @router.patch("/plans/{plan_id}", response_model=PlanSummaryResponse) async def update_plan( plan_id: str, payload: UpdatePlanRequest, ) -> PlanSummaryResponse: return await governance_service.update_plan(plan_id, payload) @router.delete("/plans/{plan_id}", status_code=204) async def delete_plan(plan_id: str) -> None: await governance_service.delete_plan(plan_id) # —— §19.4 天工阁枢 · 角色在岗 —— @router.get("/agents/roster", response_model=list[AgentRosterResponse]) async def list_agent_roster() -> list[AgentRosterResponse]: return await governance_service.list_agent_roster() @router.post("/agents/roster", response_model=AgentRosterResponse) async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse: return await governance_service.create_agent(payload) @router.patch("/agents/roster/{role}", response_model=AgentRosterResponse) async def update_agent( role: str, payload: UpdateAgentRequest, ) -> AgentRosterResponse: return await governance_service.update_agent(role, payload) @router.delete("/agents/roster/{role}", status_code=204) async def delete_agent(role: str) -> None: await governance_service.delete_agent(role) # —— §19.5 链路追踪 —— @router.get("/traces", response_model=TraceListResponse) async def trace_list( limit: int = Query(default=50, ge=1, le=500), ) -> TraceListResponse: """列出最近活动的 trace_id 概览(跨运行事件 / 安全审计 / 请求幂等聚合)。""" return await list_traces(limit=limit) @router.get("/traces/{trace_id}", response_model=TraceDetail) async def trace_detail(trace_id: str) -> TraceDetail: """按 trace_id 聚合运行事件 / 安全审计 / 请求幂等三表为升序时间线。""" return await get_trace(trace_id) # —— §8 司命台 · 起居注时间河(辰正 time.* 事件)—— @router.get("/time-events", response_model=TimeEventListResponse) async def time_event_list( limit: int = Query(default=100, ge=1, le=200), offset: int = Query(default=0, ge=0), ) -> TimeEventListResponse: """起居注时间河:辰正 time.* 事件(§8 司命台接入,source='time_sense')。""" items, total = await list_time_events(limit=limit, offset=offset) return TimeEventListResponse( items=[_time_event_to_response(item) for item in items], total=total, ) def _time_event_to_response(event: Event) -> TimeEventResponse: """复用既有 payload 解析模式(json.loads + 异常回退到空 dict,与 _event_to_item 一致)。""" try: payload = json.loads(event.payload_json) if event.payload_json else {} except (json.JSONDecodeError, TypeError): payload = {} return TimeEventResponse( id=event.id, type=event.type, payload=payload, run_id=event.run_id, character_id=event.character_id, sensitivity=event.sensitivity, created_at=event.created_at.isoformat() if event.created_at else "", ) # —— §19.6 用户管理 —— @router.get("/users", response_model=list[AdminUserResponse]) async def admin_list_users() -> list[AdminUserResponse]: """列出所有用户(管理员用)。""" from sqlalchemy import select from app.db.session import AsyncSessionLocal from app.models.user import User async with AsyncSessionLocal() as session: result = await session.execute(select(User).order_by(User.created_at)) users = result.scalars().all() return [AdminUserResponse.model_validate(u) for u in users] @router.post("/users", response_model=AdminUserResponse) async def admin_create_user(payload: AdminCreateUserRequest) -> AdminUserResponse: """创建用户(管理员用)。""" from app.db.session import AsyncSessionLocal from app.services.auth import create_user async with AsyncSessionLocal() as session: user = await create_user( session, username=payload.username, email=payload.email, password=payload.password, is_admin=payload.is_admin, ) await session.commit() await session.refresh(user) return AdminUserResponse.model_validate(user) @router.patch("/users/{user_id}", response_model=AdminUserResponse) async def admin_update_user(user_id: str, payload: AdminUpdateUserRequest) -> AdminUserResponse: """更新用户(启用/禁用、管理员角色)。""" from sqlalchemy import select from app.db.session import AsyncSessionLocal from app.models.user import User async with AsyncSessionLocal() as session: result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None: from app.core.errors import AppError raise AppError("not_found", "用户不存在。") if payload.is_active is not None: user.is_active = payload.is_active if payload.is_admin is not None: user.is_admin = payload.is_admin await session.commit() await session.refresh(user) return AdminUserResponse.model_validate(user) # —— §19.7 多租户管理视图 —— class _TenantUserOverview(BaseModel): """租户概览(管理员跨租户视角)。""" user_id: str username: str email: str is_active: bool is_admin: bool character_count: int memory_count: int @router.get("/tenants/overview", response_model=list[_TenantUserOverview]) async def admin_tenants_overview() -> list[_TenantUserOverview]: """跨租户概览:每用户的人物与记忆计数(管理员用)。""" from sqlalchemy import func, select from app.db.session import AsyncSessionLocal from app.models.character import Character from app.models.memory import Memory from app.models.user import User async with AsyncSessionLocal() as session: result = await session.execute(select(User).order_by(User.created_at)) users = result.scalars().all() overview: list[_TenantUserOverview] = [] for user in users: char_count = await session.scalar( select(func.count()) .select_from(Character) .where( Character.owner_scope == user.id, Character.is_system_template.is_(False), ) ) mem_count = await session.scalar( select(func.count()) .select_from(Memory) .join(Character, Memory.character_id == Character.id) .where( Character.owner_scope == user.id, Character.is_system_template.is_(False), ) ) overview.append( _TenantUserOverview( user_id=user.id, username=user.username, email=user.email, is_active=user.is_active, is_admin=user.is_admin, character_count=char_count or 0, memory_count=mem_count or 0, ) ) return overview @router.get("/tenants/{user_id}/characters") async def admin_tenant_characters(user_id: str) -> list[dict]: """查看指定用户的所有人物(跨租户管理员用)。""" from sqlalchemy import select from app.db.session import AsyncSessionLocal from app.models.character import Character from app.services.serializers import character_to_response async with AsyncSessionLocal() as session: result = await session.execute( select(Character) .where( Character.owner_scope == user_id, Character.is_system_template.is_(False), ) .order_by(Character.created_at) ) return [character_to_response(c).model_dump(mode="json") for c in result.scalars().all()] @router.get("/tenants/{user_id}/memories") async def admin_tenant_memories(user_id: str) -> list[dict]: """查看指定用户的所有记忆(跨租户管理员用)。""" from sqlalchemy import select from app.db.session import AsyncSessionLocal from app.models.character import Character from app.models.memory import Memory from app.services.serializers import memory_to_response async with AsyncSessionLocal() as session: result = await session.execute( select(Memory) .join(Character, Memory.character_id == Character.id) .where( Character.owner_scope == user_id, Character.is_system_template.is_(False), ) .order_by(Memory.created_at.desc()) ) return [memory_to_response(m).model_dump(mode="json") for m in result.scalars().all()] # —— §19.8 人物模板管理(管理员专用 CRUD) —— @router.get( "/characters/templates", response_model=list[AdminTemplateSummaryResponse], ) async def list_templates() -> list[AdminTemplateSummaryResponse]: """列出所有系统模板(管理端完整视图,含 profile / model_policy 全文)。""" return await admin_list_templates() @router.get( "/characters/templates/{key}", response_model=AdminTemplateSummaryResponse, ) async def get_template(key: str) -> AdminTemplateSummaryResponse: """获取单个系统模板详情。""" return await admin_get_template(key) @router.post( "/characters/templates", response_model=AdminTemplateSummaryResponse, status_code=201, ) async def create_template( payload: AdminCreateTemplateRequest, ) -> AdminTemplateSummaryResponse: """创建新的系统模板。 如 key 与 YAML 中已存在的 key 相同,下次重启时会被 YAML 配置覆盖。 """ return await admin_create_template(payload) @router.patch( "/characters/templates/{key}", response_model=AdminTemplateSummaryResponse, ) async def update_template( key: str, payload: AdminUpdateTemplateRequest, ) -> AdminTemplateSummaryResponse: """更新系统模板(部分更新)。 仅提供非 None 的字段会被更新。YAML 定义的模板字段会在下次重启时被 YAML 覆盖。 """ return await admin_update_template(key, payload) @router.delete( "/characters/templates/{key}", status_code=204, ) async def delete_template(key: str) -> None: """删除系统模板。 仅允许删除未被任何用户人物引用的模板。 被引用时返回 409 conflict。 """ await admin_delete_template(key)