AstroResearch/src/services/observation/cache.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

325 lines
11 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/cache.rs
//
// 观测数据缓存层 + 落盘辅助
//
// observation_cache 表设计:
// - UNIQUE(source, product, source_id):同一源的同一标识符可同时缓存不同产品
// (如 Gaia 同一 source_id 的光谱与光变分别独立缓存)
// - artifacts_json一个逻辑产物可对应多个物理文件Gaia 光变 G/BP/RP 三波段)
use crate::services::observation::types::{Artifact, ProductSpec, Source};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::path::Path;
use tracing::error;
// ═══════════════════════════════════════════════════════════════
// 1. 缓存命中行类型
// ═══════════════════════════════════════════════════════════════
/// observation_cache 中单条 artifacts 的 JSON 结构
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ArtifactRecord {
path: String,
format: String,
#[serde(default)]
size: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
band: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
original_name: Option<String>,
}
impl From<&ArtifactRecord> for Artifact {
fn from(r: &ArtifactRecord) -> Self {
Artifact {
band: r.band.clone(),
original_name: r.original_name.clone(),
file_path: r.path.clone(),
file_url: crate::services::observation::cache::file_url_from_path(&r.path),
file_format: r.format.clone(),
size_bytes: r.size,
cached: true,
}
}
}
/// 缓存查询结果
#[derive(sqlx::FromRow)]
struct CacheHitRow {
artifacts_json: String,
meta_json: Option<String>,
}
/// observation_cache 列表查询行(对外暴露)
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct ObservationCacheRow {
pub source: String,
pub product: String,
pub source_id: String,
pub ra: Option<f64>,
pub dec: Option<f64>,
pub artifacts_json: String,
pub meta_json: Option<String>,
pub created_at: String,
}
// ═══════════════════════════════════════════════════════════════
// 2. 缓存读写
// ═══════════════════════════════════════════════════════════════
/// 查询单条缓存(按 source + product + source_id返回命中的 artifacts
pub async fn fetch_observation_cache(
pool: &SqlitePool,
source: Source,
product: &ProductSpec,
source_id: &str,
) -> Result<Option<(Vec<Artifact>, Option<serde_json::Value>)>> {
let row = sqlx::query_as::<_, CacheHitRow>(
"SELECT artifacts_json, meta_json FROM observation_cache \
WHERE source = ? AND product = ? AND source_id = ? LIMIT 1",
)
.bind(source.as_str())
.bind(product.product.as_str())
.bind(source_id)
.fetch_optional(pool)
.await
.map_err(|e| anyhow!("查询 observation_cache 失败: {}", e))?;
match row {
Some(r) => {
let records: Vec<ArtifactRecord> = serde_json::from_str(&r.artifacts_json)
.map_err(|e| anyhow!("反序列化 artifacts_json 失败: {}", e))?;
let artifacts: Vec<Artifact> = records.iter().map(Artifact::from).collect();
let meta = r
.meta_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
Ok(Some((artifacts, meta)))
}
None => Ok(None),
}
}
/// 写入/更新缓存条目
pub async fn write_observation_cache(
pool: &SqlitePool,
source: Source,
product: &ProductSpec,
source_id: &str,
ra: Option<f64>,
dec: Option<f64>,
artifacts: &[Artifact],
meta_json: Option<&str>,
) -> Result<()> {
let records: Vec<ArtifactRecord> = artifacts
.iter()
.map(|a| ArtifactRecord {
path: a.file_path.clone(),
format: a.file_format.clone(),
size: a.size_bytes,
band: a.band.clone(),
original_name: a.original_name.clone(),
})
.collect();
let artifacts_json = serde_json::to_string(&records)?;
sqlx::query(
"INSERT OR REPLACE INTO observation_cache \
(source, product, source_id, ra, dec, artifacts_json, meta_json, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
)
.bind(source.as_str())
.bind(product.product.as_str())
.bind(source_id)
.bind(ra)
.bind(dec)
.bind(&artifacts_json)
.bind(meta_json)
.execute(pool)
.await
.map_err(|e| anyhow!("写入 observation_cache 失败: {}", e))?;
Ok(())
}
/// 列出某源某产品的全部已缓存产物
pub async fn list_cached(
pool: &SqlitePool,
source: Source,
product: &ProductSpec,
) -> Result<Vec<ObservationCacheRow>> {
let rows = sqlx::query_as::<_, ObservationCacheRow>(
"SELECT source, product, source_id, ra, dec, artifacts_json, meta_json, created_at \
FROM observation_cache WHERE source = ? AND product = ? \
ORDER BY created_at DESC LIMIT 500",
)
.bind(source.as_str())
.bind(product.product.as_str())
.fetch_all(pool)
.await
.map_err(|e| anyhow!("查询 observation_cache 列表失败: {}", e))?;
Ok(rows)
}
/// 列出全部已缓存产物(不限源/产品)
pub async fn list_all_cached(pool: &SqlitePool) -> Result<Vec<ObservationCacheRow>> {
let rows = sqlx::query_as::<_, ObservationCacheRow>(
"SELECT source, product, source_id, ra, dec, artifacts_json, meta_json, created_at \
FROM observation_cache ORDER BY created_at DESC LIMIT 500",
)
.fetch_all(pool)
.await
.map_err(|e| anyhow!("查询 observation_cache 列表失败: {}", e))?;
Ok(rows)
}
/// 缓存库列表筛选 + 排序参数(服务端分页)
#[derive(Debug, Clone, Default)]
pub struct ListCachedParams {
/// 按数据源过滤lamost/gaia/sdss/desi
pub source: Option<String>,
/// 按产品类型过滤spectrum/lightcurve/photometry/image
pub product: Option<String>,
/// source_id 模糊匹配LIKE %q%
pub q: Option<String>,
/// 排序created默认DESC/ source / product
pub sort: Option<String>,
/// 每页条数(默认 50上限 200
pub limit: Option<i64>,
/// 偏移量
pub offset: Option<i64>,
}
/// 缓存库分页查询结果
#[derive(Debug, Clone, Serialize)]
pub struct CachedPage {
pub items: Vec<ObservationCacheRow>,
pub total: i64,
}
/// 列出已缓存产物(服务端分页 + 筛选 + 排序)
pub async fn list_cached_paged(pool: &SqlitePool, params: &ListCachedParams) -> Result<CachedPage> {
let limit = params.limit.unwrap_or(50).clamp(1, 200);
let offset = params.offset.unwrap_or(0).max(0);
// 动态构造 SQL参数绑定防注入列名/排序方向用白名单)
let mut where_clauses: Vec<String> = Vec::new();
if let Some(s) = &params.source {
where_clauses.push(format!("source = '{}'", s.replace('\'', "''")));
}
if let Some(p) = &params.product {
where_clauses.push(format!("product = '{}'", p.replace('\'', "''")));
}
if let Some(q) = &params.q {
let q = q.replace('\'', "''");
where_clauses.push(format!("source_id LIKE '%{}%'", q));
}
let where_sql = if where_clauses.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_clauses.join(" AND "))
};
let order_sql = match params.sort.as_deref() {
Some("source") => " ORDER BY source ASC, created_at DESC",
Some("product") => " ORDER BY product ASC, created_at DESC",
_ => " ORDER BY created_at DESC",
};
// count
let count_sql = format!("SELECT COUNT(*) as cnt FROM observation_cache{}", where_sql);
let total: i64 = sqlx::query_scalar(&count_sql)
.fetch_one(pool)
.await
.map_err(|e| anyhow!("统计 observation_cache 总数失败: {}", e))?;
// items
let items_sql = format!(
"SELECT source, product, source_id, ra, dec, artifacts_json, meta_json, created_at \
FROM observation_cache{}{} LIMIT ? OFFSET ?",
where_sql, order_sql
);
let items = sqlx::query_as::<_, ObservationCacheRow>(&items_sql)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|e| anyhow!("查询 observation_cache 分页列表失败: {}", e))?;
Ok(CachedPage { items, total })
}
// ═══════════════════════════════════════════════════════════════
// 3. 落盘 + URL 辅助
// ═══════════════════════════════════════════════════════════════
/// 经 /api/files 前缀生成访问 URL
pub fn file_url_from_path(rel_path: &str) -> String {
format!("/api/files/{}", rel_path)
}
/// 落盘字节到 library_dir/<rel_path>,返回字节数
pub fn persist_bytes(library_dir: &Path, rel_path: &str, bytes: &[u8]) -> Result<usize> {
let abs_path = library_dir.join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow!("创建观测数据目录失败 {:?}: {}", parent, e))?;
}
std::fs::write(&abs_path, bytes)
.map_err(|e| anyhow!("写入观测数据文件失败 {:?}: {}", abs_path, e))?;
Ok(bytes.len())
}
/// 校验缓存产物中至少有一个文件真实存在;存在则返回其总大小
pub fn cached_files_total_size(library_dir: &Path, artifacts: &[Artifact]) -> Option<usize> {
let mut total = 0;
for a in artifacts {
let abs_path = library_dir.join(&a.file_path);
match std::fs::metadata(&abs_path) {
Ok(m) => total += m.len() as usize,
Err(_) => {
// 任一文件缺失即视为缓存不完整
return None;
}
}
}
if total == 0 {
None
} else {
Some(total)
}
}
/// 记录缓存写入失败但不中断流程
pub fn log_write_failure(source: &str, product: &str, e: anyhow::Error) {
error!("[Observation] 写入 observation_cache 失败 ({}/{}): {}", source, product, e);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_url_from_path() {
assert_eq!(
file_url_from_path("Telescope/lamost/spectrum/123.fits"),
"/api/files/Telescope/lamost/spectrum/123.fits"
);
}
#[test]
fn test_artifact_record_roundtrip() {
let rec = ArtifactRecord {
path: "Telescope/gaia/lightcurve/123/123_G.fits".into(),
format: "fits".into(),
size: 1024,
band: Some("G".into()),
original_name: Some("123_EPOCH_PHOTOMETRY_G.fits".into()),
};
let json = serde_json::to_string(&rec).unwrap();
let back: ArtifactRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back.path, rec.path);
assert_eq!(back.band.as_deref(), Some("G"));
}
}