feat(server): add project RBAC and guarded workflows
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
File,
|
||||
Header,
|
||||
HTTPException,
|
||||
Path as ApiPath,
|
||||
Query,
|
||||
Request,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
|
||||
from app.auth.metadata_dependencies import (
|
||||
get_current_metadata_admin,
|
||||
get_metadata_repository,
|
||||
)
|
||||
from app.core.audit import AuditAction, log_audit_event
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.services.network_import import network_update
|
||||
from app.services.tjnetwork import ChangeSet, import_inp, run_inp
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_INP_FILE_BYTES = 50 * 1024 * 1024
|
||||
INP_SECTIONS = ("[TITLE]", "[JUNCTIONS]", "[RESERVOIRS]", "[TANKS]", "[PIPES]")
|
||||
|
||||
|
||||
async def _get_active_project(project_id: UUID, metadata_repo: MetadataRepository):
|
||||
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",
|
||||
)
|
||||
if project.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Project is not active",
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
def _validate_inp_bytes(content: bytes, filename: str) -> str:
|
||||
if Path(filename).suffix.lower() != ".inp":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only .inp model files are accepted",
|
||||
)
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="INP file is empty",
|
||||
)
|
||||
if len(content) > MAX_INP_FILE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="INP file exceeds the 50 MiB limit",
|
||||
)
|
||||
for encoding in ("utf-8-sig", "gb18030"):
|
||||
try:
|
||||
text = content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="INP file encoding is not supported",
|
||||
)
|
||||
upper_text = text.upper()
|
||||
if not any(section in upper_text for section in INP_SECTIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid INP file structure",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
async def _read_upload(file: UploadFile) -> tuple[bytes, str]:
|
||||
filename = Path(file.filename or "").name
|
||||
content = await file.read(MAX_INP_FILE_BYTES + 1)
|
||||
_validate_inp_bytes(content, filename)
|
||||
return content, filename
|
||||
|
||||
|
||||
async def _audit_model_change(
|
||||
*,
|
||||
request: Request,
|
||||
current_user,
|
||||
metadata_repo: MetadataRepository,
|
||||
project_id: UUID,
|
||||
action: str,
|
||||
) -> None:
|
||||
await log_audit_event(
|
||||
action=AuditAction.UPDATE,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
resource_type="hydraulic_model",
|
||||
resource_id=action,
|
||||
request_data={"operation": action},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
request_method=request.method,
|
||||
request_path=request.url.path,
|
||||
response_status=status.HTTP_200_OK,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
|
||||
|
||||
async def _run_uploaded_inp(content: bytes) -> str:
|
||||
target_dir = Path("inp")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
model_name = f"admin_model_{uuid4().hex}"
|
||||
target_path = target_dir / f"{model_name}.inp"
|
||||
target_path.write_bytes(content)
|
||||
return run_inp(model_name)
|
||||
|
||||
|
||||
async def _update_from_inp(content: bytes) -> None:
|
||||
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)
|
||||
network_update(str(temp_path))
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def _apply_model_update(content: bytes) -> None:
|
||||
try:
|
||||
await _update_from_inp(content)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"数据库操作失败: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/projects/{project_id}/model/import",
|
||||
summary="导入桌面端水力模型",
|
||||
)
|
||||
async def import_project_model(
|
||||
request: Request,
|
||||
project_id: UUID = ApiPath(...),
|
||||
file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> dict:
|
||||
project = await _get_active_project(project_id, metadata_repo)
|
||||
content, filename = await _read_upload(file)
|
||||
result = await _run_uploaded_inp(content)
|
||||
await _audit_model_change(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
project_id=project.id,
|
||||
action="import",
|
||||
)
|
||||
return {"project_id": str(project.id), "filename": filename, "result": result}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/projects/{project_id}/model/update",
|
||||
summary="更新桌面端水力模型",
|
||||
)
|
||||
async def update_project_model(
|
||||
request: Request,
|
||||
project_id: UUID = ApiPath(...),
|
||||
file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> dict:
|
||||
project = await _get_active_project(project_id, metadata_repo)
|
||||
content, filename = await _read_upload(file)
|
||||
await _apply_model_update(content)
|
||||
await _audit_model_change(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
project_id=project.id,
|
||||
action="update",
|
||||
)
|
||||
return {"project_id": str(project.id), "filename": filename, "updated": True}
|
||||
|
||||
|
||||
@router.post("/importinp/", deprecated=True)
|
||||
async def legacy_import_inp(
|
||||
request: Request,
|
||||
network: str = Query(...),
|
||||
x_project_id: UUID = Header(..., alias="X-Project-Id"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
):
|
||||
project = await _get_active_project(x_project_id, metadata_repo)
|
||||
if network != project.code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Project scope denied",
|
||||
)
|
||||
payload = await request.json()
|
||||
inp_text = payload.get("inp") if isinstance(payload, dict) else None
|
||||
if not isinstance(inp_text, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing INP content",
|
||||
)
|
||||
_validate_inp_bytes(inp_text.encode("utf-8"), "model.inp")
|
||||
result = import_inp(network, ChangeSet({"inp": inp_text}))
|
||||
await _audit_model_change(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
project_id=project.id,
|
||||
action="import",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/uploadinp/", deprecated=True)
|
||||
async def legacy_upload_inp(
|
||||
request: Request,
|
||||
content: bytes = Body(...),
|
||||
name: str = Query(...),
|
||||
x_project_id: UUID = Header(..., alias="X-Project-Id"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> bool:
|
||||
project = await _get_active_project(x_project_id, metadata_repo)
|
||||
safe_name = Path(name).name
|
||||
if safe_name != name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid INP file name",
|
||||
)
|
||||
_validate_inp_bytes(content, safe_name)
|
||||
target_dir = Path("data")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / safe_name).write_bytes(content)
|
||||
await _audit_model_change(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
project_id=project.id,
|
||||
action="upload",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/network_project/", deprecated=True)
|
||||
async def legacy_network_project(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
x_project_id: UUID = Header(..., alias="X-Project-Id"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
):
|
||||
project = await _get_active_project(x_project_id, metadata_repo)
|
||||
content, _ = await _read_upload(file)
|
||||
result = await _run_uploaded_inp(content)
|
||||
await _audit_model_change(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
project_id=project.id,
|
||||
action="import",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/network_update/", deprecated=True)
|
||||
async def legacy_network_update(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
x_project_id: UUID = Header(..., alias="X-Project-Id"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> str:
|
||||
project = await _get_active_project(x_project_id, metadata_repo)
|
||||
content, _ = await _read_upload(file)
|
||||
await _apply_model_update(content)
|
||||
await _audit_model_change(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
project_id=project.id,
|
||||
action="update",
|
||||
)
|
||||
return json.dumps({"message": "管网更新成功"})
|
||||
Reference in New Issue
Block a user