- LAMOST 新增 DR12/13/14 及子版本(v0/v1.0/v1.1/v2.0)维度,Internal 发布标记需登录认证并前端灰显,release×subtype 交叉约束下沉至 capabilities 统一声明 - ObservationFetcher trait 扩展版本/认证/交叉约束能力声明,version 参数贯穿 client→service→API→Agent tool→前端全链路 - 安全:observation cache SQL 全参数绑定 + LIKE 转义、cone_cache_hash 加长度前缀防碰撞、DESI survey/program 白名单防穿越 - 异步化:persist/cached_files_total_size/maybe_persist_tool_result迁移到 tokio::fs;cancelled_runs 与 session_permission_checkers改用 DashMap;auth 读锁优先 + 60s 节流 - Gaia 去 native-tls 改禁用连接池规避 UnexpectedEof,reqwest 移除 native-tls feature - 重构:Source/ProductType from_str 集中解析、download 模块拆分为 try_download_pdf/html、AgentRuntime::init 抽取共享逻辑 - 部署:新增 deploy.sh 一键打包推送脚本、catch-panic 启用
343 lines
11 KiB
Rust
343 lines
11 KiB
Rust
// src/services/observation/desi.rs
|
||
//
|
||
// DESI ObservationFetcher 实现
|
||
//
|
||
// 注册的 fetcher:
|
||
// DesiSpectrumFetcher —— (Desi, Spectrum),HEALPix coadd(一像素含多目标光谱)
|
||
|
||
use crate::api::AppState;
|
||
use crate::clients::desi::DesiSpectrumRow;
|
||
use crate::services::observation::cache::{
|
||
cached_files_total_size, fetch_observation_cache, file_url_from_path, log_write_failure,
|
||
persist_bytes, write_observation_cache,
|
||
};
|
||
use crate::services::observation::fetcher::{Candidate, ObservationFetcher};
|
||
use crate::services::observation::types::{
|
||
Artifact, ObservationProduct, ProductSpec, ProductType, Source,
|
||
};
|
||
use anyhow::{anyhow, Result};
|
||
use async_trait::async_trait;
|
||
use std::time::Duration;
|
||
use tracing::{info, warn};
|
||
|
||
// ── 版本枚举 ──
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum DesiRelease {
|
||
Edr,
|
||
#[default]
|
||
Dr1,
|
||
}
|
||
|
||
impl DesiRelease {
|
||
pub fn valid_values() -> &'static [&'static str] {
|
||
&["edr", "dr1"]
|
||
}
|
||
pub fn dr_segment(&self) -> &'static str {
|
||
match self {
|
||
Self::Edr => "edr",
|
||
Self::Dr1 => "dr1",
|
||
}
|
||
}
|
||
pub fn specredux_ver(&self) -> &'static str {
|
||
match self {
|
||
Self::Edr => "fuji",
|
||
Self::Dr1 => "iron",
|
||
}
|
||
}
|
||
pub fn datalab_table(&self) -> &'static str {
|
||
match self {
|
||
Self::Edr => "desi_edr.zpix",
|
||
Self::Dr1 => "desi_dr1.zpix",
|
||
}
|
||
}
|
||
pub fn display(&self) -> &'static str {
|
||
match self {
|
||
Self::Edr => "EDR",
|
||
Self::Dr1 => "DR1",
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn parse_desi_release(s: Option<&str>) -> Result<DesiRelease> {
|
||
Ok(match s.map(|x| x.to_lowercase()).as_deref() {
|
||
None => DesiRelease::Dr1,
|
||
Some("edr") => DesiRelease::Edr,
|
||
Some("dr1") => DesiRelease::Dr1,
|
||
Some(other) => {
|
||
return Err(anyhow!(
|
||
"不支持的 DESI release '{}',可选: {:?}",
|
||
other,
|
||
DesiRelease::valid_values()
|
||
))
|
||
}
|
||
})
|
||
}
|
||
|
||
fn validate_cone_params(ra: f64, dec: f64, radius_deg: f64) -> Result<()> {
|
||
// NOIRLab Data Lab 无硬性半径上限,但大半径易超时
|
||
if !(radius_deg > 0.0 && radius_deg <= 30.0) {
|
||
return Err(anyhow!(
|
||
"检索半径应在 0~30 度之间(不含 0),当前: {}。DESI zpix 建议检索半径 ≤1° 以避免超时",
|
||
radius_deg
|
||
));
|
||
}
|
||
if radius_deg > 1.0 {
|
||
warn!(
|
||
"[Observation] DESI cone radius {}° 超出建议范围(≤1°),大半径查询可能被服务端超时拒绝",
|
||
radius_deg
|
||
);
|
||
}
|
||
if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) {
|
||
return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// DesiSpectrumFetcher
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
#[derive(Debug)]
|
||
pub struct DesiSpectrumFetcher;
|
||
|
||
#[async_trait]
|
||
impl ObservationFetcher for DesiSpectrumFetcher {
|
||
fn key(&self) -> (Source, ProductType) {
|
||
(Source::Desi, ProductType::Spectrum)
|
||
}
|
||
|
||
fn subtypes(&self) -> &'static [&'static str] {
|
||
&["coadd"]
|
||
}
|
||
|
||
fn releases(&self) -> &'static [&'static str] {
|
||
DesiRelease::valid_values()
|
||
}
|
||
|
||
fn default_release(&self) -> Option<&'static str> {
|
||
// 与 parse_desi_release(None) 的默认值保持一致
|
||
Some("dr1")
|
||
}
|
||
|
||
fn suggested_max_radius_deg(&self) -> f64 {
|
||
// 与 validate_cone_params 的建议范围一致
|
||
1.0
|
||
}
|
||
|
||
fn identifier_format(&self) -> Option<&'static str> {
|
||
Some("'survey-program-healpix',如 'main-dark-10050'")
|
||
}
|
||
|
||
async fn cone_search_raw(
|
||
&self,
|
||
state: &AppState,
|
||
ra: f64,
|
||
dec: f64,
|
||
radius_deg: f64,
|
||
release: Option<&str>,
|
||
_subtype: Option<&str>,
|
||
_version: Option<&str>,
|
||
) -> Result<Vec<Candidate>> {
|
||
validate_cone_params(ra, dec, radius_deg)?;
|
||
let rel = parse_desi_release(release)?;
|
||
let result = state.desi.cone_search(ra, dec, radius_deg, 50, rel).await?;
|
||
Ok(result.rows.iter().map(row_to_candidate).collect())
|
||
}
|
||
|
||
async fn resolve_identifier(
|
||
&self,
|
||
identifier: &str,
|
||
_release: Option<&str>,
|
||
_subtype: Option<&str>,
|
||
_version: Option<&str>,
|
||
) -> Result<Candidate> {
|
||
// 格式 "{survey}-{program}-{healpix}"
|
||
let parts: Vec<&str> = identifier.split('-').collect();
|
||
if parts.len() != 3 {
|
||
return Err(anyhow!(
|
||
"DESI source_id 应为 'survey-program-healpix' 格式(如 'main-dark-10050'),得到 '{}'",
|
||
identifier
|
||
));
|
||
}
|
||
let survey = parts[0];
|
||
let program = parts[1];
|
||
// 白名单校验:survey/program 会拼进文件系统路径和下载 URL,
|
||
// 必须防止路径穿越(如 "../etc")。仅允许 DESI 实际使用的取值。
|
||
const VALID_SURVEYS: &[&str] = &["main", "sv1", "sv2", "sv3", "sv", "backup", "cumulative"];
|
||
const VALID_PROGRAMS: &[&str] = &["dark", "bright", "backup", "other"];
|
||
if !VALID_SURVEYS.contains(&survey) {
|
||
return Err(anyhow!(
|
||
"DESI survey '{}' 不在允许列表 {:?} 中",
|
||
survey,
|
||
VALID_SURVEYS
|
||
));
|
||
}
|
||
if !VALID_PROGRAMS.contains(&program) {
|
||
return Err(anyhow!(
|
||
"DESI program '{}' 不在允许列表 {:?} 中",
|
||
program,
|
||
VALID_PROGRAMS
|
||
));
|
||
}
|
||
let healpix: i64 = parts[2]
|
||
.parse()
|
||
.map_err(|_| anyhow!("healpix 非数字: '{}'", parts[2]))?;
|
||
if healpix < 0 {
|
||
return Err(anyhow!("healpix 应为非负整数,当前: {}", healpix));
|
||
}
|
||
Ok(Candidate {
|
||
source: Source::Desi,
|
||
source_id: identifier.to_string(),
|
||
label: format!("{} {} healpix {}", survey, program, healpix),
|
||
ra: None,
|
||
dec: None,
|
||
distance: None,
|
||
raw: Some(serde_json::json!({
|
||
"survey": survey, "program": program, "healpix": healpix
|
||
})),
|
||
})
|
||
}
|
||
|
||
async fn fetch(
|
||
&self,
|
||
state: &AppState,
|
||
candidate: &Candidate,
|
||
release: Option<&str>,
|
||
_subtype: Option<&str>,
|
||
_version: Option<&str>,
|
||
force: bool,
|
||
) -> Result<ObservationProduct> {
|
||
let survey = candidate
|
||
.raw
|
||
.as_ref()
|
||
.and_then(|v| v.get("survey"))
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| anyhow!("DESI Candidate 缺 survey"))?;
|
||
let program = candidate
|
||
.raw
|
||
.as_ref()
|
||
.and_then(|v| v.get("program"))
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| anyhow!("DESI Candidate 缺 program"))?;
|
||
let healpix = candidate
|
||
.raw
|
||
.as_ref()
|
||
.and_then(|v| v.get("healpix"))
|
||
.and_then(|v| v.as_i64())
|
||
.ok_or_else(|| anyhow!("DESI Candidate 缺 healpix"))?;
|
||
let rel = parse_desi_release(release)?;
|
||
let product = ProductSpec::with_subtype(ProductType::Spectrum, "coadd");
|
||
let dr = rel.dr_segment();
|
||
let cache_key = format!("{}|{}-{}-{}", dr, survey, program, healpix);
|
||
let source_label = format!("{} {} healpix {}", survey, program, healpix);
|
||
|
||
// 1) 缓存命中
|
||
if !force {
|
||
if let Some((artifacts, meta)) =
|
||
fetch_observation_cache(&state.db, Source::Desi, &product, &cache_key).await?
|
||
{
|
||
if cached_files_total_size(&state.config.library_dir, &artifacts)
|
||
.await
|
||
.is_some()
|
||
{
|
||
info!("[DESI] coadd 缓存命中 ({})", cache_key);
|
||
return Ok(ObservationProduct {
|
||
source: Source::Desi,
|
||
product,
|
||
source_id: cache_key,
|
||
source_label,
|
||
artifacts,
|
||
source_meta: meta,
|
||
});
|
||
}
|
||
warn!("[DESI] 缓存记录存在但文件缺失,重新下载 ({})", cache_key);
|
||
}
|
||
}
|
||
|
||
// 2) 下载 coadd FITS(无压缩)
|
||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||
let fits_bytes = state
|
||
.desi
|
||
.download_coadd(survey, program, healpix, rel)
|
||
.await?;
|
||
let rel_path = format!(
|
||
"Telescope/desi/{dr}/{survey}-{program}/{healpix}.fits",
|
||
dr = dr,
|
||
survey = survey,
|
||
program = program,
|
||
healpix = healpix
|
||
);
|
||
let size = persist_bytes(&state.config.library_dir, &rel_path, &fits_bytes).await?;
|
||
info!(
|
||
"[DESI] coadd 已保存 {} → {} ({}B)",
|
||
cache_key, rel_path, size
|
||
);
|
||
|
||
let meta =
|
||
serde_json::json!({"survey": survey, "program": program, "healpix": healpix, "dr": dr});
|
||
let artifact = Artifact {
|
||
band: None,
|
||
original_name: None,
|
||
file_path: rel_path.clone(),
|
||
file_url: file_url_from_path(&rel_path),
|
||
file_format: "fits".to_string(),
|
||
size_bytes: size,
|
||
cached: false,
|
||
};
|
||
let meta_str = meta.to_string();
|
||
if let Err(e) = write_observation_cache(
|
||
&state.db,
|
||
Source::Desi,
|
||
&product,
|
||
&cache_key,
|
||
None,
|
||
None,
|
||
std::slice::from_ref(&artifact),
|
||
Some(&meta_str),
|
||
)
|
||
.await
|
||
{
|
||
log_write_failure(Source::Desi.as_str(), "spectrum", e);
|
||
}
|
||
|
||
Ok(ObservationProduct {
|
||
source: Source::Desi,
|
||
product,
|
||
source_id: cache_key,
|
||
source_label,
|
||
artifacts: vec![artifact],
|
||
source_meta: Some(meta),
|
||
})
|
||
}
|
||
}
|
||
|
||
fn row_to_candidate(row: &DesiSpectrumRow) -> Candidate {
|
||
Candidate {
|
||
source: Source::Desi,
|
||
source_id: format!("{}-{}-{}", row.survey, row.program, row.healpix),
|
||
label: format!("{} {} healpix {}", row.survey, row.program, row.healpix),
|
||
ra: row.ra,
|
||
dec: row.dec,
|
||
distance: None,
|
||
raw: Some(serde_json::json!({
|
||
"survey": row.survey, "program": row.program, "healpix": row.healpix
|
||
})),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_validate_cone_params() {
|
||
// 合法范围:0~30°(建议 ≤1°,超出仅警告)
|
||
assert!(validate_cone_params(180.0, 30.0, 0.1).is_ok());
|
||
assert!(validate_cone_params(180.0, 30.0, 1.0).is_ok()); // 建议上限内
|
||
assert!(validate_cone_params(180.0, 30.0, 5.0).is_ok()); // 超建议但仍合法
|
||
assert!(validate_cone_params(180.0, 30.0, 31.0).is_err()); // 超硬性上限
|
||
assert!(validate_cone_params(180.0, 91.0, 0.1).is_err()); // dec 越界
|
||
}
|
||
}
|