Enable LT1 and GF3 result delivery

This commit is contained in:
2026-06-30 17:12:15 +08:00
parent 04210b0551
commit ab2c9740b8
8 changed files with 702 additions and 113 deletions
+1
View File
@@ -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",
]
+6
View File
@@ -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"),
)
+24
View File
@@ -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,
+447 -36
View File
@@ -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(
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,
)
else:
sources = []
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(
max_items = int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)
sources = await self._resolve_sources_for_channel(
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)),
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),
)
else:
sources = []
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 请求只排队任务,不再同步复制大文件。
- 交付完成后用户能下载到本地。
- 普通用户不能指定任意服务器目录。
+170 -60
View File
@@ -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 支持后台交付包
SBASLT-1 正射Sentinel-1 正射和 GF3 正射先保留清晰占位
D-InSARLT-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>
);
+3
View File
@@ -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);