Files
fquiz/api/app/schemas/elevation.py
T
chengkai3 3fb4d5f170 fix:[FL-172][高程数据管理页面上传文件提交表单报错]
修复高程文件上传接口的422错误。

问题原因:
ElevationFileRecordCreateRequest schema 的 mount_code 字段设置了 min_length=2 约束,
当前端传递空字符串时,Pydantic 验证失败导致 422 Unprocessable Entity 错误。

解决方案:
1. 添加 field_validator,将空字符串转换为 None
2. 应用到以下 Request schemas 的可选字符串字段:
   - ElevationFileRecordCreateRequest: source, mount_code, notes
   - ElevationFileRecordUpdateRequest: source, notes
   - ElevationDatasetCreateRequest: source, mount_code, file_name, notes
   - ElevationDatasetUpdateRequest: name, source, notes

这样可以确保:
- 空字符串被规范化为 None
- 保持向后兼容性
- 后端逻辑可以正确使用默认 mount_code(第一个可用挂载点)

Co-authored-by: multica-agent <github@multica.ai>
2026-06-21 13:15:29 +08:00

442 lines
14 KiB
Python

from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
ElevationDatasetStatus = Literal["active", "disabled"]
ElevationDatasetUsageStatus = Literal["idle", "in_use"]
ElevationDatasetTerrainStatus = Literal["pending", "processing", "ready", "failed", "not_supported"]
ElevationApplyMode = Literal["fill_null_only", "overwrite_all"]
ElevationApplyJobStatus = Literal["pending", "running", "success", "failed"]
ElevationDataImportJobStatus = Literal["pending", "running", "success", "failed"]
class ElevationFileRecordSummary(BaseModel):
id: str
file_name: str
file_path: str
file_format: str
file_size: int
source: str | None = None
mount_code: str
resolution_m: float | None = None
status: ElevationDatasetStatus
bbox_min_lon: float | None = None
bbox_max_lon: float | None = None
bbox_min_lat: float | None = None
bbox_max_lat: float | None = None
sample_count: int = 0
analysis_task_id: str | None = None
analysis_status: str = "not_started"
analysis_error_message: str | None = None
analysis_started_at: datetime | None = None
analysis_finished_at: datetime | None = None
terrain_status: ElevationDatasetTerrainStatus = "not_supported"
terrain_task_id: str | None = None
terrain_error_message: str | None = None
terrain_root_path: str | None = None
terrain_url_template: str | None = None
terrain_min_zoom: int | None = None
terrain_max_zoom: int | None = None
terrain_bounds: dict[str, Any] | None = None
terrain_metadata: dict[str, Any] | None = None
notes: str | None = None
create_date: datetime
create_user: str | None = None
update_date: datetime
update_user: str | None = None
class ElevationFileRecordListResponse(BaseModel):
items: list[ElevationFileRecordSummary]
total: int
class ElevationFileRecordCreateRequest(BaseModel):
source: str | None = Field(default=None, max_length=512)
mount_code: str | None = Field(default=None, min_length=2, max_length=64)
resolution_m: float | None = Field(default=None, gt=0)
notes: str | None = Field(default=None, max_length=2000)
trigger_analysis: bool = Field(default=True)
@field_validator("source", "mount_code", "notes", mode="before")
@classmethod
def empty_str_to_none(cls, v: Any) -> Any:
if isinstance(v, str) and v.strip() == "":
return None
return v
class ElevationFileRecordUpdateRequest(BaseModel):
source: str | None = Field(default=None, max_length=512)
resolution_m: float | None = Field(default=None, gt=0)
status: ElevationDatasetStatus | None = None
notes: str | None = Field(default=None, max_length=2000)
@field_validator("source", "notes", mode="before")
@classmethod
def empty_str_to_none(cls, v: Any) -> Any:
if isinstance(v, str) and v.strip() == "":
return None
return v
class ElevationFileRecordAnalyzeResponse(BaseModel):
record: ElevationFileRecordSummary
task_id: str | None = None
queued: bool = True
detail: str | None = None
warnings: list[str] = Field(default_factory=list)
class ElevationFileRecordTerrainBuildResponse(BaseModel):
record: ElevationFileRecordSummary
task_id: str | None = None
queued: bool = True
detail: str | None = None
warnings: list[str] = Field(default_factory=list)
class ElevationFileRecordPreviewResponse(BaseModel):
record: ElevationFileRecordSummary
preview_mode: Literal["point_cloud", "terrain_grid"]
total_points: int
sampled_points: int
points: list[ElevationDatasetPreviewPoint] = Field(default_factory=list)
cells: list[ElevationDatasetPreviewCell] = Field(default_factory=list)
diagnostics: ElevationDatasetPreviewDiagnostics | None = None
warnings: list[str] = Field(default_factory=list)
class ElevationFileRecordUploadResponse(BaseModel):
record: ElevationFileRecordSummary
queued: bool = True
detail: str | None = None
warnings: list[str] = Field(default_factory=list)
class ElevationDatasetSummary(BaseModel):
id: str
code: str
name: str
source: str | None = None
file_format: str
mount_code: str
dataset_dir: str
file_path: str
resolution_m: float | None = None
status: ElevationDatasetStatus
usage_status: ElevationDatasetUsageStatus
sample_count: int = 0
bbox_min_lon: float | None = None
bbox_max_lon: float | None = None
bbox_min_lat: float | None = None
bbox_max_lat: float | None = None
analysis_task_id: str | None = None
analysis_status: str = "not_started"
analysis_error_message: str | None = None
analysis_started_at: datetime | None = None
analysis_finished_at: datetime | None = None
terrain_status: ElevationDatasetTerrainStatus = "not_supported"
terrain_task_id: str | None = None
terrain_error_message: str | None = None
terrain_root_path: str | None = None
terrain_url_template: str | None = None
terrain_min_zoom: int | None = None
terrain_max_zoom: int | None = None
terrain_bounds: dict[str, Any] | None = None
terrain_metadata: dict[str, Any] | None = None
notes: str | None = None
create_date: datetime
create_user: str | None = None
update_date: datetime
update_user: str | None = None
class ElevationDatasetListResponse(BaseModel):
items: list[ElevationDatasetSummary]
total: int
class ElevationDatasetBatchImportResponse(BaseModel):
imported_count: int
analyzed_count: int
skipped_count: int
warning_count: int
warnings: list[str] = Field(default_factory=list)
items: list[ElevationDatasetSummary] = Field(default_factory=list)
class ElevationDatasetCreateRequest(BaseModel):
code: str = Field(min_length=2, max_length=64)
name: str = Field(min_length=2, max_length=255)
source: str | None = Field(default=None, max_length=128)
mount_code: str | None = Field(default=None, min_length=2, max_length=64)
file_name: str | None = Field(default=None, min_length=1, max_length=255)
resolution_m: float | None = Field(default=None, gt=0)
notes: str | None = Field(default=None, max_length=2000)
@field_validator("source", "mount_code", "file_name", "notes", mode="before")
@classmethod
def empty_str_to_none(cls, v: Any) -> Any:
if isinstance(v, str) and v.strip() == "":
return None
return v
class ElevationDatasetUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=2, max_length=255)
source: str | None = Field(default=None, max_length=128)
resolution_m: float | None = Field(default=None, gt=0)
status: ElevationDatasetStatus | None = None
notes: str | None = Field(default=None, max_length=2000)
@field_validator("name", "source", "notes", mode="before")
@classmethod
def empty_str_to_none(cls, v: Any) -> Any:
if isinstance(v, str) and v.strip() == "":
return None
return v
class ElevationDatasetAnalyzeResponse(BaseModel):
dataset: ElevationDatasetSummary
task_id: str | None = None
queued: bool = True
detail: str | None = None
warnings: list[str] = Field(default_factory=list)
class ElevationDatasetTerrainBuildResponse(BaseModel):
dataset: ElevationDatasetSummary
task_id: str | None = None
queued: bool = True
detail: str | None = None
warnings: list[str] = Field(default_factory=list)
class ElevationDatasetPreviewPoint(BaseModel):
longitude: float
latitude: float
altitude_m: float
class ElevationDatasetPreviewCell(BaseModel):
min_longitude: float
max_longitude: float
min_latitude: float
max_latitude: float
altitude_m: float
class ElevationDatasetPreviewDiagnostics(BaseModel):
source_crs: str | None = None
source_bounds_min_x: float | None = None
source_bounds_max_x: float | None = None
source_bounds_min_y: float | None = None
source_bounds_max_y: float | None = None
wgs84_bounds_min_lon: float | None = None
wgs84_bounds_max_lon: float | None = None
wgs84_bounds_min_lat: float | None = None
wgs84_bounds_max_lat: float | None = None
raster_width: int | None = None
raster_height: int | None = None
target_samples: int | None = None
sampling_step: int | None = None
scanned_candidates: int | None = None
valid_preview_count: int | None = None
skip_read_error: int = 0
skip_masked: int = 0
skip_nodata: int = 0
skip_nonfinite: int = 0
skip_sample_transform_error: int = 0
sample_tx_first_error: str | None = None
skip_sample_out_of_range: int = 0
skip_cell_transform_error: int = 0
skip_cell_out_of_range: int = 0
class ElevationDatasetPreviewResponse(BaseModel):
dataset: ElevationDatasetSummary
preview_mode: Literal["point_cloud", "terrain_grid"]
total_points: int
sampled_points: int
points: list[ElevationDatasetPreviewPoint] = Field(default_factory=list)
cells: list[ElevationDatasetPreviewCell] = Field(default_factory=list)
diagnostics: ElevationDatasetPreviewDiagnostics | None = None
warnings: list[str] = Field(default_factory=list)
class ElevationDatasetDataImportResponse(BaseModel):
job: "ElevationDataImportJobSummary"
queued: bool = True
detail: str | None = None
warnings: list[str] = Field(default_factory=list)
class ElevationDatasetFileItem(BaseModel):
path: str
name: str
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):
dataset_id: str
dataset_code: str
dataset_dir: str
mount_code: str
items: list[ElevationDatasetFileItem] = Field(default_factory=list)
total: int = 0
class ElevationDatasetAnalysisTaskStatusResponse(BaseModel):
dataset_id: str
dataset_code: str
task_id: str | None = None
status: Literal["queued", "running", "success", "failed", "unknown", "not_found"] = "not_found"
detail: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
update_date: datetime | None = None
class ElevationDatasetTerrainTaskStatusResponse(BaseModel):
dataset_id: str
dataset_code: str
task_id: str | None = None
status: Literal["queued", "running", "success", "failed", "unknown", "not_found"] = "not_found"
detail: str | None = None
terrain_url_template: str | None = None
terrain_min_zoom: int | None = None
terrain_max_zoom: int | None = None
update_date: datetime | None = None
class ElevationFileRecordTaskStatusResponse(BaseModel):
record_id: str
file_name: str
task_id: str | None = None
status: Literal["queued", "running", "success", "failed", "unknown", "not_found"] = "not_found"
detail: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
update_date: datetime | None = None
class ElevationFileRecordTerrainTaskStatusResponse(BaseModel):
record_id: str
file_name: str
task_id: str | None = None
status: Literal["queued", "running", "success", "failed", "unknown", "not_found"] = "not_found"
detail: str | None = None
terrain_url_template: str | None = None
terrain_min_zoom: int | None = None
terrain_max_zoom: int | None = None
update_date: datetime | None = None
class ElevationTerrainLayerResponse(BaseModel):
tilejson: str = "2.1.0"
format: str = "heightmap-1.0"
version: str = "1.0.0"
scheme: Literal["tms"] = "tms"
projection: Literal["EPSG:4326"] = "EPSG:4326"
tiles: list[str]
minzoom: int
maxzoom: int
extensions: list[str] = Field(default_factory=list)
attribution: str | None = None
bounds: list[float] | None = None
available: list[list[dict[str, int]]] | None = None
class ElevationApplyJobSummary(BaseModel):
id: str
line_id: str
line_code: str | None = None
line_name: str | None = None
file_record_id: str | None = None
file_record_name: str | None = None
dataset_id: str | None = None
dataset_code: str | None = None
dataset_name: str | None = None
mode: ElevationApplyMode
status: ElevationApplyJobStatus
task_id: str | None = None
total_tower_count: int = 0
updated_tower_count: int = 0
skipped_tower_count: int = 0
missing_geo_count: int = 0
unmatched_count: int = 0
error_message: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
create_date: datetime
create_user: str | None = None
update_date: datetime
update_user: str | None = None
class ElevationApplyJobListResponse(BaseModel):
items: list[ElevationApplyJobSummary]
total: int
class ElevationApplyJobCreateRequest(BaseModel):
line_id: str = Field(min_length=1, max_length=64)
file_record_id: str | None = Field(default=None, min_length=1, max_length=64)
dataset_id: str | None = Field(default=None, min_length=1, max_length=64)
mode: ElevationApplyMode = "fill_null_only"
class ElevationApplyJobCreateResponse(BaseModel):
job: ElevationApplyJobSummary
queued: bool = True
class ElevationDataImportJobSummary(BaseModel):
id: str
dataset_id: str | None = None
file_record_id: str | None = None
file_record_name: str | None = None
dataset_code: str | None = None
dataset_name: str | None = None
status: ElevationDataImportJobStatus
task_id: str | None = None
progress_percent: int = 0
current_stage: str | None = None
detail_message: str | None = None
trigger_analysis: bool = True
analysis_task_queued: bool = False
analysis_task_id: str | None = None
uploaded_file_count: int = 0
extracted_file_count: int = 0
imported_file_count: int = 0
warning_count: int = 0
warnings: list[str] = Field(default_factory=list)
imported_files: list[str] = Field(default_factory=list)
started_at: datetime | None = None
finished_at: datetime | None = None
create_date: datetime
create_user: str | None = None
update_date: datetime
update_user: str | None = None
class ElevationDataImportJobListResponse(BaseModel):
items: list[ElevationDataImportJobSummary]
total: int
ElevationFileRecordPreviewResponse.model_rebuild()
ElevationDatasetDataImportResponse.model_rebuild()