1319 lines
55 KiB
Python
1319 lines
55 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, List, Optional
|
|
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from .. import database
|
|
from ..config import settings
|
|
from ..models import (
|
|
AuthUserORM,
|
|
DinsarResultORM,
|
|
RadarDataORM,
|
|
ResultAssetORM,
|
|
ResultDeliveryItemORM,
|
|
ResultDeliveryRequestORM,
|
|
ResultProductORM,
|
|
SARSceneGeoORM,
|
|
)
|
|
from .dinsar_read_service import dinsar_read_service
|
|
from .job_queue_service import job_queue_service
|
|
from .task_service import task_service
|
|
|
|
|
|
JOB_TYPE_RESULT_DELIVERY_BUILD = "RESULT_DELIVERY_BUILD"
|
|
TASK_TYPE_RESULT_DELIVERY_BUILD = "RESULT_DELIVERY_BUILD"
|
|
|
|
DELIVERY_STATUS_PENDING = "PENDING"
|
|
DELIVERY_STATUS_RUNNING = "RUNNING"
|
|
DELIVERY_STATUS_READY = "READY"
|
|
DELIVERY_STATUS_FAILED = "FAILED"
|
|
DELIVERY_STATUS_CANCELLED = "CANCELLED"
|
|
DELIVERY_STATUS_EXPIRED = "EXPIRED"
|
|
|
|
ITEM_STATUS_PENDING = "PENDING"
|
|
ITEM_STATUS_COPIED = "COPIED"
|
|
ITEM_STATUS_FAILED = "FAILED"
|
|
ITEM_STATUS_SKIPPED = "SKIPPED"
|
|
|
|
CHANNEL_DINSAR = "dinsar"
|
|
CHANNEL_LT1_ORTHO = "lt1_ortho"
|
|
CHANNEL_GF3_ORTHO = "gf3_ortho"
|
|
CHANNEL_SBAS = "sbas"
|
|
CHANNEL_S1_ORTHO = "s1_ortho"
|
|
SUPPORTED_READY_CHANNELS = {CHANNEL_DINSAR, CHANNEL_LT1_ORTHO, CHANNEL_GF3_ORTHO}
|
|
|
|
LT1_ANALYSIS_ENGINE = "lt_gamma"
|
|
LT1_ANALYSIS_PROFILE = "lt1_gamma_geocoded_mli"
|
|
GF3_STANDARD_SOURCE_FORMAT = "GF3_SARSCAPE_L2"
|
|
GF3_NATIVE_PREVIEW_SOURCE_FORMAT = "GF3_SARSCAPE_NATIVE_PREVIEW"
|
|
GF3_DELIVERABLE_SOURCE_FORMATS = {GF3_STANDARD_SOURCE_FORMAT, GF3_NATIVE_PREVIEW_SOURCE_FORMAT}
|
|
|
|
PACKAGE_MODE_DIRECTORY = "directory"
|
|
PACKAGE_MODE_ZIP = "zip"
|
|
|
|
_ASSOCIATED_EXTENSIONS = ("", ".hdr", ".sml", ".xml", ".aux.xml", ".prj")
|
|
_INVALID_SEGMENT_RE = re.compile(r'[<>:"/\\|?*\x00-\x1F]+')
|
|
_SAFE_ID_RE = re.compile(r"[^0-9A-Za-z_-]+")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeliverySource:
|
|
display_name: str
|
|
source_path: str
|
|
product: Optional[ResultProductORM] = None
|
|
compat_row: Optional[DinsarResultORM] = None
|
|
source_asset_id: Optional[int] = None
|
|
source_radar_data_id: Optional[int] = None
|
|
source_scene_geo_id: Optional[int] = None
|
|
product_id: Optional[str] = None
|
|
task_name: Optional[str] = None
|
|
source_kind: str = "result_product"
|
|
|
|
|
|
def _new_session() -> AsyncSession:
|
|
if database.AsyncSessionLocal is None:
|
|
database.init_db()
|
|
if database.AsyncSessionLocal is None:
|
|
raise RuntimeError("Database session factory is not initialized.")
|
|
return database.AsyncSessionLocal()
|
|
|
|
|
|
def _utcnow() -> datetime:
|
|
return datetime.utcnow()
|
|
|
|
|
|
def _normalize_channel(value: str) -> str:
|
|
channel = str(value or "").strip().lower()
|
|
if not channel:
|
|
raise ValueError("channel is required")
|
|
return channel
|
|
|
|
|
|
def _normalize_package_mode(value: str) -> str:
|
|
mode = str(value or PACKAGE_MODE_DIRECTORY).strip().lower()
|
|
if mode not in {PACKAGE_MODE_DIRECTORY, PACKAGE_MODE_ZIP}:
|
|
raise ValueError("package_mode must be directory or zip")
|
|
return mode
|
|
|
|
|
|
def _normalize_int_ids(values: Optional[Iterable[Any]], *, max_count: int) -> List[int]:
|
|
normalized: List[int] = []
|
|
seen = set()
|
|
for raw in values or []:
|
|
try:
|
|
value = int(raw)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if value <= 0 or value in seen:
|
|
continue
|
|
seen.add(value)
|
|
normalized.append(value)
|
|
if len(normalized) > max_count:
|
|
raise ValueError(f"selected item count exceeds max limit ({max_count})")
|
|
return normalized
|
|
|
|
|
|
def _sanitize_segment(value: Any, *, default: str = "item", max_len: int = 120) -> str:
|
|
text = str(value or "").strip()
|
|
text = _INVALID_SEGMENT_RE.sub("_", text).strip(" .")
|
|
text = _SAFE_ID_RE.sub("_", text)
|
|
return (text or default)[:max_len]
|
|
|
|
|
|
def _same_file_size(left: str, right: str) -> bool:
|
|
try:
|
|
return os.path.getsize(left) == os.path.getsize(right)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _file_size(path: str) -> int:
|
|
try:
|
|
return int(os.path.getsize(path))
|
|
except OSError:
|
|
return 0
|
|
|
|
|
|
def _sha256_file(path: str) -> str:
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _write_json(path: str, payload: Dict[str, Any]) -> None:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as stream:
|
|
json.dump(payload, stream, ensure_ascii=False, indent=2, default=str)
|
|
|
|
|
|
def _path_within(parent: str, child: str) -> bool:
|
|
parent_path = Path(parent).resolve()
|
|
child_path = Path(child).resolve()
|
|
try:
|
|
child_path.relative_to(parent_path)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _iter_associated_files(source_path: str) -> List[str]:
|
|
source = os.path.normpath(source_path)
|
|
if os.path.isdir(source):
|
|
files: List[str] = []
|
|
for root, _dirs, names in os.walk(source):
|
|
for name in names:
|
|
files.append(os.path.join(root, name))
|
|
return sorted(files)
|
|
if not os.path.isfile(source):
|
|
return []
|
|
|
|
src_dir = os.path.dirname(source)
|
|
base_name = os.path.basename(source)
|
|
files: List[str] = []
|
|
for ext in _ASSOCIATED_EXTENSIONS:
|
|
candidate = os.path.join(src_dir, base_name + ext) if ext else source
|
|
if os.path.isfile(candidate) and candidate not in files:
|
|
files.append(candidate)
|
|
return files
|
|
|
|
|
|
def _delivery_root() -> str:
|
|
root = str(settings.RESULT_DELIVERY_ROOT or "").strip()
|
|
if not root:
|
|
raise ValueError("RESULT_DELIVERY_ROOT is not configured")
|
|
return os.path.normpath(os.path.abspath(root))
|
|
|
|
|
|
def _owner_dir(root: str, username: str) -> str:
|
|
return os.path.join(root, _sanitize_segment(username, default="user", max_len=64))
|
|
|
|
|
|
def _make_delivery_id(username: str) -> str:
|
|
stamp = _utcnow().strftime("%Y%m%d%H%M%S")
|
|
digest = hashlib.sha1(f"{username}|{stamp}|{os.urandom(8).hex()}".encode("utf-8")).hexdigest()[:10]
|
|
return f"rd_{stamp}_{digest}"
|
|
|
|
|
|
def _resolve_source_path(product: ResultProductORM, assets: List[ResultAssetORM]) -> tuple[str, Optional[int]]:
|
|
primary_assets = [
|
|
asset for asset in assets
|
|
if asset.exists_flag and (asset.is_primary or str(asset.asset_role or "").lower() in {"disp", "primary_geotiff"})
|
|
]
|
|
primary_assets.sort(key=lambda item: (not item.is_primary, item.id))
|
|
for asset in primary_assets:
|
|
path = str(asset.absolute_path or "").strip()
|
|
if path:
|
|
return path, asset.id
|
|
for path in (product.primary_asset_path, product.source_primary_path, product.publish_dir):
|
|
text = str(path or "").strip()
|
|
if text:
|
|
return text, None
|
|
return "", None
|
|
|
|
|
|
def _scene_product_id(scene: SARSceneGeoORM) -> str:
|
|
return f"sar_scene_geo:{scene.id}"
|
|
|
|
|
|
def _scene_display_name(scene: SARSceneGeoORM, radar: RadarDataORM) -> str:
|
|
for value in (
|
|
radar.product_unique_id,
|
|
radar.unique_id,
|
|
radar.source_product_token,
|
|
os.path.basename(str(radar.file_path or "").rstrip("/\\")),
|
|
_scene_product_id(scene),
|
|
):
|
|
text = str(value or "").strip()
|
|
if text:
|
|
return text
|
|
return f"scene_{scene.id}"
|
|
|
|
|
|
def _radar_display_name(radar: RadarDataORM) -> str:
|
|
for value in (
|
|
radar.product_unique_id,
|
|
radar.unique_id,
|
|
radar.source_product_token,
|
|
os.path.basename(str(radar.file_path or "").rstrip("/\\")),
|
|
f"radar_{radar.id}",
|
|
):
|
|
text = str(value or "").strip()
|
|
if text:
|
|
return text
|
|
return f"radar_{radar.id}"
|
|
|
|
|
|
def _manifest_path_for_scene(scene: SARSceneGeoORM) -> Optional[str]:
|
|
metadata = scene.analysis_metadata_json if isinstance(scene.analysis_metadata_json, dict) else {}
|
|
text = str(metadata.get("manifest_path") or "").strip()
|
|
if text:
|
|
return text
|
|
if scene.analysis_dir:
|
|
return os.path.join(scene.analysis_dir, "manifest.json")
|
|
return None
|
|
|
|
|
|
def _quality_path_for_scene(scene: SARSceneGeoORM) -> Optional[str]:
|
|
if scene.analysis_dir:
|
|
return os.path.join(scene.analysis_dir, "quality.json")
|
|
return None
|
|
|
|
|
|
def _catalog_file_size(path: Any) -> int:
|
|
text = str(path or "").strip()
|
|
return _file_size(text) if text else 0
|
|
|
|
|
|
class ResultDeliveryService:
|
|
def channels(self) -> List[Dict[str, Any]]:
|
|
return [
|
|
{
|
|
"key": CHANNEL_DINSAR,
|
|
"group": "InSAR 成果",
|
|
"label": "D-InSAR 结果",
|
|
"state": "ready",
|
|
"state_text": "可交付",
|
|
"description": "已登记 D-InSAR catalog,可创建后台交付包并下载到本地。",
|
|
},
|
|
{
|
|
"key": CHANNEL_SBAS,
|
|
"group": "InSAR 成果",
|
|
"label": "SBAS-InSAR 结果",
|
|
"state": "planned",
|
|
"state_text": "目录可查",
|
|
"description": "SBAS 结果 catalog 已有基础能力,本阶段暂不开放交付打包。",
|
|
},
|
|
{
|
|
"key": CHANNEL_LT1_ORTHO,
|
|
"group": "正射成果",
|
|
"label": "LT-1 正射结果",
|
|
"state": "ready",
|
|
"state_text": "可交付",
|
|
"description": "服务器生产的 LT-1 分析就绪正射 GeoTIFF 已接入交付,可打包下载到本地。",
|
|
},
|
|
{
|
|
"key": CHANNEL_S1_ORTHO,
|
|
"group": "正射成果",
|
|
"label": "Sentinel-1 正射结果",
|
|
"state": "placeholder",
|
|
"state_text": "待接入",
|
|
"description": "Sentinel-1 正射生产尚未接入,当前只保留交付通道占位。",
|
|
},
|
|
{
|
|
"key": CHANNEL_GF3_ORTHO,
|
|
"group": "正射成果",
|
|
"label": "GF3 SARscape _geo",
|
|
"state": "ready",
|
|
"state_text": "可交付",
|
|
"description": "已登记的 GF3 SARscape 标准化正射成品可直接创建交付包。",
|
|
},
|
|
]
|
|
|
|
async def _resolve_dinsar_sources(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
product_ids: List[int],
|
|
compat_result_ids: List[int],
|
|
) -> List[DeliverySource]:
|
|
sources: List[DeliverySource] = []
|
|
seen_products = set()
|
|
|
|
if product_ids:
|
|
result = await db.execute(
|
|
select(ResultProductORM)
|
|
.options(selectinload(ResultProductORM.assets))
|
|
.where(
|
|
ResultProductORM.id.in_(product_ids),
|
|
ResultProductORM.catalog_name == CHANNEL_DINSAR,
|
|
ResultProductORM.status.in_(["READY", "PUBLISHED", "OK"]),
|
|
)
|
|
.order_by(ResultProductORM.id.asc())
|
|
)
|
|
for product in result.scalars().unique().all():
|
|
assets = list(product.assets or [])
|
|
source_path, source_asset_id = _resolve_source_path(product, assets)
|
|
if not source_path:
|
|
continue
|
|
seen_products.add(int(product.id))
|
|
sources.append(
|
|
DeliverySource(
|
|
display_name=dinsar_read_service.get_display_name(product),
|
|
source_path=source_path,
|
|
product=product,
|
|
compat_row=None,
|
|
source_asset_id=source_asset_id,
|
|
product_id=product.product_id,
|
|
task_name=product.task_alias or product.task_name,
|
|
source_kind="dinsar_product",
|
|
)
|
|
)
|
|
|
|
if compat_result_ids:
|
|
records = await dinsar_read_service.list_compat_records_by_ids(
|
|
db,
|
|
compat_result_ids=compat_result_ids,
|
|
)
|
|
product_lookup_ids = [record.product.id for record in records if record.product is not None]
|
|
assets_by_product: Dict[int, List[ResultAssetORM]] = {}
|
|
if product_lookup_ids:
|
|
asset_result = await db.execute(
|
|
select(ResultAssetORM)
|
|
.where(ResultAssetORM.product_ref_id.in_(product_lookup_ids))
|
|
.order_by(ResultAssetORM.id.asc())
|
|
)
|
|
for asset in asset_result.scalars().all():
|
|
assets_by_product.setdefault(int(asset.product_ref_id), []).append(asset)
|
|
|
|
for record in records:
|
|
product = record.product
|
|
if int(product.id) in seen_products:
|
|
continue
|
|
assets = assets_by_product.get(int(product.id), [])
|
|
source_path, source_asset_id = _resolve_source_path(product, assets)
|
|
if not source_path and record.compat_row is not None:
|
|
source_path = str(record.compat_row.file_path or "").strip()
|
|
if not source_path:
|
|
continue
|
|
seen_products.add(int(product.id))
|
|
sources.append(
|
|
DeliverySource(
|
|
display_name=record.display_name,
|
|
source_path=source_path,
|
|
product=product,
|
|
compat_row=record.compat_row,
|
|
source_asset_id=source_asset_id,
|
|
product_id=product.product_id,
|
|
task_name=product.task_alias or product.task_name,
|
|
source_kind="dinsar_product",
|
|
)
|
|
)
|
|
|
|
return sources
|
|
|
|
async def _resolve_lt1_sources(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
item_ids: List[int],
|
|
product_ids: List[int],
|
|
) -> List[DeliverySource]:
|
|
selected_ids = item_ids or product_ids
|
|
if not selected_ids:
|
|
return []
|
|
result = await db.execute(
|
|
select(SARSceneGeoORM, RadarDataORM)
|
|
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
|
.where(
|
|
SARSceneGeoORM.id.in_(selected_ids),
|
|
SARSceneGeoORM.status == "DONE",
|
|
SARSceneGeoORM.analysis_tif_path.isnot(None),
|
|
SARSceneGeoORM.analysis_engine == LT1_ANALYSIS_ENGINE,
|
|
SARSceneGeoORM.analysis_profile == LT1_ANALYSIS_PROFILE,
|
|
)
|
|
.order_by(SARSceneGeoORM.id.asc())
|
|
)
|
|
sources: List[DeliverySource] = []
|
|
for scene, radar in result.all():
|
|
analysis_dir = str(scene.analysis_dir or "").strip()
|
|
analysis_tif = str(scene.analysis_tif_path or "").strip()
|
|
source_path = analysis_dir if analysis_dir and os.path.isdir(analysis_dir) else analysis_tif
|
|
if not source_path:
|
|
continue
|
|
product_id = _scene_product_id(scene)
|
|
display_name = _scene_display_name(scene, radar)
|
|
sources.append(
|
|
DeliverySource(
|
|
display_name=display_name,
|
|
source_path=source_path,
|
|
source_radar_data_id=int(radar.id) if radar.id is not None else None,
|
|
source_scene_geo_id=int(scene.id) if scene.id is not None else None,
|
|
product_id=product_id,
|
|
task_name=display_name,
|
|
source_kind="lt1_scene_geo",
|
|
)
|
|
)
|
|
return sources
|
|
|
|
async def _resolve_gf3_sources(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
item_ids: List[int],
|
|
) -> List[DeliverySource]:
|
|
if not item_ids:
|
|
return []
|
|
result = await db.execute(
|
|
select(RadarDataORM, SARSceneGeoORM)
|
|
.outerjoin(SARSceneGeoORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
|
.where(
|
|
RadarDataORM.id.in_(item_ids),
|
|
RadarDataORM.source_format.in_(GF3_DELIVERABLE_SOURCE_FORMATS),
|
|
RadarDataORM.geocoded_flag.is_(True),
|
|
)
|
|
.order_by(RadarDataORM.id.asc())
|
|
)
|
|
sources: List[DeliverySource] = []
|
|
for radar, scene in result.all():
|
|
source_path = ""
|
|
source_scene_geo_id = None
|
|
if scene is not None and scene.analysis_tif_path:
|
|
analysis_dir = str(scene.analysis_dir or "").strip()
|
|
analysis_tif = str(scene.analysis_tif_path or "").strip()
|
|
source_path = analysis_dir if analysis_dir and os.path.isdir(analysis_dir) else analysis_tif
|
|
source_scene_geo_id = int(scene.id) if scene.id is not None else None
|
|
if not source_path:
|
|
source_path = str(radar.file_path or "").strip()
|
|
if not source_path:
|
|
continue
|
|
display_name = _radar_display_name(radar)
|
|
sources.append(
|
|
DeliverySource(
|
|
display_name=display_name,
|
|
source_path=source_path,
|
|
source_radar_data_id=int(radar.id) if radar.id is not None else None,
|
|
source_scene_geo_id=source_scene_geo_id,
|
|
product_id=f"gf3_sarscape:{radar.id}",
|
|
task_name=display_name,
|
|
source_kind="gf3_radar_data",
|
|
)
|
|
)
|
|
return sources
|
|
|
|
async def _resolve_sources_for_channel(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
channel: str,
|
|
product_ids: List[int],
|
|
compat_result_ids: List[int],
|
|
item_ids: List[int],
|
|
) -> List[DeliverySource]:
|
|
if channel == CHANNEL_DINSAR:
|
|
return await self._resolve_dinsar_sources(
|
|
db,
|
|
product_ids=product_ids,
|
|
compat_result_ids=compat_result_ids,
|
|
)
|
|
if channel == CHANNEL_LT1_ORTHO:
|
|
return await self._resolve_lt1_sources(db, item_ids=item_ids, product_ids=product_ids)
|
|
if channel == CHANNEL_GF3_ORTHO:
|
|
return await self._resolve_gf3_sources(db, item_ids=item_ids or product_ids)
|
|
return []
|
|
|
|
async def list_channel_catalog(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
channel: str,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
query: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
channel = _normalize_channel(channel)
|
|
safe_limit = min(500, max(1, int(limit or 100)))
|
|
safe_offset = max(0, int(offset or 0))
|
|
query_text = str(query or "").strip()
|
|
|
|
if channel == CHANNEL_LT1_ORTHO:
|
|
filters = [
|
|
SARSceneGeoORM.status == "DONE",
|
|
SARSceneGeoORM.analysis_tif_path.isnot(None),
|
|
SARSceneGeoORM.analysis_engine == LT1_ANALYSIS_ENGINE,
|
|
SARSceneGeoORM.analysis_profile == LT1_ANALYSIS_PROFILE,
|
|
]
|
|
if query_text:
|
|
like = f"%{query_text}%"
|
|
filters.append(
|
|
or_(
|
|
RadarDataORM.product_unique_id.ilike(like),
|
|
RadarDataORM.unique_id.ilike(like),
|
|
RadarDataORM.file_path.ilike(like),
|
|
RadarDataORM.imaging_date.ilike(like),
|
|
)
|
|
)
|
|
total_result = await db.execute(
|
|
select(func.count(SARSceneGeoORM.id))
|
|
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
|
.where(*filters)
|
|
)
|
|
rows_result = await db.execute(
|
|
select(SARSceneGeoORM, RadarDataORM)
|
|
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
|
.where(*filters)
|
|
.order_by(SARSceneGeoORM.updated_at.desc().nullslast(), SARSceneGeoORM.id.desc())
|
|
.limit(safe_limit)
|
|
.offset(safe_offset)
|
|
)
|
|
items = []
|
|
for scene, radar in rows_result.all():
|
|
display_name = _scene_display_name(scene, radar)
|
|
manifest_path = _manifest_path_for_scene(scene)
|
|
quality_path = _quality_path_for_scene(scene)
|
|
items.append(
|
|
{
|
|
"id": scene.id,
|
|
"item_id": scene.id,
|
|
"source_kind": "lt1_scene_geo",
|
|
"product_id": _scene_product_id(scene),
|
|
"catalog_name": "sar_scene_geo",
|
|
"product_family": "lt1_analysis_ready_geotiff",
|
|
"product_type": "analysis_ready_geotiff",
|
|
"display_name": display_name,
|
|
"task_name": display_name,
|
|
"status": scene.status,
|
|
"health_status": "OK",
|
|
"engine_code": scene.analysis_engine,
|
|
"profile_code": scene.analysis_profile,
|
|
"radar_data_id": radar.id,
|
|
"scene_geo_id": scene.id,
|
|
"imaging_date": radar.imaging_date,
|
|
"polarization": radar.polarization,
|
|
"pixel_size_m": scene.pixel_size_m,
|
|
"backscatter_unit": scene.analysis_backscatter_unit,
|
|
"publish_dir": scene.analysis_dir,
|
|
"manifest_path": manifest_path,
|
|
"quality_path": quality_path,
|
|
"primary_asset_path": scene.analysis_tif_path,
|
|
"file_size": _catalog_file_size(scene.analysis_tif_path),
|
|
"summary": {
|
|
"radar_data_id": radar.id,
|
|
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
|
|
"imaging_date": radar.imaging_date,
|
|
"polarization": radar.polarization,
|
|
"pixel_size_m": scene.pixel_size_m,
|
|
},
|
|
"produced_at": scene.updated_at.isoformat() if scene.updated_at else None,
|
|
"published_at": scene.updated_at.isoformat() if scene.updated_at else None,
|
|
}
|
|
)
|
|
return {
|
|
"items": items,
|
|
"total": int(total_result.scalar_one() or 0),
|
|
"limit": safe_limit,
|
|
"offset": safe_offset,
|
|
}
|
|
|
|
if channel == CHANNEL_GF3_ORTHO:
|
|
filters = [
|
|
RadarDataORM.source_format.in_(GF3_DELIVERABLE_SOURCE_FORMATS),
|
|
RadarDataORM.geocoded_flag.is_(True),
|
|
]
|
|
if query_text:
|
|
like = f"%{query_text}%"
|
|
filters.append(
|
|
or_(
|
|
RadarDataORM.product_unique_id.ilike(like),
|
|
RadarDataORM.unique_id.ilike(like),
|
|
RadarDataORM.file_path.ilike(like),
|
|
RadarDataORM.imaging_date.ilike(like),
|
|
)
|
|
)
|
|
total_result = await db.execute(select(func.count(RadarDataORM.id)).where(*filters))
|
|
rows_result = await db.execute(
|
|
select(RadarDataORM, SARSceneGeoORM)
|
|
.outerjoin(SARSceneGeoORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
|
.where(*filters)
|
|
.order_by(RadarDataORM.acquisition_start_time_utc.desc().nullslast(), RadarDataORM.id.desc())
|
|
.limit(safe_limit)
|
|
.offset(safe_offset)
|
|
)
|
|
items = []
|
|
for radar, scene in rows_result.all():
|
|
display_name = _radar_display_name(radar)
|
|
primary_path = (
|
|
str(scene.analysis_tif_path or "").strip()
|
|
if scene is not None and scene.analysis_tif_path
|
|
else str(radar.file_path or "").strip()
|
|
)
|
|
publish_dir = (
|
|
str(scene.analysis_dir or "").strip()
|
|
if scene is not None and scene.analysis_dir
|
|
else str(radar.file_path or "").strip()
|
|
)
|
|
items.append(
|
|
{
|
|
"id": radar.id,
|
|
"item_id": radar.id,
|
|
"source_kind": "gf3_radar_data",
|
|
"product_id": f"gf3_sarscape:{radar.id}",
|
|
"catalog_name": "radar_data",
|
|
"product_family": "gf3_ortho",
|
|
"product_type": "sarscape_l2_geotiff" if radar.source_format == GF3_STANDARD_SOURCE_FORMAT else "sarscape_native_geo",
|
|
"display_name": display_name,
|
|
"task_name": display_name,
|
|
"status": "READY",
|
|
"health_status": "OK",
|
|
"engine_code": (scene.analysis_engine if scene is not None else None) or "gf3_sarscape",
|
|
"profile_code": (
|
|
(scene.analysis_profile if scene is not None else None)
|
|
or ("gf3_standard_geotiff" if radar.source_format == GF3_STANDARD_SOURCE_FORMAT else "gf3_native_geo")
|
|
),
|
|
"source_format": radar.source_format,
|
|
"radar_data_id": radar.id,
|
|
"scene_geo_id": scene.id if scene is not None else None,
|
|
"imaging_date": radar.imaging_date,
|
|
"polarization": radar.polarization,
|
|
"pixel_size_m": scene.pixel_size_m if scene is not None else None,
|
|
"backscatter_unit": scene.analysis_backscatter_unit if scene is not None else None,
|
|
"publish_dir": publish_dir,
|
|
"manifest_path": _manifest_path_for_scene(scene) if scene is not None else None,
|
|
"primary_asset_path": primary_path,
|
|
"file_size": _catalog_file_size(primary_path),
|
|
"summary": {
|
|
"radar_data_id": radar.id,
|
|
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
|
|
"imaging_date": radar.imaging_date,
|
|
"polarization": radar.polarization,
|
|
},
|
|
"produced_at": (
|
|
scene.updated_at.isoformat() if scene is not None and scene.updated_at else None
|
|
),
|
|
"published_at": (
|
|
scene.updated_at.isoformat() if scene is not None and scene.updated_at else None
|
|
),
|
|
}
|
|
)
|
|
return {
|
|
"items": items,
|
|
"total": int(total_result.scalar_one() or 0),
|
|
"limit": safe_limit,
|
|
"offset": safe_offset,
|
|
}
|
|
|
|
if channel == CHANNEL_DINSAR:
|
|
total = await dinsar_read_service.count_compat_records(db)
|
|
records = await dinsar_read_service.list_compat_records(db, limit=safe_limit, offset=safe_offset)
|
|
items = []
|
|
for record in records:
|
|
product = record.product
|
|
compat_row = record.compat_row
|
|
items.append(
|
|
{
|
|
"id": int(compat_row.id if compat_row is not None else product.id),
|
|
"item_id": int(compat_row.id if compat_row is not None else product.id),
|
|
"source_kind": "dinsar_result",
|
|
"product_id": product.product_id,
|
|
"catalog_name": product.catalog_name,
|
|
"product_family": product.product_family,
|
|
"product_type": product.product_type,
|
|
"display_name": record.display_name,
|
|
"task_name": product.task_alias or product.task_name,
|
|
"status": product.status,
|
|
"health_status": product.health_status,
|
|
"engine_code": product.engine_code,
|
|
"profile_code": product.profile_code,
|
|
"pair_key": product.pair_key,
|
|
"publish_dir": product.publish_dir,
|
|
"manifest_path": product.manifest_path,
|
|
"primary_asset_path": product.primary_asset_path or product.source_primary_path,
|
|
"file_size": _catalog_file_size(product.primary_asset_path or product.source_primary_path),
|
|
"produced_at": product.produced_at.isoformat() if product.produced_at else None,
|
|
"published_at": product.published_at.isoformat() if product.published_at else None,
|
|
}
|
|
)
|
|
return {"items": items, "total": total, "limit": safe_limit, "offset": safe_offset}
|
|
|
|
return {"items": [], "total": 0, "limit": safe_limit, "offset": safe_offset}
|
|
|
|
async def create_delivery(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user: AuthUserORM,
|
|
channel: str,
|
|
product_ids: Optional[List[int]] = None,
|
|
compat_result_ids: Optional[List[int]] = None,
|
|
item_ids: Optional[List[int]] = None,
|
|
package_mode: str = PACKAGE_MODE_DIRECTORY,
|
|
include_checksums: Optional[bool] = None,
|
|
) -> ResultDeliveryRequestORM:
|
|
channel = _normalize_channel(channel)
|
|
if channel not in SUPPORTED_READY_CHANNELS:
|
|
raise ValueError(f"{channel} delivery is not connected yet")
|
|
|
|
package_mode = _normalize_package_mode(package_mode)
|
|
max_items = max(1, int(settings.RESULT_DELIVERY_MAX_ITEMS or 500))
|
|
normalized_product_ids = _normalize_int_ids(product_ids, max_count=max_items)
|
|
normalized_compat_ids = _normalize_int_ids(compat_result_ids, max_count=max_items)
|
|
normalized_item_ids = _normalize_int_ids(item_ids, max_count=max_items)
|
|
if not normalized_product_ids and not normalized_compat_ids and not normalized_item_ids:
|
|
raise ValueError("select at least one result")
|
|
|
|
if len(normalized_product_ids) + len(normalized_compat_ids) + len(normalized_item_ids) > max_items:
|
|
raise ValueError(f"selected item count exceeds max limit ({max_items})")
|
|
|
|
sources = await self._resolve_sources_for_channel(
|
|
db,
|
|
channel=channel,
|
|
product_ids=normalized_product_ids,
|
|
compat_result_ids=normalized_compat_ids,
|
|
item_ids=normalized_item_ids,
|
|
)
|
|
|
|
if not sources:
|
|
raise ValueError("selected results do not have deliverable files")
|
|
if len(sources) > max_items:
|
|
raise ValueError(f"resolved result count exceeds max limit ({max_items})")
|
|
|
|
delivery_root = _delivery_root()
|
|
delivery_id = _make_delivery_id(str(user.username or "user"))
|
|
delivery_dir = os.path.join(_owner_dir(delivery_root, str(user.username or "user")), delivery_id)
|
|
expires_at = _utcnow() + timedelta(days=max(1, int(settings.RESULT_DELIVERY_RETENTION_DAYS or 7)))
|
|
request_json = {
|
|
"channel": channel,
|
|
"product_ids": normalized_product_ids,
|
|
"compat_result_ids": normalized_compat_ids,
|
|
"item_ids": normalized_item_ids,
|
|
"package_mode": package_mode,
|
|
"include_checksums": (
|
|
bool(settings.RESULT_DELIVERY_CHECKSUM_ENABLED)
|
|
if include_checksums is None
|
|
else bool(include_checksums)
|
|
),
|
|
}
|
|
delivery = ResultDeliveryRequestORM(
|
|
delivery_id=delivery_id,
|
|
owner_user_id=user.id,
|
|
owner_username=str(user.username or "unknown"),
|
|
channel=channel,
|
|
status=DELIVERY_STATUS_PENDING,
|
|
package_mode=package_mode,
|
|
item_count=len(sources),
|
|
total_bytes=0,
|
|
copied_bytes=0,
|
|
delivery_root=delivery_root,
|
|
delivery_dir=delivery_dir,
|
|
zip_path=(f"{delivery_dir}.zip" if package_mode == PACKAGE_MODE_ZIP else None),
|
|
expires_at=expires_at,
|
|
request_json=request_json,
|
|
summary_json={
|
|
"source_count": len(sources),
|
|
"created_message": "Delivery package queued.",
|
|
},
|
|
)
|
|
db.add(delivery)
|
|
await db.flush()
|
|
|
|
task_type = f"{TASK_TYPE_RESULT_DELIVERY_BUILD}_{delivery_id}"
|
|
task_id = await task_service.create_task(
|
|
task_type,
|
|
f"成果交付包生成: {delivery_id}",
|
|
params={
|
|
"delivery_id": delivery_id,
|
|
"channel": channel,
|
|
"item_count": len(sources),
|
|
"package_mode": package_mode,
|
|
},
|
|
db=db,
|
|
)
|
|
job_id = await job_queue_service.create_job(
|
|
JOB_TYPE_RESULT_DELIVERY_BUILD,
|
|
payload={"delivery_id": delivery_id},
|
|
task_id=task_id,
|
|
max_attempts=1,
|
|
db=db,
|
|
)
|
|
delivery.task_id = task_id
|
|
delivery.job_id = job_id
|
|
await db.flush()
|
|
return delivery
|
|
|
|
async def list_deliveries(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user: AuthUserORM,
|
|
include_all: bool = False,
|
|
include_items: bool = False,
|
|
item_limit: Optional[int] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> Dict[str, Any]:
|
|
safe_limit = min(200, max(1, int(limit or 50)))
|
|
safe_offset = max(0, int(offset or 0))
|
|
stmt = select(ResultDeliveryRequestORM)
|
|
if include_items:
|
|
stmt = stmt.options(selectinload(ResultDeliveryRequestORM.items))
|
|
count_stmt = select(func.count(ResultDeliveryRequestORM.id))
|
|
if not include_all:
|
|
owner_filter = ResultDeliveryRequestORM.owner_user_id == user.id
|
|
stmt = stmt.where(owner_filter)
|
|
count_stmt = count_stmt.where(owner_filter)
|
|
stmt = (
|
|
stmt.order_by(ResultDeliveryRequestORM.created_at.desc(), ResultDeliveryRequestORM.id.desc())
|
|
.offset(safe_offset)
|
|
.limit(safe_limit)
|
|
)
|
|
result = await db.execute(stmt)
|
|
total_result = await db.execute(count_stmt)
|
|
deliveries = result.scalars().unique().all() if include_items else result.scalars().all()
|
|
items = [
|
|
self.serialize_delivery(item, include_items=include_items, item_limit=item_limit)
|
|
for item in deliveries
|
|
]
|
|
total = int(total_result.scalar_one() or 0)
|
|
return {
|
|
"items": items,
|
|
"total": total,
|
|
"limit": safe_limit,
|
|
"offset": safe_offset,
|
|
"has_more": safe_offset + len(items) < total,
|
|
}
|
|
|
|
async def get_delivery(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
delivery_id: str,
|
|
user: AuthUserORM,
|
|
include_items: bool = True,
|
|
) -> Optional[ResultDeliveryRequestORM]:
|
|
stmt = select(ResultDeliveryRequestORM).where(
|
|
ResultDeliveryRequestORM.delivery_id == str(delivery_id or "").strip()
|
|
)
|
|
if include_items:
|
|
stmt = stmt.options(selectinload(ResultDeliveryRequestORM.items))
|
|
if str(user.role or "").lower() != "admin":
|
|
stmt = stmt.where(ResultDeliveryRequestORM.owner_user_id == user.id)
|
|
result = await db.execute(stmt)
|
|
return result.scalars().unique().one_or_none()
|
|
|
|
def serialize_delivery(
|
|
self,
|
|
delivery: ResultDeliveryRequestORM,
|
|
*,
|
|
include_items: bool = False,
|
|
item_limit: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
items = list(delivery.items or []) if include_items else []
|
|
if item_limit is not None:
|
|
items = items[: max(0, int(item_limit))]
|
|
summary = delivery.summary_json if isinstance(delivery.summary_json, dict) else {}
|
|
payload = {
|
|
"id": delivery.id,
|
|
"delivery_id": delivery.delivery_id,
|
|
"owner_user_id": delivery.owner_user_id,
|
|
"owner_username": delivery.owner_username,
|
|
"channel": delivery.channel,
|
|
"status": delivery.status,
|
|
"package_mode": delivery.package_mode,
|
|
"item_count": delivery.item_count,
|
|
"total_bytes": delivery.total_bytes,
|
|
"copied_bytes": delivery.copied_bytes,
|
|
"delivery_dir": delivery.delivery_dir,
|
|
"zip_path": delivery.zip_path,
|
|
"manifest_path": delivery.manifest_path,
|
|
"expires_at": delivery.expires_at,
|
|
"task_id": delivery.task_id,
|
|
"job_id": delivery.job_id,
|
|
"error_message": delivery.error_message,
|
|
"summary": summary,
|
|
"created_at": delivery.created_at,
|
|
"updated_at": delivery.updated_at,
|
|
"started_at": delivery.started_at,
|
|
"completed_at": delivery.completed_at,
|
|
"download_urls": {
|
|
"manifest": f"/api/result-deliveries/{delivery.delivery_id}/manifest",
|
|
"archive": (
|
|
f"/api/result-deliveries/{delivery.delivery_id}/archive/download"
|
|
if delivery.zip_path
|
|
else None
|
|
),
|
|
},
|
|
}
|
|
if include_items:
|
|
payload["items"] = [self.serialize_item(item, delivery_id=delivery.delivery_id) for item in items]
|
|
return payload
|
|
|
|
def serialize_item(self, item: ResultDeliveryItemORM, *, delivery_id: str) -> Dict[str, Any]:
|
|
return {
|
|
"id": item.id,
|
|
"delivery_id": item.delivery_id,
|
|
"source_product_id": item.source_product_id,
|
|
"source_result_id": item.source_result_id,
|
|
"source_asset_id": item.source_asset_id,
|
|
"source_radar_data_id": item.source_radar_data_id,
|
|
"source_scene_geo_id": item.source_scene_geo_id,
|
|
"display_name": item.display_name,
|
|
"relative_path": item.relative_path,
|
|
"file_size": item.file_size,
|
|
"checksum_sha256": item.checksum_sha256,
|
|
"status": item.status,
|
|
"error_message": item.error_message,
|
|
"download_url": (
|
|
f"/api/result-deliveries/{delivery_id}/files/{item.id}/download"
|
|
if item.status == ITEM_STATUS_COPIED
|
|
else None
|
|
),
|
|
}
|
|
|
|
async def build_delivery(self, delivery_id: str) -> Dict[str, Any]:
|
|
async with _new_session() as db:
|
|
result = await db.execute(
|
|
select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id)
|
|
)
|
|
delivery = result.scalar_one_or_none()
|
|
if delivery is None:
|
|
raise ValueError(f"delivery not found: {delivery_id}")
|
|
if delivery.status in {DELIVERY_STATUS_READY, DELIVERY_STATUS_CANCELLED, DELIVERY_STATUS_EXPIRED}:
|
|
return self.serialize_delivery(delivery)
|
|
|
|
delivery.status = DELIVERY_STATUS_RUNNING
|
|
delivery.started_at = _utcnow()
|
|
delivery.error_message = None
|
|
await db.commit()
|
|
|
|
try:
|
|
summary = await self._build_delivery_files(delivery_id)
|
|
except Exception as exc:
|
|
async with _new_session() as db:
|
|
result = await db.execute(
|
|
select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id)
|
|
)
|
|
delivery = result.scalar_one_or_none()
|
|
if delivery is not None:
|
|
delivery.status = DELIVERY_STATUS_FAILED
|
|
delivery.error_message = str(exc)
|
|
delivery.completed_at = _utcnow()
|
|
delivery.summary_json = {
|
|
**(delivery.summary_json if isinstance(delivery.summary_json, dict) else {}),
|
|
"error": str(exc),
|
|
}
|
|
await db.commit()
|
|
if delivery.task_id:
|
|
await task_service.update_task(delivery.task_id, status="FAILED", progress=100, message=str(exc))
|
|
raise
|
|
|
|
async with _new_session() as db:
|
|
result = await db.execute(
|
|
select(ResultDeliveryRequestORM)
|
|
.options(selectinload(ResultDeliveryRequestORM.items))
|
|
.where(ResultDeliveryRequestORM.delivery_id == delivery_id)
|
|
)
|
|
delivery = result.scalars().unique().one_or_none()
|
|
if delivery is None:
|
|
raise ValueError(f"delivery not found: {delivery_id}")
|
|
available_files = int(summary.get("copied_files", 0) or 0) + int(summary.get("skipped_files", 0) or 0)
|
|
delivery.status = DELIVERY_STATUS_READY if available_files > 0 else DELIVERY_STATUS_FAILED
|
|
delivery.completed_at = _utcnow()
|
|
delivery.error_message = summary.get("error_message")
|
|
delivery.summary_json = summary
|
|
await db.commit()
|
|
if delivery.task_id:
|
|
if delivery.status == DELIVERY_STATUS_READY:
|
|
await task_service.update_task(
|
|
delivery.task_id,
|
|
status="COMPLETED",
|
|
progress=100,
|
|
message=(
|
|
f"成果交付包已生成: files={summary.get('copied_files', 0)}, "
|
|
f"bytes={summary.get('copied_bytes', 0)}"
|
|
),
|
|
)
|
|
else:
|
|
await task_service.update_task(
|
|
delivery.task_id,
|
|
status="FAILED",
|
|
progress=100,
|
|
message=summary.get("error_message") or "成果交付包生成失败",
|
|
)
|
|
return self.serialize_delivery(delivery, include_items=True)
|
|
|
|
async def _build_delivery_files(self, delivery_id: str) -> Dict[str, Any]:
|
|
async with _new_session() as db:
|
|
result = await db.execute(
|
|
select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id)
|
|
)
|
|
delivery = result.scalar_one()
|
|
request_json = delivery.request_json if isinstance(delivery.request_json, dict) else {}
|
|
max_items = int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)
|
|
sources = await self._resolve_sources_for_channel(
|
|
db,
|
|
channel=delivery.channel,
|
|
product_ids=_normalize_int_ids(request_json.get("product_ids"), max_count=max_items),
|
|
compat_result_ids=_normalize_int_ids(request_json.get("compat_result_ids"), max_count=max_items),
|
|
item_ids=_normalize_int_ids(request_json.get("item_ids"), max_count=max_items),
|
|
)
|
|
task_id = delivery.task_id
|
|
|
|
if not sources:
|
|
raise ValueError("delivery has no deliverable sources")
|
|
|
|
include_checksums = bool(
|
|
request_json.get("include_checksums")
|
|
if "include_checksums" in request_json
|
|
else settings.RESULT_DELIVERY_CHECKSUM_ENABLED
|
|
)
|
|
delivery_dir = os.path.normpath(os.path.abspath(delivery.delivery_dir))
|
|
root = os.path.normpath(os.path.abspath(delivery.delivery_root))
|
|
if not _path_within(root, delivery_dir):
|
|
raise ValueError("delivery directory is outside RESULT_DELIVERY_ROOT")
|
|
|
|
os.makedirs(delivery_dir, exist_ok=True)
|
|
copied_files = 0
|
|
skipped_files = 0
|
|
failed_items = 0
|
|
copied_bytes = 0
|
|
total_bytes = 0
|
|
checksum_lines: List[str] = []
|
|
item_payloads: List[Dict[str, Any]] = []
|
|
manifest_items: List[Dict[str, Any]] = []
|
|
|
|
total_sources = len(sources)
|
|
for source_index, source in enumerate(sources, start=1):
|
|
folder = _sanitize_segment(
|
|
source.task_name
|
|
or source.display_name
|
|
or source.product_id,
|
|
default=f"product_{source_index}",
|
|
)
|
|
source_files = _iter_associated_files(source.source_path)
|
|
if not source_files:
|
|
failed_items += 1
|
|
item_payloads.append(
|
|
{
|
|
"delivery_id": delivery_id,
|
|
"source_product_id": source.product.id if source.product else None,
|
|
"source_result_id": source.compat_row.id if source.compat_row else None,
|
|
"source_asset_id": source.source_asset_id,
|
|
"source_radar_data_id": source.source_radar_data_id,
|
|
"source_scene_geo_id": source.source_scene_geo_id,
|
|
"display_name": source.display_name,
|
|
"source_path": source.source_path,
|
|
"relative_path": None,
|
|
"file_size": 0,
|
|
"checksum_sha256": None,
|
|
"status": ITEM_STATUS_FAILED,
|
|
"error_message": "source file not found",
|
|
}
|
|
)
|
|
continue
|
|
|
|
for file_index, source_file in enumerate(source_files, start=1):
|
|
if os.path.isdir(source.source_path):
|
|
relative_under_source = os.path.relpath(source_file, source.source_path)
|
|
relative_path = os.path.join(folder, relative_under_source)
|
|
else:
|
|
relative_path = os.path.join(folder, os.path.basename(source_file))
|
|
target_path = os.path.join(delivery_dir, relative_path)
|
|
size = _file_size(source_file)
|
|
total_bytes += size
|
|
status = ITEM_STATUS_COPIED
|
|
error_message = None
|
|
checksum = None
|
|
try:
|
|
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
if os.path.isfile(target_path) and _same_file_size(source_file, target_path):
|
|
skipped_files += 1
|
|
else:
|
|
shutil.copy2(source_file, target_path)
|
|
copied_files += 1
|
|
copied_bytes += size
|
|
if include_checksums and os.path.isfile(target_path):
|
|
checksum = _sha256_file(target_path)
|
|
checksum_lines.append(f"{checksum} {relative_path.replace(os.sep, '/')}")
|
|
except OSError as exc:
|
|
status = ITEM_STATUS_FAILED
|
|
error_message = str(exc)
|
|
failed_items += 1
|
|
|
|
if task_id and (copied_files + skipped_files + failed_items) % 10 == 0:
|
|
progress = min(95, int(((source_index - 1) / max(1, total_sources)) * 100))
|
|
await task_service.update_task(
|
|
task_id,
|
|
progress=progress,
|
|
message=f"成果交付包生成中: {source_index}/{total_sources}",
|
|
)
|
|
|
|
item_payload = {
|
|
"delivery_id": delivery_id,
|
|
"source_product_id": source.product.id if source.product else None,
|
|
"source_result_id": source.compat_row.id if source.compat_row else None,
|
|
"source_asset_id": source.source_asset_id if file_index == 1 else None,
|
|
"source_radar_data_id": source.source_radar_data_id,
|
|
"source_scene_geo_id": source.source_scene_geo_id,
|
|
"display_name": source.display_name,
|
|
"source_path": source_file,
|
|
"relative_path": relative_path,
|
|
"file_size": size,
|
|
"checksum_sha256": checksum,
|
|
"status": status,
|
|
"error_message": error_message,
|
|
}
|
|
item_payloads.append(item_payload)
|
|
manifest_items.append(
|
|
{
|
|
"display_name": source.display_name,
|
|
"product_id": source.product_id or (source.product.product_id if source.product else None),
|
|
"product_ref_id": source.product.id if source.product else None,
|
|
"source_result_id": source.compat_row.id if source.compat_row else None,
|
|
"source_radar_data_id": source.source_radar_data_id,
|
|
"source_scene_geo_id": source.source_scene_geo_id,
|
|
"source_kind": source.source_kind,
|
|
"relative_path": relative_path.replace(os.sep, "/"),
|
|
"file_size": size,
|
|
"checksum_sha256": checksum,
|
|
"status": status,
|
|
"error_message": error_message,
|
|
}
|
|
)
|
|
|
|
checksums_path = os.path.join(delivery_dir, "checksums.sha256")
|
|
if include_checksums:
|
|
with open(checksums_path, "w", encoding="utf-8") as stream:
|
|
stream.write("\n".join(checksum_lines))
|
|
if checksum_lines:
|
|
stream.write("\n")
|
|
|
|
manifest_path = os.path.join(delivery_dir, "manifest.json")
|
|
summary = {
|
|
"delivery_id": delivery_id,
|
|
"channel": delivery.channel,
|
|
"package_mode": delivery.package_mode,
|
|
"source_count": total_sources,
|
|
"file_count": len(item_payloads),
|
|
"copied_files": copied_files,
|
|
"skipped_files": skipped_files,
|
|
"failed_items": failed_items,
|
|
"total_bytes": total_bytes,
|
|
"copied_bytes": copied_bytes,
|
|
"include_checksums": include_checksums,
|
|
"manifest_path": manifest_path,
|
|
"checksums_path": checksums_path if include_checksums else None,
|
|
"created_at": _utcnow().isoformat(timespec="seconds"),
|
|
}
|
|
if failed_items:
|
|
summary["error_message"] = f"{failed_items} files failed during delivery build"
|
|
_write_json(
|
|
manifest_path,
|
|
{
|
|
"schema_version": "insar.result-delivery/v1",
|
|
"summary": summary,
|
|
"items": manifest_items,
|
|
},
|
|
)
|
|
|
|
zip_path = None
|
|
if delivery.package_mode == PACKAGE_MODE_ZIP:
|
|
if total_bytes > int(settings.RESULT_DELIVERY_ZIP_MAX_BYTES or 0):
|
|
raise ValueError(
|
|
f"delivery size exceeds zip limit: {total_bytes} > {settings.RESULT_DELIVERY_ZIP_MAX_BYTES}"
|
|
)
|
|
zip_path = str(delivery.zip_path or f"{delivery_dir}.zip")
|
|
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
|
for root_dir, _dirs, files in os.walk(delivery_dir):
|
|
for name in files:
|
|
path = os.path.join(root_dir, name)
|
|
archive.write(path, os.path.relpath(path, delivery_dir))
|
|
|
|
async with _new_session() as db:
|
|
result = await db.execute(
|
|
select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id)
|
|
)
|
|
delivery_row = result.scalar_one()
|
|
await db.execute(
|
|
ResultDeliveryItemORM.__table__.delete().where(
|
|
ResultDeliveryItemORM.delivery_id == delivery_id
|
|
)
|
|
)
|
|
for payload in item_payloads:
|
|
db.add(ResultDeliveryItemORM(**payload))
|
|
delivery_row.total_bytes = total_bytes
|
|
delivery_row.copied_bytes = copied_bytes
|
|
delivery_row.item_count = len(item_payloads)
|
|
delivery_row.manifest_path = manifest_path
|
|
delivery_row.zip_path = zip_path or delivery_row.zip_path
|
|
await db.commit()
|
|
|
|
return summary
|
|
|
|
async def resolve_manifest_path(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
delivery_id: str,
|
|
user: AuthUserORM,
|
|
) -> str:
|
|
delivery = await self.get_delivery(db, delivery_id=delivery_id, user=user, include_items=False)
|
|
if delivery is None:
|
|
raise ValueError("delivery not found")
|
|
if delivery.status != DELIVERY_STATUS_READY:
|
|
raise ValueError("delivery is not ready")
|
|
path = str(delivery.manifest_path or "").strip()
|
|
if not path or not os.path.isfile(path):
|
|
raise ValueError("manifest file is missing")
|
|
if not _path_within(delivery.delivery_root, path):
|
|
raise ValueError("manifest path is outside delivery root")
|
|
return path
|
|
|
|
async def resolve_archive_path(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
delivery_id: str,
|
|
user: AuthUserORM,
|
|
) -> str:
|
|
delivery = await self.get_delivery(db, delivery_id=delivery_id, user=user, include_items=False)
|
|
if delivery is None:
|
|
raise ValueError("delivery not found")
|
|
if delivery.status != DELIVERY_STATUS_READY:
|
|
raise ValueError("delivery is not ready")
|
|
path = str(delivery.zip_path or "").strip()
|
|
if not path or not os.path.isfile(path):
|
|
raise ValueError("zip archive is not available")
|
|
if not _path_within(delivery.delivery_root, path):
|
|
raise ValueError("zip path is outside delivery root")
|
|
return path
|
|
|
|
async def resolve_item_path(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
delivery_id: str,
|
|
item_id: int,
|
|
user: AuthUserORM,
|
|
) -> str:
|
|
delivery = await self.get_delivery(db, delivery_id=delivery_id, user=user, include_items=False)
|
|
if delivery is None:
|
|
raise ValueError("delivery not found")
|
|
if delivery.status != DELIVERY_STATUS_READY:
|
|
raise ValueError("delivery is not ready")
|
|
result = await db.execute(
|
|
select(ResultDeliveryItemORM).where(
|
|
ResultDeliveryItemORM.id == int(item_id),
|
|
ResultDeliveryItemORM.delivery_id == delivery_id,
|
|
)
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if item is None:
|
|
raise ValueError("delivery item not found")
|
|
if item.status != ITEM_STATUS_COPIED:
|
|
raise ValueError("delivery item is not available")
|
|
relative_path = str(item.relative_path or "").strip()
|
|
if not relative_path:
|
|
raise ValueError("delivery item path is missing")
|
|
path = os.path.normpath(os.path.abspath(os.path.join(delivery.delivery_dir, relative_path)))
|
|
if not os.path.isfile(path):
|
|
raise ValueError("delivery item file is missing")
|
|
if not _path_within(delivery.delivery_dir, path):
|
|
raise ValueError("delivery item path is outside delivery directory")
|
|
return path
|
|
|
|
|
|
result_delivery_service = ResultDeliveryService()
|