feat: add project-scoped operations
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent d320972d57
commit 1d061f6099
73 changed files with 4384 additions and 228 deletions
+2 -2
View File
@@ -911,9 +911,9 @@ class OperationalTaskService:
previous_report is None
or previous_report.get("execution_generation") != task["execution_generation"]
or previous_result.get("stage") != "final"
or any(previous_result.get(key) != _json(command.report).get(key)
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != _json(command.report).get(key)
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
"evidence_refs", "recommendations", "pending_items", "data_gaps"))
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks"))
):
await self._resolve_notifications(
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
+37 -15
View File
@@ -6,6 +6,7 @@ from pydantic import ValidationError
from app.core.config import settings
from app.core.tianditu_logging import install_tianditu_token_filter
from app.domain.schemas.project_map_config import PlaceSearchConfig
from app.domain.schemas.place_search import (
MAX_PLACE_SEARCH_RESULTS,
PlaceSearchItem,
@@ -13,7 +14,6 @@ from app.domain.schemas.place_search import (
PlaceSearchResponse,
)
FENGXIAN_ADMIN_CODE = "156310120"
install_tianditu_token_filter()
@@ -24,11 +24,37 @@ class PlaceSearchError(RuntimeError):
self.detail = detail
class PlaceSearchScopeError(ValueError):
pass
def validate_place_scope(
config: PlaceSearchConfig, *, county_code: str, county: str, province_code: str = "",
) -> None:
admin_code = config.admin_code.removeprefix("156")
province_code = province_code.removeprefix("156")
county_code = county_code.removeprefix("156")
if province_code and province_code[:2] != admin_code[:2]:
raise PlaceSearchScopeError("Result outside configured province")
scope_prefix = admin_code
if admin_code.endswith("0000"):
scope_prefix = admin_code[:2]
elif admin_code.endswith("00"):
scope_prefix = admin_code[:4]
if county_code and not county_code.startswith(scope_prefix):
raise PlaceSearchScopeError("Result outside configured district")
if (len(scope_prefix) == 6 and county and county != config.region) or (not county_code and county != config.region):
raise PlaceSearchScopeError("Missing or mismatched district")
async def search_tianditu_places(
request: PlaceSearchRequest,
*,
config: PlaceSearchConfig,
client: httpx.AsyncClient | None = None,
) -> PlaceSearchResponse:
if not config.enabled:
raise PlaceSearchError(503, "当前项目暂未配置地名搜索")
token = settings.TIANDITU_TOKEN
if not token:
raise PlaceSearchError(503, "地名搜索服务未配置")
@@ -37,7 +63,7 @@ async def search_tianditu_places(
{
"keyWord": request.keyword,
"queryType": 12,
"specify": FENGXIAN_ADMIN_CODE,
"specify": "156" + config.admin_code.removeprefix("156"),
"start": 0,
"count": MAX_PLACE_SEARCH_RESULTS,
"show": 2,
@@ -64,15 +90,15 @@ async def search_tianditu_places(
# Request URLs contain the provider key and must not reach the client.
raise PlaceSearchError(502, "天地图地名搜索服务暂不可用") from None
try:
return _parse_response(response.json())
return _parse_response(response.json(), config)
except (ValueError, TypeError, KeyError, AttributeError, ValidationError):
raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None
def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
def _parse_response(payload: dict[str, Any], config: PlaceSearchConfig) -> PlaceSearchResponse:
code = str(payload.get("status", {}).get("infocode", ""))
if code == "3001":
return PlaceSearchResponse(items=[], total=0)
return PlaceSearchResponse(items=[], total=0, region=config.region)
if code != "1000":
raise PlaceSearchError(502, "天地图地名搜索请求失败,请稍后重试")
@@ -84,15 +110,11 @@ def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
raise ValueError("Invalid search results")
items = []
for raw in raw_items:
admin_code = str(raw.get("provinceCode") or raw.get("adminCode") or "")
if admin_code and not admin_code.startswith(("156310", "310")):
raise ValueError("Result outside Shanghai")
county_code = str(raw.get("countyCode") or (raw.get("adminCode") if result_type == "3" else "") or "")
county = raw.get("county")
if county_code and not county_code.startswith((FENGXIAN_ADMIN_CODE, "310120")):
raise ValueError("Result outside Fengxian")
if county and county != "奉贤区" or not county_code and county != "奉贤区":
raise ValueError("Missing or mismatched district")
validate_place_scope(
config, county_code=str(raw.get("countyCode") or raw.get("adminCode") or ""),
county=str(county or ""), province_code=str(raw.get("provinceCode") or ""),
)
lon, lat = str(raw["lonlat"]).split(",")
name = raw["name"]
if not isinstance(name, str) or not name.strip():
@@ -105,11 +127,11 @@ def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
address=str(raw.get("address") or name),
longitude=float(lon), latitude=float(lat),
category="行政区" if result_type == "3" else category,
district="奉贤区",
district=str(county or config.region),
))
total = int(payload.get("count", 0))
if total < len(items):
raise ValueError("Invalid search total")
return PlaceSearchResponse(
items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS,
items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS, region=config.region,
)
+72
View File
@@ -0,0 +1,72 @@
import json
from typing import Any
import httpx
from app.core.config import settings
from app.domain.schemas.place_search import PlaceSearchItem, ReverseGeocodingRequest
from app.domain.schemas.project_map_config import PlaceSearchConfig
from app.services.place_search import PlaceSearchError, PlaceSearchScopeError, validate_place_scope
async def reverse_geocode_tianditu(
request: ReverseGeocodingRequest,
*,
config: PlaceSearchConfig,
client: httpx.AsyncClient | None = None,
) -> PlaceSearchItem:
if not config.enabled:
raise PlaceSearchError(503, "当前项目暂未配置地名搜索")
if not settings.TIANDITU_TOKEN:
raise PlaceSearchError(503, "地名搜索服务未配置")
params = {
"postStr": json.dumps({"lon": request.longitude, "lat": request.latitude, "ver": 1}),
"type": "geocode",
"tk": settings.TIANDITU_TOKEN,
}
try:
if client is not None:
response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params)
else:
async with httpx.AsyncClient(timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS) as managed:
response = await managed.get(settings.TIANDITU_GEOCODER_URL, params=params)
response.raise_for_status()
except httpx.TimeoutException:
raise PlaceSearchError(504, "地图取点查询超时,请重新取点") from None
except httpx.HTTPError:
raise PlaceSearchError(502, "天地图反编码服务暂不可用,请重新取点") from None
try:
return _parse_reverse_response(response.json(), request, config)
except PlaceSearchScopeError:
raise PlaceSearchError(422, f"取点位置不在当前项目的地名搜索范围内({config.region})") from None
except (ValueError, TypeError, KeyError, AttributeError):
raise PlaceSearchError(502, "天地图返回了无效的反编码结果,请重新取点") from None
def _parse_reverse_response(
payload: dict[str, Any], request: ReverseGeocodingRequest, config: PlaceSearchConfig,
) -> PlaceSearchItem:
if str(payload.get("status")) != "0":
raise PlaceSearchError(502, "天地图反编码请求失败,请重新取点")
result = payload.get("result")
if not result:
raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点")
address = result.get("formatted_address")
if not isinstance(address, str) or not address.strip():
raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点")
component = result.get("addressComponent") or {}
validate_place_scope(
config, county_code=str(component.get("county_code") or ""),
county=str(component.get("county") or ""),
province_code=str(component.get("province_code") or ""),
)
name = component.get("poi") or component.get("road") or address
if not isinstance(name, str) or not name.strip():
raise ValueError("Invalid place name")
# A nearby POI can have a different center. Always retain the clicked point.
return PlaceSearchItem(
id=f"map:{request.longitude:.8f},{request.latitude:.8f}",
name=name.strip(), address=address.strip(), longitude=request.longitude, latitude=request.latitude,
category="附近地名" if component.get("poi") else "地址",
district=str(component.get("county") or config.region),
)
+10 -3
View File
@@ -77,8 +77,9 @@ def burst_analysis(
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
username: str | None = None,
persist_results: bool = True,
_temporary_project: str | None = None,
) -> None:
) -> dict | None:
"""
爆管模拟
:param name: 模型名称,数据库中对应的名字
@@ -92,7 +93,7 @@ def burst_analysis(
:param scheme_name: 方案名称
:return:
"""
if not username:
if persist_results and not username:
raise ValueError("username is required when storing burst analysis scheme")
scheme_detail: dict = {
@@ -193,7 +194,7 @@ def burst_analysis(
# modify_pump_pattern=modify_pump_pattern,
# valve_control=valve_control,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -206,7 +207,9 @@ def burst_analysis(
result_db_name=name,
scheme_username=username,
scheme_detail=scheme_detail,
persist_results=persist_results,
)
return result if not persist_results else None
# step 3. restore the base model status
# execute_undo(name) #有疑惑
@@ -223,6 +226,7 @@ def valve_close_analysis(
scheme_name: str = None,
_temporary_project: str | None = None,
persist_results: bool = True,
username: str = "system",
) -> dict | str | None:
"""
关阀模拟
@@ -282,6 +286,7 @@ def valve_close_analysis(
scheme_name=scheme_name,
result_db_name=name,
persist_results=persist_results,
scheme_username=username,
)
return result if not persist_results else None
# step 3. restore the base model
@@ -610,6 +615,7 @@ def pressure_regulation(
scada_mappings: simulation.ScadaElementMappings | None = None,
_temporary_project: str | None = None,
persist_results: bool = True,
username: str = "system",
) -> dict | str | None:
"""
区域调压模拟,用来模拟未来15分钟内,开关水泵对区域压力的影响
@@ -668,6 +674,7 @@ def pressure_regulation(
result_db_name=name,
scada_mappings=scada_mappings,
persist_results=persist_results,
scheme_username=username,
)
return result if not persist_results else None
# return result