ForcePilot/backend/server/routers/external_systems/quota_router.py

464 lines
18 KiB
Python
Raw Normal View History

"""Quota 子域 Router。
外部系统限界上下文的配额管理 API覆盖配额列表 / 详情 / 创建 / 更新 /
阈值查询 / 阈值设置 / 删除以及扩展能力按业务键查询 / 接近告警阈值列表 /
接近临界阈值列表 / 即将重置列表 / 统计聚合 / 重置窗口 / 批量调整 /
创建或更新 / 按系统批量删除所有端点通过 ``create_use_cases_from_db`` 装配
use_cases ``quota_service`` 端口调用用例
Request Schema Input DTO 不共享类Router 内显式构造 DTO
路径顺序约束静态路径``/thresholds`` / ``/by-key`` / ``/near-warning`` /
``/near-critical`` / ``/expiring`` / ``/stats`` / ``/batch-adjust`` /
``/upsert`` / ``/by-system/{system_id}``必须在 ``/{quota_id}`` 前声明
避免被路径参数捕获
"""
from __future__ import annotations
from datetime import datetime
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.quota import (
BatchAdjustQuotaInput,
CreateQuotaInput,
DeleteQuotaInput,
DeleteQuotasBySystemInput,
GetQuotaByKeyInput,
GetQuotaInput,
GetQuotaStatsInput,
GetQuotaThresholdInput,
ListExpiringInput,
ListNearCriticalInput,
ListNearWarningInput,
ListQuotasInput,
QuotaAdjustmentItem,
ResetQuotaInput,
SetQuotaThresholdInput,
UpdateQuotaInput,
UpsertQuotaInput,
)
from yuxi.storage.postgres.models_business import User
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
quota_router = APIRouter(prefix="/quotas", tags=["external-systems-quota"])
# ---------------- Request Schemas ----------------
class CreateQuotaRequest(BaseModel):
"""创建配额请求体。字段对齐 ``CreateQuotaInput``。
字符串字段长度约束对齐 ``ExternalSystemQuotaUsage`` ORM 列定义在边界层
拦截非法输入``window_start``/``window_end`` 使用 datetime 类型
Pydantic 解析 ISO 8601 字符串阈值字段约束 0-100
``SetQuotaThresholdRequest`` 保持一致
"""
model_config = ConfigDict(frozen=True)
system_id: int
quota_key: str = Field(..., min_length=1, max_length=128)
quota_name: str = Field(..., min_length=1, max_length=128)
quota_window: str = Field(..., min_length=1, max_length=32)
env_key: str = Field(default="default", min_length=1, max_length=32)
window_start: datetime | None = None
window_end: datetime | None = None
limit_value: int = Field(default=0, ge=0)
used_value: int = Field(default=0, ge=0)
unit: str = Field(default="calls", min_length=1, max_length=32)
source: str = Field(default="config", min_length=1, max_length=32)
source_header_name: str | None = Field(default=None, max_length=128)
warning_threshold: int = Field(default=80, ge=0, le=100)
critical_threshold: int = Field(default=95, ge=0, le=100)
class SetQuotaThresholdRequest(BaseModel):
"""设置配额阈值请求体。字段对齐 ``SetQuotaThresholdInput``(不含 id
仅透传客户端显式设置的字段
"""
model_config = ConfigDict(frozen=True)
warning_threshold: int | None = Field(default=None, ge=0, le=100)
critical_threshold: int | None = Field(default=None, ge=0, le=100)
class UpdateQuotaRequest(BaseModel):
"""更新配额请求体。字段对齐 ``UpdateQuotaInput``(不含 id
所有字段可选支持部分更新约束与 ``CreateQuotaRequest`` 保持一致
在边界层拦截非法输入仅透传客户端显式设置的字段
"""
model_config = ConfigDict(frozen=True)
quota_name: str | None = Field(default=None, min_length=1, max_length=128)
limit_value: int | None = Field(default=None, ge=0)
used_value: int | None = Field(default=None, ge=0)
unit: str | None = Field(default=None, min_length=1, max_length=32)
source: str | None = Field(default=None, min_length=1, max_length=32)
source_header_name: str | None = Field(default=None, max_length=128)
warning_threshold: int | None = Field(default=None, ge=0, le=100)
critical_threshold: int | None = Field(default=None, ge=0, le=100)
window_start: datetime | None = None
window_end: datetime | None = None
class ResetQuotaRequest(BaseModel):
"""重置配额窗口请求体。字段对齐 ``ResetQuotaInput``(不含 id"""
model_config = ConfigDict(frozen=True)
window_start: datetime | None = None
window_end: datetime | None = None
used_value: int = Field(default=0, ge=0)
class QuotaAdjustmentItemRequest(BaseModel):
"""批量调整项请求体(对齐 ``QuotaAdjustmentItem`` DTO"""
model_config = ConfigDict(frozen=True)
quota_id: int = Field(..., ge=1)
limit_value: int = Field(..., ge=0)
reason: str | None = Field(default=None, max_length=500)
class BatchAdjustQuotaRequest(BaseModel):
"""批量调整配额限额请求体。"""
model_config = ConfigDict(frozen=True)
adjustments: list[QuotaAdjustmentItemRequest] = Field(..., min_length=1, max_length=100)
class UpsertQuotaRequest(BaseModel):
"""创建或更新配额请求体。字段对齐 ``UpsertQuotaInput``。"""
model_config = ConfigDict(frozen=True)
system_id: int
quota_key: str = Field(..., min_length=1, max_length=128)
quota_name: str = Field(..., min_length=1, max_length=128)
quota_window: str = Field(..., min_length=1, max_length=32)
env_key: str = Field(default="default", min_length=1, max_length=32)
window_start: datetime | None = None
window_end: datetime | None = None
limit_value: int = Field(default=0, ge=0)
used_value: int = Field(default=0, ge=0)
unit: str = Field(default="calls", min_length=1, max_length=32)
source: str = Field(default="config", min_length=1, max_length=32)
source_header_name: str | None = Field(default=None, max_length=128)
warning_threshold: int = Field(default=80, ge=0, le=100)
critical_threshold: int = Field(default=95, ge=0, le=100)
# ---------------- Endpoints ----------------
# ---- 静态路径端点(必须在 /{quota_id} 之前声明) ----
@quota_router.get("/thresholds", response_model=dict)
async def get_quota_threshold(
system_id: int = Query(...),
env_key: str | None = Query(None),
quota_key: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取达到告警阈值的配额列表(含临界)。
``system_id`` 为必填过滤维度对齐 ``GetQuotaThresholdInput`` 契约
"""
use_cases = create_use_cases_from_db(db)
input_dto = GetQuotaThresholdInput(
system_id=system_id,
env_key=env_key,
quota_key=quota_key,
)
output = await use_cases.quota_service.get_quota_threshold(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.get("/by-key", response_model=dict)
async def get_quota_by_key(
system_id: int = Query(...),
env_key: str = Query("default"),
quota_key: str = Query(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""按业务键查询配额。无数据返回 null。
探测性查询语义配额可能尚未配置调用方需自行处理 null与按 ID
查询``GET /{quota_id}``不存在抛 404的语义不同
"""
use_cases = create_use_cases_from_db(db)
input_dto = GetQuotaByKeyInput(
system_id=system_id,
env_key=env_key,
quota_key=quota_key,
)
output = await use_cases.quota_service.get_quota_by_key(input_dto)
return {"success": True, "data": output.model_dump() if output else None}
@quota_router.get("/near-warning", response_model=dict)
async def list_near_warning(
limit: int = Query(100, ge=1, le=500, description="返回记录数上限"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""列出接近告警阈值的配额(全局,含临界)。
``GET /thresholds`` 的区别本端点为全局查询 ``system_id``
过滤用于跨系统告警大盘
"""
use_cases = create_use_cases_from_db(db)
input_dto = ListNearWarningInput(limit=limit)
output = await use_cases.quota_service.list_near_warning(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.get("/near-critical", response_model=dict)
async def list_near_critical(
limit: int = Query(100, ge=1, le=500, description="返回记录数上限"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""列出接近临界阈值的配额(全局,无 ``system_id`` 过滤)。"""
use_cases = create_use_cases_from_db(db)
input_dto = ListNearCriticalInput(limit=limit)
output = await use_cases.quota_service.list_near_critical(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.get("/expiring", response_model=dict)
async def list_expiring(
before: datetime = Query(..., description="ISO8601 截止时间,返回 window_end <= before 的配额"),
system_id: int | None = Query(None, description="按系统过滤(可选)"),
limit: int = Query(100, ge=1, le=500, description="返回记录数上限"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""列出即将重置的配额(``window_end <= before``)。"""
use_cases = create_use_cases_from_db(db)
input_dto = ListExpiringInput(
before=before,
system_id=system_id,
limit=limit,
)
output = await use_cases.quota_service.list_expiring(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.get("/stats", response_model=dict)
async def get_quota_stats(
system_id: int | None = Query(None, description="按系统过滤(可选,不传则全局聚合)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""配额统计聚合。
所有字段均为精确值统一复用仓储 ``count`` 方法支持 ``system_id``
过滤``total`` / ``by_window`` / ``near_warning_count`` /
``near_critical_count`` / ``expiring_count``窗口已结束需重置
"""
use_cases = create_use_cases_from_db(db)
input_dto = GetQuotaStatsInput(system_id=system_id)
output = await use_cases.quota_service.get_quota_stats(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.post("/batch-adjust", response_model=dict)
async def batch_adjust_quotas(
payload: BatchAdjustQuotaRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量调整配额限额。``updated_by`` 由 ``current_user.uid`` 填充
对齐 ``batch_enable_environments``批量更新操作使用 ``updated_by``"""
use_cases = create_use_cases_from_db(db)
input_dto = BatchAdjustQuotaInput(
adjustments=[
QuotaAdjustmentItem(
quota_id=item.quota_id,
limit_value=item.limit_value,
reason=item.reason,
)
for item in payload.adjustments
],
updated_by=current_user.uid,
)
output = await use_cases.quota_service.batch_adjust_quotas(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.post("/upsert", response_model=dict)
async def upsert_quota(
payload: UpsertQuotaRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建或更新配额(基于 ``system_id+env_key+quota_key`` 冲突时更新)。"""
use_cases = create_use_cases_from_db(db)
input_dto = UpsertQuotaInput(**payload.model_dump(), created_by=current_user.uid)
output = await use_cases.quota_service.upsert_quota(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.delete("/by-system/{system_id}", response_model=dict)
async def delete_quotas_by_system(
system_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""按系统批量软删除配额。"""
use_cases = create_use_cases_from_db(db)
input_dto = DeleteQuotasBySystemInput(system_id=system_id, user=current_user.uid)
output = await use_cases.quota_service.delete_quotas_by_system(input_dto)
return {"success": True, "data": output.model_dump()}
# ---- 根路径端点 ----
@quota_router.get("", response_model=dict)
async def list_quotas(
limit: int = Query(100, ge=1, le=500, description="返回记录数上限"),
offset: int = Query(0, ge=0, description="偏移量(与 limit 配合分页)"),
system_id: int | None = Query(None, description="按系统过滤"),
env_key: str | None = Query(None, description="按环境键过滤"),
quota_key: str | None = Query(None, description="按配额键过滤"),
quota_window: str | None = Query(None, description="按窗口类型过滤day/month/total 等)"),
source: str | None = Query(None, description="按来源过滤config/response_header 等)"),
near_warning: bool = Query(False, description="仅返回接近告警阈值的配额"),
near_critical: bool = Query(False, description="仅返回接近临界阈值的配额"),
expiring_before: datetime | None = Query(None, description="仅返回 window_end <= 该时间的配额"),
keyword: str | None = Query(None, description="按 quota_name / quota_key 模糊匹配"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页列出配额。支持多维度过滤。"""
use_cases = create_use_cases_from_db(db)
input_dto = ListQuotasInput(
limit=limit,
offset=offset,
system_id=system_id,
env_key=env_key,
quota_key=quota_key,
quota_window=quota_window,
source=source,
near_warning=near_warning,
near_critical=near_critical,
expiring_before=expiring_before,
keyword=keyword,
)
output = await use_cases.quota_service.list_quotas(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.post("", response_model=dict)
async def create_quota(
payload: CreateQuotaRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建配额。``created_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = CreateQuotaInput(**payload.model_dump(), created_by=current_user.uid)
output = await use_cases.quota_service.create_quota(input_dto)
return {"success": True, "data": output.model_dump()}
# ---- 动态路径端点 /{quota_id} ----
@quota_router.get("/{quota_id}", response_model=dict)
async def get_quota(
quota_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取配额详情。"""
use_cases = create_use_cases_from_db(db)
input_dto = GetQuotaInput(id=quota_id)
output = await use_cases.quota_service.get_quota(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.put("/{quota_id}", response_model=dict)
async def update_quota(
quota_id: int,
payload: UpdateQuotaRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新配额(部分更新)。仅透传显式设置的字段。``updated_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = UpdateQuotaInput(
id=quota_id,
**payload.model_dump(exclude_unset=True),
updated_by=current_user.uid,
)
output = await use_cases.quota_service.update_quota(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.patch("/{quota_id}/threshold", response_model=dict)
async def set_quota_threshold(
quota_id: int,
payload: SetQuotaThresholdRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""设置配额阈值。仅透传显式设置的字段。``updated_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = SetQuotaThresholdInput(
id=quota_id,
**payload.model_dump(exclude_unset=True),
updated_by=current_user.uid,
)
output = await use_cases.quota_service.set_quota_threshold(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.post("/{quota_id}/reset", response_model=dict)
async def reset_quota(
quota_id: int,
payload: ResetQuotaRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""重置配额窗口。仅透传显式设置的字段。``updated_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = ResetQuotaInput(
id=quota_id,
**payload.model_dump(exclude_unset=True),
updated_by=current_user.uid,
)
output = await use_cases.quota_service.reset_quota(input_dto)
return {"success": True, "data": output.model_dump()}
@quota_router.delete("/{quota_id}", response_model=dict)
async def delete_quota(
quota_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除配额。``user`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = DeleteQuotaInput(id=quota_id, user=current_user.uid)
output = await use_cases.quota_service.delete_quota(input_dto)
return {"success": True, "data": output.model_dump()}