ForcePilot/backend/server/routers/external_systems/environment_router.py
Kris 002e6a356b chore: 批量整理代码变更,修复多类细节问题
1.  清理测试文件中未使用的导入与冗余代码
2.  修复审计日志与批量操作的空值约束,统一填充"global"作为默认渠道
3.  调整批量消息撤回的响应语义,对齐其他端点的部分成功契约
4.  修复访问规则批量克隆的唯一约束问题,新增后缀自动处理逻辑
5.  替换anyio为asyncio并行调用,修正时间UTC导入路径
6.  优化前端外部系统概览页的刷新状态提示与缓存逻辑
7.  修复测试用例中的断言与请求方式问题,适配httpx删除请求特性
8.  重构后端路由的依赖注入,移除冗余的数据库会话依赖
9.  调整测试用例的权限校验逻辑,修正强制登出的权限判断
10. 修复语义分块测试的numpy依赖问题,清理冗余导入
2026-07-13 20:48:29 +08:00

533 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Environment 子域 Router。
外部系统限界上下文的环境管理 API覆盖环境 CRUD / 默认 / 启停 / 克隆 /
连通性测试 / 差异 / 导出 / 健康状态 / 统计 / 跨系统查询 /
批量克隆 / 批量启停。所有端点通过 ``create_use_cases_from_db`` 装配 use_cases
经 ``environment_service`` 端口调用用例。
路径顺序约束:静态路径(``/diff`` / ``/active`` / ``/by-key`` / ``/stats`` /
``/cross-system`` / ``/batch-test-connectivity`` / ``/batch-clone`` /
``/batch-enabled`` / ``/batch-health``)必须在 ``/{env_id}`` 前声明,避免被路径参数捕获。
认证策略:查询类端点使用 ``get_required_user``,写操作端点使用 ``get_admin_user``。
``export_environment`` 因 ``include_secrets=true`` 会泄露明文密钥,统一要求 ``get_admin_user``。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from yuxi.external_systems.infrastructure.container import UseCases
from yuxi.external_systems.use_cases.dto.environment import (
BatchCloneEnvironmentsInput,
BatchEnableEnvironmentsInput,
BatchEnvironmentHealthInput,
BulkTestEnvironmentsInput,
CloneEnvironmentInput,
CreateEnvironmentInput,
CrossSystemEnvironmentsInput,
DeleteEnvironmentInput,
DiffEnvironmentsInput,
DisableEnvironmentInput,
EnableEnvironmentInput,
ExportEnvironmentInput,
GetActiveEnvironmentInput,
GetEnvironmentByKeyInput,
GetEnvironmentHealthInput,
GetEnvironmentInput,
GetEnvironmentStatsInput,
ListEnvironmentsInput,
SetDefaultEnvironmentInput,
TestEnvironmentConnectionInput,
UpdateEnvironmentInput,
)
from yuxi.storage.postgres.models_business import User
from server.routers.external_systems import get_use_cases
from server.utils.auth_middleware import get_admin_user, get_required_user
environment_router = APIRouter(
prefix="/environments",
tags=["external-systems-environment"],
)
# ---------------- Request Schemas ----------------
class CreateEnvironmentRequest(BaseModel):
"""创建环境请求体。字段对齐 ``CreateEnvironmentInput``。
字段长度约束对齐 ``ExternalSystemEnvironment`` ORM 列定义,在边界层拦截非法输入:
``env_key`` 对齐 String(32) 并沿用 slug 标识符格式,``name`` 对齐 String(128)。
"""
model_config = ConfigDict(frozen=True)
system_id: int
env_key: str = Field(..., min_length=1, max_length=32, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$")
name: str = Field(..., min_length=1, max_length=128)
connection_config: dict[str, Any] = Field(default_factory=dict)
auth_config: dict[str, Any] | None = None
is_default: bool = False
enabled: bool = True
class UpdateEnvironmentRequest(BaseModel):
"""更新环境请求体。``id`` 与 ``system_id`` 由路径/Query 提供,不在 body 中。
字段长度约束对齐 ``ExternalSystemEnvironment`` ORM 列定义,仅透传客户端显式设置的字段。
不含 ``is_default``:默认环境切换必须通过 ``PATCH /{env_id}/default`` 端点,
该端点会清除同系统其他环境的默认标记,避免违反部分唯一约束。
不含 ``enabled``:启停必须通过 ``PATCH /{env_id}/enabled`` 端点,该端点会执行
禁用环境的安全检查(清除默认标记、释放连接池、失效 Token、发布状态变更事件
"""
model_config = ConfigDict(frozen=True)
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
class SetEnvironmentEnabledRequest(BaseModel):
"""启停环境请求体。按 ``enabled`` 值分派 enable/disable。"""
model_config = ConfigDict(frozen=True)
enabled: bool
class CloneEnvironmentRequest(BaseModel):
"""克隆环境请求体。``id`` 与 ``system_id`` 由路径/Query 提供。
字段长度约束对齐 ``ExternalSystemEnvironment`` ORM 列定义,``new_env_key`` 与
``CreateEnvironmentRequest.env_key`` 保持一致格式。
"""
model_config = ConfigDict(frozen=True)
new_env_key: str = Field(..., min_length=1, max_length=32, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$")
new_name: str | None = Field(default=None, min_length=1, max_length=128)
class BatchTestConnectivityRequest(BaseModel):
"""批量测试连通性请求体。字段对齐 ``BulkTestEnvironmentsInput``。
``env_keys`` 限制最多 100 条,对齐 ``BatchEnvironmentHealthRequest.env_ids``
及其他批量请求体的列表上限,避免请求体过大。
"""
model_config = ConfigDict(frozen=True)
system_id: int
env_keys: list[str] | None = Field(default=None, max_length=100)
class BatchCloneEnvironmentsRequest(BaseModel):
"""批量克隆环境请求体。字段对齐 ``BatchCloneEnvironmentsInput``(不含 ``created_by``)。
``created_by`` 由 ``current_user.uid`` 填充,不在请求体中暴露。
``source_env_ids`` 限制 1-100 条,对齐 ``system_router.BatchCloneRequest``。
``new_env_key_suffix`` 限制为安全字符(字母/数字/下划线/连字符),避免拼接出非法 env_key。
"""
model_config = ConfigDict(frozen=True)
system_id: int
source_env_ids: list[int] = Field(..., min_length=1, max_length=100)
new_env_key_suffix: str = Field(..., min_length=1, max_length=32, pattern=r"^[a-zA-Z0-9_-]+$")
new_name_suffix: str | None = Field(default=None, min_length=1, max_length=128)
class BatchEnableEnvironmentsRequest(BaseModel):
"""批量启停环境请求体。字段对齐 ``BatchEnableEnvironmentsInput``(不含 ``updated_by``)。
``updated_by`` 由 ``current_user.uid`` 填充,不在请求体中暴露。
``env_ids`` 限制 1-100 条,对齐 ``system_router.BatchEnabledRequest``。
"""
model_config = ConfigDict(frozen=True)
system_id: int
env_ids: list[int] = Field(..., min_length=1, max_length=100)
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 ----------------
@environment_router.get("/diff", response_model=dict)
async def diff_environments(
system_id: int = Query(...),
source_env_key: str = Query(..., max_length=32),
target_env_key: str = Query(..., max_length=32),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""对比两个环境的配置差异。"""
input_dto = DiffEnvironmentsInput(
system_id=system_id,
source_env_key=source_env_key,
target_env_key=target_env_key,
)
output = await use_cases.environment_service.diff_environments(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.get("", response_model=dict)
async def list_environments(
system_id: int = Query(...),
env_key: str | None = Query(None, max_length=32),
enabled: bool | None = Query(None),
keyword: str | None = Query(None, max_length=128),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页列出指定外部系统下的环境。"""
input_dto = ListEnvironmentsInput(
system_id=system_id,
env_key=env_key,
enabled=enabled,
keyword=keyword,
limit=limit,
offset=offset,
)
output = await use_cases.environment_service.list_environments(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.post("", response_model=dict)
async def create_environment(
body: CreateEnvironmentRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建环境。``created_by`` 由 ``current_user.uid`` 填充。"""
input_dto = CreateEnvironmentInput(**body.model_dump(), created_by=current_user.uid)
output = await use_cases.environment_service.create_environment(input_dto)
return {"success": True, "data": output.model_dump()}
# ========== 静态路径端点(必须在 /{env_id} 之前声明) ==========
@environment_router.get("/active", response_model=dict)
async def get_active_environment(
system_id: int = Query(...),
env_key: str | None = Query(None, max_length=32),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取当前生效的环境配置。
指定 ``env_key`` 返回对应环境,否则返回默认环境,都没有返回 ``data=null``。
"""
input_dto = GetActiveEnvironmentInput(system_id=system_id, env_key=env_key)
output = await use_cases.environment_service.get_active_environment(input_dto)
return {"success": True, "data": output.model_dump() if output else None}
@environment_router.get("/by-key", response_model=dict)
async def get_environment_by_key(
system_id: int = Query(...),
env_key: str = Query(..., max_length=32),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""按 ``system_id`` + ``env_key`` 获取环境详情,不存在时抛 404。"""
input_dto = GetEnvironmentByKeyInput(system_id=system_id, env_key=env_key)
output = await use_cases.environment_service.get_environment_by_key(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.get("/stats", response_model=dict)
async def get_environment_stats(
system_id: int = Query(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""统计指定系统下的环境数量(按启用状态、默认标记分组)。"""
input_dto = GetEnvironmentStatsInput(system_id=system_id)
output = await use_cases.environment_service.get_environment_stats(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.get("/cross-system", response_model=dict)
async def list_cross_system_environments(
env_key: str = Query(..., max_length=32),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""跨系统查询同 ``env_key`` 的环境列表。"""
input_dto = CrossSystemEnvironmentsInput(
env_key=env_key,
limit=limit,
offset=offset,
)
output = await use_cases.environment_service.list_cross_system_environments(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.post("/batch-test-connectivity", response_model=dict)
async def batch_test_environment_connectivity(
body: BatchTestConnectivityRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量测试环境连通性(并发上限 5"""
input_dto = BulkTestEnvironmentsInput(
system_id=body.system_id,
env_keys=body.env_keys,
)
output = await use_cases.environment_service.bulk_test_environments(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.post("/batch-clone", response_model=dict)
async def batch_clone_environments(
body: BatchCloneEnvironmentsRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量克隆环境配置。``created_by`` 由 ``current_user.uid`` 填充。"""
input_dto = BatchCloneEnvironmentsInput(
system_id=body.system_id,
source_env_ids=body.source_env_ids,
new_env_key_suffix=body.new_env_key_suffix,
new_name_suffix=body.new_name_suffix,
created_by=current_user.uid,
)
output = await use_cases.environment_service.batch_clone_environments(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.post("/batch-enabled", response_model=dict)
async def batch_set_environments_enabled(
body: BatchEnableEnvironmentsRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量启用或禁用多个环境。``updated_by`` 由 ``current_user.uid`` 填充。"""
input_dto = BatchEnableEnvironmentsInput(
system_id=body.system_id,
env_ids=body.env_ids,
enabled=body.enabled,
updated_by=current_user.uid,
)
output = await use_cases.environment_service.batch_set_environments_enabled(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.post("/batch-health", response_model=dict)
async def batch_get_environment_health(
body: BatchEnvironmentHealthRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""批量查询环境健康状态(避免前端逐条请求健康接口造成请求风暴)。"""
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,
system_id: int = Query(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取环境详情。"""
input_dto = GetEnvironmentInput(id=env_id, system_id=system_id)
output = await use_cases.environment_service.get_environment(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.put("/{env_id}", response_model=dict)
async def update_environment(
env_id: int,
body: UpdateEnvironmentRequest,
system_id: int = Query(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新环境。仅透传客户端显式设置的字段。``updated_by`` 由 ``current_user.uid`` 填充。"""
input_dto = UpdateEnvironmentInput(
id=env_id,
system_id=system_id,
updated_by=current_user.uid,
**body.model_dump(exclude_unset=True),
)
output = await use_cases.environment_service.update_environment(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.delete("/{env_id}", response_model=dict)
async def delete_environment(
env_id: int,
system_id: int = Query(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除环境。"""
input_dto = DeleteEnvironmentInput(id=env_id, system_id=system_id, deleted_by=current_user.uid)
output = await use_cases.environment_service.delete_environment(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.patch("/{env_id}/default", response_model=dict)
async def set_default_environment(
env_id: int,
system_id: int = Query(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""设置默认环境。``updated_by`` 由 ``current_user.uid`` 填充。"""
input_dto = SetDefaultEnvironmentInput(
id=env_id,
system_id=system_id,
updated_by=current_user.uid,
)
output = await use_cases.environment_service.set_default_environment(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.patch("/{env_id}/enabled", response_model=dict)
async def set_environment_enabled(
env_id: int,
body: SetEnvironmentEnabledRequest,
system_id: int = Query(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""启用/禁用环境。按 ``enabled`` 值分派 ``enable_environment``/``disable_environment``。
``updated_by`` 由 ``current_user.uid`` 填充。
"""
if body.enabled:
input_dto = EnableEnvironmentInput(
id=env_id,
system_id=system_id,
updated_by=current_user.uid,
)
output = await use_cases.environment_service.enable_environment(input_dto)
else:
input_dto = DisableEnvironmentInput(
id=env_id,
system_id=system_id,
updated_by=current_user.uid,
)
output = await use_cases.environment_service.disable_environment(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.post("/{env_id}/clone", response_model=dict)
async def clone_environment(
env_id: int,
body: CloneEnvironmentRequest,
system_id: int = Query(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""克隆环境。``created_by`` 由 ``current_user.uid`` 填充。"""
input_dto = CloneEnvironmentInput(
id=env_id,
system_id=system_id,
new_env_key=body.new_env_key,
new_name=body.new_name,
created_by=current_user.uid,
)
output = await use_cases.environment_service.clone_environment(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.post("/{env_id}/test-connectivity", response_model=dict)
async def test_environment_connection(
env_id: int,
system_id: int = Query(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""测试环境连通性。"""
input_dto = TestEnvironmentConnectionInput(id=env_id, system_id=system_id)
output = await use_cases.environment_service.test_environment_connection(input_dto)
return {"success": True, "data": output.model_dump()}
@environment_router.get("/{env_id}/export")
async def export_environment(
env_id: int,
system_id: int = Query(...),
include_secrets: bool = Query(False),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> StreamingResponse:
"""导出环境配置为 JSON 文件下载流。
``include_secrets=false``(默认)返回脱敏配置;``include_secrets=true``
返回已解密的明文配置。由于 ``include_secrets=true`` 会泄露明文密钥,
本端点统一要求管理员权限,对齐写操作的权限梯度。
返回 ``StreamingResponse````application/octet-stream``
对齐 ``system_router.export_systems`` 模式。
"""
input_dto = ExportEnvironmentInput(
id=env_id,
system_id=system_id,
include_secrets=include_secrets,
)
output = await use_cases.environment_service.export_environment(input_dto)
payload = json.dumps(
output.model_dump(),
ensure_ascii=False,
default=str,
).encode("utf-8")
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
filename = f"environment_{env_id}_{output.env_key}_{timestamp}.json"
async def _stream():
yield payload
return StreamingResponse(
_stream(),
media_type="application/octet-stream",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@environment_router.get("/{env_id}/health", response_model=dict)
async def get_environment_health(
env_id: int,
system_id: int = Query(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询环境最近健康状态(基于审计日志推导,不执行实时探测)。"""
input_dto = GetEnvironmentHealthInput(id=env_id, system_id=system_id)
output = await use_cases.environment_service.get_environment_health(input_dto)
return {"success": True, "data": output.model_dump()}