AstroResearch/src/services/observation/gaia.rs
Asfmq 2f1fd19d74 refactor: 观测层双轴正交重构——spectra→observation、工具/API 收敛、安全韧性加固
将"以光谱为中心"的观测数据架构升级为 (Source × ProductType) 双轴正交模型,
光谱降级为与光变/测光/图像平级的产品类型之一;同步把分散的工具、API、缓存表
收敛为统一入口。新增 Gaia 光变曲线(EPOCH_PHOTOMETRY)支持。

【架构】services/spectra 整体替换为 services/observation(双轴正交)
- Source(LAMOST/Gaia/SDSS/DESI)× ProductType(Spectrum/LightCurve/Photometry/Image)
  正交组合,新增源/产品类型为纯加法(OCP)
- ObservationFetcher trait + ObservationRegistry:每个有效组合实现一个 fetcher,
  启动时注册;SDSS specobj/APOGEE 共用 key 按 subtype 二级路由
- cone 缓存逻辑模板方法化(trait 默认方法),消除各源 4 份重复代码
- 多文件 Artifact 模型:一个逻辑产物可含多文件(如 Gaia 光变 G/BP/RP 三波段各一 FITS)
- 统一编排 dispatch.rs:search(仅检索)/ download(检索+下载),支持坐标模式
  (cone→选源→下载)与标识符模式(直按 ID 下载)双输入

【Agent 工具整合】26 → 24
- 新增 find_observation:跨源×跨产品×双模式统一观测下载,取代 find_spectrum
- catalog_operation 升级为 6 合 1(search/describe/query/cone/export/lookup),
  取代独立的 query_vizier + cone_search
- citation_network + library_search 合并为 library.rs

【API 路由】
- 新增 /observation/{search,download,capabilities,list} 命名空间
- 移除 /catalog/{crossmatch,spectrum/download,spectrum/list}
- GET /observation/capabilities 暴露 registry 能力清单,前端动态渲染源/产品/版本
  下拉(不再硬编码各源支持矩阵)

【数据库迁移】
- 新表 observation_cache:新增 product 列 + artifacts_json(多文件产物),无 TTL
  (观测数据不可变,区别于 vizier_query_cache 的 7 天 TTL)
- 20260705140001:spectrum_cache 旧数据迁入 observation_cache,单文件→单元素 artifacts

【前端】
- 新 ObservationPanel(988 行):双视图(检索下载 / 缓存库),选项由 capabilities 动态生成
- 新 useObservation hook、ObservationResultCard、observation/constants、utils/apiError

【安全与韧性加固】
- sessions 锁 Mutex → RwLock(读多写少,降低争用)
- 新增 upload_rate_limiter;login_rate_limiter 容量保护(10000 上限,超限清最旧一半)
- bookmarklet API 密钥 SHA-1 → SHA-256;ADMIN_PASSWORD 长度上限 128
- *_TIMEOUT_SECS / EMBEDDING_DIM 非法值告警并回退默认;DB_POOL_SIZE 可配置(原硬编码 5)
- sqlite-vec 注册逻辑下沉至 utils::register_sqlite_vec_extension
2026-07-07 01:34:02 +08:00

