feat:[FL-171][高程数据文件上传支持zip]

- 后端:elevation_file_record_service.py 新增 ZIP 文件上传支持
  - 导入 zipfile 和 IMPORTABLE_ARCHIVE_EXTENSIONS
  - create_file_record_from_upload 函数识别 .zip 扩展名
  - 新增 _create_file_records_from_zip 辅助函数处理 ZIP 解压
  - 自动为压缩包内每个有效的高程数据文件创建独立记录
  - 支持 .csv, .img, .tif, .tiff 格式的解压
  - 自动触发分析任务(如果启用)

- 前端:elevation-records/page.tsx 更新上传表单
  - Upload 组件 accept 属性新增 .zip
  - 更新提示文本说明支持 ZIP 压缩包自动解压

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-21 12:56:45 +08:00
parent 8e12b8a6e0
commit 40b57e7aa3
2 changed files with 173 additions and 14 deletions
+170 -11
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import csv import csv
import io import io
import mimetypes import mimetypes
import zipfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -28,6 +29,7 @@ from ..schemas.elevation import (
from .elevation_service import ( from .elevation_service import (
ELEVATION_FILE_EXT_FORMAT_MAP, ELEVATION_FILE_EXT_FORMAT_MAP,
IMPORTABLE_ELEVATION_EXTENSIONS, IMPORTABLE_ELEVATION_EXTENSIONS,
IMPORTABLE_ARCHIVE_EXTENSIONS,
RASTER_FILE_FORMATS, RASTER_FILE_FORMATS,
TERRAIN_SUPPORTED_DATASET_FORMATS, TERRAIN_SUPPORTED_DATASET_FORMATS,
_build_raster_preview, _build_raster_preview,
@@ -130,24 +132,19 @@ def create_file_record_from_upload(
*, *,
actor: User, actor: User,
) -> ElevationFileRecordUploadResponse: ) -> ElevationFileRecordUploadResponse:
"""Create a file record and upload the file in one operation.""" """Create a file record and upload the file in one operation. Supports ZIP files with automatic extraction."""
if not file.filename: if not file.filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件名不能为空") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件名不能为空")
filename = file.filename.strip() filename = file.filename.strip()
file_ext = Path(filename).suffix.lower() file_ext = Path(filename).suffix.lower()
if file_ext not in IMPORTABLE_ELEVATION_EXTENSIONS: if file_ext not in IMPORTABLE_ELEVATION_EXTENSIONS and file_ext not in IMPORTABLE_ARCHIVE_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的文件格式: {file_ext},仅支持 .csv/.img/.tif/.tiff" detail=f"不支持的文件格式: {file_ext},仅支持 .csv/.img/.tif/.tiff/.zip"
) )
file_format = ELEVATION_FILE_EXT_FORMAT_MAP.get(file_ext, "csv")
if file_format in RASTER_FILE_FORMATS:
_require_rasterio_available()
# Read file content # Read file content
try: try:
content = file.file.read() content = file.file.read()
@@ -165,13 +162,31 @@ def create_file_record_from_upload(
if not content: if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="上传文件为空") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="上传文件为空")
file_size = len(content) # Determine mount and storage
# Determine mount and storage path
mount_code = _resolve_dataset_mount_code(db, requested_mount_code=payload.mount_code) mount_code = _resolve_dataset_mount_code(db, requested_mount_code=payload.mount_code)
mount = _require_mount(db, mount_code) mount = _require_mount(db, mount_code)
driver = _build_driver_or_400(mount) driver = _build_driver_or_400(mount)
# Handle ZIP file extraction
if file_ext in IMPORTABLE_ARCHIVE_EXTENSIONS:
return _create_file_records_from_zip(
db=db,
zip_content=content,
zip_filename=filename,
payload=payload,
mount_code=mount_code,
driver=driver,
actor=actor,
)
# Handle single file upload (original logic)
file_format = ELEVATION_FILE_EXT_FORMAT_MAP.get(file_ext, "csv")
if file_format in RASTER_FILE_FORMATS:
_require_rasterio_available()
file_size = len(content)
# Generate unique storage path # Generate unique storage path
from uuid import uuid4 from uuid import uuid4
record_id = uuid4().hex record_id = uuid4().hex
@@ -248,6 +263,150 @@ def create_file_record_from_upload(
) )
def _create_file_records_from_zip(
db: Session,
zip_content: bytes,
zip_filename: str,
payload: ElevationFileRecordCreateRequest,
mount_code: str,
driver: Any,
actor: User,
) -> ElevationFileRecordUploadResponse:
"""Extract ZIP file and create file records for each contained elevation data file."""
warnings: list[str] = []
created_records: list[ElevationFileRecord] = []
try:
with zipfile.ZipFile(io.BytesIO(zip_content)) as archive:
for member in archive.infolist():
if member.is_dir():
continue
member_name = Path(member.filename).name
if not member_name:
warnings.append(f"压缩包条目 {member.filename} 文件名无效,已跳过")
continue
suffix = Path(member_name).suffix.lower()
if suffix not in IMPORTABLE_ELEVATION_EXTENSIONS:
warnings.append(f"压缩包条目 {member_name} 类型不支持,已跳过")
continue
try:
data = archive.read(member)
except Exception as exc:
warnings.append(f"压缩包条目 {member_name} 读取失败:{exc}")
continue
if not data:
warnings.append(f"压缩包条目 {member_name} 内容为空,已跳过")
continue
# Determine file format
file_format = ELEVATION_FILE_EXT_FORMAT_MAP.get(suffix, "csv")
if file_format in RASTER_FILE_FORMATS:
try:
_require_rasterio_available()
except HTTPException as exc:
warnings.append(f"压缩包条目 {member_name} 需要栅格处理但rasterio不可用:{exc.detail}")
continue
file_size = len(data)
# Generate unique storage path for each file
from uuid import uuid4
record_id = uuid4().hex
storage_dir = f"/elevation/records/{record_id[:2]}/{record_id[2:4]}"
storage_path = join_virtual_path(storage_dir, member_name)
# Ensure directory exists and write file
try:
driver.ensure_directory(storage_dir)
driver.write_file(
storage_path,
content=data,
content_type=mimetypes.guess_type(member_name)[0],
)
except Exception as exc:
warnings.append(f"压缩包条目 {member_name} 存储失败: {exc}")
continue
# Create database record
now = utcnow()
record = ElevationFileRecord(
id=record_id,
file_name=member_name,
file_path=storage_path,
file_format=file_format,
file_size=file_size,
source=_normalize_str(payload.source),
mount_code=mount_code,
resolution_m=payload.resolution_m,
status="active",
terrain_status=_default_terrain_status_for_format(file_format),
notes=_normalize_str(payload.notes),
create_date=now,
create_user=actor.id,
update_date=now,
update_user=actor.id,
)
db.add(record)
created_records.append(record)
except zipfile.BadZipFile as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"ZIP 文件损坏:{exc}"
) from exc
if not created_records:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ZIP 文件中没有找到有效的高程数据文件"
)
db.commit()
# Publish events and trigger analysis for each record
for record in created_records:
_publish_elevation_change(
"elevation.file_record.created",
{"action": "file_record_created", "file_record_id": record.id},
)
if payload.trigger_analysis:
try:
from ..tasks.elevation_tasks import analyze_elevation_file_record_job
task = analyze_elevation_file_record_job.delay(record.id, actor.id)
record.analysis_task_id = str(task.id)
record.analysis_status = "queued"
record.update_date = utcnow()
except Exception as exc:
warnings.append(f"文件 {record.file_name} 自动分析任务派发失败:{exc}")
db.commit()
# Return summary of first record (for consistency with existing API)
first_record = get_file_record_by_id(db, created_records[0].id)
if not first_record:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="文件记录创建失败"
)
detail = f"ZIP 文件已解压:成功创建 {len(created_records)} 个文件记录"
if warnings:
detail += f"{len(warnings)} 个警告"
return ElevationFileRecordUploadResponse(
record=serialize_file_record(first_record),
queued=payload.trigger_analysis,
detail=detail,
warnings=warnings,
)
def update_file_record( def update_file_record(
db: Session, db: Session,
record_id: str, record_id: str,
+3 -3
View File
@@ -943,7 +943,7 @@ export default function ElevationRecordsPage() {
> >
<Alert <Alert
message="上传即创建" message="上传即创建"
description="选择文件后立即上传并创建记录,自动触发分析任务。支持 CSV、IMG、TIF、TIFF 格式。" description="选择文件后立即上传并创建记录,自动触发分析任务。支持 CSV、IMG、TIF、TIFF 格式,也支持 ZIP 压缩包(自动解压)。"
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
@@ -956,7 +956,7 @@ export default function ElevationRecordsPage() {
<Form.Item <Form.Item
label="文件" label="文件"
required required
help="支持 .csv, .img, .tif, .tiff 格式" help="支持 .csv, .img, .tif, .tiff, .zip 格式"
> >
<Upload <Upload
fileList={fileList} fileList={fileList}
@@ -966,7 +966,7 @@ export default function ElevationRecordsPage() {
}} }}
onRemove={() => setFileList([])} onRemove={() => setFileList([])}
maxCount={1} maxCount={1}
accept=".csv,.img,.tif,.tiff" accept=".csv,.img,.tif,.tiff,.zip"
> >
<Button icon={<UploadOutlined />}></Button> <Button icon={<UploadOutlined />}></Button>
</Upload> </Upload>