feat(projects): automate project infrastructure provisioning
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m13s

This commit is contained in:
2026-09-11 10:57:51 +08:00
parent 10a7a66a41
commit 90b02057bc
38 changed files with 2345 additions and 189 deletions
+6
View File
@@ -252,6 +252,12 @@ async def list_admin_projects(
"/admin/projects",
response_model=AdminProjectResponse,
status_code=status.HTTP_201_CREATED,
deprecated=True,
summary="仅登记已有项目元数据",
description=(
"仅用于登记已经由外部流程完整创建的资源。新项目应调用 "
"POST /admin/project-provisions。"
),
)
async def create_admin_project(
payload: AdminProjectCreateRequest,
+174
View File
@@ -1,3 +1,4 @@
import json
from pathlib import Path
from tempfile import NamedTemporaryFile
from uuid import UUID, uuid4
@@ -6,12 +7,14 @@ from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Path as ApiPath,
Request,
UploadFile,
status,
)
from sqlalchemy.exc import IntegrityError
from starlette.concurrency import run_in_threadpool
from app.auth.metadata_dependencies import (
@@ -23,10 +26,21 @@ from app.auth.project_dependencies import (
resolve_project_business_routing,
)
from app.core.audit import AuditAction, log_audit_event
from app.core.encryption import is_database_encryption_configured
from app.domain.schemas.admin_metadata import (
AdminProjectResponse,
ProjectProvisionResponse,
)
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import activate_project_routing
from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError
from app.services.network_import import network_update
from app.services.project_provisioning import (
ProjectProvisioningError,
ProvisionedProjectInfrastructure,
provision_project_infrastructure,
validate_project_code,
)
from app.services.tjnetwork import run_inp
router = APIRouter()
@@ -157,6 +171,166 @@ async def _apply_model_update(content: bytes, project_code: str) -> None:
) from exc
def _provision_from_inp_sync(
content: bytes,
*,
code: str,
workspace: str,
) -> ProvisionedProjectInfrastructure:
temp_path: Path | None = None
try:
with NamedTemporaryFile(suffix=".inp", delete=False) as temp_file:
temp_file.write(content)
temp_path = Path(temp_file.name)
return provision_project_infrastructure(
code=code,
workspace=workspace,
inp_path=temp_path,
)
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
@router.post(
"/admin/project-provisions",
response_model=ProjectProvisionResponse,
status_code=status.HTTP_201_CREATED,
summary="创建完整供水项目",
)
async def provision_project(
request: Request,
name: str = Form(..., min_length=1, max_length=100),
code: str = Form(..., min_length=1, max_length=50),
description: str | None = Form(default=None),
gs_workspace: str | None = Form(default=None, max_length=100),
map_zoom: int = Form(default=14, ge=1, le=22),
file: UploadFile = File(..., description="EPANET INP 模型文件"),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectProvisionResponse:
try:
normalized_code = validate_project_code(code)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
workspace = gs_workspace or normalized_code
if await metadata_repo.get_project_by_code(normalized_code) is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Project code already exists",
)
if not is_database_encryption_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="DATABASE_ENCRYPTION_KEY is not configured",
)
content, filename = await _read_upload(file)
validation_result = await _run_uploaded_inp(content)
try:
validation_payload = json.loads(validation_result)
except (TypeError, json.JSONDecodeError) as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="EPANET validation returned an invalid response",
) from exc
if validation_payload.get("simulation_result") != "successful":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="EPANET model validation failed",
)
try:
infrastructure = await run_in_threadpool(
_provision_from_inp_sync,
content,
code=normalized_code,
workspace=workspace,
)
except ProjectProvisioningError as exc:
if isinstance(exc.cause, ValueError):
response_status = status.HTTP_409_CONFLICT
elif exc.stage == "preflight":
response_status = status.HTTP_503_SERVICE_UNAVAILABLE
else:
response_status = status.HTTP_500_INTERNAL_SERVER_ERROR
raise HTTPException(
status_code=response_status,
detail={
"stage": exc.stage,
"message": str(exc.cause),
"cleanup_errors": exc.cleanup_errors,
},
) from exc
map_extent = {"bbox": list(infrastructure.map_bbox), "zoom": map_zoom}
try:
project = await metadata_repo.create_provisioned_project(
name=name,
code=normalized_code,
description=description,
gs_workspace=workspace,
map_extent=map_extent,
creator_user_id=current_user.id,
business_dsn=infrastructure.business_dsn,
timescale_dsn=infrastructure.timescale_dsn,
pool_min_size=1,
pool_max_size=4,
)
except Exception as exc:
await metadata_repo.session.rollback()
cleanup_errors = await run_in_threadpool(infrastructure.cleanup)
if isinstance(exc, IntegrityError):
response_status = status.HTTP_409_CONFLICT
detail = "Project code or workspace conflicts with an existing project"
else:
response_status = status.HTTP_503_SERVICE_UNAVAILABLE
detail = f"Metadata database error: {exc}"
if cleanup_errors:
detail = f"{detail}; cleanup failures: {', '.join(cleanup_errors)}"
raise HTTPException(status_code=response_status, detail=detail) from exc
await log_audit_event(
action=AuditAction.CREATE,
user_id=current_user.id,
project_id=project.id,
resource_type="project_provision",
resource_id=str(project.id),
request_data={
"name": name,
"code": normalized_code,
"filename": filename,
"gs_workspace": workspace,
"layers": list(infrastructure.layers),
},
ip_address=request.client.host if request.client else None,
request_method=request.method,
request_path=request.url.path,
response_status=status.HTTP_201_CREATED,
session=metadata_repo.session,
)
return ProjectProvisionResponse(
project=AdminProjectResponse(
project_id=project.id,
name=project.name,
code=project.code,
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
),
business_database=normalized_code,
model_template_database=infrastructure.model_template,
timescale_database=normalized_code,
geoserver_workspace=workspace,
geoserver_layers=list(infrastructure.layers),
)
@router.post(
"/admin/projects/{project_id}/model-imports",
summary="导入桌面端水力模型",