Compare commits

..
13 Commits
72 changed files with 6300 additions and 2336 deletions
-2
View File
@@ -48,8 +48,6 @@ NGINX_HEALTH_URL=http://127.0.0.1/
# Empty means allow all. Use semicolon/comma/space-separated IPs or CIDRs, for example:
# NGINX_ALLOWED_CLIENT_IPS=192.168.1.10;192.168.1.23;192.168.1.0/24
NGINX_ALLOWED_CLIENT_IPS=
LICENSE_PATH=
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost
CORS_ALLOW_CREDENTIALS=true
CORS_STRICT_MODE=false
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from .routers import include_all_routers
from .routers.dependencies import _require_auth, _require_license
from .routers.dependencies import _require_auth
router = APIRouter(dependencies=[Depends(_require_license), Depends(_require_auth)])
router = APIRouter(dependencies=[Depends(_require_auth)])
include_all_routers(router)
-2
View File
@@ -202,7 +202,6 @@ class Settings(BaseSettings):
CORS_ALLOW_CREDENTIALS: bool = True
CORS_STRICT_MODE: bool = False
LICENSE_PATH: str = ""
INIT_ADMIN_USERNAME: str = "admin"
INIT_ADMIN_PASSWORD: str = ""
INIT_ADMIN_RESET_PASSWORD: bool = False
@@ -1333,7 +1332,6 @@ def validate_runtime_config() -> dict[str, Any]:
_check_path(label="PYTHON_PATH", value=settings.PYTHON_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="NGINX_PATH", value=settings.NGINX_PATH, errors=errors, warnings=warnings, required=True, expect_file=True)
_check_path(label="LICENSE_PATH", value=settings.LICENSE_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="IDL_EXECUTABLE", value=settings.IDL_EXECUTABLE, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="IDL_WORKBENCH_PATH", value=settings.IDL_WORKBENCH_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="GF3_GEO_DEM_PATH", value=settings.GF3_GEO_DEM_PATH, errors=errors, warnings=warnings, expect_file=True)
+1
View File
@@ -43,6 +43,7 @@ MIGRATION_FILES = [
"011_source_metadata_documents.sql",
"012_source_archive_integrity.sql",
"013_result_delivery_requests.sql",
"014_result_delivery_ortho_sources.sql",
]
-164
View File
@@ -1,164 +0,0 @@
"""
授权验证服务 — LIC2 方案
========================
- 公钥硬编码在源码中,无需 .env 配置
- 无本地 state 文件,无时间防回退(去掉脆弱的本地状态)
- 授权文件格式:LIC2|<ed25519签名_b64>|<payload_json_b64>
- 私钥只在签发工具(license-issuer/)中,不随代码部署
私钥丢失处理:
1. 在 license-issuer/ 运行 rotate-key --force 生成新密钥对
2. 将新公钥更新到本文件的 _PUBLIC_KEY_B64 常量
3. 重新部署后端,重新为客户签发授权文件
"""
import base64
import hashlib
import json
import os
import subprocess
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
from .config import settings
# ── 公钥(硬编码,与 license-issuer/public_key.b64 对应) ─────────────────────
# 私钥丢失后,用新密钥对重新签发授权,并将此处更新为新公钥。
_PUBLIC_KEY_B64 = "QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw="
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
_BACKEND_DIR = os.path.dirname(_APP_DIR)
LICENSE_PATH_DEFAULT = os.path.join(_BACKEND_DIR, "license", "license.lic")
LICENSE_STATUS_CACHE_SECONDS = int(os.getenv("LICENSE_STATUS_CACHE_SECONDS", "30"))
_LICENSE_STATUS_CACHE: Dict[str, Any] = {
"path": None,
"mtime": None,
"checked_at": 0.0,
"payload": None,
}
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
# ── 机器指纹 ──────────────────────────────────────────────────────────────────
def _run_cmd(args: list) -> str:
try:
out = subprocess.check_output(
args, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, shell=False
)
return out.decode("utf-8", errors="ignore").strip()
except Exception:
return ""
def _run_powershell(cmd: str) -> str:
return _run_cmd(["powershell", "-NoProfile", "-Command", cmd])
def _pick_value(text: str) -> str:
lines = [line.strip() for line in text.splitlines() if line.strip()]
if len(lines) <= 1:
return ""
values = [v for v in lines[1:] if v and v.lower() not in ("serialnumber", "uuid")]
return values[0] if values else ""
def _get_machine_fingerprint() -> str:
uuid_text = _run_cmd(["wmic", "csproduct", "get", "uuid"])
if not uuid_text:
uuid_text = _run_powershell("(Get-CimInstance Win32_ComputerSystemProduct).UUID")
disk_text = _run_cmd(["wmic", "diskdrive", "get", "serialnumber"])
if not disk_text:
disk_text = _run_powershell(
"(Get-CimInstance Win32_DiskDrive | Select-Object -First 1 -ExpandProperty SerialNumber)"
)
uuid_val = _pick_value(uuid_text)
disk_val = _pick_value(disk_text)
mac_val = f"{uuid.getnode():012x}"
if not mac_val or mac_val == "000000000000":
mac_val = _run_powershell(
"(Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1 -ExpandProperty MacAddress)"
) or ""
raw = "|".join([uuid_val, disk_val, mac_val])
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
# ── 授权验证 ──────────────────────────────────────────────────────────────────
def _license_result(result: Dict[str, Any], *, use_cache: bool, path: str, mtime: Optional[float]) -> Dict[str, Any]:
if use_cache:
_LICENSE_STATUS_CACHE.update({
"path": path,
"mtime": mtime,
"checked_at": time.monotonic(),
"payload": dict(result),
})
return result
def check_license(license_path: Optional[str] = None) -> Dict[str, Any]:
use_cache = license_path is None
license_path = license_path or settings.LICENSE_PATH or LICENSE_PATH_DEFAULT
mtime = os.path.getmtime(license_path) if os.path.exists(license_path) else None
if use_cache:
cached = _LICENSE_STATUS_CACHE.get("payload")
cache_age = time.monotonic() - float(_LICENSE_STATUS_CACHE.get("checked_at") or 0.0)
if (
cached is not None
and _LICENSE_STATUS_CACHE.get("path") == license_path
and _LICENSE_STATUS_CACHE.get("mtime") == mtime
and cache_age <= LICENSE_STATUS_CACHE_SECONDS
):
return dict(cached)
if not os.path.exists(license_path):
return _license_result({"ok": False, "reason": "未找到授权文件"}, use_cache=use_cache, path=license_path, mtime=mtime)
try:
blob = open(license_path, "rb").read().strip()
parts = blob.split(b"|", 2)
if len(parts) != 3 or parts[0] != b"LIC2":
return _license_result({"ok": False, "reason": "授权文件格式无效"}, use_cache=use_cache, path=license_path, mtime=mtime)
_, sig_b64, payload_b64 = parts
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(_PUBLIC_KEY_B64))
pub.verify(base64.b64decode(sig_b64), payload_b64)
payload = json.loads(base64.b64decode(payload_b64))
except InvalidSignature:
return _license_result({"ok": False, "reason": "授权文件签名无效(可能已被篡改)"}, use_cache=use_cache, path=license_path, mtime=mtime)
except Exception as e:
return _license_result({"ok": False, "reason": f"授权文件解析失败: {e}"}, use_cache=use_cache, path=license_path, mtime=mtime)
fp_expected = payload.get("fingerprint")
fp_actual = _get_machine_fingerprint()
if not fp_expected or fp_expected != fp_actual:
return _license_result({"ok": False, "reason": "机器指纹不匹配"}, use_cache=use_cache, path=license_path, mtime=mtime)
expires_at = payload.get("expires_at")
if not expires_at:
return _license_result({"ok": False, "reason": "授权文件缺少有效期"}, use_cache=use_cache, path=license_path, mtime=mtime)
try:
expires_dt = datetime.fromisoformat(expires_at)
except Exception:
return _license_result({"ok": False, "reason": "有效期格式错误"}, use_cache=use_cache, path=license_path, mtime=mtime)
if _now_utc() > expires_dt:
return _license_result({"ok": False, "reason": "授权已过期"}, use_cache=use_cache, path=license_path, mtime=mtime)
return _license_result({
"ok": True,
"issued_to": payload.get("issued_to"),
"expires_at": expires_at,
"fingerprint": fp_actual,
"license_path": license_path,
}, use_cache=use_cache, path=license_path, mtime=mtime)
+6
View File
@@ -377,6 +377,8 @@ class ResultDeliveryItemORM(Base):
source_product_id = Column(Integer, ForeignKey("result_products.id", ondelete="SET NULL"), nullable=True, index=True)
source_result_id = Column(Integer, ForeignKey("dinsar_results.id", ondelete="SET NULL"), nullable=True, index=True)
source_asset_id = Column(Integer, ForeignKey("result_assets.id", ondelete="SET NULL"), nullable=True, index=True)
source_radar_data_id = Column(Integer, ForeignKey("radar_data.id", ondelete="SET NULL"), nullable=True, index=True)
source_scene_geo_id = Column(Integer, ForeignKey("sar_scene_geo.id", ondelete="SET NULL"), nullable=True, index=True)
display_name = Column(String(255), nullable=False)
source_path = Column(String, nullable=False)
relative_path = Column(String, nullable=True)
@@ -391,10 +393,14 @@ class ResultDeliveryItemORM(Base):
product = relationship("ResultProductORM", foreign_keys=[source_product_id])
compat_result = relationship("DinsarResultORM", foreign_keys=[source_result_id])
asset = relationship("ResultAssetORM", foreign_keys=[source_asset_id])
radar_data = relationship("RadarDataORM", foreign_keys=[source_radar_data_id])
scene_geo = relationship("SARSceneGeoORM", foreign_keys=[source_scene_geo_id])
__table_args__ = (
Index("idx_result_delivery_items_delivery_status", "delivery_id", "status"),
Index("idx_result_delivery_items_product", "source_product_id"),
Index("idx_result_delivery_items_radar", "source_radar_data_id"),
Index("idx_result_delivery_items_scene_geo", "source_scene_geo_id"),
)
+9 -2
View File
@@ -284,7 +284,7 @@ class PairingRequest(BaseModel):
slave_date_to: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$')
# === 配对策略(新增) ===
strategy: str = Field(default="dinsar_production", pattern=r'^dinsar_production$')
strategy: str = Field(default="dinsar_production", pattern=r'^(dinsar_production|dinsar_province_coverage)$')
num_connections: int = Field(default=1, ge=1, le=10)
reference_image_id: Optional[int] = None
@@ -307,7 +307,8 @@ class PairingRequest(BaseModel):
normalized['overlap_threshold'] = normalized['pair_footprint_overlap_min_ratio']
if normalized.get('footprint_center_distance_max_meters') not in (None, ''):
normalized['spatial_baseline_max_meters'] = normalized['footprint_center_distance_max_meters']
normalized['strategy'] = 'dinsar_production'
if normalized.get('strategy') not in {'dinsar_production', 'dinsar_province_coverage'}:
normalized['strategy'] = 'dinsar_production'
return normalized
@field_validator(
@@ -390,6 +391,11 @@ class RadarPair(BaseModel):
selection_strategy: Optional[str] = None
selection_score: Optional[float] = None
selection_reason: Optional[str] = None
coverage_rank: Optional[int] = None
aoi_new_area_ratio: Optional[float] = None
aoi_pair_area_ratio: Optional[float] = None
aoi_coverage_ratio_after: Optional[float] = None
effective_coverage_geojson: Optional[Dict[str, Any]] = None
time_baseline_days: int
spatial_baseline_meters: float
scene_center_distance_meters: Optional[float] = None
@@ -416,6 +422,7 @@ class PairingResponse(BaseModel):
network_run_id: Optional[str] = None
candidate_count: int = 0
selected_edge_count: int = 0
coverage: Optional[Dict[str, Any]] = None
class PsRequest(BaseModel):
+2 -2
View File
@@ -15,9 +15,9 @@ from . import (
health,
idl,
landsar_lt1_production,
license,
logs,
monitor,
ops_maintenance,
orbit,
pairing,
ps_products,
@@ -41,13 +41,13 @@ def include_all_routers(router: APIRouter) -> None:
"""将所有子路由注册到给定的 APIRouter。"""
router.include_router(health.router)
router.include_router(auth.router)
router.include_router(license.router)
router.include_router(tasks_runtime.router)
router.include_router(workflow.router)
router.include_router(task_batches.router)
router.include_router(tools.router)
router.include_router(unpack.router)
router.include_router(monitor.router)
router.include_router(ops_maintenance.router)
router.include_router(orbit.router)
router.include_router(assets.router)
router.include_router(root_registry.router)
-39
View File
@@ -38,29 +38,17 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
if ip.strip()
)
from ..database import get_db
from ..license_service import check_license
from ..models import AuthRateLimitORM, AuthUserORM, DinsarTaskBatchORM, DinsarTaskItemORM, PsTaskBatchORM, PsTaskItemORM
# ---------------------------------------------------------------------------
# Path classification constants
# ---------------------------------------------------------------------------
_LICENSE_EXEMPT_PATHS = {
"/api/license/status",
"/api/license/upload",
"/api/license/refresh",
"/api/health",
"/api/auth/login",
"/api/auth/logout",
"/api/auth/me",
}
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
READ_SAFE_POST_PATHS = {
"/api/radar-data/search",
}
PUBLIC_AUTH_PATHS = {
"/api/license/status",
"/api/health",
"/api/auth/login",
"/api/auth/logout",
@@ -68,8 +56,6 @@ PUBLIC_AUTH_PATHS = {
HIGH_RISK_WRITE_PATH_PREFIXES = (
"/api/auth/users",
"/api/license/upload",
"/api/license/refresh",
"/api/workflow/runs",
"/api/task-batches/",
"/api/tools/",
@@ -103,13 +89,6 @@ LOGIN_THROTTLE_CLEANUP_INTERVAL_SECONDS = read_int_env(
_LOGIN_THROTTLE_LOCK = asyncio.Lock()
_LOGIN_THROTTLE_LAST_CLEANUP_MONO = 0.0
_LICENSE_UPLOAD_LOCK = asyncio.Lock()
MAX_LICENSE_UPLOAD_BYTES = read_int_env(
"MAX_LICENSE_UPLOAD_BYTES",
1024 * 1024,
minimum=1024,
maximum=20 * 1024 * 1024,
)
# ---------------------------------------------------------------------------
# Statistics cache
@@ -363,24 +342,6 @@ async def _add_operation_audit_log(
)
# ---------------------------------------------------------------------------
# License guard
# ---------------------------------------------------------------------------
def _require_license(request: Request):
"""
授权校验:未授权时拒绝所有 API。
使用精确路径集合匹配,防止 endswith 绕过攻击。
"""
path = (request.url.path or "").rstrip("/") or "/"
if path in _LICENSE_EXEMPT_PATHS:
return
result = check_license()
if not result.get("ok"):
raise HTTPException(status_code=403, detail=f"License required: {result.get('reason')}")
# ---------------------------------------------------------------------------
# Auth guards
# ---------------------------------------------------------------------------
-162
View File
@@ -1,162 +0,0 @@
from __future__ import annotations
import os
import tempfile
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import read_int_env, settings
from ..database import get_db
from ..license_service import check_license
from ..models import AuthUserORM
from .dependencies import (
_add_operation_audit_log,
_get_optional_session_user,
_require_admin,
_LICENSE_UPLOAD_LOCK,
MAX_LICENSE_UPLOAD_BYTES,
)
from ..auth_service import ROLE_ADMIN
router = APIRouter()
def _serialize_license_status(raw_status: Dict[str, Any], include_details: bool) -> Dict[str, Any]:
public_payload = {
"ok": bool(raw_status.get("ok")),
"reason": raw_status.get("reason"),
"expires_at": raw_status.get("expires_at"),
"issued_to": raw_status.get("issued_to"),
}
if include_details:
return {
**public_payload,
"fingerprint": raw_status.get("fingerprint"),
"license_path": raw_status.get("license_path"),
}
return public_payload
@router.get("/license/status")
async def license_status(request: Request, db: AsyncSession = Depends(get_db)):
"""
授权状态查询(无需授权)。
默认返回脱敏字段;管理员会话可看到额外调试字段。
"""
status = check_license()
session_user = await _get_optional_session_user(request, db)
include_details = bool(session_user and session_user.role == ROLE_ADMIN)
return _serialize_license_status(status, include_details=include_details)
@router.post("/license/upload")
async def license_upload(
request: Request,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
"""
上传授权文件(覆盖后立即生效)。
"""
filename = (file.filename or "").strip()
if not filename.lower().endswith('.lic'):
raise HTTPException(status_code=400, detail='License file must end with .lic')
license_path = settings.LICENSE_PATH
if not license_path:
license_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "license", "license.lic")
license_path = os.path.abspath(license_path)
license_dir = os.path.dirname(license_path)
os.makedirs(license_dir, exist_ok=True)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="License file is empty")
if len(content) > MAX_LICENSE_UPLOAD_BYTES:
raise HTTPException(
status_code=400,
detail=f"License file too large (max {MAX_LICENSE_UPLOAD_BYTES} bytes)",
)
upload_status: Optional[Dict[str, Any]] = None
upload_error: Optional[str] = None
tmp_path: Optional[str] = None
async with _LICENSE_UPLOAD_LOCK:
try:
with tempfile.NamedTemporaryFile(
mode="wb",
delete=False,
dir=license_dir,
prefix="license_upload_",
suffix=".lic.tmp",
) as temp_file:
temp_file.write(content)
temp_file.flush()
tmp_path = temp_file.name
upload_status = check_license(license_path=tmp_path)
if not upload_status.get("ok"):
upload_error = upload_status.get("reason") or "license validation failed"
else:
os.replace(tmp_path, license_path)
tmp_path = None
upload_status = check_license()
if not upload_status.get("ok"):
upload_error = upload_status.get("reason") or "license validation failed after activation"
except Exception as exc:
upload_error = str(exc)
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
if upload_error:
await _add_operation_audit_log(
db,
request=request,
action="license_upload_failed",
user=admin_user,
resource="license/upload",
detail={"filename": filename, "reason": upload_error},
)
await db.commit()
raise HTTPException(status_code=400, detail=f"License invalid: {upload_error}")
await _add_operation_audit_log(
db,
request=request,
action="license_uploaded",
user=admin_user,
resource="license/upload",
detail={"filename": filename, "size": len(content)},
)
await db.commit()
return {"message": "License uploaded", "status": upload_status}
@router.post("/license/refresh")
async def license_refresh(
request: Request,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
"""
刷新授权状态。
"""
status = check_license()
await _add_operation_audit_log(
db,
request=request,
action="license_refreshed",
user=admin_user,
resource="license/refresh",
detail={"license_ok": bool(status.get("ok")), "reason": status.get("reason")},
)
await db.commit()
return status
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import AuthUserORM
from ..services.ops_maintenance_service import ops_maintenance_service
from .dependencies import _add_operation_audit_log, _get_current_user, _require_admin
router = APIRouter(prefix="/ops-maintenance", tags=["ops-maintenance"])
class CleanupRequest(BaseModel):
confirm: bool = False
delete_landsar_work_dir: bool = True
@router.get("/tasks")
async def list_maintenance_tasks(
task_type: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
return await ops_maintenance_service.list_tasks(
db,
task_type=task_type,
status=status,
limit=limit,
offset=offset,
)
@router.get("/tasks/{task_id}/diagnosis")
async def diagnose_maintenance_task(
task_id: str,
_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
diagnosis = await ops_maintenance_service.diagnose_task(db, task_id)
if diagnosis is None:
raise HTTPException(status_code=404, detail="Task not found.")
return diagnosis
@router.post("/tasks/{task_id}/cleanup-preview")
async def preview_maintenance_cleanup(
task_id: str,
_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
preview = await ops_maintenance_service.cleanup_preview(db, task_id)
if preview is None:
raise HTTPException(status_code=404, detail="Task not found.")
return preview
@router.post("/tasks/{task_id}/cleanup")
async def cleanup_maintenance_task(
task_id: str,
request_body: CleanupRequest,
request: Request,
admin_user: AuthUserORM = Depends(_require_admin),
db: AsyncSession = Depends(get_db),
):
if not request_body.confirm:
raise HTTPException(status_code=400, detail="Cleanup confirmation is required.")
try:
result = await ops_maintenance_service.cleanup_task(
db,
task_id,
options=request_body.model_dump(),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=404, detail="Task not found.")
await _add_operation_audit_log(
db,
request,
action="ops_task_cleanup",
resource=f"ops-maintenance/tasks/{task_id}",
detail={
"task_id": task_id,
"options": request_body.model_dump(),
"result": result,
},
user=admin_user,
)
await db.commit()
return result
+61
View File
@@ -329,6 +329,7 @@ async def find_pairs_endpoint(
network_run_id=pairing_metadata.get("network_run_id"),
candidate_count=int(pairing_metadata.get("candidate_count") or 0),
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
coverage=None,
)
except Exception as e:
if isinstance(e, HTTPException):
@@ -339,6 +340,66 @@ async def find_pairs_endpoint(
raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志")
@router.post("/pairing/coverage-plan", response_model=PairingResponse)
async def build_coverage_pairing_plan_endpoint(
params: PairingRequest = Depends(get_pairing_request_from_form),
target_date_from: str = Form(...),
target_date_to: str = Form(...),
extension_days: int = Form(15),
max_pairs: int = Form(200),
target_coverage_ratio: float = Form(0.98),
min_new_coverage_ratio: float = Form(0.0005),
files: Optional[List[UploadFile]] = File(None),
aoi_geojson: Optional[str] = Form(None),
require_orbit_data: bool = Form(True),
db: AsyncSession = Depends(get_db),
):
try:
resolved_aoi = await _parse_aoi_from_files(files)
if resolved_aoi is None:
resolved_aoi = _parse_aoi_geojson_form_value(aoi_geojson)
if files and resolved_aoi is None:
raise HTTPException(status_code=400, detail="Uploaded files must include a valid SHP or GeoJSON AOI.")
aoi_wkt = resolved_aoi[0] if resolved_aoi else None
response_aoi_geojson = resolved_aoi[1] if resolved_aoi else None
if not aoi_wkt:
raise HTTPException(status_code=400, detail="区域空间覆盖规划必须选择行政区或上传 AOI。")
coverage_params = params.model_copy(update={"strategy": "dinsar_province_coverage"})
pairs, runtime_warnings, pairing_metadata = await spatial_service.find_dinsar_coverage_pairs(
db,
coverage_params,
target_date_from=target_date_from,
target_date_to=target_date_to,
extension_days=extension_days,
max_pairs=max_pairs,
target_coverage_ratio=target_coverage_ratio,
min_new_coverage_ratio=min_new_coverage_ratio,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
return PairingResponse(
pairs=pairs,
aoi_geojson=response_aoi_geojson,
warnings=runtime_warnings,
fallback_used=bool(pairing_metadata.get("fallback_used")),
degraded=bool(pairing_metadata.get("degraded")),
policy_version=pairing_metadata.get("policy_version"),
network_run_id=pairing_metadata.get("network_run_id"),
candidate_count=int(pairing_metadata.get("candidate_count") or 0),
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
coverage=pairing_metadata.get("coverage"),
)
except Exception as e:
if isinstance(e, HTTPException):
raise e
if isinstance(e, (RuntimeError, ValueError)):
raise HTTPException(status_code=409, detail=str(e))
logger.exception("Failed to build D-InSAR coverage pairing plan")
raise HTTPException(status_code=500, detail="Failed to build D-InSAR coverage pairing plan.")
@router.post("/find-ps-timeseries", response_model=Dict[str, List[RadarData]])
async def find_ps_timeseries_endpoint(
params: PsRequest = Depends(get_ps_request_from_form),
+24
View File
@@ -22,6 +22,7 @@ class ResultDeliveryCreateRequest(BaseModel):
channel: str
product_ids: Optional[List[int]] = None
compat_result_ids: Optional[List[int]] = None
item_ids: Optional[List[int]] = None
package_mode: str = "directory"
include_checksums: Optional[bool] = None
@@ -64,6 +65,7 @@ async def create_result_delivery(
channel=request.channel,
product_ids=request.product_ids,
compat_result_ids=request.compat_result_ids,
item_ids=request.item_ids,
package_mode=request.package_mode,
include_checksums=request.include_checksums,
)
@@ -89,6 +91,28 @@ async def create_result_delivery(
return result_delivery_service.serialize_delivery(delivery)
@router.get("/result-deliveries/catalog/{channel}")
async def list_result_delivery_catalog(
channel: str,
limit: int = 100,
offset: int = 0,
query: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
_ = current_user
try:
return await result_delivery_service.list_channel_catalog(
db,
channel=channel,
limit=limit,
offset=offset,
query=query,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/result-deliveries")
async def list_result_deliveries(
mine: bool = True,
+221 -52
View File
@@ -11,6 +11,8 @@ from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
SOURCE_METADATA_MANAGED_FORMATS = ("LT1_ARCHIVE", "S1_ZIP")
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import distinct, func, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -51,7 +53,6 @@ from ..services.data_service import data_service
from ..services.dinsar_read_service import dinsar_read_service
from ..services.pairing_state_service import pairing_state_service
from ..services.admin_region_lookup_service import _build_region_path, _load_region_records
from ..utils import find_xml_file
from . import dependencies as _deps
from .dependencies import _get_current_user
@@ -80,6 +81,10 @@ def _ratio(numerator: int, denominator: int) -> float:
return round(float(numerator) / float(denominator), 4)
def _is_source_metadata_managed_format(value: Any) -> bool:
return str(value or "").strip().upper() in SOURCE_METADATA_MANAGED_FORMATS
def _family_label(value: Any) -> str:
text = str(value or "").strip().upper()
if text in {"LT1", "LT-1", "LUTAN", "LUTAN1"}:
@@ -102,6 +107,14 @@ def _month_from_yyyymmdd(value: Any) -> Optional[str]:
return None
def _compact_yyyymmdd(value: Any) -> Optional[str]:
text = str(value or "").strip()
digits = "".join(ch for ch in text if ch.isdigit())
if len(digits) >= 8:
return digits[:8]
return None
def _month_from_datetime(value: Any) -> Optional[str]:
if not value:
return None
@@ -594,16 +607,36 @@ async def get_statistics_dashboard(
db,
select(func.count(SourceProductAssetORM.id)).where(SourceProductAssetORM.is_active == True),
)
metadata_source_total = await _scalar_count(
db,
select(func.count(SourceProductAssetORM.id)).where(
SourceProductAssetORM.is_active == True,
SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
radar_total = await _scalar_count(db, select(func.count(RadarDataORM.id)))
metadata_asset_total = await _scalar_count(
db,
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))),
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_doc_total = await _scalar_count(
db,
select(func.count(SourceMetadataDocumentORM.id)).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
geometry_total = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_doc_total = await _scalar_count(db, select(func.count(SourceMetadataDocumentORM.id)))
geometry_total = await _scalar_count(db, select(func.count(SARSceneGeometryProfileORM.id)))
geometry_ready = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SARSceneGeometryProfileORM.metadata_quality == "READY",
SARSceneGeometryProfileORM.production_readiness == "READY",
),
@@ -633,6 +666,7 @@ async def get_statistics_dashboard(
for family, source_format, parse_status, count in source_group_rows.all():
family_label = _family_label(family)
status_label = _status_label(parse_status)
format_label = str(source_format or "UNKNOWN")
count_int = _safe_int(count)
family_bucket = source_by_family_map.setdefault(
family_label,
@@ -649,7 +683,6 @@ async def get_statistics_dashboard(
family_bucket["ready_count"] += count_int
else:
family_bucket["issue_count"] += count_int
format_label = str(source_format or "UNKNOWN")
family_bucket["formats"][format_label] = family_bucket["formats"].get(format_label, 0) + count_int
source_by_format.append(
{
@@ -673,19 +706,22 @@ async def get_statistics_dashboard(
geometry_rows = await db.execute(
select(
SARSceneGeometryProfileORM.satellite_family,
SARSceneGeometryProfileORM.source_format,
SARSceneGeometryProfileORM.metadata_quality,
SARSceneGeometryProfileORM.production_readiness,
func.count(SARSceneGeometryProfileORM.id),
)
.where(SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS))
.group_by(
SARSceneGeometryProfileORM.satellite_family,
SARSceneGeometryProfileORM.source_format,
SARSceneGeometryProfileORM.metadata_quality,
SARSceneGeometryProfileORM.production_readiness,
)
.order_by(SARSceneGeometryProfileORM.satellite_family)
)
geometry_by_family_map: dict[str, dict[str, Any]] = {}
for family, metadata_quality, production_readiness, count in geometry_rows.all():
for family, source_format, metadata_quality, production_readiness, count in geometry_rows.all():
family_label = _family_label(family)
count_int = _safe_int(count)
bucket = geometry_by_family_map.setdefault(
@@ -1075,6 +1111,82 @@ async def get_statistics_dashboard(
issue_by_severity[severity_label] = issue_by_severity.get(severity_label, 0) + count_int
issue_by_code[code_label] = issue_by_code.get(code_label, 0) + count_int
missing_orbit_issue_rows = await db.execute(
select(AssetInventoryIssueORM.metadata_json)
.where(AssetInventoryIssueORM.status == "OPEN")
.where(AssetInventoryIssueORM.issue_code == "scene_missing_orbit")
)
orbit_missing_satellite_map: dict[str, dict[str, Any]] = {}
orbit_missing_top_dates: dict[tuple[str, str], int] = {}
orbit_missing_unknown_date_scene_count = 0
for (metadata_json,) in missing_orbit_issue_rows.all():
metadata = metadata_json if isinstance(metadata_json, dict) else {}
satellite = str(metadata.get("satellite") or "UNKNOWN").strip().upper() or "UNKNOWN"
ymd = _compact_yyyymmdd(metadata.get("imaging_date") or metadata.get("acquisition_start_time_utc"))
month = _month_from_yyyymmdd(ymd) if ymd else None
satellite_bucket = orbit_missing_satellite_map.setdefault(
satellite,
{
"satellite": satellite,
"affected_scene_count": 0,
"dates": defaultdict(int),
"months": defaultdict(lambda: {"affected_scene_count": 0, "dates": set()}),
"unknown_date_scene_count": 0,
},
)
satellite_bucket["affected_scene_count"] += 1
if not ymd:
satellite_bucket["unknown_date_scene_count"] += 1
orbit_missing_unknown_date_scene_count += 1
continue
satellite_bucket["dates"][ymd] += 1
orbit_missing_top_dates[(satellite, ymd)] = orbit_missing_top_dates.get((satellite, ymd), 0) + 1
if month:
month_bucket = satellite_bucket["months"][month]
month_bucket["affected_scene_count"] += 1
month_bucket["dates"].add(ymd)
orbit_missing_by_satellite: list[dict[str, Any]] = []
for satellite, bucket in sorted(orbit_missing_satellite_map.items()):
date_items = [
{"date": date_text, "affected_scene_count": _safe_int(count)}
for date_text, count in sorted(bucket["dates"].items())
]
month_items = [
{
"month": month,
"affected_scene_count": _safe_int(month_bucket["affected_scene_count"]),
"missing_orbit_date_count": len(month_bucket["dates"]),
}
for month, month_bucket in sorted(bucket["months"].items())
]
orbit_missing_by_satellite.append(
{
"satellite": satellite,
"affected_scene_count": _safe_int(bucket["affected_scene_count"]),
"missing_orbit_date_count": len(date_items),
"unknown_date_scene_count": _safe_int(bucket["unknown_date_scene_count"]),
"first_missing_date": date_items[0]["date"] if date_items else None,
"last_missing_date": date_items[-1]["date"] if date_items else None,
"months": month_items,
"dates": date_items,
}
)
orbit_missing_summary = {
"affected_scene_count": sum(item["affected_scene_count"] for item in orbit_missing_by_satellite),
"missing_orbit_date_count": sum(item["missing_orbit_date_count"] for item in orbit_missing_by_satellite),
"unknown_date_scene_count": orbit_missing_unknown_date_scene_count,
"by_satellite": orbit_missing_by_satellite,
"top_dates": [
{"satellite": satellite, "date": date_text, "affected_scene_count": _safe_int(count)}
for (satellite, date_text), count in sorted(
orbit_missing_top_dates.items(),
key=lambda item: (-item[1], item[0][0], item[0][1]),
)[:20]
],
}
inventory_state_rows = await db.execute(
select(
AssetInventoryStateORM.inventory_type,
@@ -1114,16 +1226,24 @@ async def get_statistics_dashboard(
avg_duration_seconds = round(sum(duration_seconds) / len(duration_seconds), 1) if duration_seconds else None
selected_orbit_rate = _ratio(selected_orbit_bindings, orbit_required_total)
geometry_ready_rate = _ratio(geometry_ready, source_total)
metadata_ready_rate = _ratio(metadata_asset_total, source_total)
geometry_ready_rate = _ratio(geometry_ready, geometry_total)
metadata_ready_rate = _ratio(metadata_asset_total, metadata_source_total)
result_ready_total = sum(item["ready_count"] for item in results_by_catalog)
metadata_missing_count = max(0, metadata_source_total - metadata_asset_total)
geometry_not_ready_count = max(0, geometry_total - geometry_ready)
orbit_missing_gap_count = max(0, orbit_required_total - selected_orbit_bindings)
non_orbit_open_issue_total = sum(
count
for code, count in issue_by_code.items()
if code != "scene_missing_orbit"
)
risk_count = (
max(0, source_total - metadata_asset_total)
+ max(0, source_total - geometry_ready)
+ max(0, orbit_required_total - selected_orbit_bindings)
metadata_missing_count
+ geometry_not_ready_count
+ orbit_missing_gap_count
+ result_assets_missing
+ open_issue_total
+ non_orbit_open_issue_total
)
kpis = [
@@ -1140,7 +1260,7 @@ async def get_statistics_dashboard(
"label": "元数据入库率",
"value": round(metadata_ready_rate * 100, 1),
"unit": "%",
"note": f"{metadata_asset_total}/{source_total} 景已提取 XML/元数据",
"note": f"{metadata_asset_total}/{metadata_source_total} 景已提取 XML/元数据",
"tone": "success" if metadata_ready_rate >= 0.98 else "warning",
},
{
@@ -1148,7 +1268,7 @@ async def get_statistics_dashboard(
"label": "几何画像可用率",
"value": round(geometry_ready_rate * 100, 1),
"unit": "%",
"note": f"{geometry_ready}/{source_total} 景可用于覆盖统计",
"note": f"{geometry_ready}/{geometry_total} 景可用于覆盖统计",
"tone": "success" if geometry_ready_rate >= 0.95 else "warning",
},
{
@@ -1172,7 +1292,7 @@ async def get_statistics_dashboard(
"label": "待关注项",
"value": risk_count,
"unit": "项",
"note": f"开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}",
"note": f"缺精轨 {orbit_missing_gap_count},开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}",
"tone": "danger" if risk_count else "success",
},
]
@@ -1186,20 +1306,23 @@ async def get_statistics_dashboard(
"source_by_family": source_by_family,
"source_by_format": source_by_format,
"source_by_month": source_by_month,
"metadata_source_total": metadata_source_total,
"metadata_asset_total": metadata_asset_total,
"metadata_doc_total": metadata_doc_total,
"metadata_missing_count": metadata_missing_count,
"metadata_ready_rate": metadata_ready_rate,
"geometry_total": geometry_total,
"geometry_ready": geometry_ready,
"geometry_not_ready_count": geometry_not_ready_count,
"geometry_ready_rate": geometry_ready_rate,
"geometry_by_family": geometry_by_family,
"preview_ready": preview_ready,
"pipeline": [
{"key": "source", "label": "源资产登记", "value": source_total, "rate": 1.0},
{"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "rate": metadata_ready_rate},
{"key": "geometry", "label": "几何画像", "value": geometry_total, "rate": _ratio(geometry_total, source_total)},
{"key": "ready", "label": "可生产画像", "value": geometry_ready, "rate": geometry_ready_rate},
{"key": "preview", "label": "预览缓存", "value": preview_ready, "rate": _ratio(preview_ready, radar_total)},
{"key": "source", "label": "源资产登记", "value": source_total, "denominator": source_total, "rate": 1.0},
{"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "denominator": metadata_source_total, "rate": metadata_ready_rate},
{"key": "geometry", "label": "几何画像", "value": geometry_total, "denominator": metadata_source_total, "rate": _ratio(geometry_total, metadata_source_total)},
{"key": "ready", "label": "可生产画像", "value": geometry_ready, "denominator": geometry_total, "rate": geometry_ready_rate},
{"key": "preview", "label": "预览缓存", "value": preview_ready, "denominator": radar_total, "rate": _ratio(preview_ready, radar_total)},
],
},
"orbit": {
@@ -1208,6 +1331,8 @@ async def get_statistics_dashboard(
"orbit_required_total": orbit_required_total,
"selected_bindings": selected_orbit_bindings,
"matched_bindings": matched_orbit_bindings,
"missing_scene_count": orbit_missing_gap_count,
"missing_summary": orbit_missing_summary,
"selected_rate": selected_orbit_rate,
},
"coverage": {
@@ -1242,6 +1367,9 @@ async def get_statistics_dashboard(
"issues": {
"issue_total": issue_total,
"open_issue_total": open_issue_total,
"risk_count": risk_count,
"non_orbit_open_issue_total": non_orbit_open_issue_total,
"orbit_missing_gap_count": orbit_missing_gap_count,
"by_severity": [
{"severity": severity, "count": count}
for severity, count in sorted(issue_by_severity.items(), key=lambda kv: (-kv[1], kv[0]))
@@ -1361,12 +1489,16 @@ async def get_statistics(
"db_ready_and_cache_exists_count": 0,
"db_ready_but_cache_missing_count": 0,
}
source_xml_consistency = {
source_metadata_consistency = {
"total_records_count": 0,
"xml_detected_count": 0,
"xml_missing_count": 0,
"xml_parsed_ok_count": 0,
"xml_detected_but_unparsed_count": 0,
"metadata_document_asset_count": 0,
"metadata_document_count": 0,
"metadata_document_missing_count": 0,
"metadata_parsed_ok_count": 0,
"metadata_detected_but_unparsed_count": 0,
"geometry_ready_count": 0,
"geometry_missing_count": 0,
"geometry_not_ready_count": 0,
}
try:
@@ -1399,7 +1531,6 @@ async def get_statistics(
)
source_rows = source_rows_res.all()
source_preview_consistency["total_records_count"] = len(source_rows)
source_xml_consistency["total_records_count"] = len(source_rows)
for (
unique_id,
@@ -1415,7 +1546,6 @@ async def get_statistics(
) in source_rows:
if not file_path:
source_preview_consistency["preview_missing_count"] += 1
source_xml_consistency["xml_missing_count"] += 1
continue
cache_key = unique_id or file_path
@@ -1443,34 +1573,64 @@ async def get_statistics(
else:
source_preview_consistency["db_ready_but_cache_missing_count"] += 1
scene_dir = file_path if os.path.isdir(file_path) else os.path.dirname(file_path)
xml_path = find_xml_file(scene_dir) if scene_dir else None
has_xml = bool(xml_path and os.path.exists(xml_path))
if has_xml:
source_xml_consistency["xml_detected_count"] += 1
parsed_ok = any(
value is not None and value != ""
for value in [
scene_center_lon,
scene_center_lat,
acquisition_time_utc,
satellite_mode,
receiving_station,
product_level,
product_unique_id,
]
)
if parsed_ok:
source_xml_consistency["xml_parsed_ok_count"] += 1
else:
source_xml_consistency["xml_detected_but_unparsed_count"] += 1
else:
source_xml_consistency["xml_missing_count"] += 1
active_source_count = await _scalar_count(
db,
select(func.count(SourceProductAssetORM.id)).where(
SourceProductAssetORM.is_active == True,
SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_asset_count = await _scalar_count(
db,
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_doc_count = await _scalar_count(
db,
select(func.count(SourceMetadataDocumentORM.id)).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_parse_issue_count = await _scalar_count(
db,
select(func.count(SourceMetadataDocumentORM.id)).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SourceMetadataDocumentORM.parse_status != "OK",
),
)
geometry_ready_count = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SARSceneGeometryProfileORM.metadata_quality == "READY",
SARSceneGeometryProfileORM.production_readiness == "READY",
),
)
geometry_total_count = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
source_metadata_consistency.update(
{
"total_records_count": active_source_count,
"metadata_document_asset_count": metadata_asset_count,
"metadata_document_count": metadata_doc_count,
"metadata_document_missing_count": max(0, active_source_count - metadata_asset_count),
"metadata_parsed_ok_count": max(0, metadata_doc_count - metadata_parse_issue_count),
"metadata_detected_but_unparsed_count": metadata_parse_issue_count,
"geometry_ready_count": geometry_ready_count,
"geometry_missing_count": max(0, active_source_count - geometry_total_count),
"geometry_not_ready_count": max(0, geometry_total_count - geometry_ready_count),
}
)
except Exception as e:
logger.warning("统计源数据时发生错误 (可能是表不存在): %s", e)
source_preview_consistency["error"] = str(e)
source_xml_consistency["error"] = str(e)
source_metadata_consistency["error"] = str(e)
# 4. AI 质量统计
labeled_good_count = sum(1 for record in dinsar_records if record.product.user_label == 1)
@@ -1756,7 +1916,16 @@ async def get_statistics(
"with_orbit_data_count": with_orbit_data_count,
},
"source_preview_consistency": source_preview_consistency,
"source_xml_consistency": source_xml_consistency,
"source_metadata_consistency": source_metadata_consistency,
"source_xml_consistency": {
"deprecated": True,
"replacement": "source_metadata_consistency",
"total_records_count": source_metadata_consistency.get("total_records_count", 0),
"xml_detected_count": source_metadata_consistency.get("metadata_document_asset_count", 0),
"xml_missing_count": 0,
"xml_parsed_ok_count": source_metadata_consistency.get("metadata_parsed_ok_count", 0),
"xml_detected_but_unparsed_count": 0,
},
"water_geo_consistency": water_geo_consistency,
"pairing_consistency": pairing_consistency,
"by_satellite": by_satellite,
+89 -3
View File
@@ -5,8 +5,9 @@ from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models import DinsarTaskItemORM, ResultProductORM
from ..models import DinsarProductionRunItemORM, DinsarTaskItemORM, ResultProductORM
from ..utils import normalize_satellite_family
@@ -102,11 +103,32 @@ def _product_status(product: ResultProductORM) -> str:
return "ready"
def _status_timestamp(value: Optional[datetime]) -> float:
return _timestamp(value)
def _latest_failed_attempt(attempts: Iterable[DinsarProductionRunItemORM]) -> Optional[DinsarProductionRunItemORM]:
latest: Optional[DinsarProductionRunItemORM] = None
for attempt in attempts:
if str(attempt.status or "").strip().upper() != "FAILED":
continue
if latest is None or (
_status_timestamp(attempt.ended_at or attempt.updated_at or attempt.created_at),
attempt.id or 0,
) > (
_status_timestamp(latest.ended_at or latest.updated_at or latest.created_at),
latest.id or 0,
):
latest = attempt
return latest
def serialize_engine_result(
*,
engine_code: str,
data_family: str,
product: Optional[ResultProductORM],
failed_attempt: Optional[DinsarProductionRunItemORM] = None,
allowed: bool,
legacy: bool = False,
) -> Dict[str, Any]:
@@ -119,6 +141,10 @@ def serialize_engine_result(
status = "blocked"
can_dispatch = False
skip_reason = "unsupported_data_family"
elif product is None and failed_attempt is not None:
status = "failed"
can_dispatch = True
skip_reason = "production_failed"
elif product is None:
status = "missing"
can_dispatch = True
@@ -140,11 +166,18 @@ def serialize_engine_result(
),
"latest_product_id": product.id if product is not None else None,
"product_id": product.product_id if product is not None else None,
"run_key": product.run_key if product is not None else None,
"run_key": product.run_key if product is not None else failed_attempt.latest_run_key if failed_attempt is not None else None,
"published_at": product.published_at if product is not None else None,
"health_status": product.health_status if product is not None else None,
"primary_asset_path": product.primary_asset_path if product is not None else None,
"preview_path": product.preview_path if product is not None else None,
"production_run_id": failed_attempt.run_id if failed_attempt is not None else None,
"production_item_id": failed_attempt.id if failed_attempt is not None else None,
"production_status": failed_attempt.status if failed_attempt is not None else None,
"production_error": failed_attempt.last_error if failed_attempt is not None else None,
"latest_log_path": failed_attempt.latest_log_path if failed_attempt is not None else None,
"latest_output_dir": failed_attempt.latest_output_dir if failed_attempt is not None else None,
"attempt_ended_at": failed_attempt.ended_at if failed_attempt is not None else None,
"can_dispatch": can_dispatch,
"skip_reason": skip_reason,
}
@@ -153,6 +186,7 @@ def serialize_engine_result(
def build_engine_results(
*,
products: Iterable[ResultProductORM],
failed_attempts: Iterable[DinsarProductionRunItemORM] = (),
data_family: str = "unknown",
include_legacy: bool = False,
) -> Dict[str, Dict[str, Any]]:
@@ -168,6 +202,17 @@ def build_engine_results(
):
target[engine] = product
failed_by_engine: Dict[str, DinsarProductionRunItemORM] = {}
attempts_by_engine: Dict[str, List[DinsarProductionRunItemORM]] = {}
for attempt in failed_attempts:
engine = normalize_dinsar_engine_code(getattr(getattr(attempt, "run", None), "engine_code", ""))
if engine:
attempts_by_engine.setdefault(engine, []).append(attempt)
for engine, attempts in attempts_by_engine.items():
latest = _latest_failed_attempt(attempts)
if latest is not None:
failed_by_engine[engine] = latest
allowed = allowed_engines_for_data_family(data_family)
matrix: Dict[str, Dict[str, Any]] = {}
for engine in CURRENT_DINSAR_ENGINE_ORDER:
@@ -175,6 +220,7 @@ def build_engine_results(
engine_code=engine,
data_family=data_family,
product=latest_by_engine.get(engine),
failed_attempt=failed_by_engine.get(engine) if latest_by_engine.get(engine) is None else None,
allowed=engine in allowed,
)
@@ -184,6 +230,7 @@ def build_engine_results(
engine_code=engine,
data_family=data_family,
product=product,
failed_attempt=None,
allowed=False,
legacy=True,
)
@@ -229,6 +276,27 @@ async def build_engine_results_for_task_items(
)
products = result.scalars().all()
attempt_conditions = []
if pair_keys:
attempt_conditions.append(DinsarProductionRunItemORM.pair_key.in_(pair_keys))
if aliases:
attempt_conditions.append(DinsarProductionRunItemORM.task_alias.in_(aliases))
attempt_conditions.append(DinsarProductionRunItemORM.task_name.in_(aliases))
failed_attempts: List[DinsarProductionRunItemORM] = []
if attempt_conditions:
attempt_result = await db.execute(
select(DinsarProductionRunItemORM)
.options(selectinload(DinsarProductionRunItemORM.run))
.where(DinsarProductionRunItemORM.status == "FAILED")
.where(or_(*attempt_conditions))
.order_by(
DinsarProductionRunItemORM.ended_at.desc().nullslast(),
DinsarProductionRunItemORM.updated_at.desc().nullslast(),
DinsarProductionRunItemORM.id.desc(),
)
)
failed_attempts = attempt_result.scalars().all()
by_pair_key: Dict[str, List[ResultProductORM]] = {}
by_alias: Dict[str, List[ResultProductORM]] = {}
for product in products:
@@ -239,15 +307,33 @@ async def build_engine_results_for_task_items(
if alias:
by_alias.setdefault(alias, []).append(product)
failed_by_pair_key: Dict[str, List[DinsarProductionRunItemORM]] = {}
failed_by_alias: Dict[str, List[DinsarProductionRunItemORM]] = {}
for attempt in failed_attempts:
pair_key = str(attempt.pair_key or "").strip()
if pair_key:
failed_by_pair_key.setdefault(pair_key, []).append(attempt)
for alias in {str(attempt.task_alias or "").strip(), str(attempt.task_name or "").strip()}:
if alias:
failed_by_alias.setdefault(alias, []).append(attempt)
output: Dict[int, Dict[str, Dict[str, Any]]] = {}
for item in items:
item_products: List[ResultProductORM] = []
item_failed_attempts: List[DinsarProductionRunItemORM] = []
pair_key = str(item.pair_key or "").strip()
alias = str(item.task_alias or item.task_name or "").strip()
if pair_key:
item_products.extend(by_pair_key.get(pair_key, []))
item_failed_attempts.extend(failed_by_pair_key.get(pair_key, []))
if not item_products and alias:
item_products.extend(by_alias.get(alias, []))
if not item_failed_attempts and alias:
item_failed_attempts.extend(failed_by_alias.get(alias, []))
data_family = infer_dinsar_data_family(item.master_satellite, item.slave_satellite)
output[int(item.id)] = build_engine_results(products=item_products, data_family=data_family)
output[int(item.id)] = build_engine_results(
products=item_products,
failed_attempts=item_failed_attempts,
data_family=data_family,
)
return output
@@ -39,6 +39,7 @@ TASK_TYPE_LANDSAR_CLUSTER_PRODUCTION = "LANDSAR_CLUSTER_RUN"
RUN_STATUS_PENDING = "PENDING"
RUN_STATUS_RUNNING = "RUNNING"
RUN_STATUS_COMPLETED = "COMPLETED"
RUN_STATUS_PARTIAL_SUCCESS = "PARTIAL_SUCCESS"
RUN_STATUS_FAILED = "FAILED"
RUN_STATUS_CANCELLED = "CANCELLED"
RUN_ITEM_STATUS_PENDING = "PENDING"
@@ -66,6 +67,7 @@ RUNS_STEP_ID = "execute_items"
RUNS_STEP_NAME = "Execute ENVI D-InSAR items"
TERMINAL_RUN_STATUSES = {
RUN_STATUS_COMPLETED,
RUN_STATUS_PARTIAL_SUCCESS,
RUN_STATUS_FAILED,
RUN_STATUS_CANCELLED,
}
@@ -78,6 +80,16 @@ TERMINAL_ITEM_STATUSES = {
_SAFE_POINTER_RE = re.compile(r"[^0-9A-Za-z._-]+")
def _item_status_counts(items: List[DinsarProductionRunItemORM]) -> Dict[str, int]:
counts: Dict[str, int] = {}
for item in items:
status = str(item.status or "").strip().upper()
if not status:
continue
counts[status] = counts.get(status, 0) + 1
return counts
def _task_type_for_engine(engine_code: str) -> str:
normalized = str(engine_code or "").strip().lower()
if normalized == "sarscape":
@@ -549,6 +561,8 @@ def _public_run_status(value: str) -> str:
normalized = str(value or "").strip().upper()
if normalized == RUN_STATUS_COMPLETED:
return "success"
if normalized == RUN_STATUS_PARTIAL_SUCCESS:
return "partial_success"
if normalized == RUN_STATUS_FAILED:
return "failed"
if normalized == RUN_STATUS_CANCELLED:
@@ -573,12 +587,14 @@ class DinsarProductionService:
task_status = str(task.status or "").strip().upper()
if run_status in TERMINAL_RUN_STATUSES:
return False
if task_status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if task_status not in {"COMPLETED", "PARTIAL_SUCCESS", "FAILED", "CANCELLED", "CANCELED"}:
return False
if task_status == "COMPLETED":
next_status = RUN_STATUS_COMPLETED
elif task_status == "CANCELLED":
elif task_status == "PARTIAL_SUCCESS":
next_status = RUN_STATUS_PARTIAL_SUCCESS
elif task_status in {"CANCELLED", "CANCELED"}:
next_status = RUN_STATUS_CANCELLED
run.cancel_requested = True
else:
@@ -991,53 +1007,59 @@ class DinsarProductionService:
)
for item in items_result.scalars().all():
items_by_run_id.setdefault(item.run_id, []).append(item)
def serialize_run(run: DinsarProductionRunORM) -> Dict[str, Any]:
run_items = items_by_run_id.get(run.run_id, [])
item_counts = _item_status_counts(run_items)
return {
"run_id": run.run_id,
"product_family": run.product_family,
"engine": run.engine_code,
"profile_code": run.profile_code,
"status": _public_run_status(run.status),
"raw_status": run.status,
"started_at": _safe_epoch(run.started_at or run.created_at),
"ended_at": _safe_epoch(run.ended_at),
"task_id": run.task_id,
"workflow_run_id": run.workflow_run_id,
"root_dir": run.source_root,
"publish_root_dir": run.publish_root_dir,
"message": run.latest_message,
"summary_json": run.summary_json if isinstance(run.summary_json, dict) else {},
"total_items": max(int(run.total_items or 0), len(run_items)),
"completed_items": item_counts.get(RUN_ITEM_STATUS_COMPLETED, int(run.completed_items or 0)),
"running_items": item_counts.get(RUN_ITEM_STATUS_RUNNING, 0),
"pending_items": item_counts.get(RUN_ITEM_STATUS_PENDING, 0),
"failed_items": item_counts.get(RUN_ITEM_STATUS_FAILED, int(run.failed_items or 0)),
"skipped_items": item_counts.get(RUN_ITEM_STATUS_SKIPPED, int(run.skipped_items or 0)),
"cancelled_items": item_counts.get(RUN_ITEM_STATUS_CANCELLED, 0),
"items": [
{
"task_name": item.task_name,
"task_alias": item.task_alias,
"pair_key": item.pair_key,
"status": item.status,
"current_step": item.current_step,
"latest_run_key": item.latest_run_key,
"latest_output_dir": item.latest_output_dir,
"latest_manifest_path": item.latest_manifest_path,
"last_error": item.last_error,
"paths": _build_output_paths(
engine_code=run.engine_code,
item=item,
run_key=str(item.latest_run_key or ""),
output_dir=str(item.latest_output_dir or _execution_dir(item, str(item.latest_run_key or ""))),
manifest_path=item.latest_manifest_path,
)
if item.latest_run_key
else {},
}
for item in run_items[:5]
],
}
return {
"runs": [
{
"run_id": run.run_id,
"product_family": run.product_family,
"engine": run.engine_code,
"profile_code": run.profile_code,
"status": _public_run_status(run.status),
"raw_status": run.status,
"started_at": _safe_epoch(run.started_at or run.created_at),
"ended_at": _safe_epoch(run.ended_at),
"task_id": run.task_id,
"workflow_run_id": run.workflow_run_id,
"root_dir": run.source_root,
"publish_root_dir": run.publish_root_dir,
"message": run.latest_message,
"summary_json": run.summary_json if isinstance(run.summary_json, dict) else {},
"total_items": run.total_items,
"completed_items": run.completed_items,
"failed_items": run.failed_items,
"skipped_items": run.skipped_items,
"items": [
{
"task_name": item.task_name,
"task_alias": item.task_alias,
"pair_key": item.pair_key,
"status": item.status,
"current_step": item.current_step,
"latest_run_key": item.latest_run_key,
"latest_output_dir": item.latest_output_dir,
"latest_manifest_path": item.latest_manifest_path,
"last_error": item.last_error,
"paths": _build_output_paths(
engine_code=run.engine_code,
item=item,
run_key=str(item.latest_run_key or ""),
output_dir=str(item.latest_output_dir or _execution_dir(item, str(item.latest_run_key or ""))),
manifest_path=item.latest_manifest_path,
)
if item.latest_run_key
else {},
}
for item in items_by_run_id.get(run.run_id, [])[:5]
],
}
for run in runs
],
"runs": [serialize_run(run) for run in runs],
"total": total,
}
@@ -1132,6 +1154,12 @@ class DinsarProductionService:
f"LandSAR cluster cancelled. completed={completed} failed={failed} "
f"cancelled={cancelled} total={total_items}"
)
elif failed > 0 and (completed > 0 or skipped > 0):
final_status = RUN_STATUS_PARTIAL_SUCCESS
latest_message = (
f"LandSAR cluster partially completed. completed={completed} "
f"failed={failed} skipped={skipped} total={total_items}"
)
elif failed > 0:
final_status = RUN_STATUS_FAILED
latest_message = (
+87 -37
View File
@@ -32,7 +32,14 @@ from .cluster_transport import (
)
from .dinsar_compat_service import dinsar_compat_service
from .dinsar_naming import build_run_key
from .dinsar_production_service import dinsar_production_service
from .dinsar_production_service import (
RUN_STATUS_CANCELLED,
RUN_STATUS_COMPLETED,
RUN_STATUS_FAILED,
RUN_STATUS_PARTIAL_SUCCESS,
TERMINAL_RUN_STATUSES,
dinsar_production_service,
)
from .dinsar_read_service import dinsar_read_service
from .dinsar_result_layout_service import (
get_run_disp_asset_paths,
@@ -73,6 +80,16 @@ from ..ai_service import (
JobHandler = Callable[[SystemJobORM], Awaitable[None]]
FINAL_TASK_STATUSES = {
RUN_STATUS_COMPLETED,
RUN_STATUS_PARTIAL_SUCCESS,
RUN_STATUS_FAILED,
RUN_STATUS_CANCELLED,
"CANCELED",
"CANCELLED",
}
JOB_TYPE_SCAN_DATA = "SCAN_DATA"
JOB_TYPE_SCAN_DINSAR = "SCAN_DINSAR"
JOB_TYPE_COPY_DATA = "COPY_DATA"
@@ -246,6 +263,28 @@ def _classify_dinsar_failure(error_message: Any) -> str:
return compact if compact != "Unknown error" else "Unclassified D-InSAR failure"
def _dinsar_final_status(completed: Any, failed: Any, skipped: Any = 0) -> str:
completed_count = int(completed or 0)
failed_count = int(failed or 0)
skipped_count = int(skipped or 0)
if failed_count > 0 and (completed_count > 0 or skipped_count > 0):
return RUN_STATUS_PARTIAL_SUCCESS
if failed_count > 0:
return RUN_STATUS_FAILED
return RUN_STATUS_COMPLETED
def _task_status_for_dinsar_run(final_status: str) -> str:
normalized = str(final_status or "").strip().upper()
if normalized == RUN_STATUS_COMPLETED:
return "COMPLETED"
if normalized == RUN_STATUS_PARTIAL_SUCCESS:
return "PARTIAL_SUCCESS"
if normalized in {RUN_STATUS_CANCELLED, "CANCELLED"}:
return "CANCELLED"
return "FAILED"
async def _build_dinsar_failure_summary(db, run) -> Dict[str, Any]:
result = await db.execute(
select(DinsarProductionRunItemORM)
@@ -2204,22 +2243,28 @@ async def _run_dinsar_production_controller(job: SystemJobORM) -> None:
cancelled = await _refresh_cancel_state()
await db.refresh(run)
latest_message = ""
final_status = "COMPLETED"
final_status = RUN_STATUS_COMPLETED
if publish_error:
final_status = "FAILED"
final_status = RUN_STATUS_FAILED
latest_message = f"Result catalog publish failed: {publish_error}"
elif cancelled:
final_status = "CANCELLED"
final_status = RUN_STATUS_CANCELLED
latest_message = (
f"D-InSAR production cancelled. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
elif int(run.failed_items or 0) > 0:
final_status = "FAILED"
latest_message = (
f"D-InSAR production finished with failures. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
final_status = _dinsar_final_status(run.completed_items, run.failed_items, run.skipped_items)
if final_status == RUN_STATUS_PARTIAL_SUCCESS:
latest_message = (
f"D-InSAR production partially completed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
else:
latest_message = (
f"D-InSAR production failed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
else:
latest_message = (
f"D-InSAR production completed. completed={run.completed_items} "
@@ -2259,23 +2304,23 @@ async def _run_dinsar_production_controller(job: SystemJobORM) -> None:
)
run_log(run.run_id, f"[finish] status={final_status} message={latest_message}")
if final_status == "COMPLETED":
if final_status in {RUN_STATUS_COMPLETED, RUN_STATUS_PARTIAL_SUCCESS}:
await task_service.update_task(
job.task_id,
status="COMPLETED",
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=latest_message,
)
return
task_status = "CANCELLED" if final_status == "CANCELLED" else "FAILED"
await task_service.update_task(
job.task_id,
status=task_status,
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=latest_message,
)
raise RuntimeError(latest_message)
if final_status == RUN_STATUS_FAILED:
raise RuntimeError(latest_message)
async def _handle_idl_run_import(job: SystemJobORM) -> None:
@@ -2298,7 +2343,7 @@ async def _handle_idl_run_dinsar(job: SystemJobORM) -> None:
try:
async with AsyncSessionLocal() as db:
run = await dinsar_production_service.get_run(production_run_id, db)
if run is not None and str(run.status or "").strip().upper() not in {"COMPLETED", "FAILED", "CANCELLED"}:
if run is not None and str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
summary_payload = dict(run.summary_json or {})
summary_payload["controller_error"] = str(exc)
await dinsar_production_service.finalize_run(
@@ -2317,7 +2362,7 @@ async def _handle_idl_run_dinsar(job: SystemJobORM) -> None:
try:
current_task = await task_service.get_task(job.task_id)
if current_task and current_task.status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if current_task and str(current_task.status or "").strip().upper() not in FINAL_TASK_STATUSES:
await task_service.add_log(job.task_id, "ERROR", latest_message)
await task_service.update_task(
job.task_id,
@@ -3191,22 +3236,28 @@ async def _run_wsl_dinsar_production_controller(
cancelled = await _refresh_cancel_state()
await dinsar_production_service.refresh_run_counters(run, db=db)
if publish_error:
final_status = "FAILED"
final_status = RUN_STATUS_FAILED
latest_message = f"Result catalog publish failed: {publish_error}"
elif cancelled:
final_status = "CANCELLED"
final_status = RUN_STATUS_CANCELLED
latest_message = (
f"{engine_title} D-InSAR production cancelled. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
elif int(run.failed_items or 0) > 0:
final_status = "FAILED"
latest_message = (
f"{engine_title} D-InSAR production finished with failures. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
final_status = _dinsar_final_status(run.completed_items, run.failed_items, run.skipped_items)
if final_status == RUN_STATUS_PARTIAL_SUCCESS:
latest_message = (
f"{engine_title} D-InSAR production partially completed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
else:
latest_message = (
f"{engine_title} D-InSAR production failed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
)
else:
final_status = "COMPLETED"
final_status = RUN_STATUS_COMPLETED
latest_message = (
f"{engine_title} D-InSAR production completed. completed={run.completed_items} "
f"failed={run.failed_items} total={run.total_items}"
@@ -3246,23 +3297,23 @@ async def _run_wsl_dinsar_production_controller(
)
run_log(run.run_id, f"[finish] status={final_status} message={latest_message}")
if final_status == "COMPLETED":
if final_status in {RUN_STATUS_COMPLETED, RUN_STATUS_PARTIAL_SUCCESS}:
await task_service.update_task(
job.task_id,
status="COMPLETED",
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=latest_message,
)
return
task_status = "CANCELLED" if final_status == "CANCELLED" else "FAILED"
await task_service.update_task(
job.task_id,
status=task_status,
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=latest_message,
)
raise RuntimeError(latest_message)
if final_status == RUN_STATUS_FAILED:
raise RuntimeError(latest_message)
async def _handle_isce2_run(job: SystemJobORM) -> None:
@@ -3281,7 +3332,7 @@ async def _handle_isce2_run(job: SystemJobORM) -> None:
try:
async with AsyncSessionLocal() as db:
run = await dinsar_production_service.get_run(production_run_id, db)
if run is not None and str(run.status or "").strip().upper() not in {"COMPLETED", "FAILED", "CANCELLED"}:
if run is not None and str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
summary_payload = dict(run.summary_json or {})
summary_payload["controller_error"] = str(exc)
await dinsar_production_service.finalize_run(
@@ -3300,7 +3351,7 @@ async def _handle_isce2_run(job: SystemJobORM) -> None:
try:
current_task = await task_service.get_task(job.task_id)
if current_task and current_task.status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if current_task and str(current_task.status or "").strip().upper() not in FINAL_TASK_STATUSES:
await task_service.add_log(job.task_id, "ERROR", latest_message)
await task_service.update_task(
job.task_id,
@@ -3335,7 +3386,7 @@ async def _handle_pyint_run(job: SystemJobORM) -> None:
try:
async with AsyncSessionLocal() as db:
run = await dinsar_production_service.get_run(production_run_id, db)
if run is not None and str(run.status or "").strip().upper() not in {"COMPLETED", "FAILED", "CANCELLED"}:
if run is not None and str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
summary_payload = dict(run.summary_json or {})
summary_payload["controller_error"] = str(exc)
await dinsar_production_service.finalize_run(
@@ -3354,7 +3405,7 @@ async def _handle_pyint_run(job: SystemJobORM) -> None:
try:
current_task = await task_service.get_task(job.task_id)
if current_task and current_task.status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if current_task and str(current_task.status or "").strip().upper() not in FINAL_TASK_STATUSES:
await task_service.add_log(job.task_id, "ERROR", latest_message)
await task_service.update_task(
job.task_id,
@@ -3389,7 +3440,7 @@ async def _handle_landsar_run(job: SystemJobORM) -> None:
try:
async with AsyncSessionLocal() as db:
run = await dinsar_production_service.get_run(production_run_id, db)
if run is not None and str(run.status or "").strip().upper() not in {"COMPLETED", "FAILED", "CANCELLED"}:
if run is not None and str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
summary_payload = dict(run.summary_json or {})
summary_payload["controller_error"] = str(exc)
await dinsar_production_service.finalize_run(
@@ -3408,7 +3459,7 @@ async def _handle_landsar_run(job: SystemJobORM) -> None:
try:
current_task = await task_service.get_task(job.task_id)
if current_task and current_task.status not in {"COMPLETED", "FAILED", "CANCELLED"}:
if current_task and str(current_task.status or "").strip().upper() not in FINAL_TASK_STATUSES:
await task_service.add_log(job.task_id, "ERROR", latest_message)
await task_service.update_task(
job.task_id,
@@ -4002,10 +4053,9 @@ async def _handle_landsar_cluster_item(job: SystemJobORM) -> None:
)
if final_status:
task_status = "COMPLETED" if final_status == "COMPLETED" else ("CANCELLED" if final_status == "CANCELLED" else "FAILED")
await task_service.update_task(
job.task_id,
status=task_status,
status=_task_status_for_dinsar_run(final_status),
progress=100,
message=run.latest_message,
)
@@ -0,0 +1,537 @@
from __future__ import annotations
import os
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import (
DinsarProductionExecutionORM,
DinsarProductionRunItemORM,
DinsarProductionRunORM,
SystemJobORM,
SystemTaskORM,
TaskLogORM,
)
MAINTENANCE_LIST_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"}
ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"}
DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"}
SUPPORTED_CLEANUP_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN"}
DEFAULT_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA", "PAIRING_CACHE_REBUILD"}
LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES = {"COMPLETED", "FAILED", "CANCELLED"}
def _utcnow_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _dt(value: Any) -> Optional[str]:
if value is None:
return None
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value)
def _norm_status(value: Any) -> str:
return str(value or "").strip().upper()
def _compact(value: Any, limit: int = 220) -> Optional[str]:
if value is None:
return None
text = str(value).replace("\r", " ").replace("\n", " ").strip()
if len(text) <= limit:
return text
return text[:limit] + "..."
def _normalize_path_text(value: Any) -> str:
return os.path.normpath(str(value or "").strip().strip('"').strip("'"))
def _path_exists(path: str) -> bool:
return bool(path) and os.path.exists(path)
def _is_within_path(path: str, parent: str) -> bool:
path_text = _normalize_path_text(path)
parent_text = _normalize_path_text(parent)
if not path_text or not parent_text:
return False
try:
return os.path.commonpath([os.path.abspath(path_text), os.path.abspath(parent_text)]) == os.path.abspath(parent_text)
except ValueError:
return False
class OpsMaintenanceService:
async def list_tasks(
self,
db: AsyncSession,
*,
task_type: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> Dict[str, Any]:
safe_limit = max(1, min(int(limit or 50), 200))
safe_offset = max(0, int(offset or 0))
task_types = [task_type.strip().upper()] if task_type else sorted(DEFAULT_TASK_TYPES)
status_filter = status.strip().upper() if status else ""
conditions = [SystemTaskORM.task_type.in_(task_types)]
if status_filter:
conditions.append(SystemTaskORM.status == status_filter)
else:
conditions.append(SystemTaskORM.status.in_(sorted(MAINTENANCE_LIST_STATUSES)))
result = await db.execute(
select(SystemTaskORM)
.where(*conditions)
.order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc())
.offset(safe_offset)
.limit(safe_limit)
)
tasks = list(result.scalars().all())
items = [await self._build_task_summary(db, task) for task in tasks]
abnormal_items = [
item
for item in items
if item.get("issue_level") in {"warning", "danger"}
or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
]
visible_items = items if status_filter else abnormal_items
return {
"items": visible_items,
"limit": safe_limit,
"offset": safe_offset,
"returned": len(visible_items),
}
async def diagnose_task(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
task = await self._get_task(db, task_id)
if task is None:
return None
jobs = await self._get_jobs(db, task.task_id)
run = await self._get_production_run_for_task(db, task, jobs)
item_counts: Dict[str, int] = {}
execution_counts: Dict[str, int] = {}
disk_paths: List[Dict[str, Any]] = []
if run is not None:
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id)
disk_paths.extend(await self._collect_run_disk_paths(db, run))
recent_logs = await self._recent_logs(db, task.task_id)
findings, cleanup_supported, cleanup_blockers = self._diagnose_findings(
task=task,
jobs=jobs,
run=run,
item_counts=item_counts,
execution_counts=execution_counts,
disk_paths=disk_paths,
)
return {
"task": self._task_payload(task),
"jobs": [self._job_payload(job) for job in jobs],
"production_run": self._run_payload(run) if run else None,
"production_item_counts": item_counts,
"production_execution_counts": execution_counts,
"recent_logs": recent_logs,
"disk_paths": disk_paths,
"diagnosis": {
"summary": findings[0] if findings else "未发现明显异常。",
"findings": findings,
"cleanup_supported": cleanup_supported,
"cleanup_blockers": cleanup_blockers,
},
}
async def cleanup_preview(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
diagnosis = await self.diagnose_task(db, task_id)
if diagnosis is None:
return None
task = diagnosis["task"]
task_type = _norm_status(task.get("task_type"))
task_status = _norm_status(task.get("status"))
cleanup_supported = bool(diagnosis["diagnosis"].get("cleanup_supported"))
blockers = list(diagnosis["diagnosis"].get("cleanup_blockers") or [])
if task_type not in SUPPORTED_CLEANUP_TASK_TYPES:
blockers.append(f"第一版暂不支持清理任务类型 {task_type}。")
if task_status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态,不能清理。")
disk_deletes = self._cleanup_disk_targets(diagnosis)
has_existing_target = any(item.get("exists") and item.get("allowed") for item in disk_deletes)
if not has_existing_target:
blockers.append("未发现可清理的 LandSAR_WORK_ROOT/run_* 目录。")
blocked = bool(blockers) or not cleanup_supported
return {
"task_id": task_id,
"blocked": blocked,
"blockers": blockers,
"cleanup_supported": cleanup_supported and not blocked,
"database_deletes": {},
"disk_deletes": disk_deletes,
}
async def cleanup_task(
self,
db: AsyncSession,
task_id: str,
*,
options: Dict[str, bool],
) -> Optional[Dict[str, Any]]:
preview = await self.cleanup_preview(db, task_id)
if preview is None:
return None
if preview.get("blocked"):
raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"]))
task = await self._get_task(db, task_id)
if task is None:
return None
disk_result = await self._delete_disk_targets(preview, options)
return {
"task_id": task_id,
"deleted_database": {},
"deleted_disk": disk_result,
}
async def _get_task(self, db: AsyncSession, task_id: str) -> Optional[SystemTaskORM]:
result = await db.execute(select(SystemTaskORM).where(SystemTaskORM.task_id == str(task_id or "").strip()))
return result.scalar_one_or_none()
async def _get_jobs(self, db: AsyncSession, task_id: str) -> List[SystemJobORM]:
result = await db.execute(
select(SystemJobORM)
.where(SystemJobORM.task_id == str(task_id or "").strip())
.order_by(SystemJobORM.updated_at.desc(), SystemJobORM.id.desc())
)
return list(result.scalars().all())
async def _get_production_run_for_task(
self,
db: AsyncSession,
task: SystemTaskORM,
jobs: List[SystemJobORM],
) -> Optional[DinsarProductionRunORM]:
candidates: List[str] = []
params = task.params or {}
if isinstance(params, dict):
for key in ("production_run_id", "run_id"):
if params.get(key):
candidates.append(str(params[key]))
for job in jobs:
payload = job.payload or {}
if isinstance(payload, dict):
for key in ("production_run_id", "run_id", "dinsar_run_id"):
if payload.get(key):
candidates.append(str(payload[key]))
if candidates:
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id.in_(candidates)))
run = result.scalar_one_or_none()
if run is not None:
return run
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.task_id == task.task_id))
return result.scalar_one_or_none()
async def _build_task_summary(self, db: AsyncSession, task: SystemTaskORM) -> Dict[str, Any]:
jobs = await self._get_jobs(db, task.task_id)
run = await self._get_production_run_for_task(db, task, jobs)
item_counts: Dict[str, int] = {}
execution_counts: Dict[str, int] = {}
if run is not None:
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id)
findings, cleanup_supported, blockers = self._diagnose_findings(
task=task,
jobs=jobs,
run=run,
item_counts=item_counts,
execution_counts=execution_counts,
disk_paths=[],
)
log_count = await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id == task.task_id))
return {
**self._task_payload(task),
"run_id": run.run_id if run else None,
"job_id": jobs[0].job_id if jobs else None,
"job_status": jobs[0].status if jobs else None,
"issue_level": "danger" if _norm_status(task.status) == "FAILED" else ("warning" if findings else "info"),
"issue_summary": findings[0] if findings else _compact(task.message, 160),
"cleanup_supported": cleanup_supported,
"cleanup_blocked_reason": "; ".join(blockers) if blockers else "",
"counts": {
"jobs": len(jobs),
"logs": log_count,
"run_items": sum(item_counts.values()),
"executions": sum(execution_counts.values()),
"completed_items": item_counts.get("COMPLETED", 0),
"failed_items": item_counts.get("FAILED", 0),
"pending_items": item_counts.get("PENDING", 0),
"running_items": item_counts.get("RUNNING", 0),
},
}
async def _status_counts(self, db: AsyncSession, model: Any, run_id: str) -> Dict[str, int]:
result = await db.execute(
select(model.status, func.count())
.where(model.run_id == run_id)
.group_by(model.status)
)
return {str(status or "UNKNOWN").upper(): int(count or 0) for status, count in result.all()}
async def _recent_logs(self, db: AsyncSession, task_id: str) -> List[Dict[str, Any]]:
result = await db.execute(
select(TaskLogORM)
.where(TaskLogORM.task_id == task_id)
.order_by(TaskLogORM.timestamp.desc(), TaskLogORM.id.desc())
.limit(30)
)
return [
{
"id": item.id,
"level": item.log_level,
"message": _compact(item.message, 500),
"timestamp": _dt(item.timestamp),
}
for item in result.scalars().all()
]
async def _collect_run_disk_paths(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
paths: Dict[str, Dict[str, Any]] = {}
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
for execution in result.scalars().all():
landsar_work_dir = self._landsar_work_dir_for_execution(run, execution)
if landsar_work_dir:
payload = self._path_payload(landsar_work_dir, "landsar_work")
payload["run_key"] = execution.run_key
payload["execution_status"] = execution.status
paths[_normalize_path_text(landsar_work_dir).lower()] = payload
return list(paths.values())
def _landsar_work_dir_for_execution(
self,
run: DinsarProductionRunORM,
execution: DinsarProductionExecutionORM,
) -> str:
if _norm_status(run.engine_code) != "LANDSAR":
return ""
if _norm_status(run.status) in ACTIVE_TASK_STATUSES:
return ""
if _norm_status(execution.status) not in LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES:
return ""
work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT)
run_key = str(execution.run_key or "").strip()
if not work_root or not run_key or Path(run_key).name != run_key or not run_key.startswith("run_"):
return ""
candidate = _normalize_path_text(os.path.join(work_root, run_key))
return candidate if self._is_landsar_work_delete_path(candidate) else ""
def _diagnose_findings(
self,
*,
task: SystemTaskORM,
jobs: List[SystemJobORM],
run: Optional[DinsarProductionRunORM],
item_counts: Dict[str, int],
execution_counts: Dict[str, int],
disk_paths: List[Dict[str, Any]],
) -> tuple[List[str], bool, List[str]]:
findings: List[str] = []
blockers: List[str] = []
status = _norm_status(task.status)
if status == "FAILED":
findings.append("任务已失败,需要人工确认后清理。")
elif status == "PARTIAL_SUCCESS":
findings.append("任务部分成功,可按需清理 LandSAR 工作目录。")
elif status == "CANCELLED":
findings.append("任务已取消,可按需清理 LandSAR 工作目录。")
elif status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态。")
active_jobs = [job for job in jobs if _norm_status(job.status) in ACTIVE_JOB_STATUSES]
stale_cutoff = _utcnow_naive() - timedelta(seconds=int(getattr(settings, "JOB_WORKER_STALE_RUNNING_SECONDS", 7200)))
stale_jobs = [
job for job in active_jobs
if job.heartbeat_at is not None and job.heartbeat_at < stale_cutoff
]
if stale_jobs:
findings.append(f"发现 {len(stale_jobs)} 个心跳超时 job。")
if active_jobs and not stale_jobs:
blockers.append("仍存在活动 job。")
residual_running = item_counts.get("RUNNING", 0) + execution_counts.get("RUNNING", 0)
if run is not None and residual_running:
findings.append(f"生产 run 中仍有 {residual_running} 个 RUNNING 残留。")
if run is not None and item_counts.get("PENDING", 0):
findings.append(f"生产 run 中仍有 {item_counts.get('PENDING', 0)} 个 PENDING 项未执行。")
missing_paths = [item for item in disk_paths if item.get("path") and not item.get("exists")]
if missing_paths:
findings.append(f"有 {len(missing_paths)} 个登记路径已不存在。")
landsar_work_paths = [
item
for item in disk_paths
if item.get("kind") == "landsar_work" and item.get("exists")
]
if landsar_work_paths:
findings.append(f"发现 {len(landsar_work_paths)} 个可清理的 LandSAR 工作目录。")
cleanup_supported = (
_norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES
and not blockers
and bool(landsar_work_paths)
)
return findings, cleanup_supported, blockers
async def _count(self, db: AsyncSession, stmt: Any) -> int:
return int((await db.execute(stmt)).scalar_one() or 0)
def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]:
targets: Dict[str, Dict[str, Any]] = {}
for item in diagnosis.get("disk_paths") or []:
if item.get("kind") != "landsar_work":
continue
path = _normalize_path_text(item.get("path"))
if not path:
continue
payload = self._path_payload(path, item.get("kind") or "unknown")
payload["allowed"] = self._is_allowed_delete_path(path)
targets[path.lower()] = payload
return list(targets.values())
def _path_payload(self, path: str, kind: str) -> Dict[str, Any]:
normalized = _normalize_path_text(path)
exists = _path_exists(normalized)
return {
"path": normalized,
"kind": kind,
"exists": exists,
"allowed": self._is_allowed_delete_path(normalized),
}
def _is_allowed_delete_path(self, path: str) -> bool:
normalized = _normalize_path_text(path)
if not normalized:
return False
return self._is_landsar_work_delete_path(normalized)
def _is_landsar_work_delete_path(self, path: str) -> bool:
work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT)
normalized = _normalize_path_text(path)
if not work_root or not normalized:
return False
root_full = os.path.abspath(work_root)
full = os.path.abspath(normalized)
if full == root_full or not _is_within_path(full, root_full):
return False
try:
relative = os.path.relpath(full, root_full)
except ValueError:
return False
parts = [part for part in relative.split(os.sep) if part]
return len(parts) == 1 and parts[0].startswith("run_")
async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]:
delete_landsar_work_dir = bool(options.get("delete_landsar_work_dir", True))
deleted: List[str] = []
missing: List[str] = []
skipped: List[str] = []
failed: List[Dict[str, str]] = []
for item in preview.get("disk_deletes") or []:
kind = item.get("kind")
path = _normalize_path_text(item.get("path"))
if kind != "landsar_work":
skipped.append(path)
continue
if not item.get("allowed"):
skipped.append(path)
continue
if not delete_landsar_work_dir:
skipped.append(path)
continue
if not _path_exists(path):
missing.append(path)
continue
try:
if os.path.isdir(path):
shutil.rmtree(path)
else:
Path(path).unlink()
deleted.append(path)
except OSError as exc:
failed.append({"path": path, "error": str(exc)})
return {
"deleted": deleted,
"missing": missing,
"skipped": skipped,
"failed": failed,
}
def _task_payload(self, task: SystemTaskORM) -> Dict[str, Any]:
return {
"id": task.id,
"task_id": task.task_id,
"task_type": task.task_type,
"task_name": task.task_name,
"status": task.status,
"progress": task.progress,
"message": _compact(task.message),
"params": task.params,
"created_at": _dt(task.created_at),
"updated_at": _dt(task.updated_at),
"started_at": _dt(task.started_at),
"ended_at": _dt(task.ended_at),
}
def _job_payload(self, job: SystemJobORM) -> Dict[str, Any]:
return {
"job_id": job.job_id,
"job_type": job.job_type,
"status": job.status,
"attempts": job.attempts,
"max_attempts": job.max_attempts,
"locked_by": job.locked_by,
"locked_at": _dt(job.locked_at),
"heartbeat_at": _dt(job.heartbeat_at),
"started_at": _dt(job.started_at),
"finished_at": _dt(job.finished_at),
"last_error": _compact(job.last_error),
}
def _run_payload(self, run: DinsarProductionRunORM) -> Dict[str, Any]:
return {
"run_id": run.run_id,
"task_id": run.task_id,
"workflow_run_id": run.workflow_run_id,
"engine_code": run.engine_code,
"profile_code": run.profile_code,
"mode": run.mode,
"source_root": run.source_root,
"publish_root_dir": run.publish_root_dir,
"status": run.status,
"total_items": run.total_items,
"completed_items": run.completed_items,
"failed_items": run.failed_items,
"skipped_items": run.skipped_items,
"latest_message": _compact(run.latest_message),
"created_at": _dt(run.created_at),
"updated_at": _dt(run.updated_at),
"started_at": _dt(run.started_at),
"ended_at": _dt(run.ended_at),
}
ops_maintenance_service = OpsMaintenanceService()
+58 -5
View File
@@ -81,6 +81,41 @@ class PairingStateService:
)
return result.scalar_one_or_none()
def _cache_requires_rebuild(
self,
*,
scene_count: int,
dirty_scene_count: int,
metric_cache_count: int,
) -> bool:
return bool(
dirty_scene_count > 0
or (scene_count > 1 and metric_cache_count == 0)
)
def _effective_status(
self,
*,
state: Optional[PairingCacheStateORM],
scene_count: int,
dirty_scene_count: int,
metric_cache_count: int,
) -> str:
raw_status = str(getattr(state, "status", None) or "").strip().upper()
if not raw_status:
return "DIRTY" if self._cache_requires_rebuild(
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
) else "READY"
if raw_status == "DIRTY" and not self._cache_requires_rebuild(
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
):
return "READY"
return raw_status
def _build_state_payload(
self,
*,
@@ -89,7 +124,13 @@ class PairingStateService:
dirty_scene_count: int,
metric_cache_count: int,
) -> Dict[str, Any]:
status = state.status if state is not None else "UNINITIALIZED"
raw_status = state.status if state is not None else "UNINITIALIZED"
status = self._effective_status(
state=state,
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
)
last_error = state.last_error if state is not None else None
needs_rebuild = bool(
dirty_scene_count > 0
@@ -103,11 +144,13 @@ class PairingStateService:
"cache_scope": state.cache_scope if state is not None else PAIRING_CACHE_SCOPE_GLOBAL,
"metric_version": (state.metric_version if state is not None else None) or self.metric_version,
"status": status,
"raw_status": raw_status,
"scene_count": int(scene_count),
"pair_count": int(metric_cache_count),
"dirty_scene_count": int(dirty_scene_count),
"needs_rebuild": needs_rebuild,
"cache_ready": cache_ready,
"stale_dirty_normalized": raw_status == "DIRTY" and status == "READY",
"last_full_rebuild_at": state_dict.get("last_full_rebuild_at"),
"last_incremental_reconcile_at": state_dict.get("last_incremental_reconcile_at"),
"last_error": last_error,
@@ -130,7 +173,11 @@ class PairingStateService:
state = PairingCacheStateORM(
cache_scope=PAIRING_CACHE_SCOPE_GLOBAL,
metric_version=self.metric_version,
status="DIRTY" if scene_count > 1 else "READY",
status="DIRTY" if self._cache_requires_rebuild(
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
) else "READY",
scene_count=scene_count,
pair_count=metric_cache_count,
dirty_scene_count=dirty_scene_count,
@@ -149,10 +196,16 @@ class PairingStateService:
state.pair_count = metric_cache_count
state.dirty_scene_count = dirty_scene_count
if state.status == "READY" and (
dirty_scene_count > 0 or (scene_count > 1 and metric_cache_count == 0)
):
requires_rebuild = self._cache_requires_rebuild(
scene_count=scene_count,
dirty_scene_count=dirty_scene_count,
metric_cache_count=metric_cache_count,
)
if requires_rebuild and state.status == "READY":
state.status = "DIRTY"
elif not requires_rebuild and state.status == "DIRTY":
state.status = "READY"
state.last_error = None
elif state.status in {None, ""}:
state.status = "DIRTY" if scene_count > 1 else "READY"
@@ -12,11 +12,13 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
from geoalchemy2.shape import from_shape
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shapely.geometry import Polygon, shape
from ..config import settings
from ..models import (
DinsarProductProfileORM,
DinsarProductionRunItemORM,
DinsarTaskItemORM,
PairingMetricCacheORM,
PairingNetworkEdgeORM,
@@ -1519,8 +1521,16 @@ class ResultCatalogService:
products = result.scalars().all()
groups: Dict[str, Dict[str, Any]] = {}
pair_keys_for_trace = set()
aliases_for_trace = set()
for product in products:
pair_key = str(product.pair_key or "").strip() or f"product:{product.id}"
if str(product.pair_key or "").strip():
pair_keys_for_trace.add(str(product.pair_key or "").strip())
for alias_value in (product.task_alias, product.task_name):
alias_text = str(alias_value or "").strip()
if alias_text:
aliases_for_trace.add(alias_text)
group = groups.setdefault(
pair_key,
{
@@ -1552,11 +1562,49 @@ class ResultCatalogService:
if group.get(key) in (None, "") and getattr(product, key) not in (None, ""):
group[key] = getattr(product, key)
failed_trace_by_pair_key: Dict[str, List[DinsarProductionRunItemORM]] = {}
failed_trace_by_alias: Dict[str, List[DinsarProductionRunItemORM]] = {}
trace_conditions = []
if pair_keys_for_trace:
trace_conditions.append(DinsarProductionRunItemORM.pair_key.in_(sorted(pair_keys_for_trace)))
if aliases_for_trace:
trace_conditions.append(DinsarProductionRunItemORM.task_alias.in_(sorted(aliases_for_trace)))
trace_conditions.append(DinsarProductionRunItemORM.task_name.in_(sorted(aliases_for_trace)))
if trace_conditions:
trace_result = await db.execute(
select(DinsarProductionRunItemORM)
.options(selectinload(DinsarProductionRunItemORM.run))
.where(DinsarProductionRunItemORM.status == "FAILED")
.where(or_(*trace_conditions))
.order_by(
DinsarProductionRunItemORM.ended_at.desc().nullslast(),
DinsarProductionRunItemORM.updated_at.desc().nullslast(),
DinsarProductionRunItemORM.id.desc(),
)
)
for attempt in trace_result.scalars().all():
pair_key = str(attempt.pair_key or "").strip()
if pair_key:
failed_trace_by_pair_key.setdefault(pair_key, []).append(attempt)
for alias_value in (attempt.task_alias, attempt.task_name):
alias_text = str(alias_value or "").strip()
if alias_text:
failed_trace_by_alias.setdefault(alias_text, []).append(attempt)
items: List[Dict[str, Any]] = []
for group in groups.values():
products_for_group: List[ResultProductORM] = group.pop("_products")
failed_attempts_for_group: List[DinsarProductionRunItemORM] = []
pair_key = str(group.get("pair_key") or "").strip()
if pair_key:
failed_attempts_for_group.extend(failed_trace_by_pair_key.get(pair_key, []))
for alias_value in (group.get("task_alias"), group.get("task_name")):
alias_text = str(alias_value or "").strip()
if alias_text and not failed_attempts_for_group:
failed_attempts_for_group.extend(failed_trace_by_alias.get(alias_text, []))
engine_results = build_engine_results(
products=products_for_group,
failed_attempts=failed_attempts_for_group,
data_family=group.get("data_family") or "unknown",
include_legacy=include_legacy,
)
+453 -42
View File
@@ -20,10 +20,12 @@ from ..config import settings
from ..models import (
AuthUserORM,
DinsarResultORM,
RadarDataORM,
ResultAssetORM,
ResultDeliveryItemORM,
ResultDeliveryRequestORM,
ResultProductORM,
SARSceneGeoORM,
)
from .dinsar_read_service import dinsar_read_service
from .job_queue_service import job_queue_service
@@ -46,7 +48,17 @@ ITEM_STATUS_FAILED = "FAILED"
ITEM_STATUS_SKIPPED = "SKIPPED"
CHANNEL_DINSAR = "dinsar"
SUPPORTED_READY_CHANNELS = {CHANNEL_DINSAR}
CHANNEL_LT1_ORTHO = "lt1_ortho"
CHANNEL_GF3_ORTHO = "gf3_ortho"
CHANNEL_SBAS = "sbas"
CHANNEL_S1_ORTHO = "s1_ortho"
SUPPORTED_READY_CHANNELS = {CHANNEL_DINSAR, CHANNEL_LT1_ORTHO, CHANNEL_GF3_ORTHO}
LT1_ANALYSIS_ENGINE = "lt_gamma"
LT1_ANALYSIS_PROFILE = "lt1_gamma_geocoded_mli"
GF3_STANDARD_SOURCE_FORMAT = "GF3_SARSCAPE_L2"
GF3_NATIVE_PREVIEW_SOURCE_FORMAT = "GF3_SARSCAPE_NATIVE_PREVIEW"
GF3_DELIVERABLE_SOURCE_FORMATS = {GF3_STANDARD_SOURCE_FORMAT, GF3_NATIVE_PREVIEW_SOURCE_FORMAT}
PACKAGE_MODE_DIRECTORY = "directory"
PACKAGE_MODE_ZIP = "zip"
@@ -58,11 +70,16 @@ _SAFE_ID_RE = re.compile(r"[^0-9A-Za-z_-]+")
@dataclass(frozen=True)
class DeliverySource:
product: ResultProductORM
compat_row: Optional[DinsarResultORM]
display_name: str
source_path: str
product: Optional[ResultProductORM] = None
compat_row: Optional[DinsarResultORM] = None
source_asset_id: Optional[int] = None
source_radar_data_id: Optional[int] = None
source_scene_geo_id: Optional[int] = None
product_id: Optional[str] = None
task_name: Optional[str] = None
source_kind: str = "result_product"
def _new_session() -> AsyncSession:
@@ -208,6 +225,59 @@ def _resolve_source_path(product: ResultProductORM, assets: List[ResultAssetORM]
return "", None
def _scene_product_id(scene: SARSceneGeoORM) -> str:
return f"sar_scene_geo:{scene.id}"
def _scene_display_name(scene: SARSceneGeoORM, radar: RadarDataORM) -> str:
for value in (
radar.product_unique_id,
radar.unique_id,
radar.source_product_token,
os.path.basename(str(radar.file_path or "").rstrip("/\\")),
_scene_product_id(scene),
):
text = str(value or "").strip()
if text:
return text
return f"scene_{scene.id}"
def _radar_display_name(radar: RadarDataORM) -> str:
for value in (
radar.product_unique_id,
radar.unique_id,
radar.source_product_token,
os.path.basename(str(radar.file_path or "").rstrip("/\\")),
f"radar_{radar.id}",
):
text = str(value or "").strip()
if text:
return text
return f"radar_{radar.id}"
def _manifest_path_for_scene(scene: SARSceneGeoORM) -> Optional[str]:
metadata = scene.analysis_metadata_json if isinstance(scene.analysis_metadata_json, dict) else {}
text = str(metadata.get("manifest_path") or "").strip()
if text:
return text
if scene.analysis_dir:
return os.path.join(scene.analysis_dir, "manifest.json")
return None
def _quality_path_for_scene(scene: SARSceneGeoORM) -> Optional[str]:
if scene.analysis_dir:
return os.path.join(scene.analysis_dir, "quality.json")
return None
def _catalog_file_size(path: Any) -> int:
text = str(path or "").strip()
return _file_size(text) if text else 0
class ResultDeliveryService:
def channels(self) -> List[Dict[str, Any]]:
return [
@@ -220,7 +290,7 @@ class ResultDeliveryService:
"description": "已登记 D-InSAR catalog,可创建后台交付包并下载到本地。",
},
{
"key": "sbas",
"key": CHANNEL_SBAS,
"group": "InSAR 成果",
"label": "SBAS-InSAR 结果",
"state": "planned",
@@ -228,15 +298,15 @@ class ResultDeliveryService:
"description": "SBAS 结果 catalog 已有基础能力,本阶段暂不开放交付打包。",
},
{
"key": "lt1_ortho",
"key": CHANNEL_LT1_ORTHO,
"group": "正射成果",
"label": "LT-1 正射结果",
"state": "placeholder",
"state_text": "待接入",
"description": "陆探一正射生产将由 LandSAR 生产链注册后接入统一交付。",
"state": "ready",
"state_text": "可交付",
"description": "服务器生产的 LT-1 分析就绪正射 GeoTIFF 已接入交付,可打包下载到本地。",
},
{
"key": "s1_ortho",
"key": CHANNEL_S1_ORTHO,
"group": "正射成果",
"label": "Sentinel-1 正射结果",
"state": "placeholder",
@@ -244,12 +314,12 @@ class ResultDeliveryService:
"description": "Sentinel-1 正射生产尚未接入,当前只保留交付通道占位。",
},
{
"key": "gf3_ortho",
"key": CHANNEL_GF3_ORTHO,
"group": "正射成果",
"label": "GF3 SARscape _geo",
"state": "placeholder",
"state_text": "待接入",
"description": "GF3 外部生产成果登记后再接入统一交付。",
"state": "ready",
"state_text": "可交付",
"description": "已登记的 GF3 SARscape 标准化正射成品可直接创建交付包。",
},
]
@@ -282,11 +352,14 @@ class ResultDeliveryService:
seen_products.add(int(product.id))
sources.append(
DeliverySource(
product=product,
compat_row=None,
display_name=dinsar_read_service.get_display_name(product),
source_path=source_path,
product=product,
compat_row=None,
source_asset_id=source_asset_id,
product_id=product.product_id,
task_name=product.task_alias or product.task_name,
source_kind="dinsar_product",
)
)
@@ -319,16 +392,344 @@ class ResultDeliveryService:
seen_products.add(int(product.id))
sources.append(
DeliverySource(
product=product,
compat_row=record.compat_row,
display_name=record.display_name,
source_path=source_path,
product=product,
compat_row=record.compat_row,
source_asset_id=source_asset_id,
product_id=product.product_id,
task_name=product.task_alias or product.task_name,
source_kind="dinsar_product",
)
)
return sources
async def _resolve_lt1_sources(
self,
db: AsyncSession,
*,
item_ids: List[int],
product_ids: List[int],
) -> List[DeliverySource]:
selected_ids = item_ids or product_ids
if not selected_ids:
return []
result = await db.execute(
select(SARSceneGeoORM, RadarDataORM)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(
SARSceneGeoORM.id.in_(selected_ids),
SARSceneGeoORM.status == "DONE",
SARSceneGeoORM.analysis_tif_path.isnot(None),
SARSceneGeoORM.analysis_engine == LT1_ANALYSIS_ENGINE,
SARSceneGeoORM.analysis_profile == LT1_ANALYSIS_PROFILE,
)
.order_by(SARSceneGeoORM.id.asc())
)
sources: List[DeliverySource] = []
for scene, radar in result.all():
analysis_dir = str(scene.analysis_dir or "").strip()
analysis_tif = str(scene.analysis_tif_path or "").strip()
source_path = analysis_dir if analysis_dir and os.path.isdir(analysis_dir) else analysis_tif
if not source_path:
continue
product_id = _scene_product_id(scene)
display_name = _scene_display_name(scene, radar)
sources.append(
DeliverySource(
display_name=display_name,
source_path=source_path,
source_radar_data_id=int(radar.id) if radar.id is not None else None,
source_scene_geo_id=int(scene.id) if scene.id is not None else None,
product_id=product_id,
task_name=display_name,
source_kind="lt1_scene_geo",
)
)
return sources
async def _resolve_gf3_sources(
self,
db: AsyncSession,
*,
item_ids: List[int],
) -> List[DeliverySource]:
if not item_ids:
return []
result = await db.execute(
select(RadarDataORM, SARSceneGeoORM)
.outerjoin(SARSceneGeoORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(
RadarDataORM.id.in_(item_ids),
RadarDataORM.source_format.in_(GF3_DELIVERABLE_SOURCE_FORMATS),
RadarDataORM.geocoded_flag.is_(True),
)
.order_by(RadarDataORM.id.asc())
)
sources: List[DeliverySource] = []
for radar, scene in result.all():
source_path = ""
source_scene_geo_id = None
if scene is not None and scene.analysis_tif_path:
analysis_dir = str(scene.analysis_dir or "").strip()
analysis_tif = str(scene.analysis_tif_path or "").strip()
source_path = analysis_dir if analysis_dir and os.path.isdir(analysis_dir) else analysis_tif
source_scene_geo_id = int(scene.id) if scene.id is not None else None
if not source_path:
source_path = str(radar.file_path or "").strip()
if not source_path:
continue
display_name = _radar_display_name(radar)
sources.append(
DeliverySource(
display_name=display_name,
source_path=source_path,
source_radar_data_id=int(radar.id) if radar.id is not None else None,
source_scene_geo_id=source_scene_geo_id,
product_id=f"gf3_sarscape:{radar.id}",
task_name=display_name,
source_kind="gf3_radar_data",
)
)
return sources
async def _resolve_sources_for_channel(
self,
db: AsyncSession,
*,
channel: str,
product_ids: List[int],
compat_result_ids: List[int],
item_ids: List[int],
) -> List[DeliverySource]:
if channel == CHANNEL_DINSAR:
return await self._resolve_dinsar_sources(
db,
product_ids=product_ids,
compat_result_ids=compat_result_ids,
)
if channel == CHANNEL_LT1_ORTHO:
return await self._resolve_lt1_sources(db, item_ids=item_ids, product_ids=product_ids)
if channel == CHANNEL_GF3_ORTHO:
return await self._resolve_gf3_sources(db, item_ids=item_ids or product_ids)
return []
async def list_channel_catalog(
self,
db: AsyncSession,
*,
channel: str,
limit: int = 100,
offset: int = 0,
query: Optional[str] = None,
) -> Dict[str, Any]:
channel = _normalize_channel(channel)
safe_limit = min(500, max(1, int(limit or 100)))
safe_offset = max(0, int(offset or 0))
query_text = str(query or "").strip()
if channel == CHANNEL_LT1_ORTHO:
filters = [
SARSceneGeoORM.status == "DONE",
SARSceneGeoORM.analysis_tif_path.isnot(None),
SARSceneGeoORM.analysis_engine == LT1_ANALYSIS_ENGINE,
SARSceneGeoORM.analysis_profile == LT1_ANALYSIS_PROFILE,
]
if query_text:
like = f"%{query_text}%"
filters.append(
or_(
RadarDataORM.product_unique_id.ilike(like),
RadarDataORM.unique_id.ilike(like),
RadarDataORM.file_path.ilike(like),
RadarDataORM.imaging_date.ilike(like),
)
)
total_result = await db.execute(
select(func.count(SARSceneGeoORM.id))
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*filters)
)
rows_result = await db.execute(
select(SARSceneGeoORM, RadarDataORM)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*filters)
.order_by(SARSceneGeoORM.updated_at.desc().nullslast(), SARSceneGeoORM.id.desc())
.limit(safe_limit)
.offset(safe_offset)
)
items = []
for scene, radar in rows_result.all():
display_name = _scene_display_name(scene, radar)
manifest_path = _manifest_path_for_scene(scene)
quality_path = _quality_path_for_scene(scene)
items.append(
{
"id": scene.id,
"item_id": scene.id,
"source_kind": "lt1_scene_geo",
"product_id": _scene_product_id(scene),
"catalog_name": "sar_scene_geo",
"product_family": "lt1_analysis_ready_geotiff",
"product_type": "analysis_ready_geotiff",
"display_name": display_name,
"task_name": display_name,
"status": scene.status,
"health_status": "OK",
"engine_code": scene.analysis_engine,
"profile_code": scene.analysis_profile,
"radar_data_id": radar.id,
"scene_geo_id": scene.id,
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
"pixel_size_m": scene.pixel_size_m,
"backscatter_unit": scene.analysis_backscatter_unit,
"publish_dir": scene.analysis_dir,
"manifest_path": manifest_path,
"quality_path": quality_path,
"primary_asset_path": scene.analysis_tif_path,
"file_size": _catalog_file_size(scene.analysis_tif_path),
"summary": {
"radar_data_id": radar.id,
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
"pixel_size_m": scene.pixel_size_m,
},
"produced_at": scene.updated_at.isoformat() if scene.updated_at else None,
"published_at": scene.updated_at.isoformat() if scene.updated_at else None,
}
)
return {
"items": items,
"total": int(total_result.scalar_one() or 0),
"limit": safe_limit,
"offset": safe_offset,
}
if channel == CHANNEL_GF3_ORTHO:
filters = [
RadarDataORM.source_format.in_(GF3_DELIVERABLE_SOURCE_FORMATS),
RadarDataORM.geocoded_flag.is_(True),
]
if query_text:
like = f"%{query_text}%"
filters.append(
or_(
RadarDataORM.product_unique_id.ilike(like),
RadarDataORM.unique_id.ilike(like),
RadarDataORM.file_path.ilike(like),
RadarDataORM.imaging_date.ilike(like),
)
)
total_result = await db.execute(select(func.count(RadarDataORM.id)).where(*filters))
rows_result = await db.execute(
select(RadarDataORM, SARSceneGeoORM)
.outerjoin(SARSceneGeoORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*filters)
.order_by(RadarDataORM.acquisition_start_time_utc.desc().nullslast(), RadarDataORM.id.desc())
.limit(safe_limit)
.offset(safe_offset)
)
items = []
for radar, scene in rows_result.all():
display_name = _radar_display_name(radar)
primary_path = (
str(scene.analysis_tif_path or "").strip()
if scene is not None and scene.analysis_tif_path
else str(radar.file_path or "").strip()
)
publish_dir = (
str(scene.analysis_dir or "").strip()
if scene is not None and scene.analysis_dir
else str(radar.file_path or "").strip()
)
items.append(
{
"id": radar.id,
"item_id": radar.id,
"source_kind": "gf3_radar_data",
"product_id": f"gf3_sarscape:{radar.id}",
"catalog_name": "radar_data",
"product_family": "gf3_ortho",
"product_type": "sarscape_l2_geotiff" if radar.source_format == GF3_STANDARD_SOURCE_FORMAT else "sarscape_native_geo",
"display_name": display_name,
"task_name": display_name,
"status": "READY",
"health_status": "OK",
"engine_code": (scene.analysis_engine if scene is not None else None) or "gf3_sarscape",
"profile_code": (
(scene.analysis_profile if scene is not None else None)
or ("gf3_standard_geotiff" if radar.source_format == GF3_STANDARD_SOURCE_FORMAT else "gf3_native_geo")
),
"source_format": radar.source_format,
"radar_data_id": radar.id,
"scene_geo_id": scene.id if scene is not None else None,
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
"pixel_size_m": scene.pixel_size_m if scene is not None else None,
"backscatter_unit": scene.analysis_backscatter_unit if scene is not None else None,
"publish_dir": publish_dir,
"manifest_path": _manifest_path_for_scene(scene) if scene is not None else None,
"primary_asset_path": primary_path,
"file_size": _catalog_file_size(primary_path),
"summary": {
"radar_data_id": radar.id,
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
},
"produced_at": (
scene.updated_at.isoformat() if scene is not None and scene.updated_at else None
),
"published_at": (
scene.updated_at.isoformat() if scene is not None and scene.updated_at else None
),
}
)
return {
"items": items,
"total": int(total_result.scalar_one() or 0),
"limit": safe_limit,
"offset": safe_offset,
}
if channel == CHANNEL_DINSAR:
total = await dinsar_read_service.count_compat_records(db)
records = await dinsar_read_service.list_compat_records(db, limit=safe_limit, offset=safe_offset)
items = []
for record in records:
product = record.product
compat_row = record.compat_row
items.append(
{
"id": int(compat_row.id if compat_row is not None else product.id),
"item_id": int(compat_row.id if compat_row is not None else product.id),
"source_kind": "dinsar_result",
"product_id": product.product_id,
"catalog_name": product.catalog_name,
"product_family": product.product_family,
"product_type": product.product_type,
"display_name": record.display_name,
"task_name": product.task_alias or product.task_name,
"status": product.status,
"health_status": product.health_status,
"engine_code": product.engine_code,
"profile_code": product.profile_code,
"pair_key": product.pair_key,
"publish_dir": product.publish_dir,
"manifest_path": product.manifest_path,
"primary_asset_path": product.primary_asset_path or product.source_primary_path,
"file_size": _catalog_file_size(product.primary_asset_path or product.source_primary_path),
"produced_at": product.produced_at.isoformat() if product.produced_at else None,
"published_at": product.published_at.isoformat() if product.published_at else None,
}
)
return {"items": items, "total": total, "limit": safe_limit, "offset": safe_offset}
return {"items": [], "total": 0, "limit": safe_limit, "offset": safe_offset}
async def create_delivery(
self,
db: AsyncSession,
@@ -337,6 +738,7 @@ class ResultDeliveryService:
channel: str,
product_ids: Optional[List[int]] = None,
compat_result_ids: Optional[List[int]] = None,
item_ids: Optional[List[int]] = None,
package_mode: str = PACKAGE_MODE_DIRECTORY,
include_checksums: Optional[bool] = None,
) -> ResultDeliveryRequestORM:
@@ -348,20 +750,20 @@ class ResultDeliveryService:
max_items = max(1, int(settings.RESULT_DELIVERY_MAX_ITEMS or 500))
normalized_product_ids = _normalize_int_ids(product_ids, max_count=max_items)
normalized_compat_ids = _normalize_int_ids(compat_result_ids, max_count=max_items)
if not normalized_product_ids and not normalized_compat_ids:
normalized_item_ids = _normalize_int_ids(item_ids, max_count=max_items)
if not normalized_product_ids and not normalized_compat_ids and not normalized_item_ids:
raise ValueError("select at least one result")
if len(normalized_product_ids) + len(normalized_compat_ids) > max_items:
if len(normalized_product_ids) + len(normalized_compat_ids) + len(normalized_item_ids) > max_items:
raise ValueError(f"selected item count exceeds max limit ({max_items})")
if channel == CHANNEL_DINSAR:
sources = await self._resolve_dinsar_sources(
db,
product_ids=normalized_product_ids,
compat_result_ids=normalized_compat_ids,
)
else:
sources = []
sources = await self._resolve_sources_for_channel(
db,
channel=channel,
product_ids=normalized_product_ids,
compat_result_ids=normalized_compat_ids,
item_ids=normalized_item_ids,
)
if not sources:
raise ValueError("selected results do not have deliverable files")
@@ -376,6 +778,7 @@ class ResultDeliveryService:
"channel": channel,
"product_ids": normalized_product_ids,
"compat_result_ids": normalized_compat_ids,
"item_ids": normalized_item_ids,
"package_mode": package_mode,
"include_checksums": (
bool(settings.RESULT_DELIVERY_CHECKSUM_ENABLED)
@@ -544,6 +947,8 @@ class ResultDeliveryService:
"source_product_id": item.source_product_id,
"source_result_id": item.source_result_id,
"source_asset_id": item.source_asset_id,
"source_radar_data_id": item.source_radar_data_id,
"source_scene_geo_id": item.source_scene_geo_id,
"display_name": item.display_name,
"relative_path": item.relative_path,
"file_size": item.file_size,
@@ -636,14 +1041,14 @@ class ResultDeliveryService:
)
delivery = result.scalar_one()
request_json = delivery.request_json if isinstance(delivery.request_json, dict) else {}
if delivery.channel == CHANNEL_DINSAR:
sources = await self._resolve_dinsar_sources(
db,
product_ids=_normalize_int_ids(request_json.get("product_ids"), max_count=int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)),
compat_result_ids=_normalize_int_ids(request_json.get("compat_result_ids"), max_count=int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)),
)
else:
sources = []
max_items = int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)
sources = await self._resolve_sources_for_channel(
db,
channel=delivery.channel,
product_ids=_normalize_int_ids(request_json.get("product_ids"), max_count=max_items),
compat_result_ids=_normalize_int_ids(request_json.get("compat_result_ids"), max_count=max_items),
item_ids=_normalize_int_ids(request_json.get("item_ids"), max_count=max_items),
)
task_id = delivery.task_id
if not sources:
@@ -672,10 +1077,9 @@ class ResultDeliveryService:
total_sources = len(sources)
for source_index, source in enumerate(sources, start=1):
folder = _sanitize_segment(
source.product.task_alias
or source.product.task_name
source.task_name
or source.display_name
or source.product.product_id,
or source.product_id,
default=f"product_{source_index}",
)
source_files = _iter_associated_files(source.source_path)
@@ -684,9 +1088,11 @@ class ResultDeliveryService:
item_payloads.append(
{
"delivery_id": delivery_id,
"source_product_id": source.product.id,
"source_product_id": source.product.id if source.product else None,
"source_result_id": source.compat_row.id if source.compat_row else None,
"source_asset_id": source.source_asset_id,
"source_radar_data_id": source.source_radar_data_id,
"source_scene_geo_id": source.source_scene_geo_id,
"display_name": source.display_name,
"source_path": source.source_path,
"relative_path": None,
@@ -736,9 +1142,11 @@ class ResultDeliveryService:
item_payload = {
"delivery_id": delivery_id,
"source_product_id": source.product.id,
"source_product_id": source.product.id if source.product else None,
"source_result_id": source.compat_row.id if source.compat_row else None,
"source_asset_id": source.source_asset_id if file_index == 1 else None,
"source_radar_data_id": source.source_radar_data_id,
"source_scene_geo_id": source.source_scene_geo_id,
"display_name": source.display_name,
"source_path": source_file,
"relative_path": relative_path,
@@ -751,9 +1159,12 @@ class ResultDeliveryService:
manifest_items.append(
{
"display_name": source.display_name,
"product_id": source.product.product_id,
"product_ref_id": source.product.id,
"product_id": source.product_id or (source.product.product_id if source.product else None),
"product_ref_id": source.product.id if source.product else None,
"source_result_id": source.compat_row.id if source.compat_row else None,
"source_radar_data_id": source.source_radar_data_id,
"source_scene_geo_id": source.source_scene_geo_id,
"source_kind": source.source_kind,
"relative_path": relative_path.replace(os.sep, "/"),
"file_size": size,
"checksum_sha256": checksum,
+436 -5
View File
@@ -7,8 +7,10 @@ import hashlib
import json
import logging
import math
import re
import uuid
from collections import defaultdict
from datetime import datetime, timedelta
from itertools import combinations
from typing import Any, Dict, List, Optional, Tuple
@@ -20,7 +22,7 @@ from sqlalchemy.orm import aliased
from geoalchemy2 import Geography
from geoalchemy2.shape import to_shape
from geoalchemy2.functions import ST_Intersects, ST_Intersection, ST_Area, ST_Centroid, ST_Covers
from shapely.geometry import Polygon
from shapely.geometry import Polygon, mapping, shape
from shapely.ops import unary_union
from ..models import (
@@ -196,6 +198,112 @@ class SpatialService:
}
return result_pairs, warnings, metadata
async def find_dinsar_coverage_pairs(
self,
db: AsyncSession,
params: PairingRequest,
*,
target_date_from: str,
target_date_to: str,
extension_days: int = 15,
max_pairs: int = 200,
target_coverage_ratio: float = 0.98,
min_new_coverage_ratio: float = 0.0005,
aoi_wkt: Optional[str] = None,
require_orbit_data: bool = True,
) -> Tuple[List[RadarPair], List[str], Dict[str, Any]]:
warnings: List[str] = []
target_start = self._parse_yyyymmdd(target_date_from, field_name="target_date_from")
target_end = self._parse_yyyymmdd(target_date_to, field_name="target_date_to")
if target_end < target_start:
raise ValueError("target_date_to must be greater than or equal to target_date_from.")
safe_extension_days = max(0, min(180, int(extension_days or 0)))
safe_max_pairs = max(1, min(5000, int(max_pairs or 200)))
safe_target_coverage_ratio = max(0.0, min(1.0, float(target_coverage_ratio or 0.98)))
safe_min_new_coverage_ratio = max(0.0, min(1.0, float(min_new_coverage_ratio or 0.0)))
query_start = target_start - timedelta(days=safe_extension_days)
query_end = target_end + timedelta(days=safe_extension_days)
effective_params = self._normalize_pairing_request(params).model_copy(
update={
"master_date_from": self._format_yyyymmdd(query_start),
"master_date_to": self._format_yyyymmdd(query_end),
"slave_date_from": self._format_yyyymmdd(query_start),
"slave_date_to": self._format_yyyymmdd(query_end),
"strategy": "dinsar_province_coverage",
}
)
pairing_status = await pairing_state_service.get_pairing_system_status(db)
cache_status = str(pairing_status.get("status") or "UNINITIALIZED")
scene_count = int(pairing_status.get("scene_count") or 0)
pair_count = int(pairing_status.get("pair_count") or 0)
degraded = bool(pairing_status.get("needs_rebuild"))
if cache_status in {"FAILED", "UNINITIALIZED", "ERROR"} or (scene_count > 1 and pair_count == 0):
raise RuntimeError(
"Pairing candidate cache is not available. Repair or rebuild the pairing foundation first."
)
if degraded:
warnings.append(
f"Pairing foundation status is {cache_status}; coverage plan uses current cached candidates."
)
candidate_pool = await self._query_pairing_metric_cache(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
selected_candidates, coverage_meta, strategy_warnings = self._apply_province_coverage_strategy(
candidate_pool,
target_start=target_start,
target_end=target_end,
query_start=query_start,
query_end=query_end,
max_pairs=safe_max_pairs,
target_coverage_ratio=safe_target_coverage_ratio,
min_new_coverage_ratio=safe_min_new_coverage_ratio,
aoi_wkt=aoi_wkt,
)
warnings.extend(strategy_warnings)
if not selected_candidates:
warnings.extend(
await self._build_empty_pairing_diagnostics(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
)
for candidate in selected_candidates:
candidate["selection_strategy"] = "dinsar_province_coverage"
self._ensure_candidate_identity(candidate)
network_run_id = await self._persist_network_run(
db,
params=effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
warnings=warnings,
candidate_pool=candidate_pool,
selected_candidates=selected_candidates,
)
await self._attach_dinsar_production_summaries(db, selected_candidates)
result_pairs = self._generate_task_names(self._build_radar_pairs(selected_candidates))
metadata = {
"fallback_used": False,
"degraded": degraded,
"policy_version": PAIRING_POLICY_VERSION,
"network_run_id": network_run_id,
"candidate_count": len(candidate_pool),
"selected_edge_count": len(result_pairs),
"coverage": coverage_meta,
}
return result_pairs, warnings, metadata
def _normalize_pairing_request(self, params: PairingRequest) -> PairingRequest:
updates: Dict[str, Any] = {}
@@ -408,6 +516,27 @@ class SpatialService:
selection_strategy=candidate.get("selection_strategy"),
selection_score=float(selection_score) if selection_score is not None else None,
selection_reason=candidate.get("selection_reason"),
coverage_rank=(
int(candidate["coverage_rank"])
if candidate.get("coverage_rank") is not None
else None
),
aoi_new_area_ratio=(
float(candidate["aoi_new_area_ratio"])
if candidate.get("aoi_new_area_ratio") is not None
else None
),
aoi_pair_area_ratio=(
float(candidate["aoi_pair_area_ratio"])
if candidate.get("aoi_pair_area_ratio") is not None
else None
),
aoi_coverage_ratio_after=(
float(candidate["aoi_coverage_ratio_after"])
if candidate.get("aoi_coverage_ratio_after") is not None
else None
),
effective_coverage_geojson=candidate.get("effective_coverage_geojson"),
time_baseline_days=int(candidate["days"]),
spatial_baseline_meters=float(candidate["dist"]),
scene_center_distance_meters=float(
@@ -701,6 +830,253 @@ class SpatialService:
output.append(item)
return output
def _apply_province_coverage_strategy(
self,
candidate_pool: List[dict],
*,
target_start: datetime,
target_end: datetime,
query_start: datetime,
query_end: datetime,
max_pairs: int,
target_coverage_ratio: float,
min_new_coverage_ratio: float,
aoi_wkt: Optional[str],
) -> Tuple[List[dict], Dict[str, Any], List[str]]:
warnings: List[str] = []
target_days = self._date_set(target_start, target_end)
covered_days: set[datetime] = set()
selected: List[dict] = []
remaining = [candidate for candidate in candidate_pool if self._candidate_date_window(candidate) is not None]
seen_metric_ids: set[int] = set()
aoi_poly = self._parse_optional_aoi_polygon(aoi_wkt)
geometry_cache: Dict[int, Any] = {}
selected_coverage = Polygon()
aoi_area = float(aoi_poly.area or 0.0) if aoi_poly is not None else 0.0
stop_reason = "no_more_gain"
max_pairs_reached = False
while remaining and len(selected) < max_pairs:
best_candidate = None
best_score: Optional[Tuple[float, float, float, float, float, float, str]] = None
current_aoi_coverage_ratio = (
float(selected_coverage.area or 0.0) / aoi_area
if aoi_poly is not None and aoi_area > 0
else None
)
if current_aoi_coverage_ratio is not None and current_aoi_coverage_ratio >= target_coverage_ratio:
stop_reason = "target_coverage_reached"
break
for candidate in remaining:
window = self._candidate_date_window(candidate)
if window is None:
continue
candidate_days = self._date_set(*window) & target_days
new_days = candidate_days - covered_days
new_area = 0.0
candidate_area = 0.0
if aoi_poly is not None:
candidate_geom = self._get_candidate_intersection_geom(
candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
)
if candidate_geom is not None and not candidate_geom.is_empty:
candidate_area = float(candidate_geom.area or 0.0)
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
if aoi_poly is not None:
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
if new_area_ratio <= 0:
continue
elif not new_days:
continue
quality_score = float(candidate.get("dinsar_quality_score") or 0.0)
overlap = float(candidate.get("overlap_ratio") or 0.0)
temporal_days = float(candidate.get("days") or 0.0)
distance = float(candidate.get("scene_center_distance_meters") or candidate.get("dist") or 0.0)
if aoi_poly is not None and aoi_area > 0:
score = (
new_area_ratio,
candidate_area / aoi_area,
float(len(new_days)) / max(1, len(target_days)),
overlap,
quality_score,
-temporal_days - (distance / 1000000.0),
str(candidate.get("pair_uid") or ""),
)
else:
score = (
float(len(new_days)),
float(len(candidate_days)),
overlap,
quality_score,
0.0,
-temporal_days - (distance / 1000000.0),
str(candidate.get("pair_uid") or ""),
)
if best_score is None or score > best_score:
best_candidate = candidate
best_score = score
if best_candidate is None:
stop_reason = "no_candidate_adds_coverage"
break
if aoi_poly is None and covered_days == target_days:
stop_reason = "target_time_reached"
break
window = self._candidate_date_window(best_candidate)
candidate_days = self._date_set(*window) & target_days if window else set()
new_days = candidate_days - covered_days
candidate_geom = self._get_candidate_intersection_geom(
best_candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
) if aoi_poly is not None else None
new_area = 0.0
candidate_area = 0.0
if candidate_geom is not None and not candidate_geom.is_empty:
candidate_area = float(candidate_geom.area or 0.0)
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
if aoi_poly is not None and len(selected) > 0 and new_area_ratio < min_new_coverage_ratio:
stop_reason = "marginal_gain_below_threshold"
break
best_candidate["selection_reason"] = "province_coverage_new_days"
if aoi_poly is not None and new_area > 0:
best_candidate["selection_reason"] = "province_aoi_new_area"
best_candidate["selection_score"] = float(best_score[0] if best_score else len(new_days))
best_candidate["coverage_rank"] = len(selected) + 1
best_candidate["coverage_new_days"] = len(new_days)
best_candidate["coverage_total_days"] = len(candidate_days)
best_candidate["aoi_new_area_ratio"] = new_area_ratio if aoi_area > 0 else None
best_candidate["aoi_pair_area_ratio"] = (candidate_area / aoi_area) if aoi_area > 0 else None
next_selected_coverage = selected_coverage
if candidate_geom is not None and not candidate_geom.is_empty:
next_selected_coverage = unary_union([selected_coverage, candidate_geom])
best_candidate["aoi_coverage_ratio_after"] = (
float(next_selected_coverage.area or 0.0) / aoi_area
if aoi_area > 0 else None
)
best_candidate["effective_coverage_geojson"] = self._geometry_to_geojson(candidate_geom)
best_candidate["target_coverage_ratio_after"] = (
len(covered_days | candidate_days) / max(1, len(target_days))
)
selected.append(best_candidate)
covered_days |= candidate_days
selected_coverage = next_selected_coverage
seen_metric_ids.add(int(best_candidate.get("metric_cache_ref_id") or 0))
remaining = [
candidate for candidate in remaining
if int(candidate.get("metric_cache_ref_id") or 0) not in seen_metric_ids
]
if remaining and len(selected) >= max_pairs:
max_pairs_reached = True
stop_reason = "max_pairs_reached"
uncovered_ranges = self._date_ranges_from_days(target_days - covered_days)
if uncovered_ranges:
warnings.append(
"Coverage plan did not fully cover the requested time range. "
f"Uncovered ranges: {', '.join(f'{item[0]}~{item[1]}' for item in uncovered_ranges[:6])}"
)
if max_pairs_reached:
warnings.append(
f"Coverage planning reached task limit max_pairs={max_pairs}; increase the limit for better spatial coverage."
)
selected_area = float(selected_coverage.area or 0.0) if aoi_poly is not None else 0.0
aoi_coverage_ratio = (selected_area / aoi_area) if aoi_area > 0 else None
if aoi_coverage_ratio is not None and aoi_coverage_ratio < target_coverage_ratio:
warnings.append(
f"AOI spatial coverage is {aoi_coverage_ratio:.1%}; target is {target_coverage_ratio:.1%}."
)
temporal_ratio = len(covered_days) / max(1, len(target_days))
coverage_meta = {
"strategy": "dinsar_province_coverage",
"optimization_goal": "minimize_pair_count_for_aoi_spatial_coverage",
"greedy_rule": "select_pair_with_largest_new_aoi_intersection_area_each_step",
"stop_reason": stop_reason,
"max_pairs": max_pairs,
"target_coverage_ratio": round(target_coverage_ratio, 6),
"min_new_coverage_ratio": round(min_new_coverage_ratio, 6),
"coverage_basis": "aoi_spatial" if aoi_coverage_ratio is not None else "temporal",
"target_date_from": self._format_yyyymmdd(target_start),
"target_date_to": self._format_yyyymmdd(target_end),
"query_date_from": self._format_yyyymmdd(query_start),
"query_date_to": self._format_yyyymmdd(query_end),
"target_day_count": len(target_days),
"covered_day_count": len(covered_days),
"temporal_coverage_ratio": round(temporal_ratio, 6),
"aoi_coverage_ratio": round(aoi_coverage_ratio, 6) if aoi_coverage_ratio is not None else None,
"coverage_ratio": round(aoi_coverage_ratio if aoi_coverage_ratio is not None else temporal_ratio, 6),
"uncovered_ranges": [
{"date_from": start, "date_to": end}
for start, end in uncovered_ranges
],
"selected_pair_count": len(selected),
"candidate_count": len(candidate_pool),
}
return selected, coverage_meta, warnings
def _candidate_date_window(self, candidate: dict) -> Optional[Tuple[datetime, datetime]]:
master = candidate.get("master")
slave = candidate.get("slave")
master_date = self._try_parse_yyyymmdd(getattr(master, "imaging_date", None))
slave_date = self._try_parse_yyyymmdd(getattr(slave, "imaging_date", None))
if master_date is None or slave_date is None:
return None
return (master_date, slave_date) if master_date <= slave_date else (slave_date, master_date)
def _sort_coverage_candidates(self, candidates: List[dict]) -> List[dict]:
return sorted(
candidates,
key=lambda item: (
str(getattr(item.get("master"), "imaging_date", "") or ""),
str(getattr(item.get("slave"), "imaging_date", "") or ""),
-float(item.get("selection_score") or 0.0),
str(item.get("pair_uid") or ""),
),
)
def _date_set(self, start: datetime, end: datetime) -> set[datetime]:
if end < start:
return set()
return {start + timedelta(days=offset) for offset in range((end - start).days + 1)}
def _date_ranges_from_days(self, days: set[datetime]) -> List[Tuple[str, str]]:
if not days:
return []
ordered = sorted(days)
ranges: List[Tuple[datetime, datetime]] = []
start = previous = ordered[0]
for day in ordered[1:]:
if day == previous + timedelta(days=1):
previous = day
continue
ranges.append((start, previous))
start = previous = day
ranges.append((start, previous))
return [(self._format_yyyymmdd(start), self._format_yyyymmdd(end)) for start, end in ranges]
def _parse_yyyymmdd(self, value: str, *, field_name: str) -> datetime:
parsed = self._try_parse_yyyymmdd(value)
if parsed is None:
raise ValueError(f"{field_name} must be YYYYMMDD.")
return parsed
def _try_parse_yyyymmdd(self, value: Any) -> Optional[datetime]:
text_value = str(value or "").strip()
if not re.match(r"^\d{8}$", text_value):
return None
try:
return datetime.strptime(text_value, "%Y%m%d")
except ValueError:
return None
def _format_yyyymmdd(self, value: datetime) -> str:
return value.strftime("%Y%m%d")
async def _persist_network_run(
self,
db: AsyncSession,
@@ -797,6 +1173,13 @@ class SpatialService:
if candidate.get("pair_aoi_overlap_ratio") is not None
else None
),
"coverage_rank": candidate.get("coverage_rank"),
"coverage_new_days": candidate.get("coverage_new_days"),
"coverage_total_days": candidate.get("coverage_total_days"),
"aoi_new_area_ratio": candidate.get("aoi_new_area_ratio"),
"aoi_pair_area_ratio": candidate.get("aoi_pair_area_ratio"),
"aoi_coverage_ratio_after": candidate.get("aoi_coverage_ratio_after"),
"target_coverage_ratio_after": candidate.get("target_coverage_ratio_after"),
}
def _stable_sha1(self, value: Any) -> str:
@@ -949,7 +1332,12 @@ class SpatialService:
warnings.extend(strategy_warnings)
edges: List[Dict[str, Any]] = []
for edge_rank, candidate in enumerate(self._sorted_candidates(selected_candidates), start=1):
ordered_candidates = (
selected_candidates
if params.strategy == "dinsar_province_coverage"
else self._sorted_candidates(selected_candidates)
)
for edge_rank, candidate in enumerate(ordered_candidates, start=1):
master = candidate["master"]
slave = candidate["slave"]
edges.append(
@@ -1820,9 +2208,14 @@ class SpatialService:
return geometry_cache[cache_key]
try:
master_poly = Polygon(candidate["master"].coverage_polygon)
slave_poly = Polygon(candidate["slave"].coverage_polygon)
if master_poly.is_empty or slave_poly.is_empty:
master_poly = self._coverage_polygon_to_shape(getattr(candidate["master"], "coverage_polygon", None))
slave_poly = self._coverage_polygon_to_shape(getattr(candidate["slave"], "coverage_polygon", None))
if (
master_poly is None
or slave_poly is None
or master_poly.is_empty
or slave_poly.is_empty
):
geometry_cache[cache_key] = None
return None
pair_geom = master_poly.intersection(slave_poly)
@@ -1834,6 +2227,44 @@ class SpatialService:
geometry_cache[cache_key] = None
return None
def _coverage_polygon_to_shape(self, coverage_polygon: Any):
if not coverage_polygon:
return None
try:
if isinstance(coverage_polygon, dict):
if coverage_polygon.get("type") == "Feature":
geometry = coverage_polygon.get("geometry")
if not geometry:
return None
geom = shape(geometry)
else:
geom = shape(coverage_polygon)
elif isinstance(coverage_polygon, list):
points = []
for point in coverage_polygon:
if isinstance(point, (list, tuple)) and len(point) >= 2:
lon = float(point[0])
lat = float(point[1])
points.append((lon, lat))
if len(points) < 3:
return None
geom = Polygon(points)
else:
return None
if geom.is_empty or not geom.is_valid:
return None
return geom
except Exception:
return None
def _geometry_to_geojson(self, geometry: Any) -> Optional[Dict[str, Any]]:
try:
if geometry is None or geometry.is_empty:
return None
return mapping(geometry)
except Exception:
return None
def _score_sbas_candidate(
self,
candidate: dict,
+8 -1
View File
@@ -48,6 +48,13 @@ TASK_QUERY_MAX_OFFSET = read_int_env(
minimum=0,
maximum=20000000,
)
TERMINAL_TASK_STATUSES = {
"COMPLETED",
"PARTIAL_SUCCESS",
"FAILED",
"CANCELLED",
"CANCELED",
}
def _clamp_pagination(limit: int, offset: int, *, default_limit: int, max_limit: int) -> tuple[int, int]:
@@ -270,7 +277,7 @@ class TaskService:
await self._add_log(task_id, "INFO", message, db=db)
# 如果任务结束,更新结束时间
if status in ["COMPLETED", "FAILED", "CANCELLED"]:
if status and str(status).strip().upper() in TERMINAL_TASK_STATUSES:
task.ended_at = datetime.now()
await self._add_log(task_id, "INFO", f"任务已结束: {status}", db=db)
else:
@@ -0,0 +1,7 @@
ALTER TABLE result_delivery_items ADD COLUMN IF NOT EXISTS source_radar_data_id INTEGER NULL REFERENCES radar_data(id) ON DELETE SET NULL;
ALTER TABLE result_delivery_items ADD COLUMN IF NOT EXISTS source_scene_geo_id INTEGER NULL REFERENCES sar_scene_geo(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS ix_result_delivery_items_source_radar_data_id ON result_delivery_items(source_radar_data_id);
CREATE INDEX IF NOT EXISTS ix_result_delivery_items_source_scene_geo_id ON result_delivery_items(source_scene_geo_id);
CREATE INDEX IF NOT EXISTS idx_result_delivery_items_radar ON result_delivery_items(source_radar_data_id);
CREATE INDEX IF NOT EXISTS idx_result_delivery_items_scene_geo ON result_delivery_items(source_scene_geo_id);
@@ -0,0 +1,32 @@
# Application License Removal Audit
Date: 2026-07-02
## Decision
The application-level offline license gate has been removed. The system now relies on login sessions, role checks, audit logging, deployment network controls, and operation-level guards. LandSAR vendor license configuration is not part of this removal and remains required for LandSAR production runtimes.
## Removed Scope
- Global FastAPI license dependency and `/api/license/*` routes.
- Backend LIC2 verification service and bundled license issuer tools.
- Frontend license status store fields, license overlay, login-page license warning, and header license chip.
- Deployment example variable `LICENSE_PATH`.
- Audit-log display labels for removed license upload/refresh actions.
## Preserved Scope
- User authentication and session cookie flow.
- Admin versus read-only write protection.
- High-risk write audit logging.
- LandSAR runtime license settings such as `LANDSAR_LICENSE_MODE`, `LANDSAR_LICENSE_HOST`, and `LANDSAR_LICENSE_PORT`.
## Operational Result
Startup and API availability no longer depend on a local `.lic` file or issuer-generated public/private key material. Removing the gate also removes a misleading security boundary: application access control must be handled by account roles, network exposure, and deployment policy rather than a local offline license file.
## Follow-up Checks
- Keep `.env` and initial admin credentials out of deployable artifacts.
- Keep LandSAR license server settings documented as vendor runtime requirements, not application authorization.
- Continue using the operations maintenance panel for failed task cleanup and audit review.
@@ -0,0 +1,63 @@
# D-InSAR Province Coverage Pairing Design
Date: 2026-07-06
## Goal
Provide a planning mode for near-term province-wide D-InSAR delivery. The user enters a target production time range as the scene pool and selects a province/AOI. The system builds standard D-InSAR candidate pairs, then greedily selects pairs that add the most new AOI spatial coverage. The time range is a freshness/pool constraint, not the primary coverage objective.
## Scope
- Reuse `pairing_metric_cache` as the only candidate source.
- Keep the existing standard D-InSAR thresholds: temporal baseline, overlap, center distance, same mode, same polarization, same family, orbit availability.
- Add a planning endpoint that returns normal `RadarPair` rows, so existing pair list, batch save, copy, and production flow remain unchanged.
- Store the selected network through `pairing_network_runs` / `pairing_network_edges` for traceability.
## Greedy Policy
1. Build candidate windows from `master_imaging_date` to `slave_imaging_date`.
2. Start with the requested target window.
3. Query candidates in the target pool plus `extension_days`.
4. When an AOI is available, prefer candidates that add new uncovered AOI area.
5. Use target-window temporal coverage as a secondary score and diagnostic.
6. Score each candidate:
- new AOI area covered
- total AOI area covered by the pair
- new target days covered
- existing target coverage overlap
- scene overlap ratio
- D-InSAR quality score
- shorter temporal baseline and smaller center distance as tie-breakers
7. Select the best edge, mark covered AOI area and days, then repeat until:
- AOI area has no meaningful new coverage candidates
- no candidate adds coverage
- `max_pairs` is reached
## Coverage Semantics
Coverage here means AOI spatial coverage when an AOI is supplied. Temporal coverage is reported separately to show whether the selected pairs span the requested production window. For province-wide use, the user must select the province AOI and set an AOI overlap threshold appropriate for production planning.
## API
`POST /pairing/coverage-plan`
Form fields:
- `target_date_from`, `target_date_to`: required `YYYYMMDD`
- standard pairing fields: `time_baseline_min`, `time_baseline_max`, `overlap_threshold`, `spatial_baseline_max_meters`, `aoi_overlap_threshold`, `allowed_satellites`
- `extension_days`: default 15
- `max_pairs`: default 200
- `require_orbit_data`: default true
- `files` or `aoi_geojson`: optional AOI, same as `/find-pairs`
Response:
- `pairs`: selected `RadarPair` rows
- `coverage`: requested range, effective query range, covered days, coverage ratio, uncovered ranges
- `warnings`, `network_run_id`, `candidate_count`, `selected_edge_count`
## Notes
- This is a planning tool, not a new production engine.
- Existing batch splitting remains the operational control for producing 100-by-100 or any user-selected batch size.
- Produced pairs should still be filtered in the pair list by existing result status indicators before batch creation.
+1 -1
View File
@@ -45,7 +45,7 @@
## 2026-06-21 页眉与生产面板补充
- 页眉移除 DB、Worker、IDL、Ollama、Nginx 等运行状态灯,系统健康状态集中放在“运行维护”模块。
- 页眉改为单位 logo、单位名称、系统名称、授权摘要、任务摘要和用户操作。
- 页眉改为单位 logo、单位名称、系统名称、任务摘要和用户操作。
- 单位名、系统名、页眉标语和 logo URL 支持通过 `VITE_APP_*` 环境变量配置,默认使用 `frontend/src/logo.jpg`。
- 新增 `PRODUCT.md`,记录系统面向科研工程单位的产品定位:科研、专业、克制,同时要求严谨、稳定、工程化。
- SBAS 生产面板中高曝光的 Runtime Status、Task queue、Workflow 说明文案改为中文表达,保留必要英文术语。
+2
View File
@@ -35,6 +35,8 @@
Result extraction and access-control audit: current D-InSAR export/registration boundaries, placeholder channels, admin/viewer limitations, and recommended exporter/operator/admin permission model.
- [RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md](RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md)
Result delivery/download design: asynchronous delivery packages, user-owned downloads, temporary delivery root, DB maintenance, and placeholder boundaries for SBAS/LT-1/Sentinel-1/GF3 ortho.
- [RESULT_CATALOG_SEARCH_DELIVERY_WORKBENCH_DESIGN_20260708.md](RESULT_CATALOG_SEARCH_DELIVERY_WORKBENCH_DESIGN_20260708.md)
Result catalog search and delivery workbench design: server-side catalog search, AOI/map preview, unified delivery item schema, selection basket, and staged implementation plan.
- [DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md](DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md)
D-InSAR 淇濈暀 ENVI/SARscape銆丩andSAR銆丟amma/PyINT 涓夊紩鎿庯紝閫€鍑?ISCE2锛岀粺涓€ Task_Pool銆佺粨鏋滆仛鍚堝拰涓棿鏂囦欢娓呯悊鐨勫綋鍓嶈璁°€?
- [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md)
@@ -0,0 +1,274 @@
# 运维任务维护面板设计
日期:2026-07-02
## 背景
当前系统的生产任务、扫描任务和数据准备任务已经统一沉淀到 `system_tasks`、`system_jobs`、`task_logs` 以及各业务 run 表中。实际运维中经常出现以下场景:
- 用户提交 D-InSAR 生产或数据准备任务后,外部进程失败、服务器断电或 worker 心跳超时。
- 数据库任务已经 `FAILED`,但业务 item/execution 仍残留 `RUNNING`。
- 磁盘上存在半成品目录,但数据库记录已经失败或需要清理。
- 需要先判断任务是否仍在真实执行,再决定是否清理数据库记录和磁盘目录。
过去这些动作依赖人工查表和命令行处理。第一版运维面板要把这个过程产品化:用户能查看任务状态、诊断失败原因、预览清理影响,并由管理员执行清理。面板不负责重跑,清理完成后用户回到业务页面重新提交。
## 目标
1. 在“运行维护”中增加“任务维护”能力。
2. 自动列出异常任务,尤其是 D-InSAR 生产、D-InSAR 数据准备、扫描、配对缓存重建等长耗时任务。
3. 对单个任务提供诊断详情:任务、job、run、item、execution、日志、结果登记和磁盘路径。
4. 对可清理任务提供清理预览,明确会删除哪些数据库记录和哪些目录。
5. 管理员确认后执行清理,并写入审计日志。
6. 不提供重跑按钮,不在运维页重新提交业务任务。
## 非目标
- 不做自动重跑。
- 不做跨业务的复杂恢复编排。
- 不在第一版支持任意 SQL 或任意路径删除。
- 不清理源资产库、DEM 库、精密轨道库、代码目录。
- 不处理仍有真实生产进程运行的任务;这种任务应先取消或等待结束。
## 异常任务定义
第一版将以下任务纳入维护列表:
- `system_tasks.status` 属于 `FAILED`、`PARTIAL_SUCCESS`、`CANCELLED`。
- `system_jobs.status` 为 `RUNNING` 但 `heartbeat_at` 超过阈值。
- `system_tasks.status` 为 `RUNNING`,但无活跃 job 且无真实进程证据。
- D-InSAR production run 终态失败,但 item/execution 中存在 `RUNNING` 或 `PENDING` 残留。
- 数据库记录引用的生产结果目录不存在。
- 生产结果目录存在,但没有匹配的 run 或 result product 登记。
第一版优先实现:
- `LANDSAR_RUN` / `LANDSAR_CLUSTER_RUN` / `PYINT_RUN` / `IDL_RUN_DINSAR`
- `COPY_DATA`
- `PAIRING_CACHE_REBUILD`
- 扫描类任务只读展示,清理动作后续扩展
## 后端设计
新增运维任务维护路由:
`backend/app/routers/ops_maintenance.py`
建议接口:
- `GET /api/ops-maintenance/tasks`
- 返回异常任务列表。
- 支持 `task_type`、`status`、`limit`、`offset`。
- `GET /api/ops-maintenance/tasks/{task_id}/diagnosis`
- 返回单任务诊断详情。
- `POST /api/ops-maintenance/tasks/{task_id}/cleanup-preview`
- 生成清理预览,不执行删除。
- `POST /api/ops-maintenance/tasks/{task_id}/cleanup`
- 管理员确认后执行清理。
### 列表返回模型
每条任务至少包含:
- `task_id`
- `task_type`
- `task_name`
- `status`
- `created_at`
- `started_at`
- `ended_at`
- `updated_at`
- `progress`
- `message`
- `run_id`
- `job_id`
- `job_status`
- `issue_level`: `info` / `warning` / `danger`
- `issue_summary`
- `cleanup_supported`
- `cleanup_blocked_reason`
- `counts`
- `jobs`
- `logs`
- `run_items`
- `executions`
- `result_products`
- `disk_paths`
### 诊断详情模型
诊断详情包含:
- `task`
- `jobs`
- `production_run`
- `production_item_counts`
- `production_execution_counts`
- `result_products`
- `compat_results`
- `recent_logs`
- `disk_paths`
- `process_check`
- `diagnosis`
- `summary`
- `findings`
- `cleanup_supported`
- `cleanup_blockers`
### 清理预览模型
清理预览必须返回完整影响范围:
- `database_deletes`
- `system_tasks`
- `system_jobs`
- `task_logs`
- `dinsar_production_runs`
- `dinsar_production_run_items`
- `dinsar_production_executions`
- `result_products`
- `dinsar_results`
- `workflow_runs`
- `workflow_steps`
- `workflow_artifacts`
- `disk_deletes`
- `path`
- `kind`: `task_pool` / `production_result` / `run_log`
- `exists`
- `size_bytes` 可选
- `allowed`
- `blocked`
- `blockers`
- `requires_admin`
### 清理执行
执行接口只接受预览中允许的清理项,不接受前端传来的任意路径。
请求体:
```json
{
"confirm": true,
"delete_task_records": true,
"delete_logs": true,
"delete_production_records": true,
"delete_result_products": true,
"delete_production_dirs": true,
"delete_task_pool_dir": true
}
```
执行顺序:
1. 重新生成清理预览。
2. 校验任务不是 `PENDING` / `RUNNING`,或已被诊断为 stale 且无真实进程。
3. 校验所有待删目录在白名单根目录内。
4. 删除数据库记录。
5. 删除磁盘目录。
6. 写审计日志。
7. 返回复查结果。
## 路径白名单
允许删除的目录根:
- `settings.DINSAR_TASK_ROOTS` 或系统配置的数据准备根,例如 `D:\Task_Pool\DInSAR`
- `settings.PRODUCTION_RESULTS_ROOT` 下的 D-InSAR 发布目录,例如 `D:\production_results\dinsar`
- 系统 runtime run log 目录中与 run_id 精确匹配的日志文件
禁止删除:
- 磁盘根目录
- 项目代码目录
- 源产品资产目录
- 精密轨道目录
- DEM 目录
- 未由系统登记的任意路径
## 前端设计
入口放在 `HealthCheckPanel.jsx` 的“低频维护”区域,新增“任务维护”区块。整体延续现有运维页视觉:高密度、少装饰、状态明确。
### 页面结构
1. 顶部摘要条
- 异常任务数
- 可清理任务数
- 阻塞清理任务数
- 最近一次清理时间
2. 筛选栏
- 任务类型
- 状态
- 关键词
- 刷新按钮
3. 异常任务表
- 状态
- 类型
- 名称
- 进度/统计
- 失败摘要
- 更新时间
- 操作:诊断
4. 诊断详情抽屉或内联详情
- 基础任务信息
- job 状态
- D-InSAR run 状态
- item/execution 统计
- 磁盘路径检查
- 最近日志
- 诊断结论
5. 清理预览
- 数据库删除数量
- 磁盘目录列表
- 阻塞原因
- 管理员确认按钮
### 交互原则
- 非管理员可以查看诊断,但不能执行清理。
- 清理按钮只在预览完成且无阻塞时启用。
- 真实进程仍在运行时禁止清理。
- 清理完成后刷新列表,不提供“重新执行”入口。
- 删除影响必须在按钮前可见,不用弹窗隐藏关键信息。
## 审计日志
每次清理写入审计日志:
- 操作人
- 操作时间
- `task_id`
- `run_id`
- 删除选项
- 删除前状态快照
- 数据库删除数量
- 磁盘删除路径
- 删除结果
- 失败原因
## 第一版实现边界
第一版聚焦 D-InSAR 生产和数据准备任务:
- `LANDSAR_RUN`
- `LANDSAR_CLUSTER_RUN`
- `PYINT_RUN`
- `IDL_RUN_DINSAR`
- `COPY_DATA`
扫描任务、SBAS 任务和结果交付任务先进入只读诊断列表,后续按同一接口扩展清理策略。
## 验收标准
1. 运维页能列出失败或部分成功的 D-InSAR/COPY_DATA 任务。
2. 点击任务能看到 task/job/run/item/execution/log/path 诊断详情。
3. 对失败且无真实进程的 D-InSAR 任务能生成清理预览。
4. 管理员能执行清理,普通用户不能。
5. 清理后关联数据库记录为 0,登记过的生产目录被删除。
6. 清理动作写入审计日志。
7. 前端构建通过。
@@ -0,0 +1,479 @@
# 成果目录检索与交付工作台设计
日期:2026-07-08
## 背景
当前“结果提取”页面已经完成了成果交付的基础链路:用户从已登记 catalog 中选择成果,后台创建交付任务,并在交付完成后下载到本地。这个方向是正确的,但当前页面仍然偏向“列表勾选 + 生成交付包”,缺少成果检索、空间预览和地图辅助选择能力。
从业务上看,用户真正需要完成的是:
1. 在成果目录中按时间、区域、传感器、生产引擎和成果状态检索。
2. 在地图上确认成果覆盖范围和预览图。
3. 选择需要交付的成果。
4. 生成后台交付包并下载到本地。
因此本设计将“结果提取”能力升级为“成果目录检索与交付工作台”。“生产结果入库”继续归属 D-InSAR 结果管理页;本页面只处理已登记成果的检索、预览、选择和交付。
## 现状问题
### 前端问题
- `frontend/src/ResultExtractionPanel.jsx` 启动时只加载固定数量结果,默认 `PAGE_SIZE = 100`。
- D-InSAR 结果走旧接口 `/api/dinsar-results`,LT-1/GF3 正射走 `/api/result-deliveries/catalog/{channel}`,通道数据源不统一。
- “结果检索”只是前端对已加载数组做字符串过滤,不能检索未加载分页,也不能按时间、区域、AOI、状态等业务条件查询。
- 页面没有地图窗口,用户无法判断成果空间覆盖。
- 页面没有成果预览图区域,不能像 D-InSAR 结果目录一样核对位移图或正射快视。
- 选择行为和检索行为耦合在当前可见列表中,缺少稳定的“已选成果篮”概念。
- SBAS、Sentinel-1 等未完全接入通道虽然有占位,但与可交付通道的工作流差异仍不够清楚。
### 后端问题
- `GET /api/result-deliveries/catalog/{channel}` 目前只支持 `limit`、`offset`、`query`。
- D-InSAR 分支没有真正应用 `query` 条件。
- catalog 返回字段不统一,缺少地图和预览需要的标准字段,例如 `coverage_polygon`、`bbox`、`preview_url`。
- LT-1/GF3 分支可从 `radar_data` 或 `sar_scene_geo` 找到部分空间和预览信息,但没有统一输出到交付 catalog。
- 当前接口语义更像“交付源列表”,不是完整“成果目录检索”。
## 目标
- 将结果提取页面升级为成果目录检索、地图预览、成果选择和交付下载的一体化工作台。
- 统一 D-InSAR、LT-1 正射、GF3 正射的可交付成果 catalog 查询入口。
- 保留 SBAS-InSAR、Sentinel-1 正射占位,但明确标注未开放交付或未接入生产。
- 支持服务端检索,不依赖前端只过滤已加载的前 100 条。
- 支持时间范围、行政区/AOI、成果通道、生产引擎、状态、关键词等查询。
- 地图窗口展示成果 footprint,列表与地图联动。
- 成果预览图和详情面板用于核对质量、日期、路径和交付状态。
- 创建交付包仍使用后台任务,避免 HTTP 请求复制大文件导致 504。
- 保持普通用户不输入服务器任意路径,所有交付都落在受控交付区。
## 非目标
- 本次不实现 Sentinel-1 正射生产。
- 本次不实现 SBAS-InSAR 交付打包,只保留目录展示和清晰占位。
- 本次不改变 D-InSAR 生产结果入库流程。
- 本次不做跨节点对象存储或外部网盘。
- 本次不让用户通过页面直接删除正式成果 catalog 原文件。
- 本次不重构整套用户权限模型,但接口需要保留后续 `exporter/operator/admin` 能力拆分空间。
## 术语边界
| 术语 | 定义 | 页面归属 |
| --- | --- | --- |
| 生产结果入库 | 从 LandSAR、ENVI/SARscape、Gamma 等生产目录提取成果并登记到 catalog | D-InSAR 结果管理 |
| 成果目录检索 | 从已登记 catalog 中按条件查询成果 | 成果目录检索与交付 |
| 成果预览 | 查看成果快视图、位移图、正射缩略图、空间 footprint | 成果目录检索与交付 |
| 成果交付 | 将用户选择的成果复制到受控交付区,生成 manifest/checksum/zip | 成果目录检索与交付 |
| 交付包 | 后台生成的用户可下载成果包 | 成果目录检索与交付 |
## 目标页面结构
页面名称建议从“结果提取”调整为:
```text
成果检索与交付
```
页面采用生产系统工作台结构,不做营销式 hero。建议结构如下:
```text
┌───────────────────────────────────────────────────────────────┐
│ 顶部状态区:当前通道 / 检索结果数 / 已选数量 / 最近交付状态 │
├───────────────┬───────────────────────────────┬───────────────┤
│ 检索条件区 │ 地图预览区 │ 详情与预览区 │
│ - 通道 │ - footprint │ - 快视图 │
│ - 时间 │ - AOI/行政区边界 │ - 元数据 │
│ - 行政区/AOI │ - 选中高亮 │ - 交付状态 │
│ - 引擎/状态 │ - 点击选择/定位 │ - 主文件路径 │
├───────────────┴───────────────────────────────┴───────────────┤
│ 成果列表:分页表格 / 批量选择 / 地图定位 / 预览 / 加入选择篮 │
├───────────────────────────────────────────────────────────────┤
│ 已选成果篮 + 交付选项 + 我的交付包 │
└───────────────────────────────────────────────────────────────┘
```
### 检索条件区
第一阶段需要支持:
- 成果通道:D-InSAR、LT-1 正射、GF3 正射、SBAS 占位、Sentinel-1 占位。
- 时间范围:
- D-InSAR:主影像日期、从影像日期或结果发布时间。
- 单景正射:成像日期。
- 行政区:复用现有省/市行政区选择能力。
- AOI:复用现有上传 SHP/GeoJSON 能力,第一阶段可以先支持行政区,第二阶段支持文件上传。
- 关键词:任务名、产品号、pair_key、路径片段。
- 生产引擎:SARscape、LandSAR、Gamma/PyINT、LT-1 正射链、GF3 SARscape。
- 成果状态:READY、DONE、PARTIAL、FAILED、健康状态。
- 是否有预览:全部、有预览、无预览。
### 地图预览区
地图窗口必须服务于选择,不只是展示。
功能要求:
- 绘制查询结果的 footprint 或 bbox。
- 绘制当前 AOI/行政区边界。
- 列表选中成果时,地图高亮对应 footprint 并定位。
- 地图点击成果时,右侧详情区展示预览和元数据。
- 已加入选择篮的成果使用独立样式标识。
- 没有空间范围的成果在列表中保留,但地图上不绘制,并显示“缺少空间范围”状态。
实现建议:
- 第一阶段可复用 `MiniCoverageMap` 的 bbox/polygon 绘制逻辑,但需要抽成支持交互的 `ResultCatalogMap`。
- 地图底图复用现有系统 `getBaseLayerConfig` 和 tile 配置。
- 不在地图上直接叠大体量 GeoTIFF,预览图放到右侧详情区。
### 详情与预览区
选中成果后展示:
- 成果名称、通道、产品 ID。
- 预览图:
- D-InSAR 使用 `/api/dinsar-products/{id}/preview` 或兼容缩略图接口。
- LT-1/GF3 优先使用已生成 WebP/PNG 预览。
- 无预览时显示原因和可维护提示,不阻塞交付。
- 时空信息:
- D-InSAR:master/slave 日期、时间基线、pair_key、引擎。
- 单景正射:成像日期、极化、像素大小、卫星。
- 空间信息:bbox、行政区匹配、coverage_polygon 状态。
- 文件信息:主文件路径、大小、manifest、quality 文件。
- 交付信息:是否已被当前用户交付过、最近交付状态。
### 成果列表
列表需要支持服务端分页,不能只展示前 100 条。
列建议:
- 选择框。
- 成果名称。
- 通道。
- 日期。
- 空间范围状态。
- 生产引擎/配置。
- 文件大小。
- 预览状态。
- 交付状态。
- 操作:定位地图、预览、加入/移出选择篮。
列表和地图联动:
- 点击行:更新详情区。
- 点击“定位”:地图飞到 footprint。
- 勾选:加入选择篮,不应随翻页丢失。
- 批量选择只作用于当前查询结果或当前页,按钮文案必须明确。
### 已选成果篮
当前页面需要独立的选择篮状态,而不是只从当前 `filteredResults` 里计算。
要求:
- 跨分页保持选择。
- 切换通道时保留各通道选择,但创建交付时默认只对当前通道提交。
- 显示已选数量、总大小估算、缺失文件数。
- 支持清空当前通道、移除单项。
- 超过后端 `RESULT_DELIVERY_MAX_ITEMS` 时提前提示。
### 我的交付包
继续复用现有后台交付任务。
展示要求:
- 状态:PENDING、RUNNING、READY、FAILED、CANCELLED、EXPIRED。
- 文件数、总大小、已复制大小。
- 失败项数量和原因摘要。
- manifest、zip、逐文件下载入口。
- 过期时间。
- 刷新按钮和运行中自动轮询。
## 后端接口设计
### 统一成果目录检索
建议保留现有路径,增强为真正的 catalog search:
```http
GET /api/result-deliveries/catalog/{channel}
```
查询参数:
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `limit` | int | 每页数量,默认 100,最大 500 |
| `offset` | int | 分页偏移 |
| `query` | string | 关键词 |
| `date_from` | string | 起始日期,`YYYY-MM-DD` 或 `YYYYMMDD` |
| `date_to` | string | 结束日期 |
| `date_mode` | string | `imaging`、`master`、`slave`、`published` |
| `region_tree_id` | string | 行政区树 ID |
| `aoi_token` | string | 已上传 AOI token,第二阶段接入 |
| `engine_code` | string | 生产引擎 |
| `profile_code` | string | 生产配置 |
| `status` | string | 成果状态 |
| `has_preview` | bool | 是否只查有预览成果 |
| `has_geometry` | bool | 是否只查有空间范围成果 |
| `sort` | string | `date_desc`、`published_desc`、`name_asc` |
返回:
```json
{
"items": [],
"total": 0,
"limit": 100,
"offset": 0,
"has_more": false,
"summary": {
"channel": "dinsar",
"matched": 0,
"with_geometry": 0,
"with_preview": 0,
"selected_hint": null
}
}
```
### 统一 catalog item 字段
所有通道返回尽量统一字段:
```json
{
"id": 1,
"item_id": 1,
"channel": "dinsar",
"source_kind": "dinsar_result",
"product_id": "xxx",
"display_name": "xxx",
"task_name": "xxx",
"status": "READY",
"health_status": "OK",
"engine_code": "landsar",
"profile_code": "lt1_dinsar",
"pair_key": "20260201_20260301",
"imaging_date": null,
"master_imaging_date": "2026-02-01",
"slave_imaging_date": "2026-03-01",
"published_at": "2026-07-08T10:00:00",
"primary_asset_path": "D:\\...",
"publish_dir": "D:\\...",
"file_size": 123456789,
"preview_url": "/api/dinsar-products/1/preview",
"preview_status": "READY",
"coverage_polygon": [[126.1, 45.1], [126.2, 45.1], [126.2, 45.2], [126.1, 45.2]],
"bbox": {
"min_lon": 126.1,
"min_lat": 45.1,
"max_lon": 126.2,
"max_lat": 45.2
},
"geometry_status": "READY",
"delivery_summary": {
"latest_delivery_id": null,
"latest_status": null
}
}
```
### D-InSAR 字段来源
D-InSAR catalog 应优先读取 `result_products`:
- `product_id`
- `catalog_name`
- `product_family`
- `product_type`
- `engine_code`
- `profile_code`
- `pair_key`
- `task_name`
- `task_alias`
- `primary_asset_path`
- `publish_dir`
- `manifest_path`
- `coverage_polygon`
- `min_lon`、`min_lat`、`max_lon`、`max_lat`
- `produced_at`
- `published_at`
兼容旧 `dinsar_results` 的 ID 时,需要同时保留 `compat_result_id`,但前端不应感知两套 ID 规则。
### LT-1 正射字段来源
LT-1 正射 catalog 读取 `sar_scene_geo` 与 `radar_data`:
- `sar_scene_geo.id` 作为 `item_id`
- `sar_scene_geo.analysis_tif_path` 作为主交付物
- `sar_scene_geo.analysis_dir` 作为发布目录
- `radar_data.imaging_date`
- `radar_data.polarization`
- `radar_data.coverage_polygon`
- `radar_data.min_lon/min_lat/max_lon/max_lat` 或由 polygon 计算 bbox
- `radar_data.preview`/WebP 缓存作为预览来源
### GF3 正射字段来源
GF3 正射 catalog 读取 `radar_data`,必要时左连接 `sar_scene_geo`:
- `radar_data.id` 作为 `item_id`
- `radar_data.file_path` 或 `sar_scene_geo.analysis_tif_path` 作为主文件/目录
- `radar_data.source_format`
- `radar_data.geocoded_flag`
- `radar_data.imaging_date`
- `radar_data.polarization`
- `radar_data.coverage_polygon`
- WebP/quicklook 作为预览来源
## 空间检索设计
第一阶段:
- 支持行政区 `region_tree_id`。
- 后端把行政区 geometry 转为 AOI。
- 对有 `coverage_polygon` 或 bbox 的成果做空间相交过滤。
- 对缺失 geometry 的成果默认不进入空间过滤结果,但在 summary 中统计缺失数量。
第二阶段:
- 支持上传 SHP/GeoJSON,复用现有 AOI upload/token 机制。
- 支持地图框选。
- 支持空间关系选项:
- `intersects`
- `contains_center`
- `overlap_ratio >= threshold`
## 预览设计
### D-InSAR
- 首选标准结果产品预览接口。
- 如果只有兼容旧 `dinsar_results` 缩略图,则 fallback 到旧 `/api/dinsar-results/{id}/thumb`。
- 预览失败不影响交付,但列表显示“预览异常”。
### LT-1/GF3 正射
- 优先使用资产 WebP/quicklook。
- 若没有 WebP,但已有 GeoTIFF,可显示“未生成预览”,由运维/扫描任务补建。
- 不在本页面同步触发重建预览,避免打开检索页导致重任务。
## 权限与审计
第一阶段沿用当前登录用户模型:
- 登录用户可查看成果 catalog。
- 登录用户可创建自己的成果交付任务。
- 管理员可查看全部交付任务和执行清理。
- 生产结果入库、目录重建、正式成果删除仍属于管理员/运维能力。
所有交付创建需要记录:
- 用户。
- 查询通道。
- 提交的 item IDs。
- package mode。
- 任务 ID。
- 交付 ID。
后续拆角色时,建议能力名:
```text
result.catalog.read
result.delivery.create
result.delivery.read_own
result.delivery.read_all
result.delivery.cleanup
result.product.ingest
```
## 性能与可靠性
- catalog 查询必须服务端分页。
- `limit` 最大 500,默认 100。
- 空间查询需要优先使用数据库索引;如果当前表缺少 geometry 索引,应先用 bbox 粗筛,再做 polygon 精筛。
- 地图首屏最多绘制当前页或当前查询前 N 个 footprint,避免一次渲染数千 polygon。
- 预览图懒加载,只有选中或进入可视区域才请求。
- 交付包创建只提交 ID 列表,不提交文件路径。
- 后台交付任务必须记录单项失败,不应一个文件失败导致整批不可用。
## 实施阶段
### 阶段 1:文档和接口契约
- 完成本设计文档。
- 明确“成果检索与交付”和“生产结果入库”边界。
- 梳理 D-InSAR、LT-1、GF3 catalog 字段来源。
### 阶段 2:后端 catalog 查询增强
- 增强 `/api/result-deliveries/catalog/{channel}` 参数。
- D-InSAR 分支应用 `query`、分页、日期、引擎和状态过滤。
- LT-1/GF3 分支补充日期、行政区/AOI、状态、预览和 geometry 字段。
- 返回统一 item schema。
- 增加必要的单元测试或接口级测试。
### 阶段 3:前端工作台改造
- 重构 `ResultExtractionPanel.jsx` 为成果检索工作台。
- 新增检索条件区。
- 新增地图预览区。
- 新增详情与预览区。
- 改造列表为服务端分页。
- 新增跨分页选择篮。
- 保留我的交付包列表和后台轮询。
### 阶段 4:联动和质量收口
- 列表、地图、详情三者联动。
- 交付创建前校验最大数量和缺失文件。
- 未接入通道显示明确占位,不可误触发交付。
- 补充空状态、错误状态、加载骨架和响应式布局。
## 验收标准
完成后应满足:
1. 用户可以按通道、时间、关键词、引擎、状态检索成果。
2. 用户可以选择行政区并只查看相交成果。
3. 检索结果不是前端过滤前 100 条,而是服务端分页结果。
4. 地图能显示当前检索结果的 footprint 或 bbox。
5. 点击列表能定位地图并展示预览详情。
6. 点击地图能选中对应成果并展示详情。
7. 用户跨分页选择成果后,选择篮不丢失。
8. 创建交付包仍是后台任务,不出现同步复制导致的 504。
9. D-InSAR、LT-1 正射、GF3 正射三类可交付成果字段一致。
10. SBAS、Sentinel-1 未接入能力不会被用户误认为可执行。
11. 缺失预览或缺失空间范围的成果有明确状态,不阻断其他成果交付。
12. 普通用户不需要输入服务器路径。
## 风险与注意事项
- D-InSAR 旧结果兼容 ID 和新 `result_products.id` 不能混淆,交付创建接口需要继续兼容 `compat_result_ids`。
- 某些历史成果可能没有 `coverage_polygon`,空间检索时要给出统计和提示。
- LT-1/GF3 的预览来源可能不一致,第一阶段允许 `preview_status = MISSING`。
- 地图渲染大量 polygon 可能卡顿,需要限制首屏绘制数量或做简化。
- 结果提取页面改名后,导航和用户手册需要同步调整。
## 建议代码入口
后端:
- `backend/app/routers/result_deliveries.py`
- `backend/app/services/result_delivery_service.py`
- `backend/app/services/dinsar_read_service.py`
- `backend/app/routers/radar.py` 中已有 AOI/搜索逻辑可参考
前端:
- `frontend/src/ResultExtractionPanel.jsx`
- `frontend/src/api/resultDeliveries.js`
- `frontend/src/components/MiniCoverageMap.jsx`
- `frontend/src/components/DinsarCatalogPanel.jsx`
- `frontend/src/panels/PairPlanningPanel.jsx`
- `frontend/src/hooks/useRadarSearch.js`
@@ -14,8 +14,9 @@
## 目标
- 所有登录用户都可以申请成果交付。
- D-InSAR 已登记成果先接入真实交付下载。
- SBAS、LT-1 正射、Sentinel-1 正射、GF3 正射在页面和接口中保留清晰占位,不暴露假执行能力。
- D-InSAR 已登记成果接入真实交付下载。
- LT-1 正射与 GF3 正射接入真实交付下载。
- SBAS 与 Sentinel-1 正射在页面和接口中保留清晰占位,不暴露假执行能力。
- 大文件交付改为后台任务,不再由 HTTP 请求同步复制。
- 用户最终可以把成果下载到本地;服务器交付区只是临时缓存。
- 每次交付可审计、可过期清理、可校验。
@@ -25,6 +26,7 @@
- 本阶段不实现 Sentinel-1 正射生产。
- 本阶段不实现 SBAS 成果交付打包,只显示目录已接入但交付未接入。
- 本阶段不触发 GF3 生产;GF3 交付只面向已经登记的 SARscape 标准化正射成品。
- 本阶段不做跨节点对象存储或外部网盘。
- 本阶段不把普通用户开放到任意服务器路径写入。
@@ -43,6 +45,13 @@
## 交付模式
## 正射成果口径
- LT-1 正射:由服务器侧单景正射流水线生产,登记在 `sar_scene_geo`,主交付物是 `analysis_ready.tif`,随包包含预览、manifest、quality 等 sidecar 文件。
- GF3 正射:系统拿到的就是外部 SARscape 已生产成品,当前可登记为 `GF3_SARSCAPE_NATIVE_PREVIEW`,标准化后也可登记为 `GF3_SARSCAPE_L2`;交付阶段只做受控打包和下载,不触发 GF3 生产。
- Sentinel-1 正射:生产链尚未接入,结果提取页面保留占位,避免用户误操作。
- SBAS-InSAR:成果目录可查,但交付打包尚未接入。
### 1. 目录交付
默认模式。后台将选中的结果文件复制到:
@@ -97,6 +106,7 @@
- `delivery_id`。
- `source_product_id` / `source_result_id`。
- `source_radar_data_id` / `source_scene_geo_id`:用于 LT-1/GF3 单景正射资产追踪。
- `display_name`。
- `source_path`。
- `relative_path`。
@@ -133,9 +143,23 @@ GET /api/result-deliveries/channels
- `dinsar`: `ready`
- `sbas`: `planned`
- `lt1_ortho`: `planned`
- `lt1_ortho`: `ready`
- `s1_ortho`: `placeholder`
- `gf3_ortho`: `placeholder`
- `gf3_ortho`: `ready`
### 获取可交付目录
```http
GET /api/result-deliveries/catalog/{channel}
```
第一版支持:
- `dinsar`:兼容已有 D-InSAR catalog。
- `lt1_ortho`:读取 `sar_scene_geo` 中 `lt_gamma / lt1_gamma_geocoded_mli` 且 `DONE` 的分析就绪 GeoTIFF。
- `gf3_ortho`:读取 `radar_data.source_format in (GF3_SARSCAPE_NATIVE_PREVIEW, GF3_SARSCAPE_L2)` 且 `geocoded_flag = true` 的 SARscape 正射成品。
返回统一字段包括 `item_id`、`source_kind`、`product_id`、`display_name`、`primary_asset_path`、`publish_dir`、`radar_data_id`、`scene_geo_id`。
### 创建交付任务
@@ -150,6 +174,7 @@ POST /api/result-deliveries
"channel": "dinsar",
"product_ids": ["..."],
"compat_result_ids": [1, 2, 3],
"item_ids": [101, 102],
"package_mode": "directory",
"include_manifest": true,
"include_checksums": true
@@ -159,7 +184,9 @@ POST /api/result-deliveries
约束:
- 普通用户只创建自己的任务。
- `channel != dinsar` 时第一版返回 409 或 422,提示“通道尚未接入交付”。
- `dinsar` 使用 `compat_result_ids` 或 `product_ids`。
- `lt1_ortho` 使用 `item_ids = sar_scene_geo.id`。
- `gf3_ortho` 使用 `item_ids = radar_data.id`。
- 每次最大数量由 `RESULT_DELIVERY_MAX_ITEMS` 控制。
- 不允许传入任意服务器输出路径。
@@ -200,7 +227,7 @@ RESULT_DELIVERY_BUILD
处理流程:
1. 将 delivery 标记为 `RUNNING`。
2. 解析 D-InSAR catalog 中的文件路径。
2. 按 channel 解析可交付源文件路径。
3. 复制到交付目录。
4. 生成 `manifest.json`。
5. 可选计算 checksum。
@@ -244,8 +271,8 @@ RESULT_DELIVERY_CHECKSUM_ENABLED=true
页面结构:
- 通道栏:D-InSAR 可用,SBAS/LT-1/Sentinel-1/GF3 明确显示“未接入交付”。
- 成果选择区:复用现有 D-InSAR catalog 列表。
- 通道栏:D-InSAR、LT-1 正射、GF3 正射可用;SBAS/Sentinel-1 显示未接入交付。
- 成果选择区:D-InSAR 复用现有 catalog 列表,LT-1/GF3 使用统一交付 catalog 查询。
- 交付选项:目录交付 / 压缩包交付。
- 我的交付包:状态、大小、文件数、过期时间、下载入口。
@@ -283,7 +310,7 @@ RESULT_DELIVERY_CHECKSUM_ENABLED=true
- 文档落地。
- 数据表和自维护 migration。
- D-InSAR 目录交付后台任务。
- D-InSAR、LT-1 正射、GF3 正射目录交付后台任务。
- 我的交付包列表和详情。
### 阶段 2
@@ -295,12 +322,12 @@ RESULT_DELIVERY_CHECKSUM_ENABLED=true
### 阶段 3
- SBAS 交付接入。
- LT-1 正射、Sentinel-1 正射、GF3 正射在生产 catalog 完成后接入。
- Sentinel-1 正射在生产 catalog 完成后接入。
- exporter/operator 角色拆分。
## 验收标准
- 普通登录用户能创建 D-InSAR 成果交付任务。
- 普通登录用户能创建 D-InSAR、LT-1 正射、GF3 正射成果交付任务。
- HTTP 请求只排队任务,不再同步复制大文件。
- 交付完成后用户能下载到本地。
- 普通用户不能指定任意服务器目录。
+1382 -59
View File
File diff suppressed because it is too large Load Diff
+8 -38
View File
@@ -184,22 +184,12 @@ function App() {
// --- Zustand stores ---
const {
currentUser, setCurrentUser, authChecked, setAuthChecked,
licenseStatus, setLicenseStatus, licenseLoading, setLicenseLoading,
licenseUploadStatus, setLicenseUploadStatus, licenseFileName, setLicenseFileName,
healthStatus, setHealthStatus, healthLoading, setHealthLoading, healthError, setHealthError,
} = useAuthStore(useShallow((state) => ({
currentUser: state.currentUser,
setCurrentUser: state.setCurrentUser,
authChecked: state.authChecked,
setAuthChecked: state.setAuthChecked,
licenseStatus: state.licenseStatus,
setLicenseStatus: state.setLicenseStatus,
licenseLoading: state.licenseLoading,
setLicenseLoading: state.setLicenseLoading,
licenseUploadStatus: state.licenseUploadStatus,
setLicenseUploadStatus: state.setLicenseUploadStatus,
licenseFileName: state.licenseFileName,
setLicenseFileName: state.setLicenseFileName,
healthStatus: state.healthStatus,
setHealthStatus: state.setHealthStatus,
healthLoading: state.healthLoading,
@@ -405,13 +395,12 @@ function App() {
const floodPairPreviewLayerRef = useRef(null);
const floodVectorLayersRef = useRef({});
const mapRegionLayerRef = useRef(null);
const prevLicenseOkRef = useRef(false);
const prevAppReadyRef = useRef(false);
const initializeAppDataRef = useRef(null);
const addLogRef = useRef(addLog);
const handleTaskCompletionRef = useRef(null);
const updateLayerTooltipRef = useRef(null);
const radarSearchRequestSeqRef = useRef(0);
const licenseFileRef = useRef(null);
const foundPairsRef = useRef(foundPairs);
const hazardLayersRef = useRef({});
const dinsarResultLayersRef = useRef({});
@@ -712,14 +701,10 @@ function App() {
const {
handleLoginSuccess,
handleLogout,
fetchLicenseStatus,
fetchHealthStatus,
handleLicenseUpload,
} = useAppAuthLifecycle({
ensureCanOperate,
clearRadarSearchResults,
radarSearchRequestSeqRef,
prevLicenseOkRef,
aoeLayerRef,
activeLayersRef,
radarPreviewLayersRef,
@@ -727,13 +712,9 @@ function App() {
setCurrentUser,
setAuthChecked,
setPendingTaskIds,
setLicenseLoading,
setLicenseStatus,
setHealthLoading,
setHealthError,
setHealthStatus,
setLicenseFileName,
setLicenseUploadStatus,
setAoiLayer,
setAllData,
setRadarPagination,
@@ -838,14 +819,14 @@ function App() {
}, [baseLayerKey, currentUser?.id]);
useEffect(() => {
if (licenseLoading || !authChecked) return;
const isOk = !!licenseStatus?.ok && !!currentUser;
const prevOk = prevLicenseOkRef.current;
if (isOk && !prevOk) {
if (!authChecked) return;
const isReady = !!currentUser;
const wasReady = prevAppReadyRef.current;
if (isReady && !wasReady) {
initializeAppDataRef.current?.();
}
prevLicenseOkRef.current = isOk;
}, [licenseLoading, licenseStatus?.ok, authChecked, currentUser]);
prevAppReadyRef.current = isReady;
}, [authChecked, currentUser]);
const {
fetchDinsarResults,
@@ -917,7 +898,6 @@ function App() {
handleCancelActiveTasks,
} = useGlobalTaskControl({
currentUser,
licenseOk: !!licenseStatus?.ok,
activeTasks,
setActiveTasks,
setRuntimeSummary,
@@ -2002,7 +1982,6 @@ function App() {
const selectedPairsCount = foundPairs.filter(p => p.isSelected).length;
const hasEnoughRadarScenesForPlanning = radarImagingDates.length >= 2;
const licenseOk = !!licenseStatus?.ok;
const avgTaskProgress = activeTasks.length
? Math.round(activeTasks.reduce((sum, t) => sum + (t.progress || 0), 0) / activeTasks.length)
: 0;
@@ -2095,7 +2074,7 @@ function App() {
if (!authChecked) {
return (
<div className="login-page-wrapper">
<div className="login-card">
<div className="login-card login-card--checking">
<h2>正在检查登录状态...</h2>
<p>请稍候,系统正在验证会话。</p>
</div>
@@ -2118,7 +2097,6 @@ function App() {
activeTasks={activeTasks}
avgTaskProgress={avgTaskProgress}
runtimeSummary={runtimeSummary}
licenseStatus={licenseStatus}
healthStatus={healthStatus}
healthLoading={healthLoading}
healthError={healthError}
@@ -2136,7 +2114,6 @@ function App() {
currentUser={currentUser}
language={language}
apiEndpoint={apiClient.defaults.baseURL}
licenseOk={licenseOk}
foundPairs={foundPairs}
dinsarTotal={dinsarPagination.total}
selectedPairsCount={selectedPairsCount}
@@ -2192,14 +2169,7 @@ function App() {
onPairingAoiModeChange={handlePairingAoiModeChange}
onPairingProvinceChange={handlePairingProvinceChange}
onPairingCityChange={handlePairingCityChange}
licenseLoading={licenseLoading}
licenseStatus={licenseStatus}
isAdmin={isAdmin}
licenseFileRef={licenseFileRef}
onUploadFile={handleLicenseUpload}
onRefreshLicenseStatus={fetchLicenseStatus}
licenseFileName={licenseFileName}
licenseUploadStatus={licenseUploadStatus}
activeTasks={activeTasks}
runtimeSummary={runtimeSummary}
showCancelTask={showCancelTask}
-3
View File
@@ -12,9 +12,6 @@ const ACTION_LABELS = {
logout: '退出登录',
user_created: '创建用户',
user_updated: '更新用户',
license_uploaded: '上传授权文件',
license_upload_failed: '上传授权失败',
license_refreshed: '刷新授权状态',
task_queued: '任务入队',
batch_created: '创建任务批次',
batch_marked_complete: '批次标记完成',
+141 -22
View File
@@ -18,7 +18,18 @@ const RUN_HISTORY_PAGE_SIZE = 200;
const TASK_HISTORY_PAGE_SIZE = 200;
const TASK_LOG_PAGE_SIZE = 1000;
const INLINE_TASK_LOG_LIMIT = 200;
const TERMINAL_STATUS_VALUES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'CANCELED', 'success', 'failed', 'cancelled', 'canceled']);
const TERMINAL_STATUS_VALUES = new Set([
'COMPLETED',
'PARTIAL_SUCCESS',
'FAILED',
'CANCELLED',
'CANCELED',
'success',
'partial_success',
'failed',
'cancelled',
'canceled',
]);
const ENGINE_STATUS_COLOR = {
ok: '#22c55e',
@@ -54,10 +65,12 @@ const STATUS_LABEL = {
PENDING: '等待中',
RUNNING: '运行中',
COMPLETED: '已完成',
PARTIAL_SUCCESS: '部分成功',
FAILED: '失败',
CANCELLED: '已取消',
CANCELED: '已取消',
success: '成功',
partial_success: '部分成功',
failed: '失败',
running: '运行中',
pending: '等待中',
@@ -133,6 +146,7 @@ function taskTypeToEngine(taskType) {
function taskStatusToRunStatus(status) {
const normalized = String(status || '').toUpperCase();
if (normalized === 'COMPLETED') return 'success';
if (normalized === 'PARTIAL_SUCCESS') return 'partial_success';
if (normalized === 'FAILED') return 'failed';
if (normalized === 'CANCELLED' || normalized === 'CANCELED') return 'cancelled';
if (normalized === 'RUNNING') return 'running';
@@ -285,23 +299,63 @@ function safeCount(value) {
function getRunCounts(run) {
const completed = safeCount(run?.completed_items);
const running = safeCount(run?.running_items);
const pending = safeCount(run?.pending_items);
const failed = safeCount(run?.failed_items);
const skipped = safeCount(run?.skipped_items);
const cancelled = safeCount(run?.cancelled_items);
const total = safeCount(run?.total_items);
return { completed, failed, skipped, total };
return { completed, running, pending, failed, skipped, cancelled, total };
}
function hasRunCounts(run) {
const { completed, failed, skipped, total } = getRunCounts(run);
return [completed, failed, skipped, total].some(value => value != null);
const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
return [completed, running, pending, failed, skipped, cancelled, total].some(value => value != null);
}
function hasMixedRunOutcome(run) {
const { completed, failed, skipped } = getRunCounts(run);
return Number(failed || 0) > 0 && (Number(completed || 0) > 0 || Number(skipped || 0) > 0);
}
function isActiveRunStatus(status) {
const normalized = String(status || '').trim().toLowerCase();
return normalized === 'running' || normalized === 'pending';
}
function getDisplayRunStatus(run) {
const rawStatus = String(run?.raw_status || '').toUpperCase();
const status = String(run?.status || '').toLowerCase();
if (isActiveRunStatus(rawStatus) || isActiveRunStatus(status)) {
return run?.status;
}
if (rawStatus === 'PARTIAL_SUCCESS' || status === 'partial_success' || hasMixedRunOutcome(run)) {
return 'partial_success';
}
return run?.status;
}
function getDisplayTaskStatus(task, relatedRun = null) {
if (!task) return '';
const taskStatus = String(task?.status || '').trim();
if (isActiveRunStatus(taskStatus) || isActiveRunStatus(relatedRun?.raw_status) || isActiveRunStatus(relatedRun?.status)) {
return task.status;
}
if (taskStatus.toUpperCase() === 'PARTIAL_SUCCESS' || hasMixedRunOutcome(relatedRun)) {
return 'PARTIAL_SUCCESS';
}
return task.status;
}
function formatRunCounts(run) {
const { completed, failed, skipped, total } = getRunCounts(run);
const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
const parts = [];
if (running != null && running > 0) parts.push(`运行中 ${running}`);
if (completed != null) parts.push(`成功 ${completed}`);
if (failed != null) parts.push(`失败 ${failed}`);
if (skipped != null && skipped > 0) parts.push(`跳过 ${skipped}`);
if (cancelled != null && cancelled > 0) parts.push(`取消 ${cancelled}`);
if (pending != null && pending > 0) parts.push(`待处理 ${pending}`);
if (total != null) parts.push(`总数 ${total}`);
return parts.join(' / ');
}
@@ -309,6 +363,7 @@ function formatRunCounts(run) {
function statusToneClass(status) {
const normalized = String(status || '').toLowerCase();
if (normalized === 'success' || normalized === 'completed') return 'tone-ready';
if (normalized === 'partial_success' || normalized === 'partial' || normalized === 'completed_with_errors') return 'tone-partial';
if (normalized === 'failed') return 'tone-error';
if (normalized === 'running') return 'tone-info';
if (normalized === 'cancelled' || normalized === 'canceled') return 'tone-neutral';
@@ -428,10 +483,11 @@ function RunPathBlock({ run }) {
}
function RunStatusBlock({ run }) {
const displayStatus = getDisplayRunStatus(run);
return (
<div style={{ display: 'grid', gap: 4, minWidth: 120 }}>
<span className={`dinsar-status-pill ${statusToneClass(run?.status)}`}>
{formatStatus(run?.status)}
<span className={`dinsar-status-pill ${statusToneClass(displayStatus)}`}>
{formatStatus(displayStatus)}
</span>
{hasRunCounts(run) && (
<span style={{ color: '#475569', fontSize: 11, lineHeight: 1.35 }}>
@@ -817,6 +873,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
recentLimit: 1,
});
const latestRunWithTask = runs.find(run => run?.task_id) || null;
const relatedMonitoredRun = runs.find(run => {
const taskId = String(run?.task_id || '').trim();
return taskId && taskId === String(taskMonitor.latestTask?.task_id || '').trim();
}) || latestRunWithTask || null;
const monitoredTask = taskMonitor.latestTask || (
latestRunWithTask
? {
@@ -827,16 +887,28 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
: latestRunWithTask.engine === 'landsar'
? (latestRunWithTask.mode === 'cluster' ? 'LANDSAR_CLUSTER_RUN' : 'LANDSAR_RUN')
: 'IDL_RUN_DINSAR',
status: latestRunWithTask.raw_status || latestRunWithTask.status,
progress: latestRunWithTask.raw_status === 'COMPLETED' || latestRunWithTask.status === 'success' ? 100 : null,
status: getDisplayTaskStatus(
{ status: latestRunWithTask.raw_status || latestRunWithTask.status },
latestRunWithTask,
),
progress: ['COMPLETED', 'PARTIAL_SUCCESS'].includes(String(latestRunWithTask.raw_status || '').toUpperCase())
|| ['success', 'partial_success'].includes(String(latestRunWithTask.status || '').toLowerCase())
? 100
: null,
message: latestRunWithTask.message || '最近一次任务',
}
: null
);
const monitoredTaskDisplayStatus = getDisplayTaskStatus(monitoredTask, relatedMonitoredRun);
const logTaskId = monitoredTask?.task_id || '';
const showingRecentTask = !taskMonitor.isBusy && !!monitoredTask;
const activeRunCount = runs.filter(run => ['running', 'pending'].includes(String(run.status || '').toLowerCase())).length;
const failedRunCount = runs.filter(run => ['failed', 'FAILED'].includes(String(run.status || ''))).length;
const issueRunCount = runs.filter(run => {
const displayStatus = String(getDisplayRunStatus(run) || '').toLowerCase();
const rawStatus = String(run.raw_status || '').toUpperCase();
return ['failed', 'partial_success'].includes(displayStatus)
|| ['FAILED', 'PARTIAL_SUCCESS'].includes(rawStatus);
}).length;
const productionReady = !!currentEngineObj?.available && !!rootDir.trim() && !pyintPreviewBlocksSubmit && !readOnly;
const loadEngines = useCallback(async () => {
@@ -1471,9 +1543,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
<span>运行中</span>
<strong>{activeRunCount}</strong>
</div>
<div className={`dinsar-production-signal ${failedRunCount > 0 ? 'warn' : ''}`}>
<span>失败记录</span>
<strong>{failedRunCount}</strong>
<div className={`dinsar-production-signal ${issueRunCount > 0 ? 'warn' : ''}`}>
<span>异常记录</span>
<strong>{issueRunCount}</strong>
</div>
</div>
</div>
@@ -1975,34 +2047,79 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
</div>
{monitoredTask && (
(() => {
const isPartial = String(monitoredTaskDisplayStatus || '').toUpperCase() === 'PARTIAL_SUCCESS';
const isFailedStatus = String(monitoredTaskDisplayStatus || '').toUpperCase() === 'FAILED';
const panelTone = isPartial
? {
background: '#fffbeb',
border: '#fcd34d',
title: '#92400e',
text: '#78350f',
track: '#fde68a',
fill: '#d97706',
}
: isFailedStatus
? {
background: '#fef2f2',
border: '#fecaca',
title: '#b91c1c',
text: '#7f1d1d',
track: '#fee2e2',
fill: '#dc2626',
}
: showingRecentTask
? {
background: '#eff6ff',
border: '#bfdbfe',
title: '#1d4ed8',
text: '#1e40af',
track: '#dbeafe',
fill: '#3b82f6',
}
: {
background: '#fefce8',
border: '#fde68a',
title: '#92400e',
text: '#78350f',
track: '#fde68a',
fill: '#f59e0b',
};
const monitoredCountsText = hasRunCounts(relatedMonitoredRun) ? formatRunCounts(relatedMonitoredRun) : '';
return (
<div
style={{
marginBottom: 10,
padding: '8px 10px',
background: showingRecentTask ? '#eff6ff' : '#fefce8',
background: panelTone.background,
borderRadius: 6,
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
border: `1px solid ${panelTone.border}`,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: showingRecentTask ? '#1d4ed8' : '#92400e',
color: panelTone.title,
marginBottom: 4,
}}
>
{showingRecentTask ? '最近一次任务' : '当前任务'}
</div>
<div style={{ fontSize: 12, color: showingRecentTask ? '#1e40af' : '#78350f', wordBreak: 'break-all' }}>
{monitoredTask.task_id} - {formatTaskType(monitoredTask.task_type)} - {formatStatus(monitoredTask.status)} - {monitoredTask.message}
<div style={{ fontSize: 12, color: panelTone.text, wordBreak: 'break-all' }}>
{monitoredTask.task_id} - {formatTaskType(monitoredTask.task_type)} - {formatStatus(monitoredTaskDisplayStatus)} - {monitoredTask.message}
</div>
{monitoredCountsText && (
<div style={{ marginTop: 4, fontSize: 11, color: panelTone.text }}>
运行情况:{monitoredCountsText}
</div>
)}
{monitoredTask.progress != null && (
<div style={{ marginTop: 6 }}>
<div
style={{
height: 6,
background: showingRecentTask ? '#dbeafe' : '#fde68a',
background: panelTone.track,
borderRadius: 3,
overflow: 'hidden',
}}
@@ -2011,11 +2128,11 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
style={{
height: '100%',
width: `${monitoredTask.progress}%`,
background: showingRecentTask ? '#3b82f6' : '#f59e0b',
background: panelTone.fill,
}}
/>
</div>
<div style={{ fontSize: 11, color: showingRecentTask ? '#1d4ed8' : '#92400e', marginTop: 2 }}>{monitoredTask.progress}%</div>
<div style={{ fontSize: 11, color: panelTone.title, marginTop: 2 }}>{monitoredTask.progress}%</div>
</div>
)}
@@ -2023,7 +2140,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
style={{
marginTop: 8,
background: '#fff',
border: `1px solid ${showingRecentTask ? '#bfdbfe' : '#fde68a'}`,
border: `1px solid ${panelTone.border}`,
borderRadius: 6,
padding: '8px 10px',
}}
@@ -2104,6 +2221,8 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)}
</div>
</div>
);
})()
)}
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>运行记录(已加载 {runs.length} 条)</div>
+7 -7
View File
@@ -128,7 +128,7 @@ const formatSourceRootRole = (role, en = false) => {
const buildConsistencySummary = (stats, en = false) => {
const dinsar = stats?.dinsar_cache_consistency || {};
const preview = stats?.source_preview_consistency || {};
const xml = stats?.source_xml_consistency || {};
const metadata = stats?.source_metadata_consistency || {};
const water = stats?.water_geo_consistency || {};
const issues = [
@@ -163,15 +163,15 @@ const buildConsistencySummary = (stats, en = false) => {
level: 'warn',
},
{
key: 'xml_unparsed',
label: en ? 'Source: XML detected but key fields not imported' : '源影像:检测到 XML 但关键字段未入库',
count: toNumber(xml.xml_detected_but_unparsed_count),
key: 'metadata_parse_issue',
label: en ? 'Source: metadata document parse issues' : '源影像:元数据文档解析异常',
count: toNumber(metadata.metadata_detected_but_unparsed_count),
level: 'warn',
},
{
key: 'xml_missing',
label: en ? 'Source: XML not detected' : '源影像:未检测到 XML',
count: toNumber(xml.xml_missing_count),
key: 'metadata_missing',
label: en ? 'Source: metadata document not registered' : '源影像:元数据文档未入库',
count: toNumber(metadata.metadata_document_missing_count),
level: 'warn',
},
{
+98 -56
View File
@@ -1,23 +1,39 @@
import React, { useEffect, useState } from 'react';
import apiClient from './api/client';
import clusterDeliveryUrl from './assets/login/cluster-delivery-art.webp';
import processingArchitectureUrl from './assets/login/processing-architecture-art.webp';
import systemOverviewUrl from './assets/login/system-overview-art.webp';
const loginSlides = [
{
title: '统一管理雷达数据生产流程',
description: '覆盖源数据入库、精密轨道匹配、生产编排、成果目录和地图分析,形成可审计的工程闭环。',
image: systemOverviewUrl,
},
{
title: '面向 D-InSAR / SBAS 的处理链路',
description: '扫描与解析先行,生产任务进入队列,运行结果和失败归因统一登记,便于复查和补跑。',
image: processingArchitectureUrl,
},
{
title: '支持集群生产与成果交付',
description: '主服务器负责资产、权限和审计,生产节点承担受控处理服务,结果回传后进入交付申请与下载流程。',
image: clusterDeliveryUrl,
},
];
const LoginPage = ({ onLoginSuccess }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [licenseStatus, setLicenseStatus] = useState(null);
const [activeSlide, setActiveSlide] = useState(0);
useEffect(() => {
const checkLicense = async () => {
try {
const response = await apiClient.get('/license/status');
setLicenseStatus(response.data || null);
} catch {
setLicenseStatus({ ok: false, reason: '无法获取授权状态' });
}
};
checkLicense();
const timer = window.setInterval(() => {
setActiveSlide((current) => (current + 1) % loginSlides.length);
}, 5200);
return () => window.clearInterval(timer);
}, []);
const handleSubmit = async (event) => {
@@ -48,57 +64,83 @@ const LoginPage = ({ onLoginSuccess }) => {
return (
<div className="login-page-wrapper">
<div className="login-card">
<div className="login-card-header">
<div className="login-brand-tag">InSAR</div>
<h2>管理系统登录</h2>
<p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p>
</div>
{licenseStatus && !licenseStatus.ok && (
<div className="login-alert warn">
当前授权状态异常:{licenseStatus.reason || '未授权'}
<div className="login-shell">
<section className="login-card" aria-label="系统登录">
<div className="login-card-header">
<div className="login-brand-tag">InSAR Production</div>
<h2>雷达数据生产管理系统</h2>
<p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p>
</div>
)}
<form onSubmit={handleSubmit} className="login-form-grid">
<label className="login-field">
<span>用户名</span>
<input
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="login-field">
<span>密码</span>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<button type="submit" className="primary-btn" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
<form onSubmit={handleSubmit} className="login-form-grid">
<label className="login-field">
<span>用户名</span>
<input
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="login-field">
<span>密码</span>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<button type="submit" className="primary-btn login-submit-btn" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
{message && (
<div className="login-alert error">
{message}
{message && (
<div className="login-alert error">
{message}
</div>
)}
<div className="login-tips">
<strong>提示:</strong>
<span>首次部署请确认已在 `.env` 配置管理员账号与密码。</span>
</div>
)}
</section>
<div className="login-tips">
<strong>提示:</strong>
<span>首次部署请确认已在 `.env` 配置管理员账号与密码。</span>
</div>
<section className="login-visual-panel" aria-label="系统能力介绍">
<div className="login-slide-copy">
<span>系统能力</span>
<h3>{loginSlides[activeSlide].title}</h3>
<p>{loginSlides[activeSlide].description}</p>
</div>
<div className="login-slide-frame">
{loginSlides.map((slide, index) => (
<img
key={slide.title}
src={slide.image}
alt={slide.title}
className={`login-slide-image ${index === activeSlide ? 'active' : ''}`}
aria-hidden={index !== activeSlide}
/>
))}
</div>
<div className="login-slide-dots" aria-label="切换系统介绍图">
{loginSlides.map((slide, index) => (
<button
key={slide.title}
type="button"
className={index === activeSlide ? 'active' : ''}
aria-label={`查看${slide.title}`}
onClick={() => setActiveSlide(index)}
/>
))}
</div>
</section>
</div>
</div>
);
+354
View File
@@ -0,0 +1,354 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
cleanupMaintenanceTask,
getMaintenanceTaskDiagnosis,
listMaintenanceTasks,
previewMaintenanceCleanup,
} from './api/opsMaintenance';
const TASK_TYPES = [
['', '全部类型'],
['LANDSAR_RUN', 'LandSAR D-InSAR'],
['LANDSAR_CLUSTER_RUN', 'LandSAR 集群'],
['PYINT_RUN', 'PyINT/Gamma'],
['IDL_RUN_DINSAR', 'SARscape D-InSAR'],
['COPY_DATA', '数据准备'],
['PAIRING_CACHE_REBUILD', '配对缓存'],
];
const STATUSES = [
['', '全部状态'],
['COMPLETED', '已完成'],
['FAILED', '失败'],
['PARTIAL_SUCCESS', '部分成功'],
['CANCELLED', '已取消'],
['RUNNING', '运行中'],
['PENDING', '等待中'],
];
const formatDate = (value) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', { hour12: false });
};
const formatCount = (value) => Number(value || 0).toLocaleString('zh-CN');
const toneForStatus = (status) => {
const value = String(status || '').toUpperCase();
if (value === 'FAILED') return 'danger';
if (value === 'PARTIAL_SUCCESS' || value === 'CANCELLED') return 'warn';
if (value === 'COMPLETED') return 'ok';
if (value === 'RUNNING' || value === 'PENDING') return 'info';
return 'neutral';
};
const StatusBadge = ({ status }) => (
<span className={`ops-task-badge tone-${toneForStatus(status)}`}>{status || '-'}</span>
);
const CountLine = ({ counts = {} }) => (
<span>
成功 {formatCount(counts.completed_items)} / 失败 {formatCount(counts.failed_items)} / 等待 {formatCount(counts.pending_items)} / 运行 {formatCount(counts.running_items)}
</span>
);
const DatabasePreview = ({ counts = {} }) => {
const entries = Object.entries(counts).filter(([, value]) => Number(value || 0) > 0);
if (!entries.length) return <div className="ops-task-muted">没有将删除的数据库记录。</div>;
return (
<div className="ops-task-preview-grid">
{entries.map(([key, value]) => (
<div key={key} className="ops-task-preview-cell">
<span>{key}</span>
<strong>{formatCount(value)}</strong>
</div>
))}
</div>
);
};
const DiskPreview = ({ paths = [] }) => {
if (!paths.length) return <div className="ops-task-muted">没有将删除的磁盘路径。</div>;
return (
<div className="ops-task-path-list">
{paths.map(item => (
<div key={`${item.kind}-${item.path}`} className={`ops-task-path ${item.allowed ? '' : 'blocked'}`}>
<span>{item.kind || 'path'}</span>
<code>{item.path}</code>
<em>{item.exists ? '存在' : '不存在'} / {item.allowed ? '允许' : '禁止'}</em>
</div>
))}
</div>
);
};
const OpsTaskMaintenancePanel = ({ isAdmin }) => {
const [filters, setFilters] = useState({ task_type: '', status: '' });
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [selectedTaskId, setSelectedTaskId] = useState('');
const [diagnosis, setDiagnosis] = useState(null);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [preview, setPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [workCleanupLoading, setWorkCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState(null);
const [panelMessage, setPanelMessage] = useState(null);
const loadTasks = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await listMaintenanceTasks({
task_type: filters.task_type || undefined,
status: filters.status || undefined,
limit: 80,
});
setTasks(Array.isArray(data?.items) ? data.items : []);
} catch (err) {
setError(err.response?.data?.detail || err.message || '任务维护列表加载失败');
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
loadTasks();
}, [loadTasks]);
const selectedTask = useMemo(
() => tasks.find(item => item.task_id === selectedTaskId) || null,
[tasks, selectedTaskId],
);
const loadDiagnosis = useCallback(async (taskId) => {
setSelectedTaskId(taskId);
setDiagnosis(null);
setPreview(null);
setCleanupResult(null);
setPanelMessage(null);
setDiagnosisLoading(true);
try {
const data = await getMaintenanceTaskDiagnosis(taskId);
setDiagnosis(data);
} catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '诊断加载失败' });
} finally {
setDiagnosisLoading(false);
}
}, []);
const loadPreview = useCallback(async () => {
if (!selectedTaskId) return;
setPreviewLoading(true);
setCleanupResult(null);
try {
const data = await previewMaintenanceCleanup(selectedTaskId);
setPreview(data);
} catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理预览失败' });
} finally {
setPreviewLoading(false);
}
}, [selectedTaskId]);
const executeLandsarWorkCleanup = useCallback(async () => {
if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return;
setWorkCleanupLoading(true);
setCleanupResult(null);
try {
const data = await cleanupMaintenanceTask(selectedTaskId, {
confirm: true,
delete_landsar_work_dir: true,
});
const deletedCount = (data.deleted_disk?.deleted || []).length;
const message = `LandSAR 工作目录清理完成:目录 ${deletedCount} 个。`;
setCleanupResult({ ok: true, message });
setPanelMessage({ ok: true, message });
setPreview(null);
await loadDiagnosis(selectedTaskId);
await loadTasks();
} catch (err) {
const message = err.response?.data?.detail || err.message || 'LandSAR 工作目录清理失败';
setCleanupResult({ ok: false, message });
setPanelMessage({ ok: false, message });
} finally {
setWorkCleanupLoading(false);
}
}, [isAdmin, loadDiagnosis, loadTasks, preview, selectedTaskId]);
const abnormalCount = tasks.length;
const cleanableCount = tasks.filter(item => item.cleanup_supported).length;
const blockedCount = tasks.filter(item => !item.cleanup_supported).length;
return (
<div className="ops-task-panel">
<div className="ops-task-header">
<div>
<h4>任务维护</h4>
<p>查看失败、部分成功、取消和残留运行的任务;清理后请回到业务页面重新提交。</p>
</div>
<button className="health-action-button" onClick={loadTasks} disabled={loading}>
{loading ? '刷新中...' : '刷新任务'}
</button>
</div>
<div className="ops-task-summary">
<div><span>异常任务</span><strong>{formatCount(abnormalCount)}</strong></div>
<div><span>可清理</span><strong>{formatCount(cleanableCount)}</strong></div>
<div><span>需复核</span><strong>{formatCount(blockedCount)}</strong></div>
</div>
<div className="ops-task-filters">
<label>
<span>任务类型</span>
<select value={filters.task_type} onChange={event => setFilters(prev => ({ ...prev, task_type: event.target.value }))}>
{TASK_TYPES.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
</select>
</label>
<label>
<span>状态</span>
<select value={filters.status} onChange={event => setFilters(prev => ({ ...prev, status: event.target.value }))}>
{STATUSES.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
</select>
</label>
</div>
{error && <div className="health-card-note error">{error}</div>}
{panelMessage && (
<div className={`health-card-note ${panelMessage.ok ? 'ok' : 'error'}`}>
{panelMessage.message}
</div>
)}
<div className="ops-task-table-wrap">
<table className="ops-task-table">
<thead>
<tr>
<th>状态</th>
<th>任务</th>
<th>统计</th>
<th>问题摘要</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{tasks.map(task => (
<tr key={task.task_id} className={task.task_id === selectedTaskId ? 'selected' : ''}>
<td><StatusBadge status={task.status} /></td>
<td>
<div className="ops-task-name">{task.task_name || task.task_id}</div>
<div className="ops-task-muted">{task.task_type} / {task.task_id}</div>
</td>
<td><CountLine counts={task.counts} /></td>
<td>{task.issue_summary || '-'}</td>
<td>{formatDate(task.updated_at)}</td>
<td>
<button className="health-inline-button" onClick={() => loadDiagnosis(task.task_id)}>
诊断
</button>
</td>
</tr>
))}
{!loading && !tasks.length && (
<tr>
<td colSpan={6} className="ops-task-empty">当前没有匹配的异常任务。</td>
</tr>
)}
</tbody>
</table>
</div>
{selectedTaskId && (
<div className="ops-task-detail">
<div className="ops-task-detail-header">
<div>
<h4>{selectedTask?.task_name || selectedTaskId}</h4>
<p>{selectedTaskId}</p>
</div>
<button className="health-inline-button" onClick={loadPreview} disabled={previewLoading || diagnosisLoading}>
{previewLoading ? '生成中...' : '生成清理预览'}
</button>
</div>
{diagnosisLoading ? (
<div className="ops-task-muted">正在加载诊断...</div>
) : diagnosis ? (
<>
<div className="ops-task-diagnosis">
{(diagnosis.diagnosis?.findings || []).map(item => (
<div key={item} className="health-card-note warn">{item}</div>
))}
{!(diagnosis.diagnosis?.findings || []).length && (
<div className="health-card-note ok">未发现明显异常。</div>
)}
{(diagnosis.diagnosis?.cleanup_blockers || []).map(item => (
<div key={item} className="health-card-note error">{item}</div>
))}
</div>
<div className="ops-task-two-col">
<div>
<div className="ops-task-section-title">生产统计</div>
<DatabasePreview counts={{
...diagnosis.production_item_counts,
...Object.fromEntries(Object.entries(diagnosis.production_execution_counts || {}).map(([key, value]) => [`execution_${key}`, value])),
}} />
</div>
<div>
<div className="ops-task-section-title">最近日志</div>
<div className="ops-task-log-list">
{(diagnosis.recent_logs || []).slice(0, 8).map(log => (
<div key={log.id}>
<span>[{formatDate(log.timestamp)}] {log.level}</span>
<p>{log.message}</p>
</div>
))}
{!(diagnosis.recent_logs || []).length && <div className="ops-task-muted">无日志。</div>}
</div>
</div>
</div>
</>
) : null}
{preview && (
<div className="ops-task-preview">
<div className="ops-task-section-title">清理预览</div>
{preview.blocked && (
<div className="health-card-note error">
{preview.blockers?.join(';') || '当前任务不允许清理。'}
</div>
)}
<div>
<div className="ops-task-section-title">LANDSAR_WORK_ROOT</div>
<DiskPreview paths={preview.disk_deletes} />
</div>
<div className="ops-task-actions">
{!isAdmin && <span className="ops-task-muted">仅管理员可执行清理。</span>}
<button
className="health-inline-button"
onClick={executeLandsarWorkCleanup}
disabled={!isAdmin || workCleanupLoading || preview.blocked}
>
{workCleanupLoading ? '清理中...' : '清理 LandSAR 工作目录'}
</button>
</div>
</div>
)}
{cleanupResult && (
<div className={`health-card-note ${cleanupResult.ok ? 'ok' : 'error'}`}>
{cleanupResult.message}
</div>
)}
</div>
)}
</div>
);
};
export default OpsTaskMaintenancePanel;
+1
View File
@@ -217,6 +217,7 @@ export default function ProductionWorkspace({
onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch}
onSearchAll={radarPanel?.onSearchAll}
onRefreshDinsar={pairingPanel?.onRefreshDinsar}
onOpenPairsView={() => setActiveView('dinsar_pairs')}
language={language}
/>
);
+170 -60
View File
@@ -7,6 +7,7 @@ import {
getResultDeliveryArchiveUrl,
getResultDeliveryDownloadUrl,
getResultDeliveryManifestUrl,
listResultDeliveryCatalog,
listResultDeliveries,
} from './api/resultDeliveries';
import { getDinsarEngineMeta } from './utils/dinsarEngines';
@@ -34,9 +35,9 @@ const PRODUCT_CHANNELS = [
key: 'lt1_ortho',
group: '正射成果',
label: 'LT-1 正射结果',
state: 'placeholder',
stateText: '待接入',
description: '陆探一正射生产将由 LandSAR 生产链注册到统一成果目录后开放交付。',
state: 'ready',
stateText: '可交付',
description: '服务器生产的 LT-1 分析就绪正射 GeoTIFF 已接入交付,可打包下载到本地。',
},
{
key: 's1_ortho',
@@ -50,9 +51,9 @@ const PRODUCT_CHANNELS = [
key: 'gf3_ortho',
group: '正射成果',
label: 'GF3 SARscape _geo',
state: 'placeholder',
stateText: '待接入',
description: 'GF3 外部生产成果登记后再接入统一交付。',
state: 'ready',
stateText: '可交付',
description: '已登记的 GF3 SARscape 标准化正射成品可直接创建交付包。',
},
];
@@ -85,17 +86,89 @@ function extractTotal(payload, fallback = 0) {
}
function resultDisplayName(result) {
return String(result?.name || result?.task_alias || result?.task_name || result?.product_id || `#${result?.id || ''}`).trim();
return String(result?.display_name || result?.name || result?.task_alias || result?.task_name || result?.product_id || `#${result?.id || ''}`).trim();
}
function resultItemId(result) {
return result?.item_id ?? result?.id;
}
function selectionKey(channelKey, id) {
return `${channelKey}:${id}`;
}
function resultDateText(result) {
const name = resultDisplayName(result);
if (result?.imaging_date) return String(result.imaging_date);
const matches = name.match(/(\d{8})/g);
if (matches?.length >= 2) return `${matches[0]} / ${matches[1]}`;
if (matches?.length === 1) return matches[0];
return '-';
}
function channelMetricLabel(channelKey) {
return {
dinsar: 'D-InSAR 可交付',
sbas: 'SBAS 目录',
lt1_ortho: 'LT-1 正射',
gf3_ortho: 'GF3 正射',
s1_ortho: 'Sentinel-1 正射',
}[channelKey] || '可交付结果';
}
function channelListTitle(channelKey) {
return {
dinsar: 'D-InSAR 结果列表',
lt1_ortho: 'LT-1 正射结果列表',
gf3_ortho: 'GF3 正射结果列表',
}[channelKey] || '结果列表';
}
function channelEmptyText(channelKey) {
return {
dinsar: '当前条件下没有可交付的 D-InSAR 结果。',
lt1_ortho: '当前条件下没有可交付的 LT-1 正射结果。',
gf3_ortho: '当前条件下没有可交付的 GF3 正射结果。',
}[channelKey] || '当前条件下没有可交付结果。';
}
function channelCreateTitle(channelKey) {
return {
dinsar: 'D-InSAR 成果交付',
lt1_ortho: 'LT-1 正射成果交付',
gf3_ortho: 'GF3 正射成果交付',
}[channelKey] || '成果交付';
}
function channelCreateSubtitle(channelKey) {
return {
dinsar: '选择已登记结果并生成下载包',
lt1_ortho: '选择服务器已生产正射 GeoTIFF 并生成下载包',
gf3_ortho: '选择已登记 GF3 SARscape 正射成品并生成下载包',
}[channelKey] || '选择结果并生成下载包';
}
function itemMetaText(item, channelKey) {
if (channelKey === 'dinsar') {
return `${resultDateText(item)} · ${item.pair_key || item.product_id || '-'}`;
}
const parts = [
item.imaging_date || resultDateText(item),
item.polarization,
item.pixel_size_m ? `${item.pixel_size_m} m` : null,
item.product_id,
].filter(Boolean);
return parts.join(' · ') || '-';
}
function itemStatusText(item, channelKey) {
if (channelKey === 'dinsar') {
return item.is_cached ? '预览就绪' : '预览待建';
}
if (item.file_size) return formatBytes(item.file_size);
return item.health_status || item.status || 'READY';
}
function stateClass(state) {
if (state === 'ready') return 'ready';
if (state === 'planned') return 'planned';
@@ -125,6 +198,8 @@ export default function ResultExtractionPanel({ readOnly = false }) {
const [activeChannel, setActiveChannel] = useState('dinsar');
const [dinsarPayload, setDinsarPayload] = useState({ items: [], total: 0 });
const [sbasPayload, setSbasPayload] = useState({ items: [], total: 0 });
const [lt1Payload, setLt1Payload] = useState({ items: [], total: 0 });
const [gf3Payload, setGf3Payload] = useState({ items: [], total: 0 });
const [deliveriesPayload, setDeliveriesPayload] = useState({ items: [], total: 0 });
const [loading, setLoading] = useState(true);
const [deliveryLoading, setDeliveryLoading] = useState(false);
@@ -161,13 +236,21 @@ export default function ResultExtractionPanel({ readOnly = false }) {
listSbasInsarProducts({ limit: 30, offset: 0 }),
listResultDeliveries({ mine: true, limit: 20, offset: 0 }),
]);
const [lt1Data, gf3Data] = await Promise.all([
listResultDeliveryCatalog('lt1_ortho', { limit: PAGE_SIZE, offset: 0 }),
listResultDeliveryCatalog('gf3_ortho', { limit: PAGE_SIZE, offset: 0 }),
]);
const dinsarItems = normalizeItems(dinsarData);
setDinsarPayload({ ...dinsarData, items: dinsarItems, total: extractTotal(dinsarData, dinsarItems.length) });
const sbasItems = normalizeItems(sbasData);
setSbasPayload({ ...sbasData, items: sbasItems, total: extractTotal(sbasData, sbasItems.length) });
const lt1Items = normalizeItems(lt1Data);
setLt1Payload({ ...lt1Data, items: lt1Items, total: extractTotal(lt1Data, lt1Items.length) });
const gf3Items = normalizeItems(gf3Data);
setGf3Payload({ ...gf3Data, items: gf3Items, total: extractTotal(gf3Data, gf3Items.length) });
const deliveryItems = normalizeItems(deliveryData);
setDeliveriesPayload({ ...deliveryData, items: deliveryItems, total: extractTotal(deliveryData, deliveryItems.length) });
setSelectedIds(new Set(dinsarItems.map(item => item.id).filter(id => id !== undefined && id !== null)));
setSelectedIds(new Set(dinsarItems.map(item => resultItemId(item)).filter(id => id !== undefined && id !== null).map(id => selectionKey('dinsar', id))));
} catch (err) {
setError(err?.response?.data?.detail || err.message || '结果目录加载失败');
} finally {
@@ -191,41 +274,63 @@ export default function ResultExtractionPanel({ readOnly = false }) {
return () => window.clearInterval(timer);
}, [deliveriesPayload.items]);
const filteredDinsar = useMemo(() => {
const activePayload = activeChannel === 'lt1_ortho'
? lt1Payload
: activeChannel === 'gf3_ortho'
? gf3Payload
: activeChannel === 'sbas'
? sbasPayload
: activeChannel === 's1_ortho'
? { items: [], total: 0 }
: dinsarPayload;
const activeItems = activePayload.items || [];
const filteredResults = useMemo(() => {
const value = query.trim().toLowerCase();
const items = dinsarPayload.items || [];
const items = activeItems;
if (!value) return items;
return items.filter(item => {
const haystack = [
item.display_name,
item.name,
item.task_name,
item.task_alias,
item.pair_key,
item.product_id,
item.engine_code,
item.profile_code,
item.imaging_date,
item.polarization,
item.file_path,
item.primary_asset_path,
item.publish_dir,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(value);
});
}, [dinsarPayload.items, query]);
}, [activeItems, query]);
const filteredIds = useMemo(
() => filteredDinsar.map(item => item.id).filter(id => id !== undefined && id !== null),
[filteredDinsar],
() => filteredResults.map(item => resultItemId(item)).filter(id => id !== undefined && id !== null),
[filteredResults],
);
const selectedCountInView = filteredIds.filter(id => selectedIds.has(id)).length;
const filteredKeys = useMemo(
() => filteredIds.map(id => selectionKey(activeChannel, id)),
[activeChannel, filteredIds],
);
const selectedCountInView = filteredKeys.filter(key => selectedIds.has(key)).length;
const allVisibleSelected = filteredIds.length > 0 && selectedCountInView === filteredIds.length;
const latestDelivery = deliveriesPayload.items?.[0] || null;
const orthoPlaceholderCount = PRODUCT_CHANNELS.filter(channel => channel.group === '正射成果').length;
const currentCatalogTotal = Number(dinsarPayload.total || 0) + Number(sbasPayload.total || 0);
const readyOrthoCount = Number(lt1Payload.total || 0) + Number(gf3Payload.total || 0);
const currentCatalogTotal = Number(dinsarPayload.total || 0) + Number(sbasPayload.total || 0) + readyOrthoCount;
const metrics = [
{
label: 'D-InSAR 可交付',
value: dinsarPayload.total,
note: `当前载入 ${filteredDinsar.length}/${dinsarPayload.items.length} 条`,
label: channelMetricLabel(activeChannel),
value: activePayload.total,
note: `当前载入 ${filteredResults.length}/${activeItems.length} 条`,
tone: 'primary',
},
{
@@ -237,24 +342,25 @@ export default function ResultExtractionPanel({ readOnly = false }) {
{
label: '当前接入目录',
value: currentCatalogTotal,
note: 'D-InSAR + SBAS 已接入清单',
note: 'D-InSAR + SBAS + LT-1/GF3 正射',
tone: 'neutral',
},
{
label: '正射通道',
value: orthoPlaceholderCount,
note: 'LT-1 / S1 / GF3 占位',
tone: 'warning',
label: '可交付正射',
value: readyOrthoCount,
note: 'LT-1 与 GF3 已接入,S1 预留',
tone: 'neutral',
},
];
const toggleOne = (id) => {
const key = selectionKey(activeChannel, id);
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
if (next.has(key)) {
next.delete(key);
} else {
next.add(id);
next.add(key);
}
return next;
});
@@ -264,30 +370,35 @@ export default function ResultExtractionPanel({ readOnly = false }) {
setSelectedIds(prev => {
const next = new Set(prev);
if (allVisibleSelected) {
filteredIds.forEach(id => next.delete(id));
filteredKeys.forEach(key => next.delete(key));
} else {
filteredIds.forEach(id => next.add(id));
filteredKeys.forEach(key => next.add(key));
}
return next;
});
};
const handleCreateDelivery = async () => {
const ids = [...selectedIds].filter(id => filteredIds.includes(id));
const ids = filteredIds.filter(id => selectedIds.has(selectionKey(activeChannel, id)));
if (ids.length === 0) {
setCreateError('请至少选择一条 D-InSAR 结果。');
setCreateError(`请至少选择一条${selectedChannel.label}。`);
return;
}
setCreating(true);
setCreateError('');
setCreateResult(null);
try {
const response = await createResultDelivery({
channel: 'dinsar',
compat_result_ids: ids,
const payload = {
channel: activeChannel,
package_mode: packageMode,
include_checksums: includeChecksums,
});
};
if (activeChannel === 'dinsar') {
payload.compat_result_ids = ids;
} else {
payload.item_ids = ids;
}
const response = await createResultDelivery(payload);
setCreateResult(response);
await loadDeliveries();
} catch (err) {
@@ -353,12 +464,12 @@ export default function ResultExtractionPanel({ readOnly = false }) {
</section>
);
const renderDinsarWorkspace = () => (
const renderReadyWorkspace = () => (
<section className="result-extraction-main-card">
<div className="result-extraction-card-head">
<div>
<span>D-InSAR 成果交付</span>
<strong>选择已登记结果并生成下载包</strong>
<span>{channelCreateTitle(activeChannel)}</span>
<strong>{channelCreateSubtitle(activeChannel)}</strong>
</div>
<button type="button" onClick={loadCatalogs} disabled={loading || creating}>
刷新目录
@@ -372,7 +483,7 @@ export default function ResultExtractionPanel({ readOnly = false }) {
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="任务名、日期、pair_key、引擎"
placeholder="任务名、日期、产品号、极化、引擎"
disabled={loading || creating}
/>
</label>
@@ -424,36 +535,34 @@ export default function ResultExtractionPanel({ readOnly = false }) {
<div className="result-extraction-workspace-split">
<div className="result-extraction-result-column">
<div className="result-extraction-list-head">
<span>结果列表</span>
<strong>{selectedCountInView}/{filteredDinsar.length}</strong>
<span>{channelListTitle(activeChannel)}</span>
<strong>{selectedCountInView}/{filteredResults.length}</strong>
</div>
<div className="result-extraction-result-list">
{loading ? (
<div className="result-extraction-empty">正在加载成果目录...</div>
) : filteredDinsar.length === 0 ? (
<div className="result-extraction-empty">当前条件下没有可交付的 D-InSAR 结果。</div>
) : filteredResults.length === 0 ? (
<div className="result-extraction-empty">{channelEmptyText(activeChannel)}</div>
) : (
filteredDinsar.map(result => {
const id = result.id;
const engineMeta = getDinsarEngineMeta(result.engine_code);
filteredResults.map(result => {
const id = resultItemId(result);
const engineMeta = activeChannel === 'dinsar'
? getDinsarEngineMeta(result.engine_code)
: { tone: 'landsar', shortLabel: result.engine_code || result.profile_code || 'ORTHO' };
return (
<label key={id} className="result-extraction-result-row">
<label key={`${activeChannel}-${id}`} className="result-extraction-result-row">
<input
type="checkbox"
checked={selectedIds.has(id)}
checked={selectedIds.has(selectionKey(activeChannel, id))}
onChange={() => toggleOne(id)}
disabled={creating}
/>
<span className="result-extraction-result-main">
<strong title={result.file_path || resultDisplayName(result)}>{resultDisplayName(result)}</strong>
<span>
{resultDateText(result)}
{' · '}
{result.pair_key || result.product_id || '-'}
</span>
<strong title={result.file_path || result.primary_asset_path || resultDisplayName(result)}>{resultDisplayName(result)}</strong>
<span>{itemMetaText(result, activeChannel)}</span>
</span>
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>{engineMeta.shortLabel}</span>
<span className="result-extraction-status-chip">{result.is_cached ? '预览就绪' : '预览待建'}</span>
<span className="result-extraction-status-chip">{itemStatusText(result, activeChannel)}</span>
</label>
);
})
@@ -480,7 +589,7 @@ export default function ResultExtractionPanel({ readOnly = false }) {
</div>
<div>
<span>交付接口</span>
<strong>{selectedChannel.key === 'dinsar' ? '已接入' : '待实现'}</strong>
<strong>{selectedChannel.state === 'ready' ? '已接入' : '待实现'}</strong>
</div>
<div>
<span>用户权限</span>
@@ -513,13 +622,14 @@ export default function ResultExtractionPanel({ readOnly = false }) {
<span>成果交付出口</span>
<strong>结果提取工作台</strong>
<p>
将 D-InSAR 成果交付下载和后续正射成果交付统一管理。当前 D-InSAR 支持后台交付包,
SBAS、LT-1 正射、Sentinel-1 正射和 GF3 正射先保留清晰占位。
将 D-InSAR、LT-1 正射和 GF3 正射成果交付下载统一管理。后台生成受控交付包,用户完成后下载到本地;
SBAS 和 Sentinel-1 正射保留清晰接入状态。
</p>
</div>
<div className="result-extraction-hero-meta">
<span>D-InSAR {formatNumber(dinsarPayload.total)}</span>
<span>SBAS {formatNumber(sbasPayload.total)}</span>
<span>LT-1 {formatNumber(lt1Payload.total)}</span>
<span>GF3 {formatNumber(gf3Payload.total)}</span>
<span>{readOnly ? '自助交付' : '管理员'}</span>
</div>
</section>
@@ -549,7 +659,7 @@ export default function ResultExtractionPanel({ readOnly = false }) {
</button>
))}
</aside>
{activeChannel === 'dinsar' ? renderDinsarWorkspace() : renderPlaceholderWorkspace()}
{selectedChannel.state === 'ready' ? renderReadyWorkspace() : renderPlaceholderWorkspace()}
</section>
</div>
);
+133 -27
View File
@@ -295,7 +295,11 @@ function buildPipelineOption(rows) {
axisPointer: { type: 'shadow' },
formatter: (params) => {
const item = params?.[0]?.data || {};
return `${item.label}<br/>数量:${formatNumber(item.value)}<br/>比例:${formatPercent(item.rate)}`;
const denominator = Number(item.denominator);
const denominatorText = Number.isFinite(denominator) && denominator > 0
? `<br/>适用总数:${formatNumber(denominator)}`
: '';
return `${item.label}<br/>数量:${formatNumber(item.value)}${denominatorText}<br/>比例:${formatPercent(item.rate)}`;
},
},
xAxis: {
@@ -327,7 +331,13 @@ function buildPipelineOption(rows) {
show: true,
position: 'right',
color: '#475569',
formatter: (params) => `${formatNumber(params.data.value)} / ${formatPercent(params.data.rate)}`,
formatter: (params) => {
const denominator = Number(params.data.denominator);
const totalText = Number.isFinite(denominator) && denominator > 0
? `/${formatNumber(denominator)}`
: '';
return `${formatNumber(params.data.value)}${totalText} / ${formatPercent(params.data.rate)}`;
},
},
},
],
@@ -743,6 +753,87 @@ function FamilyLegend({ rows }) {
);
}
function formatYmd(value) {
const text = String(value || '').trim();
if (/^\d{8}$/.test(text)) {
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
return text || '-';
}
function MissingOrbitSummary({ summary }) {
const affectedSceneCount = Number(summary?.affected_scene_count || 0);
const missingDateCount = Number(summary?.missing_orbit_date_count || 0);
const bySatellite = Array.isArray(summary?.by_satellite) ? summary.by_satellite : [];
if (!affectedSceneCount) {
return (
<div className="statistics-orbit-gap statistics-orbit-gap--ok">
<strong>精轨缺口</strong>
<span>当前没有开放的缺精轨场景。</span>
</div>
);
}
return (
<div className="statistics-orbit-gap">
<div className="statistics-orbit-gap-head">
<div>
<strong>精轨缺口说明</strong>
<span>缺轨按受影响影像景数统计;补齐时按卫星日期准备 LT-1 TXT 精轨。</span>
</div>
<div className="statistics-orbit-gap-metrics">
<div>
<span>受影响景数</span>
<b>{formatNumber(affectedSceneCount)}</b>
</div>
<div>
<span>缺失卫星日期</span>
<b>{formatNumber(missingDateCount)}</b>
</div>
</div>
</div>
<div className="statistics-orbit-gap-list">
{bySatellite.map((item) => {
const months = Array.isArray(item.months) ? item.months : [];
const dates = Array.isArray(item.dates) ? item.dates : [];
return (
<div className="statistics-orbit-gap-satellite" key={item.satellite}>
<div className="statistics-orbit-gap-satellite-head">
<strong>{item.satellite || 'UNKNOWN'}</strong>
<span>
{formatNumber(item.affected_scene_count)} 景 / {formatNumber(item.missing_orbit_date_count)} 个日期
{item.first_missing_date && item.last_missing_date
? ` / ${formatYmd(item.first_missing_date)} 至 ${formatYmd(item.last_missing_date)}`
: ''}
</span>
</div>
<div className="statistics-orbit-month-grid">
{months.map((month) => (
<span key={`${item.satellite}-${month.month}`}>
{month.month}
<b>{formatNumber(month.affected_scene_count)}景</b>
<em>{formatNumber(month.missing_orbit_date_count)}日</em>
</span>
))}
</div>
<div className="statistics-orbit-date-row" aria-label={`${item.satellite} 缺失精轨日期`}>
{dates.map((date) => (
<span key={`${item.satellite}-${date.date}`} title={`${formatNumber(date.affected_scene_count)} 景受影响`}>
{formatYmd(date.date)}
<b>{formatNumber(date.affected_scene_count)}</b>
</span>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
function ProductionRunList({ rows }) {
const data = Array.isArray(rows) ? rows : [];
if (!data.length) {
@@ -792,11 +883,17 @@ export default function StatisticsDashboard() {
const [lastLoadedAt, setLastLoadedAt] = useState(null);
const [coverageMode, setCoverageMode] = useState('source');
const loadDashboard = async () => {
const loadDashboard = async ({ fresh = false } = {}) => {
setLoading(true);
setError('');
try {
const payload = await getStatisticsDashboard();
let payload;
try {
payload = await getStatisticsDashboard(fresh);
} catch (err) {
if (!fresh) throw err;
payload = await getStatisticsDashboard(false);
}
setData(payload);
setLastLoadedAt(new Date());
} catch (err) {
@@ -875,7 +972,7 @@ export default function StatisticsDashboard() {
{data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''}
</span>
)}
<button type="button" onClick={loadDashboard} disabled={loading}>
<button type="button" onClick={() => loadDashboard({ fresh: true })} disabled={loading}>
{loading ? '刷新中' : '刷新'}
</button>
</div>
@@ -948,29 +1045,38 @@ export default function StatisticsDashboard() {
<Chart option={sourceTrendOption} className="statistics-chart-md" />
</Section>
<Section title="精轨保障" subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中`}>
<div className="statistics-orbit-summary">
<div>
<span>精轨资产</span>
<strong>{formatNumber(data?.orbit?.orbit_total)}</strong>
</div>
<div>
<span>绑定覆盖率</span>
<strong>{formatPercent(data?.orbit?.selected_rate)}</strong>
</div>
<div>
<span>匹配记录</span>
<strong>{formatNumber(data?.orbit?.matched_bindings)}</strong>
</div>
</div>
<div className="statistics-mini-bars">
{(data?.orbit?.orbit_by_family || []).map((item) => (
<div key={item.family}>
<span>{item.family}</span>
<b>{formatNumber(item.count)}</b>
<i style={{ width: `${Math.min(100, Math.max(2, item.ok_rate * 100))}%` }} />
<Section
title="精轨保障"
subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中,${formatNumber(data?.orbit?.missing_summary?.affected_scene_count || data?.orbit?.missing_scene_count)} 景待补`}
className="statistics-section--orbit"
>
<div className="statistics-orbit-layout">
<div className="statistics-orbit-overview">
<div className="statistics-orbit-summary">
<div>
<span>精轨资产</span>
<strong>{formatNumber(data?.orbit?.orbit_total)}</strong>
</div>
<div>
<span>绑定覆盖率</span>
<strong>{formatPercent(data?.orbit?.selected_rate)}</strong>
</div>
<div>
<span>匹配记录</span>
<strong>{formatNumber(data?.orbit?.matched_bindings)}</strong>
</div>
</div>
))}
<div className="statistics-mini-bars">
{(data?.orbit?.orbit_by_family || []).map((item) => (
<div key={item.family}>
<span>{item.family}</span>
<b>{formatNumber(item.count)}</b>
<i style={{ width: `${Math.min(100, Math.max(2, item.ok_rate * 100))}%` }} />
</div>
))}
</div>
</div>
<MissingOrbitSummary summary={data?.orbit?.missing_summary} />
</div>
</Section>
+318
View File
@@ -0,0 +1,318 @@
import { useMemo, useState } from 'react';
const MANUAL_SECTIONS = [
{
id: 'start',
title: '快速入门',
summary: '按生产链路理解系统:先接入数据,再规划任务,最后生产、登记和交付成果。',
tags: ['流程', '新用户'],
highlights: [
'影像检索用于确认数据是否覆盖目标区域和时间。',
'资产台账用于确认源产品、精密轨道、元数据和几何画像是否可用于生产。',
'生产管理集中处理 D-InSAR、SBAS-InSAR 和 LT-1 单景生产。',
'结果提取面向成果登记和交付下载,不负责重新生产数据。',
],
workflow: [
{ name: '数据接入', detail: '扫描源数据、精密轨道和预览缓存,形成可查询资产。' },
{ name: '任务规划', detail: '在影像池中按时间、空间、AOI 和精轨条件生成候选任务。' },
{ name: '生产运行', detail: '按批次准备 Task_Pool,选择生产引擎并跟踪运行状态。' },
{ name: '结果管理', detail: '提取标准成果、登记 catalog,并为用户交付提供入口。' },
],
tips: [
'首次使用时先看“综合统计”,确认元数据、几何画像、精轨和预览是否完整。',
'生产前优先使用影像检索或省域覆盖配对检查空间覆盖,不要只看候选对数量。',
],
},
{
id: 'data',
title: '数据资产',
summary: '数据资产页负责把磁盘上的源数据和精密轨道变成系统可审计的生产资产。',
tags: ['扫描', '资产台账', '精轨'],
highlights: [
'源数据扫描是增量扫描:未变化的压缩包会在解析元数据前跳过。',
'精密轨道扫描也是增量扫描,LT-1 TXT 和 Sentinel-1 EOF 会登记为 orbit assets。',
'源数据扫描和精轨扫描结束后都会触发一次轨道绑定,因此先扫哪一类都能最终匹配。',
'预览缓存是辅助查看能力,不等同于生产就绪状态。',
],
workflow: [
{ name: '数据接入', detail: '选择源数据或精轨目录,启动资产索引任务。' },
{ name: '资产台账', detail: '查看源产品、精轨资产、绑定状态和开放问题。' },
{ name: '影像检索', detail: '按卫星、时间、轨道、AOI 和精轨状态筛选生产候选影像。' },
],
tips: [
'“缺精轨”按受影响影像景数统计,不等于缺失文件数量;统计页会展示实际缺失的卫星日期。',
'扫描日志中的 skipped 表示缓存命中,changed/new 表示进入解析队列的新增或变化文件。',
],
},
{
id: 'pairing',
title: '配对规划',
summary: 'D-InSAR 配对规划用于从影像池中生成标准候选对,并支持省域覆盖导向的贪心规划。',
tags: ['D-InSAR', '省域覆盖', '批次'],
highlights: [
'普通配对按时间基线、空间关系、重叠率、卫星模式、极化和精轨可用性筛选。',
'省域覆盖配对以 AOI 空间覆盖最大化为目标,时间范围是影像池约束。',
'保存后的批次可以分批生产,批次数量由用户指定,不需要一次性生产全部候选对。',
'已完成且已登记的结果应在候选列表中被识别,避免重复生产。',
],
workflow: [
{ name: '设置影像池', detail: '输入目标日期范围,必要时设置扩展天数补足覆盖。' },
{ name: '设置 AOI', detail: '选择行政区或上传 AOI 文件;省域任务应明确选择省级范围。' },
{ name: '生成候选对', detail: '检查覆盖、精轨、时间基线和质量指标。' },
{ name: '保存批次', detail: '按用户输入的数量分批保存,后续在生产准备中逐批处理。' },
],
tips: [
'省域覆盖的核心指标是空间覆盖,不是生成候选对数量。',
'如果候选很多但覆盖不足,优先检查 AOI、轨道方向、时间范围和精轨条件。',
],
},
{
id: 'production',
title: '生产管理',
summary: '生产管理把配对、数据准备、引擎运行和结果目录组织在同一个工作台内。',
tags: ['D-InSAR', 'SBAS', 'LT-1'],
highlights: [
'D-InSAR 工作台包含配对规划、候选对与批次、生产准备、生产运行和结果管理。',
'D-InSAR 当前支持 ENVI/SARscape、LandSAR 和 Gamma/PyINT 等生产路线。',
'LT-1 单景生产由服务器侧流水线输出 analysis_ready.tif,并登记为可交付正射成果。',
'SBAS-InSAR 当前以 Gamma 工作流为主,生产和成果目录与 D-InSAR 分开管理。',
],
workflow: [
{ name: '生产准备', detail: '把批次材料化到 Task_Pool,并复制源数据和精密轨道。' },
{ name: '生产运行', detail: '选择生产引擎,提交运行并观察成功、失败、部分成功状态。' },
{ name: '结果管理', detail: '从生产目录提取成果,登记标准 catalog 和预览资产。' },
],
tips: [
'“部分成功”表示批次内有成功结果也有失败任务;只有全部失败才应视为失败。',
'LandSAR 访问冲突、基线精估计失败、DEM/地理编码失败通常需要结合日志判断是数据质量、参数还是软件稳定性问题。',
],
},
{
id: 'results',
title: '结果管理与交付',
summary: '结果页面区分生产结果入库和用户成果交付,避免大文件下载阻塞页面请求。',
tags: ['成果', '下载', '登记'],
highlights: [
'D-InSAR 结果提取用于把生产目录中的位移结果登记为标准成果。',
'LT-1 正射和 GF3 正射属于可交付影像成果;Sentinel-1 正射仍是占位能力。',
'大体量成果建议通过后台交付任务生成目录或压缩包,再下载到本地。',
'服务器交付区只是临时缓存,正式成果仍以登记 catalog 和源生产目录为准。',
],
workflow: [
{ name: '登记成果', detail: '确认生产目录、主成果文件、预览和 manifest。' },
{ name: '选择交付内容', detail: '按 D-InSAR、LT-1 正射、GF3 正射等通道选择可交付成果。' },
{ name: '生成交付包', detail: '后台复制文件并生成 manifest 和校验信息。' },
{ name: '下载本地', detail: '用户从交付目录或下载链接获取成果。' },
],
tips: [
'结果提取失败时先看任务日志和结果目录,不要直接删除生产源目录。',
'SBAS 和 Sentinel-1 正射如果显示为规划中,表示页面保留入口但尚未接入真实交付。',
],
},
{
id: 'statistics',
title: '综合统计',
summary: '综合统计用于快速判断系统的数据、轨道、生产和成果健康状态。',
tags: ['统计', '健康状态'],
highlights: [
'元数据入库率只统计系统当前管理的 LT-1 和 Sentinel-1 源资产。',
'几何画像可用率表示可用于空间覆盖统计的影像画像是否准备好。',
'精轨绑定率按可生产影像场景计算,缺轨会进一步拆成受影响景数和实际缺失卫星日期。',
'待关注项是生产管理指标,不应把历史 XML 旧口径或重复问题混入统计。',
],
workflow: [
{ name: '看总览', detail: '先检查 KPI 是否有 warning 或 danger。' },
{ name: '看覆盖', detail: '查看源数据和成果在行政区上的覆盖密度。' },
{ name: '看精轨', detail: '确认缺口是文件日期缺失还是受影响景数较多。' },
{ name: '看问题闭环', detail: '按问题代码判断是否需要重新扫描、补轨或清理任务。' },
],
tips: [
'刷新统计会触发较重的聚合查询,生产高峰期不要频繁刷新。',
'精轨缺口显示 1000 多景时,实际可能只缺几十个卫星日期。',
],
},
{
id: 'ops',
title: '运行维护',
summary: '运行维护用于发现失败任务、残留目录、扫描异常和服务环境问题。',
tags: ['任务维护', '清理', '自检'],
highlights: [
'任务维护面板只负责诊断和清理,不提供自动重跑。',
'清理前会预览数据库记录和磁盘目录影响范围,禁止删除源数据、DEM、精轨和代码目录。',
'扫描任务如果因为服务器断电或进程退出失败,应先确认 worker 是否仍在运行。',
'配对基础重建、数据分发、生产运行等长任务都应通过任务中心和维护面板观察。',
],
workflow: [
{ name: '运行自检', detail: '查看数据库、目录、任务、缓存和生产环境状态。' },
{ name: '任务维护', detail: '筛选失败、部分成功、取消或疑似 stale 的任务。' },
{ name: '诊断详情', detail: '查看 job、run、item、execution、日志和磁盘路径。' },
{ name: '清理后重提', detail: '管理员清理失败记录和残留目录后,用户回业务页面重新提交。' },
],
tips: [
'正在运行的生产任务不要直接清理,应等待结束或先确认没有真实进程。',
'LandSAR_Work 当前只清理运行工作目录,不清理正式成果、源数据、DEM 或精轨。',
],
},
];
const FAQ_ITEMS = [
{
question: '为什么缺精轨数量看起来很多?',
answer: '统计首页的缺精轨数量是受影响影像景数。LT-1 一个缺失卫星日期可能影响几十景,因此要同时查看“缺失卫星日期”清单。',
},
{
question: '扫描日志里的 skipped 是不是还在解析?',
answer: '不是。skipped 表示缓存命中,文件在元数据解析前已经被过滤;changed/new 才表示进入解析队列。',
},
{
question: '生产任务部分成功是不是失败?',
answer: '不是。部分成功表示同一批任务中有成功也有失败,成功结果应保留和登记,失败项需要结合日志判断是否重跑。',
},
{
question: '为什么省域覆盖配对只看见少量绿色框?',
answer: '省域覆盖规划的目标是尽量覆盖 AOI 空间范围。需要确认 AOI 是否正确选择、候选是否全量显示,以及 max_pairs 是否限制过低。',
},
{
question: '结果提取和成果下载有什么区别?',
answer: '结果提取是把生产目录中的成果登记到系统 catalog;成果下载是用户从已登记成果中申请交付包并下载到本地。',
},
];
function filterSections(sections, query) {
const keyword = query.trim().toLowerCase();
if (!keyword) return sections;
return sections.filter((section) => {
const haystack = [
section.title,
section.summary,
...(section.tags || []),
...(section.highlights || []),
...(section.tips || []),
...(section.workflow || []).flatMap((item) => [item.name, item.detail]),
].join(' ').toLowerCase();
return haystack.includes(keyword);
});
}
export default function UserManualPanel() {
const [activeSectionId, setActiveSectionId] = useState(MANUAL_SECTIONS[0].id);
const [query, setQuery] = useState('');
const visibleSections = useMemo(() => filterSections(MANUAL_SECTIONS, query), [query]);
const activeSection = visibleSections.find((section) => section.id === activeSectionId)
|| visibleSections[0]
|| MANUAL_SECTIONS[0];
const handleSelectSection = (sectionId) => {
setActiveSectionId(sectionId);
};
return (
<div className="manual-workspace">
<div className="manual-shell">
<header className="manual-hero">
<div>
<h1>用户手册</h1>
<p>面向日常生产、结果管理和运行维护的操作说明。内容来自当前有效工程文档,并按用户工作流重新整理。</p>
</div>
<div className="manual-search">
<label htmlFor="manual-search-input">搜索手册</label>
<input
id="manual-search-input"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="输入:精轨、配对、交付、任务维护..."
/>
</div>
</header>
<div className="manual-layout">
<nav className="manual-nav" aria-label="用户手册目录">
<div className="manual-nav-title">
<strong>目录</strong>
<span>{visibleSections.length}/{MANUAL_SECTIONS.length}</span>
</div>
<div className="manual-nav-list">
{visibleSections.map((section) => (
<button
key={section.id}
type="button"
className={section.id === activeSection.id ? 'is-active' : ''}
onClick={() => handleSelectSection(section.id)}
>
<strong>{section.title}</strong>
<span>{section.summary}</span>
</button>
))}
{!visibleSections.length && (
<div className="manual-empty">没有匹配的章节。</div>
)}
</div>
</nav>
<main className="manual-content">
<section className="manual-section">
<div className="manual-section-header">
<div>
<h2>{activeSection.title}</h2>
<p>{activeSection.summary}</p>
</div>
<div className="manual-tag-row">
{(activeSection.tags || []).map((tag) => <span key={tag}>{tag}</span>)}
</div>
</div>
<div className="manual-block">
<h3>关键判断</h3>
<ul className="manual-check-list">
{activeSection.highlights.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="manual-block">
<h3>推荐流程</h3>
<div className="manual-flow">
{activeSection.workflow.map((item, index) => (
<div key={item.name} className="manual-flow-step">
<span>{index + 1}</span>
<div>
<strong>{item.name}</strong>
<p>{item.detail}</p>
</div>
</div>
))}
</div>
</div>
<div className="manual-block">
<h3>操作提示</h3>
<div className="manual-tip-list">
{activeSection.tips.map((item) => (
<p key={item}>{item}</p>
))}
</div>
</div>
</section>
<aside className="manual-faq">
<div className="manual-faq-header">
<h2>常见问题</h2>
<span>生产现场高频问题</span>
</div>
<div className="manual-faq-list">
{FAQ_ITEMS.map((item) => (
<details key={item.question}>
<summary>{item.question}</summary>
<p>{item.answer}</p>
</details>
))}
</div>
</aside>
</main>
</div>
</div>
</div>
);
}
-1
View File
@@ -1,6 +1,5 @@
export { default as apiClient } from './client';
export * as authApi from './auth';
export * as licenseApi from './license';
export * as tasksApi from './tasks';
export * as healthApi from './health';
export * as radarApi from './radar';
-5
View File
@@ -1,5 +0,0 @@
import apiClient from './client';
export const getLicenseStatus = () => apiClient.get('/license/status').then(r => r.data);
export const uploadLicense = (formData) => apiClient.post('/license/upload', formData).then(r => r.data);
export const refreshLicense = () => apiClient.post('/license/refresh').then(r => r.data);
+13
View File
@@ -0,0 +1,13 @@
import apiClient from './client';
export const listMaintenanceTasks = (params = {}) =>
apiClient.get('/ops-maintenance/tasks', { params }).then(r => r.data);
export const getMaintenanceTaskDiagnosis = (taskId) =>
apiClient.get(`/ops-maintenance/tasks/${encodeURIComponent(taskId)}/diagnosis`).then(r => r.data);
export const previewMaintenanceCleanup = (taskId) =>
apiClient.post(`/ops-maintenance/tasks/${encodeURIComponent(taskId)}/cleanup-preview`).then(r => r.data);
export const cleanupMaintenanceTask = (taskId, payload) =>
apiClient.post(`/ops-maintenance/tasks/${encodeURIComponent(taskId)}/cleanup`, payload).then(r => r.data);
+1
View File
@@ -1,6 +1,7 @@
import apiClient from './client';
export const findPairs = (formData) => apiClient.post('/find-pairs', formData).then(r => r.data);
export const buildCoveragePlan = (formData) => apiClient.post('/pairing/coverage-plan', formData).then(r => r.data);
export const findPsTimeseries = (formData) => apiClient.post('/find-ps-timeseries', formData).then(r => r.data);
export const getPairingHealth = () => apiClient.get('/pairing/health').then(r => r.data);
export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data);
+3
View File
@@ -9,6 +9,9 @@ export const createResultDelivery = payload =>
export const listResultDeliveries = (params = {}) =>
apiClient.get('/result-deliveries', { params }).then(r => r.data);
export const listResultDeliveryCatalog = (channel, params = {}) =>
apiClient.get(`/result-deliveries/catalog/${encodeURIComponent(channel)}`, { params }).then(r => r.data);
export const getResultDelivery = deliveryId =>
apiClient.get(`/result-deliveries/${encodeURIComponent(deliveryId)}`).then(r => r.data);
+2 -2
View File
@@ -3,5 +3,5 @@ import apiClient from './client';
export const getStatistics = (fresh = false) =>
apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
export const getStatisticsDashboard = () =>
apiClient.get('/statistics/dashboard').then(r => r.data);
export const getStatisticsDashboard = (fresh = false) =>
apiClient.get('/statistics/dashboard', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

+30 -1
View File
@@ -67,6 +67,34 @@ function engineStatusTone(status) {
return 'neutral';
}
function formatEngineResultStatus(status) {
const normalized = String(status || 'missing').toLowerCase();
if (normalized === 'ready') return '已生产';
if (normalized === 'failed') return '生产失败';
if (normalized === 'missing') return '未生产';
if (normalized === 'blocked') return '不适用';
if (normalized === 'running') return '生产中';
if (normalized === 'legacy') return '历史结果';
return status || '未生产';
}
function buildEngineResultTitle(result = {}) {
const parts = [formatEngineResultStatus(result.status)];
if (result.skip_reason === 'production_failed') {
parts.push('该引擎已有失败生产记录,不是单纯缺失结果。');
}
if (result.production_run_id) {
parts.push(`run=${result.production_run_id}`);
}
if (result.production_error) {
parts.push(String(result.production_error).replace(/\s+/g, ' ').slice(0, 220));
}
if (result.latest_log_path) {
parts.push(`log=${result.latest_log_path}`);
}
return parts.filter(Boolean).join('\n');
}
function MetaField({ label, value, multiline = false }) {
const displayValue = value === null || value === undefined || value === '' ? '-' : value;
return (
@@ -373,8 +401,9 @@ export default function DinsarCatalogPanel({
<span
key={engineCode}
className={`dinsar-engine-result-chip tone-${engineStatusTone(result.status)}`}
title={buildEngineResultTitle(result)}
>
{engineMeta.shortLabel}: {result.status || 'missing'}
{engineMeta.shortLabel}: {formatEngineResultStatus(result.status)}
</span>
);
})}
-106
View File
@@ -1,106 +0,0 @@
export default function LicenseOverlay({
licenseLoading,
licenseStatus,
isAdmin,
licenseFileRef,
onUploadFile,
onRefreshStatus,
licenseFileName,
licenseUploadStatus,
}) {
if (!licenseLoading && licenseStatus?.ok) {
return null;
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(15, 23, 42, 0.82)',
zIndex: 2000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<div style={{
width: 'min(560px, 90%)',
background: '#ffffff',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 20px 60px rgba(15, 23, 42, 0.35)',
}}>
<h3 style={{ marginTop: 0 }}>
{licenseLoading ? '正在验证授权...' : '系统未授权'}
</h3>
{!licenseLoading && (
<>
<p style={{ color: '#475569', marginBottom: '12px' }}>
失败原因:{licenseStatus?.reason || '授权无效或已过期,请联系管理员。'}
</p>
{isAdmin ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '12px' }}>
<input
type="file"
ref={licenseFileRef}
accept=".lic"
onChange={(e) => onUploadFile(e.target.files?.[0])}
style={{ display: 'none' }}
/>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="primary-btn"
onClick={() => licenseFileRef.current && licenseFileRef.current.click()}
style={{ padding: '8px 14px' }}
>
选择授权文件
</button>
<button
className="secondary-btn"
onClick={onRefreshStatus}
style={{ padding: '8px 14px' }}
>
刷新授权状态
</button>
<span style={{ fontSize: '0.85em', color: '#64748b' }}>
{licenseFileName ? `已选择: ${licenseFileName}` : '未选择文件'}
</span>
</div>
{licenseUploadStatus?.message && (
<div style={{
fontSize: '0.85em',
color:
licenseUploadStatus.type === 'error'
? '#dc2626'
: licenseUploadStatus.type === 'success'
? '#16a34a'
: '#475569',
}}>
{licenseUploadStatus.message}
</div>
)}
</div>
) : (
<div style={{ marginBottom: '12px', color: '#b45309', background: '#fffbeb', border: '1px solid #fcd34d', padding: '8px 10px', borderRadius: '6px' }}>
当前账号无上传授权权限,请联系管理员处理授权文件。
</div>
)}
<div style={{ marginTop: '10px', background: '#f8fafc', padding: '10px 12px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
<div style={{ fontWeight: 600, marginBottom: '6px', color: '#334155' }}>授权使用说明</div>
<ol style={{ margin: 0, paddingLeft: '18px', color: '#475569', fontSize: '0.85em' }}>
<li>确认已在服务器 .env 中配置 LICENSE_SECRET 与 LICENSE_PUBLIC_KEY。</li>
<li>上传有效的 .lic 授权文件(与当前机器指纹匹配)。</li>
<li>点击“刷新授权状态”确认授权已生效。</li>
</ol>
</div>
</>
)}
{licenseLoading && (
<p style={{ color: '#64748b' }}>请稍候,正在与授权文件进行校验。</p>
)}
</div>
</div>
);
}
@@ -1,6 +1,5 @@
import { Suspense, lazy } from 'react';
import { useShallow } from 'zustand/react/shallow';
import LicenseOverlay from '../LicenseOverlay';
import { useDinsarStore, usePairingStore, useUiStore } from '../../store';
import { useI18n } from '../../i18n/I18nContext';
import { formatYmd } from '../../utils/appUiHelpers';
@@ -17,14 +16,7 @@ export default function AppOverlays({
onPairingAoiModeChange,
onPairingProvinceChange,
onPairingCityChange,
licenseLoading,
licenseStatus,
isAdmin,
licenseFileRef,
onUploadFile,
onRefreshLicenseStatus,
licenseFileName,
licenseUploadStatus,
activeTasks,
runtimeSummary,
showCancelTask,
@@ -75,17 +67,6 @@ export default function AppOverlays({
</Suspense>
)}
<LicenseOverlay
licenseLoading={licenseLoading}
licenseStatus={licenseStatus}
isAdmin={isAdmin}
licenseFileRef={licenseFileRef}
onUploadFile={onUploadFile}
onRefreshStatus={onRefreshLicenseStatus}
licenseFileName={licenseFileName}
licenseUploadStatus={licenseUploadStatus}
/>
{showDataInfo && (
<Suspense fallback={<ModalLoadingFallback message="正在加载数据详情..." />}>
<LazyDataInfoModal
+25 -2
View File
@@ -18,6 +18,7 @@ const LazyAssetInventoryPanel = lazy(() => import('../../AssetInventoryPanel'));
const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel'));
const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel'));
const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel'));
const LazyOpsTaskMaintenancePanel = lazy(() => import('../../OpsTaskMaintenancePanel'));
const LazyFloodAnalysisWorkspace = lazy(() => import('../../FloodAnalysisWorkspace'));
const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel'));
const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
@@ -30,6 +31,7 @@ const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarM
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
const LazyResultExtractionPanel = lazy(() => import('../../ResultExtractionPanel'));
const LazyUserManualPanel = lazy(() => import('../../UserManualPanel'));
export default function AppSidePanel({
leftPanelWidth,
@@ -41,7 +43,6 @@ export default function AppSidePanel({
currentUser,
language,
apiEndpoint,
licenseOk,
foundPairs,
dinsarTotal,
selectedPairsCount,
@@ -229,6 +230,14 @@ export default function AppSidePanel({
</div>
)}
{leftPanelTab === 'user_manual' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载用户手册..." />}>
<LazyUserManualPanel />
</Suspense>
</div>
)}
{leftPanelTab === 'ingest' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}>
@@ -236,7 +245,7 @@ export default function AppSidePanel({
apiEndpoint={apiEndpoint}
onTaskStart={taskPanel.onTaskStart}
readOnly={isReadOnlyUser}
enabled={!!currentUser && licenseOk}
enabled={!!currentUser}
/>
</Suspense>
</div>
@@ -330,6 +339,20 @@ export default function AppSidePanel({
</div>
)}
{leftPanelTab === 'ops_tasks' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
{isAdmin ? (
<Suspense fallback={<PanelLoadingBody message="正在加载任务维护面板..." />}>
<LazyOpsTaskMaintenancePanel isAdmin={isAdmin} />
</Suspense>
) : (
<div style={{ padding: '16px' }}>
<p className="empty-state">仅管理员可访问任务维护。</p>
</div>
)}
</div>
)}
{leftPanelTab === 'users' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
{isAdmin ? (
@@ -1,6 +1,5 @@
import { memo } from 'react';
import defaultLogoUrl from '../../logo.jpg';
import { formatUtc } from '../../utils/appUiHelpers';
const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心';
const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || '雷达数据生产管理系统';
@@ -8,17 +7,14 @@ const SYSTEM_TAGLINE = import.meta.env.VITE_APP_SYSTEM_TAGLINE || '科研工程
const LOGO_URL = import.meta.env.VITE_APP_LOGO_URL || defaultLogoUrl;
function AppStatusHeader({
language,
currentUser,
isAdmin,
isReadOnlyUser,
activeTasks,
avgTaskProgress,
runtimeSummary,
licenseStatus,
onLogout,
}) {
const licenseOk = !!licenseStatus?.ok;
const worker = runtimeSummary?.worker || {};
const jobs = runtimeSummary?.jobs || {};
const scan = runtimeSummary?.scan || {};
@@ -68,14 +64,6 @@ function AppStatusHeader({
<div className="status-system-meta" aria-label="系统状态摘要">
<span>{SYSTEM_TAGLINE}</span>
<span className={`status-license-chip ${licenseOk ? 'ok' : 'fail'}`}>
{licenseOk ? '已授权' : '未授权'}
</span>
{licenseStatus?.expires_at && (
<span className="status-license">
授权至 {formatUtc(licenseStatus.expires_at, language)}
</span>
)}
</div>
<div className="status-actions">
@@ -82,6 +82,7 @@ function PairListRow({
const overlapLabel = pair.pair_aoi_overlap_ratio != null ? 'AOI覆盖' : '影像重叠';
const production = productionLabel(pair.production_summary);
const quality = qualityLabel(pair);
const hasCoverageContribution = pair.aoi_new_area_ratio != null || pair.aoi_coverage_ratio_after != null;
const engines = Array.isArray(pair.production_summary?.engine_codes)
? pair.production_summary.engine_codes.filter(Boolean).join('/')
: '';
@@ -104,6 +105,13 @@ function PairListRow({
<span>时基: {pair.time_baseline_days}d</span>
<span>中心距: {formatDistance(centerDistance)}</span>
<span>{overlapLabel}: {formatPercent(overlap)}</span>
{hasCoverageContribution && (
<span>
覆盖贡献: {pair.coverage_rank ? `#${pair.coverage_rank} ` : ''}
新增 {formatPercent(pair.aoi_new_area_ratio)}
{pair.aoi_coverage_ratio_after != null ? ` / 累计 ${formatPercent(pair.aoi_coverage_ratio_after)}` : ''}
</span>
)}
{quality && <span>D-InSAR: {quality}</span>}
</div>
<div className="pair-status-line">
@@ -25,6 +25,22 @@ const toneColor = {
text: '#b91c1c',
fill: '#dc2626',
},
partial: {
border: '#fcd34d',
bg: '#fffbeb',
text: '#92400e',
fill: '#d97706',
},
};
const TASK_STATUS_LABEL = {
PENDING: '等待中',
RUNNING: '运行中',
COMPLETED: '已完成',
PARTIAL_SUCCESS: '部分成功',
FAILED: '失败',
CANCELLED: '已取消',
CANCELED: '已取消',
};
const normalizeProgress = (value) => {
@@ -34,6 +50,11 @@ const normalizeProgress = (value) => {
};
const isFailed = (task) => String(task?.status || '').toUpperCase() === 'FAILED';
const isPartialSuccess = (task) => String(task?.status || '').toUpperCase() === 'PARTIAL_SUCCESS';
const formatTaskStatus = (status) => {
const normalized = String(status || '-').toUpperCase();
return TASK_STATUS_LABEL[normalized] || normalized;
};
export default function TaskStatusPanel({
title = '任务状态',
@@ -48,7 +69,9 @@ export default function TaskStatusPanel({
}) {
const task = latestTask || activeTasks[0] || recentTasks[0] || null;
const showingRecent = !isBusy && !!task;
const tone = task ? (isFailed(task) ? 'error' : (showingRecent ? 'recent' : 'active')) : 'idle';
const tone = task
? (isFailed(task) ? 'error' : (isPartialSuccess(task) ? 'partial' : (showingRecent ? 'recent' : 'active')))
: 'idle';
const colors = toneColor[tone];
const progress = normalizeProgress(task?.progress);
@@ -82,7 +105,7 @@ export default function TaskStatusPanel({
<div style={{ marginTop: 8, color: colors.text, fontSize: 12, wordBreak: 'break-all' }}>
<span style={{ fontWeight: 700 }}>{getTaskTypeLabel(task.task_type)}</span>
<span style={{ margin: '0 6px' }}>·</span>
<span>{String(task.status || '-').toUpperCase()}</span>
<span>{formatTaskStatus(task.status)}</span>
{task.task_id && (
<>
<span style={{ margin: '0 6px' }}>·</span>
+5 -1
View File
@@ -197,6 +197,7 @@ export const LEFT_GROUP_LABELS = {
statistics: '综合统计',
flood_analysis: '灾害分析',
ops: '运行维护',
help: '帮助文档',
};
export const LEFT_GROUP_SECTIONS = {
@@ -221,7 +222,8 @@ export const LEFT_GROUP_TABS = {
insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
statistics: ['statistics'],
flood_analysis: ['flood_analysis'],
ops: ['health', 'users', 'audit'],
ops: ['health', 'ops_tasks', 'users', 'audit'],
help: ['user_manual'],
};
export const LEFT_TAB_GROUP = Object.entries(LEFT_GROUP_TABS).reduce((acc, [group, tabs]) => {
@@ -248,6 +250,7 @@ export const FULL_WIDTH_LEFT_TABS = new Set([
...PRODUCTION_WORKSPACE_ROUTE_TABS,
'statistics',
'result_extraction',
'user_manual',
]);
export const ADMIN_ONLY_TABS = new Set([
@@ -262,6 +265,7 @@ export const ADMIN_ONLY_TABS = new Set([
...PRODUCTION_WORKSPACE_LEGACY_TABS,
'users',
'audit',
'ops_tasks',
]);
export const DEFAULT_LIST_PAGE_SIZE = 200;
+1 -49
View File
@@ -38,10 +38,8 @@ const requestWithTransientRetry = async (
};
export default function useAppAuthLifecycle({
ensureCanOperate,
clearRadarSearchResults,
radarSearchRequestSeqRef,
prevLicenseOkRef,
aoeLayerRef,
activeLayersRef,
radarPreviewLayersRef,
@@ -49,13 +47,9 @@ export default function useAppAuthLifecycle({
setCurrentUser,
setAuthChecked,
setPendingTaskIds,
setLicenseLoading,
setLicenseStatus,
setHealthLoading,
setHealthError,
setHealthStatus,
setLicenseFileName,
setLicenseUploadStatus,
setAoiLayer,
setAllData,
setRadarPagination,
@@ -113,7 +107,6 @@ export default function useAppAuthLifecycle({
clearRadarSearchResults();
setCurrentUser(null);
setPendingTaskIds([]);
prevLicenseOkRef.current = false;
setAuthChecked(true);
}
}, [
@@ -122,29 +115,9 @@ export default function useAppAuthLifecycle({
setHasRadarSearched,
setCurrentUser,
setPendingTaskIds,
prevLicenseOkRef,
setAuthChecked,
]);
const fetchLicenseStatus = useCallback(async () => {
try {
setLicenseLoading(true);
const response = await requestWithTransientRetry(() => apiClient.get('/license/status'));
const data = response.data || {};
if (!data.ok && !data.reason) {
data.reason = '未授权';
}
setLicenseStatus(data);
} catch (error) {
setLicenseStatus({
ok: false,
reason: error.response?.data?.detail || '无法获取授权状态',
});
} finally {
setLicenseLoading(false);
}
}, [setLicenseLoading, setLicenseStatus]);
const fetchHealthStatus = useCallback(async (options = {}) => {
const { refresh = false, silent = false } = options;
try {
@@ -167,22 +140,6 @@ export default function useAppAuthLifecycle({
}
}, [setHealthLoading, setHealthError, setHealthStatus]);
const handleLicenseUpload = useCallback(async (file) => {
if (!file) return;
if (!ensureCanOperate()) return;
try {
setLicenseFileName(file.name);
setLicenseUploadStatus({ type: 'info', message: '正在上传授权文件...' });
const form = new FormData();
form.append('file', file);
const response = await apiClient.post('/license/upload', form);
setLicenseUploadStatus({ type: 'success', message: response.data?.message || '授权文件已上传' });
await fetchLicenseStatus();
} catch (error) {
setLicenseUploadStatus({ type: 'error', message: error.response?.data?.detail || '授权文件上传失败' });
}
}, [ensureCanOperate, setLicenseFileName, setLicenseUploadStatus, fetchLicenseStatus]);
useEffect(() => {
const interceptorId = apiClient.interceptors.response.use(
(response) => response,
@@ -210,7 +167,6 @@ export default function useAppAuthLifecycle({
}));
setCurrentUser(null);
setAuthChecked(true);
prevLicenseOkRef.current = false;
}
return Promise.reject(error);
}
@@ -229,13 +185,11 @@ export default function useAppAuthLifecycle({
setRadarPagination,
setCurrentUser,
setAuthChecked,
prevLicenseOkRef,
]);
useEffect(() => {
fetchCurrentUser();
fetchLicenseStatus();
}, [fetchCurrentUser, fetchLicenseStatus]);
}, [fetchCurrentUser]);
useEffect(() => {
const startupTimer = setTimeout(() => {
@@ -253,8 +207,6 @@ export default function useAppAuthLifecycle({
return {
handleLoginSuccess,
handleLogout,
fetchLicenseStatus,
fetchHealthStatus,
handleLicenseUpload,
};
}
+5 -5
View File
@@ -3,10 +3,10 @@ import apiClient from '../api/client';
import { normalizeTaskStatus } from '../utils/appUiHelpers';
const ACTIVE_TASK_FALLBACK_POLL_MS = 10000;
const TERMINAL_TASK_STATUSES = new Set(['COMPLETED', 'PARTIAL_SUCCESS', 'FAILED', 'CANCELLED', 'CANCELED']);
export default function useGlobalTaskControl({
currentUser,
licenseOk,
activeTasks,
setActiveTasks,
setRuntimeSummary,
@@ -56,7 +56,7 @@ export default function useGlobalTaskControl({
const taskStatus = normalizeTaskStatus(taskInfo?.status);
// 检查任务是否真正完成:解析 message 中的进度信息
let isReallyFinished = taskStatus === 'COMPLETED' || taskStatus === 'FAILED';
let isReallyFinished = TERMINAL_TASK_STATUSES.has(taskStatus);
// 如果任务状态是 PENDING,检查进度信息
if (taskStatus === 'PENDING' && taskInfo?.message) {
@@ -77,7 +77,7 @@ export default function useGlobalTaskControl({
if (isReallyFinished) {
reallyFinishedIds.push(taskId);
if (taskStatus === 'COMPLETED' || taskStatus === 'FAILED') {
if (TERMINAL_TASK_STATUSES.has(taskStatus)) {
handleTaskCompletionRef.current?.(taskInfo);
}
}
@@ -138,7 +138,7 @@ export default function useGlobalTaskControl({
}, [handleTasksUpdate, normalizeRuntimeSummary]);
useEffect(() => {
if (!currentUser || !licenseOk) return;
if (!currentUser) return;
// Initial fetch
syncActiveTasks();
@@ -178,7 +178,7 @@ export default function useGlobalTaskControl({
if (es) es.close();
if (fallbackInterval) clearInterval(fallbackInterval);
};
}, [currentUser, licenseOk, syncActiveTasks, handleTasksUpdate]);
}, [currentUser, syncActiveTasks, handleTasksUpdate]);
const handleCancelActiveTasks = useCallback(() => {
if (!cancelTaskPwd || activeTasks.length === 0) return;
-3
View File
@@ -3,12 +3,9 @@
{ zh: '请稍候,系统正在验证会话。', en: 'Please wait, verifying your session.' },
{ zh: '雷达数据生产管理系统', en: 'Radar Data Production Management System' },
{ zh: '科研工程模式', en: 'Research Engineering Mode' },
{ zh: '已授权', en: 'Licensed' },
{ zh: '未授权', en: 'Unlicensed' },
{ zh: '管理员', en: 'Admin' },
{ zh: '只读账号', en: 'Read-only account' },
{ zh: '任务', en: 'Tasks' },
{ zh: '授权至', en: 'License until' },
{ zh: '刷新自检状态', en: 'Refresh health status' },
{ zh: '自检中...', en: 'Checking...' },
{ zh: '刷新自检', en: 'Refresh Health' },
+436 -8
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
buildCoveragePlan,
getPairingHealth,
rebuildPairingCache,
reconcileDirtyPairingCache,
} from '../api/pairing';
import { getRegionChildren, getRegionGeometry } from '../api/aoi';
import MiniCoverageMap from '../components/MiniCoverageMap';
import { usePairingStore } from '../store';
const formatIso = (value, en = false) => {
if (!value) return en ? 'Never' : '未执行';
@@ -41,6 +44,7 @@ export default function PairPlanningPanel({
onRefreshRadarSearch,
onSearchAll,
onRefreshDinsar,
onOpenPairsView,
language,
}) {
const en = language === 'en';
@@ -50,7 +54,60 @@ export default function PairPlanningPanel({
const [pairingRepairing, setPairingRepairing] = useState(false);
const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false);
const [pairingActionResult, setPairingActionResult] = useState(null);
const previewPairs = useMemo(() => foundPairs.slice(0, 20), [foundPairs]);
const [coveragePlanning, setCoveragePlanning] = useState(false);
const [coverageResult, setCoverageResult] = useState(null);
const [coverageAoiMode, setCoverageAoiMode] = useState('region');
const [coverageAoiFiles, setCoverageAoiFiles] = useState(null);
const [coverageAoiGeojson, setCoverageAoiGeojson] = useState(null);
const [coverageAoiError, setCoverageAoiError] = useState('');
const [coverageRegionLoading, setCoverageRegionLoading] = useState(false);
const [coverageRegionLoaded, setCoverageRegionLoaded] = useState(false);
const [coverageRegionOptions, setCoverageRegionOptions] = useState({ provinces: [], cities: [] });
const [coverageRegionSelection, setCoverageRegionSelection] = useState({ province: '', city: '' });
const [coverageMapMode, setCoverageMapMode] = useState('effective');
const [coverageForm, setCoverageForm] = useState({
target_date_from: '',
target_date_to: '',
extension_days: '15',
max_pairs: '200',
target_coverage_ratio: '0.98',
min_new_coverage_ratio: '0.0005',
time_baseline_min: '1',
time_baseline_max: '30',
overlap_threshold: '0.5',
spatial_baseline_max_meters: '5000',
aoi_overlap_threshold: '0',
allowed_satellites: 'LT1',
});
const { setFoundPairs, setPairingAlert } = usePairingStore();
const selectedCoverageRegionTreeId = coverageRegionSelection.city || coverageRegionSelection.province || '';
const selectedCoverageProvince = useMemo(
() => coverageRegionOptions.provinces.find(item => String(item.tree_id) === String(coverageRegionSelection.province)),
[coverageRegionOptions.provinces, coverageRegionSelection.province],
);
const selectedCoverageCity = useMemo(
() => coverageRegionOptions.cities.find(item => String(item.tree_id) === String(coverageRegionSelection.city)),
[coverageRegionOptions.cities, coverageRegionSelection.city],
);
const coverageAoiSummary = useMemo(() => {
if (coverageAoiMode === 'region') {
if (!selectedCoverageRegionTreeId) return '请选择行政区作为空间覆盖范围';
const name = selectedCoverageCity?.name || selectedCoverageProvince?.name || selectedCoverageRegionTreeId;
return `行政区 AOI:${name}${coverageRegionSelection.city ? '' : '(全域)'}`;
}
const files = Array.from(coverageAoiFiles || []);
if (files.length === 0) return '请上传 SHP/GeoJSON 作为空间覆盖范围';
const names = files.slice(0, 2).map(file => file.name).join('、');
return `上传 AOI:${names}${files.length > 2 ? ` 等 ${files.length} 个文件` : ''}`;
}, [
coverageAoiFiles,
coverageAoiMode,
coverageRegionSelection.city,
selectedCoverageCity,
selectedCoverageProvince,
selectedCoverageRegionTreeId,
]);
const previewPairs = useMemo(() => foundPairs, [foundPairs]);
const previewPolygons = useMemo(() => (
previewPairs.flatMap((pair, index) => {
const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`;
@@ -70,6 +127,22 @@ export default function PairPlanningPanel({
];
})
), [previewPairs]);
const previewEffectiveGeojson = useMemo(() => ({
type: 'FeatureCollection',
features: previewPairs
.filter(pair => pair.effective_coverage_geojson)
.map((pair, index) => ({
type: 'Feature',
properties: {
name: `${pair.coverage_rank || index + 1}. ${pair.task_alias || pair.task_name || `Pair ${index + 1}`}`,
label: `${pair.coverage_rank || index + 1}. 新增 ${((Number(pair.aoi_new_area_ratio || 0)) * 100).toFixed(2)}% / 累计 ${((Number(pair.aoi_coverage_ratio_after || 0)) * 100).toFixed(1)}%`,
color: '#2563eb',
fillColor: '#3b82f6',
},
geometry: pair.effective_coverage_geojson,
})),
}), [previewPairs]);
const mapShowsEffectiveCoverage = coverageMapMode === 'effective' && previewEffectiveGeojson.features.length > 0;
const refreshPairingStatus = useCallback(async () => {
if (isReadOnlyUser) {
@@ -122,6 +195,169 @@ export default function PairPlanningPanel({
}, [en, refreshPairingStatus]);
const pairingActionBusy = pairingRepairing || pairingFullRebuilding;
const handleCoverageFieldChange = useCallback((field, value) => {
setCoverageForm(current => ({ ...current, [field]: value }));
}, []);
const loadCoverageProvinces = useCallback(async () => {
setCoverageRegionLoading(true);
setCoverageAoiError('');
try {
const result = await getRegionChildren('1');
setCoverageRegionOptions({
provinces: Array.isArray(result?.children) ? result.children : [],
cities: [],
});
setCoverageRegionLoaded(true);
} catch (error) {
setCoverageAoiError(error.response?.data?.detail || error.message || '行政区加载失败。');
setCoverageRegionOptions({ provinces: [], cities: [] });
setCoverageRegionLoaded(true);
} finally {
setCoverageRegionLoading(false);
}
}, []);
const loadCoverageCities = useCallback(async (provinceId) => {
if (!provinceId) {
setCoverageRegionOptions(current => ({ ...current, cities: [] }));
return;
}
setCoverageRegionLoading(true);
setCoverageAoiError('');
try {
const result = await getRegionChildren(provinceId);
setCoverageRegionOptions(current => ({
...current,
cities: Array.isArray(result?.children) ? result.children : [],
}));
} catch (error) {
setCoverageAoiError(error.response?.data?.detail || error.message || '地市加载失败。');
setCoverageRegionOptions(current => ({ ...current, cities: [] }));
} finally {
setCoverageRegionLoading(false);
}
}, []);
useEffect(() => {
if (isReadOnlyUser || coverageAoiMode !== 'region' || coverageRegionLoaded || coverageRegionLoading) return;
void loadCoverageProvinces();
}, [
coverageAoiMode,
coverageRegionLoaded,
coverageRegionLoading,
isReadOnlyUser,
loadCoverageProvinces,
]);
const handleCoverageAoiModeChange = useCallback(async (mode) => {
setCoverageAoiMode(mode);
setCoverageAoiError('');
setCoverageResult(null);
setCoverageAoiGeojson(null);
if (mode === 'region' && !coverageRegionLoaded) {
await loadCoverageProvinces();
}
}, [coverageRegionLoaded, loadCoverageProvinces]);
const handleCoverageProvinceChange = useCallback(async (value) => {
setCoverageRegionSelection({ province: value, city: '' });
setCoverageAoiGeojson(null);
setCoverageResult(null);
if (value) await loadCoverageCities(value);
else setCoverageRegionOptions(current => ({ ...current, cities: [] }));
}, [loadCoverageCities]);
const handleCoverageCityChange = useCallback((value) => {
setCoverageRegionSelection(current => ({ ...current, city: value }));
setCoverageAoiGeojson(null);
setCoverageResult(null);
}, []);
const handleCoverageFilesChange = useCallback((files) => {
setCoverageAoiFiles(files);
setCoverageAoiGeojson(null);
setCoverageAoiError('');
setCoverageResult(null);
}, []);
const handleCoveragePlan = useCallback(async () => {
if (isReadOnlyUser || coveragePlanning) return;
const targetFrom = String(coverageForm.target_date_from || '').trim();
const targetTo = String(coverageForm.target_date_to || '').trim();
if (!/^\d{8}$/.test(targetFrom) || !/^\d{8}$/.test(targetTo)) {
setCoverageResult({ error: '请输入 YYYYMMDD 格式的目标起止日期。' });
return;
}
setCoveragePlanning(true);
setCoverageResult(null);
setCoverageAoiError('');
try {
const formData = new FormData();
Object.entries(coverageForm).forEach(([key, value]) => {
const text = String(value ?? '').trim();
if (!text) return;
if (key === 'allowed_satellites') {
const families = text.split(',').map(item => item.trim()).filter(Boolean);
if (families.length > 0) formData.append(key, JSON.stringify(families));
return;
}
formData.append(key, text);
});
formData.append('require_orbit_data', 'true');
formData.append('require_same_imaging_mode', 'true');
formData.append('require_same_polarization', 'true');
formData.append('limit_footprint_center_distance', 'true');
let selectedAoiGeojson = null;
if (coverageAoiMode === 'file') {
const files = Array.from(coverageAoiFiles || []);
if (files.length === 0) {
throw new Error('请选择 AOI 文件。');
}
files.forEach(file => formData.append('files', file));
} else {
if (!selectedCoverageRegionTreeId) {
throw new Error('请选择行政区 AOI。');
}
const result = await getRegionGeometry(selectedCoverageRegionTreeId);
selectedAoiGeojson = result?.aoi_geojson || null;
if (!selectedAoiGeojson) {
throw new Error('行政区边界为空,无法进行空间覆盖规划。');
}
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeojson));
}
const response = await buildCoveragePlan(formData);
const pairs = Array.isArray(response?.pairs) ? response.pairs : [];
const warnings = Array.isArray(response?.warnings) ? response.warnings : [];
const responseAoi = response?.aoi_geojson || selectedAoiGeojson || null;
setFoundPairs(pairs.map(pair => ({ ...pair, isSelected: true, isVis: false })));
setPairingAlert({ warnings, fallbackUsed: Boolean(response?.fallback_used) });
setCoverageAoiGeojson(responseAoi);
setCoverageResult({
...response,
aoi_geojson: responseAoi,
pairCount: pairs.length,
warningCount: warnings.length,
});
} catch (error) {
const message = error.response?.data?.detail || error.message || '区域空间覆盖配对规划失败。';
setCoverageAoiError(message);
setCoverageResult({ error: message });
} finally {
setCoveragePlanning(false);
}
}, [
coverageAoiFiles,
coverageAoiMode,
coverageForm,
coveragePlanning,
isReadOnlyUser,
selectedCoverageRegionTreeId,
setFoundPairs,
setPairingAlert,
]);
return (
<div className="panel-content" style={{ flex: '1 1 auto', overflowY: 'auto', padding: '12px' }}>
@@ -139,16 +375,208 @@ export default function PairPlanningPanel({
</div>
</div>
<div className="panel-card province-coverage-card" style={{ marginTop: '12px' }}>
<div className="panel-card-title">区域空间覆盖配对规划</div>
<p className="panel-card-desc">
以目标生产时段限定影像池,按标准 D-InSAR 约束生成候选对,并优先选择能增加 AOI 空间覆盖范围的配对;扩展天数用于补充边界时段的可用影像。
</p>
<div className="coverage-form-grid">
<label>
目标开始
<input value={coverageForm.target_date_from} onChange={event => handleCoverageFieldChange('target_date_from', event.target.value)} placeholder="20260101" />
</label>
<label>
目标结束
<input value={coverageForm.target_date_to} onChange={event => handleCoverageFieldChange('target_date_to', event.target.value)} placeholder="20260531" />
</label>
<label>
扩展天数
<input type="number" min="0" max="180" value={coverageForm.extension_days} onChange={event => handleCoverageFieldChange('extension_days', event.target.value)} />
</label>
<label>
任务上限
<input type="number" min="1" max="5000" value={coverageForm.max_pairs} onChange={event => handleCoverageFieldChange('max_pairs', event.target.value)} />
</label>
<label>
目标覆盖率
<input type="number" min="0" max="1" step="0.01" value={coverageForm.target_coverage_ratio} onChange={event => handleCoverageFieldChange('target_coverage_ratio', event.target.value)} />
</label>
<label>
最低新增覆盖
<input type="number" min="0" max="1" step="0.0001" value={coverageForm.min_new_coverage_ratio} onChange={event => handleCoverageFieldChange('min_new_coverage_ratio', event.target.value)} />
</label>
<label>
时间基线
<input value={coverageForm.time_baseline_max} onChange={event => handleCoverageFieldChange('time_baseline_max', event.target.value)} />
</label>
<label>
最小重叠
<input value={coverageForm.overlap_threshold} onChange={event => handleCoverageFieldChange('overlap_threshold', event.target.value)} />
</label>
<label>
中心距上限
<input value={coverageForm.spatial_baseline_max_meters} onChange={event => handleCoverageFieldChange('spatial_baseline_max_meters', event.target.value)} />
</label>
<label>
卫星族
<input value={coverageForm.allowed_satellites} onChange={event => handleCoverageFieldChange('allowed_satellites', event.target.value)} />
</label>
</div>
<div className="coverage-aoi-panel">
<div className="coverage-aoi-header">
<strong>空间覆盖范围</strong>
<span>{coverageAoiSummary}</span>
</div>
<div className="coverage-aoi-mode-row" role="radiogroup" aria-label="空间覆盖范围来源">
<label className={`coverage-aoi-mode ${coverageAoiMode === 'region' ? 'active' : ''}`}>
<input
type="radio"
name="coverage-aoi-mode"
value="region"
checked={coverageAoiMode === 'region'}
disabled={coveragePlanning || isReadOnlyUser}
onChange={() => void handleCoverageAoiModeChange('region')}
/>
行政区边界
</label>
<label className={`coverage-aoi-mode ${coverageAoiMode === 'file' ? 'active' : ''}`}>
<input
type="radio"
name="coverage-aoi-mode"
value="file"
checked={coverageAoiMode === 'file'}
disabled={coveragePlanning || isReadOnlyUser}
onChange={() => void handleCoverageAoiModeChange('file')}
/>
上传 AOI 文件
</label>
</div>
{coverageAoiMode === 'region' ? (
<div className="coverage-aoi-selects">
<label>
省份
<select
value={coverageRegionSelection.province}
onChange={event => void handleCoverageProvinceChange(event.target.value)}
disabled={coveragePlanning || coverageRegionLoading || isReadOnlyUser}
>
<option value="">选择省份</option>
{coverageRegionOptions.provinces.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</label>
<label>
地市
<select
value={coverageRegionSelection.city}
onChange={event => handleCoverageCityChange(event.target.value)}
disabled={coveragePlanning || coverageRegionLoading || !coverageRegionSelection.province || isReadOnlyUser}
>
<option value="">全省/不限地市</option>
{coverageRegionOptions.cities.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</label>
</div>
) : (
<div className="coverage-aoi-file">
<input
type="file"
multiple
accept=".zip,.shp,.shx,.dbf,.prj,.cpg,.geojson,.json"
disabled={coveragePlanning || isReadOnlyUser}
onChange={event => handleCoverageFilesChange(event.target.files)}
/>
</div>
)}
<div className="coverage-aoi-note">
覆盖目标:优先增加所选 AOI 的空间面覆盖;时间范围用于限定候选影像池。
</div>
{coverageAoiError && (
<div className="coverage-aoi-error">{coverageAoiError}</div>
)}
</div>
<div className="coverage-action-row">
<button onClick={handleCoveragePlan} disabled={coveragePlanning || isLoading || isReadOnlyUser}>
{coveragePlanning ? '规划中...' : '生成覆盖配对'}
</button>
<span>{coverageAoiSummary}</span>
</div>
{coverageResult && (
<div className={`coverage-result ${coverageResult.error ? 'error' : 'ready'}`}>
{coverageResult.error ? (
<span>{coverageResult.error}</span>
) : (
<>
<strong>
已生成 {coverageResult.pairCount} 对,AOI 空间覆盖率 {Math.round(Number(coverageResult.coverage?.aoi_coverage_ratio ?? coverageResult.coverage?.coverage_ratio ?? 0) * 100)}%
</strong>
<span>
候选 {Number(coverageResult.candidate_count || 0)},网络 {coverageResult.network_run_id || '-'},
影像池范围 {coverageResult.coverage?.query_date_from || '-'} 至 {coverageResult.coverage?.query_date_to || '-'}。
</span>
<span>
时间覆盖率 {Math.round(Number(coverageResult.coverage?.temporal_coverage_ratio || 0) * 100)}%;
任务上限 {coverageResult.coverage?.max_pairs || coverageForm.max_pairs};
停止原因:{coverageResult.coverage?.stop_reason || '-'}。
</span>
<span>
贪心规则:每一步选择新增 AOI 有效覆盖面积最大的候选对;达到目标覆盖率或新增收益低于阈值后停止。
</span>
{coverageResult.coverage?.uncovered_ranges?.length > 0 && (
<span>
时间缺口:{coverageResult.coverage.uncovered_ranges.slice(0, 3).map(item => `${item.date_from}-${item.date_to}`).join(',')}
</span>
)}
<div className="coverage-result-actions">
<button
type="button"
onClick={onOpenPairsView}
disabled={typeof onOpenPairsView !== 'function' || foundPairs.length === 0}
>
查看全部并保存批次
</button>
</div>
</>
)}
</div>
)}
</div>
<div style={{ marginTop: '12px' }}>
{foundPairs.length > 0 && (
<div className="coverage-map-toolbar">
<strong>覆盖检视</strong>
<span>
{mapShowsEffectiveCoverage
? `显示 ${previewEffectiveGeojson.features.length} 个有效覆盖面(master ∩ slave ∩ AOI)`
: `显示 ${previewPairs.length} 对主辅影像 footprint`}
</span>
<button
type="button"
className={coverageMapMode === 'effective' ? 'active' : ''}
onClick={() => setCoverageMapMode('effective')}
disabled={previewEffectiveGeojson.features.length === 0}
>
有效覆盖面
</button>
<button
type="button"
className={coverageMapMode === 'footprint' ? 'active' : ''}
onClick={() => setCoverageMapMode('footprint')}
>
主辅影像范围
</button>
</div>
)}
<MiniCoverageMap
title={en ? 'D-InSAR Pair Coverage Preview' : 'D-InSAR配对范围预览'}
subtitle={
foundPairs.length > previewPairs.length
? `${previewPairs.length}/${foundPairs.length} 对`
: `${foundPairs.length} 对`
}
polygons={previewPolygons}
height={260}
subtitle={mapShowsEffectiveCoverage ? `${previewEffectiveGeojson.features.length} 个有效覆盖面` : `${previewPairs.length} 对`}
polygons={mapShowsEffectiveCoverage ? [] : previewPolygons}
geojson={mapShowsEffectiveCoverage ? previewEffectiveGeojson : (coverageAoiGeojson || coverageResult?.aoi_geojson)}
height={420}
emptyText={en ? 'Run pair planning to preview pair footprints.' : '生成配对后显示候选范围。'}
/>
</div>
+13 -9
View File
@@ -112,14 +112,18 @@ function PairsListPanel({
) : (
<>
<div className="list-toolbar">
<input
type="checkbox"
checked={allPairsSelected}
onChange={handleSelectAllPairs}
id="select-all-pairs"
/>
<label htmlFor="select-all-pairs">
全选 ({selectedPairsCount} / {foundPairs.length} 已选择)
<div className="list-toolbar-main">
<strong>候选配对列表</strong>
<span>共 {foundPairs.length} 对,已选择 {selectedPairsCount} 对;列表支持滚动查看全部结果。</span>
</div>
<label className="select-all-pairs-control" htmlFor="select-all-pairs">
<input
type="checkbox"
checked={allPairsSelected}
onChange={handleSelectAllPairs}
id="select-all-pairs"
/>
全选
</label>
</div>
<VirtualizedList
@@ -179,7 +183,7 @@ function PairsListPanel({
className="footer-button"
title="保存选中的配对为任务批次"
>
保存批次 ({selectedPairsCount})
保存选中配对为批次 ({selectedPairsCount})
</button>
</footer>
</>
-8
View File
@@ -7,19 +7,11 @@ const s = (set, key) => (v) =>
export const useAuthStore = create((set) => ({
currentUser: null,
authChecked: false,
licenseStatus: { ok: false, reason: '', expires_at: null },
licenseLoading: true,
licenseUploadStatus: { type: '', message: '' },
licenseFileName: '',
healthStatus: null,
healthLoading: false,
healthError: '',
setCurrentUser: s(set, 'currentUser'),
setAuthChecked: s(set, 'authChecked'),
setLicenseStatus: s(set, 'licenseStatus'),
setLicenseLoading: s(set, 'licenseLoading'),
setLicenseUploadStatus: s(set, 'licenseUploadStatus'),
setLicenseFileName: s(set, 'licenseFileName'),
setHealthStatus: s(set, 'healthStatus'),
setHealthLoading: s(set, 'healthLoading'),
setHealthError: s(set, 'healthError'),
+8
View File
@@ -87,10 +87,14 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
return '洪涝灾害分析';
case 'health':
return '运行维护';
case 'ops_tasks':
return '任务维护';
case 'users':
return '用户管理';
case 'audit':
return '审计日志';
case 'user_manual':
return '用户手册';
default:
return tabKey;
}
@@ -120,10 +124,14 @@ export const getLeftTabDescription = (tabKey) => {
return '围绕洪涝场景开展水体提取、过程分析和专题制图。';
case 'health':
return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。';
case 'ops_tasks':
return '查看失败、部分成功、取消和残留运行的任务,按诊断预览清理数据库记录和生产目录。';
case 'users':
return '维护系统用户、角色与访问权限。';
case 'audit':
return '查看关键操作和生产任务的审计记录。';
case 'user_manual':
return '查看系统主要工作流、生产口径、统计解释和常见问题,辅助用户快速定位正确操作入口。';
default:
return '当前模块用于支撑科研工程生产流程。';
}
-114
View File
@@ -1,114 +0,0 @@
# InSAR License Issuer
This folder contains the offline license issuing tool for the LIC2 scheme used by the backend.
## Files
```text
license-issuer/
├── issue_license.py # CLI entry and reusable signing logic
├── license_issuer_gui.pyw # Desktop GUI
├── start_gui.bat # Windows launcher for the GUI
├── private_key.b64 # Private key, keep offline and do not distribute
├── public_key.b64 # Public key, can be synced to backend
└── README.md
```
## Requirements
```bash
pip install cryptography
```
The GUI uses the Python standard library `tkinter`, so no extra GUI dependency is required.
## GUI Usage
Windows:
```bat
start_gui.bat
```
Or directly open:
```text
license_issuer_gui.pyw
```
The GUI provides these flows:
- Read the current machine fingerprint
- Issue a `.lic` file
- Verify an existing `.lic` file
- Rotate key pairs
- Sync `public_key.b64` into `backend/app/license_service.py`
The backend sync target is configurable. If the issuer tool is copied to another machine or another folder layout, choose the target `license_service.py` manually in the GUI, or use `--target` in CLI.
## CLI Usage
Show help:
```bash
python issue_license.py --help
```
Get local fingerprint:
```bash
python issue_license.py fingerprint
```
Issue a license:
```bash
python issue_license.py issue ^
--to "XX省自然资源厅" ^
--fingerprint <fingerprint> ^
--days 365 ^
--output license_xx.lic
```
Verify a license:
```bash
python issue_license.py verify license_xx.lic
```
Generate a new key pair:
```bash
python issue_license.py rotate-key
```
Force rotate an existing key pair:
```bash
python issue_license.py rotate-key --force
```
Rotate and immediately sync the new public key to backend:
```bash
python issue_license.py rotate-key --force --sync-backend
```
Sync the current `public_key.b64` to backend without rotating:
```bash
python issue_license.py sync-public-key
```
## Standard Flow
1. Run `fingerprint` on the target machine and collect the value.
2. Run `issue` on the issuer machine and generate the `.lic` file.
3. Upload the `.lic` file through the admin page, or replace `backend/license/license.lic`.
4. If keys are rotated, sync the new public key to `backend/app/license_service.py` and redeploy the backend.
## Notes
- The private key must stay offline and should not be committed or distributed.
- Rotating the private key invalidates old licenses. All customer licenses must then be reissued.
- The fingerprint algorithm is intentionally kept consistent with `backend/app/license_service.py`.
-423
View File
@@ -1,423 +0,0 @@
"""
InSAR license issuer for the LIC2 format.
This module keeps the existing CLI workflow, and also exposes reusable
functions for the desktop GUI.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import re
import subprocess
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, Optional
ISSUER_DIR = Path(__file__).resolve().parent
PRIVATE_KEY_FILE = ISSUER_DIR / "private_key.b64"
PUBLIC_KEY_FILE = ISSUER_DIR / "public_key.b64"
BACKEND_LICENSE_SERVICE_FILE = ISSUER_DIR.parent / "backend" / "app" / "license_service.py"
LICENSE_HEADER = b"LIC2"
PUBLIC_KEY_PATTERN = re.compile(r'^_PUBLIC_KEY_B64\s*=\s*"([^"]*)"', re.MULTILINE)
def _load_cryptography():
try:
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
return Ed25519PrivateKey, Ed25519PublicKey, InvalidSignature, serialization
except ImportError:
raise RuntimeError("Missing dependency: pip install cryptography")
def resolve_backend_license_service_file(target: str | Path | None = None) -> Path:
if target is None:
env_target = str(os.environ.get("LICENSE_ISSUER_BACKEND_FILE", "")).strip()
if env_target:
return Path(env_target).expanduser()
return BACKEND_LICENSE_SERVICE_FILE
return Path(target).expanduser()
def _run(args: list[str]) -> str:
try:
output = subprocess.check_output(
args,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
shell=False,
)
return output.decode("utf-8", errors="ignore").strip()
except Exception:
return ""
def _run_ps(command: str) -> str:
return _run(["powershell", "-NoProfile", "-Command", command])
def _pick_value(text: str) -> str:
lines = [line.strip() for line in text.splitlines() if line.strip()]
if len(lines) <= 1:
return ""
values = [value for value in lines[1:] if value.lower() not in {"serialnumber", "uuid"}]
return values[0] if values else ""
def get_machine_fingerprint() -> str:
uuid_text = _run(["wmic", "csproduct", "get", "uuid"])
if not uuid_text:
uuid_text = _run_ps("(Get-CimInstance Win32_ComputerSystemProduct).UUID")
disk_text = _run(["wmic", "diskdrive", "get", "serialnumber"])
if not disk_text:
disk_text = _run_ps(
"(Get-CimInstance Win32_DiskDrive | Select-Object -First 1 -ExpandProperty SerialNumber)"
)
uuid_value = _pick_value(uuid_text)
disk_value = _pick_value(disk_text)
mac_value = f"{uuid.getnode():012x}"
if not mac_value or mac_value == "000000000000":
mac_value = _run_ps(
"(Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | "
"Select-Object -First 1 -ExpandProperty MacAddress)"
) or ""
raw = "|".join([uuid_value, disk_value, mac_value])
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def read_public_key_b64(path: Path = PUBLIC_KEY_FILE) -> str:
if not path.exists():
raise FileNotFoundError(f"Public key file not found: {path}")
return path.read_text(encoding="utf-8").strip()
def read_private_key_b64(path: Path = PRIVATE_KEY_FILE) -> str:
if not path.exists():
raise FileNotFoundError(f"Private key file not found: {path}")
return path.read_text(encoding="utf-8").strip()
def read_backend_public_key_b64(path: str | Path | None = None) -> Optional[str]:
target_path = resolve_backend_license_service_file(path)
if not target_path.exists():
return None
content = target_path.read_text(encoding="utf-8")
match = PUBLIC_KEY_PATTERN.search(content)
return match.group(1) if match else None
def get_key_status(backend_target: str | Path | None = None) -> Dict[str, Any]:
backend_path = resolve_backend_license_service_file(backend_target)
public_key_b64 = None
backend_public_key_b64 = None
if PUBLIC_KEY_FILE.exists():
public_key_b64 = read_public_key_b64(PUBLIC_KEY_FILE)
if backend_path.exists():
backend_public_key_b64 = read_backend_public_key_b64(backend_path)
return {
"private_key_exists": PRIVATE_KEY_FILE.exists(),
"public_key_exists": PUBLIC_KEY_FILE.exists(),
"private_key_path": str(PRIVATE_KEY_FILE),
"public_key_path": str(PUBLIC_KEY_FILE),
"backend_license_service_path": str(backend_path),
"backend_license_service_exists": backend_path.exists(),
"public_key_b64": public_key_b64,
"backend_public_key_b64": backend_public_key_b64,
"backend_synced": bool(public_key_b64 and public_key_b64 == backend_public_key_b64),
}
def _load_private_key():
Ed25519PrivateKey, _, _, _ = _load_cryptography()
raw = base64.b64decode(read_private_key_b64(PRIVATE_KEY_FILE))
return Ed25519PrivateKey.from_private_bytes(raw)
def rotate_key_pair(force: bool = False) -> Dict[str, Any]:
Ed25519PrivateKey, _, _, serialization = _load_cryptography()
if PRIVATE_KEY_FILE.exists() and not force:
raise FileExistsError(
"Private key already exists. Use force=True only if you really want to rotate it."
)
key = Ed25519PrivateKey.generate()
private_key_b64 = base64.b64encode(
key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
).decode("utf-8")
public_key_b64 = base64.b64encode(
key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
).decode("utf-8")
PRIVATE_KEY_FILE.write_text(private_key_b64, encoding="utf-8")
PUBLIC_KEY_FILE.write_text(public_key_b64, encoding="utf-8")
return {
"private_key_path": str(PRIVATE_KEY_FILE),
"public_key_path": str(PUBLIC_KEY_FILE),
"public_key_b64": public_key_b64,
"force": force,
}
def sync_backend_public_key(
*,
public_key_b64: Optional[str] = None,
target_path: str | Path | None = None,
) -> Dict[str, Any]:
resolved_target_path = resolve_backend_license_service_file(target_path)
key_b64 = (public_key_b64 or read_public_key_b64(PUBLIC_KEY_FILE)).strip()
if not resolved_target_path.exists():
raise FileNotFoundError(f"Backend license service file not found: {resolved_target_path}")
content = resolved_target_path.read_text(encoding="utf-8")
match = PUBLIC_KEY_PATTERN.search(content)
if not match:
raise ValueError("Could not find _PUBLIC_KEY_B64 in backend license service.")
old_key_b64 = match.group(1)
updated_content = PUBLIC_KEY_PATTERN.sub(
f'_PUBLIC_KEY_B64 = "{key_b64}"',
content,
count=1,
)
resolved_target_path.write_text(updated_content, encoding="utf-8")
return {
"target_path": str(resolved_target_path),
"old_public_key_b64": old_key_b64,
"public_key_b64": key_b64,
"changed": old_key_b64 != key_b64,
}
def _normalize_output_path(issued_to: str, output: Optional[str]) -> Path:
if output:
return Path(output)
safe_name = re.sub(r"[^0-9A-Za-z_\-\u4e00-\u9fff]+", "_", issued_to).strip("_")
safe_name = safe_name[:32] or "license"
return ISSUER_DIR / f"license_{safe_name}.lic"
def issue_license_file(
*,
issued_to: str,
fingerprint: str,
days: int = 365,
output: Optional[str] = None,
) -> Dict[str, Any]:
if not issued_to.strip():
raise ValueError("issued_to is required")
if not fingerprint.strip():
raise ValueError("fingerprint is required")
private_key = _load_private_key()
issued_at = datetime.now(timezone.utc)
expires_at = issued_at + timedelta(days=int(days))
payload = {
"issued_to": issued_to.strip(),
"fingerprint": fingerprint.strip(),
"expires_at": expires_at.isoformat(),
"issued_at": issued_at.isoformat(),
}
payload_b64 = base64.b64encode(
json.dumps(payload, ensure_ascii=False).encode("utf-8")
)
signature = private_key.sign(payload_b64)
signature_b64 = base64.b64encode(signature)
blob = LICENSE_HEADER + b"|" + signature_b64 + b"|" + payload_b64
output_path = _normalize_output_path(payload["issued_to"], output)
output_path.write_bytes(blob)
return {
"output_path": str(output_path),
"payload": payload,
}
def verify_license_file(license_file: str | Path) -> Dict[str, Any]:
_, Ed25519PublicKey, InvalidSignature, _ = _load_cryptography()
license_path = Path(license_file)
if not license_path.exists():
raise FileNotFoundError(f"License file not found: {license_path}")
public_key_raw = base64.b64decode(read_public_key_b64(PUBLIC_KEY_FILE))
public_key = Ed25519PublicKey.from_public_bytes(public_key_raw)
try:
blob = license_path.read_bytes().strip()
header, signature_b64, payload_b64 = blob.split(b"|", 2)
if header != LICENSE_HEADER:
raise ValueError("Invalid license header")
public_key.verify(base64.b64decode(signature_b64), payload_b64)
payload = json.loads(base64.b64decode(payload_b64))
except (InvalidSignature, ValueError, json.JSONDecodeError) as exc:
return {
"ok": False,
"license_file": str(license_path),
"reason": str(exc),
}
except Exception as exc:
return {
"ok": False,
"license_file": str(license_path),
"reason": f"Failed to parse license: {exc}",
}
expires_at_text = str(payload.get("expires_at") or "")
expires_at = datetime.fromisoformat(expires_at_text)
expired = datetime.now(timezone.utc) > expires_at
return {
"ok": True,
"license_file": str(license_path),
"issued_to": payload.get("issued_to"),
"fingerprint": payload.get("fingerprint"),
"issued_at": payload.get("issued_at"),
"expires_at": expires_at_text,
"expired": expired,
}
def cmd_rotate_key(args: argparse.Namespace) -> int:
result = rotate_key_pair(force=bool(args.force))
print("=" * 60)
print("New key pair generated")
print(f"Private key: {result['private_key_path']}")
print(f"Public key : {result['public_key_path']}")
print()
print("Update backend/app/license_service.py with:")
print(f'_PUBLIC_KEY_B64 = "{result["public_key_b64"]}"')
print("=" * 60)
if getattr(args, "sync_backend", False):
sync_result = sync_backend_public_key(
public_key_b64=result["public_key_b64"],
target_path=args.target,
)
print(f"Backend public key synced: {sync_result['target_path']}")
return 0
def cmd_sync_public_key(args: argparse.Namespace) -> int:
result = sync_backend_public_key(target_path=args.target)
status = "updated" if result["changed"] else "already synced"
print(f"Backend public key {status}: {result['target_path']}")
return 0
def cmd_issue(args: argparse.Namespace) -> int:
result = issue_license_file(
issued_to=args.to,
fingerprint=args.fingerprint,
days=int(args.days),
output=args.output,
)
payload = result["payload"]
print(f"License file generated: {result['output_path']}")
print(f"Issued to : {payload['issued_to']}")
print(f"Fingerprint : {payload['fingerprint']}")
print(f"Expires at : {payload['expires_at']}")
return 0
def cmd_verify(args: argparse.Namespace) -> int:
result = verify_license_file(args.license_file)
if not result["ok"]:
print(f"[invalid] {result['reason']}")
return 1
print("[valid] Signature verified")
print(f"Issued to : {result.get('issued_to')}")
print(f"Fingerprint : {result.get('fingerprint')}")
print(f"Issued at : {result.get('issued_at')}")
print(f"Expires at : {result.get('expires_at')}")
print(f"Expired : {'yes' if result.get('expired') else 'no'}")
return 0
def cmd_fingerprint(_args: argparse.Namespace) -> int:
print(get_machine_fingerprint())
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="InSAR license issuer")
sub = parser.add_subparsers(dest="cmd")
rotate = sub.add_parser("rotate-key", help="Generate a new key pair")
rotate.add_argument("--force", action="store_true", help="Overwrite existing private/public key files")
rotate.add_argument(
"--sync-backend",
action="store_true",
help="After rotation, also update backend/app/license_service.py",
)
rotate.add_argument("--target", default=None, help="Optional backend/app/license_service.py path")
sync_public = sub.add_parser("sync-public-key", help="Sync public_key.b64 into backend/app/license_service.py")
sync_public.add_argument("--target", default=None, help="Optional target file path")
issue = sub.add_parser("issue", help="Issue a .lic file")
issue.add_argument("--to", required=True, help="Organization or customer name")
issue.add_argument("--fingerprint", required=True, help="Target machine fingerprint")
issue.add_argument("--days", default=365, help="Validity in days")
issue.add_argument("--output", default=None, help="Output .lic path")
verify = sub.add_parser("verify", help="Verify a .lic file against public_key.b64")
verify.add_argument("license_file", help="Path to .lic file")
sub.add_parser("fingerprint", help="Print the current machine fingerprint")
return parser
def main(argv: Optional[list[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.cmd == "rotate-key":
return cmd_rotate_key(args)
if args.cmd == "sync-public-key":
return cmd_sync_public_key(args)
if args.cmd == "issue":
return cmd_issue(args)
if args.cmd == "verify":
return cmd_verify(args)
if args.cmd == "fingerprint":
return cmd_fingerprint(args)
parser.print_help()
return 0
except Exception as exc:
print(f"[error] {exc}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
-404
View File
@@ -1,404 +0,0 @@
from __future__ import annotations
import json
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from tkinter.scrolledtext import ScrolledText
from issue_license import (
BACKEND_LICENSE_SERVICE_FILE,
PUBLIC_KEY_FILE,
get_key_status,
get_machine_fingerprint,
issue_license_file,
rotate_key_pair,
sync_backend_public_key,
verify_license_file,
)
class LicenseIssuerApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("InSAR 授权签发工具")
self.geometry("920x720")
self.minsize(860, 620)
self.fingerprint_var = tk.StringVar()
self.issue_to_var = tk.StringVar()
self.issue_fingerprint_var = tk.StringVar()
self.issue_days_var = tk.StringVar(value="365")
self.issue_output_var = tk.StringVar()
self.verify_path_var = tk.StringVar()
self.backend_target_var = tk.StringVar(value=str(BACKEND_LICENSE_SERVICE_FILE))
self.status_var = tk.StringVar(value="就绪")
self.key_summary_var = tk.StringVar(value="")
self._build_ui()
self.refresh_fingerprint()
self.refresh_keys()
def _build_ui(self) -> None:
container = ttk.Frame(self, padding=12)
container.pack(fill="both", expand=True)
header = ttk.Frame(container)
header.pack(fill="x")
ttk.Label(
header,
text="InSAR 授权签发工具",
font=("Segoe UI", 16, "bold"),
).pack(anchor="w")
ttk.Label(
header,
text="基于现有 LIC2 协议,通过桌面窗体完成指纹获取、签发、验签和密钥管理。",
).pack(anchor="w", pady=(4, 10))
notebook = ttk.Notebook(container)
notebook.pack(fill="both", expand=True)
self._build_fingerprint_tab(notebook)
self._build_issue_tab(notebook)
self._build_verify_tab(notebook)
self._build_keys_tab(notebook)
status_bar = ttk.Label(
container,
textvariable=self.status_var,
relief="sunken",
anchor="w",
padding=(8, 4),
)
status_bar.pack(fill="x", pady=(10, 0))
def _build_fingerprint_tab(self, notebook: ttk.Notebook) -> None:
frame = ttk.Frame(notebook, padding=12)
notebook.add(frame, text="机器指纹")
ttk.Label(
frame,
text="获取当前机器指纹。该值可以发给签发端,用于生成绑定本机的授权文件。",
wraplength=760,
).pack(anchor="w")
row = ttk.Frame(frame)
row.pack(fill="x", pady=(16, 8))
ttk.Entry(row, textvariable=self.fingerprint_var, state="readonly").pack(
side="left",
fill="x",
expand=True,
)
button_row = ttk.Frame(frame)
button_row.pack(fill="x", pady=(4, 8))
ttk.Button(button_row, text="刷新", command=self.refresh_fingerprint).pack(side="left")
ttk.Button(button_row, text="复制", command=self.copy_fingerprint).pack(side="left", padx=(8, 0))
ttk.Button(
button_row,
text="填入签发表单",
command=self.use_current_fingerprint_for_issue,
).pack(side="left", padx=(8, 0))
self.fingerprint_details = ScrolledText(frame, height=18, wrap="word")
self.fingerprint_details.pack(fill="both", expand=True, pady=(12, 0))
self._set_text(
self.fingerprint_details,
"当前指纹由硬件标识计算得到,算法与 backend/app/license_service.py 保持一致。\n",
)
def _build_issue_tab(self, notebook: ttk.Notebook) -> None:
frame = ttk.Frame(notebook, padding=12)
notebook.add(frame, text="签发授权")
form = ttk.Frame(frame)
form.pack(fill="x")
form.columnconfigure(1, weight=1)
ttk.Label(form, text="授权对象").grid(row=0, column=0, sticky="w", pady=6)
ttk.Entry(form, textvariable=self.issue_to_var).grid(row=0, column=1, sticky="ew", pady=6)
ttk.Label(form, text="机器指纹").grid(row=1, column=0, sticky="w", pady=6)
ttk.Entry(form, textvariable=self.issue_fingerprint_var).grid(row=1, column=1, sticky="ew", pady=6)
ttk.Label(form, text="有效天数").grid(row=2, column=0, sticky="w", pady=6)
ttk.Entry(form, textvariable=self.issue_days_var, width=12).grid(row=2, column=1, sticky="w", pady=6)
ttk.Label(form, text="输出文件").grid(row=3, column=0, sticky="w", pady=6)
output_row = ttk.Frame(form)
output_row.grid(row=3, column=1, sticky="ew", pady=6)
output_row.columnconfigure(0, weight=1)
ttk.Entry(output_row, textvariable=self.issue_output_var).grid(row=0, column=0, sticky="ew")
ttk.Button(output_row, text="浏览", command=self.browse_issue_output).grid(row=0, column=1, padx=(8, 0))
button_row = ttk.Frame(frame)
button_row.pack(fill="x", pady=(10, 8))
ttk.Button(button_row, text="使用本机指纹", command=self.use_current_fingerprint_for_issue).pack(side="left")
ttk.Button(button_row, text="生成授权文件", command=self.issue_license_action).pack(side="left", padx=(8, 0))
self.issue_output_box = ScrolledText(frame, height=20, wrap="word")
self.issue_output_box.pack(fill="both", expand=True, pady=(8, 0))
self._set_text(
self.issue_output_box,
"填写表单后,点击“生成授权文件”。\n",
)
def _build_verify_tab(self, notebook: ttk.Notebook) -> None:
frame = ttk.Frame(notebook, padding=12)
notebook.add(frame, text="验证授权")
ttk.Label(
frame,
text="使用当前目录中的 public_key.b64 验证已有的 .lic 授权文件。",
wraplength=760,
).pack(anchor="w")
row = ttk.Frame(frame)
row.pack(fill="x", pady=(14, 8))
row.columnconfigure(0, weight=1)
ttk.Entry(row, textvariable=self.verify_path_var).grid(row=0, column=0, sticky="ew")
ttk.Button(row, text="浏览", command=self.browse_verify_file).grid(row=0, column=1, padx=(8, 0))
ttk.Button(row, text="开始验证", command=self.verify_license_action).grid(row=0, column=2, padx=(8, 0))
self.verify_output_box = ScrolledText(frame, height=24, wrap="word")
self.verify_output_box.pack(fill="both", expand=True, pady=(8, 0))
self._set_text(
self.verify_output_box,
"选择一个授权文件,然后点击“开始验证”。\n",
)
def _build_keys_tab(self, notebook: ttk.Notebook) -> None:
frame = ttk.Frame(notebook, padding=12)
notebook.add(frame, text="密钥管理")
ttk.Label(
frame,
text="管理签发密钥,并可选地将 public_key.b64 同步到 backend/app/license_service.py。",
wraplength=760,
).pack(anchor="w")
target_row = ttk.Frame(frame)
target_row.pack(fill="x", pady=(14, 8))
target_row.columnconfigure(1, weight=1)
ttk.Label(target_row, text="后端目标文件").grid(row=0, column=0, sticky="w")
ttk.Entry(target_row, textvariable=self.backend_target_var).grid(row=0, column=1, sticky="ew", padx=(8, 0))
ttk.Button(target_row, text="浏览", command=self.browse_backend_target).grid(row=0, column=2, padx=(8, 0))
ttk.Label(frame, textvariable=self.key_summary_var, wraplength=780).pack(anchor="w", pady=(6, 8))
button_row = ttk.Frame(frame)
button_row.pack(fill="x", pady=(0, 8))
ttk.Button(button_row, text="刷新状态", command=self.refresh_keys).pack(side="left")
ttk.Button(button_row, text="轮换密钥", command=self.rotate_key_action).pack(side="left", padx=(8, 0))
ttk.Button(button_row, text="强制轮换", command=lambda: self.rotate_key_action(force=True)).pack(side="left", padx=(8, 0))
ttk.Button(button_row, text="同步公钥到后端", command=self.sync_backend_action).pack(side="left", padx=(8, 0))
self.key_output_box = ScrolledText(frame, height=24, wrap="word")
self.key_output_box.pack(fill="both", expand=True, pady=(8, 0))
self._set_text(
self.key_output_box,
f"后端文件:{BACKEND_LICENSE_SERVICE_FILE}\n公钥文件:{PUBLIC_KEY_FILE}\n",
)
def _set_status(self, message: str) -> None:
self.status_var.set(message)
def _set_text(self, widget: ScrolledText, text: str) -> None:
widget.configure(state="normal")
widget.delete("1.0", "end")
widget.insert("1.0", text)
widget.configure(state="disabled")
def _copy_text(self, text: str) -> None:
self.clipboard_clear()
self.clipboard_append(text)
self.update()
def refresh_fingerprint(self) -> None:
fingerprint = get_machine_fingerprint()
self.fingerprint_var.set(fingerprint)
self._set_text(
self.fingerprint_details,
"当前机器指纹:\n\n"
f"{fingerprint}\n\n"
"可以点击“复制”发给签发端,也可以点击“填入签发表单”做本机测试。\n",
)
self._set_status("机器指纹已刷新")
def copy_fingerprint(self) -> None:
if not self.fingerprint_var.get().strip():
self.refresh_fingerprint()
self._copy_text(self.fingerprint_var.get().strip())
self._set_status("机器指纹已复制")
def use_current_fingerprint_for_issue(self) -> None:
if not self.fingerprint_var.get().strip():
self.refresh_fingerprint()
self.issue_fingerprint_var.set(self.fingerprint_var.get().strip())
self._set_status("签发表单已填入当前指纹")
def browse_issue_output(self) -> None:
path = filedialog.asksaveasfilename(
title="选择输出 .lic 文件",
defaultextension=".lic",
filetypes=[("授权文件", "*.lic"), ("所有文件", "*.*")],
initialfile="license.lic",
)
if path:
self.issue_output_var.set(path)
def browse_verify_file(self) -> None:
path = filedialog.askopenfilename(
title="选择 .lic 文件",
filetypes=[("授权文件", "*.lic"), ("所有文件", "*.*")],
)
if path:
self.verify_path_var.set(path)
def browse_backend_target(self) -> None:
path = filedialog.askopenfilename(
title="选择 backend/app/license_service.py",
filetypes=[("Python 文件", "*.py"), ("所有文件", "*.*")],
initialfile="license_service.py",
)
if path:
self.backend_target_var.set(path)
def issue_license_action(self) -> None:
try:
days = int(self.issue_days_var.get().strip() or "365")
result = issue_license_file(
issued_to=self.issue_to_var.get().strip(),
fingerprint=self.issue_fingerprint_var.get().strip(),
days=days,
output=self.issue_output_var.get().strip() or None,
)
except Exception as exc:
messagebox.showerror("签发授权", str(exc))
self._set_status("生成授权失败")
return
payload = result["payload"]
output_path = result["output_path"]
self.issue_output_var.set(output_path)
text = (
"授权文件生成成功。\n\n"
f"输出文件:{output_path}\n"
f"授权对象:{payload['issued_to']}\n"
f"机器指纹:{payload['fingerprint']}\n"
f"签发时间:{payload['issued_at']}\n"
f"到期时间:{payload['expires_at']}\n"
)
self._set_text(self.issue_output_box, text)
self._set_status("授权文件已生成")
messagebox.showinfo("签发授权", f"授权文件已生成:\n{output_path}")
def verify_license_action(self) -> None:
path = self.verify_path_var.get().strip()
if not path:
messagebox.showwarning("验证授权", "请先选择一个 .lic 授权文件。")
return
try:
result = verify_license_file(path)
except Exception as exc:
messagebox.showerror("验证授权", str(exc))
self._set_status("授权验证失败")
return
if not result["ok"]:
self._set_text(
self.verify_output_box,
json.dumps(result, ensure_ascii=False, indent=2),
)
self._set_status("授权文件无效")
messagebox.showerror("验证授权", result["reason"])
return
text = (
"授权验证通过。\n\n"
f"授权文件:{result['license_file']}\n"
f"授权对象:{result.get('issued_to')}\n"
f"机器指纹:{result.get('fingerprint')}\n"
f"签发时间:{result.get('issued_at')}\n"
f"到期时间:{result.get('expires_at')}\n"
f"是否过期:{'是' if result.get('expired') else '否'}\n"
)
self._set_text(self.verify_output_box, text)
self._set_status("授权验证通过")
messagebox.showinfo("验证授权", "授权验证通过。")
def refresh_keys(self) -> None:
status = get_key_status(self.backend_target_var.get().strip() or None)
lines = [
f"私钥存在:{'是' if status['private_key_exists'] else '否'}",
f"公钥存在:{'是' if status['public_key_exists'] else '否'}",
f"公钥已同步:{'是' if status['backend_synced'] else '否'}",
f"私钥路径:{status['private_key_path']}",
f"公钥路径:{status['public_key_path']}",
f"后端文件:{status['backend_license_service_path']}",
]
self.key_summary_var.set("\n".join(lines))
key_details = {
"public_key_b64": status.get("public_key_b64"),
"backend_public_key_b64": status.get("backend_public_key_b64"),
"backend_synced": status.get("backend_synced"),
}
self._set_text(self.key_output_box, json.dumps(key_details, ensure_ascii=False, indent=2))
self._set_status("密钥状态已刷新")
def rotate_key_action(self, force: bool = False) -> None:
if force:
confirmed = messagebox.askyesno(
"强制轮换密钥",
"这会覆盖现有密钥对,并使旧授权文件失效。确认继续吗?",
)
if not confirmed:
return
try:
result = rotate_key_pair(force=force)
except Exception as exc:
messagebox.showerror("轮换密钥", str(exc))
self._set_status("密钥轮换失败")
return
self.refresh_keys()
self._copy_text(result["public_key_b64"])
self._set_status("密钥轮换完成,公钥已复制")
sync_now = messagebox.askyesno(
"轮换密钥",
"新密钥对已生成,公钥也已复制到剪贴板。\n\n现在要同步到 backend/app/license_service.py 吗?",
)
if sync_now:
self.sync_backend_action()
def sync_backend_action(self) -> None:
try:
result = sync_backend_public_key(
target_path=self.backend_target_var.get().strip() or None,
)
except Exception as exc:
messagebox.showerror("同步公钥", str(exc))
self._set_status("后端公钥同步失败")
return
self.refresh_keys()
changed_text = "已更新" if result["changed"] else "已是最新"
self._set_status(f"后端公钥{changed_text}")
messagebox.showinfo(
"同步公钥",
f"后端公钥{changed_text}:\n{result['target_path']}",
)
def main() -> None:
app = LicenseIssuerApp()
app.mainloop()
if __name__ == "__main__":
main()
-1
View File
@@ -1 +0,0 @@
QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw=
-38
View File
@@ -1,38 +0,0 @@
@echo off
setlocal
chcp 65001 >nul
cd /d "%~dp0"
set "APP=%~dp0license_issuer_gui.pyw"
set "PYTHONDONTWRITEBYTECODE=1"
if not defined PYTHON_PATH set "PYTHON_PATH=C:\ProgramData\anaconda3\envs\InSAR\python.exe"
for %%I in ("%PYTHON_PATH%") do set "PYTHONW_PATH=%%~dpIpythonw.exe"
if exist "%PYTHONW_PATH%" (
start "" "%PYTHONW_PATH%" "%APP%"
exit /b 0
)
if exist "%PYTHON_PATH%" (
"%PYTHON_PATH%" "%APP%"
if errorlevel 1 pause
exit /b %errorlevel%
)
where pyw >nul 2>nul
if %errorlevel%==0 (
start "" pyw -3 "%APP%"
exit /b 0
)
where pythonw >nul 2>nul
if %errorlevel%==0 (
start "" pythonw "%APP%"
exit /b 0
)
echo [!] Python not found.
echo Expected: %PYTHON_PATH%
echo Or install Python Launcher / pythonw and add it to PATH.
pause
exit /b 1
-271
View File
@@ -1,271 +0,0 @@
import base64
import hashlib
import json
import os
import tkinter as tk
from datetime import datetime, timedelta, timezone
from tkinter import filedialog, messagebox
from typing import Tuple
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
APP_TITLE = "InSAR 授权管理器"
def _derive_aes_key(secret: str) -> bytes:
return hashlib.sha256(secret.encode("utf-8")).digest()
def _b64encode(data: bytes) -> str:
return base64.b64encode(data).decode("utf-8")
def _b64decode(data: str) -> bytes:
return base64.b64decode(data.encode("utf-8"))
def _load_env(path: str) -> dict:
env = {}
if not os.path.exists(path):
return env
with open(path, "r", encoding="utf-8-sig") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, val = line.split("=", 1)
env[key.strip()] = val.strip().strip('"').strip("'")
return env
def _save_env(path: str, updates: dict) -> None:
existing = []
if os.path.exists(path):
with open(path, "r", encoding="utf-8-sig") as f:
existing = f.readlines()
def _set_line(key: str, value: str) -> bool:
for idx, line in enumerate(existing):
if line.strip().startswith(key + "="):
existing[idx] = f"{key}={value}\n"
return True
return False
for key, value in updates.items():
if not _set_line(key, value):
existing.append(f"{key}={value}\n")
with open(path, "w", encoding="utf-8") as f:
f.writelines(existing)
def _gen_keys() -> Tuple[str, str]:
priv = Ed25519PrivateKey.generate()
pub = priv.public_key()
priv_bytes = priv.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
pub_bytes = pub.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return _b64encode(priv_bytes), _b64encode(pub_bytes)
def _load_private_key(path: str) -> Ed25519PrivateKey:
raw = _b64decode(open(path, "r", encoding="utf-8").read().strip())
return Ed25519PrivateKey.from_private_bytes(raw)
def _public_from_private(priv: Ed25519PrivateKey) -> str:
pub = priv.public_key()
pub_bytes = pub.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return _b64encode(pub_bytes)
def _get_fingerprint() -> str:
try:
from get_fingerprint import get_fingerprint
except Exception:
return ""
return get_fingerprint()
def _resolve_paths(project_root: str) -> Tuple[str, str]:
lic_dir = os.path.join(project_root, "backend", "license")
lic_path = os.path.join(lic_dir, "license.lic")
return lic_dir, lic_path
def _default_expiry() -> str:
return (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S")
def _parse_expiry(value: str) -> str:
value = value.strip()
if not value:
value = _default_expiry()
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
class LicenseManagerApp:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title(APP_TITLE)
self.root.geometry("640x420")
self.root.resizable(False, False)
self.env_path = tk.StringVar()
self.expiry = tk.StringVar(value=_default_expiry())
self.status = tk.StringVar(value="请选择 .env 文件路径")
self._build_ui()
def _build_ui(self):
frame = tk.Frame(self.root, padx=16, pady=16)
frame.pack(fill=tk.BOTH, expand=True)
title = tk.Label(frame, text="离线授权文件生成器", font=("Microsoft YaHei", 14, "bold"))
title.pack(anchor="w")
env_row = tk.Frame(frame)
env_row.pack(fill=tk.X, pady=(18, 8))
tk.Label(env_row, text=".env 路径:", width=12, anchor="w").pack(side=tk.LEFT)
env_entry = tk.Entry(env_row, textvariable=self.env_path, width=60)
env_entry.pack(side=tk.LEFT, padx=(0, 8))
tk.Button(env_row, text="选择", command=self._pick_env).pack(side=tk.LEFT)
exp_row = tk.Frame(frame)
exp_row.pack(fill=tk.X, pady=8)
tk.Label(exp_row, text="到期时间:", width=12, anchor="w").pack(side=tk.LEFT)
exp_entry = tk.Entry(exp_row, textvariable=self.expiry, width=30)
exp_entry.pack(side=tk.LEFT)
tk.Label(exp_row, text="格式:YYYY-MM-DD HH:MM:SS (UTC)", fg="#666").pack(side=tk.LEFT, padx=8)
btn_row = tk.Frame(frame)
btn_row.pack(fill=tk.X, pady=12)
tk.Button(btn_row, text="生成/续期授权", width=20, command=self._run).pack(side=tk.LEFT)
note = tk.Label(
frame,
text="说明:若检测到公私钥不匹配,将自动重建密钥对(旧授权全部失效)。",
fg="#b91c1c",
)
note.pack(anchor="w", pady=(6, 10))
status_label = tk.Label(frame, textvariable=self.status, fg="#0f172a", wraplength=580, justify="left")
status_label.pack(anchor="w", pady=(12, 0))
def _pick_env(self):
path = filedialog.askopenfilename(title="选择 .env 文件", filetypes=[("Env file", ".env"), ("All files", "*.*")])
if path:
self.env_path.set(path)
self.status.set("准备就绪,可生成授权文件。")
def _run(self):
env_path = self.env_path.get().strip()
if not env_path or not os.path.exists(env_path):
messagebox.showerror("错误", "请先选择有效的 .env 文件路径。")
return
try:
exp_iso = _parse_expiry(self.expiry.get())
except Exception:
messagebox.showerror("错误", "到期时间格式错误,请使用 YYYY-MM-DD HH:MM:SS (UTC)。")
return
env = _load_env(env_path)
secret = env.get("LICENSE_SECRET")
if not secret:
secret = _b64encode(os.urandom(32))
project_root = os.path.dirname(env_path)
lic_dir, lic_path = _resolve_paths(project_root)
private_key_path = os.path.join(project_root, "license_private_key.txt")
if os.path.exists(private_key_path):
try:
priv = _load_private_key(private_key_path)
pub_from_priv = _public_from_private(priv)
pub_env = env.get("LICENSE_PUBLIC_KEY", "")
if pub_env and pub_env != pub_from_priv:
priv_b64, pub_b64 = _gen_keys()
priv = Ed25519PrivateKey.from_private_bytes(_b64decode(priv_b64))
pub_from_priv = pub_b64
with open(private_key_path, "w", encoding="utf-8") as f:
f.write(priv_b64)
elif not pub_env:
pub_env = pub_from_priv
except Exception:
priv_b64, pub_b64 = _gen_keys()
priv = Ed25519PrivateKey.from_private_bytes(_b64decode(priv_b64))
pub_from_priv = pub_b64
with open(private_key_path, "w", encoding="utf-8") as f:
f.write(priv_b64)
else:
priv_b64, pub_b64 = _gen_keys()
priv = Ed25519PrivateKey.from_private_bytes(_b64decode(priv_b64))
pub_from_priv = pub_b64
with open(private_key_path, "w", encoding="utf-8") as f:
f.write(priv_b64)
_save_env(env_path, {
"LICENSE_SECRET": secret,
"LICENSE_PUBLIC_KEY": pub_from_priv,
})
fingerprint = _get_fingerprint()
if not fingerprint:
messagebox.showerror("错误", "无法获取机器指纹,请检查 get_fingerprint.py 是否可用。")
return
payload = {
"product": "insar_management_system_v2",
"fingerprint": fingerprint,
"expires_at": exp_iso,
"issued_at": datetime.now(timezone.utc).isoformat(),
}
plaintext = json.dumps(payload, ensure_ascii=False).encode("utf-8")
aes_key = _derive_aes_key(secret)
aesgcm = AESGCM(aes_key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
header = b"LIC1"
nonce_b64 = _b64encode(nonce).encode("utf-8")
ct_b64 = _b64encode(ciphertext).encode("utf-8")
data_to_sign = b"|".join([header, nonce_b64, ct_b64])
signature = priv.sign(data_to_sign)
sig_b64 = _b64encode(signature).encode("utf-8")
blob = b"|".join([header, sig_b64, nonce_b64, ct_b64])
os.makedirs(lic_dir, exist_ok=True)
with open(lic_path, "wb") as f:
f.write(blob)
self.status.set(
"授权文件已生成:\n"
f"- license.lic: {lic_path}\n"
f"- private key: {private_key_path}\n"
f"- expires_at: {exp_iso}\n"
"已自动写入 LICENSE_SECRET / LICENSE_PUBLIC_KEY 到 .env"
)
messagebox.showinfo("完成", "授权文件已生成并写入配置。")
if __name__ == "__main__":
root = tk.Tk()
app = LicenseManagerApp(root)
root.mainloop()