2026-06-20 22:16:07 +08:00
|
|
|
|
"""Token 子域 Router。
|
|
|
|
|
|
|
|
|
|
|
|
外部系统限界上下文的 Token 管理 API,覆盖 Token 查询 / 详情 / 统计 /
|
|
|
|
|
|
历史 / 使用记录 / 失效(单条 + 环境级)/ 即将过期查询 / 轮换预览。
|
|
|
|
|
|
所有端点通过 ``create_use_cases_from_db`` 装配 use_cases,经 ``token_service`` 端口调用用例。
|
|
|
|
|
|
|
|
|
|
|
|
路径顺序约束:静态路径(``/stats``、``/history``、``/invalidate-environment``、``/expiring``)
|
|
|
|
|
|
必须在 ``/{token_id}`` 前声明,避免被路径参数捕获。
|
|
|
|
|
|
|
|
|
|
|
|
注意:``get_token_history`` 实际按 ``system_id`` 查询审计日志(非 token_id),
|
|
|
|
|
|
故采用静态路径 ``/history`` + 必填 Query ``system_id``,路径语义与查询语义对齐。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
from yuxi.external_systems.infrastructure.container import create_use_cases_from_db
|
|
|
|
|
|
from yuxi.external_systems.use_cases.dto.token import (
|
|
|
|
|
|
GetTokenDetailInput,
|
|
|
|
|
|
GetTokenHistoryInput,
|
|
|
|
|
|
GetTokenRotationPreviewInput,
|
|
|
|
|
|
GetTokenStatsInput,
|
|
|
|
|
|
InvalidateEnvironmentTokensInput,
|
|
|
|
|
|
InvalidateTokenInput,
|
|
|
|
|
|
ListExpiringTokensInput,
|
|
|
|
|
|
ListTokensInput,
|
|
|
|
|
|
ListTokenUsageInput,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.storage.postgres.models_business import User
|
|
|
|
|
|
|
|
|
|
|
|
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
|
|
|
|
|
|
|
|
|
|
|
|
token_router = APIRouter(prefix="/tokens", tags=["external-systems-token"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- Request Schemas ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InvalidateEnvironmentTokensRequest(BaseModel):
|
|
|
|
|
|
"""失效环境级 Token 请求体。字段对齐 ``InvalidateEnvironmentTokensInput``(不含 invalidated_by)。
|
|
|
|
|
|
|
|
|
|
|
|
``invalidated_by`` 由 Router 从 ``current_user.uid`` 注入。
|
|
|
|
|
|
``env_key`` 长度约束对齐 ``ExternalSystemEnvironment`` ORM 列定义(String(32)),
|
|
|
|
|
|
在边界层拦截非法输入。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
system_id: int
|
|
|
|
|
|
env_key: str = Field(..., min_length=1, max_length=32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 静态路径端点(必须在 /{token_id} 之前声明) ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@token_router.get("/stats", response_model=dict)
|
|
|
|
|
|
async def get_token_stats(
|
|
|
|
|
|
system_id: int | None = Query(None),
|
|
|
|
|
|
env_key: str | None = Query(None, max_length=32),
|
2026-07-11 06:57:24 +08:00
|
|
|
|
expiring_days: int = Query(7, ge=1, le=90, description="expiring_soon 阈值天数"),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-11 06:57:24 +08:00
|
|
|
|
"""获取 Token 统计信息。
|
|
|
|
|
|
|
|
|
|
|
|
返回字段:``total`` / ``active`` / ``invalidated`` / ``expired`` / ``expiring_soon``。
|
|
|
|
|
|
``active = total - invalidated - expired``,``expiring_days`` 控制 ``expiring_soon``
|
|
|
|
|
|
的阈值天数(默认 7,与 ``/expiring`` 端点保持一致)。
|
|
|
|
|
|
"""
|
2026-06-20 22:16:07 +08:00
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
2026-07-11 06:57:24 +08:00
|
|
|
|
input_dto = GetTokenStatsInput(
|
|
|
|
|
|
system_id=system_id,
|
|
|
|
|
|
env_key=env_key,
|
|
|
|
|
|
expiring_days=expiring_days,
|
|
|
|
|
|
)
|
2026-06-20 22:16:07 +08:00
|
|
|
|
output = await use_cases.token_service.get_token_stats(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@token_router.get("/history", response_model=dict)
|
|
|
|
|
|
async def get_token_history(
|
|
|
|
|
|
system_id: int = Query(..., description="按系统 ID 查询 Token 变更历史"),
|
|
|
|
|
|
env_key: str | None = Query(None, max_length=32),
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=500),
|
|
|
|
|
|
offset: int = Query(0, ge=0),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""获取 Token 变更历史(基于审计日志)。
|
|
|
|
|
|
|
2026-07-11 06:57:24 +08:00
|
|
|
|
按 ``system_id`` 查询 Token 子域相关的审计事件(失效 / 环境失效 / 刷新成功 / 刷新失败),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
从审计日志的 ``snapshot_after`` 构造 ``TokenHistoryItem``(Token 字段 + 审计元数据)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = GetTokenHistoryInput(
|
|
|
|
|
|
system_id=system_id,
|
|
|
|
|
|
env_key=env_key,
|
2026-07-11 06:57:24 +08:00
|
|
|
|
limit=limit,
|
|
|
|
|
|
offset=offset,
|
2026-06-20 22:16:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.token_service.get_token_history(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@token_router.post("/invalidate-environment", response_model=dict)
|
|
|
|
|
|
async def invalidate_environment_tokens(
|
|
|
|
|
|
body: InvalidateEnvironmentTokensRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-11 06:57:24 +08:00
|
|
|
|
"""失效指定系统 + 环境下的 Token(幂等)。
|
2026-06-20 22:16:07 +08:00
|
|
|
|
|
|
|
|
|
|
``(system_id, env_key)`` 唯一对应一条 Token,``invalidated_by`` 由当前管理员填充,
|
2026-07-11 06:57:24 +08:00
|
|
|
|
写入审计日志以追溯操作人。已失效的 Token 抛 ``ConflictError``(409)。
|
2026-06-20 22:16:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = InvalidateEnvironmentTokensInput(
|
|
|
|
|
|
system_id=body.system_id,
|
|
|
|
|
|
env_key=body.env_key,
|
|
|
|
|
|
invalidated_by=current_user.uid,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.token_service.invalidate_environment_tokens(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@token_router.get("/expiring", response_model=dict)
|
|
|
|
|
|
async def list_expiring_tokens(
|
|
|
|
|
|
days: int = Query(7, ge=1, le=90),
|
|
|
|
|
|
system_id: int | None = Query(None),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
env_key: str | None = Query(None, max_length=32),
|
|
|
|
|
|
token_type: str | None = Query(None),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
limit: int = Query(100, ge=1, le=500),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
offset: int = Query(0, ge=0),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-11 06:57:24 +08:00
|
|
|
|
"""查询即将过期的 Token(默认 7 天内,未失效)。
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
所有过滤条件(``system_id`` / ``env_key`` / ``token_type``)均下推至 DB 层,
|
|
|
|
|
|
``offset`` 支持分页,与 ``/`` 列表端点契约对齐。
|
2026-07-11 06:57:24 +08:00
|
|
|
|
"""
|
2026-06-20 22:16:07 +08:00
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = ListExpiringTokensInput(
|
|
|
|
|
|
days=days,
|
|
|
|
|
|
system_id=system_id,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
env_key=env_key,
|
|
|
|
|
|
token_type=token_type,
|
2026-06-20 22:16:07 +08:00
|
|
|
|
limit=limit,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
offset=offset,
|
2026-06-20 22:16:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.token_service.list_expiring_tokens(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 根路径端点 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@token_router.get("", response_model=dict)
|
|
|
|
|
|
async def list_tokens(
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=500),
|
|
|
|
|
|
offset: int = Query(0, ge=0),
|
|
|
|
|
|
system_id: int | None = Query(None),
|
|
|
|
|
|
env_key: str | None = Query(None, max_length=32),
|
|
|
|
|
|
token_type: str | None = Query(None),
|
|
|
|
|
|
is_invalidated: bool | None = Query(None),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
sort_by: str | None = Query(None, description="排序字段:updated_at/expires_at/created_at"),
|
|
|
|
|
|
sort_order: str = Query("desc", pattern="^(asc|desc)$", description="排序方向"),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-11 06:57:24 +08:00
|
|
|
|
"""分页列出 Token。
|
|
|
|
|
|
|
|
|
|
|
|
所有过滤条件(``system_id`` / ``env_key`` / ``token_type`` / ``is_invalidated``)
|
|
|
|
|
|
均下推至 DB 层,保证分页 ``total`` 与 ``items`` 的一致性。
|
2026-07-11 21:39:05 +08:00
|
|
|
|
``sort_by`` / ``sort_order`` 控制排序,由仓储白名单校验防注入。
|
2026-07-11 06:57:24 +08:00
|
|
|
|
"""
|
2026-06-20 22:16:07 +08:00
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = ListTokensInput(
|
2026-07-11 06:57:24 +08:00
|
|
|
|
limit=limit,
|
|
|
|
|
|
offset=offset,
|
2026-06-20 22:16:07 +08:00
|
|
|
|
system_id=system_id,
|
|
|
|
|
|
env_key=env_key,
|
|
|
|
|
|
token_type=token_type,
|
|
|
|
|
|
is_invalidated=is_invalidated,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
sort_by=sort_by,
|
|
|
|
|
|
sort_order=sort_order,
|
2026-06-20 22:16:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.token_service.list_tokens(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 动态路径端点 /{token_id} ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@token_router.get("/{token_id}", response_model=dict)
|
|
|
|
|
|
async def get_token_detail(
|
|
|
|
|
|
token_id: int,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-11 06:57:24 +08:00
|
|
|
|
"""获取 Token 详情。
|
|
|
|
|
|
|
|
|
|
|
|
返回 Token 字段 + 关联系统上下文(``system_name`` / ``system_slug``),
|
|
|
|
|
|
``access_token`` / ``refresh_token`` 脱敏为 ``***``。
|
|
|
|
|
|
"""
|
2026-06-20 22:16:07 +08:00
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = GetTokenDetailInput(id=token_id)
|
|
|
|
|
|
output = await use_cases.token_service.get_token_detail(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@token_router.post("/{token_id}/invalidate", response_model=dict)
|
|
|
|
|
|
async def invalidate_token(
|
|
|
|
|
|
token_id: int,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-11 06:57:24 +08:00
|
|
|
|
"""失效 Token(幂等)。``invalidated_by`` 由当前管理员填充,写入审计日志以追溯操作人。
|
|
|
|
|
|
|
|
|
|
|
|
已失效的 Token 抛 ``ConflictError``(409),避免重复审计日志与事件发布。
|
|
|
|
|
|
"""
|
2026-06-20 22:16:07 +08:00
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = InvalidateTokenInput(
|
|
|
|
|
|
id=token_id,
|
|
|
|
|
|
invalidated_by=current_user.uid,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.token_service.invalidate_token(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@token_router.get("/{token_id}/usage", response_model=dict)
|
|
|
|
|
|
async def list_token_usage(
|
|
|
|
|
|
token_id: int,
|
|
|
|
|
|
limit: int = Query(20, ge=1, le=500),
|
|
|
|
|
|
offset: int = Query(0, ge=0),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""查询 Token 使用记录。
|
|
|
|
|
|
|
2026-07-11 06:57:24 +08:00
|
|
|
|
基于 ``ExternalToolExecution`` 表按 ``(system_id, env_key)`` 关联查询,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
返回使用该 Token 的工具执行记录摘要(id / execution_id / tool_slug /
|
|
|
|
|
|
status / started_at / ended_at / duration_ms / operation / caller)。
|
2026-06-20 22:16:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = ListTokenUsageInput(
|
|
|
|
|
|
token_id=token_id,
|
2026-07-11 06:57:24 +08:00
|
|
|
|
limit=limit,
|
|
|
|
|
|
offset=offset,
|
2026-06-20 22:16:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.token_service.list_token_usage(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@token_router.get("/{token_id}/rotation-preview", response_model=dict)
|
|
|
|
|
|
async def get_token_rotation_preview(
|
|
|
|
|
|
token_id: int,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
expiring_days: int = Query(7, ge=1, le=90, description="expiring_soon 阈值天数"),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-11 06:57:24 +08:00
|
|
|
|
"""预览 Token 轮换的影响范围(关联系统、环境、工具、密钥轮换策略)。
|
2026-06-20 22:16:07 +08:00
|
|
|
|
|
|
|
|
|
|
只读查询(不调用外部适配器),对齐 ``secret_rotation_router.precheck_rotation``
|
|
|
|
|
|
的权限梯度,使用 ``get_required_user``。
|
2026-07-11 06:57:24 +08:00
|
|
|
|
|
|
|
|
|
|
返回字段:``token_status``(active/expiring_soon/expired/invalidated)+
|
|
|
|
|
|
``affected_system`` + ``affected_environment`` + ``affected_tools`` +
|
|
|
|
|
|
``affected_policies``(关联的密钥轮换策略列表)。
|
2026-07-11 21:39:05 +08:00
|
|
|
|
|
|
|
|
|
|
``expiring_days`` 控制 ``token_status`` 中 ``expiring_soon`` 的阈值天数,
|
|
|
|
|
|
与 ``/stats``、``/expiring`` 端点保持一致。
|
2026-06-20 22:16:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
input_dto = GetTokenRotationPreviewInput(
|
|
|
|
|
|
token_id=token_id,
|
|
|
|
|
|
expiring_days=expiring_days,
|
|
|
|
|
|
)
|
2026-06-20 22:16:07 +08:00
|
|
|
|
output = await use_cases.token_service.get_token_rotation_preview(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|