chore: initialize insar management system v2

This commit is contained in:
2026-04-14 13:16:01 +08:00
commit ecc72ec9cd
361 changed files with 2142522 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
import argparse
import os
import sys
from typing import Iterable, Set
import httpx
def _check_status(
response: httpx.Response,
expected: Iterable[int],
success_message: str,
fail_message: str,
failures: list[str],
) -> bool:
expected_set: Set[int] = set(expected)
if response.status_code in expected_set:
print(f"[PASS] {success_message} (status={response.status_code})")
return True
print(f"[FAIL] {fail_message} (status={response.status_code}, body={response.text})")
failures.append(fail_message)
return False
def main() -> int:
parser = argparse.ArgumentParser(description="Auth and permission smoke checks for InSAR backend.")
parser.add_argument("--base-url", default=os.getenv("AUTH_SMOKE_BASE_URL", "http://127.0.0.1:8000"))
parser.add_argument("--admin-user", default=os.getenv("INIT_ADMIN_USERNAME", "admin"))
parser.add_argument("--admin-password", default=os.getenv("INIT_ADMIN_PASSWORD", "ChangeMe123!"))
parser.add_argument("--viewer-user", default=os.getenv("AUTH_SMOKE_VIEWER_USERNAME", ""))
parser.add_argument("--viewer-password", default=os.getenv("AUTH_SMOKE_VIEWER_PASSWORD", ""))
args = parser.parse_args()
failures: list[str] = []
timeout = httpx.Timeout(10.0)
try:
with httpx.Client(base_url=args.base_url.rstrip("/"), timeout=timeout) as client:
print(f"[*] Target: {args.base_url.rstrip('/')}")
unauth_me = client.get("/api/auth/me")
_check_status(
unauth_me,
{401},
"未登录访问 /api/auth/me 返回 401",
"未登录访问 /api/auth/me 未返回 401",
failures,
)
login_admin = client.post(
"/api/auth/login",
json={"username": args.admin_user, "password": args.admin_password},
)
if not _check_status(
login_admin,
{200},
"管理员登录成功",
"管理员登录失败",
failures,
):
print("[INFO] 管理员登录失败,后续检查跳过。")
return 1
me_admin = client.get("/api/auth/me")
if _check_status(
me_admin,
{200},
"管理员会话可访问 /api/auth/me",
"管理员会话无法访问 /api/auth/me",
failures,
):
role = (me_admin.json() or {}).get("role")
if role != "admin":
print(f"[FAIL] 管理员角色异常,实际 role={role}")
failures.append("管理员角色异常")
else:
print("[PASS] 管理员角色校验通过")
audit_logs = client.get("/api/auth/audit-logs", params={"limit": 20})
if _check_status(
audit_logs,
{200},
"管理员可查询审计日志 /api/auth/audit-logs",
"管理员无法查询审计日志 /api/auth/audit-logs",
failures,
):
payload = audit_logs.json()
if isinstance(payload, list):
print(f"[PASS] 审计日志接口返回列表(条数={len(payload)}")
else:
print("[FAIL] 审计日志接口返回格式不是列表")
failures.append("审计日志接口返回格式异常")
run_now_admin = client.post("/api/monitor/run-now")
_check_status(
run_now_admin,
{200, 202, 400, 409},
"管理员可触发写操作接口(未被鉴权拒绝)",
"管理员触发写操作接口异常(可能被鉴权拒绝)",
failures,
)
client.post("/api/auth/logout")
if args.viewer_user and args.viewer_password:
login_viewer = client.post(
"/api/auth/login",
json={"username": args.viewer_user, "password": args.viewer_password},
)
if _check_status(
login_viewer,
{200},
"只读用户登录成功",
"只读用户登录失败",
failures,
):
me_viewer = client.get("/api/auth/me")
if _check_status(
me_viewer,
{200},
"只读用户会话可访问 /api/auth/me",
"只读用户会话无法访问 /api/auth/me",
failures,
):
role = (me_viewer.json() or {}).get("role")
if role != "viewer":
print(f"[FAIL] 只读用户角色异常,实际 role={role}")
failures.append("只读用户角色异常")
else:
print("[PASS] 只读用户角色校验通过")
run_now_viewer = client.post("/api/monitor/run-now")
_check_status(
run_now_viewer,
{403},
"只读用户写操作被拒绝(403",
"只读用户写操作未被拒绝",
failures,
)
client.post("/api/auth/logout")
else:
print("[INFO] 未提供只读用户凭据,跳过 viewer 鉴权回归。")
except httpx.RequestError as exc:
print(f"[FAIL] 无法连接后端: {exc}")
return 1
if failures:
print("\n[RESULT] 鉴权冒烟检查失败。")
for item in failures:
print(f"- {item}")
return 1
print("\n[RESULT] 鉴权冒烟检查通过。")
return 0
if __name__ == "__main__":
sys.exit(main())
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Database Connection Health Check Script.
Runs before the main system startup to verify PostgreSQL connectivity and authentication.
"""
import os
import socket
import sys
from urllib.parse import urlparse
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_ROOT = _project_root()
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from backend.app.config import ensure_project_env_loaded, settings
def check_database_connection() -> bool:
ensure_project_env_loaded()
print("[*] Loaded deployment config.")
database_url = settings.DATABASE_URL
if not database_url:
print("[ERROR] DATABASE_URL not found. Please check .env.")
return False
print("[*] Parsing database URL...")
parsed = urlparse(database_url)
host = parsed.hostname
port = parsed.port or 5432
user = parsed.username
password = parsed.password
dbname = parsed.path.strip("/")
if not host:
print("[ERROR] Unable to parse database host.")
return False
print(f"[*] Connecting to database server {host}:{port} ...")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
try:
result = sock.connect_ex((host, port))
if result != 0:
print(f"[FAIL] Cannot connect to {host}:{port}. Port closed or blocked.")
return False
print(f"[OK] Port {port} is reachable.")
except socket.gaierror:
print(f"[FAIL] DNS lookup failed: {host}")
return False
except Exception as exc:
print(f"[FAIL] Connection error: {exc}")
return False
finally:
sock.close()
print("[*] Verifying credentials and database access...")
conn = None
try:
import psycopg2
conn = psycopg2.connect(
host=host,
port=port,
user=user,
password=password,
dbname=dbname,
connect_timeout=5,
)
cur = conn.cursor()
cur.execute("SELECT 1;")
cur.fetchone()
cur.close()
conn.commit()
print(f"[OK] Database {dbname} authenticated successfully.")
return True
except ImportError:
print("[WARN] psycopg2 not installed, trying SQLAlchemy fallback...")
try:
from sqlalchemy import create_engine, text
sync_db_url = f"postgresql://{user}:{password}@{host}:{port}/{dbname}"
engine = create_engine(sync_db_url, connect_timeout=5)
with engine.connect() as conn_sa:
conn_sa.execute(text("SELECT 1"))
print(f"[OK] Database {dbname} authenticated (SQLAlchemy fallback).")
return True
except ImportError:
print("[WARN] No Python database drivers available.")
print("[INFO] Port check passed; startup will continue.")
return True
except Exception as exc:
print(f"[FAIL] SQLAlchemy validation failed: {exc}")
return False
finally:
if "engine" in locals() and engine:
engine.dispose()
except Exception as exc:
print("\n[FAIL] Database connection failed.")
print("=" * 50)
print(f"Error type: {type(exc).__name__}")
diag_message = str(exc)
if hasattr(exc, "diag") and exc.diag:
diag_message = exc.diag.message_primary or str(exc)
print(f"Details: {diag_message}")
print("=" * 50)
if "password authentication failed" in diag_message:
print("[HINT] Check the password in DATABASE_URL (.env).")
elif "does not exist" in diag_message:
print(f"[HINT] Database '{dbname}' does not exist.")
elif "connection refused" in diag_message:
print("[HINT] Connection refused. Verify PostgreSQL allows this IP.")
else:
print("[HINT] Unknown connection error. Check database configuration.")
print("\n[CRITICAL] Unable to connect to database. Startup aborted.")
return False
finally:
if conn:
conn.close()
if __name__ == "__main__":
ok = check_database_connection()
sys.exit(0 if ok else 1)
+22
View File
@@ -0,0 +1,22 @@
"""Check output parameters of each ENVI task."""
from envipyengine import Engine
TASK_NAMES = [
"SARsInSARInterferogramGeneration",
"SARsInSARFilterAndCoherence",
"SARsInSARRemoveResidualPhaseFrequency",
"SARsInSARPhaseUnwrapping",
"SARsInSARRefinementAndReflattening",
"SARsInSARPhaseToDisplacement",
]
engine = Engine("ENVI")
for name in TASK_NAMES:
task = engine.task(name)
params = task.parameters
print(f"=== {name} ===")
if isinstance(params, (list, tuple)):
for p in params:
if isinstance(p, dict) and p.get("direction") == "output":
print(f" [OUTPUT] {p.get('name')} ({p.get('type')})")
print()
+68
View File
@@ -0,0 +1,68 @@
"""
检查 PROJ 数据库配置和版本
"""
import os
import sys
print("=" * 60)
print("PROJ 数据库诊断")
print("=" * 60)
# 1. 检查环境变量
print("\n1. 环境变量检查:")
proj_lib = os.environ.get("PROJ_LIB")
print(f" PROJ_LIB = {proj_lib}")
if proj_lib:
print(f" 路径存在: {os.path.exists(proj_lib)}")
if os.path.exists(proj_lib):
proj_db = os.path.join(proj_lib, "proj.db")
print(f" proj.db 存在: {os.path.exists(proj_db)}")
# 2. 检查 GDAL 配置
print("\n2. GDAL 配置:")
try:
from osgeo import gdal
gdal_data = gdal.GetConfigOption("GDAL_DATA")
print(f" GDAL_DATA = {gdal_data}")
if gdal_data:
proj_lib_auto = os.path.join(os.path.dirname(gdal_data), "proj")
print(f" 自动推断 PROJ_LIB = {proj_lib_auto}")
print(f" 路径存在: {os.path.exists(proj_lib_auto)}")
if os.path.exists(proj_lib_auto):
proj_db = os.path.join(proj_lib_auto, "proj.db")
print(f" proj.db 存在: {os.path.exists(proj_db)}")
except ImportError:
print(" GDAL 未安装")
# 3. 检查 PostgreSQL PROJ
print("\n3. PostgreSQL PostGIS PROJ:")
pg_proj = r"C:\Program Files\PostgreSQL\17\share\contrib\postgis-3.6\proj\proj.db"
print(f" 路径: {pg_proj}")
print(f" 存在: {os.path.exists(pg_proj)}")
# 4. 检查 PROJ 版本
print("\n4. PROJ 库版本:")
try:
import pyproj
print(f" pyproj 版本: {pyproj.__version__}")
print(f" PROJ 版本: {pyproj.proj_version_str}")
print(f" PROJ 数据目录: {pyproj.datadir.get_data_dir()}")
except ImportError:
print(" pyproj 未安装")
# 5. 测试坐标转换
print("\n5. 坐标转换测试:")
try:
import pyproj
# WGS84 to Web Mercator
transformer = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
x, y = transformer.transform(116.4, 39.9) # 北京坐标
print(f" WGS84 (116.4, 39.9) -> Web Mercator ({x:.2f}, {y:.2f})")
print(" ✅ 坐标转换正常")
except Exception as e:
print(f" ❌ 坐标转换失败: {e}")
print("\n" + "=" * 60)
print("诊断完成")
print("=" * 60)
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""
Deployment configuration validation entrypoint.
"""
import os
import sys
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_ROOT = _project_root()
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from backend.app.config import ensure_project_env_loaded, validate_runtime_config
def main() -> int:
ensure_project_env_loaded()
result = validate_runtime_config()
print("[*] Validating deployment configuration...")
for item in result.get("info", []):
print(f"[INFO] {item}")
for item in result.get("warnings", []):
print(f"[WARN] {item}")
for item in result.get("errors", []):
print(f"[ERROR] {item}")
if result.get("ok"):
print("[OK] Deployment configuration check passed.")
return 0
print("[FAIL] Deployment configuration check failed.")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""
Read-only smoke check for the catalog-first D-InSAR architecture.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from typing import Any, Dict
from sqlalchemy import func, select
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_ROOT = _project_root()
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from backend.app import database
from backend.app.config import ensure_project_env_loaded, settings
from backend.app.db_maintenance import ensure_database_ready
from backend.app.models import AiDiagnosisORM, HazardPointORM, ResultProductORM
from backend.app.services.dinsar_read_service import dinsar_read_service
from backend.app.services.health_service import get_health_status
from backend.app.services.spatial_service import spatial_service
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run a read-only smoke check for D-InSAR catalog/compat integration."
)
parser.add_argument(
"--skip-maintenance",
action="store_true",
help="Do not run database self-maintenance before the smoke check.",
)
parser.add_argument(
"--include-external",
action="store_true",
help="Include external checks such as nginx and Ollama.",
)
parser.add_argument(
"--no-refresh",
action="store_true",
help="Do not force a schema refresh inside health_service.",
)
parser.add_argument(
"--compact",
action="store_true",
help="Print compact JSON instead of pretty JSON.",
)
return parser.parse_args()
def _print_json(payload: Dict[str, Any], compact: bool) -> None:
if compact:
print(json.dumps(payload, ensure_ascii=False, default=str))
return
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
async def _run_smoke(include_external: bool, refresh: bool) -> Dict[str, Any]:
session_factory = database.AsyncSessionLocal
if session_factory is None:
raise RuntimeError("AsyncSessionLocal is not initialized.")
health = await get_health_status(
include_external=include_external,
include_details=True,
full=True,
refresh=refresh,
)
async with session_factory() as db:
catalog_records = await dinsar_read_service.list_catalog_records(db)
compat_count = await dinsar_read_service.count_compat_records(db)
compat_records = await dinsar_read_service.list_compat_records(db, limit=5, offset=0)
diagnosis_total = int(
(await db.execute(select(func.count(AiDiagnosisORM.id)))).scalar_one() or 0
)
hazard_id = (
await db.execute(
select(HazardPointORM.id).order_by(HazardPointORM.id.asc()).limit(1)
)
).scalar_one_or_none()
spatial_count = None
if hazard_id is not None:
spatial_records = await spatial_service.find_dinsar_results_near_hazard(
db,
int(hazard_id),
)
spatial_count = len(spatial_records)
product_count = int(
(
await db.execute(
select(func.count(ResultProductORM.id)).where(
ResultProductORM.catalog_name == "dinsar"
)
)
).scalar_one()
or 0
)
return {
"health_ok": bool(health.get("ok")),
"database": {
"ok": bool(health.get("database", {}).get("ok")),
"schema_ok": bool(health.get("database", {}).get("schema_ok")),
"postgis_ok": bool(health.get("database", {}).get("postgis_ok")),
},
"worker": {
"ok": bool(health.get("worker", {}).get("ok")),
"worker_count": int(health.get("worker", {}).get("worker_count") or 0),
},
"dinsar_result_catalog": health.get("dinsar_result_catalog", {}),
"dinsar_bridge": health.get("dinsar_bridge", {}),
"source_roots": health.get("source_roots", {}),
"reads": {
"catalog_records": len(catalog_records),
"compat_records": compat_count,
"compat_preview_records": len(compat_records),
"sample_public_ids": [
int(record.compat_row.id)
for record in compat_records
if getattr(record, "compat_row", None) is not None
][:5],
},
"diagnosis_total": diagnosis_total,
"spatial_count_for_first_hazard": spatial_count,
"product_count_direct": product_count,
}
def main() -> int:
args = parse_args()
ensure_project_env_loaded()
database_url = settings.DATABASE_URL
if not database_url:
print("[ERROR] DATABASE_URL not found in .env")
return 1
maintenance = None
if not args.skip_maintenance:
maintenance = ensure_database_ready(
database_url,
bootstrap_admin=True,
seed_hazard=True,
)
database.init_db()
if database.AsyncSessionLocal is None:
print("[ERROR] Failed to initialize async database session factory.")
return 1
payload = {
"maintenance": maintenance,
"smoke": asyncio.run(
_run_smoke(
include_external=args.include_external,
refresh=not args.no_refresh,
)
),
}
_print_json(payload, args.compact)
smoke = payload["smoke"]
root_blocked = int(smoke.get("source_roots", {}).get("inaccessible_count") or 0) > 0
worker_missing = not bool(smoke.get("worker", {}).get("ok"))
return 2 if root_blocked or worker_missing else 0
if __name__ == "__main__":
raise SystemExit(main())
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""
Emit launcher-facing runtime configuration as JSON.
"""
import json
import os
import sys
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_ROOT = _project_root()
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from backend.app.config import export_launcher_config
def main() -> int:
payload = export_launcher_config()
print(json.dumps(payload, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+34
View File
@@ -0,0 +1,34 @@
"""Extract parameter names for each ENVI task (input params only)."""
import sys
TASK_NAMES = [
"SARsInSARInterferogramGeneration",
"SARsInSARFilterAndCoherence",
"SARsInSARRemoveResidualPhaseFrequency",
"SARsInSARPhaseUnwrapping",
"SARsInSARRefinementAndReflattening",
"SARsInSARPhaseToDisplacement",
]
def main():
from envipyengine import Engine
engine = Engine("ENVI")
for name in TASK_NAMES:
task = engine.task(name)
params = task.parameters
print(f"=== {name} ===")
if isinstance(params, (list, tuple)):
for p in params:
if isinstance(p, dict):
direction = p.get("direction", "?")
pname = p.get("name", "?")
ptype = p.get("type", "?")
required = p.get("required", False)
req_tag = " [REQUIRED]" if required else ""
if direction == "input":
print(f" {pname} ({ptype}){req_tag}")
print()
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
import hashlib
import subprocess
import uuid
def _run_wmic(args: str) -> str:
try:
output = subprocess.check_output(
["wmic"] + args.split(),
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
shell=False,
)
return output.decode("utf-8", errors="ignore").strip()
except Exception:
return ""
def _run_powershell(cmd: str) -> str:
try:
output = subprocess.check_output(
["powershell", "-NoProfile", "-Command", cmd],
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
shell=False,
)
return output.decode("utf-8", errors="ignore").strip()
except Exception:
return ""
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_fingerprint() -> str:
uuid_text = _run_wmic("csproduct get uuid")
if not uuid_text:
uuid_text = _run_powershell("(Get-CimInstance Win32_ComputerSystemProduct).UUID")
disk_text = _run_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()
if __name__ == "__main__":
print(get_fingerprint())
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
Database initialization and auto-maintenance entrypoint.
"""
import os
import sys
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_ROOT = _project_root()
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from backend.app.config import ensure_project_env_loaded, settings
def main() -> int:
ensure_project_env_loaded()
database_url = settings.DATABASE_URL
if not database_url:
print("[ERROR] DATABASE_URL not found in .env")
return 1
from backend.app.db_maintenance import ensure_database_ready
print("[*] Validating and maintaining database schema...")
result = ensure_database_ready(database_url, bootstrap_admin=True, seed_hazard=True)
print(f"[*] Database host: {result.get('host')}")
print(f"[*] Database name: {result.get('database')}")
if result.get("mismatch_detected"):
print("[*] Schema mismatch detected and handled.")
for reason in result.get("mismatch_reasons", []):
print(f" - {reason}")
else:
print("[OK] Database schema already matches ORM metadata.")
added_columns = result.get("added_columns", [])
if added_columns:
print(f"[OK] Added missing columns ({len(added_columns)}): {added_columns}")
if result.get("schema_reset"):
print("[WARN] Schema was reset because DB_SCHEMA_RESET_ON_MISMATCH and DB_SCHEMA_RESET_CONFIRM are enabled.")
if result.get("applied_sql_files"):
print(f"[OK] Applied SQL maintenance files: {', '.join(result['applied_sql_files'])}")
admin_status = result.get("admin") or {}
if admin_status.get("message"):
print(f"[OK] {admin_status['message']}")
hazard_status = result.get("hazard_seed") or {}
if hazard_status.get("message"):
prefix = "[OK]" if hazard_status.get("seeded") or hazard_status.get("count") else "[INFO]"
print(f"{prefix} {hazard_status['message']}")
print("\n========================================")
print(" Database Initialization Complete! ")
print("========================================")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"\n[CRITICAL ERROR] {exc}")
raise SystemExit(1)
+271
View File
@@ -0,0 +1,271 @@
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()
+41
View File
@@ -0,0 +1,41 @@
@echo off
setlocal enabledelayedexpansion
:: =====================================================================
:: InSAR Management System - Conda Environment Packing Script
:: 该脚本用于将开发环境打包成离线可用的绿色版压缩包
:: =====================================================================
set ENV_NAME=InSAR
set OUTPUT_FILE=insar_env_packed.zip
echo [1/3] 正在检查 conda-pack 是否安装...
conda-pack --version >nul 2>&1
if %errorlevel% neq 0 (
echo [!] 未检测到 conda-pack, 正在尝试安装...
pip install conda-pack
)
echo [2/3] 正在清理 Conda 缓存以减小包体积...
call conda clean -y --all
echo [3/3] 正在将环境 %ENV_NAME% 打包为 %OUTPUT_FILE%...
echo [*] 这可能需要几分钟时间,请稍候...
:: 使用 zip 格式在 Windows 上有更好的兼容性
if exist %OUTPUT_FILE% del %OUTPUT_FILE%
conda pack -n %ENV_NAME% -o %OUTPUT_FILE% --format zip --compress-level 9
if %errorlevel% equ 0 (
echo.
echo =====================================================================
echo [+] 环境打包成功: %OUTPUT_FILE%
echo [+] 你可以将此文件拷贝到内网服务器,解压后即可直接使用。
echo [+] 提示: 解压后运行目录下的 scripts\python.exe 即可调用该环境。
echo =====================================================================
) else (
echo.
echo [!] 打包失败,请检查环境 %ENV_NAME% 是否存在或是否被占用。
)
pause
+594
View File
@@ -0,0 +1,594 @@
# InSAR Management System - Startup Script (PowerShell)
# Features:
# - Process management (stop old instances, start new ones)
# - Database health check
# - Auto Nginx config update
# - Logs output
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# 1. Set project root
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$ProjectRoot = Split-Path -Parent $ScriptDir
Set-Location -LiteralPath "$ProjectRoot"
Write-Host ">>> Starting InSAR Management System V2..." -ForegroundColor Cyan
Write-Host ">>> Project Root: $ProjectRoot"
# 2. Parse .env minimally (only for resolving Python/Conda before handing off to unified config)
$EnvPath = Join-Path $ProjectRoot ".env"
if (-not (Test-Path -LiteralPath "$EnvPath")) {
Write-Error "File .env not found: $EnvPath"
return
}
$PythonExe = "python"
$CondaExe = ""
$CondaEnvName = ""
$NginxExe = "C:/nginx-1.29.4/nginx.exe"
$ServerHost = ""
$ServerPort = 18000
$envLines = Get-Content -LiteralPath "$EnvPath"
foreach ($line in $envLines) {
$trimmed = $line.Trim()
if ($trimmed.StartsWith("#") -or -not $trimmed.Contains("=")) { continue }
$parts = $trimmed.Split("=", 2)
$key = $parts[0].Trim()
$val = $parts[1].Trim().Trim('"').Trim("'")
if ($key -eq "PYTHON_PATH") { if ($val) { $PythonExe = $val } }
if ($key -eq "CONDA_EXE") { if ($val) { $CondaExe = $val } }
if ($key -eq "CONDA_ENV_NAME") { if ($val) { $CondaEnvName = $val } }
if ($key -eq "NGINX_PATH") { if ($val) { $NginxExe = $val } }
if ($key -eq "BACKEND_BIND_HOST") { if ($val) { $ServerHost = $val } }
if ($key -eq "PORT") {
$parsed = 0
if ([int]::TryParse($val, [ref]$parsed)) { $ServerPort = $parsed }
}
}
$CheckDbScript = Join-Path $ProjectRoot "scripts\check_db_connection.py"
$CheckRuntimeScript = Join-Path $ProjectRoot "scripts\check_runtime_config.py"
$ExportLauncherConfigScript = Join-Path $ProjectRoot "scripts\export_launcher_config.py"
$InitDbScript = Join-Path $ProjectRoot "scripts\init_db.py"
function Resolve-ExecutablePath {
param([string]$Candidate)
$trimmed = "$Candidate".Trim().Trim('"').Trim("'")
if (-not $trimmed) {
return $null
}
if (Test-Path -LiteralPath "$trimmed") {
$resolved = Resolve-Path -LiteralPath "$trimmed" -ErrorAction SilentlyContinue
if ($resolved) {
return $resolved.Path
}
return $trimmed
}
$cmd = Get-Command -Name "$trimmed" -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($cmd -and $cmd.Source) {
return $cmd.Source
}
return $null
}
function Resolve-CondaEnvPythonPath {
param(
[string]$ResolvedCondaExe,
[string]$EnvName
)
$trimmedEnvName = "$EnvName".Trim()
if (-not $trimmedEnvName) {
return $null
}
$anacondaRoot = Split-Path -Parent (Split-Path -Parent "$ResolvedCondaExe")
$candidatePaths = @(
(Join-Path -Path $anacondaRoot -ChildPath "envs\$trimmedEnvName\python.exe"),
(Join-Path -Path $env:USERPROFILE -ChildPath ".conda\envs\$trimmedEnvName\python.exe")
)
foreach ($candidate in $candidatePaths) {
if (Test-Path -LiteralPath "$candidate") {
$resolved = Resolve-Path -LiteralPath "$candidate" -ErrorAction SilentlyContinue
if ($resolved) {
return $resolved.Path
}
return $candidate
}
}
try {
$envsJson = & "$ResolvedCondaExe" info --envs --json 2>$null
if ($LASTEXITCODE -eq 0 -and $envsJson) {
$envsInfo = $envsJson | ConvertFrom-Json
foreach ($envPath in ($envsInfo.envs | Where-Object { $_ })) {
if ((Split-Path -Leaf "$envPath") -ne $trimmedEnvName) {
continue
}
$pythonPath = Join-Path -Path "$envPath" -ChildPath "python.exe"
if (Test-Path -LiteralPath "$pythonPath") {
$resolved = Resolve-Path -LiteralPath "$pythonPath" -ErrorAction SilentlyContinue
if ($resolved) {
return $resolved.Path
}
return $pythonPath
}
}
}
} catch {
# Ignore and continue with fallback.
}
return $null
}
function Invoke-PythonScript {
param([string]$ScriptPath)
& "$PythonExe" "$ScriptPath"
}
function Invoke-PythonScriptJson {
param([string]$ScriptPath)
$output = & "$PythonExe" "$ScriptPath"
if ($LASTEXITCODE -ne 0) {
return $null
}
if (-not $output) {
return $null
}
return ($output | ConvertFrom-Json)
}
# 3. Stop old instances
Write-Host ">>> Stopping existing processes..." -ForegroundColor Yellow
function Stop-Process-By-Name {
param([string]$Name, [string]$ExeName)
# Prefer precise cmdline matching; fall back to executable path when CIM command line is unavailable.
$NginxConfMatch = Join-Path -Path $ProjectRoot -ChildPath "nginx"
$matched = @()
$resolvedExe = Resolve-ExecutablePath -Candidate $ExeName
$candidates = Get-CimInstance Win32_Process -Filter "Name='$Name.exe'" -ErrorAction SilentlyContinue
if ($candidates) {
foreach ($proc in $candidates) {
if ($proc.CommandLine -and $proc.CommandLine -like "*$NginxConfMatch*") {
$matched += $proc
}
}
}
if ($matched.Count -eq 0 -and $resolvedExe) {
$processesByPath = Get-Process -Name $Name -ErrorAction SilentlyContinue
foreach ($proc in $processesByPath) {
$procPath = $null
try {
$procPath = $proc.Path
} catch {
$procPath = $null
}
if ($procPath -and ([string]::Equals($procPath, $resolvedExe, [System.StringComparison]::OrdinalIgnoreCase))) {
$matched += $proc
}
}
}
if ($matched.Count -gt 0) {
$targetPids = @($matched | ForEach-Object {
if ($_.PSObject.Properties['ProcessId']) { $_.ProcessId }
elseif ($_.PSObject.Properties['Id']) { $_.Id }
} | Where-Object { $_ } | Sort-Object -Unique)
Write-Host " Stopping $Name (PID: $($targetPids -join ', '))..." -NoNewline
foreach ($p in $matched) {
$processId = $null
if ($p.PSObject.Properties['ProcessId']) { $processId = $p.ProcessId }
elseif ($p.PSObject.Properties['Id']) { $processId = $p.Id }
if ($processId) {
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 1
$stillAlive = $false
foreach ($targetProcessId in $targetPids) {
if (Get-Process -Id $targetProcessId -ErrorAction SilentlyContinue) {
$stillAlive = $true
}
}
if (-not $stillAlive) {
Write-Host " [Done]" -ForegroundColor Green
} else {
Write-Host " [Failed]" -ForegroundColor Red
}
}
}
function Stop-Backend-By-Cmdline {
param([string]$MatchText)
$candidates = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -ErrorAction SilentlyContinue
foreach ($proc in $candidates) {
if ($proc.CommandLine -and $proc.CommandLine -like "*$MatchText*") {
try {
Write-Host " Stopping python (PID $($proc.ProcessId)) with cmdline match: $MatchText" -NoNewline
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
$check = Get-Process -Id $proc.ProcessId -ErrorAction SilentlyContinue
if (-not $check) {
Write-Host " [Done]" -ForegroundColor Green
} else {
Write-Host " [Failed]" -ForegroundColor Red
}
} catch {
Write-Host " [Failed]" -ForegroundColor Red
}
}
}
}
function Get-ListeningPortOwner {
param([int]$Port)
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
if ($conn) {
$procName = "Unknown"
$proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue
if ($proc) {
$procName = $proc.ProcessName
}
return [PSCustomObject]@{
Port = $Port
PID = $conn.OwningProcess
ProcessName = $procName
LocalAddress = $conn.LocalAddress
}
}
$netstatLines = netstat -ano -p tcp 2>$null
if ($netstatLines) {
foreach ($line in $netstatLines) {
$trimmed = "$line".Trim()
if (-not $trimmed.StartsWith("TCP")) {
continue
}
if ($trimmed -notmatch "\s+LISTENING\s+") {
continue
}
$parts = $trimmed -split "\s+"
if ($parts.Count -lt 5) {
continue
}
$localEndpoint = $parts[1]
$pidText = $parts[4]
$localPort = -1
if ($localEndpoint -match ":(\d+)$") {
$localPort = [int]$matches[1]
}
if ($localPort -ne $Port) {
continue
}
$ownerPid = 0
[void][int]::TryParse("$pidText", [ref]$ownerPid)
$procName = "Unknown"
if ($ownerPid -gt 0) {
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
if ($proc) {
$procName = $proc.ProcessName
}
}
return [PSCustomObject]@{
Port = $Port
PID = $ownerPid
ProcessName = $procName
LocalAddress = $localEndpoint
}
}
}
return $null
}
function Test-PortAvailable {
param([int]$Port)
try {
$probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
$probe.Start()
$probe.Stop()
return $true
} catch {
return $false
}
}
Stop-Backend-By-Cmdline -MatchText "run_backend.py"
Stop-Backend-By-Cmdline -MatchText "run_worker.py"
$NginxProcName = Split-Path -Leaf $NginxExe
$NginxProcName = $NginxProcName -replace '\.exe$', ''
Stop-Process-By-Name -Name $NginxProcName -ExeName $NginxExe
$PortAvailable = Test-PortAvailable -Port $ServerPort
if (-not $PortAvailable) {
$PortOwner = Get-ListeningPortOwner -Port $ServerPort
if ($PortOwner) {
Write-Error (
"Backend port $ServerPort is already in use by PID $($PortOwner.PID) " +
"($($PortOwner.ProcessName)) on $($PortOwner.LocalAddress). " +
"Please stop that process or change PORT in .env."
)
} else {
Write-Error (
"Backend port $ServerPort is not available (bind failed). " +
"Please stop the process using this port or change PORT in .env."
)
}
$global:LASTEXITCODE = 1
return
}
# 4. Runtime config + database pre-flight check
Write-Host ">>> Checking deployment configuration..." -ForegroundColor Yellow
$UseCondaRun = -not [string]::IsNullOrWhiteSpace("$CondaEnvName".Trim())
$ResolvedCondaExe = $null
$CondaEnvPythonExe = $null
if ($UseCondaRun) {
if ([string]::IsNullOrWhiteSpace("$CondaExe".Trim())) {
$CondaExe = "conda"
}
$ResolvedCondaExe = Resolve-ExecutablePath -Candidate $CondaExe
if (-not $ResolvedCondaExe) {
Write-Error "Conda executable not found. CONDA_EXE=$CondaExe"
$global:LASTEXITCODE = 1
return
}
$CondaEnvPythonExe = Resolve-CondaEnvPythonPath -ResolvedCondaExe "$ResolvedCondaExe" -EnvName "$CondaEnvName"
if (-not $CondaEnvPythonExe) {
Write-Error "Conda environment python not found. CONDA_ENV_NAME=$CondaEnvName"
$global:LASTEXITCODE = 1
return
}
$PythonExe = $CondaEnvPythonExe
} else {
$ResolvedPythonExe = Resolve-ExecutablePath -Candidate $PythonExe
if (-not $ResolvedPythonExe) {
Write-Error "Python executable not found. PYTHON_PATH=$PythonExe"
$global:LASTEXITCODE = 1
return
}
$PythonExe = $ResolvedPythonExe
}
$LauncherConfig = Invoke-PythonScriptJson -ScriptPath "$ExportLauncherConfigScript"
if (-not $LauncherConfig) {
Write-Error "Failed to load launcher runtime configuration from Python settings layer."
$global:LASTEXITCODE = 1
return
}
if ($LauncherConfig.nginx_path) {
$NginxExe = [string]$LauncherConfig.nginx_path
}
if ($LauncherConfig.backend_bind_host) {
$ServerHost = [string]$LauncherConfig.backend_bind_host
}
if ($LauncherConfig.port) {
$ServerPort = [int]$LauncherConfig.port
}
function Write-StatusLine {
param(
[string]$Message,
[ConsoleColor]$ForegroundColor = [ConsoleColor]::White
)
Write-Host $Message -ForegroundColor $ForegroundColor
}
function Get-BackgroundProcessLogs {
param([string]$ScriptPath)
$LogDir = Join-Path $ProjectRoot "logs"
if (-not (Test-Path -LiteralPath "$LogDir")) {
New-Item -ItemType Directory -Path "$LogDir" -Force | Out-Null
}
$ScriptBase = [System.IO.Path]::GetFileNameWithoutExtension("$ScriptPath")
return [PSCustomObject]@{
StdOut = Join-Path $LogDir "$ScriptBase.stdout.log"
StdErr = Join-Path $LogDir "$ScriptBase.stderr.log"
}
}
function Start-PythonBackground {
param([string]$ScriptPath)
$LogTargets = Get-BackgroundProcessLogs -ScriptPath "$ScriptPath"
Write-Host " stdout -> $($LogTargets.StdOut)"
Write-Host " stderr -> $($LogTargets.StdErr)"
return Start-Process `
-FilePath "$PythonExe" `
-ArgumentList @("$ScriptPath") `
-WorkingDirectory "$ProjectRoot" `
-PassThru `
-NoNewWindow `
-RedirectStandardOutput "$($LogTargets.StdOut)" `
-RedirectStandardError "$($LogTargets.StdErr)"
}
function Assert-ProcessAlive {
param(
[System.Diagnostics.Process]$Process,
[string]$DisplayName
)
if (-not $Process) {
Write-Error "$DisplayName failed to start: process handle is null."
$global:LASTEXITCODE = 1
return $false
}
Start-Sleep -Milliseconds 800
$procCheck = Get-Process -Id $Process.Id -ErrorAction SilentlyContinue
if (-not $procCheck) {
Write-Error "$DisplayName exited immediately after startup. Please check logs for details."
$global:LASTEXITCODE = 1
return $false
}
return $true
}
Invoke-PythonScript -ScriptPath "$CheckRuntimeScript"
if ($LastExitCode -ne 0) {
Write-Host "`n[ERROR] Deployment configuration check failed." -ForegroundColor Red
Write-Host "Please review the error messages above and fix the configuration." -ForegroundColor Red
$global:LASTEXITCODE = 1
return
}
Write-Host ">>> Checking database connection..." -ForegroundColor Yellow
Invoke-PythonScript -ScriptPath "$CheckDbScript"
if ($LastExitCode -ne 0) {
Write-Host "`n[ERROR] Database connection check failed." -ForegroundColor Red
Write-Host "Please review the error messages above and fix the configuration." -ForegroundColor Red
$global:LASTEXITCODE = 1
return
}
# 4.5 Schema sync
Write-Host ">>> Checking database schema..." -ForegroundColor Yellow
Invoke-PythonScript -ScriptPath "$InitDbScript"
if ($LastExitCode -ne 0) {
Write-Host "`n[ERROR] Database schema check failed." -ForegroundColor Red
Write-Host "Please review the error messages above and fix the configuration." -ForegroundColor Red
$global:LASTEXITCODE = 1
return
}
# 5. Update Nginx config (absolute path fix)
$NginxConfPath = Join-Path -Path $ProjectRoot -ChildPath "nginx\nginx.conf"
if (Test-Path -LiteralPath "$NginxConfPath") {
Write-Host ">>> Configuring Nginx paths..." -ForegroundColor Yellow
$NginxBase = Split-Path -Parent "$NginxExe"
$SrcMime = Join-Path -Path $NginxBase -ChildPath "conf\mime.types"
$DestMime = Join-Path -Path $ProjectRoot -ChildPath "nginx\mime.types"
if (-not (Test-Path -LiteralPath "$DestMime") -and (Test-Path -LiteralPath "$SrcMime")) {
Copy-Item -Path "$SrcMime" -Destination "$DestMime" -Force
}
$ForwardRoot = $ProjectRoot.Replace([char]92, [char]47)
$FrontendDist = "$ForwardRoot/frontend/dist"
$ImageCache = "$ForwardRoot/backend/image_cache"
if (-not (Test-Path -LiteralPath "$ProjectRoot/backend/image_cache")) {
New-Item -ItemType Directory -Path "$ProjectRoot/backend/image_cache" -Force | Out-Null
}
$ConfContent = Get-Content -LiteralPath "$NginxConfPath" -Raw
$NewConfContent = $ConfContent -replace 'root\s+[^;]+;', "root `"$FrontendDist`";"
$NewConfContent = $NewConfContent -replace 'alias\s+[^;]+;', "alias `"$ImageCache/`";"
$BackendProxy = "http://127.0.0.1:$ServerPort"
$NewConfContent = $NewConfContent -replace 'proxy_pass\s+http://(127\.0\.0\.1|localhost):\d+;', "proxy_pass $BackendProxy;"
# 使用 UTF8 无 BOM 编码写入
$Utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText("$NginxConfPath", $NewConfContent, $Utf8NoBom)
}
# 6. Start backend
Write-Host ">>> Launching backend (FastAPI)..." -ForegroundColor Green
if ($UseCondaRun) {
Write-Host ">>> Using Conda env python: $PythonExe (env=$CondaEnvName)"
} else {
Write-Host ">>> Using Python: $PythonExe"
}
$BackendProc = Start-PythonBackground -ScriptPath "run_backend.py"
if (-not (Assert-ProcessAlive -Process $BackendProc -DisplayName "Backend")) {
return
}
# 6.5 Start job worker
Write-Host ">>> Launching job worker..." -ForegroundColor Green
$WorkerProc = Start-PythonBackground -ScriptPath "run_worker.py"
if (-not (Assert-ProcessAlive -Process $WorkerProc -DisplayName "Worker")) {
return
}
# 7. Start Nginx
if (Test-Path -LiteralPath "$NginxExe") {
Write-Host ">>> Launching Nginx..." -ForegroundColor Green
$NginxDir = Split-Path -Parent "$NginxExe"
$NginxName = Split-Path -Leaf $NginxExe
$SafeConfPath = $NginxConfPath.Replace([char]92, [char]47)
$NginxArgs = @("-c", "$SafeConfPath")
$NginxLogs = Get-BackgroundProcessLogs -ScriptPath "$NginxName"
Write-Host " stdout -> $($NginxLogs.StdOut)"
Write-Host " stderr -> $($NginxLogs.StdErr)"
$NginxProc = Start-Process `
-FilePath "$NginxExe" `
-ArgumentList $NginxArgs `
-WorkingDirectory "$NginxDir" `
-PassThru `
-NoNewWindow `
-RedirectStandardOutput "$($NginxLogs.StdOut)" `
-RedirectStandardError "$($NginxLogs.StdErr)"
Start-Sleep -Seconds 2
$NginxProcName = $NginxName -replace '\.exe$', ''
$NginxRunning = Get-Process -Name $NginxProcName -ErrorAction SilentlyContinue
if ($NginxRunning) {
$DisplayHost = "localhost"
if ($ServerHost) { $DisplayHost = $ServerHost }
Write-Host ""
Write-StatusLine "============================================================" Green
Write-StatusLine "SUCCESS: InSAR Management System is running!" Green
Write-StatusLine "Frontend (via Nginx): http://$DisplayHost"
Write-StatusLine "Backend (internal): http://127.0.0.1`:$ServerPort"
Write-StatusLine "API Docs (internal): http://127.0.0.1`:$ServerPort/docs"
Write-StatusLine "============================================================" Green
Write-Host ""
Write-StatusLine "System is running. Press Ctrl+C to stop all services." Yellow
} else {
Write-Warning "Nginx process not found. Check logs/nginx_error.log for details."
}
} else {
Write-Warning "Nginx executable not found at: $NginxExe"
}
# 8. Wait for exit
try {
Write-Host ""
Write-StatusLine "Waiting for processes (Backend PID: $($BackendProc.Id), Worker PID: $($WorkerProc.Id))..." Cyan
$BackgroundJob = Register-ObjectEvent -InputObject $BackendProc -EventName "Exited" -Action { Write-Host "`nBackend process exited." -ForegroundColor Red } -ErrorAction SilentlyContinue
Wait-Process -Id $BackendProc.Id -ErrorAction SilentlyContinue
} finally {
Write-Host "`nShutting down..." -ForegroundColor Yellow
Stop-Backend-By-Cmdline -MatchText "run_backend.py"
Stop-Backend-By-Cmdline -MatchText "run_worker.py"
Stop-Process-By-Name -Name $NginxProcName -ExeName $NginxExe
Write-Host "Done." -ForegroundColor Green
}
+424
View File
@@ -0,0 +1,424 @@
import os
import json
import shutil
import tarfile
import logging
from datetime import datetime
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
ENV_PATH = os.path.join(PROJECT_ROOT, ".env")
# 使用统一的日志目录
LOG_DIR = os.path.join(PROJECT_ROOT, "logs", "tasks", "unpacker")
os.makedirs(LOG_DIR, exist_ok=True)
# 使用日期命名日志文件
log_date = datetime.now().strftime("%Y%m%d")
LOG_FILE = os.path.join(LOG_DIR, f"unpacker_{log_date}.json")
REPORT_FILE = os.path.join(LOG_DIR, f"unpacker_{log_date}_report.txt")
ACTIVITY_LOG = os.path.join(LOG_DIR, f"unpacker_{log_date}.log")
class ProjWarningFilter(logging.Filter):
"""过滤重复的 PROJ 数据库版本警告"""
def __init__(self):
super().__init__()
self.proj_warning_logged = False
def filter(self, record):
# 检查是否是 PROJ 警告
if "PROJ: proj_identify" in record.getMessage() and "DATABASE.LAYOUT.VERSION.MINOR" in record.getMessage():
if self.proj_warning_logged:
return False # 已经记录过,过滤掉
else:
self.proj_warning_logged = True
# 修改消息,添加提示
record.msg = record.msg + " (后续相同警告已过滤)"
return True
return True
def load_env(path):
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 parse_dirs(value):
if not value:
return []
value = value.replace(";", ",")
return [p.strip() for p in value.split(",") if p.strip()]
def parse_bool(value, default=False):
if value is None:
return default
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def get_disk_usage(path):
try:
os.makedirs(path, exist_ok=True)
total, used, free = shutil.disk_usage(path)
return total, used, free
except FileNotFoundError:
logging.error("disk usage failed for path: %s", path)
return 0, 0, 0
def find_archives(directories, extensions):
archive_files = []
for directory in directories:
if not os.path.isdir(directory):
logging.warning("source directory not found: %s", directory)
continue
for root, _, files in os.walk(directory):
for file in files:
lower = file.lower()
if any(lower.endswith(ext) for ext in extensions):
archive_files.append(os.path.join(root, file))
return archive_files
def load_progress(log_file):
if os.path.exists(log_file):
try:
with open(log_file, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
logging.warning("failed to read log file '%s': %s", log_file, e)
return {"processed_files": [], "failed_files": []}
def save_progress(log_file, progress):
try:
with open(log_file, "w", encoding="utf-8") as f:
json.dump(progress, f, indent=2, ensure_ascii=False)
except IOError as e:
logging.error("failed to write log file '%s': %s", log_file, e)
def create_report(report_file, reason, processed_count, remaining_count):
try:
with open(report_file, "w", encoding="utf-8") as f:
f.write("--- Unpacker Report ---\n\n")
f.write("Stopped at: %s\n" % datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
f.write("Reason: %s\n\n" % reason)
f.write("Processed files: %s\n" % processed_count)
f.write("Remaining files: %s\n" % remaining_count)
logging.info("report written: %s", report_file)
except IOError as e:
logging.error("failed to write report '%s': %s", report_file, e)
def _is_safe_tar_member(member_name):
norm_name = os.path.normpath(member_name)
if os.path.isabs(norm_name):
return False
if norm_name.startswith("..") or norm_name.startswith("../") or norm_name.startswith("..\\"):
return False
return True
def _validate_tar_members(tar_obj, archive_path):
for member in tar_obj.getmembers():
if not _is_safe_tar_member(member.name):
raise IOError(f"unsafe tar entry detected: {member.name} in {archive_path}")
def get_archive_uncompressed_size(archive_path):
try:
with tarfile.open(archive_path, "r:*") as tar:
_validate_tar_members(tar, archive_path)
return sum(m.size for m in tar.getmembers() if m.isfile())
except (tarfile.TarError, FileNotFoundError, IsADirectoryError) as e:
logging.error("failed to calculate size for '%s': %s", archive_path, e)
return -1
def pick_storage_dir(storage_dirs, required_bytes, min_free_bytes):
candidates = []
for d in storage_dirs:
_, _, free = get_disk_usage(d)
if (free - required_bytes) >= min_free_bytes:
candidates.append((free, d))
if not candidates:
return None
candidates.sort(reverse=True)
return candidates[0][1]
def _normalize_path(path):
return os.path.normcase(os.path.abspath(path))
def _resolve_target_root(archive_path, source_dirs, target_dirs):
if not target_dirs:
return None
if len(target_dirs) == 1:
return target_dirs[0]
if source_dirs and len(source_dirs) == len(target_dirs):
archive_norm = _normalize_path(archive_path)
matches = []
for idx, src in enumerate(source_dirs):
src_norm = _normalize_path(src)
if archive_norm == src_norm or archive_norm.startswith(src_norm + os.sep):
matches.append((len(src_norm), idx))
if matches:
_, best_idx = max(matches)
return target_dirs[best_idx]
return target_dirs[0]
def atomic_extract(archive_path, output_dir, tmp_suffix):
tmp_dir = output_dir + tmp_suffix
lock_path = output_dir + ".unpacking"
if os.path.exists(output_dir):
logging.warning("output exists, skip: %s", output_dir)
return False
if os.path.exists(tmp_dir):
logging.warning("temp dir exists, skip: %s", tmp_dir)
return False
if os.path.exists(lock_path):
logging.warning("lock exists, skip: %s", lock_path)
return False
os.makedirs(tmp_dir, exist_ok=True)
with open(lock_path, "w", encoding="utf-8") as f:
f.write(datetime.now().isoformat())
try:
with tarfile.open(archive_path, "r:*") as tar:
_validate_tar_members(tar, archive_path)
tar.extractall(path=tmp_dir)
if not os.listdir(tmp_dir):
raise IOError("extracted directory is empty")
os.replace(tmp_dir, output_dir)
return True
finally:
if os.path.exists(lock_path):
try:
os.remove(lock_path)
except OSError:
pass
if os.path.exists(tmp_dir):
try:
shutil.rmtree(tmp_dir)
except OSError:
pass
def run_unpack_job(env_path=None, log_callback=None, progress_callback=None):
def _log(level, message, *args):
logging.log(level, message, *args)
if log_callback:
formatted = message % args if args else message
log_callback(logging.getLevelName(level), formatted)
def _progress(progress, message):
if progress_callback:
progress_callback(progress, message)
# 配置日志过滤器
proj_filter = ProjWarningFilter()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.FileHandler(ACTIVITY_LOG, "a", "utf-8"),
logging.StreamHandler(),
],
)
# 为所有 handler 添加过滤器
for handler in logging.root.handlers:
handler.addFilter(proj_filter)
env = load_env(env_path or ENV_PATH)
source_dirs = parse_dirs(env.get("UNPACK_SOURCE_DIRS"))
target_dirs = parse_dirs(
env.get("INSAR_STORAGE_DIRS")
or env.get("UNPACK_TARGET_DIRS")
or env.get("UNPACK_STORAGE_DIRS")
)
min_disk_gb = float(env.get("UNPACK_MIN_DISK_SPACE_GB", "50"))
delete_archive = parse_bool(env.get("UNPACK_DELETE_ARCHIVE", "true"))
tmp_suffix = env.get("UNPACK_TMP_SUFFIX", ".unpack_tmp")
extensions = parse_dirs(env.get("UNPACK_ARCHIVE_EXTS", ".tar.gz"))
_log(logging.INFO, "=== start unpack job ===")
if not source_dirs:
_log(logging.INFO, "no UNPACK_SOURCE_DIRS configured, exit")
return {
"processed": 0,
"failed": 0,
"skipped": 0,
"total": 0,
"message": "no source dirs configured",
}
progress = load_progress(LOG_FILE)
processed_files = set(progress.get("processed_files", []))
all_archives = find_archives(source_dirs, extensions)
files_to_process = [f for f in all_archives if f not in processed_files]
_log(
logging.INFO,
"found %s archives, %s processed, %s pending",
len(all_archives),
len(processed_files),
len(files_to_process),
)
if not files_to_process:
_log(logging.INFO, "nothing to do")
return {
"processed": 0,
"failed": 0,
"skipped": 0,
"total": 0,
"message": "nothing to do",
}
min_space_bytes = min_disk_gb * (1024 ** 3)
processed_count = 0
failed_count = 0
skipped_count = 0
total_files = len(files_to_process)
for i, archive_path in enumerate(files_to_process):
current_file_number = i + 1
pct = int((i / max(total_files, 1)) * 100)
_progress(pct, f"processing {current_file_number}/{total_files}")
_log(
logging.INFO,
"--- processing %s/%s: %s ---",
current_file_number,
total_files,
archive_path,
)
uncompressed_size_bytes = get_archive_uncompressed_size(archive_path)
if uncompressed_size_bytes == -1:
failure_record = {
"file": archive_path,
"error": "size_check_failed",
"timestamp": datetime.now().isoformat(),
}
progress.setdefault("failed_files", []).append(failure_record)
save_progress(LOG_FILE, progress)
failed_count += 1
continue
if target_dirs:
target_root = _resolve_target_root(archive_path, source_dirs, target_dirs)
if not target_root:
target_root = target_dirs[0]
_, _, free = get_disk_usage(target_root)
if (free - uncompressed_size_bytes) < min_space_bytes:
reason = (
"insar_storage has insufficient free space\n"
" needed: %.2f GB\n"
" min free after: %.2f GB\n"
" target: %s\n"
% (
uncompressed_size_bytes / (1024 ** 3),
min_disk_gb,
target_root,
)
)
_log(logging.WARNING, reason)
create_report(REPORT_FILE, reason, processed_count, total_files - i)
return {
"processed": processed_count,
"failed": failed_count,
"skipped": skipped_count,
"total": total_files,
"message": "insufficient free space",
}
else:
target_root = os.path.dirname(archive_path)
base_name = os.path.basename(archive_path)
for ext in [".tar.gz", ".tgz"]:
if base_name.lower().endswith(ext):
base_name = base_name[: -len(ext)]
break
output_dir = os.path.join(target_root, base_name)
try:
ok = atomic_extract(archive_path, output_dir, tmp_suffix)
if not ok:
skipped_count += 1
continue
if delete_archive:
os.remove(archive_path)
processed_files.add(archive_path)
progress["processed_files"] = sorted(list(processed_files))
save_progress(LOG_FILE, progress)
_log(logging.INFO, "done: %s", archive_path)
processed_count += 1
except (tarfile.TarError, IOError, OSError) as e:
_log(logging.ERROR, "failed to process '%s': %s", archive_path, e)
failure_record = {
"file": archive_path,
"error": str(e),
"timestamp": datetime.now().isoformat(),
}
progress.setdefault("failed_files", []).append(failure_record)
save_progress(LOG_FILE, progress)
failed_count += 1
except Exception as e:
_log(logging.CRITICAL, "unexpected error '%s': %s", archive_path, e)
failure_record = {
"file": archive_path,
"error": "unexpected: %s" % e,
"timestamp": datetime.now().isoformat(),
}
progress.setdefault("failed_files", []).append(failure_record)
save_progress(LOG_FILE, progress)
failed_count += 1
pct = int(((i + 1) / max(total_files, 1)) * 100)
_progress(pct, f"processed {current_file_number}/{total_files}")
if os.path.exists(REPORT_FILE):
os.remove(REPORT_FILE)
_log(logging.INFO, "=== unpack job complete ===")
return {
"processed": processed_count,
"failed": failed_count,
"skipped": skipped_count,
"total": total_files,
"message": "completed",
}
def main():
run_unpack_job()
if __name__ == "__main__":
main()
+50
View File
@@ -0,0 +1,50 @@
"""Verify envipyengine task names for the 6-step custom D-InSAR workflow.
Run:
python scripts/verify_envi_tasks.py
"""
import sys
TASK_NAMES = [
"SARsInSARInterferogramGeneration",
"SARsInSARFilterAndCoherence",
"SARsInSARRemoveResidualPhaseFrequency",
"SARsInSARPhaseUnwrapping",
"SARsInSARRefinementAndReflattening",
"SARsInSARPhaseToDisplacement",
# metatask (already working)
"SARsMetataskInSARDisplacementGeneration",
]
def main():
try:
from envipyengine import Engine
except ImportError:
print("[ERROR] envipyengine not installed")
return 1
engine = Engine("ENVI")
ok = 0
fail = 0
for name in TASK_NAMES:
try:
task = engine.task(name)
params = task.parameters
param_names = list(params.keys()) if isinstance(params, dict) else str(params)
print(f"[OK] {name}")
print(f" params: {param_names}")
print()
ok += 1
except Exception as exc:
print(f"[FAIL] {name}: {exc}")
print()
fail += 1
print(f"--- Result: {ok} ok, {fail} fail ---")
return 0 if fail == 0 else 1
if __name__ == "__main__":
sys.exit(main())