Compare commits
3
Commits
9c5cbc4637
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4459180ad | ||
|
|
36a406ae49 | ||
|
|
2da2121829 |
@@ -48,8 +48,6 @@ NGINX_HEALTH_URL=http://127.0.0.1/
|
||||
# Empty means allow all. Use semicolon/comma/space-separated IPs or CIDRs, for example:
|
||||
# NGINX_ALLOWED_CLIENT_IPS=192.168.1.10;192.168.1.23;192.168.1.0/24
|
||||
NGINX_ALLOWED_CLIENT_IPS=
|
||||
LICENSE_PATH=
|
||||
|
||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost
|
||||
CORS_ALLOW_CREDENTIALS=true
|
||||
CORS_STRICT_MODE=false
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from .routers import include_all_routers
|
||||
from .routers.dependencies import _require_auth, _require_license
|
||||
from .routers.dependencies import _require_auth
|
||||
|
||||
router = APIRouter(dependencies=[Depends(_require_license), Depends(_require_auth)])
|
||||
router = APIRouter(dependencies=[Depends(_require_auth)])
|
||||
include_all_routers(router)
|
||||
|
||||
@@ -202,7 +202,6 @@ class Settings(BaseSettings):
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_STRICT_MODE: bool = False
|
||||
|
||||
LICENSE_PATH: str = ""
|
||||
INIT_ADMIN_USERNAME: str = "admin"
|
||||
INIT_ADMIN_PASSWORD: str = ""
|
||||
INIT_ADMIN_RESET_PASSWORD: bool = False
|
||||
@@ -1333,7 +1332,6 @@ def validate_runtime_config() -> dict[str, Any]:
|
||||
|
||||
_check_path(label="PYTHON_PATH", value=settings.PYTHON_PATH, errors=errors, warnings=warnings, expect_file=True)
|
||||
_check_path(label="NGINX_PATH", value=settings.NGINX_PATH, errors=errors, warnings=warnings, required=True, expect_file=True)
|
||||
_check_path(label="LICENSE_PATH", value=settings.LICENSE_PATH, errors=errors, warnings=warnings, expect_file=True)
|
||||
_check_path(label="IDL_EXECUTABLE", value=settings.IDL_EXECUTABLE, errors=errors, warnings=warnings, expect_file=True)
|
||||
_check_path(label="IDL_WORKBENCH_PATH", value=settings.IDL_WORKBENCH_PATH, errors=errors, warnings=warnings, expect_file=True)
|
||||
_check_path(label="GF3_GEO_DEM_PATH", value=settings.GF3_GEO_DEM_PATH, errors=errors, warnings=warnings, expect_file=True)
|
||||
|
||||
@@ -1,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)
|
||||
@@ -284,7 +284,7 @@ class PairingRequest(BaseModel):
|
||||
slave_date_to: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$')
|
||||
|
||||
# === 配对策略(新增) ===
|
||||
strategy: str = Field(default="dinsar_production", pattern=r'^dinsar_production$')
|
||||
strategy: str = Field(default="dinsar_production", pattern=r'^(dinsar_production|dinsar_province_coverage)$')
|
||||
num_connections: int = Field(default=1, ge=1, le=10)
|
||||
reference_image_id: Optional[int] = None
|
||||
|
||||
@@ -307,7 +307,8 @@ class PairingRequest(BaseModel):
|
||||
normalized['overlap_threshold'] = normalized['pair_footprint_overlap_min_ratio']
|
||||
if normalized.get('footprint_center_distance_max_meters') not in (None, ''):
|
||||
normalized['spatial_baseline_max_meters'] = normalized['footprint_center_distance_max_meters']
|
||||
normalized['strategy'] = 'dinsar_production'
|
||||
if normalized.get('strategy') not in {'dinsar_production', 'dinsar_province_coverage'}:
|
||||
normalized['strategy'] = 'dinsar_production'
|
||||
return normalized
|
||||
|
||||
@field_validator(
|
||||
@@ -390,6 +391,11 @@ class RadarPair(BaseModel):
|
||||
selection_strategy: Optional[str] = None
|
||||
selection_score: Optional[float] = None
|
||||
selection_reason: Optional[str] = None
|
||||
coverage_rank: Optional[int] = None
|
||||
aoi_new_area_ratio: Optional[float] = None
|
||||
aoi_pair_area_ratio: Optional[float] = None
|
||||
aoi_coverage_ratio_after: Optional[float] = None
|
||||
effective_coverage_geojson: Optional[Dict[str, Any]] = None
|
||||
time_baseline_days: int
|
||||
spatial_baseline_meters: float
|
||||
scene_center_distance_meters: Optional[float] = None
|
||||
@@ -416,6 +422,7 @@ class PairingResponse(BaseModel):
|
||||
network_run_id: Optional[str] = None
|
||||
candidate_count: int = 0
|
||||
selected_edge_count: int = 0
|
||||
coverage: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PsRequest(BaseModel):
|
||||
|
||||
@@ -15,7 +15,6 @@ from . import (
|
||||
health,
|
||||
idl,
|
||||
landsar_lt1_production,
|
||||
license,
|
||||
logs,
|
||||
monitor,
|
||||
ops_maintenance,
|
||||
@@ -42,7 +41,6 @@ def include_all_routers(router: APIRouter) -> None:
|
||||
"""将所有子路由注册到给定的 APIRouter。"""
|
||||
router.include_router(health.router)
|
||||
router.include_router(auth.router)
|
||||
router.include_router(license.router)
|
||||
router.include_router(tasks_runtime.router)
|
||||
router.include_router(workflow.router)
|
||||
router.include_router(task_batches.router)
|
||||
|
||||
@@ -38,29 +38,17 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
|
||||
if ip.strip()
|
||||
)
|
||||
from ..database import get_db
|
||||
from ..license_service import check_license
|
||||
from ..models import AuthRateLimitORM, AuthUserORM, DinsarTaskBatchORM, DinsarTaskItemORM, PsTaskBatchORM, PsTaskItemORM
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path classification constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LICENSE_EXEMPT_PATHS = {
|
||||
"/api/license/status",
|
||||
"/api/license/upload",
|
||||
"/api/license/refresh",
|
||||
"/api/health",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/me",
|
||||
}
|
||||
|
||||
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
READ_SAFE_POST_PATHS = {
|
||||
"/api/radar-data/search",
|
||||
}
|
||||
PUBLIC_AUTH_PATHS = {
|
||||
"/api/license/status",
|
||||
"/api/health",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
@@ -68,8 +56,6 @@ PUBLIC_AUTH_PATHS = {
|
||||
|
||||
HIGH_RISK_WRITE_PATH_PREFIXES = (
|
||||
"/api/auth/users",
|
||||
"/api/license/upload",
|
||||
"/api/license/refresh",
|
||||
"/api/workflow/runs",
|
||||
"/api/task-batches/",
|
||||
"/api/tools/",
|
||||
@@ -103,13 +89,6 @@ LOGIN_THROTTLE_CLEANUP_INTERVAL_SECONDS = read_int_env(
|
||||
|
||||
_LOGIN_THROTTLE_LOCK = asyncio.Lock()
|
||||
_LOGIN_THROTTLE_LAST_CLEANUP_MONO = 0.0
|
||||
_LICENSE_UPLOAD_LOCK = asyncio.Lock()
|
||||
MAX_LICENSE_UPLOAD_BYTES = read_int_env(
|
||||
"MAX_LICENSE_UPLOAD_BYTES",
|
||||
1024 * 1024,
|
||||
minimum=1024,
|
||||
maximum=20 * 1024 * 1024,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Statistics cache
|
||||
@@ -363,24 +342,6 @@ async def _add_operation_audit_log(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# License guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_license(request: Request):
|
||||
"""
|
||||
授权校验:未授权时拒绝所有 API。
|
||||
使用精确路径集合匹配,防止 endswith 绕过攻击。
|
||||
"""
|
||||
path = (request.url.path or "").rstrip("/") or "/"
|
||||
if path in _LICENSE_EXEMPT_PATHS:
|
||||
return
|
||||
result = check_license()
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=403, detail=f"License required: {result.get('reason')}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -17,12 +17,7 @@ router = APIRouter(prefix="/ops-maintenance", tags=["ops-maintenance"])
|
||||
|
||||
class CleanupRequest(BaseModel):
|
||||
confirm: bool = False
|
||||
delete_task_records: 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
|
||||
delete_landsar_work_dir: bool = True
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
|
||||
@@ -329,6 +329,7 @@ async def find_pairs_endpoint(
|
||||
network_run_id=pairing_metadata.get("network_run_id"),
|
||||
candidate_count=int(pairing_metadata.get("candidate_count") or 0),
|
||||
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
|
||||
coverage=None,
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
@@ -339,6 +340,66 @@ async def find_pairs_endpoint(
|
||||
raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志")
|
||||
|
||||
|
||||
@router.post("/pairing/coverage-plan", response_model=PairingResponse)
|
||||
async def build_coverage_pairing_plan_endpoint(
|
||||
params: PairingRequest = Depends(get_pairing_request_from_form),
|
||||
target_date_from: str = Form(...),
|
||||
target_date_to: str = Form(...),
|
||||
extension_days: int = Form(15),
|
||||
max_pairs: int = Form(200),
|
||||
target_coverage_ratio: float = Form(0.98),
|
||||
min_new_coverage_ratio: float = Form(0.0005),
|
||||
files: Optional[List[UploadFile]] = File(None),
|
||||
aoi_geojson: Optional[str] = Form(None),
|
||||
require_orbit_data: bool = Form(True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
resolved_aoi = await _parse_aoi_from_files(files)
|
||||
if resolved_aoi is None:
|
||||
resolved_aoi = _parse_aoi_geojson_form_value(aoi_geojson)
|
||||
|
||||
if files and resolved_aoi is None:
|
||||
raise HTTPException(status_code=400, detail="Uploaded files must include a valid SHP or GeoJSON AOI.")
|
||||
|
||||
aoi_wkt = resolved_aoi[0] if resolved_aoi else None
|
||||
response_aoi_geojson = resolved_aoi[1] if resolved_aoi else None
|
||||
if not aoi_wkt:
|
||||
raise HTTPException(status_code=400, detail="区域空间覆盖规划必须选择行政区或上传 AOI。")
|
||||
coverage_params = params.model_copy(update={"strategy": "dinsar_province_coverage"})
|
||||
pairs, runtime_warnings, pairing_metadata = await spatial_service.find_dinsar_coverage_pairs(
|
||||
db,
|
||||
coverage_params,
|
||||
target_date_from=target_date_from,
|
||||
target_date_to=target_date_to,
|
||||
extension_days=extension_days,
|
||||
max_pairs=max_pairs,
|
||||
target_coverage_ratio=target_coverage_ratio,
|
||||
min_new_coverage_ratio=min_new_coverage_ratio,
|
||||
aoi_wkt=aoi_wkt,
|
||||
require_orbit_data=require_orbit_data,
|
||||
)
|
||||
return PairingResponse(
|
||||
pairs=pairs,
|
||||
aoi_geojson=response_aoi_geojson,
|
||||
warnings=runtime_warnings,
|
||||
fallback_used=bool(pairing_metadata.get("fallback_used")),
|
||||
degraded=bool(pairing_metadata.get("degraded")),
|
||||
policy_version=pairing_metadata.get("policy_version"),
|
||||
network_run_id=pairing_metadata.get("network_run_id"),
|
||||
candidate_count=int(pairing_metadata.get("candidate_count") or 0),
|
||||
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
|
||||
coverage=pairing_metadata.get("coverage"),
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
if isinstance(e, (RuntimeError, ValueError)):
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
logger.exception("Failed to build D-InSAR coverage pairing plan")
|
||||
raise HTTPException(status_code=500, detail="Failed to build D-InSAR coverage pairing plan.")
|
||||
|
||||
|
||||
@router.post("/find-ps-timeseries", response_model=Dict[str, List[RadarData]])
|
||||
async def find_ps_timeseries_endpoint(
|
||||
params: PsRequest = Depends(get_ps_request_from_form),
|
||||
|
||||
+221
-52
@@ -11,6 +11,8 @@ from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOURCE_METADATA_MANAGED_FORMATS = ("LT1_ARCHIVE", "S1_ZIP")
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import distinct, func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -51,7 +53,6 @@ from ..services.data_service import data_service
|
||||
from ..services.dinsar_read_service import dinsar_read_service
|
||||
from ..services.pairing_state_service import pairing_state_service
|
||||
from ..services.admin_region_lookup_service import _build_region_path, _load_region_records
|
||||
from ..utils import find_xml_file
|
||||
from . import dependencies as _deps
|
||||
from .dependencies import _get_current_user
|
||||
|
||||
@@ -80,6 +81,10 @@ def _ratio(numerator: int, denominator: int) -> float:
|
||||
return round(float(numerator) / float(denominator), 4)
|
||||
|
||||
|
||||
def _is_source_metadata_managed_format(value: Any) -> bool:
|
||||
return str(value or "").strip().upper() in SOURCE_METADATA_MANAGED_FORMATS
|
||||
|
||||
|
||||
def _family_label(value: Any) -> str:
|
||||
text = str(value or "").strip().upper()
|
||||
if text in {"LT1", "LT-1", "LUTAN", "LUTAN1"}:
|
||||
@@ -102,6 +107,14 @@ def _month_from_yyyymmdd(value: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _compact_yyyymmdd(value: Any) -> Optional[str]:
|
||||
text = str(value or "").strip()
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
if len(digits) >= 8:
|
||||
return digits[:8]
|
||||
return None
|
||||
|
||||
|
||||
def _month_from_datetime(value: Any) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
@@ -594,16 +607,36 @@ async def get_statistics_dashboard(
|
||||
db,
|
||||
select(func.count(SourceProductAssetORM.id)).where(SourceProductAssetORM.is_active == True),
|
||||
)
|
||||
metadata_source_total = await _scalar_count(
|
||||
db,
|
||||
select(func.count(SourceProductAssetORM.id)).where(
|
||||
SourceProductAssetORM.is_active == True,
|
||||
SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
),
|
||||
)
|
||||
radar_total = await _scalar_count(db, select(func.count(RadarDataORM.id)))
|
||||
metadata_asset_total = await _scalar_count(
|
||||
db,
|
||||
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))),
|
||||
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where(
|
||||
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
),
|
||||
)
|
||||
metadata_doc_total = await _scalar_count(
|
||||
db,
|
||||
select(func.count(SourceMetadataDocumentORM.id)).where(
|
||||
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
),
|
||||
)
|
||||
geometry_total = await _scalar_count(
|
||||
db,
|
||||
select(func.count(SARSceneGeometryProfileORM.id)).where(
|
||||
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
),
|
||||
)
|
||||
metadata_doc_total = await _scalar_count(db, select(func.count(SourceMetadataDocumentORM.id)))
|
||||
geometry_total = await _scalar_count(db, select(func.count(SARSceneGeometryProfileORM.id)))
|
||||
geometry_ready = await _scalar_count(
|
||||
db,
|
||||
select(func.count(SARSceneGeometryProfileORM.id)).where(
|
||||
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
SARSceneGeometryProfileORM.metadata_quality == "READY",
|
||||
SARSceneGeometryProfileORM.production_readiness == "READY",
|
||||
),
|
||||
@@ -633,6 +666,7 @@ async def get_statistics_dashboard(
|
||||
for family, source_format, parse_status, count in source_group_rows.all():
|
||||
family_label = _family_label(family)
|
||||
status_label = _status_label(parse_status)
|
||||
format_label = str(source_format or "UNKNOWN")
|
||||
count_int = _safe_int(count)
|
||||
family_bucket = source_by_family_map.setdefault(
|
||||
family_label,
|
||||
@@ -649,7 +683,6 @@ async def get_statistics_dashboard(
|
||||
family_bucket["ready_count"] += count_int
|
||||
else:
|
||||
family_bucket["issue_count"] += count_int
|
||||
format_label = str(source_format or "UNKNOWN")
|
||||
family_bucket["formats"][format_label] = family_bucket["formats"].get(format_label, 0) + count_int
|
||||
source_by_format.append(
|
||||
{
|
||||
@@ -673,19 +706,22 @@ async def get_statistics_dashboard(
|
||||
geometry_rows = await db.execute(
|
||||
select(
|
||||
SARSceneGeometryProfileORM.satellite_family,
|
||||
SARSceneGeometryProfileORM.source_format,
|
||||
SARSceneGeometryProfileORM.metadata_quality,
|
||||
SARSceneGeometryProfileORM.production_readiness,
|
||||
func.count(SARSceneGeometryProfileORM.id),
|
||||
)
|
||||
.where(SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS))
|
||||
.group_by(
|
||||
SARSceneGeometryProfileORM.satellite_family,
|
||||
SARSceneGeometryProfileORM.source_format,
|
||||
SARSceneGeometryProfileORM.metadata_quality,
|
||||
SARSceneGeometryProfileORM.production_readiness,
|
||||
)
|
||||
.order_by(SARSceneGeometryProfileORM.satellite_family)
|
||||
)
|
||||
geometry_by_family_map: dict[str, dict[str, Any]] = {}
|
||||
for family, metadata_quality, production_readiness, count in geometry_rows.all():
|
||||
for family, source_format, metadata_quality, production_readiness, count in geometry_rows.all():
|
||||
family_label = _family_label(family)
|
||||
count_int = _safe_int(count)
|
||||
bucket = geometry_by_family_map.setdefault(
|
||||
@@ -1075,6 +1111,82 @@ async def get_statistics_dashboard(
|
||||
issue_by_severity[severity_label] = issue_by_severity.get(severity_label, 0) + count_int
|
||||
issue_by_code[code_label] = issue_by_code.get(code_label, 0) + count_int
|
||||
|
||||
missing_orbit_issue_rows = await db.execute(
|
||||
select(AssetInventoryIssueORM.metadata_json)
|
||||
.where(AssetInventoryIssueORM.status == "OPEN")
|
||||
.where(AssetInventoryIssueORM.issue_code == "scene_missing_orbit")
|
||||
)
|
||||
orbit_missing_satellite_map: dict[str, dict[str, Any]] = {}
|
||||
orbit_missing_top_dates: dict[tuple[str, str], int] = {}
|
||||
orbit_missing_unknown_date_scene_count = 0
|
||||
for (metadata_json,) in missing_orbit_issue_rows.all():
|
||||
metadata = metadata_json if isinstance(metadata_json, dict) else {}
|
||||
satellite = str(metadata.get("satellite") or "UNKNOWN").strip().upper() or "UNKNOWN"
|
||||
ymd = _compact_yyyymmdd(metadata.get("imaging_date") or metadata.get("acquisition_start_time_utc"))
|
||||
month = _month_from_yyyymmdd(ymd) if ymd else None
|
||||
satellite_bucket = orbit_missing_satellite_map.setdefault(
|
||||
satellite,
|
||||
{
|
||||
"satellite": satellite,
|
||||
"affected_scene_count": 0,
|
||||
"dates": defaultdict(int),
|
||||
"months": defaultdict(lambda: {"affected_scene_count": 0, "dates": set()}),
|
||||
"unknown_date_scene_count": 0,
|
||||
},
|
||||
)
|
||||
satellite_bucket["affected_scene_count"] += 1
|
||||
if not ymd:
|
||||
satellite_bucket["unknown_date_scene_count"] += 1
|
||||
orbit_missing_unknown_date_scene_count += 1
|
||||
continue
|
||||
satellite_bucket["dates"][ymd] += 1
|
||||
orbit_missing_top_dates[(satellite, ymd)] = orbit_missing_top_dates.get((satellite, ymd), 0) + 1
|
||||
if month:
|
||||
month_bucket = satellite_bucket["months"][month]
|
||||
month_bucket["affected_scene_count"] += 1
|
||||
month_bucket["dates"].add(ymd)
|
||||
|
||||
orbit_missing_by_satellite: list[dict[str, Any]] = []
|
||||
for satellite, bucket in sorted(orbit_missing_satellite_map.items()):
|
||||
date_items = [
|
||||
{"date": date_text, "affected_scene_count": _safe_int(count)}
|
||||
for date_text, count in sorted(bucket["dates"].items())
|
||||
]
|
||||
month_items = [
|
||||
{
|
||||
"month": month,
|
||||
"affected_scene_count": _safe_int(month_bucket["affected_scene_count"]),
|
||||
"missing_orbit_date_count": len(month_bucket["dates"]),
|
||||
}
|
||||
for month, month_bucket in sorted(bucket["months"].items())
|
||||
]
|
||||
orbit_missing_by_satellite.append(
|
||||
{
|
||||
"satellite": satellite,
|
||||
"affected_scene_count": _safe_int(bucket["affected_scene_count"]),
|
||||
"missing_orbit_date_count": len(date_items),
|
||||
"unknown_date_scene_count": _safe_int(bucket["unknown_date_scene_count"]),
|
||||
"first_missing_date": date_items[0]["date"] if date_items else None,
|
||||
"last_missing_date": date_items[-1]["date"] if date_items else None,
|
||||
"months": month_items,
|
||||
"dates": date_items,
|
||||
}
|
||||
)
|
||||
|
||||
orbit_missing_summary = {
|
||||
"affected_scene_count": sum(item["affected_scene_count"] for item in orbit_missing_by_satellite),
|
||||
"missing_orbit_date_count": sum(item["missing_orbit_date_count"] for item in orbit_missing_by_satellite),
|
||||
"unknown_date_scene_count": orbit_missing_unknown_date_scene_count,
|
||||
"by_satellite": orbit_missing_by_satellite,
|
||||
"top_dates": [
|
||||
{"satellite": satellite, "date": date_text, "affected_scene_count": _safe_int(count)}
|
||||
for (satellite, date_text), count in sorted(
|
||||
orbit_missing_top_dates.items(),
|
||||
key=lambda item: (-item[1], item[0][0], item[0][1]),
|
||||
)[:20]
|
||||
],
|
||||
}
|
||||
|
||||
inventory_state_rows = await db.execute(
|
||||
select(
|
||||
AssetInventoryStateORM.inventory_type,
|
||||
@@ -1114,16 +1226,24 @@ async def get_statistics_dashboard(
|
||||
|
||||
avg_duration_seconds = round(sum(duration_seconds) / len(duration_seconds), 1) if duration_seconds else None
|
||||
selected_orbit_rate = _ratio(selected_orbit_bindings, orbit_required_total)
|
||||
geometry_ready_rate = _ratio(geometry_ready, source_total)
|
||||
metadata_ready_rate = _ratio(metadata_asset_total, source_total)
|
||||
geometry_ready_rate = _ratio(geometry_ready, geometry_total)
|
||||
metadata_ready_rate = _ratio(metadata_asset_total, metadata_source_total)
|
||||
result_ready_total = sum(item["ready_count"] for item in results_by_catalog)
|
||||
metadata_missing_count = max(0, metadata_source_total - metadata_asset_total)
|
||||
geometry_not_ready_count = max(0, geometry_total - geometry_ready)
|
||||
orbit_missing_gap_count = max(0, orbit_required_total - selected_orbit_bindings)
|
||||
non_orbit_open_issue_total = sum(
|
||||
count
|
||||
for code, count in issue_by_code.items()
|
||||
if code != "scene_missing_orbit"
|
||||
)
|
||||
|
||||
risk_count = (
|
||||
max(0, source_total - metadata_asset_total)
|
||||
+ max(0, source_total - geometry_ready)
|
||||
+ max(0, orbit_required_total - selected_orbit_bindings)
|
||||
metadata_missing_count
|
||||
+ geometry_not_ready_count
|
||||
+ orbit_missing_gap_count
|
||||
+ result_assets_missing
|
||||
+ open_issue_total
|
||||
+ non_orbit_open_issue_total
|
||||
)
|
||||
|
||||
kpis = [
|
||||
@@ -1140,7 +1260,7 @@ async def get_statistics_dashboard(
|
||||
"label": "元数据入库率",
|
||||
"value": round(metadata_ready_rate * 100, 1),
|
||||
"unit": "%",
|
||||
"note": f"{metadata_asset_total}/{source_total} 景已提取 XML/元数据",
|
||||
"note": f"{metadata_asset_total}/{metadata_source_total} 景已提取 XML/元数据",
|
||||
"tone": "success" if metadata_ready_rate >= 0.98 else "warning",
|
||||
},
|
||||
{
|
||||
@@ -1148,7 +1268,7 @@ async def get_statistics_dashboard(
|
||||
"label": "几何画像可用率",
|
||||
"value": round(geometry_ready_rate * 100, 1),
|
||||
"unit": "%",
|
||||
"note": f"{geometry_ready}/{source_total} 景可用于覆盖统计",
|
||||
"note": f"{geometry_ready}/{geometry_total} 景可用于覆盖统计",
|
||||
"tone": "success" if geometry_ready_rate >= 0.95 else "warning",
|
||||
},
|
||||
{
|
||||
@@ -1172,7 +1292,7 @@ async def get_statistics_dashboard(
|
||||
"label": "待关注项",
|
||||
"value": risk_count,
|
||||
"unit": "项",
|
||||
"note": f"开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}",
|
||||
"note": f"缺精轨 {orbit_missing_gap_count},开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}",
|
||||
"tone": "danger" if risk_count else "success",
|
||||
},
|
||||
]
|
||||
@@ -1186,20 +1306,23 @@ async def get_statistics_dashboard(
|
||||
"source_by_family": source_by_family,
|
||||
"source_by_format": source_by_format,
|
||||
"source_by_month": source_by_month,
|
||||
"metadata_source_total": metadata_source_total,
|
||||
"metadata_asset_total": metadata_asset_total,
|
||||
"metadata_doc_total": metadata_doc_total,
|
||||
"metadata_missing_count": metadata_missing_count,
|
||||
"metadata_ready_rate": metadata_ready_rate,
|
||||
"geometry_total": geometry_total,
|
||||
"geometry_ready": geometry_ready,
|
||||
"geometry_not_ready_count": geometry_not_ready_count,
|
||||
"geometry_ready_rate": geometry_ready_rate,
|
||||
"geometry_by_family": geometry_by_family,
|
||||
"preview_ready": preview_ready,
|
||||
"pipeline": [
|
||||
{"key": "source", "label": "源资产登记", "value": source_total, "rate": 1.0},
|
||||
{"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "rate": metadata_ready_rate},
|
||||
{"key": "geometry", "label": "几何画像", "value": geometry_total, "rate": _ratio(geometry_total, source_total)},
|
||||
{"key": "ready", "label": "可生产画像", "value": geometry_ready, "rate": geometry_ready_rate},
|
||||
{"key": "preview", "label": "预览缓存", "value": preview_ready, "rate": _ratio(preview_ready, radar_total)},
|
||||
{"key": "source", "label": "源资产登记", "value": source_total, "denominator": source_total, "rate": 1.0},
|
||||
{"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "denominator": metadata_source_total, "rate": metadata_ready_rate},
|
||||
{"key": "geometry", "label": "几何画像", "value": geometry_total, "denominator": metadata_source_total, "rate": _ratio(geometry_total, metadata_source_total)},
|
||||
{"key": "ready", "label": "可生产画像", "value": geometry_ready, "denominator": geometry_total, "rate": geometry_ready_rate},
|
||||
{"key": "preview", "label": "预览缓存", "value": preview_ready, "denominator": radar_total, "rate": _ratio(preview_ready, radar_total)},
|
||||
],
|
||||
},
|
||||
"orbit": {
|
||||
@@ -1208,6 +1331,8 @@ async def get_statistics_dashboard(
|
||||
"orbit_required_total": orbit_required_total,
|
||||
"selected_bindings": selected_orbit_bindings,
|
||||
"matched_bindings": matched_orbit_bindings,
|
||||
"missing_scene_count": orbit_missing_gap_count,
|
||||
"missing_summary": orbit_missing_summary,
|
||||
"selected_rate": selected_orbit_rate,
|
||||
},
|
||||
"coverage": {
|
||||
@@ -1242,6 +1367,9 @@ async def get_statistics_dashboard(
|
||||
"issues": {
|
||||
"issue_total": issue_total,
|
||||
"open_issue_total": open_issue_total,
|
||||
"risk_count": risk_count,
|
||||
"non_orbit_open_issue_total": non_orbit_open_issue_total,
|
||||
"orbit_missing_gap_count": orbit_missing_gap_count,
|
||||
"by_severity": [
|
||||
{"severity": severity, "count": count}
|
||||
for severity, count in sorted(issue_by_severity.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
@@ -1361,12 +1489,16 @@ async def get_statistics(
|
||||
"db_ready_and_cache_exists_count": 0,
|
||||
"db_ready_but_cache_missing_count": 0,
|
||||
}
|
||||
source_xml_consistency = {
|
||||
source_metadata_consistency = {
|
||||
"total_records_count": 0,
|
||||
"xml_detected_count": 0,
|
||||
"xml_missing_count": 0,
|
||||
"xml_parsed_ok_count": 0,
|
||||
"xml_detected_but_unparsed_count": 0,
|
||||
"metadata_document_asset_count": 0,
|
||||
"metadata_document_count": 0,
|
||||
"metadata_document_missing_count": 0,
|
||||
"metadata_parsed_ok_count": 0,
|
||||
"metadata_detected_but_unparsed_count": 0,
|
||||
"geometry_ready_count": 0,
|
||||
"geometry_missing_count": 0,
|
||||
"geometry_not_ready_count": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1399,7 +1531,6 @@ async def get_statistics(
|
||||
)
|
||||
source_rows = source_rows_res.all()
|
||||
source_preview_consistency["total_records_count"] = len(source_rows)
|
||||
source_xml_consistency["total_records_count"] = len(source_rows)
|
||||
|
||||
for (
|
||||
unique_id,
|
||||
@@ -1415,7 +1546,6 @@ async def get_statistics(
|
||||
) in source_rows:
|
||||
if not file_path:
|
||||
source_preview_consistency["preview_missing_count"] += 1
|
||||
source_xml_consistency["xml_missing_count"] += 1
|
||||
continue
|
||||
|
||||
cache_key = unique_id or file_path
|
||||
@@ -1443,34 +1573,64 @@ async def get_statistics(
|
||||
else:
|
||||
source_preview_consistency["db_ready_but_cache_missing_count"] += 1
|
||||
|
||||
scene_dir = file_path if os.path.isdir(file_path) else os.path.dirname(file_path)
|
||||
xml_path = find_xml_file(scene_dir) if scene_dir else None
|
||||
has_xml = bool(xml_path and os.path.exists(xml_path))
|
||||
if has_xml:
|
||||
source_xml_consistency["xml_detected_count"] += 1
|
||||
parsed_ok = any(
|
||||
value is not None and value != ""
|
||||
for value in [
|
||||
scene_center_lon,
|
||||
scene_center_lat,
|
||||
acquisition_time_utc,
|
||||
satellite_mode,
|
||||
receiving_station,
|
||||
product_level,
|
||||
product_unique_id,
|
||||
]
|
||||
)
|
||||
if parsed_ok:
|
||||
source_xml_consistency["xml_parsed_ok_count"] += 1
|
||||
else:
|
||||
source_xml_consistency["xml_detected_but_unparsed_count"] += 1
|
||||
else:
|
||||
source_xml_consistency["xml_missing_count"] += 1
|
||||
active_source_count = await _scalar_count(
|
||||
db,
|
||||
select(func.count(SourceProductAssetORM.id)).where(
|
||||
SourceProductAssetORM.is_active == True,
|
||||
SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
),
|
||||
)
|
||||
metadata_asset_count = await _scalar_count(
|
||||
db,
|
||||
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where(
|
||||
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
),
|
||||
)
|
||||
metadata_doc_count = await _scalar_count(
|
||||
db,
|
||||
select(func.count(SourceMetadataDocumentORM.id)).where(
|
||||
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
),
|
||||
)
|
||||
metadata_parse_issue_count = await _scalar_count(
|
||||
db,
|
||||
select(func.count(SourceMetadataDocumentORM.id)).where(
|
||||
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
SourceMetadataDocumentORM.parse_status != "OK",
|
||||
),
|
||||
)
|
||||
geometry_ready_count = await _scalar_count(
|
||||
db,
|
||||
select(func.count(SARSceneGeometryProfileORM.id)).where(
|
||||
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
SARSceneGeometryProfileORM.metadata_quality == "READY",
|
||||
SARSceneGeometryProfileORM.production_readiness == "READY",
|
||||
),
|
||||
)
|
||||
geometry_total_count = await _scalar_count(
|
||||
db,
|
||||
select(func.count(SARSceneGeometryProfileORM.id)).where(
|
||||
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
|
||||
),
|
||||
)
|
||||
source_metadata_consistency.update(
|
||||
{
|
||||
"total_records_count": active_source_count,
|
||||
"metadata_document_asset_count": metadata_asset_count,
|
||||
"metadata_document_count": metadata_doc_count,
|
||||
"metadata_document_missing_count": max(0, active_source_count - metadata_asset_count),
|
||||
"metadata_parsed_ok_count": max(0, metadata_doc_count - metadata_parse_issue_count),
|
||||
"metadata_detected_but_unparsed_count": metadata_parse_issue_count,
|
||||
"geometry_ready_count": geometry_ready_count,
|
||||
"geometry_missing_count": max(0, active_source_count - geometry_total_count),
|
||||
"geometry_not_ready_count": max(0, geometry_total_count - geometry_ready_count),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("统计源数据时发生错误 (可能是表不存在): %s", e)
|
||||
source_preview_consistency["error"] = str(e)
|
||||
source_xml_consistency["error"] = str(e)
|
||||
source_metadata_consistency["error"] = str(e)
|
||||
|
||||
# 4. AI 质量统计
|
||||
labeled_good_count = sum(1 for record in dinsar_records if record.product.user_label == 1)
|
||||
@@ -1756,7 +1916,16 @@ async def get_statistics(
|
||||
"with_orbit_data_count": with_orbit_data_count,
|
||||
},
|
||||
"source_preview_consistency": source_preview_consistency,
|
||||
"source_xml_consistency": source_xml_consistency,
|
||||
"source_metadata_consistency": source_metadata_consistency,
|
||||
"source_xml_consistency": {
|
||||
"deprecated": True,
|
||||
"replacement": "source_metadata_consistency",
|
||||
"total_records_count": source_metadata_consistency.get("total_records_count", 0),
|
||||
"xml_detected_count": source_metadata_consistency.get("metadata_document_asset_count", 0),
|
||||
"xml_missing_count": 0,
|
||||
"xml_parsed_ok_count": source_metadata_consistency.get("metadata_parsed_ok_count", 0),
|
||||
"xml_detected_but_unparsed_count": 0,
|
||||
},
|
||||
"water_geo_consistency": water_geo_consistency,
|
||||
"pairing_consistency": pairing_consistency,
|
||||
"by_satellite": by_satellite,
|
||||
|
||||
@@ -80,6 +80,16 @@ TERMINAL_ITEM_STATUSES = {
|
||||
_SAFE_POINTER_RE = re.compile(r"[^0-9A-Za-z._-]+")
|
||||
|
||||
|
||||
def _item_status_counts(items: List[DinsarProductionRunItemORM]) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = {}
|
||||
for item in items:
|
||||
status = str(item.status or "").strip().upper()
|
||||
if not status:
|
||||
continue
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _task_type_for_engine(engine_code: str) -> str:
|
||||
normalized = str(engine_code or "").strip().lower()
|
||||
if normalized == "sarscape":
|
||||
@@ -997,53 +1007,59 @@ class DinsarProductionService:
|
||||
)
|
||||
for item in items_result.scalars().all():
|
||||
items_by_run_id.setdefault(item.run_id, []).append(item)
|
||||
|
||||
def serialize_run(run: DinsarProductionRunORM) -> Dict[str, Any]:
|
||||
run_items = items_by_run_id.get(run.run_id, [])
|
||||
item_counts = _item_status_counts(run_items)
|
||||
return {
|
||||
"run_id": run.run_id,
|
||||
"product_family": run.product_family,
|
||||
"engine": run.engine_code,
|
||||
"profile_code": run.profile_code,
|
||||
"status": _public_run_status(run.status),
|
||||
"raw_status": run.status,
|
||||
"started_at": _safe_epoch(run.started_at or run.created_at),
|
||||
"ended_at": _safe_epoch(run.ended_at),
|
||||
"task_id": run.task_id,
|
||||
"workflow_run_id": run.workflow_run_id,
|
||||
"root_dir": run.source_root,
|
||||
"publish_root_dir": run.publish_root_dir,
|
||||
"message": run.latest_message,
|
||||
"summary_json": run.summary_json if isinstance(run.summary_json, dict) else {},
|
||||
"total_items": max(int(run.total_items or 0), len(run_items)),
|
||||
"completed_items": item_counts.get(RUN_ITEM_STATUS_COMPLETED, int(run.completed_items or 0)),
|
||||
"running_items": item_counts.get(RUN_ITEM_STATUS_RUNNING, 0),
|
||||
"pending_items": item_counts.get(RUN_ITEM_STATUS_PENDING, 0),
|
||||
"failed_items": item_counts.get(RUN_ITEM_STATUS_FAILED, int(run.failed_items or 0)),
|
||||
"skipped_items": item_counts.get(RUN_ITEM_STATUS_SKIPPED, int(run.skipped_items or 0)),
|
||||
"cancelled_items": item_counts.get(RUN_ITEM_STATUS_CANCELLED, 0),
|
||||
"items": [
|
||||
{
|
||||
"task_name": item.task_name,
|
||||
"task_alias": item.task_alias,
|
||||
"pair_key": item.pair_key,
|
||||
"status": item.status,
|
||||
"current_step": item.current_step,
|
||||
"latest_run_key": item.latest_run_key,
|
||||
"latest_output_dir": item.latest_output_dir,
|
||||
"latest_manifest_path": item.latest_manifest_path,
|
||||
"last_error": item.last_error,
|
||||
"paths": _build_output_paths(
|
||||
engine_code=run.engine_code,
|
||||
item=item,
|
||||
run_key=str(item.latest_run_key or ""),
|
||||
output_dir=str(item.latest_output_dir or _execution_dir(item, str(item.latest_run_key or ""))),
|
||||
manifest_path=item.latest_manifest_path,
|
||||
)
|
||||
if item.latest_run_key
|
||||
else {},
|
||||
}
|
||||
for item in run_items[:5]
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"runs": [
|
||||
{
|
||||
"run_id": run.run_id,
|
||||
"product_family": run.product_family,
|
||||
"engine": run.engine_code,
|
||||
"profile_code": run.profile_code,
|
||||
"status": _public_run_status(run.status),
|
||||
"raw_status": run.status,
|
||||
"started_at": _safe_epoch(run.started_at or run.created_at),
|
||||
"ended_at": _safe_epoch(run.ended_at),
|
||||
"task_id": run.task_id,
|
||||
"workflow_run_id": run.workflow_run_id,
|
||||
"root_dir": run.source_root,
|
||||
"publish_root_dir": run.publish_root_dir,
|
||||
"message": run.latest_message,
|
||||
"summary_json": run.summary_json if isinstance(run.summary_json, dict) else {},
|
||||
"total_items": run.total_items,
|
||||
"completed_items": run.completed_items,
|
||||
"failed_items": run.failed_items,
|
||||
"skipped_items": run.skipped_items,
|
||||
"items": [
|
||||
{
|
||||
"task_name": item.task_name,
|
||||
"task_alias": item.task_alias,
|
||||
"pair_key": item.pair_key,
|
||||
"status": item.status,
|
||||
"current_step": item.current_step,
|
||||
"latest_run_key": item.latest_run_key,
|
||||
"latest_output_dir": item.latest_output_dir,
|
||||
"latest_manifest_path": item.latest_manifest_path,
|
||||
"last_error": item.last_error,
|
||||
"paths": _build_output_paths(
|
||||
engine_code=run.engine_code,
|
||||
item=item,
|
||||
run_key=str(item.latest_run_key or ""),
|
||||
output_dir=str(item.latest_output_dir or _execution_dir(item, str(item.latest_run_key or ""))),
|
||||
manifest_path=item.latest_manifest_path,
|
||||
)
|
||||
if item.latest_run_key
|
||||
else {},
|
||||
}
|
||||
for item in items_by_run_id.get(run.run_id, [])[:5]
|
||||
],
|
||||
}
|
||||
for run in runs
|
||||
],
|
||||
"runs": [serialize_run(run) for run in runs],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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 ..config import settings
|
||||
@@ -14,25 +14,18 @@ from ..models import (
|
||||
DinsarProductionExecutionORM,
|
||||
DinsarProductionRunItemORM,
|
||||
DinsarProductionRunORM,
|
||||
DinsarResultORM,
|
||||
ResultProductORM,
|
||||
SystemJobORM,
|
||||
SystemTaskORM,
|
||||
TaskLogORM,
|
||||
WorkflowArtifactORM,
|
||||
WorkflowRunORM,
|
||||
WorkflowStepORM,
|
||||
)
|
||||
from .dinsar_production_service import dinsar_production_service
|
||||
|
||||
|
||||
TERMINAL_TASK_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
|
||||
MAINTENANCE_LIST_STATUSES = {"FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
|
||||
MAINTENANCE_LIST_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
|
||||
ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"}
|
||||
ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"}
|
||||
DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"}
|
||||
SUPPORTED_CLEANUP_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA"}
|
||||
DEFAULT_TASK_TYPES = SUPPORTED_CLEANUP_TASK_TYPES | {"PAIRING_CACHE_REBUILD"}
|
||||
SUPPORTED_CLEANUP_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN"}
|
||||
DEFAULT_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA", "PAIRING_CACHE_REBUILD"}
|
||||
LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES = {"COMPLETED", "FAILED", "CANCELLED"}
|
||||
|
||||
|
||||
def _utcnow_naive() -> datetime:
|
||||
@@ -68,8 +61,15 @@ def _path_exists(path: str) -> bool:
|
||||
return bool(path) and os.path.exists(path)
|
||||
|
||||
|
||||
def _safe_count(rows: Iterable[Any]) -> int:
|
||||
return len(list(rows))
|
||||
def _is_within_path(path: str, parent: str) -> bool:
|
||||
path_text = _normalize_path_text(path)
|
||||
parent_text = _normalize_path_text(parent)
|
||||
if not path_text or not parent_text:
|
||||
return False
|
||||
try:
|
||||
return os.path.commonpath([os.path.abspath(path_text), os.path.abspath(parent_text)]) == os.path.abspath(parent_text)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class OpsMaintenanceService:
|
||||
@@ -108,11 +108,12 @@ class OpsMaintenanceService:
|
||||
if item.get("issue_level") in {"warning", "danger"}
|
||||
or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
|
||||
]
|
||||
visible_items = items if status_filter else abnormal_items
|
||||
return {
|
||||
"items": abnormal_items,
|
||||
"items": visible_items,
|
||||
"limit": safe_limit,
|
||||
"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]]:
|
||||
@@ -124,19 +125,11 @@ class OpsMaintenanceService:
|
||||
item_counts: Dict[str, int] = {}
|
||||
execution_counts: Dict[str, int] = {}
|
||||
disk_paths: List[Dict[str, Any]] = []
|
||||
products: List[Dict[str, Any]] = []
|
||||
|
||||
if run is not None:
|
||||
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
|
||||
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id)
|
||||
disk_paths.extend(await self._collect_run_disk_paths(db, run))
|
||||
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)
|
||||
findings, cleanup_supported, cleanup_blockers = self._diagnose_findings(
|
||||
task=task,
|
||||
@@ -152,8 +145,6 @@ class OpsMaintenanceService:
|
||||
"production_run": self._run_payload(run) if run else None,
|
||||
"production_item_counts": item_counts,
|
||||
"production_execution_counts": execution_counts,
|
||||
"result_products": products,
|
||||
"related_tasks": [self._task_payload(item) for item in related_tasks],
|
||||
"recent_logs": recent_logs,
|
||||
"disk_paths": disk_paths,
|
||||
"diagnosis": {
|
||||
@@ -179,15 +170,17 @@ class OpsMaintenanceService:
|
||||
if task_status in ACTIVE_TASK_STATUSES:
|
||||
blockers.append("任务仍处于活动状态,不能清理。")
|
||||
|
||||
db_counts = await self._cleanup_db_counts(db, 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
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"blocked": blocked,
|
||||
"blockers": blockers,
|
||||
"cleanup_supported": cleanup_supported and not blocked,
|
||||
"database_deletes": db_counts,
|
||||
"database_deletes": {},
|
||||
"disk_deletes": disk_deletes,
|
||||
}
|
||||
|
||||
@@ -204,82 +197,17 @@ class OpsMaintenanceService:
|
||||
if preview.get("blocked"):
|
||||
raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"]))
|
||||
|
||||
deleted_db: Dict[str, int] = {}
|
||||
task = await self._get_task(db, task_id)
|
||||
if task is 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)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"deleted_database": deleted_db,
|
||||
"deleted_database": {},
|
||||
"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]:
|
||||
result = await db.execute(select(SystemTaskORM).where(SystemTaskORM.task_id == str(task_id or "").strip()))
|
||||
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]]:
|
||||
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))
|
||||
for execution in result.scalars().all():
|
||||
if execution.output_dir:
|
||||
publish_dir = self._publish_package_dir(execution.output_dir)
|
||||
paths[publish_dir] = self._path_payload(publish_dir, "production_result")
|
||||
log_path = dinsar_production_service.read_run_log(run.run_id, max_bytes=1).get("path")
|
||||
if log_path:
|
||||
paths[_normalize_path_text(log_path)] = self._path_payload(log_path, "run_log")
|
||||
landsar_work_dir = self._landsar_work_dir_for_execution(run, execution)
|
||||
if landsar_work_dir:
|
||||
payload = self._path_payload(landsar_work_dir, "landsar_work")
|
||||
payload["run_key"] = execution.run_key
|
||||
payload["execution_status"] = execution.status
|
||||
paths[_normalize_path_text(landsar_work_dir).lower()] = payload
|
||||
return list(paths.values())
|
||||
|
||||
async def _collect_result_products(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
|
||||
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(
|
||||
def _landsar_work_dir_for_execution(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
run: Optional[DinsarProductionRunORM],
|
||||
) -> List[SystemTaskORM]:
|
||||
source_root = _normalize_path_text(run.source_root if run is not None else "")
|
||||
if not source_root:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(SystemTaskORM)
|
||||
.where(SystemTaskORM.task_type == "COPY_DATA")
|
||||
.order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc())
|
||||
.limit(1000)
|
||||
)
|
||||
tasks = []
|
||||
for task in result.scalars().all():
|
||||
if _normalize_path_text(self._copy_task_dest_dir(task)).lower() == source_root.lower():
|
||||
tasks.append(task)
|
||||
return tasks
|
||||
run: DinsarProductionRunORM,
|
||||
execution: DinsarProductionExecutionORM,
|
||||
) -> str:
|
||||
if _norm_status(run.engine_code) != "LANDSAR":
|
||||
return ""
|
||||
if _norm_status(run.status) in ACTIVE_TASK_STATUSES:
|
||||
return ""
|
||||
if _norm_status(execution.status) not in LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES:
|
||||
return ""
|
||||
work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT)
|
||||
run_key = str(execution.run_key or "").strip()
|
||||
if not work_root or not run_key or Path(run_key).name != run_key or not run_key.startswith("run_"):
|
||||
return ""
|
||||
candidate = _normalize_path_text(os.path.join(work_root, run_key))
|
||||
return candidate if self._is_landsar_work_delete_path(candidate) else ""
|
||||
|
||||
def _diagnose_findings(
|
||||
self,
|
||||
@@ -481,9 +355,9 @@ class OpsMaintenanceService:
|
||||
if status == "FAILED":
|
||||
findings.append("任务已失败,需要人工确认后清理。")
|
||||
elif status == "PARTIAL_SUCCESS":
|
||||
findings.append("任务部分成功,清理前请确认保留策略。")
|
||||
findings.append("任务部分成功,可按需清理 LandSAR 工作目录。")
|
||||
elif status == "CANCELLED":
|
||||
findings.append("任务已取消,可按需清理残留记录和目录。")
|
||||
findings.append("任务已取消,可按需清理 LandSAR 工作目录。")
|
||||
elif status in ACTIVE_TASK_STATUSES:
|
||||
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")]
|
||||
if missing_paths:
|
||||
findings.append(f"有 {len(missing_paths)} 个登记路径已不存在。")
|
||||
landsar_work_paths = [
|
||||
item
|
||||
for item in disk_paths
|
||||
if item.get("kind") == "landsar_work" and item.get("exists")
|
||||
]
|
||||
if landsar_work_paths:
|
||||
findings.append(f"发现 {len(landsar_work_paths)} 个可清理的 LandSAR 工作目录。")
|
||||
|
||||
cleanup_supported = _norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES and not blockers
|
||||
cleanup_supported = (
|
||||
_norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES
|
||||
and not blockers
|
||||
and bool(landsar_work_paths)
|
||||
)
|
||||
return findings, cleanup_supported, blockers
|
||||
|
||||
async def _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:
|
||||
return int((await db.execute(stmt)).scalar_one() or 0)
|
||||
|
||||
def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
targets: Dict[str, Dict[str, Any]] = {}
|
||||
for item in diagnosis.get("disk_paths") or []:
|
||||
if item.get("kind") != "landsar_work":
|
||||
continue
|
||||
path = _normalize_path_text(item.get("path"))
|
||||
if not path:
|
||||
continue
|
||||
@@ -581,29 +426,26 @@ class OpsMaintenanceService:
|
||||
normalized = _normalize_path_text(path)
|
||||
if not normalized:
|
||||
return False
|
||||
roots = [
|
||||
settings.DINSAR_TASK_POOL_ROOT,
|
||||
settings.DINSAR_PRODUCT_DIR,
|
||||
os.path.join(settings.PROJECT_ROOT, "backend", "runtime", "dinsar_production"),
|
||||
]
|
||||
return self._is_landsar_work_delete_path(normalized)
|
||||
|
||||
def _is_landsar_work_delete_path(self, path: str) -> bool:
|
||||
work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT)
|
||||
normalized = _normalize_path_text(path)
|
||||
if not work_root or not normalized:
|
||||
return False
|
||||
root_full = os.path.abspath(work_root)
|
||||
full = os.path.abspath(normalized)
|
||||
for root in roots:
|
||||
root_text = _normalize_path_text(root)
|
||||
if not root_text:
|
||||
continue
|
||||
root_full = os.path.abspath(root_text)
|
||||
if full == root_full:
|
||||
return False
|
||||
try:
|
||||
if os.path.commonpath([full, root_full]) == root_full:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
if full == root_full or not _is_within_path(full, root_full):
|
||||
return False
|
||||
try:
|
||||
relative = os.path.relpath(full, root_full)
|
||||
except ValueError:
|
||||
return False
|
||||
parts = [part for part in relative.split(os.sep) if part]
|
||||
return len(parts) == 1 and parts[0].startswith("run_")
|
||||
|
||||
async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]:
|
||||
delete_production_dirs = bool(options.get("delete_production_dirs", True))
|
||||
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
|
||||
delete_landsar_work_dir = bool(options.get("delete_landsar_work_dir", True))
|
||||
deleted: List[str] = []
|
||||
missing: List[str] = []
|
||||
skipped: List[str] = []
|
||||
@@ -611,13 +453,13 @@ class OpsMaintenanceService:
|
||||
for item in preview.get("disk_deletes") or []:
|
||||
kind = item.get("kind")
|
||||
path = _normalize_path_text(item.get("path"))
|
||||
if kind != "landsar_work":
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not item.get("allowed"):
|
||||
skipped.append(path)
|
||||
continue
|
||||
if kind == "task_pool" and not delete_task_pool_dir:
|
||||
skipped.append(path)
|
||||
continue
|
||||
if kind == "production_result" and not delete_production_dirs:
|
||||
if not delete_landsar_work_dir:
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not _path_exists(path):
|
||||
|
||||
@@ -7,8 +7,10 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from itertools import combinations
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -20,7 +22,7 @@ from sqlalchemy.orm import aliased
|
||||
from geoalchemy2 import Geography
|
||||
from geoalchemy2.shape import to_shape
|
||||
from geoalchemy2.functions import ST_Intersects, ST_Intersection, ST_Area, ST_Centroid, ST_Covers
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.geometry import Polygon, mapping, shape
|
||||
from shapely.ops import unary_union
|
||||
|
||||
from ..models import (
|
||||
@@ -196,6 +198,112 @@ class SpatialService:
|
||||
}
|
||||
return result_pairs, warnings, metadata
|
||||
|
||||
async def find_dinsar_coverage_pairs(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
params: PairingRequest,
|
||||
*,
|
||||
target_date_from: str,
|
||||
target_date_to: str,
|
||||
extension_days: int = 15,
|
||||
max_pairs: int = 200,
|
||||
target_coverage_ratio: float = 0.98,
|
||||
min_new_coverage_ratio: float = 0.0005,
|
||||
aoi_wkt: Optional[str] = None,
|
||||
require_orbit_data: bool = True,
|
||||
) -> Tuple[List[RadarPair], List[str], Dict[str, Any]]:
|
||||
warnings: List[str] = []
|
||||
target_start = self._parse_yyyymmdd(target_date_from, field_name="target_date_from")
|
||||
target_end = self._parse_yyyymmdd(target_date_to, field_name="target_date_to")
|
||||
if target_end < target_start:
|
||||
raise ValueError("target_date_to must be greater than or equal to target_date_from.")
|
||||
|
||||
safe_extension_days = max(0, min(180, int(extension_days or 0)))
|
||||
safe_max_pairs = max(1, min(5000, int(max_pairs or 200)))
|
||||
safe_target_coverage_ratio = max(0.0, min(1.0, float(target_coverage_ratio or 0.98)))
|
||||
safe_min_new_coverage_ratio = max(0.0, min(1.0, float(min_new_coverage_ratio or 0.0)))
|
||||
query_start = target_start - timedelta(days=safe_extension_days)
|
||||
query_end = target_end + timedelta(days=safe_extension_days)
|
||||
|
||||
effective_params = self._normalize_pairing_request(params).model_copy(
|
||||
update={
|
||||
"master_date_from": self._format_yyyymmdd(query_start),
|
||||
"master_date_to": self._format_yyyymmdd(query_end),
|
||||
"slave_date_from": self._format_yyyymmdd(query_start),
|
||||
"slave_date_to": self._format_yyyymmdd(query_end),
|
||||
"strategy": "dinsar_province_coverage",
|
||||
}
|
||||
)
|
||||
pairing_status = await pairing_state_service.get_pairing_system_status(db)
|
||||
cache_status = str(pairing_status.get("status") or "UNINITIALIZED")
|
||||
scene_count = int(pairing_status.get("scene_count") or 0)
|
||||
pair_count = int(pairing_status.get("pair_count") or 0)
|
||||
degraded = bool(pairing_status.get("needs_rebuild"))
|
||||
|
||||
if cache_status in {"FAILED", "UNINITIALIZED", "ERROR"} or (scene_count > 1 and pair_count == 0):
|
||||
raise RuntimeError(
|
||||
"Pairing candidate cache is not available. Repair or rebuild the pairing foundation first."
|
||||
)
|
||||
if degraded:
|
||||
warnings.append(
|
||||
f"Pairing foundation status is {cache_status}; coverage plan uses current cached candidates."
|
||||
)
|
||||
|
||||
candidate_pool = await self._query_pairing_metric_cache(
|
||||
db,
|
||||
effective_params,
|
||||
aoi_wkt=aoi_wkt,
|
||||
require_orbit_data=require_orbit_data,
|
||||
)
|
||||
selected_candidates, coverage_meta, strategy_warnings = self._apply_province_coverage_strategy(
|
||||
candidate_pool,
|
||||
target_start=target_start,
|
||||
target_end=target_end,
|
||||
query_start=query_start,
|
||||
query_end=query_end,
|
||||
max_pairs=safe_max_pairs,
|
||||
target_coverage_ratio=safe_target_coverage_ratio,
|
||||
min_new_coverage_ratio=safe_min_new_coverage_ratio,
|
||||
aoi_wkt=aoi_wkt,
|
||||
)
|
||||
warnings.extend(strategy_warnings)
|
||||
if not selected_candidates:
|
||||
warnings.extend(
|
||||
await self._build_empty_pairing_diagnostics(
|
||||
db,
|
||||
effective_params,
|
||||
aoi_wkt=aoi_wkt,
|
||||
require_orbit_data=require_orbit_data,
|
||||
)
|
||||
)
|
||||
|
||||
for candidate in selected_candidates:
|
||||
candidate["selection_strategy"] = "dinsar_province_coverage"
|
||||
self._ensure_candidate_identity(candidate)
|
||||
|
||||
network_run_id = await self._persist_network_run(
|
||||
db,
|
||||
params=effective_params,
|
||||
aoi_wkt=aoi_wkt,
|
||||
require_orbit_data=require_orbit_data,
|
||||
warnings=warnings,
|
||||
candidate_pool=candidate_pool,
|
||||
selected_candidates=selected_candidates,
|
||||
)
|
||||
|
||||
await self._attach_dinsar_production_summaries(db, selected_candidates)
|
||||
result_pairs = self._generate_task_names(self._build_radar_pairs(selected_candidates))
|
||||
metadata = {
|
||||
"fallback_used": False,
|
||||
"degraded": degraded,
|
||||
"policy_version": PAIRING_POLICY_VERSION,
|
||||
"network_run_id": network_run_id,
|
||||
"candidate_count": len(candidate_pool),
|
||||
"selected_edge_count": len(result_pairs),
|
||||
"coverage": coverage_meta,
|
||||
}
|
||||
return result_pairs, warnings, metadata
|
||||
|
||||
def _normalize_pairing_request(self, params: PairingRequest) -> PairingRequest:
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
@@ -408,6 +516,27 @@ class SpatialService:
|
||||
selection_strategy=candidate.get("selection_strategy"),
|
||||
selection_score=float(selection_score) if selection_score is not None else None,
|
||||
selection_reason=candidate.get("selection_reason"),
|
||||
coverage_rank=(
|
||||
int(candidate["coverage_rank"])
|
||||
if candidate.get("coverage_rank") is not None
|
||||
else None
|
||||
),
|
||||
aoi_new_area_ratio=(
|
||||
float(candidate["aoi_new_area_ratio"])
|
||||
if candidate.get("aoi_new_area_ratio") is not None
|
||||
else None
|
||||
),
|
||||
aoi_pair_area_ratio=(
|
||||
float(candidate["aoi_pair_area_ratio"])
|
||||
if candidate.get("aoi_pair_area_ratio") is not None
|
||||
else None
|
||||
),
|
||||
aoi_coverage_ratio_after=(
|
||||
float(candidate["aoi_coverage_ratio_after"])
|
||||
if candidate.get("aoi_coverage_ratio_after") is not None
|
||||
else None
|
||||
),
|
||||
effective_coverage_geojson=candidate.get("effective_coverage_geojson"),
|
||||
time_baseline_days=int(candidate["days"]),
|
||||
spatial_baseline_meters=float(candidate["dist"]),
|
||||
scene_center_distance_meters=float(
|
||||
@@ -701,6 +830,253 @@ class SpatialService:
|
||||
output.append(item)
|
||||
return output
|
||||
|
||||
def _apply_province_coverage_strategy(
|
||||
self,
|
||||
candidate_pool: List[dict],
|
||||
*,
|
||||
target_start: datetime,
|
||||
target_end: datetime,
|
||||
query_start: datetime,
|
||||
query_end: datetime,
|
||||
max_pairs: int,
|
||||
target_coverage_ratio: float,
|
||||
min_new_coverage_ratio: float,
|
||||
aoi_wkt: Optional[str],
|
||||
) -> Tuple[List[dict], Dict[str, Any], List[str]]:
|
||||
warnings: List[str] = []
|
||||
target_days = self._date_set(target_start, target_end)
|
||||
covered_days: set[datetime] = set()
|
||||
selected: List[dict] = []
|
||||
remaining = [candidate for candidate in candidate_pool if self._candidate_date_window(candidate) is not None]
|
||||
seen_metric_ids: set[int] = set()
|
||||
aoi_poly = self._parse_optional_aoi_polygon(aoi_wkt)
|
||||
geometry_cache: Dict[int, Any] = {}
|
||||
selected_coverage = Polygon()
|
||||
aoi_area = float(aoi_poly.area or 0.0) if aoi_poly is not None else 0.0
|
||||
stop_reason = "no_more_gain"
|
||||
max_pairs_reached = False
|
||||
|
||||
while remaining and len(selected) < max_pairs:
|
||||
best_candidate = None
|
||||
best_score: Optional[Tuple[float, float, float, float, float, float, str]] = None
|
||||
current_aoi_coverage_ratio = (
|
||||
float(selected_coverage.area or 0.0) / aoi_area
|
||||
if aoi_poly is not None and aoi_area > 0
|
||||
else None
|
||||
)
|
||||
if current_aoi_coverage_ratio is not None and current_aoi_coverage_ratio >= target_coverage_ratio:
|
||||
stop_reason = "target_coverage_reached"
|
||||
break
|
||||
for candidate in remaining:
|
||||
window = self._candidate_date_window(candidate)
|
||||
if window is None:
|
||||
continue
|
||||
candidate_days = self._date_set(*window) & target_days
|
||||
new_days = candidate_days - covered_days
|
||||
new_area = 0.0
|
||||
candidate_area = 0.0
|
||||
if aoi_poly is not None:
|
||||
candidate_geom = self._get_candidate_intersection_geom(
|
||||
candidate,
|
||||
aoi_poly=aoi_poly,
|
||||
geometry_cache=geometry_cache,
|
||||
)
|
||||
if candidate_geom is not None and not candidate_geom.is_empty:
|
||||
candidate_area = float(candidate_geom.area or 0.0)
|
||||
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
|
||||
if aoi_poly is not None:
|
||||
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
|
||||
if new_area_ratio <= 0:
|
||||
continue
|
||||
elif not new_days:
|
||||
continue
|
||||
quality_score = float(candidate.get("dinsar_quality_score") or 0.0)
|
||||
overlap = float(candidate.get("overlap_ratio") or 0.0)
|
||||
temporal_days = float(candidate.get("days") or 0.0)
|
||||
distance = float(candidate.get("scene_center_distance_meters") or candidate.get("dist") or 0.0)
|
||||
if aoi_poly is not None and aoi_area > 0:
|
||||
score = (
|
||||
new_area_ratio,
|
||||
candidate_area / aoi_area,
|
||||
float(len(new_days)) / max(1, len(target_days)),
|
||||
overlap,
|
||||
quality_score,
|
||||
-temporal_days - (distance / 1000000.0),
|
||||
str(candidate.get("pair_uid") or ""),
|
||||
)
|
||||
else:
|
||||
score = (
|
||||
float(len(new_days)),
|
||||
float(len(candidate_days)),
|
||||
overlap,
|
||||
quality_score,
|
||||
0.0,
|
||||
-temporal_days - (distance / 1000000.0),
|
||||
str(candidate.get("pair_uid") or ""),
|
||||
)
|
||||
if best_score is None or score > best_score:
|
||||
best_candidate = candidate
|
||||
best_score = score
|
||||
|
||||
if best_candidate is None:
|
||||
stop_reason = "no_candidate_adds_coverage"
|
||||
break
|
||||
if aoi_poly is None and covered_days == target_days:
|
||||
stop_reason = "target_time_reached"
|
||||
break
|
||||
|
||||
window = self._candidate_date_window(best_candidate)
|
||||
candidate_days = self._date_set(*window) & target_days if window else set()
|
||||
new_days = candidate_days - covered_days
|
||||
candidate_geom = self._get_candidate_intersection_geom(
|
||||
best_candidate,
|
||||
aoi_poly=aoi_poly,
|
||||
geometry_cache=geometry_cache,
|
||||
) if aoi_poly is not None else None
|
||||
new_area = 0.0
|
||||
candidate_area = 0.0
|
||||
if candidate_geom is not None and not candidate_geom.is_empty:
|
||||
candidate_area = float(candidate_geom.area or 0.0)
|
||||
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
|
||||
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
|
||||
if aoi_poly is not None and len(selected) > 0 and new_area_ratio < min_new_coverage_ratio:
|
||||
stop_reason = "marginal_gain_below_threshold"
|
||||
break
|
||||
best_candidate["selection_reason"] = "province_coverage_new_days"
|
||||
if aoi_poly is not None and new_area > 0:
|
||||
best_candidate["selection_reason"] = "province_aoi_new_area"
|
||||
best_candidate["selection_score"] = float(best_score[0] if best_score else len(new_days))
|
||||
best_candidate["coverage_rank"] = len(selected) + 1
|
||||
best_candidate["coverage_new_days"] = len(new_days)
|
||||
best_candidate["coverage_total_days"] = len(candidate_days)
|
||||
best_candidate["aoi_new_area_ratio"] = new_area_ratio if aoi_area > 0 else None
|
||||
best_candidate["aoi_pair_area_ratio"] = (candidate_area / aoi_area) if aoi_area > 0 else None
|
||||
next_selected_coverage = selected_coverage
|
||||
if candidate_geom is not None and not candidate_geom.is_empty:
|
||||
next_selected_coverage = unary_union([selected_coverage, candidate_geom])
|
||||
best_candidate["aoi_coverage_ratio_after"] = (
|
||||
float(next_selected_coverage.area or 0.0) / aoi_area
|
||||
if aoi_area > 0 else None
|
||||
)
|
||||
best_candidate["effective_coverage_geojson"] = self._geometry_to_geojson(candidate_geom)
|
||||
best_candidate["target_coverage_ratio_after"] = (
|
||||
len(covered_days | candidate_days) / max(1, len(target_days))
|
||||
)
|
||||
selected.append(best_candidate)
|
||||
covered_days |= candidate_days
|
||||
selected_coverage = next_selected_coverage
|
||||
seen_metric_ids.add(int(best_candidate.get("metric_cache_ref_id") or 0))
|
||||
remaining = [
|
||||
candidate for candidate in remaining
|
||||
if int(candidate.get("metric_cache_ref_id") or 0) not in seen_metric_ids
|
||||
]
|
||||
|
||||
if remaining and len(selected) >= max_pairs:
|
||||
max_pairs_reached = True
|
||||
stop_reason = "max_pairs_reached"
|
||||
uncovered_ranges = self._date_ranges_from_days(target_days - covered_days)
|
||||
if uncovered_ranges:
|
||||
warnings.append(
|
||||
"Coverage plan did not fully cover the requested time range. "
|
||||
f"Uncovered ranges: {', '.join(f'{item[0]}~{item[1]}' for item in uncovered_ranges[:6])}"
|
||||
)
|
||||
if max_pairs_reached:
|
||||
warnings.append(
|
||||
f"Coverage planning reached task limit max_pairs={max_pairs}; increase the limit for better spatial coverage."
|
||||
)
|
||||
selected_area = float(selected_coverage.area or 0.0) if aoi_poly is not None else 0.0
|
||||
aoi_coverage_ratio = (selected_area / aoi_area) if aoi_area > 0 else None
|
||||
if aoi_coverage_ratio is not None and aoi_coverage_ratio < target_coverage_ratio:
|
||||
warnings.append(
|
||||
f"AOI spatial coverage is {aoi_coverage_ratio:.1%}; target is {target_coverage_ratio:.1%}."
|
||||
)
|
||||
|
||||
temporal_ratio = len(covered_days) / max(1, len(target_days))
|
||||
coverage_meta = {
|
||||
"strategy": "dinsar_province_coverage",
|
||||
"optimization_goal": "minimize_pair_count_for_aoi_spatial_coverage",
|
||||
"greedy_rule": "select_pair_with_largest_new_aoi_intersection_area_each_step",
|
||||
"stop_reason": stop_reason,
|
||||
"max_pairs": max_pairs,
|
||||
"target_coverage_ratio": round(target_coverage_ratio, 6),
|
||||
"min_new_coverage_ratio": round(min_new_coverage_ratio, 6),
|
||||
"coverage_basis": "aoi_spatial" if aoi_coverage_ratio is not None else "temporal",
|
||||
"target_date_from": self._format_yyyymmdd(target_start),
|
||||
"target_date_to": self._format_yyyymmdd(target_end),
|
||||
"query_date_from": self._format_yyyymmdd(query_start),
|
||||
"query_date_to": self._format_yyyymmdd(query_end),
|
||||
"target_day_count": len(target_days),
|
||||
"covered_day_count": len(covered_days),
|
||||
"temporal_coverage_ratio": round(temporal_ratio, 6),
|
||||
"aoi_coverage_ratio": round(aoi_coverage_ratio, 6) if aoi_coverage_ratio is not None else None,
|
||||
"coverage_ratio": round(aoi_coverage_ratio if aoi_coverage_ratio is not None else temporal_ratio, 6),
|
||||
"uncovered_ranges": [
|
||||
{"date_from": start, "date_to": end}
|
||||
for start, end in uncovered_ranges
|
||||
],
|
||||
"selected_pair_count": len(selected),
|
||||
"candidate_count": len(candidate_pool),
|
||||
}
|
||||
return selected, coverage_meta, warnings
|
||||
|
||||
def _candidate_date_window(self, candidate: dict) -> Optional[Tuple[datetime, datetime]]:
|
||||
master = candidate.get("master")
|
||||
slave = candidate.get("slave")
|
||||
master_date = self._try_parse_yyyymmdd(getattr(master, "imaging_date", None))
|
||||
slave_date = self._try_parse_yyyymmdd(getattr(slave, "imaging_date", None))
|
||||
if master_date is None or slave_date is None:
|
||||
return None
|
||||
return (master_date, slave_date) if master_date <= slave_date else (slave_date, master_date)
|
||||
|
||||
def _sort_coverage_candidates(self, candidates: List[dict]) -> List[dict]:
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
str(getattr(item.get("master"), "imaging_date", "") or ""),
|
||||
str(getattr(item.get("slave"), "imaging_date", "") or ""),
|
||||
-float(item.get("selection_score") or 0.0),
|
||||
str(item.get("pair_uid") or ""),
|
||||
),
|
||||
)
|
||||
|
||||
def _date_set(self, start: datetime, end: datetime) -> set[datetime]:
|
||||
if end < start:
|
||||
return set()
|
||||
return {start + timedelta(days=offset) for offset in range((end - start).days + 1)}
|
||||
|
||||
def _date_ranges_from_days(self, days: set[datetime]) -> List[Tuple[str, str]]:
|
||||
if not days:
|
||||
return []
|
||||
ordered = sorted(days)
|
||||
ranges: List[Tuple[datetime, datetime]] = []
|
||||
start = previous = ordered[0]
|
||||
for day in ordered[1:]:
|
||||
if day == previous + timedelta(days=1):
|
||||
previous = day
|
||||
continue
|
||||
ranges.append((start, previous))
|
||||
start = previous = day
|
||||
ranges.append((start, previous))
|
||||
return [(self._format_yyyymmdd(start), self._format_yyyymmdd(end)) for start, end in ranges]
|
||||
|
||||
def _parse_yyyymmdd(self, value: str, *, field_name: str) -> datetime:
|
||||
parsed = self._try_parse_yyyymmdd(value)
|
||||
if parsed is None:
|
||||
raise ValueError(f"{field_name} must be YYYYMMDD.")
|
||||
return parsed
|
||||
|
||||
def _try_parse_yyyymmdd(self, value: Any) -> Optional[datetime]:
|
||||
text_value = str(value or "").strip()
|
||||
if not re.match(r"^\d{8}$", text_value):
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(text_value, "%Y%m%d")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _format_yyyymmdd(self, value: datetime) -> str:
|
||||
return value.strftime("%Y%m%d")
|
||||
|
||||
async def _persist_network_run(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -797,6 +1173,13 @@ class SpatialService:
|
||||
if candidate.get("pair_aoi_overlap_ratio") is not None
|
||||
else None
|
||||
),
|
||||
"coverage_rank": candidate.get("coverage_rank"),
|
||||
"coverage_new_days": candidate.get("coverage_new_days"),
|
||||
"coverage_total_days": candidate.get("coverage_total_days"),
|
||||
"aoi_new_area_ratio": candidate.get("aoi_new_area_ratio"),
|
||||
"aoi_pair_area_ratio": candidate.get("aoi_pair_area_ratio"),
|
||||
"aoi_coverage_ratio_after": candidate.get("aoi_coverage_ratio_after"),
|
||||
"target_coverage_ratio_after": candidate.get("target_coverage_ratio_after"),
|
||||
}
|
||||
|
||||
def _stable_sha1(self, value: Any) -> str:
|
||||
@@ -949,7 +1332,12 @@ class SpatialService:
|
||||
warnings.extend(strategy_warnings)
|
||||
|
||||
edges: List[Dict[str, Any]] = []
|
||||
for edge_rank, candidate in enumerate(self._sorted_candidates(selected_candidates), start=1):
|
||||
ordered_candidates = (
|
||||
selected_candidates
|
||||
if params.strategy == "dinsar_province_coverage"
|
||||
else self._sorted_candidates(selected_candidates)
|
||||
)
|
||||
for edge_rank, candidate in enumerate(ordered_candidates, start=1):
|
||||
master = candidate["master"]
|
||||
slave = candidate["slave"]
|
||||
edges.append(
|
||||
@@ -1820,9 +2208,14 @@ class SpatialService:
|
||||
return geometry_cache[cache_key]
|
||||
|
||||
try:
|
||||
master_poly = Polygon(candidate["master"].coverage_polygon)
|
||||
slave_poly = Polygon(candidate["slave"].coverage_polygon)
|
||||
if master_poly.is_empty or slave_poly.is_empty:
|
||||
master_poly = self._coverage_polygon_to_shape(getattr(candidate["master"], "coverage_polygon", None))
|
||||
slave_poly = self._coverage_polygon_to_shape(getattr(candidate["slave"], "coverage_polygon", None))
|
||||
if (
|
||||
master_poly is None
|
||||
or slave_poly is None
|
||||
or master_poly.is_empty
|
||||
or slave_poly.is_empty
|
||||
):
|
||||
geometry_cache[cache_key] = None
|
||||
return None
|
||||
pair_geom = master_poly.intersection(slave_poly)
|
||||
@@ -1834,6 +2227,44 @@ class SpatialService:
|
||||
geometry_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
def _coverage_polygon_to_shape(self, coverage_polygon: Any):
|
||||
if not coverage_polygon:
|
||||
return None
|
||||
try:
|
||||
if isinstance(coverage_polygon, dict):
|
||||
if coverage_polygon.get("type") == "Feature":
|
||||
geometry = coverage_polygon.get("geometry")
|
||||
if not geometry:
|
||||
return None
|
||||
geom = shape(geometry)
|
||||
else:
|
||||
geom = shape(coverage_polygon)
|
||||
elif isinstance(coverage_polygon, list):
|
||||
points = []
|
||||
for point in coverage_polygon:
|
||||
if isinstance(point, (list, tuple)) and len(point) >= 2:
|
||||
lon = float(point[0])
|
||||
lat = float(point[1])
|
||||
points.append((lon, lat))
|
||||
if len(points) < 3:
|
||||
return None
|
||||
geom = Polygon(points)
|
||||
else:
|
||||
return None
|
||||
if geom.is_empty or not geom.is_valid:
|
||||
return None
|
||||
return geom
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _geometry_to_geojson(self, geometry: Any) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
if geometry is None or geometry.is_empty:
|
||||
return None
|
||||
return mapping(geometry)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _score_sbas_candidate(
|
||||
self,
|
||||
candidate: dict,
|
||||
|
||||
@@ -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.
|
||||
@@ -45,7 +45,7 @@
|
||||
## 2026-06-21 页眉与生产面板补充
|
||||
|
||||
- 页眉移除 DB、Worker、IDL、Ollama、Nginx 等运行状态灯,系统健康状态集中放在“运行维护”模块。
|
||||
- 页眉改为单位 logo、单位名称、系统名称、授权摘要、任务摘要和用户操作。
|
||||
- 页眉改为单位 logo、单位名称、系统名称、任务摘要和用户操作。
|
||||
- 单位名、系统名、页眉标语和 logo URL 支持通过 `VITE_APP_*` 环境变量配置,默认使用 `frontend/src/logo.jpg`。
|
||||
- 新增 `PRODUCT.md`,记录系统面向科研工程单位的产品定位:科研、专业、克制,同时要求严谨、稳定、工程化。
|
||||
- SBAS 生产面板中高曝光的 Runtime Status、Task queue、Workflow 说明文案改为中文表达,保留必要英文术语。
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
Result extraction and access-control audit: current D-InSAR export/registration boundaries, placeholder channels, admin/viewer limitations, and recommended exporter/operator/admin permission model.
|
||||
- [RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md](RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md)
|
||||
Result delivery/download design: asynchronous delivery packages, user-owned downloads, temporary delivery root, DB maintenance, and placeholder boundaries for SBAS/LT-1/Sentinel-1/GF3 ortho.
|
||||
- [RESULT_CATALOG_SEARCH_DELIVERY_WORKBENCH_DESIGN_20260708.md](RESULT_CATALOG_SEARCH_DELIVERY_WORKBENCH_DESIGN_20260708.md)
|
||||
Result catalog search and delivery workbench design: server-side catalog search, AOI/map preview, unified delivery item schema, selection basket, and staged implementation plan.
|
||||
- [DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md](DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md)
|
||||
D-InSAR 淇濈暀 ENVI/SARscape銆丩andSAR銆丟amma/PyINT 涓夊紩鎿庯紝閫€鍑?ISCE2锛岀粺涓€ Task_Pool銆佺粨鏋滆仛鍚堝拰涓棿鏂囦欢娓呯悊鐨勫綋鍓嶈璁°€?
|
||||
- [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md)
|
||||
|
||||
@@ -0,0 +1,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
File diff suppressed because it is too large
Load Diff
+7
-37
@@ -184,22 +184,12 @@ function App() {
|
||||
// --- Zustand stores ---
|
||||
const {
|
||||
currentUser, setCurrentUser, authChecked, setAuthChecked,
|
||||
licenseStatus, setLicenseStatus, licenseLoading, setLicenseLoading,
|
||||
licenseUploadStatus, setLicenseUploadStatus, licenseFileName, setLicenseFileName,
|
||||
healthStatus, setHealthStatus, healthLoading, setHealthLoading, healthError, setHealthError,
|
||||
} = useAuthStore(useShallow((state) => ({
|
||||
currentUser: state.currentUser,
|
||||
setCurrentUser: state.setCurrentUser,
|
||||
authChecked: state.authChecked,
|
||||
setAuthChecked: state.setAuthChecked,
|
||||
licenseStatus: state.licenseStatus,
|
||||
setLicenseStatus: state.setLicenseStatus,
|
||||
licenseLoading: state.licenseLoading,
|
||||
setLicenseLoading: state.setLicenseLoading,
|
||||
licenseUploadStatus: state.licenseUploadStatus,
|
||||
setLicenseUploadStatus: state.setLicenseUploadStatus,
|
||||
licenseFileName: state.licenseFileName,
|
||||
setLicenseFileName: state.setLicenseFileName,
|
||||
healthStatus: state.healthStatus,
|
||||
setHealthStatus: state.setHealthStatus,
|
||||
healthLoading: state.healthLoading,
|
||||
@@ -405,13 +395,12 @@ function App() {
|
||||
const floodPairPreviewLayerRef = useRef(null);
|
||||
const floodVectorLayersRef = useRef({});
|
||||
const mapRegionLayerRef = useRef(null);
|
||||
const prevLicenseOkRef = useRef(false);
|
||||
const prevAppReadyRef = useRef(false);
|
||||
const initializeAppDataRef = useRef(null);
|
||||
const addLogRef = useRef(addLog);
|
||||
const handleTaskCompletionRef = useRef(null);
|
||||
const updateLayerTooltipRef = useRef(null);
|
||||
const radarSearchRequestSeqRef = useRef(0);
|
||||
const licenseFileRef = useRef(null);
|
||||
const foundPairsRef = useRef(foundPairs);
|
||||
const hazardLayersRef = useRef({});
|
||||
const dinsarResultLayersRef = useRef({});
|
||||
@@ -712,14 +701,10 @@ function App() {
|
||||
const {
|
||||
handleLoginSuccess,
|
||||
handleLogout,
|
||||
fetchLicenseStatus,
|
||||
fetchHealthStatus,
|
||||
handleLicenseUpload,
|
||||
} = useAppAuthLifecycle({
|
||||
ensureCanOperate,
|
||||
clearRadarSearchResults,
|
||||
radarSearchRequestSeqRef,
|
||||
prevLicenseOkRef,
|
||||
aoeLayerRef,
|
||||
activeLayersRef,
|
||||
radarPreviewLayersRef,
|
||||
@@ -727,13 +712,9 @@ function App() {
|
||||
setCurrentUser,
|
||||
setAuthChecked,
|
||||
setPendingTaskIds,
|
||||
setLicenseLoading,
|
||||
setLicenseStatus,
|
||||
setHealthLoading,
|
||||
setHealthError,
|
||||
setHealthStatus,
|
||||
setLicenseFileName,
|
||||
setLicenseUploadStatus,
|
||||
setAoiLayer,
|
||||
setAllData,
|
||||
setRadarPagination,
|
||||
@@ -838,14 +819,14 @@ function App() {
|
||||
}, [baseLayerKey, currentUser?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseLoading || !authChecked) return;
|
||||
const isOk = !!licenseStatus?.ok && !!currentUser;
|
||||
const prevOk = prevLicenseOkRef.current;
|
||||
if (isOk && !prevOk) {
|
||||
if (!authChecked) return;
|
||||
const isReady = !!currentUser;
|
||||
const wasReady = prevAppReadyRef.current;
|
||||
if (isReady && !wasReady) {
|
||||
initializeAppDataRef.current?.();
|
||||
}
|
||||
prevLicenseOkRef.current = isOk;
|
||||
}, [licenseLoading, licenseStatus?.ok, authChecked, currentUser]);
|
||||
prevAppReadyRef.current = isReady;
|
||||
}, [authChecked, currentUser]);
|
||||
|
||||
const {
|
||||
fetchDinsarResults,
|
||||
@@ -917,7 +898,6 @@ function App() {
|
||||
handleCancelActiveTasks,
|
||||
} = useGlobalTaskControl({
|
||||
currentUser,
|
||||
licenseOk: !!licenseStatus?.ok,
|
||||
activeTasks,
|
||||
setActiveTasks,
|
||||
setRuntimeSummary,
|
||||
@@ -2002,7 +1982,6 @@ function App() {
|
||||
const selectedPairsCount = foundPairs.filter(p => p.isSelected).length;
|
||||
const hasEnoughRadarScenesForPlanning = radarImagingDates.length >= 2;
|
||||
|
||||
const licenseOk = !!licenseStatus?.ok;
|
||||
const avgTaskProgress = activeTasks.length
|
||||
? Math.round(activeTasks.reduce((sum, t) => sum + (t.progress || 0), 0) / activeTasks.length)
|
||||
: 0;
|
||||
@@ -2118,7 +2097,6 @@ function App() {
|
||||
activeTasks={activeTasks}
|
||||
avgTaskProgress={avgTaskProgress}
|
||||
runtimeSummary={runtimeSummary}
|
||||
licenseStatus={licenseStatus}
|
||||
healthStatus={healthStatus}
|
||||
healthLoading={healthLoading}
|
||||
healthError={healthError}
|
||||
@@ -2136,7 +2114,6 @@ function App() {
|
||||
currentUser={currentUser}
|
||||
language={language}
|
||||
apiEndpoint={apiClient.defaults.baseURL}
|
||||
licenseOk={licenseOk}
|
||||
foundPairs={foundPairs}
|
||||
dinsarTotal={dinsarPagination.total}
|
||||
selectedPairsCount={selectedPairsCount}
|
||||
@@ -2192,14 +2169,7 @@ function App() {
|
||||
onPairingAoiModeChange={handlePairingAoiModeChange}
|
||||
onPairingProvinceChange={handlePairingProvinceChange}
|
||||
onPairingCityChange={handlePairingCityChange}
|
||||
licenseLoading={licenseLoading}
|
||||
licenseStatus={licenseStatus}
|
||||
isAdmin={isAdmin}
|
||||
licenseFileRef={licenseFileRef}
|
||||
onUploadFile={handleLicenseUpload}
|
||||
onRefreshLicenseStatus={fetchLicenseStatus}
|
||||
licenseFileName={licenseFileName}
|
||||
licenseUploadStatus={licenseUploadStatus}
|
||||
activeTasks={activeTasks}
|
||||
runtimeSummary={runtimeSummary}
|
||||
showCancelTask={showCancelTask}
|
||||
|
||||
@@ -12,9 +12,6 @@ const ACTION_LABELS = {
|
||||
logout: '退出登录',
|
||||
user_created: '创建用户',
|
||||
user_updated: '更新用户',
|
||||
license_uploaded: '上传授权文件',
|
||||
license_upload_failed: '上传授权失败',
|
||||
license_refreshed: '刷新授权状态',
|
||||
task_queued: '任务入队',
|
||||
batch_created: '创建任务批次',
|
||||
batch_marked_complete: '批次标记完成',
|
||||
|
||||
@@ -299,15 +299,18 @@ function safeCount(value) {
|
||||
|
||||
function getRunCounts(run) {
|
||||
const completed = safeCount(run?.completed_items);
|
||||
const running = safeCount(run?.running_items);
|
||||
const pending = safeCount(run?.pending_items);
|
||||
const failed = safeCount(run?.failed_items);
|
||||
const skipped = safeCount(run?.skipped_items);
|
||||
const cancelled = safeCount(run?.cancelled_items);
|
||||
const total = safeCount(run?.total_items);
|
||||
return { completed, failed, skipped, total };
|
||||
return { completed, running, pending, failed, skipped, cancelled, total };
|
||||
}
|
||||
|
||||
function hasRunCounts(run) {
|
||||
const { completed, failed, skipped, total } = getRunCounts(run);
|
||||
return [completed, failed, skipped, total].some(value => value != null);
|
||||
const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
|
||||
return [completed, running, pending, failed, skipped, cancelled, total].some(value => value != null);
|
||||
}
|
||||
|
||||
function hasMixedRunOutcome(run) {
|
||||
@@ -345,11 +348,14 @@ function getDisplayTaskStatus(task, relatedRun = null) {
|
||||
}
|
||||
|
||||
function formatRunCounts(run) {
|
||||
const { completed, failed, skipped, total } = getRunCounts(run);
|
||||
const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
|
||||
const parts = [];
|
||||
if (running != null && running > 0) parts.push(`运行中 ${running}`);
|
||||
if (completed != null) parts.push(`成功 ${completed}`);
|
||||
if (failed != null) parts.push(`失败 ${failed}`);
|
||||
if (skipped != null && skipped > 0) parts.push(`跳过 ${skipped}`);
|
||||
if (cancelled != null && cancelled > 0) parts.push(`取消 ${cancelled}`);
|
||||
if (pending != null && pending > 0) parts.push(`待处理 ${pending}`);
|
||||
if (total != null) parts.push(`总数 ${total}`);
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { syncWaterScenesFromDisk } from './api/water';
|
||||
import { listEngines, runWslCheck } from './api/dinsarProduction';
|
||||
import { getOrbitStatus, syncOrbitPools } from './api/orbit';
|
||||
import LogManagementPanel from './LogManagementPanel';
|
||||
import OpsTaskMaintenancePanel from './OpsTaskMaintenancePanel';
|
||||
|
||||
const toNumber = (value) => {
|
||||
const parsed = Number(value);
|
||||
@@ -129,7 +128,7 @@ const formatSourceRootRole = (role, en = false) => {
|
||||
const buildConsistencySummary = (stats, en = false) => {
|
||||
const dinsar = stats?.dinsar_cache_consistency || {};
|
||||
const preview = stats?.source_preview_consistency || {};
|
||||
const xml = stats?.source_xml_consistency || {};
|
||||
const metadata = stats?.source_metadata_consistency || {};
|
||||
const water = stats?.water_geo_consistency || {};
|
||||
|
||||
const issues = [
|
||||
@@ -164,15 +163,15 @@ const buildConsistencySummary = (stats, en = false) => {
|
||||
level: 'warn',
|
||||
},
|
||||
{
|
||||
key: 'xml_unparsed',
|
||||
label: en ? 'Source: XML detected but key fields not imported' : '源影像:检测到 XML 但关键字段未入库',
|
||||
count: toNumber(xml.xml_detected_but_unparsed_count),
|
||||
key: 'metadata_parse_issue',
|
||||
label: en ? 'Source: metadata document parse issues' : '源影像:元数据文档解析异常',
|
||||
count: toNumber(metadata.metadata_detected_but_unparsed_count),
|
||||
level: 'warn',
|
||||
},
|
||||
{
|
||||
key: 'xml_missing',
|
||||
label: en ? 'Source: XML not detected' : '源影像:未检测到 XML',
|
||||
count: toNumber(xml.xml_missing_count),
|
||||
key: 'metadata_missing',
|
||||
label: en ? 'Source: metadata document not registered' : '源影像:元数据文档未入库',
|
||||
count: toNumber(metadata.metadata_document_missing_count),
|
||||
level: 'warn',
|
||||
},
|
||||
{
|
||||
@@ -1508,7 +1507,6 @@ const HealthCheckPanel = ({ currentUser }) => {
|
||||
)}
|
||||
|
||||
{/* 日志管理 */}
|
||||
<OpsTaskMaintenancePanel isAdmin={isAdmin} />
|
||||
<LogManagementPanel isAdmin={isAdmin} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -27,21 +27,8 @@ const LoginPage = ({ onLoginSuccess }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [licenseStatus, setLicenseStatus] = useState(null);
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const checkLicense = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/license/status');
|
||||
setLicenseStatus(response.data || null);
|
||||
} catch {
|
||||
setLicenseStatus({ ok: false, reason: '无法获取授权状态' });
|
||||
}
|
||||
};
|
||||
checkLicense();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setActiveSlide((current) => (current + 1) % loginSlides.length);
|
||||
@@ -85,12 +72,6 @@ const LoginPage = ({ onLoginSuccess }) => {
|
||||
<p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p>
|
||||
</div>
|
||||
|
||||
{licenseStatus && !licenseStatus.ok && (
|
||||
<div className="login-alert warn">
|
||||
当前授权状态异常:{licenseStatus.reason || '未授权'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form-grid">
|
||||
<label className="login-field">
|
||||
<span>用户名</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ const TASK_TYPES = [
|
||||
|
||||
const STATUSES = [
|
||||
['', '全部状态'],
|
||||
['COMPLETED', '已完成'],
|
||||
['FAILED', '失败'],
|
||||
['PARTIAL_SUCCESS', '部分成功'],
|
||||
['CANCELLED', '已取消'],
|
||||
@@ -93,7 +94,7 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
|
||||
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [cleanupLoading, setCleanupLoading] = useState(false);
|
||||
const [workCleanupLoading, setWorkCleanupLoading] = useState(false);
|
||||
const [cleanupResult, setCleanupResult] = useState(null);
|
||||
const [panelMessage, setPanelMessage] = useState(null);
|
||||
|
||||
@@ -154,39 +155,30 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
|
||||
}
|
||||
}, [selectedTaskId]);
|
||||
|
||||
const executeCleanup = useCallback(async () => {
|
||||
const executeLandsarWorkCleanup = useCallback(async () => {
|
||||
if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return;
|
||||
setCleanupLoading(true);
|
||||
setWorkCleanupLoading(true);
|
||||
setCleanupResult(null);
|
||||
try {
|
||||
const data = await cleanupMaintenanceTask(selectedTaskId, {
|
||||
confirm: true,
|
||||
delete_task_records: true,
|
||||
delete_logs: true,
|
||||
delete_production_records: true,
|
||||
delete_result_products: true,
|
||||
delete_production_dirs: true,
|
||||
delete_task_pool_dir: true,
|
||||
});
|
||||
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} 个。`,
|
||||
delete_landsar_work_dir: true,
|
||||
});
|
||||
const deletedCount = (data.deleted_disk?.deleted || []).length;
|
||||
const message = `LandSAR 工作目录清理完成:目录 ${deletedCount} 个。`;
|
||||
setCleanupResult({ ok: true, message });
|
||||
setPanelMessage({ ok: true, message });
|
||||
setPreview(null);
|
||||
setDiagnosis(null);
|
||||
setSelectedTaskId('');
|
||||
await loadDiagnosis(selectedTaskId);
|
||||
await loadTasks();
|
||||
} catch (err) {
|
||||
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' });
|
||||
setPanelMessage({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' });
|
||||
const message = err.response?.data?.detail || err.message || 'LandSAR 工作目录清理失败';
|
||||
setCleanupResult({ ok: false, message });
|
||||
setPanelMessage({ ok: false, message });
|
||||
} finally {
|
||||
setCleanupLoading(false);
|
||||
setWorkCleanupLoading(false);
|
||||
}
|
||||
}, [isAdmin, loadTasks, preview, selectedTaskId]);
|
||||
}, [isAdmin, loadDiagnosis, loadTasks, preview, selectedTaskId]);
|
||||
|
||||
const abnormalCount = tasks.length;
|
||||
const cleanableCount = tasks.filter(item => item.cleanup_supported).length;
|
||||
@@ -331,24 +323,18 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
|
||||
{preview.blockers?.join(';') || '当前任务不允许清理。'}
|
||||
</div>
|
||||
)}
|
||||
<div className="ops-task-two-col">
|
||||
<div>
|
||||
<div className="ops-task-section-title">数据库记录</div>
|
||||
<DatabasePreview counts={preview.database_deletes} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="ops-task-section-title">磁盘路径</div>
|
||||
<DiskPreview paths={preview.disk_deletes} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="ops-task-section-title">LANDSAR_WORK_ROOT</div>
|
||||
<DiskPreview paths={preview.disk_deletes} />
|
||||
</div>
|
||||
<div className="ops-task-actions">
|
||||
{!isAdmin && <span className="ops-task-muted">仅管理员可执行清理。</span>}
|
||||
<button
|
||||
className="health-action-button danger"
|
||||
onClick={executeCleanup}
|
||||
disabled={!isAdmin || cleanupLoading || preview.blocked}
|
||||
className="health-inline-button"
|
||||
onClick={executeLandsarWorkCleanup}
|
||||
disabled={!isAdmin || workCleanupLoading || preview.blocked}
|
||||
>
|
||||
{cleanupLoading ? '清理中...' : '确认清理'}
|
||||
{workCleanupLoading ? '清理中...' : '清理 LandSAR 工作目录'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -217,6 +217,7 @@ export default function ProductionWorkspace({
|
||||
onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch}
|
||||
onSearchAll={radarPanel?.onSearchAll}
|
||||
onRefreshDinsar={pairingPanel?.onRefreshDinsar}
|
||||
onOpenPairsView={() => setActiveView('dinsar_pairs')}
|
||||
language={language}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -295,7 +295,11 @@ function buildPipelineOption(rows) {
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params) => {
|
||||
const item = params?.[0]?.data || {};
|
||||
return `${item.label}<br/>数量:${formatNumber(item.value)}<br/>比例:${formatPercent(item.rate)}`;
|
||||
const denominator = Number(item.denominator);
|
||||
const denominatorText = Number.isFinite(denominator) && denominator > 0
|
||||
? `<br/>适用总数:${formatNumber(denominator)}`
|
||||
: '';
|
||||
return `${item.label}<br/>数量:${formatNumber(item.value)}${denominatorText}<br/>比例:${formatPercent(item.rate)}`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
@@ -327,7 +331,13 @@ function buildPipelineOption(rows) {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#475569',
|
||||
formatter: (params) => `${formatNumber(params.data.value)} / ${formatPercent(params.data.rate)}`,
|
||||
formatter: (params) => {
|
||||
const denominator = Number(params.data.denominator);
|
||||
const totalText = Number.isFinite(denominator) && denominator > 0
|
||||
? `/${formatNumber(denominator)}`
|
||||
: '';
|
||||
return `${formatNumber(params.data.value)}${totalText} / ${formatPercent(params.data.rate)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -743,6 +753,87 @@ function FamilyLegend({ rows }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatYmd(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (/^\d{8}$/.test(text)) {
|
||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||||
}
|
||||
return text || '-';
|
||||
}
|
||||
|
||||
function MissingOrbitSummary({ summary }) {
|
||||
const affectedSceneCount = Number(summary?.affected_scene_count || 0);
|
||||
const missingDateCount = Number(summary?.missing_orbit_date_count || 0);
|
||||
const bySatellite = Array.isArray(summary?.by_satellite) ? summary.by_satellite : [];
|
||||
|
||||
if (!affectedSceneCount) {
|
||||
return (
|
||||
<div className="statistics-orbit-gap statistics-orbit-gap--ok">
|
||||
<strong>精轨缺口</strong>
|
||||
<span>当前没有开放的缺精轨场景。</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="statistics-orbit-gap">
|
||||
<div className="statistics-orbit-gap-head">
|
||||
<div>
|
||||
<strong>精轨缺口说明</strong>
|
||||
<span>缺轨按受影响影像景数统计;补齐时按卫星日期准备 LT-1 TXT 精轨。</span>
|
||||
</div>
|
||||
<div className="statistics-orbit-gap-metrics">
|
||||
<div>
|
||||
<span>受影响景数</span>
|
||||
<b>{formatNumber(affectedSceneCount)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>缺失卫星日期</span>
|
||||
<b>{formatNumber(missingDateCount)}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="statistics-orbit-gap-list">
|
||||
{bySatellite.map((item) => {
|
||||
const months = Array.isArray(item.months) ? item.months : [];
|
||||
const dates = Array.isArray(item.dates) ? item.dates : [];
|
||||
return (
|
||||
<div className="statistics-orbit-gap-satellite" key={item.satellite}>
|
||||
<div className="statistics-orbit-gap-satellite-head">
|
||||
<strong>{item.satellite || 'UNKNOWN'}</strong>
|
||||
<span>
|
||||
{formatNumber(item.affected_scene_count)} 景 / {formatNumber(item.missing_orbit_date_count)} 个日期
|
||||
{item.first_missing_date && item.last_missing_date
|
||||
? ` / ${formatYmd(item.first_missing_date)} 至 ${formatYmd(item.last_missing_date)}`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="statistics-orbit-month-grid">
|
||||
{months.map((month) => (
|
||||
<span key={`${item.satellite}-${month.month}`}>
|
||||
{month.month}
|
||||
<b>{formatNumber(month.affected_scene_count)}景</b>
|
||||
<em>{formatNumber(month.missing_orbit_date_count)}日</em>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="statistics-orbit-date-row" aria-label={`${item.satellite} 缺失精轨日期`}>
|
||||
{dates.map((date) => (
|
||||
<span key={`${item.satellite}-${date.date}`} title={`${formatNumber(date.affected_scene_count)} 景受影响`}>
|
||||
{formatYmd(date.date)}
|
||||
<b>{formatNumber(date.affected_scene_count)}</b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductionRunList({ rows }) {
|
||||
const data = Array.isArray(rows) ? rows : [];
|
||||
if (!data.length) {
|
||||
@@ -792,11 +883,17 @@ export default function StatisticsDashboard() {
|
||||
const [lastLoadedAt, setLastLoadedAt] = useState(null);
|
||||
const [coverageMode, setCoverageMode] = useState('source');
|
||||
|
||||
const loadDashboard = async () => {
|
||||
const loadDashboard = async ({ fresh = false } = {}) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = await getStatisticsDashboard();
|
||||
let payload;
|
||||
try {
|
||||
payload = await getStatisticsDashboard(fresh);
|
||||
} catch (err) {
|
||||
if (!fresh) throw err;
|
||||
payload = await getStatisticsDashboard(false);
|
||||
}
|
||||
setData(payload);
|
||||
setLastLoadedAt(new Date());
|
||||
} catch (err) {
|
||||
@@ -875,7 +972,7 @@ export default function StatisticsDashboard() {
|
||||
{data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={loadDashboard} disabled={loading}>
|
||||
<button type="button" onClick={() => loadDashboard({ fresh: true })} disabled={loading}>
|
||||
{loading ? '刷新中' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -948,29 +1045,38 @@ export default function StatisticsDashboard() {
|
||||
<Chart option={sourceTrendOption} className="statistics-chart-md" />
|
||||
</Section>
|
||||
|
||||
<Section title="精轨保障" subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中`}>
|
||||
<div className="statistics-orbit-summary">
|
||||
<div>
|
||||
<span>精轨资产</span>
|
||||
<strong>{formatNumber(data?.orbit?.orbit_total)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>绑定覆盖率</span>
|
||||
<strong>{formatPercent(data?.orbit?.selected_rate)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>匹配记录</span>
|
||||
<strong>{formatNumber(data?.orbit?.matched_bindings)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="statistics-mini-bars">
|
||||
{(data?.orbit?.orbit_by_family || []).map((item) => (
|
||||
<div key={item.family}>
|
||||
<span>{item.family}</span>
|
||||
<b>{formatNumber(item.count)}</b>
|
||||
<i style={{ width: `${Math.min(100, Math.max(2, item.ok_rate * 100))}%` }} />
|
||||
<Section
|
||||
title="精轨保障"
|
||||
subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中,${formatNumber(data?.orbit?.missing_summary?.affected_scene_count || data?.orbit?.missing_scene_count)} 景待补`}
|
||||
className="statistics-section--orbit"
|
||||
>
|
||||
<div className="statistics-orbit-layout">
|
||||
<div className="statistics-orbit-overview">
|
||||
<div className="statistics-orbit-summary">
|
||||
<div>
|
||||
<span>精轨资产</span>
|
||||
<strong>{formatNumber(data?.orbit?.orbit_total)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>绑定覆盖率</span>
|
||||
<strong>{formatPercent(data?.orbit?.selected_rate)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>匹配记录</span>
|
||||
<strong>{formatNumber(data?.orbit?.matched_bindings)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="statistics-mini-bars">
|
||||
{(data?.orbit?.orbit_by_family || []).map((item) => (
|
||||
<div key={item.family}>
|
||||
<span>{item.family}</span>
|
||||
<b>{formatNumber(item.count)}</b>
|
||||
<i style={{ width: `${Math.min(100, Math.max(2, item.ok_rate * 100))}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<MissingOrbitSummary summary={data?.orbit?.missing_summary} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
export { default as apiClient } from './client';
|
||||
export * as authApi from './auth';
|
||||
export * as licenseApi from './license';
|
||||
export * as tasksApi from './tasks';
|
||||
export * as healthApi from './health';
|
||||
export * as radarApi from './radar';
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export const findPairs = (formData) => apiClient.post('/find-pairs', formData).then(r => r.data);
|
||||
export const buildCoveragePlan = (formData) => apiClient.post('/pairing/coverage-plan', formData).then(r => r.data);
|
||||
export const findPsTimeseries = (formData) => apiClient.post('/find-ps-timeseries', formData).then(r => r.data);
|
||||
export const getPairingHealth = () => apiClient.get('/pairing/health').then(r => r.data);
|
||||
export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data);
|
||||
|
||||
@@ -3,5 +3,5 @@ import apiClient from './client';
|
||||
export const getStatistics = (fresh = false) =>
|
||||
apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
|
||||
|
||||
export const getStatisticsDashboard = () =>
|
||||
apiClient.get('/statistics/dashboard').then(r => r.data);
|
||||
export const getStatisticsDashboard = (fresh = false) =>
|
||||
apiClient.get('/statistics/dashboard', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
export default function LicenseOverlay({
|
||||
licenseLoading,
|
||||
licenseStatus,
|
||||
isAdmin,
|
||||
licenseFileRef,
|
||||
onUploadFile,
|
||||
onRefreshStatus,
|
||||
licenseFileName,
|
||||
licenseUploadStatus,
|
||||
}) {
|
||||
if (!licenseLoading && licenseStatus?.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(15, 23, 42, 0.82)',
|
||||
zIndex: 2000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 'min(560px, 90%)',
|
||||
background: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 20px 60px rgba(15, 23, 42, 0.35)',
|
||||
}}>
|
||||
<h3 style={{ marginTop: 0 }}>
|
||||
{licenseLoading ? '正在验证授权...' : '系统未授权'}
|
||||
</h3>
|
||||
{!licenseLoading && (
|
||||
<>
|
||||
<p style={{ color: '#475569', marginBottom: '12px' }}>
|
||||
失败原因:{licenseStatus?.reason || '授权无效或已过期,请联系管理员。'}
|
||||
</p>
|
||||
{isAdmin ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '12px' }}>
|
||||
<input
|
||||
type="file"
|
||||
ref={licenseFileRef}
|
||||
accept=".lic"
|
||||
onChange={(e) => onUploadFile(e.target.files?.[0])}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="primary-btn"
|
||||
onClick={() => licenseFileRef.current && licenseFileRef.current.click()}
|
||||
style={{ padding: '8px 14px' }}
|
||||
>
|
||||
选择授权文件
|
||||
</button>
|
||||
<button
|
||||
className="secondary-btn"
|
||||
onClick={onRefreshStatus}
|
||||
style={{ padding: '8px 14px' }}
|
||||
>
|
||||
刷新授权状态
|
||||
</button>
|
||||
<span style={{ fontSize: '0.85em', color: '#64748b' }}>
|
||||
{licenseFileName ? `已选择: ${licenseFileName}` : '未选择文件'}
|
||||
</span>
|
||||
</div>
|
||||
{licenseUploadStatus?.message && (
|
||||
<div style={{
|
||||
fontSize: '0.85em',
|
||||
color:
|
||||
licenseUploadStatus.type === 'error'
|
||||
? '#dc2626'
|
||||
: licenseUploadStatus.type === 'success'
|
||||
? '#16a34a'
|
||||
: '#475569',
|
||||
}}>
|
||||
{licenseUploadStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: '12px', color: '#b45309', background: '#fffbeb', border: '1px solid #fcd34d', padding: '8px 10px', borderRadius: '6px' }}>
|
||||
当前账号无上传授权权限,请联系管理员处理授权文件。
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '10px', background: '#f8fafc', padding: '10px 12px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '6px', color: '#334155' }}>授权使用说明</div>
|
||||
<ol style={{ margin: 0, paddingLeft: '18px', color: '#475569', fontSize: '0.85em' }}>
|
||||
<li>确认已在服务器 .env 中配置 LICENSE_SECRET 与 LICENSE_PUBLIC_KEY。</li>
|
||||
<li>上传有效的 .lic 授权文件(与当前机器指纹匹配)。</li>
|
||||
<li>点击“刷新授权状态”确认授权已生效。</li>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{licenseLoading && (
|
||||
<p style={{ color: '#64748b' }}>请稍候,正在与授权文件进行校验。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import LicenseOverlay from '../LicenseOverlay';
|
||||
import { useDinsarStore, usePairingStore, useUiStore } from '../../store';
|
||||
import { useI18n } from '../../i18n/I18nContext';
|
||||
import { formatYmd } from '../../utils/appUiHelpers';
|
||||
@@ -17,14 +16,7 @@ export default function AppOverlays({
|
||||
onPairingAoiModeChange,
|
||||
onPairingProvinceChange,
|
||||
onPairingCityChange,
|
||||
licenseLoading,
|
||||
licenseStatus,
|
||||
isAdmin,
|
||||
licenseFileRef,
|
||||
onUploadFile,
|
||||
onRefreshLicenseStatus,
|
||||
licenseFileName,
|
||||
licenseUploadStatus,
|
||||
activeTasks,
|
||||
runtimeSummary,
|
||||
showCancelTask,
|
||||
@@ -75,17 +67,6 @@ export default function AppOverlays({
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<LicenseOverlay
|
||||
licenseLoading={licenseLoading}
|
||||
licenseStatus={licenseStatus}
|
||||
isAdmin={isAdmin}
|
||||
licenseFileRef={licenseFileRef}
|
||||
onUploadFile={onUploadFile}
|
||||
onRefreshStatus={onRefreshLicenseStatus}
|
||||
licenseFileName={licenseFileName}
|
||||
licenseUploadStatus={licenseUploadStatus}
|
||||
/>
|
||||
|
||||
{showDataInfo && (
|
||||
<Suspense fallback={<ModalLoadingFallback message="正在加载数据详情..." />}>
|
||||
<LazyDataInfoModal
|
||||
|
||||
@@ -18,6 +18,7 @@ const LazyAssetInventoryPanel = lazy(() => import('../../AssetInventoryPanel'));
|
||||
const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel'));
|
||||
const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel'));
|
||||
const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel'));
|
||||
const LazyOpsTaskMaintenancePanel = lazy(() => import('../../OpsTaskMaintenancePanel'));
|
||||
const LazyFloodAnalysisWorkspace = lazy(() => import('../../FloodAnalysisWorkspace'));
|
||||
const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel'));
|
||||
const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
|
||||
@@ -30,6 +31,7 @@ const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarM
|
||||
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
|
||||
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
|
||||
const LazyResultExtractionPanel = lazy(() => import('../../ResultExtractionPanel'));
|
||||
const LazyUserManualPanel = lazy(() => import('../../UserManualPanel'));
|
||||
|
||||
export default function AppSidePanel({
|
||||
leftPanelWidth,
|
||||
@@ -41,7 +43,6 @@ export default function AppSidePanel({
|
||||
currentUser,
|
||||
language,
|
||||
apiEndpoint,
|
||||
licenseOk,
|
||||
foundPairs,
|
||||
dinsarTotal,
|
||||
selectedPairsCount,
|
||||
@@ -229,6 +230,14 @@ export default function AppSidePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'user_manual' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载用户手册..." />}>
|
||||
<LazyUserManualPanel />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'ingest' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}>
|
||||
@@ -236,7 +245,7 @@ export default function AppSidePanel({
|
||||
apiEndpoint={apiEndpoint}
|
||||
onTaskStart={taskPanel.onTaskStart}
|
||||
readOnly={isReadOnlyUser}
|
||||
enabled={!!currentUser && licenseOk}
|
||||
enabled={!!currentUser}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -330,6 +339,20 @@ export default function AppSidePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'ops_tasks' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
{isAdmin ? (
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载任务维护面板..." />}>
|
||||
<LazyOpsTaskMaintenancePanel isAdmin={isAdmin} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<p className="empty-state">仅管理员可访问任务维护。</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'users' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
{isAdmin ? (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import defaultLogoUrl from '../../logo.jpg';
|
||||
import { formatUtc } from '../../utils/appUiHelpers';
|
||||
|
||||
const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心';
|
||||
const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || '雷达数据生产管理系统';
|
||||
@@ -8,17 +7,14 @@ const SYSTEM_TAGLINE = import.meta.env.VITE_APP_SYSTEM_TAGLINE || '科研工程
|
||||
const LOGO_URL = import.meta.env.VITE_APP_LOGO_URL || defaultLogoUrl;
|
||||
|
||||
function AppStatusHeader({
|
||||
language,
|
||||
currentUser,
|
||||
isAdmin,
|
||||
isReadOnlyUser,
|
||||
activeTasks,
|
||||
avgTaskProgress,
|
||||
runtimeSummary,
|
||||
licenseStatus,
|
||||
onLogout,
|
||||
}) {
|
||||
const licenseOk = !!licenseStatus?.ok;
|
||||
const worker = runtimeSummary?.worker || {};
|
||||
const jobs = runtimeSummary?.jobs || {};
|
||||
const scan = runtimeSummary?.scan || {};
|
||||
@@ -68,14 +64,6 @@ function AppStatusHeader({
|
||||
|
||||
<div className="status-system-meta" aria-label="系统状态摘要">
|
||||
<span>{SYSTEM_TAGLINE}</span>
|
||||
<span className={`status-license-chip ${licenseOk ? 'ok' : 'fail'}`}>
|
||||
{licenseOk ? '已授权' : '未授权'}
|
||||
</span>
|
||||
{licenseStatus?.expires_at && (
|
||||
<span className="status-license">
|
||||
授权至 {formatUtc(licenseStatus.expires_at, language)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="status-actions">
|
||||
|
||||
@@ -82,6 +82,7 @@ function PairListRow({
|
||||
const overlapLabel = pair.pair_aoi_overlap_ratio != null ? 'AOI覆盖' : '影像重叠';
|
||||
const production = productionLabel(pair.production_summary);
|
||||
const quality = qualityLabel(pair);
|
||||
const hasCoverageContribution = pair.aoi_new_area_ratio != null || pair.aoi_coverage_ratio_after != null;
|
||||
const engines = Array.isArray(pair.production_summary?.engine_codes)
|
||||
? pair.production_summary.engine_codes.filter(Boolean).join('/')
|
||||
: '';
|
||||
@@ -104,6 +105,13 @@ function PairListRow({
|
||||
<span>时基: {pair.time_baseline_days}d</span>
|
||||
<span>中心距: {formatDistance(centerDistance)}</span>
|
||||
<span>{overlapLabel}: {formatPercent(overlap)}</span>
|
||||
{hasCoverageContribution && (
|
||||
<span>
|
||||
覆盖贡献: {pair.coverage_rank ? `#${pair.coverage_rank} ` : ''}
|
||||
新增 {formatPercent(pair.aoi_new_area_ratio)}
|
||||
{pair.aoi_coverage_ratio_after != null ? ` / 累计 ${formatPercent(pair.aoi_coverage_ratio_after)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{quality && <span>D-InSAR: {quality}</span>}
|
||||
</div>
|
||||
<div className="pair-status-line">
|
||||
|
||||
@@ -197,6 +197,7 @@ export const LEFT_GROUP_LABELS = {
|
||||
statistics: '综合统计',
|
||||
flood_analysis: '灾害分析',
|
||||
ops: '运行维护',
|
||||
help: '帮助文档',
|
||||
};
|
||||
|
||||
export const LEFT_GROUP_SECTIONS = {
|
||||
@@ -221,7 +222,8 @@ export const LEFT_GROUP_TABS = {
|
||||
insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
|
||||
statistics: ['statistics'],
|
||||
flood_analysis: ['flood_analysis'],
|
||||
ops: ['health', 'users', 'audit'],
|
||||
ops: ['health', 'ops_tasks', 'users', 'audit'],
|
||||
help: ['user_manual'],
|
||||
};
|
||||
|
||||
export const LEFT_TAB_GROUP = Object.entries(LEFT_GROUP_TABS).reduce((acc, [group, tabs]) => {
|
||||
@@ -248,6 +250,7 @@ export const FULL_WIDTH_LEFT_TABS = new Set([
|
||||
...PRODUCTION_WORKSPACE_ROUTE_TABS,
|
||||
'statistics',
|
||||
'result_extraction',
|
||||
'user_manual',
|
||||
]);
|
||||
|
||||
export const ADMIN_ONLY_TABS = new Set([
|
||||
@@ -262,6 +265,7 @@ export const ADMIN_ONLY_TABS = new Set([
|
||||
...PRODUCTION_WORKSPACE_LEGACY_TABS,
|
||||
'users',
|
||||
'audit',
|
||||
'ops_tasks',
|
||||
]);
|
||||
|
||||
export const DEFAULT_LIST_PAGE_SIZE = 200;
|
||||
|
||||
@@ -38,10 +38,8 @@ const requestWithTransientRetry = async (
|
||||
};
|
||||
|
||||
export default function useAppAuthLifecycle({
|
||||
ensureCanOperate,
|
||||
clearRadarSearchResults,
|
||||
radarSearchRequestSeqRef,
|
||||
prevLicenseOkRef,
|
||||
aoeLayerRef,
|
||||
activeLayersRef,
|
||||
radarPreviewLayersRef,
|
||||
@@ -49,13 +47,9 @@ export default function useAppAuthLifecycle({
|
||||
setCurrentUser,
|
||||
setAuthChecked,
|
||||
setPendingTaskIds,
|
||||
setLicenseLoading,
|
||||
setLicenseStatus,
|
||||
setHealthLoading,
|
||||
setHealthError,
|
||||
setHealthStatus,
|
||||
setLicenseFileName,
|
||||
setLicenseUploadStatus,
|
||||
setAoiLayer,
|
||||
setAllData,
|
||||
setRadarPagination,
|
||||
@@ -113,7 +107,6 @@ export default function useAppAuthLifecycle({
|
||||
clearRadarSearchResults();
|
||||
setCurrentUser(null);
|
||||
setPendingTaskIds([]);
|
||||
prevLicenseOkRef.current = false;
|
||||
setAuthChecked(true);
|
||||
}
|
||||
}, [
|
||||
@@ -122,29 +115,9 @@ export default function useAppAuthLifecycle({
|
||||
setHasRadarSearched,
|
||||
setCurrentUser,
|
||||
setPendingTaskIds,
|
||||
prevLicenseOkRef,
|
||||
setAuthChecked,
|
||||
]);
|
||||
|
||||
const fetchLicenseStatus = useCallback(async () => {
|
||||
try {
|
||||
setLicenseLoading(true);
|
||||
const response = await requestWithTransientRetry(() => apiClient.get('/license/status'));
|
||||
const data = response.data || {};
|
||||
if (!data.ok && !data.reason) {
|
||||
data.reason = '未授权';
|
||||
}
|
||||
setLicenseStatus(data);
|
||||
} catch (error) {
|
||||
setLicenseStatus({
|
||||
ok: false,
|
||||
reason: error.response?.data?.detail || '无法获取授权状态',
|
||||
});
|
||||
} finally {
|
||||
setLicenseLoading(false);
|
||||
}
|
||||
}, [setLicenseLoading, setLicenseStatus]);
|
||||
|
||||
const fetchHealthStatus = useCallback(async (options = {}) => {
|
||||
const { refresh = false, silent = false } = options;
|
||||
try {
|
||||
@@ -167,22 +140,6 @@ export default function useAppAuthLifecycle({
|
||||
}
|
||||
}, [setHealthLoading, setHealthError, setHealthStatus]);
|
||||
|
||||
const handleLicenseUpload = useCallback(async (file) => {
|
||||
if (!file) return;
|
||||
if (!ensureCanOperate()) return;
|
||||
try {
|
||||
setLicenseFileName(file.name);
|
||||
setLicenseUploadStatus({ type: 'info', message: '正在上传授权文件...' });
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const response = await apiClient.post('/license/upload', form);
|
||||
setLicenseUploadStatus({ type: 'success', message: response.data?.message || '授权文件已上传' });
|
||||
await fetchLicenseStatus();
|
||||
} catch (error) {
|
||||
setLicenseUploadStatus({ type: 'error', message: error.response?.data?.detail || '授权文件上传失败' });
|
||||
}
|
||||
}, [ensureCanOperate, setLicenseFileName, setLicenseUploadStatus, fetchLicenseStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const interceptorId = apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
@@ -210,7 +167,6 @@ export default function useAppAuthLifecycle({
|
||||
}));
|
||||
setCurrentUser(null);
|
||||
setAuthChecked(true);
|
||||
prevLicenseOkRef.current = false;
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -229,13 +185,11 @@ export default function useAppAuthLifecycle({
|
||||
setRadarPagination,
|
||||
setCurrentUser,
|
||||
setAuthChecked,
|
||||
prevLicenseOkRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCurrentUser();
|
||||
fetchLicenseStatus();
|
||||
}, [fetchCurrentUser, fetchLicenseStatus]);
|
||||
}, [fetchCurrentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const startupTimer = setTimeout(() => {
|
||||
@@ -253,8 +207,6 @@ export default function useAppAuthLifecycle({
|
||||
return {
|
||||
handleLoginSuccess,
|
||||
handleLogout,
|
||||
fetchLicenseStatus,
|
||||
fetchHealthStatus,
|
||||
handleLicenseUpload,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ const TERMINAL_TASK_STATUSES = new Set(['COMPLETED', 'PARTIAL_SUCCESS', 'FAILED'
|
||||
|
||||
export default function useGlobalTaskControl({
|
||||
currentUser,
|
||||
licenseOk,
|
||||
activeTasks,
|
||||
setActiveTasks,
|
||||
setRuntimeSummary,
|
||||
@@ -139,7 +138,7 @@ export default function useGlobalTaskControl({
|
||||
}, [handleTasksUpdate, normalizeRuntimeSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || !licenseOk) return;
|
||||
if (!currentUser) return;
|
||||
|
||||
// Initial fetch
|
||||
syncActiveTasks();
|
||||
@@ -179,7 +178,7 @@ export default function useGlobalTaskControl({
|
||||
if (es) es.close();
|
||||
if (fallbackInterval) clearInterval(fallbackInterval);
|
||||
};
|
||||
}, [currentUser, licenseOk, syncActiveTasks, handleTasksUpdate]);
|
||||
}, [currentUser, syncActiveTasks, handleTasksUpdate]);
|
||||
|
||||
const handleCancelActiveTasks = useCallback(() => {
|
||||
if (!cancelTaskPwd || activeTasks.length === 0) return;
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
{ zh: '请稍候,系统正在验证会话。', en: 'Please wait, verifying your session.' },
|
||||
{ zh: '雷达数据生产管理系统', en: 'Radar Data Production Management System' },
|
||||
{ zh: '科研工程模式', en: 'Research Engineering Mode' },
|
||||
{ zh: '已授权', en: 'Licensed' },
|
||||
{ zh: '未授权', en: 'Unlicensed' },
|
||||
{ zh: '管理员', en: 'Admin' },
|
||||
{ zh: '只读账号', en: 'Read-only account' },
|
||||
{ zh: '任务', en: 'Tasks' },
|
||||
{ zh: '授权至', en: 'License until' },
|
||||
{ zh: '刷新自检状态', en: 'Refresh health status' },
|
||||
{ zh: '自检中...', en: 'Checking...' },
|
||||
{ zh: '刷新自检', en: 'Refresh Health' },
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
buildCoveragePlan,
|
||||
getPairingHealth,
|
||||
rebuildPairingCache,
|
||||
reconcileDirtyPairingCache,
|
||||
} from '../api/pairing';
|
||||
import { getRegionChildren, getRegionGeometry } from '../api/aoi';
|
||||
import MiniCoverageMap from '../components/MiniCoverageMap';
|
||||
import { usePairingStore } from '../store';
|
||||
|
||||
const formatIso = (value, en = false) => {
|
||||
if (!value) return en ? 'Never' : '未执行';
|
||||
@@ -41,6 +44,7 @@ export default function PairPlanningPanel({
|
||||
onRefreshRadarSearch,
|
||||
onSearchAll,
|
||||
onRefreshDinsar,
|
||||
onOpenPairsView,
|
||||
language,
|
||||
}) {
|
||||
const en = language === 'en';
|
||||
@@ -50,7 +54,60 @@ export default function PairPlanningPanel({
|
||||
const [pairingRepairing, setPairingRepairing] = useState(false);
|
||||
const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false);
|
||||
const [pairingActionResult, setPairingActionResult] = useState(null);
|
||||
const previewPairs = useMemo(() => foundPairs.slice(0, 20), [foundPairs]);
|
||||
const [coveragePlanning, setCoveragePlanning] = useState(false);
|
||||
const [coverageResult, setCoverageResult] = useState(null);
|
||||
const [coverageAoiMode, setCoverageAoiMode] = useState('region');
|
||||
const [coverageAoiFiles, setCoverageAoiFiles] = useState(null);
|
||||
const [coverageAoiGeojson, setCoverageAoiGeojson] = useState(null);
|
||||
const [coverageAoiError, setCoverageAoiError] = useState('');
|
||||
const [coverageRegionLoading, setCoverageRegionLoading] = useState(false);
|
||||
const [coverageRegionLoaded, setCoverageRegionLoaded] = useState(false);
|
||||
const [coverageRegionOptions, setCoverageRegionOptions] = useState({ provinces: [], cities: [] });
|
||||
const [coverageRegionSelection, setCoverageRegionSelection] = useState({ province: '', city: '' });
|
||||
const [coverageMapMode, setCoverageMapMode] = useState('effective');
|
||||
const [coverageForm, setCoverageForm] = useState({
|
||||
target_date_from: '',
|
||||
target_date_to: '',
|
||||
extension_days: '15',
|
||||
max_pairs: '200',
|
||||
target_coverage_ratio: '0.98',
|
||||
min_new_coverage_ratio: '0.0005',
|
||||
time_baseline_min: '1',
|
||||
time_baseline_max: '30',
|
||||
overlap_threshold: '0.5',
|
||||
spatial_baseline_max_meters: '5000',
|
||||
aoi_overlap_threshold: '0',
|
||||
allowed_satellites: 'LT1',
|
||||
});
|
||||
const { setFoundPairs, setPairingAlert } = usePairingStore();
|
||||
const selectedCoverageRegionTreeId = coverageRegionSelection.city || coverageRegionSelection.province || '';
|
||||
const selectedCoverageProvince = useMemo(
|
||||
() => coverageRegionOptions.provinces.find(item => String(item.tree_id) === String(coverageRegionSelection.province)),
|
||||
[coverageRegionOptions.provinces, coverageRegionSelection.province],
|
||||
);
|
||||
const selectedCoverageCity = useMemo(
|
||||
() => coverageRegionOptions.cities.find(item => String(item.tree_id) === String(coverageRegionSelection.city)),
|
||||
[coverageRegionOptions.cities, coverageRegionSelection.city],
|
||||
);
|
||||
const coverageAoiSummary = useMemo(() => {
|
||||
if (coverageAoiMode === 'region') {
|
||||
if (!selectedCoverageRegionTreeId) return '请选择行政区作为空间覆盖范围';
|
||||
const name = selectedCoverageCity?.name || selectedCoverageProvince?.name || selectedCoverageRegionTreeId;
|
||||
return `行政区 AOI:${name}${coverageRegionSelection.city ? '' : '(全域)'}`;
|
||||
}
|
||||
const files = Array.from(coverageAoiFiles || []);
|
||||
if (files.length === 0) return '请上传 SHP/GeoJSON 作为空间覆盖范围';
|
||||
const names = files.slice(0, 2).map(file => file.name).join('、');
|
||||
return `上传 AOI:${names}${files.length > 2 ? ` 等 ${files.length} 个文件` : ''}`;
|
||||
}, [
|
||||
coverageAoiFiles,
|
||||
coverageAoiMode,
|
||||
coverageRegionSelection.city,
|
||||
selectedCoverageCity,
|
||||
selectedCoverageProvince,
|
||||
selectedCoverageRegionTreeId,
|
||||
]);
|
||||
const previewPairs = useMemo(() => foundPairs, [foundPairs]);
|
||||
const previewPolygons = useMemo(() => (
|
||||
previewPairs.flatMap((pair, index) => {
|
||||
const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`;
|
||||
@@ -70,6 +127,22 @@ export default function PairPlanningPanel({
|
||||
];
|
||||
})
|
||||
), [previewPairs]);
|
||||
const previewEffectiveGeojson = useMemo(() => ({
|
||||
type: 'FeatureCollection',
|
||||
features: previewPairs
|
||||
.filter(pair => pair.effective_coverage_geojson)
|
||||
.map((pair, index) => ({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
name: `${pair.coverage_rank || index + 1}. ${pair.task_alias || pair.task_name || `Pair ${index + 1}`}`,
|
||||
label: `${pair.coverage_rank || index + 1}. 新增 ${((Number(pair.aoi_new_area_ratio || 0)) * 100).toFixed(2)}% / 累计 ${((Number(pair.aoi_coverage_ratio_after || 0)) * 100).toFixed(1)}%`,
|
||||
color: '#2563eb',
|
||||
fillColor: '#3b82f6',
|
||||
},
|
||||
geometry: pair.effective_coverage_geojson,
|
||||
})),
|
||||
}), [previewPairs]);
|
||||
const mapShowsEffectiveCoverage = coverageMapMode === 'effective' && previewEffectiveGeojson.features.length > 0;
|
||||
|
||||
const refreshPairingStatus = useCallback(async () => {
|
||||
if (isReadOnlyUser) {
|
||||
@@ -122,6 +195,169 @@ export default function PairPlanningPanel({
|
||||
}, [en, refreshPairingStatus]);
|
||||
|
||||
const pairingActionBusy = pairingRepairing || pairingFullRebuilding;
|
||||
const handleCoverageFieldChange = useCallback((field, value) => {
|
||||
setCoverageForm(current => ({ ...current, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const loadCoverageProvinces = useCallback(async () => {
|
||||
setCoverageRegionLoading(true);
|
||||
setCoverageAoiError('');
|
||||
try {
|
||||
const result = await getRegionChildren('1');
|
||||
setCoverageRegionOptions({
|
||||
provinces: Array.isArray(result?.children) ? result.children : [],
|
||||
cities: [],
|
||||
});
|
||||
setCoverageRegionLoaded(true);
|
||||
} catch (error) {
|
||||
setCoverageAoiError(error.response?.data?.detail || error.message || '行政区加载失败。');
|
||||
setCoverageRegionOptions({ provinces: [], cities: [] });
|
||||
setCoverageRegionLoaded(true);
|
||||
} finally {
|
||||
setCoverageRegionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCoverageCities = useCallback(async (provinceId) => {
|
||||
if (!provinceId) {
|
||||
setCoverageRegionOptions(current => ({ ...current, cities: [] }));
|
||||
return;
|
||||
}
|
||||
setCoverageRegionLoading(true);
|
||||
setCoverageAoiError('');
|
||||
try {
|
||||
const result = await getRegionChildren(provinceId);
|
||||
setCoverageRegionOptions(current => ({
|
||||
...current,
|
||||
cities: Array.isArray(result?.children) ? result.children : [],
|
||||
}));
|
||||
} catch (error) {
|
||||
setCoverageAoiError(error.response?.data?.detail || error.message || '地市加载失败。');
|
||||
setCoverageRegionOptions(current => ({ ...current, cities: [] }));
|
||||
} finally {
|
||||
setCoverageRegionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReadOnlyUser || coverageAoiMode !== 'region' || coverageRegionLoaded || coverageRegionLoading) return;
|
||||
void loadCoverageProvinces();
|
||||
}, [
|
||||
coverageAoiMode,
|
||||
coverageRegionLoaded,
|
||||
coverageRegionLoading,
|
||||
isReadOnlyUser,
|
||||
loadCoverageProvinces,
|
||||
]);
|
||||
|
||||
const handleCoverageAoiModeChange = useCallback(async (mode) => {
|
||||
setCoverageAoiMode(mode);
|
||||
setCoverageAoiError('');
|
||||
setCoverageResult(null);
|
||||
setCoverageAoiGeojson(null);
|
||||
if (mode === 'region' && !coverageRegionLoaded) {
|
||||
await loadCoverageProvinces();
|
||||
}
|
||||
}, [coverageRegionLoaded, loadCoverageProvinces]);
|
||||
|
||||
const handleCoverageProvinceChange = useCallback(async (value) => {
|
||||
setCoverageRegionSelection({ province: value, city: '' });
|
||||
setCoverageAoiGeojson(null);
|
||||
setCoverageResult(null);
|
||||
if (value) await loadCoverageCities(value);
|
||||
else setCoverageRegionOptions(current => ({ ...current, cities: [] }));
|
||||
}, [loadCoverageCities]);
|
||||
|
||||
const handleCoverageCityChange = useCallback((value) => {
|
||||
setCoverageRegionSelection(current => ({ ...current, city: value }));
|
||||
setCoverageAoiGeojson(null);
|
||||
setCoverageResult(null);
|
||||
}, []);
|
||||
|
||||
const handleCoverageFilesChange = useCallback((files) => {
|
||||
setCoverageAoiFiles(files);
|
||||
setCoverageAoiGeojson(null);
|
||||
setCoverageAoiError('');
|
||||
setCoverageResult(null);
|
||||
}, []);
|
||||
|
||||
const handleCoveragePlan = useCallback(async () => {
|
||||
if (isReadOnlyUser || coveragePlanning) return;
|
||||
const targetFrom = String(coverageForm.target_date_from || '').trim();
|
||||
const targetTo = String(coverageForm.target_date_to || '').trim();
|
||||
if (!/^\d{8}$/.test(targetFrom) || !/^\d{8}$/.test(targetTo)) {
|
||||
setCoverageResult({ error: '请输入 YYYYMMDD 格式的目标起止日期。' });
|
||||
return;
|
||||
}
|
||||
setCoveragePlanning(true);
|
||||
setCoverageResult(null);
|
||||
setCoverageAoiError('');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
Object.entries(coverageForm).forEach(([key, value]) => {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return;
|
||||
if (key === 'allowed_satellites') {
|
||||
const families = text.split(',').map(item => item.trim()).filter(Boolean);
|
||||
if (families.length > 0) formData.append(key, JSON.stringify(families));
|
||||
return;
|
||||
}
|
||||
formData.append(key, text);
|
||||
});
|
||||
formData.append('require_orbit_data', 'true');
|
||||
formData.append('require_same_imaging_mode', 'true');
|
||||
formData.append('require_same_polarization', 'true');
|
||||
formData.append('limit_footprint_center_distance', 'true');
|
||||
|
||||
let selectedAoiGeojson = null;
|
||||
if (coverageAoiMode === 'file') {
|
||||
const files = Array.from(coverageAoiFiles || []);
|
||||
if (files.length === 0) {
|
||||
throw new Error('请选择 AOI 文件。');
|
||||
}
|
||||
files.forEach(file => formData.append('files', file));
|
||||
} else {
|
||||
if (!selectedCoverageRegionTreeId) {
|
||||
throw new Error('请选择行政区 AOI。');
|
||||
}
|
||||
const result = await getRegionGeometry(selectedCoverageRegionTreeId);
|
||||
selectedAoiGeojson = result?.aoi_geojson || null;
|
||||
if (!selectedAoiGeojson) {
|
||||
throw new Error('行政区边界为空,无法进行空间覆盖规划。');
|
||||
}
|
||||
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeojson));
|
||||
}
|
||||
|
||||
const response = await buildCoveragePlan(formData);
|
||||
const pairs = Array.isArray(response?.pairs) ? response.pairs : [];
|
||||
const warnings = Array.isArray(response?.warnings) ? response.warnings : [];
|
||||
const responseAoi = response?.aoi_geojson || selectedAoiGeojson || null;
|
||||
setFoundPairs(pairs.map(pair => ({ ...pair, isSelected: true, isVis: false })));
|
||||
setPairingAlert({ warnings, fallbackUsed: Boolean(response?.fallback_used) });
|
||||
setCoverageAoiGeojson(responseAoi);
|
||||
setCoverageResult({
|
||||
...response,
|
||||
aoi_geojson: responseAoi,
|
||||
pairCount: pairs.length,
|
||||
warningCount: warnings.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.detail || error.message || '区域空间覆盖配对规划失败。';
|
||||
setCoverageAoiError(message);
|
||||
setCoverageResult({ error: message });
|
||||
} finally {
|
||||
setCoveragePlanning(false);
|
||||
}
|
||||
}, [
|
||||
coverageAoiFiles,
|
||||
coverageAoiMode,
|
||||
coverageForm,
|
||||
coveragePlanning,
|
||||
isReadOnlyUser,
|
||||
selectedCoverageRegionTreeId,
|
||||
setFoundPairs,
|
||||
setPairingAlert,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', overflowY: 'auto', padding: '12px' }}>
|
||||
@@ -139,16 +375,208 @@ export default function PairPlanningPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-card province-coverage-card" style={{ marginTop: '12px' }}>
|
||||
<div className="panel-card-title">区域空间覆盖配对规划</div>
|
||||
<p className="panel-card-desc">
|
||||
以目标生产时段限定影像池,按标准 D-InSAR 约束生成候选对,并优先选择能增加 AOI 空间覆盖范围的配对;扩展天数用于补充边界时段的可用影像。
|
||||
</p>
|
||||
<div className="coverage-form-grid">
|
||||
<label>
|
||||
目标开始
|
||||
<input value={coverageForm.target_date_from} onChange={event => handleCoverageFieldChange('target_date_from', event.target.value)} placeholder="20260101" />
|
||||
</label>
|
||||
<label>
|
||||
目标结束
|
||||
<input value={coverageForm.target_date_to} onChange={event => handleCoverageFieldChange('target_date_to', event.target.value)} placeholder="20260531" />
|
||||
</label>
|
||||
<label>
|
||||
扩展天数
|
||||
<input type="number" min="0" max="180" value={coverageForm.extension_days} onChange={event => handleCoverageFieldChange('extension_days', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
任务上限
|
||||
<input type="number" min="1" max="5000" value={coverageForm.max_pairs} onChange={event => handleCoverageFieldChange('max_pairs', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
目标覆盖率
|
||||
<input type="number" min="0" max="1" step="0.01" value={coverageForm.target_coverage_ratio} onChange={event => handleCoverageFieldChange('target_coverage_ratio', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
最低新增覆盖
|
||||
<input type="number" min="0" max="1" step="0.0001" value={coverageForm.min_new_coverage_ratio} onChange={event => handleCoverageFieldChange('min_new_coverage_ratio', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
时间基线
|
||||
<input value={coverageForm.time_baseline_max} onChange={event => handleCoverageFieldChange('time_baseline_max', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
最小重叠
|
||||
<input value={coverageForm.overlap_threshold} onChange={event => handleCoverageFieldChange('overlap_threshold', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
中心距上限
|
||||
<input value={coverageForm.spatial_baseline_max_meters} onChange={event => handleCoverageFieldChange('spatial_baseline_max_meters', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
卫星族
|
||||
<input value={coverageForm.allowed_satellites} onChange={event => handleCoverageFieldChange('allowed_satellites', event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="coverage-aoi-panel">
|
||||
<div className="coverage-aoi-header">
|
||||
<strong>空间覆盖范围</strong>
|
||||
<span>{coverageAoiSummary}</span>
|
||||
</div>
|
||||
<div className="coverage-aoi-mode-row" role="radiogroup" aria-label="空间覆盖范围来源">
|
||||
<label className={`coverage-aoi-mode ${coverageAoiMode === 'region' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="coverage-aoi-mode"
|
||||
value="region"
|
||||
checked={coverageAoiMode === 'region'}
|
||||
disabled={coveragePlanning || isReadOnlyUser}
|
||||
onChange={() => void handleCoverageAoiModeChange('region')}
|
||||
/>
|
||||
行政区边界
|
||||
</label>
|
||||
<label className={`coverage-aoi-mode ${coverageAoiMode === 'file' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="coverage-aoi-mode"
|
||||
value="file"
|
||||
checked={coverageAoiMode === 'file'}
|
||||
disabled={coveragePlanning || isReadOnlyUser}
|
||||
onChange={() => void handleCoverageAoiModeChange('file')}
|
||||
/>
|
||||
上传 AOI 文件
|
||||
</label>
|
||||
</div>
|
||||
{coverageAoiMode === 'region' ? (
|
||||
<div className="coverage-aoi-selects">
|
||||
<label>
|
||||
省份
|
||||
<select
|
||||
value={coverageRegionSelection.province}
|
||||
onChange={event => void handleCoverageProvinceChange(event.target.value)}
|
||||
disabled={coveragePlanning || coverageRegionLoading || isReadOnlyUser}
|
||||
>
|
||||
<option value="">选择省份</option>
|
||||
{coverageRegionOptions.provinces.map(item => (
|
||||
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
地市
|
||||
<select
|
||||
value={coverageRegionSelection.city}
|
||||
onChange={event => handleCoverageCityChange(event.target.value)}
|
||||
disabled={coveragePlanning || coverageRegionLoading || !coverageRegionSelection.province || isReadOnlyUser}
|
||||
>
|
||||
<option value="">全省/不限地市</option>
|
||||
{coverageRegionOptions.cities.map(item => (
|
||||
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="coverage-aoi-file">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".zip,.shp,.shx,.dbf,.prj,.cpg,.geojson,.json"
|
||||
disabled={coveragePlanning || isReadOnlyUser}
|
||||
onChange={event => handleCoverageFilesChange(event.target.files)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="coverage-aoi-note">
|
||||
覆盖目标:优先增加所选 AOI 的空间面覆盖;时间范围用于限定候选影像池。
|
||||
</div>
|
||||
{coverageAoiError && (
|
||||
<div className="coverage-aoi-error">{coverageAoiError}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="coverage-action-row">
|
||||
<button onClick={handleCoveragePlan} disabled={coveragePlanning || isLoading || isReadOnlyUser}>
|
||||
{coveragePlanning ? '规划中...' : '生成覆盖配对'}
|
||||
</button>
|
||||
<span>{coverageAoiSummary}</span>
|
||||
</div>
|
||||
{coverageResult && (
|
||||
<div className={`coverage-result ${coverageResult.error ? 'error' : 'ready'}`}>
|
||||
{coverageResult.error ? (
|
||||
<span>{coverageResult.error}</span>
|
||||
) : (
|
||||
<>
|
||||
<strong>
|
||||
已生成 {coverageResult.pairCount} 对,AOI 空间覆盖率 {Math.round(Number(coverageResult.coverage?.aoi_coverage_ratio ?? coverageResult.coverage?.coverage_ratio ?? 0) * 100)}%
|
||||
</strong>
|
||||
<span>
|
||||
候选 {Number(coverageResult.candidate_count || 0)},网络 {coverageResult.network_run_id || '-'},
|
||||
影像池范围 {coverageResult.coverage?.query_date_from || '-'} 至 {coverageResult.coverage?.query_date_to || '-'}。
|
||||
</span>
|
||||
<span>
|
||||
时间覆盖率 {Math.round(Number(coverageResult.coverage?.temporal_coverage_ratio || 0) * 100)}%;
|
||||
任务上限 {coverageResult.coverage?.max_pairs || coverageForm.max_pairs};
|
||||
停止原因:{coverageResult.coverage?.stop_reason || '-'}。
|
||||
</span>
|
||||
<span>
|
||||
贪心规则:每一步选择新增 AOI 有效覆盖面积最大的候选对;达到目标覆盖率或新增收益低于阈值后停止。
|
||||
</span>
|
||||
{coverageResult.coverage?.uncovered_ranges?.length > 0 && (
|
||||
<span>
|
||||
时间缺口:{coverageResult.coverage.uncovered_ranges.slice(0, 3).map(item => `${item.date_from}-${item.date_to}`).join(',')}
|
||||
</span>
|
||||
)}
|
||||
<div className="coverage-result-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPairsView}
|
||||
disabled={typeof onOpenPairsView !== 'function' || foundPairs.length === 0}
|
||||
>
|
||||
查看全部并保存批次
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
{foundPairs.length > 0 && (
|
||||
<div className="coverage-map-toolbar">
|
||||
<strong>覆盖检视</strong>
|
||||
<span>
|
||||
{mapShowsEffectiveCoverage
|
||||
? `显示 ${previewEffectiveGeojson.features.length} 个有效覆盖面(master ∩ slave ∩ AOI)`
|
||||
: `显示 ${previewPairs.length} 对主辅影像 footprint`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={coverageMapMode === 'effective' ? 'active' : ''}
|
||||
onClick={() => setCoverageMapMode('effective')}
|
||||
disabled={previewEffectiveGeojson.features.length === 0}
|
||||
>
|
||||
有效覆盖面
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={coverageMapMode === 'footprint' ? 'active' : ''}
|
||||
onClick={() => setCoverageMapMode('footprint')}
|
||||
>
|
||||
主辅影像范围
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MiniCoverageMap
|
||||
title={en ? 'D-InSAR Pair Coverage Preview' : 'D-InSAR配对范围预览'}
|
||||
subtitle={
|
||||
foundPairs.length > previewPairs.length
|
||||
? `${previewPairs.length}/${foundPairs.length} 对`
|
||||
: `${foundPairs.length} 对`
|
||||
}
|
||||
polygons={previewPolygons}
|
||||
height={260}
|
||||
subtitle={mapShowsEffectiveCoverage ? `${previewEffectiveGeojson.features.length} 个有效覆盖面` : `${previewPairs.length} 对`}
|
||||
polygons={mapShowsEffectiveCoverage ? [] : previewPolygons}
|
||||
geojson={mapShowsEffectiveCoverage ? previewEffectiveGeojson : (coverageAoiGeojson || coverageResult?.aoi_geojson)}
|
||||
height={420}
|
||||
emptyText={en ? 'Run pair planning to preview pair footprints.' : '生成配对后显示候选范围。'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -112,14 +112,18 @@ function PairsListPanel({
|
||||
) : (
|
||||
<>
|
||||
<div className="list-toolbar">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPairsSelected}
|
||||
onChange={handleSelectAllPairs}
|
||||
id="select-all-pairs"
|
||||
/>
|
||||
<label htmlFor="select-all-pairs">
|
||||
全选 ({selectedPairsCount} / {foundPairs.length} 已选择)
|
||||
<div className="list-toolbar-main">
|
||||
<strong>候选配对列表</strong>
|
||||
<span>共 {foundPairs.length} 对,已选择 {selectedPairsCount} 对;列表支持滚动查看全部结果。</span>
|
||||
</div>
|
||||
<label className="select-all-pairs-control" htmlFor="select-all-pairs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPairsSelected}
|
||||
onChange={handleSelectAllPairs}
|
||||
id="select-all-pairs"
|
||||
/>
|
||||
全选
|
||||
</label>
|
||||
</div>
|
||||
<VirtualizedList
|
||||
@@ -179,7 +183,7 @@ function PairsListPanel({
|
||||
className="footer-button"
|
||||
title="保存选中的配对为任务批次"
|
||||
>
|
||||
保存批次 ({selectedPairsCount})
|
||||
保存选中配对为批次 ({selectedPairsCount})
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
|
||||
@@ -7,19 +7,11 @@ const s = (set, key) => (v) =>
|
||||
export const useAuthStore = create((set) => ({
|
||||
currentUser: null,
|
||||
authChecked: false,
|
||||
licenseStatus: { ok: false, reason: '', expires_at: null },
|
||||
licenseLoading: true,
|
||||
licenseUploadStatus: { type: '', message: '' },
|
||||
licenseFileName: '',
|
||||
healthStatus: null,
|
||||
healthLoading: false,
|
||||
healthError: '',
|
||||
setCurrentUser: s(set, 'currentUser'),
|
||||
setAuthChecked: s(set, 'authChecked'),
|
||||
setLicenseStatus: s(set, 'licenseStatus'),
|
||||
setLicenseLoading: s(set, 'licenseLoading'),
|
||||
setLicenseUploadStatus: s(set, 'licenseUploadStatus'),
|
||||
setLicenseFileName: s(set, 'licenseFileName'),
|
||||
setHealthStatus: s(set, 'healthStatus'),
|
||||
setHealthLoading: s(set, 'healthLoading'),
|
||||
setHealthError: s(set, 'healthError'),
|
||||
|
||||
@@ -87,10 +87,14 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
|
||||
return '洪涝灾害分析';
|
||||
case 'health':
|
||||
return '运行维护';
|
||||
case 'ops_tasks':
|
||||
return '任务维护';
|
||||
case 'users':
|
||||
return '用户管理';
|
||||
case 'audit':
|
||||
return '审计日志';
|
||||
case 'user_manual':
|
||||
return '用户手册';
|
||||
default:
|
||||
return tabKey;
|
||||
}
|
||||
@@ -120,10 +124,14 @@ export const getLeftTabDescription = (tabKey) => {
|
||||
return '围绕洪涝场景开展水体提取、过程分析和专题制图。';
|
||||
case 'health':
|
||||
return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。';
|
||||
case 'ops_tasks':
|
||||
return '查看失败、部分成功、取消和残留运行的任务,按诊断预览清理数据库记录和生产目录。';
|
||||
case 'users':
|
||||
return '维护系统用户、角色与访问权限。';
|
||||
case 'audit':
|
||||
return '查看关键操作和生产任务的审计记录。';
|
||||
case 'user_manual':
|
||||
return '查看系统主要工作流、生产口径、统计解释和常见问题,辅助用户快速定位正确操作入口。';
|
||||
default:
|
||||
return '当前模块用于支撑科研工程生产流程。';
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
@@ -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())
|
||||
@@ -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 +0,0 @@
|
||||
QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw=
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user