"""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, model_validator 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, BatchDeleteQuotaInput, 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 ---------------- QuotaWindowType = Literal["daily", "hourly", "rolling_24h", "minute", "custom"] SourceHeaderStrategy = Literal["remaining", "used", "limit", "fraction_used_limit"] def _validate_thresholds( warning: int | None, critical: int | None, ) -> None: """校验告警阈值不变量:``warning_threshold <= critical_threshold``。 仅在两者均显式传入时校验;部分更新场景(仅传其一)由 Service 层 读取现有值后补全校验。 """ if warning is not None and critical is not None and warning > critical: raise ValueError(f"warning_threshold({warning}) 不能大于 critical_threshold({critical})") class CreateQuotaRequest(BaseModel): """创建配额请求体。字段对齐 ``CreateQuotaInput``。 字符串字段长度约束对齐 ``ExternalSystemQuotaUsage`` ORM 列定义,在边界层 拦截非法输入;``window_start``/``window_end`` 使用 datetime 类型,由 Pydantic 解析 ISO 8601 字符串;阈值字段约束 0-100,且 ``warning_threshold <= critical_threshold``。 """ 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: QuotaWindowType = Field(..., description="窗口类型") 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: Literal["response_header", "manual", "config"] = Field(default="config") source_header_name: str | None = Field(default=None, max_length=128) source_header_strategy: SourceHeaderStrategy = Field( default="remaining", description="响应头解析策略:remaining/used/limit/fraction_used_limit", ) warning_threshold: int = Field(default=80, ge=0, le=100) critical_threshold: int = Field(default=95, ge=0, le=100) @model_validator(mode="after") def _check_thresholds(self) -> CreateQuotaRequest: _validate_thresholds(self.warning_threshold, self.critical_threshold) return self class SetQuotaThresholdRequest(BaseModel): """设置配额阈值请求体。字段对齐 ``SetQuotaThresholdInput``(不含 id)。 仅透传客户端显式设置的字段。两者均传入时校验 ``warning_threshold <= critical_threshold``;仅传其一由 Service 层 读取现有值后补全校验。 """ 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) @model_validator(mode="after") def _check_thresholds(self) -> SetQuotaThresholdRequest: _validate_thresholds(self.warning_threshold, self.critical_threshold) return self class UpdateQuotaRequest(BaseModel): """更新配额请求体。字段对齐 ``UpdateQuotaInput``(不含 id)。 所有字段可选,支持部分更新;约束与 ``CreateQuotaRequest`` 保持一致, 在边界层拦截非法输入。仅透传客户端显式设置的字段。两者均传入时校验 ``warning_threshold <= critical_threshold``;仅传其一由 Service 层 读取现有值后补全校验。 """ model_config = ConfigDict(frozen=True) quota_name: str | None = Field(default=None, min_length=1, max_length=128) quota_window: QuotaWindowType | None = Field(default=None, description="窗口类型") 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: Literal["response_header", "manual", "config"] | None = Field(default=None) source_header_name: str | None = Field(default=None, max_length=128) source_header_strategy: SourceHeaderStrategy | None = Field( default=None, description="响应头解析策略:remaining/used/limit/fraction_used_limit", ) 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 @model_validator(mode="after") def _check_thresholds(self) -> UpdateQuotaRequest: _validate_thresholds(self.warning_threshold, self.critical_threshold) return self class ResetQuotaRequest(BaseModel): """重置配额窗口请求体。字段对齐 ``ResetQuotaInput``(不含 id)。 允许仅重置 ``used_value``(如把用量清零但保持当前窗口),也允许同时更新 窗口范围。传入窗口范围时校验 ``window_start < window_end``。 """ model_config = ConfigDict(frozen=True) window_start: datetime | None = None window_end: datetime | None = None used_value: int | None = Field(default=None, ge=0) @model_validator(mode="after") def _check_window_range(self) -> ResetQuotaRequest: if self.window_start is not None and self.window_end is not None and self.window_start >= self.window_end: raise ValueError("window_start 必须早于 window_end") return self 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 BatchDeleteQuotaRequest(BaseModel): """批量删除配额请求体。""" model_config = ConfigDict(frozen=True) ids: list[int] = 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: QuotaWindowType = Field(..., description="窗口类型") 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: Literal["response_header", "manual", "config"] = Field(default="config") source_header_name: str | None = Field(default=None, max_length=128) source_header_strategy: SourceHeaderStrategy = Field( default="remaining", description="响应头解析策略:remaining/used/limit/fraction_used_limit", ) warning_threshold: int = Field(default=80, ge=0, le=100) critical_threshold: int = Field(default=95, ge=0, le=100) @model_validator(mode="after") def _check_thresholds(self) -> UpsertQuotaRequest: _validate_thresholds(self.warning_threshold, self.critical_threshold) return self # ---------------- 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), 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`` 为必填过滤维度,对齐 ``GetQuotaThresholdInput`` 契约。 """ use_cases = create_use_cases_from_db(db) input_dto = GetQuotaThresholdInput( system_id=system_id, env_key=env_key, quota_key=quota_key, limit=limit, ) 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.delete("/batch", response_model=dict) async def batch_delete_quotas( payload: BatchDeleteQuotaRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """批量删除配额。``user`` 由 ``current_user.uid`` 填充。 部分成功语义:信封 ``success`` 固定为 ``True``(表示请求已处理), 操作级结果(``deleted_count`` / ``failed_count`` / ``failures``)在 ``data`` 内返回,对齐 ``batch_adjust_quotas`` 及其他批量端点约定。 """ use_cases = create_use_cases_from_db(db) input_dto = BatchDeleteQuotaInput( ids=payload.ids, user=current_user.uid, ) output = await use_cases.quota_service.batch_delete_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`` 冲突时更新)。 ``created_by`` / ``updated_by`` 均由 ``current_user.uid`` 填充: - 新建场景:``created_by`` 生效,``updated_by`` 同步填充以便首次审计完整。 - 更新场景:``updated_by`` 生效,``created_by`` 由仓储层忽略原值保持不变。 """ use_cases = create_use_cases_from_db(db) input_dto = UpsertQuotaInput( **payload.model_dump(), created_by=current_user.uid, updated_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()}