refactor(channel routers): 统一参数校验与常量定义,新增功能端点
1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
This commit is contained in:
parent
00a5577aac
commit
2f6a4c29bb
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
"""配置管理域 Router(CFG-02 / CFG-READ-01 / CFG-UPDATE-01 / CFG-ROLL-01 / CFG-HIST-01 / CFG-EXPORT / CFG-IMPORT / CFG-BATCH-UPDATE)。
|
||||
"""配置管理域 Router。
|
||||
|
||||
操作码:CFG-02 / CFG-READ-01 / CFG-READ-MANY / CFG-UPDATE-01 /
|
||||
CFG-ROLL-01 / CFG-HIST-01 / CFG-EXPORT / CFG-IMPORT / CFG-BATCH-UPDATE。
|
||||
|
||||
本 router 实现渠道配置管理域的全部 HTTP 端点,覆盖 schema 查询、配置查询、
|
||||
更新、回滚、历史、导出、导入与批量更新共 8 个操作。所有端点统一采用模板 A
|
||||
批量查询、更新、回滚、历史、导出、导入与批量更新共 9 个操作。所有端点统一采用模板 A
|
||||
(控制面端口路由),通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``,
|
||||
经 ``config_management`` 端口调用类型化方法,由 ``ChannelControlService``
|
||||
内部走控制面管道(auth → permission → rate_limit → dispatch → audit)。
|
||||
@ -17,9 +20,9 @@
|
||||
``_executeControl`` 直接调用。模板选型决策依据详见设计方案 §2.5。
|
||||
|
||||
路径设计:静态路径 ``/config/schema`` / ``/config/export`` / ``/config/import``
|
||||
/ ``/config/batch`` 先于动态路径 ``/config/{key}`` 声明,避免被动态路径捕获
|
||||
(规范 §6.5)。子 router 不自行设置 prefix,根前缀 ``/channels`` 由
|
||||
``channels_router`` 聚合 router 统一追加。完整 HTTP 路径为
|
||||
/ ``/config/batch`` / ``/config/values`` 先于动态路径 ``/config/{key}`` 声明,
|
||||
避免被动态路径捕获(规范 §6.5)。子 router 不自行设置 prefix,根前缀 ``/channels``
|
||||
由 ``channels_router`` 聚合 router 统一追加。完整 HTTP 路径为
|
||||
``/channels/config/*``(不含 ``{channel_type}`` 段,配置管理为跨渠道
|
||||
全局能力)。
|
||||
|
||||
@ -27,14 +30,15 @@ tags 命名:``config_router = APIRouter(tags=["channels-config"])``,与现
|
||||
``channels-login``/``channels-account`` 等子 router 命名规范一致。
|
||||
|
||||
端点清单(对应《配置管理域设计方案》§2.1):
|
||||
- GET /config/schema CFG-02 get_config_schema
|
||||
- GET /config/export CFG-EXPORT export_config
|
||||
- POST /config/import CFG-IMPORT import_config
|
||||
- GET /config/schema CFG-02 get_config_schema
|
||||
- GET /config/values CFG-READ-MANY get_config_values
|
||||
- GET /config/export CFG-EXPORT export_config
|
||||
- POST /config/import CFG-IMPORT import_config
|
||||
- PUT /config/batch CFG-BATCH-UPDATE batch_update_config
|
||||
- GET /config/{key} CFG-READ-01 get_config
|
||||
- PUT /config/{key} CFG-UPDATE-01 update_config
|
||||
- POST /config/{key}/rollback CFG-ROLL-01 rollback_config
|
||||
- GET /config/{key}/history CFG-HIST-01 get_config_history
|
||||
- GET /config/{key} CFG-READ-01 get_config
|
||||
- PUT /config/{key} CFG-UPDATE-01 update_config
|
||||
- POST /config/{key}/rollback CFG-ROLL-01 rollback_config
|
||||
- GET /config/{key}/history CFG-HIST-01 get_config_history
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -44,6 +48,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from yuxi.channels.contract.dtos.config import (
|
||||
BATCH_UPDATE_LIMIT,
|
||||
BatchUpdateConfigCmd,
|
||||
ConfigExportCmd,
|
||||
ConfigScope,
|
||||
@ -100,7 +105,7 @@ class ImportConfigRequest(BaseModel):
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
config_data: dict[str, Any] = Field(..., description="待导入的配置字典")
|
||||
config_data: dict[str, Any] = Field(..., min_length=1, description="待导入的配置字典")
|
||||
scope: ConfigScope = Field(default=ConfigScope.GLOBAL, description="配置作用域")
|
||||
target: str | None = Field(default=None, description="作用域目标")
|
||||
overwrite: bool = Field(default=False, description="是否覆盖已存在 key")
|
||||
@ -120,13 +125,16 @@ class ConfigUpdateItemRequest(BaseModel):
|
||||
class BatchUpdateConfigRequest(BaseModel):
|
||||
"""批量更新配置请求体(CFG-BATCH-UPDATE)。
|
||||
|
||||
``updates`` 长度限制 1-50(dispatch handler 校验)。``stop_on_error=false``
|
||||
时逐条独立事务(模式 D 部分成功);``stop_on_error=true`` 时首条失败即终止。
|
||||
``updates`` 长度限制 1-``BATCH_UPDATE_LIMIT``(契约层单一真相源)。
|
||||
``stop_on_error=false`` 时逐条独立事务(模式 D 部分成功);
|
||||
``stop_on_error=true`` 时首条失败即终止。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
updates: list[ConfigUpdateItemRequest] = Field(..., min_length=1, max_length=50, description="配置更新项列表")
|
||||
updates: list[ConfigUpdateItemRequest] = Field(
|
||||
..., min_length=1, max_length=BATCH_UPDATE_LIMIT, description="配置更新项列表"
|
||||
)
|
||||
scope: ConfigScope = Field(default=ConfigScope.GLOBAL, description="配置作用域")
|
||||
target: str | None = Field(default=None, description="作用域目标")
|
||||
stop_on_error: bool = Field(default=False, description="首条失败即终止")
|
||||
@ -204,7 +212,10 @@ async def import_config(
|
||||
)
|
||||
result = await use_cases.config_management.importConfig(cmd)
|
||||
raiseOnControlFailure(result)
|
||||
return {"success": True, "data": serialize_control_data(result.data)}
|
||||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||||
if result.status == "partial":
|
||||
response["partial"] = True
|
||||
return response
|
||||
|
||||
|
||||
@config_router.put("/config/batch", response_model=dict)
|
||||
@ -238,13 +249,16 @@ async def batch_update_config(
|
||||
)
|
||||
result = await use_cases.config_management.batchUpdateConfig(cmd)
|
||||
raiseOnControlFailure(result)
|
||||
return {"success": True, "data": serialize_control_data(result.data)}
|
||||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||||
if result.status == "partial":
|
||||
response["partial"] = True
|
||||
return response
|
||||
|
||||
|
||||
@config_router.get("/config/values", response_model=dict)
|
||||
async def get_config_values(
|
||||
request: Request,
|
||||
keys: list[str] = Query(..., description="配置键列表"),
|
||||
keys: list[str] = Query(..., min_length=1, max_length=BATCH_UPDATE_LIMIT, description="配置键列表"),
|
||||
scope: ConfigScope = Query(default=ConfigScope.GLOBAL, description="配置作用域"),
|
||||
target: str | None = Query(default=None, description="作用域目标(channel/account 时必填)"),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
|
||||
@ -60,12 +60,15 @@ router 统一追加。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from yuxi.channels.application.rate_limit_checker import RateLimitChecker
|
||||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||||
from yuxi.channels.contract.dtos.content_review import (
|
||||
MAX_CONTENT_LENGTH,
|
||||
BatchReviewDecisionCmd,
|
||||
ContentReviewDetailQueryCmd,
|
||||
ContentReviewHistoryQueryCmd,
|
||||
@ -75,7 +78,10 @@ from yuxi.channels.contract.dtos.content_review import (
|
||||
ReviewDecisionItem,
|
||||
)
|
||||
from yuxi.channels.contract.errors import ValidationError
|
||||
from yuxi.channels.contract.ports.driven.cache_port import CachePort
|
||||
from yuxi.channels.infrastructure import DependencyInjectionContainer
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.utils.trace_context import get_trace_id
|
||||
|
||||
from server.routers.channels import (
|
||||
DEFAULT_LIMIT,
|
||||
@ -83,6 +89,7 @@ from server.routers.channels import (
|
||||
OFFSET,
|
||||
build_operator,
|
||||
dataclass_to_dict,
|
||||
get_channel_di_container,
|
||||
get_channel_use_cases,
|
||||
parse_datetime,
|
||||
raiseOnControlFailure,
|
||||
@ -92,6 +99,42 @@ from server.utils.auth_middleware import get_admin_user
|
||||
|
||||
content_review_router = APIRouter(tags=["channels-content-review"])
|
||||
|
||||
# 数据面查询端点(CR-02 / CR-03)速率限制阈值:与 RateLimitStage /
|
||||
# AdminMessageService 共用同一组环境变量,避免多处硬编码导致手动同步风险。
|
||||
_CR_QUERY_RATE_LIMIT_MAX_REQUESTS = int(os.environ.get("YUXI_RATE_LIMIT_MAX_REQUESTS", "100"))
|
||||
_CR_QUERY_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("YUXI_RATE_LIMIT_WINDOW_SECONDS", "60"))
|
||||
_CR_QUERY_RATE_LIMIT_RETRY_AFTER_MS = int(os.environ.get("YUXI_RATE_LIMIT_RETRY_AFTER_MS", "60000"))
|
||||
|
||||
|
||||
async def _enforceContentReviewQueryRateLimit(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
di_container: DependencyInjectionContainer = Depends(get_channel_di_container),
|
||||
) -> None:
|
||||
"""数据面查询端点(CR-02 / CR-03)速率限制。
|
||||
|
||||
数据面端点不经控制面管道(模板 B),无 ``RateLimitStage`` 保护。本依赖
|
||||
在 router 层复用 ``RateLimitChecker``(与控制面 ``RateLimitStage`` 同一
|
||||
helper)实现 per-admin-per-IP 速率限制,缓存键使用独立前缀
|
||||
``rate_limit:cr_query:`` 避免与控制面一级限流键冲突。``CachePort`` 从
|
||||
DI 容器解析(应用级单例)。超限抛 ``RateLimitError``(HTTP 429),
|
||||
``incr`` 缓存故障 fail-closed,``expire`` 故障 fail-fast(INV-7)。
|
||||
"""
|
||||
cache_port: CachePort = di_container.resolve(CachePort)
|
||||
checker = RateLimitChecker(cache_port)
|
||||
ip = request.client.host if request.client else None
|
||||
ip_key = ip or "unknown"
|
||||
user_id = current_user.uid
|
||||
trace_id = request.headers.get("X-Request-Id") or get_trace_id()
|
||||
await checker.check(
|
||||
resource=f"content-review-query:{user_id}:{ip_key}",
|
||||
cache_key=f"rate_limit:cr_query:{user_id}:{ip_key}",
|
||||
max_requests=_CR_QUERY_RATE_LIMIT_MAX_REQUESTS,
|
||||
window_seconds=_CR_QUERY_RATE_LIMIT_WINDOW_SECONDS,
|
||||
retry_after_ms=_CR_QUERY_RATE_LIMIT_RETRY_AFTER_MS,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
|
||||
class ContentReviewPreviewRequest(BaseModel):
|
||||
"""内容预审核请求体(CR-01)。
|
||||
@ -104,12 +147,12 @@ class ContentReviewPreviewRequest(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
channel_type: ChannelType = Field(..., description="渠道类型")
|
||||
account_id: str = Field(..., min_length=1, description="账户 ID")
|
||||
account_id: str = Field(..., min_length=1, max_length=128, description="账户 ID")
|
||||
resource_type: ContentReviewResourceType = Field(
|
||||
...,
|
||||
description="资源类型:message_text / message_attachment / user_profile",
|
||||
)
|
||||
content: str = Field(..., min_length=1, max_length=10000, description="待审核文本")
|
||||
content: str = Field(..., min_length=1, max_length=MAX_CONTENT_LENGTH, description="待审核文本")
|
||||
peer_id: str | None = Field(default=None, description="对端 ID(可选,用于上下文)")
|
||||
|
||||
|
||||
@ -224,7 +267,7 @@ async def preview_content(
|
||||
)
|
||||
async def list_review_history(
|
||||
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
|
||||
account_id: str | None = Query(default=None, description="按账户 ID 过滤"),
|
||||
account_id: str | None = Query(default=None, max_length=128, description="按账户 ID 过滤"),
|
||||
verdict: str | None = Query(
|
||||
default=None,
|
||||
description="按审核结论过滤:pass / review / block",
|
||||
@ -249,6 +292,7 @@ async def list_review_history(
|
||||
offset: int = OFFSET,
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
_: None = Depends(_enforceContentReviewQueryRateLimit),
|
||||
) -> dict[str, Any]:
|
||||
"""查询审核历史列表(FR-30,CR-02)。
|
||||
|
||||
@ -288,7 +332,7 @@ async def list_review_history(
|
||||
async def get_review_stats(
|
||||
request: Request,
|
||||
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
|
||||
account_id: str | None = Query(default=None, description="按账户 ID 过滤"),
|
||||
account_id: str | None = Query(default=None, max_length=128, description="按账户 ID 过滤"),
|
||||
start_time: str | None = Query(
|
||||
default=None,
|
||||
description="起始时间过滤(ISO 8601,含)",
|
||||
@ -369,7 +413,10 @@ async def batch_review_decision(
|
||||
)
|
||||
result = await use_cases.content_review.batchReviewDecision(cmd=cmd)
|
||||
raiseOnControlFailure(result)
|
||||
return {"success": True, "data": serialize_control_data(result.data)}
|
||||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||||
if result.status == "partial":
|
||||
response["partial"] = True
|
||||
return response
|
||||
|
||||
|
||||
# ---------------- 动态路径端点 ----------------
|
||||
@ -384,6 +431,7 @@ async def get_review_detail(
|
||||
request: Request,
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
_: None = Depends(_enforceContentReviewQueryRateLimit),
|
||||
) -> dict[str, Any]:
|
||||
"""查询单次审核详情(FR-30,CR-03)。
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
"""Dashboard 聚合视图域 Router(DSB-01~DSB-04 + DSB-DELIVERY + DSB-REALTIME)。
|
||||
"""Dashboard 聚合视图域 Router(DSB-01~DSB-04 + DSB-DELIVERY + DSB-TODOS + DSB-REALTIME)。
|
||||
|
||||
统一采用模板 A(控制面端口路由),鉴权依赖 get_admin_user,调用
|
||||
use_cases.dashboard_view.<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]:
|
||||
|
||||
@ -1,33 +1,38 @@
|
||||
"""目录查询域 Router(FR-14,DIR-QUERY-01~04 + DIR-01 + DIR-CACHE-CLEAR + Task 5)。
|
||||
|
||||
本 router 实现渠道通讯录目录查询的全部 HTTP 端点,覆盖用户搜索 / 群组搜索 /
|
||||
用户资料 / 群组详情 / 群组成员 / 缓存清理 / 目录导出共 7 个操作。除导出端点
|
||||
返回 ``StreamingResponse`` 文件流外,其余端点统一采用模板 A(控制面端口路由),
|
||||
通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``,经 ``directory`` 端口
|
||||
调用类型化方法,由 ``ChannelControlService._executeControl`` 内部走控制面管道
|
||||
用户资料 / 群组详情 / 群组成员 / 缓存清理 / 目录导出 / 搜索建议 / 批量资料
|
||||
共 9 个操作。除导出端点返回 ``StreamingResponse`` 文件流外,其余端点统一
|
||||
采用模板 A(控制面端口路由),通过 ``get_channel_use_cases`` 装配
|
||||
``ChannelUseCases``,经 ``directory`` 端口调用类型化方法,由
|
||||
``ChannelControlService._executeControl`` 内部走控制面管道
|
||||
(auth → permission → rate_limit → dispatch → audit)。导出端点同样经控制面
|
||||
管道执行,handler 返回内容字符串,router 包装为文件流以保持审计链路。
|
||||
|
||||
鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。
|
||||
角色校验由依赖函数完成,router 内不做角色判断(规范 §4)。
|
||||
|
||||
路径设计:静态后缀路径(``/search`` / ``/export``)先于动态单段路径
|
||||
(``/{peer_id}`` / ``/{group_id}``)声明,动态单段先于多段子资源路径
|
||||
(``/{group_id}/members``)声明(规范 §6.5),避免静态路径被动态参数捕获。
|
||||
子 router 不自行设置 prefix,根前缀 ``/channels`` 由 ``channels_router`` 聚合
|
||||
router 统一追加。
|
||||
路径设计:静态后缀路径(``/search`` / ``/export`` / ``/suggestions`` /
|
||||
``/batch_profiles``)先于动态单段路径(``/{peer_id}`` / ``/{group_id}``)
|
||||
声明,动态单段先于多段子资源路径(``/{group_id}/members``)声明
|
||||
(规范 §6.5),避免静态路径被动态参数捕获。子 router 不自行设置 prefix,
|
||||
根前缀 ``/channels`` 由 ``channels_router`` 聚合 router 统一追加。
|
||||
|
||||
端点清单(对应《05-目录查询域设计方案 v1.1》§2.3 + Task 5):
|
||||
- GET /{channel_type}/{account_id}/directory/users/search
|
||||
DIR-QUERY-01 search_directory_users
|
||||
- GET /{channel_type}/{account_id}/directory/groups/search
|
||||
DIR-QUERY-02 search_directory_groups
|
||||
- GET /{channel_type}/{account_id}/directory/suggestions
|
||||
DIR-SUGGEST search_directory_suggestions
|
||||
- GET /{channel_type}/{account_id}/directory/users/{peer_id}
|
||||
DIR-QUERY-03 get_directory_user_profile
|
||||
- GET /{channel_type}/{account_id}/directory/groups/{group_id}
|
||||
DIR-01 get_directory_group_detail
|
||||
- GET /{channel_type}/{account_id}/directory/groups/{group_id}/members
|
||||
DIR-QUERY-04 get_directory_group_members
|
||||
- POST /{channel_type}/{account_id}/directory/batch_profiles
|
||||
DIR-BATCH batch_directory_profiles
|
||||
- DELETE /{channel_type}/{account_id}/directory/cache
|
||||
DIR-CACHE-CLEAR clear_directory_cache
|
||||
- POST /{channel_type}/{account_id}/directory/export
|
||||
@ -37,8 +42,9 @@ router 统一追加。
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query, Request
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||||
@ -63,6 +69,24 @@ from server.utils.auth_middleware import get_admin_user
|
||||
|
||||
directory_router = APIRouter(tags=["channels-directory"])
|
||||
|
||||
# 目录导出单次上限(handler 层与 router 层共享同一常量,单一事实源)
|
||||
_DIRECTORY_EXPORT_MAX_LIMIT: int = 1000
|
||||
|
||||
# 排序字段白名单(各渠道适配器按需支持,未支持时在适配器内忽略)
|
||||
_SORT_BY_QUERY: str | None = Query(
|
||||
default=None,
|
||||
description="排序字段(如 name),渠道支持情况不同,未支持时忽略",
|
||||
)
|
||||
_SORT_ORDER_QUERY: Literal["asc", "desc"] | None = Query(
|
||||
default=None,
|
||||
description="排序方向(asc 或 desc)",
|
||||
)
|
||||
|
||||
# 账户/对端/群组路径参数最小长度校验(避免空串穿透到业务层)
|
||||
_ACCOUNT_ID_PATH: str = Path(..., min_length=1, description="渠道账户 ID")
|
||||
_PEER_ID_PATH: str = Path(..., min_length=1, description="对端用户 ID")
|
||||
_GROUP_ID_PATH: str = Path(..., min_length=1, description="群组 ID")
|
||||
|
||||
|
||||
# ---------------- Endpoints ----------------
|
||||
|
||||
@ -73,11 +97,13 @@ directory_router = APIRouter(tags=["channels-directory"])
|
||||
)
|
||||
async def search_directory_users(
|
||||
channel_type: ChannelType,
|
||||
account_id: str,
|
||||
request: Request,
|
||||
account_id: str = _ACCOUNT_ID_PATH,
|
||||
request: Request = None,
|
||||
keyword: str | None = Query(default=None, description="搜索关键词"),
|
||||
cursor: str | None = CURSOR,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
sort_by: str | None = _SORT_BY_QUERY,
|
||||
sort_order: Literal["asc", "desc"] | None = _SORT_ORDER_QUERY,
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
@ -88,7 +114,13 @@ async def search_directory_users(
|
||||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||||
"""
|
||||
operator = build_operator(current_user, request)
|
||||
query = DirectoryQuery(keyword=keyword, cursor=cursor, limit=limit)
|
||||
query = DirectoryQuery(
|
||||
keyword=keyword,
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
result = await use_cases.directory.searchDirectoryUsers(
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -105,8 +137,8 @@ async def search_directory_users(
|
||||
)
|
||||
async def search_directory_suggestions(
|
||||
channel_type: ChannelType,
|
||||
account_id: str,
|
||||
request: Request,
|
||||
account_id: str = _ACCOUNT_ID_PATH,
|
||||
request: Request = None,
|
||||
keyword: str = Query(..., min_length=1, description="搜索关键词"),
|
||||
limit: int = SUGGESTION_LIMIT,
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
@ -137,11 +169,13 @@ async def search_directory_suggestions(
|
||||
)
|
||||
async def search_directory_groups(
|
||||
channel_type: ChannelType,
|
||||
account_id: str,
|
||||
request: Request,
|
||||
account_id: str = _ACCOUNT_ID_PATH,
|
||||
request: Request = None,
|
||||
keyword: str | None = Query(default=None, description="搜索关键词"),
|
||||
cursor: str | None = CURSOR,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
sort_by: str | None = _SORT_BY_QUERY,
|
||||
sort_order: Literal["asc", "desc"] | None = _SORT_ORDER_QUERY,
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
@ -152,7 +186,13 @@ async def search_directory_groups(
|
||||
并委托 ``_executeControl`` 执行控制面管道)。
|
||||
"""
|
||||
operator = build_operator(current_user, request)
|
||||
query = DirectoryQuery(keyword=keyword, cursor=cursor, limit=limit)
|
||||
query = DirectoryQuery(
|
||||
keyword=keyword,
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
result = await use_cases.directory.searchDirectoryGroups(
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -169,9 +209,9 @@ async def search_directory_groups(
|
||||
)
|
||||
async def get_directory_user_profile(
|
||||
channel_type: ChannelType,
|
||||
account_id: str,
|
||||
peer_id: str,
|
||||
request: Request,
|
||||
account_id: str = _ACCOUNT_ID_PATH,
|
||||
peer_id: str = _PEER_ID_PATH,
|
||||
request: Request = None,
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
@ -229,10 +269,10 @@ class ExportDirectoryRequest(BaseModel):
|
||||
description="导出格式(csv 或 json)",
|
||||
)
|
||||
limit: int = Field(
|
||||
default=1000,
|
||||
default=_DIRECTORY_EXPORT_MAX_LIMIT,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="导出数量限制(1~1000)",
|
||||
le=_DIRECTORY_EXPORT_MAX_LIMIT,
|
||||
description=f"导出数量限制(1~{_DIRECTORY_EXPORT_MAX_LIMIT})",
|
||||
)
|
||||
|
||||
|
||||
@ -242,8 +282,8 @@ class ExportDirectoryRequest(BaseModel):
|
||||
)
|
||||
async def batch_directory_profiles(
|
||||
channel_type: ChannelType,
|
||||
account_id: str,
|
||||
request: Request,
|
||||
account_id: str = _ACCOUNT_ID_PATH,
|
||||
request: Request = None,
|
||||
payload: BatchProfilesRequest = Body(...),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
@ -279,8 +319,8 @@ async def batch_directory_profiles(
|
||||
)
|
||||
async def export_directory(
|
||||
channel_type: ChannelType,
|
||||
account_id: str,
|
||||
request: Request,
|
||||
account_id: str = _ACCOUNT_ID_PATH,
|
||||
request: Request = None,
|
||||
payload: ExportDirectoryRequest = Body(...),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
@ -316,7 +356,7 @@ async def export_directory(
|
||||
_stream(),
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}",
|
||||
},
|
||||
)
|
||||
|
||||
@ -327,9 +367,9 @@ async def export_directory(
|
||||
)
|
||||
async def get_directory_group_detail(
|
||||
channel_type: ChannelType,
|
||||
account_id: str,
|
||||
group_id: str,
|
||||
request: Request,
|
||||
account_id: str = _ACCOUNT_ID_PATH,
|
||||
group_id: str = _GROUP_ID_PATH,
|
||||
request: Request = None,
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
@ -356,9 +396,9 @@ async def get_directory_group_detail(
|
||||
)
|
||||
async def get_directory_group_members(
|
||||
channel_type: ChannelType,
|
||||
account_id: str,
|
||||
group_id: str,
|
||||
request: Request,
|
||||
account_id: str = _ACCOUNT_ID_PATH,
|
||||
group_id: str = _GROUP_ID_PATH,
|
||||
request: Request = None,
|
||||
cursor: str | None = CURSOR,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
@ -392,9 +432,9 @@ async def get_directory_group_members(
|
||||
)
|
||||
async def clear_directory_cache(
|
||||
channel_type: ChannelType,
|
||||
account_id: str,
|
||||
request: Request,
|
||||
scope: str = Query(
|
||||
account_id: str = _ACCOUNT_ID_PATH,
|
||||
request: Request = None,
|
||||
scope: Literal["users", "groups", "all"] = Query(
|
||||
default="all",
|
||||
description="清理范围(users / groups / all)",
|
||||
),
|
||||
@ -413,11 +453,6 @@ async def clear_directory_cache(
|
||||
适配器清理 + ``CachePort`` 失效本地缓存 → ``ControlResult`` 经
|
||||
``raiseOnControlFailure`` 转译失败 → ``serialize_control_data`` 序列化
|
||||
清理结果返回。适配器未实现 ``clearCache`` 时返回 501。
|
||||
|
||||
scope 枚举校验在 ``ClearDirectoryCacheCmd.__post_init__`` 构造时即执行,
|
||||
非法取值直接抛 400 ``ValidationError``(router 路径,未进入控制面管道);
|
||||
handler 层 ``_directoryCacheClear`` 同步校验作为非 router 调用方的
|
||||
防御兜底,与 ``user_id`` / ``group_id`` 等业务参数的分层校验策略一致。
|
||||
"""
|
||||
operator = build_operator(current_user, request)
|
||||
cmd = ClearDirectoryCacheCmd(
|
||||
|
||||
@ -32,10 +32,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Header, Query, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, Header, Path, Query, Request, UploadFile
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from yuxi.channels.contract.dtos.admin import AdminSendCmd
|
||||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||||
@ -261,10 +260,10 @@ async def batch_recall_messages(
|
||||
收集成功/失败结果。单条失败不回滚已成功条目,撤回为外部副作用操作不可
|
||||
回滚。对应控制面操作 ``message/batch_recall``。
|
||||
|
||||
部分成功语义:存在失败条目时 ``result.status="partial"``,
|
||||
``success`` 标记为 ``True``,客户端需检查 ``data.failed`` 字段获取
|
||||
失败详情。``data`` 中仍包含 ``total`` / ``succeeded`` / ``failed``
|
||||
三字段供精确判断。
|
||||
部分成功语义:存在失败条目时 ``success`` 标记为 ``False``(与
|
||||
``send_admin_message`` 语义统一),客户端需检查 ``data.failed``
|
||||
字段获取失败详情。``data`` 中仍包含 ``total`` / ``succeeded`` /
|
||||
``failed`` 三字段供精确判断。
|
||||
|
||||
静态路径先于 ``/messages/{message_id}`` 声明,避免被动态参数捕获。
|
||||
"""
|
||||
@ -276,19 +275,20 @@ async def batch_recall_messages(
|
||||
)
|
||||
result = await use_cases.message_management.batchRecallMessages(cmd)
|
||||
raiseOnControlFailure(result)
|
||||
# 部分成功语义:result.status="partial" 时 success 标记为 True,
|
||||
# 部分成功语义:status=="partial" 时 success=False,与 send_admin_message 语义统一。
|
||||
# 客户端需检查 data.failed 字段获取失败详情。data 中仍包含
|
||||
# total/succeeded/failed 三字段供精确判断。
|
||||
is_partial = result.status == "partial"
|
||||
return {
|
||||
"success": result.status in ("success", "partial"),
|
||||
"success": not is_partial,
|
||||
"data": serialize_control_data(result.data),
|
||||
}
|
||||
|
||||
|
||||
@message_router.get("/messages/{message_id}", response_model=dict)
|
||||
async def get_message(
|
||||
message_id: str,
|
||||
request: Request,
|
||||
message_id: str = Path(..., min_length=1, description="消息内部 ID"),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
@ -310,8 +310,8 @@ async def get_message(
|
||||
|
||||
@message_router.get("/messages/{message_id}/status", response_model=dict)
|
||||
async def get_message_status(
|
||||
message_id: str,
|
||||
request: Request,
|
||||
message_id: str = Path(..., min_length=1, description="消息内部 ID"),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
@ -336,7 +336,13 @@ async def send_admin_message(
|
||||
channel_type: ChannelType,
|
||||
payload: SendAdminMessageRequest,
|
||||
request: Request,
|
||||
idempotency_key: str = Header(..., alias="X-Idempotency-Key", description="幂等键,防重复发送"),
|
||||
idempotency_key: str = Header(
|
||||
...,
|
||||
alias="X-Idempotency-Key",
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
description="幂等键,防重复发送",
|
||||
),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
@ -384,7 +390,7 @@ async def send_admin_message(
|
||||
async def upload_attachment(
|
||||
channel_type: ChannelType,
|
||||
request: Request,
|
||||
account_id: str = Form(..., description="渠道账户 ID"),
|
||||
account_id: str = Form(..., min_length=1, description="渠道账户 ID"),
|
||||
file: UploadFile = File(..., description="待上传附件文件"),
|
||||
purpose: str | None = Form(default=None, description="上传用途(如 avatar / attachment)"),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
@ -406,22 +412,27 @@ async def upload_attachment(
|
||||
``/{channel_type}/messages/{message_id}`` 声明,避免被动态参数捕获。
|
||||
"""
|
||||
operator = build_operator(current_user, request)
|
||||
# 分块流式读取,累计超限即中止,避免大文件全量载入内存导致 OOM
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > MAX_ATTACHMENT_SIZE:
|
||||
raise ValidationError(
|
||||
"file",
|
||||
f"attachment size exceeds limit {MAX_ATTACHMENT_SIZE} bytes",
|
||||
)
|
||||
chunks.append(chunk)
|
||||
# 分块流式读取,累计超限即中止,避免大文件全量载入内存导致 OOM。
|
||||
# try-finally 确保超限异常路径也关闭 UploadFile,及时释放临时文件资源。
|
||||
try:
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > MAX_ATTACHMENT_SIZE:
|
||||
raise ValidationError(
|
||||
"file",
|
||||
f"attachment size exceeds limit {MAX_ATTACHMENT_SIZE} bytes",
|
||||
)
|
||||
chunks.append(chunk)
|
||||
finally:
|
||||
await file.close()
|
||||
file_data = b"".join(chunks)
|
||||
# filename 安全过滤:取 basename 防止路径穿越(如 ``../etc/passwd`` /
|
||||
# ``C:\\Windows\\system32``),适配器层无需重复校验。
|
||||
# filename 安全过滤:统一替换 \ 为 / 后取末段,兼容跨平台路径(Docker
|
||||
# 部署于 Linux,os.path.basename 不识别 \ 分隔符,会导致
|
||||
# ``..\\..\\etc\\passwd`` 原样透传给适配器,路径穿越防护失效)。
|
||||
raw_filename = file.filename or ""
|
||||
filename = os.path.basename(raw_filename)
|
||||
filename = raw_filename.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
if not filename:
|
||||
raise ValidationError(
|
||||
"filename",
|
||||
@ -446,9 +457,9 @@ async def upload_attachment(
|
||||
response_model=dict,
|
||||
)
|
||||
async def recall_message(
|
||||
channel_type: ChannelType,
|
||||
message_id: str,
|
||||
request: Request,
|
||||
channel_type: ChannelType,
|
||||
message_id: str = Path(..., min_length=1, description="消息内部 ID"),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
@ -476,10 +487,10 @@ async def recall_message(
|
||||
response_model=dict,
|
||||
)
|
||||
async def resend_message(
|
||||
channel_type: ChannelType,
|
||||
message_id: str,
|
||||
payload: ResendMessageRequest,
|
||||
request: Request,
|
||||
channel_type: ChannelType,
|
||||
payload: ResendMessageRequest,
|
||||
message_id: str = Path(..., min_length=1, description="消息内部 ID"),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -74,12 +74,15 @@ class UpdatePluginConfigRequest(BaseModel):
|
||||
``config`` 必须为非空字典(由 ``UpdatePluginConfigCmd.__post_init__``
|
||||
校验),且 key 必须在插件 manifest 的 ``config_schema`` 中声明(由
|
||||
dispatch handler 校验),否则抛 ``ValidationError``。
|
||||
``account_id`` 用于 ACCOUNT 作用域配置字段的读写,未提供时 ACCOUNT
|
||||
作用域字段更新会抛 ``ValidationError``。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
config: dict[str, Any] = Field(..., description="配置值字典")
|
||||
apply_mode: Literal["hot", "restart_required"] = Field(..., description="应用模式")
|
||||
account_id: str | None = Field(default=None, description="账户 ID(ACCOUNT 作用域配置字段需要)")
|
||||
|
||||
|
||||
class BatchPluginLifecycleRequest(BaseModel):
|
||||
@ -191,7 +194,10 @@ async def batch_start_plugins(
|
||||
)
|
||||
result = await use_cases.plugin_management.batchStartPlugins(cmd)
|
||||
raiseOnControlFailure(result)
|
||||
return {"success": True, "data": serialize_control_data(result.data)}
|
||||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||||
if result.status == "partial":
|
||||
response["partial"] = True
|
||||
return response
|
||||
|
||||
|
||||
@plugin_router.post("/batch/stop", response_model=dict)
|
||||
@ -215,7 +221,10 @@ async def batch_stop_plugins(
|
||||
)
|
||||
result = await use_cases.plugin_management.batchStopPlugins(cmd)
|
||||
raiseOnControlFailure(result)
|
||||
return {"success": True, "data": serialize_control_data(result.data)}
|
||||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||||
if result.status == "partial":
|
||||
response["partial"] = True
|
||||
return response
|
||||
|
||||
|
||||
# ---------------- 动态路径端点 ----------------
|
||||
@ -268,6 +277,7 @@ async def uninstall_plugin(
|
||||
async def get_plugin_config(
|
||||
plugin_id: str,
|
||||
request: Request,
|
||||
account_id: str | None = Query(default=None, description="账户 ID(ACCOUNT 作用域配置字段需要)"),
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
@ -275,12 +285,14 @@ async def get_plugin_config(
|
||||
|
||||
从 PluginManifest 获取配置 schema,从 ConfigManager 获取当前配置值,
|
||||
组装 ``PluginConfigResult`` 返回,含 plugin_id / config / schema /
|
||||
requires_restart / updated_at。
|
||||
requires_restart / updated_at。``account_id`` 用于读取 ACCOUNT 作用域
|
||||
配置值,未提供时该类字段回退 schema 默认值。
|
||||
"""
|
||||
operator = build_operator(current_user, request)
|
||||
result = await use_cases.plugin_management.getPluginConfig(
|
||||
plugin_id=plugin_id,
|
||||
operator=operator,
|
||||
account_id=account_id,
|
||||
)
|
||||
raiseOnControlFailure(result)
|
||||
return {"success": True, "data": serialize_control_data(result.data)}
|
||||
@ -299,7 +311,8 @@ async def update_plugin_config(
|
||||
按 ``apply_mode`` 判断:``hot`` 模式立即生效(发布 ConfigChanged 事件);
|
||||
``restart_required`` 模式仅持久化返回 ``requires_restart=true``。返回
|
||||
``PluginConfigResult``。config 的 key 必须在插件 manifest 的
|
||||
``config_schema`` 中声明。
|
||||
``config_schema`` 中声明。``account_id`` 用于 ACCOUNT 作用域配置字段,
|
||||
未提供时更新该类字段会抛 ``ValidationError``。
|
||||
"""
|
||||
operator = build_operator(current_user, request)
|
||||
cmd = UpdatePluginConfigCmd(
|
||||
@ -307,6 +320,7 @@ async def update_plugin_config(
|
||||
operator=operator,
|
||||
config=payload.config,
|
||||
apply_mode=payload.apply_mode,
|
||||
account_id=payload.account_id,
|
||||
)
|
||||
result = await use_cases.plugin_management.updatePluginConfig(cmd)
|
||||
raiseOnControlFailure(result)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -4,8 +4,10 @@
|
||||
可访问。Router 不含任何业务逻辑,仅做协议翻译:将 HTTP 请求参数组装为契约层
|
||||
命令/参数,调用 ``use_cases.session_management`` 的类型化方法走控制面管道,
|
||||
再通过 ``raiseOnControlFailure`` 转译失败、``serialize_control_data`` 序列化
|
||||
成功结果(模板 A)。P3 绑定相关端点不走控制面管道,直接调用
|
||||
``use_cases.user_binding`` 用例方法(模板 B),异常由全局异常处理器统一转译。
|
||||
成功结果。P3 绑定相关端点不走五阶段控制面管道(用例方法直接调用仓储 + 领域
|
||||
服务),但仍返回 ``ControlResult``,失败转译与成功序列化复用同一套
|
||||
``raiseOnControlFailure`` / ``serialize_control_data``;前置校验失败(如用户
|
||||
不存在)由用例直接抛契约异常,经全局异常处理器统一映射。
|
||||
|
||||
端点清单:
|
||||
- GET /sessions SES-QUERY-01 列出渠道会话
|
||||
@ -48,6 +50,7 @@ from yuxi.storage.postgres.models_business import User
|
||||
|
||||
from server.routers.channels import (
|
||||
LARGE_LIMIT,
|
||||
MESSAGE_LIMIT,
|
||||
OFFSET,
|
||||
build_operator,
|
||||
dataclass_to_dict,
|
||||
@ -65,21 +68,21 @@ session_router = APIRouter(tags=["channels-session"])
|
||||
class MergeSessionRequest(BaseModel):
|
||||
"""合并会话请求体。
|
||||
|
||||
路径参数 ``session_id`` 作为目标会话 ID,映射为
|
||||
``MergeConversationCmd.target_conversation_id``,不在 body 中。
|
||||
路径参数 ``session_id`` 作为目标渠道会话 ID,映射为
|
||||
``MergeConversationCmd.target_session_id``,不在 body 中。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
source_conversation_id: str = Field(..., min_length=1, description="源会话 ID(被合并方)")
|
||||
source_session_id: str = Field(..., min_length=1, description="源渠道会话 ID(被合并方)")
|
||||
reason: str = Field(default="", max_length=512, description="合并原因(审计用)")
|
||||
|
||||
|
||||
class TransferOwnerRequest(BaseModel):
|
||||
"""转移所有者请求体。
|
||||
|
||||
路径参数 ``session_id`` 作为目标会话 ID,映射为
|
||||
``OwnerTransferCmd.conversation_id``,不在 body 中。
|
||||
路径参数 ``session_id`` 作为目标渠道会话 ID,映射为
|
||||
``OwnerTransferCmd.session_id``,不在 body 中。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
@ -112,7 +115,7 @@ class BatchCloseFilter(BaseModel):
|
||||
default=None,
|
||||
description="最后活动时间早于该时刻(ISO 8601,视为非活跃)",
|
||||
)
|
||||
status: str | None = Field(
|
||||
status: SessionStatus | None = Field(
|
||||
default=None,
|
||||
description="会话状态过滤(active / closed)",
|
||||
)
|
||||
@ -335,12 +338,12 @@ async def merge_session(
|
||||
受 ``merge_strategy_enabled`` 配置策略门控制,falsy 时返回 422。
|
||||
对应控制面操作 ``session/merge``。
|
||||
|
||||
协议翻译:路径参数 ``session_id`` → ``MergeConversationCmd.target_conversation_id``。
|
||||
协议翻译:路径参数 ``session_id`` → ``MergeConversationCmd.target_session_id``。
|
||||
"""
|
||||
operator = build_operator(current_user, request)
|
||||
cmd = MergeConversationCmd(
|
||||
source_conversation_id=payload.source_conversation_id,
|
||||
target_conversation_id=session_id,
|
||||
source_session_id=payload.source_session_id,
|
||||
target_session_id=session_id,
|
||||
operator=operator,
|
||||
reason=payload.reason,
|
||||
)
|
||||
@ -363,11 +366,11 @@ async def transfer_session_owner(
|
||||
加入控制面管道事务。审计日志由 AuditStage 在 SHARED 事务中统一写入(fail-closed)。
|
||||
对应控制面操作 ``session/transfer_owner``。
|
||||
|
||||
协议翻译:路径参数 ``session_id`` → ``OwnerTransferCmd.conversation_id``。
|
||||
协议翻译:路径参数 ``session_id`` → ``OwnerTransferCmd.session_id``。
|
||||
"""
|
||||
operator = build_operator(current_user, request)
|
||||
cmd = OwnerTransferCmd(
|
||||
conversation_id=session_id,
|
||||
session_id=session_id,
|
||||
new_owner_id=payload.new_owner_id,
|
||||
operator=operator,
|
||||
)
|
||||
@ -406,7 +409,7 @@ async def close_session(
|
||||
async def list_session_messages(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
limit: int = LARGE_LIMIT,
|
||||
limit: int = MESSAGE_LIMIT,
|
||||
offset: int = OFFSET,
|
||||
use_cases=Depends(get_channel_use_cases),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
@ -463,8 +466,11 @@ async def bind_session_user(
|
||||
"""绑定系统用户到渠道会话(P3 渐进式绑定,SES-BIND-01)。
|
||||
|
||||
将渠道会话的统一身份绑定到系统用户,绑定后身份类型升级为 ``manual``,
|
||||
并触发跨渠道会话合并(同一统一身份下的兄弟会话合并到当前会话)。
|
||||
不走控制面管道,直接调用用例方法,异常由全局异常处理器统一转译。
|
||||
并触发跨渠道会话合并(同一统一身份下的兄弟会话合并到当前会话)。用例
|
||||
方法不走五阶段控制面管道(直接调用仓储 + 领域服务),但仍返回
|
||||
``ControlResult``,失败转译与成功序列化复用 ``raiseOnControlFailure`` /
|
||||
``serialize_control_data``;前置校验失败(如用户不存在、配置禁用)由
|
||||
用例直接抛契约异常,经全局异常处理器统一映射。
|
||||
|
||||
协议翻译:路径参数 ``session_id`` + body ``user_uid`` / ``remark`` →
|
||||
``bindUserToSession(session_id, user_uid, operator, remark=...)``。
|
||||
@ -490,8 +496,11 @@ async def unbind_session_user(
|
||||
"""解绑渠道会话的系统用户(P3 渐进式绑定,SES-UNBIND-01)。
|
||||
|
||||
解除统一身份与系统用户的绑定,身份类型回退为 ``channel_guest``。
|
||||
不删除历史消息:解绑仅修改身份关联状态,历史消息保留在原会话中。
|
||||
不走控制面管道,直接调用用例方法,异常由全局异常处理器统一转译。
|
||||
不删除历史消息:解绑仅修改身份关联状态,历史消息保留在原会话中。用例
|
||||
方法不走五阶段控制面管道(直接调用仓储 + 领域服务),但仍返回
|
||||
``ControlResult``,失败转译与成功序列化复用 ``raiseOnControlFailure`` /
|
||||
``serialize_control_data``;前置校验失败(如会话不存在)由用例直接抛
|
||||
契约异常,经全局异常处理器统一映射。
|
||||
|
||||
协议翻译:路径参数 ``session_id`` →
|
||||
``unbindUserFromSession(session_id, operator)``。
|
||||
@ -515,8 +524,10 @@ async def get_session_identity(
|
||||
"""查询渠道会话身份绑定状态(P3 渐进式绑定,SES-IDENTITY-01)。
|
||||
|
||||
返回统一身份 ID、绑定用户 UID、身份类型与绑定状态。会话未关联统一
|
||||
身份时返回 ``is_bound=False`` 的空状态。不走控制面管道,直接调用
|
||||
用例方法,异常由全局异常处理器统一转译。
|
||||
身份时返回 ``is_bound=False`` 的空状态。用例方法不走五阶段控制面管道
|
||||
(直接调用仓储),但仍返回 ``ControlResult``,失败转译与成功序列化复用
|
||||
``raiseOnControlFailure`` / ``serialize_control_data``;会话不存在时由
|
||||
用例直接抛契约异常,经全局异常处理器统一映射。
|
||||
|
||||
协议翻译:路径参数 ``session_id`` →
|
||||
``getSessionIdentity(session_id, operator)``。
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"""安装向导域 Router(WIZ-01~WIZ-06)。
|
||||
"""安装向导域 Router(WIZ-01~WIZ-08)。
|
||||
|
||||
本 router 实现渠道插件安装向导流程的 HTTP 端点,覆盖步骤查询 / 校验 /
|
||||
应用 / 完成 / OAuth 发起 / OAuth 回调共 6 个操作。所有端点统一采用模板 A
|
||||
(控制面端口路由),通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``,
|
||||
经 ``wizard`` 端口调用类型化方法,由 ``ChannelControlService._executeControl``
|
||||
内部走控制面管道(auth → permission → rate_limit → dispatch → audit)。
|
||||
应用 / 完成 / OAuth 发起 / OAuth 回调 / 扫码登录发起 / 扫码登录轮询共 8 个
|
||||
操作。所有端点统一采用模板 A(控制面端口路由),通过
|
||||
``get_channel_use_cases`` 装配 ``ChannelUseCases``,经 ``wizard`` 端口调用
|
||||
类型化方法,由 ``ChannelControlService._executeControl`` 内部走控制面管道
|
||||
(auth → permission → rate_limit → dispatch → audit)。
|
||||
|
||||
鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。
|
||||
角色校验由依赖函数完成,router 内不做角色判断(规范 §4)。
|
||||
@ -20,6 +21,8 @@
|
||||
- POST /{channel_type}/wizard/finalize WIZ-04 finalize_wizard
|
||||
- POST /{channel_type}/wizard/oauth/initiate WIZ-05 initiate_wizard_oauth
|
||||
- POST /{channel_type}/wizard/oauth-callback WIZ-06 handle_wizard_oauth_callback
|
||||
- POST /{channel_type}/wizard/qr/initiate WIZ-07 initiate_wizard_qr_login
|
||||
- POST /{channel_type}/wizard/qr/wait WIZ-08 wait_wizard_qr_login
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]:
|
||||
"""获取厂商集成的推荐配置示例,用于前端表单预填与配置引导。
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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()}
|
||||
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -6,49 +6,113 @@
|
||||
Request Schema 与 Input DTO 不共享类,Router 内显式构造 DTO,操作人字段
|
||||
(``created_by`` / ``updated_by`` / ``triggered_by``)由 ``current_user.uid`` 填充。
|
||||
|
||||
响应统一使用 ``SchedulerResponse[T]`` 泛型模型,所有端点返回 ``{"success": True, "data": ...}``
|
||||
结构;错误响应由全局异常处理器统一输出 ``{"success": False, "error": ...}``。
|
||||
|
||||
对齐 ``external_systems/system_router.py`` 写法。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query
|
||||
from fastapi import APIRouter, Body, Depends, Header, Path, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from yuxi.scheduler.infrastructure.container import create_scheduler_service
|
||||
from yuxi.scheduler.use_cases.dto.scheduler import (
|
||||
BatchTaskOperationOutput,
|
||||
CountByStatusInput,
|
||||
CountByStatusOutput,
|
||||
CreateTaskInput,
|
||||
DeleteTaskInput,
|
||||
GetHealthOutput,
|
||||
GetRunLogInput,
|
||||
GetTaskInput,
|
||||
HardDeleteTaskInput,
|
||||
ListAllRunLogsInput,
|
||||
ListAnomaliesOutput,
|
||||
ListDailyStatsInput,
|
||||
ListDailyStatsOutput,
|
||||
ListDeletedTasksInput,
|
||||
ListHandlerSummaryOutput,
|
||||
ListRunLogsInput,
|
||||
ListRunLogsOutput,
|
||||
ListTasksInput,
|
||||
ListTasksOutput,
|
||||
ListUpcomingInput,
|
||||
ListUpcomingOutput,
|
||||
PauseTaskInput,
|
||||
ReclaimStaleRunsInput,
|
||||
ReclaimStaleRunsOutput,
|
||||
RestoreTaskInput,
|
||||
ResumeTaskInput,
|
||||
RunLogOutput,
|
||||
TaskOutput,
|
||||
TriggerTaskInput,
|
||||
TriggerTaskOutput,
|
||||
UpdateTaskInput,
|
||||
)
|
||||
from yuxi.scheduler.use_cases.services.scheduler_service import SchedulerService
|
||||
from yuxi.services.run_queue_service import get_arq_pool
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from arq import ArqRedis
|
||||
|
||||
scheduler_router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
||||
|
||||
# =============================================================================
|
||||
# === 响应模型 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SchedulerResponse[T](BaseModel):
|
||||
"""调度器统一成功响应模型。"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
success: bool = True
|
||||
data: T
|
||||
|
||||
|
||||
class TaskOperationData(BaseModel):
|
||||
"""删除 / 硬删除等操作的轻量响应数据。"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
task_id: str
|
||||
status: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 校验常量 ===
|
||||
# =============================================================================
|
||||
|
||||
_ISO_DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$"
|
||||
_ISO_DATETIME_PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === Request Schemas(与 Input DTO 不共享类) ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BatchTaskRequest(BaseModel):
|
||||
"""批量任务操作请求体。"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
task_ids: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="目标任务 ID 列表,最多 100 个",
|
||||
)
|
||||
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
"""创建定时任务请求体。字段对齐 ``CreateTaskInput``(不含 ``created_by``)。"""
|
||||
|
||||
@ -63,14 +127,14 @@ class CreateTaskRequest(BaseModel):
|
||||
)
|
||||
owner_scope: str = Field(..., min_length=1, max_length=64)
|
||||
owner_id: str = Field(..., min_length=1, max_length=128)
|
||||
schedule_kind: str = Field(..., pattern=r"^(cron|at)$")
|
||||
schedule_kind: Literal["cron", "at"] = Field(..., description="调度类型:cron(周期)/ at(一次性)")
|
||||
cron_expression: str | None = Field(default=None, max_length=128)
|
||||
run_at: str | None = None
|
||||
tz: str = Field(default="Asia/Shanghai", max_length=64)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
delete_after_run: bool = False
|
||||
block_strategy: str = Field(default="discard_later", pattern=r"^(discard_later)$")
|
||||
block_strategy: Literal["discard_later"] = Field(default="discard_later", description="阻塞策略:discard_later")
|
||||
|
||||
|
||||
class UpdateTaskRequest(BaseModel):
|
||||
@ -97,7 +161,12 @@ class ReclaimStaleRunsRequest(BaseModel):
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
timeout_seconds: int | None = Field(default=None, ge=1)
|
||||
timeout_seconds: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=86400,
|
||||
description="执行超时阈值(秒),默认从配置读取,最大 86400(1 天)",
|
||||
)
|
||||
|
||||
|
||||
class TriggerTaskRequest(BaseModel):
|
||||
@ -115,100 +184,262 @@ class TriggerTaskRequest(BaseModel):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 静态路径端点(必须在 /tasks/{task_id} 之前声明) ===
|
||||
# === 依赖注入 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@scheduler_router.get("/tasks", response_model=dict)
|
||||
async def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
owner_scope: str | None = Query(None, max_length=64),
|
||||
owner_id: str | None = Query(None, max_length=128),
|
||||
handler_name: str | None = Query(None, max_length=128),
|
||||
enabled: bool | None = Query(None),
|
||||
status: str | None = Query(None, pattern=r"^(active|paused|dead_letter)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
"""分页列出定时任务(FR-ST-05 / FR-ST-09)。"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = ListTasksInput(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=owner_scope,
|
||||
owner_id=owner_id,
|
||||
handler_name=handler_name,
|
||||
enabled=enabled,
|
||||
status=status,
|
||||
)
|
||||
output = await service.list_tasks(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
|
||||
|
||||
@scheduler_router.post("/tasks", response_model=dict)
|
||||
async def create_task(
|
||||
body: CreateTaskRequest,
|
||||
async def get_idempotency_key(
|
||||
idempotency_key: str | None = Header(
|
||||
default=None,
|
||||
alias="Idempotency-Key",
|
||||
max_length=128,
|
||||
description="幂等键,启用后重复请求回放缓存响应",
|
||||
),
|
||||
) -> str | None:
|
||||
"""提取幂等键 Header 依赖。"""
|
||||
return idempotency_key
|
||||
|
||||
|
||||
async def get_scheduler_service(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SchedulerService:
|
||||
"""构造查询 / CRUD / 状态机类端点使用的 SchedulerService(无 handler_registry)。"""
|
||||
return create_scheduler_service(db)
|
||||
|
||||
|
||||
async def get_scheduler_service_with_arq_pool(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
arq_pool: ArqRedis = Depends(get_arq_pool),
|
||||
) -> SchedulerService:
|
||||
"""构造手动触发端点使用的 SchedulerService(注入 arq_pool)。"""
|
||||
return create_scheduler_service(db, arq_pool=arq_pool)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 静态路径端点(必须在 /tasks/{task_id} 之前声明) ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@scheduler_router.get(
|
||||
"/tasks",
|
||||
response_model=SchedulerResponse[ListTasksOutput],
|
||||
summary="分页列出定时任务",
|
||||
operation_id="list_scheduler_tasks",
|
||||
)
|
||||
async def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
owner_scope: str | None = Query(None, max_length=64),
|
||||
owner_id: str | None = Query(None, max_length=128),
|
||||
handler_name: str | None = Query(None, max_length=128),
|
||||
keyword: str | None = Query(None, max_length=128, description="关键词模糊搜索(task_id / handler_name)"),
|
||||
enabled: bool | None = Query(None),
|
||||
status: Literal["active", "paused", "dead_letter"] | None = Query(None, description="任务状态过滤"),
|
||||
sort_by: Literal["created_at", "next_run_at", "last_run_at", "consecutive_errors", "updated_at"] | None = Query(
|
||||
None, description="排序字段,默认 created_at"
|
||||
),
|
||||
sort_order: Literal["asc", "desc"] | None = Query(None, description="排序方向,默认 desc"),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> SchedulerResponse[ListTasksOutput]:
|
||||
"""分页列出定时任务(FR-ST-05 / FR-ST-09)。
|
||||
|
||||
数据可见性:本端点为已登录用户可读的管理面查询,owner_scope/owner_id
|
||||
作为业务筛选条件由前端控制,API 层不做强制数据隔离。如需按用户角色
|
||||
限制可见范围,应在业务层通过 owner 维度过滤。
|
||||
"""
|
||||
input_dto = ListTasksInput(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=owner_scope,
|
||||
owner_id=owner_id,
|
||||
handler_name=handler_name,
|
||||
keyword=keyword,
|
||||
enabled=enabled,
|
||||
status=status,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
output = await service.list_tasks(input_dto)
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.post(
|
||||
"/tasks/batch-pause",
|
||||
response_model=SchedulerResponse[BatchTaskOperationOutput],
|
||||
summary="批量暂停任务",
|
||||
operation_id="batch_pause_scheduler_tasks",
|
||||
)
|
||||
async def batch_pause_tasks(
|
||||
body: BatchTaskRequest,
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[BatchTaskOperationOutput]:
|
||||
"""批量暂停任务(仅 ``active`` 状态被转换,FR-ST-05)。"""
|
||||
output = await service.batch_pause_tasks(
|
||||
body.task_ids,
|
||||
operator=current_user.uid,
|
||||
)
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.post(
|
||||
"/tasks/batch-resume",
|
||||
response_model=SchedulerResponse[BatchTaskOperationOutput],
|
||||
summary="批量恢复任务",
|
||||
operation_id="batch_resume_scheduler_tasks",
|
||||
)
|
||||
async def batch_resume_tasks(
|
||||
body: BatchTaskRequest,
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> SchedulerResponse[BatchTaskOperationOutput]:
|
||||
"""批量恢复任务(``paused`` / ``dead_letter`` → ``active``,FR-ST-05)。"""
|
||||
output = await service.batch_resume_tasks(
|
||||
body.task_ids,
|
||||
operator=current_user.uid,
|
||||
)
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.post(
|
||||
"/tasks/batch-delete",
|
||||
response_model=SchedulerResponse[BatchTaskOperationOutput],
|
||||
summary="批量删除任务",
|
||||
operation_id="batch_delete_scheduler_tasks",
|
||||
)
|
||||
async def batch_delete_tasks(
|
||||
body: BatchTaskRequest,
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> SchedulerResponse[BatchTaskOperationOutput]:
|
||||
"""批量软删除任务(FR-ST-05)。"""
|
||||
output = await service.batch_delete_tasks(
|
||||
body.task_ids,
|
||||
operator=current_user.uid,
|
||||
)
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.post(
|
||||
"/tasks",
|
||||
response_model=SchedulerResponse[TaskOutput],
|
||||
summary="创建定时任务",
|
||||
operation_id="create_scheduler_task",
|
||||
)
|
||||
async def create_task(
|
||||
body: CreateTaskRequest,
|
||||
idempotency_key: str | None = Depends(get_idempotency_key),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> SchedulerResponse[TaskOutput]:
|
||||
"""创建定时任务(FR-ST-01 / FR-ST-05)。"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = CreateTaskInput(
|
||||
**body.model_dump(),
|
||||
created_by=current_user.uid,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
output = await service.create_task(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.get("/tasks/upcoming", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/tasks/upcoming",
|
||||
response_model=SchedulerResponse[ListUpcomingOutput],
|
||||
summary="列出即将执行的任务",
|
||||
operation_id="list_upcoming_scheduler_tasks",
|
||||
)
|
||||
async def list_upcoming(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
handler_name: str | None = Query(None, max_length=128),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
hours_ahead: int = Query(
|
||||
24,
|
||||
ge=1,
|
||||
le=168,
|
||||
description="预览窗口小时数(默认 24,最大 168=7 天)",
|
||||
),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
"""列出即将执行的任务(未来 24 小时内,FR-ST-09)。"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = ListUpcomingInput(limit=limit, handler_name=handler_name)
|
||||
) -> SchedulerResponse[ListUpcomingOutput]:
|
||||
"""列出即将执行的任务(未来指定小时内,FR-ST-09)。"""
|
||||
input_dto = ListUpcomingInput(
|
||||
limit=limit,
|
||||
handler_name=handler_name,
|
||||
hours_ahead=hours_ahead,
|
||||
)
|
||||
output = await service.list_upcoming(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.get("/tasks/count-by-status", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/tasks/count-by-status",
|
||||
response_model=SchedulerResponse[CountByStatusOutput],
|
||||
summary="按状态计数任务",
|
||||
operation_id="count_scheduler_tasks_by_status",
|
||||
)
|
||||
async def count_by_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
owner_scope: str | None = Query(None, max_length=64),
|
||||
owner_id: str | None = Query(None, max_length=128),
|
||||
handler_name: str | None = Query(None, max_length=128),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
"""按状态计数任务(FR-ST-07)。"""
|
||||
service = create_scheduler_service(db)
|
||||
output = await service.count_by_status()
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
) -> SchedulerResponse[CountByStatusOutput]:
|
||||
"""按状态计数任务(FR-ST-07)。支持 owner/handler 下钻过滤。"""
|
||||
input_dto = CountByStatusInput(
|
||||
owner_scope=owner_scope,
|
||||
owner_id=owner_id,
|
||||
handler_name=handler_name,
|
||||
)
|
||||
output = await service.count_by_status(input_dto)
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.get("/tasks/deleted", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/tasks/anomalies",
|
||||
response_model=SchedulerResponse[ListAnomaliesOutput],
|
||||
summary="列出工作台异常任务聚合",
|
||||
operation_id="list_scheduler_anomalies",
|
||||
)
|
||||
async def list_anomalies(
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> SchedulerResponse[ListAnomaliesOutput]:
|
||||
"""列出工作台异常任务聚合(死信 / 连续失败 / 长期未执行)。
|
||||
|
||||
一次调用返回三类异常任务,供工作台 AnomalyTaskCard 直接消费,
|
||||
避免前端依赖活跃任务列表前 N 条做前端过滤导致的漏报。
|
||||
"""
|
||||
output = await service.list_anomalies()
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.get(
|
||||
"/tasks/deleted",
|
||||
response_model=SchedulerResponse[ListTasksOutput],
|
||||
summary="列出已删除任务(回收站)",
|
||||
operation_id="list_deleted_scheduler_tasks",
|
||||
)
|
||||
async def list_deleted_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
start_date: str | None = Query(None, description="ISO 格式起始时间,按 deleted_at 过滤"),
|
||||
end_date: str | None = Query(None, description="ISO 格式截止时间,按 deleted_at 过滤"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
start_date: str | None = Query(
|
||||
None,
|
||||
pattern=_ISO_DATETIME_PATTERN,
|
||||
description="ISO 格式起始时间,按 deleted_at 过滤",
|
||||
),
|
||||
end_date: str | None = Query(
|
||||
None,
|
||||
pattern=_ISO_DATETIME_PATTERN,
|
||||
description="ISO 格式截止时间,按 deleted_at 过滤",
|
||||
),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[ListTasksOutput]:
|
||||
"""列出已删除任务(回收站,FR-ST-05)。
|
||||
|
||||
返回软删除的任务列表,按 ``deleted_at`` 降序排序,支持时间范围过滤与分页。
|
||||
仅管理员可访问。
|
||||
"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = ListDeletedTasksInput(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@ -216,7 +447,7 @@ async def list_deleted_tasks(
|
||||
end_date=end_date,
|
||||
)
|
||||
output = await service.list_deleted_tasks(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@ -224,34 +455,37 @@ async def list_deleted_tasks(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@scheduler_router.get("/tasks/{task_id}", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/tasks/{task_id}",
|
||||
response_model=SchedulerResponse[TaskOutput],
|
||||
summary="获取任务详情",
|
||||
operation_id="get_scheduler_task",
|
||||
)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
task_id: str = Path(..., min_length=1, max_length=64),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[TaskOutput]:
|
||||
"""获取任务详情(FR-ST-05)。"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = GetTaskInput(task_id=task_id)
|
||||
output = await service.get_task(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.put("/tasks/{task_id}", response_model=dict)
|
||||
@scheduler_router.put(
|
||||
"/tasks/{task_id}",
|
||||
response_model=SchedulerResponse[TaskOutput],
|
||||
summary="更新定时任务",
|
||||
operation_id="update_scheduler_task",
|
||||
)
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
body: UpdateTaskRequest,
|
||||
idempotency_key: str | None = Header(
|
||||
default=None,
|
||||
alias="Idempotency-Key",
|
||||
max_length=128,
|
||||
description="幂等键,启用后重复请求回放缓存响应",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
task_id: str = Path(..., min_length=1, max_length=64),
|
||||
body: UpdateTaskRequest = Body(...),
|
||||
idempotency_key: str | None = Depends(get_idempotency_key),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[TaskOutput]:
|
||||
"""更新定时任务(FR-ST-05)。仅透传客户端显式设置的字段,保留部分更新语义。"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = UpdateTaskInput(
|
||||
task_id=task_id,
|
||||
updated_by=current_user.uid,
|
||||
@ -259,102 +493,97 @@ async def update_task(
|
||||
**body.model_dump(exclude_unset=True),
|
||||
)
|
||||
output = await service.update_task(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.delete("/tasks/{task_id}", response_model=dict)
|
||||
@scheduler_router.delete(
|
||||
"/tasks/{task_id}",
|
||||
response_model=SchedulerResponse[TaskOperationData],
|
||||
summary="删除定时任务(软删除)",
|
||||
operation_id="delete_scheduler_task",
|
||||
)
|
||||
async def delete_task(
|
||||
task_id: str,
|
||||
idempotency_key: str | None = Header(
|
||||
default=None,
|
||||
alias="Idempotency-Key",
|
||||
max_length=128,
|
||||
description="幂等键,启用后重复请求回放缓存响应",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
task_id: str = Path(..., min_length=1, max_length=64),
|
||||
idempotency_key: str | None = Depends(get_idempotency_key),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[TaskOperationData]:
|
||||
"""删除定时任务(软删除,FR-ST-05)。"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = DeleteTaskInput(
|
||||
task_id=task_id,
|
||||
updated_by=current_user.uid,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
await service.delete_task(input_dto)
|
||||
return {"success": True, "data": {"task_id": task_id, "status": "deleted"}}
|
||||
return SchedulerResponse(data=TaskOperationData(task_id=task_id, status="deleted"))
|
||||
|
||||
|
||||
@scheduler_router.post("/tasks/{task_id}/pause", response_model=dict)
|
||||
@scheduler_router.post(
|
||||
"/tasks/{task_id}/pause",
|
||||
response_model=SchedulerResponse[TaskOutput],
|
||||
summary="暂停任务",
|
||||
operation_id="pause_scheduler_task",
|
||||
)
|
||||
async def pause_task(
|
||||
task_id: str,
|
||||
idempotency_key: str | None = Header(
|
||||
default=None,
|
||||
alias="Idempotency-Key",
|
||||
max_length=128,
|
||||
description="幂等键,启用后重复请求回放缓存响应",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
task_id: str = Path(..., min_length=1, max_length=64),
|
||||
idempotency_key: str | None = Depends(get_idempotency_key),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[TaskOutput]:
|
||||
"""暂停任务(FR-ST-05)。"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = PauseTaskInput(
|
||||
task_id=task_id,
|
||||
updated_by=current_user.uid,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
output = await service.pause_task(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.post("/tasks/{task_id}/resume", response_model=dict)
|
||||
@scheduler_router.post(
|
||||
"/tasks/{task_id}/resume",
|
||||
response_model=SchedulerResponse[TaskOutput],
|
||||
summary="恢复任务",
|
||||
operation_id="resume_scheduler_task",
|
||||
)
|
||||
async def resume_task(
|
||||
task_id: str,
|
||||
idempotency_key: str | None = Header(
|
||||
default=None,
|
||||
alias="Idempotency-Key",
|
||||
max_length=128,
|
||||
description="幂等键,启用后重复请求回放缓存响应",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
task_id: str = Path(..., min_length=1, max_length=64),
|
||||
idempotency_key: str | None = Depends(get_idempotency_key),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[TaskOutput]:
|
||||
"""恢复任务(FR-ST-05 / FR-ST-08)。
|
||||
|
||||
``paused`` -> ``active`` / ``dead_letter`` -> ``active``。
|
||||
"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = ResumeTaskInput(
|
||||
task_id=task_id,
|
||||
updated_by=current_user.uid,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
output = await service.resume_task(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.post("/tasks/{task_id}/trigger", response_model=dict)
|
||||
@scheduler_router.post(
|
||||
"/tasks/{task_id}/trigger",
|
||||
response_model=SchedulerResponse[TriggerTaskOutput],
|
||||
summary="手动触发任务执行",
|
||||
operation_id="trigger_scheduler_task",
|
||||
)
|
||||
async def trigger_task(
|
||||
task_id: str,
|
||||
body: TriggerTaskRequest | None = None,
|
||||
idempotency_key: str | None = Header(
|
||||
default=None,
|
||||
alias="Idempotency-Key",
|
||||
max_length=128,
|
||||
description="幂等键,启用后重复请求回放缓存响应",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
task_id: str = Path(..., min_length=1, max_length=64),
|
||||
body: TriggerTaskRequest | None = Body(None),
|
||||
idempotency_key: str | None = Depends(get_idempotency_key),
|
||||
service: SchedulerService = Depends(get_scheduler_service_with_arq_pool),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[TriggerTaskOutput]:
|
||||
"""手动触发任务执行(FR-ST-05)。
|
||||
|
||||
生成 ``run_id`` 后入队 ARQ 执行,不影响下一次自动执行。
|
||||
可通过 body 传入 payload 覆盖任务定义中的 payload,None 时使用任务原 payload。
|
||||
触发人 ``current_user.uid`` 透传到 worker 写入 ``run_log.created_by`` 审计字段。
|
||||
"""
|
||||
arq_pool = await get_arq_pool()
|
||||
service = create_scheduler_service(db, arq_pool=arq_pool)
|
||||
payload = body.payload if body else None
|
||||
input_dto = TriggerTaskInput(
|
||||
task_id=task_id,
|
||||
@ -364,58 +593,88 @@ async def trigger_task(
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
output = await service.trigger_task(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.post("/tasks/{task_id}/restore", response_model=dict)
|
||||
@scheduler_router.post(
|
||||
"/tasks/{task_id}/restore",
|
||||
response_model=SchedulerResponse[TaskOutput],
|
||||
summary="恢复已删除任务",
|
||||
operation_id="restore_scheduler_task",
|
||||
)
|
||||
async def restore_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
task_id: str = Path(..., min_length=1, max_length=64),
|
||||
idempotency_key: str | None = Depends(get_idempotency_key),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[TaskOutput]:
|
||||
"""恢复已删除任务(FR-ST-05)。
|
||||
|
||||
将软删除的任务恢复为 ``is_deleted=0``。若恢复前 ``status='active'``,
|
||||
自动置为 ``paused`` 避免立即被 tick 扫描执行,需管理员确认后手动 ``resume``。
|
||||
仅管理员可操作。
|
||||
"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = RestoreTaskInput(task_id=task_id, updated_by=current_user.uid)
|
||||
input_dto = RestoreTaskInput(
|
||||
task_id=task_id,
|
||||
updated_by=current_user.uid,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
output = await service.restore_task(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.delete("/tasks/{task_id}/hard", response_model=dict)
|
||||
@scheduler_router.delete(
|
||||
"/tasks/{task_id}/hard",
|
||||
response_model=SchedulerResponse[TaskOperationData],
|
||||
summary="硬删除任务(不可恢复)",
|
||||
operation_id="hard_delete_scheduler_task",
|
||||
)
|
||||
async def hard_delete_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
task_id: str = Path(..., min_length=1, max_length=64),
|
||||
idempotency_key: str | None = Depends(get_idempotency_key),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[TaskOperationData]:
|
||||
"""硬删除任务(不可恢复,FR-ST-05)。
|
||||
|
||||
物理删除已软删除的任务记录,操作不可逆。仅管理员可操作。
|
||||
"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = HardDeleteTaskInput(task_id=task_id, updated_by=current_user.uid)
|
||||
input_dto = HardDeleteTaskInput(
|
||||
task_id=task_id,
|
||||
updated_by=current_user.uid,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
await service.hard_delete_task(input_dto)
|
||||
return {"success": True, "data": {"task_id": task_id, "status": "hard_deleted"}}
|
||||
return SchedulerResponse(data=TaskOperationData(task_id=task_id, status="hard_deleted"))
|
||||
|
||||
|
||||
@scheduler_router.get("/tasks/{task_id}/run-logs", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/tasks/{task_id}/run-logs",
|
||||
response_model=SchedulerResponse[ListRunLogsOutput],
|
||||
summary="列出任务执行日志",
|
||||
operation_id="list_scheduler_run_logs",
|
||||
)
|
||||
async def list_run_logs(
|
||||
task_id: str,
|
||||
task_id: str = Path(..., min_length=1, max_length=64),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: str | None = Query(
|
||||
None, pattern=r"^(running|success|failure|timeout|skipped)$"
|
||||
status: Literal["running", "success", "failure", "timeout", "skipped"] | None = Query(
|
||||
None, description="执行状态过滤"
|
||||
),
|
||||
start_date: str | None = Query(None, description="ISO 格式起始时间"),
|
||||
end_date: str | None = Query(None, description="ISO 格式截止时间"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
start_date: str | None = Query(
|
||||
None,
|
||||
pattern=_ISO_DATETIME_PATTERN,
|
||||
description="ISO 格式起始时间,按 started_at 过滤",
|
||||
),
|
||||
end_date: str | None = Query(
|
||||
None,
|
||||
pattern=_ISO_DATETIME_PATTERN,
|
||||
description="ISO 格式截止时间,按 started_at 过滤",
|
||||
),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[ListRunLogsOutput]:
|
||||
"""列出任务执行日志(FR-ST-05 / FR-ST-09)。"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = ListRunLogsInput(
|
||||
task_id=task_id,
|
||||
page=page,
|
||||
@ -425,7 +684,7 @@ async def list_run_logs(
|
||||
end_date=end_date,
|
||||
)
|
||||
output = await service.list_run_logs(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@ -433,50 +692,71 @@ async def list_run_logs(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@scheduler_router.get("/run-logs", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/run-logs",
|
||||
response_model=SchedulerResponse[ListRunLogsOutput],
|
||||
summary="跨任务列出执行日志",
|
||||
operation_id="list_all_scheduler_run_logs",
|
||||
)
|
||||
async def list_all_run_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: str | None = Query(
|
||||
None, pattern=r"^(running|success|failure|timeout|skipped)$"
|
||||
status: Literal["running", "success", "failure", "timeout", "skipped"] | None = Query(
|
||||
None, description="执行状态过滤"
|
||||
),
|
||||
handler_name: str | None = Query(
|
||||
None, min_length=1, max_length=128, description="handler 名称过滤(跨任务按 handler 维度查询)"
|
||||
),
|
||||
start_date: str | None = Query(
|
||||
None,
|
||||
pattern=_ISO_DATETIME_PATTERN,
|
||||
description="ISO 格式起始时间,按 started_at 过滤",
|
||||
),
|
||||
end_date: str | None = Query(
|
||||
None,
|
||||
pattern=_ISO_DATETIME_PATTERN,
|
||||
description="ISO 格式截止时间,按 started_at 过滤",
|
||||
),
|
||||
start_date: str | None = Query(None, description="ISO 格式起始时间"),
|
||||
end_date: str | None = Query(None, description="ISO 格式截止时间"),
|
||||
task_id: str | None = Query(None, min_length=1, max_length=64),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[ListRunLogsOutput]:
|
||||
"""跨任务列出执行日志(FR-ST-09)。
|
||||
|
||||
``task_id`` 与 ``status`` 不可同时为空(由用例层校验),避免无过滤的全表扫描。
|
||||
至少需指定 ``task_id``、``status`` 或时间范围之一(由用例层校验),
|
||||
避免无过滤的全表扫描。``handler_name`` 为可选过滤维度。
|
||||
"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = ListAllRunLogsInput(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=status,
|
||||
handler_name=handler_name,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
task_id=task_id,
|
||||
)
|
||||
output = await service.list_all_run_logs(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.get("/run-logs/{run_id}", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/run-logs/{run_id}",
|
||||
response_model=SchedulerResponse[RunLogOutput],
|
||||
summary="获取执行日志详情",
|
||||
operation_id="get_scheduler_run_log",
|
||||
)
|
||||
async def get_run_log(
|
||||
run_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
run_id: str = Path(..., min_length=1, max_length=64),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[RunLogOutput]:
|
||||
"""获取执行日志详情(FR-ST-05)。
|
||||
|
||||
按 ``run_id`` 查询单条执行日志,供告警跳转 / 排查失败执行使用。
|
||||
"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = GetRunLogInput(run_id=run_id)
|
||||
output = await service.get_run_log(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@ -484,15 +764,19 @@ async def get_run_log(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@scheduler_router.get("/handlers/summary", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/handlers/summary",
|
||||
response_model=SchedulerResponse[ListHandlerSummaryOutput],
|
||||
summary="列出 handler 聚合摘要",
|
||||
operation_id="list_scheduler_handler_summary",
|
||||
)
|
||||
async def list_handler_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[ListHandlerSummaryOutput]:
|
||||
"""列出 handler 聚合摘要(FR-ST-05)。"""
|
||||
service = create_scheduler_service(db)
|
||||
output = await service.list_handler_summary()
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@ -500,27 +784,39 @@ async def list_handler_summary(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@scheduler_router.get("/stats/daily", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/stats/daily",
|
||||
response_model=SchedulerResponse[ListDailyStatsOutput],
|
||||
summary="列出日聚合统计",
|
||||
operation_id="list_scheduler_daily_stats",
|
||||
)
|
||||
async def list_daily_stats(
|
||||
start_date: str = Query(..., description="ISO 日期 YYYY-MM-DD(必填)"),
|
||||
end_date: str = Query(..., description="ISO 日期 YYYY-MM-DD(必填)"),
|
||||
start_date: str = Query(
|
||||
...,
|
||||
pattern=_ISO_DATE_PATTERN,
|
||||
description="ISO 日期 YYYY-MM-DD(必填)",
|
||||
),
|
||||
end_date: str = Query(
|
||||
...,
|
||||
pattern=_ISO_DATE_PATTERN,
|
||||
description="ISO 日期 YYYY-MM-DD(必填)",
|
||||
),
|
||||
handler_name: str | None = Query(None, max_length=128),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[ListDailyStatsOutput]:
|
||||
"""列出日聚合统计(FR-ST-09)。
|
||||
|
||||
按日期范围 + handler 维度查询日聚合统计,返回含 ``total_count`` /
|
||||
``success_rate`` 派生字段。日期范围上限 90 天。
|
||||
"""
|
||||
service = create_scheduler_service(db)
|
||||
input_dto = ListDailyStatsInput(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
handler_name=handler_name,
|
||||
)
|
||||
output = await service.list_daily_stats(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@ -528,29 +824,37 @@ async def list_daily_stats(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@scheduler_router.get("/health", response_model=dict)
|
||||
@scheduler_router.get(
|
||||
"/health",
|
||||
response_model=SchedulerResponse[GetHealthOutput],
|
||||
summary="获取调度器健康状态",
|
||||
operation_id="get_scheduler_health",
|
||||
)
|
||||
async def get_health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
) -> dict[str, Any]:
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> SchedulerResponse[GetHealthOutput]:
|
||||
"""获取调度器健康状态(FR-ST-07)。"""
|
||||
service = create_scheduler_service(db)
|
||||
output = await service.get_health()
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
|
||||
@scheduler_router.post("/reclaim-stale-runs", response_model=dict)
|
||||
@scheduler_router.post(
|
||||
"/reclaim-stale-runs",
|
||||
response_model=SchedulerResponse[ReclaimStaleRunsOutput],
|
||||
summary="回收僵尸执行记录",
|
||||
operation_id="reclaim_scheduler_stale_runs",
|
||||
)
|
||||
async def reclaim_stale_runs(
|
||||
body: ReclaimStaleRunsRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: SchedulerService = Depends(get_scheduler_service),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
) -> SchedulerResponse[ReclaimStaleRunsOutput]:
|
||||
"""回收僵尸执行记录(运维恢复)。
|
||||
|
||||
回收 ``status=running`` 但实际已超时的执行记录,标记为 ``timeout``。
|
||||
"""
|
||||
service = create_scheduler_service(db)
|
||||
timeout_seconds = body.timeout_seconds if body else None
|
||||
input_dto = ReclaimStaleRunsInput(timeout_seconds=timeout_seconds)
|
||||
output = await service.reclaim_stale_runs(input_dto)
|
||||
return {"success": True, "data": output.model_dump()}
|
||||
return SchedulerResponse(data=output)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user