"""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`` 填充,不在请求体中暴露。 """ model_config = ConfigDict(frozen=True) system_id: int source_env_ids: list[int] new_env_key_suffix: str = Field(..., min_length=1, max_length=32) 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`` 填充,不在请求体中暴露。 """ model_config = ConfigDict(frozen=True) system_id: int env_ids: list[int] 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), 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, page=offset // limit + 1, page_size=limit, ) 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), 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) 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()}