feat(auth)!: migrate customer metadata auth
Remove local auth and user-management endpoints in favor of Keycloak-backed metadata users, project context, admin metadata APIs, and agent auth context.
This commit is contained in:
@@ -0,0 +1,695 @@
|
||||
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("/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("/users/sync", 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("/users/sync/batch", 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("/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("/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(
|
||||
"/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,
|
||||
)
|
||||
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(
|
||||
"/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(
|
||||
"/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(
|
||||
"/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(
|
||||
"/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(
|
||||
"/projects/{project_id}/databases/{db_role}/health",
|
||||
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("/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("/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(
|
||||
"/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(
|
||||
"/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(
|
||||
"/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("/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,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_payload
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.auth.project_dependencies import (
|
||||
ProjectContext,
|
||||
get_project_context,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AgentAuthContextResponse(BaseModel):
|
||||
user_id: str
|
||||
keycloak_sub: str
|
||||
username: str
|
||||
role: str
|
||||
is_superuser: bool
|
||||
project_id: str
|
||||
network: str
|
||||
project_role: str
|
||||
token_expires_at: str | None = None
|
||||
|
||||
|
||||
@router.get("/agent/auth/context", response_model=AgentAuthContextResponse)
|
||||
async def get_agent_auth_context(
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
keycloak_payload: dict = Depends(get_current_keycloak_payload),
|
||||
) -> AgentAuthContextResponse:
|
||||
exp = keycloak_payload.get("exp")
|
||||
token_expires_at = (
|
||||
datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
|
||||
if isinstance(exp, int)
|
||||
else None
|
||||
)
|
||||
return AgentAuthContextResponse(
|
||||
user_id=str(current_user.id),
|
||||
keycloak_sub=str(current_user.keycloak_id),
|
||||
username=current_user.username,
|
||||
role=current_user.role,
|
||||
is_superuser=current_user.is_superuser,
|
||||
project_id=str(ctx.project_id),
|
||||
network=ctx.project_code,
|
||||
project_role=ctx.project_role,
|
||||
token_expires_at=token_expires_at,
|
||||
)
|
||||
@@ -1,190 +0,0 @@
|
||||
from typing import Annotated
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
||||
from app.domain.schemas.user import UserCreate, UserResponse, UserLogin, Token
|
||||
from app.infra.db.metadb.repositories.user_repository import UserRepository
|
||||
from app.auth.dependencies import get_user_repository, get_current_active_user
|
||||
from app.domain.schemas.user import UserInDB
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def register(
|
||||
user_data: UserCreate, user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> UserResponse:
|
||||
"""
|
||||
用户注册
|
||||
|
||||
创建新用户账号
|
||||
"""
|
||||
# 检查用户名和邮箱是否已存在
|
||||
if await user_repo.user_exists(username=user_data.username):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
if await user_repo.user_exists(email=user_data.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||
)
|
||||
|
||||
# 创建用户
|
||||
try:
|
||||
user = await user_repo.create_user(user_data)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create user",
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during user registration: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Registration failed",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> Token:
|
||||
"""
|
||||
用户登录(OAuth2 标准格式)
|
||||
|
||||
返回 JWT Access Token 和 Refresh Token
|
||||
"""
|
||||
# 验证用户(支持用户名或邮箱登录)
|
||||
user = await user_repo.get_user_by_username(form_data.username)
|
||||
if not user:
|
||||
# 尝试用邮箱登录
|
||||
user = await user_repo.get_user_by_email(form_data.username)
|
||||
|
||||
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user account"
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(subject=user.username)
|
||||
refresh_token = create_refresh_token(subject=user.username)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login/simple", response_model=Token)
|
||||
async def login_simple(
|
||||
username: str,
|
||||
password: str,
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> Token:
|
||||
"""
|
||||
简化版登录接口(保持向后兼容)
|
||||
|
||||
直接使用 username 和 password 参数
|
||||
"""
|
||||
# 验证用户
|
||||
user = await user_repo.get_user_by_username(username)
|
||||
if not user:
|
||||
user = await user_repo.get_user_by_email(username)
|
||||
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user account"
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(subject=user.username)
|
||||
refresh_token = create_refresh_token(subject=user.username)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(
|
||||
current_user: UserInDB = Depends(get_current_active_user),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
获取当前登录用户信息
|
||||
"""
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
refresh_token: str, user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> Token:
|
||||
"""
|
||||
刷新 Access Token
|
||||
|
||||
使用 Refresh Token 获取新的 Access Token
|
||||
"""
|
||||
from jose import jwt, JWTError
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate refresh token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
token_type: str = payload.get("type")
|
||||
|
||||
if username is None or token_type != "refresh":
|
||||
raise credentials_exception
|
||||
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
# 验证用户仍然存在且激活
|
||||
user = await user_repo.get_user_by_username(username)
|
||||
if not user or not user.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
# 生成新的 Access Token
|
||||
new_access_token = create_access_token(subject=user.username)
|
||||
|
||||
return Token(
|
||||
access_token=new_access_token,
|
||||
refresh_token=refresh_token, # 保持原 refresh token
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
@@ -10,7 +10,7 @@ from app.services.tjnetwork import (
|
||||
get_network_node_coords,
|
||||
get_node_coord,
|
||||
)
|
||||
from app.auth.dependencies import get_current_user as verify_token
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.infra.cache.redis_client import redis_client, encode_datetime, decode_datetime
|
||||
import msgpack
|
||||
|
||||
@@ -64,7 +64,7 @@ async def fastapi_get_network_in_extent(
|
||||
|
||||
@router.get(
|
||||
"/getnetworkgeometries/",
|
||||
dependencies=[Depends(verify_token)],
|
||||
dependencies=[Depends(get_current_metadata_user)],
|
||||
summary="获取完整网络几何信息",
|
||||
description="获取整个水网的所有节点、管线和SCADA点的几何信息(需要身份验证)"
|
||||
)
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
"""
|
||||
用户管理 API 接口
|
||||
|
||||
演示权限控制的使用
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
|
||||
from app.domain.schemas.user import UserResponse, UserUpdate, UserCreate
|
||||
from app.domain.models.role import UserRole
|
||||
from app.domain.schemas.user import UserInDB
|
||||
from app.infra.db.metadb.repositories.user_repository import UserRepository
|
||||
from app.auth.dependencies import get_user_repository, get_current_active_user
|
||||
from app.auth.permissions import get_current_admin, require_role, check_resource_owner
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
summary="列出所有用户",
|
||||
description="获取用户列表(仅管理员)",
|
||||
response_model=List[UserResponse],
|
||||
)
|
||||
async def list_users(
|
||||
skip: int = Query(0, ge=0, description="跳过的用户数"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="返回的最大用户数"),
|
||||
current_user: UserInDB = Depends(require_role(UserRole.ADMIN)),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> List[UserResponse]:
|
||||
"""
|
||||
获取用户列表
|
||||
|
||||
获取系统中所有的用户信息(需要管理员权限)
|
||||
"""
|
||||
users = await user_repo.get_all_users(skip=skip, limit=limit)
|
||||
return [UserResponse.model_validate(user) for user in users]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}",
|
||||
summary="获取用户详情",
|
||||
description="获取指定用户的详细信息",
|
||||
response_model=UserResponse,
|
||||
)
|
||||
async def get_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
current_user: UserInDB = Depends(get_current_active_user),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
获取用户详情
|
||||
|
||||
管理员可查看所有用户,普通用户只能查看自己
|
||||
"""
|
||||
# 检查权限
|
||||
if not check_resource_owner(user_id, current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have permission to view this user",
|
||||
)
|
||||
|
||||
user = await user_repo.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{user_id}",
|
||||
summary="更新用户信息",
|
||||
description="更新指定用户的信息",
|
||||
response_model=UserResponse,
|
||||
)
|
||||
async def update_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
user_update: UserUpdate = None,
|
||||
current_user: UserInDB = Depends(get_current_active_user),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
管理员可更新所有用户,普通用户只能更新自己(且不能修改角色)
|
||||
"""
|
||||
# 检查用户是否存在
|
||||
target_user = await user_repo.get_user_by_id(user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
# 权限检查
|
||||
is_owner = current_user.id == user_id
|
||||
is_admin = UserRole(current_user.role).has_permission(UserRole.ADMIN)
|
||||
|
||||
if not is_owner and not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have permission to update this user",
|
||||
)
|
||||
|
||||
# 非管理员不能修改角色和激活状态
|
||||
if not is_admin:
|
||||
if user_update.role is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can change user roles",
|
||||
)
|
||||
if user_update.is_active is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can change user active status",
|
||||
)
|
||||
|
||||
# 更新用户
|
||||
updated_user = await user_repo.update_user(user_id, user_update)
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update user",
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", summary="删除用户", description="删除指定用户(仅管理员)")
|
||||
async def delete_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> dict:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
删除指定用户(需要管理员权限,不能删除自己)
|
||||
"""
|
||||
# 不能删除自己
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot delete your own account",
|
||||
)
|
||||
|
||||
success = await user_repo.delete_user(user_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/activate",
|
||||
summary="激活用户",
|
||||
description="激活指定用户账户(仅管理员)",
|
||||
response_model=UserResponse,
|
||||
)
|
||||
async def activate_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
激活用户
|
||||
|
||||
激活指定用户的账户(需要管理员权限)
|
||||
"""
|
||||
user_update = UserUpdate(is_active=True)
|
||||
updated_user = await user_repo.update_user(user_id, user_update)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/deactivate",
|
||||
summary="停用用户",
|
||||
description="停用指定用户账户(仅管理员)",
|
||||
response_model=UserResponse,
|
||||
)
|
||||
async def deactivate_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
停用用户
|
||||
|
||||
停用指定用户的账户(需要管理员权限,不能停用自己)
|
||||
"""
|
||||
# 不能停用自己
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot deactivate your own account",
|
||||
)
|
||||
|
||||
user_update = UserUpdate(is_active=False)
|
||||
updated_user = await user_repo.update_user(user_id, user_update)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import (
|
||||
auth,
|
||||
admin_metadata,
|
||||
agent_auth,
|
||||
project,
|
||||
simulation,
|
||||
scada,
|
||||
@@ -15,7 +16,6 @@ from app.api.v1.endpoints import (
|
||||
leakage,
|
||||
burst_detection,
|
||||
burst_location,
|
||||
user_management, # 新增:用户管理
|
||||
audit, # 新增:审计日志
|
||||
meta,
|
||||
web_search,
|
||||
@@ -54,10 +54,10 @@ from app.api.v1.endpoints.timeseries import (
|
||||
api_router = APIRouter()
|
||||
|
||||
# Core Services
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
|
||||
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
||||
api_router.include_router(
|
||||
user_management.router, prefix="/users", tags=["User Management"]
|
||||
) # 新增
|
||||
admin_metadata.router, prefix="/admin", tags=["Metadata Admin"]
|
||||
)
|
||||
api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增
|
||||
api_router.include_router(meta.router, tags=["Metadata"])
|
||||
api_router.include_router(project.router, tags=["Project"])
|
||||
|
||||
Reference in New Issue
Block a user