from typing import List from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status from sqlalchemy import text from sqlalchemy.engine.url import make_url from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.ext.asyncio import create_async_engine from app.auth.metadata_dependencies import ( get_current_metadata_admin, get_metadata_repository, ) from app.core.audit import AuditAction, log_audit_event from app.domain.schemas.admin_metadata import ( AdminProjectCreateRequest, AdminProjectResponse, AdminProjectUpdateRequest, MetadataUsersBatchSyncRequest, MetadataUserResponse, MetadataUserSyncRequest, MetadataUserSyncResult, MetadataUserUpdateRequest, ProjectDatabaseHealthResponse, ProjectDatabaseHealthRequest, ProjectDatabaseResponse, ProjectDatabaseUpsertRequest, ProjectDbRole, ProjectMemberCreateRequest, ProjectMemberResponse, ProjectMemberUpdateRequest, ) from app.infra.db.metadb import models from app.infra.db.metadb.repositories.metadata_repository import ( MetadataRepository, ProjectDbRouting, ) router = APIRouter() def _project_response(project: models.Project) -> AdminProjectResponse: return 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, ) def _project_database_response( record: models.ProjectDatabase, ) -> ProjectDatabaseResponse: return ProjectDatabaseResponse( id=record.id, project_id=record.project_id, db_role=record.db_role, db_type=record.db_type, pool_min_size=record.pool_min_size, pool_max_size=record.pool_max_size, has_dsn=bool(record.dsn_encrypted), ) def _database_audit_payload(payload: ProjectDatabaseUpsertRequest) -> dict: return { "db_role": payload.db_role, "db_type": _db_type_for_role(payload.db_role), "pool_min_size": payload.pool_min_size, "pool_max_size": payload.pool_max_size, "dsn_updated": payload.dsn is not None, } def _to_async_sqlalchemy_url(dsn: str) -> str: parsed = make_url(dsn) if parsed.drivername in {"postgresql", "postgres"}: parsed = parsed.set(drivername="postgresql+psycopg") return parsed.render_as_string(hide_password=False) def _db_type_for_role(db_role: str) -> str: if db_role == "iot_data": return "timescaledb" return "postgresql" def _status_for_config_value_error(exc: ValueError) -> int: if "DATABASE_ENCRYPTION_KEY" in str(exc): return status.HTTP_503_SERVICE_UNAVAILABLE return status.HTTP_400_BAD_REQUEST async def _check_database_connection(routing: ProjectDbRouting) -> None: engine = create_async_engine( _to_async_sqlalchemy_url(routing.dsn), pool_size=1, max_overflow=0, pool_pre_ping=True, ) try: async with engine.connect() as conn: await conn.execute(text("SELECT 1")) finally: await engine.dispose() def _database_health_error_detail(exc: Exception) -> str: message = str(exc) lower_message = message.lower() if "password authentication failed" in lower_message: return "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。" if "connection refused" in lower_message: return "连通性测试失败:目标主机或端口拒绝连接,请检查地址、端口和服务状态。" if "timeout" in lower_message or "timed out" in lower_message: return "连通性测试失败:连接超时,请检查网络、防火墙和数据库服务状态。" if "could not translate host name" in lower_message or "name or service not known" in lower_message: return "连通性测试失败:数据库主机名无法解析,请检查 DSN 中的主机地址。" first_line = message.splitlines()[0] if message else exc.__class__.__name__ return f"连通性测试失败:{first_line}" async def _upsert_and_audit_metadata_user( payload: MetadataUserSyncRequest, *, current_user, metadata_repo: MetadataRepository, response_status: int, ) -> MetadataUserResponse: user = await metadata_repo.upsert_user_from_keycloak( keycloak_id=payload.keycloak_id, username=payload.username, email=str(payload.email), role=payload.role, is_active=payload.is_active, ) await log_audit_event( action=AuditAction.UPDATE, user_id=current_user.id, resource_type="metadata_user", resource_id=str(user.id), request_data=payload.model_dump(mode="json"), response_status=response_status, session=metadata_repo.session, ) return MetadataUserResponse.model_validate(user) @router.get("/admin/users/me", response_model=MetadataUserResponse) async def get_metadata_admin_me( current_user=Depends(get_current_metadata_admin), ) -> MetadataUserResponse: return MetadataUserResponse.model_validate(current_user) @router.post("/admin/user-syncs", response_model=MetadataUserResponse) async def sync_metadata_user( payload: MetadataUserSyncRequest, current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> MetadataUserResponse: try: return await _upsert_and_audit_metadata_user( payload, current_user=current_user, metadata_repo=metadata_repo, response_status=status.HTTP_200_OK, ) except IntegrityError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="User keycloak_id, username, or email conflicts with an existing user", ) from exc except SQLAlchemyError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Metadata database error: {exc}", ) from exc @router.post("/admin/user-syncs/batches", response_model=List[MetadataUserSyncResult]) async def sync_metadata_users_batch( payload: MetadataUsersBatchSyncRequest, current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> List[MetadataUserSyncResult]: results: list[MetadataUserSyncResult] = [] for item in payload.users: try: user = await _upsert_and_audit_metadata_user( item, current_user=current_user, metadata_repo=metadata_repo, response_status=status.HTTP_200_OK, ) except IntegrityError as exc: results.append( MetadataUserSyncResult( keycloak_id=item.keycloak_id, success=False, error="User keycloak_id, username, or email conflicts with an existing user", ) ) await metadata_repo.session.rollback() except SQLAlchemyError as exc: results.append( MetadataUserSyncResult( keycloak_id=item.keycloak_id, success=False, error=f"Metadata database error: {exc}", ) ) await metadata_repo.session.rollback() else: results.append( MetadataUserSyncResult( keycloak_id=item.keycloak_id, success=True, user=user, ) ) return results @router.get("/admin/users", response_model=List[MetadataUserResponse]) async def list_metadata_users( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> List[MetadataUserResponse]: users = await metadata_repo.list_users(skip=skip, limit=limit) return [MetadataUserResponse.model_validate(user) for user in users] @router.get("/admin/projects", response_model=List[AdminProjectResponse]) async def list_admin_projects( current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> List[AdminProjectResponse]: projects = await metadata_repo.list_project_records() return [_project_response(project) for project in projects] @router.post( "/admin/projects", response_model=AdminProjectResponse, status_code=status.HTTP_201_CREATED, ) async def create_admin_project( payload: AdminProjectCreateRequest, current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> AdminProjectResponse: try: project = await metadata_repo.create_project( name=payload.name, code=payload.code, description=payload.description, gs_workspace=payload.gs_workspace, map_extent=payload.map_extent, status=payload.status, creator_user_id=current_user.id, ) except IntegrityError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Project code or workspace conflicts with an existing project", ) from exc except SQLAlchemyError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Metadata database error: {exc}", ) from exc await log_audit_event( action=AuditAction.CREATE, user_id=current_user.id, project_id=project.id, resource_type="project", resource_id=str(project.id), request_data=payload.model_dump(mode="json"), response_status=status.HTTP_201_CREATED, session=metadata_repo.session, ) return _project_response(project) @router.patch( "/admin/projects/{project_id}", response_model=AdminProjectResponse, ) async def update_admin_project( payload: AdminProjectUpdateRequest, project_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> AdminProjectResponse: updates = payload.model_dump(mode="json", exclude_unset=True) try: project = await metadata_repo.update_project(project_id, updates=updates) except IntegrityError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Project code or workspace conflicts with an existing project", ) from exc except SQLAlchemyError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Metadata database error: {exc}", ) from exc if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") await log_audit_event( action=AuditAction.UPDATE, user_id=current_user.id, project_id=project.id, resource_type="project", resource_id=str(project.id), request_data=updates, response_status=status.HTTP_200_OK, session=metadata_repo.session, ) return _project_response(project) @router.get( "/admin/projects/{project_id}/databases", response_model=List[ProjectDatabaseResponse], ) async def list_project_databases( project_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> List[ProjectDatabaseResponse]: project = await metadata_repo.get_project_by_id(project_id) if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") records = await metadata_repo.list_project_databases(project_id) return [_project_database_response(record) for record in records] @router.put( "/admin/projects/{project_id}/databases", response_model=ProjectDatabaseResponse, ) async def upsert_project_database( payload: ProjectDatabaseUpsertRequest, project_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> ProjectDatabaseResponse: project = await metadata_repo.get_project_by_id(project_id) if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") try: routing = ( ProjectDbRouting( project_id=project_id, db_role=payload.db_role, db_type=_db_type_for_role(payload.db_role), dsn=payload.dsn, pool_min_size=payload.pool_min_size, pool_max_size=payload.pool_max_size, ) if payload.dsn else await metadata_repo.get_project_db_routing(project_id, payload.db_role) ) if routing is None: raise ValueError("dsn is required when creating project database config") await _check_database_connection(routing) except ValueError as exc: raise HTTPException( status_code=_status_for_config_value_error(exc), detail=str(exc), ) from exc except Exception as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=_database_health_error_detail(exc), ) from exc try: record = await metadata_repo.upsert_project_database_config( project_id, db_role=payload.db_role, db_type=_db_type_for_role(payload.db_role), dsn=payload.dsn, pool_min_size=payload.pool_min_size, pool_max_size=payload.pool_max_size, ) except IntegrityError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Project database role conflicts with an existing config", ) from exc except SQLAlchemyError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Metadata database error: {exc}", ) from exc await log_audit_event( action=AuditAction.CONFIG_CHANGE, user_id=current_user.id, project_id=project_id, resource_type="project_database", resource_id=payload.db_role, request_data=_database_audit_payload(payload), response_status=status.HTTP_200_OK, session=metadata_repo.session, ) return _project_database_response(record) @router.delete( "/admin/projects/{project_id}/databases/{db_role}", status_code=status.HTTP_204_NO_CONTENT, ) async def delete_project_database( project_id: UUID = Path(...), db_role: ProjectDbRole = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> None: removed = await metadata_repo.delete_project_database_config(project_id, db_role) if not removed: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project database config not found", ) await log_audit_event( action=AuditAction.CONFIG_CHANGE, user_id=current_user.id, project_id=project_id, resource_type="project_database", resource_id=db_role, request_data={"deleted": True}, response_status=status.HTTP_204_NO_CONTENT, session=metadata_repo.session, ) @router.post( "/admin/projects/{project_id}/databases/{db_role}/health-checks", response_model=ProjectDatabaseHealthResponse, ) async def check_project_database_health( response: Response, project_id: UUID = Path(...), db_role: ProjectDbRole = Path(...), payload: ProjectDatabaseHealthRequest | None = None, current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> ProjectDatabaseHealthResponse: dsn_to_test = payload.dsn if payload and payload.dsn else None if dsn_to_test: routing = ProjectDbRouting( project_id=project_id, db_role=db_role, db_type=_db_type_for_role(db_role), dsn=dsn_to_test, pool_min_size=1, pool_max_size=1, ) else: try: routing = await metadata_repo.get_project_db_routing(project_id, db_role) except ValueError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Project database routing DSN is invalid: {exc}", ) from exc if routing is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project database config not found", ) try: await _check_database_connection(routing) except Exception as exc: # health endpoint should return diagnostic status response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return ProjectDatabaseHealthResponse( project_id=project_id, db_role=db_role, db_type=routing.db_type, ok=False, detail=_database_health_error_detail(exc), ) return ProjectDatabaseHealthResponse( project_id=project_id, db_role=db_role, db_type=routing.db_type, ok=True, detail="连通性测试通过", ) @router.get("/admin/users/{user_id}", response_model=MetadataUserResponse) async def get_metadata_user( user_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> MetadataUserResponse: user = await metadata_repo.get_user_by_id(user_id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") return MetadataUserResponse.model_validate(user) @router.patch("/admin/users/{user_id}", response_model=MetadataUserResponse) async def update_metadata_user( payload: MetadataUserUpdateRequest, user_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> MetadataUserResponse: updates = payload.model_dump(mode="json", exclude_unset=True) if user_id == current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Users cannot modify themselves", ) user = await metadata_repo.update_user_admin( user_id, updates=updates, ) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") await log_audit_event( action=AuditAction.UPDATE, user_id=current_user.id, resource_type="metadata_user", resource_id=str(user.id), request_data=updates, response_status=status.HTTP_200_OK, session=metadata_repo.session, ) return MetadataUserResponse.model_validate(user) @router.get( "/admin/projects/{project_id}/members", response_model=List[ProjectMemberResponse], ) async def list_project_members( project_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> List[ProjectMemberResponse]: project = await metadata_repo.get_project_by_id(project_id) if project is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) members = await metadata_repo.list_project_members(project_id) return [ProjectMemberResponse(**member.__dict__) for member in members] @router.post( "/admin/projects/{project_id}/members", response_model=ProjectMemberResponse, status_code=status.HTTP_201_CREATED, ) async def add_project_member( payload: ProjectMemberCreateRequest, project_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> ProjectMemberResponse: if payload.user_id == current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Users cannot modify their own project membership", ) project = await metadata_repo.get_project_by_id(project_id) if project is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) user = await metadata_repo.get_user_by_id(payload.user_id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") existing = await metadata_repo.get_project_membership(project_id, payload.user_id) if existing is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="User is already a project member", ) membership = await metadata_repo.add_project_member( project_id, payload.user_id, payload.project_role ) await log_audit_event( action=AuditAction.PERMISSION_CHANGE, user_id=current_user.id, project_id=project_id, resource_type="project_member", resource_id=str(payload.user_id), request_data=payload.model_dump(mode="json"), response_status=status.HTTP_201_CREATED, session=metadata_repo.session, ) return ProjectMemberResponse( id=membership.id, user_id=membership.user_id, project_id=membership.project_id, project_role=membership.project_role, username=user.username, email=user.email, is_active=user.is_active, ) @router.patch( "/admin/projects/{project_id}/members/{user_id}", response_model=ProjectMemberResponse, ) async def update_project_member( payload: ProjectMemberUpdateRequest, project_id: UUID = Path(...), user_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> ProjectMemberResponse: if user_id == current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Users cannot modify their own project membership", ) user = await metadata_repo.get_user_by_id(user_id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") membership = await metadata_repo.update_project_member_role( project_id, user_id, payload.project_role ) if membership is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found" ) await log_audit_event( action=AuditAction.PERMISSION_CHANGE, user_id=current_user.id, project_id=project_id, resource_type="project_member", resource_id=str(user_id), request_data=payload.model_dump(mode="json"), response_status=status.HTTP_200_OK, session=metadata_repo.session, ) return ProjectMemberResponse( id=membership.id, user_id=membership.user_id, project_id=membership.project_id, project_role=membership.project_role, username=user.username, email=user.email, is_active=user.is_active, ) @router.delete("/admin/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_project_member( project_id: UUID = Path(...), user_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> None: if user_id == current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Users cannot modify their own project membership", ) removed = await metadata_repo.remove_project_member(project_id, user_id) if not removed: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found" ) await log_audit_event( action=AuditAction.PERMISSION_CHANGE, user_id=current_user.id, project_id=project_id, resource_type="project_member", resource_id=str(user_id), response_status=status.HTTP_204_NO_CONTENT, session=metadata_repo.session, )