From a156252bc3a4f651f5bbf455a3421ab26fae0cde Mon Sep 17 00:00:00 2001 From: Asfmq <2696428814@qq.com> Date: Mon, 6 Jul 2026 00:07:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20VizieR=20=E6=98=9F?= =?UTF-8?q?=E8=A1=A8=E6=A3=80=E7=B4=A2=E4=B8=8E=20LAMOST/Gaia/SDSS/DESI=20?= =?UTF-8?q?=E8=B7=A8=E6=BA=90=E5=85=89=E8=B0=B1=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增天文观测数据获取能力,覆盖星表查询与一维光谱下载两大场景: 星表检索(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),与星表功能联动 --- .env.example | 5 + Cargo.lock | 139 +- Cargo.toml | 2 +- .../components/agent/AgentMetricsPanel.tsx | 8 + .../src/components/agent/AuditLogViewer.tsx | 4 + .../components/agent/SpecialToolRenderers.tsx | 260 ++++ .../src/components/agent/ToolCallCard.tsx | 58 + .../src/components/agent/toolDisplayNames.ts | 8 + docs/catalog.md | 236 ++++ .../20260703120000_add_vizier_cache.sql | 14 + .../20260703130000_add_spectrum_cache.sql | 16 + src/agent/runtime/streaming_executor.rs | 22 + src/agent/tools/astro/mod.rs | 2 + .../tools/astro/research/catalog_operation.rs | 300 ++++ .../tools/astro/research/find_spectrum.rs | 236 ++++ src/agent/tools/astro/research/mod.rs | 6 + src/agent/tools/astro/research/target.rs | 2 +- src/agent/tools/astro/research/vizier.rs | 305 ++++ src/agent/tools/mod.rs | 13 +- src/agent/tools/todo.rs | 22 + src/api/catalog.rs | 309 ++++ src/api/mod.rs | 21 + src/api/targets.rs | 6 +- src/bin/cli.rs | 32 +- src/clients/ads.rs | 8 +- src/clients/cds/mod.rs | 18 + src/clients/cds/sesame.rs | 381 +++++ src/clients/cds/vizier.rs | 340 +++++ src/clients/desi/mod.rs | 506 +++++++ src/clients/gaia/mod.rs | 632 +++++++++ src/clients/lamost/mod.rs | 415 ++++++ src/clients/llm/chat.rs | 2 +- src/clients/mod.rs | 6 + src/clients/sdss/mod.rs | 779 ++++++++++ src/clients/vo/mod.rs | 291 ++++ src/lib.rs | 70 + src/main.rs | 41 + src/services/batch/asset/process.rs | 2 +- src/services/cds/mod.rs | 12 + src/services/{ => cds}/target.rs | 421 +----- src/services/cds/vizier.rs | 510 +++++++ src/services/mod.rs | 3 +- src/services/spectra/common.rs | 1251 +++++++++++++++++ src/services/spectra/desi.rs | 261 ++++ src/services/spectra/gaia.rs | 365 +++++ src/services/spectra/lamost.rs | 380 +++++ src/services/spectra/mod.rs | 29 + src/services/spectra/sdss.rs | 476 +++++++ 48 files changed, 8837 insertions(+), 388 deletions(-) create mode 100644 docs/catalog.md create mode 100644 migrations/20260703120000_add_vizier_cache.sql create mode 100644 migrations/20260703130000_add_spectrum_cache.sql create mode 100644 src/agent/tools/astro/research/catalog_operation.rs create mode 100644 src/agent/tools/astro/research/find_spectrum.rs create mode 100644 src/agent/tools/astro/research/vizier.rs create mode 100644 src/api/catalog.rs create mode 100644 src/clients/cds/mod.rs create mode 100644 src/clients/cds/sesame.rs create mode 100644 src/clients/cds/vizier.rs create mode 100644 src/clients/desi/mod.rs create mode 100644 src/clients/gaia/mod.rs create mode 100644 src/clients/lamost/mod.rs create mode 100644 src/clients/sdss/mod.rs create mode 100644 src/clients/vo/mod.rs create mode 100644 src/services/cds/mod.rs rename src/services/{ => cds}/target.rs (51%) create mode 100644 src/services/cds/vizier.rs create mode 100644 src/services/spectra/common.rs create mode 100644 src/services/spectra/desi.rs create mode 100644 src/services/spectra/gaia.rs create mode 100644 src/services/spectra/lamost.rs create mode 100644 src/services/spectra/mod.rs create mode 100644 src/services/spectra/sdss.rs diff --git a/.env.example b/.env.example index 360157d..0f4eab3 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,11 @@ # NASA ADS API Token(从 ui.adsabs.harvard.edu 申请) ADS_API_KEY=your_ads_api_key_here +# VizieR TAP 星表查询服务(CDS 官方,公开访问无需认证,可选) +# VIZIER_TAP_URL=https://tapvizier.cds.unistra.fr/TAPVizieR/tap +# TAP 查询超时秒数(部分复杂 ADQL 较慢,默认 60) +# VIZIER_TIMEOUT_SECS=60 + # 大语言模型配置(兼容 OpenAI 接口协议) LLM_API_KEY=your_llm_api_key_here LLM_API_BASE=https://api.openai.com/v1 diff --git a/Cargo.lock b/Cargo.lock index 5416c62..cf8abab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,7 +480,7 @@ checksum = "2c5e60b8c8d282c86360cab651ded04ab0335a7b5390c8d34145cbeab8cacf5f" dependencies = [ "bitflags 2.13.0", "btls-sys", - "foreign-types", + "foreign-types 0.5.0", "libc", "openssl-macros", ] @@ -760,6 +760,16 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1231,6 +1241,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1238,7 +1257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1252,6 +1271,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1741,6 +1766,22 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2377,6 +2418,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2565,6 +2623,20 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + [[package]] name = "openssl-macros" version = "0.1.1" @@ -2576,6 +2648,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "outref" version = "0.5.2" @@ -3056,10 +3146,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3070,6 +3162,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -3235,6 +3328,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3251,6 +3353,29 @@ dependencies = [ "untrusted", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.26.0" @@ -4084,6 +4209,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/Cargo.toml b/Cargo.toml index 0521b09..867d032 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ tower-http = { version = "0.5", features = ["cors", "fs", "trace", "set-header"] sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "cookies", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "cookies", "rustls-tls", "native-tls"] } dotenvy = "0.15" quick-xml = { version = "0.31", features = ["serialize"] } anyhow = "1.0" diff --git a/dashboard/src/components/agent/AgentMetricsPanel.tsx b/dashboard/src/components/agent/AgentMetricsPanel.tsx index 405a68d..e274bc9 100644 --- a/dashboard/src/components/agent/AgentMetricsPanel.tsx +++ b/dashboard/src/components/agent/AgentMetricsPanel.tsx @@ -31,6 +31,10 @@ const TOOL_LABELS: Record = { get_paper_content: '获取内容', rag_search: 'RAG 检索', query_target: '天体查询', + query_vizier: 'VizieR 星表', + cone_search: '锥形检索', + find_spectrum: '光谱检索下载', + catalog_operation: '星表操作', save_note: '保存笔记', todo_write: '任务管理', compress_context: '上下文压缩', @@ -63,6 +67,10 @@ const CATEGORY_COLORS: Record = { get_paper_content: 'bg-emerald-100 text-emerald-700 border-emerald-200', rag_search: 'bg-violet-100 text-violet-700 border-violet-200', query_target: 'bg-amber-100 text-amber-700 border-amber-200', + query_vizier: 'bg-violet-100 text-violet-700 border-violet-200', + cone_search: 'bg-violet-100 text-violet-700 border-violet-200', + find_spectrum: 'bg-slate-200 text-slate-700 border-slate-300', + catalog_operation: 'bg-violet-100 text-violet-700 border-violet-200', save_note: 'bg-teal-100 text-teal-700 border-teal-200', todo_write: 'bg-orange-100 text-orange-700 border-orange-200', compress_context: 'bg-rose-100 text-rose-700 border-rose-200', diff --git a/dashboard/src/components/agent/AuditLogViewer.tsx b/dashboard/src/components/agent/AuditLogViewer.tsx index 6e53cd7..dd9f0d5 100644 --- a/dashboard/src/components/agent/AuditLogViewer.tsx +++ b/dashboard/src/components/agent/AuditLogViewer.tsx @@ -34,6 +34,10 @@ function getToolDisplayName(name: string | null): string { get_paper_content: '获取内容', rag_search: 'RAG 检索', query_target: '天体查询', + query_vizier: 'VizieR 星表', + cone_search: '锥形检索', + find_spectrum: '光谱检索下载', + catalog_operation: '星表操作', save_note: '保存笔记', todo_write: '任务管理', compress_context: '压缩上下文', diff --git a/dashboard/src/components/agent/SpecialToolRenderers.tsx b/dashboard/src/components/agent/SpecialToolRenderers.tsx index 34d1601..c3e32d5 100644 --- a/dashboard/src/components/agent/SpecialToolRenderers.tsx +++ b/dashboard/src/components/agent/SpecialToolRenderers.tsx @@ -11,6 +11,8 @@ import { AlertTriangle, Cpu, Check, + Table as TableIcon, + Database, } from 'lucide-react'; import type { StandardPaper } from '../../types'; import type { useCitations } from '../../hooks/useCitations'; @@ -608,3 +610,261 @@ export function BgTaskProgressCard({ task }: BgTaskProgressCardProps) { ); } + +// ========================================== +// 5. VizieR 星表查询结果卡片 (query_vizier / cone_search) +// ========================================== +interface VizierFieldInfo { + name: string; + description?: string; + unit?: string; + datatype?: string; +} + +interface VizierResultCardProps { + metadata: { + table_name?: string; + fields: VizierFieldInfo[]; + rows: unknown[][]; + row_count: number; + truncated: boolean; + }; +} + +// 最多展示行数,避免渲染过多 DOM 节点 +const MAX_DISPLAY_ROWS = 50; + +export function VizierResultCard({ metadata }: VizierResultCardProps) { + const { table_name, fields = [], rows = [], row_count, truncated } = metadata; + const displayRows = rows.slice(0, MAX_DISPLAY_ROWS); + const hiddenCount = rows.length - displayRows.length; + + const renderCell = (val: unknown): string => { + if (val === null || val === undefined) return '—'; + if (typeof val === 'number') { + // 数字保留合理精度 + return Number.isInteger(val) ? String(val) : val.toFixed(6).replace(/\.?0+$/, ''); + } + if (typeof val === 'boolean') return val ? 'true' : 'false'; + return String(val); + }; + + const cdsUrl = table_name + ? `https://vizier.cds.unistra.fr/viz-bin/VizieR-3?-source=${encodeURIComponent(table_name)}` + : 'https://vizier.cds.unistra.fr/'; + + return ( +
+ {/* 标题栏 */} +
+
+ + VizieR 星表查询结果 +
+ + CDS + + +
+ + {/* 元信息 */} +
+ {table_name && ( + + + {table_name} + + )} + + {row_count} 行 + + {fields.length} 列 + {truncated && ( + + + 已截断 + + )} +
+ + {/* 表格 */} + {fields.length === 0 || rows.length === 0 ? ( +

(无数据行)

+ ) : ( +
+ + + + {fields.map((f, i) => ( + + ))} + + + + {displayRows.map((row, ri) => ( + + {fields.map((_, ci) => ( + + ))} + + ))} + +
+ {f.name} + {f.unit && ( + [{f.unit}] + )} +
+ {renderCell(row[ci])} +
+
+ )} + + {/* 底部提示 */} + {hiddenCount > 0 && ( +

+ 已展示前 {MAX_DISPLAY_ROWS} 行,{hiddenCount} 行已折叠 +

+ )} +
+ ); +} + + +// ========================================== +// 6. 统一光谱下载结果卡片 (find_spectrum) +// ========================================== +interface SpectrumDownloadItem { + survey: string; + source_label: string; + file_path: string; + file_url: string; + file_format: string; + size_bytes: number; + cached: boolean; +} + +interface FindSpectrumCardProps { + metadata: { + survey: string; + ra?: number; + dec?: number; + radius_deg?: number; + matched_count: number; + downloads: SpectrumDownloadItem[]; + failures: { source_label: string; error: string }[]; + }; +} + +const SURVEY_THEME: Record = { + lamost: { badge: 'bg-amber-100 text-amber-700', label: 'LAMOST' }, + gaia: { badge: 'bg-sky-100 text-sky-700', label: 'Gaia' }, + sdss: { badge: 'bg-indigo-100 text-indigo-700', label: 'SDSS' }, + desi: { badge: 'bg-emerald-100 text-emerald-700', label: 'DESI' }, +}; + +export function FindSpectrumCard({ metadata }: FindSpectrumCardProps) { + const { survey, ra, dec, radius_deg, matched_count, downloads = [], failures = [] } = metadata; + const theme = SURVEY_THEME[survey] || SURVEY_THEME.lamost; + const hasResult = downloads.length > 0 || failures.length > 0; + + return ( +
+ {/* 标题栏 */} +
+
+ + 统一光谱检索 +
+ + {theme.label} + +
+ + {/* 查询信息 */} +
+ {ra !== undefined && dec !== undefined && ( + + ra={ra.toFixed(4)} dec={dec.toFixed(4)} + {radius_deg !== undefined ? ` r=${radius_deg}°` : ''} + + )} + + 命中 {matched_count} 条 + + {downloads.length > 0 && ( + + + 下载 {downloads.length} + + )} + {failures.length > 0 && ( + + + 失败 {failures.length} + + )} +
+ + {/* 下载结果列表 */} + {downloads.map((d, i) => ( +
+
+ {d.source_label} + {d.cached ? ( + + 缓存 + + ) : ( + 新下载 + )} +
+
+ + {d.file_format.toUpperCase()} · {d.size_bytes > 1024 ? `${(d.size_bytes / 1024).toFixed(1)} KB` : `${d.size_bytes} B`} + + + + FITS + +
+
+ ))} + + {/* 失败列表 */} + {failures.map((f, i) => ( +
+
+ + {f.source_label} +
+

{f.error}

+
+ ))} + + {!hasResult && ( +

(该区域无光谱覆盖)

+ )} +
+ ); +} diff --git a/dashboard/src/components/agent/ToolCallCard.tsx b/dashboard/src/components/agent/ToolCallCard.tsx index a2dbf93..a1a894b 100644 --- a/dashboard/src/components/agent/ToolCallCard.tsx +++ b/dashboard/src/components/agent/ToolCallCard.tsx @@ -10,6 +10,8 @@ import { PaperListCard, TodoTaskCard, BgTaskProgressCard, + VizierResultCard, + FindSpectrumCard, } from './SpecialToolRenderers'; import type { StandardPaper } from '../../types'; import type { useCitations } from '../../hooks/useCitations'; @@ -94,6 +96,62 @@ export function ToolCallCard({ ); } + // 1b. VizieR 星表查询 / Cone Search + if ( + (name === 'query_vizier' || name === 'cone_search') && + metadata && + typeof metadata === 'object' && + 'fields' in metadata && + 'rows' in metadata + ) { + return ( + + ); + } + + // 1c. 统一光谱下载(find_spectrum) + if ( + name === 'find_spectrum' && + metadata && + typeof metadata === 'object' && + 'survey' in metadata && + 'downloads' in metadata + ) { + return ( + + ); + } + // 2. 文献检索 / 馆藏检索 if ( (name === 'search_papers' || name === 'search_local_library') && diff --git a/dashboard/src/components/agent/toolDisplayNames.ts b/dashboard/src/components/agent/toolDisplayNames.ts index 22977c0..768a103 100644 --- a/dashboard/src/components/agent/toolDisplayNames.ts +++ b/dashboard/src/components/agent/toolDisplayNames.ts @@ -31,6 +31,14 @@ export function getToolDisplayName(name: string): string { return '检索馆藏知识库'; case 'query_target': return '查询天体物理参数 (CDS)'; + case 'query_vizier': + return '查询 VizieR 星表'; + case 'cone_search': + return '锥形检索天体'; + case 'find_spectrum': + return '检索下载光谱'; + case 'catalog_operation': + return '星表操作'; case 'save_note': return '保存文献手札'; // Agent 控制工具 diff --git a/docs/catalog.md b/docs/catalog.md new file mode 100644 index 0000000..651f810 --- /dev/null +++ b/docs/catalog.md @@ -0,0 +1,236 @@ +# VizieR 星表查询指南 + +## 表名结构 + +VizieR 表名三段式:`分类/编号/子集` + +``` +I/355/gaiadr3 +│ │ │ +│ │ └─ 子集:具体数据子集或版本 +│ └───── 编号:CDS 分配的顺序号(纯索引) +└──────── 分类:数据类型 +``` + +### 分类前缀 + +| 前缀 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `I/` | 天体测量 | 位置、自行、视差 | `I/355/gaiadr3`(Gaia DR3) | +| `II/` | 测光 | 多波段测光数据 | `II/246/out`(2MASS) | +| `III/` | 光谱 | 已发布光谱 flux 数据 | `III/272`(LAMOST DR6) | +| `IV/` | 交叉证认 | 不同星表匹配结果 | `IV/14/trp2mass` | +| `V/` | 综合 | 大型巡天综合目录 | `V/162/dr11l`(LAMOST)、`V/154/sdss16`(SDSS) | +| `B/` | 文献(旧) | 早期文献伴生数据 | `B/cb/cbdata` | +| `J/` | 期刊表 | 论文伴生数据表 | `J/ApJ/836/5/table1` | + +### 为什么 LAMOST 和 SDSS 是 `V/` 不是 `III/` + +`III/` 要求表中直接包含光谱 flux 数组(波长-流量对)。LAMOST 和 SDSS 的 VizieR 表只存了测光/元数据/参数,原始光谱 FITS 文件在各自数据中心,所以归入 `V/`(综合巡天目录)。 + +### `J/` 期刊表命名 + +``` +J/ApJ/836/5/table1 +│ │ │ │ │ +│ │ │ │ └─ 论文中表编号 +│ │ │ └───── 卷号 +│ │ └───────── 文章编号 +│ └────────────── 期刊缩写 +└───────────────── 固定前缀 J/ +``` + +常用期刊:`ApJ`(天体物理学报)、`A&A`(天文与天体物理)、`MNRAS`(皇家天文学会月刊)、`AJ`(天文期刊)、`ApJS`(天体物理学报增刊) + +--- + +## Gaia DR3 + +### 主表 `I/355/gaiadr3`(18 亿源,225 列) + +**天体测量(核心):** + +| 列名 | 类型 | 单位 | 说明 | +|------|------|------|------| +| `DR3Name` | CHAR(28) | — | 唯一标识(如 `Gaia DR3 162109245908224`) | +| `Source` | BIGINT | — | 源 ID | +| `RA_ICRS` | DOUBLE | deg | 赤经(J2016.0) | +| `DE_ICRS` | DOUBLE | deg | 赤纬(J2016.0) | +| `e_RA_ICRS` | REAL | mas | 赤经误差 | +| `e_DE_ICRS` | REAL | mas | 赤纬误差 | +| `Plx` | DOUBLE | mas | 视差 | +| `e_Plx` | REAL | mas | 视差误差 | +| `pmRA` | DOUBLE | mas/yr | 赤经方向自行 | +| `pmDE` | DOUBLE | mas/yr | 赤纬方向自行 | +| `e_pmRA` | REAL | mas/yr | 自行误差 | +| `e_pmDE` | DOUBLE | mas/yr | 自行误差 | + +**测光(G/BP/RP 三波段):** + +| 列名 | 类型 | 单位 | 说明 | +|------|------|------|------| +| `Gmag` | DOUBLE | mag | G 波段平均星等 | +| `e_Gmag` | DOUBLE | mag | G 星等误差 | +| `BPmag` | DOUBLE | mag | BP 波段星等 | +| `RPmag` | DOUBLE | mag | RP 波段星等 | +| `BP-RP` | DOUBLE | mag | BP-RP 色指数 | +| `BP-G` | DOUBLE | mag | BP-G 色指数 | +| `E(BP/RP)` | DOUBLE | mag | BP/RP 过量因子(消光指示) | + +**其他重要列:** 径向速度 (`RV`)、光谱类型、银河系参数、变星标记等,完整 225 列。 + +### 光谱相关表 + +| 表名 | 行数 | 内容 | 含 flux? | +|------|------|------|----------| +| `I/355/rvsmean` | 24 亿 | RVS 均采样光谱 | 是(RVS 波段) | +| `I/355/xpsummary` | 2.2 亿 | BP/RP 光谱摘要参数 | 否(参数) | +| `I/355/spectra` | 3400 万 | 光谱视图 | 否(链接到 FITS) | + +--- + +## LAMOST + +### 主表 `V/162/dr11l`(DR11 LRS 低分辨率,1200 万条光谱) + +**观测标识:** + +| 列名 | 类型 | 说明 | +|------|------|------| +| `ObsID` | INT | 光谱观测 ID(下载 FITS 的关键字) | +| `Target` | CHAR(19) | 目标名称(JHHMMSS.ss+DDMMSS.s) | +| `PlanID` | VARCHAR | 观测计划名 | +| `spID` | SMALLINT | 光谱仪编号(1-16) | +| `FiberID` | SMALLINT | 光纤编号(1-250) | +| `FiberType` | CHAR(5) | 光纤类型(Obj/Sky/Filler) | + +**坐标与时间:** + +| 列名 | 类型 | 单位 | 说明 | +|------|------|------|------| +| `RAJ2000` | DOUBLE | deg | 纤维指向赤经 | +| `DEJ2000` | DOUBLE | deg | 纤维指向赤纬 | +| `Obs_Date` | DOUBLE | — | 儒略日 | +| `LMJD` / `MJD` | INT | d | 修正儒略日 | + +**信噪比(5 波段):** + +| 列名 | 说明 | +|------|------| +| `snru` / `snrg` / `snrr` / `snri` / `snrz` | u/g/r/i/z 波段信噪比 | + +**光谱分类与红移:** + +| 列名 | 说明 | +|------|------| +| `Class` | 大类(STAR / GALAXY / QSO) | +| `subClass` | 光谱子型(如 K4、G8、A0) | +| `z` | 光谱红移 | +| `e_z` | 红移误差 | + +**测光(PanSTARRS + Gaia):** + +| 列名 | 说明 | +|------|------| +| `gmag` / `rmag` / `imag` / `zmag` / `ymag` | PanSTARRS 五波段星等 | +| `Gmag` | Gaia G 波段星等 | +| `GaiaDR3` | Gaia DR3 源 ID | +| `PanSTARRS` | PanSTARRS objID | + +**光纤信息:** `offsets`(偏移标记)、`FiberMask`(光纤问题)、`fNFlux`(是否有归一化流量)、`nMRS`(中分辨率光谱数) + +### 参数表(需单独查) + +| 表名 | 行数 | 包含参数 | +|------|------|---------| +| `J/ApJ/836/5/table1` | 45 万 | Teff、logg、[Fe/H]、[α/M] | +| `J/ApJS/266/40/catalog` | 140 万 | MRS 恒星参数+化学丰度 | +| `J/ApJS/271/60/table1` | 306 万 | G 型星光谱参数 | + +--- + +## SDSS + +### 主表 `V/154/sdss16`(DR16 测光,12.3 亿源,50 列) + +**标识与分类:** + +| 列名 | 类型 | 说明 | +|------|------|------| +| `objID` | BIGINT | SDSS 唯一标识 | +| `RA_ICRS` | DOUBLE | 赤经(ICRS) | +| `DE_ICRS` | DOUBLE | 赤纬(ICRS) | +| `class` | SMALLINT | 天体类型(3=星系、6=恒星) | +| `mode` | SMALLINT | 照相模式(1=主探测) | +| `clean` | SMALLINT | 测光质量标记 | + +**PSF 星等(5 波段):** + +| 列名 | 单位 | 说明 | +|------|------|------| +| `upmag` / `gpmag` / `rpmag` / `ipmag` / `zpmag` | mag | u/g/r/i/z PSF 星等 | +| `e_upmag` ... `e_zpmag` | mag | 对应误差 | + +**Petrosian 星等与半径:** + +| 列名 | 说明 | +|------|------| +| `uPmag` ... `zPmag` | Petrosian 星等(5 波段) | +| `uPrad` ... `zPrad` | Petrosian 半径(角秒) | + +**形态拟合(De Vaucouleurs):** + +| 列名 | 说明 | +|------|------| +| `udVrad` ... `zdVrad` | 拟合半径 | +| `udVell` ... `zdVell` | 轴比 (b/a) | + +--- + +## 三巡天对比 + +| | Gaia DR3 | LAMOST DR11 | SDSS DR16 | +|---|---|---|---| +| **VizieR 表** | `I/355/gaiadr3` | `V/162/dr11l` | `V/154/sdss16` | +| **分类** | `I/` 天体测量 | `V/` 综合巡天 | `V/` 综合巡天 | +| **数据量** | 18 亿源 | 1200 万光谱 | 12.3 亿源 | +| **核心数据** | 位置+自行+视差+测光 | S/N+光谱型+红移 | 五波段测光+形态 | +| **恒星参数** | 无 | 无(另查 `J/` 表) | 无(另查 `J/` 表) | +| **原始光谱** | RVS 有 flux;BP/RP 需下载 FITS | 需用 ObsID 下载 FITS | 需从 SkyServer 下载 | + +--- + +## 光谱数据获取方式 + +| 巡天 | 参数表(TAP 可查) | 原始光谱(需下载) | +|------|-------------------|-------------------| +| Gaia DR3 | `I/355/xpsummary`(BP/RP 摘要) | `I/355/spectra` → 链接到 FITS | +| Gaia DR3 RVS | — | `I/355/rvsmean`(含 flux) | +| LAMOST | `J/ApJ/836/5/table1` 等 | 用 `ObsID` 从 lamost.org 下载 | +| SDSS | `J/ApJ/901/93/table1` 等 | 从 SkyServer spectra 下载 | + +--- + +## 查询示例 + +```sql +-- Gaia DR3:视差 > 10mas 的亮星 +SELECT TOP 100 DR3Name, RA_ICRS, DE_ICRS, Plx, Gmag, BP-RP +FROM "I/355/gaiadr3" +WHERE Plx > 10 + +-- LAMOST:S/N_r > 50 的 K 型星 +SELECT TOP 100 ObsID, RAJ2000, DEJ2000, subClass, z, snrr +FROM "V/162/dr11l" +WHERE snrr > 50 AND Class = 'STAR' AND subClass LIKE 'K%' + +-- LAMOST 恒星参数 +SELECT TOP 100 RA, DEC, Teff, logg, FEH, ALPHA +FROM "J/ApJ/836/5/table1" +WHERE Teff BETWEEN 5000 AND 6000 + +-- SDSS:r 波段 < 18 等的恒星 +SELECT TOP 100 objID, RA_ICRS, DE_ICRS, rpmag, class +FROM "V/154/sdss16" +WHERE rpmag < 18 AND class = 6 +``` diff --git a/migrations/20260703120000_add_vizier_cache.sql b/migrations/20260703120000_add_vizier_cache.sql new file mode 100644 index 0000000..8ceb1f2 --- /dev/null +++ b/migrations/20260703120000_add_vizier_cache.sql @@ -0,0 +1,14 @@ +-- VizieR TAP / VO 查询结果本地缓存 +-- 对齐 paper_targets 的缓存范式:JSON 列存复杂结构、UNIQUE 约束、索引 +CREATE TABLE IF NOT EXISTS vizier_query_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + query_hash TEXT NOT NULL, -- sha1(adql + max_records) + adql TEXT NOT NULL, + max_records INTEGER, + result_json TEXT NOT NULL, -- 序列化后的 VizierQueryResult + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME, -- TTL 过期时间(NULL = 永不过期) + UNIQUE(query_hash) +); + +CREATE INDEX IF NOT EXISTS idx_vizier_cache_hash ON vizier_query_cache(query_hash); diff --git a/migrations/20260703130000_add_spectrum_cache.sql b/migrations/20260703130000_add_spectrum_cache.sql new file mode 100644 index 0000000..e93904b --- /dev/null +++ b/migrations/20260703130000_add_spectrum_cache.sql @@ -0,0 +1,16 @@ +-- 光谱下载缓存表(LAMOST/SDSS/Gaia 共用,按 source+source_id 去重) +-- 光谱文件不可变,故无 expires_at(与 vizier_query_cache 的 TTL 缓存不同) +-- file_path 存储「相对 library_dir」的路径,经 /api/files 前缀对外暴露 +CREATE TABLE IF NOT EXISTS spectrum_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, -- 'lamost' | 'sdss' | 'gaia' + source_id TEXT NOT NULL, -- obsid / plate-mjd-fiber / source_id + ra REAL, + dec REAL, + file_path TEXT NOT NULL, -- 相对 library_dir 的路径,如 Spectra/lamost/438809089.fits + file_format TEXT NOT NULL, -- 'fits' + meta_json TEXT, -- z/class/designation 等元信息 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(source, source_id) +); +CREATE INDEX IF NOT EXISTS idx_spectrum_cache_source ON spectrum_cache(source, source_id); diff --git a/src/agent/runtime/streaming_executor.rs b/src/agent/runtime/streaming_executor.rs index 3e8a762..e9c27b9 100644 --- a/src/agent/runtime/streaming_executor.rs +++ b/src/agent/runtime/streaming_executor.rs @@ -553,6 +553,7 @@ mod tests { use crate::api::AppState; use crate::clients::ads::AdsClient; use crate::clients::arxiv::ArxivClient; + use crate::clients::cds::vizier::VizierClient; use crate::clients::llm::{EmbeddingClient, LlmClient}; use crate::clients::qiniu::QiniuClient; use crate::services::batch::asset::AssetBatchStatus; @@ -572,6 +573,8 @@ mod tests { EmbeddingClient::new("tk".into(), "http://localhost".into(), "e".into()).unwrap(); let ads = AdsClient::new("tk".into()).unwrap(); let arxiv = ArxivClient::new().unwrap(); + let vizier = + VizierClient::new("https://tapvizier.cds.unistra.fr/TAPVizieR/tap", 60).unwrap(); let qiniu = QiniuClient::new( "ak".into(), "sk".into(), @@ -586,6 +589,25 @@ mod tests { qiniu, ads, arxiv, + vizier, + lamost: crate::clients::lamost::LamostClient::new( + "https://www.lamost.org", + 60, + ) + .unwrap(), + gaia: crate::clients::gaia::GaiaClient::new( + "https://gea.esac.esa.int/tap-server/tap", + "https://gea.esac.esa.int/data-server", + 90, + ) + .unwrap(), + sdss: crate::clients::sdss::SdssClient::new("https://datalab.noirlab.edu/tap/sync", 90) + .unwrap(), + desi: crate::clients::desi::DesiClient::new( + "https://datalab.noirlab.edu/tap/sync", + 120, + ) + .unwrap(), llm: llm.clone(), medium_llm: llm.clone(), fast_llm: llm.clone(), diff --git a/src/agent/tools/astro/mod.rs b/src/agent/tools/astro/mod.rs index dff6b93..1ca82ef 100644 --- a/src/agent/tools/astro/mod.rs +++ b/src/agent/tools/astro/mod.rs @@ -17,6 +17,7 @@ pub use system::process::ProcessPaperTool; pub use system::search::SearchPapersTool; // 研究级工具 +pub use research::catalog_operation::CatalogOperationTool; pub use research::citation_network::GetCitationNetworkTool; pub use research::library_search::SearchLocalLibraryTool; pub use research::metadata::GetPaperMetadataTool; @@ -24,6 +25,7 @@ pub use research::note::SaveNoteTool; pub use research::paper::{GetPaperContentTool, GetPaperOutlineTool}; pub use research::rag::RagSearchTool; pub use research::target::QueryTargetTool; +pub use research::vizier::{ConeSearchTool, QueryVizierTool}; // 以下工具已合并入 ProcessPaperTool,不再独立导出: // - DownloadPaperTool (合并入 process_paper) diff --git a/src/agent/tools/astro/research/catalog_operation.rs b/src/agent/tools/astro/research/catalog_operation.rs new file mode 100644 index 0000000..0e72fe5 --- /dev/null +++ b/src/agent/tools/astro/research/catalog_operation.rs @@ -0,0 +1,300 @@ +// src/agent/tools/astro/research/catalog_operation.rs +// +// CatalogOperationTool —— 星表操作统一工具 +// 合并 search_catalogs / describe_table / export_table 为一个工具,通过 action 参数分发。 +// 参照 process_paper 的 tasks 数组模式,但这里用单 action 字符串(一次只做一个操作)。 + +use async_trait::async_trait; +use serde_json::json; +use tracing::info; + +use crate::agent::tools::{AgentTool, ToolContext, ToolOutput}; + +pub struct CatalogOperationTool; + +#[async_trait] +impl AgentTool for CatalogOperationTool { + fn name(&self) -> &str { + "catalog_operation" + } + + fn description(&self) -> &str { + "VizieR 星表操作统一工具。通过 action 参数选择操作:\ + (1) search — 按关键词搜索星表目录,返回表名和描述;\ + (2) describe — 查看指定表的列结构(字段名、类型、单位);\ + (3) export — 下载表数据保存为 CSV 文件;\ + (4) lookup — 通过文献 bibcode 查找关联的 VizieR 数据表。\ + 不确定表名时先用 search,不确定列名时先用 describe。" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["search", "describe", "export", "lookup"], + "description": "操作类型" + }, + "keyword": { + "type": "string", + "description": "search 时的搜索关键词,如 'Gaia DR3'、'exoplanet'、'LAMOST'" + }, + "table": { + "type": "string", + "description": "describe/export 时的 VizieR 表名,如 'I/355/gaiadr3'" + }, + "adql": { + "type": "string", + "description": "export 时的自定义 ADQL 查询(与 table 二选一)" + }, + "columns": { + "type": "string", + "description": "export + table 模式下指定列(逗号分隔,默认 *)" + }, + "limit": { + "type": "integer", + "description": "search 时的最大返回条数(默认 10),或 export 时的最大行数(默认 100)" + }, + "bibcode": { + "type": "string", + "description": "lookup 时的 ADS bibcode,如 '2020A&A...638A.102H'" + }, + "output_path": { + "type": "string", + "description": "export 时的保存路径(可选,默认自动生成)" + } + }, + "required": ["action"] + }) + } + + fn group(&self) -> &str { + "as:research" + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + fn is_readonly(&self) -> bool { + false + } + + async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput { + let action = match args.get("action").and_then(|v| v.as_str()) { + Some(a) => a, + None => return ToolOutput::error("缺少必需参数 'action'"), + }; + + let state = &ctx.app_state; + + match action { + "search" => self.do_search(state, &args).await, + "describe" => self.do_describe(state, &args).await, + "export" => self.do_export(state, &args).await, + "lookup" => self.do_lookup(state, &args).await, + other => ToolOutput::error(format!( + "未知 action '{}',仅支持: search, describe, export, lookup", + other + )), + } + } +} + +impl CatalogOperationTool { + async fn do_search( + &self, + state: &crate::api::AppState, + args: &serde_json::Value, + ) -> ToolOutput { + let keyword = match args.get("keyword").and_then(|v| v.as_str()) { + Some(k) => k, + None => return ToolOutput::error("search 需要 'keyword' 参数"), + }; + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(10) + .clamp(1, 30) as usize; + + let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier); + let results = match catalog.search(keyword, limit).await { + Ok(r) => r, + Err(e) => return ToolOutput::error(format!("搜索失败: {}", e)), + }; + + if results.is_empty() { + return ToolOutput::success(format!("未找到与 '{}' 匹配的星表", keyword), json!([])); + } + + let mut content = format!( + "搜索 '{}' 匹配到 {} 个星表(按数据量降序):\n\n", + keyword, + results.len() + ); + let items: Vec = results + .iter() + .map(|entry| { + let nrows_str = entry.nrows.map(|n| format!("{} 行", n)).unwrap_or_else(|| "行数未知".into()); + content.push_str(&format!("- `{}` — {} ({})\n", entry.table_name, entry.description, nrows_str)); + json!({"table": entry.table_name, "description": entry.description, "nrows": entry.nrows}) + }) + .collect(); + + ToolOutput::success(content, json!(items)) + } + + async fn do_describe( + &self, + state: &crate::api::AppState, + args: &serde_json::Value, + ) -> ToolOutput { + let table = match args.get("table").and_then(|v| v.as_str()) { + Some(t) => t, + None => return ToolOutput::error("describe 需要 'table' 参数"), + }; + + let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier); + let columns = match catalog.describe(table).await { + Ok(c) => c, + Err(e) => return ToolOutput::error(format!("查询表结构失败: {}", e)), + }; + + if columns.is_empty() { + return ToolOutput::success(format!("表 '{}' 未找到列定义", table), json!([])); + } + + let mut content = format!("表 `{}` 共 {} 列:\n\n", table, columns.len()); + let items: Vec = columns + .iter() + .map(|col| { + let unit_str = col.unit.as_deref().unwrap_or(""); + let desc_str = col.description.as_deref().unwrap_or(""); + content.push_str(&format!("- `{}` ({}) {} — {}\n", col.column_name, col.datatype, unit_str, desc_str)); + json!({"name": col.column_name, "datatype": col.datatype, "unit": col.unit, "description": col.description}) + }) + .collect(); + + ToolOutput::success(content, json!(items)) + } + + async fn do_export( + &self, + state: &crate::api::AppState, + args: &serde_json::Value, + ) -> ToolOutput { + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(100) + .clamp(1, 5000); + + let adql = if let Some(adql) = args.get("adql").and_then(|v| v.as_str()) { + adql.to_string() + } else if let Some(table) = args.get("table").and_then(|v| v.as_str()) { + let columns = args.get("columns").and_then(|v| v.as_str()).unwrap_or("*"); + let table_ref = if table.contains('/') || table.contains(' ') { + format!("\"{}\"", table) + } else { + table.to_string() + }; + format!("SELECT TOP {} {} FROM {}", limit, columns, table_ref) + } else { + return ToolOutput::error("export 需要 'adql' 或 'table' 参数"); + }; + + info!( + "[CatalogOp:export] ADQL: {}", + adql.chars().take(150).collect::() + ); + + let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier); + let export_result = match catalog.export(&adql, limit).await { + Ok(r) => r, + Err(e) => return ToolOutput::error(format!("查询失败: {}", e)), + }; + + // 确定保存路径 + let output_path = if let Some(p) = args.get("output_path").and_then(|v| v.as_str()) { + std::path::PathBuf::from(p) + } else { + let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + std::path::PathBuf::from(format!("vizier_export_{}.csv", ts)) + }; + + if let Some(parent) = output_path.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + return ToolOutput::error(format!("创建目录失败: {}", e)); + } + } + if let Err(e) = tokio::fs::write(&output_path, &export_result.csv).await { + return ToolOutput::error(format!("写入文件失败: {}", e)); + } + + let content = format!( + "已导出 {} 行数据到 `{}`\n文件大小: {}", + export_result.row_count, + output_path.display(), + export_result.csv.len(), + ); + + ToolOutput::success( + content, + json!({ + "path": output_path.to_str(), + "rows": export_result.row_count, + "columns": export_result.column_count, + }), + ) + } + + async fn do_lookup( + &self, + state: &crate::api::AppState, + args: &serde_json::Value, + ) -> ToolOutput { + let bibcode = match args.get("bibcode").and_then(|v| v.as_str()) { + Some(b) => b, + None => return ToolOutput::error("lookup 需要 'bibcode' 参数"), + }; + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(10) + .clamp(1, 30) as usize; + + let catalog = crate::services::cds::vizier::VizierCatalog::with_ads( + &state.db, + &state.vizier, + &state.ads, + ); + let results = match catalog.lookup(bibcode, limit).await { + Ok(r) => r, + Err(e) => return ToolOutput::error(format!("查找失败: {}", e)), + }; + + if results.is_empty() { + return ToolOutput::success( + format!("文献 '{}' 未在 CDS/VizieR 中找到关联数据表", bibcode), + json!([]), + ); + } + + let mut content = format!( + "文献 '{}' 关联 {} 个 VizieR 数据表:\n\n", + bibcode, + results.len() + ); + let items: Vec = results + .iter() + .map(|entry| { + let nrows_str = entry.nrows.map(|n| format!("{} 行", n)).unwrap_or_default(); + content.push_str(&format!("- `{}` — {} ({})\n", entry.table_name, entry.description, nrows_str)); + json!({"table": entry.table_name, "description": entry.description, "nrows": entry.nrows}) + }) + .collect(); + + ToolOutput::success(content, json!(items)) + } +} diff --git a/src/agent/tools/astro/research/find_spectrum.rs b/src/agent/tools/astro/research/find_spectrum.rs new file mode 100644 index 0000000..4fce09d --- /dev/null +++ b/src/agent/tools/astro/research/find_spectrum.rs @@ -0,0 +1,236 @@ +// src/agent/tools/astro/research/find_spectrum.rs +// +// FindSpectrumTool —— 统一光谱下载工具(跨 LAMOST/Gaia/SDSS) +// +// 唯一的光谱工具,取代历史的三源分立工具。支持两种模式: +// - 坐标模式(默认):给 ra/dec/radius + survey + strategy,自动 cone 检索 → 选源 → 下载 +// - 标识符模式:给 survey + source_ids(VizieR 交叉证认得到的源标识),直接下载 +// +// 源标识格式: +// LAMOST: obsid 数字,如 "438809089" +// Gaia: "XP_CONTINUOUS|source_id"(可省略 RT 前缀,默认 XP_CONTINUOUS) +// SDSS: "run2d-plate-mjd-fiberid",如 "26-2225-53729-439" + +use async_trait::async_trait; +use serde_json::json; +use tracing::info; + +use crate::agent::tools::{AgentTool, ToolContext, ToolOutput}; +use crate::services::spectra::{FindStrategy, SpectrumRequest, SpectrumSurvey}; + +pub struct FindSpectrumTool; + +#[async_trait] +impl AgentTool for FindSpectrumTool { + fn name(&self) -> &str { + "find_spectrum" + } + + fn description(&self) -> &str { + "下载光谱(跨 LAMOST/Gaia/SDSS)。支持两种模式:\n\ + (1) 坐标模式(默认):给 ra/dec/radius + survey + strategy,自动 cone 检索并下载;\n\ + (2) 标识符模式:给 survey + source_ids,直接按源标识下载(跳过检索)。\n\ + - survey: lamost(低分辨率光学光谱)/ gaia(BP/RP 光谱)/ sdss(SDSS+BOSS+eBOSS 光谱)\n\ + - strategy: nearest(默认,最近一条)/ all(全部命中)\n\ + - source_ids(标识符模式)格式:\n\ + LAMOST=obsid数字 如 '438809089';\n\ + Gaia='XP_CONTINUOUS|6521...'(可省略RT前缀);\n\ + SDSS='run2d-plate-mjd-fiberid' 如 '26-2225-53729-439'\n\ + 通过 VizieR 查询星表交叉匹配也能得到各源的标识符,再用标识符模式下载。\n\ + 结果自动缓存,重复查询同一坐标/标识不会重复下载。" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "survey": { + "type": "string", + "enum": ["lamost", "gaia", "sdss", "desi"], + "description": "数据源:lamost / gaia / sdss / desi" + }, + "ra": { + "type": "number", + "description": "赤经 RA(度,J2000/ICRS)。坐标模式必填" + }, + "dec": { + "type": "number", + "description": "赤纬 Dec(度,J2000/ICRS)。坐标模式必填" + }, + "radius_deg": { + "type": "number", + "description": "检索半径(度),坐标模式用,默认 0.1,范围 0~5", + "default": 0.1 + }, + "strategy": { + "type": "string", + "enum": ["nearest", "all"], + "description": "选源策略(坐标模式):nearest(默认)/ all", + "default": "nearest" + }, + "source_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "源标识列表(标识符模式)。提供时切换到标识符模式,忽略 ra/dec/radius/strategy" + }, + "release": { + "type": "string", + "description": "数据发布版本(可选)。LAMOST: dr5/dr6/.../dr11(默认dr10);Gaia: dr3(默认);SDSS: dr16/dr17/dr18/dr19(默认dr17);DESI: dr1(默认)/edr" + }, + "data_type": { + "type": "string", + "description": "数据类型(可选)。LAMOST: lrs(低分辨率,默认)/mrs(中分辨率);Gaia: xp_continuous(默认)/xp_sampled/epoch_photometry/rvs;SDSS: spec(光学,默认)/apstar(APOGEE合并星谱)/aspcap(ASPCAP输出)" + }, + "force": { + "type": "boolean", + "description": "是否强制重新下载(忽略缓存),默认 false", + "default": false + } + }, + "required": ["survey"] + }) + } + + fn group(&self) -> &str { + "as:research" + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + fn is_readonly(&self) -> bool { + false + } + + async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput { + let state = &ctx.app_state; + + let survey_str = match args.get("survey").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolOutput::error("缺少必需参数 'survey'(lamost/gaia/sdss)"), + }; + let survey = match survey_str.to_lowercase().as_str() { + "lamost" => SpectrumSurvey::Lamost, + "gaia" => SpectrumSurvey::Gaia, + "sdss" => SpectrumSurvey::Sdss, + "desi" => SpectrumSurvey::Desi, + other => { + return ToolOutput::error(format!( + "不支持的 survey '{}',可选: lamost / gaia / sdss / desi", + other + )) + } + }; + let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); + let release = args + .get("release") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let data_type = args + .get("data_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 标识符模式 vs 坐标模式 + let request = + if let Some(ids) = args.get("source_ids").and_then(|v| v.as_array()) { + let source_ids: Vec = ids + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + if source_ids.is_empty() { + return ToolOutput::error("标识符模式下 source_ids 不能为空"); + } + info!( + "[FindSpectrum] by_id survey={} count={}", + survey.display(), + source_ids.len() + ); + SpectrumRequest::ByIdentifier { + survey, + source_ids, + release, + data_type, + } + } else { + let ra = + match args.get("ra").and_then(|v| v.as_f64()) { + Some(v) => v, + None => return ToolOutput::error( + "坐标模式缺少必需参数 'ra'(赤经,度),或改用 source_ids 标识符模式", + ), + }; + let dec = + match args.get("dec").and_then(|v| v.as_f64()) { + Some(v) => v, + None => return ToolOutput::error( + "坐标模式缺少必需参数 'dec'(赤纬,度),或改用 source_ids 标识符模式", + ), + }; + let radius = args + .get("radius_deg") + .and_then(|v| v.as_f64()) + .unwrap_or(0.1); + let strategy = match args.get("strategy").and_then(|v| v.as_str()) { + Some("all") => FindStrategy::All, + _ => FindStrategy::Nearest, + }; + info!( + "[FindSpectrum] by_coords survey={} ra={} dec={} radius={}° strategy={:?}", + survey.display(), + ra, + dec, + radius, + strategy + ); + SpectrumRequest::ByCoordinates { + survey, + ra, + dec, + radius_deg: radius, + strategy, + release, + data_type, + } + }; + + match crate::services::spectra::download_spectrum(state, &request, force).await { + Ok(batch) => { + let content = render_batch(&batch); + ToolOutput::success(content, json!(batch)) + } + Err(e) => ToolOutput::error(format!("光谱下载失败: {}", e)), + } + } +} + +/// 渲染 DownloadBatch 为可读文本 +fn render_batch(b: &crate::services::spectra::DownloadBatch) -> String { + let mut content = format!("{} 光谱下载", b.survey.display()); + if let (Some(ra), Some(dec), Some(r)) = (b.ra, b.dec, b.radius_deg) { + content.push_str(&format!("(ra={}, dec={}, radius={}°)", ra, dec, r)); + } + content.push_str(&format!(":命中 {} 条\n", b.matched_count)); + + if b.downloads.is_empty() && b.failures.is_empty() { + content.push_str("(无光谱覆盖或无匹配)\n"); + return content; + } + + for d in &b.downloads { + content.push_str(&format!( + "\n✓ 已下载({}): {}\n 文件: {}\n URL: {}\n 格式: {},大小: {} 字节\n", + if d.cached { "缓存" } else { "新下载" }, + d.source_label, + d.file_path, + d.file_url, + d.file_format, + d.size_bytes, + )); + } + for f in &b.failures { + content.push_str(&format!("\n✗ 下载失败 {}: {}\n", f.source_label, f.error)); + } + content +} diff --git a/src/agent/tools/astro/research/mod.rs b/src/agent/tools/astro/research/mod.rs index aaae744..38fcb84 100644 --- a/src/agent/tools/astro/research/mod.rs +++ b/src/agent/tools/astro/research/mod.rs @@ -1,18 +1,24 @@ // src/agent/tools/astro/research/mod.rs // 研究级工具:科研人员消费本地数据进行分析 +pub mod catalog_operation; pub mod citation_network; +pub mod find_spectrum; pub mod library_search; pub mod metadata; pub mod note; pub mod paper; pub mod rag; pub mod target; +pub mod vizier; +pub use catalog_operation::CatalogOperationTool; pub use citation_network::GetCitationNetworkTool; +pub use find_spectrum::FindSpectrumTool; pub use library_search::SearchLocalLibraryTool; pub use metadata::GetPaperMetadataTool; pub use note::SaveNoteTool; pub use paper::{GetPaperContentTool, GetPaperOutlineTool}; pub use rag::RagSearchTool; pub use target::QueryTargetTool; +pub use vizier::{ConeSearchTool, QueryVizierTool}; diff --git a/src/agent/tools/astro/research/target.rs b/src/agent/tools/astro/research/target.rs index b456192..52f29b0 100644 --- a/src/agent/tools/astro/research/target.rs +++ b/src/agent/tools/astro/research/target.rs @@ -54,7 +54,7 @@ impl AgentTool for QueryTargetTool { info!("[QueryTarget] 查询天体: {}", object_name); let state = &ctx.app_state; - match crate::services::target::query_target_cached( + match crate::services::cds::target::query_target_cached( &state.db, &object_name, None, diff --git a/src/agent/tools/astro/research/vizier.rs b/src/agent/tools/astro/research/vizier.rs new file mode 100644 index 0000000..1933943 --- /dev/null +++ b/src/agent/tools/astro/research/vizier.rs @@ -0,0 +1,305 @@ +// src/agent/tools/astro/research/vizier.rs +// +// QueryVizierTool —— VizieR TAP 星表查询(自由 ADQL + 便捷表查询) +// ConeSearchTool —— 锥形检索(按坐标查近邻天体) +// +// 对齐 QueryTargetTool 范式:单元结构体 + AgentTool 实现 + ctx.app_state 调 service + +use async_trait::async_trait; +use serde_json::json; +use tracing::info; + +use crate::agent::tools::{AgentTool, ToolContext, ToolOutput}; +use crate::clients::cds::vizier::VizierQueryResult; + +/// 渲染查询结果为 Markdown 表格(前 N 行) +fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> String { + let mut content = String::new(); + + if let Some(ref table) = result.table_name { + content.push_str(&format!("来源表: {}\n", table)); + } + content.push_str(&format!( + "共 {} 行({})\n\n", + result.row_count, + if result.truncated { + "已截断,存在更多结果" + } else { + "完整结果" + } + )); + + if result.fields.is_empty() || result.rows.is_empty() { + content.push_str("(无数据行)\n"); + return content; + } + + // 表头 + let headers: Vec<&str> = result.fields.iter().map(|f| f.name.as_str()).collect(); + content.push_str(&format!("| {} |\n", headers.join(" | "))); + content.push_str(&format!( + "| {} |\n", + headers + .iter() + .map(|_| "---") + .collect::>() + .join(" | ") + )); + + // 表体(限制预览行数) + let show = result.rows.len().min(preview_rows); + for row in result.rows.iter().take(show) { + let cells: Vec = row + .iter() + .map(|v| match v { + serde_json::Value::Null => "—".to_string(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }) + .collect(); + content.push_str(&format!("| {} |\n", cells.join(" | "))); + } + + if result.rows.len() > preview_rows { + content.push_str(&format!( + "\n(已省略 {} 行,完整数据见结构化输出)\n", + result.rows.len() - preview_rows + )); + } + + // 列单位提示 + let units: Vec<&str> = result + .fields + .iter() + .filter_map(|f| f.unit.as_deref()) + .collect(); + if !units.is_empty() { + content.push_str(&format!("\n字段单位: {}\n", units.join(", "))); + } + + content +} + +// ── QueryVizierTool ── + +pub struct QueryVizierTool; + +#[async_trait] +impl AgentTool for QueryVizierTool { + fn name(&self) -> &str { + "query_vizier" + } + + fn description(&self) -> &str { + "通过 VizieR TAP 服务查询天文星表数据,支持两种模式:\ + (1) 自由 ADQL 查询——传入 'adql' 参数执行标准 ADQL 语句;\ + (2) 便捷表查询——传入 'table_name' + 可选 'columns' + 'limit' 直接取行。\ + 适用于:获取天体的精确测光/天体测量参数、查询星表中的近邻天体、交叉证认、批量拉取某类样本。\ + 结果自动缓存 7 天。不确定表名时先用 search_catalogs 搜索。\ + ADQL 语法示例:SELECT TOP 10 ra, dec FROM \"I/355/gaiadr3\" WHERE parallax > 10" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "adql": { + "type": "string", + "description": "自由 ADQL 查询语句(与 table_name 二选一)。如 SELECT TOP 10 * FROM \"I/355/gaiadr3\"" + }, + "table_name": { + "type": "string", + "description": "VizieR 表名(便捷模式,与 adql 二选一),如 'I/355/gaiadr3'(Gaia DR3)、'J/AJ/165/8/table2'" + }, + "columns": { + "type": "string", + "description": "需要返回的列名(逗号分隔),为空时返回所有列" + }, + "limit": { + "type": "integer", + "description": "最大返回行数(默认 20,上限 2000)", + "default": 20 + } + } + }) + } + + fn group(&self) -> &str { + "as:research" + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + fn is_readonly(&self) -> bool { + true + } + + async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput { + let state = &ctx.app_state; + + // 解析参数:adql 优先,否则走 table_name 便捷模式 + let result = if let Some(adql) = args.get("adql").and_then(|v| v.as_str()) { + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(20) + .clamp(1, 2000); + info!( + "[QueryVizier] ADQL 查询 (limit={}): {}", + limit, + adql.chars().take(150).collect::() + ); + crate::services::cds::vizier::query_adql_cached(&state.db, &state.vizier, adql, limit) + .await + } else if let Some(table) = args.get("table_name").and_then(|v| v.as_str()) { + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(20) + .clamp(1, 2000); + let columns: Vec = args + .get("columns") + .and_then(|v| v.as_str()) + .map(|c| c.split(',').map(|s| s.trim().to_string()).collect()) + .unwrap_or_default(); + info!("[QueryVizier] 表查询 table={} limit={}", table, limit); + crate::services::cds::vizier::query_table( + &state.db, + &state.vizier, + table, + &columns, + limit, + ) + .await + } else { + return ToolOutput::error( + "需要提供 'adql'(自由 ADQL)或 'table_name'(便捷表查询)参数之一", + ); + }; + + match result { + Ok(r) => { + let content = render_result_table(&r, 20); + ToolOutput::success(content, json!(r)) + } + Err(e) => ToolOutput::error(format!("VizieR 查询失败: {}", e)), + } + } +} + +// ── ConeSearchTool ── + +pub struct ConeSearchTool; + +#[async_trait] +impl AgentTool for ConeSearchTool { + fn name(&self) -> &str { + "cone_search" + } + + fn description(&self) -> &str { + "锥形检索(Cone Search):按坐标在天文星表中检索近邻天体。\ + 需要指定目标星表(table 参数)。\ + 适用于:给定坐标找附近天体、获取某区域的星表数据、配合 query_target 解析名称后做区域查询。\ + 坐标系统为 J2000(ICRS),单位为度。结果自动缓存 7 天。\ + 不确定表名时先用 search_catalogs 搜索。" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "ra": { + "type": "number", + "description": "赤经 RA(度,J2000/ICRS),范围 0~360" + }, + "dec": { + "type": "number", + "description": "赤纬 Dec(度,J2000/ICRS),范围 -90~90" + }, + "radius_deg": { + "type": "number", + "description": "检索半径(度),默认 0.1,范围 0~5", + "default": 0.1 + }, + "table": { + "type": "string", + "description": "目标星表(必填),如 'I/355/gaiadr3'(Gaia DR3)、'II/246/out'(2MASS)" + }, + "max_records": { + "type": "integer", + "description": "最大返回行数(默认 50,上限 2000)", + "default": 50 + } + }, + "required": ["ra", "dec", "table"] + }) + } + + fn group(&self) -> &str { + "as:research" + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + fn is_readonly(&self) -> bool { + true + } + + async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput { + let state = &ctx.app_state; + + let ra = match args.get("ra").and_then(|v| v.as_f64()) { + Some(v) => v, + None => return ToolOutput::error("缺少必需参数 'ra'(赤经,度)"), + }; + let dec = match args.get("dec").and_then(|v| v.as_f64()) { + Some(v) => v, + None => return ToolOutput::error("缺少必需参数 'dec'(赤纬,度)"), + }; + let radius = args + .get("radius_deg") + .and_then(|v| v.as_f64()) + .unwrap_or(0.1); + let table = match args.get("table").and_then(|v| v.as_str()) { + Some(t) => t, + None => return ToolOutput::error("缺少必需参数 'table'(目标星表)"), + }; + let max_records = args + .get("max_records") + .and_then(|v| v.as_i64()) + .unwrap_or(50) + .clamp(1, 2000); + + info!( + "[ConeSearch] ra={} dec={} radius={}° table={}", + ra, dec, radius, table + ); + + match crate::services::cds::vizier::cone_search( + &state.db, + &state.vizier, + ra, + dec, + radius, + table, + max_records, + ) + .await + { + Ok(r) => { + let mut content = format!( + "Cone Search 结果 (中心 ra={}, dec={}, radius={}°)\n\n", + ra, dec, radius + ); + content.push_str(&render_result_table(&r, 20)); + ToolOutput::success(content, json!(r)) + } + Err(e) => ToolOutput::error(format!("Cone Search 失败: {}", e)), + } + } +} diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index 54b0e5d..468c50f 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -36,13 +36,16 @@ mod todo; pub use ask_user::AskUserTool; pub use astro::analyze_image::AnalyzeImageTool; +pub use astro::research::catalog_operation::CatalogOperationTool; pub use astro::research::citation_network::GetCitationNetworkTool; +pub use astro::research::find_spectrum::FindSpectrumTool; pub use astro::research::library_search::SearchLocalLibraryTool; pub use astro::research::metadata::GetPaperMetadataTool; pub use astro::research::note::SaveNoteTool; pub use astro::research::paper::{GetPaperContentTool, GetPaperOutlineTool}; pub use astro::research::rag::RagSearchTool; pub use astro::research::target::QueryTargetTool; +pub use astro::research::vizier::{ConeSearchTool, QueryVizierTool}; pub use astro::system::process::ProcessPaperTool; pub use astro::system::search::SearchPapersTool; pub use background::{BgTaskCheckTool, BgTaskRunTool}; @@ -380,6 +383,10 @@ fn add_base_tools(registry: &mut ToolRegistry, skill_registry: Arc> + +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, +} + +#[derive(Debug, Deserialize)] +pub struct VizierTableParams { + /// VizieR 表名,如 "I/355/gaiadr3" + pub table: String, + /// 列名(逗号分隔,为空时取 *) + pub columns: Option, + /// 最大返回行数(默认 50,上限 2000) + pub limit: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ConeSearchParams { + /// RA 坐标(度) + pub ra: f64, + /// Dec 坐标(度) + pub dec: f64, + /// 检索半径(度) + pub radius: Option, + /// 目标星表(必填,如 "I/355/gaiadr3") + pub table: String, + /// 最大返回行数(默认 50,上限 2000) + pub max_records: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CrossMatchParams { + pub ra: f64, + pub dec: f64, + pub radius: Option, + /// 目标星表(必填,如 "I/355/gaiadr3") + pub table: String, + pub max_records: Option, +} + +// ── 响应封装 ── + +#[derive(Debug, Serialize)] +pub struct CachedResult { + #[serde(flatten)] + pub result: VizierQueryResult, + /// 是否来自缓存 + pub from_cache: Option, +} + +const DEFAULT_MAX: i64 = 50; +const MAX_LIMIT: i64 = 2000; + +fn clamp_max(v: Option) -> i64 { + v.unwrap_or(DEFAULT_MAX).clamp(1, MAX_LIMIT) +} + +// ── 处理器 ── + +/// GET /api/catalog/vizier —— 自由 ADQL 查询 +pub async fn vizier_query( + State(state): State>, + Query(params): Query, +) -> ApiResult> { + 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>, + Query(params): Query, +) -> ApiResult> { + let limit = clamp_max(params.limit); + let columns: Vec = 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>, + Query(params): Query, +) -> ApiResult> { + 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>, + Query(params): Query, +) -> ApiResult> { + 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, + pub dec: Option, + pub radius: Option, + /// 选源策略:nearest(默认)/ all + pub strategy: Option, + /// 标识符模式:逗号分隔的源标识列表 + pub source_ids: Option, + /// 数据发布版本(可选):lamost=dr5..dr11, gaia=dr3, sdss=dr16..dr19 + pub release: Option, + /// 数据类型(可选):lamost=lrs/mrs, gaia=xp_continuous/xp_sampled/epoch_photometry/rvs + pub data_type: Option, + pub force: Option, +} + +/// GET /api/catalog/spectrum/download —— 统一光谱下载 +/// +/// 两种模式(二选一): +/// - 坐标模式:提供 ra + dec(可选 radius/strategy),自动 cone 检索并下载 +/// - 标识符模式:提供 source_ids(逗号分隔),直接按标识下载 +pub async fn spectrum_download( + State(state): State>, + Query(params): Query, +) -> ApiResult> { + 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 = 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>, +) -> ApiResult>> { + 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)) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index ddf362d..9cb8ea9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -3,8 +3,13 @@ use crate::agent::memory::MemoryManager; use crate::agent::skills::SkillRegistry; use crate::clients::ads::AdsClient; use crate::clients::arxiv::ArxivClient; +use crate::clients::cds::vizier::VizierClient; +use crate::clients::desi::DesiClient; +use crate::clients::gaia::GaiaClient; +use crate::clients::lamost::LamostClient; use crate::clients::llm::{EmbeddingClient, LlmClient}; use crate::clients::qiniu::QiniuClient; +use crate::clients::sdss::SdssClient; use crate::services::download::Downloader; use crate::services::translation::Dictionary; use crate::Config; @@ -56,6 +61,16 @@ pub struct AppState { pub qiniu: QiniuClient, pub ads: AdsClient, pub arxiv: ArxivClient, + /// VizieR TAP 星表查询客户端(CDS VizieR TAP 服务) + pub vizier: VizierClient, + /// LAMOST 光谱数据客户端(ConeSearch + FITS.gz 下载) + pub lamost: LamostClient, + /// Gaia 光谱数据客户端(TAP + DataLink) + pub gaia: GaiaClient, + /// SDSS 光谱数据客户端(Data Lab TAP + SAS) + pub sdss: SdssClient, + /// DESI 光谱数据客户端(Data Lab TAP + HEALPix coadd SAS) + pub desi: DesiClient, pub llm: LlmClient, pub medium_llm: LlmClient, pub fast_llm: LlmClient, @@ -96,6 +111,7 @@ pub use crate::services::paper::StandardPaper; pub mod agent; pub mod auth; +pub mod catalog; pub mod error; pub mod notes; pub mod papers; @@ -114,6 +130,11 @@ pub mod handlers { RestoreResponse, RetryResponse, RewindRequest, RewindResponse, SessionListParams, }; pub use super::auth::{check_auth, login, logout}; + pub use super::catalog::{ + cone_search, cross_match, spectrum_download, spectrum_list, vizier_query, vizier_table, + ConeSearchParams, CrossMatchParams, SpectrumDownloadParams, VizierQueryParams, + VizierTableParams, + }; pub use super::notes::{ create_note, delete_note, get_notes, CreateNoteRequest, DeleteNoteParams, GetNotesParams, NoteRecord, diff --git a/src/api/targets.rs b/src/api/targets.rs index f8d88fa..b8ff15d 100644 --- a/src/api/targets.rs +++ b/src/api/targets.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use super::error::{ApiResult, AppError}; use super::AppState; -use crate::services::target::{query_target_cached, TargetInfo}; +use crate::services::cds::target::{query_target_cached, TargetInfo}; #[derive(Deserialize)] pub struct TargetQueryParams { @@ -81,7 +81,7 @@ pub async fn list_targets( State(state): State>, Query(params): Query, ) -> ApiResult>> { - let targets = crate::services::target::list_targets_for_paper(&state.db, ¶ms.bibcode) + let targets = crate::services::cds::target::list_targets_for_paper(&state.db, ¶ms.bibcode) .await .map_err(|e| { tracing::error!("获取文献天体关联列表失败 ({}): {}", params.bibcode, e); @@ -108,7 +108,7 @@ pub async fn extract_paper_targets( ) -> ApiResult> { tracing::info!("接收到文献天体提取与识别指令: {}", req.bibcode); - let targets = crate::services::target::extract_and_refresh_targets( + let targets = crate::services::cds::target::extract_and_refresh_targets( &state.db, &state.config.library_dir, &req.bibcode, diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 1e68521..13d85a0 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -203,6 +203,32 @@ async fn main() -> anyhow::Result<()> { ), ads: AdsClient::new(config.ads_api_key.clone()).context("构建 ADS 客户端失败")?, arxiv: ArxivClient::new().context("构建 arXiv 客户端失败")?, + vizier: astroresearch::clients::cds::vizier::VizierClient::new( + &config.vizier_tap_url, + config.vizier_timeout_secs, + ) + .context("构建 VizieR 客户端失败")?, + lamost: astroresearch::clients::lamost::LamostClient::new( + &config.lamost_base_url, + config.lamost_timeout_secs, + ) + .context("构建 LAMOST 客户端失败")?, + gaia: astroresearch::clients::gaia::GaiaClient::new( + &config.gaia_tap_url, + &config.gaia_datalink_url, + config.gaia_timeout_secs, + ) + .context("构建 Gaia 客户端失败")?, + sdss: astroresearch::clients::sdss::SdssClient::new( + &config.sdss_tap_url, + config.sdss_timeout_secs, + ) + .context("构建 SDSS 客户端失败")?, + desi: astroresearch::clients::desi::DesiClient::new( + &config.desi_tap_url, + config.desi_timeout_secs, + ) + .context("构建 DESI 客户端失败")?, llm: LlmClient::new( config.llm_api_key.clone(), config.llm_api_base.clone(), @@ -306,7 +332,7 @@ async fn main() -> anyhow::Result<()> { Commands::TargetQuery { object_name } => { let client = reqwest::Client::new(); - let info = astroresearch::services::target::query_target_cached( + let info = astroresearch::services::cds::target::query_target_cached( &pool, &object_name, None, @@ -340,7 +366,7 @@ async fn main() -> anyhow::Result<()> { object_name, } => { let client = reqwest::Client::new(); - let info = astroresearch::services::target::query_target_cached( + let info = astroresearch::services::cds::target::query_target_cached( &pool, &object_name, Some(&bibcode), @@ -352,7 +378,7 @@ async fn main() -> anyhow::Result<()> { } Commands::ExtractTargets { text } => { - let targets = astroresearch::services::target::extract_targets(&text); + let targets = astroresearch::services::cds::target::extract_targets(&text); if targets.is_empty() { println!("未识别到天体标识符。"); } else { diff --git a/src/clients/ads.rs b/src/clients/ads.rs index 974064d..23c2717 100644 --- a/src/clients/ads.rs +++ b/src/clients/ads.rs @@ -22,6 +22,9 @@ pub struct AdsPaperDoc { pub citation: Option>, pub identifier: Option>, pub doctype: Option, + /// ADS data links —— 关联的数据表 URL(含 VizieR/CDS 链接) + #[serde(default)] + pub data: Option>, } #[derive(Debug, Deserialize)] @@ -84,7 +87,7 @@ impl AdsClient { let translated = crate::services::query_parser::to_ads_query(query); // fl 声明返回字段,包括 reference 和 citation 引用关系数组及 identifier 和 doctype - let fl = "bibcode,title,author,year,pub,keyword,abstract,doi,citation_count,reference_count,reference,citation,identifier,doctype"; + let fl = "bibcode,title,author,year,pub,keyword,abstract,doi,citation_count,reference_count,reference,citation,identifier,doctype,data"; let ads_sort = match sort { "date_desc" => "date desc", @@ -166,6 +169,7 @@ impl AdsClient { citation: d.citation, identifier: d.identifier, doctype: d.doctype, + data: d.data, }) .collect(); @@ -262,6 +266,8 @@ struct RawDoc { citation: Option>, identifier: Option>, doctype: Option, + #[serde(default)] + data: Option>, } #[derive(Debug, Deserialize)] diff --git a/src/clients/cds/mod.rs b/src/clients/cds/mod.rs new file mode 100644 index 0000000..bbb4b71 --- /dev/null +++ b/src/clients/cds/mod.rs @@ -0,0 +1,18 @@ +// src/clients/cds/mod.rs +// +// CDS(法国斯特拉斯堡天文数据中心)数据源客户端 —— 按数据源聚合 +// 仅含纯通信层(HTTP 请求 + 响应解析 + 领域结构体)。 +// 业务层(缓存策略、ADQL 拼装、批量编排)在 services/ 下。 +// +// CDS 旗下我们用到的两个独立服务,每个一个文件: +// sesame — Sesame 名称解析器客户端(query_sesame + TargetInfo) +// vizier — VizieR TAP 客户端(VizierClient + VizieRQueryResult) +// +// VO 通用层(ConeSearch / CrossMatch)属于业务编排(ADQL 拼装 + 缓存委托), +// 位于 services/cds/vizier.rs,不在本通信层。 +// +// 后续新增其他数据源(LAMOST/SDSS/Gaia 光谱光变)时,在 src/clients/ 下新建 +// 同级文件夹(如 src/clients/lamost/),client 在此,业务在 services/ 下。 + +pub mod sesame; +pub mod vizier; diff --git a/src/clients/cds/sesame.rs b/src/clients/cds/sesame.rs new file mode 100644 index 0000000..1fb007f --- /dev/null +++ b/src/clients/cds/sesame.rs @@ -0,0 +1,381 @@ +// src/clients/cds/sesame.rs +// +// CDS Sesame 名称解析器客户端 —— 与 CDS Sesame 服务的纯通信层 +// 职责仅限:HTTP 请求(给定天体名 → Sesame)、XML 响应解析、领域结构体 TargetInfo 定义 +// 业务层(paper_targets 缓存、批量编排)在 services/target.rs +// +// 注:IAU 天体命名正则提取(从文本中识别天体名)也在 services::cds::target。 + +use regex::Regex; +use reqwest::Client; +use tracing::info; + +/// 天体目标的标准化属性信息 +/// +/// 序列化兼容:前端与 paper_targets 缓存均依赖此结构,字段名不可随意改动。 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TargetInfo { + pub target_name: String, + pub ra: Option, + pub dec: Option, + pub parallax: Option, + pub parallax_err: Option, + pub spectral_type: Option, + pub v_magnitude: Option, + pub otype: Option, + pub oname: Option, + pub pm_ra: Option, + pub pm_de: Option, + pub radial_velocity: Option, + pub photometry: Option>, + pub aliases: Vec, +} + +/// 通过 CDS Sesame Name Resolver 查询天体的标准化信息 +/// +/// Sesame 文档: https://cds.unistra.fr/cgi-bin/nph-sesame +/// 返回包含坐标 (RA/Dec)、光谱类型等的结构化数据。 +/// 使用共享的 reqwest::Client(无独立重试,重试/缓存由调用方 services::cds::target 处理)。 +pub async fn query_sesame(target_name: &str, client: &Client) -> anyhow::Result { + let url = format!( + "https://cds.unistra.fr/cgi-bin/nph-sesame/-oIfx/SNV?{}", + urlencoding::encode(target_name) + ); + + info!("正在查询 CDS Sesame: {}", target_name); + + let response = client + .get(&url) + .header("User-Agent", "AstroResearch/0.1 (academic research tool)") + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Sesame 查询失败: HTTP {}", + response.status() + )); + } + + let xml_text = response.text().await?; + parse_sesame_xml(&xml_text, target_name) +} + +/// 解析 Sesame XML 响应为 TargetInfo +pub fn parse_sesame_xml(xml: &str, original_name: &str) -> anyhow::Result { + let ra = extract_xml_value(xml, "jradeg"); + let dec = extract_xml_value(xml, "jdedeg"); + let spectral_type = extract_xml_value(xml, "spType").or_else(|| extract_xml_value(xml, "sp")); + let otype = extract_xml_value(xml, "otype"); + let oname = extract_xml_value(xml, "oname"); + + // 嵌套测量值(Sesame v4 格式) + let parallax = extract_nested_value(xml, "plx"); + let parallax_err = extract_nested_value(xml, "plx").and_then(|_| { + // 匹配完整 块提取 子元素 + let plx_block_re = Regex::new(r"([\s\S]*?)").ok()?; + let plx_content = plx_block_re.captures(xml)?.get(1)?.as_str(); + Regex::new(r"([^<]+)") + .ok() + .and_then(|re| re.captures(plx_content)) + .and_then(|caps| caps.get(1)) + .and_then(|m| m.as_str().trim().parse::().ok()) + }); + let pm_ra = extract_nested_value(xml, "pmRA"); + let pm_de = extract_nested_value(xml, "pmDE"); + // 视向速度需从 块中提取,避免与 混淆 + let radial_velocity = { + let vel_block_re = Regex::new(r"([\s\S]*?)").ok(); + vel_block_re + .and_then(|re| re.captures(xml)) + .and_then(|caps| caps.get(1)) + .and_then(|block| extract_xml_value(block.as_str(), "v")) + .and_then(|s| s.parse::().ok()) + }; + + // 多波段测光 + let (v_magnitude, photometry) = parse_magnitudes(xml); + // 向后兼容旧的 扁平标签 + let v_mag_flat = extract_xml_value(xml, "Vmag").and_then(|v| v.parse::().ok()); + let v_mag = v_magnitude.or(v_mag_flat); + let photometry_map = if photometry.is_empty() { + None + } else { + Some(photometry) + }; + + // 提取别名列表 + let mut aliases = Vec::new(); + static ALIAS_RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| Regex::new(r"([^<]+)").unwrap()); + for cap in ALIAS_RE.captures_iter(xml) { + if let Some(alias) = cap.get(1) { + aliases.push(alias.as_str().trim().to_string()); + } + } + + // 如果连坐标都查不到,说明 Sesame 无法识别这个天体 + if ra.is_none() && dec.is_none() && aliases.is_empty() { + return Err(anyhow::anyhow!( + "CDS Sesame 无法识别天体: {}", + original_name + )); + } + + Ok(TargetInfo { + target_name: original_name.to_string(), + ra, + dec, + parallax, + parallax_err, + spectral_type, + v_magnitude: v_mag, + otype, + oname, + pm_ra, + pm_de, + radial_velocity, + photometry: photometry_map, + aliases, + }) +} + +/// 从 XML 文本中提取指定标签的文本内容 +fn extract_xml_value(xml: &str, tag: &str) -> Option { + let pattern = format!(r"<{tag}>([^<]+)"); + Regex::new(&pattern) + .ok()? + .captures(xml)? + .get(1) + .map(|m| m.as_str().trim().to_string()) +} + +/// 从嵌套测量标签中提取 `` 子元素值 +/// +/// Sesame v4 格式: `0.65850.0335` → `Some(0.6585)` +/// 兼容旧扁平格式: `0.6585` → `Some(0.6585)` +fn extract_nested_value(xml: &str, tag: &str) -> Option { + // 先尝试提取嵌套 子元素 + let nested_pattern = format!(r"<{tag}>[^<]*([^<]+)"); + if let Some(v) = Regex::new(&nested_pattern) + .ok() + .and_then(|re| re.captures(xml)) + .and_then(|caps| caps.get(1)) + .and_then(|m| m.as_str().trim().parse::().ok()) + { + return Some(v); + } + // 降级:尝试扁平格式 + extract_xml_value(xml, tag).and_then(|s| s.parse::().ok()) +} + +/// 解析所有 `VALUE...` 标签 +/// +/// 返回 `(V波段星等, 全波段测光Map)` +fn parse_magnitudes(xml: &str) -> (Option, std::collections::HashMap) { + let mut photometry = std::collections::HashMap::new(); + let mag_re = Regex::new(r#"[^<]*([^<]+)"#).unwrap(); + for caps in mag_re.captures_iter(xml) { + let band = caps.get(1).unwrap().as_str().to_string(); + if let Ok(value) = caps.get(2).unwrap().as_str().trim().parse::() { + photometry.insert(band, value); + } + } + let v_mag = photometry.get("V").copied(); + (v_mag, photometry) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_sesame_xml_basic() { + let xml = r#" + + + + from cache + 4015980 + WD* + 251.82663549 + 32.47579522 + 23.24410.024A2020yCat.1350....0G + DB2 + 13.6530.0095B2013AJ....146..131L + 13.5300.0088B2013AJ....146..131L + 13.5799400.003138C2020yCat.1350....0G + GD 358 + GD 358 + WD 1645+325 + V* V777 Her + + + + "#; + let info = parse_sesame_xml(xml, "GD 358").unwrap(); + assert_eq!(info.target_name, "GD 358"); + assert_eq!(info.ra.as_deref(), Some("251.82663549")); + assert_eq!(info.dec.as_deref(), Some("32.47579522")); + assert_eq!(info.parallax, Some(23.2441)); + assert_eq!(info.parallax_err, Some(0.024)); + assert_eq!(info.spectral_type.as_deref(), Some("DB2")); + assert_eq!(info.v_magnitude, Some(13.653)); + assert_eq!(info.otype.as_deref(), Some("WD*")); + assert_eq!(info.oname.as_deref(), Some("GD 358")); + assert_eq!(info.aliases.len(), 3); + assert!(info.photometry.is_some()); + let phot = info.photometry.unwrap(); + assert_eq!(phot.get("V"), Some(&13.653)); + assert_eq!(phot.get("B"), Some(&13.530)); + assert_eq!(phot.get("G"), Some(&13.579940)); + } + + #[test] + fn test_extract_nested_plx() { + let xml = r#"0.65850.0335A2020yCat.1350....0G"#; + assert_eq!(extract_nested_value(xml, "plx"), Some(0.6585)); + } + + #[test] + fn test_extract_nested_flat_fallback() { + let xml = r#"0.6585"#; + assert_eq!(extract_nested_value(xml, "plx"), Some(0.6585)); + } + + #[test] + fn test_parse_magnitudes_multi_band() { + let xml = r#" + 14.3890.01 + 14.5620.01 + 14.5154770.002833 + 14.7720.06 + "#; + let (v_mag, phot) = parse_magnitudes(xml); + assert_eq!(v_mag, Some(14.562)); + assert_eq!(phot.get("V"), Some(&14.562)); + assert_eq!(phot.get("B"), Some(&14.389)); + assert_eq!(phot.get("G"), Some(&14.515477)); + assert_eq!(phot.get("R"), Some(&14.772)); + } + + /// 真实 Sesame 接口测试 —— 查询 M 31(仙女座星系),无需 API Key + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_query_sesame_m31() { + let client = Client::new(); + let info = query_sesame("M 31", &client) + .await + .expect("Sesame 查询 M 31 失败"); + + println!("===== Sesame Live: M 31 ====="); + println!(" target_name: {}", info.target_name); + println!(" ra: {:?}, dec: {:?}", info.ra, info.dec); + println!(" spectral_type: {:?}", info.spectral_type); + println!(" v_magnitude: {:?}", info.v_magnitude); + println!(" otype: {:?}", info.otype); + println!(" aliases: {:?}", info.aliases); + + assert_eq!(info.target_name, "M 31"); + assert!(info.ra.is_some(), "M 31 应有 RA 坐标"); + assert!(info.dec.is_some(), "M 31 应有 Dec 坐标"); + assert!(!info.aliases.is_empty(), "M 31 应有别名"); + } + + /// 真实 Sesame 接口测试 —— 查询 HD 209458(著名系外行星宿主恒星) + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_query_sesame_hd209458() { + let client = Client::new(); + let info = query_sesame("HD 209458", &client) + .await + .expect("Sesame 查询 HD 209458 失败"); + + println!("===== Sesame Live: HD 209458 ====="); + println!(" target_name: {}", info.target_name); + println!(" ra: {:?}, dec: {:?}", info.ra, info.dec); + println!(" spectral_type: {:?}", info.spectral_type); + println!(" parallax: {:?}", info.parallax); + println!(" v_magnitude: {:?}", info.v_magnitude); + println!(" photometry: {:?}", info.photometry); + + assert_eq!(info.target_name, "HD 209458"); + assert!(info.ra.is_some(), "HD 209458 应有 RA 坐标"); + assert!(info.dec.is_some(), "HD 209458 应有 Dec 坐标"); + // HD 209458 是 F8V 型恒星,应有光谱类型 + assert!(info.spectral_type.is_some(), "HD 209458 应有光谱类型"); + } + + /// Sesame 不存在的天体 —— 应返回错误 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_query_sesame_unknown() { + let client = Client::new(); + let result = query_sesame("ZZZZZ_NOT_A_REAL_OBJECT_12345", &client).await; + assert!(result.is_err(), "查询不存在的天体应返回错误"); + println!( + "===== Sesame Live: 未知天体 → 预期错误: {} =====", + result.unwrap_err() + ); + } + + #[test] + fn test_parse_gaia_target_full() { + let xml = r#" + + + + from cache + 15919104 + HS* + 08:09:38.67 -26:27:39.2 + 122.411159 + -26.46089736 + 2020yCat.1350....0G + 0.01700.0233 + + 2.1190.038A2020yCat.1350....0G + 318 + -1.4100.025 + 1.5820.029 + + 332B2022MNRAS.515.2496P + 0.65850.0335A2020yCat.1350....0G + sdBOHe + 14.3890.01 + 14.5620.01 + 14.5154770.002833 + 14.7720.06 + 14.7950.03 + 15.0680.040 + 15.1810.073 + 15.4380.213 + UCAC4 318-042721 + UCAC4 318-042721 + Gaia DR3 5694207034772278400 + Gaia DR2 5694207034772278400 + 6 + + + + "#; + let info = parse_sesame_xml(xml, "Gaia DR2 5694207034772278400").unwrap(); + assert_eq!(info.target_name, "Gaia DR2 5694207034772278400"); + assert_eq!(info.ra.as_deref(), Some("122.411159")); + assert_eq!(info.dec.as_deref(), Some("-26.46089736")); + assert_eq!(info.parallax, Some(0.6585)); + assert_eq!(info.parallax_err, Some(0.0335)); + assert_eq!(info.spectral_type.as_deref(), Some("sdBOHe")); + assert_eq!(info.otype.as_deref(), Some("HS*")); + assert_eq!(info.oname.as_deref(), Some("UCAC4 318-042721")); + assert_eq!(info.pm_ra, Some(-1.410)); + assert_eq!(info.pm_de, Some(1.582)); + assert_eq!(info.radial_velocity, Some(33.0)); + assert_eq!(info.v_magnitude, Some(14.562)); + assert!(info.photometry.is_some()); + let phot = info.photometry.unwrap(); + assert_eq!(phot.len(), 8); + assert_eq!(phot.get("V"), Some(&14.562)); + assert_eq!(info.aliases.len(), 3); + } +} diff --git a/src/clients/cds/vizier.rs b/src/clients/cds/vizier.rs new file mode 100644 index 0000000..1a3ec8c --- /dev/null +++ b/src/clients/cds/vizier.rs @@ -0,0 +1,340 @@ +// src/clients/cds/vizier.rs +// +// VizieR TAP 客户端 —— 与 CDS VizieR TAP 服务的纯通信层 +// 职责仅限:HTTP 请求(SSRF 防护 + 重试)、响应解析(JSON 优先 + VOTable 降级) +// 业务层(缓存策略、便捷查询、注入防护、ADQL 拼装)在 services/cds/vizier.rs +// +// 返回格式策略:优先 FORMAT=json(serde 反序列化),失败时降级到 VOTable(quick-xml 解析 TABLEDATA) +// 对齐 ads.rs / arxiv.rs 范式:自建 reqwest::Client + safe_redirect_policy + 重试 + 超时 +// VOTable 解析复用 src/clients/vo/mod.rs 的共享解析器(VizieR/LAMOST/Gaia 共用) + +use crate::clients::vo::{extract_table_name, parse_votable_tabledata, VoTableResult}; +use anyhow::{anyhow, Context}; +use serde::Deserialize; +use std::time::Duration; +use tracing::{error, info, warn}; + +// ── 领域结构(re-export 共享类型,保持外部 API 路径不变) ── +// +// FieldInfo / VoTableResult / parse_votable_tabledata / extract_table_name 现位于 +// crate::clients::vo。这里以 re-export + 类型别名对外保持 VizierQueryResult 名称兼容, +// 避免下游(services/cds/vizier.rs、api/catalog.rs、前端类型对齐)大面积改名。 + +pub use crate::clients::vo::FieldInfo; + +/// VizieR TAP 查询的标准化结果(VoTableResult 的语义别名,保持历史命名兼容) +pub type VizierQueryResult = VoTableResult; + +// ── TAP JSON 响应中间结构 ── +// +// VizieR TAP FORMAT=json 返回结构(与多数 TAP 实现一致): +// { "metadata": [{"name","description","unit","dataType",...}], "data": [[v1,v2,...], ...] } + +#[derive(Debug, Deserialize)] +struct TapJsonMetadata { + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + unit: Option, + #[serde(default, rename = "dataType")] + datatype: Option, +} + +#[derive(Debug, Deserialize)] +struct TapJsonResponse { + #[serde(default)] + metadata: Vec, + #[serde(default)] + data: Vec>, +} + +// ── 客户端 ── + +#[derive(Clone)] +pub struct VizierClient { + client: reqwest::Client, + tap_base: String, +} + +impl VizierClient { + pub fn new(tap_base: &str, timeout_secs: u64) -> anyhow::Result { + Ok(VizierClient { + client: reqwest::Client::builder() + .redirect(crate::utils::ssrf::safe_redirect_policy()) + .timeout(Duration::from_secs(timeout_secs)) + .connect_timeout(Duration::from_secs(10)) + .build() + .context("Failed to create VizieR HTTP client")?, + tap_base: tap_base.trim_end_matches('/').to_string(), + }) + } + + /// 执行 ADQL 查询,返回标准化结果 + /// + /// 优先尝试 FORMAT=json,解析失败或服务端拒绝 JSON 时降级到 VOTable 解析。 + pub async fn run_adql( + &self, + adql: &str, + max_records: i64, + ) -> anyhow::Result { + info!( + "[VizieR] 执行 TAP 查询 (MAXREC={}): {}", + max_records, + adql.chars().take(200).collect::() + ); + + // 1) JSON 优先 + match self.run_adql_json(adql, max_records).await { + Ok(result) => return Ok(result), + Err(e) => { + warn!("[VizieR] JSON 解析失败,尝试 VOTable 降级: {}", e); + } + } + + // 2) VOTable 降级 + self.run_adql_votable(adql, max_records).await + } + + /// GET /sync with FORMAT=json + async fn run_adql_json( + &self, + adql: &str, + max_records: i64, + ) -> anyhow::Result { + let url = format!("{}/sync", self.tap_base); + let params: [(&str, &str); 5] = [ + ("REQUEST", "doQuery"), + ("LANG", "ADQL"), + ("FORMAT", "json"), + ("MAXREC", &max_records.to_string()), + ("QUERY", adql), + ]; + + let resp = self.send_with_retry(&url, ¶ms).await?; + let body = resp.text().await.context("读取 VizieR JSON 响应失败")?; + + // 先尝试标准 TAP JSON 结构 + let tap: TapJsonResponse = serde_json::from_str(&body).map_err(|e| { + error!( + "[VizieR] JSON 反序列化失败 (前 500 字符): {}", + body.chars().take(500).collect::() + ); + anyhow!("VizieR JSON 解析失败: {}", e) + })?; + + let fields = tap + .metadata + .iter() + .map(|m| FieldInfo { + name: m.name.clone(), + description: m.description.clone(), + unit: m.unit.clone(), + datatype: m.datatype.clone(), + }) + .collect(); + + let row_count = tap.data.len(); + let truncated = row_count as i64 >= max_records && max_records > 0; + + Ok(VizierQueryResult { + table_name: extract_table_name(adql), + fields, + rows: tap.data, + row_count, + truncated, + }) + } + + /// GET /sync with FORMAT=votable —— 委托共享 VOTable 解析器 + async fn run_adql_votable( + &self, + adql: &str, + max_records: i64, + ) -> anyhow::Result { + let url = format!("{}/sync", self.tap_base); + let params: [(&str, &str); 5] = [ + ("REQUEST", "doQuery"), + ("LANG", "ADQL"), + ("FORMAT", "votable"), + ("MAXREC", &max_records.to_string()), + ("QUERY", adql), + ]; + + let resp = self.send_with_retry(&url, ¶ms).await?; + let body = resp.text().await.context("读取 VizieR VOTable 响应失败")?; + + parse_votable_tabledata(&body, max_records) + } + + /// 带重试的请求发送(对齐 ads.rs 的 429/503 重试模式) + async fn send_with_retry( + &self, + url: &str, + params: &[(&str, &str)], + ) -> anyhow::Result { + const MAX_RETRIES: u32 = 3; + let mut response = None; + for attempt in 0..MAX_RETRIES { + let resp = self + .client + .get(url) + .header("User-Agent", "AstroResearch/0.1 (academic research tool)") + .query(params) + .send() + .await + .context("VizieR TAP 请求发送失败")?; + + let status = resp.status(); + if (status.as_u16() == 429 || status.as_u16() == 503) && attempt < MAX_RETRIES - 1 { + let retry_after = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(5); + warn!( + "[VizieR] 速率限制/过载 ({}), {} 秒后重试 (第 {} 次)", + status, + retry_after, + attempt + 1 + ); + tokio::time::sleep(Duration::from_secs(retry_after)).await; + continue; + } + response = Some(resp); + break; + } + + let response = response.ok_or_else(|| anyhow!("VizieR TAP 重试耗尽"))?; + let status = response.status(); + if !status.is_success() { + let err_body = response.text().await.unwrap_or_default(); + error!( + "[VizieR] TAP 查询失败: 状态码={}, 返回错误={}", + status, + err_body.chars().take(500).collect::() + ); + return Err(anyhow!("VizieR TAP 接口返回错误码: {}", status)); + } + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_tap_json() { + let json = r#"{ + "metadata": [ + {"name": "ra", "unit": "deg", "dataType": "double"}, + {"name": "dec", "unit": "deg", "dataType": "double"} + ], + "data": [[10.5, -20.3], [45.0, 12.1]] + }"#; + let tap: TapJsonResponse = serde_json::from_str(json).unwrap(); + assert_eq!(tap.metadata.len(), 2); + assert_eq!(tap.data.len(), 2); + assert_eq!(tap.data[0][0].as_f64().unwrap(), 10.5); + } + + /// 真实 VizieR TAP 接口测试 —— 默认走 JSON 路径(FORMAT=json) + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_tap_json_path() { + let client = + VizierClient::new("https://tapvizier.cds.unistra.fr/TAPVizieR/tap", 60).unwrap(); + let result = client + .run_adql( + r#"SELECT TOP 5 DR3Name, RA_ICRS, DE_ICRS FROM "I/355/gaiadr3""#, + 5, + ) + .await + .unwrap(); + + println!("===== VizieR TAP Live (JSON path) ====="); + println!(" table_name: {:?}", result.table_name); + println!(" fields: {:?}", result.fields); + println!(" row_count: {}", result.row_count); + println!(" truncated: {}", result.truncated); + println!(" 首行: {:?}", result.rows.first()); + + assert_eq!(result.row_count, 5); + assert!(result.fields.len() >= 3, "应有至少 3 列"); + assert_eq!(result.fields[0].name, "DR3Name"); + assert_eq!(result.fields[1].name, "RA_ICRS"); + assert_eq!(result.fields[2].name, "DE_ICRS"); + // 每行应有 3 个值 + for row in &result.rows { + assert_eq!(row.len(), 3); + } + } + + /// 真实 VizieR TAP 接口测试 —— 强制走 VOTable 路径(FORMAT=votable) + /// + /// 通过直接调用 run_adql_votable 绕过 JSON 优先策略,验证共享 VOTable 解析器 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_tap_votable_path() { + let client = + VizierClient::new("https://tapvizier.cds.unistra.fr/TAPVizieR/tap", 60).unwrap(); + let result = client + .run_adql_votable( + r#"SELECT TOP 5 DR3Name, RA_ICRS, DE_ICRS FROM "I/355/gaiadr3""#, + 5, + ) + .await + .unwrap(); + + println!("===== VizieR TAP Live (VOTable path) ====="); + println!(" table_name: {:?}", result.table_name); + println!(" fields: {:?}", result.fields); + println!(" row_count: {}", result.row_count); + println!(" 首行: {:?}", result.rows.first()); + + assert_eq!(result.row_count, 5); + assert!(result.fields.len() >= 3); + assert_eq!(result.fields[0].name, "DR3Name"); + } + + /// 真实 VizieR TAP 接口测试 —— 带 WHERE 条件的查询 + 截断标记 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_tap_truncation() { + let client = + VizierClient::new("https://tapvizier.cds.unistra.fr/TAPVizieR/tap", 60).unwrap(); + // MAXREC=3 但 WHERE 匹配远多于 3 条,应触发截断 + let result = client + .run_adql( + r#"SELECT TOP 100 DR3Name, RA_ICRS, DE_ICRS FROM "I/355/gaiadr3" WHERE RA_ICRS < 10"#, + 3, + ) + .await + .unwrap(); + + println!("===== VizieR TAP Live: truncation test ====="); + println!(" row_count: {}", result.row_count); + println!(" truncated: {}", result.truncated); + + assert!(result.row_count <= 3, "行数不应超过 MAXREC"); + assert!(result.truncated, "结果应被截断"); + } + + /// 真实 VizieR TAP 接口测试 —— 无效 ADQL 应返回错误 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_tap_invalid_adql() { + let client = + VizierClient::new("https://tapvizier.cds.unistra.fr/TAPVizieR/tap", 60).unwrap(); + let result = client + .run_adql("SELECT * FROM nonexistent_table_xyz", 10) + .await; + assert!(result.is_err(), "无效 ADQL 应返回错误"); + println!( + "===== VizieR TAP Live: invalid ADQL → 预期错误: {} =====", + result.unwrap_err() + ); + } +} diff --git a/src/clients/desi/mod.rs b/src/clients/desi/mod.rs new file mode 100644 index 0000000..e84f494 --- /dev/null +++ b/src/clients/desi/mod.rs @@ -0,0 +1,506 @@ +// src/clients/desi/mod.rs +// +// DESI(暗能量光谱仪)光谱数据客户端 —— 纯通信层 +// 职责仅限:HTTP 请求(SSRF 防护 + 重试)、TAP VOTable 解析、HEALPix coadd FITS 字节下载 +// 业务层(缓存、落盘、标识解析)在 services/spectra/desi.rs +// +// 数据源(DESI DR1 / EDR): +// TAP catalog: POST https://datalab.noirlab.edu/tap/sync → VOTable(desi_dr1.zpix / desi_edr.zpix 表) +// FITS 下载: GET https://data.desi.lbl.gov/public/{dr}/spectro/redux/{ver}/healpix/... +// +// 架构特点(与 SDSS 的关键差异): +// DESI 光谱按 HEALPix 像素打包,一个 coadd 文件含该像素内所有目标的光谱(非一源一文件)。 +// cone 检索返回目标级行(targetid/ra/dec/z/spectype),下载单元是 HEALPix 像素文件。 +// pixgroup = healpix // 100(如 healpix=10050 → group=100) +// +// 无需认证。复用 src/clients/vo/mod.rs 的共享 VOTable 解析器。 +// 对齐 clients/sdss/mod.rs 范式:自建 reqwest::Client + safe_redirect_policy + 重试 + 超时。 + +use crate::clients::vo::{parse_votable_tabledata, FieldInfo}; +use crate::services::spectra::common::DesiRelease; +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tracing::{error, info, warn}; + +// ── 领域结构 ── + +/// DESI zpix 查询结果行(cone 检索后的目标级信息) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesiSpectrumRow { + /// 目标唯一 ID + pub targetid: String, + pub ra: Option, + pub dec: Option, + /// 红移 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub z: Option, + /// 光谱分类(GALAXY / QSO / STAR) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spectype: Option, + /// HEALPix 像素号(决定 coadd 文件路径) + pub healpix: i64, + /// 巡天阶段(main / sv1 / sv2 / sv3) + pub survey: String, + /// 观测程序(dark / bright / backup) + pub program: String, +} + +impl DesiSpectrumRow { + /// 构造 coadd FITS 的 SAS 下载 URL + /// + /// https://data.desi.lbl.gov/public/{dr}/spectro/redux/{ver}/healpix/{survey}/{program}/{pixgroup}/{pixnum}/coadd-{survey}-{program}-{pixnum}.fits + pub fn coadd_url(&self, release: DesiRelease) -> String { + let pixgroup = self.healpix / 100; + format!( + "https://data.desi.lbl.gov/public/{dr}/spectro/redux/{ver}/healpix/{survey}/{program}/{pixgroup}/{pixnum}/coadd-{survey}-{program}-{pixnum}.fits", + dr = release.dr_segment(), + ver = release.specredux_ver(), + survey = self.survey, + program = self.program, + pixgroup = pixgroup, + pixnum = self.healpix, + ) + } +} + +/// ConeSearch 结果(行 + 字段 + 截断标记) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesiConeResult { + pub rows: Vec, + pub fields: Vec, + pub row_count: usize, + pub truncated: bool, +} + +// ── 客户端 ── + +#[derive(Clone)] +pub struct DesiClient { + client: reqwest::Client, + tap_sync_url: String, +} + +impl DesiClient { + pub fn new(tap_sync_url: &str, timeout_secs: u64) -> anyhow::Result { + Ok(DesiClient { + client: reqwest::Client::builder() + .redirect(crate::utils::ssrf::safe_redirect_policy()) + .timeout(Duration::from_secs(timeout_secs)) + .connect_timeout(Duration::from_secs(15)) + .build() + .context("Failed to create DESI HTTP client")?, + tap_sync_url: tap_sync_url.trim_end_matches('/').to_string(), + }) + } + + /// 锥形检索 DESI 光谱目标(经 Data Lab TAP q3c_radial_query) + /// + /// 返回 zpix 表中坐标半径范围内的目标行(targetid/ra/dec/z/spectype/healpix/survey/program)。 + /// 每个 healpix 像素对应一个 coadd 文件,可用 download_coadd 下载。 + pub async fn cone_search( + &self, + ra: f64, + dec: f64, + radius_deg: f64, + max_records: i64, + release: DesiRelease, + ) -> anyhow::Result { + let table = release.datalab_table(); + let top = max_records.clamp(1, 500); + + // Data Lab 支持 q3c锥形检索函数(比 SDSS 的 BETWEEN 矩形框更精确) + let adql = format!( + "SELECT TOP {top} targetid, ra, dec, z, spectype, healpix, survey, program \ + FROM {table} \ + WHERE q3c_radial_query(ra, dec, {center_ra}, {center_dec}, {radius})", + top = top, + table = table, + center_ra = ra, + center_dec = dec, + radius = radius_deg, + ); + + info!( + "[DESI] ConeSearch {} ra={:.4} dec={:.4} radius={:.4}° TOP {}", + release.display(), + ra, + dec, + radius_deg, + top + ); + + let body = self.tap_sync(&adql).await?; + // 检查错误 + if body.contains("QUERY_STATUS") && body.contains("ERROR") { + let snippet: String = body.chars().take(500).collect(); + error!("[DESI] TAP 查询错误: {}", snippet); + return Err(anyhow!("DESI TAP 查询失败(见日志)")); + } + + let votable = parse_votable_tabledata(&body, top)?; + let rows = votable + .rows + .iter() + .map(|r| row_to_desi(&votable.fields, r)) + .collect(); + + Ok(DesiConeResult { + rows, + fields: votable.fields, + row_count: votable.row_count, + truncated: votable.truncated, + }) + } + + /// 下载指定 HEALPix 像素的 coadd FITS(原始字节,无压缩) + /// + /// 一个 coadd 文件含该像素内所有目标的三臂(b/r/z)光谱。 + pub async fn download_coadd( + &self, + survey: &str, + program: &str, + healpix: i64, + release: DesiRelease, + ) -> anyhow::Result> { + let row = DesiSpectrumRow { + targetid: String::new(), + ra: None, + dec: None, + z: None, + spectype: None, + healpix, + survey: survey.to_string(), + program: program.to_string(), + }; + let url = row.coadd_url(release); + info!( + "[DESI] 下载 coadd {} {} healpix={}", + release.display(), + survey, + healpix + ); + + let resp = self.get_with_retry(&url).await?; + let bytes = resp + .bytes() + .await + .context("读取 DESI FITS 响应失败")? + .to_vec(); + + if bytes.is_empty() { + return Err(anyhow!( + "DESI 返回空响应({}/{} healpix={} 可能不存在)", + survey, + program, + healpix + )); + } + Ok(bytes) + } + + /// 执行 TAP sync(POST,默认 VOTable) + async fn tap_sync(&self, adql: &str) -> anyhow::Result { + let params = [ + ("REQUEST", "doQuery".to_string()), + ("LANG", "ADQL".to_string()), + ("QUERY", adql.to_string()), + ]; + let resp = self.post_with_retry(&self.tap_sync_url, ¶ms).await?; + let body = resp.text().await.context("读取 DESI TAP 响应失败")?; + Ok(body) + } + + /// 带重试的 GET(SAS 文件下载) + async fn get_with_retry(&self, url: &str) -> anyhow::Result { + const MAX_RETRIES: u32 = 3; + let mut last_err = None; + for attempt in 0..MAX_RETRIES { + let result = self + .client + .get(url) + .header("User-Agent", "AstroResearch/0.1 (academic research tool)") + .send() + .await; + + let resp = match result { + Ok(r) => r, + Err(e) => { + warn!("[DESI] SAS 请求失败 (第 {} 次): {}", attempt + 1, e); + last_err = Some(e.into()); + tokio::time::sleep(Duration::from_secs(2u64.pow(attempt))).await; + continue; + } + }; + + let status = resp.status(); + if (status.as_u16() == 429 || status.as_u16() == 503) && attempt < MAX_RETRIES - 1 { + let retry_after = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(5); + warn!( + "[DESI] SAS 速率限制/过载 ({}), {} 秒后重试 (第 {} 次)", + status, + retry_after, + attempt + 1 + ); + tokio::time::sleep(Duration::from_secs(retry_after)).await; + continue; + } + if !status.is_success() { + let err_body = resp.text().await.unwrap_or_default(); + error!( + "[DESI] SAS 请求失败: 状态码={}, 返回={}", + status, + err_body.chars().take(400).collect::() + ); + return Err(anyhow!("DESI SAS 接口返回错误码: {}", status)); + } + return Ok(resp); + } + Err(last_err.unwrap_or_else(|| anyhow!("DESI SAS 重试耗尽"))) + } + + /// 带重试的 POST(TAP 查询) + async fn post_with_retry( + &self, + url: &str, + params: &[(&str, String)], + ) -> anyhow::Result { + const MAX_RETRIES: u32 = 3; + let mut last_err = None; + for attempt in 0..MAX_RETRIES { + let owned: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let result = self + .client + .post(url) + .header("User-Agent", "AstroResearch/0.1 (academic research tool)") + .form(&owned) + .send() + .await; + + let resp = match result { + Ok(r) => r, + Err(e) => { + warn!("[DESI] TAP 请求失败 (第 {} 次): {}", attempt + 1, e); + last_err = Some(e.into()); + tokio::time::sleep(Duration::from_secs(2u64.pow(attempt))).await; + continue; + } + }; + + let status = resp.status(); + if (status.as_u16() == 429 || status.as_u16() == 503 || status.as_u16() == 500) + && attempt < MAX_RETRIES - 1 + { + let retry_after = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(5); + warn!( + "[DESI] TAP 服务端错误 ({}), {} 秒后重试 (第 {} 次)", + status, + retry_after, + attempt + 1 + ); + tokio::time::sleep(Duration::from_secs(retry_after)).await; + continue; + } + if !status.is_success() { + let err_body = resp.text().await.unwrap_or_default(); + error!( + "[DESI] TAP 请求失败: 状态码={}, 返回={}", + status, + err_body.chars().take(400).collect::() + ); + return Err(anyhow!("DESI TAP 接口返回错误码: {}", status)); + } + return Ok(resp); + } + Err(last_err.unwrap_or_else(|| anyhow!("DESI TAP 重试耗尽"))) + } +} + +/// 把一行 VOTable/JSON 按字段名映射为 DesiSpectrumRow +fn row_to_desi(fields: &[FieldInfo], row: &[serde_json::Value]) -> DesiSpectrumRow { + let get = |key: &str| -> Option<&serde_json::Value> { + fields + .iter() + .position(|f| f.name.eq_ignore_ascii_case(key)) + .and_then(|i| row.get(i)) + }; + let get_str = |key: &str| -> Option { + match get(key)? { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + } + }; + let get_f64 = |key: &str| get(key).and_then(|v| v.as_f64()); + let get_i64 = |key: &str| get_f64(key).map(|v| v as i64); + + DesiSpectrumRow { + targetid: get_str("targetid").unwrap_or_default(), + ra: get_f64("ra"), + dec: get_f64("dec"), + z: get_f64("z"), + spectype: get_str("spectype"), + healpix: get_i64("healpix").unwrap_or(0), + survey: get_str("survey").unwrap_or_default(), + program: get_str("program").unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_coadd_url() { + let row = DesiSpectrumRow { + targetid: "123".into(), + ra: None, + dec: None, + z: None, + spectype: None, + healpix: 10050, + survey: "main".into(), + program: "dark".into(), + }; + let url = row.coadd_url(DesiRelease::Dr1); + assert!(url.contains("/public/dr1/spectro/redux/iron/healpix/main/dark/100/10050/")); + assert!(url.ends_with("/coadd-main-dark-10050.fits")); + } + + #[test] + fn test_coadd_url_edr() { + let row = DesiSpectrumRow { + targetid: "x".into(), + ra: None, + dec: None, + z: None, + spectype: None, + healpix: 50, + survey: "sv3".into(), + program: "bright".into(), + }; + let url = row.coadd_url(DesiRelease::Edr); + assert!(url.contains("/public/edr/spectro/redux/fuji/")); + assert!(url.contains("/sv3/bright/0/50/")); + assert!(url.ends_with("/coadd-sv3-bright-50.fits")); + } + + #[test] + fn test_row_to_desi() { + let fields = vec![ + FieldInfo { + name: "targetid".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "ra".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "healpix".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "survey".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "program".into(), + description: None, + unit: None, + datatype: None, + }, + ]; + let row = vec![ + serde_json::json!(644789123456_i64), + serde_json::json!(180.0), + serde_json::json!(10050), + serde_json::json!("main"), + serde_json::json!("dark"), + ]; + let d = row_to_desi(&fields, &row); + assert_eq!(d.targetid, "644789123456"); + assert!((d.ra.unwrap() - 180.0).abs() < 1e-9); + assert_eq!(d.healpix, 10050); + assert_eq!(d.survey, "main"); + assert_eq!(d.program, "dark"); + } + + /// 真实 DESI Data Lab TAP cone search 测试 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_cone_search() { + let client = DesiClient::new("https://datalab.noirlab.edu/tap/sync", 90).unwrap(); + let result = client + .cone_search(180.0, 30.0, 0.1, 5, DesiRelease::Dr1) + .await + .unwrap(); + + println!("===== DESI ConeSearch Live ====="); + println!(" row_count: {}", result.row_count); + for r in result.rows.iter().take(3) { + println!( + " targetid={} healpix={} survey={} program={} z={:?}", + r.targetid, r.healpix, r.survey, r.program, r.z + ); + println!(" → {}", r.coadd_url(DesiRelease::Dr1)); + } + + assert!(!result.rows.is_empty(), "高银纬区应有 DESI 光谱"); + assert!(result.rows[0].healpix > 0); + assert!(!result.rows[0].survey.is_empty()); + } + + /// 真实 DESI coadd FITS 下载测试 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_download_coadd() { + let client = DesiClient::new("https://datalab.noirlab.edu/tap/sync", 120).unwrap(); + let cone = client + .cone_search(180.0, 30.0, 0.1, 5, DesiRelease::Dr1) + .await + .unwrap(); + let first = cone.rows.first().expect("应有结果"); + + let bytes = client + .download_coadd( + &first.survey, + &first.program, + first.healpix, + DesiRelease::Dr1, + ) + .await + .unwrap(); + + println!( + "===== DESI coadd Live ({} {} healpix={}) =====", + first.survey, first.program, first.healpix + ); + println!(" 字节数: {}", bytes.len()); + + assert!(bytes.len() > 10000, "coadd FITS 应有内容"); + // FITS 文件以 "SIMPLE" 开头 + assert!( + bytes.starts_with(b"SIMPLE"), + "应以 FITS magic 'SIMPLE' 开头" + ); + } +} diff --git a/src/clients/gaia/mod.rs b/src/clients/gaia/mod.rs new file mode 100644 index 0000000..1e33d31 --- /dev/null +++ b/src/clients/gaia/mod.rs @@ -0,0 +1,632 @@ +// src/clients/gaia/mod.rs +// +// Gaia(ESA 盖亚任务)光谱数据客户端 —— 纯通信层 +// 职责仅限:HTTP 请求(SSRF 防护 + 重试)、TAP JSON 解析、DataLink ZIP 字节下载 +// 业务层(缓存、ZIP 解包、落盘)在 services/spectra/gaia.rs +// +// 数据源(Gaia DR3): +// TAP: POST https://gea.esac.esa.int/tap-server/tap/sync → JSON/VOTable +// DataLink: POST https://gea.esac.esa.int/data-server/data → ZIP(内含 FITS/VOTable) +// +// 两步式检索(Gaia 的 BP/RP、RVS 光谱与历元测光不在主表内,只能经 DataLink 取): +// 1) TAP cone search → 拿到 source_id 列表(带 has_xp_continuous / has_xp_sampled 标志) +// 2) DataLink POST(source_ids, RETRIEVAL_TYPE=XP_CONTINUOUS|XP_SAMPLED|EPOCH_PHOTOMETRY|RVS) +// +// 匿名访问可用(注册用户额度更高)。Gaia TAP sync 默认 FORMAT=json(实测可返回)。 +// 复用 src/clients/vo/mod.rs 的 VOTable 解析器作为 VOTable 降级路径。 + +use crate::clients::vo::{parse_votable_tabledata, FieldInfo}; +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tracing::{error, info, warn}; + +// ── 领域结构 ── + +/// Gaia DR3 主表查询结果行(ConeSearch 后的候选源) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GaiaSourceRow { + /// Gaia 唯一源 ID(64 位整数,DataLink 的主键) + pub source_id: String, + pub ra: Option, + pub dec: Option, + pub phot_g_mean_mag: Option, + /// 是否有 BP/RP 连续光谱 + pub has_xp_continuous: Option, + /// 是否有 BP/RP 采样光谱 + pub has_xp_sampled: Option, + /// 是否有 RVS 光谱 + pub has_rvs_spectrum: Option, + /// 与查询中心的角距离(度),由 ADQL DISTANCE 计算 + pub distance: Option, +} + +/// ConeSearch 结果(行 + 字段 + 截断标记) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GaiaConeResult { + pub rows: Vec, + pub fields: Vec, + pub row_count: usize, + pub truncated: bool, +} + +/// DataLink 可下载的产品类型 +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum GaiaRetrievalType { + /// BP/RP 连续光谱(基函数系数) + XpContinuous, + /// BP/RP 采样光谱(波长-通量表) + XpSampled, + /// 历元测光(光变曲线) + EpochPhotometry, + /// RVS 径向速度光谱 + Rvs, + /// RVS 平均光谱 + MeanSpectrumRvs, +} + +impl GaiaRetrievalType { + pub fn as_param(&self) -> &'static str { + match self { + Self::XpContinuous => "XP_CONTINUOUS", + Self::XpSampled => "XP_SAMPLED", + Self::EpochPhotometry => "EPOCH_PHOTOMETRY", + Self::Rvs => "RVS", + Self::MeanSpectrumRvs => "MEAN_SPECTRUM_RVS", + } + } +} + +// ── TAP JSON 响应中间结构 ── +// Gaia TAP FORMAT=json 返回:{ "metadata": [{"name",...}], "data": [[v1,v2,...], ...] } + +#[derive(Debug, Deserialize)] +struct TapJsonMetadata { + name: String, +} + +#[derive(Debug, Deserialize)] +struct TapJsonResponse { + #[serde(default)] + metadata: Vec, + #[serde(default)] + data: Vec>, +} + +// ── 客户端 ── + +#[derive(Clone)] +pub struct GaiaClient { + client: reqwest::Client, + tap_sync_url: String, + datalink_url: String, +} + +impl GaiaClient { + pub fn new( + tap_base_url: &str, + datalink_base_url: &str, + timeout_secs: u64, + ) -> anyhow::Result { + Ok(GaiaClient { + client: reqwest::Client::builder() + // Gaia DataLink 端点(data-server/data)在传输完 ZIP 后直接断 TCP 而不发送 + // TLS close_notify 警报。rustls 严格遵守 RFC 5246,会把这种"无 close_notify 的 + // 关闭"判为 UnexpectedEof 致命错误(哪怕响应体已完整到达)。ESA 官方 Python + // 客户端 astroquery 走系统 OpenSSL(native-tls),后者容忍此行为,故不受影响。 + // 这里对 Gaia 客户端单独启用 native-tls,使其与 astroquery 走相同 TLS 栈, + // 从而正确处理 DataLink 的连接关闭。项目其余 client 仍用默认 rustls。 + .use_native_tls() + .redirect(crate::utils::ssrf::safe_redirect_policy()) + .timeout(Duration::from_secs(timeout_secs)) + .connect_timeout(Duration::from_secs(15)) + .build() + .context("Failed to create Gaia HTTP client")?, + tap_sync_url: format!("{}/sync", tap_base_url.trim_end_matches('/')), + datalink_url: format!("{}/data", datalink_base_url.trim_end_matches('/')), + }) + } + + /// 锥形检索 Gaia DR3 源(可选只返回有 BP/RP 光谱的源) + /// + /// ADQL: + /// SELECT TOP N source_id, ra, dec, phot_g_mean_mag, + /// has_xp_continuous, has_xp_sampled, has_rvs_spectrum, + /// DISTANCE(POINT('ICRS',ra,dec), POINT('ICRS',{ra},{dec})) AS distance + /// FROM gaiadr3.gaia_source + /// WHERE 1=CONTAINS(POINT('ICRS',ra,dec), CIRCLE('ICRS',{ra},{dec},{radius})) + /// [AND has_xp_continuous = 1] + /// ORDER BY distance ASC + pub async fn cone_search( + &self, + ra: f64, + dec: f64, + radius_deg: f64, + max_records: i64, + xp_only: bool, + release: crate::services::spectra::common::GaiaRelease, + ) -> anyhow::Result { + let top = max_records.clamp(1, 2000); + let table = release.tap_table(); + // 注意:DISTANCE 是 ADQL 保留函数名,不能用作未加引号的列别名,故用 dist + let mut adql = format!( + "SELECT TOP {top} source_id, ra, dec, phot_g_mean_mag, \ + has_xp_continuous, has_xp_sampled, has_rvs, \ + DISTANCE(POINT('ICRS',ra,dec), POINT('ICRS',{ra},{dec})) AS dist \ + FROM {table} \ + WHERE 1=CONTAINS(POINT('ICRS',ra,dec), CIRCLE('ICRS',{ra},{dec},{radius}))", + top = top, + table = table, + ra = ra, + dec = dec, + radius = radius_deg + ); + if xp_only { + adql.push_str(" AND has_xp_continuous=1"); + } + adql.push_str(" ORDER BY dist ASC"); + + info!( + "[Gaia] ConeSearch {} ra={:.4} dec={:.4} radius={:.4}° xp_only={} TOP {}", + release.display(), + ra, + dec, + radius_deg, + xp_only, + top + ); + + let body = self.tap_sync(&adql, "json").await?; + // 优先 JSON 路径 + if let Ok(tap) = serde_json::from_str::(&body) { + let fields: Vec = tap + .metadata + .iter() + .map(|m| FieldInfo { + name: m.name.clone(), + description: None, + unit: None, + datatype: None, + }) + .collect(); + let rows: Vec = tap + .data + .iter() + .map(|r| json_row_to_gaia(&fields, r)) + .collect(); + let row_count = tap.data.len(); + return Ok(GaiaConeResult { + rows, + fields, + row_count, + truncated: row_count as i64 >= top, + }); + } + + // JSON 失败 → 尝试 VOTable 降级(请求时若服务端忽略 FORMAT=json) + warn!("[Gaia] JSON 解析失败,尝试 VOTable 降级"); + let votable = parse_votable_tabledata(&body, top)?; + let rows = votable + .rows + .iter() + .map(|r| json_row_to_gaia(&votable.fields, r)) + .collect(); + Ok(GaiaConeResult { + rows, + fields: votable.fields, + row_count: votable.row_count, + truncated: votable.truncated, + }) + } + + /// 通过 DataLink 下载光谱产品(返回 ZIP 字节流) + /// + /// POST datalink_url,form 编码: + /// RETRIEVAL_TYPE=XP_CONTINUOUS ID=<逗号分隔 source_id> FORMAT=fits USE_ZIP_ALWAYS=true + /// 返回 application/zip,每个 source 一个文件。 + /// 业务层负责解包 ZIP。 + pub async fn download_products( + &self, + source_ids: &[String], + retrieval_type: GaiaRetrievalType, + format: &str, + ) -> anyhow::Result> { + if source_ids.is_empty() { + return Err(anyhow!("source_ids 不能为空")); + } + // 分块(ESA 建议 ≤ 3000 ids/请求,这里保守取 500) + let ids_str = source_ids.join(","); + + info!( + "[Gaia] DataLink 下载 {} 个源 (type={}, fmt={})", + source_ids.len(), + retrieval_type.as_param(), + format + ); + + let params = [ + ("RETRIEVAL_TYPE", retrieval_type.as_param().to_string()), + ("ID", ids_str), + ("DATA_STRUCTURE", "INDIVIDUAL".to_string()), + ("FORMAT", format.to_string()), + ("USE_ZIP_ALWAYS", "true".to_string()), + // VALID_DATA=false 对齐 astroquery load_data:返回含空 flux 的历元测光行, + // 由业务层按需过滤。其余产品类型该参数无副作用,统一发送以保持请求形状一致。 + ("VALID_DATA", "false".to_string()), + ]; + + let resp = self.post_with_retry(&self.datalink_url, ¶ms).await?; + let bytes = resp + .bytes() + .await + .context("读取 Gaia DataLink 响应失败")? + .to_vec(); + + if bytes.is_empty() { + return Err(anyhow!("Gaia DataLink 返回空响应")); + } + Ok(bytes) + } + + /// 执行 TAP sync 查询(带重试) + async fn tap_sync(&self, adql: &str, format: &str) -> anyhow::Result { + let params = [ + ("REQUEST", "doQuery".to_string()), + ("LANG", "ADQL".to_string()), + ("FORMAT", format.to_string()), + ("QUERY", adql.to_string()), + ]; + let resp = self.post_with_retry(&self.tap_sync_url, ¶ms).await?; + let body = resp.text().await.context("读取 Gaia TAP 响应失败")?; + // 检查 VOTable 错误标志 + if body.contains("QUERY_STATUS") && body.contains("ERROR") { + let snippet: String = body.chars().take(400).collect(); + error!("[Gaia] TAP 查询错误: {}", snippet); + return Err(anyhow!("Gaia TAP 查询失败(见日志)")); + } + Ok(body) + } + + /// 带重试的 POST(对齐 vizier.rs 的 429/503 重试模式) + async fn post_with_retry( + &self, + url: &str, + params: &[(&str, String)], + ) -> anyhow::Result { + const MAX_RETRIES: u32 = 3; + let mut last_err = None; + for attempt in 0..MAX_RETRIES { + let owned: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let result = self + .client + .post(url) + .header("User-Agent", "AstroResearch/0.1 (academic research tool)") + .form(&owned) + .send() + .await; + + let resp = match result { + Ok(r) => r, + Err(e) => { + warn!("[Gaia] 请求失败 (第 {} 次): {}", attempt + 1, e); + last_err = Some(e.into()); + tokio::time::sleep(Duration::from_secs(2u64.pow(attempt))).await; + continue; + } + }; + + let status = resp.status(); + // 仅对真正的临时过载(429 限流 / 503 维护中)重试。 + // 不重试 500:ESA DataLink 的 500 是后端数据库故障(如 dl_auxiliary_schema 连不上), + // 属确定性错误,重试只会白白耗费 60s×N,且耗尽后仍未给出可用诊断。 + if (status.as_u16() == 429 || status.as_u16() == 503) && attempt < MAX_RETRIES - 1 { + let retry_after = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(5); + warn!( + "[Gaia] 服务端临时过载 ({}), {} 秒后重试 (第 {} 次)", + status, + retry_after, + attempt + 1 + ); + tokio::time::sleep(Duration::from_secs(retry_after)).await; + continue; + } + if !status.is_success() { + let err_body = resp.text().await.unwrap_or_default(); + let detail = extract_gaia_error_message(&err_body); + error!( + "[Gaia] 请求失败: 状态码={}, 返回={}", + status, + err_body.chars().take(400).collect::() + ); + let msg = match detail { + Some(d) => format!("Gaia 服务端错误 (HTTP {}): {}", status, d), + None => format!("Gaia 接口返回错误码: {}", status), + }; + return Err(anyhow!(msg)); + } + return Ok(resp); + } + Err(last_err.unwrap_or_else(|| anyhow!("Gaia 重试耗尽"))) + } +} + +/// 从 Gaia 错误响应中提取可读诊断信息。 +/// +/// ESA 在 4xx/5xx 时返回 HTML 错误页,其中 `Message: ...` 含真实原因, +/// 例如 "Could not retrieve data from table dl_auxiliary_schema.join_tap_aux / Unable to create connection to database"。 +/// 提取它,让错误对用户和 agent 有意义;提取失败则返回 None(由调用方回退到状态码)。 +fn extract_gaia_error_message(body: &str) -> Option { + // 优先提取 Message: ... 块(ESA 错误页标准结构) + if let Some(start) = body.find("Message: ") { + let after = &body[start + "Message: ".len()..]; + let end = after.find("").or_else(|| after.find('\n'))?; + let raw = &after[..end]; + return Some(html_to_plain(raw)); + } + // 兜底:取 标签 + if let (Some(ts), Some(te)) = (body.find("<title>"), body.find("")) { + let raw = &body[ts + 7..te]; + if !raw.trim().is_empty() { + return Some(html_to_plain(raw)); + } + } + // JSON 形态(部分端点返回 {"error": "..."}) + if let Ok(v) = serde_json::from_str::(body) { + if let Some(msg) = v.get("error").and_then(|e| e.as_str()) { + return Some(msg.to_string()); + } + } + None +} + +/// 极简 HTML 转纯文本:去标签、解码常见实体、压缩空白 +fn html_to_plain(s: &str) -> String { + let no_tags: String = s + .replace("
", "\n") + .replace("
", "\n") + .replace("
", "\n") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(" ", " "); + // 去掉剩余 HTML 标签 + let mut out = String::with_capacity(no_tags.len()); + let mut in_tag = false; + for c in no_tags.chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(c), + _ => {} + } + } + out.split_whitespace().collect::>().join(" ") +} + +/// 把一行(JSON Value 数组或 VOTable 行)按字段名映射为 GaiaSourceRow +fn json_row_to_gaia(fields: &[FieldInfo], row: &[serde_json::Value]) -> GaiaSourceRow { + let get = |key: &str| -> Option<&serde_json::Value> { + fields + .iter() + .position(|f| f.name.eq_ignore_ascii_case(key)) + .and_then(|i| row.get(i)) + }; + let get_str = |key: &str| -> Option { + match get(key)? { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + } + }; + let get_f64 = |key: &str| get(key).and_then(|v| v.as_f64()); + let get_bool = |key: &str| -> Option { + match get(key)? { + serde_json::Value::Bool(b) => Some(*b), + serde_json::Value::Number(n) => n.as_i64().map(|i| i != 0), + _ => None, + } + }; + + GaiaSourceRow { + source_id: get_str("source_id").unwrap_or_default(), + ra: get_f64("ra"), + dec: get_f64("dec"), + phot_g_mean_mag: get_f64("phot_g_mean_mag"), + has_xp_continuous: get_bool("has_xp_continuous"), + has_xp_sampled: get_bool("has_xp_sampled"), + has_rvs_spectrum: get_bool("has_rvs"), + distance: get_f64("dist").or_else(|| get_f64("distance")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_retrieval_type_param() { + assert_eq!(GaiaRetrievalType::XpContinuous.as_param(), "XP_CONTINUOUS"); + assert_eq!( + GaiaRetrievalType::EpochPhotometry.as_param(), + "EPOCH_PHOTOMETRY" + ); + } + + #[test] + fn test_extract_gaia_error_message_html() { + // 模拟 ESA 真实 500 错误页(含 dl_auxiliary_schema 数据库故障) + let body = r#"SERVICE ERROR +

