diff --git a/backend/app/db_maintenance.py b/backend/app/db_maintenance.py index 64e7970..a0dbc66 100644 --- a/backend/app/db_maintenance.py +++ b/backend/app/db_maintenance.py @@ -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", ] diff --git a/backend/app/models/orm.py b/backend/app/models/orm.py index 325a47c..b89b7dd 100644 --- a/backend/app/models/orm.py +++ b/backend/app/models/orm.py @@ -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"), ) diff --git a/backend/app/routers/result_deliveries.py b/backend/app/routers/result_deliveries.py index 19e7362..877b83c 100644 --- a/backend/app/routers/result_deliveries.py +++ b/backend/app/routers/result_deliveries.py @@ -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, diff --git a/backend/app/services/result_delivery_service.py b/backend/app/services/result_delivery_service.py index bbbdd95..f52583d 100644 --- a/backend/app/services/result_delivery_service.py +++ b/backend/app/services/result_delivery_service.py @@ -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, diff --git a/backend/migrations/014_result_delivery_ortho_sources.sql b/backend/migrations/014_result_delivery_ortho_sources.sql new file mode 100644 index 0000000..403f8ac --- /dev/null +++ b/backend/migrations/014_result_delivery_ortho_sources.sql @@ -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); diff --git a/docs/RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md b/docs/RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md index 5ac5bd3..e047661 100644 --- a/docs/RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md +++ b/docs/RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md @@ -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 请求只排队任务,不再同步复制大文件。 - 交付完成后用户能下载到本地。 - 普通用户不能指定任意服务器目录。 diff --git a/frontend/src/ResultExtractionPanel.jsx b/frontend/src/ResultExtractionPanel.jsx index 9369428..7c09a6b 100644 --- a/frontend/src/ResultExtractionPanel.jsx +++ b/frontend/src/ResultExtractionPanel.jsx @@ -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 }) { ); - const renderDinsarWorkspace = () => ( + const renderReadyWorkspace = () => (
- D-InSAR 成果交付 - 选择已登记结果并生成下载包 + {channelCreateTitle(activeChannel)} + {channelCreateSubtitle(activeChannel)}
@@ -549,7 +659,7 @@ export default function ResultExtractionPanel({ readOnly = false }) { ))} - {activeChannel === 'dinsar' ? renderDinsarWorkspace() : renderPlaceholderWorkspace()} + {selectedChannel.state === 'ready' ? renderReadyWorkspace() : renderPlaceholderWorkspace()} ); diff --git a/frontend/src/api/resultDeliveries.js b/frontend/src/api/resultDeliveries.js index 3cb7357..658992a 100644 --- a/frontend/src/api/resultDeliveries.js +++ b/frontend/src/api/resultDeliveries.js @@ -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);