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:
parent
a156252bc3
commit
2f1fd19d74
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -158,6 +158,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha1 0.10.6",
|
||||
"sha2 0.10.9",
|
||||
"sqlite-vec",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '任务管理',
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -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 }[];
|
||||
}
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
161
dashboard/src/components/observation/ObservationResultCard.tsx
Normal file
161
dashboard/src/components/observation/ObservationResultCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
dashboard/src/components/observation/constants.ts
Normal file
85
dashboard/src/components/observation/constants.ts
Normal 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[];
|
||||
}
|
||||
@ -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}`, '操作出错');
|
||||
}
|
||||
};
|
||||
|
||||
370
dashboard/src/hooks/useObservation.ts
Normal file
370
dashboard/src/hooks/useObservation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
988
dashboard/src/pages/ObservationPanel.tsx
Normal file
988
dashboard/src/pages/ObservationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
40
dashboard/src/utils/apiError.ts
Normal file
40
dashboard/src/utils/apiError.ts
Normal file
@ -0,0 +1,40 @@
|
||||
// dashboard/src/utils/apiError.ts
|
||||
//
|
||||
// 统一的 axios 错误消息提取工具
|
||||
//
|
||||
// 后端 AppError 序列化为 JSON 对象 { "error": "..." }(src/api/error.rs),
|
||||
// 而非纯字符串。若直接存入 React state 并在 JSX 渲染,会触发
|
||||
// React error #31(Objects 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;
|
||||
}
|
||||
23
migrations/20260705140000_add_observation_cache.sql
Normal file
23
migrations/20260705140000_add_observation_cache.sql
Normal 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);
|
||||
@ -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'
|
||||
);
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -1,236 +0,0 @@
|
||||
// src/agent/tools/astro/research/find_spectrum.rs
|
||||
//
|
||||
// FindSpectrumTool —— 统一光谱下载工具(跨 LAMOST/Gaia/SDSS)
|
||||
//
|
||||
// 唯一的光谱工具,取代历史的三源分立工具。支持两种模式:
|
||||
// - 坐标模式(默认):给 ra/dec/radius + survey + strategy,自动 cone 检索 → 选源 → 下载
|
||||
// - 标识符模式:给 survey + source_ids(VizieR 交叉证认得到的源标识),直接下载
|
||||
//
|
||||
// 源标识格式:
|
||||
// LAMOST: obsid 数字,如 "438809089"
|
||||
// Gaia: "XP_CONTINUOUS|source_id"(可省略 RT 前缀,默认 XP_CONTINUOUS)
|
||||
// SDSS: "run2d-plate-mjd-fiberid",如 "26-2225-53729-439"
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
|
||||
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
|
||||
use crate::services::spectra::{FindStrategy, SpectrumRequest, SpectrumSurvey};
|
||||
|
||||
pub struct FindSpectrumTool;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for FindSpectrumTool {
|
||||
fn name(&self) -> &str {
|
||||
"find_spectrum"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"下载光谱(跨 LAMOST/Gaia/SDSS)。支持两种模式:\n\
|
||||
(1) 坐标模式(默认):给 ra/dec/radius + survey + strategy,自动 cone 检索并下载;\n\
|
||||
(2) 标识符模式:给 survey + source_ids,直接按源标识下载(跳过检索)。\n\
|
||||
- survey: lamost(低分辨率光学光谱)/ gaia(BP/RP 光谱)/ sdss(SDSS+BOSS+eBOSS 光谱)\n\
|
||||
- strategy: nearest(默认,最近一条)/ all(全部命中)\n\
|
||||
- source_ids(标识符模式)格式:\n\
|
||||
LAMOST=obsid数字 如 '438809089';\n\
|
||||
Gaia='XP_CONTINUOUS|6521...'(可省略RT前缀);\n\
|
||||
SDSS='run2d-plate-mjd-fiberid' 如 '26-2225-53729-439'\n\
|
||||
通过 VizieR 查询星表交叉匹配也能得到各源的标识符,再用标识符模式下载。\n\
|
||||
结果自动缓存,重复查询同一坐标/标识不会重复下载。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"survey": {
|
||||
"type": "string",
|
||||
"enum": ["lamost", "gaia", "sdss", "desi"],
|
||||
"description": "数据源:lamost / gaia / sdss / desi"
|
||||
},
|
||||
"ra": {
|
||||
"type": "number",
|
||||
"description": "赤经 RA(度,J2000/ICRS)。坐标模式必填"
|
||||
},
|
||||
"dec": {
|
||||
"type": "number",
|
||||
"description": "赤纬 Dec(度,J2000/ICRS)。坐标模式必填"
|
||||
},
|
||||
"radius_deg": {
|
||||
"type": "number",
|
||||
"description": "检索半径(度),坐标模式用,默认 0.1,范围 0~5",
|
||||
"default": 0.1
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["nearest", "all"],
|
||||
"description": "选源策略(坐标模式):nearest(默认)/ all",
|
||||
"default": "nearest"
|
||||
},
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "源标识列表(标识符模式)。提供时切换到标识符模式,忽略 ra/dec/radius/strategy"
|
||||
},
|
||||
"release": {
|
||||
"type": "string",
|
||||
"description": "数据发布版本(可选)。LAMOST: dr5/dr6/.../dr11(默认dr10);Gaia: dr3(默认);SDSS: dr16/dr17/dr18/dr19(默认dr17);DESI: dr1(默认)/edr"
|
||||
},
|
||||
"data_type": {
|
||||
"type": "string",
|
||||
"description": "数据类型(可选)。LAMOST: lrs(低分辨率,默认)/mrs(中分辨率);Gaia: xp_continuous(默认)/xp_sampled/epoch_photometry/rvs;SDSS: spec(光学,默认)/apstar(APOGEE合并星谱)/aspcap(ASPCAP输出)"
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"description": "是否强制重新下载(忽略缓存),默认 false",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["survey"]
|
||||
})
|
||||
}
|
||||
|
||||
fn group(&self) -> &str {
|
||||
"as:research"
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
|
||||
let state = &ctx.app_state;
|
||||
|
||||
let survey_str = match args.get("survey").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return ToolOutput::error("缺少必需参数 'survey'(lamost/gaia/sdss)"),
|
||||
};
|
||||
let survey = match survey_str.to_lowercase().as_str() {
|
||||
"lamost" => SpectrumSurvey::Lamost,
|
||||
"gaia" => SpectrumSurvey::Gaia,
|
||||
"sdss" => SpectrumSurvey::Sdss,
|
||||
"desi" => SpectrumSurvey::Desi,
|
||||
other => {
|
||||
return ToolOutput::error(format!(
|
||||
"不支持的 survey '{}',可选: lamost / gaia / sdss / desi",
|
||||
other
|
||||
))
|
||||
}
|
||||
};
|
||||
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let release = args
|
||||
.get("release")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let data_type = args
|
||||
.get("data_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// 标识符模式 vs 坐标模式
|
||||
let request =
|
||||
if let Some(ids) = args.get("source_ids").and_then(|v| v.as_array()) {
|
||||
let source_ids: Vec<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
|
||||
}
|
||||
@ -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)) => {
|
||||
@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
259
src/agent/tools/astro/research/observation.rs
Normal file
259
src/agent/tools/astro/research/observation.rs
Normal 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/mrs;Gaia spectrum: xp_continuous/xp_sampled/rvs;Gaia lightcurve: epoch_photometry;SDSS spectrum: spec/apstar/aspcap;DESI spectrum: coadd"
|
||||
},
|
||||
"ra": {
|
||||
"type": "number",
|
||||
"description": "赤经 RA(度,J2000/ICRS)。坐标模式必填"
|
||||
},
|
||||
"dec": {
|
||||
"type": "number",
|
||||
"description": "赤纬 Dec(度,J2000/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(默认dr10,MRS 需 dr7+);Gaia: dr3;SDSS spec: dr16/dr17(默认dr17);SDSS apstar/aspcap: dr17;DESI: 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)
|
||||
}
|
||||
}
|
||||
@ -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 解析名称后做区域查询。\
|
||||
坐标系统为 J2000(ICRS),单位为度。结果自动缓存 7 天。\
|
||||
不确定表名时先用 search_catalogs 搜索。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ra": {
|
||||
"type": "number",
|
||||
"description": "赤经 RA(度,J2000/ICRS),范围 0~360"
|
||||
},
|
||||
"dec": {
|
||||
"type": "number",
|
||||
"description": "赤纬 Dec(度,J2000/ICRS),范围 -90~90"
|
||||
},
|
||||
"radius_deg": {
|
||||
"type": "number",
|
||||
"description": "检索半径(度),默认 0.1,范围 0~5",
|
||||
"default": 0.1
|
||||
"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))
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
¶ms.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,
|
||||
¶ms.table,
|
||||
max_records,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("交叉证认失败: {}", e);
|
||||
let msg = e.to_string();
|
||||
if msg.contains("半径") || msg.contains("坐标") || msg.contains("非法字符") {
|
||||
AppError::bad_request(msg)
|
||||
} else {
|
||||
AppError::internal(format!("交叉证认失败: {}", msg))
|
||||
}
|
||||
})?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// 统一光谱下载(跨 LAMOST/Gaia/SDSS)
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpectrumDownloadParams {
|
||||
/// 数据源:lamost / gaia / sdss
|
||||
pub survey: String,
|
||||
/// 坐标模式:ra/dec/radius + strategy
|
||||
pub ra: Option<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))
|
||||
}
|
||||
|
||||
@ -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
283
src/api/observation.rs
Normal 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_cache(POST,重型副作用)
|
||||
// - 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(¶ms.source)?;
|
||||
let product = build_product_spec(¶ms.product, ¶ms.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,
|
||||
}))
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// src/clients/gaia/mod.rs
|
||||
//
|
||||
// Gaia(ESA 盖亚任务)光谱数据客户端 —— 纯通信层
|
||||
// Gaia(ESA 盖亚任务)观测数据客户端 —— 纯通信层
|
||||
// 职责仅限: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();
|
||||
|
||||
@ -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 → voservice;MRS → 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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
35
src/lib.rs
35
src/lib.rs
@ -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 {
|
||||
|
||||
53
src/main.rs
53
src/main.rs
@ -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))
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
// ┌──────────────────────────────────────────────────────────────┐
|
||||
// │ 1. 缓存层 query_adql_cached / query_table │
|
||||
// │ 2. ADQL 安全 sanitize_identifier / escape_string_literal │
|
||||
// │ 3. 几何查询 cone_search / cross_match │
|
||||
// │ 3. 几何查询 cone_search(nearest/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> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 构建导出 ADQL(table + 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 客户端"))?;
|
||||
|
||||
@ -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;
|
||||
|
||||
324
src/services/observation/cache.rs
Normal file
324
src/services/observation/cache.rs
Normal 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) = ¶ms.source {
|
||||
where_clauses.push(format!("source = '{}'", s.replace('\'', "''")));
|
||||
}
|
||||
if let Some(p) = ¶ms.product {
|
||||
where_clauses.push(format!("product = '{}'", p.replace('\'', "''")));
|
||||
}
|
||||
if let Some(q) = ¶ms.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"));
|
||||
}
|
||||
}
|
||||
249
src/services/observation/desi.rs
Normal file
249
src/services/observation/desi.rs
Normal 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 越界
|
||||
}
|
||||
}
|
||||
198
src/services/observation/dispatch.rs
Normal file
198
src/services/observation/dispatch.rs
Normal 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
|
||||
}
|
||||
348
src/services/observation/fetcher.rs
Normal file
348
src/services/observation/fetcher.rs
Normal 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 里转成 Candidate,fetch 时从 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 hash(prefix + 坐标 + 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);
|
||||
}
|
||||
}
|
||||
492
src/services/observation/gaia.rs
Normal file
492
src/services/observation/gaia.rs
Normal 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_PHOTOMETRY(G/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
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// GaiaLightCurveFetcher(EPOCH_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())));
|
||||
}
|
||||
}
|
||||
334
src/services/observation/lamost.rs
Normal file
334
src/services/observation/lamost.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
44
src/services/observation/mod.rs
Normal file
44
src/services/observation/mod.rs
Normal file
@ -0,0 +1,44 @@
|
||||
// src/services/observation/mod.rs
|
||||
//
|
||||
// 观测数据业务服务层 —— 跨数据源 × 跨产品类型统一编排
|
||||
//
|
||||
// 架构(双轴正交):
|
||||
// Source(LAMOST/Gaia/SDSS/DESI...)× ProductType(Spectrum/LightCurve/Photometry/Image...)
|
||||
// 每个有效组合实现一个 ObservationFetcher,注册到 ObservationRegistry。
|
||||
//
|
||||
// 模块布局:
|
||||
// types.rs —— Source/ProductType/ProductSpec/Artifact/ObservationProduct 等通用类型
|
||||
// cache.rs —— observation_cache 表读写 + 落盘辅助
|
||||
// fetcher.rs —— ObservationFetcher trait + Candidate + cone 缓存模板方法
|
||||
// registry.rs —— ObservationRegistry(fetcher 注册表)
|
||||
// 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,
|
||||
};
|
||||
212
src/services/observation/registry.rs
Normal file
212
src/services/observation/registry.rs
Normal 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);
|
||||
// SDSS(specobj 与 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"));
|
||||
}
|
||||
}
|
||||
420
src/services/observation/sdss.rs
Normal file
420
src/services/observation/sdss.rs
Normal 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/aspcap,APOGEE 近红外
|
||||
|
||||
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()); // 超硬性上限
|
||||
}
|
||||
}
|
||||
262
src/services/observation/types.rs
Normal file
262
src/services/observation/types.rs
Normal 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 Gaia(BP/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
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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 返回 ZIP(USE_ZIP_ALWAYS=true),内含每个 source 的 FITS/VOTable 文件。
|
||||
// 解包策略:取 ZIP 内第一个非目录条目落盘(单个 source 下载场景)。
|
||||
//
|
||||
// 纯通信层(TAP POST、DataLink POST)在 clients::gaia。
|
||||
|
||||
use crate::clients::gaia::{GaiaClient, GaiaConeResult, GaiaRetrievalType, GaiaSourceRow};
|
||||
use anyhow::{anyhow, Result};
|
||||
use sha1::{Digest, Sha1};
|
||||
use sqlx::SqlitePool;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
// 下载结果与缓存/落盘辅助统一在 super::common;
|
||||
|
||||
const CONE_TTL_SECS: i64 = 7 * 24 * 3600;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 1. ConeSearch(带缓存)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
pub async fn cone_search_cached(
|
||||
pool: &SqlitePool,
|
||||
client: &GaiaClient,
|
||||
ra: f64,
|
||||
dec: f64,
|
||||
radius_deg: f64,
|
||||
max_records: i64,
|
||||
xp_only: bool,
|
||||
release: super::common::GaiaRelease,
|
||||
) -> Result<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 当前仅 DR3,DR4 发布后改 release 参数
|
||||
let cache_key = format!("{}|{}|{}", dr, rt_param, source_id);
|
||||
|
||||
// 1) 缓存命中检查
|
||||
if !force {
|
||||
if let Some(cached) = fetch_spectrum_cache(pool, "gaia", &cache_key).await? {
|
||||
if let Some(size) = cached_file_size(library_dir, &cached.file_path) {
|
||||
info!("[Gaia] 光谱缓存命中 (source_id={})", source_id);
|
||||
let file_url = file_url_from_path(&cached.file_path);
|
||||
let source_meta = cached
|
||||
.meta_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok());
|
||||
return Ok(super::common::DownloadResult {
|
||||
survey: SpectrumSurvey::Gaia,
|
||||
source_id: cache_key.clone(),
|
||||
source_label: format!("Gaia {} ({})", source_id, rt_param),
|
||||
file_path: cached.file_path,
|
||||
file_url,
|
||||
file_format: cached.file_format,
|
||||
size_bytes: size,
|
||||
cached: true,
|
||||
source_meta,
|
||||
});
|
||||
}
|
||||
warn!(
|
||||
"[Gaia] 缓存记录存在但文件缺失,重新下载 (source_id={})",
|
||||
source_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) DataLink 下载(ZIP)+ 解包取首个文件条目
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let zip_bytes = client
|
||||
.download_products(&[source_id.to_string()], retrieval_type, "fits")
|
||||
.await?;
|
||||
let (file_bytes, ext) = extract_first_zip_entry(&zip_bytes, source_id)?;
|
||||
|
||||
// 3) 落盘:Telescope/gaia/{dr}/{rt}/{source_id}.{ext}
|
||||
let rel_path = format!(
|
||||
"Telescope/gaia/{dr}/{rt}/{sid}.{ext}",
|
||||
dr = dr,
|
||||
rt = rt_lower,
|
||||
sid = source_id,
|
||||
ext = ext
|
||||
);
|
||||
let size = persist_bytes(library_dir, &rel_path, &file_bytes)?;
|
||||
info!(
|
||||
"[Gaia] 光谱已保存 source_id={} type={} ({}B) → {}",
|
||||
source_id, rt_param, size, rel_path
|
||||
);
|
||||
let meta = serde_json::json!({"source_id": source_id, "retrieval_type": rt_param, "dr": dr});
|
||||
if let Err(e) = write_spectrum_cache(
|
||||
pool,
|
||||
"gaia",
|
||||
&cache_key,
|
||||
None,
|
||||
None,
|
||||
&rel_path,
|
||||
Some(&meta.to_string()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("[Gaia] 写入 spectrum_cache 失败: {}", e);
|
||||
}
|
||||
|
||||
let file_url = file_url_from_path(&rel_path);
|
||||
Ok(super::common::DownloadResult {
|
||||
survey: SpectrumSurvey::Gaia,
|
||||
source_id: cache_key,
|
||||
source_label: format!("Gaia {} ({})", source_id, rt_param),
|
||||
file_path: rel_path,
|
||||
file_url,
|
||||
file_format: ext.to_string(),
|
||||
size_bytes: size,
|
||||
cached: false,
|
||||
source_meta: Some(meta),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 ZIP 字节流中提取首个非目录文件条目
|
||||
///
|
||||
/// Gaia DataLink 的 ZIP 内每个 source 一个文件(.fits/.votable/.csv)。
|
||||
/// 返回 (文件字节, 扩展名)。扩展名从 ZIP 条目名推断,默认 fits。
|
||||
fn extract_first_zip_entry(zip_bytes: &[u8], source_id: &str) -> Result<(Vec<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");
|
||||
}
|
||||
}
|
||||
@ -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' 开头"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
@ -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 光谱 FITS(apStar 或 aspcapStar)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn download_apogee_spectrum(
|
||||
pool: &SqlitePool,
|
||||
client: &SdssClient,
|
||||
library_dir: &Path,
|
||||
apogee_id: &str,
|
||||
telescope: &str,
|
||||
field: &str,
|
||||
release: super::common::SdssRelease,
|
||||
data_type: &str, // "apstar" | "aspcap"
|
||||
force: bool,
|
||||
) -> Result<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);
|
||||
}
|
||||
}
|
||||
@ -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 ()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user