672 lines
25 KiB
Python
672 lines
25 KiB
Python
"""Earth boundary static asset service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
import sys
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
SOURCE_OUTPUT_DIR = REPO_ROOT / "data/earth-boundary-sources"
|
|
SOURCE_MANIFEST_PATH = SOURCE_OUTPUT_DIR / "manifest.json"
|
|
BUILD_RESULT_PATH = SOURCE_OUTPUT_DIR / "build-result.json"
|
|
BUILD_JOB_PATH = SOURCE_OUTPUT_DIR / "build-job.json"
|
|
BOUNDARY_OUTPUT_DIR = REPO_ROOT / "frontend/public/earth/data/boundaries/v1"
|
|
BOUNDARY_MANIFEST_PATH = BOUNDARY_OUTPUT_DIR / "manifest.json"
|
|
PMTILES_ARTIFACT_PATH = (
|
|
REPO_ROOT / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
|
|
)
|
|
LEGACY_GEOJSON_PATH = REPO_ROOT / "frontend/public/earth/data/countries-admin0.min.geojson"
|
|
POV_POLICY_PATH = REPO_ROOT / "config/earth-boundary-pov-policy.china-v1.json"
|
|
LOCAL_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.local.json"
|
|
EXAMPLE_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.example.json"
|
|
|
|
BOUNDARY_SOURCE_KINDS = {
|
|
"earth_admin0_boundaries": "admin0-boundaries",
|
|
"earth_coastline": "coastline",
|
|
"earth_claim_lines": "claim-lines",
|
|
}
|
|
|
|
DEFAULT_PUBLIC_BOUNDARY_SOURCES = {
|
|
"earth_admin0_boundaries": {
|
|
"displayName": "Natural Earth Admin-0 Countries",
|
|
"sourceKind": "admin0-boundaries",
|
|
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson",
|
|
"method": "GET",
|
|
"headers": {},
|
|
"auth_type": "none",
|
|
"license": "Natural Earth public domain",
|
|
},
|
|
"earth_coastline": {
|
|
"displayName": "Natural Earth Coastline",
|
|
"sourceKind": "coastline",
|
|
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson",
|
|
"method": "GET",
|
|
"headers": {},
|
|
"auth_type": "none",
|
|
"license": "Natural Earth public domain",
|
|
},
|
|
"earth_claim_lines": {
|
|
"displayName": "Natural Earth Disputed Boundaries",
|
|
"sourceKind": "claim-lines",
|
|
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson",
|
|
"method": "GET",
|
|
"headers": {},
|
|
"auth_type": "none",
|
|
"license": "Natural Earth public domain",
|
|
},
|
|
}
|
|
|
|
BUILD_CONFIG = {
|
|
"builder": "scripts/build_earth_boundary_pmtiles.py",
|
|
"format": "pmtiles+mvt",
|
|
"production_target": "pmtiles-mvt",
|
|
}
|
|
|
|
|
|
class EarthBoundaryBuildError(RuntimeError):
|
|
def __init__(self, message: str, *, code: str = "build_failed", details: Any = None) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.details = details
|
|
|
|
|
|
_build_job_lock = asyncio.Lock()
|
|
_build_task: asyncio.Task | None = None
|
|
_build_job_state: dict[str, Any] = {}
|
|
|
|
|
|
def _utc_now_iso() -> str:
|
|
return datetime.now(UTC).isoformat()
|
|
|
|
|
|
def _public_job_state() -> dict[str, Any]:
|
|
if _build_job_state:
|
|
return dict(_build_job_state)
|
|
return _read_json(BUILD_JOB_PATH)
|
|
|
|
|
|
def get_boundary_build_status() -> dict[str, Any]:
|
|
return {"job": _public_job_state()}
|
|
|
|
|
|
def _set_job_state(**updates: Any) -> dict[str, Any]:
|
|
global _build_job_state
|
|
current = dict(_build_job_state)
|
|
current.update(updates)
|
|
current["updated_at"] = _utc_now_iso()
|
|
_build_job_state = current
|
|
_write_json(BUILD_JOB_PATH, current)
|
|
return current
|
|
|
|
|
|
def _append_job_log(message: str) -> None:
|
|
logs = list(_build_job_state.get("logs") or [])
|
|
logs.append({"time": _utc_now_iso(), "message": message})
|
|
_set_job_state(logs=logs[-40:])
|
|
|
|
|
|
def _update_job_progress(progress: float, phase: str, message: str, **extra: Any) -> None:
|
|
bounded_progress = max(0, min(100, int(round(progress))))
|
|
_set_job_state(
|
|
status="running",
|
|
progress=bounded_progress,
|
|
phase=phase,
|
|
message=message,
|
|
**extra,
|
|
)
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {}
|
|
with path.open("r", encoding="utf-8") as f:
|
|
payload = json.load(f)
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
|
|
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8") as f:
|
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
f.write("\n")
|
|
|
|
|
|
def _sha256_bytes(payload: bytes) -> str:
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def _stable_json_hash(payload: Any) -> str:
|
|
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _artifact_extension(endpoint: str, content_type: str, payload: bytes) -> str:
|
|
suffix = Path(endpoint.split("?", 1)[0]).suffix.lower()
|
|
if suffix in {".json", ".geojson", ".zip", ".pbf"}:
|
|
return suffix
|
|
if "geo+json" in content_type or b'"FeatureCollection"' in payload[:4096]:
|
|
return ".geojson"
|
|
if "json" in content_type:
|
|
return ".json"
|
|
return ".dat"
|
|
|
|
|
|
def _json_feature_count(payload: Any) -> int:
|
|
if isinstance(payload, dict) and isinstance(payload.get("features"), list):
|
|
return len(payload["features"])
|
|
if isinstance(payload, list):
|
|
return len(payload)
|
|
return 1 if payload else 0
|
|
|
|
|
|
def _directory_stats(path: Path) -> dict[str, int]:
|
|
if not path.exists():
|
|
return {"file_count": 0, "size_bytes": 0}
|
|
files = [item for item in path.rglob("*") if item.is_file()]
|
|
return {"file_count": len(files), "size_bytes": sum(item.stat().st_size for item in files)}
|
|
|
|
|
|
def _load_source_feature_collection(source: dict[str, Any]) -> dict[str, Any]:
|
|
path = REPO_ROOT / source["path"]
|
|
payload = _read_json(path)
|
|
features = payload.get("features") if isinstance(payload, dict) else None
|
|
return {
|
|
"type": "FeatureCollection",
|
|
"features": features if isinstance(features, list) else [],
|
|
}
|
|
|
|
|
|
def _write_high_precision_geojson_manifest(
|
|
sources: list[dict[str, Any]],
|
|
build_input_hash: str,
|
|
missing_tools: list[str],
|
|
) -> dict[str, Any]:
|
|
BOUNDARY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
|
coastline = next(source for source in sources if source["kind"] == "coastline")
|
|
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
|
|
|
admin0_payload = _load_source_feature_collection(admin0)
|
|
coastline_payload = _load_source_feature_collection(coastline)
|
|
claim_payload = _load_source_feature_collection(claim_lines)
|
|
for feature in coastline_payload["features"]:
|
|
props = feature.setdefault("properties", {})
|
|
if isinstance(props, dict):
|
|
props["PLANET_LAYER"] = "coastline"
|
|
|
|
base_payload = {
|
|
"type": "FeatureCollection",
|
|
"features": [*admin0_payload["features"], *coastline_payload["features"]],
|
|
}
|
|
base_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-base.geojson"
|
|
hover_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-hover.geojson"
|
|
claim_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-claims.geojson"
|
|
_write_json(base_path, base_payload)
|
|
_write_json(hover_path, admin0_payload)
|
|
_write_json(claim_path, claim_payload)
|
|
|
|
manifest = {
|
|
"version": "natural-earth-v1",
|
|
"builtAt": _utc_now_iso(),
|
|
"tileProvider": "geojson-high-precision",
|
|
"format": "geojson-directory",
|
|
"buildInputHash": build_input_hash,
|
|
"base": base_path.name,
|
|
"hoverIndex": hover_path.name,
|
|
"claimLine": claim_path.name,
|
|
"sourceFeatureCount": {
|
|
"admin0": len(admin0_payload["features"]),
|
|
"coastline": len(coastline_payload["features"]),
|
|
"claimLines": len(claim_payload["features"]),
|
|
},
|
|
"pmtiles": None,
|
|
"missingTools": missing_tools,
|
|
}
|
|
_write_json(BOUNDARY_MANIFEST_PATH, manifest)
|
|
return manifest
|
|
|
|
|
|
def _relative(path: Path) -> str:
|
|
return str(path.relative_to(REPO_ROOT))
|
|
|
|
|
|
def load_boundary_config() -> tuple[dict[str, Any], str]:
|
|
if LOCAL_CONFIG_PATH.exists():
|
|
return _read_json(LOCAL_CONFIG_PATH), "local"
|
|
return _read_json(EXAMPLE_CONFIG_PATH), "example"
|
|
|
|
|
|
def save_boundary_config(payload: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
raise EarthBoundaryBuildError("Earth boundary config must be a JSON object", code="invalid_config")
|
|
_write_json(LOCAL_CONFIG_PATH, payload)
|
|
return get_boundary_status()
|
|
|
|
|
|
def _source_configs(payload: dict[str, Any]) -> dict[str, Any]:
|
|
raw_sources = payload.get("collectorConfigs") or payload.get("sources") or {}
|
|
return raw_sources if isinstance(raw_sources, dict) else {}
|
|
|
|
|
|
def _is_placeholder_endpoint(endpoint: Any) -> bool:
|
|
value = str(endpoint or "").strip()
|
|
return not value or "example.com" in value
|
|
|
|
|
|
def _source_configs_with_defaults(payload: dict[str, Any]) -> dict[str, Any]:
|
|
raw_sources = _source_configs(payload)
|
|
merged: dict[str, Any] = {}
|
|
for source_key, default_config in DEFAULT_PUBLIC_BOUNDARY_SOURCES.items():
|
|
configured = raw_sources.get(source_key)
|
|
if not isinstance(configured, dict) or _is_placeholder_endpoint(configured.get("endpoint")):
|
|
merged[source_key] = dict(default_config)
|
|
else:
|
|
merged[source_key] = {**default_config, **configured}
|
|
for source_key, source_config in raw_sources.items():
|
|
if source_key not in merged:
|
|
merged[source_key] = source_config
|
|
return merged
|
|
|
|
|
|
def _build_input_hash(source_manifest: dict[str, Any]) -> str:
|
|
return _stable_json_hash(
|
|
{
|
|
"source_manifest_schema": source_manifest.get("schema"),
|
|
"sources": [
|
|
{
|
|
"id": source.get("id"),
|
|
"sha256": source.get("sha256"),
|
|
"kind": source.get("kind"),
|
|
}
|
|
for source in source_manifest.get("sources", [])
|
|
],
|
|
"pov_policy": source_manifest.get("povPolicy"),
|
|
"build_config": BUILD_CONFIG,
|
|
}
|
|
)
|
|
|
|
|
|
def _has_current_artifacts(boundary_manifest: dict[str, Any], build_input_hash: str) -> bool:
|
|
return (
|
|
bool(boundary_manifest)
|
|
and boundary_manifest.get("buildInputHash") == build_input_hash
|
|
and boundary_manifest.get("tileProvider") == "pmtiles-mvt"
|
|
and PMTILES_ARTIFACT_PATH.exists()
|
|
)
|
|
|
|
|
|
def get_boundary_status() -> dict[str, Any]:
|
|
config_payload, config_source = load_boundary_config()
|
|
effective_source_configs = _source_configs_with_defaults(config_payload)
|
|
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
|
pmtiles_exists = PMTILES_ARTIFACT_PATH.exists()
|
|
manifest_exists = BOUNDARY_MANIFEST_PATH.exists()
|
|
high_precision_ready = (
|
|
manifest_exists
|
|
and (
|
|
(
|
|
boundary_manifest.get("tileProvider") == "pmtiles-mvt"
|
|
and pmtiles_exists
|
|
)
|
|
or boundary_manifest.get("tileProvider") == "geojson-high-precision"
|
|
)
|
|
)
|
|
legacy_exists = LEGACY_GEOJSON_PATH.exists()
|
|
provider = (
|
|
boundary_manifest.get("tileProvider")
|
|
if high_precision_ready
|
|
else "legacy-geojson" if legacy_exists else "missing"
|
|
)
|
|
return {
|
|
"provider": provider,
|
|
"high_precision_ready": high_precision_ready,
|
|
"fallback_available": legacy_exists,
|
|
"config_source": config_source,
|
|
"config_path": _relative(LOCAL_CONFIG_PATH),
|
|
"config_exists": LOCAL_CONFIG_PATH.exists(),
|
|
"config": config_payload,
|
|
"effective_default_sources": [
|
|
source_key
|
|
for source_key, source_config in effective_source_configs.items()
|
|
if source_key in DEFAULT_PUBLIC_BOUNDARY_SOURCES
|
|
and source_config.get("endpoint") == DEFAULT_PUBLIC_BOUNDARY_SOURCES[source_key]["endpoint"]
|
|
],
|
|
"manifest": {
|
|
"path": _relative(BOUNDARY_MANIFEST_PATH),
|
|
"exists": manifest_exists,
|
|
"tileProvider": boundary_manifest.get("tileProvider"),
|
|
"buildInputHash": boundary_manifest.get("buildInputHash"),
|
|
"builtAt": boundary_manifest.get("builtAt"),
|
|
},
|
|
"pmtiles": {
|
|
"path": _relative(PMTILES_ARTIFACT_PATH),
|
|
"exists": pmtiles_exists,
|
|
"size_bytes": PMTILES_ARTIFACT_PATH.stat().st_size if pmtiles_exists else 0,
|
|
},
|
|
"legacy": {
|
|
"path": _relative(LEGACY_GEOJSON_PATH),
|
|
"exists": legacy_exists,
|
|
"size_bytes": LEGACY_GEOJSON_PATH.stat().st_size if legacy_exists else 0,
|
|
},
|
|
"source_manifest": {
|
|
"path": _relative(SOURCE_MANIFEST_PATH),
|
|
"exists": SOURCE_MANIFEST_PATH.exists(),
|
|
},
|
|
"last_build": _read_json(BUILD_RESULT_PATH),
|
|
"current_job": _public_job_state(),
|
|
}
|
|
|
|
|
|
async def _download_source(
|
|
source_key: str,
|
|
source_config: dict[str, Any],
|
|
progress_callback: Any = None,
|
|
) -> dict[str, Any]:
|
|
endpoint = str(source_config.get("endpoint") or "").strip()
|
|
if _is_placeholder_endpoint(endpoint):
|
|
raise EarthBoundaryBuildError(
|
|
f"{source_key} endpoint is not configured",
|
|
code="source_not_configured",
|
|
details={"source": source_key},
|
|
)
|
|
method = str(source_config.get("method") or "GET").upper()
|
|
if method not in {"GET", "POST"}:
|
|
raise EarthBoundaryBuildError(
|
|
f"{source_key} method must be GET or POST",
|
|
code="invalid_config",
|
|
details={"source": source_key, "method": method},
|
|
)
|
|
|
|
if endpoint.startswith("file://") or Path(endpoint).expanduser().exists():
|
|
payload = Path(endpoint.removeprefix("file://")).expanduser().read_bytes()
|
|
content_type = "application/octet-stream"
|
|
if progress_callback:
|
|
progress_callback(1, len(payload), len(payload))
|
|
else:
|
|
timeout = float(source_config.get("timeout") or 120)
|
|
headers = source_config.get("headers") if isinstance(source_config.get("headers"), dict) else {}
|
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
async with client.stream(method, endpoint, headers=headers) as response:
|
|
response.raise_for_status()
|
|
content_type = response.headers.get("content-type", "")
|
|
total = int(response.headers.get("content-length") or 0)
|
|
chunks = []
|
|
downloaded = 0
|
|
async for chunk in response.aiter_bytes():
|
|
if not chunk:
|
|
continue
|
|
chunks.append(chunk)
|
|
downloaded += len(chunk)
|
|
if progress_callback:
|
|
progress_callback(
|
|
(downloaded / total) if total else None,
|
|
downloaded,
|
|
total,
|
|
)
|
|
payload = b"".join(chunks)
|
|
|
|
extension = _artifact_extension(endpoint, content_type, payload)
|
|
parsed: Any = None
|
|
if extension in {".json", ".geojson"}:
|
|
parsed = json.loads(payload.decode("utf-8"))
|
|
feature_count = _json_feature_count(parsed)
|
|
if feature_count <= 0:
|
|
raise EarthBoundaryBuildError(
|
|
f"{source_key} downloaded payload contains no features",
|
|
code="empty_source",
|
|
details={"source": source_key},
|
|
)
|
|
|
|
sha256 = _sha256_bytes(payload)
|
|
source_dir = SOURCE_OUTPUT_DIR / source_key
|
|
source_dir.mkdir(parents=True, exist_ok=True)
|
|
artifact_path = source_dir / f"{sha256}{extension}"
|
|
artifact_path.write_bytes(payload)
|
|
return {
|
|
"id": source_key,
|
|
"kind": source_config.get("sourceKind") or BOUNDARY_SOURCE_KINDS[source_key],
|
|
"path": _relative(artifact_path),
|
|
"sha256": sha256,
|
|
"featureCount": feature_count,
|
|
"license": source_config.get("license"),
|
|
}
|
|
|
|
|
|
async def _run_step(args: list[str]) -> dict[str, Any]:
|
|
process = await asyncio.create_subprocess_exec(
|
|
sys.executable,
|
|
*args,
|
|
cwd=REPO_ROOT,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout_bytes, stderr_bytes = await process.communicate()
|
|
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
payload: dict[str, Any] = {"stdout": stdout, "stderr": stderr, "returncode": process.returncode}
|
|
last_line = stdout.splitlines()[-1:] or []
|
|
if last_line:
|
|
try:
|
|
payload["result"] = json.loads(last_line[0])
|
|
except json.JSONDecodeError:
|
|
payload["result"] = last_line[0]
|
|
if process.returncode != 0:
|
|
raise EarthBoundaryBuildError(
|
|
stderr or stdout or f"command failed: {' '.join(args)}",
|
|
code="build_command_failed",
|
|
details=payload,
|
|
)
|
|
return payload
|
|
|
|
|
|
async def build_boundary_assets(progress_callback: Any = None) -> dict[str, Any]:
|
|
config_payload, config_source = load_boundary_config()
|
|
|
|
source_configs = _source_configs_with_defaults(config_payload)
|
|
missing = [source for source in BOUNDARY_SOURCE_KINDS if source not in source_configs]
|
|
if missing:
|
|
raise EarthBoundaryBuildError(
|
|
f"Missing Earth boundary source configs: {', '.join(missing)}",
|
|
code="missing_sources",
|
|
details={"missing": missing},
|
|
)
|
|
|
|
sources = []
|
|
source_keys = list(BOUNDARY_SOURCE_KINDS)
|
|
for index, source_key in enumerate(source_keys):
|
|
source_config = source_configs[source_key]
|
|
if not isinstance(source_config, dict):
|
|
raise EarthBoundaryBuildError(
|
|
f"{source_key} config must be an object",
|
|
code="invalid_config",
|
|
details={"source": source_key},
|
|
)
|
|
source_start = 8 + index * 18
|
|
source_end = source_start + 18
|
|
if progress_callback:
|
|
progress_callback(source_start, "download", f"正在下载 {source_key}")
|
|
|
|
def report_download_progress(ratio: float | None, downloaded: int, total: int) -> None:
|
|
if not progress_callback:
|
|
return
|
|
if ratio is None:
|
|
progress_callback(source_start + 8, "download", f"{source_key} 已下载 {downloaded} bytes")
|
|
return
|
|
progress_callback(
|
|
source_start + (source_end - source_start) * ratio,
|
|
"download",
|
|
f"{source_key} 下载 {int(ratio * 100)}%",
|
|
downloaded_bytes=downloaded,
|
|
total_bytes=total,
|
|
)
|
|
|
|
sources.append(await _download_source(source_key, source_config, report_download_progress))
|
|
|
|
source_manifest = {
|
|
"schema": "planet-earth-boundary-sources/v2",
|
|
"sources": sources,
|
|
"povPolicy": _read_json(POV_POLICY_PATH),
|
|
}
|
|
if progress_callback:
|
|
progress_callback(65, "manifest", "正在写入边界源 manifest")
|
|
_write_json(SOURCE_MANIFEST_PATH, source_manifest)
|
|
build_input_hash = _build_input_hash(source_manifest)
|
|
|
|
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
|
build_skipped = _has_current_artifacts(boundary_manifest, build_input_hash)
|
|
missing_tools = [tool for tool in ("tippecanoe", "pmtiles") if shutil.which(tool) is None]
|
|
if missing_tools and not build_skipped:
|
|
if progress_callback:
|
|
progress_callback(82, "build", "缺少 PMTiles 工具,正在生成 GeoJSON 高清包")
|
|
boundary_manifest = _write_high_precision_geojson_manifest(
|
|
sources,
|
|
build_input_hash,
|
|
missing_tools,
|
|
)
|
|
result = {
|
|
"status": "built_geojson_fallback",
|
|
"code": "missing_tools",
|
|
"missing_tools": missing_tools,
|
|
"sources": sources,
|
|
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
|
|
"manifest": boundary_manifest,
|
|
}
|
|
_write_json(BUILD_RESULT_PATH, result)
|
|
if progress_callback:
|
|
progress_callback(96, "finalize", "GeoJSON 高清国界包已生成")
|
|
return {**get_boundary_status(), "build": result}
|
|
|
|
if build_skipped:
|
|
if progress_callback:
|
|
progress_callback(96, "unchanged", "高精国界已是最新")
|
|
build_result = {
|
|
"status": "unchanged",
|
|
"reason": "source manifest and build config hash unchanged",
|
|
"buildInputHash": build_input_hash,
|
|
}
|
|
else:
|
|
if progress_callback:
|
|
progress_callback(72, "build", "正在构建 PMTiles/MVT")
|
|
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
|
coastline = next(source for source in sources if source["kind"] == "coastline")
|
|
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
|
build_result = await _run_step(
|
|
[
|
|
"scripts/build_earth_boundary_pmtiles.py",
|
|
"--admin0-source",
|
|
admin0["path"],
|
|
"--coastline-source",
|
|
coastline["path"],
|
|
"--claims-source",
|
|
claim_lines["path"],
|
|
"--output",
|
|
_relative(PMTILES_ARTIFACT_PATH),
|
|
"--manifest",
|
|
_relative(BOUNDARY_MANIFEST_PATH),
|
|
"--build-input-hash",
|
|
build_input_hash,
|
|
"--pov-policy",
|
|
_relative(POV_POLICY_PATH),
|
|
]
|
|
)
|
|
if progress_callback:
|
|
progress_callback(95, "finalize", "正在校验构建产物")
|
|
|
|
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
|
boundary_stats = _directory_stats(BOUNDARY_OUTPUT_DIR)
|
|
result = {
|
|
"status": "unchanged" if build_skipped else "built",
|
|
"sources": sources,
|
|
"source_manifest": _relative(SOURCE_MANIFEST_PATH),
|
|
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
|
|
"pmtiles_artifact": _relative(PMTILES_ARTIFACT_PATH),
|
|
"pmtiles_exists": PMTILES_ARTIFACT_PATH.exists(),
|
|
"boundary_stats": boundary_stats,
|
|
"manifest": boundary_manifest,
|
|
"build_result": build_result,
|
|
}
|
|
_write_json(BUILD_RESULT_PATH, result)
|
|
return {**get_boundary_status(), "build": result}
|
|
|
|
|
|
async def _run_boundary_build_job(job_id: str) -> None:
|
|
def report(progress: float, phase: str, message: str, **extra: Any) -> None:
|
|
if _build_job_state.get("id") != job_id:
|
|
return
|
|
_update_job_progress(progress, phase, message, **extra)
|
|
|
|
try:
|
|
report(3, "prepare", "正在准备高精国界构建")
|
|
result = await build_boundary_assets(report)
|
|
_set_job_state(
|
|
id=job_id,
|
|
status="succeeded",
|
|
progress=100,
|
|
phase="complete",
|
|
message="高精国界构建完成",
|
|
finished_at=_utc_now_iso(),
|
|
result={
|
|
"provider": result.get("provider"),
|
|
"high_precision_ready": result.get("high_precision_ready"),
|
|
"pmtiles": result.get("pmtiles"),
|
|
"manifest": result.get("manifest"),
|
|
},
|
|
)
|
|
_append_job_log("高精国界构建完成")
|
|
except EarthBoundaryBuildError as exc:
|
|
_set_job_state(
|
|
id=job_id,
|
|
status="failed",
|
|
progress=_build_job_state.get("progress", 0),
|
|
phase="failed",
|
|
message=str(exc),
|
|
code=exc.code,
|
|
details=exc.details,
|
|
finished_at=_utc_now_iso(),
|
|
)
|
|
_append_job_log(str(exc))
|
|
except Exception as exc: # pragma: no cover - defensive guard for background task
|
|
_set_job_state(
|
|
id=job_id,
|
|
status="failed",
|
|
progress=_build_job_state.get("progress", 0),
|
|
phase="failed",
|
|
message=str(exc),
|
|
code="build_failed",
|
|
finished_at=_utc_now_iso(),
|
|
)
|
|
_append_job_log(str(exc))
|
|
|
|
|
|
async def start_boundary_build_job() -> dict[str, Any]:
|
|
global _build_task
|
|
async with _build_job_lock:
|
|
if _build_task and not _build_task.done():
|
|
return {"accepted": False, "job": _public_job_state()}
|
|
job_id = uuid4().hex
|
|
_set_job_state(
|
|
id=job_id,
|
|
status="queued",
|
|
progress=0,
|
|
phase="queued",
|
|
message="高精国界构建已加入队列",
|
|
logs=[],
|
|
started_at=_utc_now_iso(),
|
|
finished_at=None,
|
|
code=None,
|
|
details=None,
|
|
)
|
|
_append_job_log("高精国界构建已启动")
|
|
_build_task = asyncio.create_task(_run_boundary_build_job(job_id))
|
|
return {"accepted": True, "job": _public_job_state()}
|