ForcePilot/backend/server/routers/external_systems/environment_router.py
Kris 6dd16ad3f8 refactor(external-systems-routers): 统一分页参数格式并完善各路由文档与校验
本次提交对多个外部系统路由进行了多维度优化:
1.  统一分页参数:将所有路由的`page = offset//limit +1`、`page_size=limit`替换为标准的`limit`+`offset`分页格式
2.  完善接口文档:补充多个端点的功能说明、参数含义与返回字段解释
3.  增强参数校验:新增字段长度限制、正则校验、枚举类型约束与业务逻辑校验
4.  优化代码复用:提取重复逻辑为辅助函数,减少样板代码
5.  修复接口问题:修正工具健康检查端点路径参数类型,优化导出接口响应格式
6.  补充异常处理:为批量操作添加异常捕获与日志记录,避免流程中断
2026-07-11 06:57:24 +08:00

519 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``)必须在 ``/{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 sqlalchemy.ext.asyncio import AsyncSession
from yuxi.external_systems.infrastructure.container import create_use_cases_from_db
from yuxi.external_systems.use_cases.dto.environment import (
BatchCloneEnvironmentsInput,
BatchEnableEnvironmentsInput,
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.utils.auth_middleware import get_admin_user, get_db, 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`` 端点,
该端点会清除同系统其他环境的默认标记,避免违反部分唯一约束。
"""
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
enabled: bool | 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``。"""
model_config = ConfigDict(frozen=True)
system_id: int
env_keys: list[str] | None = None
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
# ---------------- 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),
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 = 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),
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 = 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,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建环境。``created_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = 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),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取当前生效的环境配置。
指定 ``env_key`` 返回对应环境,否则返回默认环境,都没有返回 ``data=null``。
"""
use_cases = create_use_cases_from_db(db)
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),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""按 ``system_id`` + ``env_key`` 获取环境详情,不存在时抛 404。"""
use_cases = create_use_cases_from_db(db)
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(...),
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 = 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),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""跨系统查询同 ``env_key`` 的环境列表。"""
use_cases = create_use_cases_from_db(db)
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,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量测试环境连通性(并发上限 5"""
use_cases = create_use_cases_from_db(db)
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,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量克隆环境配置。``created_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = 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,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量启用或禁用多个环境。``updated_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = 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.get("/{env_id}", response_model=dict)
async def get_environment(
env_id: int,
system_id: int = Query(...),
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 = 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(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新环境。仅透传客户端显式设置的字段。``updated_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = 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(...),
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 = 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(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""设置默认环境。``updated_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = 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(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""启用/禁用环境。按 ``enabled`` 值分派 ``enable_environment``/``disable_environment``。
``updated_by`` 由 ``current_user.uid`` 填充。
"""
use_cases = create_use_cases_from_db(db)
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(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""克隆环境。``created_by`` 由 ``current_user.uid`` 填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = 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(...),
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 = 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),
db: AsyncSession = Depends(get_db),
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`` 模式。
"""
use_cases = create_use_cases_from_db(db)
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(...),
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 = 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()}