Enable LT1 and GF3 result delivery
This commit is contained in:
@@ -43,6 +43,7 @@ MIGRATION_FILES = [
|
||||
"011_source_metadata_documents.sql",
|
||||
"012_source_archive_integrity.sql",
|
||||
"013_result_delivery_requests.sql",
|
||||
"014_result_delivery_ortho_sources.sql",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -377,6 +377,8 @@ class ResultDeliveryItemORM(Base):
|
||||
source_product_id = Column(Integer, ForeignKey("result_products.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
source_result_id = Column(Integer, ForeignKey("dinsar_results.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
source_asset_id = Column(Integer, ForeignKey("result_assets.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
source_radar_data_id = Column(Integer, ForeignKey("radar_data.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
source_scene_geo_id = Column(Integer, ForeignKey("sar_scene_geo.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
display_name = Column(String(255), nullable=False)
|
||||
source_path = Column(String, nullable=False)
|
||||
relative_path = Column(String, nullable=True)
|
||||
@@ -391,10 +393,14 @@ class ResultDeliveryItemORM(Base):
|
||||
product = relationship("ResultProductORM", foreign_keys=[source_product_id])
|
||||
compat_result = relationship("DinsarResultORM", foreign_keys=[source_result_id])
|
||||
asset = relationship("ResultAssetORM", foreign_keys=[source_asset_id])
|
||||
radar_data = relationship("RadarDataORM", foreign_keys=[source_radar_data_id])
|
||||
scene_geo = relationship("SARSceneGeoORM", foreign_keys=[source_scene_geo_id])
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_result_delivery_items_delivery_status", "delivery_id", "status"),
|
||||
Index("idx_result_delivery_items_product", "source_product_id"),
|
||||
Index("idx_result_delivery_items_radar", "source_radar_data_id"),
|
||||
Index("idx_result_delivery_items_scene_geo", "source_scene_geo_id"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class ResultDeliveryCreateRequest(BaseModel):
|
||||
channel: str
|
||||
product_ids: Optional[List[int]] = None
|
||||
compat_result_ids: Optional[List[int]] = None
|
||||
item_ids: Optional[List[int]] = None
|
||||
package_mode: str = "directory"
|
||||
include_checksums: Optional[bool] = None
|
||||
|
||||
@@ -64,6 +65,7 @@ async def create_result_delivery(
|
||||
channel=request.channel,
|
||||
product_ids=request.product_ids,
|
||||
compat_result_ids=request.compat_result_ids,
|
||||
item_ids=request.item_ids,
|
||||
package_mode=request.package_mode,
|
||||
include_checksums=request.include_checksums,
|
||||
)
|
||||
@@ -89,6 +91,28 @@ async def create_result_delivery(
|
||||
return result_delivery_service.serialize_delivery(delivery)
|
||||
|
||||
|
||||
@router.get("/result-deliveries/catalog/{channel}")
|
||||
async def list_result_delivery_catalog(
|
||||
channel: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
query: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
_ = current_user
|
||||
try:
|
||||
return await result_delivery_service.list_channel_catalog(
|
||||
db,
|
||||
channel=channel,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
query=query,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/result-deliveries")
|
||||
async def list_result_deliveries(
|
||||
mine: bool = True,
|
||||
|
||||
@@ -20,10 +20,12 @@ from ..config import settings
|
||||
from ..models import (
|
||||
AuthUserORM,
|
||||
DinsarResultORM,
|
||||
RadarDataORM,
|
||||
ResultAssetORM,
|
||||
ResultDeliveryItemORM,
|
||||
ResultDeliveryRequestORM,
|
||||
ResultProductORM,
|
||||
SARSceneGeoORM,
|
||||
)
|
||||
from .dinsar_read_service import dinsar_read_service
|
||||
from .job_queue_service import job_queue_service
|
||||
@@ -46,7 +48,17 @@ ITEM_STATUS_FAILED = "FAILED"
|
||||
ITEM_STATUS_SKIPPED = "SKIPPED"
|
||||
|
||||
CHANNEL_DINSAR = "dinsar"
|
||||
SUPPORTED_READY_CHANNELS = {CHANNEL_DINSAR}
|
||||
CHANNEL_LT1_ORTHO = "lt1_ortho"
|
||||
CHANNEL_GF3_ORTHO = "gf3_ortho"
|
||||
CHANNEL_SBAS = "sbas"
|
||||
CHANNEL_S1_ORTHO = "s1_ortho"
|
||||
SUPPORTED_READY_CHANNELS = {CHANNEL_DINSAR, CHANNEL_LT1_ORTHO, CHANNEL_GF3_ORTHO}
|
||||
|
||||
LT1_ANALYSIS_ENGINE = "lt_gamma"
|
||||
LT1_ANALYSIS_PROFILE = "lt1_gamma_geocoded_mli"
|
||||
GF3_STANDARD_SOURCE_FORMAT = "GF3_SARSCAPE_L2"
|
||||
GF3_NATIVE_PREVIEW_SOURCE_FORMAT = "GF3_SARSCAPE_NATIVE_PREVIEW"
|
||||
GF3_DELIVERABLE_SOURCE_FORMATS = {GF3_STANDARD_SOURCE_FORMAT, GF3_NATIVE_PREVIEW_SOURCE_FORMAT}
|
||||
|
||||
PACKAGE_MODE_DIRECTORY = "directory"
|
||||
PACKAGE_MODE_ZIP = "zip"
|
||||
@@ -58,11 +70,16 @@ _SAFE_ID_RE = re.compile(r"[^0-9A-Za-z_-]+")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliverySource:
|
||||
product: ResultProductORM
|
||||
compat_row: Optional[DinsarResultORM]
|
||||
display_name: str
|
||||
source_path: str
|
||||
product: Optional[ResultProductORM] = None
|
||||
compat_row: Optional[DinsarResultORM] = None
|
||||
source_asset_id: Optional[int] = None
|
||||
source_radar_data_id: Optional[int] = None
|
||||
source_scene_geo_id: Optional[int] = None
|
||||
product_id: Optional[str] = None
|
||||
task_name: Optional[str] = None
|
||||
source_kind: str = "result_product"
|
||||
|
||||
|
||||
def _new_session() -> AsyncSession:
|
||||
@@ -208,6 +225,59 @@ def _resolve_source_path(product: ResultProductORM, assets: List[ResultAssetORM]
|
||||
return "", None
|
||||
|
||||
|
||||
def _scene_product_id(scene: SARSceneGeoORM) -> str:
|
||||
return f"sar_scene_geo:{scene.id}"
|
||||
|
||||
|
||||
def _scene_display_name(scene: SARSceneGeoORM, radar: RadarDataORM) -> str:
|
||||
for value in (
|
||||
radar.product_unique_id,
|
||||
radar.unique_id,
|
||||
radar.source_product_token,
|
||||
os.path.basename(str(radar.file_path or "").rstrip("/\\")),
|
||||
_scene_product_id(scene),
|
||||
):
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return f"scene_{scene.id}"
|
||||
|
||||
|
||||
def _radar_display_name(radar: RadarDataORM) -> str:
|
||||
for value in (
|
||||
radar.product_unique_id,
|
||||
radar.unique_id,
|
||||
radar.source_product_token,
|
||||
os.path.basename(str(radar.file_path or "").rstrip("/\\")),
|
||||
f"radar_{radar.id}",
|
||||
):
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return f"radar_{radar.id}"
|
||||
|
||||
|
||||
def _manifest_path_for_scene(scene: SARSceneGeoORM) -> Optional[str]:
|
||||
metadata = scene.analysis_metadata_json if isinstance(scene.analysis_metadata_json, dict) else {}
|
||||
text = str(metadata.get("manifest_path") or "").strip()
|
||||
if text:
|
||||
return text
|
||||
if scene.analysis_dir:
|
||||
return os.path.join(scene.analysis_dir, "manifest.json")
|
||||
return None
|
||||
|
||||
|
||||
def _quality_path_for_scene(scene: SARSceneGeoORM) -> Optional[str]:
|
||||
if scene.analysis_dir:
|
||||
return os.path.join(scene.analysis_dir, "quality.json")
|
||||
return None
|
||||
|
||||
|
||||
def _catalog_file_size(path: Any) -> int:
|
||||
text = str(path or "").strip()
|
||||
return _file_size(text) if text else 0
|
||||
|
||||
|
||||
class ResultDeliveryService:
|
||||
def channels(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
@@ -220,7 +290,7 @@ class ResultDeliveryService:
|
||||
"description": "已登记 D-InSAR catalog,可创建后台交付包并下载到本地。",
|
||||
},
|
||||
{
|
||||
"key": "sbas",
|
||||
"key": CHANNEL_SBAS,
|
||||
"group": "InSAR 成果",
|
||||
"label": "SBAS-InSAR 结果",
|
||||
"state": "planned",
|
||||
@@ -228,15 +298,15 @@ class ResultDeliveryService:
|
||||
"description": "SBAS 结果 catalog 已有基础能力,本阶段暂不开放交付打包。",
|
||||
},
|
||||
{
|
||||
"key": "lt1_ortho",
|
||||
"key": CHANNEL_LT1_ORTHO,
|
||||
"group": "正射成果",
|
||||
"label": "LT-1 正射结果",
|
||||
"state": "placeholder",
|
||||
"state_text": "待接入",
|
||||
"description": "陆探一正射生产将由 LandSAR 生产链注册后接入统一交付。",
|
||||
"state": "ready",
|
||||
"state_text": "可交付",
|
||||
"description": "服务器生产的 LT-1 分析就绪正射 GeoTIFF 已接入交付,可打包下载到本地。",
|
||||
},
|
||||
{
|
||||
"key": "s1_ortho",
|
||||
"key": CHANNEL_S1_ORTHO,
|
||||
"group": "正射成果",
|
||||
"label": "Sentinel-1 正射结果",
|
||||
"state": "placeholder",
|
||||
@@ -244,12 +314,12 @@ class ResultDeliveryService:
|
||||
"description": "Sentinel-1 正射生产尚未接入,当前只保留交付通道占位。",
|
||||
},
|
||||
{
|
||||
"key": "gf3_ortho",
|
||||
"key": CHANNEL_GF3_ORTHO,
|
||||
"group": "正射成果",
|
||||
"label": "GF3 SARscape _geo",
|
||||
"state": "placeholder",
|
||||
"state_text": "待接入",
|
||||
"description": "GF3 外部生产成果登记后再接入统一交付。",
|
||||
"state": "ready",
|
||||
"state_text": "可交付",
|
||||
"description": "已登记的 GF3 SARscape 标准化正射成品可直接创建交付包。",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -282,11 +352,14 @@ class ResultDeliveryService:
|
||||
seen_products.add(int(product.id))
|
||||
sources.append(
|
||||
DeliverySource(
|
||||
product=product,
|
||||
compat_row=None,
|
||||
display_name=dinsar_read_service.get_display_name(product),
|
||||
source_path=source_path,
|
||||
product=product,
|
||||
compat_row=None,
|
||||
source_asset_id=source_asset_id,
|
||||
product_id=product.product_id,
|
||||
task_name=product.task_alias or product.task_name,
|
||||
source_kind="dinsar_product",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -319,16 +392,344 @@ class ResultDeliveryService:
|
||||
seen_products.add(int(product.id))
|
||||
sources.append(
|
||||
DeliverySource(
|
||||
product=product,
|
||||
compat_row=record.compat_row,
|
||||
display_name=record.display_name,
|
||||
source_path=source_path,
|
||||
product=product,
|
||||
compat_row=record.compat_row,
|
||||
source_asset_id=source_asset_id,
|
||||
product_id=product.product_id,
|
||||
task_name=product.task_alias or product.task_name,
|
||||
source_kind="dinsar_product",
|
||||
)
|
||||
)
|
||||
|
||||
return sources
|
||||
|
||||
async def _resolve_lt1_sources(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
item_ids: List[int],
|
||||
product_ids: List[int],
|
||||
) -> List[DeliverySource]:
|
||||
selected_ids = item_ids or product_ids
|
||||
if not selected_ids:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(
|
||||
SARSceneGeoORM.id.in_(selected_ids),
|
||||
SARSceneGeoORM.status == "DONE",
|
||||
SARSceneGeoORM.analysis_tif_path.isnot(None),
|
||||
SARSceneGeoORM.analysis_engine == LT1_ANALYSIS_ENGINE,
|
||||
SARSceneGeoORM.analysis_profile == LT1_ANALYSIS_PROFILE,
|
||||
)
|
||||
.order_by(SARSceneGeoORM.id.asc())
|
||||
)
|
||||
sources: List[DeliverySource] = []
|
||||
for scene, radar in result.all():
|
||||
analysis_dir = str(scene.analysis_dir or "").strip()
|
||||
analysis_tif = str(scene.analysis_tif_path or "").strip()
|
||||
source_path = analysis_dir if analysis_dir and os.path.isdir(analysis_dir) else analysis_tif
|
||||
if not source_path:
|
||||
continue
|
||||
product_id = _scene_product_id(scene)
|
||||
display_name = _scene_display_name(scene, radar)
|
||||
sources.append(
|
||||
DeliverySource(
|
||||
display_name=display_name,
|
||||
source_path=source_path,
|
||||
source_radar_data_id=int(radar.id) if radar.id is not None else None,
|
||||
source_scene_geo_id=int(scene.id) if scene.id is not None else None,
|
||||
product_id=product_id,
|
||||
task_name=display_name,
|
||||
source_kind="lt1_scene_geo",
|
||||
)
|
||||
)
|
||||
return sources
|
||||
|
||||
async def _resolve_gf3_sources(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
item_ids: List[int],
|
||||
) -> List[DeliverySource]:
|
||||
if not item_ids:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(RadarDataORM, SARSceneGeoORM)
|
||||
.outerjoin(SARSceneGeoORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(
|
||||
RadarDataORM.id.in_(item_ids),
|
||||
RadarDataORM.source_format.in_(GF3_DELIVERABLE_SOURCE_FORMATS),
|
||||
RadarDataORM.geocoded_flag.is_(True),
|
||||
)
|
||||
.order_by(RadarDataORM.id.asc())
|
||||
)
|
||||
sources: List[DeliverySource] = []
|
||||
for radar, scene in result.all():
|
||||
source_path = ""
|
||||
source_scene_geo_id = None
|
||||
if scene is not None and scene.analysis_tif_path:
|
||||
analysis_dir = str(scene.analysis_dir or "").strip()
|
||||
analysis_tif = str(scene.analysis_tif_path or "").strip()
|
||||
source_path = analysis_dir if analysis_dir and os.path.isdir(analysis_dir) else analysis_tif
|
||||
source_scene_geo_id = int(scene.id) if scene.id is not None else None
|
||||
if not source_path:
|
||||
source_path = str(radar.file_path or "").strip()
|
||||
if not source_path:
|
||||
continue
|
||||
display_name = _radar_display_name(radar)
|
||||
sources.append(
|
||||
DeliverySource(
|
||||
display_name=display_name,
|
||||
source_path=source_path,
|
||||
source_radar_data_id=int(radar.id) if radar.id is not None else None,
|
||||
source_scene_geo_id=source_scene_geo_id,
|
||||
product_id=f"gf3_sarscape:{radar.id}",
|
||||
task_name=display_name,
|
||||
source_kind="gf3_radar_data",
|
||||
)
|
||||
)
|
||||
return sources
|
||||
|
||||
async def _resolve_sources_for_channel(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
channel: str,
|
||||
product_ids: List[int],
|
||||
compat_result_ids: List[int],
|
||||
item_ids: List[int],
|
||||
) -> List[DeliverySource]:
|
||||
if channel == CHANNEL_DINSAR:
|
||||
return await self._resolve_dinsar_sources(
|
||||
db,
|
||||
product_ids=product_ids,
|
||||
compat_result_ids=compat_result_ids,
|
||||
)
|
||||
if channel == CHANNEL_LT1_ORTHO:
|
||||
return await self._resolve_lt1_sources(db, item_ids=item_ids, product_ids=product_ids)
|
||||
if channel == CHANNEL_GF3_ORTHO:
|
||||
return await self._resolve_gf3_sources(db, item_ids=item_ids or product_ids)
|
||||
return []
|
||||
|
||||
async def list_channel_catalog(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
channel: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
query: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
channel = _normalize_channel(channel)
|
||||
safe_limit = min(500, max(1, int(limit or 100)))
|
||||
safe_offset = max(0, int(offset or 0))
|
||||
query_text = str(query or "").strip()
|
||||
|
||||
if channel == CHANNEL_LT1_ORTHO:
|
||||
filters = [
|
||||
SARSceneGeoORM.status == "DONE",
|
||||
SARSceneGeoORM.analysis_tif_path.isnot(None),
|
||||
SARSceneGeoORM.analysis_engine == LT1_ANALYSIS_ENGINE,
|
||||
SARSceneGeoORM.analysis_profile == LT1_ANALYSIS_PROFILE,
|
||||
]
|
||||
if query_text:
|
||||
like = f"%{query_text}%"
|
||||
filters.append(
|
||||
or_(
|
||||
RadarDataORM.product_unique_id.ilike(like),
|
||||
RadarDataORM.unique_id.ilike(like),
|
||||
RadarDataORM.file_path.ilike(like),
|
||||
RadarDataORM.imaging_date.ilike(like),
|
||||
)
|
||||
)
|
||||
total_result = await db.execute(
|
||||
select(func.count(SARSceneGeoORM.id))
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(*filters)
|
||||
)
|
||||
rows_result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(*filters)
|
||||
.order_by(SARSceneGeoORM.updated_at.desc().nullslast(), SARSceneGeoORM.id.desc())
|
||||
.limit(safe_limit)
|
||||
.offset(safe_offset)
|
||||
)
|
||||
items = []
|
||||
for scene, radar in rows_result.all():
|
||||
display_name = _scene_display_name(scene, radar)
|
||||
manifest_path = _manifest_path_for_scene(scene)
|
||||
quality_path = _quality_path_for_scene(scene)
|
||||
items.append(
|
||||
{
|
||||
"id": scene.id,
|
||||
"item_id": scene.id,
|
||||
"source_kind": "lt1_scene_geo",
|
||||
"product_id": _scene_product_id(scene),
|
||||
"catalog_name": "sar_scene_geo",
|
||||
"product_family": "lt1_analysis_ready_geotiff",
|
||||
"product_type": "analysis_ready_geotiff",
|
||||
"display_name": display_name,
|
||||
"task_name": display_name,
|
||||
"status": scene.status,
|
||||
"health_status": "OK",
|
||||
"engine_code": scene.analysis_engine,
|
||||
"profile_code": scene.analysis_profile,
|
||||
"radar_data_id": radar.id,
|
||||
"scene_geo_id": scene.id,
|
||||
"imaging_date": radar.imaging_date,
|
||||
"polarization": radar.polarization,
|
||||
"pixel_size_m": scene.pixel_size_m,
|
||||
"backscatter_unit": scene.analysis_backscatter_unit,
|
||||
"publish_dir": scene.analysis_dir,
|
||||
"manifest_path": manifest_path,
|
||||
"quality_path": quality_path,
|
||||
"primary_asset_path": scene.analysis_tif_path,
|
||||
"file_size": _catalog_file_size(scene.analysis_tif_path),
|
||||
"summary": {
|
||||
"radar_data_id": radar.id,
|
||||
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
|
||||
"imaging_date": radar.imaging_date,
|
||||
"polarization": radar.polarization,
|
||||
"pixel_size_m": scene.pixel_size_m,
|
||||
},
|
||||
"produced_at": scene.updated_at.isoformat() if scene.updated_at else None,
|
||||
"published_at": scene.updated_at.isoformat() if scene.updated_at else None,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": int(total_result.scalar_one() or 0),
|
||||
"limit": safe_limit,
|
||||
"offset": safe_offset,
|
||||
}
|
||||
|
||||
if channel == CHANNEL_GF3_ORTHO:
|
||||
filters = [
|
||||
RadarDataORM.source_format.in_(GF3_DELIVERABLE_SOURCE_FORMATS),
|
||||
RadarDataORM.geocoded_flag.is_(True),
|
||||
]
|
||||
if query_text:
|
||||
like = f"%{query_text}%"
|
||||
filters.append(
|
||||
or_(
|
||||
RadarDataORM.product_unique_id.ilike(like),
|
||||
RadarDataORM.unique_id.ilike(like),
|
||||
RadarDataORM.file_path.ilike(like),
|
||||
RadarDataORM.imaging_date.ilike(like),
|
||||
)
|
||||
)
|
||||
total_result = await db.execute(select(func.count(RadarDataORM.id)).where(*filters))
|
||||
rows_result = await db.execute(
|
||||
select(RadarDataORM, SARSceneGeoORM)
|
||||
.outerjoin(SARSceneGeoORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(*filters)
|
||||
.order_by(RadarDataORM.acquisition_start_time_utc.desc().nullslast(), RadarDataORM.id.desc())
|
||||
.limit(safe_limit)
|
||||
.offset(safe_offset)
|
||||
)
|
||||
items = []
|
||||
for radar, scene in rows_result.all():
|
||||
display_name = _radar_display_name(radar)
|
||||
primary_path = (
|
||||
str(scene.analysis_tif_path or "").strip()
|
||||
if scene is not None and scene.analysis_tif_path
|
||||
else str(radar.file_path or "").strip()
|
||||
)
|
||||
publish_dir = (
|
||||
str(scene.analysis_dir or "").strip()
|
||||
if scene is not None and scene.analysis_dir
|
||||
else str(radar.file_path or "").strip()
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"id": radar.id,
|
||||
"item_id": radar.id,
|
||||
"source_kind": "gf3_radar_data",
|
||||
"product_id": f"gf3_sarscape:{radar.id}",
|
||||
"catalog_name": "radar_data",
|
||||
"product_family": "gf3_ortho",
|
||||
"product_type": "sarscape_l2_geotiff" if radar.source_format == GF3_STANDARD_SOURCE_FORMAT else "sarscape_native_geo",
|
||||
"display_name": display_name,
|
||||
"task_name": display_name,
|
||||
"status": "READY",
|
||||
"health_status": "OK",
|
||||
"engine_code": (scene.analysis_engine if scene is not None else None) or "gf3_sarscape",
|
||||
"profile_code": (
|
||||
(scene.analysis_profile if scene is not None else None)
|
||||
or ("gf3_standard_geotiff" if radar.source_format == GF3_STANDARD_SOURCE_FORMAT else "gf3_native_geo")
|
||||
),
|
||||
"source_format": radar.source_format,
|
||||
"radar_data_id": radar.id,
|
||||
"scene_geo_id": scene.id if scene is not None else None,
|
||||
"imaging_date": radar.imaging_date,
|
||||
"polarization": radar.polarization,
|
||||
"pixel_size_m": scene.pixel_size_m if scene is not None else None,
|
||||
"backscatter_unit": scene.analysis_backscatter_unit if scene is not None else None,
|
||||
"publish_dir": publish_dir,
|
||||
"manifest_path": _manifest_path_for_scene(scene) if scene is not None else None,
|
||||
"primary_asset_path": primary_path,
|
||||
"file_size": _catalog_file_size(primary_path),
|
||||
"summary": {
|
||||
"radar_data_id": radar.id,
|
||||
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
|
||||
"imaging_date": radar.imaging_date,
|
||||
"polarization": radar.polarization,
|
||||
},
|
||||
"produced_at": (
|
||||
scene.updated_at.isoformat() if scene is not None and scene.updated_at else None
|
||||
),
|
||||
"published_at": (
|
||||
scene.updated_at.isoformat() if scene is not None and scene.updated_at else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": int(total_result.scalar_one() or 0),
|
||||
"limit": safe_limit,
|
||||
"offset": safe_offset,
|
||||
}
|
||||
|
||||
if channel == CHANNEL_DINSAR:
|
||||
total = await dinsar_read_service.count_compat_records(db)
|
||||
records = await dinsar_read_service.list_compat_records(db, limit=safe_limit, offset=safe_offset)
|
||||
items = []
|
||||
for record in records:
|
||||
product = record.product
|
||||
compat_row = record.compat_row
|
||||
items.append(
|
||||
{
|
||||
"id": int(compat_row.id if compat_row is not None else product.id),
|
||||
"item_id": int(compat_row.id if compat_row is not None else product.id),
|
||||
"source_kind": "dinsar_result",
|
||||
"product_id": product.product_id,
|
||||
"catalog_name": product.catalog_name,
|
||||
"product_family": product.product_family,
|
||||
"product_type": product.product_type,
|
||||
"display_name": record.display_name,
|
||||
"task_name": product.task_alias or product.task_name,
|
||||
"status": product.status,
|
||||
"health_status": product.health_status,
|
||||
"engine_code": product.engine_code,
|
||||
"profile_code": product.profile_code,
|
||||
"pair_key": product.pair_key,
|
||||
"publish_dir": product.publish_dir,
|
||||
"manifest_path": product.manifest_path,
|
||||
"primary_asset_path": product.primary_asset_path or product.source_primary_path,
|
||||
"file_size": _catalog_file_size(product.primary_asset_path or product.source_primary_path),
|
||||
"produced_at": product.produced_at.isoformat() if product.produced_at else None,
|
||||
"published_at": product.published_at.isoformat() if product.published_at else None,
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": total, "limit": safe_limit, "offset": safe_offset}
|
||||
|
||||
return {"items": [], "total": 0, "limit": safe_limit, "offset": safe_offset}
|
||||
|
||||
async def create_delivery(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -337,6 +738,7 @@ class ResultDeliveryService:
|
||||
channel: str,
|
||||
product_ids: Optional[List[int]] = None,
|
||||
compat_result_ids: Optional[List[int]] = None,
|
||||
item_ids: Optional[List[int]] = None,
|
||||
package_mode: str = PACKAGE_MODE_DIRECTORY,
|
||||
include_checksums: Optional[bool] = None,
|
||||
) -> ResultDeliveryRequestORM:
|
||||
@@ -348,20 +750,20 @@ class ResultDeliveryService:
|
||||
max_items = max(1, int(settings.RESULT_DELIVERY_MAX_ITEMS or 500))
|
||||
normalized_product_ids = _normalize_int_ids(product_ids, max_count=max_items)
|
||||
normalized_compat_ids = _normalize_int_ids(compat_result_ids, max_count=max_items)
|
||||
if not normalized_product_ids and not normalized_compat_ids:
|
||||
normalized_item_ids = _normalize_int_ids(item_ids, max_count=max_items)
|
||||
if not normalized_product_ids and not normalized_compat_ids and not normalized_item_ids:
|
||||
raise ValueError("select at least one result")
|
||||
|
||||
if len(normalized_product_ids) + len(normalized_compat_ids) > max_items:
|
||||
if len(normalized_product_ids) + len(normalized_compat_ids) + len(normalized_item_ids) > max_items:
|
||||
raise ValueError(f"selected item count exceeds max limit ({max_items})")
|
||||
|
||||
if channel == CHANNEL_DINSAR:
|
||||
sources = await self._resolve_dinsar_sources(
|
||||
db,
|
||||
product_ids=normalized_product_ids,
|
||||
compat_result_ids=normalized_compat_ids,
|
||||
)
|
||||
else:
|
||||
sources = []
|
||||
sources = await self._resolve_sources_for_channel(
|
||||
db,
|
||||
channel=channel,
|
||||
product_ids=normalized_product_ids,
|
||||
compat_result_ids=normalized_compat_ids,
|
||||
item_ids=normalized_item_ids,
|
||||
)
|
||||
|
||||
if not sources:
|
||||
raise ValueError("selected results do not have deliverable files")
|
||||
@@ -376,6 +778,7 @@ class ResultDeliveryService:
|
||||
"channel": channel,
|
||||
"product_ids": normalized_product_ids,
|
||||
"compat_result_ids": normalized_compat_ids,
|
||||
"item_ids": normalized_item_ids,
|
||||
"package_mode": package_mode,
|
||||
"include_checksums": (
|
||||
bool(settings.RESULT_DELIVERY_CHECKSUM_ENABLED)
|
||||
@@ -544,6 +947,8 @@ class ResultDeliveryService:
|
||||
"source_product_id": item.source_product_id,
|
||||
"source_result_id": item.source_result_id,
|
||||
"source_asset_id": item.source_asset_id,
|
||||
"source_radar_data_id": item.source_radar_data_id,
|
||||
"source_scene_geo_id": item.source_scene_geo_id,
|
||||
"display_name": item.display_name,
|
||||
"relative_path": item.relative_path,
|
||||
"file_size": item.file_size,
|
||||
@@ -636,14 +1041,14 @@ class ResultDeliveryService:
|
||||
)
|
||||
delivery = result.scalar_one()
|
||||
request_json = delivery.request_json if isinstance(delivery.request_json, dict) else {}
|
||||
if delivery.channel == CHANNEL_DINSAR:
|
||||
sources = await self._resolve_dinsar_sources(
|
||||
db,
|
||||
product_ids=_normalize_int_ids(request_json.get("product_ids"), max_count=int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)),
|
||||
compat_result_ids=_normalize_int_ids(request_json.get("compat_result_ids"), max_count=int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)),
|
||||
)
|
||||
else:
|
||||
sources = []
|
||||
max_items = int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)
|
||||
sources = await self._resolve_sources_for_channel(
|
||||
db,
|
||||
channel=delivery.channel,
|
||||
product_ids=_normalize_int_ids(request_json.get("product_ids"), max_count=max_items),
|
||||
compat_result_ids=_normalize_int_ids(request_json.get("compat_result_ids"), max_count=max_items),
|
||||
item_ids=_normalize_int_ids(request_json.get("item_ids"), max_count=max_items),
|
||||
)
|
||||
task_id = delivery.task_id
|
||||
|
||||
if not sources:
|
||||
@@ -672,10 +1077,9 @@ class ResultDeliveryService:
|
||||
total_sources = len(sources)
|
||||
for source_index, source in enumerate(sources, start=1):
|
||||
folder = _sanitize_segment(
|
||||
source.product.task_alias
|
||||
or source.product.task_name
|
||||
source.task_name
|
||||
or source.display_name
|
||||
or source.product.product_id,
|
||||
or source.product_id,
|
||||
default=f"product_{source_index}",
|
||||
)
|
||||
source_files = _iter_associated_files(source.source_path)
|
||||
@@ -684,9 +1088,11 @@ class ResultDeliveryService:
|
||||
item_payloads.append(
|
||||
{
|
||||
"delivery_id": delivery_id,
|
||||
"source_product_id": source.product.id,
|
||||
"source_product_id": source.product.id if source.product else None,
|
||||
"source_result_id": source.compat_row.id if source.compat_row else None,
|
||||
"source_asset_id": source.source_asset_id,
|
||||
"source_radar_data_id": source.source_radar_data_id,
|
||||
"source_scene_geo_id": source.source_scene_geo_id,
|
||||
"display_name": source.display_name,
|
||||
"source_path": source.source_path,
|
||||
"relative_path": None,
|
||||
@@ -736,9 +1142,11 @@ class ResultDeliveryService:
|
||||
|
||||
item_payload = {
|
||||
"delivery_id": delivery_id,
|
||||
"source_product_id": source.product.id,
|
||||
"source_product_id": source.product.id if source.product else None,
|
||||
"source_result_id": source.compat_row.id if source.compat_row else None,
|
||||
"source_asset_id": source.source_asset_id if file_index == 1 else None,
|
||||
"source_radar_data_id": source.source_radar_data_id,
|
||||
"source_scene_geo_id": source.source_scene_geo_id,
|
||||
"display_name": source.display_name,
|
||||
"source_path": source_file,
|
||||
"relative_path": relative_path,
|
||||
@@ -751,9 +1159,12 @@ class ResultDeliveryService:
|
||||
manifest_items.append(
|
||||
{
|
||||
"display_name": source.display_name,
|
||||
"product_id": source.product.product_id,
|
||||
"product_ref_id": source.product.id,
|
||||
"product_id": source.product_id or (source.product.product_id if source.product else None),
|
||||
"product_ref_id": source.product.id if source.product else None,
|
||||
"source_result_id": source.compat_row.id if source.compat_row else None,
|
||||
"source_radar_data_id": source.source_radar_data_id,
|
||||
"source_scene_geo_id": source.source_scene_geo_id,
|
||||
"source_kind": source.source_kind,
|
||||
"relative_path": relative_path.replace(os.sep, "/"),
|
||||
"file_size": size,
|
||||
"checksum_sha256": checksum,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE result_delivery_items ADD COLUMN IF NOT EXISTS source_radar_data_id INTEGER NULL REFERENCES radar_data(id) ON DELETE SET NULL;
|
||||
ALTER TABLE result_delivery_items ADD COLUMN IF NOT EXISTS source_scene_geo_id INTEGER NULL REFERENCES sar_scene_geo(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_result_delivery_items_source_radar_data_id ON result_delivery_items(source_radar_data_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_result_delivery_items_source_scene_geo_id ON result_delivery_items(source_scene_geo_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_result_delivery_items_radar ON result_delivery_items(source_radar_data_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_result_delivery_items_scene_geo ON result_delivery_items(source_scene_geo_id);
|
||||
@@ -14,8 +14,9 @@
|
||||
## 目标
|
||||
|
||||
- 所有登录用户都可以申请成果交付。
|
||||
- D-InSAR 已登记成果先接入真实交付下载。
|
||||
- SBAS、LT-1 正射、Sentinel-1 正射、GF3 正射在页面和接口中保留清晰占位,不暴露假执行能力。
|
||||
- D-InSAR 已登记成果接入真实交付下载。
|
||||
- LT-1 正射与 GF3 正射接入真实交付下载。
|
||||
- SBAS 与 Sentinel-1 正射在页面和接口中保留清晰占位,不暴露假执行能力。
|
||||
- 大文件交付改为后台任务,不再由 HTTP 请求同步复制。
|
||||
- 用户最终可以把成果下载到本地;服务器交付区只是临时缓存。
|
||||
- 每次交付可审计、可过期清理、可校验。
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
- 本阶段不实现 Sentinel-1 正射生产。
|
||||
- 本阶段不实现 SBAS 成果交付打包,只显示目录已接入但交付未接入。
|
||||
- 本阶段不触发 GF3 生产;GF3 交付只面向已经登记的 SARscape 标准化正射成品。
|
||||
- 本阶段不做跨节点对象存储或外部网盘。
|
||||
- 本阶段不把普通用户开放到任意服务器路径写入。
|
||||
|
||||
@@ -43,6 +45,13 @@
|
||||
|
||||
## 交付模式
|
||||
|
||||
## 正射成果口径
|
||||
|
||||
- LT-1 正射:由服务器侧单景正射流水线生产,登记在 `sar_scene_geo`,主交付物是 `analysis_ready.tif`,随包包含预览、manifest、quality 等 sidecar 文件。
|
||||
- GF3 正射:系统拿到的就是外部 SARscape 已生产成品,当前可登记为 `GF3_SARSCAPE_NATIVE_PREVIEW`,标准化后也可登记为 `GF3_SARSCAPE_L2`;交付阶段只做受控打包和下载,不触发 GF3 生产。
|
||||
- Sentinel-1 正射:生产链尚未接入,结果提取页面保留占位,避免用户误操作。
|
||||
- SBAS-InSAR:成果目录可查,但交付打包尚未接入。
|
||||
|
||||
### 1. 目录交付
|
||||
|
||||
默认模式。后台将选中的结果文件复制到:
|
||||
@@ -97,6 +106,7 @@
|
||||
|
||||
- `delivery_id`。
|
||||
- `source_product_id` / `source_result_id`。
|
||||
- `source_radar_data_id` / `source_scene_geo_id`:用于 LT-1/GF3 单景正射资产追踪。
|
||||
- `display_name`。
|
||||
- `source_path`。
|
||||
- `relative_path`。
|
||||
@@ -133,9 +143,23 @@ GET /api/result-deliveries/channels
|
||||
|
||||
- `dinsar`: `ready`
|
||||
- `sbas`: `planned`
|
||||
- `lt1_ortho`: `planned`
|
||||
- `lt1_ortho`: `ready`
|
||||
- `s1_ortho`: `placeholder`
|
||||
- `gf3_ortho`: `placeholder`
|
||||
- `gf3_ortho`: `ready`
|
||||
|
||||
### 获取可交付目录
|
||||
|
||||
```http
|
||||
GET /api/result-deliveries/catalog/{channel}
|
||||
```
|
||||
|
||||
第一版支持:
|
||||
|
||||
- `dinsar`:兼容已有 D-InSAR catalog。
|
||||
- `lt1_ortho`:读取 `sar_scene_geo` 中 `lt_gamma / lt1_gamma_geocoded_mli` 且 `DONE` 的分析就绪 GeoTIFF。
|
||||
- `gf3_ortho`:读取 `radar_data.source_format in (GF3_SARSCAPE_NATIVE_PREVIEW, GF3_SARSCAPE_L2)` 且 `geocoded_flag = true` 的 SARscape 正射成品。
|
||||
|
||||
返回统一字段包括 `item_id`、`source_kind`、`product_id`、`display_name`、`primary_asset_path`、`publish_dir`、`radar_data_id`、`scene_geo_id`。
|
||||
|
||||
### 创建交付任务
|
||||
|
||||
@@ -150,6 +174,7 @@ POST /api/result-deliveries
|
||||
"channel": "dinsar",
|
||||
"product_ids": ["..."],
|
||||
"compat_result_ids": [1, 2, 3],
|
||||
"item_ids": [101, 102],
|
||||
"package_mode": "directory",
|
||||
"include_manifest": true,
|
||||
"include_checksums": true
|
||||
@@ -159,7 +184,9 @@ POST /api/result-deliveries
|
||||
约束:
|
||||
|
||||
- 普通用户只创建自己的任务。
|
||||
- `channel != dinsar` 时第一版返回 409 或 422,提示“通道尚未接入交付”。
|
||||
- `dinsar` 使用 `compat_result_ids` 或 `product_ids`。
|
||||
- `lt1_ortho` 使用 `item_ids = sar_scene_geo.id`。
|
||||
- `gf3_ortho` 使用 `item_ids = radar_data.id`。
|
||||
- 每次最大数量由 `RESULT_DELIVERY_MAX_ITEMS` 控制。
|
||||
- 不允许传入任意服务器输出路径。
|
||||
|
||||
@@ -200,7 +227,7 @@ RESULT_DELIVERY_BUILD
|
||||
处理流程:
|
||||
|
||||
1. 将 delivery 标记为 `RUNNING`。
|
||||
2. 解析 D-InSAR catalog 中的文件路径。
|
||||
2. 按 channel 解析可交付源文件路径。
|
||||
3. 复制到交付目录。
|
||||
4. 生成 `manifest.json`。
|
||||
5. 可选计算 checksum。
|
||||
@@ -244,8 +271,8 @@ RESULT_DELIVERY_CHECKSUM_ENABLED=true
|
||||
|
||||
页面结构:
|
||||
|
||||
- 通道栏:D-InSAR 可用,SBAS/LT-1/Sentinel-1/GF3 明确显示“未接入交付”。
|
||||
- 成果选择区:复用现有 D-InSAR catalog 列表。
|
||||
- 通道栏:D-InSAR、LT-1 正射、GF3 正射可用;SBAS/Sentinel-1 显示未接入交付。
|
||||
- 成果选择区:D-InSAR 复用现有 catalog 列表,LT-1/GF3 使用统一交付 catalog 查询。
|
||||
- 交付选项:目录交付 / 压缩包交付。
|
||||
- 我的交付包:状态、大小、文件数、过期时间、下载入口。
|
||||
|
||||
@@ -283,7 +310,7 @@ RESULT_DELIVERY_CHECKSUM_ENABLED=true
|
||||
|
||||
- 文档落地。
|
||||
- 数据表和自维护 migration。
|
||||
- D-InSAR 目录交付后台任务。
|
||||
- D-InSAR、LT-1 正射、GF3 正射目录交付后台任务。
|
||||
- 我的交付包列表和详情。
|
||||
|
||||
### 阶段 2
|
||||
@@ -295,12 +322,12 @@ RESULT_DELIVERY_CHECKSUM_ENABLED=true
|
||||
### 阶段 3
|
||||
|
||||
- SBAS 交付接入。
|
||||
- LT-1 正射、Sentinel-1 正射、GF3 正射在生产 catalog 完成后接入。
|
||||
- Sentinel-1 正射在生产 catalog 完成后接入。
|
||||
- exporter/operator 角色拆分。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 普通登录用户能创建 D-InSAR 成果交付任务。
|
||||
- 普通登录用户能创建 D-InSAR、LT-1 正射、GF3 正射成果交付任务。
|
||||
- HTTP 请求只排队任务,不再同步复制大文件。
|
||||
- 交付完成后用户能下载到本地。
|
||||
- 普通用户不能指定任意服务器目录。
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getResultDeliveryArchiveUrl,
|
||||
getResultDeliveryDownloadUrl,
|
||||
getResultDeliveryManifestUrl,
|
||||
listResultDeliveryCatalog,
|
||||
listResultDeliveries,
|
||||
} from './api/resultDeliveries';
|
||||
import { getDinsarEngineMeta } from './utils/dinsarEngines';
|
||||
@@ -34,9 +35,9 @@ const PRODUCT_CHANNELS = [
|
||||
key: 'lt1_ortho',
|
||||
group: '正射成果',
|
||||
label: 'LT-1 正射结果',
|
||||
state: 'placeholder',
|
||||
stateText: '待接入',
|
||||
description: '陆探一正射生产将由 LandSAR 生产链注册到统一成果目录后开放交付。',
|
||||
state: 'ready',
|
||||
stateText: '可交付',
|
||||
description: '服务器生产的 LT-1 分析就绪正射 GeoTIFF 已接入交付,可打包下载到本地。',
|
||||
},
|
||||
{
|
||||
key: 's1_ortho',
|
||||
@@ -50,9 +51,9 @@ const PRODUCT_CHANNELS = [
|
||||
key: 'gf3_ortho',
|
||||
group: '正射成果',
|
||||
label: 'GF3 SARscape _geo',
|
||||
state: 'placeholder',
|
||||
stateText: '待接入',
|
||||
description: 'GF3 外部生产成果登记后再接入统一交付。',
|
||||
state: 'ready',
|
||||
stateText: '可交付',
|
||||
description: '已登记的 GF3 SARscape 标准化正射成品可直接创建交付包。',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -85,17 +86,89 @@ function extractTotal(payload, fallback = 0) {
|
||||
}
|
||||
|
||||
function resultDisplayName(result) {
|
||||
return String(result?.name || result?.task_alias || result?.task_name || result?.product_id || `#${result?.id || ''}`).trim();
|
||||
return String(result?.display_name || result?.name || result?.task_alias || result?.task_name || result?.product_id || `#${result?.id || ''}`).trim();
|
||||
}
|
||||
|
||||
function resultItemId(result) {
|
||||
return result?.item_id ?? result?.id;
|
||||
}
|
||||
|
||||
function selectionKey(channelKey, id) {
|
||||
return `${channelKey}:${id}`;
|
||||
}
|
||||
|
||||
function resultDateText(result) {
|
||||
const name = resultDisplayName(result);
|
||||
if (result?.imaging_date) return String(result.imaging_date);
|
||||
const matches = name.match(/(\d{8})/g);
|
||||
if (matches?.length >= 2) return `${matches[0]} / ${matches[1]}`;
|
||||
if (matches?.length === 1) return matches[0];
|
||||
return '-';
|
||||
}
|
||||
|
||||
function channelMetricLabel(channelKey) {
|
||||
return {
|
||||
dinsar: 'D-InSAR 可交付',
|
||||
sbas: 'SBAS 目录',
|
||||
lt1_ortho: 'LT-1 正射',
|
||||
gf3_ortho: 'GF3 正射',
|
||||
s1_ortho: 'Sentinel-1 正射',
|
||||
}[channelKey] || '可交付结果';
|
||||
}
|
||||
|
||||
function channelListTitle(channelKey) {
|
||||
return {
|
||||
dinsar: 'D-InSAR 结果列表',
|
||||
lt1_ortho: 'LT-1 正射结果列表',
|
||||
gf3_ortho: 'GF3 正射结果列表',
|
||||
}[channelKey] || '结果列表';
|
||||
}
|
||||
|
||||
function channelEmptyText(channelKey) {
|
||||
return {
|
||||
dinsar: '当前条件下没有可交付的 D-InSAR 结果。',
|
||||
lt1_ortho: '当前条件下没有可交付的 LT-1 正射结果。',
|
||||
gf3_ortho: '当前条件下没有可交付的 GF3 正射结果。',
|
||||
}[channelKey] || '当前条件下没有可交付结果。';
|
||||
}
|
||||
|
||||
function channelCreateTitle(channelKey) {
|
||||
return {
|
||||
dinsar: 'D-InSAR 成果交付',
|
||||
lt1_ortho: 'LT-1 正射成果交付',
|
||||
gf3_ortho: 'GF3 正射成果交付',
|
||||
}[channelKey] || '成果交付';
|
||||
}
|
||||
|
||||
function channelCreateSubtitle(channelKey) {
|
||||
return {
|
||||
dinsar: '选择已登记结果并生成下载包',
|
||||
lt1_ortho: '选择服务器已生产正射 GeoTIFF 并生成下载包',
|
||||
gf3_ortho: '选择已登记 GF3 SARscape 正射成品并生成下载包',
|
||||
}[channelKey] || '选择结果并生成下载包';
|
||||
}
|
||||
|
||||
function itemMetaText(item, channelKey) {
|
||||
if (channelKey === 'dinsar') {
|
||||
return `${resultDateText(item)} · ${item.pair_key || item.product_id || '-'}`;
|
||||
}
|
||||
const parts = [
|
||||
item.imaging_date || resultDateText(item),
|
||||
item.polarization,
|
||||
item.pixel_size_m ? `${item.pixel_size_m} m` : null,
|
||||
item.product_id,
|
||||
].filter(Boolean);
|
||||
return parts.join(' · ') || '-';
|
||||
}
|
||||
|
||||
function itemStatusText(item, channelKey) {
|
||||
if (channelKey === 'dinsar') {
|
||||
return item.is_cached ? '预览就绪' : '预览待建';
|
||||
}
|
||||
if (item.file_size) return formatBytes(item.file_size);
|
||||
return item.health_status || item.status || 'READY';
|
||||
}
|
||||
|
||||
function stateClass(state) {
|
||||
if (state === 'ready') return 'ready';
|
||||
if (state === 'planned') return 'planned';
|
||||
@@ -125,6 +198,8 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
const [activeChannel, setActiveChannel] = useState('dinsar');
|
||||
const [dinsarPayload, setDinsarPayload] = useState({ items: [], total: 0 });
|
||||
const [sbasPayload, setSbasPayload] = useState({ items: [], total: 0 });
|
||||
const [lt1Payload, setLt1Payload] = useState({ items: [], total: 0 });
|
||||
const [gf3Payload, setGf3Payload] = useState({ items: [], total: 0 });
|
||||
const [deliveriesPayload, setDeliveriesPayload] = useState({ items: [], total: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deliveryLoading, setDeliveryLoading] = useState(false);
|
||||
@@ -161,13 +236,21 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
listSbasInsarProducts({ limit: 30, offset: 0 }),
|
||||
listResultDeliveries({ mine: true, limit: 20, offset: 0 }),
|
||||
]);
|
||||
const [lt1Data, gf3Data] = await Promise.all([
|
||||
listResultDeliveryCatalog('lt1_ortho', { limit: PAGE_SIZE, offset: 0 }),
|
||||
listResultDeliveryCatalog('gf3_ortho', { limit: PAGE_SIZE, offset: 0 }),
|
||||
]);
|
||||
const dinsarItems = normalizeItems(dinsarData);
|
||||
setDinsarPayload({ ...dinsarData, items: dinsarItems, total: extractTotal(dinsarData, dinsarItems.length) });
|
||||
const sbasItems = normalizeItems(sbasData);
|
||||
setSbasPayload({ ...sbasData, items: sbasItems, total: extractTotal(sbasData, sbasItems.length) });
|
||||
const lt1Items = normalizeItems(lt1Data);
|
||||
setLt1Payload({ ...lt1Data, items: lt1Items, total: extractTotal(lt1Data, lt1Items.length) });
|
||||
const gf3Items = normalizeItems(gf3Data);
|
||||
setGf3Payload({ ...gf3Data, items: gf3Items, total: extractTotal(gf3Data, gf3Items.length) });
|
||||
const deliveryItems = normalizeItems(deliveryData);
|
||||
setDeliveriesPayload({ ...deliveryData, items: deliveryItems, total: extractTotal(deliveryData, deliveryItems.length) });
|
||||
setSelectedIds(new Set(dinsarItems.map(item => item.id).filter(id => id !== undefined && id !== null)));
|
||||
setSelectedIds(new Set(dinsarItems.map(item => resultItemId(item)).filter(id => id !== undefined && id !== null).map(id => selectionKey('dinsar', id))));
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.detail || err.message || '结果目录加载失败');
|
||||
} finally {
|
||||
@@ -191,41 +274,63 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [deliveriesPayload.items]);
|
||||
|
||||
const filteredDinsar = useMemo(() => {
|
||||
const activePayload = activeChannel === 'lt1_ortho'
|
||||
? lt1Payload
|
||||
: activeChannel === 'gf3_ortho'
|
||||
? gf3Payload
|
||||
: activeChannel === 'sbas'
|
||||
? sbasPayload
|
||||
: activeChannel === 's1_ortho'
|
||||
? { items: [], total: 0 }
|
||||
: dinsarPayload;
|
||||
const activeItems = activePayload.items || [];
|
||||
|
||||
const filteredResults = useMemo(() => {
|
||||
const value = query.trim().toLowerCase();
|
||||
const items = dinsarPayload.items || [];
|
||||
const items = activeItems;
|
||||
if (!value) return items;
|
||||
return items.filter(item => {
|
||||
const haystack = [
|
||||
item.display_name,
|
||||
item.name,
|
||||
item.task_name,
|
||||
item.task_alias,
|
||||
item.pair_key,
|
||||
item.product_id,
|
||||
item.engine_code,
|
||||
item.profile_code,
|
||||
item.imaging_date,
|
||||
item.polarization,
|
||||
item.file_path,
|
||||
item.primary_asset_path,
|
||||
item.publish_dir,
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.includes(value);
|
||||
});
|
||||
}, [dinsarPayload.items, query]);
|
||||
}, [activeItems, query]);
|
||||
|
||||
const filteredIds = useMemo(
|
||||
() => filteredDinsar.map(item => item.id).filter(id => id !== undefined && id !== null),
|
||||
[filteredDinsar],
|
||||
() => filteredResults.map(item => resultItemId(item)).filter(id => id !== undefined && id !== null),
|
||||
[filteredResults],
|
||||
);
|
||||
|
||||
const selectedCountInView = filteredIds.filter(id => selectedIds.has(id)).length;
|
||||
const filteredKeys = useMemo(
|
||||
() => filteredIds.map(id => selectionKey(activeChannel, id)),
|
||||
[activeChannel, filteredIds],
|
||||
);
|
||||
|
||||
const selectedCountInView = filteredKeys.filter(key => selectedIds.has(key)).length;
|
||||
const allVisibleSelected = filteredIds.length > 0 && selectedCountInView === filteredIds.length;
|
||||
const latestDelivery = deliveriesPayload.items?.[0] || null;
|
||||
|
||||
const orthoPlaceholderCount = PRODUCT_CHANNELS.filter(channel => channel.group === '正射成果').length;
|
||||
const currentCatalogTotal = Number(dinsarPayload.total || 0) + Number(sbasPayload.total || 0);
|
||||
const readyOrthoCount = Number(lt1Payload.total || 0) + Number(gf3Payload.total || 0);
|
||||
const currentCatalogTotal = Number(dinsarPayload.total || 0) + Number(sbasPayload.total || 0) + readyOrthoCount;
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: 'D-InSAR 可交付',
|
||||
value: dinsarPayload.total,
|
||||
note: `当前载入 ${filteredDinsar.length}/${dinsarPayload.items.length} 条`,
|
||||
label: channelMetricLabel(activeChannel),
|
||||
value: activePayload.total,
|
||||
note: `当前载入 ${filteredResults.length}/${activeItems.length} 条`,
|
||||
tone: 'primary',
|
||||
},
|
||||
{
|
||||
@@ -237,24 +342,25 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
{
|
||||
label: '当前接入目录',
|
||||
value: currentCatalogTotal,
|
||||
note: 'D-InSAR + SBAS 已接入清单',
|
||||
note: 'D-InSAR + SBAS + LT-1/GF3 正射',
|
||||
tone: 'neutral',
|
||||
},
|
||||
{
|
||||
label: '正射通道',
|
||||
value: orthoPlaceholderCount,
|
||||
note: 'LT-1 / S1 / GF3 占位',
|
||||
tone: 'warning',
|
||||
label: '可交付正射',
|
||||
value: readyOrthoCount,
|
||||
note: 'LT-1 与 GF3 已接入,S1 预留',
|
||||
tone: 'neutral',
|
||||
},
|
||||
];
|
||||
|
||||
const toggleOne = (id) => {
|
||||
const key = selectionKey(activeChannel, id);
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(id);
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -264,30 +370,35 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (allVisibleSelected) {
|
||||
filteredIds.forEach(id => next.delete(id));
|
||||
filteredKeys.forEach(key => next.delete(key));
|
||||
} else {
|
||||
filteredIds.forEach(id => next.add(id));
|
||||
filteredKeys.forEach(key => next.add(key));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateDelivery = async () => {
|
||||
const ids = [...selectedIds].filter(id => filteredIds.includes(id));
|
||||
const ids = filteredIds.filter(id => selectedIds.has(selectionKey(activeChannel, id)));
|
||||
if (ids.length === 0) {
|
||||
setCreateError('请至少选择一条 D-InSAR 结果。');
|
||||
setCreateError(`请至少选择一条${selectedChannel.label}。`);
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setCreateError('');
|
||||
setCreateResult(null);
|
||||
try {
|
||||
const response = await createResultDelivery({
|
||||
channel: 'dinsar',
|
||||
compat_result_ids: ids,
|
||||
const payload = {
|
||||
channel: activeChannel,
|
||||
package_mode: packageMode,
|
||||
include_checksums: includeChecksums,
|
||||
});
|
||||
};
|
||||
if (activeChannel === 'dinsar') {
|
||||
payload.compat_result_ids = ids;
|
||||
} else {
|
||||
payload.item_ids = ids;
|
||||
}
|
||||
const response = await createResultDelivery(payload);
|
||||
setCreateResult(response);
|
||||
await loadDeliveries();
|
||||
} catch (err) {
|
||||
@@ -353,12 +464,12 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderDinsarWorkspace = () => (
|
||||
const renderReadyWorkspace = () => (
|
||||
<section className="result-extraction-main-card">
|
||||
<div className="result-extraction-card-head">
|
||||
<div>
|
||||
<span>D-InSAR 成果交付</span>
|
||||
<strong>选择已登记结果并生成下载包</strong>
|
||||
<span>{channelCreateTitle(activeChannel)}</span>
|
||||
<strong>{channelCreateSubtitle(activeChannel)}</strong>
|
||||
</div>
|
||||
<button type="button" onClick={loadCatalogs} disabled={loading || creating}>
|
||||
刷新目录
|
||||
@@ -372,7 +483,7 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="任务名、日期、pair_key、引擎"
|
||||
placeholder="任务名、日期、产品号、极化、引擎"
|
||||
disabled={loading || creating}
|
||||
/>
|
||||
</label>
|
||||
@@ -424,36 +535,34 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
<div className="result-extraction-workspace-split">
|
||||
<div className="result-extraction-result-column">
|
||||
<div className="result-extraction-list-head">
|
||||
<span>结果列表</span>
|
||||
<strong>{selectedCountInView}/{filteredDinsar.length}</strong>
|
||||
<span>{channelListTitle(activeChannel)}</span>
|
||||
<strong>{selectedCountInView}/{filteredResults.length}</strong>
|
||||
</div>
|
||||
<div className="result-extraction-result-list">
|
||||
{loading ? (
|
||||
<div className="result-extraction-empty">正在加载成果目录...</div>
|
||||
) : filteredDinsar.length === 0 ? (
|
||||
<div className="result-extraction-empty">当前条件下没有可交付的 D-InSAR 结果。</div>
|
||||
) : filteredResults.length === 0 ? (
|
||||
<div className="result-extraction-empty">{channelEmptyText(activeChannel)}</div>
|
||||
) : (
|
||||
filteredDinsar.map(result => {
|
||||
const id = result.id;
|
||||
const engineMeta = getDinsarEngineMeta(result.engine_code);
|
||||
filteredResults.map(result => {
|
||||
const id = resultItemId(result);
|
||||
const engineMeta = activeChannel === 'dinsar'
|
||||
? getDinsarEngineMeta(result.engine_code)
|
||||
: { tone: 'landsar', shortLabel: result.engine_code || result.profile_code || 'ORTHO' };
|
||||
return (
|
||||
<label key={id} className="result-extraction-result-row">
|
||||
<label key={`${activeChannel}-${id}`} className="result-extraction-result-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(id)}
|
||||
checked={selectedIds.has(selectionKey(activeChannel, id))}
|
||||
onChange={() => toggleOne(id)}
|
||||
disabled={creating}
|
||||
/>
|
||||
<span className="result-extraction-result-main">
|
||||
<strong title={result.file_path || resultDisplayName(result)}>{resultDisplayName(result)}</strong>
|
||||
<span>
|
||||
{resultDateText(result)}
|
||||
{' · '}
|
||||
{result.pair_key || result.product_id || '-'}
|
||||
</span>
|
||||
<strong title={result.file_path || result.primary_asset_path || resultDisplayName(result)}>{resultDisplayName(result)}</strong>
|
||||
<span>{itemMetaText(result, activeChannel)}</span>
|
||||
</span>
|
||||
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>{engineMeta.shortLabel}</span>
|
||||
<span className="result-extraction-status-chip">{result.is_cached ? '预览就绪' : '预览待建'}</span>
|
||||
<span className="result-extraction-status-chip">{itemStatusText(result, activeChannel)}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
@@ -480,7 +589,7 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
</div>
|
||||
<div>
|
||||
<span>交付接口</span>
|
||||
<strong>{selectedChannel.key === 'dinsar' ? '已接入' : '待实现'}</strong>
|
||||
<strong>{selectedChannel.state === 'ready' ? '已接入' : '待实现'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>用户权限</span>
|
||||
@@ -513,13 +622,14 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
<span>成果交付出口</span>
|
||||
<strong>结果提取工作台</strong>
|
||||
<p>
|
||||
将 D-InSAR 成果交付下载和后续正射成果交付统一管理。当前 D-InSAR 支持后台交付包,
|
||||
SBAS、LT-1 正射、Sentinel-1 正射和 GF3 正射先保留清晰占位。
|
||||
将 D-InSAR、LT-1 正射和 GF3 正射成果交付下载统一管理。后台生成受控交付包,用户完成后下载到本地;
|
||||
SBAS 和 Sentinel-1 正射保留清晰接入状态。
|
||||
</p>
|
||||
</div>
|
||||
<div className="result-extraction-hero-meta">
|
||||
<span>D-InSAR {formatNumber(dinsarPayload.total)}</span>
|
||||
<span>SBAS {formatNumber(sbasPayload.total)}</span>
|
||||
<span>LT-1 {formatNumber(lt1Payload.total)}</span>
|
||||
<span>GF3 {formatNumber(gf3Payload.total)}</span>
|
||||
<span>{readOnly ? '自助交付' : '管理员'}</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -549,7 +659,7 @@ export default function ResultExtractionPanel({ readOnly = false }) {
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
{activeChannel === 'dinsar' ? renderDinsarWorkspace() : renderPlaceholderWorkspace()}
|
||||
{selectedChannel.state === 'ready' ? renderReadyWorkspace() : renderPlaceholderWorkspace()}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,9 @@ export const createResultDelivery = payload =>
|
||||
export const listResultDeliveries = (params = {}) =>
|
||||
apiClient.get('/result-deliveries', { params }).then(r => r.data);
|
||||
|
||||
export const listResultDeliveryCatalog = (channel, params = {}) =>
|
||||
apiClient.get(`/result-deliveries/catalog/${encodeURIComponent(channel)}`, { params }).then(r => r.data);
|
||||
|
||||
export const getResultDelivery = deliveryId =>
|
||||
apiClient.get(`/result-deliveries/${encodeURIComponent(deliveryId)}`).then(r => r.data);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user