fix: 修复重新导入的模块数缺失、名称被覆盖与 URL 抓取报错

- 项目详情接口 _count 漏 select modules,重新导入弹框因此显示「undefined 个模块」
- 重新导入不再用文档中的 name/description 覆盖用户自行设置的项目名称与描述
- URL 抓取失败原因此前被完全吞掉,只剩无信息量的 502:前端改为预判混合内容、
  跳过必然超时的内网服务端代理、直连加超时,并抛出带错误码的 SpecFetchError
- 服务端代理加 15s 超时并始终返回 JSON,不再挂到 nginx 超时返回 HTML 错误页
- 新增 SpecFetchErrorNotice 组件,跨域类失败时可展开具体的 CORS 配置引导
- nginx /api/ 补 client_max_body_size 12m,避免大文档在 nginx 层被 413 挡掉

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 11:00:22 +08:00
parent 65e4e6778d
commit 0e163e02ed
11 changed files with 254 additions and 39 deletions

View File

@@ -4,6 +4,9 @@ server {
index index.html;
location /api/ {
# OpenAPI 文档整份提交,默认 1m 上限会被大文档打爆server 侧允许 10mb
client_max_body_size 12m;
proxy_read_timeout 120s;
proxy_pass http://server:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
import { SpecFetchError, describeSpecFetchError } from '../lib/fetch-spec';
import { useI18n } from '../lib/i18n';
/** 这些失败都卡在浏览器的跨域策略上,光看报错用户无从下手,需要给出具体配置 */
const FIXABLE_BY_CONFIG: ReadonlySet<string> = new Set(['blocked', 'mixedContent', 'privateNetwork']);
function corsSnippet(origin: string): string {
return [
'# Response headers',
`Access-Control-Allow-Origin: ${origin}`,
'Access-Control-Allow-Private-Network: true',
'',
'# Nginx',
`add_header Access-Control-Allow-Origin "${origin}" always;`,
'add_header Access-Control-Allow-Private-Network "true" always;',
].join('\n');
}
/** 导入失败提示:错误原因 + 可展开的跨域配置引导 */
export default function SpecFetchErrorNotice({ error }: { error: unknown }) {
const [showHelp, setShowHelp] = useState(false);
const { t } = useI18n();
if (!error) return null;
const code = error instanceof SpecFetchError ? error.code : '';
const helpful = FIXABLE_BY_CONFIG.has(code);
return (
<div className="p-3 rounded-lg bg-danger-muted space-y-2">
<div className="flex items-start gap-2">
<svg className="w-4 h-4 text-danger shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6m0-6l6 6" />
</svg>
<span className="text-danger text-[13px]">{describeSpecFetchError(error, t)}</span>
</div>
{helpful && (
<>
<button
onClick={() => setShowHelp((v) => !v)}
className="text-[12px] text-danger underline underline-offset-2 hover:opacity-80"
>
{showHelp ? t('common.specFetch.helpHide') : t('common.specFetch.helpShow')}
</button>
{showHelp && (
<div className="text-[12px] text-text-secondary space-y-2 pt-1">
<p>{t('common.specFetch.helpIntro')}</p>
<ol className="list-decimal pl-4 space-y-2">
<li>
<p>{t('common.specFetch.helpCors')}</p>
<pre className="mt-1.5 p-2 rounded-md bg-bg-tertiary text-[11px] leading-relaxed text-text-primary overflow-x-auto select-all">
{corsSnippet(window.location.origin)}
</pre>
</li>
<li>{t('common.specFetch.helpMixed')}</li>
<li>{t('common.specFetch.helpFallback')}</li>
</ol>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -1,32 +1,129 @@
import yaml from 'js-yaml';
import { apiFetch } from './api';
import type { TFunction } from './i18n';
const DIRECT_TIMEOUT_MS = 30_000;
export type SpecFetchCode = 'invalidUrl' | 'mixedContent' | 'privateNetwork' | 'blocked' | 'httpStatus' | 'parse';
/** 带错误码的抓取失败,供界面翻译成可操作的提示 */
export class SpecFetchError extends Error {
code: SpecFetchCode;
detail: string;
proxyDetail: string;
constructor(code: SpecFetchCode, detail = '', proxyDetail = '') {
super(`spec fetch failed: ${code}${detail ? ` (${detail})` : ''}`);
this.name = 'SpecFetchError';
this.code = code;
this.detail = detail;
this.proxyDetail = proxyDetail;
}
}
function parseSpecText(text: string): object {
const trimmed = text.trim();
if (!trimmed) throw new SpecFetchError('parse', 'empty response');
try {
return JSON.parse(text);
return JSON.parse(trimmed);
} catch {
return yaml.load(text) as object;
// 不是 JSON继续按 YAML 解析
}
let loaded: unknown;
try {
loaded = yaml.load(trimmed);
} catch (err) {
throw new SpecFetchError('parse', err instanceof Error ? err.message.split('\n')[0] : 'invalid YAML');
}
if (!loaded || typeof loaded !== 'object') {
throw new SpecFetchError('parse', trimmed.slice(0, 60));
}
return loaded as object;
}
function isLoopback(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
}
/** 私有网段 / 本机 / 无域名后缀的主机名——只有用户的浏览器所在网络能访问到,服务端代理跑一趟必然超时 */
function isPrivateHost(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (isLoopback(host)) return true;
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.lan')) return true;
if (host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80:')) return true; // IPv6 ULA / link-local
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])];
return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254);
}
// 单段主机名(如 http://gateway/openapi.json只在内网 DNS 里能解析
return !host.includes('.');
}
/**
* Fetch an OpenAPI spec from a URL and parse it.
* 1. Try direct fetch from browser (works for localhost/intranet)
* 2. If CORS blocks it, fall back to server-side proxy
* Returns a parsed spec object (JSON or YAML).
* 从 URL 抓取 OpenAPI 文档。
*
* 1. 优先由浏览器直连抓取——用户的文档常位于内网或需要 VPN只有浏览器所在的网络环境能访问到。
* 2. 浏览器被拦截CORS / 混合内容)时才退回服务端代理,服务端通常访问不到内网地址。
* 3. 两条路都失败时抛出带错误码的 SpecFetchError界面据此告诉用户到底卡在哪一步。
*/
export async function fetchSpecFromUrl(url: string): Promise<object> {
// Try direct fetch first (handles localhost, intranet, CORS-friendly APIs)
let target: URL;
try {
const res = await fetch(url, {
headers: { Accept: 'application/json, application/yaml, text/yaml, */*' },
});
if (res.ok) return parseSpecText(await res.text());
target = new URL(url.trim());
} catch {
// CORS or network error — fall through to server proxy
throw new SpecFetchError('invalidUrl');
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new SpecFetchError('invalidUrl');
}
// Fall back to server-side proxy for CORS-restricted URLs
const data = await apiFetch<{ content: string }>(`/fetch-spec?url=${encodeURIComponent(url)}`);
return parseSpecText(data.content);
// HTTPS 页面读取 http:// 资源会被浏览器的混合内容策略直接拦截,连请求都发不出去
const mixedContent =
window.location.protocol === 'https:' && target.protocol === 'http:' && !isLoopback(target.hostname);
let directDetail = '';
if (mixedContent) {
directDetail = 'mixed content blocked by the browser';
} else {
try {
const res = await fetch(target.toString(), {
headers: { Accept: 'application/json, application/yaml, text/yaml, */*' },
signal: AbortSignal.timeout(DIRECT_TIMEOUT_MS),
});
// 拿到了真实状态码说明 CORS 已通过,结果是确定的,无需再走服务端代理
if (!res.ok) throw new SpecFetchError('httpStatus', `${res.status} ${res.statusText}`.trim());
return parseSpecText(await res.text());
} catch (err) {
if (err instanceof SpecFetchError) throw err;
directDetail = err instanceof Error ? err.message : String(err);
}
}
// 内网地址服务端必然访问不到,不必白等一次超时
if (isPrivateHost(target.hostname)) {
throw new SpecFetchError('privateNetwork', directDetail);
}
// 浏览器直连失败,退回服务端代理(对公网可达但未开 CORS 的地址有效)
try {
const data = await apiFetch<{ content: string }>(`/fetch-spec?url=${encodeURIComponent(target.toString())}`);
return parseSpecText(data.content);
} catch (err) {
if (err instanceof SpecFetchError) throw err;
const proxyDetail = err instanceof Error ? err.message : String(err);
throw new SpecFetchError(mixedContent ? 'mixedContent' : 'blocked', directDetail, proxyDetail);
}
}
/** 把抓取错误翻译成用户可读、可操作的提示 */
export function describeSpecFetchError(err: unknown, t: TFunction): string {
if (err instanceof SpecFetchError) {
return t(`common.specFetch.${err.code}` as 'common.specFetch.blocked', {
detail: err.detail,
proxyDetail: err.proxyDetail,
});
}
if (err instanceof Error) return err.message;
return t('common.specFetch.blocked', { detail: '', proxyDetail: '' });
}

View File

@@ -166,6 +166,20 @@ const en = {
'common.dropFile': 'Drop your OpenAPI file here',
'common.jsonOrYaml': 'JSON or YAML',
// ===== Spec fetching =====
'common.specFetch.invalidUrl': 'Invalid URL. Enter a full address starting with http:// or https://.',
'common.specFetch.mixedContent': 'This page is served over HTTPS, so the browser blocks reading an http:// address (mixed content). The server-side fallback failed too ({proxyDetail}).',
'common.specFetch.privateNetwork': 'This address lives on a private network, so only your browser can reach it — but the browser was blocked from reading it ({detail}).',
'common.specFetch.blocked': 'The browser could not read this URL directly — the target service most likely does not allow cross-origin requests ({detail}). The server-side fallback failed too ({proxyDetail}).',
'common.specFetch.httpStatus': 'The document server returned {detail}.',
'common.specFetch.parse': 'The fetched content is not a valid JSON or YAML document ({detail}).',
'common.specFetch.helpShow': 'How do I make URL import work?',
'common.specFetch.helpHide': 'Hide',
'common.specFetch.helpIntro': 'Browsers only read cross-origin URLs that explicitly allow it. URL import works once all of the following are true:',
'common.specFetch.helpCors': 'The document service returns CORS headers allowing this site. Private network addresses need the Private Network header too:',
'common.specFetch.helpMixed': 'If the document URL is http:// while this site is HTTPS, also set "Insecure content" to "Allow" under the lock icon → Site settings in your browser.',
'common.specFetch.helpFallback': 'If changing the document service is not an option, open the URL in your browser, save it as a .json or .yaml file, and import it via "Upload File" — that path is not subject to cross-origin rules and gives the same result.',
// ===== Theme =====
'theme.light': 'Light',
'theme.dark': 'Dark',

View File

@@ -168,6 +168,20 @@ const zh: Record<TranslationKey, string> = {
'common.dropFile': '将 OpenAPI 文件拖放到这里',
'common.jsonOrYaml': 'JSON 或 YAML',
// ===== Spec fetching =====
'common.specFetch.invalidUrl': 'URL 无效,请填写以 http:// 或 https:// 开头的完整地址。',
'common.specFetch.mixedContent': '当前页面是 HTTPS浏览器禁止读取 http:// 地址(混合内容拦截);服务端代抓取同样失败({proxyDetail})。',
'common.specFetch.privateNetwork': '该地址位于内网或私有网段,只有你的浏览器能访问到,但浏览器直连被拦截({detail})。',
'common.specFetch.blocked': '浏览器无法直接读取该地址,通常是目标服务未允许跨域访问({detail});服务端代抓取同样失败({proxyDetail})。',
'common.specFetch.httpStatus': '文档服务返回 {detail}。',
'common.specFetch.parse': '抓取到的内容不是有效的 JSON 或 YAML 文档({detail})。',
'common.specFetch.helpShow': '如何让 URL 导入可用?',
'common.specFetch.helpHide': '收起',
'common.specFetch.helpIntro': '浏览器只允许读取明确放行的跨域地址。要让 URL 导入生效,需要同时满足下面几点:',
'common.specFetch.helpCors': '文档服务在响应中带上跨域头,放行本站;内网地址还需要 Private Network 头:',
'common.specFetch.helpMixed': '文档地址是 http:// 而本站是 HTTPS 时,还需在浏览器地址栏的锁图标 →「网站设置」→「不安全内容」中选择「允许」。',
'common.specFetch.helpFallback': '不方便改动文档服务时,在浏览器里打开该地址、另存为 .json 或 .yaml 文件,再用「上传文件」导入——这条路不受跨域限制,效果完全相同。',
// ===== Theme =====
'theme.light': '浅色',
'theme.dark': '深色',

View File

@@ -5,6 +5,7 @@ import { apiFetch } from '../lib/api';
import { fetchSpecFromUrl } from '../lib/fetch-spec';
import { useI18n } from '../lib/i18n';
import Modal from '../components/Modal';
import SpecFetchErrorNotice from '../components/SpecFetchErrorNotice';
type ImportResult = {
project: { id: string; name: string };
@@ -17,7 +18,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
const [fileContent, setFileContent] = useState<string>('');
const [fileName, setFileName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState<unknown>(null);
const [result, setResult] = useState<ImportResult | null>(null);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -41,7 +42,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
const handleImport = async () => {
setLoading(true);
setError('');
setError(null);
try {
let body: Record<string, unknown>;
if (mode === 'url') {
@@ -56,7 +57,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
setResult(data);
queryClient.invalidateQueries({ queryKey: ['projects'] });
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
setError(err);
} finally {
setLoading(false);
}
@@ -104,12 +105,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
</div>
)}
{error && (
<div className="p-3 rounded-lg bg-danger-muted flex items-center gap-2">
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
<span className="text-danger text-[13px]">{error}</span>
</div>
)}
<SpecFetchErrorNotice error={error} />
<div className="flex justify-end gap-2.5">
<button onClick={onClose} className="btn-ghost">{t('common.cancel')}</button>

View File

@@ -3,6 +3,7 @@ import { apiFetch } from '../lib/api';
import { fetchSpecFromUrl } from '../lib/fetch-spec';
import { useI18n } from '../lib/i18n';
import Modal from '../components/Modal';
import SpecFetchErrorNotice from '../components/SpecFetchErrorNotice';
type ReimportResult = {
stats: { modules: number; endpoints: number };
@@ -24,7 +25,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
const [fileContent, setFileContent] = useState('');
const [fileName, setFileName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState<unknown>(null);
const [result, setResult] = useState<ReimportResult | null>(null);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -46,7 +47,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
const handleReimport = async () => {
setLoading(true);
setError('');
setError(null);
try {
let body: Record<string, unknown>;
if (mode === 'url') {
@@ -61,7 +62,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
setResult(data);
setStep('success');
} catch (err) {
setError(err instanceof Error ? err.message : 'Re-import failed');
setError(err);
} finally {
setLoading(false);
}
@@ -84,8 +85,8 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
<div className="text-sm">
<p className="font-medium text-warning mb-1">{t('dashboard.reimport.warningTitle')}</p>
<ul className="text-text-secondary space-y-1">
<li>{t('dashboard.reimport.warningModules', { count: currentStats.modules })}</li>
<li>{t('dashboard.reimport.warningEndpoints', { count: currentStats.endpoints })}</li>
<li>{t('dashboard.reimport.warningModules', { count: currentStats.modules ?? 0 })}</li>
<li>{t('dashboard.reimport.warningEndpoints', { count: currentStats.endpoints ?? 0 })}</li>
</ul>
<p className="text-text-muted mt-2">{t('dashboard.reimport.warningNote')}</p>
</div>
@@ -138,7 +139,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
</div>
)}
{error && <div className="p-3 rounded-lg bg-danger-muted text-danger text-sm">{error}</div>}
<SpecFetchErrorNotice error={error} />
<div className="flex justify-end gap-3">
<button onClick={() => setStep('confirm')} className="btn-ghost">{t('common.back')}</button>

View File

@@ -66,7 +66,7 @@ export default function ProjectSettings({ project }: { project: Project }) {
<section className="border-t border-border-default pt-8">
<p className="section-title">{t('dashboard.projectSettings.reimportTitle')}</p>
<p className="section-desc mb-4">
{t('dashboard.projectSettings.reimportDesc', { modules: project._count.modules, endpoints: project._count.endpoints })}
{t('dashboard.projectSettings.reimportDesc', { modules: project._count.modules ?? 0, endpoints: project._count.endpoints ?? 0 })}
</p>
<button onClick={() => setShowReimport(true)} className="btn-outline">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>