Add result delivery workflow

This commit is contained in:
2026-06-30 15:54:26 +08:00
parent 9c80b95385
commit 04210b0551
18 changed files with 2021 additions and 98 deletions
+21
View File
@@ -77,6 +77,14 @@ def _default_result_publish_root(project_root: str) -> str:
return os.path.join(normalized_root, "production_results")
def _default_result_delivery_root(project_root: str) -> str:
normalized_root = os.path.normpath(project_root)
drive, _tail = os.path.splitdrive(normalized_root)
if drive:
return os.path.join(drive + os.sep, "Result_Delivery")
return os.path.join(normalized_root, "result_delivery")
def _default_input_root(project_root: str) -> str:
normalized_root = os.path.normpath(project_root)
drive, _tail = os.path.splitdrive(normalized_root)
@@ -313,6 +321,12 @@ class Settings(BaseSettings):
PSINSAR_PRODUCT_DIR: str = ""
RESULT_QUARANTINE_ROOT: str = ""
RESULT_CATALOG_AUTO_REBUILD_ON_STARTUP: bool = True
RESULT_DELIVERY_ROOT: str = ""
RESULT_DELIVERY_PUBLIC_BASE_URL: str = "/deliveries"
RESULT_DELIVERY_RETENTION_DAYS: int = 7
RESULT_DELIVERY_MAX_ITEMS: int = 500
RESULT_DELIVERY_ZIP_MAX_BYTES: int = 21474836480
RESULT_DELIVERY_CHECKSUM_ENABLED: bool = True
WSL_DISTRO: str = ""
WSL_SHARED_CONDA_ENV: str = ""
@@ -626,6 +640,12 @@ class Settings(BaseSettings):
"RESULT_QUARANTINE_ROOT",
os.path.join(self.RESULT_PUBLISH_ROOT, "_quarantine"),
)
if not self.RESULT_DELIVERY_ROOT:
object.__setattr__(
self,
"RESULT_DELIVERY_ROOT",
_default_result_delivery_root(project_root),
)
if not self.ISCE2_WORK_ROOT:
object.__setattr__(
self,
@@ -1088,6 +1108,7 @@ class Settings(BaseSettings):
os.makedirs(settings.TIMESERIES_PRODUCT_DIR, exist_ok=True)
os.makedirs(settings.PSINSAR_PRODUCT_DIR, exist_ok=True)
os.makedirs(settings.RESULT_QUARANTINE_ROOT, exist_ok=True)
os.makedirs(settings.RESULT_DELIVERY_ROOT, exist_ok=True)
os.makedirs(settings.SAR_ANALYSIS_READY_ROOT, exist_ok=True)
os.makedirs(settings.SAR_ANALYSIS_WORK_ROOT, exist_ok=True)
os.makedirs(settings.TASK_POOL_ROOT, exist_ok=True)
+1
View File
@@ -42,6 +42,7 @@ MIGRATION_FILES = [
"010_source_orbit_asset_inventory.sql",
"011_source_metadata_documents.sql",
"012_source_archive_integrity.sql",
"013_result_delivery_requests.sql",
]
+3
View File
@@ -10,6 +10,8 @@ from .orm import (
ResultAssetORM,
ResultIssueORM,
ResultCatalogStateORM,
ResultDeliveryRequestORM,
ResultDeliveryItemORM,
PairingCacheStateORM,
PairingDirtySceneORM,
PairingMetricCacheORM,
@@ -100,6 +102,7 @@ __all__ = [
"RadarDataORM", "DinsarResultORM", "HazardPointORM",
"ResultProductORM", "DinsarProductProfileORM", "ResultAssetORM",
"ResultIssueORM", "ResultCatalogStateORM",
"ResultDeliveryRequestORM", "ResultDeliveryItemORM",
"PairingCacheStateORM", "PairingDirtySceneORM", "PairingMetricCacheORM",
"PairingNetworkRunORM", "PairingNetworkEdgeORM",
"TimeseriesStackPlanORM", "TimeseriesStackPlanItemORM", "TimeseriesStackPlanEdgeORM",
+76
View File
@@ -322,6 +322,82 @@ class ResultCatalogStateORM(Base):
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class ResultDeliveryRequestORM(Base):
__tablename__ = "result_delivery_requests"
id = Column(Integer, primary_key=True, autoincrement=True)
delivery_id = Column(String(64), unique=True, index=True, nullable=False)
owner_user_id = Column(Integer, ForeignKey("auth_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_username = Column(String(64), index=True, nullable=False)
channel = Column(String(32), index=True, nullable=False)
status = Column(String(32), index=True, nullable=False, default="PENDING", server_default="PENDING")
package_mode = Column(String(16), nullable=False, default="directory", server_default="directory")
item_count = Column(Integer, nullable=False, default=0, server_default="0")
total_bytes = Column(BigInteger, nullable=False, default=0, server_default="0")
copied_bytes = Column(BigInteger, nullable=False, default=0, server_default="0")
delivery_root = Column(String, nullable=False)
delivery_dir = Column(String, nullable=False)
zip_path = Column(String, nullable=True)
manifest_path = Column(String, nullable=True)
expires_at = Column(DateTime, nullable=True, index=True)
task_id = Column(String(128), nullable=True, index=True)
job_id = Column(String(128), nullable=True, index=True)
error_message = Column(Text, nullable=True)
request_json = Column(JSON, nullable=True)
summary_json = Column(JSON, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
owner = relationship("AuthUserORM")
items = relationship(
"ResultDeliveryItemORM",
back_populates="delivery",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("idx_result_delivery_owner_status", "owner_user_id", "status"),
Index("idx_result_delivery_channel_status", "channel", "status"),
Index("idx_result_delivery_created", "created_at"),
)
class ResultDeliveryItemORM(Base):
__tablename__ = "result_delivery_items"
id = Column(Integer, primary_key=True, autoincrement=True)
delivery_id = Column(
String(64),
ForeignKey("result_delivery_requests.delivery_id", ondelete="CASCADE"),
index=True,
nullable=False,
)
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)
display_name = Column(String(255), nullable=False)
source_path = Column(String, nullable=False)
relative_path = Column(String, nullable=True)
file_size = Column(BigInteger, nullable=False, default=0, server_default="0")
checksum_sha256 = Column(String(64), nullable=True)
status = Column(String(32), index=True, nullable=False, default="PENDING", server_default="PENDING")
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
delivery = relationship("ResultDeliveryRequestORM", back_populates="items")
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])
__table_args__ = (
Index("idx_result_delivery_items_delivery_status", "delivery_id", "status"),
Index("idx_result_delivery_items_product", "source_product_id"),
)
class PairingCacheStateORM(Base):
__tablename__ = "pairing_cache_state"
+2
View File
@@ -21,6 +21,7 @@ from . import (
orbit,
pairing,
ps_products,
result_deliveries,
sbas_insar_products,
radar,
root_registry,
@@ -55,6 +56,7 @@ def include_all_routers(router: APIRouter) -> None:
router.include_router(pairing.router)
router.include_router(dinsar.router)
router.include_router(dinsar_products.router)
router.include_router(result_deliveries.router)
router.include_router(dinsar_production.router)
router.include_router(landsar_lt1_production.router)
router.include_router(sbas_insar_production.router)
+11 -1
View File
@@ -296,6 +296,12 @@ def _is_high_risk_write_path(path: str, method: str) -> bool:
return any(normalized.startswith(prefix) for prefix in HIGH_RISK_WRITE_PATH_PREFIXES)
def _is_user_self_service_write_path(path: str, method: str) -> bool:
normalized = _normalize_request_path(path)
upper_method = (method or "").upper()
return upper_method == "POST" and normalized == "/api/result-deliveries"
def _get_client_ip(request: Request) -> Optional[str]:
direct_ip = request.client.host if request.client else None
if direct_ip in _TRUSTED_PROXY_IPS:
@@ -400,7 +406,11 @@ async def _require_auth(request: Request, db: AsyncSession = Depends(get_db)):
await db.commit()
raise HTTPException(status_code=401, detail="Authentication required.")
if (not _is_read_only_operation(path, method)) and user.role != ROLE_ADMIN:
if (
(not _is_read_only_operation(path, method))
and user.role != ROLE_ADMIN
and not _is_user_self_service_write_path(path, method)
):
if _is_high_risk_write_path(path, method):
await add_audit_log(
db,
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import mimetypes
import os
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import AuthUserORM
from ..services.result_delivery_service import result_delivery_service
from .dependencies import _add_operation_audit_log, _get_current_user
router = APIRouter()
class ResultDeliveryCreateRequest(BaseModel):
channel: str
product_ids: Optional[List[int]] = None
compat_result_ids: Optional[List[int]] = None
package_mode: str = "directory"
include_checksums: Optional[bool] = None
@field_validator("channel", mode="before")
@classmethod
def _validate_channel(cls, value):
text = str(value or "").strip().lower()
if not text:
raise ValueError("channel is required")
return text
@field_validator("package_mode", mode="before")
@classmethod
def _validate_package_mode(cls, value):
text = str(value or "directory").strip().lower()
if text not in {"directory", "zip"}:
raise ValueError("package_mode must be directory or zip")
return text
@router.get("/result-deliveries/channels")
async def get_result_delivery_channels(
current_user: AuthUserORM = Depends(_get_current_user),
):
_ = current_user
return {"items": result_delivery_service.channels()}
@router.post("/result-deliveries", status_code=202)
async def create_result_delivery(
request: ResultDeliveryCreateRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
try:
delivery = await result_delivery_service.create_delivery(
db,
user=current_user,
channel=request.channel,
product_ids=request.product_ids,
compat_result_ids=request.compat_result_ids,
package_mode=request.package_mode,
include_checksums=request.include_checksums,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await _add_operation_audit_log(
db,
request=http_request,
action="result_delivery_created",
resource="result-deliveries",
detail={
"delivery_id": delivery.delivery_id,
"channel": delivery.channel,
"package_mode": delivery.package_mode,
"item_count": delivery.item_count,
"task_id": delivery.task_id,
"job_id": delivery.job_id,
},
user=current_user,
)
await db.commit()
return result_delivery_service.serialize_delivery(delivery)
@router.get("/result-deliveries")
async def list_result_deliveries(
mine: bool = True,
include_all: bool = False,
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
allow_all = bool(include_all) and str(current_user.role or "").lower() == "admin"
if mine:
allow_all = False
return await result_delivery_service.list_deliveries(
db,
user=current_user,
include_all=allow_all,
include_items=True,
item_limit=5,
limit=limit,
offset=offset,
)
@router.get("/result-deliveries/{delivery_id}")
async def get_result_delivery(
delivery_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
delivery = await result_delivery_service.get_delivery(
db,
delivery_id=delivery_id,
user=current_user,
include_items=True,
)
if delivery is None:
raise HTTPException(status_code=404, detail="delivery not found")
return result_delivery_service.serialize_delivery(delivery, include_items=True)
@router.get("/result-deliveries/{delivery_id}/manifest")
async def download_result_delivery_manifest(
delivery_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
try:
path = await result_delivery_service.resolve_manifest_path(
db,
delivery_id=delivery_id,
user=current_user,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return FileResponse(path, filename=os.path.basename(path), media_type="application/json")
@router.get("/result-deliveries/{delivery_id}/archive/download")
async def download_result_delivery_archive(
delivery_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
try:
path = await result_delivery_service.resolve_archive_path(
db,
delivery_id=delivery_id,
user=current_user,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return FileResponse(path, filename=os.path.basename(path), media_type="application/zip")
@router.get("/result-deliveries/{delivery_id}/files/{item_id}/download")
async def download_result_delivery_item(
delivery_id: str,
item_id: int,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
try:
path = await result_delivery_service.resolve_item_path(
db,
delivery_id=delivery_id,
item_id=item_id,
user=current_user,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
media_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
return FileResponse(path, filename=os.path.basename(path), media_type=media_type)
+26
View File
@@ -44,6 +44,7 @@ from .engine_lock_service import engine_lock_service
from .envi_service import build_envi_runner_command, extract_disp_results, get_envi_runner_cwd, get_envi_runner_env
from .psinsar_catalog_service import psinsar_catalog_service
from .result_catalog_service import result_catalog_service
from .result_delivery_service import JOB_TYPE_RESULT_DELIVERY_BUILD, result_delivery_service
from .sbas_insar_catalog_service import sbas_insar_catalog_service
from .task_service import task_service
from .timeseries_service import (
@@ -5160,6 +5161,30 @@ async def _handle_rebuild_dinsar_catalog_clean(job: SystemJobORM) -> None:
)
async def _handle_result_delivery_build(job: SystemJobORM) -> None:
if not job.task_id:
raise ValueError("RESULT_DELIVERY_BUILD requires task_id for progress tracking.")
payload = job.payload or {}
delivery_id = str(payload.get("delivery_id") or "").strip()
if not delivery_id:
raise ValueError("RESULT_DELIVERY_BUILD requires delivery_id payload.")
await task_service.start_task(job.task_id, message=f"正在生成成果交付包: {delivery_id}")
await task_service.add_log(job.task_id, "INFO", f"Result delivery build started: {delivery_id}")
result = await result_delivery_service.build_delivery(delivery_id)
summary = result.get("summary") or {}
await task_service.add_log(
job.task_id,
"INFO",
(
f"Result delivery build summary: delivery_id={delivery_id}, "
f"status={result.get('status')}, files={summary.get('file_count', 0)}, "
f"copied={summary.get('copied_files', 0)}, skipped={summary.get('skipped_files', 0)}, "
f"failed={summary.get('failed_items', 0)}, dir={result.get('delivery_dir')}"
),
)
async def _handle_timeseries_prepare(job: SystemJobORM) -> None:
if not job.task_id:
raise ValueError("TIMESERIES_PREPARE requires task_id for progress tracking.")
@@ -6206,6 +6231,7 @@ _HANDLERS = {
JOB_TYPE_EXTRACT_DINSAR_PRODUCTS: _handle_extract_dinsar_products,
JOB_TYPE_PUBLISH_DINSAR_PRODUCTS: _handle_publish_dinsar_products_clean,
JOB_TYPE_REBUILD_DINSAR_CATALOG: _handle_rebuild_dinsar_catalog_clean,
JOB_TYPE_RESULT_DELIVERY_BUILD: _handle_result_delivery_build,
JOB_TYPE_PAIRING_CACHE_REBUILD: _handle_pairing_cache_rebuild,
JOB_TYPE_TIMESERIES_PREPARE: _handle_timeseries_prepare,
JOB_TYPE_TIMESERIES_STACK_PREP: _handle_timeseries_stack_prep,
@@ -0,0 +1,907 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import zipfile
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from .. import database
from ..config import settings
from ..models import (
AuthUserORM,
DinsarResultORM,
ResultAssetORM,
ResultDeliveryItemORM,
ResultDeliveryRequestORM,
ResultProductORM,
)
from .dinsar_read_service import dinsar_read_service
from .job_queue_service import job_queue_service
from .task_service import task_service
JOB_TYPE_RESULT_DELIVERY_BUILD = "RESULT_DELIVERY_BUILD"
TASK_TYPE_RESULT_DELIVERY_BUILD = "RESULT_DELIVERY_BUILD"
DELIVERY_STATUS_PENDING = "PENDING"
DELIVERY_STATUS_RUNNING = "RUNNING"
DELIVERY_STATUS_READY = "READY"
DELIVERY_STATUS_FAILED = "FAILED"
DELIVERY_STATUS_CANCELLED = "CANCELLED"
DELIVERY_STATUS_EXPIRED = "EXPIRED"
ITEM_STATUS_PENDING = "PENDING"
ITEM_STATUS_COPIED = "COPIED"
ITEM_STATUS_FAILED = "FAILED"
ITEM_STATUS_SKIPPED = "SKIPPED"
CHANNEL_DINSAR = "dinsar"
SUPPORTED_READY_CHANNELS = {CHANNEL_DINSAR}
PACKAGE_MODE_DIRECTORY = "directory"
PACKAGE_MODE_ZIP = "zip"
_ASSOCIATED_EXTENSIONS = ("", ".hdr", ".sml", ".xml", ".aux.xml", ".prj")
_INVALID_SEGMENT_RE = re.compile(r'[<>:"/\\|?*\x00-\x1F]+')
_SAFE_ID_RE = re.compile(r"[^0-9A-Za-z_-]+")
@dataclass(frozen=True)
class DeliverySource:
product: ResultProductORM
compat_row: Optional[DinsarResultORM]
display_name: str
source_path: str
source_asset_id: Optional[int] = None
def _new_session() -> AsyncSession:
if database.AsyncSessionLocal is None:
database.init_db()
if database.AsyncSessionLocal is None:
raise RuntimeError("Database session factory is not initialized.")
return database.AsyncSessionLocal()
def _utcnow() -> datetime:
return datetime.utcnow()
def _normalize_channel(value: str) -> str:
channel = str(value or "").strip().lower()
if not channel:
raise ValueError("channel is required")
return channel
def _normalize_package_mode(value: str) -> str:
mode = str(value or PACKAGE_MODE_DIRECTORY).strip().lower()
if mode not in {PACKAGE_MODE_DIRECTORY, PACKAGE_MODE_ZIP}:
raise ValueError("package_mode must be directory or zip")
return mode
def _normalize_int_ids(values: Optional[Iterable[Any]], *, max_count: int) -> List[int]:
normalized: List[int] = []
seen = set()
for raw in values or []:
try:
value = int(raw)
except (TypeError, ValueError):
continue
if value <= 0 or value in seen:
continue
seen.add(value)
normalized.append(value)
if len(normalized) > max_count:
raise ValueError(f"selected item count exceeds max limit ({max_count})")
return normalized
def _sanitize_segment(value: Any, *, default: str = "item", max_len: int = 120) -> str:
text = str(value or "").strip()
text = _INVALID_SEGMENT_RE.sub("_", text).strip(" .")
text = _SAFE_ID_RE.sub("_", text)
return (text or default)[:max_len]
def _same_file_size(left: str, right: str) -> bool:
try:
return os.path.getsize(left) == os.path.getsize(right)
except OSError:
return False
def _file_size(path: str) -> int:
try:
return int(os.path.getsize(path))
except OSError:
return 0
def _sha256_file(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _write_json(path: str, payload: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as stream:
json.dump(payload, stream, ensure_ascii=False, indent=2, default=str)
def _path_within(parent: str, child: str) -> bool:
parent_path = Path(parent).resolve()
child_path = Path(child).resolve()
try:
child_path.relative_to(parent_path)
return True
except ValueError:
return False
def _iter_associated_files(source_path: str) -> List[str]:
source = os.path.normpath(source_path)
if os.path.isdir(source):
files: List[str] = []
for root, _dirs, names in os.walk(source):
for name in names:
files.append(os.path.join(root, name))
return sorted(files)
if not os.path.isfile(source):
return []
src_dir = os.path.dirname(source)
base_name = os.path.basename(source)
files: List[str] = []
for ext in _ASSOCIATED_EXTENSIONS:
candidate = os.path.join(src_dir, base_name + ext) if ext else source
if os.path.isfile(candidate) and candidate not in files:
files.append(candidate)
return files
def _delivery_root() -> str:
root = str(settings.RESULT_DELIVERY_ROOT or "").strip()
if not root:
raise ValueError("RESULT_DELIVERY_ROOT is not configured")
return os.path.normpath(os.path.abspath(root))
def _owner_dir(root: str, username: str) -> str:
return os.path.join(root, _sanitize_segment(username, default="user", max_len=64))
def _make_delivery_id(username: str) -> str:
stamp = _utcnow().strftime("%Y%m%d%H%M%S")
digest = hashlib.sha1(f"{username}|{stamp}|{os.urandom(8).hex()}".encode("utf-8")).hexdigest()[:10]
return f"rd_{stamp}_{digest}"
def _resolve_source_path(product: ResultProductORM, assets: List[ResultAssetORM]) -> tuple[str, Optional[int]]:
primary_assets = [
asset for asset in assets
if asset.exists_flag and (asset.is_primary or str(asset.asset_role or "").lower() in {"disp", "primary_geotiff"})
]
primary_assets.sort(key=lambda item: (not item.is_primary, item.id))
for asset in primary_assets:
path = str(asset.absolute_path or "").strip()
if path:
return path, asset.id
for path in (product.primary_asset_path, product.source_primary_path, product.publish_dir):
text = str(path or "").strip()
if text:
return text, None
return "", None
class ResultDeliveryService:
def channels(self) -> List[Dict[str, Any]]:
return [
{
"key": CHANNEL_DINSAR,
"group": "InSAR 成果",
"label": "D-InSAR 结果",
"state": "ready",
"state_text": "可交付",
"description": "已登记 D-InSAR catalog,可创建后台交付包并下载到本地。",
},
{
"key": "sbas",
"group": "InSAR 成果",
"label": "SBAS-InSAR 结果",
"state": "planned",
"state_text": "目录可查",
"description": "SBAS 结果 catalog 已有基础能力,本阶段暂不开放交付打包。",
},
{
"key": "lt1_ortho",
"group": "正射成果",
"label": "LT-1 正射结果",
"state": "placeholder",
"state_text": "待接入",
"description": "陆探一正射生产将由 LandSAR 生产链注册后接入统一交付。",
},
{
"key": "s1_ortho",
"group": "正射成果",
"label": "Sentinel-1 正射结果",
"state": "placeholder",
"state_text": "待接入",
"description": "Sentinel-1 正射生产尚未接入,当前只保留交付通道占位。",
},
{
"key": "gf3_ortho",
"group": "正射成果",
"label": "GF3 SARscape _geo",
"state": "placeholder",
"state_text": "待接入",
"description": "GF3 外部生产成果登记后再接入统一交付。",
},
]
async def _resolve_dinsar_sources(
self,
db: AsyncSession,
*,
product_ids: List[int],
compat_result_ids: List[int],
) -> List[DeliverySource]:
sources: List[DeliverySource] = []
seen_products = set()
if product_ids:
result = await db.execute(
select(ResultProductORM)
.options(selectinload(ResultProductORM.assets))
.where(
ResultProductORM.id.in_(product_ids),
ResultProductORM.catalog_name == CHANNEL_DINSAR,
ResultProductORM.status.in_(["READY", "PUBLISHED", "OK"]),
)
.order_by(ResultProductORM.id.asc())
)
for product in result.scalars().unique().all():
assets = list(product.assets or [])
source_path, source_asset_id = _resolve_source_path(product, assets)
if not source_path:
continue
seen_products.add(int(product.id))
sources.append(
DeliverySource(
product=product,
compat_row=None,
display_name=dinsar_read_service.get_display_name(product),
source_path=source_path,
source_asset_id=source_asset_id,
)
)
if compat_result_ids:
records = await dinsar_read_service.list_compat_records_by_ids(
db,
compat_result_ids=compat_result_ids,
)
product_lookup_ids = [record.product.id for record in records if record.product is not None]
assets_by_product: Dict[int, List[ResultAssetORM]] = {}
if product_lookup_ids:
asset_result = await db.execute(
select(ResultAssetORM)
.where(ResultAssetORM.product_ref_id.in_(product_lookup_ids))
.order_by(ResultAssetORM.id.asc())
)
for asset in asset_result.scalars().all():
assets_by_product.setdefault(int(asset.product_ref_id), []).append(asset)
for record in records:
product = record.product
if int(product.id) in seen_products:
continue
assets = assets_by_product.get(int(product.id), [])
source_path, source_asset_id = _resolve_source_path(product, assets)
if not source_path and record.compat_row is not None:
source_path = str(record.compat_row.file_path or "").strip()
if not source_path:
continue
seen_products.add(int(product.id))
sources.append(
DeliverySource(
product=product,
compat_row=record.compat_row,
display_name=record.display_name,
source_path=source_path,
source_asset_id=source_asset_id,
)
)
return sources
async def create_delivery(
self,
db: AsyncSession,
*,
user: AuthUserORM,
channel: str,
product_ids: Optional[List[int]] = None,
compat_result_ids: Optional[List[int]] = None,
package_mode: str = PACKAGE_MODE_DIRECTORY,
include_checksums: Optional[bool] = None,
) -> ResultDeliveryRequestORM:
channel = _normalize_channel(channel)
if channel not in SUPPORTED_READY_CHANNELS:
raise ValueError(f"{channel} delivery is not connected yet")
package_mode = _normalize_package_mode(package_mode)
max_items = max(1, int(settings.RESULT_DELIVERY_MAX_ITEMS or 500))
normalized_product_ids = _normalize_int_ids(product_ids, max_count=max_items)
normalized_compat_ids = _normalize_int_ids(compat_result_ids, max_count=max_items)
if not normalized_product_ids and not normalized_compat_ids:
raise ValueError("select at least one result")
if len(normalized_product_ids) + len(normalized_compat_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 = []
if not sources:
raise ValueError("selected results do not have deliverable files")
if len(sources) > max_items:
raise ValueError(f"resolved result count exceeds max limit ({max_items})")
delivery_root = _delivery_root()
delivery_id = _make_delivery_id(str(user.username or "user"))
delivery_dir = os.path.join(_owner_dir(delivery_root, str(user.username or "user")), delivery_id)
expires_at = _utcnow() + timedelta(days=max(1, int(settings.RESULT_DELIVERY_RETENTION_DAYS or 7)))
request_json = {
"channel": channel,
"product_ids": normalized_product_ids,
"compat_result_ids": normalized_compat_ids,
"package_mode": package_mode,
"include_checksums": (
bool(settings.RESULT_DELIVERY_CHECKSUM_ENABLED)
if include_checksums is None
else bool(include_checksums)
),
}
delivery = ResultDeliveryRequestORM(
delivery_id=delivery_id,
owner_user_id=user.id,
owner_username=str(user.username or "unknown"),
channel=channel,
status=DELIVERY_STATUS_PENDING,
package_mode=package_mode,
item_count=len(sources),
total_bytes=0,
copied_bytes=0,
delivery_root=delivery_root,
delivery_dir=delivery_dir,
zip_path=(f"{delivery_dir}.zip" if package_mode == PACKAGE_MODE_ZIP else None),
expires_at=expires_at,
request_json=request_json,
summary_json={
"source_count": len(sources),
"created_message": "Delivery package queued.",
},
)
db.add(delivery)
await db.flush()
task_type = f"{TASK_TYPE_RESULT_DELIVERY_BUILD}_{delivery_id}"
task_id = await task_service.create_task(
task_type,
f"成果交付包生成: {delivery_id}",
params={
"delivery_id": delivery_id,
"channel": channel,
"item_count": len(sources),
"package_mode": package_mode,
},
db=db,
)
job_id = await job_queue_service.create_job(
JOB_TYPE_RESULT_DELIVERY_BUILD,
payload={"delivery_id": delivery_id},
task_id=task_id,
max_attempts=1,
db=db,
)
delivery.task_id = task_id
delivery.job_id = job_id
await db.flush()
return delivery
async def list_deliveries(
self,
db: AsyncSession,
*,
user: AuthUserORM,
include_all: bool = False,
include_items: bool = False,
item_limit: Optional[int] = None,
limit: int = 50,
offset: int = 0,
) -> Dict[str, Any]:
safe_limit = min(200, max(1, int(limit or 50)))
safe_offset = max(0, int(offset or 0))
stmt = select(ResultDeliveryRequestORM)
if include_items:
stmt = stmt.options(selectinload(ResultDeliveryRequestORM.items))
count_stmt = select(func.count(ResultDeliveryRequestORM.id))
if not include_all:
owner_filter = ResultDeliveryRequestORM.owner_user_id == user.id
stmt = stmt.where(owner_filter)
count_stmt = count_stmt.where(owner_filter)
stmt = (
stmt.order_by(ResultDeliveryRequestORM.created_at.desc(), ResultDeliveryRequestORM.id.desc())
.offset(safe_offset)
.limit(safe_limit)
)
result = await db.execute(stmt)
total_result = await db.execute(count_stmt)
deliveries = result.scalars().unique().all() if include_items else result.scalars().all()
items = [
self.serialize_delivery(item, include_items=include_items, item_limit=item_limit)
for item in deliveries
]
total = int(total_result.scalar_one() or 0)
return {
"items": items,
"total": total,
"limit": safe_limit,
"offset": safe_offset,
"has_more": safe_offset + len(items) < total,
}
async def get_delivery(
self,
db: AsyncSession,
*,
delivery_id: str,
user: AuthUserORM,
include_items: bool = True,
) -> Optional[ResultDeliveryRequestORM]:
stmt = select(ResultDeliveryRequestORM).where(
ResultDeliveryRequestORM.delivery_id == str(delivery_id or "").strip()
)
if include_items:
stmt = stmt.options(selectinload(ResultDeliveryRequestORM.items))
if str(user.role or "").lower() != "admin":
stmt = stmt.where(ResultDeliveryRequestORM.owner_user_id == user.id)
result = await db.execute(stmt)
return result.scalars().unique().one_or_none()
def serialize_delivery(
self,
delivery: ResultDeliveryRequestORM,
*,
include_items: bool = False,
item_limit: Optional[int] = None,
) -> Dict[str, Any]:
items = list(delivery.items or []) if include_items else []
if item_limit is not None:
items = items[: max(0, int(item_limit))]
summary = delivery.summary_json if isinstance(delivery.summary_json, dict) else {}
payload = {
"id": delivery.id,
"delivery_id": delivery.delivery_id,
"owner_user_id": delivery.owner_user_id,
"owner_username": delivery.owner_username,
"channel": delivery.channel,
"status": delivery.status,
"package_mode": delivery.package_mode,
"item_count": delivery.item_count,
"total_bytes": delivery.total_bytes,
"copied_bytes": delivery.copied_bytes,
"delivery_dir": delivery.delivery_dir,
"zip_path": delivery.zip_path,
"manifest_path": delivery.manifest_path,
"expires_at": delivery.expires_at,
"task_id": delivery.task_id,
"job_id": delivery.job_id,
"error_message": delivery.error_message,
"summary": summary,
"created_at": delivery.created_at,
"updated_at": delivery.updated_at,
"started_at": delivery.started_at,
"completed_at": delivery.completed_at,
"download_urls": {
"manifest": f"/api/result-deliveries/{delivery.delivery_id}/manifest",
"archive": (
f"/api/result-deliveries/{delivery.delivery_id}/archive/download"
if delivery.zip_path
else None
),
},
}
if include_items:
payload["items"] = [self.serialize_item(item, delivery_id=delivery.delivery_id) for item in items]
return payload
def serialize_item(self, item: ResultDeliveryItemORM, *, delivery_id: str) -> Dict[str, Any]:
return {
"id": item.id,
"delivery_id": item.delivery_id,
"source_product_id": item.source_product_id,
"source_result_id": item.source_result_id,
"source_asset_id": item.source_asset_id,
"display_name": item.display_name,
"relative_path": item.relative_path,
"file_size": item.file_size,
"checksum_sha256": item.checksum_sha256,
"status": item.status,
"error_message": item.error_message,
"download_url": (
f"/api/result-deliveries/{delivery_id}/files/{item.id}/download"
if item.status == ITEM_STATUS_COPIED
else None
),
}
async def build_delivery(self, delivery_id: str) -> Dict[str, Any]:
async with _new_session() as db:
result = await db.execute(
select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id)
)
delivery = result.scalar_one_or_none()
if delivery is None:
raise ValueError(f"delivery not found: {delivery_id}")
if delivery.status in {DELIVERY_STATUS_READY, DELIVERY_STATUS_CANCELLED, DELIVERY_STATUS_EXPIRED}:
return self.serialize_delivery(delivery)
delivery.status = DELIVERY_STATUS_RUNNING
delivery.started_at = _utcnow()
delivery.error_message = None
await db.commit()
try:
summary = await self._build_delivery_files(delivery_id)
except Exception as exc:
async with _new_session() as db:
result = await db.execute(
select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id)
)
delivery = result.scalar_one_or_none()
if delivery is not None:
delivery.status = DELIVERY_STATUS_FAILED
delivery.error_message = str(exc)
delivery.completed_at = _utcnow()
delivery.summary_json = {
**(delivery.summary_json if isinstance(delivery.summary_json, dict) else {}),
"error": str(exc),
}
await db.commit()
if delivery.task_id:
await task_service.update_task(delivery.task_id, status="FAILED", progress=100, message=str(exc))
raise
async with _new_session() as db:
result = await db.execute(
select(ResultDeliveryRequestORM)
.options(selectinload(ResultDeliveryRequestORM.items))
.where(ResultDeliveryRequestORM.delivery_id == delivery_id)
)
delivery = result.scalars().unique().one_or_none()
if delivery is None:
raise ValueError(f"delivery not found: {delivery_id}")
available_files = int(summary.get("copied_files", 0) or 0) + int(summary.get("skipped_files", 0) or 0)
delivery.status = DELIVERY_STATUS_READY if available_files > 0 else DELIVERY_STATUS_FAILED
delivery.completed_at = _utcnow()
delivery.error_message = summary.get("error_message")
delivery.summary_json = summary
await db.commit()
if delivery.task_id:
if delivery.status == DELIVERY_STATUS_READY:
await task_service.update_task(
delivery.task_id,
status="COMPLETED",
progress=100,
message=(
f"成果交付包已生成: files={summary.get('copied_files', 0)}, "
f"bytes={summary.get('copied_bytes', 0)}"
),
)
else:
await task_service.update_task(
delivery.task_id,
status="FAILED",
progress=100,
message=summary.get("error_message") or "成果交付包生成失败",
)
return self.serialize_delivery(delivery, include_items=True)
async def _build_delivery_files(self, delivery_id: str) -> Dict[str, Any]:
async with _new_session() as db:
result = await db.execute(
select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id)
)
delivery = result.scalar_one()
request_json = delivery.request_json if isinstance(delivery.request_json, dict) else {}
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 = []
task_id = delivery.task_id
if not sources:
raise ValueError("delivery has no deliverable sources")
include_checksums = bool(
request_json.get("include_checksums")
if "include_checksums" in request_json
else settings.RESULT_DELIVERY_CHECKSUM_ENABLED
)
delivery_dir = os.path.normpath(os.path.abspath(delivery.delivery_dir))
root = os.path.normpath(os.path.abspath(delivery.delivery_root))
if not _path_within(root, delivery_dir):
raise ValueError("delivery directory is outside RESULT_DELIVERY_ROOT")
os.makedirs(delivery_dir, exist_ok=True)
copied_files = 0
skipped_files = 0
failed_items = 0
copied_bytes = 0
total_bytes = 0
checksum_lines: List[str] = []
item_payloads: List[Dict[str, Any]] = []
manifest_items: List[Dict[str, Any]] = []
total_sources = len(sources)
for source_index, source in enumerate(sources, start=1):
folder = _sanitize_segment(
source.product.task_alias
or source.product.task_name
or source.display_name
or source.product.product_id,
default=f"product_{source_index}",
)
source_files = _iter_associated_files(source.source_path)
if not source_files:
failed_items += 1
item_payloads.append(
{
"delivery_id": delivery_id,
"source_product_id": source.product.id,
"source_result_id": source.compat_row.id if source.compat_row else None,
"source_asset_id": source.source_asset_id,
"display_name": source.display_name,
"source_path": source.source_path,
"relative_path": None,
"file_size": 0,
"checksum_sha256": None,
"status": ITEM_STATUS_FAILED,
"error_message": "source file not found",
}
)
continue
for file_index, source_file in enumerate(source_files, start=1):
if os.path.isdir(source.source_path):
relative_under_source = os.path.relpath(source_file, source.source_path)
relative_path = os.path.join(folder, relative_under_source)
else:
relative_path = os.path.join(folder, os.path.basename(source_file))
target_path = os.path.join(delivery_dir, relative_path)
size = _file_size(source_file)
total_bytes += size
status = ITEM_STATUS_COPIED
error_message = None
checksum = None
try:
os.makedirs(os.path.dirname(target_path), exist_ok=True)
if os.path.isfile(target_path) and _same_file_size(source_file, target_path):
skipped_files += 1
else:
shutil.copy2(source_file, target_path)
copied_files += 1
copied_bytes += size
if include_checksums and os.path.isfile(target_path):
checksum = _sha256_file(target_path)
checksum_lines.append(f"{checksum} {relative_path.replace(os.sep, '/')}")
except OSError as exc:
status = ITEM_STATUS_FAILED
error_message = str(exc)
failed_items += 1
if task_id and (copied_files + skipped_files + failed_items) % 10 == 0:
progress = min(95, int(((source_index - 1) / max(1, total_sources)) * 100))
await task_service.update_task(
task_id,
progress=progress,
message=f"成果交付包生成中: {source_index}/{total_sources}",
)
item_payload = {
"delivery_id": delivery_id,
"source_product_id": source.product.id,
"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,
"display_name": source.display_name,
"source_path": source_file,
"relative_path": relative_path,
"file_size": size,
"checksum_sha256": checksum,
"status": status,
"error_message": error_message,
}
item_payloads.append(item_payload)
manifest_items.append(
{
"display_name": source.display_name,
"product_id": source.product.product_id,
"product_ref_id": source.product.id,
"source_result_id": source.compat_row.id if source.compat_row else None,
"relative_path": relative_path.replace(os.sep, "/"),
"file_size": size,
"checksum_sha256": checksum,
"status": status,
"error_message": error_message,
}
)
checksums_path = os.path.join(delivery_dir, "checksums.sha256")
if include_checksums:
with open(checksums_path, "w", encoding="utf-8") as stream:
stream.write("\n".join(checksum_lines))
if checksum_lines:
stream.write("\n")
manifest_path = os.path.join(delivery_dir, "manifest.json")
summary = {
"delivery_id": delivery_id,
"channel": delivery.channel,
"package_mode": delivery.package_mode,
"source_count": total_sources,
"file_count": len(item_payloads),
"copied_files": copied_files,
"skipped_files": skipped_files,
"failed_items": failed_items,
"total_bytes": total_bytes,
"copied_bytes": copied_bytes,
"include_checksums": include_checksums,
"manifest_path": manifest_path,
"checksums_path": checksums_path if include_checksums else None,
"created_at": _utcnow().isoformat(timespec="seconds"),
}
if failed_items:
summary["error_message"] = f"{failed_items} files failed during delivery build"
_write_json(
manifest_path,
{
"schema_version": "insar.result-delivery/v1",
"summary": summary,
"items": manifest_items,
},
)
zip_path = None
if delivery.package_mode == PACKAGE_MODE_ZIP:
if total_bytes > int(settings.RESULT_DELIVERY_ZIP_MAX_BYTES or 0):
raise ValueError(
f"delivery size exceeds zip limit: {total_bytes} > {settings.RESULT_DELIVERY_ZIP_MAX_BYTES}"
)
zip_path = str(delivery.zip_path or f"{delivery_dir}.zip")
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
for root_dir, _dirs, files in os.walk(delivery_dir):
for name in files:
path = os.path.join(root_dir, name)
archive.write(path, os.path.relpath(path, delivery_dir))
async with _new_session() as db:
result = await db.execute(
select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id)
)
delivery_row = result.scalar_one()
await db.execute(
ResultDeliveryItemORM.__table__.delete().where(
ResultDeliveryItemORM.delivery_id == delivery_id
)
)
for payload in item_payloads:
db.add(ResultDeliveryItemORM(**payload))
delivery_row.total_bytes = total_bytes
delivery_row.copied_bytes = copied_bytes
delivery_row.item_count = len(item_payloads)
delivery_row.manifest_path = manifest_path
delivery_row.zip_path = zip_path or delivery_row.zip_path
await db.commit()
return summary
async def resolve_manifest_path(
self,
db: AsyncSession,
*,
delivery_id: str,
user: AuthUserORM,
) -> str:
delivery = await self.get_delivery(db, delivery_id=delivery_id, user=user, include_items=False)
if delivery is None:
raise ValueError("delivery not found")
if delivery.status != DELIVERY_STATUS_READY:
raise ValueError("delivery is not ready")
path = str(delivery.manifest_path or "").strip()
if not path or not os.path.isfile(path):
raise ValueError("manifest file is missing")
if not _path_within(delivery.delivery_root, path):
raise ValueError("manifest path is outside delivery root")
return path
async def resolve_archive_path(
self,
db: AsyncSession,
*,
delivery_id: str,
user: AuthUserORM,
) -> str:
delivery = await self.get_delivery(db, delivery_id=delivery_id, user=user, include_items=False)
if delivery is None:
raise ValueError("delivery not found")
if delivery.status != DELIVERY_STATUS_READY:
raise ValueError("delivery is not ready")
path = str(delivery.zip_path or "").strip()
if not path or not os.path.isfile(path):
raise ValueError("zip archive is not available")
if not _path_within(delivery.delivery_root, path):
raise ValueError("zip path is outside delivery root")
return path
async def resolve_item_path(
self,
db: AsyncSession,
*,
delivery_id: str,
item_id: int,
user: AuthUserORM,
) -> str:
delivery = await self.get_delivery(db, delivery_id=delivery_id, user=user, include_items=False)
if delivery is None:
raise ValueError("delivery not found")
if delivery.status != DELIVERY_STATUS_READY:
raise ValueError("delivery is not ready")
result = await db.execute(
select(ResultDeliveryItemORM).where(
ResultDeliveryItemORM.id == int(item_id),
ResultDeliveryItemORM.delivery_id == delivery_id,
)
)
item = result.scalar_one_or_none()
if item is None:
raise ValueError("delivery item not found")
if item.status != ITEM_STATUS_COPIED:
raise ValueError("delivery item is not available")
relative_path = str(item.relative_path or "").strip()
if not relative_path:
raise ValueError("delivery item path is missing")
path = os.path.normpath(os.path.abspath(os.path.join(delivery.delivery_dir, relative_path)))
if not os.path.isfile(path):
raise ValueError("delivery item file is missing")
if not _path_within(delivery.delivery_dir, path):
raise ValueError("delivery item path is outside delivery directory")
return path
result_delivery_service = ResultDeliveryService()