fix(sensor-placement): enforce project write boundaries

Bind every scheme request to ProjectContext, keep viewer access read-only, reject concurrent optimization jobs without blocking worker threads, and cap export/update payload sizes. Run optimization and workbook work off the event loop.
This commit is contained in:
2026-07-30 16:21:38 +08:00
parent ddbb50173c
commit 3fbb17bb30
4 changed files with 152 additions and 13 deletions
+10 -1
View File
@@ -7,6 +7,7 @@ from app.algorithms.sensor import kmeans as kmeans_sensor
from app.algorithms.sensor import sensitivity
from app.native.wndb.s42_sensor_placement import create_sensor_placement
from app.services.sensor_placement import (
SensorPlacementConflictError,
SensorPlacementValidationError,
validate_sensor_placement_nodes,
)
@@ -31,7 +32,15 @@ def _sensor_inp_lock(name: str):
inp_path.parent.mkdir(parents=True, exist_ok=True)
lock_path = inp_path.with_suffix(".sensor.lock")
with lock_path.open("w", encoding="utf-8") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
fcntl.flock(
lock_file.fileno(),
fcntl.LOCK_EX | fcntl.LOCK_NB,
)
except BlockingIOError as exc:
raise SensorPlacementConflictError(
"当前项目已有监测点优化任务正在运行,请稍后重试"
) from exc
try:
yield inp_path
finally:
+46 -5
View File
@@ -41,6 +41,25 @@ def _project_network(network: str, project_context: ProjectContext) -> str:
return project_context.project_code
def _can_modify_project(project_context: ProjectContext, current_user: Any) -> bool:
return bool(
project_context.project_role in {"owner", "admin", "member"}
or getattr(current_user, "role", None) == "admin"
or getattr(current_user, "is_superuser", False)
)
def _require_project_write(
project_context: ProjectContext,
current_user: Any,
) -> None:
if not _can_modify_project(project_context, current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前项目角色为只读,不能修改监测点方案",
)
def _service_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, SensorPlacementNotFoundError):
return HTTPException(
@@ -62,12 +81,16 @@ def _get_scheme_response(
network: str,
scheme_id: int,
current_user: Any,
project_context: ProjectContext,
) -> dict[str, Any]:
try:
scheme = get_sensor_placement_scheme(network, scheme_id)
return {
**scheme,
"can_edit": can_edit_sensor_placement(current_user, scheme),
"can_edit": (
_can_modify_project(project_context, current_user)
and can_edit_sensor_placement(current_user, scheme)
),
}
except (
SensorPlacementNotFoundError,
@@ -87,6 +110,7 @@ async def optimize_sensor_placement_scheme(
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(payload.network, project_context)
_require_project_write(project_context, current_user)
optimizer = (
pressure_sensor_placement_sensitivity
if payload.method == "sensitivity"
@@ -103,7 +127,11 @@ async def optimize_sensor_placement_scheme(
)
scheme = get_sensor_placement_scheme(network, int(created["id"]))
return {**scheme, "can_edit": True}
except (SensorPlacementValidationError, ValueError) as exc:
except (
SensorPlacementConflictError,
SensorPlacementValidationError,
ValueError,
) as exc:
raise _service_http_error(exc) from exc
except Exception as exc:
logger.exception("Sensor placement optimization failed")
@@ -128,6 +156,7 @@ async def get_sensor_placement_scheme_detail(
_project_network(network, project_context),
scheme_id,
current_user,
project_context,
)
@@ -144,7 +173,13 @@ async def overwrite_sensor_placement_scheme(
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(network, project_context)
scheme = _get_scheme_response(network, scheme_id, current_user)
_require_project_write(project_context, current_user)
scheme = _get_scheme_response(
network,
scheme_id,
current_user,
project_context,
)
if not scheme["can_edit"]:
raise HTTPException(status_code=403, detail="无权修改该监测点方案")
@@ -176,7 +211,12 @@ async def export_sensor_placement_excel(
current_user=Depends(get_current_metadata_user),
) -> StreamingResponse:
network = _project_network(network, project_context)
scheme = _get_scheme_response(network, scheme_id, current_user)
scheme = _get_scheme_response(
network,
scheme_id,
current_user,
project_context,
)
if (
payload.sensor_location != scheme["sensor_location"]
and not scheme["can_edit"]
@@ -184,7 +224,8 @@ async def export_sensor_placement_excel(
raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿")
try:
workbook = build_sensor_placement_workbook(
workbook = await run_in_threadpool(
build_sensor_placement_workbook,
network=network,
scheme=scheme,
sensor_location=payload.sensor_location,
+11 -4
View File
@@ -39,8 +39,12 @@ class SensorPlacementOptimizeRequest(BaseModel):
class SensorPlacementUpdateRequest(BaseModel):
expected_sensor_location: list[str] = Field(..., min_length=1)
sensor_location: list[str] = Field(..., min_length=1)
expected_sensor_location: list[str] = Field(
...,
min_length=1,
max_length=200,
)
sensor_location: list[str] = Field(..., min_length=1, max_length=200)
@field_validator("expected_sensor_location", "sensor_location")
@classmethod
@@ -49,8 +53,11 @@ class SensorPlacementUpdateRequest(BaseModel):
class SensorPlacementExportRequest(BaseModel):
sensor_location: list[str] = Field(..., min_length=1)
adjustment_status: dict[str, AdjustmentStatus] = Field(default_factory=dict)
sensor_location: list[str] = Field(..., min_length=1, max_length=200)
adjustment_status: dict[str, AdjustmentStatus] = Field(
default_factory=dict,
max_length=200,
)
@field_validator("sensor_location")
@classmethod