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:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user