refactor: 观测层双轴正交重构——spectra→observation、工具/API 收敛、安全韧性加固

将"以光谱为中心"的观测数据架构升级为 (Source × ProductType) 双轴正交模型,
光谱降级为与光变/测光/图像平级的产品类型之一;同步把分散的工具、API、缓存表
收敛为统一入口。新增 Gaia 光变曲线(EPOCH_PHOTOMETRY)支持。

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

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

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

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

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

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

1
Cargo.lock generated
View File

@ -158,6 +158,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"sha1 0.10.6",
"sha2 0.10.9",
"sqlite-vec",
"sqlx",
"subtle",

View File

@ -19,7 +19,7 @@ path = "src/bin/health_check.rs"
name = "astroresearch_cli"
path = "src/bin/cli.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "signal", "time", "fs", "net", "sync", "process"] }
axum = { version = "0.7", features = ["macros", "multipart"] }
tower-http = { version = "0.5", features = ["cors", "fs", "trace", "set-header"] }
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "json"] }
@ -37,6 +37,7 @@ rand = "0.8"
regex = "1.10"
chrono = { version = "0.4", features = ["serde"] }
sha1 = "0.10"
sha2 = "0.10"
hmac = "0.12"
subtle = "2" # 恒定时间密码比较,防时序侧信道攻击
base64 = "0.22"

View File