493 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/services/observation/gaia.rs
//
// Gaia ObservationFetcher 实现
//
// 注册的 fetcher
// GaiaSpectrumFetcher —— (Gaia, Spectrum)XP_CONTINUOUS/XP_SAMPLED/RVS
// GaiaLightCurveFetcher —— (Gaia, LightCurve)EPOCH_PHOTOMETRYG/BP/RP 三波段多文件)
//
// 关键修复EPOCH_PHOTOMETRY 的 ZIP 内含 G/BP/RP 三个独立文件,
// extract_all_zip_entries 返回全部而非首个(旧 extract_first_zip_entry 丢失 BP/RP 波段)。
use crate::api::AppState;
use crate::clients::gaia::{GaiaRetrievalType, GaiaSourceRow};
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::io::{Cursor, Read};
use std::time::Duration;
use tracing::{info, warn};
// ── 版本与解析辅助 ──
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GaiaRelease { #[default] Dr3 }
impl GaiaRelease {
pub fn valid_values() -> &'static [&'static str] {
&["dr3"]
}
pub fn dr_str(&self) -> &'static str { "dr3" }
pub fn tap_table(&self) -> &'static str { "gaiadr3.gaia_source" }
pub fn display(&self) -> &'static str { "DR3" }
}
pub fn parse_gaia_release(s: Option<&str>) -> Result<GaiaRelease> {
match s {
None | Some("dr3") => Ok(GaiaRelease::Dr3),
Some(other) => Err(anyhow!("不支持的 Gaia release '{}',可选: {:?}", other, GaiaRelease::valid_values())),
}
}
pub fn parse_gaia_retrieval_type_str(s: Option<&str>) -> Result<GaiaRetrievalType> {
match s.map(|x| x.to_uppercase()).as_deref() {
None => Ok(GaiaRetrievalType::XpContinuous),
Some("XP_CONTINUOUS") => Ok(GaiaRetrievalType::XpContinuous),
Some("XP_SAMPLED") => Ok(GaiaRetrievalType::XpSampled),
Some("EPOCH_PHOTOMETRY") => Ok(GaiaRetrievalType::EpochPhotometry),
Some("RVS") => Ok(GaiaRetrievalType::Rvs),
Some(other) => Err(anyhow!("不支持的 Gaia subtype '{}',可选: {:?}", other, GaiaRetrievalType::valid_values())),
}
}
pub fn parse_gaia_retrieval_type(s: &str) -> Result<GaiaRetrievalType> {
use GaiaRetrievalType::*;
Ok(match s.trim().to_uppercase().as_str() {
"XP_CONTINUOUS" => XpContinuous,
"XP_SAMPLED" => XpSampled,
"EPOCH_PHOTOMETRY" => EpochPhotometry,
"RVS" => Rvs,
other => return Err(anyhow!("不支持的 Gaia retrieval_type '{}',可选: {:?}", other, GaiaRetrievalType::valid_values())),
})
}
fn validate_cone_params(ra: f64, dec: f64, radius_deg: f64) -> Result<()> {
// Gaia TAP/ADQL 无硬性半径上限,但同步查询限 2000 行、大半径易超时
if !(radius_deg > 0.0 && radius_deg <= 30.0) {
return Err(anyhow!(
"检索半径应在 0~30 度之间(不含 0当前: {}。Gaia 主表很大,建议 ≤1° 以内避免同步查询超时",
radius_deg
));
}
if radius_deg > 1.0 {
warn!(
"[Observation] Gaia cone radius {}° 超出建议范围≤1°gaiadr3 主表很大易导致同步查询超时",
radius_deg
);
}
if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) {
return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec));
}
Ok(())
}
fn row_to_candidate(row: &GaiaSourceRow) -> Candidate {
Candidate {
source: Source::Gaia,
source_id: row.source_id.clone(),
label: format!("Gaia {}", row.source_id),
ra: row.ra,
dec: row.dec,
distance: row.distance,
raw: Some(serde_json::to_value(row).unwrap_or(serde_json::Value::Null)),
}
}
/// 通用 Gaia 下载逻辑spectrum 与 lightcurve 共用,仅 retrieval_type 与产品类型不同)
async fn download_gaia_product(
state: &AppState,
source_id: &str,
retrieval_type: GaiaRetrievalType,
product_type: ProductType,
force: bool,
) -> Result<ObservationProduct> {
if source_id.trim().is_empty() {
return Err(anyhow!("source_id 不能为空"));
}
let rt_param = retrieval_type.as_param();
let rt_lower = rt_param.to_lowercase();
let product = ProductSpec::with_subtype(product_type, rt_lower.clone());
let dr = "dr3";
let cache_key = format!("{}|{}|{}", dr, rt_param, source_id);
let source_label = format!("Gaia {} ({})", source_id, rt_param);
// 1) 缓存命中
if !force {
if let Some((artifacts, meta)) = fetch_observation_cache(&state.db, Source::Gaia, &product, &cache_key).await? {
if cached_files_total_size(&state.config.library_dir, &artifacts).is_some() {
info!("[Gaia] 缓存命中 (source_id={}, type={})", source_id, rt_param);
let artifacts = artifacts.into_iter().map(|mut a| {
if a.size_bytes == 0 {
if let Ok(m) = std::fs::metadata(state.config.library_dir.join(&a.file_path)) {
a.size_bytes = m.len() as usize;
}
}
a
}).collect();
return Ok(ObservationProduct {
source: Source::Gaia, product,
source_id: cache_key, source_label, artifacts, source_meta: meta,
});
}
warn!("[Gaia] 缓存记录存在但文件缺失,重新下载 (source_id={})", source_id);
}
}
// 2) DataLink 下载 ZIP + 解包取**全部**文件(修复 EPOCH_PHOTOMETRY 多文件丢失)
tokio::time::sleep(Duration::from_millis(50)).await;
let zip_bytes = state.gaia.download_products(&[source_id.to_string()], retrieval_type, "fits").await?;
let extracted = extract_all_zip_entries(&zip_bytes, source_id)?;
// 3) 落盘
// 光谱:单文件 → Telescope/gaia/spectrum/{rt}/{source_id}.{ext}
// 光变:多文件 → Telescope/gaia/lightcurve/{rt}/{source_id}/{orig_name}
let mut artifacts = Vec::with_capacity(extracted.len());
for ext_file in extracted {
let rel_path = if product_type == ProductType::LightCurve {
let safe_name = ext_file.name.replace('/', "_");
format!("Telescope/gaia/lightcurve/{rt}/{sid}/{name}",
rt = rt_lower, sid = source_id, name = safe_name)
} else {
format!("Telescope/gaia/spectrum/{rt}/{sid}.{ext}",
rt = rt_lower, sid = source_id, ext = ext_file.ext)
};
let size = persist_bytes(&state.config.library_dir, &rel_path, &ext_file.bytes)?;
info!("[Gaia] 已保存 {} ({}B) → {}", ext_file.name, size, rel_path);
artifacts.push(Artifact {
band: parse_band_from_name(&ext_file.name),
original_name: Some(ext_file.name.clone()),
file_path: rel_path,
file_url: String::new(),
file_format: ext_file.ext.to_string(),
size_bytes: size,
cached: false,
});
}
for a in &mut artifacts {
a.file_url = file_url_from_path(&a.file_path);
}
if artifacts.is_empty() {
return Err(anyhow!("Gaia DataLink ZIP 内无可下载文件条目 (source_id={}, type={})", source_id, rt_param));
}
let meta = serde_json::json!({"source_id": source_id, "retrieval_type": rt_param, "dr": dr});
let meta_str = meta.to_string();
if let Err(e) = write_observation_cache(
&state.db, Source::Gaia, &product, &cache_key, None, None,
&artifacts, Some(&meta_str),
).await {
log_write_failure(Source::Gaia.as_str(), product.product.as_str(), e);
}
Ok(ObservationProduct {
source: Source::Gaia, product,
source_id: cache_key, source_label,
artifacts, source_meta: Some(meta),
})
}
// ═══════════════════════════════════════════════════════════════
// ZIP 解包(全量提取,修复历史 EPOCH_PHOTOMETRY 多文件丢失 bug
// ═══════════════════════════════════════════════════════════════
struct ExtractedFile {
name: String,
ext: &'static str,
bytes: Vec<u8>,
}
/// 从 ZIP 字节流中提取**全部**非目录文件条目
///
/// 旧实现 `extract_first_zip_entry` 只取首个文件,丢失了 BP/RP 波段——本函数修复之。
fn extract_all_zip_entries(zip_bytes: &[u8], source_id: &str) -> Result<Vec<ExtractedFile>> {
let cursor = Cursor::new(zip_bytes);
let mut archive = zip::ZipArchive::new(cursor).map_err(|e| anyhow!("Gaia ZIP 解析失败: {}", e))?;
let mut out = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)
.map_err(|e| anyhow!("读取 Gaia ZIP 条目 {} 失败: {}", i, e))?;
if file.is_dir() { continue; }
let name = file.name().to_string();
let mut buf = Vec::new();
file.read_to_end(&mut buf)
.map_err(|e| anyhow!("解压 Gaia ZIP 条目失败: {}", e))?;
if buf.is_empty() { continue; }
let lower = name.to_lowercase();
let ext = if lower.ends_with(".fits") { "fits" }
else if lower.ends_with(".vot") || lower.ends_with(".xml") { "vot" }
else if lower.ends_with(".csv") { "csv" }
else { "fits" };
out.push(ExtractedFile { name, ext, bytes: buf });
}
if out.is_empty() {
return Err(anyhow!("Gaia ZIP 内无可下载文件条目 (source_id={})", source_id));
}
Ok(out)
}
/// 从 Gaia DataLink 文件名中提取波段标签
fn parse_band_from_name(name: &str) -> Option<String> {
let upper = name.to_uppercase();
for band in ["G", "BP", "RP"] {
if upper.contains(&format!("_{}.", band)) || upper.contains(&format!("_{}_", band)) {
return Some(band.to_string());
}
}
None
}
// ═══════════════════════════════════════════════════════════════
// GaiaSpectrumFetcher
// ═══════════════════════════════════════════════════════════════
#[derive(Debug)]
pub struct GaiaSpectrumFetcher;
#[async_trait]
impl ObservationFetcher for GaiaSpectrumFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Gaia, ProductType::Spectrum)
}
fn subtypes(&self) -> &'static [&'static str] {
&["xp_continuous", "xp_sampled", "rvs"]
}
fn releases(&self) -> &'static [&'static str] {
GaiaRelease::valid_values()
}
fn default_release(&self) -> Option<&'static str> {
Some("dr3")
}
fn suggested_max_radius_deg(&self) -> f64 {
// 与 validate_cone_params 的建议范围一致
1.0
}
fn identifier_format(&self) -> Option<&'static str> {
Some("'XP_CONTINUOUS|source_id'(可省略 RT 前缀,默认 XP_CONTINUOUS")
}
async fn cone_search_raw(
&self,
state: &AppState,
ra: f64, dec: f64, radius_deg: f64,
_release: Option<&str>, _subtype: Option<&str>,
) -> Result<Vec<Candidate>> {
validate_cone_params(ra, dec, radius_deg)?;
let rel = parse_gaia_release(_release)?;
let result = state.gaia.cone_search(ra, dec, radius_deg, 50, true, rel).await?;
Ok(result.rows.iter().map(row_to_candidate).collect())
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
subtype: Option<&str>,
) -> Result<Candidate> {
let (rt_param, gaia_sid) = match identifier.split_once('|') {
Some((rt, s)) => (rt, s),
None => ("XP_CONTINUOUS", identifier),
};
let rt = if subtype.is_some() {
parse_gaia_retrieval_type_str(subtype)?
} else {
parse_gaia_retrieval_type(rt_param)?
};
if gaia_sid.trim().is_empty() {
return Err(anyhow!("source_id 不能为空"));
}
Ok(Candidate {
source: Source::Gaia,
source_id: gaia_sid.to_string(),
label: format!("Gaia {}", gaia_sid),
ra: None, dec: None, distance: None,
raw: Some(serde_json::json!({"source_id": gaia_sid, "retrieval_type": rt.as_param()})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
_release: Option<&str>,
subtype: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let source_id = candidate.raw.as_ref()
.and_then(|v| v.get("source_id"))
.and_then(|v| v.as_str())
.unwrap_or(&candidate.source_id);
let rt = if let Some(rt_str) = candidate.raw.as_ref()
.and_then(|v| v.get("retrieval_type"))
.and_then(|v| v.as_str())
{
parse_gaia_retrieval_type(rt_str)?
} else {
parse_gaia_retrieval_type_str(subtype)?
};
download_gaia_product(state, source_id, rt, ProductType::Spectrum, force).await
}
}
// ═══════════════════════════════════════════════════════════════
// GaiaLightCurveFetcherEPOCH_PHOTOMETRY多文件产物
// ═══════════════════════════════════════════════════════════════
#[derive(Debug)]
pub struct GaiaLightCurveFetcher;
#[async_trait]
impl ObservationFetcher for GaiaLightCurveFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Gaia, ProductType::LightCurve)
}
fn subtypes(&self) -> &'static [&'static str] {
&["epoch_photometry"]
}
fn releases(&self) -> &'static [&'static str] {
GaiaRelease::valid_values()
}
fn default_release(&self) -> Option<&'static str> {
Some("dr3")
}
fn suggested_max_radius_deg(&self) -> f64 {
// 与 validate_cone_params 的建议范围一致
1.0
}
fn identifier_format(&self) -> Option<&'static str> {
Some("source_id如 '65214031805717376'")
}
async fn cone_search_raw(
&self,
state: &AppState,
ra: f64, dec: f64, radius_deg: f64,
_release: Option<&str>, _subtype: Option<&str>,
) -> Result<Vec<Candidate>> {
// 光变 cone 复用 Gaia 主表检索(不强制 xp_only因为光变对所有源都有
validate_cone_params(ra, dec, radius_deg)?;
let rel = parse_gaia_release(_release)?;
let result = state.gaia.cone_search(ra, dec, radius_deg, 50, false, rel).await?;
Ok(result.rows.iter().map(row_to_candidate).collect())
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
_subtype: Option<&str>,
) -> Result<Candidate> {
let sid = identifier.trim();
if sid.is_empty() {
return Err(anyhow!("source_id 不能为空"));
}
Ok(Candidate {
source: Source::Gaia,
source_id: sid.to_string(),
label: format!("Gaia {}", sid),
ra: None, dec: None, distance: None,
raw: Some(serde_json::json!({"source_id": sid, "retrieval_type": "EPOCH_PHOTOMETRY"})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
_release: Option<&str>,
_subtype: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let source_id = candidate.raw.as_ref()
.and_then(|v| v.get("source_id"))
.and_then(|v| v.as_str())
.unwrap_or(&candidate.source_id);
download_gaia_product(state, source_id, GaiaRetrievalType::EpochPhotometry, ProductType::LightCurve, force).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[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()); // 超硬性上限
}
#[test]
fn test_parse_band_from_name_epoch() {
assert_eq!(parse_band_from_name("65214031805717376_EPOCH_PHOTOMETRY_G.fits").as_deref(), Some("G"));
assert_eq!(parse_band_from_name("65214031805717376_EPOCH_PHOTOMETRY_BP.fits").as_deref(), Some("BP"));
assert_eq!(parse_band_from_name("65214031805717376_EPOCH_PHOTOMETRY_RP.fits").as_deref(), Some("RP"));
}
#[test]
fn test_parse_band_from_name_xp() {
assert_eq!(parse_band_from_name("65214031805717376_XP_CONTINUOUS.fits"), None);
}
#[test]
fn test_extract_all_zip_entries_single() {
let mut buf = Vec::new();
{
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = zip::write::SimpleFileOptions::default();
writer.start_file("123_XP_CONTINUOUS.fits", opts).unwrap();
writer.write_all(b"SIMPLE").unwrap();
writer.finish().unwrap();
}
let files = extract_all_zip_entries(&buf, "123").unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].ext, "fits");
}
#[test]
fn test_extract_all_zip_entries_multi_band() {
// 历史 bug 验证EPOCH_PHOTOMETRY 三波段,旧实现只取首个 G 波段
let mut buf = Vec::new();
{
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = zip::write::SimpleFileOptions::default();
writer.start_file("123_EPOCH_PHOTOMETRY_G.fits", opts).unwrap();
writer.write_all(b"G_DATA").unwrap();
writer.start_file("123_EPOCH_PHOTOMETRY_BP.fits", opts).unwrap();
writer.write_all(b"BP_DATA").unwrap();
writer.start_file("123_EPOCH_PHOTOMETRY_RP.fits", opts).unwrap();
writer.write_all(b"RP_DATA").unwrap();
writer.finish().unwrap();
}
let files = extract_all_zip_entries(&buf, "123").unwrap();
assert_eq!(files.len(), 3, "EPOCH_PHOTOMETRY 应返回全部 3 个波段文件");
let bands: Vec<_> = files.iter().map(|f| parse_band_from_name(&f.name)).collect();
assert!(bands.contains(&Some("G".into())));
assert!(bands.contains(&Some("BP".into())));
assert!(bands.contains(&Some("RP".into())));
}
}