refactor(channel routers): 统一参数校验与常量定义,新增功能端点

1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用
2. 新增扫码登录向导端点,完善文档说明
3. 优化配对统计接口,移除无效参数
4. 为出站箱接口添加批量上限与202状态码
5. 新增测试用例、访问规则、配额等模块的查询与校验参数
6. 新增适配器健康批量查询、健康检查触发接口
7. 统一告警、审计日志的错误处理方式
8. 新增插件配置账户ID支持,优化批量操作响应
9. 新增环境健康批量查询、Webhook限流与参数校验
10. 完善会话管理、审计日志的参数与文档说明
11. 修复导入模块的校验错误处理逻辑
This commit is contained in:
Kris 2026-07-11 21:39:05 +08:00
parent 00a5577aac
commit 2f6a4c29bb
39 changed files with 1690 additions and 629 deletions

View File

@ -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,

View File

@ -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,
)

View File

@ -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]:

View File

@ -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),

View File

@ -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),

View File

@ -1,7 +1,10 @@
"""配置管理域 RouterCFG-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-50dispatch 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),

View File

@ -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-fastINV-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-30CR-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-30CR-03

View File

@ -1,12 +1,12 @@
"""Dashboard 聚合视图域 RouterDSB-01~DSB-04 + DSB-DELIVERY + DSB-REALTIME
"""Dashboard 聚合视图域 RouterDSB-01~DSB-04 + DSB-DELIVERY + DSB-TODOS + DSB-REALTIME
统一采用模板 A控制面端口路由鉴权依赖 get_admin_user调用
use_cases.dashboard_view.<method>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]:

View File

@ -1,33 +1,38 @@
"""目录查询域 RouterFR-14DIR-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(

View File

@ -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
# 部署于 Linuxos.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]:

View File

@ -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,

View File

@ -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)

View File

@ -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="账户 IDACCOUNT 作用域配置字段需要)")
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="账户 IDACCOUNT 作用域配置字段需要)"),
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)

View File

@ -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)

View File

@ -4,8 +4,10 @@
可访问Router 不含任何业务逻辑仅做协议翻译 HTTP 请求参数组装为契约层
命令/参数调用 ``use_cases.session_management`` 的类型化方法走控制面管道
再通过 ``raiseOnControlFailure`` 转译失败``serialize_control_data`` 序列化
成功结果模板 AP3 绑定相关端点不走控制面管道直接调用
``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)``

View File

@ -1,10 +1,11 @@
"""安装向导域 RouterWIZ-01~WIZ-06)。
"""安装向导域 RouterWIZ-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

View File

@ -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()}

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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,

View File

@ -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]:

View File

@ -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()}

View File

@ -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)

View File

@ -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]:
"""获取厂商集成的推荐配置示例,用于前端表单预填与配置引导。

View File

@ -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()}

View File

@ -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_<key>)。
不传时间范围时默认导出最近 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)

View File

@ -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,

View File

@ -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()}

View File

@ -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]:

View File

@ -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()}

View File

@ -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()}

View File

@ -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]:

View File

@ -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(

View File

@ -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,

View File

@ -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="执行超时阈值(秒),默认从配置读取,最大 864001 天)",
)
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 覆盖任务定义中的 payloadNone 时使用任务原 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)