@ -9,6 +9,7 @@ import { ReaderPanel } from './pages/ReaderPanel';
import { CitationPanel } from './pages/CitationPanel';
import { SyncPanel } from './pages/SyncPanel';
import { ResearchAgentPanel } from './pages/ResearchAgentPanel';
import { ObservationPanel } from './pages/ObservationPanel';
import type { StandardPaper, NoteRecord } from './types';
import { GlobalDialog } from './components/dialogs/GlobalDialog';
import { UncachedPaperModal } from './components/dialogs/UncachedPaperModal';
@ -22,6 +23,7 @@ import { useLibrary } from './hooks/useLibrary';
import { useSearch } from './hooks/useSearch';
import { useNotes } from './hooks/useNotes';
import { useCitations } from './hooks/useCitations';
import { useObservation } from './hooks/useObservation';
export default function App() {
// 移动端菜单显示状态
@ -59,7 +61,7 @@ export default function App() {
// 2. 局部状态定义 (多组件共用或全局网络进度状态)
const [activeTab, setActiveTab] = useState<
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'observation' | 'agent'
>(() => {
const saved = localStorage.getItem('astro_active_tab');
const validTabs = [
@ -68,6 +70,7 @@ export default function App() {
'reader',
'citation',
'sync',
'observation',
'agent',
] as const;
return (
@ -90,6 +93,9 @@ export default function App() {
const auth = useAuth();
const notes = useNotes();
const citations = useCitations();
const observation = useObservation({
isAuthenticated: auth.isAuthenticated,
});
// 协调:进入阅读器方法 (需要拉取正文和笔记)
const libraryRef = useRef<ReturnType<typeof useLibrary> | null>(null);
@ -451,6 +457,7 @@ export default function App() {
{activeTab === 'reader' && '双语阅读'}
{activeTab === 'citation' && '引用星系'}
{activeTab === 'sync' && '批量任务'}
{activeTab === 'observation' && '观测数据'}
{activeTab === 'agent' && '智能科研'}
</span>
</header>
@ -649,6 +656,48 @@ export default function App() {
{activeTab === 'sync' && <SyncPanel />}
{activeTab === 'observation' && (
<ObservationPanel
capabilities={observation.capabilities}
currentSpec={observation.currentSpec}
searchForm={observation.searchForm}
setSearchForm={observation.setSearchForm}
searchResults={observation.searchResults}
searching={observation.searching}
searchError={observation.searchError}
runSearch={observation.runSearch}
selectedSourceIds={observation.selectedSourceIds}
toggleSelect={observation.toggleSelect}
selectAll={observation.selectAll}
selectNone={observation.selectNone}
downloading={observation.downloading}
downloadResult={observation.downloadResult}
downloadError={observation.downloadError}
setDownloadResult={observation.setDownloadResult}
downloadSelected={observation.downloadSelected}
downloadByIds={observation.downloadByIds}
downloadByCoordinates={observation.downloadByCoordinates}
libraryItems={observation.libraryItems}
libraryTotal={observation.libraryTotal}
libraryLoading={observation.libraryLoading}
libraryError={observation.libraryError}
fetchLibrary={observation.fetchLibrary}
libSource={observation.libSource}
setLibSource={observation.setLibSource}
libProduct={observation.libProduct}
setLibProduct={observation.setLibProduct}
libSearch={observation.libSearch}
setLibSearch={observation.setLibSearch}
libSort={observation.libSort}
setLibSort={observation.setLibSort}
libPage={observation.libPage}
setLibPage={observation.setLibPage}
libPageSize={observation.libPageSize}
setLibPageSize={observation.setLibPageSize}
resetLibraryFilters={observation.resetLibraryFilters}
/>
)}
{activeTab === 'agent' && (
<ResearchAgentPanel
showConfirm={showConfirm}

View File

@ -30,6 +30,7 @@ import type {
import type { StandardPaper } from '../../types';
import type { useCitations } from '../../hooks/useCitations';
import type { useLibrary } from '../../hooks/useLibrary';
import type { TabId } from '../layout/Sidebar';
import { AskUserQuestionCard } from './AskUserQuestionCard';
import { PermissionRequestCard } from './PermissionRequestCard';
import { AgentMetricsPanel } from './AgentMetricsPanel';
@ -94,9 +95,7 @@ interface AgentMessageListProps {
onSuccess?: () => void
) => Promise<void>;
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
setActiveTab?: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab?: (tab: TabId) => void;
citations?: ReturnType<typeof useCitations>;
library?: ReturnType<typeof useLibrary>;
}

View File

@ -33,7 +33,7 @@ const TOOL_LABELS: Record<string, string> = {
query_target: '天体查询',
query_vizier: 'VizieR 星表',
cone_search: '锥形检索',
find_spectrum: '光谱检索下载',
find_observation: '观测数据下载',
catalog_operation: '星表操作',
save_note: '保存笔记',
todo_write: '任务管理',
@ -69,7 +69,7 @@ const CATEGORY_COLORS: Record<string, string> = {
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',
find_observation: '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',

View File

@ -36,7 +36,7 @@ function getToolDisplayName(name: string | null): string {
query_target: '天体查询',
query_vizier: 'VizieR 星表',
cone_search: '锥形检索',
find_spectrum: '光谱检索下载',
find_observation: '观测数据下载',
catalog_operation: '星表操作',
save_note: '保存笔记',
todo_write: '任务管理',

View File

@ -17,6 +17,7 @@ import {
import type { StandardPaper } from '../../types';
import type { useCitations } from '../../hooks/useCitations';
import type { useLibrary } from '../../hooks/useLibrary';
import type { TabId } from '../layout/Sidebar';
// ==========================================
// 1. 天体参数卡片 (query_target)
@ -204,9 +205,7 @@ interface PaperListCardProps {
};
library?: ReturnType<typeof useLibrary>;
citations?: ReturnType<typeof useCitations>;
setActiveTab?: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab?: (tab: TabId) => void;
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
}
@ -747,124 +746,20 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
// ==========================================
// 6. 统一光谱下载结果卡片 (find_spectrum)
// 6. 统一观测数据下载结果卡片 (find_observation)
// 跨 (LAMOST/Gaia/SDSS/DESI) × (Spectrum/LightCurve/Photometry/Image)
// 支持多 artifact 产物(如 Gaia 光变 G/BP/RP 三波段各一个文件)
//
// 实现已抽出到共享组件 ObservationResultCard本函数仅作为 Agent 工具结果
// 渲染入口metadata 形状与 ObservationBatchResult 完全一致)。
// ==========================================
interface SpectrumDownloadItem {
survey: string;
source_label: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
import { ObservationResultCard } from '../observation/ObservationResultCard';
import type { ObservationBatchResult } from '../observation/constants';
interface FindObservationCardProps {
metadata: ObservationBatchResult;
}
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<string, { badge: string; label: string }> = {
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 (
<div className="bg-slate-50/60 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
{/* 标题栏 */}
<div className="flex items-center justify-between border-b border-slate-200/70 pb-2">
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide">
<Activity className="w-3.5 h-3.5 text-slate-500" />
<span></span>
</div>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}>
{theme.label}
</span>
</div>
{/* 查询信息 */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-600">
{ra !== undefined && dec !== undefined && (
<span className="font-mono">
ra={ra.toFixed(4)} dec={dec.toFixed(4)}
{radius_deg !== undefined ? ` r=${radius_deg}°` : ''}
</span>
)}
<span className="bg-slate-200 text-slate-700 px-1.5 py-0.5 rounded font-medium">
{matched_count}
</span>
{downloads.length > 0 && (
<span className="flex items-center gap-0.5 text-emerald-600 font-medium">
<CheckCircle2 className="w-3 h-3" />
{downloads.length}
</span>
)}
{failures.length > 0 && (
<span className="flex items-center gap-0.5 text-rose-600 font-medium">
<AlertTriangle className="w-3 h-3" />
{failures.length}
</span>
)}
</div>
{/* 下载结果列表 */}
{downloads.map((d, i) => (
<div key={i} className="border border-slate-200 rounded bg-white p-2.5 space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-mono font-medium text-slate-700">{d.source_label}</span>
{d.cached ? (
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600">
<CheckCircle2 className="w-3 h-3" />
</span>
) : (
<span className="text-[10px] text-slate-400"></span>
)}
</div>
<div className="flex items-center justify-between text-[10px]">
<span className="text-slate-500">
{d.file_format.toUpperCase()} · {d.size_bytes > 1024 ? `${(d.size_bytes / 1024).toFixed(1)} KB` : `${d.size_bytes} B`}
</span>
<a
href={d.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
>
<Download className="w-3 h-3" />
<span>FITS</span>
</a>
</div>
</div>
))}
{/* 失败列表 */}
{failures.map((f, i) => (
<div key={`f${i}`} className="border border-rose-200 rounded bg-rose-50/50 p-2.5">
<div className="flex items-center gap-1 text-rose-700 font-medium">
<AlertTriangle className="w-3 h-3" />
<span className="font-mono text-[10px]">{f.source_label}</span>
</div>
<p className="text-[10px] text-rose-600 mt-1 break-all">{f.error}</p>
</div>
))}
{!hasResult && (
<p className="text-[11px] text-slate-400 italic py-2">()</p>
)}
</div>
);
export function FindObservationCard({ metadata }: FindObservationCardProps) {
return <ObservationResultCard result={metadata} />;
}

View File

@ -11,11 +11,12 @@ import {
TodoTaskCard,
BgTaskProgressCard,
VizierResultCard,
FindSpectrumCard,
FindObservationCard,
} from './SpecialToolRenderers';
import type { StandardPaper } from '../../types';
import type { useCitations } from '../../hooks/useCitations';
import type { useLibrary } from '../../hooks/useLibrary';
import type { TabId } from '../layout/Sidebar';
interface ToolCallCardProps {
step: number;
@ -33,9 +34,7 @@ interface ToolCallCardProps {
onToggleArgs: () => void;
onToggleResult: () => void;
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
setActiveTab?: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab?: (tab: TabId) => void;
citations?: ReturnType<typeof useCitations>;
library?: ReturnType<typeof useLibrary>;
}
@ -119,31 +118,36 @@ export function ToolCallCard({
);
}
// 1c. 统一光谱下载find_spectrum
// 1c. 统一观测数据下载find_observation
if (
name === 'find_spectrum' &&
name === 'find_observation' &&
metadata &&
typeof metadata === 'object' &&
'survey' in metadata &&
'downloads' in metadata
'source' in metadata &&
'products' in metadata
) {
return (
<FindSpectrumCard
<FindObservationCard
metadata={
metadata as {
survey: string;
source: string;
product: { product: string; subtype?: string };
ra?: number;
dec?: number;
radius_deg?: number;
matched_count: number;
downloads: {
survey: string;
products: {
source: string;
source_id: string;
source_label: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
artifacts: {
band?: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
}[];
}[];
failures: { source_label: string; error: string }[];
}

View File

@ -35,8 +35,8 @@ export function getToolDisplayName(name: string): string {
return '查询 VizieR 星表';
case 'cone_search':
return '锥形检索天体';
case 'find_spectrum':
return '检索下载光谱';
case 'find_observation':
return '下载观测数据';
case 'catalog_operation':
return '星表操作';
case 'save_note':

View File

@ -3,6 +3,7 @@ import { Loader, Download, RefreshCw, AlertTriangle } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../../utils/paper';
import { BaseModal } from './BaseModal';
import type { TabId } from '../layout/Sidebar';
interface PaperDetailModalProps {
paper: StandardPaper | null | undefined;
@ -17,9 +18,7 @@ interface PaperDetailModalProps {
handleMarkNoResource: (bibcode: string, clear: boolean) => Promise<void>;
handleDownload: (bibcode: string, force?: boolean) => Promise<void>;
openReader: (paper: StandardPaper) => void;
setActiveTab: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab: (tab: TabId) => void;
setSelectedPaper: (paper: StandardPaper) => void;
loadCitations: (bibcode: string) => Promise<void>;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;

View File

@ -9,12 +9,19 @@ import {
ChevronLeft,
Sparkles,
LogOut,
Telescope,
} from 'lucide-react';
import type { StandardPaper } from '../../types';
import { Logo } from '../Logo';
export type TabId =
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
| 'search'
| 'library'
| 'reader'
| 'citation'
| 'sync'
| 'observation'
| 'agent';
interface SidebarProps {
activeTab: TabId;
@ -127,6 +134,11 @@ export function Sidebar({
{ id: 'reader' as TabId, label: '双语阅读', icon: BookOpen },
{ id: 'citation' as TabId, label: '引用星系', icon: GitFork },
{ id: 'sync' as TabId, label: '批量任务', icon: RefreshCw },
{
id: 'observation' as TabId,
label: '观测数据',
icon: Telescope,
},
{ id: 'agent' as TabId, label: '智能科研', icon: Sparkles },
].map((tab) => {
const Icon = tab.icon;

View File

@ -0,0 +1,161 @@
// dashboard/src/components/observation/ObservationResultCard.tsx
//
// 观测数据下载结果卡片 —— 可复用渲染 ObservationBatch
//
// 用途:
// 1. 观测数据面板的"检索下载"视图在下载完成后展示结果
// 2. 可选SpecialToolRenderers 的 FindObservationCard 可改为薄封装调用本组件
//
// 视觉与 SpecialToolRenderers::FindObservationCard 保持一致,统一数据源主题色。
import {
Activity,
CheckCircle2,
AlertTriangle,
Download,
} from 'lucide-react';
import {
SOURCE_THEME,
PRODUCT_LABEL,
formatFileSize,
type ObservationBatchResult,
} from './constants';
interface ObservationResultCardProps {
result: ObservationBatchResult;
}
export function ObservationResultCard({ result }: ObservationResultCardProps) {
const {
source,
product,
ra,
dec,
radius_deg,
matched_count,
products = [],
failures = [],
} = result;
const theme = SOURCE_THEME[source] ?? SOURCE_THEME.lamost;
const productLabel = PRODUCT_LABEL[product.product] ?? product.product;
const hasResult = products.length > 0 || failures.length > 0;
return (
<div className="bg-slate-50/60 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
{/* 标题栏:数据源 + 产品类型双标签 */}
<div className="flex items-center justify-between border-b border-slate-200/70 pb-2">
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide">
<Activity className="w-3.5 h-3.5 text-slate-500" />
<span></span>
</div>
<div className="flex items-center gap-1">
<span
className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}
>
{theme.label}
</span>
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-200 text-slate-700">
{productLabel}
{product.subtype ? ` · ${product.subtype}` : ''}
</span>
</div>
</div>
{/* 查询信息 */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-600">
{ra !== undefined && dec !== undefined && (
<span className="font-mono">
ra={ra.toFixed(4)} dec={dec.toFixed(4)}
{radius_deg !== undefined ? ` r=${radius_deg}°` : ''}
</span>
)}
<span className="bg-slate-200 text-slate-700 px-1.5 py-0.5 rounded font-medium">
{matched_count}
</span>
{products.length > 0 && (
<span className="flex items-center gap-0.5 text-emerald-600 font-medium">
<CheckCircle2 className="w-3 h-3" />
{products.length}
</span>
)}
{failures.length > 0 && (
<span className="flex items-center gap-0.5 text-rose-600 font-medium">
<AlertTriangle className="w-3 h-3" />
{failures.length}
</span>
)}
</div>
{/* 产物列表(每个 product 含 1~N 个 artifact */}
{products.map((p, i) => {
const allCached =
p.artifacts.length > 0 && p.artifacts.every((a) => a.cached);
return (
<div
key={i}
className="border border-slate-200 rounded bg-white p-2.5 space-y-1.5"
>
<div className="flex items-center justify-between">
<span className="font-mono font-medium text-slate-700">
{p.source_label}
</span>
{allCached ? (
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600">
<CheckCircle2 className="w-3 h-3" />
</span>
) : (
<span className="text-[10px] text-slate-400"></span>
)}
</div>
{/* 多 artifact 展示Gaia 光变 G/BP/RP 三波段) */}
{p.artifacts.map((a, ai) => (
<div
key={ai}
className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100"
>
<span className="text-slate-500 flex items-center gap-1">
{a.band && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium">
{a.band}
</span>
)}
{a.file_format.toUpperCase()} ·{' '}
{formatFileSize(a.size_bytes)}
</span>
<a
href={a.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
>
<Download className="w-3 h-3" />
<span>{a.file_format.toUpperCase()}</span>
</a>
</div>
))}
</div>
);
})}
{/* 失败列表 */}
{failures.map((f, i) => (
<div
key={`f${i}`}
className="border border-rose-200 rounded bg-rose-50/50 p-2.5"
>
<div className="flex items-center gap-1 text-rose-700 font-medium">
<AlertTriangle className="w-3 h-3" />
<span className="font-mono text-[10px]">{f.source_label}</span>
</div>
<p className="text-[10px] text-rose-600 mt-1 break-all">{f.error}</p>
</div>
))}
{!hasResult && (
<p className="text-[11px] text-slate-400 italic py-2">
({productLabel})
</p>
)}
</div>
);
}

View File

@ -0,0 +1,85 @@
// dashboard/src/components/observation/constants.ts
//
// 观测数据共享常量与类型 —— 跨 Agent 工具卡片 / 观测数据面板 / 检索结果卡片复用
// 统一 (Source × ProductType) 双轴的视觉主题与中文标签映射
// ── 数据源主题色source 轴)──
// 与 SOURCE_THEME 在 SpecialToolRenderers.tsx 的取色保持一致,统一视觉
export const SOURCE_THEME: Record<
string,
{ badge: string; label: string; dot: string }
> = {
lamost: {
badge: 'bg-amber-100 text-amber-700',
label: 'LAMOST',
dot: 'bg-amber-500',
},
gaia: {
badge: 'bg-sky-100 text-sky-700',
label: 'Gaia',
dot: 'bg-sky-500',
},
sdss: {
badge: 'bg-indigo-100 text-indigo-700',
label: 'SDSS',
dot: 'bg-indigo-500',
},
desi: {
badge: 'bg-emerald-100 text-emerald-700',
label: 'DESI',
dot: 'bg-emerald-500',
},
};
// ── 产品类型中文标签product 轴)──
export const PRODUCT_LABEL: Record<string, string> = {
spectrum: '光谱',
lightcurve: '光变曲线',
photometry: '测光',
image: '图像',
};
// ── 文件大小格式化 ——
export function formatFileSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—';
if (bytes > 1024 * 1024 * 1024)
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
if (bytes > 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes > 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
// ── 共享 TS 接口(镜像 Rust services::observation::types──
export interface ObservationArtifact {
band?: string;
original_name?: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
}
export interface ObservationProductItem {
source: string;
source_id: string;
source_label: string;
artifacts: ObservationArtifact[];
source_meta?: Record<string, unknown>;
}
export interface DownloadFailure {
source_label: string;
error: string;
}
export interface ObservationBatchResult {
source: string;
product: { product: string; subtype?: string };
ra?: number;
dec?: number;
radius_deg?: number;
matched_count: number;
products: ObservationProductItem[];
failures: DownloadFailure[];
}

View File

@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import type { StandardPaper } from '../types';
import type { TabId } from '../components/layout/Sidebar';
import { extractErrorMessage } from '../utils/apiError';
interface UseLibraryProps {
isAuthenticated: boolean | null;
@ -260,9 +261,7 @@ export function useLibrary({
showAlert('手动文献文件上传导入成功!', '上传成功');
} catch (e: unknown) {
console.error('手动文件上传失败', e);
const axiosError = e as { response?: { data?: string } };
const errMsg =
axiosError.response?.data || '请确保上传的是合法且完整的文件。';
const errMsg = extractErrorMessage(e, '请确保上传的是合法且完整的文件。');
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
} finally {
setUploadingBibcode(null);
@ -305,8 +304,7 @@ export function useLibrary({
);
} catch (e: unknown) {
console.error('标记更新失败', e);
const axiosError = e as { response?: { data?: string } };
const errMsg = axiosError.response?.data || '请稍后重试。';
const errMsg = extractErrorMessage(e, '请稍后重试。');
showAlert(`标记失败: ${errMsg}`, '操作出错');
}
};

View File

@ -0,0 +1,370 @@
// dashboard/src/hooks/useObservation.ts
//
// 观测数据 Hook —— 检索 / 下载 / 缓存库浏览 / 能力清单 四块状态
//
// 后端端点(检索逻辑全部在后端):
// GET /api/observation/capabilities —— 返回支持的 (source × product × subtypes) 组合
// GET /api/observation/search —— 按坐标 cone 检索候选源(不下载)
// POST /api/observation/download —— 按坐标或标识符下载(写入 observation_cache
// GET /api/observation/list —— 列出已缓存条目(服务端分页 + 筛选)
//
// 前端只负责表单提交和结果渲染,不做任何筛选/分页/排序计算。
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import axios from 'axios';
import { extractErrorMessage } from '../utils/apiError';
import type {
ObservationRecord,
Candidate,
CapabilitySpec,
} from '../types';
import type { ObservationBatchResult } from '../components/observation/constants';
interface UseObservationProps {
isAuthenticated: boolean | null;
}
// ── 检索表单状态 ──
export interface SearchForm {
source: string;
product: string;
subtype: string;
ra: string;
dec: string;
radius: string;
release: string;
}
export const EMPTY_SEARCH_FORM: SearchForm = {
source: 'lamost',
product: 'spectrum',
subtype: '',
ra: '',
dec: '',
radius: '0.1',
release: '',
};
export function useObservation({ isAuthenticated }: UseObservationProps) {
// ════════════════════════════════════════════════════
// A. 能力清单(启动时加载一次)
// ════════════════════════════════════════════════════
const [capabilities, setCapabilities] = useState<CapabilitySpec[]>([]);
const capabilitiesLoadedRef = useRef(false);
const fetchCapabilities = useCallback(async () => {
if (capabilitiesLoadedRef.current) return;
try {
const res = await axios.get<CapabilitySpec[]>(
'/api/observation/capabilities'
);
setCapabilities(res.data ?? []);
capabilitiesLoadedRef.current = true;
} catch (e) {
console.error('加载观测数据能力清单失败', e);
}
}, []);
useEffect(() => {
if (isAuthenticated === true) fetchCapabilities();
}, [isAuthenticated, fetchCapabilities]);
// ════════════════════════════════════════════════════
// B. 检索search—— 坐标 cone 检索候选源
// ════════════════════════════════════════════════════
const [searchForm, setSearchForm] = useState<SearchForm>(EMPTY_SEARCH_FORM);
// 当前 (source, product) 对应的能力描述(供前端表单派生选项与联动校验)
const currentSpec = useMemo<CapabilitySpec | undefined>(() => {
return capabilities.find(
(c) => c.source === searchForm.source && c.product === searchForm.product
);
}, [capabilities, searchForm.source, searchForm.product]);
// 当 source/product 切换后:
// 1. 若当前 release 不在新源的版本列表里,则回落到默认版本
// 2. 若当前 radius 超出硬性上限,则回落到建议上限(而非硬上限,避免误导)
useEffect(() => {
if (!currentSpec) return;
const valid = currentSpec.releases;
const hardMax = currentSpec.hard_max_radius_deg;
const suggested = currentSpec.suggested_max_radius_deg;
setSearchForm((f) => {
let next = f;
// release 回落(仅在该源区分版本时)
if (valid.length > 0 && (!f.release || !valid.includes(f.release))) {
next = { ...next, release: currentSpec.default_release ?? valid[0] };
}
// radius 回落:超出硬性上限时回到建议值(不回到硬上限,避免默认就触发警告)
const cur = parseFloat(f.radius);
if (!isNaN(cur) && cur > hardMax) {
next = { ...next, radius: String(suggested) };
}
return next;
});
}, [currentSpec]);
const [searchResults, setSearchResults] = useState<Candidate[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [selectedSourceIds, setSelectedSourceIds] = useState<Set<string>>(
new Set()
);
const runSearch = useCallback(async () => {
const ra = parseFloat(searchForm.ra);
const dec = parseFloat(searchForm.dec);
if (isNaN(ra) || isNaN(dec)) {
setSearchError('请输入有效的 ra / dec 坐标');
setSearchResults([]);
return;
}
setSearching(true);
setSearchError(null);
setSelectedSourceIds(new Set());
try {
const res = await axios.get<Candidate[]>('/api/observation/search', {
params: {
source: searchForm.source,
product: searchForm.product,
subtype: searchForm.subtype || undefined,
ra,
dec,
radius: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1),
release: searchForm.release || undefined,
},
});
setSearchResults(res.data ?? []);
} catch (e: unknown) {
setSearchError(
extractErrorMessage(e, '检索失败,请检查坐标与数据源参数')
);
setSearchResults([]);
} finally {
setSearching(false);
}
}, [searchForm]);
const toggleSelect = useCallback((sourceId: string) => {
setSelectedSourceIds((prev) => {
const next = new Set(prev);
if (next.has(sourceId)) next.delete(sourceId);
else next.add(sourceId);
return next;
});
}, []);
const selectAll = useCallback(() => {
setSelectedSourceIds(new Set(searchResults.map((c) => c.source_id)));
}, [searchResults]);
const selectNone = useCallback(() => {
setSelectedSourceIds(new Set());
}, []);
// ════════════════════════════════════════════════════
// C. 下载download—— 按坐标或标识符
// ════════════════════════════════════════════════════
const [downloading, setDownloading] = useState(false);
const [downloadResult, setDownloadResult] =
useState<ObservationBatchResult | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
/** 下载选中的候选源(标识符模式) */
const downloadSelected = useCallback(async () => {
const ids = Array.from(selectedSourceIds);
if (ids.length === 0) return;
await downloadByIds(ids);
}, [selectedSourceIds]); // eslint-disable-line react-hooks/exhaustive-deps
/** 按标识符列表下载 */
const downloadByIds = useCallback(
async (sourceIds: string[]) => {
if (sourceIds.length === 0) return;
setDownloading(true);
setDownloadError(null);
try {
const res = await axios.post<ObservationBatchResult>(
'/api/observation/download',
{
source: searchForm.source,
product: searchForm.product,
subtype: searchForm.subtype || undefined,
release: searchForm.release || undefined,
force: false,
mode: 'identifiers',
source_ids: sourceIds,
}
);
setDownloadResult(res.data);
} catch (e: unknown) {
setDownloadError(extractErrorMessage(e, '下载失败,请稍后重试'));
} finally {
setDownloading(false);
}
},
[searchForm]
);
/** 按坐标下载cone 检索 + 全部下载) */
const downloadByCoordinates = useCallback(
async (strategy: 'nearest' | 'all' = 'nearest') => {
const ra = parseFloat(searchForm.ra);
const dec = parseFloat(searchForm.dec);
if (isNaN(ra) || isNaN(dec)) {
setDownloadError('请输入有效的 ra / dec 坐标');
return;
}
setDownloading(true);
setDownloadError(null);
try {
const res = await axios.post<ObservationBatchResult>(
'/api/observation/download',
{
source: searchForm.source,
product: searchForm.product,
subtype: searchForm.subtype || undefined,
release: searchForm.release || undefined,
force: false,
mode: 'coordinates',
ra,
dec,
radius_deg: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1),
strategy,
}
);
setDownloadResult(res.data);
} catch (e: unknown) {
setDownloadError(extractErrorMessage(e, '下载失败,请稍后重试'));
} finally {
setDownloading(false);
}
},
[searchForm]
);
// ════════════════════════════════════════════════════
// D. 缓存库library—— 服务端分页浏览已下载数据
// ════════════════════════════════════════════════════
const [libraryItems, setLibraryItems] = useState<ObservationRecord[]>([]);
const [libraryTotal, setLibraryTotal] = useState(0);
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState<string | null>(null);
// 筛选状态(提交给后端,不在前端筛)
const [libSource, setLibSource] = useState<string>('all');
const [libProduct, setLibProduct] = useState<string>('all');
const [libSearch, setLibSearch] = useState('');
const [libSort, setLibSort] = useState<'created' | 'source' | 'product'>(
'created'
);
const [libPage, setLibPage] = useState(1);
const [libPageSize, setLibPageSize] = useState(12);
const fetchLibrary = useCallback(async () => {
if (isAuthenticated !== true) return;
setLibraryLoading(true);
setLibraryError(null);
try {
const res = await axios.get<{ items: ObservationRecord[]; total: number }>(
'/api/observation/list',
{
params: {
source: libSource !== 'all' ? libSource : undefined,
product: libProduct !== 'all' ? libProduct : undefined,
q: libSearch.trim() || undefined,
sort: libSort,
limit: libPageSize,
offset: (libPage - 1) * libPageSize,
},
}
);
setLibraryItems(res.data.items ?? []);
setLibraryTotal(res.data.total ?? 0);
} catch (e: unknown) {
setLibraryError(
extractErrorMessage(e, '加载缓存库失败,请检查后端连接')
);
setLibraryItems([]);
} finally {
setLibraryLoading(false);
}
}, [
isAuthenticated,
libSource,
libProduct,
libSearch,
libSort,
libPage,
libPageSize,
]);
// 任意筛选/分页变更 → 重新请求后端
useEffect(() => {
fetchLibrary();
}, [fetchLibrary]);
const resetLibraryFilters = useCallback(() => {
setLibSource('all');
setLibProduct('all');
setLibSearch('');
setLibSort('created');
setLibPage(1);
}, []);
return {
// A. 能力清单
capabilities,
currentSpec,
fetchCapabilities,
// B. 检索
searchForm,
setSearchForm,
searchResults,
searching,
searchError,
runSearch,
selectedSourceIds,
toggleSelect,
selectAll,
selectNone,
// C. 下载
downloading,
downloadResult,
downloadError,
setDownloadResult,
downloadSelected,
downloadByIds,
downloadByCoordinates,
// D. 缓存库
libraryItems,
libraryTotal,
libraryLoading,
libraryError,
fetchLibrary,
libSource,
setLibSource: (v: string) => {
setLibSource(v);
setLibPage(1);
},
libProduct,
setLibProduct: (v: string) => {
setLibProduct(v);
setLibPage(1);
},
libSearch,
setLibSearch: (v: string) => {
setLibSearch(v);
setLibPage(1);
},
libSort,
setLibSort: (v: 'created' | 'source' | 'product') => {
setLibSort(v);
setLibPage(1);
},
libPage,
setLibPage,
libPageSize,
setLibPageSize,
resetLibraryFilters,
};
}

View File

@ -9,6 +9,7 @@ import {
} from '../components/agent';
import type { TimelineItem } from '../components/agent';
import type { SessionSummary, MessageRecord } from '../types';
import { extractErrorMessage } from '../utils/apiError';
export type { SessionSummary, MessageRecord } from '../types';
@ -433,11 +434,7 @@ export function useResearchAgent({
loadSessionHistory(currentSessionId, true);
fetchSessions(); // 更新侧栏 turn_count
} catch (e: unknown) {
const axiosError = e as {
response?: { data?: string };
message?: string;
};
const errMsg = `回退失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
const errMsg = `回退失败: ${extractErrorMessage(e, '未知错误')}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@ -481,11 +478,7 @@ export function useResearchAgent({
}
}
} catch (e: unknown) {
const axiosError = e as {
response?: { data?: string };
message?: string;
};
const errMsg = `恢复失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
const errMsg = `恢复失败: ${extractErrorMessage(e, '未知错误')}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@ -516,11 +509,7 @@ export function useResearchAgent({
await fetchSessions();
setCurrentSessionId(res.data.branch_session_id);
} catch (e: unknown) {
const axiosError = e as {
response?: { data?: string };
message?: string;
};
const errMsg = `分叉失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
const errMsg = `分叉失败: ${extractErrorMessage(e, '未知错误')}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@ -585,11 +574,7 @@ export function useResearchAgent({
// 触发自动重发
handleSend(questionText);
} catch (e: unknown) {
const axiosError = e as {
response?: { data?: string };
message?: string;
};
const errMsg = `重试失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
const errMsg = `重试失败: ${extractErrorMessage(e, '未知错误')}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {

View File

@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import type { SavedSyncQuery } from '../types';
import { extractErrorMessage } from '../utils/apiError';
export interface BatchStatus {
active: boolean;
@ -178,8 +179,7 @@ export function useSyncState() {
setTimeout(fetchSyncQueries, 500);
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '启动快速同步失败。');
setErrorMsg(extractErrorMessage(e, '启动快速同步失败。'));
fetchStatus();
}
};
@ -285,8 +285,7 @@ export function useSyncState() {
startBatchPolling();
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setBatchError(axiosError.response?.data || '启动批量任务失败。');
setBatchError(extractErrorMessage(e, '启动批量任务失败。'));
}
};
@ -296,8 +295,7 @@ export function useSyncState() {
fetchBatchStatus();
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setBatchError(axiosError.response?.data || '停止任务失败。');
setBatchError(extractErrorMessage(e, '停止任务失败。'));
}
};
@ -362,9 +360,8 @@ export function useSyncState() {
setEstimatedCount(res.data.total);
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setErrorMsg(
axiosError.response?.data || '估算文献总量失败,请检查 API 密钥或网络。'
extractErrorMessage(e, '估算文献总量失败,请检查 API 密钥或网络。')
);
} finally {
setEstimating(false);
@ -399,8 +396,7 @@ export function useSyncState() {
setTimeout(fetchSyncQueries, 500);
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '启动元数据同步任务失败。');
setErrorMsg(extractErrorMessage(e, '启动元数据同步任务失败。'));
fetchStatus();
}
};

View File

@ -15,13 +15,12 @@ import {
import type { StandardPaper } from '../types';
import { CustomSelect } from '../components/CustomSelect';
import { PaperCard } from '../components/PaperCard';
import type { TabId } from '../components/layout/Sidebar';
interface LibraryPanelProps {
library: StandardPaper[];
fetchLibrary: () => Promise<void>;
setActiveTab: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync'
) => void;
setActiveTab: (tab: TabId) => void;
onShowDetail: (paper: StandardPaper) => void;
onOpenReader: (paper: StandardPaper) => void;
onOpenCitation: (paper: StandardPaper) => void;

View File

@ -0,0 +1,988 @@
// dashboard/src/pages/ObservationPanel.tsx
//
// 观测数据标签页 —— 双视图切换:检索下载 / 缓存库
//
// 视图 A检索下载
// 表单(源/产品/子类型/坐标/release) → GET /observation/search → 候选源列表(可勾选)
// → POST /observation/download → 下载结果卡片
// 标识符模式textarea 输入 source_id → 直接 POST 下载
//
// 视图 B缓存库
// GET /observation/list(分页+筛选) → 已缓存数据卡片网格 + 分页栏
//
// 数据源/产品选项由 GET /observation/capabilities 动态生成,不硬编码。
import { useState, useMemo } from 'react';
import {
Telescope,
Search,
X,
CheckCircle2,
AlertTriangle,
Download,
Loader2,
Activity,
HardDrive,
Database,
Send,
RotateCw,
} from 'lucide-react';
import { CustomSelect } from '../components/CustomSelect';
import { ObservationResultCard } from '../components/observation/ObservationResultCard';
import {
SOURCE_THEME,
PRODUCT_LABEL,
formatFileSize,
} from '../components/observation/constants';
import type { ObservationRecord, Candidate, CapabilitySpec } from '../types';
import type { SearchForm } from '../hooks/useObservation';
// ── 视图切换类型 ──
type ViewMode = 'search' | 'library';
interface ObservationPanelProps {
// 能力清单
capabilities: CapabilitySpec[];
currentSpec?: CapabilitySpec;
// 检索
searchForm: SearchForm;
setSearchForm: React.Dispatch<React.SetStateAction<SearchForm>>;
searchResults: Candidate[];
searching: boolean;
searchError: string | null;
runSearch: () => Promise<void>;
selectedSourceIds: Set<string>;
toggleSelect: (id: string) => void;
selectAll: () => void;
selectNone: () => void;
// 下载
downloading: boolean;
downloadResult: import('../components/observation/constants').ObservationBatchResult | null;
downloadError: string | null;
setDownloadResult: React.Dispatch<
React.SetStateAction<
import('../components/observation/constants').ObservationBatchResult | null
>
>;
downloadSelected: () => Promise<void>;
downloadByIds: (ids: string[]) => Promise<void>;
downloadByCoordinates: (strategy?: 'nearest' | 'all') => Promise<void>;
// 缓存库
libraryItems: ObservationRecord[];
libraryTotal: number;
libraryLoading: boolean;
libraryError: string | null;
fetchLibrary: () => Promise<void>;
libSource: string;
setLibSource: (v: string) => void;
libProduct: string;
setLibProduct: (v: string) => void;
libSearch: string;
setLibSearch: (v: string) => void;
libSort: 'created' | 'source' | 'product';
setLibSort: (v: 'created' | 'source' | 'product') => void;
libPage: number;
setLibPage: (p: number) => void;
libPageSize: number;
setLibPageSize: (s: number) => void;
resetLibraryFilters: () => void;
}
export function ObservationPanel(props: ObservationPanelProps) {
const [view, setView] = useState<ViewMode>('search');
return (
<div className="w-full max-w-5xl mx-auto space-y-6">
{/* 标题栏 */}
<div className="flex items-center justify-between mb-4 border-b border-slate-200 pb-4 select-none">
<div>
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
</h2>
<p className="text-xs text-slate-500 mt-1">
/ 线 / /
</p>
</div>
{/* 二级视图切换 SegControl */}
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200">
<button
onClick={() => setView('search')}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
view === 'search'
? 'bg-white text-slate-800 shadow-xs'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<Search className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setView('library')}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
view === 'library'
? 'bg-white text-slate-800 shadow-xs'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<Database className="w-3.5 h-3.5" />
</button>
</div>
</div>
{view === 'search' ? (
<SearchView {...props} />
) : (
<LibraryView {...props} />
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 视图 A检索下载
// ════════════════════════════════════════════════════════════
function SearchView({
capabilities,
currentSpec,
searchForm,
setSearchForm,
searchResults,
searching,
searchError,
runSearch,
selectedSourceIds,
toggleSelect,
selectAll,
selectNone,
downloading,
downloadResult,
downloadError,
setDownloadResult,
downloadSelected,
downloadByIds,
downloadByCoordinates,
}: ObservationPanelProps) {
const [inputMode, setInputMode] = useState<'coordinates' | 'identifiers'>(
'coordinates'
);
const [idsText, setIdsText] = useState('');
// 从 capabilities 派生选项
const sourceOptions = useMemo(() => {
const sources = Array.from(new Set(capabilities.map((c) => c.source)));
return [
...sources.map((s) => ({
value: s,
label: SOURCE_THEME[s]?.label ?? s,
})),
];
}, [capabilities]);
const productOptions = useMemo(() => {
const products = Array.from(
new Set(
capabilities
.filter((c) => c.source === searchForm.source)
.map((c) => c.product)
)
);
return products.map((p) => ({
value: p,
label: PRODUCT_LABEL[p] ?? p,
}));
}, [capabilities, searchForm.source]);
const subtypeOptions = useMemo(() => {
const cap = capabilities.find(
(c) =>
c.source === searchForm.source && c.product === searchForm.product
);
return (cap?.subtypes ?? []).map((s) => ({ value: s, label: s }));
}, [capabilities, searchForm.source, searchForm.product]);
// 当前选中源+产品的标识符格式提示
const identifierFormat = useMemo(() => {
return currentSpec?.identifier_format ?? undefined;
}, [currentSpec]);
// 当前源+产品支持的版本列表(供下拉)
const releaseOptions = useMemo(() => {
return (currentSpec?.releases ?? []).map((r) => ({
value: r,
label: r.toUpperCase(),
}));
}, [currentSpec]);
// LAMOST MRS 联动约束MRS中分辨率从 DR7 起才支持DR5/DR6 无 MRS 数据
const lamostMrsBlocked = useMemo(() => {
if (searchForm.source !== 'lamost' || searchForm.subtype !== 'mrs')
return null;
const rel = searchForm.release;
if (rel === 'dr5' || rel === 'dr6') {
return 'LAMOST 中分辨率MRS从 DR7 起才支持,当前版本无 MRS 数据';
}
return null;
}, [searchForm.source, searchForm.subtype, searchForm.release]);
const handleSubmitIds = () => {
const ids = idsText
.split(/[\s,;\n]+/)
.map((s) => s.trim())
.filter(Boolean);
if (ids.length > 0) downloadByIds(ids);
};
return (
<div className="space-y-5">
{/* 表单卡片 */}
<div className="bg-white border border-slate-200 rounded-lg p-4 shadow-xs space-y-4">
{/* 输入模式切换 */}
<div className="flex items-center gap-2 text-xs font-bold">
<span className="text-slate-500"></span>
<button
onClick={() => setInputMode('coordinates')}
className={`px-2.5 py-1 rounded-md transition-all ${
inputMode === 'coordinates'
? 'bg-blueprint text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
</button>
<button
onClick={() => setInputMode('identifiers')}
className={`px-2.5 py-1 rounded-md transition-all ${
inputMode === 'identifiers'
? 'bg-blueprint text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
</button>
</div>
{/* 源 / 产品 / 子类型 / release 公共字段 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="数据源">
<CustomSelect
value={searchForm.source}
onChange={(v) => {
setSearchForm((f) => {
// 切源后若当前产品不被新源支持,则回落到新源首个产品
const newProducts = capabilities
.filter((c) => c.source === v)
.map((c) => c.product);
const safeProduct = newProducts.includes(f.product)
? f.product
: newProducts[0] ?? f.product;
return { ...f, source: v, product: safeProduct, subtype: '' };
});
}}
className="w-full"
options={sourceOptions}
/>
</Field>
<Field label="产品类型">
<CustomSelect
value={searchForm.product}
onChange={(v) =>
setSearchForm((f) => ({ ...f, product: v, subtype: '' }))
}
className="w-full"
options={productOptions}
/>
</Field>
<Field label="子类型(可选)">
<CustomSelect
value={searchForm.subtype || '__none__'}
onChange={(v) =>
setSearchForm((f) => ({
...f,
subtype: v === '__none__' ? '' : v,
}))
}
className="w-full"
options={[
{ value: '__none__', label: '默认' },
...subtypeOptions,
]}
/>
</Field>
<Field
label={
releaseOptions.length === 0
? '数据发布版本'
: `数据发布版本(${releaseOptions.length}`
}
>
{releaseOptions.length === 0 ? (
<div className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-200 text-slate-400 text-xs font-medium select-none">
</div>
) : (
<CustomSelect
value={searchForm.release || '__none__'}
onChange={(v) =>
setSearchForm((f) => ({
...f,
release: v === '__none__' ? '' : v,
}))
}
className="w-full"
options={[
{ value: '__none__', label: `默认${currentSpec?.default_release ? `${currentSpec.default_release.toUpperCase()}` : ''}` },
...releaseOptions,
]}
/>
)}
</Field>
</div>
{/* LAMOST MRS 版本约束提示 */}
{lamostMrsBlocked && (
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2.5">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<span>{lamostMrsBlocked}</span>
<button
onClick={() =>
setSearchForm((f) => ({ ...f, subtype: 'lrs' }))
}
className="ml-auto px-2 py-0.5 rounded bg-amber-100 hover:bg-amber-200 text-amber-800 font-bold text-[10px]"
>
LRS
</button>
</div>
)}
{/* 坐标模式表单 */}
{inputMode === 'coordinates' && (
<>
<div className="grid grid-cols-3 gap-3">
<Field label="RA">
<input
type="number"
step="0.0001"
value={searchForm.ra}
onChange={(e) =>
setSearchForm((f) => ({ ...f, ra: e.target.value }))
}
placeholder="0~360"
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
/>
</Field>
<Field label="Dec">
<input
type="number"
step="0.0001"
value={searchForm.dec}
onChange={(e) =>
setSearchForm((f) => ({ ...f, dec: e.target.value }))
}
placeholder="-90~90"
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
/>
</Field>
<Field
label={`检索半径(度,建议 ≤${currentSpec?.suggested_max_radius_deg ?? 1}°)`}
>
<input
type="number"
step="0.01"
max={currentSpec?.hard_max_radius_deg ?? 30}
value={searchForm.radius}
onChange={(e) =>
setSearchForm((f) => ({ ...f, radius: e.target.value }))
}
placeholder={`默认 0.1,建议 ≤${currentSpec?.suggested_max_radius_deg ?? 1}°`}
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
/>
</Field>
</div>
{/* 半径超出建议值的警告(不阻止提交,由后端决定是否超时) */}
{currentSpec &&
parseFloat(searchForm.radius) >
currentSpec.suggested_max_radius_deg &&
parseFloat(searchForm.radius) <=
currentSpec.hard_max_radius_deg && (
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2.5">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<span>
{searchForm.radius}°
{currentSpec.suggested_max_radius_deg}°
</span>
</div>
)}
<div className="flex items-center gap-2 pt-1">
<button
onClick={runSearch}
disabled={searching}
className="btn-console btn-console-primary px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50"
>
{searching ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Search className="w-3.5 h-3.5" />
)}
{searching ? '检索中...' : '检索候选源'}
</button>
<span className="text-[10px] text-slate-400">
"按坐标下载全部"
</span>
<button
onClick={() => downloadByCoordinates('nearest')}
disabled={downloading}
className="ml-auto px-3 py-2 rounded-md text-xs font-bold flex items-center gap-1.5 bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</>
)}
{/* 标识符模式表单 */}
{inputMode === 'identifiers' && (
<>
<Field label="源标识符列表(逗号/换行/空格分隔)">
<textarea
value={idsText}
onChange={(e) => setIdsText(e.target.value)}
rows={3}
placeholder={
identifierFormat
? `格式:${identifierFormat},如 65214031805717376`
: '输入源标识符,多个用逗号或换行分隔'
}
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
/>
</Field>
<button
onClick={handleSubmitIds}
disabled={downloading || !idsText.trim()}
className="btn-console btn-console-primary px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50"
>
{downloading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />
)}
</button>
</>
)}
</div>
{/* 检索错误 */}
{searchError && (
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-3">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{searchError}</span>
</div>
)}
{/* 候选源检索结果 */}
{inputMode === 'coordinates' && searchResults.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-blueprint" />
<span className="text-slate-400 font-normal">
{searchResults.length}
</span>
</h3>
<div className="flex items-center gap-2 text-[10px]">
<button
onClick={selectAll}
className="text-blueprint hover:underline font-bold"
>
</button>
<span className="text-slate-300">|</span>
<button
onClick={selectNone}
className="text-slate-500 hover:underline font-bold"
>
</button>
<span className="text-slate-400 ml-2">
{selectedSourceIds.size}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{searchResults.map((c) => {
const theme = SOURCE_THEME[c.source] ?? SOURCE_THEME.lamost;
const checked = selectedSourceIds.has(c.source_id);
return (
<label
key={c.source_id}
className={`flex items-start gap-2 p-2.5 rounded-md border cursor-pointer transition-all ${
checked
? 'border-blueprint bg-blueprint/5 ring-1 ring-blueprint'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSelect(c.source_id)}
className="mt-0.5 w-3.5 h-3.5 accent-blueprint shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 mb-1">
<span
className={`px-1.5 py-0.5 rounded text-[9px] font-bold ${theme.badge}`}
>
{theme.label}
</span>
<span className="font-mono text-[10px] text-slate-700 truncate font-semibold">
{c.source_id}
</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
{c.ra != null && c.dec != null && (
<span className="font-mono">
({c.ra.toFixed(4)}, {c.dec.toFixed(4)})
</span>
)}
{c.distance != null && (
<span className="text-amber-600 font-medium">
Δ = {c.distance.toFixed(4)}°
</span>
)}
</div>
</div>
</label>
);
})}
</div>
<button
onClick={downloadSelected}
disabled={downloading || selectedSourceIds.size === 0}
className="btn-console btn-console-primary px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50"
>
{downloading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
{selectedSourceIds.size}
</button>
</div>
)}
{/* 检索无结果提示 */}
{inputMode === 'coordinates' &&
!searching &&
searchResults.length === 0 &&
!searchError &&
searchForm.ra &&
searchForm.dec && (
<div className="text-center py-8 text-xs text-slate-400">
<Telescope className="w-8 h-8 mx-auto mb-2 text-slate-300" />
</div>
)}
{/* 下载错误 */}
{downloadError && (
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-3">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{downloadError}</span>
</div>
)}
{/* 下载结果 */}
{downloadResult && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
</h3>
<button
onClick={() => setDownloadResult(null)}
className="text-[10px] text-slate-400 hover:text-slate-600"
>
<X className="w-3 h-3" />
</button>
</div>
<ObservationResultCard result={downloadResult} />
</div>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 视图 B缓存库
// ════════════════════════════════════════════════════════════
function LibraryView({
libraryItems,
libraryTotal,
libraryLoading,
libraryError,
fetchLibrary,
libSource,
setLibSource,
libProduct,
setLibProduct,
libSearch,
setLibSearch,
libSort,
setLibSort,
libPage,
setLibPage,
libPageSize,
setLibPageSize,
resetLibraryFilters,
}: ObservationPanelProps) {
const totalPages = Math.max(1, Math.ceil(libraryTotal / libPageSize));
return (
<div className="space-y-5">
{/* 概览统计 */}
<div className="grid grid-cols-3 gap-3">
<StatCard
icon={<Database className="w-4 h-4 text-blueprint" />}
label="缓存条目"
value={libraryTotal.toString()}
/>
<StatCard
icon={<HardDrive className="w-4 h-4 text-star" />}
label="当前页"
value={`${libraryItems.length}`}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-emerald-600" />}
label="页码"
value={`${libPage} / ${totalPages}`}
/>
</div>
{/* 筛选工具栏 */}
<div className="flex flex-col sm:flex-row gap-3 bg-white p-4 rounded-lg border border-slate-200 shadow-sm text-xs font-semibold text-slate-700">
<div className="flex-1 space-y-1.5">
<label className="block text-slate-500 font-bold">
source_id
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
<input
type="text"
value={libSearch}
onChange={(e) => setLibSearch(e.target.value)}
placeholder="按 source_id 模糊匹配..."
className="w-full pl-9 pr-8 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
/>
{libSearch && (
<button
type="button"
onClick={() => setLibSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 p-0.5"
>
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
<Field label="数据源" compact>
<CustomSelect
value={libSource}
onChange={setLibSource}
className="w-full"
options={[
{ value: 'all', label: '全部源' },
{ value: 'lamost', label: 'LAMOST' },
{ value: 'gaia', label: 'Gaia' },
{ value: 'sdss', label: 'SDSS' },
{ value: 'desi', label: 'DESI' },
]}
/>
</Field>
<Field label="产品类型" compact>
<CustomSelect
value={libProduct}
onChange={setLibProduct}
className="w-full"
options={[
{ value: 'all', label: '全部' },
{ value: 'spectrum', label: '光谱' },
{ value: 'lightcurve', label: '光变曲线' },
{ value: 'photometry', label: '测光' },
{ value: 'image', label: '图像' },
]}
/>
</Field>
<Field label="排序" compact>
<CustomSelect
value={libSort}
onChange={(v) => setLibSort(v as 'created' | 'source' | 'product')}
className="w-full"
options={[
{ value: 'created', label: '最近缓存' },
{ value: 'source', label: '按数据源' },
{ value: 'product', label: '按产品' },
]}
/>
</Field>
<div className="flex items-end gap-2">
<button
onClick={resetLibraryFilters}
className="btn-console px-3 py-2 rounded-md text-xs font-bold flex items-center gap-1.5"
>
<X className="w-3 h-3" />
</button>
<button
onClick={fetchLibrary}
className="btn-console px-3 py-2 rounded-md text-xs font-bold flex items-center gap-1.5"
>
<RotateCw className="w-3 h-3" />
</button>
</div>
</div>
{/* 错误提示 */}
{libraryError && (
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-3">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{libraryError}</span>
</div>
)}
{/* 加载态 */}
{libraryLoading && (
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-blueprint mb-2" />
<span className="text-xs">...</span>
</div>
)}
{/* 空态 */}
{!libraryLoading && libraryItems.length === 0 && !libraryError && (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Database className="w-10 h-10 mb-3 text-slate-300" />
<p className="text-xs font-medium"></p>
<p className="text-[10px] mt-1">
</p>
</div>
)}
{/* 列表 */}
{!libraryLoading && libraryItems.length > 0 && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{libraryItems.map((rec) => (
<LibraryCard key={`${rec.source}|${rec.product}|${rec.source_id}`} rec={rec} />
))}
</div>
{/* 分页栏 */}
<div className="flex items-center justify-between flex-wrap gap-3 pt-4 border-t border-slate-200 text-xs font-semibold text-slate-600">
<div className="flex items-center gap-2">
<span></span>
<CustomSelect
size="sm"
value={libPageSize}
onChange={(v) => {
setLibPageSize(v);
setLibPage(1);
}}
options={[
{ value: 12, label: '12' },
{ value: 24, label: '24' },
{ value: 48, label: '48' },
]}
/>
<span> · {libraryTotal} </span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setLibPage(1)}
disabled={libPage <= 1}
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
>
</button>
<button
onClick={() => setLibPage(libPage - 1)}
disabled={libPage <= 1}
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
>
</button>
<span className="px-2 font-mono">
{libPage} / {totalPages}
</span>
<button
onClick={() => setLibPage(libPage + 1)}
disabled={libPage >= totalPages}
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
>
</button>
<button
onClick={() => setLibPage(totalPages)}
disabled={libPage >= totalPages}
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
>
</button>
</div>
</div>
</>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 局部子组件
// ════════════════════════════════════════════════════════════
function Field({
label,
children,
compact = false,
}: {
label: string;
children: React.ReactNode;
compact?: boolean;
}) {
return (
<div className={compact ? 'w-full sm:w-36 space-y-1.5 font-bold' : 'space-y-1.5'}>
<label className="block text-slate-500 font-bold text-xs">{label}</label>
{children}
</div>
);
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="bg-white border border-slate-200 rounded-lg p-3 shadow-xs">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-500 tracking-wider uppercase mb-1.5">
{icon}
<span>{label}</span>
</div>
<div className="text-base font-bold text-slate-800">{value}</div>
</div>
);
}
function LibraryCard({ rec }: { rec: ObservationRecord }) {
const theme = SOURCE_THEME[rec.source] ?? SOURCE_THEME.lamost;
const productLabel = PRODUCT_LABEL[rec.product] ?? rec.product;
let artifacts: Array<{ path: string; format: string; size?: number; band?: string }> = [];
try {
const parsed = JSON.parse(rec.artifacts_json);
if (Array.isArray(parsed)) artifacts = parsed;
} catch {
// ignore
}
const totalSize = artifacts.reduce(
(acc, a) => acc + (typeof a.size === 'number' ? a.size : 0),
0
);
return (
<div className="bg-white border border-slate-200 rounded-lg p-3.5 shadow-xs hover:shadow-md hover:border-slate-300 transition-all">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-slate-100">
<div className="flex items-center gap-1.5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}>
{theme.label}
</span>
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-200 text-slate-700">
{productLabel}
</span>
</div>
<span className="text-[9px] text-slate-400 font-mono">
{rec.created_at.replace('T', ' ').slice(0, 19)}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<div className="min-w-0">
<div className="text-[9px] font-bold text-slate-400 tracking-widest uppercase">
Source ID
</div>
<div className="font-mono text-xs text-slate-800 font-semibold truncate" title={rec.source_id}>
{rec.source_id}
</div>
</div>
{rec.ra != null && rec.dec != null && (
<div className="text-right shrink-0 ml-2">
<div className="text-[9px] font-bold text-slate-400 tracking-widest uppercase">
</div>
<div className="font-mono text-[10px] text-slate-600">
{rec.ra.toFixed(4)}, {rec.dec.toFixed(4)}
</div>
</div>
)}
</div>
{artifacts.length === 0 ? (
<p className="text-[10px] text-slate-400 italic"></p>
) : (
<div className="space-y-1">
{artifacts.map((a, i) => (
<div
key={i}
className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100"
>
<span className="text-slate-500 flex items-center gap-1 min-w-0">
{a.band && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium shrink-0">
{a.band}
</span>
)}
<span className="font-mono uppercase shrink-0">{a.format}</span>
<span className="text-slate-400">
· {typeof a.size === 'number' ? formatFileSize(a.size) : '—'}
</span>
</span>
<a
href={`/api/files/${a.path}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline shrink-0 ml-2"
>
<Download className="w-3 h-3" />
</a>
</div>
))}
{artifacts.length > 1 && (
<div className="flex items-center gap-1 text-[10px] text-slate-400 pt-1">
<CheckCircle2 className="w-3 h-3" />
<span>
{artifacts.length} · {formatFileSize(totalSize)}
</span>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -5,6 +5,7 @@ import { AgentInputArea } from '../components/agent/AgentInputArea';
import type { StandardPaper } from '../types';
import type { useCitations } from '../hooks/useCitations';
import type { useLibrary } from '../hooks/useLibrary';
import type { TabId } from '../components/layout/Sidebar';
interface ResearchAgentPanelProps {
showConfirm?: (
@ -14,9 +15,7 @@ interface ResearchAgentPanelProps {
) => void;
showAlert?: (message: string, title?: string) => void;
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
setActiveTab?: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab?: (tab: TabId) => void;
citations?: ReturnType<typeof useCitations>;
library?: ReturnType<typeof useLibrary>;
}

View File

@ -15,6 +15,7 @@ import {
import type { StandardPaper } from '../types';
import { CustomSelect } from '../components/CustomSelect';
import { PaperCard } from '../components/PaperCard';
import type { TabId } from '../components/layout/Sidebar';
interface SearchPanelProps {
searchQuery: string;
@ -40,9 +41,7 @@ interface SearchPanelProps {
selectedPaper: StandardPaper | null;
setSelectedPaper: (paper: StandardPaper | null) => void;
openReader: (paper: StandardPaper) => void;
setActiveTab: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync'
) => void;
setActiveTab: (tab: TabId) => void;
loadCitations: (bibcode: string, reset?: boolean) => void;
showAlert: (msg: string, title?: string) => void;
onShowDetail: (paper: StandardPaper) => void;

View File

@ -206,3 +206,53 @@ export interface RetryResponse {
deleted_count: number;
session_id: string;
}
// ── 观测数据 (observation_cache) ──
// 镜像后端 src/services/observation/cache.rs::ObservationCacheRow
// 与 src/services/observation/types.rs::Artifact
export interface ObservationArtifactRecord {
path: string;
format: string;
size?: number;
band?: string;
original_name?: string;
}
export interface ObservationRecord {
source: string; // lamost / gaia / sdss / desi
product: string; // spectrum / lightcurve / photometry / image
source_id: string;
ra?: number | null;
dec?: number | null;
artifacts_json: string; // JSON 字符串,前端解析为 ObservationArtifactRecord[]
meta_json?: string | null;
created_at: string;
}
// ── 观测数据检索:候选源 (镜像 services::observation::fetcher::Candidate) ──
export interface Candidate {
source: string;
source_id: string;
label: string;
ra?: number | null;
dec?: number | null;
distance?: number | null;
raw?: Record<string, unknown>;
}
// ── 观测数据能力清单 (镜像 services::observation::registry::CapabilitySpec) ──
export interface CapabilitySpec {
source: string;
product: string;
subtypes: string[];
releases: string[];
default_release?: string | null;
suggested_max_radius_deg: number;
hard_max_radius_deg: number;
identifier_format?: string | null;
supports_coordinates: boolean;
supports_identifiers: boolean;
}

View File

@ -0,0 +1,40 @@
// dashboard/src/utils/apiError.ts
//
// 统一的 axios 错误消息提取工具
//
// 后端 AppError 序列化为 JSON 对象 { "error": "..." }src/api/error.rs
// 而非纯字符串。若直接存入 React state 并在 JSX 渲染,会触发
// React error #31Objects are not valid as a React child
// 本工具按优先级从 axios 错误中提取人类可读字符串。
interface AxiosLikeError {
response?: { data?: unknown; status?: number };
message?: string;
}
/// 从 axios 错误中提取人类可读的字符串消息
///
/// 优先级response.data 为字符串 → 对象里的 error/message/detail/description
/// → 整体 JSON 序列化兜底 → axios message → fallback
export function extractErrorMessage(
e: unknown,
fallback: string
): string {
const axiosError = e as AxiosLikeError;
const data = axiosError?.response?.data;
if (typeof data === 'string') return data;
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>;
for (const key of ['error', 'message', 'detail', 'description']) {
const v = obj[key];
if (typeof v === 'string') return v;
}
// 整个 JSON 序列化兜底(避免渲染 [object Object]
try {
return JSON.stringify(data);
} catch {
return fallback;
}
}
return axiosError?.message || fallback;
}

View File

@ -0,0 +1,23 @@
-- 观测数据下载缓存表(取代 spectrum_cache支持多产品类型 + 多文件产物)
--
-- 与 spectrum_cache 的关键差异:
-- 1. 新增 product 列:区分 spectrum / lightcurve / photometry / image
-- 2. UNIQUE 加入 product同一源的同一标识符可同时缓存光谱与光变
-- 3. file_path/file_format 改为 artifacts_json一个逻辑产物可对应多个物理文件
-- (如 Gaia EPOCH_PHOTOMETRY 的 G/BP/RP 三波段各一个 FITS
-- 4. 观测数据不可变,无 expires_at与 vizier_query_cache 的 TTL 缓存不同)
--
-- 兼容spectrum_cache 旧数据由 20260705140001_migrate_spectrum_to_observation.sql 迁移
CREATE TABLE IF NOT EXISTS observation_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL, -- 'lamost' | 'gaia' | 'sdss' | 'desi' | ...
product TEXT NOT NULL, -- 'spectrum' | 'lightcurve' | 'photometry' | 'image'
source_id TEXT NOT NULL, -- obsid / source_id / plate-mjd-fiber / targetid...
ra REAL,
dec REAL,
artifacts_json TEXT NOT NULL, -- [{"path","format","size"}] 多文件产物
meta_json TEXT, -- z/class/band 等元信息
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source, product, source_id)
);
CREATE INDEX IF NOT EXISTS idx_obs_cache_source ON observation_cache(source, product, source_id);

View File

@ -0,0 +1,22 @@
-- 将 spectrum_cache 旧数据迁移到 observation_cache并将单文件转成单元素 artifacts 数组
-- 仅当 spectrum_cache 存在且有数据时执行;迁移后旧表保留(由后续清理迁移删除)
INSERT OR IGNORE INTO observation_cache (source, product, source_id, ra, dec, artifacts_json, meta_json, created_at)
SELECT
source,
'spectrum' AS product,
source_id,
ra,
dec,
json_object(
'path', file_path,
'format', file_format,
'size', 0,
'band', NULL,
'original_name', NULL
) AS artifacts_json,
meta_json,
created_at
FROM spectrum_cache
WHERE EXISTS (
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'spectrum_cache'
);

View File

@ -242,16 +242,50 @@ pub fn micro_compact(messages: &[ChatMessage], keep_recent: usize) -> Vec<ChatMe
/// 粗略估算消息列表的 token 数(用作首次调用的近似值)。
/// 后续迭代优先使用 API 返回的精确 prompt_tokens。
///
/// 启发式算法:按字符类型加权估算。
/// - ASCII 字符约 4 字符/token
/// - CJK 字符约 1.5 字符/token每个汉字通常 1-2 token
pub fn rough_estimate_tokens(messages: &[ChatMessage]) -> usize {
messages
.iter()
.map(|m| {
let content_len = m.text().map_or(0, |c| c.len());
let content_len = m.text().map_or(0, |c| estimate_tokens_from_text(c));
content_len + 4 // 消息结构 overhead 约 4 token
})
.sum()
}
/// 根据文本内容估算 token 数。
fn estimate_tokens_from_text(text: &str) -> usize {
let mut ascii_chars = 0usize;
let mut cjk_chars = 0usize;
for ch in text.chars() {
if ch.is_ascii() {
ascii_chars += 1;
} else if is_cjk(ch) {
cjk_chars += 1;
} else {
// 其他 Unicode 字符(如重音字母、数学符号)按 2 字符/token 估算
ascii_chars += 2;
}
}
// ASCII: ~4 字符/token, CJK: ~1.5 字符/token
ascii_chars.div_ceil(4) + (cjk_chars * 2).div_ceil(3)
}
/// 判断字符是否为 CJK 统一表意文字
fn is_cjk(ch: char) -> bool {
matches!(ch,
'\u{4E00}'..='\u{9FFF}' | // CJK 统一汉字
'\u{3400}'..='\u{4DBF}' | // CJK 扩展 A
'\u{F900}'..='\u{FAFF}' | // CJK 兼容汉字
'\u{2E80}'..='\u{2EFF}' | // CJK 部首补充
'\u{3000}'..='\u{303F}' | // CJK 符号和标点
'\u{FF00}'..='\u{FFEF}' // 全角字符
)
}
/// 在压缩后注入身份确认块,防止模型丢失上下文认知。
/// 参考 Claude Code s11: identity re-injection after compression.
fn inject_identity_block(messages: &mut Vec<ChatMessage>) {
@ -622,6 +656,16 @@ pub fn extract_memories_from_compaction(
}
// 只提取非系统消息的摘要(系统消息在每次压缩后保留)
let context_text = extract_snippets(before_messages);
if context_text.len() < 300 {
return; // 太少内容,不值得提取
}
spawn_memory_extraction(context_text, session_id, memory_manager, app_state);
}
/// 从消息列表中提取用于记忆检索的文本片段(供外部预提取后调用)。
pub fn extract_snippets(before_messages: &[ChatMessage]) -> String {
let snippets: Vec<String> = before_messages
.iter()
.filter(|m| m.role != crate::clients::llm::MessageRole::System)
@ -643,11 +687,16 @@ pub fn extract_memories_from_compaction(
.unwrap_or(None)
})
.collect();
snippets.join("\n")
}
let context_text = snippets.join("\n");
if context_text.len() < 300 {
return; // 太少内容,不值得提取
}
/// 启动记忆提取子代理fire-and-forget
fn spawn_memory_extraction(
context_text: String,
session_id: &str,
memory_manager: std::sync::Arc<tokio::sync::Mutex<crate::agent::memory::MemoryManager>>,
app_state: std::sync::Arc<crate::api::AppState>,
) {
let sid = session_id.to_string();
let mem_mgr = memory_manager.clone();
@ -704,6 +753,20 @@ pub fn extract_memories_from_compaction(
});
}
/// 从预提取的文本片段启动记忆提取(避免克隆完整消息列表)。
pub fn spawn_memory_extraction_from_snippets(
context_text: String,
session_id: &str,
memory_manager: std::sync::Arc<tokio::sync::Mutex<crate::agent::memory::MemoryManager>>,
app_state: std::sync::Arc<crate::api::AppState>,
) {
let config = crate::agent::memory::extraction::ExtractionConfig::from_env();
if !config.enabled || context_text.len() < 300 {
return;
}
spawn_memory_extraction(context_text, session_id, memory_manager, app_state);
}
#[cfg(test)]
mod tests {
use super::*;
@ -852,8 +915,11 @@ mod tests {
ChatMessage::user("Hello, world!"),
];
let estimate = rough_estimate_tokens(&messages);
// 每个消息 content.len() + 4 overhead
let expected = ("You are an assistant.".len()) + 4 + ("Hello, world!".len()) + 4;
// 新算法ASCII 文本约 4 字符/token + 消息 overhead 4 token
// "You are an assistant." = 21 ASCII chars → ceil(21/4)=6 + 4 = 10
// "Hello, world!" = 13 ASCII chars → ceil(13/4)=4 + 4 = 8
// Total = 18
let expected = 21_usize.div_ceil(4) + 4 + 13_usize.div_ceil(4) + 4;
assert_eq!(estimate, expected);
}

View File

@ -237,12 +237,12 @@ impl AgentRuntime {
}
}
/// 返回当前运行指标快照(锁异常时返回默认值
/// 返回当前运行指标快照(锁异常时返回默认值并记录警告
pub fn get_metrics(&self) -> super::hooks::MetricsData {
self.metrics_data
.try_lock()
.map(|m| m.clone())
.unwrap_or_default()
self.metrics_data.try_lock().map(|m| m.clone()).unwrap_or_else(|_| {
warn!("[AgentRuntime] 获取指标锁失败,返回默认值");
super::hooks::MetricsData::default()
})
}
/// 设置是否启用 LLM 思考模式(向后兼容,优先使用 mode 设置)
@ -306,8 +306,8 @@ impl AgentRuntime {
}
};
// 压缩前捕获消息快照用于记忆提取桥接P3
let pre_compact_snapshot: Vec<crate::clients::llm::ChatMessage> = messages.to_vec();
// 压缩前预提取记忆片段(避免克隆整个消息列表,仅提取前 400 字符的摘要
let pre_compact_snippets = compact::extract_snippets(messages);
compact::compress_context_with_hooks_and_log(
messages,
@ -321,8 +321,8 @@ impl AgentRuntime {
.await;
// 压缩后提取记忆P3 桥接:将丢弃的消息内容喂给记忆提取子代理)
compact::extract_memories_from_compaction(
&pre_compact_snapshot,
compact::spawn_memory_extraction_from_snippets(
pre_compact_snippets,
session_id,
self.app_state.memory_manager.clone(),
self.app_state.clone(),
@ -545,13 +545,16 @@ impl AgentRuntime {
let estimated_tokens = match last_api_prompt_tokens {
Some(last_tokens) => {
let new_msg_count = messages.len().saturating_sub(msg_count_at_last_call);
let new_tokens_estimate: u32 = messages
let new_tokens_estimate: usize = messages
.iter()
.rev()
.take(new_msg_count)
.map(|m| (m.content.as_ref().map_or(0, |c| c.len()) + 4) as u32)
.map(|m| {
let content_len = m.content.as_ref().map_or(0, |c| c.len());
content_len / 3 + 4 // 粗略估算:~3 字符/token + 消息 overhead
})
.sum();
(last_tokens + new_tokens_estimate) as usize
last_tokens as usize + new_tokens_estimate
}
None => compact::rough_estimate_tokens(messages),
};

View File

@ -608,6 +608,9 @@ mod tests {
120,
)
.unwrap(),
observation_registry: std::sync::Arc::new(
crate::services::observation::ObservationRegistry::default(),
),
llm: llm.clone(),
medium_llm: llm.clone(),
fast_llm: llm.clone(),
@ -633,8 +636,9 @@ mod tests {
memory_manager: Arc::new(tokio::sync::Mutex::new(MemoryManager::new(PathBuf::from(
"/tmp/test_mem",
)))),
sessions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
login_rate_limiter: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
upload_rate_limiter: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
});
ToolContext::new(app_state)

View File

@ -17,15 +17,13 @@ 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::library::{GetCitationNetworkTool, SearchLocalLibraryTool};
pub use research::metadata::GetPaperMetadataTool;
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};
pub use research::vizier::CatalogOperationTool;
// 以下工具已合并入 ProcessPaperTool不再独立导出
// - DownloadPaperTool (合并入 process_paper)

View File

@ -1,300 +0,0 @@
// 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<serde_json::Value> = 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<serde_json::Value> = 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::<String>()
);
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<serde_json::Value> = 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))
}
}

View File

@ -1,236 +0,0 @@
// src/agent/tools/astro/research/find_spectrum.rs
//
// FindSpectrumTool —— 统一光谱下载工具(跨 LAMOST/Gaia/SDSS
//
// 唯一的光谱工具,取代历史的三源分立工具。支持两种模式:
// - 坐标模式(默认):给 ra/dec/radius + survey + strategy自动 cone 检索 → 选源 → 下载
// - 标识符模式:给 survey + source_idsVizieR 交叉证认得到的源标识),直接下载
//
// 源标识格式:
// 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/ gaiaBP/RP / sdssSDSS+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": "赤经 RAJ2000/ICRS。坐标模式必填"
},
"dec": {
"type": "number",
"description": "赤纬 DecJ2000/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默认dr10Gaia: dr3默认SDSS: dr16/dr17/dr18/dr19默认dr17DESI: dr1(默认)/edr"
},
"data_type": {
"type": "string",
"description": "数据类型可选。LAMOST: lrs(低分辨率,默认)/mrs(中分辨率)Gaia: xp_continuous(默认)/xp_sampled/epoch_photometry/rvsSDSS: 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<String> = 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
}

View File

@ -1,16 +1,144 @@
// src/agent/tools/astro/research/citation_network.rs
// GetCitationNetworkTool — 引用查找与引用网络浏览
// src/agent/tools/astro/research/library.rs
//
// 两个核心场景:
// 1. 引用查找:阅读中遇到 "Lei et al. 2023",通过作者+年份匹配定位文献
// 2. 引用网络:分页浏览参考文献/被引列表,支持排序
// SearchLocalLibraryTool — 本地文献库 FTS5 全文检索
// GetCitationNetworkTool — 引用查找与引用网络浏览
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
use crate::services::citation::{get_citations_paginated, search_citations};
// ── SearchLocalLibraryTool ──
pub struct SearchLocalLibraryTool;
#[async_trait]
impl AgentTool for SearchLocalLibraryTool {
fn name(&self) -> &str {
"search_local_library"
}
fn description(&self) -> &str {
"在本地文献库中进行全文检索。搜索范围包括标题、作者、关键词、摘要和期刊名。\
使 BM25 "
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词。支持多词联合搜索,如 'white dwarf atmosphere'"
},
"limit": {
"type": "integer",
"description": "返回结果数量,默认 10最大 50",
"default": 10
}
},
"required": ["query"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_readonly(&self) -> bool {
true
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let query = match args.get("query").and_then(|q| q.as_str()) {
Some(q) => q.to_string(),
None => return ToolOutput::error("缺少必需参数 'query'"),
};
let limit = args
.get("limit")
.and_then(|l| l.as_u64())
.unwrap_or(10)
.min(50) as usize;
info!(
"[SearchLocalLibrary] 本地检索: query='{}', limit={}",
query, limit
);
let state = &ctx.app_state;
match crate::services::search::search_local_library(&state.db, &query, limit).await {
Ok(results) => {
if results.is_empty() {
return ToolOutput::success(
"本地文献库中未找到匹配的文献。请尝试调整搜索关键词,或使用 search_papers 从外部检索新文献。",
json!({ "count": 0 }),
);
}
let display: Vec<serde_json::Value> = results
.iter()
.map(|p| {
let first_author = p
.authors
.first()
.cloned()
.unwrap_or_else(|| "未知".to_string());
json!({
"bibcode": p.bibcode,
"title": p.title,
"first_author": first_author,
"year": p.year,
"pub_journal": p.pub_journal,
"citation_count": p.citation_count,
"has_markdown": p.has_markdown,
})
})
.collect();
let content = display
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. [{}] {} ({})\n 第一作者: {} | 期刊: {}\n 被引: {} 次 | 已解析: {}",
i + 1,
r["bibcode"].as_str().unwrap_or(""),
r["title"].as_str().unwrap_or(""),
r["year"].as_str().unwrap_or(""),
r["first_author"].as_str().unwrap_or(""),
r["pub_journal"].as_str().unwrap_or(""),
r["citation_count"].as_i64().unwrap_or(0),
if r["has_markdown"].as_bool().unwrap_or(false) {
""
} else {
""
},
)
})
.collect::<Vec<_>>()
.join("\n\n");
ToolOutput::success(
content,
json!({ "count": results.len(), "papers": display }),
)
}
Err(e) => ToolOutput::error(format!("本地文献检索失败: {}", e)),
}
}
}
// ── GetCitationNetworkTool ──
//
// 引用查找与引用网络浏览:
// 1. 引用查找:阅读中遇到 "Lei et al. 2023",通过作者+年份匹配定位文献
// 2. 引用网络:分页浏览参考文献/被引列表,支持排序
pub struct GetCitationNetworkTool;
@ -125,7 +253,7 @@ impl AgentTool for GetCitationNetworkTool {
// 有 query → 引用查找模式:在关联文献中按作者+年份搜索
if let Some(ref q) = query {
match search_citations(&state.db, &paper.bibcode, direction, q).await {
match crate::services::citation::search_citations(&state.db, &paper.bibcode, direction, q).await {
Ok(results) => {
if results.is_empty() {
let dir_label = if direction == "citations" {
@ -187,7 +315,7 @@ impl AgentTool for GetCitationNetworkTool {
}
} else {
// 无 query → 分页浏览模式
match get_citations_paginated(&state.db, &paper.bibcode, direction, sort, offset, limit)
match crate::services::citation::get_citations_paginated(&state.db, &paper.bibcode, direction, sort, offset, limit)
.await
{
Ok((rows, total)) => {

View File

@ -1,131 +0,0 @@
// src/agent/tools/astro/research/library_search.rs
// SearchLocalLibraryTool — 本地文献库 FTS5 全文检索
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct SearchLocalLibraryTool;
#[async_trait]
impl AgentTool for SearchLocalLibraryTool {
fn name(&self) -> &str {
"search_local_library"
}
fn description(&self) -> &str {
"在本地文献库中进行全文检索。搜索范围包括标题、作者、关键词、摘要和期刊名。\
使 BM25 "
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词。支持多词联合搜索,如 'white dwarf atmosphere'"
},
"limit": {
"type": "integer",
"description": "返回结果数量,默认 10最大 50",
"default": 10
}
},
"required": ["query"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_readonly(&self) -> bool {
true
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let query = match args.get("query").and_then(|q| q.as_str()) {
Some(q) => q.to_string(),
None => return ToolOutput::error("缺少必需参数 'query'"),
};
let limit = args
.get("limit")
.and_then(|l| l.as_u64())
.unwrap_or(10)
.min(50) as usize;
info!(
"[SearchLocalLibrary] 本地检索: query='{}', limit={}",
query, limit
);
let state = &ctx.app_state;
match crate::services::search::search_local_library(&state.db, &query, limit).await {
Ok(results) => {
if results.is_empty() {
return ToolOutput::success(
"本地文献库中未找到匹配的文献。请尝试调整搜索关键词,或使用 search_papers 从外部检索新文献。",
json!({ "count": 0 }),
);
}
let display: Vec<serde_json::Value> = results
.iter()
.map(|p| {
let first_author = p
.authors
.first()
.cloned()
.unwrap_or_else(|| "未知".to_string());
json!({
"bibcode": p.bibcode,
"title": p.title,
"first_author": first_author,
"year": p.year,
"pub_journal": p.pub_journal,
"citation_count": p.citation_count,
"has_markdown": p.has_markdown,
})
})
.collect();
let content = display
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. [{}] {} ({})\n 第一作者: {} | 期刊: {}\n 被引: {} 次 | 已解析: {}",
i + 1,
r["bibcode"].as_str().unwrap_or(""),
r["title"].as_str().unwrap_or(""),
r["year"].as_str().unwrap_or(""),
r["first_author"].as_str().unwrap_or(""),
r["pub_journal"].as_str().unwrap_or(""),
r["citation_count"].as_i64().unwrap_or(0),
if r["has_markdown"].as_bool().unwrap_or(false) {
""
} else {
""
},
)
})
.collect::<Vec<_>>()
.join("\n\n");
ToolOutput::success(
content,
json!({ "count": results.len(), "papers": display }),
)
}
Err(e) => ToolOutput::error(format!("本地文献检索失败: {}", e)),
}
}
}

View File

@ -1,24 +1,20 @@
// 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 library;
pub mod metadata;
pub mod note;
pub mod observation;
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 library::{GetCitationNetworkTool, SearchLocalLibraryTool};
pub use observation::FindObservationTool;
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};
pub use vizier::CatalogOperationTool;

View File

@ -0,0 +1,259 @@
// src/agent/tools/astro/research/observation.rs
//
// FindObservationTool —— 统一观测数据下载工具
//
// 跨 (LAMOST/Gaia/SDSS/DESI) × (Spectrum/LightCurve/Photometry/Image) 双轴:
// - 坐标模式(默认):给 ra/dec/radius + source + product + strategy自动 cone 检索 → 选源 → 下载
// - 标识符模式:给 source + product + source_ids直接按标识下载跳过检索
//
// 源 × 产品支持矩阵registry 注册决定,工具运行时按需校验):
// Spectrum LightCurve Photometry
// LAMOST lrs/mrs - -
// Gaia xp_continuous epoch_photometry -
// xp_sampled
// rvs
// SDSS spec/apstar - -
// aspcap
// DESI coadd - -
//
// 源标识格式:
// LAMOST spectrum: obsid 数字,如 "438809089"
// Gaia spectrum: "XP_CONTINUOUS|source_id"(可省略 RT 前缀,默认 XP_CONTINUOUS
// Gaia lightcurve(epoch_photometry): source_id
// SDSS spectrum(spec): "run2d-plate-mjd-fiberid",如 "26-2225-53729-439"
// SDSS spectrum(apstar/aspcap): "telescope|field|apogee_id"
// DESI spectrum: "survey-program-healpix",如 "main-dark-10050"
//
// 结果自动缓存observation_cache按 source+product+source_id 去重),重复查询不会重复下载。
// Gaia 光变(EPOCH_PHOTOMETRY) 返回 G/BP/RP 三波段各一个文件(多 artifact
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
use crate::services::observation::{
download_observation, FindStrategy, ObservationRequest, ProductSpec, ProductType, Source,
};
pub struct FindObservationTool;
#[async_trait]
impl AgentTool for FindObservationTool {
fn name(&self) -> &str {
"find_observation"
}
fn description(&self) -> &str {
"下载天文观测数据LAMOST/Gaia/SDSS/DESI 的光谱/光变/测光/图像)。\n\
ra/dec + source + product cone \n\
source + product + source_ids\n\
"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["lamost", "gaia", "sdss", "desi"],
"description": "数据源"
},
"product": {
"type": "string",
"enum": ["spectrum", "lightcurve", "photometry", "image"],
"description": "产品类型,默认 spectrum",
"default": "spectrum"
},
"subtype": {
"type": "string",
"description": "产品子类型可选。LAMOST spectrum: lrs/mrsGaia spectrum: xp_continuous/xp_sampled/rvsGaia lightcurve: epoch_photometrySDSS spectrum: spec/apstar/aspcapDESI spectrum: coadd"
},
"ra": {
"type": "number",
"description": "赤经 RAJ2000/ICRS。坐标模式必填"
},
"dec": {
"type": "number",
"description": "赤纬 DecJ2000/ICRS。坐标模式必填"
},
"radius_deg": {
"type": "number",
"description": "检索半径(度),坐标模式用,默认 0.1。各源无硬性 API 上限但建议值LAMOST≤5°Gaia/SDSS/DESI≤1°主表很大超出易超时。硬性上限 30°超出报错。可通过 GET /api/observation/capabilities 查询各源建议值",
"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": "数据发布版本(可选,留空用各源默认)。可选值与默认值可通过 GET /api/observation/capabilities 查询LAMOST: dr5..dr11默认dr10MRS 需 dr7+Gaia: dr3SDSS spec: dr16/dr17默认dr17SDSS apstar/aspcap: dr17DESI: edr/dr1默认dr1"
},
"force": {
"type": "boolean",
"description": "是否强制重新下载(忽略缓存),默认 false",
"default": false
}
},
"required": ["source"]
})
}
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 source = match args.get("source").and_then(|v| v.as_str()) {
Some(s) => match s.to_lowercase().as_str() {
"lamost" => Source::Lamost,
"gaia" => Source::Gaia,
"sdss" => Source::Sdss,
"desi" => Source::Desi,
other => return ToolOutput::error(format!(
"不支持的 source '{}',可选: {:?}", other, Source::valid_values()
)),
},
None => return ToolOutput::error("缺少必需参数 'source'lamost/gaia/sdss/desi"),
};
let product_type = match args.get("product").and_then(|v| v.as_str()) {
None | Some("spectrum") => ProductType::Spectrum,
Some("lightcurve") | Some("light_curve") | Some("lc") => ProductType::LightCurve,
Some("photometry") => ProductType::Photometry,
Some("image") => ProductType::Image,
Some(other) => return ToolOutput::error(format!(
"不支持的 product '{}',可选: {:?}", other, ProductType::valid_values()
)),
};
let subtype = args.get("subtype")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let product = ProductSpec { product: product_type, subtype };
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());
// 标识符模式 vs 坐标模式
let request = if let Some(ids) = args.get("source_ids").and_then(|v| v.as_array()) {
let identifiers: Vec<String> = ids.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if identifiers.is_empty() {
return ToolOutput::error("标识符模式下 source_ids 不能为空");
}
info!(
"[FindObservation] by_id source={:?} product={:?} count={}",
source, product.product, identifiers.len()
);
ObservationRequest::ByIdentifier { source, product, identifiers, release }
} 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!(
"[FindObservation] by_coords source={:?} product={:?} ra={} dec={} radius={}° strategy={:?}",
source, product.product, ra, dec, radius, strategy
);
ObservationRequest::ByCoordinates {
source, product, ra, dec, radius_deg: radius, strategy, release,
}
};
match download_observation(state, &state.observation_registry, &request, force).await {
Ok(batch) => {
let content = render_batch(&batch);
ToolOutput::success(content, json!(batch))
}
Err(e) => ToolOutput::error(format!("观测数据下载失败: {}", e)),
}
}
}
/// 渲染 ObservationBatch 为可读文本(支持多 artifact 展示)
fn render_batch(b: &crate::services::observation::ObservationBatch) -> String {
let mut content = format!("{} {} 下载", b.source.display(), b.product.product.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));
}
if let Some(st) = &b.product.subtype {
content.push_str(&format!(" [{}]", st));
}
content.push_str(&format!(":命中 {}\n", b.matched_count));
if b.products.is_empty() && b.failures.is_empty() {
content.push_str("(无观测数据覆盖或无匹配)\n");
return content;
}
for p in &b.products {
// 多 artifact 展示(如 Gaia 光变 G/BP/RP 三波段)
let artifact_summary: Vec<String> = p.artifacts.iter().map(|a| {
match &a.band {
Some(band) => format!("{}波段 {} ({})",
band, a.file_format.to_uppercase(), format_size(a.size_bytes)),
None => format!("{} ({})",
a.file_format.to_uppercase(), format_size(a.size_bytes)),
}
}).collect();
content.push_str(&format!(
"\n✓ 已下载({}: {} —— {}\n",
if p.artifacts.iter().all(|a| a.cached) { "缓存" } else { "新下载" }
.to_string(),
p.source_label,
artifact_summary.join(", ")
));
for a in &p.artifacts {
content.push_str(&format!(
" {}{}\n",
a.file_path,
a.band.as_ref().map(|b| format!(" [{}]", b)).unwrap_or_default()
));
}
}
for f in &b.failures {
content.push_str(&format!("\n✗ 下载失败 {}: {}\n", f.source_label, f.error));
}
content
}
fn format_size(bytes: usize) -> String {
if bytes > 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / 1024.0 / 1024.0)
} else if bytes > 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}

View File

@ -1,9 +1,12 @@
// src/agent/tools/astro/research/vizier.rs
//
// QueryVizierTool —— VizieR TAP 星表查询(自由 ADQL + 便捷表查询)
// ConeSearchTool —— 锥形检索(按坐标查近邻天体)
//
// 对齐 QueryTargetTool 范式:单元结构体 + AgentTool 实现 + ctx.app_state 调 service
// CatalogOperationTool —— VizieR 星表统一操作工具
// search: 按关键词搜索星表目录
// describe: 查看表的列结构
// query: 执行 ADQL 或按表名查询,返回数据
// cone: 按坐标锥形检索
// export: 导出为 CSV 文件
// lookup: 通过 bibcode 查找关联数据表
use async_trait::async_trait;
use serde_json::json;
@ -34,7 +37,6 @@ fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> Strin
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!(
@ -46,7 +48,6 @@ fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> Strin
.join(" | ")
));
// 表体(限制预览行数)
let show = result.rows.len().min(preview_rows);
for row in result.rows.iter().take(show) {
let cells: Vec<String> = row
@ -67,7 +68,6 @@ fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> Strin
));
}
// 列单位提示
let units: Vec<&str> = result
.fields
.iter()
@ -80,161 +80,73 @@ fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> Strin
content
}
// ── QueryVizierTool ──
// ── CatalogOperationTool ──
pub struct QueryVizierTool;
pub struct CatalogOperationTool;
#[async_trait]
impl AgentTool for QueryVizierTool {
impl AgentTool for CatalogOperationTool {
fn name(&self) -> &str {
"query_vizier"
"catalog_operation"
}
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"
"VizieR 星表统一操作。通过 action 选择:\n\
search \n\
describe \n\
query ADQL \n\
cone \n\
export CSV\n\
lookup bibcode "
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"adql": {
"action": {
"type": "string",
"description": "自由 ADQL 查询语句(与 table_name 二选一)。如 SELECT TOP 10 * FROM \"I/355/gaiadr3\""
"enum": ["search", "describe", "query", "cone", "export", "lookup"],
"description": "操作类型"
},
"table_name": {
"keyword": {
"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::<String>()
);
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<String> = 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 \
J2000ICRS 7 \
search_catalogs "
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"ra": {
"type": "number",
"description": "赤经 RAJ2000/ICRS范围 0~360"
},
"dec": {
"type": "number",
"description": "赤纬 DecJ2000/ICRS范围 -90~90"
},
"radius_deg": {
"type": "number",
"description": "检索半径(度),默认 0.1,范围 0~5",
"default": 0.1
"description": "search 时的搜索关键词,如 'Gaia DR3'"
},
"table": {
"type": "string",
"description": "目标星表(必填),如 'I/355/gaiadr3'Gaia DR3、'II/246/out'2MASS"
"description": "VizieR 表名describe/query/cone/export 时使用,如 'I/355/gaiadr3'"
},
"max_records": {
"adql": {
"type": "string",
"description": "query/export 时的 ADQL 语句(与 table 二选一)"
},
"columns": {
"type": "string",
"description": "query/export + table 模式下指定列(逗号分隔,默认 *"
},
"coords": {
"type": "object",
"description": "cone 时的坐标参数",
"properties": {
"ra": { "type": "number", "description": "赤经 RA" },
"dec": { "type": "number", "description": "赤纬 Dec" },
"radius_deg": { "type": "number", "description": "检索半径(度),默认 0.1", "default": 0.1 },
"strategy": { "type": "string", "enum": ["nearest", "all"], "description": "nearest默认/ all", "default": "nearest" }
},
"required": ["ra", "dec"]
},
"limit": {
"type": "integer",
"description": "最大返回行数(默认 50上限 2000",
"default": 50
},
"bibcode": {
"type": "string",
"description": "lookup 时的 ADS bibcode"
}
},
"required": ["ra", "dec", "table"]
"required": ["action"]
})
}
@ -247,59 +159,301 @@ impl AgentTool for ConeSearchTool {
}
fn is_readonly(&self) -> bool {
true
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;
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)),
match action {
"search" => do_catalog_search(state, &args).await,
"describe" => do_catalog_describe(state, &args).await,
"query" => do_catalog_query(state, &args).await,
"cone" => do_catalog_cone(state, &args).await,
"export" => do_catalog_export(state, &args).await,
"lookup" => do_catalog_lookup(state, &args).await,
other => ToolOutput::error(format!(
"未知 action '{}',可选: search/describe/query/cone/export/lookup",
other
)),
}
}
}
async fn do_catalog_search(
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<serde_json::Value> = 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_catalog_describe(
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<serde_json::Value> = 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_catalog_query(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
let limit = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(50)
.clamp(1, 2000);
let result = if let Some(adql) = args.get("adql").and_then(|v| v.as_str()) {
info!(
"[CatalogOp:query] ADQL (limit={}): {}",
limit,
adql.chars().take(150).collect::<String>()
);
crate::services::cds::vizier::query_adql_cached(&state.db, &state.vizier, adql, limit)
.await
} else if let Some(table) = args.get("table").and_then(|v| v.as_str()) {
let columns: Vec<String> = args
.get("columns")
.and_then(|v| v.as_str())
.map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
info!("[CatalogOp:query] table={} limit={}", table, limit);
crate::services::cds::vizier::query_table(
&state.db,
&state.vizier,
table,
&columns,
limit,
)
.await
} else {
return ToolOutput::error("query 需要 'adql' 或 'table' 参数之一");
};
match result {
Ok(r) => {
let content = render_result_table(&r, 20);
ToolOutput::success(content, json!(r))
}
Err(e) => ToolOutput::error(format!("查询失败: {}", e)),
}
}
async fn do_catalog_cone(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
let coords = match args.get("coords").and_then(|v| v.as_object()) {
Some(c) => c,
None => return ToolOutput::error("cone 需要 'coords' 参数(含 ra/dec"),
};
let ra = match coords.get("ra").and_then(|v| v.as_f64()) {
Some(v) => v,
None => return ToolOutput::error("coords 缺少 'ra'"),
};
let dec = match coords.get("dec").and_then(|v| v.as_f64()) {
Some(v) => v,
None => return ToolOutput::error("coords 缺少 'dec'"),
};
let radius = coords
.get("radius_deg")
.and_then(|v| v.as_f64())
.unwrap_or(0.1);
let nearest = coords.get("strategy").and_then(|v| v.as_str()) != Some("all");
let table = match args.get("table").and_then(|v| v.as_str()) {
Some(t) => t,
None => return ToolOutput::error("cone 需要 'table' 参数"),
};
let max_records = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(50)
.clamp(1, 2000);
info!(
"[CatalogOp:cone] ra={} dec={} radius={}° table={} strategy={}",
ra, dec, radius, table, if nearest { "nearest" } else { "all" }
);
match crate::services::cds::vizier::cone_search(
&state.db,
&state.vizier,
ra,
dec,
radius,
table,
max_records,
nearest,
)
.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)),
}
}
async fn do_catalog_export(
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 = args.get("adql").and_then(|v| v.as_str());
let table = args.get("table").and_then(|v| v.as_str());
let columns = args.get("columns").and_then(|v| v.as_str());
if adql.is_none() && table.is_none() {
return ToolOutput::error("export 需要 'adql' 或 'table' 参数");
}
let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier);
let result = match catalog.export_to_file(
state.config.library_dir.to_str().unwrap_or("."),
adql,
table,
columns,
limit,
).await {
Ok(r) => r,
Err(e) => return ToolOutput::error(format!("导出失败: {}", e)),
};
ToolOutput::success(
format!(
"已导出 {} 行数据到 `{}`\n文件大小: {}",
result.row_count,
result.path.display(),
result.size_bytes,
),
json!({
"path": result.path.to_str(),
"rows": result.row_count,
"columns": result.column_count,
}),
)
}
async fn do_catalog_lookup(
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<serde_json::Value> = 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))
}

View File

@ -36,16 +36,14 @@ 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::library::{GetCitationNetworkTool, SearchLocalLibraryTool};
pub use astro::research::metadata::GetPaperMetadataTool;
pub use astro::research::note::SaveNoteTool;
pub use astro::research::observation::FindObservationTool;
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::research::vizier::CatalogOperationTool;
pub use astro::system::process::ProcessPaperTool;
pub use astro::system::search::SearchPapersTool;
pub use background::{BgTaskCheckTool, BgTaskRunTool};
@ -383,9 +381,7 @@ fn add_base_tools(registry: &mut ToolRegistry, skill_registry: Arc<RwLock<SkillR
Box::new(GetCitationNetworkTool),
Box::new(RagSearchTool),
Box::new(QueryTargetTool),
Box::new(QueryVizierTool),
Box::new(ConeSearchTool),
Box::new(FindSpectrumTool),
Box::new(FindObservationTool),
Box::new(CatalogOperationTool),
Box::new(SaveNoteTool),
Box::new(TodoWriteTool),
@ -725,7 +721,7 @@ mod tests {
"./skills",
)))));
let defs = registry.definitions();
assert_eq!(defs.len(), 26);
assert_eq!(defs.len(), 24);
assert!(defs.iter().any(|d| d.function.name == "read_file"));
assert!(defs.iter().any(|d| d.function.name == "grep_files"));
assert!(defs.iter().any(|d| d.function.name == "glob_files"));
@ -745,9 +741,7 @@ mod tests {
.any(|d| d.function.name == "get_citation_network"));
assert!(defs.iter().any(|d| d.function.name == "rag_search"));
assert!(defs.iter().any(|d| d.function.name == "query_target"));
assert!(defs.iter().any(|d| d.function.name == "query_vizier"));
assert!(defs.iter().any(|d| d.function.name == "cone_search"));
assert!(defs.iter().any(|d| d.function.name == "find_spectrum"));
assert!(defs.iter().any(|d| d.function.name == "find_observation"));
assert!(defs.iter().any(|d| d.function.name == "catalog_operation"));
assert!(defs.iter().any(|d| d.function.name == "save_note"));
assert!(defs.iter().any(|d| d.function.name == "todo_write"));

View File

@ -277,6 +277,9 @@ mod tests {
120,
)
.unwrap(),
observation_registry: std::sync::Arc::new(
crate::services::observation::ObservationRegistry::default(),
),
llm,
medium_llm,
fast_llm,
@ -302,8 +305,9 @@ mod tests {
memory_manager: Arc::new(tokio::sync::Mutex::new(
crate::agent::memory::MemoryManager::new(PathBuf::from("/tmp/test_memory")),
)),
sessions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
login_rate_limiter: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
upload_rate_limiter: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
});
ToolContext {

View File

@ -38,7 +38,7 @@ fn parse_session_cookie(cookie_str: &str) -> Option<String> {
/// 从请求头中提取认证 Token。
/// 优先从 Authorization: Bearer 头读取,其次从 Cookie 中的 session_id 读取。
fn extract_auth_token(headers: &HeaderMap) -> Option<String> {
pub fn extract_auth_token_from_headers(headers: &HeaderMap) -> Option<String> {
// 1. Authorization: Bearer <token>
if let Some(auth_str) = headers
.get(header::AUTHORIZATION)
@ -94,10 +94,10 @@ fn prune_expired_sessions(
});
}
/// 计算浏览器书签专用的永久 API 密钥(基于 admin_password + salt 的 SHA-1 哈希)
/// 计算浏览器书签专用的永久 API 密钥(基于 admin_password + salt 的 SHA-256 哈希)
fn get_bookmarklet_api_key(password: &str) -> String {
use sha1::{Digest, Sha1};
let mut hasher = Sha1::new();
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(b"_bookmarklet_salt_key");
format!("{:x}", hasher.finalize())
@ -119,10 +119,20 @@ pub async fn login(
// 检查速率限制5 次失败 / 5 分钟窗口
const MAX_FAILURES: u32 = 5;
const WINDOW_SECS: u64 = 300;
const MAX_RATE_LIMITER_ENTRIES: usize = 10000;
{
let mut limiter = state.login_rate_limiter.lock().await;
// 批量清理过期条目,防止内存无限增长
limiter.retain(|_, (_, first_fail)| first_fail.elapsed().as_secs() < WINDOW_SECS);
// 容量保护:超过上限时强制清理最旧的一半条目
if limiter.len() > MAX_RATE_LIMITER_ENTRIES {
let mut entries: Vec<_> = limiter.iter().map(|(k, v)| (k.clone(), v.1)).collect();
entries.sort_by_key(|(_, t)| *t);
let cutoff = entries.len() / 2;
for (key, _) in &entries[..cutoff] {
limiter.remove(key);
}
}
if let Some((count, first_fail)) = limiter.get(&client_ip) {
let elapsed = first_fail.elapsed().as_secs();
@ -150,7 +160,7 @@ pub async fn login(
// 异常安全地记录活跃会话及其当前时间
{
let mut sessions = state.sessions.lock().await;
let mut sessions = state.sessions.write().await;
// 先清理过期会话
prune_expired_sessions(&mut sessions);
// 检查会话上限
@ -199,10 +209,10 @@ pub async fn logout(
State(state): State<Arc<AppState>>,
req: Request<axum::body::Body>,
) -> ApiResult<impl IntoResponse> {
let token_to_remove = extract_auth_token(req.headers());
let token_to_remove = extract_auth_token_from_headers(req.headers());
if let Some(ref token) = token_to_remove {
state.sessions.lock().await.remove(token);
state.sessions.write().await.remove(token);
}
// 通过 Max-Age=0 清除浏览器端的 Cookie
@ -229,12 +239,12 @@ pub async fn auth_middleware(
req: axum::extract::Request,
next: Next,
) -> Response {
let token = extract_auth_token(req.headers());
let token = extract_auth_token_from_headers(req.headers());
let mut is_valid = false;
if let Some(tok) = token {
// 1. 优先校验内存中的动态 Session
let mut sessions = state.sessions.lock().await;
let mut sessions = state.sessions.write().await;
// 顺便执行过期会话自动清理垃圾收集
prune_expired_sessions(&mut sessions);

View File

@ -37,22 +37,14 @@ pub struct ConeSearchParams {
pub ra: f64,
/// Dec 坐标(度)
pub dec: f64,
/// 检索半径(度
/// 检索半径(度0~20
pub radius: Option<f64>,
/// 目标星表(必填,如 "I/355/gaiadr3"
pub table: String,
/// 最大返回行数(默认 50上限 2000
pub max_records: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct CrossMatchParams {
pub ra: f64,
pub dec: f64,
pub radius: Option<f64>,
/// 目标星表(必填,如 "I/355/gaiadr3"
pub table: String,
pub max_records: Option<i64>,
/// 返回策略:"nearest"(按角距离排序,返回最近的)或 "all"(无序返回全部),默认 nearest
pub strategy: Option<String>,
}
// ── 响应封装 ──
@ -134,6 +126,7 @@ pub async fn cone_search(
) -> ApiResult<Json<VizierQueryResult>> {
let radius = params.radius.unwrap_or(0.1);
let max_records = clamp_max(params.max_records);
let nearest = params.strategy.as_deref() != Some("all");
let result = crate::services::cds::vizier::cone_search(
&state.db,
@ -143,6 +136,7 @@ pub async fn cone_search(
radius,
&params.table,
max_records,
nearest,
)
.await
.map_err(|e| {
@ -156,154 +150,3 @@ pub async fn cone_search(
})?;
Ok(Json(result))
}
/// GET /api/catalog/crossmatch —— 交叉证认
pub async fn cross_match(
State(state): State<std::sync::Arc<AppState>>,
Query(params): Query<CrossMatchParams>,
) -> ApiResult<Json<VizierQueryResult>> {
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,
&params.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<f64>,
pub dec: Option<f64>,
pub radius: Option<f64>,
/// 选源策略nearest默认/ all
pub strategy: Option<String>,
/// 标识符模式:逗号分隔的源标识列表
pub source_ids: Option<String>,
/// 数据发布版本可选lamost=dr5..dr11, gaia=dr3, sdss=dr16..dr19
pub release: Option<String>,
/// 数据类型可选lamost=lrs/mrs, gaia=xp_continuous/xp_sampled/epoch_photometry/rvs
pub data_type: Option<String>,
pub force: Option<bool>,
}
/// GET /api/catalog/spectrum/download —— 统一光谱下载
///
/// 两种模式(二选一):
/// - 坐标模式:提供 ra + dec可选 radius/strategy自动 cone 检索并下载
/// - 标识符模式:提供 source_ids逗号分隔直接按标识下载
pub async fn spectrum_download(
State(state): State<std::sync::Arc<AppState>>,
Query(params): Query<SpectrumDownloadParams>,
) -> ApiResult<Json<crate::services::spectra::DownloadBatch>> {
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<String> = 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<std::sync::Arc<AppState>>,
) -> ApiResult<Json<Vec<crate::services::spectra::common::SpectrumCacheRow>>> {
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))
}

View File

@ -63,14 +63,16 @@ pub struct AppState {
pub arxiv: ArxivClient,
/// VizieR TAP 星表查询客户端CDS VizieR TAP 服务)
pub vizier: VizierClient,
/// LAMOST 光谱数据客户端ConeSearch + FITS.gz 下载
/// LAMOST 观测数据客户端ConeSearch + FITS.gz 下载,服务光谱/光变等产品
pub lamost: LamostClient,
/// Gaia 光谱数据客户端TAP + DataLink
/// Gaia 观测数据客户端TAP + DataLink服务光谱/光变等产品
pub gaia: GaiaClient,
/// SDSS 光谱数据客户端Data Lab TAP + SAS
/// SDSS 观测数据客户端Data Lab TAP + SAS服务光谱/APOGEE 等产品
pub sdss: SdssClient,
/// DESI 光谱数据客户端Data Lab TAP + HEALPix coadd SAS
/// DESI 观测数据客户端Data Lab TAP + HEALPix coadd SAS,服务光谱等产品
pub desi: DesiClient,
/// 观测数据 fetcher 注册表(跨源 × 跨产品类型的统一获取入口)
pub observation_registry: Arc<crate::services::observation::ObservationRegistry>,
pub llm: LlmClient,
pub medium_llm: LlmClient,
pub fast_llm: LlmClient,
@ -100,10 +102,13 @@ pub struct AppState {
/// 项目记忆管理器(跨会话持久化)
pub memory_manager: Arc<tokio::sync::Mutex<MemoryManager>>,
/// 活跃的登录会话 Token 及其最后活跃时间(单用户内存管理)
pub sessions: Arc<Mutex<std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>>>,
pub sessions: Arc<RwLock<std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>>>,
/// 登录速率限制器IP -> 最近失败次数 + 首次失败时间)
pub login_rate_limiter:
Arc<Mutex<std::collections::HashMap<String, (u32, std::time::Instant)>>>,
/// 上传速率限制器token -> 最近上传次数 + 首次上传时间)
pub upload_rate_limiter:
Arc<Mutex<std::collections::HashMap<String, (u32, std::time::Instant)>>>,
}
// 统一标准化的文献格式,用于向前端传输
@ -114,6 +119,7 @@ pub mod auth;
pub mod catalog;
pub mod error;
pub mod notes;
pub mod observation;
pub mod papers;
pub mod permissions;
pub mod search;
@ -131,14 +137,18 @@ pub mod handlers {
};
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,
cone_search, vizier_query, vizier_table, ConeSearchParams, VizierQueryParams,
VizierTableParams,
};
pub use super::notes::{
create_note, delete_note, get_notes, CreateNoteRequest, DeleteNoteParams, GetNotesParams,
NoteRecord,
};
pub use super::observation::{
observation_capabilities, observation_download, observation_list, observation_search,
DownloadMode, ObservationDownloadRequest, ObservationListParams, ObservationListResponse,
ObservationSearchParams,
};
pub use super::papers::{
download_paper, embed_paper, export_citations, get_active_bibcode, get_citation_network,
get_library, get_paper_detail, mark_no_resource, parse_paper, search_papers,

283
src/api/observation.rs Normal file
View File

@ -0,0 +1,283 @@
// src/api/observation.rs
//
// 观测数据 HTTP 处理器 —— 跨 (LAMOST/Gaia/SDSS/DESI) × (Spectrum/LightCurve/Photometry/Image)
//
// 职责:调用 services::observation 统一编排层完成检索、下载与缓存管理
// 对齐 catalog.rs / targets.rs 的 handler 范式:
// State + Query/Json 参数 → service 调用 → ApiResult<Json<...>>
//
// 命名空间:/api/observation/{search,download,capabilities,list}
// - search :按坐标 cone 检索候选源(不下载,供前端预览挑选)
// - download :按坐标或标识符下载观测产物并写入 observation_cachePOST重型副作用
// - capabilities :返回 registry 支持的 (source × product × subtypes) 组合,供前端动态渲染表单
// - list :列出 observation_cache 中已缓存条目(服务端分页 + 筛选)
use axum::extract::{Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use super::error::{ApiResult, AppError};
use super::AppState;
// ═══════════════════════════════════════════════════════════════
// 1. 通用参数解析辅助
// ═══════════════════════════════════════════════════════════════
fn default_product() -> String {
"spectrum".to_string()
}
fn default_radius() -> f64 {
0.1
}
fn parse_source(s: &str) -> Result<crate::services::observation::Source, AppError> {
use crate::services::observation::Source;
Ok(match s.to_lowercase().as_str() {
"lamost" => Source::Lamost,
"gaia" => Source::Gaia,
"sdss" => Source::Sdss,
"desi" => Source::Desi,
other => {
return Err(AppError::bad_request(format!(
"不支持的 source '{}',可选: {:?}",
other,
Source::valid_values()
)))
}
})
}
fn parse_product(s: &str) -> Result<crate::services::observation::ProductType, AppError> {
use crate::services::observation::ProductType;
Ok(match s.to_lowercase().as_str() {
"spectrum" => ProductType::Spectrum,
"lightcurve" | "light_curve" | "lc" => ProductType::LightCurve,
"photometry" => ProductType::Photometry,
"image" => ProductType::Image,
other => {
return Err(AppError::bad_request(format!(
"不支持的 product '{}',可选: {:?}",
other,
ProductType::valid_values()
)))
}
})
}
/// 把 (source, product, subtype) 字符串参数聚合为 ProductSpec
fn build_product_spec(
product: &str,
subtype: &Option<String>,
) -> Result<crate::services::observation::ProductSpec, AppError> {
Ok(crate::services::observation::ProductSpec {
product: parse_product(product)?,
subtype: subtype.clone(),
})
}
// ═══════════════════════════════════════════════════════════════
// 2. GET /api/observation/search —— 按坐标检索候选源(不下载)
// ═══════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize)]
pub struct ObservationSearchParams {
pub source: String,
#[serde(default = "default_product")]
pub product: String,
pub subtype: Option<String>,
pub ra: f64,
pub dec: f64,
#[serde(default = "default_radius")]
pub radius: f64,
pub release: Option<String>,
}
pub async fn observation_search(
State(state): State<std::sync::Arc<AppState>>,
Query(params): Query<ObservationSearchParams>,
) -> ApiResult<Json<Vec<crate::services::observation::Candidate>>> {
let source = parse_source(&params.source)?;
let product = build_product_spec(&params.product, &params.subtype)?;
let candidates = crate::services::observation::search_observation(
&state,
&state.observation_registry,
source,
&product,
params.ra,
params.dec,
params.radius,
params.release.as_deref(),
)
.await
.map_err(|e| {
tracing::error!("观测数据检索失败: {}", e);
let msg = e.to_string();
if msg.contains("半径")
|| msg.contains("坐标")
|| msg.contains("格式")
|| msg.contains("暂不支持")
|| msg.contains("应为")
|| msg.contains("不支持的")
{
AppError::bad_request(msg)
} else {
AppError::internal(format!("观测数据检索失败: {}", msg))
}
})?;
Ok(Json(candidates))
}
// ═══════════════════════════════════════════════════════════════
// 3. POST /api/observation/download —— 检索并下载观测产物
// ═══════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize)]
pub struct ObservationDownloadRequest {
pub source: String,
#[serde(default = "default_product")]
pub product: String,
pub subtype: Option<String>,
pub release: Option<String>,
#[serde(default)]
pub force: bool,
#[serde(flatten)]
pub mode: DownloadMode,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum DownloadMode {
/// 坐标模式cone 检索 → 选源 → 下载
Coordinates {
ra: f64,
dec: f64,
#[serde(default = "default_radius")]
radius_deg: f64,
#[serde(default)]
strategy: crate::services::observation::FindStrategy,
},
/// 标识符模式:直接按 source_ids 下载(跳过 cone 检索)
Identifiers { source_ids: Vec<String> },
}
pub async fn observation_download(
State(state): State<std::sync::Arc<AppState>>,
Json(req): Json<ObservationDownloadRequest>,
) -> ApiResult<Json<crate::services::observation::ObservationBatch>> {
use crate::services::observation::{download_observation, ObservationRequest};
let source = parse_source(&req.source)?;
let product = build_product_spec(&req.product, &req.subtype)?;
let request = match req.mode {
DownloadMode::Coordinates {
ra,
dec,
radius_deg,
strategy,
} => ObservationRequest::ByCoordinates {
source,
product,
ra,
dec,
radius_deg,
strategy,
release: req.release.clone(),
},
DownloadMode::Identifiers { source_ids } => {
let identifiers: Vec<String> = source_ids
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if identifiers.is_empty() {
return Err(AppError::bad_request("source_ids 不能为空"));
}
ObservationRequest::ByIdentifier {
source,
product,
identifiers,
release: req.release.clone(),
}
}
};
let result = download_observation(&state, &state.observation_registry, &request, req.force)
.await
.map_err(|e| {
tracing::error!("观测数据下载失败: {}", e);
let msg = e.to_string();
if msg.contains("半径")
|| msg.contains("坐标")
|| msg.contains("格式")
|| msg.contains("暂不支持")
|| msg.contains("应为")
|| msg.contains("不支持的")
{
AppError::bad_request(msg)
} else {
AppError::internal(format!("观测数据下载失败: {}", msg))
}
})?;
Ok(Json(result))
}
// ═══════════════════════════════════════════════════════════════
// 4. GET /api/observation/capabilities —— 返回支持的源/产品组合
// ═══════════════════════════════════════════════════════════════
pub async fn observation_capabilities(
State(state): State<std::sync::Arc<AppState>>,
) -> ApiResult<Json<Vec<crate::services::observation::CapabilitySpec>>> {
let caps = state.observation_registry.list_capabilities();
Ok(Json(caps))
}
// ═══════════════════════════════════════════════════════════════
// 5. GET /api/observation/list —— 列出已缓存的观测数据(服务端分页)
// ═══════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize)]
pub struct ObservationListParams {
pub source: Option<String>,
pub product: Option<String>,
pub q: Option<String>,
pub sort: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct ObservationListResponse {
pub items: Vec<crate::services::observation::cache::ObservationCacheRow>,
pub total: i64,
}
pub async fn observation_list(
State(state): State<std::sync::Arc<AppState>>,
Query(params): Query<ObservationListParams>,
) -> ApiResult<Json<ObservationListResponse>> {
use crate::services::observation::cache::{list_cached_paged, ListCachedParams};
let list_params = ListCachedParams {
source: params.source.filter(|s| !s.is_empty()),
product: params.product.filter(|s| !s.is_empty()),
q: params.q.filter(|s| !s.is_empty()),
sort: params.sort.filter(|s| !s.is_empty()),
limit: params.limit,
offset: params.offset,
};
let page = list_cached_paged(&state.db, &list_params)
.await
.map_err(|e| {
tracing::error!("观测数据列表查询失败: {}", e);
AppError::internal(format!("观测数据列表查询失败: {}", e))
})?;
Ok(Json(ObservationListResponse {
items: page.items,
total: page.total,
}))
}

View File

@ -293,6 +293,25 @@ pub async fn upload_paper_file(
State(state): State<Arc<AppState>>,
mut multipart: axum::extract::Multipart,
) -> ApiResult<Json<StandardPaper>> {
// 上传速率限制:全局每小时最多 50 次(单用户应用,按全局计数即可)
const UPLOAD_MAX_PER_HOUR: u32 = 50;
const UPLOAD_WINDOW_SECS: u64 = 3600;
{
let mut limiter = state.upload_rate_limiter.lock().await;
limiter.retain(|_, (_, first)| first.elapsed().as_secs() < UPLOAD_WINDOW_SECS);
let entry = limiter
.entry("global".to_string())
.or_insert((0, std::time::Instant::now()));
if entry.0 >= UPLOAD_MAX_PER_HOUR {
let remaining = UPLOAD_WINDOW_SECS.saturating_sub(entry.1.elapsed().as_secs());
return Err(AppError::unauthorized(format!(
"上传过于频繁,请在 {} 秒后重试",
remaining
)));
}
entry.0 += 1;
}
let mut bibcode = String::new();
let mut file_type = String::new(); // "pdf" 或 "html"
let mut file_bytes = Vec::new();

View File

@ -163,18 +163,7 @@ async fn main() -> anyhow::Result<()> {
tracing_subscriber::util::SubscriberInitExt::init(subscriber);
// SAFETY: 注册静态 sqlite-vec 初始化函数。
unsafe {
libsqlite3_sys::sqlite3_auto_extension(Some(std::mem::transmute::<
*const (),
unsafe extern "C" fn(
*mut libsqlite3_sys::sqlite3,
*mut *const i8,
*const libsqlite3_sys::sqlite3_api_routines,
) -> i32,
>(
sqlite_vec::sqlite3_vec_init as *const ()
)));
}
astroresearch::utils::register_sqlite_vec_extension();
let config = Config::from_env();
@ -229,6 +218,9 @@ async fn main() -> anyhow::Result<()> {
config.desi_timeout_secs,
)
.context("构建 DESI 客户端失败")?,
observation_registry: Arc::new(
astroresearch::services::observation::ObservationRegistry::default(),
),
llm: LlmClient::new(
config.llm_api_key.clone(),
config.llm_api_base.clone(),
@ -281,8 +273,9 @@ async fn main() -> anyhow::Result<()> {
memory_manager: Arc::new(tokio::sync::Mutex::new(
astroresearch::agent::memory::MemoryManager::new(config.library_dir.clone()),
)),
sessions: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
sessions: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
login_rate_limiter: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
upload_rate_limiter: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
});
let cli = Cli::parse();

View File

@ -220,18 +220,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// SAFETY: sqlite3_vec_init 的函数签名严格符合 SQLite C API 自动扩展
// 回调规范。即使 health_check 不直接使用 vec0也需要注册以防数据库
// 包含 vec0 虚拟表时连接崩溃。
unsafe {
libsqlite3_sys::sqlite3_auto_extension(Some(std::mem::transmute::<
*const (),
unsafe extern "C" fn(
*mut libsqlite3_sys::sqlite3,
*mut *const i8,
*const libsqlite3_sys::sqlite3_api_routines,
) -> i32,
>(
sqlite_vec::sqlite3_vec_init as *const ()
)));
}
astroresearch::utils::register_sqlite_vec_extension();
let args: Vec<String> = std::env::args().collect();
let fix = args.contains(&"--fix".to_string());

View File

@ -1,6 +1,6 @@
// src/clients/desi/mod.rs
//
// DESI暗能量光谱仪光谱数据客户端 —— 纯通信层
// DESI暗能量光谱仪观测数据客户端 —— 纯通信层
// 职责仅限HTTP 请求SSRF 防护 + 重试、TAP VOTable 解析、HEALPix coadd FITS 字节下载
// 业务层(缓存、落盘、标识解析)在 services/spectra/desi.rs
//
@ -17,7 +17,7 @@
// 对齐 clients/sdss/mod.rs 范式:自建 reqwest::Client + safe_redirect_policy + 重试 + 超时。
use crate::clients::vo::{parse_votable_tabledata, FieldInfo};
use crate::services::spectra::common::DesiRelease;
use crate::services::observation::desi::DesiRelease;
use anyhow::{anyhow, Context};
use serde::{Deserialize, Serialize};
use std::time::Duration;
@ -94,10 +94,14 @@ impl DesiClient {
})
}
/// 锥形检索 DESI 光谱目标(经 Data Lab TAP q3c_radial_query
/// 锥形检索 DESI 光谱目标(经 Data Lab TAP
///
/// 返回 zpix 表中坐标半径范围内的目标行targetid/ra/dec/z/spectype/healpix/survey/program
/// 每个 healpix 像素对应一个 coadd 文件,可用 download_coadd 下载。
/// 返回 zpix 表中坐标半径范围内的目标行targetid/mean_fiber_ra/mean_fiber_dec/z/
/// spectype/healpix/survey/program。每个 healpix 像素对应一个 coadd 文件,可用 download_coadd 下载。
///
/// 实现说明Data Lab TAP 的 ADQL 解析器jsqlparser不支持 q3c_radial_query 函数,
/// 也不支持 CONTAINS/POINT/CIRCLE 几何函数,故与 SDSS 一致采用 RA/Dec BETWEEN 矩形框
/// 近似圆锥。zpix 表无 ra/dec 列,用的是 mean_fiber_ra/mean_fiber_dec该像素所有光纤的平均位置
pub async fn cone_search(
&self,
ra: f64,
@ -108,21 +112,29 @@ impl DesiClient {
) -> anyhow::Result<DesiConeResult> {
let table = release.datalab_table();
let top = max_records.clamp(1, 500);
// RA 方向按 cos(dec) 收缩以近似圆形(与 SDSS 同款策略)
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;
// Data Lab 支持 q3c锥形检索函数比 SDSS 的 BETWEEN 矩形框更精确)
let adql = format!(
"SELECT TOP {top} targetid, ra, dec, z, spectype, healpix, survey, program \
"SELECT TOP {top} targetid, mean_fiber_ra, mean_fiber_dec, z, spectype, healpix, survey, program \
FROM {table} \
WHERE q3c_radial_query(ra, dec, {center_ra}, {center_dec}, {radius})",
WHERE mean_fiber_ra BETWEEN {ra_min} AND {ra_max} \
AND mean_fiber_dec BETWEEN {dec_min} AND {dec_max}",
top = top,
table = table,
center_ra = ra,
center_dec = dec,
radius = radius_deg,
ra_min = ra_min,
ra_max = ra_max,
dec_min = dec_min,
dec_max = dec_max
);
info!(
"[DESI] ConeSearch {} ra={:.4} dec={:.4} radius={:.4}° TOP {}",
"[DESI] ConeSearch {} ra={:.4} dec={:.4} radius={:.4}° TOP {} (bbox)",
release.display(),
ra,
dec,
@ -346,8 +358,9 @@ fn row_to_desi(fields: &[FieldInfo], row: &[serde_json::Value]) -> DesiSpectrumR
DesiSpectrumRow {
targetid: get_str("targetid").unwrap_or_default(),
ra: get_f64("ra"),
dec: get_f64("dec"),
// zpix 表的坐标列名为 mean_fiber_ra/mean_fiber_dec
ra: get_f64("mean_fiber_ra").or_else(|| get_f64("ra")),
dec: get_f64("mean_fiber_dec").or_else(|| get_f64("dec")),
z: get_f64("z"),
spectype: get_str("spectype"),
healpix: get_i64("healpix").unwrap_or(0),
@ -450,7 +463,7 @@ mod tests {
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)
.cone_search(150.0, 2.0, 0.5, 5, DesiRelease::Dr1)
.await
.unwrap();
@ -464,7 +477,7 @@ mod tests {
println!("{}", r.coadd_url(DesiRelease::Dr1));
}
assert!(!result.rows.is_empty(), "高银纬区应有 DESI 光谱");
assert!(!result.rows.is_empty(), "DESI 主巡天区(低银纬)应有光谱");
assert!(result.rows[0].healpix > 0);
assert!(!result.rows[0].survey.is_empty());
}

View File

@ -1,6 +1,6 @@
// src/clients/gaia/mod.rs
//
// GaiaESA 盖亚任务)光谱数据客户端 —— 纯通信层
// GaiaESA 盖亚任务)观测数据客户端 —— 纯通信层
// 职责仅限HTTP 请求SSRF 防护 + 重试、TAP JSON 解析、DataLink ZIP 字节下载
// 业务层缓存、ZIP 解包、落盘)在 services/spectra/gaia.rs
//
@ -60,20 +60,20 @@ pub enum GaiaRetrievalType {
XpSampled,
/// 历元测光(光变曲线)
EpochPhotometry,
/// RVS 径向速度光谱
/// RVS 径向速度光谱DataLink 的 RVS 类型即返回 RVS 平均采样光谱)
Rvs,
/// RVS 平均光谱
MeanSpectrumRvs,
}
impl GaiaRetrievalType {
pub fn valid_values() -> &'static [&'static str] {
&["xp_continuous", "xp_sampled", "epoch_photometry", "rvs"]
}
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",
}
}
}
@ -145,35 +145,41 @@ impl GaiaClient {
radius_deg: f64,
max_records: i64,
xp_only: bool,
release: crate::services::spectra::common::GaiaRelease,
release: crate::services::observation::gaia::GaiaRelease,
) -> anyhow::Result<GaiaConeResult> {
let top = max_records.clamp(1, 2000);
let table = release.tap_table();
// xp_only 时多取行数供客户端过滤后再截断。
//
// 背景Gaia TAP 的 WHERE 子句中对 has_xp_continuous 等 boolean 列做相等
// 比较(=1会稳定触发服务端 PostgreSQL "current transaction is aborted"
// 错误HTTP 500这是 Gaia 服务端已知缺陷。因此把 has_xp 标志只放进
// SELECT 取回,在 Rust 侧按 has_xp_continuous==Some(true) 过滤,避免下推到 SQL。
let fetch_top = if xp_only { (top * 4).min(2000) } else { top };
// 注意DISTANCE 是 ADQL 保留函数名,不能用作未加引号的列别名,故用 dist
let mut adql = format!(
let 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,
WHERE 1=CONTAINS(POINT('ICRS',ra,dec), CIRCLE('ICRS',{ra},{dec},{radius})) \
ORDER BY dist ASC",
top = fetch_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 {}",
"[Gaia] ConeSearch {} ra={:.4} dec={:.4} radius={:.4}° xp_only={} fetch_TOP={} want={}",
release.display(),
ra,
dec,
radius_deg,
xp_only,
fetch_top,
top
);
@ -190,33 +196,46 @@ impl GaiaClient {
datatype: None,
})
.collect();
let rows: Vec<GaiaSourceRow> = tap
let mut rows: Vec<GaiaSourceRow> = tap
.data
.iter()
.map(|r| json_row_to_gaia(&fields, r))
.collect();
let row_count = tap.data.len();
let total_fetched = tap.data.len();
let truncated = total_fetched as i64 >= fetch_top;
// 客户端过滤 has_xp_continuous规避 Gaia TAP WHERE boolean 列缺陷)
if xp_only {
rows.retain(|r| r.has_xp_continuous == Some(true));
}
rows.truncate(top as usize);
let row_count = rows.len();
return Ok(GaiaConeResult {
rows,
fields,
row_count,
truncated: row_count as i64 >= top,
truncated,
});
}
// JSON 失败 → 尝试 VOTable 降级(请求时若服务端忽略 FORMAT=json
warn!("[Gaia] JSON 解析失败,尝试 VOTable 降级");
let votable = parse_votable_tabledata(&body, top)?;
let rows = votable
let votable = parse_votable_tabledata(&body, fetch_top)?;
let mut rows: Vec<GaiaSourceRow> = votable
.rows
.iter()
.map(|r| json_row_to_gaia(&votable.fields, r))
.collect();
let truncated = votable.truncated;
if xp_only {
rows.retain(|r| r.has_xp_continuous == Some(true));
}
rows.truncate(top as usize);
let row_count = rows.len();
Ok(GaiaConeResult {
rows,
fields: votable.fields,
row_count: votable.row_count,
truncated: votable.truncated,
row_count,
truncated,
})
}
@ -556,7 +575,7 @@ Source: Unable to create connection to database</li>
0.05,
5,
false,
crate::services::spectra::common::GaiaRelease::Dr3,
crate::services::observation::gaia::GaiaRelease::Dr3,
)
.await
.unwrap();

View File

@ -1,12 +1,14 @@
// src/clients/lamost/mod.rs
//
// LAMOST郭守敬望远镜 / 大天区面积多目标光纤光谱天文望远镜)光谱数据客户端 —— 纯通信层
// 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
// ConeSearch: GET {base}/{dr}/{ver}/{voservice|medvoservice}/conesearch?RA=&DEC=&SR= → VOTable
// - LRS低分辨率→ voservice
// - MRS中分辨率→ medvoservice
// FITS 下载: GET {base}/{dr}/{ver}/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 重试 + 超时。
@ -95,29 +97,36 @@ impl LamostClient {
/// 锥形检索:返回坐标半径范围内的 LAMOST 光谱列表
///
/// GET {base}/{dr}/{ver}/{voservice|medvoservice}/conesearch?RA={ra}&DEC={dec}&SR={radius_deg}
/// - LRS低分辨率→ voservice 子路径
/// - MRS中分辨率→ medvoservice 子路径
/// 服务端返回 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,
release: crate::services::observation::lamost::LamostRelease,
resolution: crate::services::observation::lamost::LamostResolution,
) -> anyhow::Result<LamostConeResult> {
if resolution == crate::services::spectra::common::LamostResolution::Mrs
&& !release.supports_mrs()
{
use crate::services::observation::lamost::LamostResolution;
if resolution == LamostResolution::Mrs && !release.supports_mrs() {
return Err(anyhow!(
"MRS中分辨率从 DR7 起才支持,{:?} 无 MRS 数据",
release
));
}
// LRS → voserviceMRS → medvoservice对应 LAMOST 官方服务路径)
let service_segment = match resolution {
LamostResolution::Lrs => "voservice",
LamostResolution::Mrs => "medvoservice",
};
let url = format!(
"{}/{}/{}/conesearch",
"{}/{}/{}/{}/conesearch",
self.base_url,
release.path_segment(),
release.version_segment(),
service_segment,
);
info!(
"[LAMOST] ConeSearch {} {} ra={:.4} dec={:.4} radius={:.4}°",
@ -166,7 +175,7 @@ impl LamostClient {
pub async fn download_fits(
&self,
obsid: i64,
release: crate::services::spectra::common::LamostRelease,
release: crate::services::observation::lamost::LamostRelease,
) -> anyhow::Result<Vec<u8>> {
let url = format!(
"{}/{}/{}/spectrum/fits/{}",
@ -355,7 +364,7 @@ mod tests {
#[tokio::test]
#[ignore = "需要网络访问"]
async fn test_live_cone_search() {
use crate::services::spectra::common::{LamostRelease, LamostResolution};
use crate::services::observation::lamost::{LamostRelease, LamostResolution};
let client = LamostClient::new("https://www.lamost.org", 60).unwrap();
// M31 附近DR10 LRS
let result = client
@ -385,7 +394,7 @@ mod tests {
#[tokio::test]
#[ignore = "需要网络访问"]
async fn test_live_download_fits() {
use crate::services::spectra::common::{LamostRelease, LamostResolution};
use crate::services::observation::lamost::{LamostRelease, LamostResolution};
let client = LamostClient::new("https://www.lamost.org", 60).unwrap();
// 先用 ConeSearch 找一个真实 obsid
let cone = client

View File

@ -262,7 +262,7 @@ impl LlmClient {
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim().to_string();
buffer = buffer[pos + 1..].to_string();
buffer.drain(..=pos);
if line.is_empty() || !line.starts_with("data: ") {
continue;
@ -370,7 +370,7 @@ impl LlmClient {
// 按行解析 SSE 事件
while let Some(line_end) = buffer.find('\n') {
let line = buffer[..line_end].trim().to_string();
buffer = buffer[line_end + 1..].to_string();
buffer.drain(..=line_end);
if line.is_empty() || !line.starts_with("data: ") {
continue;

View File

@ -1,6 +1,6 @@
// src/clients/sdss/mod.rs
//
// SDSS斯隆数字巡天光谱数据客户端 —— 纯通信层
// SDSS斯隆数字巡天观测数据客户端 —— 纯通信层
// 职责仅限HTTP 请求SSRF 防护 + 重试、TAP VOTable 解析、SAS FITS 字节下载
// 业务层(缓存、落盘)在 services/spectra/sdss.rs
//
@ -64,8 +64,8 @@ impl SdssSpectrumRow {
/// 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;
pub fn sas_url(&self, release: crate::services::observation::sdss::SdssRelease) -> String {
use crate::services::observation::sdss::SdssRelease;
let dr = release.sas_dr_segment();
match release {
SdssRelease::Dr16 | SdssRelease::Dr17 => {
@ -166,7 +166,7 @@ impl ApogeeStarRow {
/// field 中的 + 在 URL 中编码为 %2B。文件名格式为 {type}-dr17-{apogee_id}.fits不含 telescope/field
pub fn apogee_sas_url(
&self,
release: crate::services::spectra::common::SdssRelease,
release: crate::services::observation::sdss::SdssRelease,
data_type: &str,
) -> String {
let dr = release.sas_dr_segment();
@ -227,7 +227,7 @@ impl SdssClient {
dec: f64,
radius_deg: f64,
max_records: i64,
release: crate::services::spectra::common::SdssRelease,
release: crate::services::observation::sdss::SdssRelease,
) -> anyhow::Result<SdssConeResult> {
let table = release.datalab_table().ok_or_else(|| {
anyhow!(
@ -297,7 +297,7 @@ impl SdssClient {
mjd: i64,
fiberid: i64,
run2d: &str,
release: crate::services::spectra::common::SdssRelease,
release: crate::services::observation::sdss::SdssRelease,
) -> anyhow::Result<Vec<u8>> {
let row = SdssSpectrumRow {
specobjid: String::new(),
@ -349,7 +349,7 @@ impl SdssClient {
dec: f64,
radius_deg: f64,
max_records: i64,
release: crate::services::spectra::common::SdssRelease,
release: crate::services::observation::sdss::SdssRelease,
) -> anyhow::Result<ApogeeConeResult> {
let table = release.apogee_table().ok_or_else(|| {
anyhow!(
@ -411,7 +411,7 @@ impl SdssClient {
apogee_id: &str,
telescope: &str,
field: &str,
release: crate::services::spectra::common::SdssRelease,
release: crate::services::observation::sdss::SdssRelease,
data_type: &str,
) -> anyhow::Result<Vec<u8>> {
let row = ApogeeStarRow {
@ -623,7 +623,7 @@ mod tests {
#[test]
fn test_sas_url_sdss_legacy() {
use crate::services::spectra::common::SdssRelease;
use crate::services::observation::sdss::SdssRelease;
let row = SdssSpectrumRow {
specobjid: "x".into(),
plate: 2224,
@ -650,7 +650,7 @@ mod tests {
#[test]
fn test_sas_url_eboss() {
use crate::services::spectra::common::SdssRelease;
use crate::services::observation::sdss::SdssRelease;
let row = SdssSpectrumRow {
specobjid: "x".into(),
plate: 3606,
@ -671,7 +671,7 @@ mod tests {
#[test]
fn test_sas_url_dr18_no_prefix() {
use crate::services::spectra::common::SdssRelease;
use crate::services::observation::sdss::SdssRelease;
let row = SdssSpectrumRow {
specobjid: "x".into(),
plate: 10001,
@ -695,7 +695,7 @@ mod tests {
#[test]
fn test_sas_url_dr19_sdss_v() {
use crate::services::spectra::common::SdssRelease;
use crate::services::observation::sdss::SdssRelease;
let row = SdssSpectrumRow {
specobjid: "x".into(),
plate: 0,
@ -722,7 +722,7 @@ mod tests {
#[tokio::test]
#[ignore = "需要网络访问"]
async fn test_live_cone_search() {
use crate::services::spectra::common::SdssRelease;
use crate::services::observation::sdss::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)
@ -744,7 +744,7 @@ mod tests {
#[tokio::test]
#[ignore = "需要网络访问"]
async fn test_live_download_fits() {
use crate::services::spectra::common::SdssRelease;
use crate::services::observation::sdss::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)

View File

@ -135,14 +135,24 @@ impl Config {
.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::<u64>().ok())
.and_then(|v| {
v.parse::<u64>().ok().or_else(|| {
tracing::warn!("VIZIER_TIMEOUT_SECS '{}' 不是有效数字,使用默认值 60", v);
None
})
})
.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::<u64>().ok())
.and_then(|v| {
v.parse::<u64>().ok().or_else(|| {
tracing::warn!("LAMOST_TIMEOUT_SECS '{}' 不是有效数字,使用默认值 60", v);
None
})
})
.unwrap_or(60);
let gaia_tap_url = env::var("GAIA_TAP_URL")
@ -151,21 +161,36 @@ impl Config {
.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::<u64>().ok())
.and_then(|v| {
v.parse::<u64>().ok().or_else(|| {
tracing::warn!("GAIA_TIMEOUT_SECS '{}' 不是有效数字,使用默认值 90", v);
None
})
})
.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::<u64>().ok())
.and_then(|v| {
v.parse::<u64>().ok().or_else(|| {
tracing::warn!("SDSS_TIMEOUT_SECS '{}' 不是有效数字,使用默认值 90", v);
None
})
})
.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::<u64>().ok())
.and_then(|v| {
v.parse::<u64>().ok().or_else(|| {
tracing::warn!("DESI_TIMEOUT_SECS '{}' 不是有效数字,使用默认值 120", v);
None
})
})
.unwrap_or(120);
Config {

View File

@ -57,23 +57,7 @@ async fn main() -> anyhow::Result<()> {
info!("正在启动 AstroResearch 天文学文献辅助系统后端服务...");
// 1.5 静态注册 sqlite-vec 自动扩展
// SAFETY: sqlite3_vec_init 的函数签名严格符合 SQLite C API 自动扩展
// 回调规范 (sqlite3*, char**, const sqlite3_api_routines*)。transmute
// 将该函数指针转换为 sqlite3_auto_extension 所需的 Option<fn()> 类型。
// 该注册必须在任何数据库连接开启之前执行,保证所有 Connection
// 自动拥有 vec0 虚拟表能力。
unsafe {
libsqlite3_sys::sqlite3_auto_extension(Some(std::mem::transmute::<
*const (),
unsafe extern "C" fn(
*mut libsqlite3_sys::sqlite3,
*mut *const i8,
*const libsqlite3_sys::sqlite3_api_routines,
) -> i32,
>(
sqlite_vec::sqlite3_vec_init as *const ()
)));
}
astroresearch::utils::register_sqlite_vec_extension();
info!("sqlite-vec 自动扩展注册完成。");
// 2. 加载环境变量配置
@ -95,6 +79,8 @@ async fn main() -> anyhow::Result<()> {
];
if pwd.is_empty() {
anyhow::bail!("启动失败: ADMIN_PASSWORD 未设置。请在 .env 文件中设置一个强密码后重试。");
} else if pwd.len() > 128 {
anyhow::bail!("启动失败: ADMIN_PASSWORD 长度超过 128 个字符,请使用合理长度的密码。");
} else if pwd.len() < 6 {
warn!(
"⚠️ 安全警告: ADMIN_PASSWORD 长度仅 {} 个字符,属弱密码。请设至少 8 位的强密码。",
@ -126,8 +112,12 @@ async fn main() -> anyhow::Result<()> {
.foreign_keys(true)
.create_if_missing(true);
let db_pool_size: u32 = std::env::var("DB_POOL_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.max_connections(db_pool_size)
.connect_with(options)
.await?;
@ -140,8 +130,13 @@ async fn main() -> anyhow::Result<()> {
// 4.5 动态创建 vec0 向量虚拟表(维度可由环境变量 EMBEDDING_DIM 控制)
let embedding_dim: usize = std::env::var("EMBEDDING_DIM")
.unwrap_or_else(|_| "1536".to_string())
.parse()
.ok()
.and_then(|v| {
v.parse().ok().or_else(|| {
warn!("EMBEDDING_DIM '{}' 不是有效数字,使用默认值 1536", v);
None
})
})
.unwrap_or(1536);
// 检测并自愈:如果已存在的 vec_paper_chunks 维度与当前配置不一致,则重建该虚拟表并清空切片内容
@ -295,6 +290,7 @@ async fn main() -> anyhow::Result<()> {
gaia,
sdss,
desi,
observation_registry: Arc::new(astroresearch::services::observation::ObservationRegistry::default()),
llm,
medium_llm,
fast_llm,
@ -327,8 +323,9 @@ async fn main() -> anyhow::Result<()> {
session_permission_checkers: Arc::new(tokio::sync::RwLock::new(
std::collections::HashMap::new(),
)),
sessions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
login_rate_limiter: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
upload_rate_limiter: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
});
// 7. 设置 Axum 路由、CORS 头以及 React 仪表盘静态资源托管
@ -453,13 +450,17 @@ async fn main() -> anyhow::Result<()> {
.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支持坐标模式与标识符模式
// 观测数据检索、下载与缓存管理(跨 LAMOST/Gaia/SDSS/DESI × 跨 Spectrum/LightCurve/Photometry
.route("/observation/search", get(handlers::observation_search))
.route(
"/catalog/spectrum/download",
get(handlers::spectrum_download),
"/observation/download",
post(handlers::observation_download),
)
.route("/catalog/spectrum/list", get(handlers::spectrum_list))
.route(
"/observation/capabilities",
get(handlers::observation_capabilities),
)
.route("/observation/list", get(handlers::observation_list))
// 智能体路由
.route("/chat/agent", post(handlers::chat_agent))
.route("/chat/modes", get(handlers::get_agent_modes))

View File

@ -5,7 +5,7 @@
// ┌──────────────────────────────────────────────────────────────┐
// │ 1. 缓存层 query_adql_cached / query_table │
// │ 2. ADQL 安全 sanitize_identifier / escape_string_literal │
// │ 3. 几何查询 cone_search / cross_match
// │ 3. 几何查询 cone_searchnearest/all
// │ 4. 星表发现 VizierCatalog { search, describe, lookup } │
// │ 5. 数据导出 rows_to_csv │
// └──────────────────────────────────────────────────────────────┘
@ -16,7 +16,7 @@
use crate::clients::ads::AdsClient;
use crate::clients::cds::vizier::{FieldInfo, VizierClient, VizierQueryResult};
use anyhow::{anyhow, Result};
use sha1::{Digest, Sha1};
use sha2::{Digest, Sha256};
use sqlx::SqlitePool;
use std::time::Duration;
use tracing::{error, info, warn};
@ -90,7 +90,7 @@ pub async fn query_table(
// ── 缓存辅助 ──
fn hash_query(adql: &str, max_records: i64) -> String {
let mut hasher = Sha1::new();
let mut hasher = Sha256::new();
hasher.update(adql.as_bytes());
hasher.update(max_records.to_le_bytes());
format!("{:x}", hasher.finalize())
@ -182,6 +182,8 @@ pub fn escape_string_literal(s: &str) -> String {
// ═══════════════════════════════════════════════════════════════
/// 锥形检索
///
/// strategy="nearest" 时按角距离排序返回最近的源strategy="all" 时无序返回全部。
pub async fn cone_search(
pool: &SqlitePool,
client: &VizierClient,
@ -190,46 +192,29 @@ pub async fn cone_search(
radius_deg: f64,
table_name: &str,
max_records: i64,
nearest: bool,
) -> Result<VizierQueryResult> {
let table = sanitize_identifier(table_name)?;
let table_ref = format!("\"{}\"", table);
if !(0.0..=5.0).contains(&radius_deg) {
return Err(anyhow!("检索半径应在 0~5 度之间,当前: {}", radius_deg));
if !(0.0..=20.0).contains(&radius_deg) {
return Err(anyhow!("检索半径应在 0~20 度之间,当前: {}", radius_deg));
}
if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) {
return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec));
}
let adql = 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<VizierQueryResult> {
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
);
let adql = if nearest {
format!(
"SELECT TOP {} *, DISTANCE(POINT('ICRS', ra, dec), POINT('ICRS', {}, {})) AS separation_deg FROM {} WHERE 1=CONTAINS(POINT('ICRS', ra, dec), CIRCLE('ICRS', {}, {}, {})) ORDER BY separation_deg ASC",
max_records.max(1), ra, dec, table_ref, ra, dec, radius_deg
)
} else {
format!(
"SELECT TOP {} * FROM {} WHERE 1=CONTAINS(POINT('ICRS', ra, dec), CIRCLE('ICRS', {}, {}, {}))",
max_records.max(1), table_ref, ra, dec, radius_deg
)
};
query_adql_cached(pool, client, &adql, max_records.max(1)).await
}
@ -262,6 +247,14 @@ pub struct ExportResult {
pub column_count: usize,
}
#[derive(Debug)]
pub struct ExportToFileResult {
pub path: std::path::PathBuf,
pub row_count: usize,
pub column_count: usize,
pub size_bytes: usize,
}
pub struct VizierCatalog<'a> {
pub pool: &'a SqlitePool,
pub vizier: &'a VizierClient,
@ -323,6 +316,71 @@ impl<'a> VizierCatalog<'a> {
})
}
/// 构建导出 ADQLtable + columns → ADQL
pub fn build_export_adql(table: &str, columns: &str, limit: i64) -> Result<String> {
let table_ref = if table.contains('/') || table.contains(' ') {
format!("\"{}\"", sanitize_identifier(table)?)
} else {
sanitize_identifier(table)?
};
Ok(format!("SELECT TOP {} {} FROM {}", limit, columns, table_ref))
}
/// 导出星表数据到 CSV 文件
///
/// 优先使用 adql 参数;若未提供则从 table + columns 构建。
/// 文件保存到 `{library_dir}/vizier/{catalog_name}/{timestamp}.csv`。
pub async fn export_to_file(
&self,
library_dir: &str,
adql: Option<&str>,
table: Option<&str>,
columns: Option<&str>,
limit: i64,
) -> Result<ExportToFileResult> {
let adql_str = if let Some(a) = adql {
a.to_string()
} else if let Some(t) = table {
let cols = columns.unwrap_or("*");
Self::build_export_adql(t, cols, limit)?
} else {
return Err(anyhow!("export 需要 'adql' 或 'table' 参数"));
};
info!(
"[VizieR:export] ADQL: {}",
adql_str.chars().take(150).collect::<String>()
);
let export = self.export(&adql_str, limit).await?;
let catalog_name = table.unwrap_or("adql_query");
let safe_name: String = catalog_name
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let output_path = std::path::PathBuf::from(library_dir)
.join("vizier")
.join(&safe_name)
.join(format!("{}.csv", ts));
if let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow!("创建目录失败: {}", e))?;
}
tokio::fs::write(&output_path, &export.csv).await
.map_err(|e| anyhow!("写入文件失败: {}", e))?;
Ok(ExportToFileResult {
path: output_path,
row_count: export.row_count,
column_count: export.column_count,
size_bytes: export.csv.len(),
})
}
/// 通过文献 bibcode 查找关联的 VizieR 表
pub async fn lookup(&self, bibcode: &str, limit: usize) -> Result<Vec<CatalogEntry>> {
let ads = self.ads.ok_or_else(|| anyhow!("lookup 需要 ADS 客户端"))?;

View File

@ -5,6 +5,7 @@ pub mod citation;
pub mod download;
pub mod logging;
pub mod note;
pub mod observation;
pub mod paper;
pub mod parser;
pub mod pipeline;
@ -12,6 +13,5 @@ pub mod query_parser;
pub mod rag;
pub mod search;
pub mod session;
pub mod spectra;
pub mod translation;
pub mod vision;

View File

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

View File

@ -0,0 +1,249 @@
// src/services/observation/desi.rs
//
// DESI ObservationFetcher 实现
//
// 注册的 fetcher
// DesiSpectrumFetcher —— (Desi, Spectrum)HEALPix coadd一像素含多目标光谱
use crate::api::AppState;
use crate::clients::desi::DesiSpectrumRow;
use crate::services::observation::cache::{
cached_files_total_size, fetch_observation_cache, file_url_from_path, log_write_failure,
persist_bytes, write_observation_cache,
};
use crate::services::observation::fetcher::{Candidate, ObservationFetcher};
use crate::services::observation::types::{
Artifact, ObservationProduct, ProductSpec, ProductType, Source,
};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use std::time::Duration;
use tracing::{info, warn};
// ── 版本枚举 ──
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DesiRelease { Edr, Dr1 }
impl Default for DesiRelease {
fn default() -> Self { Self::Dr1 }
}
impl DesiRelease {
pub fn valid_values() -> &'static [&'static str] {
&["edr", "dr1"]
}
pub fn dr_segment(&self) -> &'static str {
match self { Self::Edr => "edr", Self::Dr1 => "dr1" }
}
pub fn specredux_ver(&self) -> &'static str {
match self { Self::Edr => "fuji", Self::Dr1 => "iron" }
}
pub fn datalab_table(&self) -> &'static str {
match self { Self::Edr => "desi_edr.zpix", Self::Dr1 => "desi_dr1.zpix" }
}
pub fn display(&self) -> &'static str {
match self { Self::Edr => "EDR", Self::Dr1 => "DR1" }
}
}
pub fn parse_desi_release(s: Option<&str>) -> Result<DesiRelease> {
Ok(match s.map(|x| x.to_lowercase()).as_deref() {
None => DesiRelease::Dr1,
Some("edr") => DesiRelease::Edr,
Some("dr1") => DesiRelease::Dr1,
Some(other) => return Err(anyhow!("不支持的 DESI release '{}',可选: {:?}", other, DesiRelease::valid_values())),
})
}
fn validate_cone_params(ra: f64, dec: f64, radius_deg: f64) -> Result<()> {
// NOIRLab Data Lab 无硬性半径上限,但大半径易超时
if !(radius_deg > 0.0 && radius_deg <= 30.0) {
return Err(anyhow!(
"检索半径应在 0~30 度之间(不含 0当前: {}。DESI zpix 建议检索半径 ≤1° 以避免超时",
radius_deg
));
}
if radius_deg > 1.0 {
warn!(
"[Observation] DESI cone radius {}° 超出建议范围≤1°大半径查询可能被服务端超时拒绝",
radius_deg
);
}
if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) {
return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec));
}
Ok(())
}
// ═══════════════════════════════════════════════════════════════
// DesiSpectrumFetcher
// ═══════════════════════════════════════════════════════════════
#[derive(Debug)]
pub struct DesiSpectrumFetcher;
#[async_trait]
impl ObservationFetcher for DesiSpectrumFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Desi, ProductType::Spectrum)
}
fn subtypes(&self) -> &'static [&'static str] {
&["coadd"]
}
fn releases(&self) -> &'static [&'static str] {
DesiRelease::valid_values()
}
fn default_release(&self) -> Option<&'static str> {
// 与 parse_desi_release(None) 的默认值保持一致
Some("dr1")
}
fn suggested_max_radius_deg(&self) -> f64 {
// 与 validate_cone_params 的建议范围一致
1.0
}
fn identifier_format(&self) -> Option<&'static str> {
Some("'survey-program-healpix',如 'main-dark-10050'")
}
async fn cone_search_raw(
&self,
state: &AppState,
ra: f64, dec: f64, radius_deg: f64,
release: Option<&str>, _subtype: Option<&str>,
) -> Result<Vec<Candidate>> {
validate_cone_params(ra, dec, radius_deg)?;
let rel = parse_desi_release(release)?;
let result = state.desi.cone_search(ra, dec, radius_deg, 50, rel).await?;
Ok(result.rows.iter().map(row_to_candidate).collect())
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
_subtype: Option<&str>,
) -> Result<Candidate> {
// 格式 "{survey}-{program}-{healpix}"
let parts: Vec<&str> = identifier.split('-').collect();
if parts.len() != 3 {
return Err(anyhow!(
"DESI source_id 应为 'survey-program-healpix' 格式(如 'main-dark-10050'),得到 '{}'",
identifier
));
}
let healpix: i64 = parts[2].parse()
.map_err(|_| anyhow!("healpix 非数字: '{}'", parts[2]))?;
if healpix < 0 {
return Err(anyhow!("healpix 应为非负整数,当前: {}", healpix));
}
Ok(Candidate {
source: Source::Desi,
source_id: identifier.to_string(),
label: format!("{} {} healpix {}", parts[0], parts[1], healpix),
ra: None, dec: None, distance: None,
raw: Some(serde_json::json!({
"survey": parts[0], "program": parts[1], "healpix": healpix
})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
release: Option<&str>,
_subtype: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let survey = candidate.raw.as_ref().and_then(|v| v.get("survey")).and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("DESI Candidate 缺 survey"))?;
let program = candidate.raw.as_ref().and_then(|v| v.get("program")).and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("DESI Candidate 缺 program"))?;
let healpix = candidate.raw.as_ref().and_then(|v| v.get("healpix")).and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("DESI Candidate 缺 healpix"))?;
let rel = parse_desi_release(release)?;
let product = ProductSpec::with_subtype(ProductType::Spectrum, "coadd");
let dr = rel.dr_segment();
let cache_key = format!("{}|{}-{}-{}", dr, survey, program, healpix);
let source_label = format!("{} {} healpix {}", survey, program, healpix);
// 1) 缓存命中
if !force {
if let Some((artifacts, meta)) = fetch_observation_cache(&state.db, Source::Desi, &product, &cache_key).await? {
if cached_files_total_size(&state.config.library_dir, &artifacts).is_some() {
info!("[DESI] coadd 缓存命中 ({})", cache_key);
return Ok(ObservationProduct {
source: Source::Desi, product,
source_id: cache_key, source_label, artifacts, source_meta: meta,
});
}
warn!("[DESI] 缓存记录存在但文件缺失,重新下载 ({})", cache_key);
}
}
// 2) 下载 coadd FITS无压缩
tokio::time::sleep(Duration::from_millis(50)).await;
let fits_bytes = state.desi.download_coadd(survey, program, healpix, rel).await?;
let rel_path = format!(
"Telescope/desi/{dr}/{survey}-{program}/{healpix}.fits",
dr = dr, survey = survey, program = program, healpix = healpix
);
let size = persist_bytes(&state.config.library_dir, &rel_path, &fits_bytes)?;
info!("[DESI] coadd 已保存 {} → {} ({}B)", cache_key, rel_path, size);
let meta = serde_json::json!({"survey": survey, "program": program, "healpix": healpix, "dr": dr});
let artifact = Artifact {
band: None, original_name: None,
file_path: rel_path.clone(),
file_url: file_url_from_path(&rel_path),
file_format: "fits".to_string(),
size_bytes: size, cached: false,
};
let meta_str = meta.to_string();
if let Err(e) = write_observation_cache(
&state.db, Source::Desi, &product, &cache_key, None, None,
std::slice::from_ref(&artifact), Some(&meta_str),
).await {
log_write_failure(Source::Desi.as_str(), "spectrum", e);
}
Ok(ObservationProduct {
source: Source::Desi, product,
source_id: cache_key, source_label,
artifacts: vec![artifact], source_meta: Some(meta),
})
}
}
fn row_to_candidate(row: &DesiSpectrumRow) -> Candidate {
Candidate {
source: Source::Desi,
source_id: format!("{}-{}-{}", row.survey, row.program, row.healpix),
label: format!("{} {} healpix {}", row.survey, row.program, row.healpix),
ra: row.ra, dec: row.dec, distance: None,
raw: Some(serde_json::json!({
"survey": row.survey, "program": row.program, "healpix": row.healpix
})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_cone_params() {
// 合法范围0~30°建议 ≤1°超出仅警告
assert!(validate_cone_params(180.0, 30.0, 0.1).is_ok());
assert!(validate_cone_params(180.0, 30.0, 1.0).is_ok()); // 建议上限内
assert!(validate_cone_params(180.0, 30.0, 5.0).is_ok()); // 超建议但仍合法
assert!(validate_cone_params(180.0, 30.0, 31.0).is_err()); // 超硬性上限
assert!(validate_cone_params(180.0, 91.0, 0.1).is_err()); // dec 越界
}
}

View File

@ -0,0 +1,198 @@
// src/services/observation/dispatch.rs
//
// 统一编排入口 —— 把 (坐标/标识符) + (Source, ProductType) 翻译成对 fetcher 的调用
//
// 这是 observation 服务的最上层api/observation.rs 和 agent tools 都通过本模块调用。
// 本模块只负责编排(选源、并发控制、失败聚合),不关心各源 HTTP 细节(那是 fetcher 的职责)。
use crate::api::AppState;
use crate::services::observation::fetcher::{pick_nearest, Candidate};
use crate::services::observation::registry::ObservationRegistry;
use crate::services::observation::types::{
DownloadFailure, FindStrategy, ObservationBatch, ObservationProduct, ObservationRequest,
ProductSpec,
};
use anyhow::Result;
use tracing::{info, warn};
/// 统一检索入口:按坐标 cone 检索候选源(**不下载**
///
/// 与 `download_observation` 的 ByCoordinates 模式相比,本函数只执行 cone_search_cached
/// 这一步,把命中的候选源列表返回给调用方预览,由调用方决定是否下载、下载哪些。
///
/// 标识符模式无需"检索"——标识符本身就是确定源,调用方直接走 download 即可。
pub async fn search_observation(
state: &AppState,
registry: &ObservationRegistry,
source: crate::services::observation::types::Source,
product: &ProductSpec,
ra: f64,
dec: f64,
radius_deg: f64,
release: Option<&str>,
) -> Result<Vec<Candidate>> {
let subtype = product.subtype.as_deref();
let fetcher = registry.get(source, product.product, subtype)?;
info!(
"[Observation] search source={:?} product={:?} ra={:.4} dec={:.4} radius={:.4}°",
source, product.product, ra, dec, radius_deg
);
let matched = fetcher
.cone_search_cached(state, ra, dec, radius_deg, release, subtype)
.await?;
Ok(matched)
}
/// 统一下载入口:按坐标或按标识符下载观测数据
///
/// 这是观测数据的唯一上层入口。调用方通过 `ObservationRequest` 选择模式force 控制是否忽略缓存。
pub async fn download_observation(
state: &AppState,
registry: &ObservationRegistry,
request: &ObservationRequest,
force: bool,
) -> Result<ObservationBatch> {
match request {
ObservationRequest::ByCoordinates {
source, product, ra, dec, radius_deg, strategy, release,
} => {
download_by_coordinates(
state, registry, *source, product, *ra, *dec, *radius_deg,
*strategy, release.as_deref(), force,
).await
}
ObservationRequest::ByIdentifier {
source, product, identifiers, release,
} => {
download_by_identifiers(
state, registry, *source, product, identifiers, release.as_deref(), force,
).await
}
}
}
/// 按坐标cone 检索 → 选源 → 逐个下载
async fn download_by_coordinates(
state: &AppState,
registry: &ObservationRegistry,
source: crate::services::observation::types::Source,
product: &ProductSpec,
ra: f64, dec: f64, radius_deg: f64,
strategy: FindStrategy,
release: Option<&str>,
force: bool,
) -> Result<ObservationBatch> {
let subtype = product.subtype.as_deref();
let fetcher = registry.get(source, product.product, subtype)?;
info!(
"[Observation] by_coords source={:?} product={:?} ra={:.4} dec={:.4} radius={:.4}° strategy={:?}",
source, product.product, ra, dec, radius_deg, strategy
);
let matched = fetcher.cone_search_cached(state, ra, dec, radius_deg, release, subtype).await?;
let matched_count = matched.len();
if matched_count == 0 {
return Ok(ObservationBatch {
source, product: product.clone(),
ra: Some(ra), dec: Some(dec), radius_deg: Some(radius_deg),
matched_count: 0, products: Vec::new(), failures: Vec::new(),
});
}
let targets: Vec<usize> = match strategy {
FindStrategy::Nearest => vec![pick_nearest(&matched, ra, dec)],
FindStrategy::All => (0..matched.len()).collect(),
};
let mut products = Vec::with_capacity(targets.len());
let mut failures = Vec::new();
for idx in targets {
let candidate = &matched[idx];
match fetcher.fetch(state, candidate, release, subtype, force).await {
Ok(p) => products.push(p),
Err(e) => {
let label = candidate.label.clone();
warn!("[Observation] 下载失败 {}: {}", label, e);
failures.push(DownloadFailure {
source_label: label,
error: format!("{:#}", e),
});
}
}
}
Ok(ObservationBatch {
source, product: product.clone(),
ra: Some(ra), dec: Some(dec), radius_deg: Some(radius_deg),
matched_count, products, failures,
})
}
/// 按标识符:解析各源标识字符串 → 逐个下载(跳过 cone 检索)
async fn download_by_identifiers(
state: &AppState,
registry: &ObservationRegistry,
source: crate::services::observation::types::Source,
product: &ProductSpec,
identifiers: &[String],
release: Option<&str>,
force: bool,
) -> Result<ObservationBatch> {
let subtype = product.subtype.as_deref();
let fetcher = registry.get(source, product.product, subtype)?;
info!(
"[Observation] by_id source={:?} product={:?} count={} release={:?}",
source, product.product, identifiers.len(), release
);
let mut products = Vec::with_capacity(identifiers.len());
let mut failures = Vec::new();
for id in identifiers {
// 先把标识符解析为 Candidate验证格式 + 提取字段)
let candidate = match fetcher.resolve_identifier(id, release, subtype).await {
Ok(c) => c,
Err(e) => {
warn!("[Observation] 标识符解析失败 {}: {}", id, e);
failures.push(DownloadFailure {
source_label: id.clone(),
error: format!("{:#}", e),
});
continue;
}
};
match fetcher.fetch(state, &candidate, release, subtype, force).await {
Ok(p) => products.push(p),
Err(e) => {
warn!("[Observation] 下载失败 {}: {}", id, e);
failures.push(DownloadFailure {
source_label: id.clone(),
error: format!("{:#}", e),
});
}
}
}
Ok(ObservationBatch {
source, product: product.clone(),
ra: None, dec: None, radius_deg: None,
matched_count: products.len() + failures.len(),
products, failures,
})
}
/// 便捷:从单个标识符构造请求并下载(供 CLI / 简单场景使用)
pub async fn download_one(
state: &AppState,
registry: &ObservationRegistry,
source: crate::services::observation::types::Source,
product: ProductSpec,
identifier: &str,
release: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let subtype = product.subtype.as_deref();
let fetcher = registry.get(source, product.product, subtype)?;
let candidate = fetcher.resolve_identifier(identifier, release, subtype).await?;
fetcher.fetch(state, &candidate, release, subtype, force).await
}

View File

@ -0,0 +1,348 @@
// src/services/observation/fetcher.rs
//
// ObservationFetcher trait —— 各 (Source, ProductType) 组合的统一获取接口
//
// 设计核心:
// 1. 每个 (Source, ProductType) 组合实现一个 fetcher注册到 Registry
// 2. cone 缓存逻辑hash + TTL fetch + write由 trait 默认方法模板化,
// 消除各源 4 份重复代码;各源只实现 cone_search_raw纯检索
// 3. 异构的各源行类型LamostSpectrumRow/GaiaSourceRow/...)统一归一化为 Candidate
// 4. 新增数据源/产品类型 = 新建 fetcher 文件 + registry 加一行OCP对扩展开放对修改关闭
use crate::api::AppState;
use crate::services::observation::types::{ObservationProduct, ProductType, Source};
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
use sqlx::SqlitePool;
use tracing::{error, warn};
// ═══════════════════════════════════════════════════════════════
// 1. Candidate —— cone 检索命中的归一化候选源
// ═══════════════════════════════════════════════════════════════
/// cone 检索命中的归一化候选源(跨源统一)
///
/// 各源的行类型LamostSpectrumRow/GaiaSourceRow/SdssSpectrumRow/DesiSpectrumRow
/// 在 cone_search_raw 里转成 Candidatefetch 时从 raw 提取源专属字段。
/// 这样上层编排dispatch.rs无需知道各源差异只与 Candidate 打交道。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Candidate {
pub source: Source,
/// 归一化源标识(与 observation_cache 去重键一致)
pub source_id: String,
/// 人类可读标签
pub label: String,
pub ra: Option<f64>,
pub dec: Option<f64>,
/// 与查询中心的角距离None 时由上层按坐标现场计算
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance: Option<f64>,
/// 源专属字段obsid/source_id/plate-mjd-fiber/healpix-survey-program...
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<serde_json::Value>,
}
// ═══════════════════════════════════════════════════════════════
// 2. ObservationFetcher trait
// ═══════════════════════════════════════════════════════════════
/// 单个 (Source, ProductType) 组合的获取策略
///
/// 实现者只需关注三件事:
/// 1. cone_search_raw —— 怎么从坐标拿到候选源
/// 2. resolve_identifier —— 怎么从标识符构造候选源
/// 3. fetch —— 怎么把候选源下载成 ObservationProduct
///
/// 缓存、重试、选源等通用编排由 trait 默认方法和 dispatch.rs 负责。
#[async_trait]
pub trait ObservationFetcher: Send + Sync + std::fmt::Debug {
/// 该 fetcher 处理的 (源, 产品类型) 组合
fn key(&self) -> (Source, ProductType);
/// 支持的子类型(用于工具描述和参数校验),空表示无子类型
fn subtypes(&self) -> &'static [&'static str] {
&[]
}
/// 支持的数据发布版本列表(小写字符串,用于前端下拉与参数校验),空表示不区分版本
fn releases(&self) -> &'static [&'static str] {
&[]
}
/// 默认版本(`release` 参数为 None 时使用None 表示无版本概念
fn default_release(&self) -> Option<&'static str> {
None
}
/// 坐标模式检索半径的建议上限(度),用于前端表单提示与服务端警告
///
/// 各源主表规模差异极大Gaia/SDSS/DESI 主表上亿行,大范围查询易超时),
/// 因此建议上限因源而异。超出仅警告不拒绝;硬性拒绝见 `hard_max_radius_deg`。
fn suggested_max_radius_deg(&self) -> f64 {
1.0
}
/// 坐标模式检索半径的硬性上限(度),超出会返回错误
///
/// 默认 30°可覆盖全天区合理导航范围。各源一般无需覆写。
fn hard_max_radius_deg(&self) -> f64 {
30.0
}
/// 标识符格式的人类可读说明(用于错误信息和工具描述)
fn identifier_format(&self) -> Option<&'static str> {
None
}
/// 是否支持坐标模式(默认 true少数只支持标识符的源可覆写为 false
fn supports_coordinates(&self) -> bool {
true
}
/// 是否支持标识符模式(默认 true
fn supports_identifiers(&self) -> bool {
true
}
// ── 检索(各源实现) ──
/// 纯 cone 检索(不查缓存)—— 各源实现自己的端点调用与行映射
async fn cone_search_raw(
&self,
state: &AppState,
ra: f64,
dec: f64,
radius_deg: f64,
release: Option<&str>,
subtype: Option<&str>,
) -> Result<Vec<Candidate>>;
/// 标识符模式:验证并归一化为 Candidate不发起下载
async fn resolve_identifier(
&self,
identifier: &str,
release: Option<&str>,
subtype: Option<&str>,
) -> Result<Candidate>;
// ── 下载(各源实现) ──
/// 下载一个候选源的产物(返回多文件 ObservationProduct
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
release: Option<&str>,
subtype: Option<&str>,
force: bool,
) -> Result<ObservationProduct>;
// ── cone 缓存(模板方法,子类一般不覆写) ──
/// cone 缓存键前缀:`{source}|{product}|cone|`
fn cone_cache_prefix(&self) -> String {
let (s, p) = self.key();
format!("{}|{}|cone|", s.as_str(), p.as_str())
}
/// 带缓存 + 速率节流的 cone 检索(模板方法)
///
/// 复用 vizier_query_cache 表(与历史设计一致),用 cache_prefix 区分各源各产品。
/// 缓存命中反序列化为 Vec<Candidate>;未命中调 cone_search_raw 并写回。
async fn cone_search_cached(
&self,
state: &AppState,
ra: f64,
dec: f64,
radius_deg: f64,
release: Option<&str>,
subtype: Option<&str>,
) -> Result<Vec<Candidate>> {
let query_hash = self.cone_cache_hash(ra, dec, radius_deg, release, subtype);
if let Some(cached) = fetch_cone_cache(&state.db, &query_hash).await? {
tracing::info!(
"[Observation] cone 缓存命中 ({:?}/{:?}, hash={:.12})",
self.key().0,
self.key().1,
query_hash
);
return Ok(cached);
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let result = self
.cone_search_raw(state, ra, dec, radius_deg, release, subtype)
.await?;
if let Err(e) = write_cone_cache(&state.db, &query_hash, &self.cone_cache_prefix(), &result).await {
error!(
"[Observation] 写入 cone 缓存失败 ({:?}/{:?}): {}",
self.key().0,
self.key().1,
e
);
}
Ok(result)
}
/// 计算 cone 缓存的 sha1 hashprefix + 坐标 + release + subtype
fn cone_cache_hash(
&self,
ra: f64,
dec: f64,
radius_deg: f64,
release: Option<&str>,
subtype: Option<&str>,
) -> String {
let mut h = Sha1::new();
h.update(self.cone_cache_prefix().as_bytes());
h.update(ra.to_le_bytes());
h.update(dec.to_le_bytes());
h.update(radius_deg.to_le_bytes());
if let Some(r) = release {
h.update(r.as_bytes());
}
if let Some(st) = subtype {
h.update(st.as_bytes());
}
format!("{:x}", h.finalize())
}
}
// ═══════════════════════════════════════════════════════════════
// 3. cone 缓存通用函数vizier_query_cache 复用,供 trait 默认方法调用)
// ═══════════════════════════════════════════════════════════════
const CONE_TTL_SECS: i64 = 7 * 24 * 3600;
#[derive(sqlx::FromRow)]
struct ConeCacheRow {
result_json: String,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn fetch_cone_cache(
pool: &SqlitePool,
query_hash: &str,
) -> Result<Option<Vec<Candidate>>> {
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::anyhow!("查询 cone 缓存失败: {}", e))?;
match row {
Some(r) => {
if let Some(exp) = r.expires_at {
if exp < chrono::Utc::now() {
warn!("[Observation] cone 缓存已过期 (hash={:.12})", query_hash);
return Ok(None);
}
}
let candidates: Vec<Candidate> = serde_json::from_str(&r.result_json)
.map_err(|e| anyhow::anyhow!("反序列化 cone 缓存失败: {}", e))?;
Ok(Some(candidates))
}
None => Ok(None),
}
}
async fn write_cone_cache(
pool: &SqlitePool,
query_hash: &str,
prefix: &str,
candidates: &[Candidate],
) -> Result<()> {
let result_json = serde_json::to_string(candidates)?;
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(format!("{}cone", prefix.trim_end_matches('|')))
.bind(candidates.len() as i64)
.bind(&result_json)
.bind(expires_at)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("写入 cone 缓存失败: {}", e))?;
Ok(())
}
// ═══════════════════════════════════════════════════════════════
// 4. 通用几何辅助(供各源 fetcher 和 dispatch 复用)
// ═══════════════════════════════════════════════════════════════
/// 球面角距离(大圆弧,度)
pub 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()
}
/// 从候选源列表中选角距离最近的一条索引distance 优先,缺失则按坐标现场计算)
pub fn pick_nearest(candidates: &[Candidate], center_ra: f64, center_dec: f64) -> usize {
let mut best_idx = 0;
let mut best_dist = f64::INFINITY;
for (i, c) in candidates.iter().enumerate() {
let d = c.distance.unwrap_or_else(|| match (c.ra, c.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
}
#[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 mk = |distance: Option<f64>| Candidate {
source: Source::Lamost,
source_id: String::new(),
label: String::new(),
ra: None,
dec: None,
distance,
raw: None,
};
let rows = vec![mk(Some(0.5)), mk(Some(0.1)), mk(Some(0.9))];
assert_eq!(pick_nearest(&rows, 0.0, 0.0), 1);
}
#[test]
fn test_pick_nearest_without_distance_uses_coords() {
let rows = vec![
Candidate {
source: Source::Lamost, source_id: String::new(), label: String::new(),
ra: Some(10.5), dec: Some(41.0), distance: None, raw: None,
},
Candidate {
source: Source::Lamost, source_id: String::new(), label: String::new(),
ra: Some(10.51), dec: Some(41.01), distance: None, raw: None,
},
];
assert_eq!(pick_nearest(&rows, 10.5, 41.0), 0);
}
}

View File

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

View File

@ -0,0 +1,334 @@
// src/services/observation/lamost.rs
//
// LAMOST ObservationFetcher 实现
//
// 当前注册的 fetcher
// LamostSpectrumFetcher —— (Lamost, Spectrum)LRS/MRS 双分辨率
use crate::api::AppState;
use crate::clients::lamost::LamostSpectrumRow;
use crate::services::observation::cache::{
cached_files_total_size, fetch_observation_cache, file_url_from_path, log_write_failure,
persist_bytes, write_observation_cache,
};
use crate::services::observation::fetcher::{Candidate, ObservationFetcher};
use crate::services::observation::types::{
Artifact, ObservationProduct, ProductSpec, ProductType, Source,
};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use std::io::Read;
use std::time::Duration;
use tracing::{info, warn};
// ── 版本与分辨率枚举(领域知识) ──
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LamostRelease {
Dr5, Dr6, Dr7, Dr8, Dr9, Dr10, Dr11,
}
impl Default for LamostRelease {
fn default() -> Self { Self::Dr10 }
}
impl LamostRelease {
pub fn valid_values() -> &'static [&'static str] {
&["dr5", "dr6", "dr7", "dr8", "dr9", "dr10", "dr11"]
}
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",
}
}
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",
}
}
pub fn display(&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",
}
}
pub fn supports_mrs(&self) -> bool {
!matches!(self, Self::Dr5 | Self::Dr6)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LamostResolution { Lrs, Mrs }
impl LamostResolution {
pub fn valid_values() -> &'static [&'static str] {
&["lrs", "mrs"]
}
pub fn as_str(&self) -> &'static str {
match self { Self::Lrs => "lrs", Self::Mrs => "mrs" }
}
pub fn display(&self) -> &'static str {
match self { Self::Lrs => "LRS", Self::Mrs => "MRS" }
}
}
pub fn parse_lamost_release(s: Option<&str>) -> Result<LamostRelease> {
Ok(match s.map(|x| x.to_lowercase()).as_deref() {
None => LamostRelease::Dr10,
Some("dr5") => LamostRelease::Dr5,
Some("dr6") => LamostRelease::Dr6,
Some("dr7") => LamostRelease::Dr7,
Some("dr8") => LamostRelease::Dr8,
Some("dr9") => LamostRelease::Dr9,
Some("dr10") => LamostRelease::Dr10,
Some("dr11") => LamostRelease::Dr11,
Some(other) => return Err(anyhow!("不支持的 LAMOST release '{}',可选: {:?}", other, LamostRelease::valid_values())),
})
}
pub fn parse_lamost_resolution(subtype: Option<&str>) -> Result<LamostResolution> {
Ok(match subtype.map(|x| x.to_lowercase()).as_deref() {
None => LamostResolution::Lrs,
Some("lrs") | Some("low") => LamostResolution::Lrs,
Some("mrs") | Some("medium") => LamostResolution::Mrs,
Some(other) => return Err(anyhow!("不支持的 LAMOST subtype '{}',可选: {:?}", other, LamostResolution::valid_values())),
})
}
fn validate_cone_params(ra: f64, dec: f64, radius_deg: f64) -> Result<()> {
if !(radius_deg > 0.0 && radius_deg <= 30.0) {
return Err(anyhow!(
"检索半径应在 0~30 度之间(不含 0当前: {}。LAMOST 官方矩形检索建议 ≤10 平方度(≈半径 1.78°)",
radius_deg
));
}
if radius_deg > 5.0 {
warn!(
"[Observation] LAMOST cone radius {}° 超出建议范围≤5°可能命中大量行导致响应缓慢",
radius_deg
);
}
if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) {
return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec));
}
Ok(())
}
// ═══════════════════════════════════════════════════════════════
// LamostSpectrumFetcher
// ═══════════════════════════════════════════════════════════════
#[derive(Debug)]
pub struct LamostSpectrumFetcher;
#[async_trait]
impl ObservationFetcher for LamostSpectrumFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Lamost, ProductType::Spectrum)
}
fn subtypes(&self) -> &'static [&'static str] {
&["lrs", "mrs"]
}
fn releases(&self) -> &'static [&'static str] {
LamostRelease::valid_values()
}
fn default_release(&self) -> Option<&'static str> {
// 与 parse_lamost_release(None) 的默认值保持一致
Some("dr10")
}
fn suggested_max_radius_deg(&self) -> f64 {
// 与 validate_cone_params 的建议范围一致≤5° 仅警告不拒绝)
5.0
}
fn identifier_format(&self) -> Option<&'static str> {
Some("obsid 数字,如 '438809089'")
}
async fn cone_search_raw(
&self,
state: &AppState,
ra: f64, dec: f64, radius_deg: f64,
release: Option<&str>, subtype: Option<&str>,
) -> Result<Vec<Candidate>> {
validate_cone_params(ra, dec, radius_deg)?;
let rel = parse_lamost_release(release)?;
let res = parse_lamost_resolution(subtype)?;
if res == LamostResolution::Mrs && !rel.supports_mrs() {
return Err(anyhow!("MRS中分辨率从 DR7 起才支持,{:?} 无 MRS 数据", rel));
}
let result = state.lamost.cone_search(ra, dec, radius_deg, rel, res).await?;
Ok(result.rows.iter().map(|r| row_to_candidate(r, res.as_str())).collect())
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
subtype: Option<&str>,
) -> Result<Candidate> {
let obsid: i64 = identifier.trim().parse()
.map_err(|_| anyhow!("LAMOST source_id 应为纯数字 obsid得到 '{}'", identifier))?;
if obsid <= 0 {
return Err(anyhow!("obsid 应为正整数,当前: {}", obsid));
}
let res = parse_lamost_resolution(subtype)?.as_str().to_string();
Ok(Candidate {
source: Source::Lamost,
source_id: obsid.to_string(),
label: format!("obsid {}", obsid),
ra: None,
dec: None,
distance: None,
raw: Some(serde_json::json!({"obsid": obsid, "resolution": res})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
release: Option<&str>,
subtype: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let obsid = candidate.raw.as_ref()
.and_then(|v| v.get("obsid"))
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("LAMOST Candidate 缺 obsid 字段"))?;
let rel = parse_lamost_release(release)?;
let res = parse_lamost_resolution(subtype)?;
let product = ProductSpec::with_subtype(ProductType::Spectrum, res.as_str());
let dr = rel.path_segment();
let ver = rel.version_segment();
let res_str = res.as_str();
let cache_key = format!("{}|{}|{}|{}", dr, ver, res_str, obsid);
let source_label = format!("obsid {}", obsid);
// 1) 缓存命中
if !force {
if let Some((artifacts, meta)) = fetch_observation_cache(&state.db, Source::Lamost, &product, &cache_key).await? {
if let Some(_) = cached_files_total_size(&state.config.library_dir, &artifacts) {
info!("[LAMOST] 光谱缓存命中 (obsid={})", obsid);
return Ok(ObservationProduct {
source: Source::Lamost, product,
source_id: cache_key, source_label, artifacts, source_meta: meta,
});
}
warn!("[LAMOST] 缓存记录存在但文件缺失,重新下载 (obsid={})", obsid);
}
}
// 2) 下载 + 解压
tokio::time::sleep(Duration::from_millis(50)).await;
let gz_bytes = state.lamost.download_fits(obsid, rel).await?;
let fits_bytes = decompress_if_gzip(&gz_bytes)?;
// 3) 落盘
let rel_path = format!(
"Telescope/lamost/spectrum/{res}/{dr}/{ver}/{obsid}.fits",
res = res_str, dr = dr, ver = ver, obsid = obsid
);
let size = persist_bytes(&state.config.library_dir, &rel_path, &fits_bytes)?;
info!("[LAMOST] FITS 已保存 obsid={} → {} ({}B)", obsid, rel_path, size);
let meta = serde_json::json!({"obsid": obsid, "dr": dr, "version": ver, "resolution": res_str});
let artifact = Artifact {
band: None, original_name: None,
file_path: rel_path.clone(),
file_url: file_url_from_path(&rel_path),
file_format: "fits".to_string(),
size_bytes: size, cached: false,
};
let meta_str = meta.to_string();
if let Err(e) = write_observation_cache(
&state.db, Source::Lamost, &product, &cache_key, None, None,
std::slice::from_ref(&artifact), Some(&meta_str),
).await {
log_write_failure(Source::Lamost.as_str(), "spectrum", e);
}
Ok(ObservationProduct {
source: Source::Lamost, product,
source_id: cache_key, source_label,
artifacts: vec![artifact], source_meta: Some(meta),
})
}
}
fn row_to_candidate(row: &LamostSpectrumRow, resolution: &str) -> Candidate {
let obsid = row.obsid;
Candidate {
source: Source::Lamost,
source_id: obsid.to_string(),
label: format!("obsid {}", obsid),
ra: row.ra_obs,
dec: row.dec_obs,
distance: None,
raw: Some(serde_json::json!({"obsid": obsid, "resolution": resolution})),
}
}
fn decompress_if_gzip(bytes: &[u8]) -> Result<Vec<u8>> {
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())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_cone_params() {
// 合法范围0~30°
assert!(validate_cone_params(10.0, 41.0, 0.1).is_ok());
assert!(validate_cone_params(10.0, 41.0, 5.0).is_ok()); // 建议上限内
assert!(validate_cone_params(10.0, 41.0, 29.0).is_ok()); // 超建议但仍合法
assert!(validate_cone_params(10.0, 41.0, 31.0).is_err()); // 超硬性上限
assert!(validate_cone_params(10.0, 91.0, 0.1).is_err()); // dec 越界
}
#[test]
fn test_decompress_if_gzip_plain() {
let plain = b"SIMPLE = T";
let out = decompress_if_gzip(plain).unwrap();
assert_eq!(out, plain);
}
#[test]
fn test_decompress_if_gzip_compressed() {
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");
}
#[test]
fn test_row_to_candidate() {
let row = LamostSpectrumRow {
obsid: 438809089, designation: None, ra_obs: Some(10.5), dec_obs: Some(41.0),
z: None, class: None, subclass: None,
sn_u: None, sn_g: None, sn_r: None, sn_i: None, sn_z: None,
};
let c = row_to_candidate(&row, "lrs");
assert_eq!(c.source_id, "438809089");
assert_eq!(c.ra.unwrap(), 10.5);
}
}

View File

@ -0,0 +1,44 @@
// src/services/observation/mod.rs
//
// 观测数据业务服务层 —— 跨数据源 × 跨产品类型统一编排
//
// 架构(双轴正交):
// SourceLAMOST/Gaia/SDSS/DESI...× ProductTypeSpectrum/LightCurve/Photometry/Image...
// 每个有效组合实现一个 ObservationFetcher注册到 ObservationRegistry。
//
// 模块布局:
// types.rs —— Source/ProductType/ProductSpec/Artifact/ObservationProduct 等通用类型
// cache.rs —— observation_cache 表读写 + 落盘辅助
// fetcher.rs —— ObservationFetcher trait + Candidate + cone 缓存模板方法
// registry.rs —— ObservationRegistryfetcher 注册表)
// dispatch.rs —— search_observation仅检索+ download_observation检索并下载
// lamost.rs —— LamostSpectrumFetcher
// gaia.rs —— GaiaSpectrumFetcher + GaiaLightCurveFetcher
// sdss.rs —— SdssSpectrumFetcher + SdssApogeeFetcher
// desi.rs —— DesiSpectrumFetcher
//
// 外部统一通过 dispatch::{search_observation, download_observation} + ObservationRequest 调用。
//
// 新增数据源/产品类型步骤OCP纯加法不修改现有 fetcher/dispatch
// 1. 新建 {source}.rs实现 ObservationFetcher trait
// 2. 在 registry.rs::default() 加一行 register!({YourFetcher})
// 3. 在 types.rs Source/ProductType 枚举加变体(若为新源/新产品)
pub mod cache;
pub mod desi;
pub mod dispatch;
pub mod fetcher;
pub mod gaia;
pub mod lamost;
pub mod registry;
pub mod sdss;
pub mod types;
// 统一入口 re-export
pub use dispatch::{download_observation, download_one, search_observation};
pub use fetcher::{Candidate, ObservationFetcher};
pub use registry::{CapabilitySpec, ObservationRegistry};
pub use types::{
Artifact, DownloadFailure, FindStrategy, ObservationBatch, ObservationProduct,
ObservationRequest, ProductSpec, ProductType, Source,
};

View File

@ -0,0 +1,212 @@
// src/services/observation/registry.rs
//
// ObservationRegistry —— fetcher 注册表
//
// 设计:
// - 启动时注册所有 (Source, ProductType) → fetcher 的映射
// - 新增数据源/产品类型 = 新建 fetcher 文件 + 在 default() 里加一行注册
// - 查询时按 (Source, ProductType) 取 fetcher找不到则返回清晰错误
//
// 注意SDSS 的 specobj 与 APOGEE 共用 (Sdss, Spectrum) key但 subtype 不同。
// registry 用 (Source, ProductType) → Vec<Arc<dyn Fetcher>>dispatch 再按 subtype 二级筛选。
use crate::services::observation::desi::DesiSpectrumFetcher;
use crate::services::observation::fetcher::ObservationFetcher;
use crate::services::observation::gaia::{GaiaLightCurveFetcher, GaiaSpectrumFetcher};
use crate::services::observation::lamost::LamostSpectrumFetcher;
use crate::services::observation::sdss::{SdssApogeeFetcher, SdssSpectrumFetcher};
use crate::services::observation::types::{ProductType, Source};
use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::sync::Arc;
pub struct ObservationRegistry {
/// (Source, ProductType) → 该组合下的所有 fetcher可能多个按 subtype 区分)
fetchers: HashMap<(Source, ProductType), Vec<Arc<dyn ObservationFetcher>>>,
}
impl ObservationRegistry {
/// 构建默认注册表(项目启动时调用一次)
pub fn default() -> Self {
let mut fetchers: HashMap<(Source, ProductType), Vec<Arc<dyn ObservationFetcher>>> =
HashMap::new();
// 注册宏:简化加 fetcher 的样板
macro_rules! register {
($fetcher:expr) => {{
let f: Arc<dyn ObservationFetcher> = Arc::new($fetcher);
fetchers.entry(f.key()).or_default().push(f);
}};
}
// LAMOST
register!(LamostSpectrumFetcher);
// Gaia
register!(GaiaSpectrumFetcher);
register!(GaiaLightCurveFetcher);
// SDSSspecobj 与 APOGEE 共用 (Sdss, Spectrum) key按 subtype 二级区分)
register!(SdssSpectrumFetcher);
register!(SdssApogeeFetcher);
// DESI
register!(DesiSpectrumFetcher);
Self { fetchers }
}
/// 按 (Source, ProductType) + subtype 取 fetcher
///
/// subtype 匹配规则:
/// - subtype = None返回该 key 下首个 fetcher默认
/// - subtype = Some(s):优先找 subtypes() 含 s 的;找不到则返回首个(让 fetcher 自己报错)
pub fn get(
&self,
source: Source,
product: ProductType,
subtype: Option<&str>,
) -> Result<Arc<dyn ObservationFetcher>> {
let candidates = self
.fetchers
.get(&(source, product))
.ok_or_else(|| anyhow!(
"{:?} 暂不支持 {:?} 产品。支持的组合:{}",
source,
product,
self.list_supported_combinations()
))?;
// subtype 精确匹配
if let Some(st) = subtype {
let st_lower = st.to_lowercase();
for f in candidates {
if f.subtypes().iter().any(|s| s.eq_ignore_ascii_case(&st_lower)) {
return Ok(f.clone());
}
}
// 未找到匹配的 subtype — 报错
let valid: Vec<&str> = candidates.iter()
.flat_map(|f| f.subtypes())
.copied()
.collect();
return Err(anyhow!(
"{:?} 的 {:?} 不支持 subtype '{}',可选: {:?}",
source, product, st, valid
));
}
// subtype = None取首个默认
candidates
.first()
.cloned()
.ok_or_else(|| anyhow!("{:?} 的 {:?} 产品无已注册 fetcher", source, product))
}
/// 列出所有已注册的 (Source, ProductType) 组合(用于错误信息)
fn list_supported_combinations(&self) -> String {
let mut keys: Vec<_> = self.fetchers.keys().collect();
keys.sort_by_key(|(s, p)| (*s as usize, *p as usize));
keys.iter()
.map(|(s, p)| format!("{:?}/{:?}", s, p))
.collect::<Vec<_>>()
.join(", ")
}
/// 列出某源支持的所有产品类型(用于工具描述)
pub fn products_for_source(&self, source: Source) -> Vec<ProductType> {
self.fetchers
.keys()
.filter(|(s, _)| *s == source)
.map(|(_, p)| *p)
.collect()
}
/// 列出所有已注册 (Source, ProductType) 组合的能力清单
///
/// 供 API 层 `/observation/capabilities` 暴露给前端,用于动态渲染源/产品/版本下拉选项,
/// 避免前端硬编码各源支持的产品、子类型与版本。
pub fn list_capabilities(&self) -> Vec<CapabilitySpec> {
let mut specs: Vec<CapabilitySpec> = self
.fetchers
.iter()
.flat_map(|((s, p), fetchers)| {
fetchers.iter().map(move |f| CapabilitySpec {
source: s.as_str().to_string(),
product: p.as_str().to_string(),
subtypes: f.subtypes().iter().map(|s| s.to_string()).collect(),
releases: f.releases().iter().map(|s| s.to_string()).collect(),
default_release: f.default_release().map(|s| s.to_string()),
suggested_max_radius_deg: f.suggested_max_radius_deg(),
hard_max_radius_deg: f.hard_max_radius_deg(),
identifier_format: f.identifier_format().map(|s| s.to_string()),
supports_coordinates: f.supports_coordinates(),
supports_identifiers: f.supports_identifiers(),
})
})
.collect();
// 稳定排序:按 source → product → 首个 subtype
specs.sort_by(|a, b| {
a.source
.cmp(&b.source)
.then_with(|| a.product.cmp(&b.product))
.then_with(|| a.subtypes.first().cmp(&b.subtypes.first()))
});
specs
}
}
/// 单个 (Source, ProductType) fetcher 的能力描述(对外序列化)
#[derive(Debug, Clone, serde::Serialize)]
pub struct CapabilitySpec {
pub source: String,
pub product: String,
pub subtypes: Vec<String>,
/// 支持的数据发布版本(小写),空表示不区分版本
pub releases: Vec<String>,
/// 默认版本release 参数为空时使用None 表示无版本概念
pub default_release: Option<String>,
/// 坐标模式检索半径的建议上限(度),超出仅警告不拒绝
pub suggested_max_radius_deg: f64,
/// 坐标模式检索半径的硬性上限(度),超出会返回错误
pub hard_max_radius_deg: f64,
pub identifier_format: Option<String>,
pub supports_coordinates: bool,
pub supports_identifiers: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_registry_has_all_fetchers() {
let reg = ObservationRegistry::default();
// LAMOST spectrum
assert!(reg.get(Source::Lamost, ProductType::Spectrum, None).is_ok());
// Gaia spectrum + lightcurve
assert!(reg.get(Source::Gaia, ProductType::Spectrum, None).is_ok());
assert!(reg.get(Source::Gaia, ProductType::LightCurve, None).is_ok());
// SDSS spectrum (specobj 默认 + apogee/aspcap subtype)
assert!(reg.get(Source::Sdss, ProductType::Spectrum, None).is_ok());
assert!(reg.get(Source::Sdss, ProductType::Spectrum, Some("apstar")).is_ok());
assert!(reg.get(Source::Sdss, ProductType::Spectrum, Some("aspcap")).is_ok());
// DESI spectrum
assert!(reg.get(Source::Desi, ProductType::Spectrum, None).is_ok());
}
#[test]
fn test_unsupported_combination_error() {
let reg = ObservationRegistry::default();
let err = reg.get(Source::Lamost, ProductType::LightCurve, None).unwrap_err();
assert!(format!("{}", err).contains("暂不支持"));
}
#[test]
fn test_sdss_subtype_routing() {
let reg = ObservationRegistry::default();
// subtype=apstar 应路由到 SdssApogeeFetcher其 subtypes 含 "apstar"
let apstar = reg.get(Source::Sdss, ProductType::Spectrum, Some("apstar")).unwrap();
assert!(apstar.subtypes().contains(&"apstar"));
// 默认(无 subtype应路由到 SdssSpectrumFetcher
let specobj = reg.get(Source::Sdss, ProductType::Spectrum, None).unwrap();
assert!(specobj.subtypes().contains(&"spec"));
}
}

View File

@ -0,0 +1,420 @@
// src/services/observation/sdss.rs
//
// SDSS ObservationFetcher 实现
//
// 注册的 fetcher
// SdssSpectrumFetcher —— (Sdss, Spectrum),光学 specLite (plate-mjd-fiberid)
// SdssApogeeFetcher —— (Sdss, Spectrum) subtype=apstar/aspcapAPOGEE 近红外
use crate::api::AppState;
use crate::clients::sdss::{ApogeeStarRow, SdssSpectrumRow};
use crate::services::observation::cache::{
cached_files_total_size, fetch_observation_cache, file_url_from_path, log_write_failure,
persist_bytes, write_observation_cache,
};
use crate::services::observation::fetcher::{Candidate, ObservationFetcher};
use crate::services::observation::types::{
Artifact, ObservationProduct, ProductSpec, ProductType, Source,
};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use std::time::Duration;
use tracing::{info, warn};
// ── 版本枚举 ──
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SdssRelease { Dr16, Dr17, Dr18, Dr19 }
impl Default for SdssRelease {
fn default() -> Self { Self::Dr17 }
}
impl SdssRelease {
pub fn valid_values() -> &'static [&'static str] {
&["dr16", "dr17", "dr18", "dr19"]
}
pub fn sas_dr_segment(&self) -> &'static str {
match self {
Self::Dr16 => "dr16", Self::Dr17 => "dr17",
Self::Dr18 => "dr18", Self::Dr19 => "dr19",
}
}
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,
}
}
pub fn apogee_table(&self) -> Option<&'static str> {
match self {
Self::Dr17 => Some("sdss_dr17.apogee2_allstar"),
_ => None,
}
}
pub fn display(&self) -> &'static str {
match self {
Self::Dr16 => "DR16", Self::Dr17 => "DR17",
Self::Dr18 => "DR18", Self::Dr19 => "DR19",
}
}
}
pub fn parse_sdss_release(s: Option<&str>) -> Result<SdssRelease> {
Ok(match s.map(|x| x.to_lowercase()).as_deref() {
None => SdssRelease::Dr17,
Some("dr16") => SdssRelease::Dr16,
Some("dr17") => SdssRelease::Dr17,
Some("dr18") => SdssRelease::Dr18,
Some("dr19") => SdssRelease::Dr19,
Some(other) => return Err(anyhow!("不支持的 SDSS release '{}',可选: {:?}", other, SdssRelease::valid_values())),
})
}
fn validate_cone_params(ra: f64, dec: f64, radius_deg: f64) -> Result<()> {
// NOIRLab Data Lab 无硬性半径上限,但大半径易超时
if !(radius_deg > 0.0 && radius_deg <= 30.0) {
return Err(anyhow!(
"检索半径应在 0~30 度之间(不含 0当前: {}。SDSS specobj 建议检索半径 ≤1° 以避免超时",
radius_deg
));
}
if radius_deg > 1.0 {
warn!(
"[Observation] SDSS cone radius {}° 超出建议范围≤1°大半径查询可能被服务端超时拒绝",
radius_deg
);
}
if !(-360.0..=360.0).contains(&ra) || !(-90.0..=90.0).contains(&dec) {
return Err(anyhow!("坐标范围异常 (ra={}, dec={})", ra, dec));
}
Ok(())
}
// ═══════════════════════════════════════════════════════════════
// SdssSpectrumFetcher —— 光学 specLite (plate-mjd-fiberid)
// ═══════════════════════════════════════════════════════════════
#[derive(Debug)]
pub struct SdssSpectrumFetcher;
#[async_trait]
impl ObservationFetcher for SdssSpectrumFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Sdss, ProductType::Spectrum)
}
fn subtypes(&self) -> &'static [&'static str] {
&["spec"]
}
fn releases(&self) -> &'static [&'static str] {
// specobj cone 依赖 datalab_table(),仅 DR16/DR17 有表
&["dr16", "dr17"]
}
fn default_release(&self) -> Option<&'static str> {
// 与 parse_sdss_release(None) 的默认值保持一致
Some("dr17")
}
fn suggested_max_radius_deg(&self) -> f64 {
// 与 validate_cone_params 的建议范围一致
1.0
}
fn identifier_format(&self) -> Option<&'static str> {
Some("'run2d-plate-mjd-fiberid',如 '26-2225-53729-439'")
}
async fn cone_search_raw(
&self,
state: &AppState,
ra: f64, dec: f64, radius_deg: f64,
release: Option<&str>, _subtype: Option<&str>,
) -> Result<Vec<Candidate>> {
validate_cone_params(ra, dec, radius_deg)?;
let rel = parse_sdss_release(release)?;
let result = state.sdss.cone_search(ra, dec, radius_deg, 50, rel).await?;
Ok(result.rows.iter().map(row_to_candidate).collect())
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
_subtype: Option<&str>,
) -> Result<Candidate> {
let parts: Vec<&str> = identifier.split('-').collect();
if parts.len() != 4 {
return Err(anyhow!(
"SDSS source_id 应为 'run2d-plate-mjd-fiberid' 格式,得到 '{}'",
identifier
));
}
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]))?;
Ok(Candidate {
source: Source::Sdss,
source_id: identifier.to_string(),
label: format!("plate {} mjd {} fiber {}", plate, mjd, fiberid),
ra: None, dec: None, distance: None,
raw: Some(serde_json::json!({
"plate": plate, "mjd": mjd, "fiberid": fiberid, "run2d": parts[0]
})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
release: Option<&str>,
_subtype: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let plate = candidate.raw.as_ref().and_then(|v| v.get("plate")).and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("SDSS Candidate 缺 plate"))?;
let mjd = candidate.raw.as_ref().and_then(|v| v.get("mjd")).and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("SDSS Candidate 缺 mjd"))?;
let fiberid = candidate.raw.as_ref().and_then(|v| v.get("fiberid")).and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("SDSS Candidate 缺 fiberid"))?;
let run2d = candidate.raw.as_ref().and_then(|v| v.get("run2d")).and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("SDSS Candidate 缺 run2d"))?;
let rel = parse_sdss_release(release)?;
let product = ProductSpec::with_subtype(ProductType::Spectrum, "spec");
let dr = rel.sas_dr_segment();
let cache_key = format!("{}|{}-{}-{}-{}", dr, run2d, plate, mjd, fiberid);
let source_label = format!("plate {} mjd {} fiber {}", plate, mjd, fiberid);
// 1) 缓存命中
if !force {
if let Some((artifacts, meta)) = fetch_observation_cache(&state.db, Source::Sdss, &product, &cache_key).await? {
if cached_files_total_size(&state.config.library_dir, &artifacts).is_some() {
info!("[SDSS] 光谱缓存命中 ({})", cache_key);
return Ok(ObservationProduct {
source: Source::Sdss, product,
source_id: cache_key, source_label, artifacts, source_meta: meta,
});
}
warn!("[SDSS] 缓存记录存在但文件缺失,重新下载 ({})", cache_key);
}
}
// 2) SAS 下载(未压缩 FITS+ 落盘
tokio::time::sleep(Duration::from_millis(50)).await;
let fits_bytes = state.sdss.download_fits(plate, mjd, fiberid, run2d, rel).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(&state.config.library_dir, &rel_path, &fits_bytes)?;
info!("[SDSS] FITS 已保存 {} → {} ({}B)", cache_key, rel_path, size);
let meta = serde_json::json!({"plate": plate, "mjd": mjd, "fiberid": fiberid, "run2d": run2d, "dr": dr});
let artifact = Artifact {
band: None, original_name: None,
file_path: rel_path.clone(),
file_url: file_url_from_path(&rel_path),
file_format: "fits".to_string(),
size_bytes: size, cached: false,
};
let meta_str = meta.to_string();
if let Err(e) = write_observation_cache(
&state.db, Source::Sdss, &product, &cache_key, None, None,
std::slice::from_ref(&artifact), Some(&meta_str),
).await {
log_write_failure(Source::Sdss.as_str(), "spectrum", e);
}
Ok(ObservationProduct {
source: Source::Sdss, product,
source_id: cache_key, source_label,
artifacts: vec![artifact], source_meta: Some(meta),
})
}
}
fn row_to_candidate(row: &SdssSpectrumRow) -> Candidate {
Candidate {
source: Source::Sdss,
source_id: format!("{}-{}-{}-{}", row.run2d, row.plate, row.mjd, row.fiberid),
label: format!("plate {} mjd {} fiber {}", row.plate, row.mjd, row.fiberid),
ra: row.ra, dec: row.dec, distance: None,
raw: Some(serde_json::json!({
"plate": row.plate, "mjd": row.mjd, "fiberid": row.fiberid, "run2d": row.run2d
})),
}
}
// ═══════════════════════════════════════════════════════════════
// SdssApogeeFetcher —— APOGEE 近红外 (apStar / aspcapStar)
// ═══════════════════════════════════════════════════════════════
#[derive(Debug)]
pub struct SdssApogeeFetcher;
#[async_trait]
impl ObservationFetcher for SdssApogeeFetcher {
fn key(&self) -> (Source, ProductType) {
(Source::Sdss, ProductType::Spectrum)
}
fn subtypes(&self) -> &'static [&'static str] {
&["apstar", "aspcap"]
}
fn releases(&self) -> &'static [&'static str] {
// APOGEE cone 依赖 apogee_table(),仅 DR17 有表
&["dr17"]
}
fn default_release(&self) -> Option<&'static str> {
Some("dr17")
}
fn suggested_max_radius_deg(&self) -> f64 {
// 与 validate_cone_params 的建议范围一致
1.0
}
fn identifier_format(&self) -> Option<&'static str> {
Some("'telescope|field|apogee_id',如 'apo25m|359+01|2M17400083-2858496'")
}
async fn cone_search_raw(
&self,
state: &AppState,
ra: f64, dec: f64, radius_deg: f64,
release: Option<&str>, _subtype: Option<&str>,
) -> Result<Vec<Candidate>> {
validate_cone_params(ra, dec, radius_deg)?;
let rel = parse_sdss_release(release)?;
let result = state.sdss.apogee_cone_search(ra, dec, radius_deg, 50, rel).await?;
Ok(result.rows.iter().map(apogee_row_to_candidate).collect())
}
async fn resolve_identifier(
&self,
identifier: &str,
_release: Option<&str>,
subtype: Option<&str>,
) -> Result<Candidate> {
// 格式 "{telescope}|{field}|{apogee_id}"
let parts: Vec<&str> = identifier.splitn(3, '|').collect();
if parts.len() != 3 {
return Err(anyhow!(
"APOGEE source_id 应为 'telescope|field|apogee_id' 格式,得到 '{}'",
identifier
));
}
let dt = subtype.unwrap_or("apstar");
Ok(Candidate {
source: Source::Sdss,
source_id: identifier.to_string(),
label: format!("{} {} {}", parts[0], dt, parts[2]),
ra: None, dec: None, distance: None,
raw: Some(serde_json::json!({
"apogee_id": parts[2], "telescope": parts[0], "field": parts[1], "data_type": dt
})),
})
}
async fn fetch(
&self,
state: &AppState,
candidate: &Candidate,
release: Option<&str>,
subtype: Option<&str>,
force: bool,
) -> Result<ObservationProduct> {
let apogee_id = candidate.raw.as_ref().and_then(|v| v.get("apogee_id")).and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("APOGEE Candidate 缺 apogee_id"))?;
let telescope = candidate.raw.as_ref().and_then(|v| v.get("telescope")).and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("APOGEE Candidate 缺 telescope"))?;
let field = candidate.raw.as_ref().and_then(|v| v.get("field")).and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("APOGEE Candidate 缺 field"))?;
let dt = candidate.raw.as_ref().and_then(|v| v.get("data_type")).and_then(|v| v.as_str())
.or(subtype).unwrap_or("apstar");
let rel = parse_sdss_release(release)?;
let product = ProductSpec::with_subtype(ProductType::Spectrum, dt);
let dr = rel.sas_dr_segment();
let cache_key = format!("{}|{}|{}|{}|{}", dr, dt, telescope, field, apogee_id);
let source_label = format!("{} {} {}", telescope, dt, apogee_id);
// 1) 缓存命中
if !force {
if let Some((artifacts, meta)) = fetch_observation_cache(&state.db, Source::Sdss, &product, &cache_key).await? {
if cached_files_total_size(&state.config.library_dir, &artifacts).is_some() {
info!("[APOGEE] 缓存命中 ({})", cache_key);
return Ok(ObservationProduct {
source: Source::Sdss, product,
source_id: cache_key, source_label, artifacts, source_meta: meta,
});
}
warn!("[APOGEE] 缓存记录存在但文件缺失,重新下载 ({})", cache_key);
}
}
// 2) 下载 FITS无压缩
tokio::time::sleep(Duration::from_millis(50)).await;
let fits_bytes = state.sdss.download_apogee_fits(apogee_id, telescope, field, rel, dt).await?;
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(&state.config.library_dir, &rel_path, &fits_bytes)?;
info!("[APOGEE] 已保存 {} → {} ({}B)", cache_key, rel_path, size);
let meta = serde_json::json!({
"apogee_id": apogee_id, "telescope": telescope, "field": field,
"data_type": dt, "dr": dr
});
let artifact = Artifact {
band: None, original_name: None,
file_path: rel_path.clone(),
file_url: file_url_from_path(&rel_path),
file_format: "fits".to_string(),
size_bytes: size, cached: false,
};
let meta_str = meta.to_string();
if let Err(e) = write_observation_cache(
&state.db, Source::Sdss, &product, &cache_key, None, None,
std::slice::from_ref(&artifact), Some(&meta_str),
).await {
log_write_failure(Source::Sdss.as_str(), "apogee", e);
}
Ok(ObservationProduct {
source: Source::Sdss, product,
source_id: cache_key, source_label,
artifacts: vec![artifact], source_meta: Some(meta),
})
}
}
fn apogee_row_to_candidate(row: &ApogeeStarRow) -> Candidate {
Candidate {
source: Source::Sdss,
source_id: format!("{}|{}|{}", row.telescope, row.field, row.apogee_id),
label: format!("{} {}", row.telescope, row.apogee_id),
ra: row.ra, dec: row.dec, distance: None,
raw: Some(serde_json::json!({
"apogee_id": row.apogee_id, "telescope": row.telescope, "field": row.field
})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_cone_params() {
// 合法范围0~30°建议 ≤1°超出仅警告
assert!(validate_cone_params(180.0, 30.0, 0.1).is_ok());
assert!(validate_cone_params(180.0, 30.0, 1.0).is_ok()); // 建议上限内
assert!(validate_cone_params(180.0, 30.0, 5.0).is_ok()); // 超建议但仍合法
assert!(validate_cone_params(180.0, 30.0, 31.0).is_err()); // 超硬性上限
}
}

View File

@ -0,0 +1,262 @@
// src/services/observation/types.rs
//
// 观测数据通用类型层 —— (望远镜源 × 产品类型) 双轴正交
//
// 设计核心:观测数据不再以"光谱"为轴心,而是以 (Source, ProductType) 正交组合。
// 光谱只是 ProductType 之一;光变、测光、图像均为平级产品类型。
// 新增数据源或产品类型是纯加法:扩展枚举 + 在 dispatch.rs 加 match 分支。
use serde::{Deserialize, Serialize};
// ═══════════════════════════════════════════════════════════════
// 1. 第一轴:望远镜源
// ═══════════════════════════════════════════════════════════════
/// 望远镜/巡天数据源
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Source {
/// 郭守敬望远镜低分辨率光学光谱
Lamost,
/// ESA GaiaBP/RP 光谱、RVS、历元测光
Gaia,
/// SDSS/BOSS/eBOSS 光学光谱 + APOGEE 近红外
Sdss,
/// DESI 暗能量光谱仪HEALPix coadd
Desi,
}
impl Source {
pub fn valid_values() -> &'static [&'static str] {
&["lamost", "gaia", "sdss", "desi"]
}
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",
}
}
}
// ═══════════════════════════════════════════════════════════════
// 2. 第二轴:观测数据产品类型
// ═══════════════════════════════════════════════════════════════
/// 观测数据产品类型
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum ProductType {
/// 一维光谱flux vs wavelength
#[default]
Spectrum,
/// 光变曲线flux vs time含 Gaia EPOCH_PHOTOMETRY、ZTF alert 等)
LightCurve,
/// 测光星表/星等(单 epoch 或多 epoch 聚合)
Photometry,
/// 图像/cutout后续扩展
Image,
}
impl ProductType {
pub fn valid_values() -> &'static [&'static str] {
&["spectrum", "lightcurve", "photometry", "image"]
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Spectrum => "spectrum",
Self::LightCurve => "lightcurve",
Self::Photometry => "photometry",
Self::Image => "image",
}
}
pub fn display(&self) -> &'static str {
match self {
Self::Spectrum => "光谱",
Self::LightCurve => "光变曲线",
Self::Photometry => "测光",
Self::Image => "图像",
}
}
/// 落盘子目录名Telescope/{source}/{product}/...
pub fn path_segment(&self) -> &'static str {
self.as_str()
}
}
/// 产品细分(可选):用 (类型, 子类型) 描述具体产品变体
///
/// 子类型语义因源而异:
/// - LAMOST spectrum: "lrs"/"mrs"
/// - Gaia spectrum: "xp_continuous"/"xp_sampled"/"rvs"
/// - Gaia lightcurve: "epoch_photometry"
/// - SDSS spectrum: "spec"/"apstar"/"aspcap"
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
pub struct ProductSpec {
pub product: ProductType,
/// 子类型None 表示该源的默认产品
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subtype: Option<String>,
}
impl ProductSpec {
pub fn new(product: ProductType) -> Self {
Self { product, subtype: None }
}
pub fn with_subtype(product: ProductType, subtype: impl Into<String>) -> Self {
Self { product, subtype: Some(subtype.into()) }
}
/// 落盘子目录:有 subtype 则拼 "product/subtype",否则只用 product
pub fn path_segment(&self) -> String {
match &self.subtype {
Some(st) => format!("{}/{}", self.product.path_segment(), st),
None => self.product.path_segment().to_string(),
}
}
}
// ═══════════════════════════════════════════════════════════════
// 3. 产物结构(多文件产物支持)
// ═══════════════════════════════════════════════════════════════
/// 单个物理文件(一个逻辑产物可能含多个文件,如 Gaia 光变 G/BP/RP 三波段)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
/// 该文件的语义标签:如 "G band"、"BP band"、"combined";无波段则 None
#[serde(default, skip_serializing_if = "Option::is_none")]
pub band: Option<String>,
/// 在 ZIP/响应中的原始名(保留波段等区分信息,便于审计)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original_name: Option<String>,
pub file_path: String,
pub file_url: String,
pub file_format: String,
pub size_bytes: usize,
pub cached: bool,
}
/// 一个逻辑观测产物(一个源 × 一个产品 = 一组 Artifact
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObservationProduct {
pub source: Source,
pub product: ProductSpec,
/// 归一化源标识(用于缓存去重键)
pub source_id: String,
/// 人类可读的源标签
pub source_label: String,
/// 物理文件列表——多数情况 1 个Gaia 光变 3 个,多波段测光可能 N 个
pub artifacts: Vec<Artifact>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_meta: Option<serde_json::Value>,
}
/// 一批下载的聚合结果
#[derive(Debug, Clone, Serialize)]
pub struct ObservationBatch {
pub source: Source,
pub product: ProductSpec,
/// ByCoordinates 模式下有值ByIdentifier 模式为 None
#[serde(skip_serializing_if = "Option::is_none")]
pub ra: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dec: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub radius_deg: Option<f64>,
/// cone 命中数ByCoordinates或请求标识数ByIdentifier
pub matched_count: usize,
pub products: Vec<ObservationProduct>,
/// 失败条目(不中断整体流程)
pub failures: Vec<DownloadFailure>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DownloadFailure {
pub source_label: String,
pub error: String,
}
/// cone 命中多条时的选源策略(仅 ByCoordinates 模式生效)
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FindStrategy {
/// 仅下载最近的一条(默认)
#[default]
Nearest,
/// 下载锥形内全部命中
All,
}
// ═══════════════════════════════════════════════════════════════
// 4. 统一下载请求
// ═══════════════════════════════════════════════════════════════
/// 观测数据下载请求 —— 支持两种输入模式
///
/// 源标识格式(与各 service 的 observation_cache 去重键一致):
/// LAMOST spectrum: obsid 数字,如 "438809089"
/// Gaia spectrum: "XP_CONTINUOUS|source_id",如 "XP_CONTINUOUS|65214031805717376"
/// Gaia lightcurve(epoch_photometry): source_id
/// SDSS spectrum: "run2d-plate-mjd-fiberid",如 "26-2225-53729-439"
/// SDSS spectrum(apstar/aspcap): "telescope|field|apogee_id"
/// DESI spectrum: "survey-program-healpix",如 "main-dark-10050"
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum ObservationRequest {
ByCoordinates {
source: Source,
product: ProductSpec,
ra: f64,
dec: f64,
#[serde(default = "default_radius")]
radius_deg: f64,
#[serde(default)]
strategy: FindStrategy,
#[serde(default)]
release: Option<String>,
},
ByIdentifier {
source: Source,
product: ProductSpec,
identifiers: Vec<String>,
#[serde(default)]
release: Option<String>,
},
}
fn default_radius() -> f64 {
0.1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_source_roundtrip() {
assert_eq!(Source::Lamost.as_str(), "lamost");
assert_eq!(Source::Gaia.display(), "Gaia");
}
#[test]
fn test_product_spec_path() {
assert_eq!(ProductSpec::new(ProductType::Spectrum).path_segment(), "spectrum");
assert_eq!(
ProductSpec::with_subtype(ProductType::Spectrum, "lrs").path_segment(),
"spectrum/lrs"
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,261 +0,0 @@
// 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<DesiConeResult> {
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<chrono::DateTime<chrono::Utc>>,
}
async fn fetch_cone_cache(pool: &SqlitePool, query_hash: &str) -> Result<Option<DesiConeResult>> {
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<crate::services::spectra::common::DownloadResult> {
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");
}
}

View File

@ -1,365 +0,0 @@
// 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 返回 ZIPUSE_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<GaiaConeResult> {
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<chrono::DateTime<chrono::Utc>>,
}
async fn fetch_cone_cache(pool: &SqlitePool, query_hash: &str) -> Result<Option<GaiaConeResult>> {
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<super::common::DownloadResult> {
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 当前仅 DR3DR4 发布后改 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<u8>, &'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");
}
}

View File

@ -1,380 +0,0 @@
// 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<LamostConeResult> {
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<chrono::DateTime<chrono::Utc>>,
}
async fn fetch_cone_cache(pool: &SqlitePool, query_hash: &str) -> Result<Option<LamostConeResult>> {
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<super::common::DownloadResult> {
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<Vec<u8>> {
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<Vec<super::common::SpectrumCacheRow>> {
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' 开头"
);
}
}

View File

@ -1,29 +0,0 @@
// 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,
};

View File

@ -1,476 +0,0 @@
// 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<SdssConeResult> {
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<chrono::DateTime<chrono::Utc>>,
}
async fn fetch_cone_cache(pool: &SqlitePool, query_hash: &str) -> Result<Option<SdssConeResult>> {
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<super::common::DownloadResult> {
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<crate::clients::sdss::ApogeeConeResult> {
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<Option<crate::clients::sdss::ApogeeConeResult>> {
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 光谱 FITSapStar 或 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<super::common::DownloadResult> {
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);
}
}

View File

@ -1 +1,25 @@
pub mod ssrf;
/// 静态注册 sqlite-vec 自动扩展。
///
/// 必须在任何数据库连接开启之前调用,保证所有 Connection 自动拥有 vec0 虚拟表能力。
///
/// # Safety
///
/// `sqlite3_vec_init` 的函数签名严格符合 SQLite C API 自动扩展回调规范
/// `(sqlite3*, char**, const sqlite3_api_routines*) -> i32`。`transmute` 将该函数指针
/// 转换为 `sqlite3_auto_extension` 所需的 `Option<fn()>` 类型。
pub fn register_sqlite_vec_extension() {
unsafe {
libsqlite3_sys::sqlite3_auto_extension(Some(std::mem::transmute::<
*const (),
unsafe extern "C" fn(
*mut libsqlite3_sys::sqlite3,
*mut *const i8,
*const libsqlite3_sys::sqlite3_api_routines,
) -> i32,
>(
sqlite_vec::sqlite3_vec_init as *const ()
)));
}
}