Compare commits

...
3 Commits
Author SHA1 Message Date
Harmon e4459180ad Document result delivery workbench design 2026-07-08 10:20:14 +08:00
Harmon 36a406ae49 Improve pairing planning and statistics visibility 2026-07-07 01:04:47 +08:00
Harmon 2da2121829 Remove application license gate 2026-07-02 12:58:44 +08:00
52 changed files with 3334 additions and 2381 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: # 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=192.168.1.10;192.168.1.23;192.168.1.0/24
NGINX_ALLOWED_CLIENT_IPS= NGINX_ALLOWED_CLIENT_IPS=
LICENSE_PATH=
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost CORS_ORIGINS=http://127.0.0.1:5173,http://localhost
CORS_ALLOW_CREDENTIALS=true CORS_ALLOW_CREDENTIALS=true
CORS_STRICT_MODE=false CORS_STRICT_MODE=false
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from .routers import include_all_routers 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) include_all_routers(router)
-2
View File
@@ -202,7 +202,6 @@ class Settings(BaseSettings):
CORS_ALLOW_CREDENTIALS: bool = True CORS_ALLOW_CREDENTIALS: bool = True
CORS_STRICT_MODE: bool = False CORS_STRICT_MODE: bool = False
LICENSE_PATH: str = ""
INIT_ADMIN_USERNAME: str = "admin" INIT_ADMIN_USERNAME: str = "admin"
INIT_ADMIN_PASSWORD: str = "" INIT_ADMIN_PASSWORD: str = ""
INIT_ADMIN_RESET_PASSWORD: bool = False 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="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="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_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="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) _check_path(label="GF3_GEO_DEM_PATH", value=settings.GF3_GEO_DEM_PATH, errors=errors, warnings=warnings, expect_file=True)
-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)
+9 -2
View File
@@ -284,7 +284,7 @@ class PairingRequest(BaseModel):
slave_date_to: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$') 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) num_connections: int = Field(default=1, ge=1, le=10)
reference_image_id: Optional[int] = None reference_image_id: Optional[int] = None
@@ -307,7 +307,8 @@ class PairingRequest(BaseModel):
normalized['overlap_threshold'] = normalized['pair_footprint_overlap_min_ratio'] normalized['overlap_threshold'] = normalized['pair_footprint_overlap_min_ratio']
if normalized.get('footprint_center_distance_max_meters') not in (None, ''): if normalized.get('footprint_center_distance_max_meters') not in (None, ''):
normalized['spatial_baseline_max_meters'] = normalized['footprint_center_distance_max_meters'] 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 return normalized
@field_validator( @field_validator(
@@ -390,6 +391,11 @@ class RadarPair(BaseModel):
selection_strategy: Optional[str] = None selection_strategy: Optional[str] = None
selection_score: Optional[float] = None selection_score: Optional[float] = None
selection_reason: Optional[str] = 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 time_baseline_days: int
spatial_baseline_meters: float spatial_baseline_meters: float
scene_center_distance_meters: Optional[float] = None scene_center_distance_meters: Optional[float] = None
@@ -416,6 +422,7 @@ class PairingResponse(BaseModel):
network_run_id: Optional[str] = None network_run_id: Optional[str] = None
candidate_count: int = 0 candidate_count: int = 0
selected_edge_count: int = 0 selected_edge_count: int = 0
coverage: Optional[Dict[str, Any]] = None
class PsRequest(BaseModel): class PsRequest(BaseModel):
-2
View File
@@ -15,7 +15,6 @@ from . import (
health, health,
idl, idl,
landsar_lt1_production, landsar_lt1_production,
license,
logs, logs,
monitor, monitor,
ops_maintenance, ops_maintenance,
@@ -42,7 +41,6 @@ def include_all_routers(router: APIRouter) -> None:
"""将所有子路由注册到给定的 APIRouter。""" """将所有子路由注册到给定的 APIRouter。"""
router.include_router(health.router) router.include_router(health.router)
router.include_router(auth.router) router.include_router(auth.router)
router.include_router(license.router)
router.include_router(tasks_runtime.router) router.include_router(tasks_runtime.router)
router.include_router(workflow.router) router.include_router(workflow.router)
router.include_router(task_batches.router) router.include_router(task_batches.router)
-39
View File
@@ -38,29 +38,17 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
if ip.strip() if ip.strip()
) )
from ..database import get_db from ..database import get_db
from ..license_service import check_license
from ..models import AuthRateLimitORM, AuthUserORM, DinsarTaskBatchORM, DinsarTaskItemORM, PsTaskBatchORM, PsTaskItemORM from ..models import AuthRateLimitORM, AuthUserORM, DinsarTaskBatchORM, DinsarTaskItemORM, PsTaskBatchORM, PsTaskItemORM
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Path classification constants # 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_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
READ_SAFE_POST_PATHS = { READ_SAFE_POST_PATHS = {
"/api/radar-data/search", "/api/radar-data/search",
} }
PUBLIC_AUTH_PATHS = { PUBLIC_AUTH_PATHS = {
"/api/license/status",
"/api/health", "/api/health",
"/api/auth/login", "/api/auth/login",
"/api/auth/logout", "/api/auth/logout",
@@ -68,8 +56,6 @@ PUBLIC_AUTH_PATHS = {
HIGH_RISK_WRITE_PATH_PREFIXES = ( HIGH_RISK_WRITE_PATH_PREFIXES = (
"/api/auth/users", "/api/auth/users",
"/api/license/upload",
"/api/license/refresh",
"/api/workflow/runs", "/api/workflow/runs",
"/api/task-batches/", "/api/task-batches/",
"/api/tools/", "/api/tools/",
@@ -103,13 +89,6 @@ LOGIN_THROTTLE_CLEANUP_INTERVAL_SECONDS = read_int_env(
_LOGIN_THROTTLE_LOCK = asyncio.Lock() _LOGIN_THROTTLE_LOCK = asyncio.Lock()
_LOGIN_THROTTLE_LAST_CLEANUP_MONO = 0.0 _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 # 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 # 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
+1 -6
View File
@@ -17,12 +17,7 @@ router = APIRouter(prefix="/ops-maintenance", tags=["ops-maintenance"])
class CleanupRequest(BaseModel): class CleanupRequest(BaseModel):
confirm: bool = False confirm: bool = False
delete_task_records: bool = True delete_landsar_work_dir: bool = True
delete_logs: bool = True
delete_production_records: bool = True
delete_result_products: bool = True
delete_production_dirs: bool = True
delete_task_pool_dir: bool = True
@router.get("/tasks") @router.get("/tasks")
+61
View File
@@ -329,6 +329,7 @@ async def find_pairs_endpoint(
network_run_id=pairing_metadata.get("network_run_id"), network_run_id=pairing_metadata.get("network_run_id"),
candidate_count=int(pairing_metadata.get("candidate_count") or 0), candidate_count=int(pairing_metadata.get("candidate_count") or 0),
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0), selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
coverage=None,
) )
except Exception as e: except Exception as e:
if isinstance(e, HTTPException): if isinstance(e, HTTPException):
@@ -339,6 +340,66 @@ async def find_pairs_endpoint(
raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志") 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]]) @router.post("/find-ps-timeseries", response_model=Dict[str, List[RadarData]])
async def find_ps_timeseries_endpoint( async def find_ps_timeseries_endpoint(
params: PsRequest = Depends(get_ps_request_from_form), params: PsRequest = Depends(get_ps_request_from_form),
+221 -52
View File
@@ -11,6 +11,8 @@ from typing import Any, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SOURCE_METADATA_MANAGED_FORMATS = ("LT1_ARCHIVE", "S1_ZIP")
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import distinct, func, text from sqlalchemy import distinct, func, text
from sqlalchemy.ext.asyncio import AsyncSession 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.dinsar_read_service import dinsar_read_service
from ..services.pairing_state_service import pairing_state_service from ..services.pairing_state_service import pairing_state_service
from ..services.admin_region_lookup_service import _build_region_path, _load_region_records 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 . import dependencies as _deps
from .dependencies import _get_current_user from .dependencies import _get_current_user
@@ -80,6 +81,10 @@ def _ratio(numerator: int, denominator: int) -> float:
return round(float(numerator) / float(denominator), 4) 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: def _family_label(value: Any) -> str:
text = str(value or "").strip().upper() text = str(value or "").strip().upper()
if text in {"LT1", "LT-1", "LUTAN", "LUTAN1"}: if text in {"LT1", "LT-1", "LUTAN", "LUTAN1"}:
@@ -102,6 +107,14 @@ def _month_from_yyyymmdd(value: Any) -> Optional[str]:
return None 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]: def _month_from_datetime(value: Any) -> Optional[str]:
if not value: if not value:
return None return None
@@ -594,16 +607,36 @@ async def get_statistics_dashboard(
db, db,
select(func.count(SourceProductAssetORM.id)).where(SourceProductAssetORM.is_active == True), 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))) radar_total = await _scalar_count(db, select(func.count(RadarDataORM.id)))
metadata_asset_total = await _scalar_count( metadata_asset_total = await _scalar_count(
db, 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( geometry_ready = await _scalar_count(
db, db,
select(func.count(SARSceneGeometryProfileORM.id)).where( select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SARSceneGeometryProfileORM.metadata_quality == "READY", SARSceneGeometryProfileORM.metadata_quality == "READY",
SARSceneGeometryProfileORM.production_readiness == "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(): for family, source_format, parse_status, count in source_group_rows.all():
family_label = _family_label(family) family_label = _family_label(family)
status_label = _status_label(parse_status) status_label = _status_label(parse_status)
format_label = str(source_format or "UNKNOWN")
count_int = _safe_int(count) count_int = _safe_int(count)
family_bucket = source_by_family_map.setdefault( family_bucket = source_by_family_map.setdefault(
family_label, family_label,
@@ -649,7 +683,6 @@ async def get_statistics_dashboard(
family_bucket["ready_count"] += count_int family_bucket["ready_count"] += count_int
else: else:
family_bucket["issue_count"] += count_int 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 family_bucket["formats"][format_label] = family_bucket["formats"].get(format_label, 0) + count_int
source_by_format.append( source_by_format.append(
{ {
@@ -673,19 +706,22 @@ async def get_statistics_dashboard(
geometry_rows = await db.execute( geometry_rows = await db.execute(
select( select(
SARSceneGeometryProfileORM.satellite_family, SARSceneGeometryProfileORM.satellite_family,
SARSceneGeometryProfileORM.source_format,
SARSceneGeometryProfileORM.metadata_quality, SARSceneGeometryProfileORM.metadata_quality,
SARSceneGeometryProfileORM.production_readiness, SARSceneGeometryProfileORM.production_readiness,
func.count(SARSceneGeometryProfileORM.id), func.count(SARSceneGeometryProfileORM.id),
) )
.where(SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS))
.group_by( .group_by(
SARSceneGeometryProfileORM.satellite_family, SARSceneGeometryProfileORM.satellite_family,
SARSceneGeometryProfileORM.source_format,
SARSceneGeometryProfileORM.metadata_quality, SARSceneGeometryProfileORM.metadata_quality,
SARSceneGeometryProfileORM.production_readiness, SARSceneGeometryProfileORM.production_readiness,
) )
.order_by(SARSceneGeometryProfileORM.satellite_family) .order_by(SARSceneGeometryProfileORM.satellite_family)
) )
geometry_by_family_map: dict[str, dict[str, Any]] = {} 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) family_label = _family_label(family)
count_int = _safe_int(count) count_int = _safe_int(count)
bucket = geometry_by_family_map.setdefault( 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_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 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( inventory_state_rows = await db.execute(
select( select(
AssetInventoryStateORM.inventory_type, 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 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) selected_orbit_rate = _ratio(selected_orbit_bindings, orbit_required_total)
geometry_ready_rate = _ratio(geometry_ready, source_total) geometry_ready_rate = _ratio(geometry_ready, geometry_total)
metadata_ready_rate = _ratio(metadata_asset_total, source_total) metadata_ready_rate = _ratio(metadata_asset_total, metadata_source_total)
result_ready_total = sum(item["ready_count"] for item in results_by_catalog) 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 = ( risk_count = (
max(0, source_total - metadata_asset_total) metadata_missing_count
+ max(0, source_total - geometry_ready) + geometry_not_ready_count
+ max(0, orbit_required_total - selected_orbit_bindings) + orbit_missing_gap_count
+ result_assets_missing + result_assets_missing
+ open_issue_total + non_orbit_open_issue_total
) )
kpis = [ kpis = [
@@ -1140,7 +1260,7 @@ async def get_statistics_dashboard(
"label": "元数据入库率", "label": "元数据入库率",
"value": round(metadata_ready_rate * 100, 1), "value": round(metadata_ready_rate * 100, 1),
"unit": "%", "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", "tone": "success" if metadata_ready_rate >= 0.98 else "warning",
}, },
{ {
@@ -1148,7 +1268,7 @@ async def get_statistics_dashboard(
"label": "几何画像可用率", "label": "几何画像可用率",
"value": round(geometry_ready_rate * 100, 1), "value": round(geometry_ready_rate * 100, 1),
"unit": "%", "unit": "%",
"note": f"{geometry_ready}/{source_total} 景可用于覆盖统计", "note": f"{geometry_ready}/{geometry_total} 景可用于覆盖统计",
"tone": "success" if geometry_ready_rate >= 0.95 else "warning", "tone": "success" if geometry_ready_rate >= 0.95 else "warning",
}, },
{ {
@@ -1172,7 +1292,7 @@ async def get_statistics_dashboard(
"label": "待关注项", "label": "待关注项",
"value": risk_count, "value": risk_count,
"unit": "项", "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", "tone": "danger" if risk_count else "success",
}, },
] ]
@@ -1186,20 +1306,23 @@ async def get_statistics_dashboard(
"source_by_family": source_by_family, "source_by_family": source_by_family,
"source_by_format": source_by_format, "source_by_format": source_by_format,
"source_by_month": source_by_month, "source_by_month": source_by_month,
"metadata_source_total": metadata_source_total,
"metadata_asset_total": metadata_asset_total, "metadata_asset_total": metadata_asset_total,
"metadata_doc_total": metadata_doc_total, "metadata_doc_total": metadata_doc_total,
"metadata_missing_count": metadata_missing_count,
"metadata_ready_rate": metadata_ready_rate, "metadata_ready_rate": metadata_ready_rate,
"geometry_total": geometry_total, "geometry_total": geometry_total,
"geometry_ready": geometry_ready, "geometry_ready": geometry_ready,
"geometry_not_ready_count": geometry_not_ready_count,
"geometry_ready_rate": geometry_ready_rate, "geometry_ready_rate": geometry_ready_rate,
"geometry_by_family": geometry_by_family, "geometry_by_family": geometry_by_family,
"preview_ready": preview_ready, "preview_ready": preview_ready,
"pipeline": [ "pipeline": [
{"key": "source", "label": "源资产登记", "value": source_total, "rate": 1.0}, {"key": "source", "label": "源资产登记", "value": source_total, "denominator": source_total, "rate": 1.0},
{"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "rate": metadata_ready_rate}, {"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "denominator": metadata_source_total, "rate": metadata_ready_rate},
{"key": "geometry", "label": "几何画像", "value": geometry_total, "rate": _ratio(geometry_total, source_total)}, {"key": "geometry", "label": "几何画像", "value": geometry_total, "denominator": metadata_source_total, "rate": _ratio(geometry_total, metadata_source_total)},
{"key": "ready", "label": "可生产画像", "value": geometry_ready, "rate": geometry_ready_rate}, {"key": "ready", "label": "可生产画像", "value": geometry_ready, "denominator": geometry_total, "rate": geometry_ready_rate},
{"key": "preview", "label": "预览缓存", "value": preview_ready, "rate": _ratio(preview_ready, radar_total)}, {"key": "preview", "label": "预览缓存", "value": preview_ready, "denominator": radar_total, "rate": _ratio(preview_ready, radar_total)},
], ],
}, },
"orbit": { "orbit": {
@@ -1208,6 +1331,8 @@ async def get_statistics_dashboard(
"orbit_required_total": orbit_required_total, "orbit_required_total": orbit_required_total,
"selected_bindings": selected_orbit_bindings, "selected_bindings": selected_orbit_bindings,
"matched_bindings": matched_orbit_bindings, "matched_bindings": matched_orbit_bindings,
"missing_scene_count": orbit_missing_gap_count,
"missing_summary": orbit_missing_summary,
"selected_rate": selected_orbit_rate, "selected_rate": selected_orbit_rate,
}, },
"coverage": { "coverage": {
@@ -1242,6 +1367,9 @@ async def get_statistics_dashboard(
"issues": { "issues": {
"issue_total": issue_total, "issue_total": issue_total,
"open_issue_total": open_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": [ "by_severity": [
{"severity": severity, "count": count} {"severity": severity, "count": count}
for severity, count in sorted(issue_by_severity.items(), key=lambda kv: (-kv[1], kv[0])) 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_and_cache_exists_count": 0,
"db_ready_but_cache_missing_count": 0, "db_ready_but_cache_missing_count": 0,
} }
source_xml_consistency = { source_metadata_consistency = {
"total_records_count": 0, "total_records_count": 0,
"xml_detected_count": 0, "metadata_document_asset_count": 0,
"xml_missing_count": 0, "metadata_document_count": 0,
"xml_parsed_ok_count": 0, "metadata_document_missing_count": 0,
"xml_detected_but_unparsed_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: try:
@@ -1399,7 +1531,6 @@ async def get_statistics(
) )
source_rows = source_rows_res.all() source_rows = source_rows_res.all()
source_preview_consistency["total_records_count"] = len(source_rows) source_preview_consistency["total_records_count"] = len(source_rows)
source_xml_consistency["total_records_count"] = len(source_rows)
for ( for (
unique_id, unique_id,
@@ -1415,7 +1546,6 @@ async def get_statistics(
) in source_rows: ) in source_rows:
if not file_path: if not file_path:
source_preview_consistency["preview_missing_count"] += 1 source_preview_consistency["preview_missing_count"] += 1
source_xml_consistency["xml_missing_count"] += 1
continue continue
cache_key = unique_id or file_path cache_key = unique_id or file_path
@@ -1443,34 +1573,64 @@ async def get_statistics(
else: else:
source_preview_consistency["db_ready_but_cache_missing_count"] += 1 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) active_source_count = await _scalar_count(
xml_path = find_xml_file(scene_dir) if scene_dir else None db,
has_xml = bool(xml_path and os.path.exists(xml_path)) select(func.count(SourceProductAssetORM.id)).where(
if has_xml: SourceProductAssetORM.is_active == True,
source_xml_consistency["xml_detected_count"] += 1 SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
parsed_ok = any( ),
value is not None and value != "" )
for value in [ metadata_asset_count = await _scalar_count(
scene_center_lon, db,
scene_center_lat, select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where(
acquisition_time_utc, SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
satellite_mode, ),
receiving_station, )
product_level, metadata_doc_count = await _scalar_count(
product_unique_id, db,
] select(func.count(SourceMetadataDocumentORM.id)).where(
) SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
if parsed_ok: ),
source_xml_consistency["xml_parsed_ok_count"] += 1 )
else: metadata_parse_issue_count = await _scalar_count(
source_xml_consistency["xml_detected_but_unparsed_count"] += 1 db,
else: select(func.count(SourceMetadataDocumentORM.id)).where(
source_xml_consistency["xml_missing_count"] += 1 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: except Exception as e:
logger.warning("统计源数据时发生错误 (可能是表不存在): %s", e) logger.warning("统计源数据时发生错误 (可能是表不存在): %s", e)
source_preview_consistency["error"] = str(e) source_preview_consistency["error"] = str(e)
source_xml_consistency["error"] = str(e) source_metadata_consistency["error"] = str(e)
# 4. AI 质量统计 # 4. AI 质量统计
labeled_good_count = sum(1 for record in dinsar_records if record.product.user_label == 1) 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, "with_orbit_data_count": with_orbit_data_count,
}, },
"source_preview_consistency": source_preview_consistency, "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, "water_geo_consistency": water_geo_consistency,
"pairing_consistency": pairing_consistency, "pairing_consistency": pairing_consistency,
"by_satellite": by_satellite, "by_satellite": by_satellite,
@@ -80,6 +80,16 @@ TERMINAL_ITEM_STATUSES = {
_SAFE_POINTER_RE = re.compile(r"[^0-9A-Za-z._-]+") _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: def _task_type_for_engine(engine_code: str) -> str:
normalized = str(engine_code or "").strip().lower() normalized = str(engine_code or "").strip().lower()
if normalized == "sarscape": if normalized == "sarscape":
@@ -997,53 +1007,59 @@ class DinsarProductionService:
) )
for item in items_result.scalars().all(): for item in items_result.scalars().all():
items_by_run_id.setdefault(item.run_id, []).append(item) 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 { return {
"runs": [ "runs": [serialize_run(run) for run in 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
],
"total": total, "total": total,
} }
+82 -240
View File
@@ -4,9 +4,9 @@ import os
import shutil import shutil
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, List, Optional
from sqlalchemy import delete, func, or_, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings from ..config import settings
@@ -14,25 +14,18 @@ from ..models import (
DinsarProductionExecutionORM, DinsarProductionExecutionORM,
DinsarProductionRunItemORM, DinsarProductionRunItemORM,
DinsarProductionRunORM, DinsarProductionRunORM,
DinsarResultORM,
ResultProductORM,
SystemJobORM, SystemJobORM,
SystemTaskORM, SystemTaskORM,
TaskLogORM, TaskLogORM,
WorkflowArtifactORM,
WorkflowRunORM,
WorkflowStepORM,
) )
from .dinsar_production_service import dinsar_production_service
MAINTENANCE_LIST_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
TERMINAL_TASK_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
MAINTENANCE_LIST_STATUSES = {"FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"} ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"}
ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"} ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"}
DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"} DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"}
SUPPORTED_CLEANUP_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA"} SUPPORTED_CLEANUP_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN"}
DEFAULT_TASK_TYPES = SUPPORTED_CLEANUP_TASK_TYPES | {"PAIRING_CACHE_REBUILD"} DEFAULT_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA", "PAIRING_CACHE_REBUILD"}
LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES = {"COMPLETED", "FAILED", "CANCELLED"}
def _utcnow_naive() -> datetime: def _utcnow_naive() -> datetime:
@@ -68,8 +61,15 @@ def _path_exists(path: str) -> bool:
return bool(path) and os.path.exists(path) return bool(path) and os.path.exists(path)
def _safe_count(rows: Iterable[Any]) -> int: def _is_within_path(path: str, parent: str) -> bool:
return len(list(rows)) 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: class OpsMaintenanceService:
@@ -108,11 +108,12 @@ class OpsMaintenanceService:
if item.get("issue_level") in {"warning", "danger"} if item.get("issue_level") in {"warning", "danger"}
or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"} or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
] ]
visible_items = items if status_filter else abnormal_items
return { return {
"items": abnormal_items, "items": visible_items,
"limit": safe_limit, "limit": safe_limit,
"offset": safe_offset, "offset": safe_offset,
"returned": len(abnormal_items), "returned": len(visible_items),
} }
async def diagnose_task(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]: async def diagnose_task(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
@@ -124,19 +125,11 @@ class OpsMaintenanceService:
item_counts: Dict[str, int] = {} item_counts: Dict[str, int] = {}
execution_counts: Dict[str, int] = {} execution_counts: Dict[str, int] = {}
disk_paths: List[Dict[str, Any]] = [] disk_paths: List[Dict[str, Any]] = []
products: List[Dict[str, Any]] = []
if run is not None: if run is not None:
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id) item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, 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)) disk_paths.extend(await self._collect_run_disk_paths(db, run))
products = await self._collect_result_products(db, run)
related_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
copy_dest = self._copy_task_dest_dir(task)
if copy_dest:
disk_paths.append(self._path_payload(copy_dest, "task_pool"))
recent_logs = await self._recent_logs(db, task.task_id) recent_logs = await self._recent_logs(db, task.task_id)
findings, cleanup_supported, cleanup_blockers = self._diagnose_findings( findings, cleanup_supported, cleanup_blockers = self._diagnose_findings(
task=task, task=task,
@@ -152,8 +145,6 @@ class OpsMaintenanceService:
"production_run": self._run_payload(run) if run else None, "production_run": self._run_payload(run) if run else None,
"production_item_counts": item_counts, "production_item_counts": item_counts,
"production_execution_counts": execution_counts, "production_execution_counts": execution_counts,
"result_products": products,
"related_tasks": [self._task_payload(item) for item in related_tasks],
"recent_logs": recent_logs, "recent_logs": recent_logs,
"disk_paths": disk_paths, "disk_paths": disk_paths,
"diagnosis": { "diagnosis": {
@@ -179,15 +170,17 @@ class OpsMaintenanceService:
if task_status in ACTIVE_TASK_STATUSES: if task_status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态,不能清理。") blockers.append("任务仍处于活动状态,不能清理。")
db_counts = await self._cleanup_db_counts(db, diagnosis)
disk_deletes = self._cleanup_disk_targets(diagnosis) 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 blocked = bool(blockers) or not cleanup_supported
return { return {
"task_id": task_id, "task_id": task_id,
"blocked": blocked, "blocked": blocked,
"blockers": blockers, "blockers": blockers,
"cleanup_supported": cleanup_supported and not blocked, "cleanup_supported": cleanup_supported and not blocked,
"database_deletes": db_counts, "database_deletes": {},
"disk_deletes": disk_deletes, "disk_deletes": disk_deletes,
} }
@@ -204,82 +197,17 @@ class OpsMaintenanceService:
if preview.get("blocked"): if preview.get("blocked"):
raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"])) raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"]))
deleted_db: Dict[str, int] = {}
task = await self._get_task(db, task_id) task = await self._get_task(db, task_id)
if task is None: if task is None:
return None return None
jobs = await self._get_jobs(db, task_id)
run = await self._get_production_run_for_task(db, task, jobs)
delete_logs = bool(options.get("delete_logs", True))
delete_task_records = bool(options.get("delete_task_records", True))
delete_production_records = bool(options.get("delete_production_records", True))
delete_result_products = bool(options.get("delete_result_products", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
related_copy_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
if delete_result_products and run is not None:
products = await self._result_product_orms_for_run(db, run)
product_ids = [item.product_id for item in products]
compat_ids = await self._compat_ids_for_products(db, product_ids)
if product_ids:
result = await db.execute(delete(ResultProductORM).where(ResultProductORM.product_id.in_(product_ids)))
deleted_db["result_products"] = int(result.rowcount or 0)
if compat_ids:
result = await db.execute(delete(DinsarResultORM).where(DinsarResultORM.id.in_(compat_ids)))
deleted_db["dinsar_results"] = int(result.rowcount or 0)
if delete_production_records and run is not None:
deleted_run = await dinsar_production_service.delete_run_record(run.run_id, db=db)
deleted_db["dinsar_production_runs"] = 1 if deleted_run else 0
deleted_db["dinsar_production_run_items"] = int(preview["database_deletes"].get("dinsar_production_run_items", 0))
deleted_db["dinsar_production_executions"] = int(preview["database_deletes"].get("dinsar_production_executions", 0))
deleted_db["system_jobs"] = int(preview["database_deletes"].get("system_jobs", 0))
deleted_db["system_tasks"] = 1
deleted_db["task_logs"] = int(preview["database_deletes"].get("task_logs", 0))
if delete_task_pool_dir:
related_deleted = await self._delete_related_tasks(db, related_copy_tasks)
for key, value in related_deleted.items():
deleted_db[key] = int(deleted_db.get(key, 0)) + int(value or 0)
else:
if delete_logs:
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id))
deleted_db["task_logs"] = int(result.rowcount or 0)
if delete_task_records:
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id == task_id))
deleted_db["system_jobs"] = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id == task_id))
deleted_db["system_tasks"] = int(result.rowcount or 0)
await db.commit()
disk_result = await self._delete_disk_targets(preview, options) disk_result = await self._delete_disk_targets(preview, options)
return { return {
"task_id": task_id, "task_id": task_id,
"deleted_database": deleted_db, "deleted_database": {},
"deleted_disk": disk_result, "deleted_disk": disk_result,
} }
async def _delete_related_tasks(
self,
db: AsyncSession,
tasks: List[SystemTaskORM],
) -> Dict[str, int]:
task_ids = [task.task_id for task in tasks if task.task_id]
if not task_ids:
return {}
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id.in_(task_ids)))
logs = int(result.rowcount or 0)
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id.in_(task_ids)))
jobs = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id.in_(task_ids)))
task_count = int(result.rowcount or 0)
await db.commit()
return {
"related_task_logs": logs,
"related_system_jobs": jobs,
"related_system_tasks": task_count,
}
async def _get_task(self, db: AsyncSession, task_id: str) -> Optional[SystemTaskORM]: 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())) result = await db.execute(select(SystemTaskORM).where(SystemTaskORM.task_id == str(task_id or "").strip()))
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -383,87 +311,33 @@ class OpsMaintenanceService:
async def _collect_run_disk_paths(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]: async def _collect_run_disk_paths(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
paths: Dict[str, Dict[str, Any]] = {} paths: Dict[str, Dict[str, Any]] = {}
if run.source_root:
paths[_normalize_path_text(run.source_root)] = self._path_payload(run.source_root, "task_pool")
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id)) result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
for execution in result.scalars().all(): for execution in result.scalars().all():
if execution.output_dir: landsar_work_dir = self._landsar_work_dir_for_execution(run, execution)
publish_dir = self._publish_package_dir(execution.output_dir) if landsar_work_dir:
paths[publish_dir] = self._path_payload(publish_dir, "production_result") payload = self._path_payload(landsar_work_dir, "landsar_work")
log_path = dinsar_production_service.read_run_log(run.run_id, max_bytes=1).get("path") payload["run_key"] = execution.run_key
if log_path: payload["execution_status"] = execution.status
paths[_normalize_path_text(log_path)] = self._path_payload(log_path, "run_log") paths[_normalize_path_text(landsar_work_dir).lower()] = payload
return list(paths.values()) return list(paths.values())
async def _collect_result_products(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]: def _landsar_work_dir_for_execution(
products = await self._result_product_orms_for_run(db, run)
return [
{
"product_id": item.product_id,
"display_name": item.display_name,
"status": item.status,
"health_status": item.health_status,
"publish_dir": item.publish_dir,
"manifest_path": item.manifest_path,
}
for item in products
]
async def _result_product_orms_for_run(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[ResultProductORM]:
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
dirs = [self._publish_package_dir(item.output_dir) for item in result.scalars().all() if item.output_dir]
clauses = []
for path in dirs:
clauses.append(ResultProductORM.publish_dir == path)
clauses.append(ResultProductORM.native_output_dir.like(path + "%"))
clauses.append(ResultProductORM.manifest_path.like(path + "%"))
clauses.append(ResultProductORM.primary_asset_path.like(path + "%"))
if not clauses:
return []
products = await db.execute(select(ResultProductORM).where(or_(*clauses)))
by_id: Dict[str, ResultProductORM] = {}
for product in products.scalars().all():
by_id[product.product_id] = product
return list(by_id.values())
async def _compat_ids_for_products(self, db: AsyncSession, product_ids: List[str]) -> List[int]:
if not product_ids:
return []
result = await db.execute(select(DinsarResultORM).where(DinsarResultORM.compat_product_id.in_(product_ids)))
return [int(item.id) for item in result.scalars().all()]
def _publish_package_dir(self, output_dir: str) -> str:
normalized = _normalize_path_text(output_dir)
marker = os.sep + "runs" + os.sep
if marker.lower() in normalized.lower():
lower = normalized.lower()
index = lower.index(marker.lower())
return normalized[:index]
return normalized
def _copy_task_dest_dir(self, task: SystemTaskORM) -> str:
params = task.params if isinstance(task.params, dict) else {}
return _normalize_path_text(params.get("dest_dir")) if params.get("dest_dir") else ""
async def _related_copy_tasks_for_run(
self, self,
db: AsyncSession, run: DinsarProductionRunORM,
run: Optional[DinsarProductionRunORM], execution: DinsarProductionExecutionORM,
) -> List[SystemTaskORM]: ) -> str:
source_root = _normalize_path_text(run.source_root if run is not None else "") if _norm_status(run.engine_code) != "LANDSAR":
if not source_root: return ""
return [] if _norm_status(run.status) in ACTIVE_TASK_STATUSES:
result = await db.execute( return ""
select(SystemTaskORM) if _norm_status(execution.status) not in LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES:
.where(SystemTaskORM.task_type == "COPY_DATA") return ""
.order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc()) work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT)
.limit(1000) 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_"):
tasks = [] return ""
for task in result.scalars().all(): candidate = _normalize_path_text(os.path.join(work_root, run_key))
if _normalize_path_text(self._copy_task_dest_dir(task)).lower() == source_root.lower(): return candidate if self._is_landsar_work_delete_path(candidate) else ""
tasks.append(task)
return tasks
def _diagnose_findings( def _diagnose_findings(
self, self,
@@ -481,9 +355,9 @@ class OpsMaintenanceService:
if status == "FAILED": if status == "FAILED":
findings.append("任务已失败,需要人工确认后清理。") findings.append("任务已失败,需要人工确认后清理。")
elif status == "PARTIAL_SUCCESS": elif status == "PARTIAL_SUCCESS":
findings.append("任务部分成功,清理前请确认保留策略。") findings.append("任务部分成功,可按需清理 LandSAR 工作目录。")
elif status == "CANCELLED": elif status == "CANCELLED":
findings.append("任务已取消,可按需清理残留记录和目录。") findings.append("任务已取消,可按需清理 LandSAR 工作目录。")
elif status in ACTIVE_TASK_STATUSES: elif status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态。") blockers.append("任务仍处于活动状态。")
@@ -507,58 +381,29 @@ class OpsMaintenanceService:
missing_paths = [item for item in disk_paths if item.get("path") and not item.get("exists")] missing_paths = [item for item in disk_paths if item.get("path") and not item.get("exists")]
if missing_paths: if missing_paths:
findings.append(f"有 {len(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 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 return findings, cleanup_supported, blockers
async def _cleanup_db_counts(self, db: AsyncSession, diagnosis: Dict[str, Any]) -> Dict[str, int]:
task_id = diagnosis["task"]["task_id"]
run = diagnosis.get("production_run") or {}
run_id = run.get("run_id")
workflow_run_id = run.get("workflow_run_id")
counts = {
"system_tasks": await self._count(db, select(func.count()).select_from(SystemTaskORM).where(SystemTaskORM.task_id == task_id)),
"system_jobs": await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id == task_id)),
"task_logs": await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id == task_id)),
"related_system_tasks": 0,
"related_system_jobs": 0,
"related_task_logs": 0,
"dinsar_production_runs": 0,
"dinsar_production_run_items": 0,
"dinsar_production_executions": 0,
"result_products": len(diagnosis.get("result_products") or []),
"dinsar_results": 0,
"workflow_runs": 0,
"workflow_steps": 0,
"workflow_artifacts": 0,
}
if run_id:
counts["dinsar_production_runs"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
counts["dinsar_production_run_items"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunItemORM).where(DinsarProductionRunItemORM.run_id == run_id))
counts["dinsar_production_executions"] = await self._count(db, select(func.count()).select_from(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run_id))
run_obj = await self._get_run_by_id(db, run_id)
related_tasks = await self._related_copy_tasks_for_run(db, run_obj)
related_task_ids = [item.task_id for item in related_tasks if item.task_id]
counts["related_system_tasks"] = len(related_task_ids)
if related_task_ids:
counts["related_system_jobs"] = await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id.in_(related_task_ids)))
counts["related_task_logs"] = await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id.in_(related_task_ids)))
if workflow_run_id:
counts["workflow_runs"] = await self._count(db, select(func.count()).select_from(WorkflowRunORM).where(WorkflowRunORM.run_id == workflow_run_id))
counts["workflow_steps"] = await self._count(db, select(func.count()).select_from(WorkflowStepORM).where(WorkflowStepORM.run_id == workflow_run_id))
counts["workflow_artifacts"] = await self._count(db, select(func.count()).select_from(WorkflowArtifactORM).where(WorkflowArtifactORM.run_id == workflow_run_id))
return counts
async def _get_run_by_id(self, db: AsyncSession, run_id: str) -> Optional[DinsarProductionRunORM]:
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
return result.scalar_one_or_none()
async def _count(self, db: AsyncSession, stmt: Any) -> int: async def _count(self, db: AsyncSession, stmt: Any) -> int:
return int((await db.execute(stmt)).scalar_one() or 0) return int((await db.execute(stmt)).scalar_one() or 0)
def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]: def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]:
targets: Dict[str, Dict[str, Any]] = {} targets: Dict[str, Dict[str, Any]] = {}
for item in diagnosis.get("disk_paths") or []: for item in diagnosis.get("disk_paths") or []:
if item.get("kind") != "landsar_work":
continue
path = _normalize_path_text(item.get("path")) path = _normalize_path_text(item.get("path"))
if not path: if not path:
continue continue
@@ -581,29 +426,26 @@ class OpsMaintenanceService:
normalized = _normalize_path_text(path) normalized = _normalize_path_text(path)
if not normalized: if not normalized:
return False return False
roots = [ return self._is_landsar_work_delete_path(normalized)
settings.DINSAR_TASK_POOL_ROOT,
settings.DINSAR_PRODUCT_DIR, def _is_landsar_work_delete_path(self, path: str) -> bool:
os.path.join(settings.PROJECT_ROOT, "backend", "runtime", "dinsar_production"), 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) full = os.path.abspath(normalized)
for root in roots: if full == root_full or not _is_within_path(full, root_full):
root_text = _normalize_path_text(root) return False
if not root_text: try:
continue relative = os.path.relpath(full, root_full)
root_full = os.path.abspath(root_text) except ValueError:
if full == root_full: return False
return False parts = [part for part in relative.split(os.sep) if part]
try: return len(parts) == 1 and parts[0].startswith("run_")
if os.path.commonpath([full, root_full]) == root_full:
return True
except ValueError:
continue
return False
async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]: async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]:
delete_production_dirs = bool(options.get("delete_production_dirs", True)) delete_landsar_work_dir = bool(options.get("delete_landsar_work_dir", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
deleted: List[str] = [] deleted: List[str] = []
missing: List[str] = [] missing: List[str] = []
skipped: List[str] = [] skipped: List[str] = []
@@ -611,13 +453,13 @@ class OpsMaintenanceService:
for item in preview.get("disk_deletes") or []: for item in preview.get("disk_deletes") or []:
kind = item.get("kind") kind = item.get("kind")
path = _normalize_path_text(item.get("path")) path = _normalize_path_text(item.get("path"))
if kind != "landsar_work":
skipped.append(path)
continue
if not item.get("allowed"): if not item.get("allowed"):
skipped.append(path) skipped.append(path)
continue continue
if kind == "task_pool" and not delete_task_pool_dir: if not delete_landsar_work_dir:
skipped.append(path)
continue
if kind == "production_result" and not delete_production_dirs:
skipped.append(path) skipped.append(path)
continue continue
if not _path_exists(path): if not _path_exists(path):
+436 -5
View File
@@ -7,8 +7,10 @@ import hashlib
import json import json
import logging import logging
import math import math
import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta
from itertools import combinations from itertools import combinations
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -20,7 +22,7 @@ from sqlalchemy.orm import aliased
from geoalchemy2 import Geography from geoalchemy2 import Geography
from geoalchemy2.shape import to_shape from geoalchemy2.shape import to_shape
from geoalchemy2.functions import ST_Intersects, ST_Intersection, ST_Area, ST_Centroid, ST_Covers 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 shapely.ops import unary_union
from ..models import ( from ..models import (
@@ -196,6 +198,112 @@ class SpatialService:
} }
return result_pairs, warnings, metadata 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: def _normalize_pairing_request(self, params: PairingRequest) -> PairingRequest:
updates: Dict[str, Any] = {} updates: Dict[str, Any] = {}
@@ -408,6 +516,27 @@ class SpatialService:
selection_strategy=candidate.get("selection_strategy"), selection_strategy=candidate.get("selection_strategy"),
selection_score=float(selection_score) if selection_score is not None else None, selection_score=float(selection_score) if selection_score is not None else None,
selection_reason=candidate.get("selection_reason"), 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"]), time_baseline_days=int(candidate["days"]),
spatial_baseline_meters=float(candidate["dist"]), spatial_baseline_meters=float(candidate["dist"]),
scene_center_distance_meters=float( scene_center_distance_meters=float(
@@ -701,6 +830,253 @@ class SpatialService:
output.append(item) output.append(item)
return output 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( async def _persist_network_run(
self, self,
db: AsyncSession, db: AsyncSession,
@@ -797,6 +1173,13 @@ class SpatialService:
if candidate.get("pair_aoi_overlap_ratio") is not None if candidate.get("pair_aoi_overlap_ratio") is not None
else 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: def _stable_sha1(self, value: Any) -> str:
@@ -949,7 +1332,12 @@ class SpatialService:
warnings.extend(strategy_warnings) warnings.extend(strategy_warnings)
edges: List[Dict[str, Any]] = [] 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"] master = candidate["master"]
slave = candidate["slave"] slave = candidate["slave"]
edges.append( edges.append(
@@ -1820,9 +2208,14 @@ class SpatialService:
return geometry_cache[cache_key] return geometry_cache[cache_key]
try: try:
master_poly = Polygon(candidate["master"].coverage_polygon) master_poly = self._coverage_polygon_to_shape(getattr(candidate["master"], "coverage_polygon", None))
slave_poly = Polygon(candidate["slave"].coverage_polygon) slave_poly = self._coverage_polygon_to_shape(getattr(candidate["slave"], "coverage_polygon", None))
if master_poly.is_empty or slave_poly.is_empty: 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 geometry_cache[cache_key] = None
return None return None
pair_geom = master_poly.intersection(slave_poly) pair_geom = master_poly.intersection(slave_poly)
@@ -1834,6 +2227,44 @@ class SpatialService:
geometry_cache[cache_key] = None geometry_cache[cache_key] = None
return 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( def _score_sbas_candidate(
self, self,
candidate: dict, candidate: dict,
@@ -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 页眉与生产面板补充 ## 2026-06-21 页眉与生产面板补充
- 页眉移除 DB、Worker、IDL、Ollama、Nginx 等运行状态灯,系统健康状态集中放在“运行维护”模块。 - 页眉移除 DB、Worker、IDL、Ollama、Nginx 等运行状态灯,系统健康状态集中放在“运行维护”模块。
- 页眉改为单位 logo、单位名称、系统名称、授权摘要、任务摘要和用户操作。 - 页眉改为单位 logo、单位名称、系统名称、任务摘要和用户操作。
- 单位名、系统名、页眉标语和 logo URL 支持通过 `VITE_APP_*` 环境变量配置,默认使用 `frontend/src/logo.jpg`。 - 单位名、系统名、页眉标语和 logo URL 支持通过 `VITE_APP_*` 环境变量配置,默认使用 `frontend/src/logo.jpg`。
- 新增 `PRODUCT.md`,记录系统面向科研工程单位的产品定位:科研、专业、克制,同时要求严谨、稳定、工程化。 - 新增 `PRODUCT.md`,记录系统面向科研工程单位的产品定位:科研、专业、克制,同时要求严谨、稳定、工程化。
- SBAS 生产面板中高曝光的 Runtime Status、Task queue、Workflow 说明文案改为中文表达,保留必要英文术语。 - 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 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_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 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) - [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銆佺粨鏋滆仛鍚堝拰涓棿鏂囦欢娓呯悊鐨勫綋鍓嶈璁°€? D-InSAR 淇濈暀 ENVI/SARscape銆丩andSAR銆丟amma/PyINT 涓夊紩鎿庯紝閫€鍑?ISCE2锛岀粺涓€ Task_Pool銆佺粨鏋滆仛鍚堝拰涓棿鏂囦欢娓呯悊鐨勫綋鍓嶈璁°€?
- [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md) - [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md)
@@ -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`
+884 -42
View File
File diff suppressed because it is too large Load Diff
+7 -37
View File
@@ -184,22 +184,12 @@ function App() {
// --- Zustand stores --- // --- Zustand stores ---
const { const {
currentUser, setCurrentUser, authChecked, setAuthChecked, currentUser, setCurrentUser, authChecked, setAuthChecked,
licenseStatus, setLicenseStatus, licenseLoading, setLicenseLoading,
licenseUploadStatus, setLicenseUploadStatus, licenseFileName, setLicenseFileName,
healthStatus, setHealthStatus, healthLoading, setHealthLoading, healthError, setHealthError, healthStatus, setHealthStatus, healthLoading, setHealthLoading, healthError, setHealthError,
} = useAuthStore(useShallow((state) => ({ } = useAuthStore(useShallow((state) => ({
currentUser: state.currentUser, currentUser: state.currentUser,
setCurrentUser: state.setCurrentUser, setCurrentUser: state.setCurrentUser,
authChecked: state.authChecked, authChecked: state.authChecked,
setAuthChecked: state.setAuthChecked, 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, healthStatus: state.healthStatus,
setHealthStatus: state.setHealthStatus, setHealthStatus: state.setHealthStatus,
healthLoading: state.healthLoading, healthLoading: state.healthLoading,
@@ -405,13 +395,12 @@ function App() {
const floodPairPreviewLayerRef = useRef(null); const floodPairPreviewLayerRef = useRef(null);
const floodVectorLayersRef = useRef({}); const floodVectorLayersRef = useRef({});
const mapRegionLayerRef = useRef(null); const mapRegionLayerRef = useRef(null);
const prevLicenseOkRef = useRef(false); const prevAppReadyRef = useRef(false);
const initializeAppDataRef = useRef(null); const initializeAppDataRef = useRef(null);
const addLogRef = useRef(addLog); const addLogRef = useRef(addLog);
const handleTaskCompletionRef = useRef(null); const handleTaskCompletionRef = useRef(null);
const updateLayerTooltipRef = useRef(null); const updateLayerTooltipRef = useRef(null);
const radarSearchRequestSeqRef = useRef(0); const radarSearchRequestSeqRef = useRef(0);
const licenseFileRef = useRef(null);
const foundPairsRef = useRef(foundPairs); const foundPairsRef = useRef(foundPairs);
const hazardLayersRef = useRef({}); const hazardLayersRef = useRef({});
const dinsarResultLayersRef = useRef({}); const dinsarResultLayersRef = useRef({});
@@ -712,14 +701,10 @@ function App() {
const { const {
handleLoginSuccess, handleLoginSuccess,
handleLogout, handleLogout,
fetchLicenseStatus,
fetchHealthStatus, fetchHealthStatus,
handleLicenseUpload,
} = useAppAuthLifecycle({ } = useAppAuthLifecycle({
ensureCanOperate,
clearRadarSearchResults, clearRadarSearchResults,
radarSearchRequestSeqRef, radarSearchRequestSeqRef,
prevLicenseOkRef,
aoeLayerRef, aoeLayerRef,
activeLayersRef, activeLayersRef,
radarPreviewLayersRef, radarPreviewLayersRef,
@@ -727,13 +712,9 @@ function App() {
setCurrentUser, setCurrentUser,
setAuthChecked, setAuthChecked,
setPendingTaskIds, setPendingTaskIds,
setLicenseLoading,
setLicenseStatus,
setHealthLoading, setHealthLoading,
setHealthError, setHealthError,
setHealthStatus, setHealthStatus,
setLicenseFileName,
setLicenseUploadStatus,
setAoiLayer, setAoiLayer,
setAllData, setAllData,
setRadarPagination, setRadarPagination,
@@ -838,14 +819,14 @@ function App() {
}, [baseLayerKey, currentUser?.id]); }, [baseLayerKey, currentUser?.id]);
useEffect(() => { useEffect(() => {
if (licenseLoading || !authChecked) return; if (!authChecked) return;
const isOk = !!licenseStatus?.ok && !!currentUser; const isReady = !!currentUser;
const prevOk = prevLicenseOkRef.current; const wasReady = prevAppReadyRef.current;
if (isOk && !prevOk) { if (isReady && !wasReady) {
initializeAppDataRef.current?.(); initializeAppDataRef.current?.();
} }
prevLicenseOkRef.current = isOk; prevAppReadyRef.current = isReady;
}, [licenseLoading, licenseStatus?.ok, authChecked, currentUser]); }, [authChecked, currentUser]);
const { const {
fetchDinsarResults, fetchDinsarResults,
@@ -917,7 +898,6 @@ function App() {
handleCancelActiveTasks, handleCancelActiveTasks,
} = useGlobalTaskControl({ } = useGlobalTaskControl({
currentUser, currentUser,
licenseOk: !!licenseStatus?.ok,
activeTasks, activeTasks,
setActiveTasks, setActiveTasks,
setRuntimeSummary, setRuntimeSummary,
@@ -2002,7 +1982,6 @@ function App() {
const selectedPairsCount = foundPairs.filter(p => p.isSelected).length; const selectedPairsCount = foundPairs.filter(p => p.isSelected).length;
const hasEnoughRadarScenesForPlanning = radarImagingDates.length >= 2; const hasEnoughRadarScenesForPlanning = radarImagingDates.length >= 2;
const licenseOk = !!licenseStatus?.ok;
const avgTaskProgress = activeTasks.length const avgTaskProgress = activeTasks.length
? Math.round(activeTasks.reduce((sum, t) => sum + (t.progress || 0), 0) / activeTasks.length) ? Math.round(activeTasks.reduce((sum, t) => sum + (t.progress || 0), 0) / activeTasks.length)
: 0; : 0;
@@ -2118,7 +2097,6 @@ function App() {
activeTasks={activeTasks} activeTasks={activeTasks}
avgTaskProgress={avgTaskProgress} avgTaskProgress={avgTaskProgress}
runtimeSummary={runtimeSummary} runtimeSummary={runtimeSummary}
licenseStatus={licenseStatus}
healthStatus={healthStatus} healthStatus={healthStatus}
healthLoading={healthLoading} healthLoading={healthLoading}
healthError={healthError} healthError={healthError}
@@ -2136,7 +2114,6 @@ function App() {
currentUser={currentUser} currentUser={currentUser}
language={language} language={language}
apiEndpoint={apiClient.defaults.baseURL} apiEndpoint={apiClient.defaults.baseURL}
licenseOk={licenseOk}
foundPairs={foundPairs} foundPairs={foundPairs}
dinsarTotal={dinsarPagination.total} dinsarTotal={dinsarPagination.total}
selectedPairsCount={selectedPairsCount} selectedPairsCount={selectedPairsCount}
@@ -2192,14 +2169,7 @@ function App() {
onPairingAoiModeChange={handlePairingAoiModeChange} onPairingAoiModeChange={handlePairingAoiModeChange}
onPairingProvinceChange={handlePairingProvinceChange} onPairingProvinceChange={handlePairingProvinceChange}
onPairingCityChange={handlePairingCityChange} onPairingCityChange={handlePairingCityChange}
licenseLoading={licenseLoading}
licenseStatus={licenseStatus}
isAdmin={isAdmin} isAdmin={isAdmin}
licenseFileRef={licenseFileRef}
onUploadFile={handleLicenseUpload}
onRefreshLicenseStatus={fetchLicenseStatus}
licenseFileName={licenseFileName}
licenseUploadStatus={licenseUploadStatus}
activeTasks={activeTasks} activeTasks={activeTasks}
runtimeSummary={runtimeSummary} runtimeSummary={runtimeSummary}
showCancelTask={showCancelTask} showCancelTask={showCancelTask}
-3
View File
@@ -12,9 +12,6 @@ const ACTION_LABELS = {
logout: '退出登录', logout: '退出登录',
user_created: '创建用户', user_created: '创建用户',
user_updated: '更新用户', user_updated: '更新用户',
license_uploaded: '上传授权文件',
license_upload_failed: '上传授权失败',
license_refreshed: '刷新授权状态',
task_queued: '任务入队', task_queued: '任务入队',
batch_created: '创建任务批次', batch_created: '创建任务批次',
batch_marked_complete: '批次标记完成', batch_marked_complete: '批次标记完成',
+10 -4
View File
@@ -299,15 +299,18 @@ function safeCount(value) {
function getRunCounts(run) { function getRunCounts(run) {
const completed = safeCount(run?.completed_items); 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 failed = safeCount(run?.failed_items);
const skipped = safeCount(run?.skipped_items); const skipped = safeCount(run?.skipped_items);
const cancelled = safeCount(run?.cancelled_items);
const total = safeCount(run?.total_items); const total = safeCount(run?.total_items);
return { completed, failed, skipped, total }; return { completed, running, pending, failed, skipped, cancelled, total };
} }
function hasRunCounts(run) { function hasRunCounts(run) {
const { completed, failed, skipped, total } = getRunCounts(run); const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
return [completed, failed, skipped, total].some(value => value != null); return [completed, running, pending, failed, skipped, cancelled, total].some(value => value != null);
} }
function hasMixedRunOutcome(run) { function hasMixedRunOutcome(run) {
@@ -345,11 +348,14 @@ function getDisplayTaskStatus(task, relatedRun = null) {
} }
function formatRunCounts(run) { function formatRunCounts(run) {
const { completed, failed, skipped, total } = getRunCounts(run); const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
const parts = []; const parts = [];
if (running != null && running > 0) parts.push(`运行中 ${running}`);
if (completed != null) parts.push(`成功 ${completed}`); if (completed != null) parts.push(`成功 ${completed}`);
if (failed != null) parts.push(`失败 ${failed}`); if (failed != null) parts.push(`失败 ${failed}`);
if (skipped != null && skipped > 0) parts.push(`跳过 ${skipped}`); 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}`); if (total != null) parts.push(`总数 ${total}`);
return parts.join(' / '); return parts.join(' / ');
} }
+7 -9
View File
@@ -7,7 +7,6 @@ import { syncWaterScenesFromDisk } from './api/water';
import { listEngines, runWslCheck } from './api/dinsarProduction'; import { listEngines, runWslCheck } from './api/dinsarProduction';
import { getOrbitStatus, syncOrbitPools } from './api/orbit'; import { getOrbitStatus, syncOrbitPools } from './api/orbit';
import LogManagementPanel from './LogManagementPanel'; import LogManagementPanel from './LogManagementPanel';
import OpsTaskMaintenancePanel from './OpsTaskMaintenancePanel';
const toNumber = (value) => { const toNumber = (value) => {
const parsed = Number(value); const parsed = Number(value);
@@ -129,7 +128,7 @@ const formatSourceRootRole = (role, en = false) => {
const buildConsistencySummary = (stats, en = false) => { const buildConsistencySummary = (stats, en = false) => {
const dinsar = stats?.dinsar_cache_consistency || {}; const dinsar = stats?.dinsar_cache_consistency || {};
const preview = stats?.source_preview_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 water = stats?.water_geo_consistency || {};
const issues = [ const issues = [
@@ -164,15 +163,15 @@ const buildConsistencySummary = (stats, en = false) => {
level: 'warn', level: 'warn',
}, },
{ {
key: 'xml_unparsed', key: 'metadata_parse_issue',
label: en ? 'Source: XML detected but key fields not imported' : '源影像:检测到 XML 但关键字段未入库', label: en ? 'Source: metadata document parse issues' : '源影像:元数据文档解析异常',
count: toNumber(xml.xml_detected_but_unparsed_count), count: toNumber(metadata.metadata_detected_but_unparsed_count),
level: 'warn', level: 'warn',
}, },
{ {
key: 'xml_missing', key: 'metadata_missing',
label: en ? 'Source: XML not detected' : '源影像:未检测到 XML', label: en ? 'Source: metadata document not registered' : '源影像:元数据文档未入库',
count: toNumber(xml.xml_missing_count), count: toNumber(metadata.metadata_document_missing_count),
level: 'warn', level: 'warn',
}, },
{ {
@@ -1508,7 +1507,6 @@ const HealthCheckPanel = ({ currentUser }) => {
)} )}
{/* 日志管理 */} {/* 日志管理 */}
<OpsTaskMaintenancePanel isAdmin={isAdmin} />
<LogManagementPanel isAdmin={isAdmin} /> <LogManagementPanel isAdmin={isAdmin} />
</div> </div>
</section> </section>
-19
View File
@@ -27,21 +27,8 @@ const LoginPage = ({ onLoginSuccess }) => {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [licenseStatus, setLicenseStatus] = useState(null);
const [activeSlide, setActiveSlide] = useState(0); 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();
}, []);
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
setActiveSlide((current) => (current + 1) % loginSlides.length); setActiveSlide((current) => (current + 1) % loginSlides.length);
@@ -85,12 +72,6 @@ const LoginPage = ({ onLoginSuccess }) => {
<p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p> <p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p>
</div> </div>
{licenseStatus && !licenseStatus.ok && (
<div className="login-alert warn">
当前授权状态异常:{licenseStatus.reason || '未授权'}
</div>
)}
<form onSubmit={handleSubmit} className="login-form-grid"> <form onSubmit={handleSubmit} className="login-form-grid">
<label className="login-field"> <label className="login-field">
<span>用户名</span> <span>用户名</span>
+22 -36
View File
@@ -18,6 +18,7 @@ const TASK_TYPES = [
const STATUSES = [ const STATUSES = [
['', '全部状态'], ['', '全部状态'],
['COMPLETED', '已完成'],
['FAILED', '失败'], ['FAILED', '失败'],
['PARTIAL_SUCCESS', '部分成功'], ['PARTIAL_SUCCESS', '部分成功'],
['CANCELLED', '已取消'], ['CANCELLED', '已取消'],
@@ -93,7 +94,7 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [preview, setPreview] = useState(null); const [preview, setPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false); const [workCleanupLoading, setWorkCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState(null); const [cleanupResult, setCleanupResult] = useState(null);
const [panelMessage, setPanelMessage] = useState(null); const [panelMessage, setPanelMessage] = useState(null);
@@ -154,39 +155,30 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
} }
}, [selectedTaskId]); }, [selectedTaskId]);
const executeCleanup = useCallback(async () => { const executeLandsarWorkCleanup = useCallback(async () => {
if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return; if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return;
setCleanupLoading(true); setWorkCleanupLoading(true);
setCleanupResult(null); setCleanupResult(null);
try { try {
const data = await cleanupMaintenanceTask(selectedTaskId, { const data = await cleanupMaintenanceTask(selectedTaskId, {
confirm: true, confirm: true,
delete_task_records: true, delete_landsar_work_dir: true,
delete_logs: true,
delete_production_records: true,
delete_result_products: true,
delete_production_dirs: true,
delete_task_pool_dir: true,
});
setCleanupResult({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
});
setPanelMessage({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
}); });
const deletedCount = (data.deleted_disk?.deleted || []).length;
const message = `LandSAR 工作目录清理完成:目录 ${deletedCount} 个。`;
setCleanupResult({ ok: true, message });
setPanelMessage({ ok: true, message });
setPreview(null); setPreview(null);
setDiagnosis(null); await loadDiagnosis(selectedTaskId);
setSelectedTaskId('');
await loadTasks(); await loadTasks();
} catch (err) { } catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' }); const message = err.response?.data?.detail || err.message || 'LandSAR 工作目录清理失败';
setPanelMessage({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' }); setCleanupResult({ ok: false, message });
setPanelMessage({ ok: false, message });
} finally { } finally {
setCleanupLoading(false); setWorkCleanupLoading(false);
} }
}, [isAdmin, loadTasks, preview, selectedTaskId]); }, [isAdmin, loadDiagnosis, loadTasks, preview, selectedTaskId]);
const abnormalCount = tasks.length; const abnormalCount = tasks.length;
const cleanableCount = tasks.filter(item => item.cleanup_supported).length; const cleanableCount = tasks.filter(item => item.cleanup_supported).length;
@@ -331,24 +323,18 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
{preview.blockers?.join(';') || '当前任务不允许清理。'} {preview.blockers?.join(';') || '当前任务不允许清理。'}
</div> </div>
)} )}
<div className="ops-task-two-col"> <div>
<div> <div className="ops-task-section-title">LANDSAR_WORK_ROOT</div>
<div className="ops-task-section-title">数据库记录</div> <DiskPreview paths={preview.disk_deletes} />
<DatabasePreview counts={preview.database_deletes} />
</div>
<div>
<div className="ops-task-section-title">磁盘路径</div>
<DiskPreview paths={preview.disk_deletes} />
</div>
</div> </div>
<div className="ops-task-actions"> <div className="ops-task-actions">
{!isAdmin && <span className="ops-task-muted">仅管理员可执行清理。</span>} {!isAdmin && <span className="ops-task-muted">仅管理员可执行清理。</span>}
<button <button
className="health-action-button danger" className="health-inline-button"
onClick={executeCleanup} onClick={executeLandsarWorkCleanup}
disabled={!isAdmin || cleanupLoading || preview.blocked} disabled={!isAdmin || workCleanupLoading || preview.blocked}
> >
{cleanupLoading ? '清理中...' : '确认清理'} {workCleanupLoading ? '清理中...' : '清理 LandSAR 工作目录'}
</button> </button>
</div> </div>
</div> </div>
+1
View File
@@ -217,6 +217,7 @@ export default function ProductionWorkspace({
onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch} onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch}
onSearchAll={radarPanel?.onSearchAll} onSearchAll={radarPanel?.onSearchAll}
onRefreshDinsar={pairingPanel?.onRefreshDinsar} onRefreshDinsar={pairingPanel?.onRefreshDinsar}
onOpenPairsView={() => setActiveView('dinsar_pairs')}
language={language} language={language}
/> />
); );
+133 -27
View File
@@ -295,7 +295,11 @@ function buildPipelineOption(rows) {
axisPointer: { type: 'shadow' }, axisPointer: { type: 'shadow' },
formatter: (params) => { formatter: (params) => {
const item = params?.[0]?.data || {}; 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: { xAxis: {
@@ -327,7 +331,13 @@ function buildPipelineOption(rows) {
show: true, show: true,
position: 'right', position: 'right',
color: '#475569', 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 }) { function ProductionRunList({ rows }) {
const data = Array.isArray(rows) ? rows : []; const data = Array.isArray(rows) ? rows : [];
if (!data.length) { if (!data.length) {
@@ -792,11 +883,17 @@ export default function StatisticsDashboard() {
const [lastLoadedAt, setLastLoadedAt] = useState(null); const [lastLoadedAt, setLastLoadedAt] = useState(null);
const [coverageMode, setCoverageMode] = useState('source'); const [coverageMode, setCoverageMode] = useState('source');
const loadDashboard = async () => { const loadDashboard = async ({ fresh = false } = {}) => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const payload = await getStatisticsDashboard(); let payload;
try {
payload = await getStatisticsDashboard(fresh);
} catch (err) {
if (!fresh) throw err;
payload = await getStatisticsDashboard(false);
}
setData(payload); setData(payload);
setLastLoadedAt(new Date()); setLastLoadedAt(new Date());
} catch (err) { } catch (err) {
@@ -875,7 +972,7 @@ export default function StatisticsDashboard() {
{data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''} {data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''}
</span> </span>
)} )}
<button type="button" onClick={loadDashboard} disabled={loading}> <button type="button" onClick={() => loadDashboard({ fresh: true })} disabled={loading}>
{loading ? '刷新中' : '刷新'} {loading ? '刷新中' : '刷新'}
</button> </button>
</div> </div>
@@ -948,29 +1045,38 @@ export default function StatisticsDashboard() {
<Chart option={sourceTrendOption} className="statistics-chart-md" /> <Chart option={sourceTrendOption} className="statistics-chart-md" />
</Section> </Section>
<Section title="精轨保障" subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中`}> <Section
<div className="statistics-orbit-summary"> title="精轨保障"
<div> subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中,${formatNumber(data?.orbit?.missing_summary?.affected_scene_count || data?.orbit?.missing_scene_count)} 景待补`}
<span>精轨资产</span> className="statistics-section--orbit"
<strong>{formatNumber(data?.orbit?.orbit_total)}</strong> >
</div> <div className="statistics-orbit-layout">
<div> <div className="statistics-orbit-overview">
<span>绑定覆盖率</span> <div className="statistics-orbit-summary">
<strong>{formatPercent(data?.orbit?.selected_rate)}</strong> <div>
</div> <span>精轨资产</span>
<div> <strong>{formatNumber(data?.orbit?.orbit_total)}</strong>
<span>匹配记录</span> </div>
<strong>{formatNumber(data?.orbit?.matched_bindings)}</strong> <div>
</div> <span>绑定覆盖率</span>
</div> <strong>{formatPercent(data?.orbit?.selected_rate)}</strong>
<div className="statistics-mini-bars"> </div>
{(data?.orbit?.orbit_by_family || []).map((item) => ( <div>
<div key={item.family}> <span>匹配记录</span>
<span>{item.family}</span> <strong>{formatNumber(data?.orbit?.matched_bindings)}</strong>
<b>{formatNumber(item.count)}</b> </div>
<i style={{ width: `${Math.min(100, Math.max(2, item.ok_rate * 100))}%` }} />
</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> </div>
</Section> </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 { default as apiClient } from './client';
export * as authApi from './auth'; export * as authApi from './auth';
export * as licenseApi from './license';
export * as tasksApi from './tasks'; export * as tasksApi from './tasks';
export * as healthApi from './health'; export * as healthApi from './health';
export * as radarApi from './radar'; 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);
+1
View File
@@ -1,6 +1,7 @@
import apiClient from './client'; import apiClient from './client';
export const findPairs = (formData) => apiClient.post('/find-pairs', formData).then(r => r.data); 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 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 getPairingHealth = () => apiClient.get('/pairing/health').then(r => r.data);
export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data); export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data);
+2 -2
View File
@@ -3,5 +3,5 @@ import apiClient from './client';
export const getStatistics = (fresh = false) => export const getStatistics = (fresh = false) =>
apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data); apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
export const getStatisticsDashboard = () => export const getStatisticsDashboard = (fresh = false) =>
apiClient.get('/statistics/dashboard').then(r => r.data); apiClient.get('/statistics/dashboard', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
-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 { Suspense, lazy } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import LicenseOverlay from '../LicenseOverlay';
import { useDinsarStore, usePairingStore, useUiStore } from '../../store'; import { useDinsarStore, usePairingStore, useUiStore } from '../../store';
import { useI18n } from '../../i18n/I18nContext'; import { useI18n } from '../../i18n/I18nContext';
import { formatYmd } from '../../utils/appUiHelpers'; import { formatYmd } from '../../utils/appUiHelpers';
@@ -17,14 +16,7 @@ export default function AppOverlays({
onPairingAoiModeChange, onPairingAoiModeChange,
onPairingProvinceChange, onPairingProvinceChange,
onPairingCityChange, onPairingCityChange,
licenseLoading,
licenseStatus,
isAdmin, isAdmin,
licenseFileRef,
onUploadFile,
onRefreshLicenseStatus,
licenseFileName,
licenseUploadStatus,
activeTasks, activeTasks,
runtimeSummary, runtimeSummary,
showCancelTask, showCancelTask,
@@ -75,17 +67,6 @@ export default function AppOverlays({
</Suspense> </Suspense>
)} )}
<LicenseOverlay
licenseLoading={licenseLoading}
licenseStatus={licenseStatus}
isAdmin={isAdmin}
licenseFileRef={licenseFileRef}
onUploadFile={onUploadFile}
onRefreshStatus={onRefreshLicenseStatus}
licenseFileName={licenseFileName}
licenseUploadStatus={licenseUploadStatus}
/>
{showDataInfo && ( {showDataInfo && (
<Suspense fallback={<ModalLoadingFallback message="正在加载数据详情..." />}> <Suspense fallback={<ModalLoadingFallback message="正在加载数据详情..." />}>
<LazyDataInfoModal <LazyDataInfoModal
+25 -2
View File
@@ -18,6 +18,7 @@ const LazyAssetInventoryPanel = lazy(() => import('../../AssetInventoryPanel'));
const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel')); const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel'));
const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel')); const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel'));
const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel')); const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel'));
const LazyOpsTaskMaintenancePanel = lazy(() => import('../../OpsTaskMaintenancePanel'));
const LazyFloodAnalysisWorkspace = lazy(() => import('../../FloodAnalysisWorkspace')); const LazyFloodAnalysisWorkspace = lazy(() => import('../../FloodAnalysisWorkspace'));
const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel')); const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel'));
const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel')); const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
@@ -30,6 +31,7 @@ const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarM
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace')); const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard')); const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
const LazyResultExtractionPanel = lazy(() => import('../../ResultExtractionPanel')); const LazyResultExtractionPanel = lazy(() => import('../../ResultExtractionPanel'));
const LazyUserManualPanel = lazy(() => import('../../UserManualPanel'));
export default function AppSidePanel({ export default function AppSidePanel({
leftPanelWidth, leftPanelWidth,
@@ -41,7 +43,6 @@ export default function AppSidePanel({
currentUser, currentUser,
language, language,
apiEndpoint, apiEndpoint,
licenseOk,
foundPairs, foundPairs,
dinsarTotal, dinsarTotal,
selectedPairsCount, selectedPairsCount,
@@ -229,6 +230,14 @@ export default function AppSidePanel({
</div> </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' && ( {leftPanelTab === 'ingest' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}> <div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}> <Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}>
@@ -236,7 +245,7 @@ export default function AppSidePanel({
apiEndpoint={apiEndpoint} apiEndpoint={apiEndpoint}
onTaskStart={taskPanel.onTaskStart} onTaskStart={taskPanel.onTaskStart}
readOnly={isReadOnlyUser} readOnly={isReadOnlyUser}
enabled={!!currentUser && licenseOk} enabled={!!currentUser}
/> />
</Suspense> </Suspense>
</div> </div>
@@ -330,6 +339,20 @@ export default function AppSidePanel({
</div> </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' && ( {leftPanelTab === 'users' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}> <div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
{isAdmin ? ( {isAdmin ? (
@@ -1,6 +1,5 @@
import { memo } from 'react'; import { memo } from 'react';
import defaultLogoUrl from '../../logo.jpg'; import defaultLogoUrl from '../../logo.jpg';
import { formatUtc } from '../../utils/appUiHelpers';
const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心'; const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心';
const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_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; const LOGO_URL = import.meta.env.VITE_APP_LOGO_URL || defaultLogoUrl;
function AppStatusHeader({ function AppStatusHeader({
language,
currentUser, currentUser,
isAdmin, isAdmin,
isReadOnlyUser, isReadOnlyUser,
activeTasks, activeTasks,
avgTaskProgress, avgTaskProgress,
runtimeSummary, runtimeSummary,
licenseStatus,
onLogout, onLogout,
}) { }) {
const licenseOk = !!licenseStatus?.ok;
const worker = runtimeSummary?.worker || {}; const worker = runtimeSummary?.worker || {};
const jobs = runtimeSummary?.jobs || {}; const jobs = runtimeSummary?.jobs || {};
const scan = runtimeSummary?.scan || {}; const scan = runtimeSummary?.scan || {};
@@ -68,14 +64,6 @@ function AppStatusHeader({
<div className="status-system-meta" aria-label="系统状态摘要"> <div className="status-system-meta" aria-label="系统状态摘要">
<span>{SYSTEM_TAGLINE}</span> <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>
<div className="status-actions"> <div className="status-actions">
@@ -82,6 +82,7 @@ function PairListRow({
const overlapLabel = pair.pair_aoi_overlap_ratio != null ? 'AOI覆盖' : '影像重叠'; const overlapLabel = pair.pair_aoi_overlap_ratio != null ? 'AOI覆盖' : '影像重叠';
const production = productionLabel(pair.production_summary); const production = productionLabel(pair.production_summary);
const quality = qualityLabel(pair); 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) const engines = Array.isArray(pair.production_summary?.engine_codes)
? pair.production_summary.engine_codes.filter(Boolean).join('/') ? pair.production_summary.engine_codes.filter(Boolean).join('/')
: ''; : '';
@@ -104,6 +105,13 @@ function PairListRow({
<span>时基: {pair.time_baseline_days}d</span> <span>时基: {pair.time_baseline_days}d</span>
<span>中心距: {formatDistance(centerDistance)}</span> <span>中心距: {formatDistance(centerDistance)}</span>
<span>{overlapLabel}: {formatPercent(overlap)}</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>} {quality && <span>D-InSAR: {quality}</span>}
</div> </div>
<div className="pair-status-line"> <div className="pair-status-line">
+5 -1
View File
@@ -197,6 +197,7 @@ export const LEFT_GROUP_LABELS = {
statistics: '综合统计', statistics: '综合统计',
flood_analysis: '灾害分析', flood_analysis: '灾害分析',
ops: '运行维护', ops: '运行维护',
help: '帮助文档',
}; };
export const LEFT_GROUP_SECTIONS = { 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), insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
statistics: ['statistics'], statistics: ['statistics'],
flood_analysis: ['flood_analysis'], 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]) => { 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, ...PRODUCTION_WORKSPACE_ROUTE_TABS,
'statistics', 'statistics',
'result_extraction', 'result_extraction',
'user_manual',
]); ]);
export const ADMIN_ONLY_TABS = new Set([ export const ADMIN_ONLY_TABS = new Set([
@@ -262,6 +265,7 @@ export const ADMIN_ONLY_TABS = new Set([
...PRODUCTION_WORKSPACE_LEGACY_TABS, ...PRODUCTION_WORKSPACE_LEGACY_TABS,
'users', 'users',
'audit', 'audit',
'ops_tasks',
]); ]);
export const DEFAULT_LIST_PAGE_SIZE = 200; export const DEFAULT_LIST_PAGE_SIZE = 200;
+1 -49
View File
@@ -38,10 +38,8 @@ const requestWithTransientRetry = async (
}; };
export default function useAppAuthLifecycle({ export default function useAppAuthLifecycle({
ensureCanOperate,
clearRadarSearchResults, clearRadarSearchResults,
radarSearchRequestSeqRef, radarSearchRequestSeqRef,
prevLicenseOkRef,
aoeLayerRef, aoeLayerRef,
activeLayersRef, activeLayersRef,
radarPreviewLayersRef, radarPreviewLayersRef,
@@ -49,13 +47,9 @@ export default function useAppAuthLifecycle({
setCurrentUser, setCurrentUser,
setAuthChecked, setAuthChecked,
setPendingTaskIds, setPendingTaskIds,
setLicenseLoading,
setLicenseStatus,
setHealthLoading, setHealthLoading,
setHealthError, setHealthError,
setHealthStatus, setHealthStatus,
setLicenseFileName,
setLicenseUploadStatus,
setAoiLayer, setAoiLayer,
setAllData, setAllData,
setRadarPagination, setRadarPagination,
@@ -113,7 +107,6 @@ export default function useAppAuthLifecycle({
clearRadarSearchResults(); clearRadarSearchResults();
setCurrentUser(null); setCurrentUser(null);
setPendingTaskIds([]); setPendingTaskIds([]);
prevLicenseOkRef.current = false;
setAuthChecked(true); setAuthChecked(true);
} }
}, [ }, [
@@ -122,29 +115,9 @@ export default function useAppAuthLifecycle({
setHasRadarSearched, setHasRadarSearched,
setCurrentUser, setCurrentUser,
setPendingTaskIds, setPendingTaskIds,
prevLicenseOkRef,
setAuthChecked, 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 fetchHealthStatus = useCallback(async (options = {}) => {
const { refresh = false, silent = false } = options; const { refresh = false, silent = false } = options;
try { try {
@@ -167,22 +140,6 @@ export default function useAppAuthLifecycle({
} }
}, [setHealthLoading, setHealthError, setHealthStatus]); }, [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(() => { useEffect(() => {
const interceptorId = apiClient.interceptors.response.use( const interceptorId = apiClient.interceptors.response.use(
(response) => response, (response) => response,
@@ -210,7 +167,6 @@ export default function useAppAuthLifecycle({
})); }));
setCurrentUser(null); setCurrentUser(null);
setAuthChecked(true); setAuthChecked(true);
prevLicenseOkRef.current = false;
} }
return Promise.reject(error); return Promise.reject(error);
} }
@@ -229,13 +185,11 @@ export default function useAppAuthLifecycle({
setRadarPagination, setRadarPagination,
setCurrentUser, setCurrentUser,
setAuthChecked, setAuthChecked,
prevLicenseOkRef,
]); ]);
useEffect(() => { useEffect(() => {
fetchCurrentUser(); fetchCurrentUser();
fetchLicenseStatus(); }, [fetchCurrentUser]);
}, [fetchCurrentUser, fetchLicenseStatus]);
useEffect(() => { useEffect(() => {
const startupTimer = setTimeout(() => { const startupTimer = setTimeout(() => {
@@ -253,8 +207,6 @@ export default function useAppAuthLifecycle({
return { return {
handleLoginSuccess, handleLoginSuccess,
handleLogout, handleLogout,
fetchLicenseStatus,
fetchHealthStatus, fetchHealthStatus,
handleLicenseUpload,
}; };
} }
+2 -3
View File
@@ -7,7 +7,6 @@ const TERMINAL_TASK_STATUSES = new Set(['COMPLETED', 'PARTIAL_SUCCESS', 'FAILED'
export default function useGlobalTaskControl({ export default function useGlobalTaskControl({
currentUser, currentUser,
licenseOk,
activeTasks, activeTasks,
setActiveTasks, setActiveTasks,
setRuntimeSummary, setRuntimeSummary,
@@ -139,7 +138,7 @@ export default function useGlobalTaskControl({
}, [handleTasksUpdate, normalizeRuntimeSummary]); }, [handleTasksUpdate, normalizeRuntimeSummary]);
useEffect(() => { useEffect(() => {
if (!currentUser || !licenseOk) return; if (!currentUser) return;
// Initial fetch // Initial fetch
syncActiveTasks(); syncActiveTasks();
@@ -179,7 +178,7 @@ export default function useGlobalTaskControl({
if (es) es.close(); if (es) es.close();
if (fallbackInterval) clearInterval(fallbackInterval); if (fallbackInterval) clearInterval(fallbackInterval);
}; };
}, [currentUser, licenseOk, syncActiveTasks, handleTasksUpdate]); }, [currentUser, syncActiveTasks, handleTasksUpdate]);
const handleCancelActiveTasks = useCallback(() => { const handleCancelActiveTasks = useCallback(() => {
if (!cancelTaskPwd || activeTasks.length === 0) return; if (!cancelTaskPwd || activeTasks.length === 0) return;
-3
View File
@@ -3,12 +3,9 @@
{ zh: '请稍候,系统正在验证会话。', en: 'Please wait, verifying your session.' }, { zh: '请稍候,系统正在验证会话。', en: 'Please wait, verifying your session.' },
{ zh: '雷达数据生产管理系统', en: 'Radar Data Production Management System' }, { zh: '雷达数据生产管理系统', en: 'Radar Data Production Management System' },
{ zh: '科研工程模式', en: 'Research Engineering Mode' }, { zh: '科研工程模式', en: 'Research Engineering Mode' },
{ zh: '已授权', en: 'Licensed' },
{ zh: '未授权', en: 'Unlicensed' },
{ zh: '管理员', en: 'Admin' }, { zh: '管理员', en: 'Admin' },
{ zh: '只读账号', en: 'Read-only account' }, { zh: '只读账号', en: 'Read-only account' },
{ zh: '任务', en: 'Tasks' }, { zh: '任务', en: 'Tasks' },
{ zh: '授权至', en: 'License until' },
{ zh: '刷新自检状态', en: 'Refresh health status' }, { zh: '刷新自检状态', en: 'Refresh health status' },
{ zh: '自检中...', en: 'Checking...' }, { zh: '自检中...', en: 'Checking...' },
{ zh: '刷新自检', en: 'Refresh Health' }, { zh: '刷新自检', en: 'Refresh Health' },
+436 -8
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
buildCoveragePlan,
getPairingHealth, getPairingHealth,
rebuildPairingCache, rebuildPairingCache,
reconcileDirtyPairingCache, reconcileDirtyPairingCache,
} from '../api/pairing'; } from '../api/pairing';
import { getRegionChildren, getRegionGeometry } from '../api/aoi';
import MiniCoverageMap from '../components/MiniCoverageMap'; import MiniCoverageMap from '../components/MiniCoverageMap';
import { usePairingStore } from '../store';
const formatIso = (value, en = false) => { const formatIso = (value, en = false) => {
if (!value) return en ? 'Never' : '未执行'; if (!value) return en ? 'Never' : '未执行';
@@ -41,6 +44,7 @@ export default function PairPlanningPanel({
onRefreshRadarSearch, onRefreshRadarSearch,
onSearchAll, onSearchAll,
onRefreshDinsar, onRefreshDinsar,
onOpenPairsView,
language, language,
}) { }) {
const en = language === 'en'; const en = language === 'en';
@@ -50,7 +54,60 @@ export default function PairPlanningPanel({
const [pairingRepairing, setPairingRepairing] = useState(false); const [pairingRepairing, setPairingRepairing] = useState(false);
const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false); const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false);
const [pairingActionResult, setPairingActionResult] = useState(null); 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(() => ( const previewPolygons = useMemo(() => (
previewPairs.flatMap((pair, index) => { previewPairs.flatMap((pair, index) => {
const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`; const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`;
@@ -70,6 +127,22 @@ export default function PairPlanningPanel({
]; ];
}) })
), [previewPairs]); ), [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 () => { const refreshPairingStatus = useCallback(async () => {
if (isReadOnlyUser) { if (isReadOnlyUser) {
@@ -122,6 +195,169 @@ export default function PairPlanningPanel({
}, [en, refreshPairingStatus]); }, [en, refreshPairingStatus]);
const pairingActionBusy = pairingRepairing || pairingFullRebuilding; 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 ( return (
<div className="panel-content" style={{ flex: '1 1 auto', overflowY: 'auto', padding: '12px' }}> <div className="panel-content" style={{ flex: '1 1 auto', overflowY: 'auto', padding: '12px' }}>
@@ -139,16 +375,208 @@ export default function PairPlanningPanel({
</div> </div>
</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' }}> <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 <MiniCoverageMap
title={en ? 'D-InSAR Pair Coverage Preview' : 'D-InSAR配对范围预览'} title={en ? 'D-InSAR Pair Coverage Preview' : 'D-InSAR配对范围预览'}
subtitle={ subtitle={mapShowsEffectiveCoverage ? `${previewEffectiveGeojson.features.length} 个有效覆盖面` : `${previewPairs.length} 对`}
foundPairs.length > previewPairs.length polygons={mapShowsEffectiveCoverage ? [] : previewPolygons}
? `${previewPairs.length}/${foundPairs.length} 对` geojson={mapShowsEffectiveCoverage ? previewEffectiveGeojson : (coverageAoiGeojson || coverageResult?.aoi_geojson)}
: `${foundPairs.length} 对` height={420}
}
polygons={previewPolygons}
height={260}
emptyText={en ? 'Run pair planning to preview pair footprints.' : '生成配对后显示候选范围。'} emptyText={en ? 'Run pair planning to preview pair footprints.' : '生成配对后显示候选范围。'}
/> />
</div> </div>
+13 -9
View File
@@ -112,14 +112,18 @@ function PairsListPanel({
) : ( ) : (
<> <>
<div className="list-toolbar"> <div className="list-toolbar">
<input <div className="list-toolbar-main">
type="checkbox" <strong>候选配对列表</strong>
checked={allPairsSelected} <span>共 {foundPairs.length} 对,已选择 {selectedPairsCount} 对;列表支持滚动查看全部结果。</span>
onChange={handleSelectAllPairs} </div>
id="select-all-pairs" <label className="select-all-pairs-control" htmlFor="select-all-pairs">
/> <input
<label htmlFor="select-all-pairs"> type="checkbox"
全选 ({selectedPairsCount} / {foundPairs.length} 已选择) checked={allPairsSelected}
onChange={handleSelectAllPairs}
id="select-all-pairs"
/>
全选
</label> </label>
</div> </div>
<VirtualizedList <VirtualizedList
@@ -179,7 +183,7 @@ function PairsListPanel({
className="footer-button" className="footer-button"
title="保存选中的配对为任务批次" title="保存选中的配对为任务批次"
> >
保存批次 ({selectedPairsCount}) 保存选中配对为批次 ({selectedPairsCount})
</button> </button>
</footer> </footer>
</> </>
-8
View File
@@ -7,19 +7,11 @@ const s = (set, key) => (v) =>
export const useAuthStore = create((set) => ({ export const useAuthStore = create((set) => ({
currentUser: null, currentUser: null,
authChecked: false, authChecked: false,
licenseStatus: { ok: false, reason: '', expires_at: null },
licenseLoading: true,
licenseUploadStatus: { type: '', message: '' },
licenseFileName: '',
healthStatus: null, healthStatus: null,
healthLoading: false, healthLoading: false,
healthError: '', healthError: '',
setCurrentUser: s(set, 'currentUser'), setCurrentUser: s(set, 'currentUser'),
setAuthChecked: s(set, 'authChecked'), setAuthChecked: s(set, 'authChecked'),
setLicenseStatus: s(set, 'licenseStatus'),
setLicenseLoading: s(set, 'licenseLoading'),
setLicenseUploadStatus: s(set, 'licenseUploadStatus'),
setLicenseFileName: s(set, 'licenseFileName'),
setHealthStatus: s(set, 'healthStatus'), setHealthStatus: s(set, 'healthStatus'),
setHealthLoading: s(set, 'healthLoading'), setHealthLoading: s(set, 'healthLoading'),
setHealthError: s(set, 'healthError'), setHealthError: s(set, 'healthError'),
+8
View File
@@ -87,10 +87,14 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
return '洪涝灾害分析'; return '洪涝灾害分析';
case 'health': case 'health':
return '运行维护'; return '运行维护';
case 'ops_tasks':
return '任务维护';
case 'users': case 'users':
return '用户管理'; return '用户管理';
case 'audit': case 'audit':
return '审计日志'; return '审计日志';
case 'user_manual':
return '用户手册';
default: default:
return tabKey; return tabKey;
} }
@@ -120,10 +124,14 @@ export const getLeftTabDescription = (tabKey) => {
return '围绕洪涝场景开展水体提取、过程分析和专题制图。'; return '围绕洪涝场景开展水体提取、过程分析和专题制图。';
case 'health': case 'health':
return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。'; return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。';
case 'ops_tasks':
return '查看失败、部分成功、取消和残留运行的任务,按诊断预览清理数据库记录和生产目录。';
case 'users': case 'users':
return '维护系统用户、角色与访问权限。'; return '维护系统用户、角色与访问权限。';
case 'audit': case 'audit':
return '查看关键操作和生产任务的审计记录。'; return '查看关键操作和生产任务的审计记录。';
case 'user_manual':
return '查看系统主要工作流、生产口径、统计解释和常见问题,辅助用户快速定位正确操作入口。';
default: default:
return '当前模块用于支撑科研工程生产流程。'; 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()