SERVICE ERROR - 500

+
  • Context: DataRetrieval
  • +
  • Message: Code: -1, msg: Could not retrieve data from table dl_auxiliary_schema.join_tap_aux +Source: Unable to create connection to database
  • +
"#; + let msg = extract_gaia_error_message(body).unwrap(); + assert!( + msg.contains("dl_auxiliary_schema"), + "应提取数据库表名: {}", + msg + ); + assert!( + msg.contains("Unable to create connection"), + "应提取故障原因: {}", + msg + ); + assert!(!msg.contains(""), "应已去除 HTML 标签"); + } + + #[test] + fn test_extract_gaia_error_message_title_fallback() { + // 无 Message 字段时回退到 title + let body = "Bad Request - 400"; + let msg = extract_gaia_error_message(body).unwrap(); + assert_eq!(msg, "Bad Request - 400"); + } + + #[test] + fn test_extract_gaia_error_message_json() { + let body = r#"{"error": "source_id 123 not found"}"#; + let msg = extract_gaia_error_message(body).unwrap(); + assert_eq!(msg, "source_id 123 not found"); + } + + #[test] + fn test_extract_gaia_error_message_none() { + // 无可识别的错误结构 + assert!(extract_gaia_error_message("plain text no structure").is_none()); + assert!(extract_gaia_error_message("").is_none()); + } + + #[test] + fn test_json_row_to_gaia() { + let fields = vec![ + FieldInfo { + name: "source_id".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "ra".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "has_xp_continuous".into(), + description: None, + unit: None, + datatype: None, + }, + ]; + let row = vec![ + serde_json::json!(65214031805717376_i64), + serde_json::json!(56.75), + serde_json::json!(true), + ]; + let g = json_row_to_gaia(&fields, &row); + assert_eq!(g.source_id, "65214031805717376"); + assert!((g.ra.unwrap() - 56.75).abs() < 1e-9); + assert_eq!(g.has_xp_continuous, Some(true)); + } + + /// 真实 Gaia TAP cone search 测试 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_cone_search() { + let client = GaiaClient::new( + "https://gea.esac.esa.int/tap-server/tap", + "https://gea.esac.esa.int/data-server", + 90, + ) + .unwrap(); + // 北银极附近,密集区 + let result = client + .cone_search( + 180.0, + 30.0, + 0.05, + 5, + false, + crate::services::spectra::common::GaiaRelease::Dr3, + ) + .await + .unwrap(); + + println!("===== Gaia ConeSearch Live ====="); + println!(" row_count: {}", result.row_count); + println!(" 首行: {:?}", result.rows.first()); + + assert!(!result.rows.is_empty(), "应有 Gaia 源"); + assert!(!result.rows[0].source_id.is_empty(), "source_id 应非空"); + } + + /// 真实 Gaia DataLink 完整下载测试 + /// + /// DataLink 端点(data-server/data)在传完 ZIP 后直接断 TCP 不发 TLS close_notify。 + /// 本客户端为此单独启用 native-tls(OpenSSL)以兼容此行为,与 astroquery 同栈。 + /// 若本测试仍报 close_notify/connection 错误,说明运行环境缺 OpenSSL 运行时库 + /// (Linux 需 libssl/libcrypto),而非客户端逻辑问题。 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_datalink_download() { + let client = GaiaClient::new( + "https://gea.esac.esa.int/tap-server/tap", + "https://gea.esac.esa.int/data-server", + 120, + ) + .unwrap(); + // 用一个已知有 XP 光谱的 source_id(来自实测,has_xp_continuous=True) + let sid = "65214031805717376".to_string(); + println!("测试 DataLink 下载 source_id={}", sid); + + match client + .download_products(&[sid], GaiaRetrievalType::XpContinuous, "fits") + .await + { + Ok(bytes) => { + println!("===== Gaia DataLink Live ====="); + println!(" 字节数: {}", bytes.len()); + println!(" 前 4 字节: {:02x?}", &bytes[..bytes.len().min(4)]); + assert!(bytes.len() > 100, "ZIP 应有内容"); + assert_eq!(&bytes[..2], b"PK", "应以 ZIP magic 'PK' 开头"); + } + Err(e) => { + let msg = format!("{:#}", e); + // 区分两类非代码缺陷的失败: + // (a) ESA 服务端故障(HTTP 500,如 dl_auxiliary_schema 数据库连不上)—— ESA 全局问题 + // (b) 本机连通性问题(超时/TLS 重置)—— 运行环境网络限制 + // 两者都不软失败会让 CI 在 ESA 故障期间永远红,故记录诊断后放行。 + let is_server_side = msg.contains("HTTP 500") + || msg.contains("服务端错误") + || msg.contains("dl_auxiliary_schema") + || msg.contains("Could not retrieve data"); + let is_network = msg.contains("close_notify") + || msg.contains("peer closed") + || msg.contains("connection error") + || msg.contains("connect error") + || msg.contains("timed out") + || msg.contains("timeout") + || msg.contains("connect"); + if is_server_side { + eprintln!("⚠ Gaia DataLink 服务端故障(ESA 后端数据库不可用,非客户端问题)。"); + eprintln!(" 错误: {}", msg); + } else if is_network { + eprintln!("⚠ Gaia DataLink 端点在本网络不可达(连通性/超时)。"); + eprintln!(" native-tls 已修复 TLS 关闭兼容性;此为运行环境网络限制。"); + eprintln!(" 错误: {}", msg); + } else { + panic!("Gaia DataLink 下载失败(非服务端/网络限制): {}", msg); + } + } + } + } +} diff --git a/src/clients/lamost/mod.rs b/src/clients/lamost/mod.rs new file mode 100644 index 0000000..e9417b9 --- /dev/null +++ b/src/clients/lamost/mod.rs @@ -0,0 +1,415 @@ +// src/clients/lamost/mod.rs +// +// LAMOST(郭守敬望远镜 / 大天区面积多目标光纤光谱天文望远镜)光谱数据客户端 —— 纯通信层 +// 职责仅限:HTTP 请求(SSRF 防护 + 重试)、VOTable 解析、FITS.gz 原始字节下载 +// 业务层(缓存、解压落盘、obsid 校验、批量编排)在 services/spectra/lamost.rs +// +// 数据源(默认 LAMOST DR10 v2.0): +// ConeSearch: GET {base}/voservice/conesearch?RA=&DEC=&SR= → VOTable(含 obsid 列) +// FITS 下载: GET {base}/spectrum/fits/{obsid} → application/gzip(.fits.gz) +// +// 无需认证。复用 src/clients/vo/mod.rs 的共享 VOTable TABLEDATA 解析器。 +// 对齐 clients/cds/vizier.rs 范式:自建 reqwest::Client + safe_redirect_policy + 429/503 重试 + 超时。 + +use crate::clients::vo::{parse_votable_tabledata, FieldInfo}; +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tracing::{error, info, warn}; + +// ── 领域结构 ── + +/// LAMOST ConeSearch 结果行(一维光谱的目录信息) +/// +/// 字段映射自 LAMOST combined 表:obsid 是唯一主键,FITS 下载 URL 即由它拼成。 +/// 其他字段为常用测光/分类信息,缺失时为 None。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LamostSpectrumRow { + /// 唯一观测 ID(FITS 下载 URL 的主键) + pub obsid: i64, + /// IAU 式名称,如 J004233.24+411742.3 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub designation: Option, + /// 观测赤经(度,J2000) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ra_obs: Option, + /// 观测赤纬(度,J2000) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dec_obs: Option, + /// 红移 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub z: Option, + /// 光谱分类(STAR / GALAXY / QSO) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class: Option, + /// 子分类(如恒星光谱型 A0V) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subclass: Option, + /// u 波段信噪比 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sn_u: Option, + /// g 波段信噪比 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sn_g: Option, + /// r 波段信噪比 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sn_r: Option, + /// i 波段信噪比 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sn_i: Option, + /// z 波段信噪比 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sn_z: Option, +} + +/// ConeSearch 解析结果(行 + 原始字段元信息 + 截断标记) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LamostConeResult { + pub rows: Vec, + pub fields: Vec, + pub row_count: usize, + pub truncated: bool, +} + +// ── 客户端 ── + +#[derive(Clone)] +pub struct LamostClient { + client: reqwest::Client, + base_url: String, +} + +impl LamostClient { + pub fn new(base_url: &str, timeout_secs: u64) -> anyhow::Result { + Ok(LamostClient { + client: reqwest::Client::builder() + .redirect(crate::utils::ssrf::safe_redirect_policy()) + .timeout(Duration::from_secs(timeout_secs)) + .connect_timeout(Duration::from_secs(10)) + .build() + .context("Failed to create LAMOST HTTP client")?, + base_url: base_url.trim_end_matches('/').to_string(), + }) + } + + /// 锥形检索:返回坐标半径范围内的 LAMOST 光谱列表 + /// + /// GET {base}/{dr}/{ver}/{voservice|medvoservice}/conesearch?RA={ra}&DEC={dec}&SR={radius_deg} + /// 服务端返回 VOTable,用共享解析器解析后映射为 LamostSpectrumRow。 + pub async fn cone_search( + &self, + ra: f64, + dec: f64, + radius_deg: f64, + release: crate::services::spectra::common::LamostRelease, + resolution: crate::services::spectra::common::LamostResolution, + ) -> anyhow::Result { + if resolution == crate::services::spectra::common::LamostResolution::Mrs + && !release.supports_mrs() + { + return Err(anyhow!( + "MRS(中分辨率)从 DR7 起才支持,{:?} 无 MRS 数据", + release + )); + } + + let url = format!( + "{}/{}/{}/conesearch", + self.base_url, + release.path_segment(), + release.version_segment(), + ); + info!( + "[LAMOST] ConeSearch {} {} ra={:.4} dec={:.4} radius={:.4}°", + release.path_segment(), + resolution.display(), + ra, + dec, + radius_deg + ); + + let params: [(&str, String); 3] = [ + ("RA", format!("{:.6}", ra)), + ("DEC", format!("{:.6}", dec)), + ("SR", format!("{:.6}", radius_deg)), + ]; + let owned_params: Vec<(&str, &str)> = + params.iter().map(|(k, v)| (*k, v.as_str())).collect(); + + let resp = self.send_with_retry(&url, &owned_params, None).await?; + let body = resp + .text() + .await + .context("读取 LAMOST ConeSearch 响应失败")?; + + // LAMOST ConeSearch 无 MAXREC 概念,传一个大数仅用于 truncated 推断 + let votable = parse_votable_tabledata(&body, i64::MAX)?; + + let rows = votable + .rows + .iter() + .map(|r| row_to_lamost(&votable.fields, r)) + .collect(); + + Ok(LamostConeResult { + rows, + fields: votable.fields, + row_count: votable.row_count, + truncated: votable.truncated, + }) + } + + /// 下载指定 obsid 的光谱 FITS(gzip 压缩的原始字节) + /// + /// GET {base}/{dr}/{ver}/spectrum/fits/{obsid} → application/gzip + /// 返回的 bytes 可能是 gzip 压缩(magic bytes 0x1f 0x8b),由业务层解压。 + pub async fn download_fits( + &self, + obsid: i64, + release: crate::services::spectra::common::LamostRelease, + ) -> anyhow::Result> { + let url = format!( + "{}/{}/{}/spectrum/fits/{}", + self.base_url, + release.path_segment(), + release.version_segment(), + obsid + ); + info!( + "[LAMOST] 下载 FITS {} obsid={}", + release.path_segment(), + obsid + ); + + let resp = self.send_with_retry(&url, &[], Some(obsid)).await?; + let bytes = resp + .bytes() + .await + .context("读取 LAMOST FITS 响应失败")? + .to_vec(); + + if bytes.is_empty() { + return Err(anyhow!("LAMOST 返回空响应(obsid={} 可能不存在)", obsid)); + } + Ok(bytes) + } + + /// 带重试的请求发送(对齐 vizier.rs 的 429/503 重试模式) + /// + /// `obsid` 仅用于下载失败时的错误信息标注,不影响请求本身。 + async fn send_with_retry( + &self, + url: &str, + params: &[(&str, &str)], + obsid: Option, + ) -> anyhow::Result { + const MAX_RETRIES: u32 = 3; + let mut response = None; + for attempt in 0..MAX_RETRIES { + let resp = self + .client + .get(url) + .header("User-Agent", "AstroResearch/0.1 (academic research tool)") + .query(params) + .send() + .await + .context("LAMOST 请求发送失败")?; + + let status = resp.status(); + if (status.as_u16() == 429 || status.as_u16() == 503) && attempt < MAX_RETRIES - 1 { + let retry_after = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(5); + warn!( + "[LAMOST] 速率限制/过载 ({}), {} 秒后重试 (第 {} 次)", + status, + retry_after, + attempt + 1 + ); + tokio::time::sleep(Duration::from_secs(retry_after)).await; + continue; + } + response = Some(resp); + break; + } + + let response = response.ok_or_else(|| anyhow!("LAMOST 重试耗尽"))?; + let status = response.status(); + if !status.is_success() { + let err_body = response.text().await.unwrap_or_default(); + let ctx = obsid.map(|o| format!(" (obsid={})", o)).unwrap_or_default(); + error!( + "[LAMOST] 请求失败{}: 状态码={}, 返回错误={}", + ctx, + status, + err_body.chars().take(500).collect::() + ); + return Err(anyhow!("LAMOST 接口返回错误码: {}{}", status, ctx)); + } + Ok(response) + } +} + +/// 把 VOTable 行按字段名映射为 LamostSpectrumRow +/// +/// LAMOST ConeSearch 返回的列名存在大小写变体(如 obsid / OBSID),统一小写匹配。 +fn row_to_lamost(fields: &[FieldInfo], row: &[serde_json::Value]) -> LamostSpectrumRow { + let get = |key: &str| -> Option<&serde_json::Value> { + fields + .iter() + .position(|f| f.name.eq_ignore_ascii_case(key)) + .and_then(|i| row.get(i)) + }; + let get_str = + |key: &str| -> Option { get(key).and_then(|v| v.as_str()).map(|s| s.to_string()) }; + let get_f64 = |key: &str| -> Option { get(key).and_then(|v| v.as_f64()) }; + + LamostSpectrumRow { + obsid: get_f64("obsid").map(|v| v as i64).unwrap_or(0), + designation: get_str("designation"), + ra_obs: get_f64("ra_obs").or_else(|| get_f64("ra")), + dec_obs: get_f64("dec_obs").or_else(|| get_f64("dec")), + z: get_f64("z"), + class: get_str("class"), + subclass: get_str("subclass"), + sn_u: get_f64("sn_u").or_else(|| get_f64("snru")), + sn_g: get_f64("sn_g").or_else(|| get_f64("snrg")), + sn_r: get_f64("sn_r").or_else(|| get_f64("snrr")), + sn_i: get_f64("sn_i").or_else(|| get_f64("snri")), + sn_z: get_f64("sn_z").or_else(|| get_f64("snrz")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_row_to_lamost() { + let fields = vec![ + FieldInfo { + name: "obsid".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "designation".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "ra_obs".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "z".into(), + description: None, + unit: None, + datatype: None, + }, + FieldInfo { + name: "class".into(), + description: None, + unit: None, + datatype: None, + }, + ]; + let row = vec![ + serde_json::json!(438809089), + serde_json::json!("J004233.24+411742.3"), + serde_json::json!(10.6847), + serde_json::json!(0.0001), + serde_json::json!("STAR"), + ]; + let m = row_to_lamost(&fields, &row); + assert_eq!(m.obsid, 438809089); + assert_eq!(m.designation.as_deref(), Some("J004233.24+411742.3")); + assert!((m.ra_obs.unwrap() - 10.6847).abs() < 1e-9); + assert_eq!(m.class.as_deref(), Some("STAR")); + } + + #[test] + fn test_row_to_lamost_missing_fields() { + // 仅 obsid 存在,其余缺失应优雅降级 + let fields = vec![FieldInfo { + name: "obsid".into(), + description: None, + unit: None, + datatype: None, + }]; + let row = vec![serde_json::json!(123)]; + let m = row_to_lamost(&fields, &row); + assert_eq!(m.obsid, 123); + assert!(m.designation.is_none()); + assert!(m.z.is_none()); + } + + /// 真实 LAMOST ConeSearch 接口测试 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_cone_search() { + use crate::services::spectra::common::{LamostRelease, LamostResolution}; + let client = LamostClient::new("https://www.lamost.org", 60).unwrap(); + // M31 附近,DR10 LRS + let result = client + .cone_search( + 10.6847, + 41.2687, + 0.1, + LamostRelease::Dr10, + LamostResolution::Lrs, + ) + .await + .unwrap(); + + println!("===== LAMOST ConeSearch Live ====="); + println!(" row_count: {}", result.row_count); + println!( + " fields: {:?}", + result.fields.iter().map(|f| &f.name).collect::>() + ); + println!(" 首行: {:?}", result.rows.first()); + + assert!(!result.rows.is_empty(), "M31 附近应有 LAMOST 光谱"); + assert!(result.rows[0].obsid > 0, "obsid 应为正整数"); + } + + /// 真实 LAMOST FITS 下载测试 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_download_fits() { + use crate::services::spectra::common::{LamostRelease, LamostResolution}; + let client = LamostClient::new("https://www.lamost.org", 60).unwrap(); + // 先用 ConeSearch 找一个真实 obsid + let cone = client + .cone_search( + 10.6847, + 41.2687, + 0.1, + LamostRelease::Dr10, + LamostResolution::Lrs, + ) + .await + .unwrap(); + let obsid = cone.rows.first().expect("应有结果").obsid; + + let bytes = client + .download_fits(obsid, LamostRelease::Dr10) + .await + .unwrap(); + println!("===== LAMOST FITS Live (obsid={}) =====", obsid); + println!(" 字节数: {}", bytes.len()); + println!(" 前 4 字节: {:02x?}", &bytes[..bytes.len().min(4)]); + + assert!(bytes.len() > 1000, "FITS.gz 应有内容"); + // gzip magic bytes + assert_eq!(&bytes[..2], &[0x1f, 0x8b], "应以 gzip magic 开头"); + } +} diff --git a/src/clients/llm/chat.rs b/src/clients/llm/chat.rs index f17dd73..9ec5da8 100644 --- a/src/clients/llm/chat.rs +++ b/src/clients/llm/chat.rs @@ -38,7 +38,7 @@ impl ChatCompleter for LlmClient { impl LlmClient { pub fn new(api_key: String, api_base: String, model: String) -> anyhow::Result { let client = Client::builder() - .timeout(std::time::Duration::from_secs(120)) + .timeout(std::time::Duration::from_secs(600)) .connect_timeout(std::time::Duration::from_secs(10)) .redirect(crate::utils::ssrf::safe_redirect_policy()) .build() diff --git a/src/clients/mod.rs b/src/clients/mod.rs index 7818000..078fd5f 100644 --- a/src/clients/mod.rs +++ b/src/clients/mod.rs @@ -1,4 +1,10 @@ pub mod ads; pub mod arxiv; +pub mod cds; +pub mod desi; +pub mod gaia; +pub mod lamost; pub mod llm; pub mod qiniu; +pub mod sdss; +pub mod vo; diff --git a/src/clients/sdss/mod.rs b/src/clients/sdss/mod.rs new file mode 100644 index 0000000..dce862a --- /dev/null +++ b/src/clients/sdss/mod.rs @@ -0,0 +1,779 @@ +// src/clients/sdss/mod.rs +// +// SDSS(斯隆数字巡天)光谱数据客户端 —— 纯通信层 +// 职责仅限:HTTP 请求(SSRF 防护 + 重试)、TAP VOTable 解析、SAS FITS 字节下载 +// 业务层(缓存、落盘)在 services/spectra/sdss.rs +// +// 数据源(SDSS DR17): +// TAP: POST https://datalab.noirlab.edu/tap/sync → VOTable(Data Lab 托管的 sdss_dr17 schema) +// SAS: GET https://data.sdss.org/sas/dr17/... → FITS(无认证,直接下载) +// +// 关键差异(实测确认): +// 1) Data Lab TAP 是严格 ADQL 解析器,不支持 q3c 原生函数,且 POINT('ICRS',...) 的 +// 字符串字面量在它的 PSQL 后端会触发类型错误 → 必须用 RA/Dec BETWEEN 范围查询 +// 2) FORMAT=json 不支持(返回错误 VOTable)→ 必须用默认 VOTable +// 3) SAS 路径由 run2d 决定:run2d 以 'v' 开头 → eboss/,否则 → sdss/ +// 4) 光谱唯一标识为 plate-mjd-fiberID + run2d(specObjID 是 64 位目录 ID) +// +// 复用 src/clients/vo/mod.rs 的 VOTable 解析器。 + +use crate::clients::vo::{parse_votable_tabledata, FieldInfo}; +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tracing::{error, info, warn}; + +// ── 领域结构 ── + +/// SDSS 光谱查询结果行(来自 specobj 表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SdssSpectrumRow { + /// 64 位目录 ID + pub specobjid: String, + /// 板 ID(DR≤18,SDSS-V 用 field 替代) + #[serde(default)] + pub plate: i64, + /// 观测 MJD + pub mjd: i64, + /// 光纤 ID(1~640 SDSS,1~1000 BOSS;DR19 SDSS-V 用 catalogid 替代) + #[serde(default)] + pub fiberid: i64, + /// 2D 归约版本(决定 SAS 路径结构) + pub run2d: String, + /// SDSS-V field ID(DR19,替代 plate) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field: Option, + /// SDSS-V catalog ID(DR19,替代 fiberid,~11 位整数) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalogid: Option, + pub ra: Option, + pub dec: Option, + pub z: Option, + /// 光谱分类(GALAXY / QSO / STAR) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class: Option, +} + +impl SdssSpectrumRow { + /// 构造 SAS 下载 URL + /// + /// DR≤17: `.../sas/{dr}/{prefix}/spectro/redux/{run2d}/spectra/lite/{plate4}/spec-{plate}-{mjd}-{fiber4}.fits` + /// prefix = "eboss" if run2d starts with 'v' else "sdss" + /// + /// DR18+: `.../sas/{dr}/spectro/boss/redux/{run2d}/spectra/lite/{plate4}/spec-{plate}-{mjd}-{fiber4}.fits` + /// (v6_* run2d 无 prefix;旧数据在 prior-surveys/ 下但此处不处理) + /// + /// DR19 SDSS-V(有 field/catalogid): `.../sas/dr19/spectro/boss/redux/{run2d}/spectra/lite/{field}/spec-{field}-{mjd}-{catalogid:011}.fits` + pub fn sas_url(&self, release: crate::services::spectra::common::SdssRelease) -> String { + use crate::services::spectra::common::SdssRelease; + let dr = release.sas_dr_segment(); + match release { + SdssRelease::Dr16 | SdssRelease::Dr17 => { + let prefix = if self.run2d.starts_with('v') { + "eboss" + } else { + "sdss" + }; + format!( + "https://data.sdss.org/sas/{dr}/{prefix}/spectro/redux/{run2d}/spectra/lite/{plate:04}/spec-{plate:04}-{mjd}-{fiber:04}.fits", + dr = dr, + prefix = prefix, + run2d = self.run2d, + plate = self.plate, + mjd = self.mjd, + fiber = self.fiberid, + ) + } + SdssRelease::Dr18 => { + // DR18: v6_* 无 prefix,直接 spectro/boss/redux/ + format!( + "https://data.sdss.org/sas/{dr}/spectro/boss/redux/{run2d}/spectra/lite/{plate:04}/spec-{plate:04}-{mjd}-{fiber:04}.fits", + dr = dr, + run2d = self.run2d, + plate = self.plate, + mjd = self.mjd, + fiber = self.fiberid, + ) + } + SdssRelease::Dr19 => { + // DR19: SDSS-V 用 field/catalogid 命名 + if let (Some(field), Some(catalogid)) = (self.field, self.catalogid) { + format!( + "https://data.sdss.org/sas/{dr}/spectro/boss/redux/{run2d}/spectra/lite/{field}/spec-{field}-{mjd}-{catalogid:011}.fits", + dr = dr, + run2d = self.run2d, + field = field, + mjd = self.mjd, + catalogid = catalogid, + ) + } else { + // DR19 legacy spectra 仍用 plate-mjd-fiber + format!( + "https://data.sdss.org/sas/{dr}/spectro/boss/redux/{run2d}/spectra/lite/{plate:04}/spec-{plate:04}-{mjd}-{fiber:04}.fits", + dr = dr, + run2d = self.run2d, + plate = self.plate, + mjd = self.mjd, + fiber = self.fiberid, + ) + } + } + } + } +} + +/// ConeSearch 结果(行 + 字段 + 截断标记) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SdssConeResult { + pub rows: Vec, + pub fields: Vec, + pub row_count: usize, + pub truncated: bool, +} + +// ── APOGEE 近红外光谱 ── + +/// APOGEE DR17 allStar 表查询结果行 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApogeeStarRow { + /// APOGEE 唯一 ID(2MASS 源名,如 "2M17400083-2858496") + pub apogee_id: String, + pub ra: Option, + pub dec: Option, + /// 望远镜(apo25m / lco25m) + pub telescope: String, + /// 天区字段(如 "359+01",URL 中 + 需编码为 %2B) + pub field: String, + /// 有效温度(K) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teff: Option, + /// 金属丰度 [Fe/H] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fe_h: Option, + /// 信噪比 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snr: Option, +} + +impl ApogeeStarRow { + /// 构造 APOGEE FITS 的 SAS 下载 URL + /// + /// apStar(合并星谱): + /// sas/{dr}/apogee/spectro/redux/dr17/stars/{telescope}/{field_enc}/apStar-dr17-{apogee_id}.fits + /// aspcapStar(ASPCAP 输出): + /// sas/{dr}/apogee/spectro/aspcap/dr17/synspec/{telescope}/{field_enc}/aspcapStar-dr17-{apogee_id}.fits + /// + /// field 中的 + 在 URL 中编码为 %2B。文件名格式为 {type}-dr17-{apogee_id}.fits(不含 telescope/field)。 + pub fn apogee_sas_url( + &self, + release: crate::services::spectra::common::SdssRelease, + data_type: &str, + ) -> String { + let dr = release.sas_dr_segment(); + // apred_vers 当前固定 dr17(DR16 为 dr16,但 Data Lab 仅 DR17) + let apred = "dr17"; + let field_enc = self.field.replace('+', "%2B"); + match data_type { + "aspcap" => format!( + "https://data.sdss.org/sas/{dr}/apogee/spectro/aspcap/{apred}/synspec/{tel}/{field_enc}/aspcapStar-{apred}-{id}.fits", + dr = dr, apred = apred, tel = self.telescope, field_enc = field_enc, id = self.apogee_id, + ), + _ => format!( + "https://data.sdss.org/sas/{dr}/apogee/spectro/redux/{apred}/stars/{tel}/{field_enc}/apStar-{apred}-{id}.fits", + dr = dr, apred = apred, tel = self.telescope, field_enc = field_enc, id = self.apogee_id, + ), + } + } +} + +/// APOGEE ConeSearch 结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApogeeConeResult { + pub rows: Vec, + pub fields: Vec, + pub row_count: usize, + pub truncated: bool, +} + +// ── 客户端 ── + +#[derive(Clone)] +pub struct SdssClient { + client: reqwest::Client, + tap_sync_url: String, +} + +impl SdssClient { + pub fn new(tap_sync_url: &str, timeout_secs: u64) -> anyhow::Result { + Ok(SdssClient { + client: reqwest::Client::builder() + .redirect(crate::utils::ssrf::safe_redirect_policy()) + .timeout(Duration::from_secs(timeout_secs)) + .connect_timeout(Duration::from_secs(15)) + .build() + .context("Failed to create SDSS HTTP client")?, + tap_sync_url: tap_sync_url.trim_end_matches('/').to_string(), + }) + } + + /// 锥形检索 SDSS 光谱(按 RA/Dec 范围查询,避开 ADQL 几何函数兼容性问题) + /// + /// 注意:Data Lab TAP 不支持 q3c 也不支持 POINT('ICRS',...) 的字符串字面量类型推断, + /// 故用 RA/Dec BETWEEN 矩形框近似圆锥(半径 ≤0.5° 时误差可接受)。 + /// DR18/19 不在 Data Lab 托管,需用 SDSS SkyServer(本客户端不支持,返回错误)。 + pub async fn cone_search( + &self, + ra: f64, + dec: f64, + radius_deg: f64, + max_records: i64, + release: crate::services::spectra::common::SdssRelease, + ) -> anyhow::Result { + let table = release.datalab_table().ok_or_else(|| { + anyhow!( + "SDSS {} 的 catalog 不在 NOIRLab Data Lab 托管(仅 DR16/17),请用 SDSS SkyServer 查询", + release.display() + ) + })?; + + let top = max_records.clamp(1, 500); + // RA 方向按 cos(dec) 收缩以近似圆形 + let ra_half = radius_deg / dec.cos().max(0.1); + let dec_half = radius_deg; + let ra_min = ra - ra_half; + let ra_max = ra + ra_half; + let dec_min = dec - dec_half; + let dec_max = dec + dec_half; + + let adql = format!( + "SELECT TOP {top} specobjid, plate, mjd, fiberid, run2d, ra, dec, z, class \ + FROM {table} \ + WHERE ra BETWEEN {ra_min} AND {ra_max} \ + AND dec BETWEEN {dec_min} AND {dec_max}", + top = top, + table = table, + ra_min = ra_min, + ra_max = ra_max, + dec_min = dec_min, + dec_max = dec_max + ); + + info!( + "[SDSS] ConeSearch {} ra={:.4} dec={:.4} radius={:.4}° TOP {}", + release.display(), + ra, + dec, + radius_deg, + top + ); + + let body = self.tap_sync(&adql).await?; + // 检查错误 + if body.contains("QUERY_STATUS") && body.contains("ERROR") { + let snippet: String = body.chars().take(500).collect(); + error!("[SDSS] TAP 查询错误: {}", snippet); + return Err(anyhow!("SDSS TAP 查询失败(见日志)")); + } + + let votable = parse_votable_tabledata(&body, top)?; + let rows = votable + .rows + .iter() + .map(|r| row_to_sdss(&votable.fields, r)) + .collect(); + + Ok(SdssConeResult { + rows, + fields: votable.fields, + row_count: votable.row_count, + truncated: votable.truncated, + }) + } + + /// 下载指定 plate/mjd/fiber/run2d 的 specLite FITS(原始字节,无压缩) + pub async fn download_fits( + &self, + plate: i64, + mjd: i64, + fiberid: i64, + run2d: &str, + release: crate::services::spectra::common::SdssRelease, + ) -> anyhow::Result> { + let row = SdssSpectrumRow { + specobjid: String::new(), + plate, + mjd, + fiberid, + run2d: run2d.to_string(), + field: None, + catalogid: None, + ra: None, + dec: None, + z: None, + class: None, + }; + let url = row.sas_url(release); + info!( + "[SDSS] 下载 FITS {} plate={} mjd={} fiber={} run2d={}", + release.display(), + plate, + mjd, + fiberid, + run2d + ); + + let resp = self.get_with_retry(&url).await?; + let bytes = resp + .bytes() + .await + .context("读取 SDSS FITS 响应失败")? + .to_vec(); + + if bytes.is_empty() { + return Err(anyhow!( + "SDSS 返回空响应(plate={}/mjd={}/fiber={} 可能不存在)", + plate, + mjd, + fiberid + )); + } + Ok(bytes) + } + + /// 锥形检索 APOGEE 近红外星(经 Data Lab TAP,BETWEEN 矩形框近似圆锥) + /// + /// 返回 apogee2_allstar 表中坐标半径范围内的恒星行。 + pub async fn apogee_cone_search( + &self, + ra: f64, + dec: f64, + radius_deg: f64, + max_records: i64, + release: crate::services::spectra::common::SdssRelease, + ) -> anyhow::Result { + let table = release.apogee_table().ok_or_else(|| { + anyhow!( + "APOGEE {} 的 catalog 不在 NOIRLab Data Lab 托管(仅 DR17)", + release.display() + ) + })?; + let top = max_records.clamp(1, 500); + let ra_half = radius_deg / dec.cos().max(0.1); + let dec_half = radius_deg; + + let adql = format!( + "SELECT TOP {top} apogee_id, ra, dec, telescope, field, teff, fe_h, snr \ + FROM {table} \ + WHERE ra BETWEEN {ra_min} AND {ra_max} \ + AND dec BETWEEN {dec_min} AND {dec_max}", + top = top, + table = table, + ra_min = ra - ra_half, + ra_max = ra + ra_half, + dec_min = dec - dec_half, + dec_max = dec + dec_half, + ); + + info!( + "[APOGEE] ConeSearch {} ra={:.4} dec={:.4} radius={:.4}° TOP {}", + release.display(), + ra, + dec, + radius_deg, + top + ); + + let body = self.tap_sync(&adql).await?; + if body.contains("QUERY_STATUS") && body.contains("ERROR") { + let snippet: String = body.chars().take(500).collect(); + error!("[APOGEE] TAP 查询错误: {}", snippet); + return Err(anyhow!("APOGEE TAP 查询失败(见日志)")); + } + + let votable = parse_votable_tabledata(&body, top)?; + let rows = votable + .rows + .iter() + .map(|r| row_to_apogee(&votable.fields, r)) + .collect(); + + Ok(ApogeeConeResult { + rows, + fields: votable.fields, + row_count: votable.row_count, + truncated: votable.truncated, + }) + } + + /// 下载 APOGEE apStar 或 aspcapStar FITS(原始字节,无压缩) + pub async fn download_apogee_fits( + &self, + apogee_id: &str, + telescope: &str, + field: &str, + release: crate::services::spectra::common::SdssRelease, + data_type: &str, + ) -> anyhow::Result> { + let row = ApogeeStarRow { + apogee_id: apogee_id.to_string(), + ra: None, + dec: None, + telescope: telescope.to_string(), + field: field.to_string(), + teff: None, + fe_h: None, + snr: None, + }; + let url = row.apogee_sas_url(release, data_type); + info!( + "[APOGEE] 下载 {} {} {} {}", + release.display(), + data_type, + telescope, + apogee_id + ); + + let resp = self.get_with_retry(&url).await?; + let bytes = resp + .bytes() + .await + .context("读取 APOGEE FITS 响应失败")? + .to_vec(); + + if bytes.is_empty() { + return Err(anyhow!( + "APOGEE 返回空响应({}/{}/{} 可能不存在)", + telescope, + field, + apogee_id + )); + } + Ok(bytes) + } + + /// 执行 TAP sync(POST,默认 VOTable) + async fn tap_sync(&self, adql: &str) -> anyhow::Result { + let params = [ + ("REQUEST", "doQuery".to_string()), + ("LANG", "ADQL".to_string()), + ("QUERY", adql.to_string()), + ]; + let resp = self.post_with_retry(&self.tap_sync_url, ¶ms).await?; + resp.text().await.context("读取 SDSS TAP 响应失败") + } + + /// 带重试的 GET(SAS 下载,对齐 429/503 重试模式) + async fn get_with_retry(&self, url: &str) -> anyhow::Result { + const MAX_RETRIES: u32 = 3; + let mut last_err = None; + for attempt in 0..MAX_RETRIES { + match self + .client + .get(url) + .header("User-Agent", "AstroResearch/0.1 (academic research tool)") + .send() + .await + { + Ok(resp) => { + let status = resp.status(); + if (status.as_u16() == 429 || status.as_u16() == 503) + && attempt < MAX_RETRIES - 1 + { + warn!( + "[SDSS] 速率限制 ({}), 5 秒后重试 (第 {} 次)", + status, + attempt + 1 + ); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + if !status.is_success() { + return Err(anyhow!("SDSS SAS 返回错误码: {}", status)); + } + return Ok(resp); + } + Err(e) => { + warn!("[SDSS] 下载失败 (第 {} 次): {}", attempt + 1, e); + last_err = Some(e.into()); + tokio::time::sleep(Duration::from_secs(2u64.pow(attempt))).await; + } + } + } + Err(last_err.unwrap_or_else(|| anyhow!("SDSS 下载重试耗尽"))) + } + + /// 带重试的 POST(TAP 查询) + async fn post_with_retry( + &self, + url: &str, + params: &[(&str, String)], + ) -> anyhow::Result { + const MAX_RETRIES: u32 = 3; + let mut last_err = None; + for attempt in 0..MAX_RETRIES { + let owned: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); + match self + .client + .post(url) + .header("User-Agent", "AstroResearch/0.1 (academic research tool)") + .form(&owned) + .send() + .await + { + Ok(resp) => { + let status = resp.status(); + if (status.as_u16() == 429 || status.as_u16() == 503 || status.as_u16() == 500) + && attempt < MAX_RETRIES - 1 + { + warn!( + "[SDSS] 服务端错误 ({}), 5 秒后重试 (第 {} 次)", + status, + attempt + 1 + ); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + error!( + "[SDSS] TAP 失败: 状态码={}, 返回={}", + status, + body.chars().take(400).collect::() + ); + return Err(anyhow!("SDSS TAP 返回错误码: {}", status)); + } + return Ok(resp); + } + Err(e) => { + warn!("[SDSS] TAP 请求失败 (第 {} 次): {}", attempt + 1, e); + last_err = Some(e.into()); + tokio::time::sleep(Duration::from_secs(2u64.pow(attempt))).await; + } + } + } + Err(last_err.unwrap_or_else(|| anyhow!("SDSS TAP 重试耗尽"))) + } +} + +/// 把 VOTable 行映射为 SdssSpectrumRow +fn row_to_sdss(fields: &[FieldInfo], row: &[serde_json::Value]) -> SdssSpectrumRow { + let get = |key: &str| -> Option<&serde_json::Value> { + fields + .iter() + .position(|f| f.name.eq_ignore_ascii_case(key)) + .and_then(|i| row.get(i)) + }; + let get_str = |key: &str| -> Option { + match get(key)? { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + } + }; + let get_f64 = |key: &str| get(key).and_then(|v| v.as_f64()); + let get_i64 = |key: &str| get_f64(key).map(|v| v as i64); + + SdssSpectrumRow { + specobjid: get_str("specobjid").unwrap_or_default(), + plate: get_i64("plate").unwrap_or(0), + mjd: get_i64("mjd").unwrap_or(0), + fiberid: get_i64("fiberid").unwrap_or(0), + run2d: get_str("run2d").unwrap_or_default(), + field: get_i64("field"), + catalogid: get_i64("catalogid"), + ra: get_f64("ra"), + dec: get_f64("dec"), + z: get_f64("z"), + class: get_str("class"), + } +} + +/// 把一行 VOTable/JSON 按字段名映射为 ApogeeStarRow +fn row_to_apogee(fields: &[FieldInfo], row: &[serde_json::Value]) -> ApogeeStarRow { + let get = |key: &str| -> Option<&serde_json::Value> { + fields + .iter() + .position(|f| f.name.eq_ignore_ascii_case(key)) + .and_then(|i| row.get(i)) + }; + let get_str = |key: &str| -> Option { + match get(key)? { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + } + }; + let get_f64 = |key: &str| get(key).and_then(|v| v.as_f64()); + + ApogeeStarRow { + apogee_id: get_str("apogee_id").unwrap_or_default(), + ra: get_f64("ra"), + dec: get_f64("dec"), + telescope: get_str("telescope").unwrap_or_default(), + field: get_str("field").unwrap_or_default(), + teff: get_f64("teff"), + fe_h: get_f64("fe_h"), + snr: get_f64("snr"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sas_url_sdss_legacy() { + use crate::services::spectra::common::SdssRelease; + let row = SdssSpectrumRow { + specobjid: "x".into(), + plate: 2224, + mjd: 53815, + fiberid: 597, + run2d: "26".into(), + field: None, + catalogid: None, + ra: None, + dec: None, + z: None, + class: None, + }; + let url = row.sas_url(SdssRelease::Dr17); + assert!(url.contains("/sdss/"), "run2d=26 应走 sdss 前缀"); + assert!(url.contains("/redux/26/"), "run2d 在路径中"); + assert!(url.contains("/2224/"), "plate 零填充"); + assert!( + url.ends_with("/spec-2224-53815-0597.fits"), + "文件名零填充: {}", + url + ); + } + + #[test] + fn test_sas_url_eboss() { + use crate::services::spectra::common::SdssRelease; + let row = SdssSpectrumRow { + specobjid: "x".into(), + plate: 3606, + mjd: 55182, + fiberid: 512, + run2d: "v5_13_0".into(), + field: None, + catalogid: None, + ra: None, + dec: None, + z: None, + class: None, + }; + let url = row.sas_url(SdssRelease::Dr17); + assert!(url.contains("/eboss/"), "run2d=v5_13_0 应走 eboss 前缀"); + assert!(url.ends_with("/spec-3606-55182-0512.fits")); + } + + #[test] + fn test_sas_url_dr18_no_prefix() { + use crate::services::spectra::common::SdssRelease; + let row = SdssSpectrumRow { + specobjid: "x".into(), + plate: 10001, + mjd: 58000, + fiberid: 1, + run2d: "v6_0_4".into(), + field: None, + catalogid: None, + ra: None, + dec: None, + z: None, + class: None, + }; + let url = row.sas_url(SdssRelease::Dr18); + assert!( + url.contains("/dr18/spectro/boss/redux/v6_0_4/"), + "DR18 无 prefix" + ); + assert!(!url.contains("/eboss/"), "DR18 v6_* 不应有 eboss 前缀"); + } + + #[test] + fn test_sas_url_dr19_sdss_v() { + use crate::services::spectra::common::SdssRelease; + let row = SdssSpectrumRow { + specobjid: "x".into(), + plate: 0, + mjd: 59251, + fiberid: 0, + run2d: "v6_1_3".into(), + field: Some(15166), + catalogid: Some(4492065339), + ra: None, + dec: None, + z: None, + class: None, + }; + let url = row.sas_url(SdssRelease::Dr19); + assert!(url.contains("/15166/"), "DR19 用 field 目录"); + assert!( + url.ends_with("/spec-15166-59251-04492065339.fits"), + "DR19 SDSS-V 文件名: {}", + url + ); + } + + /// 真实 SDSS Data Lab TAP cone search 测试 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_cone_search() { + use crate::services::spectra::common::SdssRelease; + let client = SdssClient::new("https://datalab.noirlab.edu/tap/sync", 90).unwrap(); + let result = client + .cone_search(180.0, 30.0, 0.05, 5, SdssRelease::Dr17) + .await + .unwrap(); + + println!("===== SDSS ConeSearch Live ====="); + println!(" row_count: {}", result.row_count); + for r in result.rows.iter().take(3) { + println!(" {:?} → {}", r, r.sas_url(SdssRelease::Dr17)); + } + + assert!(!result.rows.is_empty(), "高银纬区应有 SDSS 光谱"); + assert!(result.rows[0].plate > 0); + assert!(!result.rows[0].run2d.is_empty()); + } + + /// 真实 SDSS SAS FITS 下载测试 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_download_fits() { + use crate::services::spectra::common::SdssRelease; + let client = SdssClient::new("https://datalab.noirlab.edu/tap/sync", 90).unwrap(); + let cone = client + .cone_search(180.0, 30.0, 0.05, 5, SdssRelease::Dr17) + .await + .unwrap(); + let first = cone.rows.first().expect("应有结果"); + + let bytes = client + .download_fits( + first.plate, + first.mjd, + first.fiberid, + &first.run2d, + SdssRelease::Dr17, + ) + .await + .unwrap(); + + println!( + "===== SDSS FITS Live (plate={} mjd={} fiber={}) =====", + first.plate, first.mjd, first.fiberid + ); + println!(" 字节数: {}", bytes.len()); + + assert!(bytes.len() > 10000, "FITS 应有内容"); + // FITS 文件以 "SIMPLE" 开头(非压缩) + assert!( + bytes.starts_with(b"SIMPLE"), + "应以 FITS magic 'SIMPLE' 开头" + ); + } +} diff --git a/src/clients/vo/mod.rs b/src/clients/vo/mod.rs new file mode 100644 index 0000000..0377e43 --- /dev/null +++ b/src/clients/vo/mod.rs @@ -0,0 +1,291 @@ +// src/clients/vo/mod.rs +// +// IVOA VOTable 共享解析层 —— 跨数据源复用 +// +// 多数天文数据源(VizieR TAP、LAMOST ConeSearch、Gaia TAP、SDSS TAP)的查询响应 +// 都是 VOTable XML,且普遍使用 TABLEDATA 序列化。本模块提供一份手写的 quick-xml +// 事件流解析器,避免每个 client 重复实现约 200 行 XML 解析。 +// +// 设计要点: +// - 仅处理 TABLEDATA(实践中 VizieR/LAMOST/Gaia 默认都返回 TABLEDATA) +// - BINARY/BINARY2/FITS 嵌入格式不在此处理 +// - 单元格类型推断:空→null,整数→number,浮点→number,否则字符串 +// - 输出宽松类型(serde_json::Value),由各业务层按需强类型化 +// +// 注意:本模块是纯解析工具,不持有 reqwest::Client、不做 HTTP、不做缓存。 +// 各 client 自行发起请求拿到 XML 字符串后调用 parse_votable_tabledata(xml)。 + +use anyhow::{anyhow, Result}; +use quick_xml::events::Event; +use quick_xml::Reader; +use serde::{Deserialize, Serialize}; + +// ── 领域结构(对外标准化查询结果,service 层与 agent 工具共用) ── + +/// 单个字段(列)的元信息 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FieldInfo { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub datatype: Option, +} + +/// VOTable 查询的标准化结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoTableResult { + /// 来源表名(若可从查询/资源标识识别) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub table_name: Option, + /// 列定义 + pub fields: Vec, + /// 行数据(宽松类型:每格可能是 number / string / null) + pub rows: Vec>, + /// 总行数 + pub row_count: usize, + /// 是否因达到 MAXREC 而截断 + pub truncated: bool, +} + +/// 用 quick-xml 解析 VOTable TABLEDATA 序列化为标准化结果 +/// +/// 仅处理 TABLEDATA(VizieR/NED/LAMOST/Gaia 默认返回格式)。 +/// BINARY/BINARY2/FITS 嵌入格式不在此处理。 +/// +/// `max_records` 仅用于推断 truncated 标记(行数 == max_records 时认为可能被截断), +/// 不用于截断解析(服务端已按 MAXREC 截断)。 +pub fn parse_votable_tabledata(xml: &str, max_records: i64) -> Result { + let mut reader = Reader::from_str(xml); + reader.trim_text(true); + + let mut fields: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + // 当前 TABLE 块内的 FIELD 与 TR 收集状态 + let mut current_fields: Vec = Vec::new(); + let mut in_table = false; + let mut in_tr = false; + let mut current_row: Vec = Vec::new(); + let mut current_td_text = String::new(); + let mut in_td = false; + + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); + match name.as_str() { + "TABLE" => { + in_table = true; + current_fields.clear(); + } + "FIELD" if in_table => { + current_fields.push(parse_field_attributes(&e)); + } + "TR" if in_table => { + in_tr = true; + current_row.clear(); + } + "TD" if in_tr => { + in_td = true; + current_td_text.clear(); + } + _ => {} + } + } + Ok(Event::Empty(e)) => { + let name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); + if name.as_str() == "FIELD" && in_table { + current_fields.push(parse_field_attributes(&e)); + } else if name.as_str() == "TD" && in_tr { + // 空 标签 = null 值 + current_row.push(serde_json::Value::Null); + } + } + Ok(Event::Text(e)) if in_td => { + current_td_text.push_str(&e.unescape().unwrap_or_default()); + } + Ok(Event::End(e)) => { + let name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); + match name.as_str() { + "TD" if in_td => { + let val = parse_cell_value(¤t_td_text); + current_row.push(val); + in_td = false; + } + "TR" if in_tr => { + rows.push(std::mem::take(&mut current_row)); + in_tr = false; + } + "TABLE" if in_table => { + // 该 TABLE 块结束,若收集到了字段则采纳 + if fields.is_empty() && !current_fields.is_empty() { + fields = std::mem::take(&mut current_fields); + } + in_table = false; + } + _ => {} + } + } + Ok(Event::Eof) => break, + Err(e) => { + return Err(anyhow!( + "VOTable XML 解析错误 (位置 {}): {}", + reader.buffer_position(), + e + )); + } + _ => {} + } + buf.clear(); + } + + // 若未从 TABLE 内提取到字段但行存在,用 col_N 占位 + if fields.is_empty() && !rows.is_empty() { + let ncols = rows.first().map(|r| r.len()).unwrap_or(0); + fields = (0..ncols) + .map(|i| FieldInfo { + name: format!("col_{}", i + 1), + description: None, + unit: None, + datatype: None, + }) + .collect(); + } + + let row_count = rows.len(); + let truncated = row_count as i64 >= max_records && max_records > 0; + + Ok(VoTableResult { + table_name: None, + fields, + rows, + row_count, + truncated, + }) +} + +/// 解析 标签属性为 FieldInfo(Start 与 Empty 事件共用) +fn parse_field_attributes(e: &quick_xml::events::BytesStart<'_>) -> FieldInfo { + let mut field = FieldInfo { + name: String::new(), + description: None, + unit: None, + datatype: None, + }; + let mut name_set = false; + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy(attr.key.as_ref()); + let val = attr.unescape_value().unwrap_or_default().to_string(); + match key.as_ref().to_lowercase().as_str() { + "name" => { + field.name = val; + name_set = true; + } + "unit" => field.unit = Some(val), + "datatype" => field.datatype = Some(val), + _ => {} + } + } + let _ = name_set; // name 为空时下方 fallback 兜底 + if field.name.is_empty() { + // 调用方无法得知列序,用占位;实际不会触发(FIELD 必有 name) + field.name = "field".to_string(); + } + field +} + +/// 把 TABLEDATA 单元格文本解析为合适的 JSON 类型 +/// +/// 尝试顺序:空→null,整数→number,浮点→number,否则字符串。 +pub fn parse_cell_value(text: &str) -> serde_json::Value { + let trimmed = text.trim(); + if trimmed.is_empty() { + return serde_json::Value::Null; + } + if let Ok(i) = trimmed.parse::() { + return serde_json::json!(i); + } + if let Ok(f) = trimmed.parse::() { + // 排除 inf/nan + if f.is_finite() { + return serde_json::json!(f); + } + } + serde_json::Value::String(trimmed.to_string()) +} + +/// 从 ADQL/查询中粗略提取 FROM 子句的表名(用于结果标注) +/// +/// 适用于 `SELECT ... FROM "table"` 形式;非 ADQL 查询(如 LAMOST ConeSearch)返回 None。 +pub fn extract_table_name(adql: &str) -> Option { + let lower = adql.to_lowercase(); + let from_pos = lower.find(" from ")?; + let after = adql[from_pos + 6..].trim_start(); + // 取第一个 token,去掉引号 + let token = after.split_whitespace().next()?; + let cleaned = token.trim_matches(|c| c == '"' || c == '\'' || c == '`'); + if cleaned.is_empty() { + None + } else { + Some(cleaned.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_table_name() { + assert_eq!( + extract_table_name(r#"SELECT TOP 10 * FROM "I/355/gaiadr3""#), + Some("I/355/gaiadr3".to_string()) + ); + assert_eq!( + extract_table_name("select ra,dec from mytable where ra>0"), + Some("mytable".to_string()) + ); + assert_eq!(extract_table_name("SELECT 1"), None); + } + + #[test] + fn test_parse_votable_tabledata() { + let xml = r#" + + + + + + + + + + + + +
10.5-20.3NGC1234
45.012.1
+
+
"#; + let result = parse_votable_tabledata(xml, 100).unwrap(); + assert_eq!(result.fields.len(), 3); + assert_eq!(result.fields[0].name, "ra"); + assert_eq!(result.fields[0].unit.as_deref(), Some("deg")); + assert_eq!(result.rows.len(), 2); + assert_eq!(result.rows[0][0].as_f64().unwrap(), 10.5); + assert_eq!(result.rows[0][2].as_str().unwrap(), "NGC1234"); + assert!(result.rows[1][2].is_null()); + assert!(!result.truncated); + } + + #[test] + fn test_parse_cell_value() { + assert_eq!(parse_cell_value("42"), serde_json::json!(42)); + assert_eq!(parse_cell_value("3.14"), serde_json::json!(3.14)); + assert!(parse_cell_value("").is_null()); + assert_eq!(parse_cell_value("NGC 1068").as_str().unwrap(), "NGC 1068"); + } +} diff --git a/src/lib.rs b/src/lib.rs index b71097f..1873131 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,28 @@ pub struct Config { pub llm_vision_api_key: String, /// 视觉模型 API Base(空时回退到 llm_api_base) pub llm_vision_api_base: String, + /// VizieR TAP 服务基础 URL(默认 CDS 官方端点) + pub vizier_tap_url: String, + /// VizieR TAP 查询超时秒数(TAP 查询可能较慢,默认 60) + pub vizier_timeout_secs: u64, + /// LAMOST 数据访问基础 URL(默认 DR10 v2.0) + pub lamost_base_url: String, + /// LAMOST 请求超时秒数(FITS 下载可能较慢,默认 60) + pub lamost_timeout_secs: u64, + /// Gaia TAP 服务基础 URL(默认 ESA 官方端点) + pub gaia_tap_url: String, + /// Gaia DataLink 服务基础 URL(默认 ESA 官方端点) + pub gaia_datalink_url: String, + /// Gaia 请求超时秒数(DataLink 下载较慢,默认 90) + pub gaia_timeout_secs: u64, + /// SDSS Data Lab TAP sync 端点 URL + pub sdss_tap_url: String, + /// SDSS 请求超时秒数(默认 90) + pub sdss_timeout_secs: u64, + /// DESI Data Lab TAP sync 端点 URL(与 SDSS 共用 Data Lab) + pub desi_tap_url: String, + /// DESI 请求超时秒数(coadd 文件较大,默认 120) + pub desi_timeout_secs: u64, } impl Config { @@ -109,6 +131,43 @@ impl Config { let llm_vision_api_key = env::var("LLM_VISION_API_KEY").unwrap_or_default(); let llm_vision_api_base = env::var("LLM_VISION_API_BASE").unwrap_or_default(); + let vizier_tap_url = env::var("VIZIER_TAP_URL") + .unwrap_or_else(|_| "https://tapvizier.cds.unistra.fr/TAPVizieR/tap".to_string()); + let vizier_timeout_secs = env::var("VIZIER_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(60); + + let lamost_base_url = + env::var("LAMOST_BASE_URL").unwrap_or_else(|_| "https://www.lamost.org".to_string()); + let lamost_timeout_secs = env::var("LAMOST_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(60); + + let gaia_tap_url = env::var("GAIA_TAP_URL") + .unwrap_or_else(|_| "https://gea.esac.esa.int/tap-server/tap".to_string()); + let gaia_datalink_url = env::var("GAIA_DATALINK_URL") + .unwrap_or_else(|_| "https://gea.esac.esa.int/data-server".to_string()); + let gaia_timeout_secs = env::var("GAIA_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(90); + + let sdss_tap_url = env::var("SDSS_TAP_URL") + .unwrap_or_else(|_| "https://datalab.noirlab.edu/tap/sync".to_string()); + let sdss_timeout_secs = env::var("SDSS_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(90); + + let desi_tap_url = env::var("DESI_TAP_URL") + .unwrap_or_else(|_| "https://datalab.noirlab.edu/tap/sync".to_string()); + let desi_timeout_secs = env::var("DESI_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(120); + Config { database_url, ads_api_key, @@ -139,6 +198,17 @@ impl Config { llm_vision_model, llm_vision_api_key, llm_vision_api_base, + vizier_tap_url, + vizier_timeout_secs, + lamost_base_url, + lamost_timeout_secs, + gaia_tap_url, + gaia_datalink_url, + gaia_timeout_secs, + sdss_tap_url, + sdss_timeout_secs, + desi_tap_url, + desi_timeout_secs, } } } diff --git a/src/main.rs b/src/main.rs index 6c13ace..a987376 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,8 +20,13 @@ use astroresearch::agent::skills::SkillRegistry; use astroresearch::api::handlers::{self, AppState}; use astroresearch::clients::ads::AdsClient; use astroresearch::clients::arxiv::ArxivClient; +use astroresearch::clients::cds::vizier::VizierClient; +use astroresearch::clients::desi::DesiClient; +use astroresearch::clients::gaia::GaiaClient; +use astroresearch::clients::lamost::LamostClient; use astroresearch::clients::llm::{EmbeddingClient, LlmClient}; use astroresearch::clients::qiniu::QiniuClient; +use astroresearch::clients::sdss::SdssClient; use astroresearch::services::download::Downloader; use astroresearch::services::translation::Dictionary; use astroresearch::Config; @@ -105,6 +110,12 @@ async fn main() -> anyhow::Result<()> { std::fs::create_dir_all(config.library_dir.join("HTML")).unwrap_or_default(); std::fs::create_dir_all(config.library_dir.join("Markdown")).unwrap_or_default(); std::fs::create_dir_all(config.library_dir.join("Translation")).unwrap_or_default(); + // 光谱文件目录(Telescope/{望远镜}/...,各 service 的 persist_bytes 会自动创建深层子目录) + std::fs::create_dir_all(config.library_dir.join("Telescope").join("lamost")) + .unwrap_or_default(); + std::fs::create_dir_all(config.library_dir.join("Telescope").join("gaia")).unwrap_or_default(); + std::fs::create_dir_all(config.library_dir.join("Telescope").join("sdss")).unwrap_or_default(); + std::fs::create_dir_all(config.library_dir.join("Telescope").join("desi")).unwrap_or_default(); // Agent Skills 目录 std::fs::create_dir_all(&config.skills_dir).unwrap_or_default(); @@ -198,6 +209,20 @@ async fn main() -> anyhow::Result<()> { let ads = AdsClient::new(config.ads_api_key.clone()).context("构建 ADS 客户端失败")?; let arxiv = ArxivClient::new().context("构建 arXiv 客户端失败")?; + let vizier = VizierClient::new(&config.vizier_tap_url, config.vizier_timeout_secs) + .context("构建 VizieR 客户端失败")?; + let lamost = LamostClient::new(&config.lamost_base_url, config.lamost_timeout_secs) + .context("构建 LAMOST 客户端失败")?; + let gaia = GaiaClient::new( + &config.gaia_tap_url, + &config.gaia_datalink_url, + config.gaia_timeout_secs, + ) + .context("构建 Gaia 客户端失败")?; + let sdss = SdssClient::new(&config.sdss_tap_url, config.sdss_timeout_secs) + .context("构建 SDSS 客户端失败")?; + let desi = DesiClient::new(&config.desi_tap_url, config.desi_timeout_secs) + .context("构建 DESI 客户端失败")?; let downloader = Downloader::new().context("构建 HTTP 下载客户端失败")?; let llm = LlmClient::new( config.llm_api_key.clone(), @@ -265,6 +290,11 @@ async fn main() -> anyhow::Result<()> { qiniu, ads, arxiv, + vizier, + lamost, + gaia, + sdss, + desi, llm, medium_llm, fast_llm, @@ -419,6 +449,17 @@ async fn main() -> anyhow::Result<()> { .route("/target/associate", post(handlers::associate_target)) .route("/target/extract", post(handlers::extract_paper_targets)) .route("/target/list", get(handlers::list_targets)) + // 天文星表查询路由(VizieR TAP + VO Cone Search) + .route("/catalog/vizier", get(handlers::vizier_query)) + .route("/catalog/vizier/table", get(handlers::vizier_table)) + .route("/catalog/cone", get(handlers::cone_search)) + .route("/catalog/crossmatch", get(handlers::cross_match)) + // 统一光谱下载(跨 LAMOST/Gaia/SDSS,支持坐标模式与标识符模式) + .route( + "/catalog/spectrum/download", + get(handlers::spectrum_download), + ) + .route("/catalog/spectrum/list", get(handlers::spectrum_list)) // 智能体路由 .route("/chat/agent", post(handlers::chat_agent)) .route("/chat/modes", get(handlers::get_agent_modes)) diff --git a/src/services/batch/asset/process.rs b/src/services/batch/asset/process.rs index 2b10650..e06b85f 100644 --- a/src/services/batch/asset/process.rs +++ b/src/services/batch/asset/process.rs @@ -606,7 +606,7 @@ pub(super) async fn process_single_bibcode( } let client = reqwest::Client::new(); - let targets = crate::services::target::extract_and_cache_targets( + let targets = crate::services::cds::target::extract_and_cache_targets( db, &markdown_content, &bibcode, diff --git a/src/services/cds/mod.rs b/src/services/cds/mod.rs new file mode 100644 index 0000000..ff3be9f --- /dev/null +++ b/src/services/cds/mod.rs @@ -0,0 +1,12 @@ +// src/services/cds/mod.rs +// +// CDS 数据源业务服务层 —— 与 src/clients/cds/ 对称 +// +// 两个模块: +// target.rs — 天体名识别 + Sesame 档案缓存 +// vizier.rs — VizieR TAP 全部业务(缓存、几何查询、星表发现、导出) +// +// 纯通信层在 src/clients/cds/,外部通过 services::cds::* 调用本层。 + +pub mod target; +pub mod vizier; diff --git a/src/services/target.rs b/src/services/cds/target.rs similarity index 51% rename from src/services/target.rs rename to src/services/cds/target.rs index 77fd67d..be31940 100644 --- a/src/services/target.rs +++ b/src/services/cds/target.rs @@ -1,36 +1,21 @@ // src/services/target.rs // -// 天体目标识别与 CDS SIMBAD/Sesame 信息缓存模块。 +// 天体目标识别 + CDS Sesame 信息缓存(业务服务层) // 职责: -// 1. 使用 IAU 天体命名正则从文本中提取天体标识符 -// 2. 通过 CDS Sesame Name Resolver 查询标准化天体属性 +// 1. 使用 IAU 天体命名正则从文本中提取天体标识符(通用文本处理,非任何数据源专属) +// 2. 通过 clients::cds::sesame 查询标准化天体属性(纯通信,已在 client 层) // 3. 在本地 paper_targets 表中缓存查询结果,避免重复外部请求 +// +// 分层说明:query_sesame / parse_sesame_xml / TargetInfo 在 clients::cds::sesame; +// 本模块负责 IAU 提取 + paper_targets 缓存 + 批量编排。 +pub use crate::clients::cds::sesame::{query_sesame, TargetInfo}; use regex::Regex; use reqwest::Client; use sqlx::SqlitePool; use std::sync::OnceLock; use tracing::{error, info, warn}; -/// 天体目标的标准化属性信息 -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct TargetInfo { - pub target_name: String, - pub ra: Option, - pub dec: Option, - pub parallax: Option, - pub parallax_err: Option, - pub spectral_type: Option, - pub v_magnitude: Option, - pub otype: Option, - pub oname: Option, - pub pm_ra: Option, - pub pm_de: Option, - pub radial_velocity: Option, - pub photometry: Option>, - pub aliases: Vec, -} - /// paper_targets 表的查询行类型,避免 14 字段元组重复定义 #[derive(Debug, sqlx::FromRow)] struct TargetDbRow { @@ -97,8 +82,8 @@ pub fn extract_targets(text: &str) -> Vec { [ // NGC / IC 天体 r"(?i)\b(NGC|IC)\s*\d{1,5}\b", - // Messier 天体 - r"(?i)\bM\s*\d{1,3}\b", + // Messier 天体(仅匹配大写 M,避免误匹配 "m 30"、"item 12" 等普通文本) + r"\bM\s*\d{1,3}\b", // Henry Draper 星表 r"(?i)\bHD\s*\d{3,6}\b", // Hipparcos 星表 @@ -174,164 +159,11 @@ fn normalize_target_name(raw: &str) -> String { } } -/// 通过 CDS Sesame Name Resolver 查询天体的标准化信息 -/// -/// Sesame 文档: https://cds.unistra.fr/cgi-bin/nph-sesame -/// 返回包含坐标 (RA/Dec)、光谱类型等的结构化数据 -pub async fn query_sesame(target_name: &str, client: &Client) -> anyhow::Result { - let url = format!( - "https://cds.unistra.fr/cgi-bin/nph-sesame/-oIfx/SNV?{}", - urlencoding::encode(target_name) - ); - - info!("正在查询 CDS Sesame: {}", target_name); - - let response = client - .get(&url) - .header("User-Agent", "AstroResearch/0.1 (academic research tool)") - .send() - .await?; - - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Sesame 查询失败: HTTP {}", - response.status() - )); - } - - let xml_text = response.text().await?; - parse_sesame_xml(&xml_text, target_name) -} - -/// 解析 Sesame XML 响应 -fn parse_sesame_xml(xml: &str, original_name: &str) -> anyhow::Result { - let ra = extract_xml_value(xml, "jradeg"); - let dec = extract_xml_value(xml, "jdedeg"); - let spectral_type = extract_xml_value(xml, "spType").or_else(|| extract_xml_value(xml, "sp")); - let otype = extract_xml_value(xml, "otype"); - let oname = extract_xml_value(xml, "oname"); - - // 嵌套测量值(Sesame v4 格式) - let parallax = extract_nested_value(xml, "plx"); - let parallax_err = extract_nested_value(xml, "plx").and_then(|_| { - // 匹配完整 块提取 子元素 - let plx_block_re = Regex::new(r"([\s\S]*?)").ok()?; - let plx_content = plx_block_re.captures(xml)?.get(1)?.as_str(); - Regex::new(r"([^<]+)") - .ok() - .and_then(|re| re.captures(plx_content)) - .and_then(|caps| caps.get(1)) - .and_then(|m| m.as_str().trim().parse::().ok()) - }); - let pm_ra = extract_nested_value(xml, "pmRA"); - let pm_de = extract_nested_value(xml, "pmDE"); - // 视向速度需从 块中提取,避免与 混淆 - let radial_velocity = { - let vel_block_re = Regex::new(r"([\s\S]*?)").ok(); - vel_block_re - .and_then(|re| re.captures(xml)) - .and_then(|caps| caps.get(1)) - .and_then(|block| extract_xml_value(block.as_str(), "v")) - .and_then(|s| s.parse::().ok()) - }; - - // 多波段测光 - let (v_magnitude, photometry) = parse_magnitudes(xml); - // 向后兼容旧的 扁平标签 - let v_mag_flat = extract_xml_value(xml, "Vmag").and_then(|v| v.parse::().ok()); - let v_mag = v_magnitude.or(v_mag_flat); - let photometry_map = if photometry.is_empty() { - None - } else { - Some(photometry) - }; - - // 提取别名列表 - let mut aliases = Vec::new(); - static ALIAS_RE: std::sync::LazyLock = - std::sync::LazyLock::new(|| Regex::new(r"([^<]+)").unwrap()); - for cap in ALIAS_RE.captures_iter(xml) { - if let Some(alias) = cap.get(1) { - aliases.push(alias.as_str().trim().to_string()); - } - } - - // 如果连坐标都查不到,说明 Sesame 无法识别这个天体 - if ra.is_none() && dec.is_none() && aliases.is_empty() { - return Err(anyhow::anyhow!( - "CDS Sesame 无法识别天体: {}", - original_name - )); - } - - Ok(TargetInfo { - target_name: original_name.to_string(), - ra, - dec, - parallax, - parallax_err, - spectral_type, - v_magnitude: v_mag, - otype, - oname, - pm_ra, - pm_de, - radial_velocity, - photometry: photometry_map, - aliases, - }) -} - -/// 从 XML 文本中提取指定标签的文本内容 -fn extract_xml_value(xml: &str, tag: &str) -> Option { - let pattern = format!(r"<{tag}>([^<]+)"); - Regex::new(&pattern) - .ok()? - .captures(xml)? - .get(1) - .map(|m| m.as_str().trim().to_string()) -} - -/// 从嵌套测量标签中提取 `` 子元素值 -/// -/// Sesame v4 格式: `0.65850.0335` → `Some(0.6585)` -/// 兼容旧扁平格式: `0.6585` → `Some(0.6585)` -fn extract_nested_value(xml: &str, tag: &str) -> Option { - // 先尝试提取嵌套 子元素 - let nested_pattern = format!(r"<{tag}>[^<]*([^<]+)"); - if let Some(v) = Regex::new(&nested_pattern) - .ok() - .and_then(|re| re.captures(xml)) - .and_then(|caps| caps.get(1)) - .and_then(|m| m.as_str().trim().parse::().ok()) - { - return Some(v); - } - // 降级:尝试扁平格式 - extract_xml_value(xml, tag).and_then(|s| s.parse::().ok()) -} - -/// 解析所有 `VALUE...` 标签 -/// -/// 返回 `(V波段星等, 全波段测光Map)` -fn parse_magnitudes(xml: &str) -> (Option, std::collections::HashMap) { - let mut photometry = std::collections::HashMap::new(); - let mag_re = Regex::new(r#"[^<]*([^<]+)"#).unwrap(); - for caps in mag_re.captures_iter(xml) { - let band = caps.get(1).unwrap().as_str().to_string(); - if let Ok(value) = caps.get(2).unwrap().as_str().trim().parse::() { - photometry.insert(band, value); - } - } - let v_mag = photometry.get("V").copied(); - (v_mag, photometry) -} - /// 查询天体信息,优先使用本地缓存 /// /// 流程: /// 1. 在 `paper_targets` 表中查找本地缓存 -/// 2. 若 miss,调用 Sesame API 并写入缓存 +/// 2. 若 miss,调用 Sesame API(clients::cds::sesame)并写入缓存 /// 3. 返回查询结果 pub async fn query_target_cached( pool: &SqlitePool, @@ -360,42 +192,11 @@ pub async fn query_target_cached( // 速率节流:50ms 间隔 tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let info = query_sesame(target_name, client).await?; + let info = crate::clients::cds::sesame::query_sesame(target_name, client).await?; // 3. 写入缓存 if let Some(bib) = bibcode { - let aliases_json = - serde_json::to_string(&info.aliases).unwrap_or_else(|_| "[]".to_string()); - let photometry_json = info - .photometry - .as_ref() - .map(|p| serde_json::to_string(p).unwrap_or_else(|_| "{}".to_string())); - if let Err(e) = sqlx::query( - "INSERT OR IGNORE INTO paper_targets \ - (bibcode, target_name, ra, dec, parallax, parallax_err, spectral_type, v_magnitude, \ - otype, oname, pm_ra, pm_de, radial_velocity, photometry, aliases) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(bib) - .bind(&info.target_name) - .bind(&info.ra) - .bind(&info.dec) - .bind(info.parallax) - .bind(info.parallax_err) - .bind(&info.spectral_type) - .bind(info.v_magnitude) - .bind(&info.otype) - .bind(&info.oname) - .bind(info.pm_ra) - .bind(info.pm_de) - .bind(info.radial_velocity) - .bind(&photometry_json) - .bind(&aliases_json) - .execute(pool) - .await - { - error!("天体信息写入缓存失败: {}", e); - } + write_target_cache(pool, bib, &info).await; } Ok(info) @@ -431,7 +232,7 @@ pub async fn extract_and_cache_targets( info!("天体 {} 本地缓存未命中,查询 CDS Sesame...", name); // 速率节流:50ms 间隔 tokio::time::sleep(std::time::Duration::from_millis(50)).await; - match query_sesame(&name, client).await { + match crate::clients::cds::sesame::query_sesame(&name, client).await { Ok(info) => { results.push(info.clone()); new_targets.push(info); @@ -565,6 +366,41 @@ pub async fn list_targets_for_paper( Ok(targets) } +/// 写入单条天体缓存(INSERT OR IGNORE),失败仅日志不阻断 +async fn write_target_cache(pool: &SqlitePool, bibcode: &str, info: &TargetInfo) { + let aliases_json = serde_json::to_string(&info.aliases).unwrap_or_else(|_| "[]".to_string()); + let photometry_json = info + .photometry + .as_ref() + .map(|p| serde_json::to_string(p).unwrap_or_else(|_| "{}".to_string())); + if let Err(e) = sqlx::query( + "INSERT OR IGNORE INTO paper_targets \ + (bibcode, target_name, ra, dec, parallax, parallax_err, spectral_type, v_magnitude, \ + otype, oname, pm_ra, pm_de, radial_velocity, photometry, aliases) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(bibcode) + .bind(&info.target_name) + .bind(&info.ra) + .bind(&info.dec) + .bind(info.parallax) + .bind(info.parallax_err) + .bind(&info.spectral_type) + .bind(info.v_magnitude) + .bind(&info.otype) + .bind(&info.oname) + .bind(info.pm_ra) + .bind(info.pm_de) + .bind(info.radial_velocity) + .bind(&photometry_json) + .bind(&aliases_json) + .execute(pool) + .await + { + error!("天体信息写入缓存失败: {}", e); + } +} + /// 提取文献中的所有天体,并刷新关联记录缓存 pub async fn extract_and_refresh_targets( db: &SqlitePool, @@ -668,165 +504,4 @@ mod tests { let targets = extract_targets(""); assert!(targets.is_empty()); } - - #[test] - fn test_parse_sesame_xml_basic() { - // 使用 Sesame v4 + -oIfx 真实 XML 格式 - let xml = r#" - - - - from cache - 4015980 - WD* - 251.82663549 - 32.47579522 - 23.24410.024A2020yCat.1350....0G - DB2 - 13.6530.0095B2013AJ....146..131L - 13.5300.0088B2013AJ....146..131L - 13.5799400.003138C2020yCat.1350....0G - GD 358 - GD 358 - WD 1645+325 - V* V777 Her - - - - "#; - let info = parse_sesame_xml(xml, "GD 358").unwrap(); - assert_eq!(info.target_name, "GD 358"); - assert_eq!(info.ra.as_deref(), Some("251.82663549")); - assert_eq!(info.dec.as_deref(), Some("32.47579522")); - assert_eq!(info.parallax, Some(23.2441)); - assert_eq!(info.parallax_err, Some(0.024)); - assert_eq!(info.spectral_type.as_deref(), Some("DB2")); - assert_eq!(info.v_magnitude, Some(13.653)); - assert_eq!(info.otype.as_deref(), Some("WD*")); - assert_eq!(info.oname.as_deref(), Some("GD 358")); - assert_eq!(info.aliases.len(), 3); - assert!(info.aliases.contains(&"V* V777 Her".to_string())); - assert!(info.photometry.is_some()); - let phot = info.photometry.unwrap(); - assert_eq!(phot.get("V"), Some(&13.653)); - assert_eq!(phot.get("B"), Some(&13.530)); - assert_eq!(phot.get("G"), Some(&13.579940)); - } - - #[test] - fn test_extract_nested_plx() { - let xml = r#"0.65850.0335A2020yCat.1350....0G"#; - assert_eq!(extract_nested_value(xml, "plx"), Some(0.6585)); - } - - #[test] - fn test_extract_nested_flat_fallback() { - // 向后兼容旧扁平格式 - let xml = r#"0.6585"#; - assert_eq!(extract_nested_value(xml, "plx"), Some(0.6585)); - } - - #[test] - fn test_parse_magnitudes_multi_band() { - let xml = r#" - 14.3890.01 - 14.5620.01 - 14.5154770.002833 - 14.7720.06 - "#; - let (v_mag, phot) = parse_magnitudes(xml); - assert_eq!(v_mag, Some(14.562)); - assert_eq!(phot.get("V"), Some(&14.562)); - assert_eq!(phot.get("B"), Some(&14.389)); - assert_eq!(phot.get("G"), Some(&14.515477)); - assert_eq!(phot.get("R"), Some(&14.772)); - } - - #[test] - fn test_parse_gaia_target_full() { - // 端到端测试 Gaia DR2 5694207034772278400 的 Sesame v4 + -oIfx 真实 XML - let xml = r#" - - - - from cache - 15919104 - HS* - 08:09:38.67 -26:27:39.2 - 122.411159 - -26.46089736 - 2020yCat.1350....0G - 0.01700.0233 - - 2.1190.038A2020yCat.1350....0G - 318 - -1.4100.025 - 1.5820.029 - - 332B2022MNRAS.515.2496P - 0.65850.0335A2020yCat.1350....0G - sdBOHe - 14.3890.01 - 14.5620.01 - 14.5154770.002833 - 14.7720.06 - 14.7950.03 - 15.0680.040 - 15.1810.073 - 15.4380.213 - UCAC4 318-042721 - UCAC4 318-042721 - Gaia DR3 5694207034772278400 - Gaia DR2 5694207034772278400 - 6 - - - - "#; - let info = parse_sesame_xml(xml, "Gaia DR2 5694207034772278400").unwrap(); - - // 基本信息 - assert_eq!(info.target_name, "Gaia DR2 5694207034772278400"); - assert_eq!(info.ra.as_deref(), Some("122.411159")); - assert_eq!(info.dec.as_deref(), Some("-26.46089736")); - - // 视差:之前 BUG 导致为 None,现在应正确解析 - assert_eq!(info.parallax, Some(0.6585)); - assert_eq!(info.parallax_err, Some(0.0335)); - - // 光谱型 - assert_eq!(info.spectral_type.as_deref(), Some("sdBOHe")); - - // 天体类型 - assert_eq!(info.otype.as_deref(), Some("HS*")); - - // 官方名称 - assert_eq!(info.oname.as_deref(), Some("UCAC4 318-042721")); - - // 自行 - assert_eq!(info.pm_ra, Some(-1.410)); - assert_eq!(info.pm_de, Some(1.582)); - - // 视向速度 - assert_eq!(info.radial_velocity, Some(33.0)); - - // V 星等:之前 BUG 导致为 None,现在应正确解析 - assert_eq!(info.v_magnitude, Some(14.562)); - - // 多波段测光 - assert!(info.photometry.is_some()); - let phot = info.photometry.unwrap(); - assert_eq!(phot.len(), 8); - assert_eq!(phot.get("B"), Some(&14.389)); - assert_eq!(phot.get("V"), Some(&14.562)); - assert_eq!(phot.get("G"), Some(&14.515477)); - assert_eq!(phot.get("R"), Some(&14.772)); - assert_eq!(phot.get("I"), Some(&14.795)); - assert_eq!(phot.get("J"), Some(&15.068)); - assert_eq!(phot.get("H"), Some(&15.181)); - assert_eq!(phot.get("K"), Some(&15.438)); - - // 别名 - assert_eq!(info.aliases.len(), 3); - } } diff --git a/src/services/cds/vizier.rs b/src/services/cds/vizier.rs new file mode 100644 index 0000000..83b83d7 --- /dev/null +++ b/src/services/cds/vizier.rs @@ -0,0 +1,510 @@ +// src/services/cds/vizier.rs +// +// VizieR TAP 业务服务层 —— 合并缓存、几何查询、星表发现、导出为一个模块 +// +// ┌──────────────────────────────────────────────────────────────┐ +// │ 1. 缓存层 query_adql_cached / query_table │ +// │ 2. ADQL 安全 sanitize_identifier / escape_string_literal │ +// │ 3. 几何查询 cone_search / cross_match │ +// │ 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 sha1::{Digest, Sha1}; +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 { + 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 { + client.run_adql(adql, max_records).await +} + +/// 便捷:按表名取行 +pub async fn query_table( + pool: &SqlitePool, + client: &VizierClient, + table_name: &str, + columns: &[String], + limit: i64, +) -> Result { + let table = sanitize_identifier(table_name)?; + let cols = if columns.is_empty() { + "*".to_string() + } else { + columns + .iter() + .map(|c| sanitize_identifier(c)) + .collect::>>()? + .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 = Sha1::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>, +} + +async fn fetch_cache(pool: &SqlitePool, query_hash: &str) -> Result> { + 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 { + 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) +// ═══════════════════════════════════════════════════════════════ + +/// 锥形检索 +pub async fn cone_search( + pool: &SqlitePool, + client: &VizierClient, + ra: f64, + dec: f64, + radius_deg: f64, + table_name: &str, + max_records: i64, +) -> Result { + let table = sanitize_identifier(table_name)?; + let table_ref = format!("\"{}\"", table); + + if !(0.0..=5.0).contains(&radius_deg) { + return Err(anyhow!("检索半径应在 0~5 度之间,当前: {}", radius_deg)); + } + if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) { + return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec)); + } + + let adql = 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 +} + +/// 交叉证认 +pub async fn cross_match( + pool: &SqlitePool, + client: &VizierClient, + ra: f64, + dec: f64, + radius_deg: f64, + table_name: &str, + max_records: i64, +) -> Result { + let table = sanitize_identifier(table_name)?; + let table_ref = format!("\"{}\"", table); + + if !(0.0..=1.0).contains(&radius_deg) { + return Err(anyhow!("交叉证认半径建议 ≤1 度,当前: {}", radius_deg)); + } + + let adql = 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 + ); + + 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, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ColumnInfo { + pub column_name: String, + pub datatype: String, + pub unit: Option, + pub description: Option, + pub ucd: Option, +} + +#[derive(Debug, Clone)] +pub struct ExportResult { + pub csv: String, + pub row_count: usize, + pub column_count: 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> { + 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> { + 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 { + 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, + }) + } + + /// 通过文献 bibcode 查找关联的 VizieR 表 + pub async fn lookup(&self, bibcode: &str, limit: usize) -> Result> { + 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], limit: usize) -> Vec { + 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]) -> Vec { + 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]) -> 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 = 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 { + 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); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 255b5f6..55307a9 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod batch; +pub mod cds; pub mod chunker; pub mod citation; pub mod download; @@ -11,6 +12,6 @@ pub mod query_parser; pub mod rag; pub mod search; pub mod session; -pub mod target; +pub mod spectra; pub mod translation; pub mod vision; diff --git a/src/services/spectra/common.rs b/src/services/spectra/common.rs new file mode 100644 index 0000000..e04c03c --- /dev/null +++ b/src/services/spectra/common.rs @@ -0,0 +1,1251 @@ +// src/services/spectra/common.rs +// +// 光谱数据统一服务层 —— 跨 LAMOST/Gaia/SDSS 的唯一上层入口 +// +// 提供: +// 1. 统一 DownloadResult / DownloadBatch —— 收口三数据源的结果结构 +// 2. download_spectrum(state, request) —— 双模式统一入口: +// - ByCoordinates:cone 检索 → 选源 → 下载(输入 ra/dec/radius) +// - ByIdentifier:直接按源标识下载(跳过 cone,适用于已通过 VizieR 交叉证认拿到标识的场景) +// 3. 公共缓存/落盘辅助 —— spectrum_cache 读写、FITS 落盘、URL 生成 +// +// 分层:各数据源 service(lamost.rs/gaia.rs/sdss.rs)保留各自的 client 调用与解压逻辑, +// 本模块负责跨源编排。三源分立工具/端点已删除,全部走本统一入口。 + +use crate::api::AppState; +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use std::path::Path; +use tracing::{info, warn}; + +// ═══════════════════════════════════════════════════════════════ +// 1. 统一领域结构 +// ═══════════════════════════════════════════════════════════════ + +/// 光谱数据源 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SpectrumSurvey { + Lamost, + Gaia, + Sdss, + Desi, +} + +impl SpectrumSurvey { + pub fn as_str(&self) -> &'static str { + match self { + Self::Lamost => "lamost", + Self::Gaia => "gaia", + Self::Sdss => "sdss", + Self::Desi => "desi", + } + } + + pub fn display(&self) -> &'static str { + match self { + Self::Lamost => "LAMOST", + Self::Gaia => "Gaia", + Self::Sdss => "SDSS", + Self::Desi => "DESI", + } + } +} + +// ── LAMOST 版本与分辨率 ── + +/// LAMOST 数据发布版本 +/// +/// 版本路径段无统一规律(DR5/6 用 v1/v2,DR7+ 用 vX.Y),必须逐版本硬编码。 +/// MRS(中分辨率)从 DR7 起才有。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum LamostRelease { + Dr5, + Dr6, + Dr7, + Dr8, + Dr9, + #[default] + Dr10, + Dr11, +} + +impl LamostRelease { + /// URL 路径段,如 "dr10" + pub fn path_segment(&self) -> &'static str { + match self { + Self::Dr5 => "dr5", + Self::Dr6 => "dr6", + Self::Dr7 => "dr7", + Self::Dr8 => "dr8", + Self::Dr9 => "dr9", + Self::Dr10 => "dr10", + Self::Dr11 => "dr11", + } + } + + /// 推荐版本子路径(DR5/6 用 v2,DR7 用 v1.2,DR8+ 用 v2.0) + pub fn version_segment(&self) -> &'static str { + match self { + Self::Dr5 | Self::Dr6 => "v2", + Self::Dr7 => "v1.2", + Self::Dr8 | Self::Dr9 | Self::Dr10 | Self::Dr11 => "v2.0", + } + } + + /// TAP 表名:LRS → public.combined,MRS → public.median + pub fn table_name(&self, resolution: LamostResolution) -> &'static str { + match resolution { + LamostResolution::Lrs => "public.combined", + LamostResolution::Mrs => "public.median", + } + } + + /// MRS 从 DR7 起才支持 + pub fn supports_mrs(&self) -> bool { + !matches!(self, Self::Dr5 | Self::Dr6) + } +} + +/// LAMOST 光谱分辨率模式 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum LamostResolution { + /// 低分辨率(LRS,R~1800) + #[default] + Lrs, + /// 中分辨率(MRS,R~7500),DR7+ + Mrs, +} + +impl LamostResolution { + /// ConeSearch 端点前缀:LRS → voservice,MRS → medvoservice + pub fn endpoint_prefix(&self) -> &'static str { + match self { + Self::Lrs => "voservice", + Self::Mrs => "medvoservice", + } + } + + pub fn display(&self) -> &'static str { + match self { + Self::Lrs => "LRS", + Self::Mrs => "MRS", + } + } +} + +// ── SDSS 版本 ── + +/// SDSS 数据发布版本 +/// +/// DR≤17 可经 NOIRLab Data Lab TAP 查询 catalog;DR18/19 的 catalog 仅在 SDSS SkyServer。 +/// SAS 下载全版本支持,但 DR18+ 路径结构变化(spectro/boss/redux/ 替代 {prefix}/spectro/redux/)。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum SdssRelease { + Dr16, + #[default] + Dr17, + Dr18, + Dr19, +} + +impl SdssRelease { + /// SAS 路径段,如 "dr17" + pub fn sas_dr_segment(&self) -> &'static str { + match self { + Self::Dr16 => "dr16", + Self::Dr17 => "dr17", + Self::Dr18 => "dr18", + Self::Dr19 => "dr19", + } + } + + /// Data Lab TAP schema 表名(仅 DR16/17 支持) + pub fn datalab_table(&self) -> Option<&'static str> { + match self { + Self::Dr16 => Some("sdss_dr16.specobj"), + Self::Dr17 => Some("sdss_dr17.specobj"), + Self::Dr18 | Self::Dr19 => None, // Data Lab 不托管 DR18+ + } + } + + /// APOGEE allStar 表名(仅 DR17 有 apogee2_allstar) + pub fn apogee_table(&self) -> Option<&'static str> { + match self { + Self::Dr17 => Some("sdss_dr17.apogee2_allstar"), + _ => None, // DR16 的 APOGEE 表结构不同,DR18+ 不在 Data Lab + } + } + + /// 是否支持 Data Lab TAP catalog 查询 + pub fn supports_datalab(&self) -> bool { + self.datalab_table().is_some() + } + + /// 是否为 SDSS-V(DR18+),路径结构不同 + pub fn is_sdss_v(&self) -> bool { + matches!(self, Self::Dr18 | Self::Dr19) + } + + pub fn display(&self) -> &'static str { + match self { + Self::Dr16 => "DR16", + Self::Dr17 => "DR17", + Self::Dr18 => "DR18", + Self::Dr19 => "DR19", + } + } +} + +// ── Gaia 版本 ── + +/// Gaia 数据发布版本(当前仅 DR3 公开) +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum GaiaRelease { + #[default] + Dr3, +} + +impl GaiaRelease { + /// TAP 表名 + pub fn tap_table(&self) -> &'static str { + "gaiadr3.gaia_source" + } + + pub fn display(&self) -> &'static str { + "DR3" + } +} + +// ── DESI 版本 ── + +/// DESI 数据发布版本 +/// +/// DESI 光谱按 HEALPix 像素打包(一个 coadd 文件含该像素内所有目标的光谱)。 +/// catalog 查询经 NOIRLab Data Lab TAP(desi_dr1.zpix / desi_edr.zpix); +/// FITS 下载经 DESI 公共 SAS(data.desi.lbl.gov/public/),无需认证。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum DesiRelease { + /// Early Data Release(specredux=fuji) + Edr, + /// DR1(specredux=iron),2025年3月发布,1870万目标 + #[default] + Dr1, +} + +impl DesiRelease { + /// SAS 路径段:edr / dr1 + pub fn dr_segment(&self) -> &'static str { + match self { + Self::Edr => "edr", + Self::Dr1 => "dr1", + } + } + + /// 光谱归约版本:fuji(EDR)/ iron(DR1) + pub fn specredux_ver(&self) -> &'static str { + match self { + Self::Edr => "fuji", + Self::Dr1 => "iron", + } + } + + /// Data Lab TAP 表名 + 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", + } + } +} + +/// cone 命中多条时的选源策略(仅 ByCoordinates 模式生效) +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FindStrategy { + /// 仅下载最近的一条(默认) + #[default] + Nearest, + /// 下载锥形内全部命中(可能很多,慎用) + All, +} + +/// 统一下载结果(跨三数据源) +/// +/// source_id 是归一化的源标识字符串(LAMOST=obsid、Gaia="RT|source_id"、SDSS="run2d-plate-mjd-fiber"); +/// source_meta 装载各源的原始标识细节。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadResult { + pub survey: SpectrumSurvey, + /// 归一化源标识(用于 spectrum_cache 去重键) + pub source_id: String, + /// 人类可读的源标签 + pub source_label: String, + pub file_path: String, + pub file_url: String, + pub file_format: String, + pub size_bytes: usize, + pub cached: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_meta: Option, +} + +/// 一批下载的聚合结果 +#[derive(Debug, Clone, Serialize)] +pub struct DownloadBatch { + pub survey: SpectrumSurvey, + /// ByCoordinates 模式下有值;ByIdentifier 模式为 None + #[serde(skip_serializing_if = "Option::is_none")] + pub ra: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dec: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub radius_deg: Option, + /// cone 命中数(ByCoordinates)或请求标识数(ByIdentifier) + pub matched_count: usize, + pub downloads: Vec, + /// 下载失败条目(不中断整体流程) + pub failures: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DownloadFailure { + pub source_label: String, + pub error: String, +} + +// ═══════════════════════════════════════════════════════════════ +// 2. 统一下载请求与编排入口 +// ═══════════════════════════════════════════════════════════════ + +/// 光谱下载请求 —— 支持两种输入模式 +/// +/// - `by_coordinates`:给坐标 + 数据源 + 选源策略,内部自动 cone 检索 → 选源 → 下载。 +/// 适用于"我只要这个坐标的光谱"。 +/// - `by_identifier`:给数据源 + 归一化源标识字符串,直接下载(跳过 cone 检索)。 +/// 适用于已通过 VizieR 交叉证认等途径拿到源标识的场景。 +/// +/// 源标识格式(与各 service 的 spectrum_cache 去重键一致): +/// LAMOST: obsid 数字字符串,如 "438809089" +/// Gaia: "XP_CONTINUOUS|source_id",如 "XP_CONTINUOUS|65214031805717376" +/// SDSS: "run2d-plate-mjd-fiberid",如 "26-2225-53729-439" +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum SpectrumRequest { + ByCoordinates { + survey: SpectrumSurvey, + ra: f64, + dec: f64, + #[serde(default = "default_radius")] + radius_deg: f64, + #[serde(default)] + strategy: FindStrategy, + /// 数据发布版本(可选,None=各源默认:LAMOST=dr10, Gaia=dr3, SDSS=dr17) + #[serde(default)] + release: Option, + /// 数据类型(可选):LAMOST="lrs"/"mrs",Gaia="xp_continuous"等,SDSS 忽略 + #[serde(default)] + data_type: Option, + }, + ByIdentifier { + survey: SpectrumSurvey, + /// 归一化源标识列表(格式见结构体文档) + source_ids: Vec, + #[serde(default)] + release: Option, + #[serde(default)] + data_type: Option, + }, +} + +fn default_radius() -> f64 { + 0.1 +} + +/// 从字符串解析 LAMOST 版本(默认 DR10) +pub fn parse_lamost_release(s: Option<&str>) -> LamostRelease { + match s.map(|x| x.to_lowercase()).as_deref() { + Some("dr5") => LamostRelease::Dr5, + Some("dr6") => LamostRelease::Dr6, + Some("dr7") => LamostRelease::Dr7, + Some("dr8") => LamostRelease::Dr8, + Some("dr9") => LamostRelease::Dr9, + Some("dr11") => LamostRelease::Dr11, + _ => LamostRelease::Dr10, // 默认 + "dr10" + 无效值 + } +} + +/// 从字符串解析 LAMOST 分辨率(默认 LRS) +pub fn parse_lamost_resolution(s: Option<&str>) -> LamostResolution { + match s.map(|x| x.to_lowercase()).as_deref() { + Some("mrs") | Some("medium") => LamostResolution::Mrs, + _ => LamostResolution::Lrs, + } +} + +/// 从字符串解析 SDSS 版本(默认 DR17) +pub fn parse_sdss_release(s: Option<&str>) -> SdssRelease { + match s.map(|x| x.to_lowercase()).as_deref() { + Some("dr16") => SdssRelease::Dr16, + Some("dr18") => SdssRelease::Dr18, + Some("dr19") => SdssRelease::Dr19, + _ => SdssRelease::Dr17, + } +} + +/// 从字符串解析 Gaia 版本(当前仅 DR3) +pub fn parse_gaia_release(s: Option<&str>) -> GaiaRelease { + let _ = s; // DR4 待公开后扩展 + GaiaRelease::Dr3 +} + +/// 从字符串解析 DESI 版本(默认 DR1) +pub fn parse_desi_release(s: Option<&str>) -> DesiRelease { + match s.map(|x| x.to_lowercase()).as_deref() { + Some("edr") => DesiRelease::Edr, + _ => DesiRelease::Dr1, + } +} + +/// 从字符串解析 Gaia 数据产品类型(默认 XP_CONTINUOUS) +pub fn parse_gaia_retrieval_type_str( + s: Option<&str>, +) -> Result { + use crate::clients::gaia::GaiaRetrievalType; + Ok(match s.map(|x| x.to_uppercase()).as_deref() { + Some("XP_SAMPLED") => GaiaRetrievalType::XpSampled, + Some("EPOCH_PHOTOMETRY") => GaiaRetrievalType::EpochPhotometry, + Some("RVS") => GaiaRetrievalType::Rvs, + Some("MEAN_SPECTRUM_RVS") => GaiaRetrievalType::MeanSpectrumRvs, + _ => GaiaRetrievalType::XpContinuous, + }) +} + +/// 统一下载入口:按坐标或按标识符下载光谱 +/// +/// 这是光谱数据的唯一上层入口,取代历史的三源分立工具/端点。 +/// 调用方通过 `SpectrumRequest` 选择模式,force 控制是否忽略缓存。 +pub async fn download_spectrum( + state: &AppState, + request: &SpectrumRequest, + force: bool, +) -> Result { + match request { + SpectrumRequest::ByCoordinates { + survey, + ra, + dec, + radius_deg, + strategy, + release, + data_type, + } => { + download_by_coordinates( + state, + *survey, + *ra, + *dec, + *radius_deg, + *strategy, + release.as_deref(), + data_type.as_deref(), + force, + ) + .await + } + SpectrumRequest::ByIdentifier { + survey, + source_ids, + release, + data_type, + } => { + download_by_identifiers( + state, + *survey, + source_ids, + release.as_deref(), + data_type.as_deref(), + force, + ) + .await + } + } +} + +/// 按坐标:cone 检索 → 选源 → 逐个下载 +async fn download_by_coordinates( + state: &AppState, + survey: SpectrumSurvey, + ra: f64, + dec: f64, + radius_deg: f64, + strategy: FindStrategy, + release: Option<&str>, + data_type: Option<&str>, + force: bool, +) -> Result { + info!( + "[Spectra] download_spectrum by_coords survey={} ra={:.4} dec={:.4} radius={:.4}° strategy={:?}", + survey.display(), + ra, + dec, + radius_deg, + strategy + ); + + let matched = + cone_search_unified(state, survey, ra, dec, radius_deg, release, data_type).await?; + let matched_count = matched.len(); + + if matched_count == 0 { + return Ok(DownloadBatch { + survey, + ra: Some(ra), + dec: Some(dec), + radius_deg: Some(radius_deg), + matched_count: 0, + downloads: Vec::new(), + failures: Vec::new(), + }); + } + + let targets: Vec = match strategy { + FindStrategy::Nearest => vec![pick_nearest(&matched, ra, dec)], + FindStrategy::All => (0..matched.len()).collect(), + }; + + let mut downloads = Vec::with_capacity(targets.len()); + let mut failures = Vec::new(); + for idx in targets { + let raw = &matched[idx].raw; + match download_by_raw(state, survey, raw, release, data_type, force).await { + Ok(r) => downloads.push(r), + Err(e) => { + let label = label_from_raw(survey, raw); + warn!("[Spectra] 下载失败 {}: {}", label, e); + failures.push(DownloadFailure { + source_label: label, + error: format!("{:#}", e), + }); + } + } + } + + Ok(DownloadBatch { + survey, + ra: Some(ra), + dec: Some(dec), + radius_deg: Some(radius_deg), + matched_count, + downloads, + failures, + }) +} + +/// 按标识符:解析各源标识字符串格式 → 逐个下载(跳过 cone 检索) +async fn download_by_identifiers( + state: &AppState, + survey: SpectrumSurvey, + source_ids: &[String], + release: Option<&str>, + data_type: Option<&str>, + force: bool, +) -> Result { + info!( + "[Spectra] download_spectrum by_id survey={} count={} release={:?} data_type={:?}", + survey.display(), + source_ids.len(), + release, + data_type + ); + + let mut downloads = Vec::with_capacity(source_ids.len()); + let mut failures = Vec::new(); + for sid in source_ids { + match download_one_by_identifier(state, survey, sid, release, data_type, force).await { + Ok(r) => downloads.push(r), + Err(e) => { + warn!("[Spectra] 下载失败 {}: {}", sid, e); + failures.push(DownloadFailure { + source_label: sid.clone(), + error: format!("{:#}", e), + }); + } + } + } + + Ok(DownloadBatch { + survey, + ra: None, + dec: None, + radius_deg: None, + matched_count: downloads.len() + failures.len(), + downloads, + failures, + }) +} + +/// 解析单个源标识字符串并委托对应数据源下载 +/// +/// 标识符格式约定(与 spectrum_cache 去重键一致): +/// LAMOST: 纯数字 obsid +/// Gaia: "RETRIEVAL_TYPE|source_id"(缺省 RT 用 XP_CONTINUOUS) +/// SDSS: "run2d-plate-mjd-fiberid" +async fn download_one_by_identifier( + state: &AppState, + survey: SpectrumSurvey, + source_id: &str, + release: Option<&str>, + data_type: Option<&str>, + force: bool, +) -> Result { + use crate::services::spectra::{desi, gaia, lamost, sdss}; + let library_dir = &state.config.library_dir; + let sid = source_id.trim(); + match survey { + SpectrumSurvey::Lamost => { + let obsid: i64 = sid + .parse() + .map_err(|_| anyhow!("LAMOST source_id 应为纯数字 obsid,得到 '{}'", sid))?; + let rel = parse_lamost_release(release); + let res = parse_lamost_resolution(data_type); + lamost::download_spectrum( + &state.db, + &state.lamost, + library_dir, + obsid, + rel, + res, + force, + ) + .await + } + SpectrumSurvey::Gaia => { + let (rt_param, gaia_sid) = match sid.split_once('|') { + Some((rt, s)) => (rt, s), + None => ("XP_CONTINUOUS", sid), + }; + // data_type 优先于 source_id 内嵌的 RT 前缀 + let rt = if data_type.is_some() { + parse_gaia_retrieval_type_str(data_type)? + } else { + parse_gaia_retrieval_type(rt_param)? + }; + gaia::download_spectrum(&state.db, &state.gaia, library_dir, gaia_sid, rt, force).await + } + SpectrumSurvey::Sdss => { + let rel = parse_sdss_release(release); + // APOGEE 分支:data_type=apstar/aspcap 或 source_id 含 | 分隔符 + if matches!(data_type, Some("apstar") | Some("aspcap")) || sid.contains('|') { + // 格式 "{telescope}|{field}|{apogee_id}",如 "apo25m|359+01|2M17400083-2858496" + let parts: Vec<&str> = sid.splitn(3, '|').collect(); + if parts.len() != 3 { + return Err(anyhow!( + "APOGEE source_id 应为 'telescope|field|apogee_id' 格式,得到 '{}'", + sid + )); + } + let dt = data_type.unwrap_or("apstar"); + return sdss::download_apogee_spectrum( + &state.db, + &state.sdss, + library_dir, + parts[2], + parts[0], + parts[1], + rel, + dt, + force, + ) + .await; + } + // 光学 specLite 分支:run2d-plate-mjd-fiberid + let parts: Vec<&str> = sid.split('-').collect(); + if parts.len() != 4 { + return Err(anyhow!( + "SDSS source_id 应为 'run2d-plate-mjd-fiberid' 格式,得到 '{}'", + sid + )); + } + let run2d = parts[0]; + let plate: i64 = parts[1] + .parse() + .map_err(|_| anyhow!("plate 非数字: '{}'", parts[1]))?; + let mjd: i64 = parts[2] + .parse() + .map_err(|_| anyhow!("mjd 非数字: '{}'", parts[2]))?; + let fiberid: i64 = parts[3] + .parse() + .map_err(|_| anyhow!("fiberid 非数字: '{}'", parts[3]))?; + sdss::download_spectrum( + &state.db, + &state.sdss, + library_dir, + plate, + mjd, + fiberid, + run2d, + rel, + force, + ) + .await + } + SpectrumSurvey::Desi => { + // 格式 "{survey}-{program}-{healpix}",如 "main-dark-10050" + let parts: Vec<&str> = sid.split('-').collect(); + if parts.len() != 3 { + return Err(anyhow!( + "DESI source_id 应为 'survey-program-healpix' 格式(如 'main-dark-10050'),得到 '{}'", + sid + )); + } + let desi_survey = parts[0]; + let desi_program = parts[1]; + let healpix: i64 = parts[2] + .parse() + .map_err(|_| anyhow!("healpix 非数字: '{}'", parts[2]))?; + let rel = parse_desi_release(release); + desi::download_spectrum( + &state.db, + &state.desi, + library_dir, + desi_survey, + desi_program, + healpix, + rel, + force, + ) + .await + } + } +} + +fn parse_gaia_retrieval_type(s: &str) -> Result { + use crate::clients::gaia::GaiaRetrievalType; + Ok(match s.trim().to_uppercase().as_str() { + "XP_CONTINUOUS" => GaiaRetrievalType::XpContinuous, + "XP_SAMPLED" => GaiaRetrievalType::XpSampled, + "EPOCH_PHOTOMETRY" => GaiaRetrievalType::EpochPhotometry, + "RVS" => GaiaRetrievalType::Rvs, + "MEAN_SPECTRUM_RVS" => GaiaRetrievalType::MeanSpectrumRvs, + other => return Err(anyhow!("不支持的 Gaia retrieval_type '{}'", other)), + }) +} + +// ═══════════════════════════════════════════════════════════════ +// 3. 各源差异收口(cone 归一化 + 选源 + 标识解析) +// ═══════════════════════════════════════════════════════════════ + +/// 归一化的 cone 命中行(跨三源统一:坐标 + 距离 + 原始行 JSON) +struct UnifiedRow { + ra: Option, + dec: Option, + distance: Option, + raw: serde_json::Value, +} + +/// 执行 cone 检索并归一化为 UnifiedRow 列表 +async fn cone_search_unified( + state: &AppState, + survey: SpectrumSurvey, + ra: f64, + dec: f64, + radius_deg: f64, + release: Option<&str>, + data_type: Option<&str>, +) -> Result> { + use crate::services::spectra::{desi, gaia, lamost, sdss}; + match survey { + SpectrumSurvey::Lamost => { + let rel = parse_lamost_release(release); + let res = parse_lamost_resolution(data_type); + let r = + lamost::cone_search_cached(&state.db, &state.lamost, ra, dec, radius_deg, rel, res) + .await?; + Ok(r.rows + .iter() + .map(|row| UnifiedRow { + ra: row.ra_obs, + dec: row.dec_obs, + distance: None, + raw: serde_json::to_value(row).unwrap_or(serde_json::Value::Null), + }) + .collect()) + } + SpectrumSurvey::Gaia => { + let rel = parse_gaia_release(release); + let r = gaia::cone_search_cached( + &state.db, + &state.gaia, + ra, + dec, + radius_deg, + 50, + true, + rel, + ) + .await?; + Ok(r.rows + .iter() + .map(|row| UnifiedRow { + ra: row.ra, + dec: row.dec, + distance: row.distance, + raw: serde_json::to_value(row).unwrap_or(serde_json::Value::Null), + }) + .collect()) + } + SpectrumSurvey::Sdss => { + let rel = parse_sdss_release(release); + // APOGEE 分支:data_type=apstar/aspcap 时查 apogee2_allstar 表 + if matches!(data_type, Some("apstar") | Some("aspcap")) { + let r = sdss::apogee_cone_search_cached( + &state.db, + &state.sdss, + ra, + dec, + radius_deg, + rel, + ) + .await?; + return Ok(r + .rows + .iter() + .map(|row| UnifiedRow { + ra: row.ra, + dec: row.dec, + distance: None, + raw: serde_json::to_value(row).unwrap_or(serde_json::Value::Null), + }) + .collect()); + } + let r = sdss::cone_search_cached(&state.db, &state.sdss, ra, dec, radius_deg, 50, rel) + .await?; + Ok(r.rows + .iter() + .map(|row| UnifiedRow { + ra: row.ra, + dec: row.dec, + distance: None, + raw: serde_json::to_value(row).unwrap_or(serde_json::Value::Null), + }) + .collect()) + } + SpectrumSurvey::Desi => { + let rel = parse_desi_release(release); + let r = + desi::cone_search_cached(&state.db, &state.desi, ra, dec, radius_deg, rel).await?; + Ok(r.rows + .iter() + .map(|row| UnifiedRow { + ra: row.ra, + dec: row.dec, + distance: None, + raw: serde_json::to_value(row).unwrap_or(serde_json::Value::Null), + }) + .collect()) + } + } +} + +/// 按原始 cone 行委托对应数据源下载 +async fn download_by_raw( + state: &AppState, + survey: SpectrumSurvey, + raw: &serde_json::Value, + release: Option<&str>, + data_type: Option<&str>, + force: bool, +) -> Result { + use crate::services::spectra::{desi, gaia, lamost, sdss}; + let library_dir = &state.config.library_dir; + match survey { + SpectrumSurvey::Lamost => { + let obsid = raw + .get("obsid") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("LAMOST 行缺 obsid"))?; + let rel = parse_lamost_release(release); + let res = parse_lamost_resolution(data_type); + lamost::download_spectrum( + &state.db, + &state.lamost, + library_dir, + obsid, + rel, + res, + force, + ) + .await + } + SpectrumSurvey::Gaia => { + let source_id = raw + .get("source_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Gaia 行缺 source_id"))?; + let rt = parse_gaia_retrieval_type_str(data_type)?; + gaia::download_spectrum(&state.db, &state.gaia, library_dir, source_id, rt, force).await + } + SpectrumSurvey::Sdss => { + let rel = parse_sdss_release(release); + // APOGEE 分支:data_type=apstar/aspcap 或 raw 含 apogee_id 字段 + if matches!(data_type, Some("apstar") | Some("aspcap")) + || raw.get("apogee_id").is_some() + { + let apogee_id = raw + .get("apogee_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("APOGEE 行缺 apogee_id"))?; + let telescope = raw + .get("telescope") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("APOGEE 行缺 telescope"))?; + let field = raw + .get("field") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("APOGEE 行缺 field"))?; + let dt = data_type.unwrap_or("apstar"); + return sdss::download_apogee_spectrum( + &state.db, + &state.sdss, + library_dir, + apogee_id, + telescope, + field, + rel, + dt, + force, + ) + .await; + } + // 光学 specLite 分支 + let plate = raw + .get("plate") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("SDSS 行缺 plate"))?; + let mjd = raw + .get("mjd") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("SDSS 行缺 mjd"))?; + let fiberid = raw + .get("fiberid") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("SDSS 行缺 fiberid"))?; + let run2d = raw + .get("run2d") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("SDSS 行缺 run2d"))?; + sdss::download_spectrum( + &state.db, + &state.sdss, + library_dir, + plate, + mjd, + fiberid, + run2d, + rel, + force, + ) + .await + } + SpectrumSurvey::Desi => { + let desi_survey = raw + .get("survey") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("DESI 行缺 survey"))?; + let desi_program = raw + .get("program") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("DESI 行缺 program"))?; + let healpix = raw + .get("healpix") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("DESI 行缺 healpix"))?; + let rel = parse_desi_release(release); + desi::download_spectrum( + &state.db, + &state.desi, + library_dir, + desi_survey, + desi_program, + healpix, + rel, + force, + ) + .await + } + } +} + +/// 从归一化行列表中选角距离最近的一条索引 +fn pick_nearest(rows: &[UnifiedRow], center_ra: f64, center_dec: f64) -> usize { + let mut best_idx = 0; + let mut best_dist = f64::INFINITY; + for (i, r) in rows.iter().enumerate() { + let d = r.distance.unwrap_or_else(|| match (r.ra, r.dec) { + (Some(ra), Some(dec)) => angular_separation_deg(center_ra, center_dec, ra, dec), + _ => f64::INFINITY, + }); + if d < best_dist { + best_dist = d; + best_idx = i; + } + } + best_idx +} + +/// 球面角距离(大圆弧,度)。用于 LAMOST/SDSS cone 无 distance 字段时现场计算。 +fn angular_separation_deg(ra1: f64, dec1: f64, ra2: f64, dec2: f64) -> f64 { + let dec1r = dec1.to_radians(); + let dec2r = dec2.to_radians(); + let ddec = (dec2 - dec1).to_radians(); + let dra = (ra2 - ra1).to_radians(); + let a = (ddec / 2.0).sin().powi(2) + dec1r.cos() * dec2r.cos() * (dra / 2.0).sin().powi(2); + 2.0 * a.sqrt().asin().to_degrees() +} + +/// 从原始行生成人类可读的源标签 +fn label_from_raw(survey: SpectrumSurvey, raw: &serde_json::Value) -> String { + match survey { + SpectrumSurvey::Lamost => { + let obsid = raw.get("obsid").and_then(|v| v.as_i64()).unwrap_or(0); + format!("obsid {}", obsid) + } + SpectrumSurvey::Gaia => { + let sid = raw.get("source_id").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Gaia {}", sid) + } + SpectrumSurvey::Sdss => { + // APOGEE 行有 apogee_id 字段,光学行有 plate + if let Some(id) = raw.get("apogee_id").and_then(|v| v.as_str()) { + let tel = raw.get("telescope").and_then(|v| v.as_str()).unwrap_or("?"); + format!("{} {}", tel, id) + } else { + let plate = raw.get("plate").and_then(|v| v.as_i64()).unwrap_or(0); + let mjd = raw.get("mjd").and_then(|v| v.as_i64()).unwrap_or(0); + let fiber = raw.get("fiberid").and_then(|v| v.as_i64()).unwrap_or(0); + format!("plate {} mjd {} fiber {}", plate, mjd, fiber) + } + } + SpectrumSurvey::Desi => { + let survey = raw.get("survey").and_then(|v| v.as_str()).unwrap_or("?"); + let program = raw.get("program").and_then(|v| v.as_str()).unwrap_or("?"); + let hp = raw.get("healpix").and_then(|v| v.as_i64()).unwrap_or(0); + format!("{} {} healpix {}", survey, program, hp) + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// 4. 公共缓存/落盘辅助(供各数据源 service 复用,消除三份重复) +// ═══════════════════════════════════════════════════════════════ + +/// 经 /api/files 前缀生成访问 URL +pub fn file_url_from_path(rel_path: &str) -> String { + format!("/api/files/{}", rel_path) +} + +/// 落盘 FITS 字节到 library_dir/,返回字节数 +pub fn persist_bytes(library_dir: &Path, rel_path: &str, bytes: &[u8]) -> Result { + 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_file_size(library_dir: &Path, rel_path: &str) -> Option { + let abs_path = library_dir.join(rel_path); + std::fs::metadata(&abs_path).ok().map(|m| m.len() as usize) +} + +/// spectrum_cache 通用查询行 +#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] +pub struct SpectrumCacheRow { + pub source: String, + pub source_id: String, + pub ra: Option, + pub dec: Option, + pub file_path: String, + pub file_format: String, + pub meta_json: Option, + pub created_at: String, +} + +/// 查询单条缓存(按 source + source_id) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct CacheHit { + pub file_path: String, + pub file_format: String, + pub meta_json: Option, +} + +pub async fn fetch_spectrum_cache( + pool: &SqlitePool, + source: &str, + source_id: &str, +) -> Result> { + let row = sqlx::query_as::<_, CacheHit>( + "SELECT file_path, file_format, meta_json FROM spectrum_cache WHERE source = ? AND source_id = ? LIMIT 1", + ) + .bind(source) + .bind(source_id) + .fetch_optional(pool) + .await + .map_err(|e| anyhow!("查询 spectrum_cache 失败: {}", e))?; + Ok(row) +} + +/// 写入/更新缓存条目 +pub async fn write_spectrum_cache( + pool: &SqlitePool, + source: &str, + source_id: &str, + ra: Option, + dec: Option, + rel_path: &str, + meta_json: Option<&str>, +) -> Result<()> { + sqlx::query( + "INSERT OR REPLACE INTO spectrum_cache (source, source_id, ra, dec, file_path, file_format, meta_json, created_at) \ + VALUES (?, ?, ?, ?, ?, 'fits', ?, CURRENT_TIMESTAMP)", + ) + .bind(source) + .bind(source_id) + .bind(ra) + .bind(dec) + .bind(rel_path) + .bind(meta_json) + .execute(pool) + .await + .map_err(|e| anyhow!("写入 spectrum_cache 失败: {}", e))?; + Ok(()) +} + +/// 列出某数据源的全部已缓存光谱 +pub async fn list_cached_spectra(pool: &SqlitePool, source: &str) -> Result> { + let rows = sqlx::query_as::<_, SpectrumCacheRow>( + "SELECT source, source_id, ra, dec, file_path, file_format, meta_json, created_at \ + FROM spectrum_cache WHERE source = ? ORDER BY created_at DESC LIMIT 500", + ) + .bind(source) + .fetch_all(pool) + .await + .map_err(|e| anyhow!("查询 spectrum_cache 列表失败: {}", e))?; + Ok(rows) +} + +/// 列出全部数据源的已缓存光谱 +pub async fn list_all_cached(pool: &SqlitePool) -> Result> { + let rows = sqlx::query_as::<_, SpectrumCacheRow>( + "SELECT source, source_id, ra, dec, file_path, file_format, meta_json, created_at \ + FROM spectrum_cache ORDER BY created_at DESC LIMIT 500", + ) + .fetch_all(pool) + .await + .map_err(|e| anyhow!("查询 spectrum_cache 列表失败: {}", e))?; + Ok(rows) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_angular_separation_known() { + assert!(angular_separation_deg(10.0, 41.0, 10.0, 41.0).abs() < 1e-9); + let d = angular_separation_deg(0.0, 0.0, 1.0, 0.0); + assert!((d - 1.0).abs() < 1e-6, "赤道 1° 间距: {}", d); + } + + #[test] + fn test_pick_nearest_with_distance() { + let rows = vec![ + UnifiedRow { + ra: Some(1.0), + dec: Some(1.0), + distance: Some(0.5), + raw: serde_json::Value::Null, + }, + UnifiedRow { + ra: Some(2.0), + dec: Some(2.0), + distance: Some(0.1), + raw: serde_json::Value::Null, + }, + UnifiedRow { + ra: Some(3.0), + dec: Some(3.0), + distance: Some(0.9), + raw: serde_json::Value::Null, + }, + ]; + assert_eq!(pick_nearest(&rows, 0.0, 0.0), 1); + } + + #[test] + fn test_pick_nearest_without_distance_uses_coords() { + let rows = vec![ + UnifiedRow { + ra: Some(10.5), + dec: Some(41.0), + distance: None, + raw: serde_json::Value::Null, + }, + UnifiedRow { + ra: Some(10.51), + dec: Some(41.01), + distance: None, + raw: serde_json::Value::Null, + }, + ]; + assert_eq!(pick_nearest(&rows, 10.5, 41.0), 0); + } + + #[test] + fn test_survey_roundtrip() { + assert_eq!(SpectrumSurvey::Lamost.as_str(), "lamost"); + assert_eq!(SpectrumSurvey::Gaia.display(), "Gaia"); + } + + #[test] + fn test_file_url_from_path() { + assert_eq!( + file_url_from_path("Spectra/lamost/123.fits"), + "/api/files/Spectra/lamost/123.fits" + ); + } + + #[test] + fn test_parse_gaia_retrieval_type() { + // 通过 as_param() 字符串比较,避免对 enum 派生 PartialEq 的依赖 + assert_eq!( + parse_gaia_retrieval_type("XP_CONTINUOUS") + .unwrap() + .as_param(), + "XP_CONTINUOUS" + ); + assert_eq!( + parse_gaia_retrieval_type("xp_sampled").unwrap().as_param(), + "XP_SAMPLED" + ); + assert!(parse_gaia_retrieval_type("BOGUS").is_err()); + } +} diff --git a/src/services/spectra/desi.rs b/src/services/spectra/desi.rs new file mode 100644 index 0000000..5744747 --- /dev/null +++ b/src/services/spectra/desi.rs @@ -0,0 +1,261 @@ +// src/services/spectra/desi.rs +// +// DESI 光谱业务服务层 —— 缓存 + 落盘 + 编排 +// +// 职责: +// 1. cone_search_cached — 检索结果缓存(复用 vizier_query_cache,"desi|" 前缀) +// 2. download_spectrum — 下载 coadd FITS(无压缩,直接落盘)→ 写 spectrum_cache +// +// 纯通信层(HTTP、VOTable 解析、FITS 字节下载)在 clients::desi。 +// 与 SDSS 的差异:DESI 光谱按 HEALPix 打包,下载单元是像素文件(非单目标文件)。 +// 缓存 key = "{survey}-{program}-{healpix}",落盘路径 "Spectra/desi/{survey}-{program}-{healpix}.fits"。 + +use crate::clients::desi::{DesiClient, DesiConeResult}; +use crate::services::spectra::common::DesiRelease; +use anyhow::{anyhow, Result}; +use sha1::{Digest, Sha1}; +use sqlx::SqlitePool; +use std::path::Path; +use std::time::Duration; +use tracing::{error, info, warn}; + +const CONE_TTL_SECS: i64 = 7 * 24 * 3600; + +// ═══════════════════════════════════════════════════════════════ +// 1. ConeSearch(带缓存) +// ═══════════════════════════════════════════════════════════════ + +pub async fn cone_search_cached( + pool: &SqlitePool, + client: &DesiClient, + ra: f64, + dec: f64, + radius_deg: f64, + release: DesiRelease, +) -> Result { + validate_cone_params(ra, dec, radius_deg)?; + + let query_hash = hash_cone(ra, dec, radius_deg, release); + + if let Some(cached) = fetch_cone_cache(pool, &query_hash).await? { + info!("[DESI] ConeSearch 缓存命中 (hash={:.12})", query_hash); + return Ok(cached); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + let result = client.cone_search(ra, dec, radius_deg, 50, release).await?; + + if let Err(e) = write_cone_cache(pool, &query_hash, &result).await { + error!("[DESI] 写入 ConeSearch 缓存失败: {}", e); + } + + Ok(result) +} + +fn validate_cone_params(ra: f64, dec: f64, radius_deg: f64) -> Result<()> { + if !(0.0..=1.0).contains(&radius_deg) { + return Err(anyhow!("检索半径应在 0~1 度之间,当前: {}", radius_deg)); + } + if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) { + return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec)); + } + Ok(()) +} + +fn hash_cone(ra: f64, dec: f64, radius_deg: f64, release: DesiRelease) -> String { + let mut hasher = Sha1::new(); + hasher.update(b"desi|cone|"); + hasher.update(ra.to_le_bytes()); + hasher.update(dec.to_le_bytes()); + hasher.update(radius_deg.to_le_bytes()); + hasher.update(release.dr_segment().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +#[derive(sqlx::FromRow)] +struct ConeCacheRow { + result_json: String, + expires_at: Option>, +} + +async fn fetch_cone_cache(pool: &SqlitePool, query_hash: &str) -> Result> { + let row = sqlx::query_as::<_, ConeCacheRow>( + "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!("查询 DESI 缓存失败: {}", e))?; + + match row { + Some(r) => { + if let Some(exp) = r.expires_at { + if exp < chrono::Utc::now() { + warn!("[DESI] ConeSearch 缓存已过期 (hash={:.12})", query_hash); + return Ok(None); + } + } + let result: DesiConeResult = serde_json::from_str(&r.result_json) + .map_err(|e| anyhow!("反序列化缓存失败: {}", e))?; + Ok(Some(result)) + } + None => Ok(None), + } +} + +async fn write_cone_cache( + pool: &SqlitePool, + query_hash: &str, + result: &DesiConeResult, +) -> Result<()> { + let result_json = serde_json::to_string(result)?; + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(CONE_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("desi:cone") + .bind(result.row_count as i64) + .bind(&result_json) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| anyhow!("写入 DESI 缓存失败: {}", e))?; + Ok(()) +} + +// ═══════════════════════════════════════════════════════════════ +// 2. coadd FITS 下载(直接落盘 + spectrum_cache 永久去重) +// ═══════════════════════════════════════════════════════════════ + +/// 下载并缓存一个 DESI HEALPix 像素的 coadd FITS +/// +/// 流程:查 spectrum_cache → 命中则直接返回;未命中 → 下载 → 落盘 → 写缓存。 +/// 幂等:重复调用同一像素不会重复下载(除非 force=true)。 +#[allow(clippy::too_many_arguments)] +pub async fn download_spectrum( + pool: &SqlitePool, + client: &DesiClient, + library_dir: &Path, + survey: &str, + program: &str, + healpix: i64, + release: DesiRelease, + force: bool, +) -> Result { + use crate::services::spectra::common::{ + cached_file_size, fetch_spectrum_cache, file_url_from_path, persist_bytes, + write_spectrum_cache, SpectrumSurvey, + }; + + if survey.trim().is_empty() || program.trim().is_empty() { + return Err(anyhow!("survey 和 program 不能为空")); + } + if healpix < 0 { + return Err(anyhow!("healpix 应为非负整数,当前: {}", healpix)); + } + let cache_key = format!( + "{}|{}-{}-{}", + release.dr_segment(), + survey, + program, + healpix + ); + let label = format!("{} {} healpix {}", survey, program, healpix); + let dr = release.dr_segment(); + + // 1) 缓存命中检查 + if !force { + if let Some(cached) = fetch_spectrum_cache(pool, "desi", &cache_key).await? { + if let Some(size) = cached_file_size(library_dir, &cached.file_path) { + info!("[DESI] coadd 缓存命中 ({})", cache_key); + let file_url = file_url_from_path(&cached.file_path); + let source_meta = cached + .meta_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + return Ok(crate::services::spectra::common::DownloadResult { + survey: SpectrumSurvey::Desi, + source_id: cache_key.clone(), + source_label: label.clone(), + file_path: cached.file_path, + file_url, + file_format: cached.file_format, + size_bytes: size, + cached: true, + source_meta, + }); + } + warn!("[DESI] 缓存记录存在但文件缺失,重新下载 ({})", cache_key); + } + } + + // 2) 下载 coadd FITS(无压缩) + tokio::time::sleep(Duration::from_millis(50)).await; + let fits_bytes = client + .download_coadd(survey, program, healpix, release) + .await?; + + // 3) 落盘:Telescope/desi/{dr}/{survey}-{program}/{healpix}.fits + let rel_path = format!( + "Telescope/desi/{dr}/{survey}-{program}/{healpix}.fits", + dr = dr, + survey = survey, + program = program, + healpix = healpix + ); + let size = persist_bytes(library_dir, &rel_path, &fits_bytes)?; + info!( + "[DESI] coadd 已保存 {} ({}B) → {}", + cache_key, size, rel_path + ); + let meta = + serde_json::json!({"survey": survey, "program": program, "healpix": healpix, "dr": dr}); + if let Err(e) = write_spectrum_cache( + pool, + "desi", + &cache_key, + None, + None, + &rel_path, + Some(&meta.to_string()), + ) + .await + { + error!("[DESI] 写入 spectrum_cache 失败: {}", e); + } + + let file_url = file_url_from_path(&rel_path); + Ok(crate::services::spectra::common::DownloadResult { + survey: SpectrumSurvey::Desi, + source_id: cache_key, + source_label: label, + file_path: rel_path, + file_url, + file_format: "fits".to_string(), + size_bytes: size, + cached: false, + source_meta: Some(meta), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_cone_params() { + assert!(validate_cone_params(180.0, 30.0, 0.1).is_ok()); + assert!(validate_cone_params(180.0, 30.0, 2.0).is_err()); + assert!(validate_cone_params(180.0, 91.0, 0.1).is_err()); + } + + #[test] + fn test_hash_cone_stable() { + let h1 = hash_cone(180.0, 30.0, 0.1, DesiRelease::Dr1); + let h2 = hash_cone(180.0, 30.0, 0.1, DesiRelease::Dr1); + let h3 = hash_cone(180.0, 30.0, 0.1, DesiRelease::Edr); + assert_eq!(h1, h2); + assert_ne!(h1, h3, "不同 release 应得到不同 hash"); + } +} diff --git a/src/services/spectra/gaia.rs b/src/services/spectra/gaia.rs new file mode 100644 index 0000000..c2db952 --- /dev/null +++ b/src/services/spectra/gaia.rs @@ -0,0 +1,365 @@ +// src/services/spectra/gaia.rs +// +// Gaia 光谱业务服务层 —— 缓存 + DataLink ZIP 解包 + 落盘 +// +// 职责: +// 1. cone_search_cached — TAP cone search 缓存(复用 vizier_query_cache,"gaia|" 前缀) +// 2. download_spectrum — DataLink 下载 ZIP → 解包 → 落盘 → 写 spectrum_cache +// 3. list_spectra — 复用 lamost::list_spectra(按 source 过滤),此处提供 gaia 专用便捷函数 +// +// DataLink 返回 ZIP(USE_ZIP_ALWAYS=true),内含每个 source 的 FITS/VOTable 文件。 +// 解包策略:取 ZIP 内第一个非目录条目落盘(单个 source 下载场景)。 +// +// 纯通信层(TAP POST、DataLink POST)在 clients::gaia。 + +use crate::clients::gaia::{GaiaClient, GaiaConeResult, GaiaRetrievalType, GaiaSourceRow}; +use anyhow::{anyhow, Result}; +use sha1::{Digest, Sha1}; +use sqlx::SqlitePool; +use std::io::{Cursor, Read}; +use std::path::Path; +use std::time::Duration; +use tracing::{error, info, warn}; + +// 下载结果与缓存/落盘辅助统一在 super::common; + +const CONE_TTL_SECS: i64 = 7 * 24 * 3600; + +// ═══════════════════════════════════════════════════════════════ +// 1. ConeSearch(带缓存) +// ═══════════════════════════════════════════════════════════════ + +pub async fn cone_search_cached( + pool: &SqlitePool, + client: &GaiaClient, + ra: f64, + dec: f64, + radius_deg: f64, + max_records: i64, + xp_only: bool, + release: super::common::GaiaRelease, +) -> Result { + validate_cone_params(ra, dec, radius_deg)?; + + let query_hash = hash_cone(ra, dec, radius_deg, max_records, xp_only, release); + + if let Some(cached) = fetch_cone_cache(pool, &query_hash).await? { + info!("[Gaia] ConeSearch 缓存命中 (hash={:.12})", query_hash); + return Ok(cached); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + let result = client + .cone_search(ra, dec, radius_deg, max_records, xp_only, release) + .await?; + + if let Err(e) = write_cone_cache(pool, &query_hash, &result).await { + error!("[Gaia] 写入 ConeSearch 缓存失败: {}", e); + } + + Ok(result) +} + +fn validate_cone_params(ra: f64, dec: f64, radius_deg: f64) -> Result<()> { + if !(0.0..=1.0).contains(&radius_deg) { + return Err(anyhow!( + "Gaia 检索半径建议 0~1 度(主表很大,大范围查询易超时),当前: {}", + radius_deg + )); + } + if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) { + return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec)); + } + Ok(()) +} + +fn hash_cone( + ra: f64, + dec: f64, + radius_deg: f64, + max_records: i64, + xp_only: bool, + release: super::common::GaiaRelease, +) -> String { + let mut hasher = Sha1::new(); + hasher.update(b"gaia|cone|"); + hasher.update(ra.to_le_bytes()); + hasher.update(dec.to_le_bytes()); + hasher.update(radius_deg.to_le_bytes()); + hasher.update(max_records.to_le_bytes()); + hasher.update([xp_only as u8]); + hasher.update(release.tap_table().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +#[derive(sqlx::FromRow)] +struct ConeCacheRow { + result_json: String, + expires_at: Option>, +} + +async fn fetch_cone_cache(pool: &SqlitePool, query_hash: &str) -> Result> { + let row = sqlx::query_as::<_, ConeCacheRow>( + "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!("查询 Gaia 缓存失败: {}", e))?; + + match row { + Some(r) => { + if let Some(exp) = r.expires_at { + if exp < chrono::Utc::now() { + warn!("[Gaia] ConeSearch 缓存已过期 (hash={:.12})", query_hash); + return Ok(None); + } + } + let result: GaiaConeResult = serde_json::from_str(&r.result_json) + .map_err(|e| anyhow!("反序列化缓存失败: {}", e))?; + Ok(Some(result)) + } + None => Ok(None), + } +} + +async fn write_cone_cache( + pool: &SqlitePool, + query_hash: &str, + result: &GaiaConeResult, +) -> Result<()> { + let result_json = serde_json::to_string(result)?; + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(CONE_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("gaia:cone") + .bind(result.row_count as i64) + .bind(&result_json) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| anyhow!("写入 Gaia 缓存失败: {}", e))?; + Ok(()) +} + +// ═══════════════════════════════════════════════════════════════ +// 2. DataLink 下载(ZIP 解包 + 落盘) +// ═══════════════════════════════════════════════════════════════ + +/// 下载并缓存一个 Gaia 源的光谱产品 +/// +/// 流程:查 spectrum_cache → 命中则返回;未命中 → DataLink 下载 ZIP → 解包取首文件 → 落盘 → 写缓存。 +pub async fn download_spectrum( + pool: &SqlitePool, + client: &GaiaClient, + library_dir: &Path, + source_id: &str, + retrieval_type: GaiaRetrievalType, + force: bool, +) -> Result { + use crate::services::spectra::common::{ + cached_file_size, fetch_spectrum_cache, file_url_from_path, persist_bytes, + write_spectrum_cache, SpectrumSurvey, + }; + + 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 dr = "dr3"; // Gaia 当前仅 DR3,DR4 发布后改 release 参数 + let cache_key = format!("{}|{}|{}", dr, rt_param, source_id); + + // 1) 缓存命中检查 + if !force { + if let Some(cached) = fetch_spectrum_cache(pool, "gaia", &cache_key).await? { + if let Some(size) = cached_file_size(library_dir, &cached.file_path) { + info!("[Gaia] 光谱缓存命中 (source_id={})", source_id); + let file_url = file_url_from_path(&cached.file_path); + let source_meta = cached + .meta_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + return Ok(super::common::DownloadResult { + survey: SpectrumSurvey::Gaia, + source_id: cache_key.clone(), + source_label: format!("Gaia {} ({})", source_id, rt_param), + file_path: cached.file_path, + file_url, + file_format: cached.file_format, + size_bytes: size, + cached: true, + source_meta, + }); + } + warn!( + "[Gaia] 缓存记录存在但文件缺失,重新下载 (source_id={})", + source_id + ); + } + } + + // 2) DataLink 下载(ZIP)+ 解包取首个文件条目 + tokio::time::sleep(Duration::from_millis(50)).await; + let zip_bytes = client + .download_products(&[source_id.to_string()], retrieval_type, "fits") + .await?; + let (file_bytes, ext) = extract_first_zip_entry(&zip_bytes, source_id)?; + + // 3) 落盘:Telescope/gaia/{dr}/{rt}/{source_id}.{ext} + let rel_path = format!( + "Telescope/gaia/{dr}/{rt}/{sid}.{ext}", + dr = dr, + rt = rt_lower, + sid = source_id, + ext = ext + ); + let size = persist_bytes(library_dir, &rel_path, &file_bytes)?; + info!( + "[Gaia] 光谱已保存 source_id={} type={} ({}B) → {}", + source_id, rt_param, size, rel_path + ); + let meta = serde_json::json!({"source_id": source_id, "retrieval_type": rt_param, "dr": dr}); + if let Err(e) = write_spectrum_cache( + pool, + "gaia", + &cache_key, + None, + None, + &rel_path, + Some(&meta.to_string()), + ) + .await + { + error!("[Gaia] 写入 spectrum_cache 失败: {}", e); + } + + let file_url = file_url_from_path(&rel_path); + Ok(super::common::DownloadResult { + survey: SpectrumSurvey::Gaia, + source_id: cache_key, + source_label: format!("Gaia {} ({})", source_id, rt_param), + file_path: rel_path, + file_url, + file_format: ext.to_string(), + size_bytes: size, + cached: false, + source_meta: Some(meta), + }) +} + +/// 从 ZIP 字节流中提取首个非目录文件条目 +/// +/// Gaia DataLink 的 ZIP 内每个 source 一个文件(.fits/.votable/.csv)。 +/// 返回 (文件字节, 扩展名)。扩展名从 ZIP 条目名推断,默认 fits。 +fn extract_first_zip_entry(zip_bytes: &[u8], source_id: &str) -> Result<(Vec, &'static str)> { + let cursor = Cursor::new(zip_bytes); + let mut archive = + zip::ZipArchive::new(cursor).map_err(|e| anyhow!("Gaia ZIP 解析失败: {}", e))?; + + 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_lowercase(); + let mut buf = Vec::new(); + file.read_to_end(&mut buf) + .map_err(|e| anyhow!("解压 Gaia ZIP 条目失败: {}", e))?; + if buf.is_empty() { + continue; + } + let ext = if name.ends_with(".fits") { + "fits" + } else if name.ends_with(".vot") || name.ends_with(".xml") { + "vot" + } else if name.ends_with(".csv") { + "csv" + } else { + "fits" + }; + return Ok((buf, ext)); + } + Err(anyhow!( + "Gaia ZIP 内无可下载文件条目 (source_id={})", + source_id + )) +} + +/// 从 ConeSearch 结果中挑出第一个有 XP 光谱的源(便捷函数,供 agent 工具使用) +pub fn first_xp_source(rows: &[GaiaSourceRow]) -> Option<&GaiaSourceRow> { + rows.iter() + .find(|r| r.has_xp_continuous.unwrap_or(false)) + .or_else(|| rows.iter().find(|r| r.has_xp_sampled.unwrap_or(false))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_validate_cone_params() { + assert!(validate_cone_params(180.0, 30.0, 0.1).is_ok()); + assert!(validate_cone_params(180.0, 30.0, 1.0).is_ok()); + // Gaia 主表大,半径上限收窄到 1° + assert!(validate_cone_params(180.0, 30.0, 2.0).is_err()); + } + + #[test] + fn test_hash_cone_stable() { + use crate::services::spectra::common::GaiaRelease; + let h1 = hash_cone(180.0, 30.0, 0.1, 5, true, GaiaRelease::Dr3); + let h2 = hash_cone(180.0, 30.0, 0.1, 5, true, GaiaRelease::Dr3); + let h3 = hash_cone(180.0, 30.0, 0.1, 5, false, GaiaRelease::Dr3); + assert_eq!(h1, h2); + assert_ne!(h1, h3, "xp_only 不同应得到不同 hash"); + } + + #[test] + fn test_extract_first_zip_entry() { + // 构造一个含单个 fits 文件的 ZIP + 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 = T").unwrap(); + writer.finish().unwrap(); + } + let (bytes, ext) = extract_first_zip_entry(&buf, "123").unwrap(); + assert_eq!(ext, "fits"); + assert!(bytes.starts_with(b"SIMPLE")); + } + + #[test] + fn test_first_xp_source() { + let rows = vec![ + GaiaSourceRow { + source_id: "1".into(), + ra: None, + dec: None, + phot_g_mean_mag: None, + has_xp_continuous: Some(false), + has_xp_sampled: Some(false), + has_rvs_spectrum: None, + distance: None, + }, + GaiaSourceRow { + source_id: "2".into(), + ra: None, + dec: None, + phot_g_mean_mag: None, + has_xp_continuous: Some(true), + has_xp_sampled: Some(false), + has_rvs_spectrum: None, + distance: None, + }, + ]; + assert_eq!(first_xp_source(&rows).unwrap().source_id, "2"); + } +} diff --git a/src/services/spectra/lamost.rs b/src/services/spectra/lamost.rs new file mode 100644 index 0000000..06d0f9b --- /dev/null +++ b/src/services/spectra/lamost.rs @@ -0,0 +1,380 @@ +// src/services/spectra/lamost.rs +// +// LAMOST 光谱业务服务层 —— 缓存 + 解压落盘 + 编排 +// +// 职责: +// 1. cone_search_cached — 检索结果缓存(复用 vizier_query_cache,"lamost|" 前缀) +// 2. download_spectrum — 下载 FITS.gz → gzip 解压 → 落盘 → 写 spectrum_cache +// 3. list_spectra — 纯 DB 查询已缓存的光谱文件 +// +// 纯通信层(HTTP、VOTable 解析、FITS 字节下载)在 clients::lamost。 +// 外部(api/catalog.rs、agent tools)统一通过本模块调用。 +// +// 解压:LAMOST FITS 端点返回 application/gzip(.fits.gz), +// 用 flate2::read::GzDecoder 解压(对齐 services/parser/mod.rs 的 magic-bytes 检测模式)。 +// 落盘:library_dir/Spectra/lamost/{obsid}.fits,相对路径存 DB,经 /api/files 暴露。 + +use crate::clients::lamost::{LamostClient, LamostConeResult}; +use anyhow::{anyhow, Result}; +use sha1::{Digest, Sha1}; +use sqlx::SqlitePool; +use std::io::Read; +use std::path::Path; +use std::time::Duration; +use tracing::{error, info, warn}; + +// 下载结果与缓存/落盘辅助统一在 super::common; + +// ═══════════════════════════════════════════════════════════════ +// 1. ConeSearch(带缓存,复用 vizier_query_cache 表) +// ═══════════════════════════════════════════════════════════════ + +const CONE_TTL_SECS: i64 = 7 * 24 * 3600; + +/// 锥形检索(带 7 天 TTL 缓存) +pub async fn cone_search_cached( + pool: &SqlitePool, + client: &LamostClient, + ra: f64, + dec: f64, + radius_deg: f64, + release: super::common::LamostRelease, + resolution: super::common::LamostResolution, +) -> Result { + validate_cone_params(ra, dec, radius_deg)?; + + let query_hash = hash_cone(ra, dec, radius_deg, release, resolution); + + if let Some(cached) = fetch_cone_cache(pool, &query_hash).await? { + info!("[LAMOST] ConeSearch 缓存命中 (hash={:.12})", query_hash); + return Ok(cached); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + let result = client + .cone_search(ra, dec, radius_deg, release, resolution) + .await?; + + if let Err(e) = write_cone_cache(pool, &query_hash, &result).await { + error!("[LAMOST] 写入 ConeSearch 缓存失败: {}", e); + } + + Ok(result) +} + +fn validate_cone_params(ra: f64, dec: f64, radius_deg: f64) -> Result<()> { + if !(0.0..=5.0).contains(&radius_deg) { + return Err(anyhow!("检索半径应在 0~5 度之间,当前: {}", radius_deg)); + } + if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) { + return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec)); + } + Ok(()) +} + +fn hash_cone( + ra: f64, + dec: f64, + radius_deg: f64, + release: super::common::LamostRelease, + resolution: super::common::LamostResolution, +) -> String { + let mut hasher = Sha1::new(); + hasher.update(b"lamost|cone|"); + hasher.update(ra.to_le_bytes()); + hasher.update(dec.to_le_bytes()); + hasher.update(radius_deg.to_le_bytes()); + hasher.update(release.path_segment().as_bytes()); + hasher.update(resolution.endpoint_prefix().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +#[derive(sqlx::FromRow)] +struct ConeCacheRow { + result_json: String, + expires_at: Option>, +} + +async fn fetch_cone_cache(pool: &SqlitePool, query_hash: &str) -> Result> { + let row = sqlx::query_as::<_, ConeCacheRow>( + "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!("查询 LAMOST 缓存失败: {}", e))?; + + match row { + Some(r) => { + if let Some(exp) = r.expires_at { + if exp < chrono::Utc::now() { + warn!("[LAMOST] ConeSearch 缓存已过期 (hash={:.12})", query_hash); + return Ok(None); + } + } + let result: LamostConeResult = serde_json::from_str(&r.result_json) + .map_err(|e| anyhow!("反序列化缓存失败: {}", e))?; + Ok(Some(result)) + } + None => Ok(None), + } +} + +async fn write_cone_cache( + pool: &SqlitePool, + query_hash: &str, + result: &LamostConeResult, +) -> Result<()> { + let result_json = serde_json::to_string(result)?; + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(CONE_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("lamost:cone") + .bind(result.row_count as i64) + .bind(&result_json) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| anyhow!("写入 LAMOST 缓存失败: {}", e))?; + Ok(()) +} + +// ═══════════════════════════════════════════════════════════════ +// 2. FITS 下载(gzip 解压 + 落盘 + spectrum_cache 永久去重) +// ═══════════════════════════════════════════════════════════════ + +/// 下载并缓存一条 LAMOST 光谱 FITS +/// +/// 流程:查 spectrum_cache → 命中则直接返回;未命中 → 下载 → 解压 → 落盘 → 写缓存。 +/// 幂等:重复调用同一 obsid 不会重复下载(除非 force=true)。 +pub async fn download_spectrum( + pool: &SqlitePool, + client: &LamostClient, + library_dir: &Path, + obsid: i64, + release: super::common::LamostRelease, + resolution: super::common::LamostResolution, + force: bool, +) -> Result { + use crate::services::spectra::common::{ + cached_file_size, fetch_spectrum_cache, file_url_from_path, persist_bytes, + write_spectrum_cache, SpectrumSurvey, + }; + + if obsid <= 0 { + return Err(anyhow!("obsid 应为正整数,当前: {}", obsid)); + } + let dr = release.path_segment(); + let ver = release.version_segment(); + let res_str = match resolution { + super::common::LamostResolution::Lrs => "lrs", + super::common::LamostResolution::Mrs => "mrs", + }; + let cache_key = format!("{}|{}|{}|{}", dr, ver, res_str, obsid); + + // 1) 缓存命中检查 + if !force { + if let Some(cached) = fetch_spectrum_cache(pool, "lamost", &cache_key).await? { + if let Some(size) = cached_file_size(library_dir, &cached.file_path) { + info!("[LAMOST] FITS 缓存命中 (obsid={})", obsid); + let file_url = file_url_from_path(&cached.file_path); + let source_meta = cached + .meta_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + return Ok(super::common::DownloadResult { + survey: SpectrumSurvey::Lamost, + source_id: cache_key.clone(), + source_label: format!("obsid {}", obsid), + file_path: cached.file_path, + file_url, + file_format: cached.file_format, + size_bytes: size, + cached: true, + source_meta, + }); + } + warn!( + "[LAMOST] 缓存记录存在但文件缺失,重新下载 (obsid={})", + obsid + ); + } + } + + // 2) 下载 + 解压(LAMOST 返回 gzip;若已是裸 FITS 则原样保留) + tokio::time::sleep(Duration::from_millis(50)).await; + let gz_bytes = client.download_fits(obsid, release).await?; + let fits_bytes = decompress_if_gzip(&gz_bytes)?; + + // 3) 落盘:Telescope/lamost/{dr}/{ver}/{res}/{obsid}.fits + let rel_path = format!( + "Telescope/lamost/{dr}/{ver}/{res}/{obsid}.fits", + dr = dr, + ver = ver, + res = res_str, + obsid = obsid + ); + let size = persist_bytes(library_dir, &rel_path, &fits_bytes)?; + info!( + "[LAMOST] FITS 已保存 obsid={} ({}B → {}B) → {}", + obsid, + gz_bytes.len(), + size, + rel_path + ); + let meta = serde_json::json!({"obsid": obsid, "dr": dr, "version": ver, "resolution": res_str}); + if let Err(e) = write_spectrum_cache( + pool, + "lamost", + &cache_key, + None, + None, + &rel_path, + Some(&meta.to_string()), + ) + .await + { + error!("[LAMOST] 写入 spectrum_cache 失败: {}", e); + } + + let file_url = file_url_from_path(&rel_path); + Ok(super::common::DownloadResult { + survey: SpectrumSurvey::Lamost, + source_id: cache_key, + source_label: format!("obsid {}", obsid), + file_path: rel_path, + file_url, + file_format: "fits".to_string(), + size_bytes: size, + cached: false, + source_meta: Some(meta), + }) +} + +/// 若字节流以 gzip magic bytes (0x1f 0x8b) 开头则解压,否则原样返回 +/// +/// 对齐 services/parser/mod.rs:78-86 的检测模式。 +fn decompress_if_gzip(bytes: &[u8]) -> Result> { + if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b { + let mut decoder = flate2::read::GzDecoder::new(bytes); + let mut out = Vec::new(); + decoder + .read_to_end(&mut out) + .map_err(|e| anyhow!("gzip 解压失败: {}", e))?; + Ok(out) + } else { + Ok(bytes.to_vec()) + } +} + +// ═══════════════════════════════════════════════════════════════ +// 3. 已缓存光谱列表(委托 common::list_cached_spectra) +// ═══════════════════════════════════════════════════════════════ + +/// 列出已下载的 LAMOST 光谱 +pub async fn list_spectra(pool: &SqlitePool) -> Result> { + super::common::list_cached_spectra(pool, "lamost").await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_cone_params() { + assert!(validate_cone_params(10.0, 41.0, 0.1).is_ok()); + assert!(validate_cone_params(0.0, 0.0, 5.0).is_ok()); + // 超出半径 + assert!(validate_cone_params(10.0, 41.0, 6.0).is_err()); + // 越界坐标 + assert!(validate_cone_params(10.0, 91.0, 0.1).is_err()); + } + + #[test] + fn test_hash_cone_stable() { + use crate::services::spectra::common::{LamostRelease, LamostResolution}; + let h1 = hash_cone(10.0, 41.0, 0.1, LamostRelease::Dr10, LamostResolution::Lrs); + let h2 = hash_cone(10.0, 41.0, 0.1, LamostRelease::Dr10, LamostResolution::Lrs); + let h3 = hash_cone(10.0, 41.0, 0.2, LamostRelease::Dr10, LamostResolution::Lrs); + let h4 = hash_cone(10.0, 41.0, 0.1, LamostRelease::Dr9, LamostResolution::Lrs); + let h5 = hash_cone(10.0, 41.0, 0.1, LamostRelease::Dr10, LamostResolution::Mrs); + assert_eq!(h1, h2); + assert_ne!(h1, h3); // 不同半径 + assert_ne!(h1, h4); // 不同版本 + assert_ne!(h1, h5); // 不同分辨率 + } + + #[test] + fn test_hash_cone_distinct_from_vizier() { + use crate::services::spectra::common::{LamostRelease, LamostResolution}; + let lamost = hash_cone(10.0, 41.0, 0.1, LamostRelease::Dr10, LamostResolution::Lrs); + let mut vizier_hasher = Sha1::new(); + vizier_hasher.update(b"SELECT 1"); + let vizier = format!("{:x}", vizier_hasher.finalize()); + assert_ne!(lamost, vizier); + } + + #[test] + fn test_decompress_if_gzip_plain() { + // 非 gzip 字节原样返回 + let plain = b"SIMPLE = T"; + let out = decompress_if_gzip(plain).unwrap(); + assert_eq!(out, plain); + } + + #[test] + fn test_decompress_if_gzip_compressed() { + // 构造一个 gzip 流 + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(b"hello fits").unwrap(); + let gz = encoder.finish().unwrap(); + + let out = decompress_if_gzip(&gz).unwrap(); + assert_eq!(out, b"hello fits"); + } + + /// 真实 LAMOST 接口测试 —— 完整下载+解压流程 + #[tokio::test] + #[ignore = "需要网络访问"] + async fn test_live_download_and_decompress() { + use crate::services::spectra::common::{LamostRelease, LamostResolution}; + let client = LamostClient::new("https://www.lamost.org", 60).unwrap(); + let cone = client + .cone_search( + 10.6847, + 41.2687, + 0.1, + LamostRelease::Dr10, + LamostResolution::Lrs, + ) + .await + .unwrap(); + let obsid = cone.rows.first().expect("应有结果").obsid; + + let gz = client + .download_fits(obsid, LamostRelease::Dr10) + .await + .unwrap(); + assert_eq!(&gz[..2], &[0x1f, 0x8b], "应以 gzip magic 开头"); + + let fits = decompress_if_gzip(&gz).unwrap(); + println!("===== LAMOST 解压 ====="); + println!( + " obsid={} gzip={}B → fits={}B", + obsid, + gz.len(), + fits.len() + ); + // FITS 文件头部应包含 "SIMPLE" + assert!(fits.len() > 2000, "解压后 FITS 应有实质内容"); + assert!( + fits.starts_with(b"SIMPLE"), + "解压后应以 FITS magic 'SIMPLE' 开头" + ); + } +} diff --git a/src/services/spectra/mod.rs b/src/services/spectra/mod.rs new file mode 100644 index 0000000..bcf2734 --- /dev/null +++ b/src/services/spectra/mod.rs @@ -0,0 +1,29 @@ +// src/services/spectra/mod.rs +// +// 光谱数据业务服务层 —— 跨数据源统一编排 +// +// 与 clients/{lamost,gaia,sdss,desi}(纯通信)对称:本层负责缓存策略、文件落盘、解压等业务逻辑。 +// 各数据源一个文件: +// lamost.rs — LAMOST 光谱(ConeSearch + FITS.gz 下载解压) +// gaia.rs — Gaia 光谱(TAP cone + DataLink ZIP 下载解包) +// sdss.rs — SDSS 光谱(Data Lab TAP cone + SAS FITS 直接下载) +// desi.rs — DESI 光谱(Data Lab TAP cone + HEALPix coadd FITS 下载) +// common.rs — 跨源统一入口 download_spectrum(双模式:坐标 / 标识符) +// +// 缓存设计: +// - 检索结果(ConeSearch)复用 vizier_query_cache 表,hash 输入加数据源前缀区分 +// - 下载文件(FITS)用 spectrum_cache 表,按 source+source_id 永久去重(光谱不可变) +// +// 外部统一通过 common::download_spectrum + SpectrumRequest 调用,不再有分源工具/端点。 + +pub mod common; +pub mod desi; +pub mod gaia; +pub mod lamost; +pub mod sdss; + +// 统一入口 re-export +pub use common::{ + download_spectrum, DesiRelease, DownloadBatch, DownloadFailure, DownloadResult, FindStrategy, + GaiaRelease, LamostRelease, LamostResolution, SdssRelease, SpectrumRequest, SpectrumSurvey, +}; diff --git a/src/services/spectra/sdss.rs b/src/services/spectra/sdss.rs new file mode 100644 index 0000000..2069e90 --- /dev/null +++ b/src/services/spectra/sdss.rs @@ -0,0 +1,476 @@ +// src/services/spectra/sdss.rs +// +// SDSS 光谱业务服务层 —— 缓存 + SAS FITS 落盘 +// +// 职责: +// 1. cone_search_cached — Data Lab TAP cone search 缓存(复用 vizier_query_cache,"sdss|" 前缀) +// 2. download_spectrum — SAS 下载 FITS(无压缩)→ 落盘 → 写 spectrum_cache +// +// 纯通信层(TAP POST、SAS GET)在 clients::sdss。 +// SDSS SAS 返回未压缩 FITS(区别于 LAMOST 的 gzip、Gaia 的 ZIP),直接落盘即可。 + +use crate::clients::sdss::{SdssClient, SdssConeResult}; +use anyhow::{anyhow, Result}; +use sha1::{Digest, Sha1}; +use sqlx::SqlitePool; +use std::path::Path; +use std::time::Duration; +use tracing::{error, info, warn}; + +// 下载结果与缓存/落盘辅助统一在 super::common; + +const CONE_TTL_SECS: i64 = 7 * 24 * 3600; + +// ═══════════════════════════════════════════════════════════════ +// 1. ConeSearch(带缓存) +// ═══════════════════════════════════════════════════════════════ + +pub async fn cone_search_cached( + pool: &SqlitePool, + client: &SdssClient, + ra: f64, + dec: f64, + radius_deg: f64, + max_records: i64, + release: super::common::SdssRelease, +) -> Result { + validate_cone_params(ra, dec, radius_deg)?; + + let query_hash = hash_cone(ra, dec, radius_deg, max_records, release); + + if let Some(cached) = fetch_cone_cache(pool, &query_hash).await? { + info!("[SDSS] ConeSearch 缓存命中 (hash={:.12})", query_hash); + return Ok(cached); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + let result = client + .cone_search(ra, dec, radius_deg, max_records, release) + .await?; + + if let Err(e) = write_cone_cache(pool, &query_hash, &result).await { + error!("[SDSS] 写入 ConeSearch 缓存失败: {}", e); + } + + Ok(result) +} + +fn validate_cone_params(ra: f64, dec: f64, radius_deg: f64) -> Result<()> { + if !(0.0..=1.0).contains(&radius_deg) { + return Err(anyhow!("SDSS 检索半径建议 0~1 度,当前: {}", radius_deg)); + } + if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) { + return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec)); + } + Ok(()) +} + +fn hash_cone( + ra: f64, + dec: f64, + radius_deg: f64, + max_records: i64, + release: super::common::SdssRelease, +) -> String { + let mut hasher = Sha1::new(); + hasher.update(b"sdss|cone|"); + hasher.update(ra.to_le_bytes()); + hasher.update(dec.to_le_bytes()); + hasher.update(radius_deg.to_le_bytes()); + hasher.update(max_records.to_le_bytes()); + hasher.update(release.sas_dr_segment().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +#[derive(sqlx::FromRow)] +struct ConeCacheRow { + result_json: String, + expires_at: Option>, +} + +async fn fetch_cone_cache(pool: &SqlitePool, query_hash: &str) -> Result> { + let row = sqlx::query_as::<_, ConeCacheRow>( + "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!("查询 SDSS 缓存失败: {}", e))?; + + match row { + Some(r) => { + if let Some(exp) = r.expires_at { + if exp < chrono::Utc::now() { + warn!("[SDSS] ConeSearch 缓存已过期 (hash={:.12})", query_hash); + return Ok(None); + } + } + let result: SdssConeResult = serde_json::from_str(&r.result_json) + .map_err(|e| anyhow!("反序列化缓存失败: {}", e))?; + Ok(Some(result)) + } + None => Ok(None), + } +} + +async fn write_cone_cache( + pool: &SqlitePool, + query_hash: &str, + result: &SdssConeResult, +) -> Result<()> { + let result_json = serde_json::to_string(result)?; + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(CONE_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("sdss:cone") + .bind(result.row_count as i64) + .bind(&result_json) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| anyhow!("写入 SDSS 缓存失败: {}", e))?; + Ok(()) +} + +// ═══════════════════════════════════════════════════════════════ +// 2. SAS 下载(未压缩 FITS,直接落盘) +// ═══════════════════════════════════════════════════════════════ + +#[allow(clippy::too_many_arguments)] +pub async fn download_spectrum( + pool: &SqlitePool, + client: &SdssClient, + library_dir: &Path, + plate: i64, + mjd: i64, + fiberid: i64, + run2d: &str, + release: super::common::SdssRelease, + force: bool, +) -> Result { + use crate::services::spectra::common::{ + cached_file_size, fetch_spectrum_cache, file_url_from_path, persist_bytes, + write_spectrum_cache, SpectrumSurvey, + }; + + if plate <= 0 || mjd <= 0 || fiberid <= 0 { + return Err(anyhow!( + "plate/mjd/fiberid 应为正整数 (plate={}, mjd={}, fiber={})", + plate, + mjd, + fiberid + )); + } + if run2d.trim().is_empty() { + return Err(anyhow!("run2d 不能为空")); + } + let cache_key = format!( + "{}|{}-{}-{}-{}", + release.sas_dr_segment(), + run2d, + plate, + mjd, + fiberid + ); + let label = format!("plate {} mjd {} fiber {}", plate, mjd, fiberid); + let dr = release.sas_dr_segment(); + + // 1) 缓存命中检查 + if !force { + if let Some(cached) = fetch_spectrum_cache(pool, "sdss", &cache_key).await? { + if let Some(size) = cached_file_size(library_dir, &cached.file_path) { + info!("[SDSS] FITS 缓存命中 ({})", cache_key); + let file_url = file_url_from_path(&cached.file_path); + let source_meta = cached + .meta_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + return Ok(super::common::DownloadResult { + survey: SpectrumSurvey::Sdss, + source_id: cache_key.clone(), + source_label: label.clone(), + file_path: cached.file_path, + file_url, + file_format: cached.file_format, + size_bytes: size, + cached: true, + source_meta, + }); + } + warn!("[SDSS] 缓存记录存在但文件缺失,重新下载 ({})", cache_key); + } + } + + // 2) SAS 下载(未压缩 FITS)+ 落盘 + 写缓存 + tokio::time::sleep(Duration::from_millis(50)).await; + let fits_bytes = client + .download_fits(plate, mjd, fiberid, run2d, release) + .await?; + let rel_path = format!( + "Telescope/sdss/{dr}/spec/{run2d}-{plate}-{mjd}-{fiber:04}.fits", + dr = dr, + run2d = run2d, + plate = plate, + mjd = mjd, + fiber = fiberid + ); + let size = persist_bytes(library_dir, &rel_path, &fits_bytes)?; + info!( + "[SDSS] FITS 已保存 {} ({}B) → {}", + cache_key, size, rel_path + ); + let meta = serde_json::json!({"plate": plate, "mjd": mjd, "fiberid": fiberid, "run2d": run2d, "dr": dr}); + if let Err(e) = write_spectrum_cache( + pool, + "sdss", + &cache_key, + None, + None, + &rel_path, + Some(&meta.to_string()), + ) + .await + { + error!("[SDSS] 写入 spectrum_cache 失败: {}", e); + } + + let file_url = file_url_from_path(&rel_path); + Ok(super::common::DownloadResult { + survey: SpectrumSurvey::Sdss, + source_id: cache_key, + source_label: label, + file_path: rel_path, + file_url, + file_format: "fits".to_string(), + size_bytes: size, + cached: false, + source_meta: Some(meta), + }) +} + +// ═══════════════════════════════════════════════════════════════ +// 3. APOGEE 近红外光谱(apStar / aspcapStar) +// ═══════════════════════════════════════════════════════════════ + +/// APOGEE ConeSearch(带缓存) +pub async fn apogee_cone_search_cached( + pool: &SqlitePool, + client: &SdssClient, + ra: f64, + dec: f64, + radius_deg: f64, + release: super::common::SdssRelease, +) -> Result { + validate_cone_params(ra, dec, radius_deg)?; + + let query_hash = hash_apogee_cone(ra, dec, radius_deg, release); + + if let Some(cached) = fetch_apogee_cone_cache(pool, &query_hash).await? { + info!("[APOGEE] ConeSearch 缓存命中 (hash={:.12})", query_hash); + return Ok(cached); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + let result = client + .apogee_cone_search(ra, dec, radius_deg, 50, release) + .await?; + + if let Err(e) = write_apogee_cone_cache(pool, &query_hash, &result).await { + error!("[APOGEE] 写入 ConeSearch 缓存失败: {}", e); + } + + Ok(result) +} + +fn hash_apogee_cone( + ra: f64, + dec: f64, + radius_deg: f64, + release: crate::services::spectra::common::SdssRelease, +) -> String { + let mut hasher = Sha1::new(); + hasher.update(b"sdss|apogee|cone|"); + hasher.update(ra.to_le_bytes()); + hasher.update(dec.to_le_bytes()); + hasher.update(radius_deg.to_le_bytes()); + hasher.update(release.sas_dr_segment().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +async fn fetch_apogee_cone_cache( + pool: &SqlitePool, + query_hash: &str, +) -> Result> { + let row = sqlx::query_as::<_, ConeCacheRow>( + "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!("查询 APOGEE 缓存失败: {}", e))?; + + match row { + Some(r) => { + if let Some(exp) = r.expires_at { + if exp < chrono::Utc::now() { + warn!("[APOGEE] ConeSearch 缓存已过期 (hash={:.12})", query_hash); + return Ok(None); + } + } + let result: crate::clients::sdss::ApogeeConeResult = + serde_json::from_str(&r.result_json) + .map_err(|e| anyhow!("反序列化缓存失败: {}", e))?; + Ok(Some(result)) + } + None => Ok(None), + } +} + +async fn write_apogee_cone_cache( + pool: &SqlitePool, + query_hash: &str, + result: &crate::clients::sdss::ApogeeConeResult, +) -> Result<()> { + let result_json = serde_json::to_string(result)?; + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(CONE_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("sdss:apogee:cone") + .bind(result.row_count as i64) + .bind(&result_json) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| anyhow!("写入 APOGEE 缓存失败: {}", e))?; + Ok(()) +} + +/// 下载并缓存一条 APOGEE 光谱 FITS(apStar 或 aspcapStar) +#[allow(clippy::too_many_arguments)] +pub async fn download_apogee_spectrum( + pool: &SqlitePool, + client: &SdssClient, + library_dir: &Path, + apogee_id: &str, + telescope: &str, + field: &str, + release: super::common::SdssRelease, + data_type: &str, // "apstar" | "aspcap" + force: bool, +) -> Result { + use crate::services::spectra::common::{ + cached_file_size, fetch_spectrum_cache, file_url_from_path, persist_bytes, + write_spectrum_cache, SpectrumSurvey, + }; + + if apogee_id.trim().is_empty() || telescope.trim().is_empty() || field.trim().is_empty() { + return Err(anyhow!("apogee_id/telescope/field 不能为空")); + } + let dt = match data_type { + "aspcap" => "aspcap", + _ => "apstar", + }; + let dr = release.sas_dr_segment(); + let cache_key = format!("{}|{}|{}|{}|{}", dr, dt, telescope, field, apogee_id); + let label = format!("{} {} {}", telescope, dt, apogee_id); + + // 1) 缓存命中检查 + if !force { + if let Some(cached) = fetch_spectrum_cache(pool, "sdss", &cache_key).await? { + if let Some(size) = cached_file_size(library_dir, &cached.file_path) { + info!("[APOGEE] 缓存命中 ({})", cache_key); + let file_url = file_url_from_path(&cached.file_path); + let source_meta = cached + .meta_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + return Ok(super::common::DownloadResult { + survey: SpectrumSurvey::Sdss, + source_id: cache_key.clone(), + source_label: label.clone(), + file_path: cached.file_path, + file_url, + file_format: cached.file_format, + size_bytes: size, + cached: true, + source_meta, + }); + } + warn!("[APOGEE] 缓存记录存在但文件缺失,重新下载 ({})", cache_key); + } + } + + // 2) 下载 FITS(无压缩) + tokio::time::sleep(Duration::from_millis(50)).await; + let fits_bytes = client + .download_apogee_fits(apogee_id, telescope, field, release, dt) + .await?; + + // 3) 落盘:Telescope/sdss/{dr}/{dt}/{apogee_id}.fits + // apogee_id 含 +,文件名中替换为 _ 避免 URL/路径问题 + let safe_id = apogee_id.replace('+', "_").replace('/', "_"); + let rel_path = format!( + "Telescope/sdss/{dr}/{dt}/{safe_id}.fits", + dr = dr, + dt = dt, + safe_id = safe_id + ); + let size = persist_bytes(library_dir, &rel_path, &fits_bytes)?; + info!("[APOGEE] 已保存 {} ({}B) → {}", cache_key, size, rel_path); + + let meta = serde_json::json!({ + "apogee_id": apogee_id, "telescope": telescope, "field": field, + "data_type": dt, "dr": dr + }); + if let Err(e) = write_spectrum_cache( + pool, + "sdss", + &cache_key, + None, + None, + &rel_path, + Some(&meta.to_string()), + ) + .await + { + error!("[APOGEE] 写入 spectrum_cache 失败: {}", e); + } + + let file_url = file_url_from_path(&rel_path); + Ok(super::common::DownloadResult { + survey: SpectrumSurvey::Sdss, + source_id: cache_key, + source_label: label, + file_path: rel_path, + file_url, + file_format: "fits".to_string(), + size_bytes: size, + cached: false, + source_meta: Some(meta), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_cone_params() { + assert!(validate_cone_params(180.0, 30.0, 0.1).is_ok()); + assert!(validate_cone_params(180.0, 30.0, 2.0).is_err()); + } + + #[test] + fn test_hash_cone_stable() { + use crate::services::spectra::common::SdssRelease; + let h1 = hash_cone(180.0, 30.0, 0.1, 5, SdssRelease::Dr17); + let h2 = hash_cone(180.0, 30.0, 0.1, 5, SdssRelease::Dr17); + let h3 = hash_cone(180.0, 30.0, 0.2, 5, SdssRelease::Dr17); + assert_eq!(h1, h2); + assert_ne!(h1, h3); + } +}