feat: LAMOST DR12-14 与子版本体系接入、观测层安全加固与并发异步化

- LAMOST 新增 DR12/13/14 及子版本(v0/v1.0/v1.1/v2.0)维度,Internal 发布标记需登录认证并前端灰显,release×subtype 交叉约束下沉至 capabilities 统一声明
- ObservationFetcher trait 扩展版本/认证/交叉约束能力声明,version 参数贯穿 client→service→API→Agent tool→前端全链路
- 安全:observation cache SQL 全参数绑定 + LIKE 转义、cone_cache_hash 加长度前缀防碰撞、DESI survey/program 白名单防穿越
- 异步化:persist/cached_files_total_size/maybe_persist_tool_result迁移到 tokio::fs;cancelled_runs 与 session_permission_checkers改用 DashMap;auth 读锁优先 + 60s 节流
- Gaia 去 native-tls 改禁用连接池规避 UnexpectedEof,reqwest 移除 native-tls feature
- 重构:Source/ProductType from_str 集中解析、download 模块拆分为 try_download_pdf/html、AgentRuntime::init 抽取共享逻辑
- 部署:新增 deploy.sh 一键打包推送脚本、catch-panic 启用
This commit is contained in:
fmq
2026-07-07 21:16:46 +08:00
parent 2f1fd19d74
commit 2c8d0b8f8b
75 changed files with 2482 additions and 1370 deletions
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<!-- iOS PWA 独立全屏支持 -->
+7 -1
View File
@@ -61,7 +61,13 @@ export default function App() {
// 2. 局部状态定义 (多组件共用或全局网络进度状态)
const [activeTab, setActiveTab] = useState<
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'observation' | 'agent'
| 'search'
| 'library'
| 'reader'
| 'citation'
| 'sync'
| 'observation'
| 'agent'
>(() => {
const saved = localStorage.getItem('astro_active_tab');
const validTabs = [
@@ -642,7 +642,9 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
if (val === null || val === undefined) return '—';
if (typeof val === 'number') {
// 数字保留合理精度
return Number.isInteger(val) ? String(val) : val.toFixed(6).replace(/\.?0+$/, '');
return Number.isInteger(val)
? String(val)
: val.toFixed(6).replace(/\.?0+$/, '');
}
if (typeof val === 'boolean') return val ? 'true' : 'false';
return String(val);
@@ -703,11 +705,17 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
<th
key={i}
className="px-2 py-1.5 text-left font-bold text-slate-600 border-b border-slate-200 whitespace-nowrap"
title={f.description || f.unit ? `${f.description || ''} ${f.unit ? `[${f.unit}]` : ''}`.trim() : undefined}
title={
f.description || f.unit
? `${f.description || ''} ${f.unit ? `[${f.unit}]` : ''}`.trim()
: undefined
}
>
{f.name}
{f.unit && (
<span className="text-slate-400 font-normal ml-1">[{f.unit}]</span>
<span className="text-slate-400 font-normal ml-1">
[{f.unit}]
</span>
)}
</th>
))}
@@ -744,7 +752,6 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
);
}
// ==========================================
// 6. 统一观测数据下载结果卡片 (find_observation)
// 跨 (LAMOST/Gaia/SDSS/DESI) × (Spectrum/LightCurve/Photometry/Image)
@@ -108,7 +108,12 @@ export function ToolCallCard({
metadata={
metadata as {
table_name?: string;
fields: { name: string; description?: string; unit?: string; datatype?: string }[];
fields: {
name: string;
description?: string;
unit?: string;
datatype?: string;
}[];
rows: unknown[][];
row_count: number;
truncated: boolean;
@@ -7,12 +7,7 @@
// 2. (可选)SpecialToolRenderers 的 FindObservationCard 可改为薄封装调用本组件
//
// 视觉与 SpecialToolRenderers::FindObservationCard 保持一致,统一数据源主题色。
import {
Activity,
CheckCircle2,
AlertTriangle,
Download,
} from 'lucide-react';
import { Activity, CheckCircle2, AlertTriangle, Download } from 'lucide-react';
import {
SOURCE_THEME,
PRODUCT_LABEL,
@@ -119,8 +114,7 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
{a.band}
</span>
)}
{a.file_format.toUpperCase()} ·{' '}
{formatFileSize(a.size_bytes)}
{a.file_format.toUpperCase()} · {formatFileSize(a.size_bytes)}
</span>
<a
href={a.file_url}
+54 -29
View File
@@ -12,11 +12,7 @@
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 { ObservationRecord, Candidate, CapabilitySpec } from '../types';
import type { ObservationBatchResult } from '../components/observation/constants';
interface UseObservationProps {
@@ -32,6 +28,8 @@ export interface SearchForm {
dec: string;
radius: string;
release: string;
/// 数据发布的子版本(仅 LAMOST 有意义);空串表示用该 DR 的默认子版本
version: string;
}
export const EMPTY_SEARCH_FORM: SearchForm = {
@@ -42,6 +40,7 @@ export const EMPTY_SEARCH_FORM: SearchForm = {
dec: '',
radius: '0.1',
release: '',
version: '',
};
export function useObservation({ isAuthenticated }: UseObservationProps) {
@@ -65,7 +64,9 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
}, []);
useEffect(() => {
if (isAuthenticated === true) fetchCapabilities();
if (isAuthenticated !== true) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState in microtask
fetchCapabilities();
}, [isAuthenticated, fetchCapabilities]);
// ════════════════════════════════════════════════════
@@ -88,6 +89,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
const valid = currentSpec.releases;
const hardMax = currentSpec.hard_max_radius_deg;
const suggested = currentSpec.suggested_max_radius_deg;
// eslint-disable-next-line react-hooks/set-state-in-effect -- derived form sync on spec change
setSearchForm((f) => {
let next = f;
// release 回落(仅在该源区分版本时)
@@ -103,6 +105,27 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
});
}, [currentSpec]);
// 当 release 切换后:若该 release 有子版本列表,则校验当前 version 是否在列表里,
// 不在则清空(清空后端用默认子版本)。无子版本概念的源(release_versions 为空)也清空。
useEffect(() => {
if (!currentSpec) return;
const versionsMap = currentSpec.release_versions ?? {};
const versions = searchForm.release
? versionsMap[searchForm.release]
: undefined;
// eslint-disable-next-line react-hooks/set-state-in-effect -- derived version reset on release change
setSearchForm((f) => {
if (!versions || versions.length === 0) {
// 该 release 无子版本概念
return f.version ? { ...f, version: '' } : f;
}
if (!f.version || !versions.includes(f.version)) {
return { ...f, version: '' }; // 清空 = 用默认(最新公开)子版本
}
return f;
});
}, [currentSpec, searchForm.release]);
const [searchResults, setSearchResults] = useState<Candidate[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
@@ -131,6 +154,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
dec,
radius: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1),
release: searchForm.release || undefined,
version: searchForm.version || undefined,
},
});
setSearchResults(res.data ?? []);
@@ -169,13 +193,6 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
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[]) => {
@@ -190,6 +207,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
product: searchForm.product,
subtype: searchForm.subtype || undefined,
release: searchForm.release || undefined,
version: searchForm.version || undefined,
force: false,
mode: 'identifiers',
source_ids: sourceIds,
@@ -205,6 +223,13 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
[searchForm]
);
/** 下载选中的候选源(标识符模式) */
const downloadSelected = useCallback(async () => {
const ids = Array.from(selectedSourceIds);
if (ids.length === 0) return;
await downloadByIds(ids);
}, [selectedSourceIds, downloadByIds]);
/** 按坐标下载(cone 检索 + 全部下载) */
const downloadByCoordinates = useCallback(
async (strategy: 'nearest' | 'all' = 'nearest') => {
@@ -224,6 +249,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
product: searchForm.product,
subtype: searchForm.subtype || undefined,
release: searchForm.release || undefined,
version: searchForm.version || undefined,
force: false,
mode: 'coordinates',
ra,
@@ -265,25 +291,23 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
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,
},
}
);
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, '加载缓存库失败,请检查后端连接')
);
setLibraryError(extractErrorMessage(e, '加载缓存库失败,请检查后端连接'));
setLibraryItems([]);
} finally {
setLibraryLoading(false);
@@ -300,6 +324,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
// 任意筛选/分页变更 → 重新请求后端
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState in microtask
fetchLibrary();
}, [fetchLibrary]);
+114 -34
View File
@@ -25,6 +25,7 @@ import {
Database,
Send,
RotateCw,
Lock,
} from 'lucide-react';
import { CustomSelect } from '../components/CustomSelect';
import { ObservationResultCard } from '../components/observation/ObservationResultCard';
@@ -56,11 +57,13 @@ interface ObservationPanelProps {
selectNone: () => void;
// 下载
downloading: boolean;
downloadResult: import('../components/observation/constants').ObservationBatchResult | null;
downloadResult:
import('../components/observation/constants').ObservationBatchResult | null;
downloadError: string | null;
setDownloadResult: React.Dispatch<
React.SetStateAction<
import('../components/observation/constants').ObservationBatchResult | null
| import('../components/observation/constants').ObservationBatchResult
| null
>
>;
downloadSelected: () => Promise<void>;
@@ -195,8 +198,7 @@ function SearchView({
const subtypeOptions = useMemo(() => {
const cap = capabilities.find(
(c) =>
c.source === searchForm.source && c.product === searchForm.product
(c) => c.source === searchForm.source && c.product === searchForm.product
);
return (cap?.subtypes ?? []).map((s) => ({ value: s, label: s }));
}, [capabilities, searchForm.source, searchForm.product]);
@@ -208,22 +210,42 @@ function SearchView({
// 当前源+产品支持的版本列表(供下拉)
const releaseOptions = useMemo(() => {
const authRequired = new Set(currentSpec?.releases_requiring_auth ?? []);
return (currentSpec?.releases ?? []).map((r) => ({
value: r,
label: r.toUpperCase(),
label: authRequired.has(r)
? `${r.toUpperCase()} · 需登录`
: 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 数据';
// 当前选中的 release 是否需要登录(Internal DR
const currentReleaseNeedsAuth = useMemo(() => {
if (!searchForm.release || !currentSpec) return false;
return (currentSpec.releases_requiring_auth ?? []).includes(
searchForm.release
);
}, [searchForm.release, currentSpec]);
// 交叉约束:当前 release 是否支持当前 subtype(从 capabilities 派生,不硬编码)
const subtypeBlocked = useMemo(() => {
if (!currentSpec || !searchForm.release || !searchForm.subtype) return null;
const map = currentSpec.subtypes_per_release;
if (!map || Object.keys(map).length === 0) return null; // 无交叉约束
const valid = map[searchForm.release];
if (valid && !valid.includes(searchForm.subtype)) {
return `'${searchForm.release.toUpperCase()}' 不支持子类型 '${searchForm.subtype}'`;
}
return null;
}, [searchForm.source, searchForm.subtype, searchForm.release]);
}, [currentSpec, searchForm.release, searchForm.subtype]);
// 当前 release 对应的子版本列表(仅 LAMOST 有内容)
const versionOptions = useMemo(() => {
if (!currentSpec || !searchForm.release) return [];
const versionsMap = currentSpec.release_versions ?? {};
const versions = versionsMap[searchForm.release] ?? [];
return versions.map((v) => ({ value: v, label: v.toUpperCase() }));
}, [currentSpec, searchForm.release]);
const handleSubmitIds = () => {
const ids = idsText
@@ -275,7 +297,7 @@ function SearchView({
.map((c) => c.product);
const safeProduct = newProducts.includes(f.product)
? f.product
: newProducts[0] ?? f.product;
: (newProducts[0] ?? f.product);
return { ...f, source: v, product: safeProduct, subtype: '' };
});
}}
@@ -331,27 +353,67 @@ function SearchView({
}
className="w-full"
options={[
{ value: '__none__', label: `默认${currentSpec?.default_release ? `${currentSpec.default_release.toUpperCase()}` : ''}` },
{
value: '__none__',
label: `默认${currentSpec?.default_release ? `${currentSpec.default_release.toUpperCase()}` : ''}`,
},
...releaseOptions,
]}
/>
)}
</Field>
{/* 子版本(仅 LAMOST 有内容;其他源该 Field 不渲染) */}
{versionOptions.length > 0 && (
<Field label="子版本">
<CustomSelect
value={searchForm.version || '__none__'}
onChange={(v) =>
setSearchForm((f) => ({
...f,
version: v === '__none__' ? '' : v,
}))
}
className="w-full"
options={[
{ value: '__none__', label: '默认(最新公开)' },
...versionOptions,
]}
/>
</Field>
)}
</div>
{/* LAMOST MRS 版本约束提示 */}
{lamostMrsBlocked && (
{/* Internal DR 需登录提示 */}
{currentReleaseNeedsAuth && (
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-2.5">
<Lock className="w-3.5 h-3.5 shrink-0" />
<span>
{searchForm.release.toUpperCase()} Internal
oauth.china-vo.org 访
</span>
</div>
)}
{/* 交叉约束提示:当前 release 不支持该 subtype */}
{subtypeBlocked && (
<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>
<span>{subtypeBlocked}</span>
{currentSpec?.subtypes && currentSpec.subtypes.length > 0 && (
<button
onClick={() => {
// 切换到该 release 支持的首个 subtype
const map = currentSpec?.subtypes_per_release;
const valid =
(map && searchForm.release && map[searchForm.release]) ||
currentSpec?.subtypes;
setSearchForm((f) => ({ ...f, subtype: valid?.[0] ?? '' }));
}}
className="ml-auto px-2 py-0.5 rounded bg-amber-100 hover:bg-amber-200 text-amber-800 font-bold text-[10px]"
>
</button>
)}
</div>
)}
@@ -410,14 +472,15 @@ function SearchView({
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<span>
{searchForm.radius}°
{currentSpec.suggested_max_radius_deg}°
{currentSpec.suggested_max_radius_deg}
°
</span>
</div>
)}
<div className="flex items-center gap-2 pt-1">
<button
onClick={runSearch}
disabled={searching}
disabled={searching || currentReleaseNeedsAuth}
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 ? (
@@ -432,7 +495,7 @@ function SearchView({
</span>
<button
onClick={() => downloadByCoordinates('nearest')}
disabled={downloading}
disabled={downloading || currentReleaseNeedsAuth}
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" />
@@ -786,7 +849,10 @@ function LibraryView({
<>
<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} />
<LibraryCard
key={`${rec.source}|${rec.product}|${rec.source_id}`}
rec={rec}
/>
))}
</div>
@@ -863,7 +929,11 @@ function Field({
compact?: boolean;
}) {
return (
<div className={compact ? 'w-full sm:w-36 space-y-1.5 font-bold' : 'space-y-1.5'}>
<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>
@@ -894,7 +964,12 @@ 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 }> = [];
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;
@@ -910,7 +985,9 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
<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}`}>
<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">
@@ -927,7 +1004,10 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
<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}>
<div
className="font-mono text-xs text-slate-800 font-semibold truncate"
title={rec.source_id}
>
{rec.source_id}
</div>
</div>
+6
View File
@@ -250,6 +250,12 @@ export interface CapabilitySpec {
subtypes: string[];
releases: string[];
default_release?: string | null;
/// 每个 release 对应的子版本列表(仅 LAMOST 有内容,如 {dr11: ["v0","v1.0","v1.1","v2.0"]}
release_versions?: Record<string, string[]>;
/// 需要登录认证才能访问的 release 列表(如 LAMOST Internal DR12-14),空表示全部公开
releases_requiring_auth?: string[];
/// 每个 release 实际支持的 subtype(交叉约束,如 LAMOST DR5/6 无 MRS);空表示全部支持
subtypes_per_release?: Record<string, string[]>;
suggested_max_radius_deg: number;
hard_max_radius_deg: number;
identifier_format?: string | null;
+1 -4
View File
@@ -16,10 +16,7 @@ interface AxiosLikeError {
///
/// 优先级:response.data 为字符串 → 对象里的 error/message/detail/description
/// → 整体 JSON 序列化兜底 → axios message → fallback
export function extractErrorMessage(
e: unknown,
fallback: string
): string {
export function extractErrorMessage(e: unknown, fallback: string): string {
const axiosError = e as AxiosLikeError;
const data = axiosError?.response?.data;
if (typeof data === 'string') return data;