feat: 接入 VizieR 星表检索与 LAMOST/Gaia/SDSS/DESI 跨源光谱下载
新增天文观测数据获取能力,覆盖星表查询与一维光谱下载两大场景:
星表检索(CDS VizieR)
- VizieR TAP 客户端(JSON 优先 + VOTable 降级),共享 IVOA VOTable 解析层
- 业务层支持自由 ADQL、锥形检索、交叉证认、星表发现与 CSV 导出
- ADQL 注入防护(标识符清洗 + 字符串字面量转义),TTL 缓存(7 天)
跨望远镜光谱下载(统一入口)
- 接入 LAMOST(ConeSearch + FITS.gz)、Gaia(TAP + DataLink ZIP)、
SDSS(Data Lab TAP + SAS)、DESI(HEALPix coadd)四源
- 双模式:坐标模式(cone 检索 → 选源 → 下载)/ 标识符模式(直按 ID 下载)
- 光谱文件永久缓存(不可变),按 source+source_id 去重
Agent 与 API
- +4 工具:query_vizier / cone_search / find_spectrum / catalog_operation(22 → 26)
- +6 路由:/catalog/vizier、/cone、/crossmatch、/spectrum/{download,list}
- 前端新增 VizierResultCard / FindSpectrumCard 可视化卡片
工程重构
- services/target.rs (832 行) 拆分为 services/cds/{target,vizier}.rs + clients/cds/sesame.rs,
贯彻 client(通信)/ service(缓存+编排)分层
- ADS 返回字段新增 data(关联数据表 URL),与星表功能联动
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
// src/api/catalog.rs
|
||||
//
|
||||
// 天文星表查询 HTTP 处理器 —— VizieR TAP + Cone Search
|
||||
// 对齐 targets.rs 的 handler 范式:State + Query/Json 参数 → service 调用 → ApiResult<Json<...>>
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::error::{ApiResult, AppError};
|
||||
use super::AppState;
|
||||
use crate::clients::cds::vizier::VizierQueryResult;
|
||||
|
||||
// ── 请求参数 ──
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VizierQueryParams {
|
||||
/// 自由 ADQL 查询语句
|
||||
pub adql: String,
|
||||
/// 最大返回行数(默认 50,上限 2000)
|
||||
pub max_records: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VizierTableParams {
|
||||
/// VizieR 表名,如 "I/355/gaiadr3"
|
||||
pub table: String,
|
||||
/// 列名(逗号分隔,为空时取 *)
|
||||
pub columns: Option<String>,
|
||||
/// 最大返回行数(默认 50,上限 2000)
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConeSearchParams {
|
||||
/// RA 坐标(度)
|
||||
pub ra: f64,
|
||||
/// Dec 坐标(度)
|
||||
pub dec: f64,
|
||||
/// 检索半径(度)
|
||||
pub radius: Option<f64>,
|
||||
/// 目标星表(必填,如 "I/355/gaiadr3")
|
||||
pub table: String,
|
||||
/// 最大返回行数(默认 50,上限 2000)
|
||||
pub max_records: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CrossMatchParams {
|
||||
pub ra: f64,
|
||||
pub dec: f64,
|
||||
pub radius: Option<f64>,
|
||||
/// 目标星表(必填,如 "I/355/gaiadr3")
|
||||
pub table: String,
|
||||
pub max_records: Option<i64>,
|
||||
}
|
||||
|
||||
// ── 响应封装 ──
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CachedResult {
|
||||
#[serde(flatten)]
|
||||
pub result: VizierQueryResult,
|
||||
/// 是否来自缓存
|
||||
pub from_cache: Option<bool>,
|
||||
}
|
||||
|
||||
const DEFAULT_MAX: i64 = 50;
|
||||
const MAX_LIMIT: i64 = 2000;
|
||||
|
||||
fn clamp_max(v: Option<i64>) -> i64 {
|
||||
v.unwrap_or(DEFAULT_MAX).clamp(1, MAX_LIMIT)
|
||||
}
|
||||
|
||||
// ── 处理器 ──
|
||||
|
||||
/// GET /api/catalog/vizier —— 自由 ADQL 查询
|
||||
pub async fn vizier_query(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
Query(params): Query<VizierQueryParams>,
|
||||
) -> ApiResult<Json<VizierQueryResult>> {
|
||||
let max_records = clamp_max(params.max_records);
|
||||
let result = crate::services::cds::vizier::query_adql_cached(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
¶ms.adql,
|
||||
max_records,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("VizieR 查询失败: {}", e);
|
||||
AppError::internal(format!("VizieR 查询失败: {}", e))
|
||||
})?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /api/catalog/vizier/table —— 按表名便捷查询
|
||||
pub async fn vizier_table(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
Query(params): Query<VizierTableParams>,
|
||||
) -> ApiResult<Json<VizierQueryResult>> {
|
||||
let limit = clamp_max(params.limit);
|
||||
let columns: Vec<String> = params
|
||||
.columns
|
||||
.as_deref()
|
||||
.map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let result = crate::services::cds::vizier::query_table(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
¶ms.table,
|
||||
&columns,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("VizieR 表查询失败: {}", e);
|
||||
// 标识符非法 → bad_request
|
||||
let msg = e.to_string();
|
||||
if msg.contains("非法字符") || msg.contains("不能为空") {
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("VizieR 表查询失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /api/catalog/cone —— 锥形检索
|
||||
pub async fn cone_search(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
Query(params): Query<ConeSearchParams>,
|
||||
) -> ApiResult<Json<VizierQueryResult>> {
|
||||
let radius = params.radius.unwrap_or(0.1);
|
||||
let max_records = clamp_max(params.max_records);
|
||||
|
||||
let result = crate::services::cds::vizier::cone_search(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
params.ra,
|
||||
params.dec,
|
||||
radius,
|
||||
¶ms.table,
|
||||
max_records,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Cone Search 失败: {}", e);
|
||||
let msg = e.to_string();
|
||||
if msg.contains("半径") || msg.contains("坐标") || msg.contains("非法字符") {
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("Cone Search 失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /api/catalog/crossmatch —— 交叉证认
|
||||
pub async fn cross_match(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
Query(params): Query<CrossMatchParams>,
|
||||
) -> ApiResult<Json<VizierQueryResult>> {
|
||||
let radius = params.radius.unwrap_or(0.05);
|
||||
let max_records = clamp_max(params.max_records);
|
||||
|
||||
let result = crate::services::cds::vizier::cross_match(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
params.ra,
|
||||
params.dec,
|
||||
radius,
|
||||
¶ms.table,
|
||||
max_records,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("交叉证认失败: {}", e);
|
||||
let msg = e.to_string();
|
||||
if msg.contains("半径") || msg.contains("坐标") || msg.contains("非法字符") {
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("交叉证认失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// 统一光谱下载(跨 LAMOST/Gaia/SDSS)
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpectrumDownloadParams {
|
||||
/// 数据源:lamost / gaia / sdss
|
||||
pub survey: String,
|
||||
/// 坐标模式:ra/dec/radius + strategy
|
||||
pub ra: Option<f64>,
|
||||
pub dec: Option<f64>,
|
||||
pub radius: Option<f64>,
|
||||
/// 选源策略:nearest(默认)/ all
|
||||
pub strategy: Option<String>,
|
||||
/// 标识符模式:逗号分隔的源标识列表
|
||||
pub source_ids: Option<String>,
|
||||
/// 数据发布版本(可选):lamost=dr5..dr11, gaia=dr3, sdss=dr16..dr19
|
||||
pub release: Option<String>,
|
||||
/// 数据类型(可选):lamost=lrs/mrs, gaia=xp_continuous/xp_sampled/epoch_photometry/rvs
|
||||
pub data_type: Option<String>,
|
||||
pub force: Option<bool>,
|
||||
}
|
||||
|
||||
/// GET /api/catalog/spectrum/download —— 统一光谱下载
|
||||
///
|
||||
/// 两种模式(二选一):
|
||||
/// - 坐标模式:提供 ra + dec(可选 radius/strategy),自动 cone 检索并下载
|
||||
/// - 标识符模式:提供 source_ids(逗号分隔),直接按标识下载
|
||||
pub async fn spectrum_download(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
Query(params): Query<SpectrumDownloadParams>,
|
||||
) -> ApiResult<Json<crate::services::spectra::DownloadBatch>> {
|
||||
use crate::services::spectra::{FindStrategy, SpectrumRequest, SpectrumSurvey};
|
||||
|
||||
let survey = match params.survey.to_lowercase().as_str() {
|
||||
"lamost" => SpectrumSurvey::Lamost,
|
||||
"gaia" => SpectrumSurvey::Gaia,
|
||||
"sdss" => SpectrumSurvey::Sdss,
|
||||
"desi" => SpectrumSurvey::Desi,
|
||||
other => {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"不支持的 survey '{}',可选: lamost / gaia / sdss / desi",
|
||||
other
|
||||
)))
|
||||
}
|
||||
};
|
||||
let force = params.force.unwrap_or(false);
|
||||
|
||||
let request = if let Some(ids_str) = params.source_ids.as_deref() {
|
||||
// 标识符模式
|
||||
let source_ids: Vec<String> = ids_str
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if source_ids.is_empty() {
|
||||
return Err(AppError::bad_request("source_ids 不能为空"));
|
||||
}
|
||||
SpectrumRequest::ByIdentifier {
|
||||
survey,
|
||||
source_ids,
|
||||
release: params.release.clone(),
|
||||
data_type: params.data_type.clone(),
|
||||
}
|
||||
} else {
|
||||
// 坐标模式
|
||||
let ra = params.ra.ok_or_else(|| {
|
||||
AppError::bad_request("坐标模式缺少 ra(或改用 source_ids 标识符模式)")
|
||||
})?;
|
||||
let dec = params.dec.ok_or_else(|| {
|
||||
AppError::bad_request("坐标模式缺少 dec(或改用 source_ids 标识符模式)")
|
||||
})?;
|
||||
let radius = params.radius.unwrap_or(0.1);
|
||||
let strategy = match params.strategy.as_deref().unwrap_or("nearest") {
|
||||
"nearest" => FindStrategy::Nearest,
|
||||
"all" => FindStrategy::All,
|
||||
other => {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"不支持的 strategy '{}',可选: nearest / all",
|
||||
other
|
||||
)))
|
||||
}
|
||||
};
|
||||
SpectrumRequest::ByCoordinates {
|
||||
survey,
|
||||
ra,
|
||||
dec,
|
||||
radius_deg: radius,
|
||||
strategy,
|
||||
release: params.release.clone(),
|
||||
data_type: params.data_type.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
let result = crate::services::spectra::download_spectrum(&state, &request, force)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("光谱下载失败: {}", e);
|
||||
let msg = e.to_string();
|
||||
if msg.contains("半径") || msg.contains("坐标") || msg.contains("格式") {
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("光谱下载失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /api/catalog/spectrum/list —— 列出全部数据源的已缓存光谱
|
||||
pub async fn spectrum_list(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
) -> ApiResult<Json<Vec<crate::services::spectra::common::SpectrumCacheRow>>> {
|
||||
let rows = crate::services::spectra::common::list_all_cached(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("光谱列表查询失败: {}", e);
|
||||
AppError::internal(format!("光谱列表查询失败: {}", e))
|
||||
})?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
Reference in New Issue
Block a user