feat: [FL-104][高程数据管理中文件明细要展示各个文件的坐标范围]

- 添加 ElevationDatasetFileMeta 数据库模型存储文件级别坐标范围
- 更新 API schema 和 service,返回每个文件的 bbox 信息
- 修改高程数据分析任务,遍历目录所有文件并提取坐标范围
- 前端文件明细表格新增坐标范围列
- 创建数据库迁移脚本

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-13 07:58:43 +08:00
parent 4905064c3a
commit 07735fb23f
6 changed files with 262 additions and 9 deletions
+35
View File
@@ -163,3 +163,38 @@ class ElevationDataImportJob(Base):
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
dataset: Mapped[ElevationDataset] = relationship("ElevationDataset", lazy="selectin")
class ElevationDatasetFileMeta(Base):
__tablename__ = "elevation_dataset_file_meta"
__table_args__ = (
Index("idx_elevation_file_meta_dataset", "dataset_id"),
Index("idx_elevation_file_meta_path", "dataset_id", "file_path"),
)
id: Mapped[str] = mapped_column(
String(32),
primary_key=True,
default=lambda: uuid4().hex,
)
dataset_id: Mapped[str] = mapped_column(
String(32),
ForeignKey("elevation_dataset.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
file_path: Mapped[str] = mapped_column(String(2048), nullable=False)
file_name: Mapped[str] = mapped_column(String(512), nullable=False)
bbox_min_lon: Mapped[float | None] = mapped_column(Float)
bbox_max_lon: Mapped[float | None] = mapped_column(Float)
bbox_min_lat: Mapped[float | None] = mapped_column(Float)
bbox_max_lat: Mapped[float | None] = mapped_column(Float)
sample_count: Mapped[int] = mapped_column(Integer, default=0)
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
update_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
dataset: Mapped[ElevationDataset] = relationship("ElevationDataset", lazy="selectin")
+4
View File
@@ -165,6 +165,10 @@ class ElevationDatasetFileItem(BaseModel):
size: int
modified_at: datetime | None = None
mime_type: str | None = None
bbox_min_lon: float | None = None
bbox_max_lon: float | None = None
bbox_min_lat: float | None = None
bbox_max_lat: float | None = None
class ElevationDatasetFileListResponse(BaseModel):
+173 -4
View File
@@ -19,7 +19,7 @@ from sqlalchemy.orm import Session
from ..core.database import SessionLocal
from ..models.base import utcnow
from ..models.elevation import ElevationApplyJob, ElevationDataImportJob, ElevationDataset
from ..models.elevation import ElevationApplyJob, ElevationDataImportJob, ElevationDataset, ElevationDatasetFileMeta
from ..models.line import Line
from ..models.line_tower import LineTower
from ..models.user import User
@@ -380,6 +380,12 @@ def list_dataset_files(
except StorageDriverError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
# Query file metadata with bbox information
stmt = select(ElevationDatasetFileMeta).where(
ElevationDatasetFileMeta.dataset_id == dataset_id
)
file_metas = {meta.file_path: meta for meta in db.execute(stmt).scalars().all()}
files = [
ElevationDatasetFileItem(
path=item.path,
@@ -387,6 +393,10 @@ def list_dataset_files(
size=max(0, int(item.size)),
modified_at=item.modified_at,
mime_type=item.mime_type,
bbox_min_lon=file_metas[item.path].bbox_min_lon if item.path in file_metas else None,
bbox_max_lon=file_metas[item.path].bbox_max_lon if item.path in file_metas else None,
bbox_min_lat=file_metas[item.path].bbox_min_lat if item.path in file_metas else None,
bbox_max_lat=file_metas[item.path].bbox_max_lat if item.path in file_metas else None,
)
for item in entries
if not item.is_dir
@@ -2398,17 +2408,54 @@ def _analyze_dataset_content(
db: Session,
dataset: ElevationDataset,
) -> tuple[dict[str, float | int], list[str]]:
# First, analyze the main dataset file (for overall stats)
file_format = _resolve_dataset_file_format(dataset)
if file_format == "csv":
points, warnings = _load_dataset_points(db, dataset)
return _compute_dataset_stats(points), warnings
if file_format in RASTER_FILE_FORMATS:
return _compute_raster_stats(db, dataset)
overall_stats = _compute_dataset_stats(points)
elif file_format in RASTER_FILE_FORMATS:
overall_stats, warnings = _compute_raster_stats(db, dataset)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的高程文件格式: {file_format}",
)
# Then, analyze each file in the dataset directory for file-level metadata
try:
mount = _require_mount(db, dataset.mount_code)
driver = _build_driver_or_400(mount)
dataset_dir = _resolve_dataset_dir(dataset.code)
try:
entries = driver.list_dir(dataset_dir)
except (StoragePathNotFoundError, StorageInvalidPathError, StorageDriverError):
entries = []
for entry in entries:
if entry.is_dir:
continue
file_ext = Path(entry.path).suffix.lower()
if file_ext not in ELEVATION_FILE_EXT_FORMAT_MAP:
continue
file_stats_result = _analyze_single_file(db, dataset, entry.path)
if file_stats_result is not None:
file_stats, _ = file_stats_result
_store_file_metadata(
db,
dataset_id=dataset.id,
file_path=entry.path,
file_name=entry.name,
stats=file_stats,
)
except Exception:
# If file-level analysis fails, don't fail the whole dataset analysis
pass
return overall_stats, warnings
def _apply_points_to_line_towers(
db: Session,
@@ -3541,3 +3588,125 @@ def _fire_and_forget(coro: object) -> None:
close()
return
loop.create_task(coro)
def _analyze_single_file(
db: Session,
dataset: ElevationDataset,
file_path: str,
) -> tuple[dict[str, float | int], list[str]] | None:
"""Analyze a single elevation file and return its bbox stats."""
mount = _require_mount(db, dataset.mount_code)
driver = _build_driver_or_400(mount)
file_ext = Path(file_path).suffix.lower()
if file_ext not in ELEVATION_FILE_EXT_FORMAT_MAP:
return None
file_format = ELEVATION_FILE_EXT_FORMAT_MAP[file_ext]
try:
if file_format == "csv":
read_result = driver.read_file(file_path)
text = _decode_csv_bytes(read_result.content)
rows = list(csv.DictReader(io.StringIO(text)))
if not rows:
return None
points: list[ElevationSamplePoint] = []
for row in rows:
lon = _pick_float(row, ["longitude", "lon", "lng", "经度"])
lat = _pick_float(row, ["latitude", "lat", "纬度"])
altitude = _pick_float(row, ["altitude_m", "altitude", "elevation", "dem", "海拔m", "高程"])
if lon is None or lat is None or altitude is None:
continue
if lon < -180 or lon > 180 or lat < -90 or lat > 90:
continue
points.append(ElevationSamplePoint(lon=lon, lat=lat, altitude_m=altitude))
if not points:
return None
lon_values = [p.lon for p in points]
lat_values = [p.lat for p in points]
return {
"sample_count": len(points),
"bbox_min_lon": min(lon_values),
"bbox_max_lon": max(lon_values),
"bbox_min_lat": min(lat_values),
"bbox_max_lat": max(lat_values),
}, []
elif file_format in RASTER_FILE_FORMATS:
# For raster files, open and extract bounds
try:
import rasterio
except ImportError:
return None
read_result = driver.read_file(file_path)
with NamedTemporaryFile(delete=False, suffix=file_ext) as tmp_file:
tmp_file.write(read_result.content)
tmp_path = tmp_file.name
try:
with rasterio.open(tmp_path) as src:
bounds = src.bounds
wgs84_bounds = _compute_raster_wgs84_bounds(rasterio=rasterio, src=src)
width = int(src.width or 0)
height = int(src.height or 0)
sample_count = width * height
if sample_count > MAX_SAMPLE_COUNT_INT:
sample_count = MAX_SAMPLE_COUNT_INT
return {
"sample_count": sample_count,
"bbox_min_lon": float(wgs84_bounds["west"]),
"bbox_max_lon": float(wgs84_bounds["east"]),
"bbox_min_lat": float(wgs84_bounds["south"]),
"bbox_max_lat": float(wgs84_bounds["north"]),
}, []
finally:
import os
os.unlink(tmp_path)
return None
except Exception:
return None
def _store_file_metadata(
db: Session,
dataset_id: str,
file_path: str,
file_name: str,
stats: dict[str, float | int],
) -> None:
"""Store or update file metadata in database."""
stmt = select(ElevationDatasetFileMeta).where(
ElevationDatasetFileMeta.dataset_id == dataset_id,
ElevationDatasetFileMeta.file_path == file_path,
)
existing = db.execute(stmt).scalar_one_or_none()
if existing:
existing.bbox_min_lon = stats.get("bbox_min_lon")
existing.bbox_max_lon = stats.get("bbox_max_lon")
existing.bbox_min_lat = stats.get("bbox_min_lat")
existing.bbox_max_lat = stats.get("bbox_max_lat")
existing.sample_count = int(stats.get("sample_count", 0))
existing.update_date = utcnow()
else:
meta = ElevationDatasetFileMeta(
dataset_id=dataset_id,
file_path=file_path,
file_name=file_name,
bbox_min_lon=stats.get("bbox_min_lon"),
bbox_max_lon=stats.get("bbox_max_lon"),
bbox_min_lat=stats.get("bbox_min_lat"),
bbox_max_lat=stats.get("bbox_max_lat"),
sample_count=int(stats.get("sample_count", 0)),
)
db.add(meta)
db.commit()
+25
View File
@@ -0,0 +1,25 @@
-- Migration: Add elevation_dataset_file_meta table for storing file-level coordinate ranges
-- Date: 2026-06-13
-- Description: Create new table to store bbox and metadata for each elevation file in a dataset
CREATE TABLE IF NOT EXISTS elevation_dataset_file_meta (
id VARCHAR(32) PRIMARY KEY,
dataset_id VARCHAR(32) NOT NULL,
file_path VARCHAR(2048) NOT NULL,
file_name VARCHAR(512) NOT NULL,
bbox_min_lon DOUBLE PRECISION,
bbox_max_lon DOUBLE PRECISION,
bbox_min_lat DOUBLE PRECISION,
bbox_max_lat DOUBLE PRECISION,
sample_count INTEGER DEFAULT 0,
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
update_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
FOREIGN KEY (dataset_id) REFERENCES elevation_dataset(id) ON DELETE CASCADE
);
CREATE INDEX idx_elevation_file_meta_dataset ON elevation_dataset_file_meta(dataset_id);
CREATE INDEX idx_elevation_file_meta_path ON elevation_dataset_file_meta(dataset_id, file_path);
-- Notes:
-- After running this migration, run the elevation dataset analysis task for each dataset
-- to populate the file metadata with coordinate ranges.
+17 -1
View File
@@ -618,6 +618,22 @@ export default function AdminElevationPage() {
width: 160,
render: (value: string | null) => value || "-",
},
{
title: "坐标范围",
key: "bbox",
width: 320,
render: (_, row) => {
if (row.bbox_min_lon === null || row.bbox_max_lon === null ||
row.bbox_min_lat === null || row.bbox_max_lat === null) {
return <Typography.Text type="secondary">-</Typography.Text>;
}
return (
<Typography.Text type="secondary">
{formatNumber(row.bbox_min_lon, 6)}, {formatNumber(row.bbox_min_lat, 6)} ~ {formatNumber(row.bbox_max_lon, 6)}, {formatNumber(row.bbox_max_lat, 6)}
</Typography.Text>
);
},
},
],
[],
);
@@ -1489,7 +1505,7 @@ export default function AdminElevationPage() {
columns={fileColumns}
dataSource={datasetFiles}
pagination={false}
scroll={{ x: 1000 }}
scroll={{ x: 1320 }}
/>
)}
</div>
+4
View File
@@ -343,6 +343,10 @@ export type ElevationDatasetFileItem = {
name: string;
size: number;
modified_at: string | null;
bbox_min_lon: number | null;
bbox_max_lon: number | null;
bbox_min_lat: number | null;
bbox_max_lat: number | null;
mime_type: string | null;
};