AstroResearch/src/services/cds/vizier.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

569 lines
20 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/cds/vizier.rs
//
// VizieR TAP 业务服务层 —— 合并缓存、几何查询、星表发现、导出为一个模块
//
// ┌──────────────────────────────────────────────────────────────┐
// │ 1. 缓存层 query_adql_cached / query_table │
// │ 2. ADQL 安全 sanitize_identifier / escape_string_literal │
// │ 3. 几何查询 cone_searchnearest/all
// │ 4. 星表发现 VizierCatalog { search, describe, lookup } │
// │ 5. 数据导出 rows_to_csv │
// └──────────────────────────────────────────────────────────────┘
//
// 纯通信层HTTP、VOTable/JSON 解析)在 clients::cds::vizier。
// 外部api/、agent tools/、cli统一通过本模块调用。
use crate::clients::ads::AdsClient;
use crate::clients::cds::vizier::{FieldInfo, VizierClient, VizierQueryResult};
use anyhow::{anyhow, Result};
use sha2::{Digest, Sha256};
use sqlx::SqlitePool;
use std::time::Duration;
use tracing::{error, info, warn};
// ═══════════════════════════════════════════════════════════════
// 1. 缓存层
// ═══════════════════════════════════════════════════════════════
const CACHE_TTL_SECS: i64 = 7 * 24 * 3600;
/// 执行 ADQL 查询(带缓存)
pub async fn query_adql_cached(
pool: &SqlitePool,
client: &VizierClient,
adql: &str,
max_records: i64,
) -> Result<VizierQueryResult> {
let query_hash = hash_query(adql, max_records);
if let Some(cached) = fetch_cache(pool, &query_hash).await? {
info!("[VizieR] 缓存命中 (hash={:.12})", query_hash);
return Ok(cached);
}
tokio::time::sleep(Duration::from_millis(50)).await;
let result = client.run_adql(adql, max_records).await?;
if let Err(e) = write_cache(pool, &query_hash, adql, max_records, &result).await {
error!("[VizieR] 写入查询缓存失败: {}", e);
}
Ok(result)
}
/// 执行 ADQL 查询(不经过缓存)
pub async fn query_adql(
client: &VizierClient,
adql: &str,
max_records: i64,
) -> Result<VizierQueryResult> {
client.run_adql(adql, max_records).await
}
/// 便捷:按表名取行
pub async fn query_table(
pool: &SqlitePool,
client: &VizierClient,
table_name: &str,
columns: &[String],
limit: i64,
) -> Result<VizierQueryResult> {
let table = sanitize_identifier(table_name)?;
let cols = if columns.is_empty() {
"*".to_string()
} else {
columns
.iter()
.map(|c| sanitize_identifier(c))
.collect::<Result<Vec<_>>>()?
.join(", ")
};
let table_ref = if table.contains('/') || table.contains(' ') {
format!("\"{}\"", table)
} else {
table
};
let adql = format!("SELECT TOP {} {} FROM {}", limit.max(1), cols, table_ref);
query_adql_cached(pool, client, &adql, limit.max(1)).await
}
// ── 缓存辅助 ──
fn hash_query(adql: &str, max_records: i64) -> String {
let mut hasher = Sha256::new();
hasher.update(adql.as_bytes());
hasher.update(max_records.to_le_bytes());
format!("{:x}", hasher.finalize())
}
#[derive(sqlx::FromRow)]
struct CacheRow {
result_json: String,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn fetch_cache(pool: &SqlitePool, query_hash: &str) -> Result<Option<VizierQueryResult>> {
let row = sqlx::query_as::<_, CacheRow>(
"SELECT result_json, expires_at FROM vizier_query_cache WHERE query_hash = ? LIMIT 1",
)
.bind(query_hash)
.fetch_optional(pool)
.await
.map_err(|e| anyhow!("查询 VizieR 缓存失败: {}", e))?;
match row {
Some(r) => {
if let Some(exp) = r.expires_at {
if exp < chrono::Utc::now() {
warn!("[VizieR] 缓存已过期 (hash={:.12})", query_hash);
return Ok(None);
}
}
let result: VizierQueryResult = serde_json::from_str(&r.result_json)
.map_err(|e| anyhow!("反序列化缓存失败: {}", e))?;
Ok(Some(result))
}
None => Ok(None),
}
}
async fn write_cache(
pool: &SqlitePool,
query_hash: &str,
adql: &str,
max_records: i64,
result: &VizierQueryResult,
) -> Result<()> {
let result_json = serde_json::to_string(result)?;
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(CACHE_TTL_SECS);
sqlx::query(
"INSERT OR REPLACE INTO vizier_query_cache (query_hash, adql, max_records, result_json, created_at, expires_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)",
)
.bind(query_hash)
.bind(adql)
.bind(max_records)
.bind(&result_json)
.bind(expires_at)
.execute(pool)
.await
.map_err(|e| anyhow!("写入 VizieR 缓存失败: {}", e))?;
Ok(())
}
// ═══════════════════════════════════════════════════════════════
// 2. ADQL 安全
// ═══════════════════════════════════════════════════════════════
/// 校验 ADQL 标识符(表名/列名白名单字母、数字、_、.、/
pub fn sanitize_identifier(name: &str) -> Result<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(anyhow!("标识符不能为空"));
}
let valid = trimmed
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '/');
if !valid {
return Err(anyhow!(
"标识符包含非法字符(仅允许字母、数字、下划线、点、斜杠): {}",
trimmed
));
}
Ok(trimmed.to_string())
}
/// 转义字符串字面量(单引号 → 双单引号)
pub fn escape_string_literal(s: &str) -> String {
s.replace('\'', "''")
}
// ═══════════════════════════════════════════════════════════════
// 3. 几何查询Cone Search / Cross-Match
// ═══════════════════════════════════════════════════════════════
/// 锥形检索
///
/// strategy="nearest" 时按角距离排序返回最近的源strategy="all" 时无序返回全部。
pub async fn cone_search(
pool: &SqlitePool,
client: &VizierClient,
ra: f64,
dec: f64,
radius_deg: f64,
table_name: &str,
max_records: i64,
nearest: bool,
) -> Result<VizierQueryResult> {
let table = sanitize_identifier(table_name)?;
let table_ref = format!("\"{}\"", table);
if !(0.0..=20.0).contains(&radius_deg) {
return Err(anyhow!("检索半径应在 0~20 度之间,当前: {}", radius_deg));
}
if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) {
return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec));
}
let adql = if nearest {
format!(
"SELECT TOP {} *, DISTANCE(POINT('ICRS', ra, dec), POINT('ICRS', {}, {})) AS separation_deg FROM {} WHERE 1=CONTAINS(POINT('ICRS', ra, dec), CIRCLE('ICRS', {}, {}, {})) ORDER BY separation_deg ASC",
max_records.max(1), ra, dec, table_ref, ra, dec, radius_deg
)
} else {
format!(
"SELECT TOP {} * FROM {} WHERE 1=CONTAINS(POINT('ICRS', ra, dec), CIRCLE('ICRS', {}, {}, {}))",
max_records.max(1), table_ref, ra, dec, radius_deg
)
};
query_adql_cached(pool, client, &adql, max_records.max(1)).await
}
// ═══════════════════════════════════════════════════════════════
// 4. 星表发现VizierCatalog 统一入口)
// ═══════════════════════════════════════════════════════════════
#[derive(Debug, Clone)]
pub struct CatalogEntry {
pub table_name: String,
pub description: String,
pub schema_name: String,
pub nrows: Option<i64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ColumnInfo {
pub column_name: String,
pub datatype: String,
pub unit: Option<String>,
pub description: Option<String>,
pub ucd: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ExportResult {
pub csv: String,
pub row_count: usize,
pub column_count: usize,
}
#[derive(Debug)]
pub struct ExportToFileResult {
pub path: std::path::PathBuf,
pub row_count: usize,
pub column_count: usize,
pub size_bytes: usize,
}
pub struct VizierCatalog<'a> {
pub pool: &'a SqlitePool,
pub vizier: &'a VizierClient,
pub ads: Option<&'a AdsClient>,
}
impl<'a> VizierCatalog<'a> {
pub fn new(pool: &'a SqlitePool, vizier: &'a VizierClient) -> Self {
Self {
pool,
vizier,
ads: None,
}
}
pub fn with_ads(pool: &'a SqlitePool, vizier: &'a VizierClient, ads: &'a AdsClient) -> Self {
Self {
pool,
vizier,
ads: Some(ads),
}
}
/// 按关键词搜索星表目录
pub async fn search(&self, keyword: &str, limit: usize) -> Result<Vec<CatalogEntry>> {
let kw_escaped = keyword.replace('\'', "''");
let top = (limit * 2).min(100);
let adql = format!(
"SELECT TOP {top} table_name, description, schema_name, nrows \
FROM TAP_SCHEMA.tables \
WHERE table_name LIKE '%{kw}%' OR description LIKE '%{kw}%' \
ORDER BY nrows DESC",
top = top,
kw = kw_escaped,
);
let result = query_adql_cached(self.pool, self.vizier, &adql, top as i64).await?;
Ok(parse_catalog_rows(&result.rows, limit))
}
/// 查看表的列结构
pub async fn describe(&self, table_name: &str) -> Result<Vec<ColumnInfo>> {
let tn = table_name.replace('\'', "''");
let adql = format!(
"SELECT TOP 500 column_name, datatype, unit, description, ucd \
FROM TAP_SCHEMA.columns WHERE table_name = '\"{tn}\"' OR table_name = '{tn}'",
);
let result = query_adql_cached(self.pool, self.vizier, &adql, 500).await?;
Ok(parse_column_rows(&result.rows))
}
/// 下载数据并导出为 CSV
pub async fn export(&self, adql: &str, max_records: i64) -> Result<ExportResult> {
let result = query_adql_cached(self.pool, self.vizier, adql, max_records).await?;
let csv = rows_to_csv(&result.fields, &result.rows);
Ok(ExportResult {
row_count: result.row_count,
column_count: result.fields.len(),
csv,
})
}
/// 构建导出 ADQLtable + columns → ADQL
pub fn build_export_adql(table: &str, columns: &str, limit: i64) -> Result<String> {
let table_ref = if table.contains('/') || table.contains(' ') {
format!("\"{}\"", sanitize_identifier(table)?)
} else {
sanitize_identifier(table)?
};
Ok(format!("SELECT TOP {} {} FROM {}", limit, columns, table_ref))
}
/// 导出星表数据到 CSV 文件
///
/// 优先使用 adql 参数;若未提供则从 table + columns 构建。
/// 文件保存到 `{library_dir}/vizier/{catalog_name}/{timestamp}.csv`。
pub async fn export_to_file(
&self,
library_dir: &str,
adql: Option<&str>,
table: Option<&str>,
columns: Option<&str>,
limit: i64,
) -> Result<ExportToFileResult> {
let adql_str = if let Some(a) = adql {
a.to_string()
} else if let Some(t) = table {
let cols = columns.unwrap_or("*");
Self::build_export_adql(t, cols, limit)?
} else {
return Err(anyhow!("export 需要 'adql' 或 'table' 参数"));
};
info!(
"[VizieR:export] ADQL: {}",
adql_str.chars().take(150).collect::<String>()
);
let export = self.export(&adql_str, limit).await?;
let catalog_name = table.unwrap_or("adql_query");
let safe_name: String = catalog_name
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let output_path = std::path::PathBuf::from(library_dir)
.join("vizier")
.join(&safe_name)
.join(format!("{}.csv", ts));
if let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow!("创建目录失败: {}", e))?;
}
tokio::fs::write(&output_path, &export.csv).await
.map_err(|e| anyhow!("写入文件失败: {}", e))?;
Ok(ExportToFileResult {
path: output_path,
row_count: export.row_count,
column_count: export.column_count,
size_bytes: export.csv.len(),
})
}
/// 通过文献 bibcode 查找关联的 VizieR 表
pub async fn lookup(&self, bibcode: &str, limit: usize) -> Result<Vec<CatalogEntry>> {
let ads = self.ads.ok_or_else(|| anyhow!("lookup 需要 ADS 客户端"))?;
let query = format!("bibcode:{}", bibcode);
let docs = ads.search(&query, 0, 1, "score desc").await?;
let doc = docs
.first()
.ok_or_else(|| anyhow!("ADS 未找到 bibcode: {}", bibcode))?;
let data_links = match doc.data {
Some(ref links) if !links.is_empty() => links,
_ => return Ok(Vec::new()),
};
let mut entries = Vec::new();
for link in data_links {
if let Some(table_name) = extract_table_from_ads_url(link) {
let adql = format!(
"SELECT TOP 1 table_name, description, schema_name, nrows \
FROM TAP_SCHEMA.tables WHERE table_name = '\"{}\"' OR table_name = '{}'",
table_name.replace('"', ""),
table_name.replace('\'', "''"),
);
if let Ok(result) = query_adql_cached(self.pool, self.vizier, &adql, 1).await {
entries.extend(parse_catalog_rows(&result.rows, 1));
}
}
}
entries.truncate(limit);
Ok(entries)
}
}
fn parse_catalog_rows(rows: &[Vec<serde_json::Value>], limit: usize) -> Vec<CatalogEntry> {
rows.iter()
.filter_map(|row| {
if row.len() < 4 {
return None;
}
Some(CatalogEntry {
table_name: row[0].as_str()?.trim_matches('"').to_string(),
description: row[1].as_str().unwrap_or("").to_string(),
schema_name: row[2].as_str().unwrap_or("").to_string(),
nrows: row[3].as_f64().map(|v| v as i64),
})
})
.take(limit)
.collect()
}
fn parse_column_rows(rows: &[Vec<serde_json::Value>]) -> Vec<ColumnInfo> {
rows.iter()
.filter_map(|row| {
if row.len() < 5 {
return None;
}
Some(ColumnInfo {
column_name: row[0].as_str()?.trim_matches('"').to_string(),
datatype: row[1].as_str().unwrap_or("unknown").to_string(),
unit: row[2].as_str().map(|s| s.to_string()),
description: row[3].as_str().map(|s| s.to_string()),
ucd: row[4].as_str().map(|s| s.to_string()),
})
})
.collect()
}
// ═══════════════════════════════════════════════════════════════
// 5. 数据导出
// ═══════════════════════════════════════════════════════════════
pub fn rows_to_csv(columns: &[FieldInfo], rows: &[Vec<serde_json::Value>]) -> String {
let mut csv = String::new();
let headers: Vec<&str> = columns.iter().map(|f| f.name.as_str()).collect();
csv.push_str(&headers.join(","));
csv.push('\n');
for row in rows {
let cells: Vec<String> = row
.iter()
.map(|v| match v {
serde_json::Value::Null => String::new(),
serde_json::Value::String(s) => {
if s.contains(',') || s.contains('\n') || s.contains('"') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.clone()
}
}
other => other.to_string(),
})
.collect();
csv.push_str(&cells.join(","));
csv.push('\n');
}
csv
}
/// 从 ADS data 链接中提取 VizieR 表名
pub fn extract_table_from_ads_url(url: &str) -> Option<String> {
if let Some(pos) = url.find("-source=") {
let table = &url[pos + 8..];
let table = table.split_whitespace().next()?;
let table = table.trim_matches('"').trim_matches('\'');
return Some(table.to_string());
}
if let Some(pos) = url.find("VizieR") {
let after = &url[pos..];
if let Some(qpos) = after.find('?') {
let query = &after[qpos + 1..];
for param in query.split('&') {
if let Some(val) = param.strip_prefix("-source=") {
return Some(val.to_string());
}
}
}
}
if url.contains('/') && !url.starts_with("http") {
return Some(url.to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_identifier_valid() {
assert_eq!(sanitize_identifier("gaiadr3").unwrap(), "gaiadr3");
assert_eq!(
sanitize_identifier("I/355/gaiadr3").unwrap(),
"I/355/gaiadr3"
);
}
#[test]
fn test_sanitize_identifier_rejects() {
assert!(sanitize_identifier("ra; DROP TABLE").is_err());
assert!(sanitize_identifier("' OR '1'='1").is_err());
}
#[test]
fn test_escape_string_literal() {
assert_eq!(escape_string_literal("O'Brien"), "O''Brien");
}
#[test]
fn test_hash_query_stable() {
let h1 = hash_query("SELECT 1", 10);
let h2 = hash_query("SELECT 1", 10);
let h3 = hash_query("SELECT 1", 20);
assert_eq!(h1, h2);
assert_ne!(h1, h3);
}
#[test]
fn test_extract_table_from_ads_url() {
assert_eq!(
extract_table_from_ads_url(
"https://vizier.cds.unistra.fr/viz-bin/VizieR?-source=I/355/gaiadr3"
),
Some("I/355/gaiadr3".to_string())
);
assert_eq!(
extract_table_from_ads_url("I/355/gaiadr3"),
Some("I/355/gaiadr3".to_string())
);
assert_eq!(extract_table_from_ads_url("http://example.com"), None);
}
#[tokio::test]
#[ignore = "需要网络访问"]
async fn test_live_vizier_tap() {
let result = query_adql(
&VizierClient::new("https://tapvizier.cds.unistra.fr/TAPVizieR/tap", 60).unwrap(),
r#"SELECT TOP 3 DR3Name, RA_ICRS FROM "I/355/gaiadr3""#,
3,
)
.await
.unwrap();
assert_eq!(result.row_count, 3);
}
}