From 2f6a4c29bbb271be341b4684253497f6e98d0e31 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Sat, 11 Jul 2026 21:39:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor(channel=20routers):=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=8F=82=E6=95=B0=E6=A0=A1=E9=AA=8C=E4=B8=8E=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E5=AE=9A=E4=B9=89=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑 --- backend/server/routers/channels/__init__.py | 18 +- .../server/routers/channels/account_router.py | 21 +- .../routers/channels/allowlist_router.py | 84 ++- .../routers/channels/analytics_router.py | 2 +- .../server/routers/channels/audit_router.py | 37 +- .../server/routers/channels/config_router.py | 52 +- .../routers/channels/content_review_router.py | 58 +- .../routers/channels/dashboard_router.py | 30 +- .../routers/channels/directory_router.py | 121 +-- .../server/routers/channels/message_router.py | 73 +- .../server/routers/channels/outbox_router.py | 11 +- .../server/routers/channels/pairing_router.py | 7 +- .../server/routers/channels/plugin_router.py | 22 +- .../routers/channels/route_binding_router.py | 10 +- .../server/routers/channels/session_router.py | 51 +- .../server/routers/channels/wizard_router.py | 13 +- .../external_systems/access_rule_router.py | 8 +- .../external_systems/adapter_router.py | 39 +- .../routers/external_systems/alert_router.py | 19 +- .../routers/external_systems/asset_router.py | 132 +++- .../external_systems/audit_log_router.py | 28 +- .../external_systems/auth_plugin_router.py | 29 +- .../external_systems/dashboard_router.py | 94 ++- .../external_systems/environment_router.py | 42 +- .../external_systems/execution_router.py | 21 +- .../external_systems/health_check_router.py | 39 +- .../routers/external_systems/import_router.py | 65 +- .../external_systems/integration_router.py | 56 +- .../integration_tool_router.py | 31 +- .../routers/external_systems/metric_router.py | 68 +- .../routers/external_systems/quota_router.py | 39 +- .../secret_rotation_router.py | 37 +- .../routers/external_systems/system_router.py | 76 +- .../external_systems/test_case_router.py | 12 +- .../routers/external_systems/token_router.py | 27 +- .../routers/external_systems/tool_router.py | 39 +- .../routers/external_systems/trash_router.py | 92 ++- .../external_systems/webhook_router.py | 28 +- backend/server/routers/scheduler_router.py | 688 +++++++++++++----- 39 files changed, 1690 insertions(+), 629 deletions(-) diff --git a/backend/server/routers/channels/__init__.py b/backend/server/routers/channels/__init__.py index fd05adb4..22ddbe8f 100644 --- a/backend/server/routers/channels/__init__.py +++ b/backend/server/routers/channels/__init__.py @@ -59,6 +59,13 @@ from yuxi.utils.trace_context import get_trace_id # 时间粒度查询参数类型(analytics / dashboard / pairing / content_review / outbox 共享) Granularity = Literal["hour", "day", "week"] +# analytics 共享常量(从 DTO 层单一事实源引用,避免多处定义不同步) +from yuxi.channels.contract.dtos.analytics import ( # noqa: E402 + DEFAULT_LIMIT as ANALYTICS_DEFAULT_LIMIT, + MAX_LIMIT as ANALYTICS_MAX_LIMIT, + MIN_LIMIT as ANALYTICS_MIN_LIMIT, +) + async def get_channel_di_container(request: Request) -> DependencyInjectionContainer: """从 app.state 获取 channels 模块 DI 容器。 @@ -402,7 +409,8 @@ def parse_datetime(field: str, raw: str | None) -> datetime | None: # 不一、上限阈值随意等问题。各预设按业务语义分档: # # - DEFAULT_LIMIT : 高频翻页场景(审核 / 搜索 / 目录 / 路由绑定) -# - LARGE_LIMIT : 列表查询场景(会话 / 配对 / 白名单 / 审计 / 消息历史) +# - LARGE_LIMIT : 列表查询场景(会话 / 配对 / 白名单 / 审计) +# - MESSAGE_LIMIT : 消息历史场景(单会话消息流,上限 200 防止全量加载) # - ANALYTICS_LIMIT : 分析 Top N 场景 # - EXPORT_LIMIT : 导出场景(参数名建议用 export_limit 以区分语义) # - SUGGESTION_LIMIT: 输入联想场景 @@ -414,7 +422,13 @@ def parse_datetime(field: str, raw: str | None) -> datetime | None: # ------------------------------------------------------------------ DEFAULT_LIMIT: int = Query(default=20, ge=1, le=100, description="分页大小(1-100,默认 20)") LARGE_LIMIT: int = Query(default=100, ge=1, le=1000, description="分页大小(1-1000,默认 100)") -ANALYTICS_LIMIT: int = Query(default=100, ge=1, le=500, description="Top N 上限(1-500,默认 100)") +MESSAGE_LIMIT: int = Query(default=50, ge=1, le=200, description="消息分页大小(1-200,默认 50)") +ANALYTICS_LIMIT: int = Query( + default=ANALYTICS_DEFAULT_LIMIT, + ge=ANALYTICS_MIN_LIMIT, + le=ANALYTICS_MAX_LIMIT, + description="Top N 上限(1-500,默认 100)", +) EXPORT_LIMIT: int = Query( default=10000, ge=1, diff --git a/backend/server/routers/channels/account_router.py b/backend/server/routers/channels/account_router.py index 2a86ee69..38001746 100644 --- a/backend/server/routers/channels/account_router.py +++ b/backend/server/routers/channels/account_router.py @@ -121,13 +121,23 @@ class BatchStateChangeRequest(BaseModel): ``account_ids`` 与 ``filter`` 二选一:二者不可同时为空,亦不可同时 提供(schema 层强制互斥,避免歧义)。``action`` 由端点路径决定 (``enable`` / ``disable``),不在 body 中。 + + ``channel_type`` 在 ``account_ids`` 模式下必填:``account_id`` 仅在 + ``(channel_type, account_id)`` 复合键下唯一,批量操作需显式限定单一 + 渠道才能定位账户(由 ``_require_exclusive_one_of`` 校验器保证)。 + ``filter`` 模式下可选,渠道信息可置于 ``filter.channel_type`` 内。 """ model_config = ConfigDict(frozen=True) + channel_type: ChannelType | None = Field( + default=None, + description="目标渠道类型(account_ids 模式必填,filter 模式可选)", + ) account_ids: list[str] | None = Field( default=None, - description="显式账户 ID 列表(与 filter 二选一)", + max_length=500, + description="显式账户 ID 列表(与 filter 二选一,限单一渠道)", ) filter: dict[str, Any] | None = Field( default=None, @@ -141,6 +151,8 @@ class BatchStateChangeRequest(BaseModel): 二者均为空时抛 ``ValidationError``(缺目标),二者均非空时抛 ``ValidationError``(互斥违反,避免 ``filter`` 字段被静默忽略)。 + ``account_ids`` 模式下 ``channel_type`` 必填:``account_id`` 非全局 + 唯一,需显式限定渠道,否则 handler 无法将 account_id 解析到渠道。 """ has_ids = bool(self.account_ids) has_filter = bool(self.filter) @@ -154,6 +166,11 @@ class BatchStateChangeRequest(BaseModel): "account_ids", "account_ids and filter are mutually exclusive", ) + if has_ids and self.channel_type is None: + raise ValidationError( + "channel_type", + "channel_type is required when account_ids are provided", + ) return self @@ -228,6 +245,7 @@ async def batch_enable_accounts( action="enable", operator=operator, account_ids=tuple(payload.account_ids) if payload.account_ids else (), + channel_type=payload.channel_type, filter=payload.filter, reason=payload.reason, ) @@ -258,6 +276,7 @@ async def batch_disable_accounts( action="disable", operator=operator, account_ids=tuple(payload.account_ids) if payload.account_ids else (), + channel_type=payload.channel_type, filter=payload.filter, reason=payload.reason, ) diff --git a/backend/server/routers/channels/allowlist_router.py b/backend/server/routers/channels/allowlist_router.py index 094d7002..c9f8e92a 100644 --- a/backend/server/routers/channels/allowlist_router.py +++ b/backend/server/routers/channels/allowlist_router.py @@ -34,7 +34,7 @@ from __future__ import annotations from datetime import datetime from typing import Any -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Path, Query, Request from pydantic import BaseModel, ConfigDict, Field from yuxi.channels.contract.dtos.channel import ChannelType from yuxi.channels.contract.dtos.whitelist import ( @@ -67,6 +67,11 @@ _WHITELIST_POLICY_VALUES = " | ".join(e.value for e in WhitelistPolicyType) _WHITELIST_EXPORT_FORMATS = ("json", "csv") _WHITELIST_EXPORT_FORMAT_VALUES = " | ".join(_WHITELIST_EXPORT_FORMATS) +# 路径参数校验(避免空串穿透到业务层,与 directory_router 保持一致) +_ACCOUNT_ID_PATH: str = Path(..., min_length=1, description="渠道账户 ID") +_POLICY_TYPE_PATH: str = Path(..., description=f"白名单策略类型({_WHITELIST_POLICY_VALUES})") +_PEER_ID_PATH: str = Path(..., min_length=1, max_length=256, description="对端 ID(非空,最长 256 字符)") + def _parse_policy_type(raw: str) -> WhitelistPolicyType: """string → WhitelistPolicyType,失败抛 ValidationError。""" @@ -197,9 +202,9 @@ class WhitelistCleanExpiredRequest(BaseModel): ) async def list_whitelist( channel_type: ChannelType, - account_id: str, - policy_type: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + policy_type: str = _POLICY_TYPE_PATH, + request: Request = None, limit: int = LARGE_LIMIT, offset: int = OFFSET, keyword: str | None = Query(default=None, description="关键词(模糊匹配 peer_id / peer_name)"), @@ -232,10 +237,10 @@ async def list_whitelist( ) async def add_whitelist_entry( channel_type: ChannelType, - account_id: str, - policy_type: str, - payload: WhitelistAddRequest, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + policy_type: str = _POLICY_TYPE_PATH, + payload: WhitelistAddRequest = ..., + request: Request = None, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -266,10 +271,10 @@ async def add_whitelist_entry( ) async def batch_add_whitelist( channel_type: ChannelType, - account_id: str, - policy_type: str, - payload: WhitelistBatchRequest, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + policy_type: str = _POLICY_TYPE_PATH, + payload: WhitelistBatchRequest = ..., + request: Request = None, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -302,7 +307,10 @@ async def batch_add_whitelist( operator=operator, ) raiseOnControlFailure(result) - return {"success": True, "data": serialize_control_data(result.data)} + response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)} + if result.status == "partial": + response["partial"] = True + return response @allowlist_router.get( @@ -311,9 +319,9 @@ async def batch_add_whitelist( ) async def export_whitelist( channel_type: ChannelType, - account_id: str, - policy_type: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + policy_type: str = _POLICY_TYPE_PATH, + request: Request = None, export_format: str = Query(default="json", alias="format", description="导出格式:json / csv"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), @@ -344,10 +352,10 @@ async def export_whitelist( ) async def batch_delete_whitelist( channel_type: ChannelType, - account_id: str, - policy_type: str, - payload: WhitelistBatchDeleteRequest, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + policy_type: str = _POLICY_TYPE_PATH, + payload: WhitelistBatchDeleteRequest = ..., + request: Request = None, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -370,7 +378,10 @@ async def batch_delete_whitelist( ) result = await use_cases.whitelist_management.batchDeleteWhitelist(cmd) raiseOnControlFailure(result) - return {"success": True, "data": serialize_control_data(result.data)} + response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)} + if result.status == "partial": + response["partial"] = True + return response @allowlist_router.post( @@ -379,10 +390,10 @@ async def batch_delete_whitelist( ) async def clean_expired_whitelist( channel_type: ChannelType, - account_id: str, - policy_type: str, - payload: WhitelistCleanExpiredRequest, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + policy_type: str = _POLICY_TYPE_PATH, + payload: WhitelistCleanExpiredRequest = ..., + request: Request = None, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -406,7 +417,10 @@ async def clean_expired_whitelist( ) result = await use_cases.whitelist_management.cleanExpiredWhitelist(cmd) raiseOnControlFailure(result) - return {"success": True, "data": serialize_control_data(result.data)} + response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)} + if result.status == "partial": + response["partial"] = True + return response @allowlist_router.patch( @@ -415,11 +429,11 @@ async def clean_expired_whitelist( ) async def update_whitelist_entry( channel_type: ChannelType, - account_id: str, - policy_type: str, - peer_id: str, - payload: WhitelistUpdateRequest, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + policy_type: str = _POLICY_TYPE_PATH, + payload: WhitelistUpdateRequest = ..., + request: Request = None, + peer_id: str = _PEER_ID_PATH, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -475,10 +489,10 @@ async def update_whitelist_entry( ) async def remove_whitelist_entry( channel_type: ChannelType, - account_id: str, - policy_type: str, - peer_id: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + policy_type: str = _POLICY_TYPE_PATH, + request: Request = None, + peer_id: str = _PEER_ID_PATH, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: diff --git a/backend/server/routers/channels/analytics_router.py b/backend/server/routers/channels/analytics_router.py index 993c80fc..5c87ecd5 100644 --- a/backend/server/routers/channels/analytics_router.py +++ b/backend/server/routers/channels/analytics_router.py @@ -214,7 +214,7 @@ async def analyze_peers( start_time: str = Query(..., description="开始时间(ISO 8601)"), end_time: str = Query(..., description="结束时间(ISO 8601)"), channel_type: ChannelType | None = Query(default=None, description="渠道类型过滤"), - account_id: str | None = Query(default=None, description="账户 ID 过滤"), + account_id: str | None = Query(default=None, min_length=1, description="账户 ID 过滤"), limit: int = ANALYTICS_LIMIT, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), diff --git a/backend/server/routers/channels/audit_router.py b/backend/server/routers/channels/audit_router.py index 01fd0f1b..45f24f96 100644 --- a/backend/server/routers/channels/audit_router.py +++ b/backend/server/routers/channels/audit_router.py @@ -44,12 +44,13 @@ tags 命名:``audit_router = APIRouter(tags=["channels-audit"])``,与现有 from __future__ import annotations +import base64 import json from datetime import datetime from typing import Any from zoneinfo import ZoneInfo -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Path, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, ConfigDict, Field from yuxi.channels.contract.dtos.audit import ( @@ -77,6 +78,19 @@ audit_router = APIRouter(tags=["channels-audit"]) AUDIT_EXPORT_LIMIT = 10000 +def _build_cursor(timestamp: str | None, record_id: int | None) -> str | None: + """从最后一条记录构造 keyset pagination 游标。 + + 游标为 base64 编码的 "timestamp,id" 字符串。``timestamp`` 为序列化后的 + ISO 8601 字符串(与 ``_auditEntryToDict`` 输出一致),``record_id`` 为 + ORM 主键。任一为 None 时返回 None(无更多数据)。 + """ + if timestamp is None or record_id is None: + return None + payload = f"{timestamp},{record_id}".encode() + return base64.b64encode(payload).decode() + + class UpdateRetentionPolicyRequest(BaseModel): """更新审计保留策略请求体(AUD-RETENTION-PUT)。 @@ -105,6 +119,7 @@ async def query_audit_logs( start_time: str | None = Query(default=None, description="起始时间(ISO 8601)"), end_time: str | None = Query(default=None, description="结束时间(ISO 8601)"), trace_id: str | None = Query(default=None, description="按链路追踪 ID 过滤"), + cursor: str | None = Query(default=None, description="分页游标(base64 编码,用于 keyset pagination)"), limit: int = LARGE_LIMIT, offset: int = OFFSET, use_cases=Depends(get_channel_use_cases), @@ -113,7 +128,8 @@ async def query_audit_logs( """查询审计日志列表(AUD-QUERY-01)。对应控制面操作 audit/query。 ``operation_type`` 查询参数由 FastAPI 按 ``AuditOperationType`` 枚举校验, - 非法值返回 422。 + 非法值返回 422。当 ``cursor`` 提供时启用 keyset pagination,忽略 + ``offset``,响应中携带 ``next_cursor`` 供下一页查询使用。 """ operator_vo = build_operator(current_user, request) start_time_dt = parse_datetime("start_time", start_time) @@ -126,18 +142,26 @@ async def query_audit_logs( start_time=start_time_dt, end_time=end_time_dt, trace_id=trace_id, + cursor=cursor, limit=limit, offset=offset, ) result = await use_cases.audit_query.queryAuditLogs(query=query, operator=operator_vo) raiseOnControlFailure(result) - return {"success": True, "data": serialize_control_data(result.data)} + data = serialize_control_data(result.data) + entries = data["entries"] + next_cursor = None + if entries: + last = entries[-1] + next_cursor = _build_cursor(last.get("timestamp"), last.get("id")) + return {"success": True, "data": data, "next_cursor": next_cursor} @audit_router.get("/audit/logs/stats", response_model=dict) async def get_audit_log_stats( request: Request, operation_type: AuditOperationType | None = Query(default=None, description="操作类型"), + operator: str | None = Query(default=None, description="操作人用户 ID"), target_channel: ChannelType | None = Query(default=None, description="目标渠道类型"), target_account: str | None = Query(default=None, description="目标账户 ID"), start_time: str | None = Query(default=None, description="起始时间(ISO 8601)"), @@ -154,6 +178,7 @@ async def get_audit_log_stats( end_time_dt = parse_datetime("end_time", end_time) query = AuditQuery( operation_type=operation_type, + operator=operator, target_channel=target_channel, target_account=target_account, start_time=start_time_dt, @@ -234,6 +259,9 @@ async def export_audit_logs( total = data["total"] async def _stream_json_array(): + # 异步生成器逐条输出,避免一次性拼接大字符串。 + # 未来可通过数据库游标进一步优化:将全量加载改为分批 stream, + # 当前受控制面管道(模板 A)约束,数据在 dispatch 阶段已全量加载。 yield "[" first = True for entry in entries: @@ -308,7 +336,8 @@ async def update_retention_policy( @audit_router.get("/audit/logs/{log_id}", response_model=dict) async def get_audit_log( - log_id: str, + *, + log_id: str = Path(..., min_length=1, max_length=64, description="审计日志 ID"), request: Request, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), diff --git a/backend/server/routers/channels/config_router.py b/backend/server/routers/channels/config_router.py index 900ae309..088513b0 100644 --- a/backend/server/routers/channels/config_router.py +++ b/backend/server/routers/channels/config_router.py @@ -1,7 +1,10 @@ -"""配置管理域 Router(CFG-02 / CFG-READ-01 / CFG-UPDATE-01 / CFG-ROLL-01 / CFG-HIST-01 / CFG-EXPORT / CFG-IMPORT / CFG-BATCH-UPDATE)。 +"""配置管理域 Router。 + +操作码:CFG-02 / CFG-READ-01 / CFG-READ-MANY / CFG-UPDATE-01 / +CFG-ROLL-01 / CFG-HIST-01 / CFG-EXPORT / CFG-IMPORT / CFG-BATCH-UPDATE。 本 router 实现渠道配置管理域的全部 HTTP 端点,覆盖 schema 查询、配置查询、 -更新、回滚、历史、导出、导入与批量更新共 8 个操作。所有端点统一采用模板 A +批量查询、更新、回滚、历史、导出、导入与批量更新共 9 个操作。所有端点统一采用模板 A (控制面端口路由),通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``, 经 ``config_management`` 端口调用类型化方法,由 ``ChannelControlService`` 内部走控制面管道(auth → permission → rate_limit → dispatch → audit)。 @@ -17,9 +20,9 @@ ``_executeControl`` 直接调用。模板选型决策依据详见设计方案 §2.5。 路径设计:静态路径 ``/config/schema`` / ``/config/export`` / ``/config/import`` -/ ``/config/batch`` 先于动态路径 ``/config/{key}`` 声明,避免被动态路径捕获 -(规范 §6.5)。子 router 不自行设置 prefix,根前缀 ``/channels`` 由 -``channels_router`` 聚合 router 统一追加。完整 HTTP 路径为 +/ ``/config/batch`` / ``/config/values`` 先于动态路径 ``/config/{key}`` 声明, +避免被动态路径捕获(规范 §6.5)。子 router 不自行设置 prefix,根前缀 ``/channels`` +由 ``channels_router`` 聚合 router 统一追加。完整 HTTP 路径为 ``/channels/config/*``(不含 ``{channel_type}`` 段,配置管理为跨渠道 全局能力)。 @@ -27,14 +30,15 @@ tags 命名:``config_router = APIRouter(tags=["channels-config"])``,与现 ``channels-login``/``channels-account`` 等子 router 命名规范一致。 端点清单(对应《配置管理域设计方案》§2.1): - - GET /config/schema CFG-02 get_config_schema - - GET /config/export CFG-EXPORT export_config - - POST /config/import CFG-IMPORT import_config + - GET /config/schema CFG-02 get_config_schema + - GET /config/values CFG-READ-MANY get_config_values + - GET /config/export CFG-EXPORT export_config + - POST /config/import CFG-IMPORT import_config - PUT /config/batch CFG-BATCH-UPDATE batch_update_config - - GET /config/{key} CFG-READ-01 get_config - - PUT /config/{key} CFG-UPDATE-01 update_config - - POST /config/{key}/rollback CFG-ROLL-01 rollback_config - - GET /config/{key}/history CFG-HIST-01 get_config_history + - GET /config/{key} CFG-READ-01 get_config + - PUT /config/{key} CFG-UPDATE-01 update_config + - POST /config/{key}/rollback CFG-ROLL-01 rollback_config + - GET /config/{key}/history CFG-HIST-01 get_config_history """ from __future__ import annotations @@ -44,6 +48,7 @@ from typing import Any from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, ConfigDict, Field from yuxi.channels.contract.dtos.config import ( + BATCH_UPDATE_LIMIT, BatchUpdateConfigCmd, ConfigExportCmd, ConfigScope, @@ -100,7 +105,7 @@ class ImportConfigRequest(BaseModel): model_config = ConfigDict(frozen=True) - config_data: dict[str, Any] = Field(..., description="待导入的配置字典") + config_data: dict[str, Any] = Field(..., min_length=1, description="待导入的配置字典") scope: ConfigScope = Field(default=ConfigScope.GLOBAL, description="配置作用域") target: str | None = Field(default=None, description="作用域目标") overwrite: bool = Field(default=False, description="是否覆盖已存在 key") @@ -120,13 +125,16 @@ class ConfigUpdateItemRequest(BaseModel): class BatchUpdateConfigRequest(BaseModel): """批量更新配置请求体(CFG-BATCH-UPDATE)。 - ``updates`` 长度限制 1-50(dispatch handler 校验)。``stop_on_error=false`` - 时逐条独立事务(模式 D 部分成功);``stop_on_error=true`` 时首条失败即终止。 + ``updates`` 长度限制 1-``BATCH_UPDATE_LIMIT``(契约层单一真相源)。 + ``stop_on_error=false`` 时逐条独立事务(模式 D 部分成功); + ``stop_on_error=true`` 时首条失败即终止。 """ model_config = ConfigDict(frozen=True) - updates: list[ConfigUpdateItemRequest] = Field(..., min_length=1, max_length=50, description="配置更新项列表") + updates: list[ConfigUpdateItemRequest] = Field( + ..., min_length=1, max_length=BATCH_UPDATE_LIMIT, description="配置更新项列表" + ) scope: ConfigScope = Field(default=ConfigScope.GLOBAL, description="配置作用域") target: str | None = Field(default=None, description="作用域目标") stop_on_error: bool = Field(default=False, description="首条失败即终止") @@ -204,7 +212,10 @@ async def import_config( ) result = await use_cases.config_management.importConfig(cmd) raiseOnControlFailure(result) - return {"success": True, "data": serialize_control_data(result.data)} + response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)} + if result.status == "partial": + response["partial"] = True + return response @config_router.put("/config/batch", response_model=dict) @@ -238,13 +249,16 @@ async def batch_update_config( ) result = await use_cases.config_management.batchUpdateConfig(cmd) raiseOnControlFailure(result) - return {"success": True, "data": serialize_control_data(result.data)} + response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)} + if result.status == "partial": + response["partial"] = True + return response @config_router.get("/config/values", response_model=dict) async def get_config_values( request: Request, - keys: list[str] = Query(..., description="配置键列表"), + keys: list[str] = Query(..., min_length=1, max_length=BATCH_UPDATE_LIMIT, description="配置键列表"), scope: ConfigScope = Query(default=ConfigScope.GLOBAL, description="配置作用域"), target: str | None = Query(default=None, description="作用域目标(channel/account 时必填)"), use_cases=Depends(get_channel_use_cases), diff --git a/backend/server/routers/channels/content_review_router.py b/backend/server/routers/channels/content_review_router.py index a8927f3f..cafa8f99 100644 --- a/backend/server/routers/channels/content_review_router.py +++ b/backend/server/routers/channels/content_review_router.py @@ -60,12 +60,15 @@ router 统一追加。 from __future__ import annotations +import os from typing import Any, Literal from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, ConfigDict, Field +from yuxi.channels.application.rate_limit_checker import RateLimitChecker from yuxi.channels.contract.dtos.channel import ChannelType from yuxi.channels.contract.dtos.content_review import ( + MAX_CONTENT_LENGTH, BatchReviewDecisionCmd, ContentReviewDetailQueryCmd, ContentReviewHistoryQueryCmd, @@ -75,7 +78,10 @@ from yuxi.channels.contract.dtos.content_review import ( ReviewDecisionItem, ) from yuxi.channels.contract.errors import ValidationError +from yuxi.channels.contract.ports.driven.cache_port import CachePort +from yuxi.channels.infrastructure import DependencyInjectionContainer from yuxi.storage.postgres.models_business import User +from yuxi.utils.trace_context import get_trace_id from server.routers.channels import ( DEFAULT_LIMIT, @@ -83,6 +89,7 @@ from server.routers.channels import ( OFFSET, build_operator, dataclass_to_dict, + get_channel_di_container, get_channel_use_cases, parse_datetime, raiseOnControlFailure, @@ -92,6 +99,42 @@ from server.utils.auth_middleware import get_admin_user content_review_router = APIRouter(tags=["channels-content-review"]) +# 数据面查询端点(CR-02 / CR-03)速率限制阈值:与 RateLimitStage / +# AdminMessageService 共用同一组环境变量,避免多处硬编码导致手动同步风险。 +_CR_QUERY_RATE_LIMIT_MAX_REQUESTS = int(os.environ.get("YUXI_RATE_LIMIT_MAX_REQUESTS", "100")) +_CR_QUERY_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("YUXI_RATE_LIMIT_WINDOW_SECONDS", "60")) +_CR_QUERY_RATE_LIMIT_RETRY_AFTER_MS = int(os.environ.get("YUXI_RATE_LIMIT_RETRY_AFTER_MS", "60000")) + + +async def _enforceContentReviewQueryRateLimit( + request: Request, + current_user: User = Depends(get_admin_user), + di_container: DependencyInjectionContainer = Depends(get_channel_di_container), +) -> None: + """数据面查询端点(CR-02 / CR-03)速率限制。 + + 数据面端点不经控制面管道(模板 B),无 ``RateLimitStage`` 保护。本依赖 + 在 router 层复用 ``RateLimitChecker``(与控制面 ``RateLimitStage`` 同一 + helper)实现 per-admin-per-IP 速率限制,缓存键使用独立前缀 + ``rate_limit:cr_query:`` 避免与控制面一级限流键冲突。``CachePort`` 从 + DI 容器解析(应用级单例)。超限抛 ``RateLimitError``(HTTP 429), + ``incr`` 缓存故障 fail-closed,``expire`` 故障 fail-fast(INV-7)。 + """ + cache_port: CachePort = di_container.resolve(CachePort) + checker = RateLimitChecker(cache_port) + ip = request.client.host if request.client else None + ip_key = ip or "unknown" + user_id = current_user.uid + trace_id = request.headers.get("X-Request-Id") or get_trace_id() + await checker.check( + resource=f"content-review-query:{user_id}:{ip_key}", + cache_key=f"rate_limit:cr_query:{user_id}:{ip_key}", + max_requests=_CR_QUERY_RATE_LIMIT_MAX_REQUESTS, + window_seconds=_CR_QUERY_RATE_LIMIT_WINDOW_SECONDS, + retry_after_ms=_CR_QUERY_RATE_LIMIT_RETRY_AFTER_MS, + trace_id=trace_id, + ) + class ContentReviewPreviewRequest(BaseModel): """内容预审核请求体(CR-01)。 @@ -104,12 +147,12 @@ class ContentReviewPreviewRequest(BaseModel): model_config = ConfigDict(frozen=True) channel_type: ChannelType = Field(..., description="渠道类型") - account_id: str = Field(..., min_length=1, description="账户 ID") + account_id: str = Field(..., min_length=1, max_length=128, description="账户 ID") resource_type: ContentReviewResourceType = Field( ..., description="资源类型:message_text / message_attachment / user_profile", ) - content: str = Field(..., min_length=1, max_length=10000, description="待审核文本") + content: str = Field(..., min_length=1, max_length=MAX_CONTENT_LENGTH, description="待审核文本") peer_id: str | None = Field(default=None, description="对端 ID(可选,用于上下文)") @@ -224,7 +267,7 @@ async def preview_content( ) async def list_review_history( channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"), - account_id: str | None = Query(default=None, description="按账户 ID 过滤"), + account_id: str | None = Query(default=None, max_length=128, description="按账户 ID 过滤"), verdict: str | None = Query( default=None, description="按审核结论过滤:pass / review / block", @@ -249,6 +292,7 @@ async def list_review_history( offset: int = OFFSET, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), + _: None = Depends(_enforceContentReviewQueryRateLimit), ) -> dict[str, Any]: """查询审核历史列表(FR-30,CR-02)。 @@ -288,7 +332,7 @@ async def list_review_history( async def get_review_stats( request: Request, channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"), - account_id: str | None = Query(default=None, description="按账户 ID 过滤"), + account_id: str | None = Query(default=None, max_length=128, description="按账户 ID 过滤"), start_time: str | None = Query( default=None, description="起始时间过滤(ISO 8601,含)", @@ -369,7 +413,10 @@ async def batch_review_decision( ) result = await use_cases.content_review.batchReviewDecision(cmd=cmd) raiseOnControlFailure(result) - return {"success": True, "data": serialize_control_data(result.data)} + response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)} + if result.status == "partial": + response["partial"] = True + return response # ---------------- 动态路径端点 ---------------- @@ -384,6 +431,7 @@ async def get_review_detail( request: Request, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), + _: None = Depends(_enforceContentReviewQueryRateLimit), ) -> dict[str, Any]: """查询单次审核详情(FR-30,CR-03)。 diff --git a/backend/server/routers/channels/dashboard_router.py b/backend/server/routers/channels/dashboard_router.py index 9f01d678..28aa76c7 100644 --- a/backend/server/routers/channels/dashboard_router.py +++ b/backend/server/routers/channels/dashboard_router.py @@ -1,12 +1,12 @@ -"""Dashboard 聚合视图域 Router(DSB-01~DSB-04 + DSB-DELIVERY + DSB-REALTIME)。 +"""Dashboard 聚合视图域 Router(DSB-01~DSB-04 + DSB-DELIVERY + DSB-TODOS + DSB-REALTIME)。 统一采用模板 A(控制面端口路由),鉴权依赖 get_admin_user,调用 use_cases.dashboard_view.,raiseOnControlFailure 转译失败, serialize_control_data 序列化响应。 -路径设计:6 个端点均为静态路径(/dashboard/overview / /dashboard/accounts +路径设计:7 个端点均为静态路径(/dashboard/overview / /dashboard/accounts /dashboard/messages / /dashboard/sessions / /dashboard/delivery -/dashboard/realtime)。子 router 自身不设置 prefix,根前缀 ``/channels`` +/dashboard/todos / /dashboard/realtime)。子 router 自身不设置 prefix,根前缀 ``/channels`` 由 ``channels_router`` 聚合 router 统一追加。 端点清单: @@ -14,13 +14,13 @@ serialize_control_data 序列化响应。 - GET /dashboard/accounts DSB-02 getAccountStats - GET /dashboard/messages DSB-03 getMessageStats - GET /dashboard/sessions DSB-04 getDashboardSessionStats + - GET /dashboard/delivery DSB-DELIVERY getDashboardDelivery + - GET /dashboard/todos DSB-TODOS getDashboardTodos + - GET /dashboard/realtime DSB-REALTIME getRealtimeMetrics DSB-01~04 对应《16-聚合视图域-dashboard-router-设计方案》(仅覆盖 4 个 -KPI 快照端点)。DSB-DELIVERY / DSB-REALTIME 为后续扩展端点,分别提供 -投递 KPI 总览与实时监控指标,不在上述设计方案范围内。 - - - GET /dashboard/delivery DSB-DELIVERY getDashboardDelivery - - GET /dashboard/realtime DSB-REALTIME getRealtimeMetrics +KPI 快照端点)。DSB-DELIVERY / DSB-TODOS / DSB-REALTIME 为后续扩展端点, +分别提供投递 KPI 总览、工作台待办计数与实时监控指标,不在上述设计方案范围内。 """ from __future__ import annotations @@ -29,7 +29,12 @@ from typing import Any from fastapi import APIRouter, Depends, Query, Request from yuxi.channels.contract.dtos.channel import ChannelType -from yuxi.channels.contract.dtos.dashboard import RealtimeQuery +from yuxi.channels.contract.dtos.dashboard import ( + DEFAULT_WINDOW_SECONDS, + MAX_WINDOW_SECONDS, + MIN_WINDOW_SECONDS, + RealtimeQuery, +) from yuxi.storage.postgres.models_business import User from server.routers.channels import ( @@ -196,7 +201,12 @@ async def get_dashboard_todos( async def get_dashboard_realtime( request: Request, channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"), - window_seconds: int = Query(default=60, ge=10, le=300, description="滑动窗口秒数(10-300,默认 60)"), + window_seconds: int = Query( + default=DEFAULT_WINDOW_SECONDS, + ge=MIN_WINDOW_SECONDS, + le=MAX_WINDOW_SECONDS, + description="滑动窗口秒数(10-300,默认 60)", + ), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: diff --git a/backend/server/routers/channels/directory_router.py b/backend/server/routers/channels/directory_router.py index b3f64cd8..9bd7ad88 100644 --- a/backend/server/routers/channels/directory_router.py +++ b/backend/server/routers/channels/directory_router.py @@ -1,33 +1,38 @@ """目录查询域 Router(FR-14,DIR-QUERY-01~04 + DIR-01 + DIR-CACHE-CLEAR + Task 5)。 本 router 实现渠道通讯录目录查询的全部 HTTP 端点,覆盖用户搜索 / 群组搜索 / -用户资料 / 群组详情 / 群组成员 / 缓存清理 / 目录导出共 7 个操作。除导出端点 -返回 ``StreamingResponse`` 文件流外,其余端点统一采用模板 A(控制面端口路由), -通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``,经 ``directory`` 端口 -调用类型化方法,由 ``ChannelControlService._executeControl`` 内部走控制面管道 +用户资料 / 群组详情 / 群组成员 / 缓存清理 / 目录导出 / 搜索建议 / 批量资料 +共 9 个操作。除导出端点返回 ``StreamingResponse`` 文件流外,其余端点统一 +采用模板 A(控制面端口路由),通过 ``get_channel_use_cases`` 装配 +``ChannelUseCases``,经 ``directory`` 端口调用类型化方法,由 +``ChannelControlService._executeControl`` 内部走控制面管道 (auth → permission → rate_limit → dispatch → audit)。导出端点同样经控制面 管道执行,handler 返回内容字符串,router 包装为文件流以保持审计链路。 鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。 角色校验由依赖函数完成,router 内不做角色判断(规范 §4)。 -路径设计:静态后缀路径(``/search`` / ``/export``)先于动态单段路径 -(``/{peer_id}`` / ``/{group_id}``)声明,动态单段先于多段子资源路径 -(``/{group_id}/members``)声明(规范 §6.5),避免静态路径被动态参数捕获。 -子 router 不自行设置 prefix,根前缀 ``/channels`` 由 ``channels_router`` 聚合 -router 统一追加。 +路径设计:静态后缀路径(``/search`` / ``/export`` / ``/suggestions`` / +``/batch_profiles``)先于动态单段路径(``/{peer_id}`` / ``/{group_id}``) +声明,动态单段先于多段子资源路径(``/{group_id}/members``)声明 +(规范 §6.5),避免静态路径被动态参数捕获。子 router 不自行设置 prefix, +根前缀 ``/channels`` 由 ``channels_router`` 聚合 router 统一追加。 端点清单(对应《05-目录查询域设计方案 v1.1》§2.3 + Task 5): - GET /{channel_type}/{account_id}/directory/users/search DIR-QUERY-01 search_directory_users - GET /{channel_type}/{account_id}/directory/groups/search DIR-QUERY-02 search_directory_groups + - GET /{channel_type}/{account_id}/directory/suggestions + DIR-SUGGEST search_directory_suggestions - GET /{channel_type}/{account_id}/directory/users/{peer_id} DIR-QUERY-03 get_directory_user_profile - GET /{channel_type}/{account_id}/directory/groups/{group_id} DIR-01 get_directory_group_detail - GET /{channel_type}/{account_id}/directory/groups/{group_id}/members DIR-QUERY-04 get_directory_group_members + - POST /{channel_type}/{account_id}/directory/batch_profiles + DIR-BATCH batch_directory_profiles - DELETE /{channel_type}/{account_id}/directory/cache DIR-CACHE-CLEAR clear_directory_cache - POST /{channel_type}/{account_id}/directory/export @@ -37,8 +42,9 @@ router 统一追加。 from __future__ import annotations from typing import Any, Literal +from urllib.parse import quote -from fastapi import APIRouter, Body, Depends, Query, Request +from fastapi import APIRouter, Body, Depends, Path, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, ConfigDict, Field from yuxi.channels.contract.dtos.channel import ChannelType @@ -63,6 +69,24 @@ from server.utils.auth_middleware import get_admin_user directory_router = APIRouter(tags=["channels-directory"]) +# 目录导出单次上限(handler 层与 router 层共享同一常量,单一事实源) +_DIRECTORY_EXPORT_MAX_LIMIT: int = 1000 + +# 排序字段白名单(各渠道适配器按需支持,未支持时在适配器内忽略) +_SORT_BY_QUERY: str | None = Query( + default=None, + description="排序字段(如 name),渠道支持情况不同,未支持时忽略", +) +_SORT_ORDER_QUERY: Literal["asc", "desc"] | None = Query( + default=None, + description="排序方向(asc 或 desc)", +) + +# 账户/对端/群组路径参数最小长度校验(避免空串穿透到业务层) +_ACCOUNT_ID_PATH: str = Path(..., min_length=1, description="渠道账户 ID") +_PEER_ID_PATH: str = Path(..., min_length=1, description="对端用户 ID") +_GROUP_ID_PATH: str = Path(..., min_length=1, description="群组 ID") + # ---------------- Endpoints ---------------- @@ -73,11 +97,13 @@ directory_router = APIRouter(tags=["channels-directory"]) ) async def search_directory_users( channel_type: ChannelType, - account_id: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + request: Request = None, keyword: str | None = Query(default=None, description="搜索关键词"), cursor: str | None = CURSOR, limit: int = DEFAULT_LIMIT, + sort_by: str | None = _SORT_BY_QUERY, + sort_order: Literal["asc", "desc"] | None = _SORT_ORDER_QUERY, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -88,7 +114,13 @@ async def search_directory_users( 内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。 """ operator = build_operator(current_user, request) - query = DirectoryQuery(keyword=keyword, cursor=cursor, limit=limit) + query = DirectoryQuery( + keyword=keyword, + cursor=cursor, + limit=limit, + sort_by=sort_by, + sort_order=sort_order, + ) result = await use_cases.directory.searchDirectoryUsers( channel_type=channel_type, account_id=account_id, @@ -105,8 +137,8 @@ async def search_directory_users( ) async def search_directory_suggestions( channel_type: ChannelType, - account_id: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + request: Request = None, keyword: str = Query(..., min_length=1, description="搜索关键词"), limit: int = SUGGESTION_LIMIT, use_cases=Depends(get_channel_use_cases), @@ -137,11 +169,13 @@ async def search_directory_suggestions( ) async def search_directory_groups( channel_type: ChannelType, - account_id: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + request: Request = None, keyword: str | None = Query(default=None, description="搜索关键词"), cursor: str | None = CURSOR, limit: int = DEFAULT_LIMIT, + sort_by: str | None = _SORT_BY_QUERY, + sort_order: Literal["asc", "desc"] | None = _SORT_ORDER_QUERY, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -152,7 +186,13 @@ async def search_directory_groups( 并委托 ``_executeControl`` 执行控制面管道)。 """ operator = build_operator(current_user, request) - query = DirectoryQuery(keyword=keyword, cursor=cursor, limit=limit) + query = DirectoryQuery( + keyword=keyword, + cursor=cursor, + limit=limit, + sort_by=sort_by, + sort_order=sort_order, + ) result = await use_cases.directory.searchDirectoryGroups( channel_type=channel_type, account_id=account_id, @@ -169,9 +209,9 @@ async def search_directory_groups( ) async def get_directory_user_profile( channel_type: ChannelType, - account_id: str, - peer_id: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + peer_id: str = _PEER_ID_PATH, + request: Request = None, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -229,10 +269,10 @@ class ExportDirectoryRequest(BaseModel): description="导出格式(csv 或 json)", ) limit: int = Field( - default=1000, + default=_DIRECTORY_EXPORT_MAX_LIMIT, ge=1, - le=1000, - description="导出数量限制(1~1000)", + le=_DIRECTORY_EXPORT_MAX_LIMIT, + description=f"导出数量限制(1~{_DIRECTORY_EXPORT_MAX_LIMIT})", ) @@ -242,8 +282,8 @@ class ExportDirectoryRequest(BaseModel): ) async def batch_directory_profiles( channel_type: ChannelType, - account_id: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + request: Request = None, payload: BatchProfilesRequest = Body(...), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), @@ -279,8 +319,8 @@ async def batch_directory_profiles( ) async def export_directory( channel_type: ChannelType, - account_id: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + request: Request = None, payload: ExportDirectoryRequest = Body(...), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), @@ -316,7 +356,7 @@ async def export_directory( _stream(), media_type=media_type, headers={ - "Content-Disposition": f"attachment; filename={filename}", + "Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}", }, ) @@ -327,9 +367,9 @@ async def export_directory( ) async def get_directory_group_detail( channel_type: ChannelType, - account_id: str, - group_id: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + group_id: str = _GROUP_ID_PATH, + request: Request = None, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -356,9 +396,9 @@ async def get_directory_group_detail( ) async def get_directory_group_members( channel_type: ChannelType, - account_id: str, - group_id: str, - request: Request, + account_id: str = _ACCOUNT_ID_PATH, + group_id: str = _GROUP_ID_PATH, + request: Request = None, cursor: str | None = CURSOR, limit: int = DEFAULT_LIMIT, use_cases=Depends(get_channel_use_cases), @@ -392,9 +432,9 @@ async def get_directory_group_members( ) async def clear_directory_cache( channel_type: ChannelType, - account_id: str, - request: Request, - scope: str = Query( + account_id: str = _ACCOUNT_ID_PATH, + request: Request = None, + scope: Literal["users", "groups", "all"] = Query( default="all", description="清理范围(users / groups / all)", ), @@ -413,11 +453,6 @@ async def clear_directory_cache( 适配器清理 + ``CachePort`` 失效本地缓存 → ``ControlResult`` 经 ``raiseOnControlFailure`` 转译失败 → ``serialize_control_data`` 序列化 清理结果返回。适配器未实现 ``clearCache`` 时返回 501。 - - scope 枚举校验在 ``ClearDirectoryCacheCmd.__post_init__`` 构造时即执行, - 非法取值直接抛 400 ``ValidationError``(router 路径,未进入控制面管道); - handler 层 ``_directoryCacheClear`` 同步校验作为非 router 调用方的 - 防御兜底,与 ``user_id`` / ``group_id`` 等业务参数的分层校验策略一致。 """ operator = build_operator(current_user, request) cmd = ClearDirectoryCacheCmd( diff --git a/backend/server/routers/channels/message_router.py b/backend/server/routers/channels/message_router.py index 05821bdd..266239d8 100644 --- a/backend/server/routers/channels/message_router.py +++ b/backend/server/routers/channels/message_router.py @@ -32,10 +32,9 @@ from __future__ import annotations -import os from typing import Any, Literal -from fastapi import APIRouter, Depends, File, Form, Header, Query, Request, UploadFile +from fastapi import APIRouter, Depends, File, Form, Header, Path, Query, Request, UploadFile from pydantic import BaseModel, ConfigDict, Field from yuxi.channels.contract.dtos.admin import AdminSendCmd from yuxi.channels.contract.dtos.channel import ChannelType @@ -261,10 +260,10 @@ async def batch_recall_messages( 收集成功/失败结果。单条失败不回滚已成功条目,撤回为外部副作用操作不可 回滚。对应控制面操作 ``message/batch_recall``。 - 部分成功语义:存在失败条目时 ``result.status="partial"``, - ``success`` 标记为 ``True``,客户端需检查 ``data.failed`` 字段获取 - 失败详情。``data`` 中仍包含 ``total`` / ``succeeded`` / ``failed`` - 三字段供精确判断。 + 部分成功语义:存在失败条目时 ``success`` 标记为 ``False``(与 + ``send_admin_message`` 语义统一),客户端需检查 ``data.failed`` + 字段获取失败详情。``data`` 中仍包含 ``total`` / ``succeeded`` / + ``failed`` 三字段供精确判断。 静态路径先于 ``/messages/{message_id}`` 声明,避免被动态参数捕获。 """ @@ -276,19 +275,20 @@ async def batch_recall_messages( ) result = await use_cases.message_management.batchRecallMessages(cmd) raiseOnControlFailure(result) - # 部分成功语义:result.status="partial" 时 success 标记为 True, + # 部分成功语义:status=="partial" 时 success=False,与 send_admin_message 语义统一。 # 客户端需检查 data.failed 字段获取失败详情。data 中仍包含 # total/succeeded/failed 三字段供精确判断。 + is_partial = result.status == "partial" return { - "success": result.status in ("success", "partial"), + "success": not is_partial, "data": serialize_control_data(result.data), } @message_router.get("/messages/{message_id}", response_model=dict) async def get_message( - message_id: str, request: Request, + message_id: str = Path(..., min_length=1, description="消息内部 ID"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -310,8 +310,8 @@ async def get_message( @message_router.get("/messages/{message_id}/status", response_model=dict) async def get_message_status( - message_id: str, request: Request, + message_id: str = Path(..., min_length=1, description="消息内部 ID"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -336,7 +336,13 @@ async def send_admin_message( channel_type: ChannelType, payload: SendAdminMessageRequest, request: Request, - idempotency_key: str = Header(..., alias="X-Idempotency-Key", description="幂等键,防重复发送"), + idempotency_key: str = Header( + ..., + alias="X-Idempotency-Key", + min_length=1, + max_length=128, + description="幂等键,防重复发送", + ), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -384,7 +390,7 @@ async def send_admin_message( async def upload_attachment( channel_type: ChannelType, request: Request, - account_id: str = Form(..., description="渠道账户 ID"), + account_id: str = Form(..., min_length=1, description="渠道账户 ID"), file: UploadFile = File(..., description="待上传附件文件"), purpose: str | None = Form(default=None, description="上传用途(如 avatar / attachment)"), use_cases=Depends(get_channel_use_cases), @@ -406,22 +412,27 @@ async def upload_attachment( ``/{channel_type}/messages/{message_id}`` 声明,避免被动态参数捕获。 """ operator = build_operator(current_user, request) - # 分块流式读取,累计超限即中止,避免大文件全量载入内存导致 OOM - chunks: list[bytes] = [] - total = 0 - while chunk := await file.read(1024 * 1024): - total += len(chunk) - if total > MAX_ATTACHMENT_SIZE: - raise ValidationError( - "file", - f"attachment size exceeds limit {MAX_ATTACHMENT_SIZE} bytes", - ) - chunks.append(chunk) + # 分块流式读取,累计超限即中止,避免大文件全量载入内存导致 OOM。 + # try-finally 确保超限异常路径也关闭 UploadFile,及时释放临时文件资源。 + try: + chunks: list[bytes] = [] + total = 0 + while chunk := await file.read(1024 * 1024): + total += len(chunk) + if total > MAX_ATTACHMENT_SIZE: + raise ValidationError( + "file", + f"attachment size exceeds limit {MAX_ATTACHMENT_SIZE} bytes", + ) + chunks.append(chunk) + finally: + await file.close() file_data = b"".join(chunks) - # filename 安全过滤:取 basename 防止路径穿越(如 ``../etc/passwd`` / - # ``C:\\Windows\\system32``),适配器层无需重复校验。 + # filename 安全过滤:统一替换 \ 为 / 后取末段,兼容跨平台路径(Docker + # 部署于 Linux,os.path.basename 不识别 \ 分隔符,会导致 + # ``..\\..\\etc\\passwd`` 原样透传给适配器,路径穿越防护失效)。 raw_filename = file.filename or "" - filename = os.path.basename(raw_filename) + filename = raw_filename.replace("\\", "/").rsplit("/", 1)[-1] if not filename: raise ValidationError( "filename", @@ -446,9 +457,9 @@ async def upload_attachment( response_model=dict, ) async def recall_message( - channel_type: ChannelType, - message_id: str, request: Request, + channel_type: ChannelType, + message_id: str = Path(..., min_length=1, description="消息内部 ID"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -476,10 +487,10 @@ async def recall_message( response_model=dict, ) async def resend_message( - channel_type: ChannelType, - message_id: str, - payload: ResendMessageRequest, request: Request, + channel_type: ChannelType, + payload: ResendMessageRequest, + message_id: str = Path(..., min_length=1, description="消息内部 ID"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: diff --git a/backend/server/routers/channels/outbox_router.py b/backend/server/routers/channels/outbox_router.py index 2ea45000..4b1ddc18 100644 --- a/backend/server/routers/channels/outbox_router.py +++ b/backend/server/routers/channels/outbox_router.py @@ -66,6 +66,7 @@ class UpdateRetryPolicyRequest(BaseModel): retry_backoff_schedule: list[int] = Field( ..., min_length=1, + max_length=20, description="指数退避序列(秒),非空正整数列表", ) @@ -249,10 +250,14 @@ class DeadLetterBatchCmd(BaseModel): """ model_config = ConfigDict(frozen=True) - outbox_ids: list[str] | None = Field(default=None, description="指定死信条目 ID 列表;为 None 时作用于全部") + outbox_ids: list[str] | None = Field( + default=None, + max_length=500, + description="指定死信条目 ID 列表;为 None 时作用于全部(上限 500)", + ) -@outbox_router.post("/dead-letter/batch-retry", response_model=dict) +@outbox_router.post("/dead-letter/batch-retry", response_model=dict, status_code=202) async def batch_retry_dead_letter( request: Request, cmd: DeadLetterBatchCmd | None = None, @@ -394,7 +399,7 @@ async def delete_dead_letter_outbox( } -@outbox_router.post("/messages/{outbox_id}/retry", response_model=dict) +@outbox_router.post("/messages/{outbox_id}/retry", response_model=dict, status_code=202) async def retry_outbox_message( outbox_id: str, request: Request, diff --git a/backend/server/routers/channels/pairing_router.py b/backend/server/routers/channels/pairing_router.py index 6e3c75d1..5244ec66 100644 --- a/backend/server/routers/channels/pairing_router.py +++ b/backend/server/routers/channels/pairing_router.py @@ -1,6 +1,6 @@ """配对审批域 Router。 -提供 DM 安全配对审批的 10 个 HTTP 端点,全部由 ``get_admin_user`` 守门,仅 +提供 DM 安全配对审批的 11 个 HTTP 端点,全部由 ``get_admin_user`` 守门,仅 管理员可访问。Router 不含任何业务逻辑,仅做协议翻译:将 HTTP 请求参数组装 为契约层命令/参数,调用 ``use_cases.pairing_management`` 的类型化方法走控制 面管道,再通过 ``raiseOnControlFailure`` 转译失败、``serialize_control_data`` @@ -8,6 +8,7 @@ 端点清单: - GET /pairings PRG-QUERY-01 列出配对审批记录 + - GET /pairings/count PRG-COUNT 统计配对记录数 - POST /pairings PRG-CREATE-01 发起配对请求 - POST /pairings/batch-approve PRG-BATCH-ACT 批量批准配对 - POST /pairings/batch-reject PRG-BATCH-ACT 批量拒绝配对 @@ -167,6 +168,8 @@ async def count_pairings( operator = build_operator(current_user, request) created_after_dt = parse_datetime("created_after", created_after) created_before_dt = parse_datetime("created_before", created_before) + # count 仅消费过滤字段,limit/offset 由 use case 与 dispatch handler 忽略, + # 此处不传入以避免误导维护者认为 count 会取行数据。 query = PairingQuery( channel_type=channel_type, account_id=account_id, @@ -174,8 +177,6 @@ async def count_pairings( status=status, created_after=created_after_dt, created_before=created_before_dt, - limit=1, - offset=0, ) result = await use_cases.pairing_management.countPairings(query, operator) raiseOnControlFailure(result) diff --git a/backend/server/routers/channels/plugin_router.py b/backend/server/routers/channels/plugin_router.py index 9b07fd16..ea66179b 100644 --- a/backend/server/routers/channels/plugin_router.py +++ b/backend/server/routers/channels/plugin_router.py @@ -74,12 +74,15 @@ class UpdatePluginConfigRequest(BaseModel): ``config`` 必须为非空字典(由 ``UpdatePluginConfigCmd.__post_init__`` 校验),且 key 必须在插件 manifest 的 ``config_schema`` 中声明(由 dispatch handler 校验),否则抛 ``ValidationError``。 + ``account_id`` 用于 ACCOUNT 作用域配置字段的读写,未提供时 ACCOUNT + 作用域字段更新会抛 ``ValidationError``。 """ model_config = ConfigDict(frozen=True) config: dict[str, Any] = Field(..., description="配置值字典") apply_mode: Literal["hot", "restart_required"] = Field(..., description="应用模式") + account_id: str | None = Field(default=None, description="账户 ID(ACCOUNT 作用域配置字段需要)") class BatchPluginLifecycleRequest(BaseModel): @@ -191,7 +194,10 @@ async def batch_start_plugins( ) result = await use_cases.plugin_management.batchStartPlugins(cmd) raiseOnControlFailure(result) - return {"success": True, "data": serialize_control_data(result.data)} + response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)} + if result.status == "partial": + response["partial"] = True + return response @plugin_router.post("/batch/stop", response_model=dict) @@ -215,7 +221,10 @@ async def batch_stop_plugins( ) result = await use_cases.plugin_management.batchStopPlugins(cmd) raiseOnControlFailure(result) - return {"success": True, "data": serialize_control_data(result.data)} + response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)} + if result.status == "partial": + response["partial"] = True + return response # ---------------- 动态路径端点 ---------------- @@ -268,6 +277,7 @@ async def uninstall_plugin( async def get_plugin_config( plugin_id: str, request: Request, + account_id: str | None = Query(default=None, description="账户 ID(ACCOUNT 作用域配置字段需要)"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -275,12 +285,14 @@ async def get_plugin_config( 从 PluginManifest 获取配置 schema,从 ConfigManager 获取当前配置值, 组装 ``PluginConfigResult`` 返回,含 plugin_id / config / schema / - requires_restart / updated_at。 + requires_restart / updated_at。``account_id`` 用于读取 ACCOUNT 作用域 + 配置值,未提供时该类字段回退 schema 默认值。 """ operator = build_operator(current_user, request) result = await use_cases.plugin_management.getPluginConfig( plugin_id=plugin_id, operator=operator, + account_id=account_id, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @@ -299,7 +311,8 @@ async def update_plugin_config( 按 ``apply_mode`` 判断:``hot`` 模式立即生效(发布 ConfigChanged 事件); ``restart_required`` 模式仅持久化返回 ``requires_restart=true``。返回 ``PluginConfigResult``。config 的 key 必须在插件 manifest 的 - ``config_schema`` 中声明。 + ``config_schema`` 中声明。``account_id`` 用于 ACCOUNT 作用域配置字段, + 未提供时更新该类字段会抛 ``ValidationError``。 """ operator = build_operator(current_user, request) cmd = UpdatePluginConfigCmd( @@ -307,6 +320,7 @@ async def update_plugin_config( operator=operator, config=payload.config, apply_mode=payload.apply_mode, + account_id=payload.account_id, ) result = await use_cases.plugin_management.updatePluginConfig(cmd) raiseOnControlFailure(result) diff --git a/backend/server/routers/channels/route_binding_router.py b/backend/server/routers/channels/route_binding_router.py index dd363480..8d276726 100644 --- a/backend/server/routers/channels/route_binding_router.py +++ b/backend/server/routers/channels/route_binding_router.py @@ -70,6 +70,7 @@ class CreateRouteBindingRequest(BaseModel): match_source: str = Field(..., min_length=1, description="匹配来源(tier 名)") match_value: str | None = Field(default=None, description="匹配值(按 tier 约束)") agent_binding: str = Field(..., min_length=1, description="Agent 绑定(agent slug)") + enabled: bool = Field(default=True, description="是否启用(默认 True)") description: str | None = Field(default=None, description="描述") #: 当前运行时已实现 matcher 的 tier 集合;其余 tier 创建规则会被拒绝。 @@ -114,6 +115,10 @@ class UpdateRouteBindingRequest(BaseModel): 的 ``None`` 默认值表示"不更新"。``binding_id`` 由路径参数提供,不在请求体中。 ``version`` 必填,取自上次加载(GET/POST 返回的 ``version``),用于乐观锁 校验:服务端持久化版本不一致时返回 409,客户端需重新拉取后重试。 + + ``match_value`` 更新时需满足现有 ``match_source`` 的组合约束(与创建 + 一致,由 handler 层校验):``account`` / ``default`` tier 不允许携带 + match_value;``chat_type`` tier 的 match_value 必须为 ``p2p`` 或 ``group``。 """ model_config = ConfigDict(frozen=True) @@ -148,6 +153,7 @@ async def list_route_bindings( channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"), account_id: str | None = Query(default=None, description="按账户 ID 过滤"), match_source: str | None = Query(default=None, description="按匹配来源过滤"), + match_value: str | None = Query(default=None, description="按匹配值过滤"), enabled: bool | None = Query(default=None, description="按启用状态过滤"), agent_binding: str | None = Query(default=None, description="按绑定 Agent 过滤"), limit: int = DEFAULT_LIMIT, @@ -157,7 +163,7 @@ async def list_route_bindings( ) -> dict[str, Any]: """列出路由绑定规则(RB-01)。 - 支持按渠道类型 / 账户 / 匹配来源 / 启用状态 / 绑定 Agent 过滤,分页返回。 + 支持按渠道类型 / 账户 / 匹配来源 / 匹配值 / 启用状态 / 绑定 Agent 过滤,分页返回。 对应控制面操作 ``route_binding/list``。 """ operator = build_operator(current_user, request) @@ -165,6 +171,7 @@ async def list_route_bindings( channel_type=channel_type, account_id=account_id, match_source=match_source, + match_value=match_value, enabled=enabled, agent_binding=agent_binding, ) @@ -191,6 +198,7 @@ async def create_route_binding( match_source=payload.match_source, match_value=payload.match_value, agent_binding=payload.agent_binding, + enabled=payload.enabled, description=payload.description, ) result = await use_cases.route_binding_management.createRouteBinding(operator, cmd) diff --git a/backend/server/routers/channels/session_router.py b/backend/server/routers/channels/session_router.py index 19962db5..34cc0a85 100644 --- a/backend/server/routers/channels/session_router.py +++ b/backend/server/routers/channels/session_router.py @@ -4,8 +4,10 @@ 可访问。Router 不含任何业务逻辑,仅做协议翻译:将 HTTP 请求参数组装为契约层 命令/参数,调用 ``use_cases.session_management`` 的类型化方法走控制面管道, 再通过 ``raiseOnControlFailure`` 转译失败、``serialize_control_data`` 序列化 -成功结果(模板 A)。P3 绑定相关端点不走控制面管道,直接调用 -``use_cases.user_binding`` 用例方法(模板 B),异常由全局异常处理器统一转译。 +成功结果。P3 绑定相关端点不走五阶段控制面管道(用例方法直接调用仓储 + 领域 +服务),但仍返回 ``ControlResult``,失败转译与成功序列化复用同一套 +``raiseOnControlFailure`` / ``serialize_control_data``;前置校验失败(如用户 +不存在)由用例直接抛契约异常,经全局异常处理器统一映射。 端点清单: - GET /sessions SES-QUERY-01 列出渠道会话 @@ -48,6 +50,7 @@ from yuxi.storage.postgres.models_business import User from server.routers.channels import ( LARGE_LIMIT, + MESSAGE_LIMIT, OFFSET, build_operator, dataclass_to_dict, @@ -65,21 +68,21 @@ session_router = APIRouter(tags=["channels-session"]) class MergeSessionRequest(BaseModel): """合并会话请求体。 - 路径参数 ``session_id`` 作为目标会话 ID,映射为 - ``MergeConversationCmd.target_conversation_id``,不在 body 中。 + 路径参数 ``session_id`` 作为目标渠道会话 ID,映射为 + ``MergeConversationCmd.target_session_id``,不在 body 中。 """ model_config = ConfigDict(frozen=True) - source_conversation_id: str = Field(..., min_length=1, description="源会话 ID(被合并方)") + source_session_id: str = Field(..., min_length=1, description="源渠道会话 ID(被合并方)") reason: str = Field(default="", max_length=512, description="合并原因(审计用)") class TransferOwnerRequest(BaseModel): """转移所有者请求体。 - 路径参数 ``session_id`` 作为目标会话 ID,映射为 - ``OwnerTransferCmd.conversation_id``,不在 body 中。 + 路径参数 ``session_id`` 作为目标渠道会话 ID,映射为 + ``OwnerTransferCmd.session_id``,不在 body 中。 """ model_config = ConfigDict(frozen=True) @@ -112,7 +115,7 @@ class BatchCloseFilter(BaseModel): default=None, description="最后活动时间早于该时刻(ISO 8601,视为非活跃)", ) - status: str | None = Field( + status: SessionStatus | None = Field( default=None, description="会话状态过滤(active / closed)", ) @@ -335,12 +338,12 @@ async def merge_session( 受 ``merge_strategy_enabled`` 配置策略门控制,falsy 时返回 422。 对应控制面操作 ``session/merge``。 - 协议翻译:路径参数 ``session_id`` → ``MergeConversationCmd.target_conversation_id``。 + 协议翻译:路径参数 ``session_id`` → ``MergeConversationCmd.target_session_id``。 """ operator = build_operator(current_user, request) cmd = MergeConversationCmd( - source_conversation_id=payload.source_conversation_id, - target_conversation_id=session_id, + source_session_id=payload.source_session_id, + target_session_id=session_id, operator=operator, reason=payload.reason, ) @@ -363,11 +366,11 @@ async def transfer_session_owner( 加入控制面管道事务。审计日志由 AuditStage 在 SHARED 事务中统一写入(fail-closed)。 对应控制面操作 ``session/transfer_owner``。 - 协议翻译:路径参数 ``session_id`` → ``OwnerTransferCmd.conversation_id``。 + 协议翻译:路径参数 ``session_id`` → ``OwnerTransferCmd.session_id``。 """ operator = build_operator(current_user, request) cmd = OwnerTransferCmd( - conversation_id=session_id, + session_id=session_id, new_owner_id=payload.new_owner_id, operator=operator, ) @@ -406,7 +409,7 @@ async def close_session( async def list_session_messages( session_id: str, request: Request, - limit: int = LARGE_LIMIT, + limit: int = MESSAGE_LIMIT, offset: int = OFFSET, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), @@ -463,8 +466,11 @@ async def bind_session_user( """绑定系统用户到渠道会话(P3 渐进式绑定,SES-BIND-01)。 将渠道会话的统一身份绑定到系统用户,绑定后身份类型升级为 ``manual``, - 并触发跨渠道会话合并(同一统一身份下的兄弟会话合并到当前会话)。 - 不走控制面管道,直接调用用例方法,异常由全局异常处理器统一转译。 + 并触发跨渠道会话合并(同一统一身份下的兄弟会话合并到当前会话)。用例 + 方法不走五阶段控制面管道(直接调用仓储 + 领域服务),但仍返回 + ``ControlResult``,失败转译与成功序列化复用 ``raiseOnControlFailure`` / + ``serialize_control_data``;前置校验失败(如用户不存在、配置禁用)由 + 用例直接抛契约异常,经全局异常处理器统一映射。 协议翻译:路径参数 ``session_id`` + body ``user_uid`` / ``remark`` → ``bindUserToSession(session_id, user_uid, operator, remark=...)``。 @@ -490,8 +496,11 @@ async def unbind_session_user( """解绑渠道会话的系统用户(P3 渐进式绑定,SES-UNBIND-01)。 解除统一身份与系统用户的绑定,身份类型回退为 ``channel_guest``。 - 不删除历史消息:解绑仅修改身份关联状态,历史消息保留在原会话中。 - 不走控制面管道,直接调用用例方法,异常由全局异常处理器统一转译。 + 不删除历史消息:解绑仅修改身份关联状态,历史消息保留在原会话中。用例 + 方法不走五阶段控制面管道(直接调用仓储 + 领域服务),但仍返回 + ``ControlResult``,失败转译与成功序列化复用 ``raiseOnControlFailure`` / + ``serialize_control_data``;前置校验失败(如会话不存在)由用例直接抛 + 契约异常,经全局异常处理器统一映射。 协议翻译:路径参数 ``session_id`` → ``unbindUserFromSession(session_id, operator)``。 @@ -515,8 +524,10 @@ async def get_session_identity( """查询渠道会话身份绑定状态(P3 渐进式绑定,SES-IDENTITY-01)。 返回统一身份 ID、绑定用户 UID、身份类型与绑定状态。会话未关联统一 - 身份时返回 ``is_bound=False`` 的空状态。不走控制面管道,直接调用 - 用例方法,异常由全局异常处理器统一转译。 + 身份时返回 ``is_bound=False`` 的空状态。用例方法不走五阶段控制面管道 + (直接调用仓储),但仍返回 ``ControlResult``,失败转译与成功序列化复用 + ``raiseOnControlFailure`` / ``serialize_control_data``;会话不存在时由 + 用例直接抛契约异常,经全局异常处理器统一映射。 协议翻译:路径参数 ``session_id`` → ``getSessionIdentity(session_id, operator)``。 diff --git a/backend/server/routers/channels/wizard_router.py b/backend/server/routers/channels/wizard_router.py index b7939223..bf33684f 100644 --- a/backend/server/routers/channels/wizard_router.py +++ b/backend/server/routers/channels/wizard_router.py @@ -1,10 +1,11 @@ -"""安装向导域 Router(WIZ-01~WIZ-06)。 +"""安装向导域 Router(WIZ-01~WIZ-08)。 本 router 实现渠道插件安装向导流程的 HTTP 端点,覆盖步骤查询 / 校验 / -应用 / 完成 / OAuth 发起 / OAuth 回调共 6 个操作。所有端点统一采用模板 A -(控制面端口路由),通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``, -经 ``wizard`` 端口调用类型化方法,由 ``ChannelControlService._executeControl`` -内部走控制面管道(auth → permission → rate_limit → dispatch → audit)。 +应用 / 完成 / OAuth 发起 / OAuth 回调 / 扫码登录发起 / 扫码登录轮询共 8 个 +操作。所有端点统一采用模板 A(控制面端口路由),通过 +``get_channel_use_cases`` 装配 ``ChannelUseCases``,经 ``wizard`` 端口调用 +类型化方法,由 ``ChannelControlService._executeControl`` 内部走控制面管道 +(auth → permission → rate_limit → dispatch → audit)。 鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。 角色校验由依赖函数完成,router 内不做角色判断(规范 §4)。 @@ -20,6 +21,8 @@ - POST /{channel_type}/wizard/finalize WIZ-04 finalize_wizard - POST /{channel_type}/wizard/oauth/initiate WIZ-05 initiate_wizard_oauth - POST /{channel_type}/wizard/oauth-callback WIZ-06 handle_wizard_oauth_callback + - POST /{channel_type}/wizard/qr/initiate WIZ-07 initiate_wizard_qr_login + - POST /{channel_type}/wizard/qr/wait WIZ-08 wait_wizard_qr_login """ from __future__ import annotations diff --git a/backend/server/routers/external_systems/access_rule_router.py b/backend/server/routers/external_systems/access_rule_router.py index 47c9d767..809878cb 100644 --- a/backend/server/routers/external_systems/access_rule_router.py +++ b/backend/server/routers/external_systems/access_rule_router.py @@ -77,7 +77,7 @@ class CreateAccessRuleRequest(BaseModel): principal_name: str | None = Field(default=None, max_length=128) env_key: str | None = Field(default=None, max_length=32) effect: Literal["allow", "deny"] = "allow" - priority: int = 0 + priority: int = Field(default=0, ge=0) conditions: dict[str, Any] = Field(default_factory=dict) expires_at: datetime | None = None enabled: bool = True @@ -96,7 +96,7 @@ class UpdateAccessRuleRequest(BaseModel): principal_name: str | None = Field(default=None, max_length=128) effect: Literal["allow", "deny"] | None = None - priority: int | None = None + priority: int | None = Field(default=None, ge=0) conditions: dict[str, Any] | None = None expires_at: datetime | None = None enabled: bool | None = None @@ -152,7 +152,7 @@ class ReorderItem(BaseModel): model_config = ConfigDict(frozen=True) rule_id: int - priority: int + priority: int = Field(ge=0) class ReorderAccessRulesRequest(BaseModel): @@ -211,6 +211,7 @@ async def list_access_rules( env_key: str | None = Query(None, max_length=32), effect: Literal["allow", "deny"] | None = Query(None, description="规则效果:allow/deny"), enabled: bool | None = Query(None), + keyword: str | None = Query(None, max_length=128, description="关键词搜索:主体ID/名称/描述"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -226,6 +227,7 @@ async def list_access_rules( env_key=env_key, effect=effect, enabled=enabled, + keyword=keyword, ) output = await use_cases.access_rule_service.list_access_rules(input_dto) return {"success": True, "data": output.model_dump()} diff --git a/backend/server/routers/external_systems/adapter_router.py b/backend/server/routers/external_systems/adapter_router.py index 00d74a47..da7ab81d 100644 --- a/backend/server/routers/external_systems/adapter_router.py +++ b/backend/server/routers/external_systems/adapter_router.py @@ -103,6 +103,30 @@ async def get_adapters_overview( return {"success": True, "data": {"items": items, "total": len(items)}} +@adapter_router.get("/health-batch", response_model=dict) +async def get_adapters_health_batch( + current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """批量查询所有已注册适配器的健康状态。 + + 遍历 ``default_registry`` 中所有适配器,通过 ``AdapterStatsService`` + 逐个聚合健康状态,返回 ``{ adapter_type: health_data }`` 列表。 + 避免前端逐个发起 ``GET /{adapter_type}/health`` 请求(N+1 HTTP 问题)。 + """ + metadata_list = sorted( + default_registry.list_adapter_metadata(), + key=lambda m: m.adapter_type, + ) + stats_service = create_adapter_stats_service_from_db(db) + items = [] + for metadata in metadata_list: + input_dto = GetAdapterHealthInput(adapter_type=metadata.adapter_type) + output = await stats_service.get_adapter_health(input_dto) + items.append(output.model_dump()) + return {"success": True, "data": {"items": items, "total": len(items)}} + + # ============================================================================= # === 路径参数端点(/{adapter_type}) === # ============================================================================= @@ -143,21 +167,6 @@ async def get_adapter( return {"success": True, "data": data} -@adapter_router.get("/{adapter_type}/capabilities", response_model=dict) -async def get_adapter_capabilities( - adapter_type: str, - current_user: User = Depends(get_required_user), -) -> dict[str, Any]: - """查询适配器支持的能力(轻量端点,仅返回 5 个能力字段)。 - - 与详情端点的差异:详情端点返回完整元数据(含 Schema 与资产类型), - 本端点仅返回 ``to_capabilities()`` 的 5 个能力字段,响应体减小约 80%。 - ``AdapterNotFoundError`` 由全局处理器映射为 404。 - """ - metadata = default_registry.get_adapter_metadata(adapter_type) - return {"success": True, "data": metadata.to_capabilities()} - - @adapter_router.get("/{adapter_type}/config-schema", response_model=dict) async def get_adapter_config_schema( adapter_type: str, diff --git a/backend/server/routers/external_systems/alert_router.py b/backend/server/routers/external_systems/alert_router.py index 7c5605e5..7e9dc34b 100644 --- a/backend/server/routers/external_systems/alert_router.py +++ b/backend/server/routers/external_systems/alert_router.py @@ -25,9 +25,10 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query +from fastapi import APIRouter, Body, Depends, Path, Query from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.external_systems.exceptions import DomainValidationError from yuxi.external_systems.infrastructure.container import create_use_cases_from_db from yuxi.external_systems.use_cases.dto.alert import ( AcknowledgeAlertInput, @@ -138,14 +139,12 @@ async def list_alerts( ) -> dict[str, Any]: """分页列出告警事件(支持 keyword / resource_type / resource_id / related_trace_id / dedup_key 过滤)。 - 时间范围校验:若 ``start_time`` 晚于 ``end_time``,返回 422 校验错误。 + 时间范围校验:若 ``start_time`` 晚于 ``end_time``,抛 ``DomainValidationError`` + (由全局 ``unified_error_handler`` 统一映射为 400 + 项目统一错误格式)。 列表结果包含 ``system_name`` / ``system_slug``(由 LEFT JOIN 填充)。 """ if start_time is not None and end_time is not None and start_time > end_time: - raise HTTPException( - status_code=422, - detail="start_time 不能晚于 end_time", - ) + raise DomainValidationError("start_time 不能晚于 end_time") use_cases = create_use_cases_from_db(db) input_dto = ListAlertsInput( limit=limit, @@ -178,13 +177,11 @@ async def get_alert_stats( ) -> dict[str, Any]: """查询告警统计聚合(支持 system_id / env_key / 时间范围过滤)。 - 时间范围基于 ``triggered_at`` 字段,若 ``start_time`` 晚于 ``end_time`` 返回 422。 + 时间范围基于 ``triggered_at`` 字段,若 ``start_time`` 晚于 ``end_time`` 抛 + ``DomainValidationError``(由全局 ``unified_error_handler`` 统一映射为 400)。 """ if start_time is not None and end_time is not None and start_time > end_time: - raise HTTPException( - status_code=422, - detail="start_time 不能晚于 end_time", - ) + raise DomainValidationError("start_time 不能晚于 end_time") use_cases = create_use_cases_from_db(db) input_dto = AlertStatsInput( system_id=system_id, diff --git a/backend/server/routers/external_systems/asset_router.py b/backend/server/routers/external_systems/asset_router.py index e441935b..bb8f79e7 100644 --- a/backend/server/routers/external_systems/asset_router.py +++ b/backend/server/routers/external_systems/asset_router.py @@ -35,9 +35,15 @@ from pydantic import ( model_validator, ) from sqlalchemy.ext.asyncio import AsyncSession -from yuxi.external_systems.exceptions import ExternalSystemError +from yuxi.external_systems.exceptions import ExternalSystemError, FrameworkError from yuxi.external_systems.infrastructure.container import create_use_cases_from_db from yuxi.external_systems.use_cases.dto.asset import ( + BatchDeleteFailureItem, + BatchDeleteOutput, + BatchUploadFailureItem, + BatchUploadOutput, + BatchValidateFailureItem, + BatchValidateOutput, CreateAssetInput, DeleteAssetInput, GeneratePreviewInput, @@ -50,7 +56,9 @@ from yuxi.external_systems.use_cases.dto.asset import ( UpdateAssetInput, ValidateAssetInput, ) +from yuxi.external_systems.use_cases.services.asset_service import MAX_CONTENT_BYTES from yuxi.storage.postgres.models_business import User +from yuxi.utils.trace_context import get_trace_id from server.utils.auth_middleware import get_admin_user, get_db, get_required_user @@ -58,8 +66,9 @@ logger = logging.getLogger(__name__) asset_router = APIRouter(prefix="/assets", tags=["external-systems-asset"]) -# base64 字符串最大长度(≈10MB 二进制,含 33% 编码开销 + padding 余量) -_MAX_BASE64_LENGTH = 14_000_000 +# base64 字符串最大长度(≈10MB 二进制,含 33% 编码开销 + padding 余量)。 +# 二进制上限引用 ``asset_service.MAX_CONTENT_BYTES``,保证两处一致。 +_MAX_BASE64_LENGTH = int(MAX_CONTENT_BYTES * 1.4) # ---------------- Request Schemas ---------------- @@ -178,6 +187,15 @@ async def list_assets( current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """分页列出适配器资产。""" + logger.info( + "list_assets: user=%s limit=%s offset=%s adapter_type=%s asset_type=%s status=%s", + current_user.uid, + limit, + offset, + adapter_type, + asset_type, + status, + ) use_cases = create_use_cases_from_db(db) input_dto = ListAssetsInput( limit=limit, @@ -220,6 +238,7 @@ async def get_asset_stats( current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """查询资产统计(按适配器类型、资产类型、状态分组)。""" + logger.info("get_asset_stats: user=%s", current_user.uid) use_cases = create_use_cases_from_db(db) output = await use_cases.asset_service.get_asset_stats() return {"success": True, "data": output.model_dump()} @@ -244,24 +263,24 @@ async def get_asset_by_checksum( return {"success": True, "data": output.model_dump() if output else None} -@asset_router.post("/batch-upload", response_model=dict) +@asset_router.post("/batch-upload", response_model=BatchUploadOutput) async def batch_upload_assets( adapter_type: str = Form(..., max_length=32), asset_type: str = Form(..., max_length=32), files: list[UploadFile] = File(..., min_length=1, max_length=100), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> BatchUploadOutput: """批量上传资产文件(multipart/form-data),允许部分成功。 所有文件共享一个 ``adapter_type`` 与 ``asset_type``,适用于批量上传同类型资产场景。 单次最多 100 个文件,每个文件独立创建,失败项记录 filename 与 reason。 - 业务异常(``ExternalSystemError``)与技术异常均不中断批量流程, + 业务异常(``ExternalSystemError``)与技术异常(``FrameworkError``)均不中断批量流程, 技术异常额外记录堆栈日志便于排查。 """ use_cases = create_use_cases_from_db(db) - uploaded: list[dict[str, Any]] = [] - failed: list[dict[str, str]] = [] + uploaded = [] + failed = [] for file in files: filename = file.filename or "unnamed" try: @@ -274,68 +293,125 @@ async def batch_upload_assets( created_by=current_user.uid, ) output = await use_cases.asset_service.create_asset(input_dto) - uploaded.append(output.model_dump()) + uploaded.append(output) + except FrameworkError as exc: + logger.exception("批量上传文件 %s 时发生框架异常", filename) + failed.append(BatchUploadFailureItem( + filename=filename, + reason=str(exc), + error_code=exc.error_code, + trace_id=exc.trace_id or get_trace_id(), + )) except ExternalSystemError as exc: - failed.append({"filename": filename, "reason": str(exc)}) + failed.append(BatchUploadFailureItem( + filename=filename, + reason=str(exc), + error_code=exc.error_code, + trace_id=exc.trace_id or get_trace_id(), + )) except Exception as exc: logger.exception("批量上传文件 %s 时发生非预期异常", filename) - failed.append({"filename": filename, "reason": str(exc)}) - return {"success": True, "data": {"uploaded": uploaded, "failed": failed}} + wrapped = FrameworkError(f"批量上传文件 {filename} 失败: {exc}") + failed.append(BatchUploadFailureItem( + filename=filename, + reason=str(wrapped), + error_code=wrapped.error_code, + trace_id=get_trace_id(), + )) + return BatchUploadOutput(uploaded=uploaded, failed=failed) -@asset_router.post("/batch-delete", response_model=dict) +@asset_router.post("/batch-delete", response_model=BatchDeleteOutput) async def batch_delete_assets( payload: BatchDeleteAssetsRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> BatchDeleteOutput: """批量删除资产(软删除),允许部分成功。 循环调用 ``delete_asset`` 保证每个资产的引用检查完整执行。 - 业务异常(``ExternalSystemError``)与技术异常均不中断批量流程, + 业务异常(``ExternalSystemError``)与技术异常(``FrameworkError``)均不中断批量流程, 技术异常额外记录堆栈日志便于排查。 """ use_cases = create_use_cases_from_db(db) deleted_count = 0 - failed: list[dict[str, Any]] = [] + failed = [] for asset_id in payload.asset_ids: try: input_dto = DeleteAssetInput(id=asset_id, user=current_user.uid) await use_cases.asset_service.delete_asset(input_dto) deleted_count += 1 + except FrameworkError as exc: + logger.exception("批量删除资产 %s 时发生框架异常", asset_id) + failed.append(BatchDeleteFailureItem( + asset_id=asset_id, + reason=str(exc), + error_code=exc.error_code, + trace_id=exc.trace_id or get_trace_id(), + )) except ExternalSystemError as exc: - failed.append({"asset_id": asset_id, "reason": str(exc)}) + failed.append(BatchDeleteFailureItem( + asset_id=asset_id, + reason=str(exc), + error_code=exc.error_code, + trace_id=exc.trace_id or get_trace_id(), + )) except Exception as exc: logger.exception("批量删除资产 %s 时发生非预期异常", asset_id) - failed.append({"asset_id": asset_id, "reason": str(exc)}) - return {"success": True, "data": {"deleted_count": deleted_count, "failed": failed}} + wrapped = FrameworkError(f"批量删除资产 {asset_id} 失败: {exc}") + failed.append(BatchDeleteFailureItem( + asset_id=asset_id, + reason=str(wrapped), + error_code=wrapped.error_code, + trace_id=get_trace_id(), + )) + return BatchDeleteOutput(deleted_count=deleted_count, failed=failed) -@asset_router.post("/batch-validate", response_model=dict) +@asset_router.post("/batch-validate", response_model=BatchValidateOutput) async def batch_validate_assets( payload: BatchValidateAssetsRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> BatchValidateOutput: """批量校验资产并更新状态,允许部分成功。 - 业务异常(``ExternalSystemError``)与技术异常均不中断批量流程, + 业务异常(``ExternalSystemError``)与技术异常(``FrameworkError``)均不中断批量流程, 技术异常额外记录堆栈日志便于排查。 """ use_cases = create_use_cases_from_db(db) - validated: list[dict[str, Any]] = [] - failed: list[dict[str, Any]] = [] + validated = [] + failed = [] for asset_id in payload.asset_ids: try: input_dto = ValidateAssetInput(id=asset_id, user=current_user.uid) output = await use_cases.asset_service.validate_asset(input_dto) - validated.append(output.model_dump()) + validated.append(output) + except FrameworkError as exc: + logger.exception("批量校验资产 %s 时发生框架异常", asset_id) + failed.append(BatchValidateFailureItem( + asset_id=asset_id, + reason=str(exc), + error_code=exc.error_code, + trace_id=exc.trace_id or get_trace_id(), + )) except ExternalSystemError as exc: - failed.append({"asset_id": asset_id, "reason": str(exc)}) + failed.append(BatchValidateFailureItem( + asset_id=asset_id, + reason=str(exc), + error_code=exc.error_code, + trace_id=exc.trace_id or get_trace_id(), + )) except Exception as exc: logger.exception("批量校验资产 %s 时发生非预期异常", asset_id) - failed.append({"asset_id": asset_id, "reason": str(exc)}) - return {"success": True, "data": {"validated": validated, "failed": failed}} + wrapped = FrameworkError(f"批量校验资产 {asset_id} 失败: {exc}") + failed.append(BatchValidateFailureItem( + asset_id=asset_id, + reason=str(wrapped), + error_code=wrapped.error_code, + trace_id=get_trace_id(), + )) + return BatchValidateOutput(validated=validated, failed=failed) @asset_router.post("/probe", response_model=dict) diff --git a/backend/server/routers/external_systems/audit_log_router.py b/backend/server/routers/external_systems/audit_log_router.py index 68e9e71e..c157442b 100644 --- a/backend/server/routers/external_systems/audit_log_router.py +++ b/backend/server/routers/external_systems/audit_log_router.py @@ -14,6 +14,7 @@ from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, Path, Query +from fastapi.exceptions import RequestValidationError from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from yuxi.external_systems.infrastructure.container import create_use_cases_from_db @@ -37,6 +38,25 @@ audit_log_router = APIRouter( ) +def _validate_time_range(start: datetime | None, end: datetime | None) -> None: + """校验时间范围语义:start 不晚于 end。 + + 若校验失败,抛出 ``RequestValidationError``,由全局 + ``request_validation_error_handler`` 统一映射为 422 + 项目统一错误格式。 + """ + if start is not None and end is not None and start > end: + raise RequestValidationError( + errors=[ + { + "loc": ("query", "start_time"), + "msg": "start_time 不能晚于 end_time", + "type": "value_error", + "input": {"start_time": start, "end_time": end}, + } + ] + ) + + # ============================================================================= # === 静态路径端点(必须在 /{log_id} 之前声明) === # ============================================================================= @@ -56,7 +76,7 @@ async def export_audit_logs( offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), -): +) -> StreamingResponse: """导出审计日志为 JSON 字节流。 审计日志含跨系统敏感操作记录,导出需管理员权限。 @@ -65,6 +85,7 @@ async def export_audit_logs( 采用增量流式输出,避免一次性构建大 payload 导致内存峰值。 """ + _validate_time_range(start_time, end_time) use_cases = create_use_cases_from_db(db) input_dto = ExportAuditLogsInput( system_id=system_id, @@ -105,7 +126,7 @@ async def export_audit_logs( headers = { "Content-Disposition": f"attachment; filename=audit_logs_{timestamp_str}.json", "X-Total-Count": str(output.total), - "X-Truncated": str(output.truncated), + "X-Truncated": str(output.truncated).lower(), } return StreamingResponse( @@ -131,6 +152,7 @@ async def list_audit_logs( current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """分页列出审计日志。""" + _validate_time_range(start_time, end_time) use_cases = create_use_cases_from_db(db) input_dto = ListAuditLogsInput( limit=limit, @@ -157,6 +179,7 @@ async def get_audit_log_stats( current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """查询审计日志统计聚合(按 action_type 分组)。""" + _validate_time_range(start_time, end_time) use_cases = create_use_cases_from_db(db) input_dto = GetAuditLogStatsInput( system_id=system_id, @@ -186,6 +209,7 @@ async def list_config_changes( ``action_type`` 默认不限制,返回所有含快照的记录(CREATE/UPDATE/DELETE 等)。 """ + _validate_time_range(start_time, end_time) use_cases = create_use_cases_from_db(db) input_dto = ListConfigChangesInput( limit=limit, diff --git a/backend/server/routers/external_systems/auth_plugin_router.py b/backend/server/routers/external_systems/auth_plugin_router.py index b082abcc..8532e847 100644 --- a/backend/server/routers/external_systems/auth_plugin_router.py +++ b/backend/server/routers/external_systems/auth_plugin_router.py @@ -13,8 +13,8 @@ from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel -from yuxi.external_systems.exceptions import AuthError, SecretResolutionError +from pydantic import BaseModel, ValidationError +from yuxi.external_systems.exceptions import AuthError, DomainValidationError, SecretResolutionError from yuxi.external_systems.framework.auth_plugins import ( get_auth_plugin, get_auth_json_schema, @@ -202,7 +202,13 @@ async def get_token_strategies( """ auth_types = token_refresh_registry.list_strategies() framework_plugins = list_auth_plugins(None) + protocol_plugins = list_all_protocol_auth_plugins() display_name_map = {plugin["type"]: plugin["display_name"] for plugin in framework_plugins} + # 协议专属插件可能使用与框架级相同的 auth_type(如 tls),但展示名可能不同; + # 刷新策略若由协议专属插件注册,应优先显示其 display_name。 + display_name_map.update( + {plugin["type"]: plugin["display_name"] for plugin in protocol_plugins} + ) items = [ { @@ -304,8 +310,9 @@ async def validate_auth_config( 调用 ``resolve_credential`` 进行完整凭证解析校验(含必填字段检查与密钥引用 解析),而非仅 Pydantic 结构校验。``AuthPluginNotFoundError`` (404) 由全局 - 处理器映射,Router 内不捕获;``AuthError`` / ``SecretResolutionError`` 为 - 校验失败结果,Router 内显式捕获并返回 200 + ``valid=False``。 + 处理器映射,Router 内不捕获;``AuthError`` / ``SecretResolutionError`` / + ``DomainValidationError`` / Pydantic ``ValidationError`` 为校验失败结果, + Router 内显式捕获并返回 200 + ``valid=False``。 不返回解析后的凭证对象(含敏感信息)。 @@ -322,7 +329,7 @@ async def validate_auth_config( resolver=None, adapter_type=adapter_type, ) - except (AuthError, SecretResolutionError) as exc: + except (AuthError, SecretResolutionError, DomainValidationError) as exc: return { "success": True, "data": { @@ -332,6 +339,18 @@ async def validate_auth_config( "has_refresh_strategy": has_refresh_strategy, }, } + except ValidationError as exc: + # Pydantic 校验失败(如字段类型不匹配),errors 格式化为 list[str], + # 与 adapter_router / system_router 校验端点对齐 + return { + "success": True, + "data": { + "valid": False, + "errors": [f"{'.'.join(str(p) for p in e['loc'])}: {e['msg']}" for e in exc.errors()], + "is_token_based": is_token_based, + "has_refresh_strategy": has_refresh_strategy, + }, + } return { "success": True, diff --git a/backend/server/routers/external_systems/dashboard_router.py b/backend/server/routers/external_systems/dashboard_router.py index a002fc19..4d542a9d 100644 --- a/backend/server/routers/external_systems/dashboard_router.py +++ b/backend/server/routers/external_systems/dashboard_router.py @@ -2,8 +2,8 @@ 外部系统限界上下文的监控仪表板 API,覆盖全局总览 / 系统维度 / 执行维度 / 告警维度 / 配额维度 / 健康维度 / 活动维度的跨子域聚合统计。 -所有端点通过 ``create_use_cases_from_db`` 装配 use_cases, -经 ``dashboard_service`` 端口调用用例。 +所有端点通过 ``create_dashboard_service_from_db`` 装配轻量级 dashboard_service, +经 ``DashboardServicePort`` 端口调用用例(纯只读聚合,无需完整 use_cases 容器)。 路径顺序约束:本 router 所有端点均为静态路径(无动态路径参数),无路由匹配歧义。 端点按业务维度组织(总览 → 系统 → 执行 → 告警 → 配额 → 健康 → 活动), @@ -19,9 +19,14 @@ from datetime import datetime from typing import Any, Literal from fastapi import APIRouter, Depends, Query +from fastapi.exceptions import RequestValidationError +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from yuxi.external_systems.infrastructure.container import create_dashboard_service_from_db from yuxi.external_systems.use_cases.dto.dashboard import ( + ActivitiesStatsOutput, + AlertsStatsOutput, + ExecutionsStatsOutput, GetActivitiesStatsInput, GetAlertsStatsInput, GetExecutionsStatsInput, @@ -29,6 +34,12 @@ from yuxi.external_systems.use_cases.dto.dashboard import ( GetOverviewInput, GetQuotasStatsInput, GetSystemsStatsInput, + GetTodosStatsInput, + HealthStatsOutput, + OverviewOutput, + QuotasStatsOutput, + SystemsStatsOutput, + TodosStatsOutput, ) from yuxi.storage.postgres.models_business import User @@ -37,6 +48,36 @@ from server.utils.auth_middleware import get_db, get_required_user dashboard_router = APIRouter(prefix="/dashboard", tags=["external-systems-dashboard"]) +# ---------------- Response Helpers ---------------- + + +def _build_response(output: BaseModel) -> dict[str, Any]: + """构建统一成功响应,使用 JSON 模式序列化确保日期/枚举值可安全输出。""" + return {"success": True, "data": output.model_dump(mode="json")} + + +def _validate_time_range( + start: datetime | None, + end: datetime | None, +) -> None: + """校验时间范围语义:start 不晚于 end。 + + 若校验失败,抛出 ``RequestValidationError``,由全局 + ``request_validation_error_handler`` 统一映射为 422 + 项目统一错误格式。 + """ + if start is not None and end is not None and start > end: + raise RequestValidationError( + errors=[ + { + "loc": ("query", "start_time"), + "msg": "start_time 不能晚于 end_time", + "type": "value_error", + "input": {"start_time": start, "end_time": end}, + } + ] + ) + + # ---------------- Endpoints ---------------- @@ -48,10 +89,11 @@ async def get_overview( current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """全局总览。聚合 5 个子域的关键指标。""" + _validate_time_range(start_time, end_time) dashboard_service = create_dashboard_service_from_db(db) input_dto = GetOverviewInput(start=start_time, end=end_time) - output = await dashboard_service.get_overview(input_dto) - return {"success": True, "data": output.model_dump()} + output: OverviewOutput = await dashboard_service.get_overview(input_dto) + return _build_response(output) @dashboard_router.get("/systems", response_model=dict) @@ -62,8 +104,8 @@ async def get_systems_stats( """系统维度统计。含启用状态/适配器类型/分类分组 + 熔断器状态。""" dashboard_service = create_dashboard_service_from_db(db) input_dto = GetSystemsStatsInput() - output = await dashboard_service.get_systems_stats(input_dto) - return {"success": True, "data": output.model_dump()} + output: SystemsStatsOutput = await dashboard_service.get_systems_stats(input_dto) + return _build_response(output) @dashboard_router.get("/executions", response_model=dict) @@ -75,14 +117,15 @@ async def get_executions_stats( current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """执行维度统计。含按状态/工具分组。""" + _validate_time_range(start_time, end_time) dashboard_service = create_dashboard_service_from_db(db) input_dto = GetExecutionsStatsInput( start=start_time, end=end_time, tool_limit=tool_limit, ) - output = await dashboard_service.get_executions_stats(input_dto) - return {"success": True, "data": output.model_dump()} + output: ExecutionsStatsOutput = await dashboard_service.get_executions_stats(input_dto) + return _build_response(output) @dashboard_router.get("/alerts", response_model=dict) @@ -101,8 +144,8 @@ async def get_alerts_stats( severity_status=severity_status, top_limit=top_limit, ) - output = await dashboard_service.get_alerts_stats(input_dto) - return {"success": True, "data": output.model_dump()} + output: AlertsStatsOutput = await dashboard_service.get_alerts_stats(input_dto) + return _build_response(output) @dashboard_router.get("/quotas", response_model=dict) @@ -118,8 +161,8 @@ async def get_quotas_stats( warning_limit=warning_limit, critical_limit=critical_limit, ) - output = await dashboard_service.get_quotas_stats(input_dto) - return {"success": True, "data": output.model_dump()} + output: QuotasStatsOutput = await dashboard_service.get_quotas_stats(input_dto) + return _build_response(output) @dashboard_router.get("/health", response_model=dict) @@ -130,8 +173,8 @@ async def get_health_stats( """健康维度统计。含按健康状态分组。""" dashboard_service = create_dashboard_service_from_db(db) input_dto = GetHealthStatsInput() - output = await dashboard_service.get_health_stats(input_dto) - return {"success": True, "data": output.model_dump()} + output: HealthStatsOutput = await dashboard_service.get_health_stats(input_dto) + return _build_response(output) @dashboard_router.get("/activities", response_model=dict) @@ -142,7 +185,26 @@ async def get_activities_stats( current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """活动维度统计。含按操作类型分组。""" + _validate_time_range(start_time, end_time) dashboard_service = create_dashboard_service_from_db(db) input_dto = GetActivitiesStatsInput(start=start_time, end=end_time) - output = await dashboard_service.get_activities_stats(input_dto) - return {"success": True, "data": output.model_dump()} + output: ActivitiesStatsOutput = await dashboard_service.get_activities_stats(input_dto) + return _build_response(output) + + +@dashboard_router.get("/todos", response_model=dict) +async def get_todos_stats( + expiring_token_days: int = Query( + 7, + ge=1, + le=90, + description="Token 即将过期阈值(天)", + ), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_required_user), +) -> dict[str, Any]: + """待办统计。跨子域聚合影响导航健康灯/角标的关键计数。""" + dashboard_service = create_dashboard_service_from_db(db) + input_dto = GetTodosStatsInput(expiring_token_days=expiring_token_days) + output: TodosStatsOutput = await dashboard_service.get_todos_stats(input_dto) + return _build_response(output) diff --git a/backend/server/routers/external_systems/environment_router.py b/backend/server/routers/external_systems/environment_router.py index 154cc3af..342c500a 100644 --- a/backend/server/routers/external_systems/environment_router.py +++ b/backend/server/routers/external_systems/environment_router.py @@ -7,7 +7,7 @@ 路径顺序约束:静态路径(``/diff`` / ``/active`` / ``/by-key`` / ``/stats`` / ``/cross-system`` / ``/batch-test-connectivity`` / ``/batch-clone`` / -``/batch-enabled``)必须在 ``/{env_id}`` 前声明,避免被路径参数捕获。 +``/batch-enabled`` / ``/batch-health``)必须在 ``/{env_id}`` 前声明,避免被路径参数捕获。 认证策略:查询类端点使用 ``get_required_user``,写操作端点使用 ``get_admin_user``。 ``export_environment`` 因 ``include_secrets=true`` 会泄露明文密钥,统一要求 ``get_admin_user``。 @@ -27,6 +27,7 @@ from yuxi.external_systems.infrastructure.container import create_use_cases_from from yuxi.external_systems.use_cases.dto.environment import ( BatchCloneEnvironmentsInput, BatchEnableEnvironmentsInput, + BatchEnvironmentHealthInput, BulkTestEnvironmentsInput, CloneEnvironmentInput, CreateEnvironmentInput, @@ -83,6 +84,8 @@ class UpdateEnvironmentRequest(BaseModel): 字段长度约束对齐 ``ExternalSystemEnvironment`` ORM 列定义,仅透传客户端显式设置的字段。 不含 ``is_default``:默认环境切换必须通过 ``PATCH /{env_id}/default`` 端点, 该端点会清除同系统其他环境的默认标记,避免违反部分唯一约束。 + 不含 ``enabled``:启停必须通过 ``PATCH /{env_id}/enabled`` 端点,该端点会执行 + 禁用环境的安全检查(清除默认标记、释放连接池、失效 Token、发布状态变更事件)。 """ model_config = ConfigDict(frozen=True) @@ -90,7 +93,6 @@ class UpdateEnvironmentRequest(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=128) connection_config: dict[str, Any] | None = None auth_config: dict[str, Any] | None = None - enabled: bool | None = None class SetEnvironmentEnabledRequest(BaseModel): @@ -115,12 +117,16 @@ class CloneEnvironmentRequest(BaseModel): class BatchTestConnectivityRequest(BaseModel): - """批量测试连通性请求体。字段对齐 ``BulkTestEnvironmentsInput``。""" + """批量测试连通性请求体。字段对齐 ``BulkTestEnvironmentsInput``。 + + ``env_keys`` 限制最多 100 条,对齐 ``BatchEnvironmentHealthRequest.env_ids`` + 及其他批量请求体的列表上限,避免请求体过大。 + """ model_config = ConfigDict(frozen=True) system_id: int - env_keys: list[str] | None = None + env_keys: list[str] | None = Field(default=None, max_length=100) class BatchCloneEnvironmentsRequest(BaseModel): @@ -153,6 +159,18 @@ class BatchEnableEnvironmentsRequest(BaseModel): enabled: bool +class BatchEnvironmentHealthRequest(BaseModel): + """批量查询环境健康状态请求体。字段对齐 ``BatchEnvironmentHealthInput``。 + + ``env_ids`` 为 None 时查询系统下所有环境的健康状态。 + """ + + model_config = ConfigDict(frozen=True) + + system_id: int + env_ids: list[int] | None = Field(default=None, max_length=100) + + # ---------------- Endpoints ---------------- @@ -332,6 +350,22 @@ async def batch_set_environments_enabled( return {"success": True, "data": output.model_dump()} +@environment_router.post("/batch-health", response_model=dict) +async def batch_get_environment_health( + body: BatchEnvironmentHealthRequest, + 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 = BatchEnvironmentHealthInput( + system_id=body.system_id, + env_ids=body.env_ids, + ) + output = await use_cases.environment_service.batch_get_environment_health(input_dto) + return {"success": True, "data": output.model_dump()} + + @environment_router.get("/{env_id}", response_model=dict) async def get_environment( env_id: int, diff --git a/backend/server/routers/external_systems/execution_router.py b/backend/server/routers/external_systems/execution_router.py index 4a071c6c..2d1ad3cb 100644 --- a/backend/server/routers/external_systems/execution_router.py +++ b/backend/server/routers/external_systems/execution_router.py @@ -17,12 +17,13 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from fastapi import APIRouter, Depends, Query +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.execution import ( CleanupInput, + ExecutionStatusLiteral, GetExecutionContextInput, GetExecutionDetailInput, GetExecutionStatsInput, @@ -51,9 +52,9 @@ class CleanupExecutionsRequest(BaseModel): model_config = ConfigDict(frozen=True) - system_id: int | None = None + system_id: int | None = Field(default=None, ge=1) before_at: datetime | None = None - status: Literal["pending", "running", "success", "failed", "timeout", "throttled", "cancelled"] | None = None + status: ExecutionStatusLiteral | None = None batch_size: int = Field(default=1000, ge=1, le=10000) @@ -87,7 +88,7 @@ async def paginate_executions( system_id: int | None = Query(None, ge=1), env_key: str | None = Query(None, max_length=32), tool_slug: str | None = Query(None, max_length=128), - status: Literal["pending", "running", "success", "failed", "timeout", "throttled", "cancelled"] | None = Query( + status: ExecutionStatusLiteral | None = Query( None, description="执行状态:pending/running/success/failed/timeout/throttled/cancelled" ), caller: str | None = Query(None, max_length=32), @@ -248,7 +249,7 @@ async def list_slow_executions( @execution_router.get("/by-trace/{trace_id}", response_model=dict) async def list_by_trace( - trace_id: str, + trace_id: str = Path(..., min_length=1, max_length=64, description="调用链路 ID"), limit: int = Query(50, ge=1, le=200, description="返回数量"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), @@ -270,7 +271,7 @@ async def list_by_trace( @execution_router.get("/{execution_id}", response_model=dict) async def get_execution_detail( - execution_id: str, + execution_id: str = Path(..., min_length=1, max_length=128, description="执行记录 ID"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -283,7 +284,7 @@ async def get_execution_detail( @execution_router.get("/{execution_id}/trace", response_model=dict) async def get_execution_trace( - execution_id: str, + execution_id: str = Path(..., min_length=1, max_length=128, description="执行记录 ID"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -296,7 +297,7 @@ async def get_execution_trace( @execution_router.get("/{execution_id}/retries", response_model=dict) async def list_retries( - execution_id: str, + execution_id: str = Path(..., min_length=1, max_length=128, description="执行记录 ID"), limit: int = Query(50, ge=1, le=200, description="返回数量"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), @@ -313,7 +314,7 @@ async def list_retries( @execution_router.get("/{execution_id}/context", response_model=dict) async def get_execution_context( - execution_id: str, + execution_id: str = Path(..., min_length=1, max_length=128, description="执行记录 ID"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -326,7 +327,7 @@ async def get_execution_context( @execution_router.post("/{execution_id}/retry", response_model=dict) async def retry_execution( - execution_id: str, + execution_id: str = Path(..., min_length=1, max_length=128, description="执行记录 ID"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: diff --git a/backend/server/routers/external_systems/health_check_router.py b/backend/server/routers/external_systems/health_check_router.py index 72f130e1..07d786be 100644 --- a/backend/server/routers/external_systems/health_check_router.py +++ b/backend/server/routers/external_systems/health_check_router.py @@ -20,6 +20,7 @@ from datetime import datetime from typing import Any, Literal from fastapi import APIRouter, Depends, Path, Query +from pydantic import BaseModel, 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.health_check import ( @@ -30,6 +31,7 @@ from yuxi.external_systems.use_cases.dto.health_check import ( ListFailingInput, ListLatestHealthInput, ListUncheckedInput, + TriggerSystemHealthCheckInput, ) from yuxi.storage.postgres.models_business import User @@ -110,7 +112,7 @@ async def list_failing( @health_check_router.get("/unchecked", response_model=dict) async def list_unchecked( - system_ids: list[int] = Query(..., description="待检查的系统 ID 列表(逗号分隔)"), + system_ids: list[int] = Query(..., min_length=1, description="待检查的系统 ID 列表(逗号分隔)"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -127,9 +129,9 @@ async def delete_old_records( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: - """清理过期记录(软删除)。按 checked_at 过滤。""" + """清理过期记录(软删除)。按 checked_at 过滤,记录操作人以备审计。""" use_cases = create_use_cases_from_db(db) - input_dto = DeleteOldRecordsInput(before=before) + input_dto = DeleteOldRecordsInput(before=before, deleted_by=current_user.uid) output = await use_cases.health_check_service.delete_old_records(input_dto) return {"success": True, "data": output.model_dump()} @@ -171,3 +173,34 @@ async def get_latest_by_system( input_dto = GetLatestBySystemInput(system_id=system_id, env_key=env_key) data = await use_cases.health_check_service.get_latest_by_system(input_dto) return {"success": True, "data": data} + + +# ---- 系统级健康检查触发(管理员) ---- + + +class _TriggerSystemHealthCheckBody(BaseModel): + """触发系统级健康检查请求体。""" + + env_key: str | None = Field(default=None, max_length=32, description="环境标识(不传则用默认环境)") + + +@health_check_router.post("/systems/{system_id}", response_model=dict) +async def trigger_system_health_check( + system_id: int = Path(ge=1), + body: _TriggerSystemHealthCheckBody | None = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_admin_user), +) -> dict[str, Any]: + """触发系统级健康检查。 + + 自动选取该系统下第一个启用的系统级工具执行探测。 + 若系统无可用工具,返回 400 错误(提示用户先配置工具)。 + """ + use_cases = create_use_cases_from_db(db) + input_dto = TriggerSystemHealthCheckInput( + system_id=system_id, + env_key=body.env_key if body else None, + triggered_by=current_user.uid, + ) + output = await use_cases.tool_service.trigger_system_health_check(input_dto) + return {"success": True, "data": output.model_dump()} diff --git a/backend/server/routers/external_systems/import_router.py b/backend/server/routers/external_systems/import_router.py index 0502bda3..7830062e 100644 --- a/backend/server/routers/external_systems/import_router.py +++ b/backend/server/routers/external_systems/import_router.py @@ -34,12 +34,13 @@ from __future__ import annotations import base64 import binascii +import json from typing import Any from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator from sqlalchemy.ext.asyncio import AsyncSession -from yuxi.external_systems.exceptions import AccessDeniedError +from yuxi.external_systems.exceptions import AccessDeniedError, DomainValidationError from yuxi.external_systems.infrastructure.container import create_use_cases_from_db from yuxi.external_systems.use_cases.dto.environment import EnvironmentOutput from yuxi.external_systems.use_cases.dto.import_draft import ( @@ -65,6 +66,7 @@ from yuxi.external_systems.use_cases.dto.tool import ( ExternalToolCreateInput, ToolOutput, ) +from yuxi.external_systems.use_cases.services.asset_service import MAX_CONTENT_BYTES from yuxi.storage.postgres.models_business import User from server.utils.auth_middleware import get_admin_user, get_db @@ -72,8 +74,8 @@ from server.utils.auth_middleware import get_admin_user, get_db import_router = APIRouter(prefix="/imports", tags=["external-systems-import"]) # base64 字符串最大长度(≈10MB 二进制,含 33% 编码开销 + padding 余量) -# 对齐 asset_router._MAX_BASE64_LENGTH,保证工具包资产与单资产上传同一上限 -_MAX_BASE64_LENGTH = 14_000_000 +# 引用 asset_service.MAX_CONTENT_BYTES,与 asset_router 保持同一上限 +_MAX_BASE64_LENGTH = int(MAX_CONTENT_BYTES * 1.4) # slug 格式正则,对齐 ExternalSystemCreateInput / ExternalToolCreateInput 的 pattern _SLUG_PATTERN = r"^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$" @@ -354,29 +356,40 @@ def _build_tool_package(package: ToolPackageRequest) -> ToolPackageInput: 显式构造嵌套 DTO(``ToolPackageItemInput`` / ``ToolOutput`` / ``SystemOutput`` / ``EnvironmentOutput`` / ``ToolPackageAssetInput``),保持 Router 层 Schema ↔ DTO 不共享类的边界。 + + ``tool``/``system``/``environments`` 使用 ``dict[str, Any]`` 而非显式 Schema, + 结构校验由此处的 Pydantic 构造完成。若 dict 缺失必填字段或类型不匹配, + Pydantic 抛出 ``ValidationError``,此处捕获并转换为 ``DomainValidationError`` + (400),避免原生异常穿透到 ``unhandled_exception_handler`` 返回 500。 """ - return ToolPackageInput( - items=[ - ToolPackageItemInput( - tool=ToolOutput(**item.tool), - system=SystemOutput(**item.system) if item.system else None, - environments=[EnvironmentOutput(**env) for env in item.environments], - assets=[ - ToolPackageAssetInput( - id=asset.id, - adapter_type=asset.adapter_type, - asset_type=asset.asset_type, - name=asset.name, - content_b64=asset.content_b64, - size=asset.size, - checksum=asset.checksum, - ) - for asset in item.assets - ], - ) - for item in package.items - ] - ) + try: + return ToolPackageInput( + items=[ + ToolPackageItemInput( + tool=ToolOutput(**item.tool), + system=SystemOutput(**item.system) if item.system else None, + environments=[EnvironmentOutput(**env) for env in item.environments], + assets=[ + ToolPackageAssetInput( + id=asset.id, + adapter_type=asset.adapter_type, + asset_type=asset.asset_type, + name=asset.name, + content_b64=asset.content_b64, + size=asset.size, + checksum=asset.checksum, + ) + for asset in item.assets + ], + ) + for item in package.items + ] + ) + except ValidationError as exc: + raise DomainValidationError( + "工具包数据结构校验失败", + details={"errors": json.loads(json.dumps(exc.errors(), default=str))}, + ) from exc @import_router.post("/preview", response_model=dict) diff --git a/backend/server/routers/external_systems/integration_router.py b/backend/server/routers/external_systems/integration_router.py index 3075395a..1e878b9f 100644 --- a/backend/server/routers/external_systems/integration_router.py +++ b/backend/server/routers/external_systems/integration_router.py @@ -12,14 +12,17 @@ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession from yuxi.external_systems.integrations.operation_registry import ( IntegrationOperationRegistry, ) from yuxi.external_systems.integrations.registry import IntegrationRegistry from yuxi.storage.postgres.models_business import User +from yuxi.storage.postgres.models_external import ExternalSystem -from server.utils.auth_middleware import get_required_user +from server.utils.auth_middleware import get_db, get_required_user integration_router = APIRouter( prefix="/integrations", @@ -41,7 +44,7 @@ async def list_integrations( adapter_type: str | None = Query(None, max_length=32, description="按适配器类型精确过滤"), tags: str | None = Query(None, max_length=256, description="按标签过滤,多个标签以逗号分隔(OR 关系)"), keyword: str | None = Query( - None, max_length=128, description="模糊匹配 display_name / description(大小写不敏感)" + None, max_length=128, description="模糊匹配 key / display_name / description(大小写不敏感)" ), limit: int = Query(50, ge=1, le=200, description="分页大小,最大 200"), offset: int = Query(0, ge=0, description="分页偏移"), @@ -88,12 +91,15 @@ async def list_integrations( @integration_router.get("/overview", response_model=dict) async def get_integrations_overview( current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: - """查询厂商集成的分组总览(按 adapter_type 分组 + 标签统计)。 + """查询厂商集成的分组总览(按 adapter_type 分组 + 标签统计 + 使用统计)。 通过 filter_integrations 获取全量集成(无分页),按 ``adapter_type`` 分组构造 ``by_adapter_type`` 字典(每组仅含精简字段),并统计每个标签 - 出现次数构造 ``by_tag`` 字典。适合前端管理页树形渲染与统计卡片。 + 出现次数构造 ``by_tag`` 字典(按标签名升序排序)。同时查询 + ``ext_systems`` 表,按 ``integration_key`` 聚合已创建系统数量,返回 + ``usage_stats``。 """ metadata_list = IntegrationRegistry.filter_integrations() @@ -108,15 +114,28 @@ async def get_integrations_overview( "tags": metadata.tags, } ) - for tag in metadata.tags: + for tag in metadata.tags or (): by_tag[tag] = by_tag.get(tag, 0) + 1 + # 查询数据库统计每个 integration_key 对应的非删除系统数量 + stmt = ( + select(ExternalSystem.integration_key, func.count(ExternalSystem.id)) + .where( + ExternalSystem.integration_key.is_not(None), + ExternalSystem.is_deleted.is_(False), + ) + .group_by(ExternalSystem.integration_key) + ) + result = await db.execute(stmt) + usage_stats = {key: int(count) for key, count in result.all() if key} + return { "success": True, "data": { "by_adapter_type": by_adapter_type, "total": len(metadata_list), - "by_tag": by_tag, + "by_tag": dict(sorted(by_tag.items())), + "usage_stats": usage_stats, }, } @@ -135,7 +154,7 @@ async def get_integration_categories( tag_to_integrations: dict[str, list[str]] = {} for metadata in metadata_list: - for tag in metadata.tags: + for tag in metadata.tags or (): tag_to_integrations.setdefault(tag, []).append(metadata.key) items = [{"tag": tag, "count": len(keys), "integrations": keys} for tag, keys in tag_to_integrations.items()] @@ -195,10 +214,10 @@ async def get_integration_source_types( @integration_router.get("/{key}", response_model=dict) async def get_integration_detail( - key: str, + key: str = Path(..., min_length=1, max_length=64), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: - """查询指定厂商集成的完整详情(含所有 12 个字段)。 + """查询单个厂商集成的完整元数据详情。 ``get_integration_or_raise`` 未找到时抛 ``EntityNotFoundError`` (404), 由全局处理器统一映射。返回 ``model_dump()`` 完整字段,包含 @@ -210,22 +229,23 @@ async def get_integration_detail( @integration_router.get("/{key}/operations", response_model=dict) async def get_integration_operations( - key: str, + key: str = Path(..., min_length=1, max_length=64), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """查询指定厂商集成已注册的操作 handler 列表(按 source_type 分组)。 - 遍历 ``metadata.source_types``,对每个 source_type 调用 - ``list_operations`` 获取已注册操作。``operations_by_source_type`` 为 - ``{source_type: [operations]}`` 字典;``supported_operations`` 为所有 - source_type 操作的并集(去重,按字母升序排序)。 + 遍历 ``metadata.source_types``(按字母升序排序,保证字典键顺序稳定), + 对每个 source_type 调用 ``list_operations`` 获取已注册操作。 + ``operations_by_source_type`` 为 ``{source_type: [operations]}`` 字典; + ``supported_operations`` 为所有 source_type 操作的并集(去重,按字母 + 升序排序)。 ``get_integration_or_raise`` 未找到时抛 ``EntityNotFoundError`` (404)。 """ metadata = IntegrationRegistry.get_integration_or_raise(key) operations_by_source_type: dict[str, list[str]] = {} - for source_type in metadata.source_types: + for source_type in sorted(metadata.source_types): operations_by_source_type[source_type] = IntegrationOperationRegistry.list_operations(source_type) supported_operations = sorted(set(op for ops in operations_by_source_type.values() for op in ops)) @@ -242,7 +262,7 @@ async def get_integration_operations( @integration_router.get("/{key}/connection-schema", response_model=dict) async def get_integration_connection_schema( - key: str, + key: str = Path(..., min_length=1, max_length=64), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """获取厂商集成的连接配置 JSON Schema,用于前端动态表单渲染。 @@ -265,7 +285,7 @@ async def get_integration_connection_schema( @integration_router.get("/{key}/example-config", response_model=dict) async def get_integration_example_config( - key: str, + key: str = Path(..., min_length=1, max_length=64), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """获取厂商集成的推荐配置示例,用于前端表单预填与配置引导。 diff --git a/backend/server/routers/external_systems/integration_tool_router.py b/backend/server/routers/external_systems/integration_tool_router.py index b4b4fb8e..82af478d 100644 --- a/backend/server/routers/external_systems/integration_tool_router.py +++ b/backend/server/routers/external_systems/integration_tool_router.py @@ -20,7 +20,7 @@ from collections.abc import Awaitable, Callable from typing import Any from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_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.tool import ( @@ -51,7 +51,8 @@ class PersistIntegrationToolsRequest(BaseModel): model_config = ConfigDict(frozen=True) system_id: int = Field(..., ge=1) - tools: list[ExternalToolItemRequest] = Field(..., min_length=1) + tools: list[ExternalToolItemRequest] = Field(..., min_length=1, max_length=100) + source_type: str | None = Field(default=None, max_length=64) class IntegrationToolFlowRequest(BaseModel): @@ -72,6 +73,31 @@ class IntegrationToolFlowRequest(BaseModel): env_key: str | None = Field(default=None, max_length=32) discovery_options: dict[str, Any] | None = None + @field_validator("discovery_options") + @classmethod + def _validate_discovery_options(cls, v: dict[str, Any] | None) -> dict[str, Any] | None: + if v is None: + return v + if not isinstance(v, dict): + raise ValueError("discovery_options 必须是对象") + + def _check_value(value: Any, path: str = "root") -> None: + if isinstance(value, (str, int, float, bool)) or value is None: + return + if isinstance(value, list): + for idx, item in enumerate(value): + _check_value(item, f"{path}[{idx}]") + return + if isinstance(value, dict): + for key, item in value.items(): + _check_value(item, f"{path}.{key}") + return + raise ValueError(f"discovery_options.{path} 包含不支持的类型 {type(value).__name__}") + + for key, value in v.items(): + _check_value(value, key) + return v + # ============================================================================= # === 内部辅助 === @@ -186,6 +212,7 @@ async def persist_generated_tools( system_id=body.system_id, tools=[ExternalToolCreateInput(**item.model_dump()) for item in body.tools], created_by=current_user.uid, + source_type=body.source_type, ) output = await use_cases.integration_tool_service.persist_generated_tools(input_dto) return {"success": True, "data": output.model_dump()} diff --git a/backend/server/routers/external_systems/metric_router.py b/backend/server/routers/external_systems/metric_router.py index 44b57acb..ab31faac 100644 --- a/backend/server/routers/external_systems/metric_router.py +++ b/backend/server/routers/external_systems/metric_router.py @@ -24,8 +24,9 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import Any, Literal -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi import APIRouter, Depends, Query, Response from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.external_systems.exceptions import DomainValidationError from yuxi.external_systems.infrastructure.container import create_use_cases_from_db from yuxi.external_systems.use_cases.dto.metric import ( DeleteOldBucketsInput, @@ -38,6 +39,7 @@ from yuxi.external_systems.use_cases.dto.metric import ( PrometheusExportInput, ) from yuxi.storage.postgres.models_business import User +from yuxi.utils.datetime_utils import utc_now_naive from server.utils.auth_middleware import get_admin_user, get_db, get_required_user @@ -52,17 +54,15 @@ _TIMESERIES_MAX_RANGE: dict[str, timedelta] = { def _validate_time_range(start: datetime, end: datetime, max_delta: timedelta) -> None: - """校验时间范围:start 不晚于 end,且不超过 max_delta。""" + """校验时间范围:start 不晚于 end,且不超过 max_delta。 + + 使用 DomainValidationError 以遵循 UnifiedError 协议, + 由全局 ``unified_error_handler`` 统一映射为 HTTP 响应。 + """ if start > end: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="start_time 不能晚于 end_time", - ) + raise DomainValidationError("start_time 不能晚于 end_time") if end - start > max_delta: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"时间范围不得超过 {max_delta.days} 天", - ) + raise DomainValidationError(f"时间范围不得超过 {max_delta.days} 天") # ---------------- Endpoints ---------------- @@ -76,12 +76,12 @@ async def aggregate_metrics( system_id: int | None = Query(None), env_key: str | None = Query(None, max_length=32), adapter_type: str | None = Query(None, max_length=32), - start_time: datetime = Query(..., description="起始时间(必填)"), - end_time: datetime = Query(..., description="结束时间(必填)"), + start_time: datetime = Query(..., description="起始时间(必填,避免全表扫描)"), + end_time: datetime = Query(..., description="结束时间(必填,避免全表扫描)"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: - """单系统指标聚合。响应字段对齐 ORM aggregate 返回。""" + """指标聚合(可按系统/环境/适配器类型过滤)。响应字段对齐 ORM aggregate 返回。""" _validate_time_range(start_time, end_time, timedelta(days=_MAX_RANGE_DAYS)) use_cases = create_use_cases_from_db(db) input_dto = MetricAggregateInput( @@ -100,13 +100,13 @@ async def get_metrics_timeseries( system_id: int | None = Query(None), env_key: str | None = Query(None, max_length=32), adapter_type: str | None = Query(None, max_length=32), - start_time: datetime = Query(..., description="起始时间(必填)"), - end_time: datetime = Query(..., description="结束时间(必填)"), + start_time: datetime = Query(..., description="起始时间(必填,避免全表扫描)"), + end_time: datetime = Query(..., description="结束时间(必填,避免全表扫描)"), interval: Literal["hour", "day"] = Query("hour", description="聚合间隔:hour / day"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: - """时间序列聚合。start_time / end_time 必填,避免全表扫描。 + """时间序列聚合。 最大范围约束:hour 间隔 ≤ 31 天,day 间隔 ≤ 366 天。 """ @@ -126,22 +126,21 @@ async def get_metrics_timeseries( @metric_router.get("/by-system", response_model=dict) async def get_metrics_by_system( + system_id: int | None = Query(None, description="按系统 ID 过滤(可选)"), env_key: str | None = Query(None, max_length=32), adapter_type: str | None = Query(None, max_length=32), - start_time: datetime = Query(..., description="起始时间(必填)"), - end_time: datetime = Query(..., description="结束时间(必填)"), + start_time: datetime = Query(..., description="起始时间(必填,避免全表扫描)"), + end_time: datetime = Query(..., description="结束时间(必填,避免全表扫描)"), limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: - """跨系统聚合排名。按 total_calls 降序,返回 Top N。 - - total 为匹配过滤条件的系统总数(不受 limit 截断),支持 offset 分页。 - """ + """跨系统聚合排名。按 total_calls 降序,支持 offset 分页。""" _validate_time_range(start_time, end_time, timedelta(days=_MAX_RANGE_DAYS)) use_cases = create_use_cases_from_db(db) input_dto = MetricBySystemInput( + system_id=system_id, env_key=env_key, adapter_type=adapter_type, start=start_time, @@ -157,21 +156,26 @@ async def get_metrics_by_system( @metric_router.get("/by-env", response_model=dict) async def get_metrics_by_env( system_id: int = Query(..., description="系统 ID(必填)"), - start_time: datetime = Query(..., description="起始时间(必填)"), - end_time: datetime = Query(..., description="结束时间(必填)"), + start_time: datetime = Query(..., description="起始时间(必填,避免全表扫描)"), + end_time: datetime = Query(..., description="结束时间(必填,避免全表扫描)"), + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: - """单系统按环境分解指标。""" + """单系统按环境分解指标。支持 offset 分页。""" _validate_time_range(start_time, end_time, timedelta(days=_MAX_RANGE_DAYS)) use_cases = create_use_cases_from_db(db) input_dto = MetricByEnvInput( system_id=system_id, start=start_time, end=end_time, + limit=limit, + offset=offset, ) items = await use_cases.metric_service.aggregate_by_env(input_dto) - return {"success": True, "data": {"items": items, "total": len(items)}} + total = await use_cases.metric_service.count_envs(input_dto) + return {"success": True, "data": {"items": items, "total": total}} @metric_router.get("/latest", response_model=dict) @@ -190,23 +194,29 @@ async def get_latest_metric_bucket( @metric_router.get("/prometheus") async def export_metrics_prometheus( - start_time: datetime | None = Query(None), - end_time: datetime | None = Query(None), + start_time: datetime | None = Query(None, description="起始时间(不传时默认最近 1 小时)"), + end_time: datetime | None = Query(None, description="结束时间(不传时默认当前)"), env_key: str | None = Query(None, max_length=32), adapter_type: str | None = Query(None, max_length=32), + system_id: int | None = Query(None, description="按系统 ID 过滤(可选)"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> Response: """Prometheus 格式导出。支持 API Key 认证(Authorization: Bearer yxkey_)。 - 不传时间范围时默认导出最近 1 小时数据。支持 adapter_type 按适配器类型过滤。 + 不传时间范围时默认导出最近 1 小时数据。支持 adapter_type / system_id 过滤。 + 传入 start_time 时校验 start ≤ end 且不超过 366 天(end_time 未传时以当前时间补全)。 """ + if start_time is not None: + effective_end = end_time if end_time is not None else utc_now_naive() + _validate_time_range(start_time, effective_end, timedelta(days=_MAX_RANGE_DAYS)) use_cases = create_use_cases_from_db(db) input_dto = PrometheusExportInput( start=start_time, end=end_time, env_key=env_key, adapter_type=adapter_type, + system_id=system_id, ) output = await use_cases.metric_service.export_prometheus(input_dto) return Response(content=output.content, media_type=output.content_type) diff --git a/backend/server/routers/external_systems/quota_router.py b/backend/server/routers/external_systems/quota_router.py index 1b945013..66aee6ac 100644 --- a/backend/server/routers/external_systems/quota_router.py +++ b/backend/server/routers/external_systems/quota_router.py @@ -25,6 +25,7 @@ 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, @@ -159,21 +160,18 @@ class UpdateQuotaRequest(BaseModel): class ResetQuotaRequest(BaseModel): """重置配额窗口请求体。字段对齐 ``ResetQuotaInput``(不含 id)。 - 语义约束:``window_start`` / ``window_end`` 至少传一个,否则仅重置 - ``used_value`` 会导致窗口时间与用量不一致(``window_end`` 仍为过去时间, - ``list_expiring`` 永远返回该记录)。 + 允许仅重置 ``used_value``(如把用量清零但保持当前窗口),也允许同时更新 + 窗口范围。传入窗口范围时校验 ``window_start < window_end``。 """ model_config = ConfigDict(frozen=True) window_start: datetime | None = None window_end: datetime | None = None - used_value: int = Field(default=0, ge=0) + used_value: int | None = Field(default=None, ge=0) @model_validator(mode="after") def _check_window_range(self) -> ResetQuotaRequest: - if self.window_start is None and self.window_end is None: - raise ValueError("重置配额窗口必须指定新的窗口时间范围(window_start 或 window_end)") 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 @@ -197,6 +195,14 @@ class BatchAdjustQuotaRequest(BaseModel): 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``。""" @@ -371,6 +377,27 @@ async def batch_adjust_quotas( 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, diff --git a/backend/server/routers/external_systems/secret_rotation_router.py b/backend/server/routers/external_systems/secret_rotation_router.py index ebc6b11a..3dcc3841 100644 --- a/backend/server/routers/external_systems/secret_rotation_router.py +++ b/backend/server/routers/external_systems/secret_rotation_router.py @@ -31,6 +31,7 @@ from yuxi.external_systems.use_cases.dto.secret_rotation import ( ResetPolicyInput, RotatePolicyInput, SecretRotationStatsInput, + TogglePolicyStatusInput, UpdateNotifyConfigInput, UpdatePolicyInput, ) @@ -60,7 +61,7 @@ class CreateSecretRotationPolicyRequest(BaseModel): secret_type: Literal["client_secret", "api_key", "cert", "password", "connection_string"] secret_name: str = Field(..., min_length=1, max_length=128) secret_ref: str = Field(..., min_length=1, max_length=128) - env_key: str = Field(default="default", max_length=32) + env_key: str = Field(default="default", min_length=1, max_length=32) rotation_mode: Literal["auto", "manual"] = "manual" rotation_interval_days: int = Field(default=90, ge=1) notify_before_days: int = Field(default=7, ge=0) @@ -80,7 +81,7 @@ class UpdateSecretRotationPolicyRequest(BaseModel): rotation_mode: Literal["auto", "manual"] | None = None rotation_interval_days: int | None = Field(default=None, ge=1) notify_before_days: int | None = Field(default=None, ge=0) - notify_channels: dict[str, Any] | None = None + notify_channels: dict[str, Any] = Field(default_factory=dict) max_rotation_retries: int | None = Field(default=None, ge=0) @@ -106,7 +107,15 @@ class ResetPolicyRequest(BaseModel): model_config = ConfigDict(frozen=True) - reason: str | None = None + reason: str | None = Field(default=None, max_length=512) + + +class TogglePolicyStatusRequest(BaseModel): + """切换策略状态请求体(active <-> disabled)。""" + + model_config = ConfigDict(frozen=True) + + status: Literal["active", "disabled"] class BatchClonePoliciesRequest(BaseModel): @@ -126,8 +135,8 @@ async def list_policies( 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), - secret_type: str | None = Query(None), + env_key: str | None = Query(None, max_length=32), + secret_type: Literal["client_secret", "api_key", "cert", "password", "connection_string"] | None = Query(None), rotation_mode: Literal["auto", "manual"] | None = Query(None), status: Literal["active", "rotating", "overdue", "failed", "disabled"] | None = Query(None), keyword: str | None = Query(None, max_length=128, description="搜索 secret_name/secret_ref"), @@ -375,3 +384,21 @@ async def reset_policy( ) output = await use_cases.secret_rotation_service.reset_policy(input_dto) return {"success": True, "data": output.model_dump()} + + +@secret_rotation_router.post("/policies/{policy_id}/toggle-status", response_model=dict) +async def toggle_policy_status( + policy_id: int, + payload: TogglePolicyStatusRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_admin_user), +) -> dict[str, Any]: + """切换策略状态(active <-> disabled)。``operated_by`` 由当前管理员填充。""" + use_cases = create_use_cases_from_db(db) + input_dto = TogglePolicyStatusInput( + policy_id=policy_id, + status=payload.status, + operated_by=current_user.uid, + ) + output = await use_cases.secret_rotation_service.toggle_policy_status(input_dto) + return {"success": True, "data": output.model_dump()} diff --git a/backend/server/routers/external_systems/system_router.py b/backend/server/routers/external_systems/system_router.py index 291eed83..c76453b3 100644 --- a/backend/server/routers/external_systems/system_router.py +++ b/backend/server/routers/external_systems/system_router.py @@ -9,9 +9,9 @@ from __future__ import annotations import json from datetime import UTC, datetime -from typing import Any +from typing import Annotated, Any -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Path, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -276,7 +276,7 @@ class BatchCloneRequest(BaseModel): @system_router.get("/export") async def export_systems( - ids: list[int] | None = Query(None), + ids: list[int] | None = Query(None, max_length=500), category: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), @@ -352,6 +352,20 @@ async def create_system( return {"success": True, "data": output.model_dump()} +@system_router.get("/dashboard", response_model=dict) +async def get_system_dashboard( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_required_user), +) -> dict[str, Any]: + """获取系统管理页 dashboard 聚合数据。 + + 一次性聚合 stats 与 circuit breaker 状态,供列表页首屏使用。 + """ + use_cases = create_use_cases_from_db(db) + output = await use_cases.system_service.get_system_dashboard() + return {"success": True, "data": output.model_dump()} + + @system_router.get("/stats", response_model=dict) async def get_system_stats( db: AsyncSession = Depends(get_db), @@ -418,7 +432,7 @@ async def get_circuit_breaker_status( @system_router.get("/{system_id}", response_model=dict) async def get_system( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -431,17 +445,21 @@ async def get_system( @system_router.put("/{system_id}", response_model=dict) async def update_system( - system_id: int, + system_id: Annotated[int, Path(ge=1)], body: UpdateSystemRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: - """更新外部系统。仅透传客户端显式设置的字段,保留部分更新语义。""" + """更新外部系统。仅透传客户端显式设置的字段,保留部分更新语义。 + + 使用 ``exclude_unset=True`` 保留客户端显式发送的 ``null`` 值(用于清空可空字段), + Service 层的 ``exclude_unset=True`` 会过滤掉未设置的字段。 + """ use_cases = create_use_cases_from_db(db) input_dto = UpdateSystemInput( id=system_id, updated_by=current_user.uid, - **body.model_dump(exclude_unset=True, exclude_none=True), + **body.model_dump(exclude_unset=True), ) output = await use_cases.system_service.update_system(input_dto) return {"success": True, "data": output.model_dump()} @@ -449,7 +467,7 @@ async def update_system( @system_router.delete("/{system_id}", response_model=dict) async def delete_system( - system_id: int, + system_id: Annotated[int, Path(ge=1)], cascade: bool = Query(False), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), @@ -463,7 +481,7 @@ async def delete_system( @system_router.patch("/{system_id}/enabled", response_model=dict) async def set_system_enabled( - system_id: int, + system_id: Annotated[int, Path(ge=1)], body: SetSystemEnabledRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), @@ -481,7 +499,7 @@ async def set_system_enabled( @system_router.post("/{system_id}/clone", response_model=dict) async def clone_system( - system_id: int, + system_id: Annotated[int, Path(ge=1)], body: CloneSystemRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), @@ -499,7 +517,7 @@ async def clone_system( @system_router.get("/{system_id}/health", response_model=dict, deprecated=True) async def get_system_health( - system_id: int, + system_id: Annotated[int, Path(ge=1)], env_key: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), @@ -518,7 +536,7 @@ async def get_system_health( @system_router.get("/{system_id}/metrics", response_model=dict, deprecated=True) async def get_system_metrics( - system_id: int, + system_id: Annotated[int, Path(ge=1)], env_key: str | None = Query(None), start_at: datetime | None = Query(None, description="ISO8601 开始时间"), end_at: datetime | None = Query(None, description="ISO8601 结束时间"), @@ -550,7 +568,7 @@ async def get_system_metrics( @system_router.get("/{system_id}/rate-limit", response_model=dict) async def get_rate_limit( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -563,7 +581,7 @@ async def get_rate_limit( @system_router.put("/{system_id}/rate-limit", response_model=dict) async def update_rate_limit( - system_id: int, + system_id: Annotated[int, Path(ge=1)], body: UpdateRateLimitRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), @@ -581,7 +599,7 @@ async def update_rate_limit( @system_router.get("/{system_id}/circuit-breaker", response_model=dict) async def get_circuit_breaker( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -594,7 +612,7 @@ async def get_circuit_breaker( @system_router.put("/{system_id}/circuit-breaker", response_model=dict) async def update_circuit_breaker( - system_id: int, + system_id: Annotated[int, Path(ge=1)], body: UpdateCircuitBreakerRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), @@ -612,7 +630,7 @@ async def update_circuit_breaker( @system_router.post("/{system_id}/circuit-breaker/reset", response_model=dict) async def reset_circuit_breaker( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -625,7 +643,7 @@ async def reset_circuit_breaker( @system_router.get("/{system_id}/connection-pool", response_model=dict) async def get_connection_pool( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -638,7 +656,7 @@ async def get_connection_pool( @system_router.put("/{system_id}/connection-pool", response_model=dict) async def update_connection_pool( - system_id: int, + system_id: Annotated[int, Path(ge=1)], body: UpdatePoolConfigRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), @@ -656,7 +674,7 @@ async def update_connection_pool( @system_router.get("/{system_id}/observability", response_model=dict) async def get_observability( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -669,7 +687,7 @@ async def get_observability( @system_router.put("/{system_id}/observability", response_model=dict) async def update_observability( - system_id: int, + system_id: Annotated[int, Path(ge=1)], body: UpdateObservabilityRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), @@ -687,7 +705,7 @@ async def update_observability( @system_router.get("/{system_id}/retry-policy", response_model=dict) async def get_retry_policy( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -700,7 +718,7 @@ async def get_retry_policy( @system_router.put("/{system_id}/retry-policy", response_model=dict) async def update_retry_policy( - system_id: int, + system_id: Annotated[int, Path(ge=1)], body: UpdateRetryPolicyRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), @@ -718,7 +736,7 @@ async def update_retry_policy( @system_router.get("/{system_id}/secret-refs", response_model=dict) async def get_secret_refs( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -731,7 +749,7 @@ async def get_secret_refs( @system_router.put("/{system_id}/secret-refs", response_model=dict) async def update_secret_refs( - system_id: int, + system_id: Annotated[int, Path(ge=1)], body: UpdateSecretRefsRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), @@ -754,20 +772,20 @@ async def update_secret_refs( @system_router.post("/{system_id}/validate-config", response_model=dict) async def validate_system_config( - system_id: int, + system_id: Annotated[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 = ValidateSystemConfigInput(system_id=system_id, validated_by=current_user.uid) + input_dto = ValidateSystemConfigInput(system_id=system_id) output = await use_cases.system_service.validate_system_config(input_dto) return {"success": True, "data": output.model_dump()} @system_router.get("/{system_id}/resource-summary", response_model=dict) async def get_system_resource_summary( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -780,7 +798,7 @@ async def get_system_resource_summary( @system_router.get("/{system_id}/impact-analysis", response_model=dict) async def get_system_impact_analysis( - system_id: int, + system_id: Annotated[int, Path(ge=1)], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: diff --git a/backend/server/routers/external_systems/test_case_router.py b/backend/server/routers/external_systems/test_case_router.py index d8e6c327..267ab551 100644 --- a/backend/server/routers/external_systems/test_case_router.py +++ b/backend/server/routers/external_systems/test_case_router.py @@ -147,11 +147,14 @@ async def list_test_cases( enabled: bool | None = Query(None), schedule_cron: str | None = Query(None, max_length=64), last_run_status: str | None = Query(None, max_length=32), + expected_status: str | None = Query(None, max_length=32), keyword: str | None = Query(None, max_length=128), + sort_by: str = Query("created_at", max_length=32), + sort_order: str = Query("desc", pattern=r"^(asc|desc)$"), 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 = ListTestCasesInput( limit=limit, @@ -161,7 +164,10 @@ async def list_test_cases( enabled=enabled, schedule_cron=schedule_cron, last_run_status=last_run_status, + expected_status=expected_status, keyword=keyword, + sort_by=sort_by, + sort_order=sort_order, ) output = await use_cases.test_case_service.list_test_cases(input_dto) return {"success": True, "data": output.model_dump()} @@ -308,9 +314,9 @@ async def delete_test_case( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: - """删除测试用例。""" + """删除测试用例。``deleted_by`` 由当前登录用户填充。""" use_cases = create_use_cases_from_db(db) - input_dto = DeleteTestCaseInput(id=case_id) + input_dto = DeleteTestCaseInput(id=case_id, deleted_by=current_user.uid) output = await use_cases.test_case_service.delete_test_case(input_dto) return {"success": True, "data": output.model_dump()} diff --git a/backend/server/routers/external_systems/token_router.py b/backend/server/routers/external_systems/token_router.py index 896e0136..8fdc222f 100644 --- a/backend/server/routers/external_systems/token_router.py +++ b/backend/server/routers/external_systems/token_router.py @@ -131,19 +131,26 @@ async def invalidate_environment_tokens( async def list_expiring_tokens( days: int = Query(7, ge=1, le=90), system_id: int | None = Query(None), + env_key: str | None = Query(None, max_length=32), + token_type: str | None = Query(None), 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(默认 7 天内,未失效)。 - ``system_id`` 透传至 DB 层过滤,保证 ``limit`` 截取的准确性。 + 所有过滤条件(``system_id`` / ``env_key`` / ``token_type``)均下推至 DB 层, + ``offset`` 支持分页,与 ``/`` 列表端点契约对齐。 """ use_cases = create_use_cases_from_db(db) input_dto = ListExpiringTokensInput( days=days, system_id=system_id, + env_key=env_key, + token_type=token_type, limit=limit, + offset=offset, ) output = await use_cases.token_service.list_expiring_tokens(input_dto) return {"success": True, "data": output.model_dump()} @@ -160,6 +167,8 @@ async def list_tokens( env_key: str | None = Query(None, max_length=32), token_type: str | None = Query(None), is_invalidated: bool | None = Query(None), + sort_by: str | None = Query(None, description="排序字段:updated_at/expires_at/created_at"), + sort_order: str = Query("desc", pattern="^(asc|desc)$", description="排序方向"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -167,6 +176,7 @@ async def list_tokens( 所有过滤条件(``system_id`` / ``env_key`` / ``token_type`` / ``is_invalidated``) 均下推至 DB 层,保证分页 ``total`` 与 ``items`` 的一致性。 + ``sort_by`` / ``sort_order`` 控制排序,由仓储白名单校验防注入。 """ use_cases = create_use_cases_from_db(db) input_dto = ListTokensInput( @@ -176,6 +186,8 @@ async def list_tokens( env_key=env_key, token_type=token_type, is_invalidated=is_invalidated, + sort_by=sort_by, + sort_order=sort_order, ) output = await use_cases.token_service.list_tokens(input_dto) return {"success": True, "data": output.model_dump()} @@ -231,8 +243,8 @@ async def list_token_usage( """查询 Token 使用记录。 基于 ``ExternalToolExecution`` 表按 ``(system_id, env_key)`` 关联查询, - 返回使用该 Token 的工具执行记录摘要(execution_id / tool_slug / status / - started_at / ended_at / duration_ms / operation / caller)。 + 返回使用该 Token 的工具执行记录摘要(id / execution_id / tool_slug / + status / started_at / ended_at / duration_ms / operation / caller)。 """ use_cases = create_use_cases_from_db(db) input_dto = ListTokenUsageInput( @@ -247,6 +259,7 @@ async def list_token_usage( @token_router.get("/{token_id}/rotation-preview", response_model=dict) async def get_token_rotation_preview( token_id: int, + expiring_days: int = Query(7, ge=1, le=90, description="expiring_soon 阈值天数"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -258,8 +271,14 @@ async def get_token_rotation_preview( 返回字段:``token_status``(active/expiring_soon/expired/invalidated)+ ``affected_system`` + ``affected_environment`` + ``affected_tools`` + ``affected_policies``(关联的密钥轮换策略列表)。 + + ``expiring_days`` 控制 ``token_status`` 中 ``expiring_soon`` 的阈值天数, + 与 ``/stats``、``/expiring`` 端点保持一致。 """ use_cases = create_use_cases_from_db(db) - input_dto = GetTokenRotationPreviewInput(token_id=token_id) + input_dto = GetTokenRotationPreviewInput( + token_id=token_id, + expiring_days=expiring_days, + ) output = await use_cases.token_service.get_token_rotation_preview(input_dto) return {"success": True, "data": output.model_dump()} diff --git a/backend/server/routers/external_systems/tool_router.py b/backend/server/routers/external_systems/tool_router.py index 14c0431a..08597bdf 100644 --- a/backend/server/routers/external_systems/tool_router.py +++ b/backend/server/routers/external_systems/tool_router.py @@ -78,6 +78,7 @@ class CreateToolRequest(BaseModel): retry_policy: dict[str, Any] = Field(default_factory=dict) enabled: bool = True system_id: int | None = None + source_type: str | None = Field(default=None, max_length=64) class UpdateToolRequest(BaseModel): @@ -124,7 +125,7 @@ class ExternalToolItemRequest(BaseModel): slug: str = Field(..., min_length=1, max_length=128, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$") name: str = Field(..., min_length=1, max_length=128) - description: str = Field(..., min_length=1) + description: str = "" category: str = Field(default="default", max_length=64) adapter_type: str = Field(default="http", max_length=32) enabled: bool = True @@ -133,6 +134,7 @@ class ExternalToolItemRequest(BaseModel): auth_type: str = Field(default="none", max_length=32) auth_config: dict[str, Any] = Field(default_factory=dict) adapter_config: dict[str, Any] = Field(default_factory=dict) + source_type: str | None = Field(default=None, max_length=64) class BatchImportToolsRequest(BaseModel): @@ -141,7 +143,7 @@ class BatchImportToolsRequest(BaseModel): model_config = ConfigDict(frozen=True) system_id: int | None = None - tools: list[ExternalToolItemRequest] = Field(default_factory=list) + tools: list[ExternalToolItemRequest] = Field(..., min_length=1, max_length=100) class ImportWsdlRequest(BaseModel): @@ -161,7 +163,7 @@ class ExecuteToolRequest(BaseModel): tool_slug: str = Field(..., min_length=1, max_length=128, pattern=_TOOL_SLUG_PATTERN) system_id: int | None = None - env_key: str = "default" + env_key: str = Field(default="default", max_length=32) arguments: dict[str, Any] = Field(default_factory=dict) caller_id: str | None = None correlation_id: str | None = None @@ -183,7 +185,7 @@ class BatchExecuteToolItemRequest(BaseModel): tool_slug: str = Field(..., min_length=1, max_length=128, pattern=_TOOL_SLUG_PATTERN) system_id: int | None = None - env_key: str = "default" + env_key: str = Field(default="default", max_length=32) arguments: dict[str, Any] = Field(default_factory=dict) caller_id: str | None = None correlation_id: str | None = None @@ -195,7 +197,7 @@ class BatchExecuteToolsRequest(BaseModel): model_config = ConfigDict(frozen=True) - executions: list[BatchExecuteToolItemRequest] = Field(..., min_length=1) + executions: list[BatchExecuteToolItemRequest] = Field(..., min_length=1, max_length=100) class BatchCloneToolsRequest(BaseModel): @@ -203,7 +205,7 @@ class BatchCloneToolsRequest(BaseModel): model_config = ConfigDict(frozen=True) - source_tool_ids: list[int] = Field(..., min_length=1) + source_tool_ids: list[int] = Field(..., min_length=1, max_length=100) slug_suffix: str = Field(..., min_length=1, max_length=64, pattern=_SLUG_SUFFIX_PATTERN) override_config: dict[str, Any] = Field(default_factory=dict) @@ -216,8 +218,8 @@ class BatchCloneToolsRequest(BaseModel): @tool_router.get("/enabled", response_model=dict) async def get_enabled_tools( system_id: int | None = Query(None), - env_key: str | None = Query(None), - category: str | None = Query(None), + env_key: str | None = Query(None, max_length=32), + category: str | None = Query(None, max_length=64), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -231,7 +233,7 @@ async def get_enabled_tools( @tool_router.get("/options", response_model=dict) async def list_tool_options( system_id: int | None = Query(None), - category: str | None = Query(None), + category: str | None = Query(None, max_length=64), enabled_only: bool = Query(True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), @@ -251,7 +253,7 @@ async def list_tool_options( async def export_tools( system_id: int | None = Query(None), ids: list[int] | None = Query(None), - category: str | None = Query(None), + category: str | None = Query(None, max_length=64), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> Response: @@ -277,7 +279,7 @@ async def export_tools( @tool_router.get("/export-wsdl", response_model=dict) async def export_wsdl( system_id: int = Query(..., description="目标外部系统 ID"), - tool_slug: str | None = Query(None, description="指定工具 slug,未指定则取系统下首个 SOAP 工具"), + tool_slug: str | None = Query(None, max_length=128, description="指定工具 slug,未指定则取系统下首个 SOAP 工具"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: @@ -365,6 +367,7 @@ async def health_check_external_tool( input_dto = HealthCheckToolInput( tool_id=tool_id, env_key=body.env_key if body is not None else None, + triggered_by=current_user.uid, ) output = await use_cases.tool_service.health_check_external_tool(input_dto) return {"success": True, "data": output.model_dump()} @@ -422,14 +425,16 @@ async def list_tools( limit: int = Query(20, ge=1, le=500), offset: int = Query(0, ge=0), system_id: int | None = Query(None), - category: str | None = Query(None), - adapter_type: str | None = Query(None), + category: str | None = Query(None, max_length=64), + adapter_type: str | None = Query(None, max_length=32), enabled: bool | None = Query(None), - keyword: str | None = Query(None), + keyword: str | None = Query(None, max_length=128), + sort_by: str | None = Query(None, description="排序字段:created_at/updated_at/name/slug/category/adapter_type"), + sort_order: str = Query("desc", pattern="^(asc|desc)$", description="排序方向"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: - """分页列出外部工具。""" + """分页列出外部工具。``sort_by`` / ``sort_order`` 控制排序,由仓储白名单校验防注入。""" use_cases = create_use_cases_from_db(db) input_dto = ListToolsInput( offset=offset, @@ -439,6 +444,8 @@ async def list_tools( adapter_type=adapter_type, enabled=enabled, keyword=keyword, + sort_by=sort_by, + sort_order=sort_order, ) output = await use_cases.tool_service.list_tools(input_dto) return {"success": True, "data": output.model_dump()} @@ -621,7 +628,7 @@ async def get_tool_execution_trend( start_time: datetime | None = Query(None, description="开始时间(ISO 8601)"), end_time: datetime | None = Query(None, description="结束时间(ISO 8601)"), interval: Literal["hour", "day"] = Query("day", description="时间桶粒度(hour/day)"), - env_key: str | None = Query(None, description="环境 key 过滤"), + env_key: str | None = Query(None, max_length=32, description="环境 key 过滤"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: diff --git a/backend/server/routers/external_systems/trash_router.py b/backend/server/routers/external_systems/trash_router.py index a07de539..5144462d 100644 --- a/backend/server/routers/external_systems/trash_router.py +++ b/backend/server/routers/external_systems/trash_router.py @@ -4,10 +4,14 @@ 物理删除 / 清理过期回收站。所有端点通过 ``create_use_cases_from_db`` 装配 use_cases,经 ``trash_service`` 端口调用用例。 -回收站为高危操作,所有端点使用 ``get_admin_user`` 鉴权。Request Schema 与 -Input DTO 不共享类,Router 内显式构造 DTO,操作者字段(``restored_by`` / -``deleted_by`` / ``purged_by``)由 ``current_user.uid`` 填充。Router 内不 -try/except 领域异常,由全局异常处理器统一处理。 +鉴权分层(与前端权限保持一致): +- 查询(``list_trash``)与恢复(``restore``)使用 ``get_admin_user``(admin + superadmin) +- 物理删除(``hard_delete``)与清理(``purge``)使用 ``get_superadmin_user``(仅 superadmin), + 因物理删除不可恢复,前端同样仅对 superadmin 开放物理删除按钮 + +Request Schema 与 Input DTO 不共享类,Router 内显式构造 DTO,操作者字段 +(``restored_by`` / ``deleted_by`` / ``purged_by``)由 ``current_user.uid`` 填充。 +Router 内不 try/except 领域异常,由全局异常处理器统一处理。 路径顺序(关键,来自 FastAPI 路由匹配规则):静态路径端点(``GET ""`` / ``POST "/purge"``)必须在动态路径端点(``POST "/{resource_type}/{resource_id}/restore"`` @@ -29,6 +33,9 @@ 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.trash import ( + BatchHardDeleteInput, + BatchItemInput, + BatchRestoreInput, HardDeleteInput, ListTrashInput, PurgeTrashInput, @@ -36,7 +43,7 @@ from yuxi.external_systems.use_cases.dto.trash import ( ) from yuxi.storage.postgres.models_business import User -from server.utils.auth_middleware import get_admin_user, get_db +from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user trash_router = APIRouter( prefix="/trash", @@ -78,6 +85,23 @@ class PurgeTrashRequest(BaseModel): older_than_days: int = Field(default=30, ge=1) +class BatchItemRequest(BaseModel): + """批量操作的单条资源项请求体。""" + + model_config = ConfigDict(frozen=True) + + resource_type: ResourceType + resource_id: int = Field(ge=1) + + +class BatchOperationRequest(BaseModel): + """批量操作请求体。字段对齐 ``BatchRestoreInput`` / ``BatchHardDeleteInput``。""" + + model_config = ConfigDict(frozen=True) + + items: list[BatchItemRequest] = Field(min_length=1, max_length=100) + + # ---------------- Endpoints ---------------- @@ -86,8 +110,8 @@ async def list_trash( resource_type: ResourceType | None = Query(None, description="资源类型过滤;不传时返回各类型计数摘要"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), - start_time: datetime | None = Query(None, description="仅明细模式生效(需传 resource_type)"), - end_time: datetime | None = Query(None, description="仅明细模式生效(需传 resource_type)"), + start_time: datetime | None = Query(None, description="仅明细模式生效(需传 resource_type),按 deleted_at 过滤下界"), + end_time: datetime | None = Query(None, description="仅明细模式生效(需传 resource_type),按 deleted_at 过滤上界"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: @@ -112,11 +136,11 @@ async def list_trash( async def purge_trash( payload: PurgeTrashRequest, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_superadmin_user), ) -> dict[str, Any]: """清理过期回收站(物理删除超过保留期的软删除资源)。 - ``purged_by`` 由当前管理员填充。 + ``purged_by`` 由当前管理员填充。物理删除不可恢复,仅 superadmin 可执行。 """ use_cases = create_use_cases_from_db(db) input_dto = PurgeTrashInput( @@ -127,6 +151,52 @@ async def purge_trash( return {"success": True, "data": output.model_dump()} +@trash_router.post("/batch/restore", response_model=dict) +async def batch_restore_resources( + payload: BatchOperationRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_admin_user), +) -> dict[str, Any]: + """批量恢复软删除资源。 + + 每条独立事务,已知领域异常记录为失败项,未预期异常冒泡。 + ``restored_by`` 由当前管理员填充。 + """ + use_cases = create_use_cases_from_db(db) + input_dto = BatchRestoreInput( + items=[ + BatchItemInput(resource_type=item.resource_type.value, resource_id=item.resource_id) + for item in payload.items + ], + restored_by=current_user.uid, + ) + output = await use_cases.trash_service.batch_restore(input_dto) + return {"success": True, "data": output.model_dump()} + + +@trash_router.post("/batch/hard-delete", response_model=dict) +async def batch_hard_delete_resources( + payload: BatchOperationRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_superadmin_user), +) -> dict[str, Any]: + """批量物理删除资源(不可恢复,仅 superadmin)。 + + 每条独立事务,已知领域异常记录为失败项,未预期异常冒泡。 + ``deleted_by`` 由当前管理员填充。 + """ + use_cases = create_use_cases_from_db(db) + input_dto = BatchHardDeleteInput( + items=[ + BatchItemInput(resource_type=item.resource_type.value, resource_id=item.resource_id) + for item in payload.items + ], + deleted_by=current_user.uid, + ) + output = await use_cases.trash_service.batch_hard_delete(input_dto) + return {"success": True, "data": output.model_dump()} + + @trash_router.post("/{resource_type}/{resource_id}/restore", response_model=dict) async def restore_resource( resource_type: ResourceType, @@ -153,11 +223,11 @@ async def hard_delete_resource( resource_type: ResourceType, resource_id: int = Path(..., ge=1), db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_superadmin_user), ) -> dict[str, Any]: """物理删除回收站中的资源(不可恢复)。 - ``deleted_by`` 由当前管理员填充。 + ``deleted_by`` 由当前管理员填充。物理删除不可恢复,仅 superadmin 可执行。 """ use_cases = create_use_cases_from_db(db) input_dto = HardDeleteInput( diff --git a/backend/server/routers/external_systems/webhook_router.py b/backend/server/routers/external_systems/webhook_router.py index 863f591b..74faf206 100644 --- a/backend/server/routers/external_systems/webhook_router.py +++ b/backend/server/routers/external_systems/webhook_router.py @@ -17,12 +17,16 @@ from __future__ import annotations +import json from datetime import datetime from typing import Any, Literal from fastapi import APIRouter, Body, Depends, Header, Path, Query, Request from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.external_systems.framework.runtime.webhook_callback_rate_limiter import ( + webhook_callback_rate_limiter, +) from yuxi.external_systems.infrastructure.container import create_use_cases_from_db from yuxi.external_systems.use_cases.dto.webhook import ( BatchReplayEventsInput, @@ -99,12 +103,18 @@ class CreateWebhookSubscriptionRequest(BaseModel): name: str = Field(..., min_length=1, max_length=128) system_id: int = Field(..., ge=1) source_event_type: str = Field(..., min_length=1, max_length=128) - target_handler_type: str = Field(..., min_length=1, max_length=32) + target_handler_type: Literal["agent", "workflow", "webhook_forward"] = Field( + ..., description="订阅处理方类型:agent/workflow/webhook_forward" + ) callback_path: str = Field(..., min_length=1, max_length=256) description: str = "" env_key: str = Field(default="default", max_length=32) + subscription_id: str | None = Field(default=None, max_length=256) target_handler_id: str | None = Field(default=None, max_length=64) - secret_algorithm: str = Field(default="hmac_sha256", max_length=32) + secret: str | None = Field(default=None, max_length=512) + secret_algorithm: Literal["hmac_sha256", "hmac_sha1", "md5"] = Field( + default="hmac_sha256", description="签名算法:hmac_sha256/hmac_sha1/md5" + ) filter_rules: dict[str, Any] = Field(default_factory=dict) transform_rules: dict[str, Any] = Field(default_factory=dict) enabled: bool = True @@ -125,7 +135,14 @@ class UpdateWebhookSubscriptionRequest(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=128) description: str | None = None + source_event_type: str | None = Field(default=None, min_length=1, max_length=128) + target_handler_type: Literal["agent", "workflow", "webhook_forward"] | None = Field( + default=None, description="订阅处理方类型" + ) + callback_path: str | None = Field(default=None, min_length=1, max_length=256) target_handler_id: str | None = Field(default=None, max_length=64) + secret: str | None = Field(default=None, min_length=0, max_length=512) + secret_algorithm: Literal["hmac_sha256", "hmac_sha1", "md5"] | None = Field(default=None, description="签名算法") filter_rules: dict[str, Any] | None = None transform_rules: dict[str, Any] | None = None enabled: bool | None = None @@ -151,8 +168,6 @@ class HandleEventRequest(BaseModel): @field_validator("payload") @classmethod def _validate_payload_size(cls, v: dict[str, Any]) -> dict[str, Any]: - import json - encoded = json.dumps(v, ensure_ascii=False) if len(encoded.encode("utf-8")) > _MAX_PAYLOAD_BYTES: raise ValueError( @@ -287,7 +302,12 @@ async def handle_event( ``signature`` 可选:订阅未配置 secret 时不要求签名;订阅配置 secret 时由 use case 层 ``WebhookEventHandler.handle_event`` 强制验签。请求头在透传到 持久化前由 ``_sanitize_headers`` 剔除 ``authorization`` / ``cookie`` 等敏感字段。 + + 限流:在创建 use_cases 之前按 ``subscription_slug`` 维度获取 Token Bucket + 配额,超限直接返回 429(带 ``Retry-After``),避免无效的数据库/业务层开销。 """ + await webhook_callback_rate_limiter.acquire(subscription_slug) + use_cases = create_use_cases_from_db(db) input_dto = HandleEventInput( subscription_slug=subscription_slug, diff --git a/backend/server/routers/scheduler_router.py b/backend/server/routers/scheduler_router.py index 64ffcb46..b8e9b61d 100644 --- a/backend/server/routers/scheduler_router.py +++ b/backend/server/routers/scheduler_router.py @@ -6,49 +6,113 @@ Request Schema 与 Input DTO 不共享类,Router 内显式构造 DTO,操作人字段 (``created_by`` / ``updated_by`` / ``triggered_by``)由 ``current_user.uid`` 填充。 +响应统一使用 ``SchedulerResponse[T]`` 泛型模型,所有端点返回 ``{"success": True, "data": ...}`` +结构;错误响应由全局异常处理器统一输出 ``{"success": False, "error": ...}``。 + 对齐 ``external_systems/system_router.py`` 写法。 """ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any, Literal -from fastapi import APIRouter, Depends, Header, Query +from fastapi import APIRouter, Body, Depends, Header, Path, Query from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from yuxi.scheduler.infrastructure.container import create_scheduler_service from yuxi.scheduler.use_cases.dto.scheduler import ( + BatchTaskOperationOutput, + CountByStatusInput, + CountByStatusOutput, CreateTaskInput, DeleteTaskInput, + GetHealthOutput, GetRunLogInput, GetTaskInput, HardDeleteTaskInput, ListAllRunLogsInput, + ListAnomaliesOutput, ListDailyStatsInput, + ListDailyStatsOutput, ListDeletedTasksInput, + ListHandlerSummaryOutput, ListRunLogsInput, + ListRunLogsOutput, ListTasksInput, + ListTasksOutput, ListUpcomingInput, + ListUpcomingOutput, PauseTaskInput, ReclaimStaleRunsInput, + ReclaimStaleRunsOutput, RestoreTaskInput, ResumeTaskInput, + RunLogOutput, + TaskOutput, TriggerTaskInput, + TriggerTaskOutput, UpdateTaskInput, ) +from yuxi.scheduler.use_cases.services.scheduler_service import SchedulerService from yuxi.services.run_queue_service import get_arq_pool from yuxi.storage.postgres.models_business import User from server.utils.auth_middleware import get_admin_user, get_db, get_required_user +if TYPE_CHECKING: + from arq import ArqRedis + scheduler_router = APIRouter(prefix="/scheduler", tags=["scheduler"]) +# ============================================================================= +# === 响应模型 === +# ============================================================================= + + +class SchedulerResponse[T](BaseModel): + """调度器统一成功响应模型。""" + + model_config = ConfigDict(frozen=True) + + success: bool = True + data: T + + +class TaskOperationData(BaseModel): + """删除 / 硬删除等操作的轻量响应数据。""" + + model_config = ConfigDict(frozen=True) + + task_id: str + status: str + + +# ============================================================================= +# === 校验常量 === +# ============================================================================= + +_ISO_DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$" +_ISO_DATETIME_PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$" + # ============================================================================= # === Request Schemas(与 Input DTO 不共享类) === # ============================================================================= +class BatchTaskRequest(BaseModel): + """批量任务操作请求体。""" + + model_config = ConfigDict(frozen=True) + + task_ids: list[str] = Field( + ..., + min_length=1, + max_length=100, + description="目标任务 ID 列表,最多 100 个", + ) + + class CreateTaskRequest(BaseModel): """创建定时任务请求体。字段对齐 ``CreateTaskInput``(不含 ``created_by``)。""" @@ -63,14 +127,14 @@ class CreateTaskRequest(BaseModel): ) owner_scope: str = Field(..., min_length=1, max_length=64) owner_id: str = Field(..., min_length=1, max_length=128) - schedule_kind: str = Field(..., pattern=r"^(cron|at)$") + schedule_kind: Literal["cron", "at"] = Field(..., description="调度类型:cron(周期)/ at(一次性)") cron_expression: str | None = Field(default=None, max_length=128) run_at: str | None = None tz: str = Field(default="Asia/Shanghai", max_length=64) payload: dict[str, Any] = Field(default_factory=dict) enabled: bool = True delete_after_run: bool = False - block_strategy: str = Field(default="discard_later", pattern=r"^(discard_later)$") + block_strategy: Literal["discard_later"] = Field(default="discard_later", description="阻塞策略:discard_later") class UpdateTaskRequest(BaseModel): @@ -97,7 +161,12 @@ class ReclaimStaleRunsRequest(BaseModel): model_config = ConfigDict(frozen=True) - timeout_seconds: int | None = Field(default=None, ge=1) + timeout_seconds: int | None = Field( + default=None, + ge=1, + le=86400, + description="执行超时阈值(秒),默认从配置读取,最大 86400(1 天)", + ) class TriggerTaskRequest(BaseModel): @@ -115,100 +184,262 @@ class TriggerTaskRequest(BaseModel): # ============================================================================= -# === 静态路径端点(必须在 /tasks/{task_id} 之前声明) === +# === 依赖注入 === # ============================================================================= -@scheduler_router.get("/tasks", response_model=dict) -async def list_tasks( - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - owner_scope: str | None = Query(None, max_length=64), - owner_id: str | None = Query(None, max_length=128), - handler_name: str | None = Query(None, max_length=128), - enabled: bool | None = Query(None), - status: str | None = Query(None, pattern=r"^(active|paused|dead_letter)$"), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_required_user), -) -> dict[str, Any]: - """分页列出定时任务(FR-ST-05 / FR-ST-09)。""" - service = create_scheduler_service(db) - input_dto = ListTasksInput( - page=page, - page_size=page_size, - owner_scope=owner_scope, - owner_id=owner_id, - handler_name=handler_name, - enabled=enabled, - status=status, - ) - output = await service.list_tasks(input_dto) - return {"success": True, "data": output.model_dump()} - - -@scheduler_router.post("/tasks", response_model=dict) -async def create_task( - body: CreateTaskRequest, +async def get_idempotency_key( idempotency_key: str | None = Header( default=None, alias="Idempotency-Key", max_length=128, description="幂等键,启用后重复请求回放缓存响应", ), +) -> str | None: + """提取幂等键 Header 依赖。""" + return idempotency_key + + +async def get_scheduler_service( db: AsyncSession = Depends(get_db), +) -> SchedulerService: + """构造查询 / CRUD / 状态机类端点使用的 SchedulerService(无 handler_registry)。""" + return create_scheduler_service(db) + + +async def get_scheduler_service_with_arq_pool( + db: AsyncSession = Depends(get_db), + arq_pool: ArqRedis = Depends(get_arq_pool), +) -> SchedulerService: + """构造手动触发端点使用的 SchedulerService(注入 arq_pool)。""" + return create_scheduler_service(db, arq_pool=arq_pool) + + +# ============================================================================= +# === 静态路径端点(必须在 /tasks/{task_id} 之前声明) === +# ============================================================================= + + +@scheduler_router.get( + "/tasks", + response_model=SchedulerResponse[ListTasksOutput], + summary="分页列出定时任务", + operation_id="list_scheduler_tasks", +) +async def list_tasks( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + owner_scope: str | None = Query(None, max_length=64), + owner_id: str | None = Query(None, max_length=128), + handler_name: str | None = Query(None, max_length=128), + keyword: str | None = Query(None, max_length=128, description="关键词模糊搜索(task_id / handler_name)"), + enabled: bool | None = Query(None), + status: Literal["active", "paused", "dead_letter"] | None = Query(None, description="任务状态过滤"), + sort_by: Literal["created_at", "next_run_at", "last_run_at", "consecutive_errors", "updated_at"] | None = Query( + None, description="排序字段,默认 created_at" + ), + sort_order: Literal["asc", "desc"] | None = Query(None, description="排序方向,默认 desc"), + service: SchedulerService = Depends(get_scheduler_service), + current_user: User = Depends(get_required_user), +) -> SchedulerResponse[ListTasksOutput]: + """分页列出定时任务(FR-ST-05 / FR-ST-09)。 + + 数据可见性:本端点为已登录用户可读的管理面查询,owner_scope/owner_id + 作为业务筛选条件由前端控制,API 层不做强制数据隔离。如需按用户角色 + 限制可见范围,应在业务层通过 owner 维度过滤。 + """ + input_dto = ListTasksInput( + page=page, + page_size=page_size, + owner_scope=owner_scope, + owner_id=owner_id, + handler_name=handler_name, + keyword=keyword, + enabled=enabled, + status=status, + sort_by=sort_by, + sort_order=sort_order, + ) + output = await service.list_tasks(input_dto) + return SchedulerResponse(data=output) + + +@scheduler_router.post( + "/tasks/batch-pause", + response_model=SchedulerResponse[BatchTaskOperationOutput], + summary="批量暂停任务", + operation_id="batch_pause_scheduler_tasks", +) +async def batch_pause_tasks( + body: BatchTaskRequest, + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[BatchTaskOperationOutput]: + """批量暂停任务(仅 ``active`` 状态被转换,FR-ST-05)。""" + output = await service.batch_pause_tasks( + body.task_ids, + operator=current_user.uid, + ) + return SchedulerResponse(data=output) + + +@scheduler_router.post( + "/tasks/batch-resume", + response_model=SchedulerResponse[BatchTaskOperationOutput], + summary="批量恢复任务", + operation_id="batch_resume_scheduler_tasks", +) +async def batch_resume_tasks( + body: BatchTaskRequest, + service: SchedulerService = Depends(get_scheduler_service), + current_user: User = Depends(get_admin_user), +) -> SchedulerResponse[BatchTaskOperationOutput]: + """批量恢复任务(``paused`` / ``dead_letter`` → ``active``,FR-ST-05)。""" + output = await service.batch_resume_tasks( + body.task_ids, + operator=current_user.uid, + ) + return SchedulerResponse(data=output) + + +@scheduler_router.post( + "/tasks/batch-delete", + response_model=SchedulerResponse[BatchTaskOperationOutput], + summary="批量删除任务", + operation_id="batch_delete_scheduler_tasks", +) +async def batch_delete_tasks( + body: BatchTaskRequest, + service: SchedulerService = Depends(get_scheduler_service), + current_user: User = Depends(get_admin_user), +) -> SchedulerResponse[BatchTaskOperationOutput]: + """批量软删除任务(FR-ST-05)。""" + output = await service.batch_delete_tasks( + body.task_ids, + operator=current_user.uid, + ) + return SchedulerResponse(data=output) + + +@scheduler_router.post( + "/tasks", + response_model=SchedulerResponse[TaskOutput], + summary="创建定时任务", + operation_id="create_scheduler_task", +) +async def create_task( + body: CreateTaskRequest, + idempotency_key: str | None = Depends(get_idempotency_key), + service: SchedulerService = Depends(get_scheduler_service), + current_user: User = Depends(get_admin_user), +) -> SchedulerResponse[TaskOutput]: """创建定时任务(FR-ST-01 / FR-ST-05)。""" - service = create_scheduler_service(db) input_dto = CreateTaskInput( **body.model_dump(), created_by=current_user.uid, idempotency_key=idempotency_key, ) output = await service.create_task(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.get("/tasks/upcoming", response_model=dict) +@scheduler_router.get( + "/tasks/upcoming", + response_model=SchedulerResponse[ListUpcomingOutput], + summary="列出即将执行的任务", + operation_id="list_upcoming_scheduler_tasks", +) async def list_upcoming( limit: int = Query(100, ge=1, le=1000), handler_name: str | None = Query(None, max_length=128), - db: AsyncSession = Depends(get_db), + hours_ahead: int = Query( + 24, + ge=1, + le=168, + description="预览窗口小时数(默认 24,最大 168=7 天)", + ), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_required_user), -) -> dict[str, Any]: - """列出即将执行的任务(未来 24 小时内,FR-ST-09)。""" - service = create_scheduler_service(db) - input_dto = ListUpcomingInput(limit=limit, handler_name=handler_name) +) -> SchedulerResponse[ListUpcomingOutput]: + """列出即将执行的任务(未来指定小时内,FR-ST-09)。""" + input_dto = ListUpcomingInput( + limit=limit, + handler_name=handler_name, + hours_ahead=hours_ahead, + ) output = await service.list_upcoming(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.get("/tasks/count-by-status", response_model=dict) +@scheduler_router.get( + "/tasks/count-by-status", + response_model=SchedulerResponse[CountByStatusOutput], + summary="按状态计数任务", + operation_id="count_scheduler_tasks_by_status", +) async def count_by_status( - db: AsyncSession = Depends(get_db), + owner_scope: str | None = Query(None, max_length=64), + owner_id: str | None = Query(None, max_length=128), + handler_name: str | None = Query(None, max_length=128), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_required_user), -) -> dict[str, Any]: - """按状态计数任务(FR-ST-07)。""" - service = create_scheduler_service(db) - output = await service.count_by_status() - return {"success": True, "data": output.model_dump()} +) -> SchedulerResponse[CountByStatusOutput]: + """按状态计数任务(FR-ST-07)。支持 owner/handler 下钻过滤。""" + input_dto = CountByStatusInput( + owner_scope=owner_scope, + owner_id=owner_id, + handler_name=handler_name, + ) + output = await service.count_by_status(input_dto) + return SchedulerResponse(data=output) -@scheduler_router.get("/tasks/deleted", response_model=dict) +@scheduler_router.get( + "/tasks/anomalies", + response_model=SchedulerResponse[ListAnomaliesOutput], + summary="列出工作台异常任务聚合", + operation_id="list_scheduler_anomalies", +) +async def list_anomalies( + service: SchedulerService = Depends(get_scheduler_service), + current_user: User = Depends(get_required_user), +) -> SchedulerResponse[ListAnomaliesOutput]: + """列出工作台异常任务聚合(死信 / 连续失败 / 长期未执行)。 + + 一次调用返回三类异常任务,供工作台 AnomalyTaskCard 直接消费, + 避免前端依赖活跃任务列表前 N 条做前端过滤导致的漏报。 + """ + output = await service.list_anomalies() + return SchedulerResponse(data=output) + + +@scheduler_router.get( + "/tasks/deleted", + response_model=SchedulerResponse[ListTasksOutput], + summary="列出已删除任务(回收站)", + operation_id="list_deleted_scheduler_tasks", +) async def list_deleted_tasks( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), - start_date: str | None = Query(None, description="ISO 格式起始时间,按 deleted_at 过滤"), - end_date: str | None = Query(None, description="ISO 格式截止时间,按 deleted_at 过滤"), - db: AsyncSession = Depends(get_db), + start_date: str | None = Query( + None, + pattern=_ISO_DATETIME_PATTERN, + description="ISO 格式起始时间,按 deleted_at 过滤", + ), + end_date: str | None = Query( + None, + pattern=_ISO_DATETIME_PATTERN, + description="ISO 格式截止时间,按 deleted_at 过滤", + ), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[ListTasksOutput]: """列出已删除任务(回收站,FR-ST-05)。 返回软删除的任务列表,按 ``deleted_at`` 降序排序,支持时间范围过滤与分页。 仅管理员可访问。 """ - service = create_scheduler_service(db) input_dto = ListDeletedTasksInput( page=page, page_size=page_size, @@ -216,7 +447,7 @@ async def list_deleted_tasks( end_date=end_date, ) output = await service.list_deleted_tasks(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) # ============================================================================= @@ -224,34 +455,37 @@ async def list_deleted_tasks( # ============================================================================= -@scheduler_router.get("/tasks/{task_id}", response_model=dict) +@scheduler_router.get( + "/tasks/{task_id}", + response_model=SchedulerResponse[TaskOutput], + summary="获取任务详情", + operation_id="get_scheduler_task", +) async def get_task( - task_id: str, - db: AsyncSession = Depends(get_db), + task_id: str = Path(..., min_length=1, max_length=64), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_required_user), -) -> dict[str, Any]: +) -> SchedulerResponse[TaskOutput]: """获取任务详情(FR-ST-05)。""" - service = create_scheduler_service(db) input_dto = GetTaskInput(task_id=task_id) output = await service.get_task(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.put("/tasks/{task_id}", response_model=dict) +@scheduler_router.put( + "/tasks/{task_id}", + response_model=SchedulerResponse[TaskOutput], + summary="更新定时任务", + operation_id="update_scheduler_task", +) async def update_task( - task_id: str, - body: UpdateTaskRequest, - idempotency_key: str | None = Header( - default=None, - alias="Idempotency-Key", - max_length=128, - description="幂等键,启用后重复请求回放缓存响应", - ), - db: AsyncSession = Depends(get_db), + task_id: str = Path(..., min_length=1, max_length=64), + body: UpdateTaskRequest = Body(...), + idempotency_key: str | None = Depends(get_idempotency_key), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[TaskOutput]: """更新定时任务(FR-ST-05)。仅透传客户端显式设置的字段,保留部分更新语义。""" - service = create_scheduler_service(db) input_dto = UpdateTaskInput( task_id=task_id, updated_by=current_user.uid, @@ -259,102 +493,97 @@ async def update_task( **body.model_dump(exclude_unset=True), ) output = await service.update_task(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.delete("/tasks/{task_id}", response_model=dict) +@scheduler_router.delete( + "/tasks/{task_id}", + response_model=SchedulerResponse[TaskOperationData], + summary="删除定时任务(软删除)", + operation_id="delete_scheduler_task", +) async def delete_task( - task_id: str, - idempotency_key: str | None = Header( - default=None, - alias="Idempotency-Key", - max_length=128, - description="幂等键,启用后重复请求回放缓存响应", - ), - db: AsyncSession = Depends(get_db), + task_id: str = Path(..., min_length=1, max_length=64), + idempotency_key: str | None = Depends(get_idempotency_key), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[TaskOperationData]: """删除定时任务(软删除,FR-ST-05)。""" - service = create_scheduler_service(db) input_dto = DeleteTaskInput( task_id=task_id, updated_by=current_user.uid, idempotency_key=idempotency_key, ) await service.delete_task(input_dto) - return {"success": True, "data": {"task_id": task_id, "status": "deleted"}} + return SchedulerResponse(data=TaskOperationData(task_id=task_id, status="deleted")) -@scheduler_router.post("/tasks/{task_id}/pause", response_model=dict) +@scheduler_router.post( + "/tasks/{task_id}/pause", + response_model=SchedulerResponse[TaskOutput], + summary="暂停任务", + operation_id="pause_scheduler_task", +) async def pause_task( - task_id: str, - idempotency_key: str | None = Header( - default=None, - alias="Idempotency-Key", - max_length=128, - description="幂等键,启用后重复请求回放缓存响应", - ), - db: AsyncSession = Depends(get_db), + task_id: str = Path(..., min_length=1, max_length=64), + idempotency_key: str | None = Depends(get_idempotency_key), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[TaskOutput]: """暂停任务(FR-ST-05)。""" - service = create_scheduler_service(db) input_dto = PauseTaskInput( task_id=task_id, updated_by=current_user.uid, idempotency_key=idempotency_key, ) output = await service.pause_task(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.post("/tasks/{task_id}/resume", response_model=dict) +@scheduler_router.post( + "/tasks/{task_id}/resume", + response_model=SchedulerResponse[TaskOutput], + summary="恢复任务", + operation_id="resume_scheduler_task", +) async def resume_task( - task_id: str, - idempotency_key: str | None = Header( - default=None, - alias="Idempotency-Key", - max_length=128, - description="幂等键,启用后重复请求回放缓存响应", - ), - db: AsyncSession = Depends(get_db), + task_id: str = Path(..., min_length=1, max_length=64), + idempotency_key: str | None = Depends(get_idempotency_key), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[TaskOutput]: """恢复任务(FR-ST-05 / FR-ST-08)。 ``paused`` -> ``active`` / ``dead_letter`` -> ``active``。 """ - service = create_scheduler_service(db) input_dto = ResumeTaskInput( task_id=task_id, updated_by=current_user.uid, idempotency_key=idempotency_key, ) output = await service.resume_task(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.post("/tasks/{task_id}/trigger", response_model=dict) +@scheduler_router.post( + "/tasks/{task_id}/trigger", + response_model=SchedulerResponse[TriggerTaskOutput], + summary="手动触发任务执行", + operation_id="trigger_scheduler_task", +) async def trigger_task( - task_id: str, - body: TriggerTaskRequest | None = None, - idempotency_key: str | None = Header( - default=None, - alias="Idempotency-Key", - max_length=128, - description="幂等键,启用后重复请求回放缓存响应", - ), - db: AsyncSession = Depends(get_db), + task_id: str = Path(..., min_length=1, max_length=64), + body: TriggerTaskRequest | None = Body(None), + idempotency_key: str | None = Depends(get_idempotency_key), + service: SchedulerService = Depends(get_scheduler_service_with_arq_pool), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[TriggerTaskOutput]: """手动触发任务执行(FR-ST-05)。 生成 ``run_id`` 后入队 ARQ 执行,不影响下一次自动执行。 可通过 body 传入 payload 覆盖任务定义中的 payload,None 时使用任务原 payload。 触发人 ``current_user.uid`` 透传到 worker 写入 ``run_log.created_by`` 审计字段。 """ - arq_pool = await get_arq_pool() - service = create_scheduler_service(db, arq_pool=arq_pool) payload = body.payload if body else None input_dto = TriggerTaskInput( task_id=task_id, @@ -364,58 +593,88 @@ async def trigger_task( idempotency_key=idempotency_key, ) output = await service.trigger_task(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.post("/tasks/{task_id}/restore", response_model=dict) +@scheduler_router.post( + "/tasks/{task_id}/restore", + response_model=SchedulerResponse[TaskOutput], + summary="恢复已删除任务", + operation_id="restore_scheduler_task", +) async def restore_task( - task_id: str, - db: AsyncSession = Depends(get_db), + task_id: str = Path(..., min_length=1, max_length=64), + idempotency_key: str | None = Depends(get_idempotency_key), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[TaskOutput]: """恢复已删除任务(FR-ST-05)。 将软删除的任务恢复为 ``is_deleted=0``。若恢复前 ``status='active'``, 自动置为 ``paused`` 避免立即被 tick 扫描执行,需管理员确认后手动 ``resume``。 仅管理员可操作。 """ - service = create_scheduler_service(db) - input_dto = RestoreTaskInput(task_id=task_id, updated_by=current_user.uid) + input_dto = RestoreTaskInput( + task_id=task_id, + updated_by=current_user.uid, + idempotency_key=idempotency_key, + ) output = await service.restore_task(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.delete("/tasks/{task_id}/hard", response_model=dict) +@scheduler_router.delete( + "/tasks/{task_id}/hard", + response_model=SchedulerResponse[TaskOperationData], + summary="硬删除任务(不可恢复)", + operation_id="hard_delete_scheduler_task", +) async def hard_delete_task( - task_id: str, - db: AsyncSession = Depends(get_db), + task_id: str = Path(..., min_length=1, max_length=64), + idempotency_key: str | None = Depends(get_idempotency_key), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[TaskOperationData]: """硬删除任务(不可恢复,FR-ST-05)。 物理删除已软删除的任务记录,操作不可逆。仅管理员可操作。 """ - service = create_scheduler_service(db) - input_dto = HardDeleteTaskInput(task_id=task_id, updated_by=current_user.uid) + input_dto = HardDeleteTaskInput( + task_id=task_id, + updated_by=current_user.uid, + idempotency_key=idempotency_key, + ) await service.hard_delete_task(input_dto) - return {"success": True, "data": {"task_id": task_id, "status": "hard_deleted"}} + return SchedulerResponse(data=TaskOperationData(task_id=task_id, status="hard_deleted")) -@scheduler_router.get("/tasks/{task_id}/run-logs", response_model=dict) +@scheduler_router.get( + "/tasks/{task_id}/run-logs", + response_model=SchedulerResponse[ListRunLogsOutput], + summary="列出任务执行日志", + operation_id="list_scheduler_run_logs", +) async def list_run_logs( - task_id: str, + task_id: str = Path(..., min_length=1, max_length=64), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), - status: str | None = Query( - None, pattern=r"^(running|success|failure|timeout|skipped)$" + status: Literal["running", "success", "failure", "timeout", "skipped"] | None = Query( + None, description="执行状态过滤" ), - start_date: str | None = Query(None, description="ISO 格式起始时间"), - end_date: str | None = Query(None, description="ISO 格式截止时间"), - db: AsyncSession = Depends(get_db), + start_date: str | None = Query( + None, + pattern=_ISO_DATETIME_PATTERN, + description="ISO 格式起始时间,按 started_at 过滤", + ), + end_date: str | None = Query( + None, + pattern=_ISO_DATETIME_PATTERN, + description="ISO 格式截止时间,按 started_at 过滤", + ), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_required_user), -) -> dict[str, Any]: +) -> SchedulerResponse[ListRunLogsOutput]: """列出任务执行日志(FR-ST-05 / FR-ST-09)。""" - service = create_scheduler_service(db) input_dto = ListRunLogsInput( task_id=task_id, page=page, @@ -425,7 +684,7 @@ async def list_run_logs( end_date=end_date, ) output = await service.list_run_logs(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) # ============================================================================= @@ -433,50 +692,71 @@ async def list_run_logs( # ============================================================================= -@scheduler_router.get("/run-logs", response_model=dict) +@scheduler_router.get( + "/run-logs", + response_model=SchedulerResponse[ListRunLogsOutput], + summary="跨任务列出执行日志", + operation_id="list_all_scheduler_run_logs", +) async def list_all_run_logs( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), - status: str | None = Query( - None, pattern=r"^(running|success|failure|timeout|skipped)$" + status: Literal["running", "success", "failure", "timeout", "skipped"] | None = Query( + None, description="执行状态过滤" + ), + handler_name: str | None = Query( + None, min_length=1, max_length=128, description="handler 名称过滤(跨任务按 handler 维度查询)" + ), + start_date: str | None = Query( + None, + pattern=_ISO_DATETIME_PATTERN, + description="ISO 格式起始时间,按 started_at 过滤", + ), + end_date: str | None = Query( + None, + pattern=_ISO_DATETIME_PATTERN, + description="ISO 格式截止时间,按 started_at 过滤", ), - start_date: str | None = Query(None, description="ISO 格式起始时间"), - end_date: str | None = Query(None, description="ISO 格式截止时间"), task_id: str | None = Query(None, min_length=1, max_length=64), - db: AsyncSession = Depends(get_db), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_required_user), -) -> dict[str, Any]: +) -> SchedulerResponse[ListRunLogsOutput]: """跨任务列出执行日志(FR-ST-09)。 - ``task_id`` 与 ``status`` 不可同时为空(由用例层校验),避免无过滤的全表扫描。 + 至少需指定 ``task_id``、``status`` 或时间范围之一(由用例层校验), + 避免无过滤的全表扫描。``handler_name`` 为可选过滤维度。 """ - service = create_scheduler_service(db) input_dto = ListAllRunLogsInput( page=page, page_size=page_size, status=status, + handler_name=handler_name, start_date=start_date, end_date=end_date, task_id=task_id, ) output = await service.list_all_run_logs(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.get("/run-logs/{run_id}", response_model=dict) +@scheduler_router.get( + "/run-logs/{run_id}", + response_model=SchedulerResponse[RunLogOutput], + summary="获取执行日志详情", + operation_id="get_scheduler_run_log", +) async def get_run_log( - run_id: str, - db: AsyncSession = Depends(get_db), + run_id: str = Path(..., min_length=1, max_length=64), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_required_user), -) -> dict[str, Any]: +) -> SchedulerResponse[RunLogOutput]: """获取执行日志详情(FR-ST-05)。 按 ``run_id`` 查询单条执行日志,供告警跳转 / 排查失败执行使用。 """ - service = create_scheduler_service(db) input_dto = GetRunLogInput(run_id=run_id) output = await service.get_run_log(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) # ============================================================================= @@ -484,15 +764,19 @@ async def get_run_log( # ============================================================================= -@scheduler_router.get("/handlers/summary", response_model=dict) +@scheduler_router.get( + "/handlers/summary", + response_model=SchedulerResponse[ListHandlerSummaryOutput], + summary="列出 handler 聚合摘要", + operation_id="list_scheduler_handler_summary", +) async def list_handler_summary( - db: AsyncSession = Depends(get_db), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_required_user), -) -> dict[str, Any]: +) -> SchedulerResponse[ListHandlerSummaryOutput]: """列出 handler 聚合摘要(FR-ST-05)。""" - service = create_scheduler_service(db) output = await service.list_handler_summary() - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) # ============================================================================= @@ -500,27 +784,39 @@ async def list_handler_summary( # ============================================================================= -@scheduler_router.get("/stats/daily", response_model=dict) +@scheduler_router.get( + "/stats/daily", + response_model=SchedulerResponse[ListDailyStatsOutput], + summary="列出日聚合统计", + operation_id="list_scheduler_daily_stats", +) async def list_daily_stats( - start_date: str = Query(..., description="ISO 日期 YYYY-MM-DD(必填)"), - end_date: str = Query(..., description="ISO 日期 YYYY-MM-DD(必填)"), + start_date: str = Query( + ..., + pattern=_ISO_DATE_PATTERN, + description="ISO 日期 YYYY-MM-DD(必填)", + ), + end_date: str = Query( + ..., + pattern=_ISO_DATE_PATTERN, + description="ISO 日期 YYYY-MM-DD(必填)", + ), handler_name: str | None = Query(None, max_length=128), - db: AsyncSession = Depends(get_db), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_required_user), -) -> dict[str, Any]: +) -> SchedulerResponse[ListDailyStatsOutput]: """列出日聚合统计(FR-ST-09)。 按日期范围 + handler 维度查询日聚合统计,返回含 ``total_count`` / ``success_rate`` 派生字段。日期范围上限 90 天。 """ - service = create_scheduler_service(db) input_dto = ListDailyStatsInput( start_date=start_date, end_date=end_date, handler_name=handler_name, ) output = await service.list_daily_stats(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) # ============================================================================= @@ -528,29 +824,37 @@ async def list_daily_stats( # ============================================================================= -@scheduler_router.get("/health", response_model=dict) +@scheduler_router.get( + "/health", + response_model=SchedulerResponse[GetHealthOutput], + summary="获取调度器健康状态", + operation_id="get_scheduler_health", +) async def get_health( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_required_user), -) -> dict[str, Any]: + service: SchedulerService = Depends(get_scheduler_service), + current_user: User = Depends(get_admin_user), +) -> SchedulerResponse[GetHealthOutput]: """获取调度器健康状态(FR-ST-07)。""" - service = create_scheduler_service(db) output = await service.get_health() - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output) -@scheduler_router.post("/reclaim-stale-runs", response_model=dict) +@scheduler_router.post( + "/reclaim-stale-runs", + response_model=SchedulerResponse[ReclaimStaleRunsOutput], + summary="回收僵尸执行记录", + operation_id="reclaim_scheduler_stale_runs", +) async def reclaim_stale_runs( body: ReclaimStaleRunsRequest | None = None, - db: AsyncSession = Depends(get_db), + service: SchedulerService = Depends(get_scheduler_service), current_user: User = Depends(get_admin_user), -) -> dict[str, Any]: +) -> SchedulerResponse[ReclaimStaleRunsOutput]: """回收僵尸执行记录(运维恢复)。 回收 ``status=running`` 但实际已超时的执行记录,标记为 ``timeout``。 """ - service = create_scheduler_service(db) timeout_seconds = body.timeout_seconds if body else None input_dto = ReclaimStaleRunsInput(timeout_seconds=timeout_seconds) output = await service.reclaim_stale_runs(input_dto) - return {"success": True, "data": output.model_dump()} + return SchedulerResponse(data=output)