1. 为所有查询参数添加max_length长度限制,规范参数输入范围 2. 使用Literal类型替换普通字符串参数,限定合法取值范围 3. 为路径参数添加Path校验,确保ID参数合法有效 4. 优化请求体参数的声明,补充缺失的Body注解和校验规则 5. 统一分页参数的offset/limit使用方式,替换旧的page/page_size模式
468 lines
18 KiB
Python
468 lines
18 KiB
Python
"""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, Literal
|
||
|
||
from fastapi import APIRouter, Depends, Path, 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, max_length=32),
|
||
quota_key: str | None = Query(None, max_length=128),
|
||
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", max_length=32),
|
||
quota_key: str = Query(..., max_length=128),
|
||
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 = Path(ge=1),
|
||
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, max_length=32, description="按环境键过滤"),
|
||
quota_key: str | None = Query(None, max_length=128, description="按配额键过滤"),
|
||
quota_window: Literal["daily", "hourly", "rolling_24h", "minute", "custom"] | None = Query(
|
||
None, description="窗口类型:daily/hourly/rolling_24h/minute/custom"
|
||
),
|
||
source: Literal["response_header", "manual", "config"] | None = Query(
|
||
None, description="配额来源:response_header/manual/config"
|
||
),
|
||
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, max_length=128, 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 = Path(ge=1),
|
||
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(
|
||
payload: UpdateQuotaRequest,
|
||
quota_id: int = Path(ge=1),
|
||
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(
|
||
payload: SetQuotaThresholdRequest,
|
||
quota_id: int = Path(ge=1),
|
||
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(
|
||
payload: ResetQuotaRequest,
|
||
quota_id: int = Path(ge=1),
|
||
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 = Path(ge=1),
|
||
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()}
|