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,26 +4,47 @@ import { requireAuth } from '../middleware/auth.js';
const router: RouterType = Router();
router.use(requireAuth);
// CORS proxy: frontend calls this when direct fetch is blocked by CORS
// 服务端兜底抓取:只有当浏览器直连被 CORS / 混合内容拦截时前端才会调用这里。
// 目标地址若是内网 / VPN 资源,服务器通常访问不到,必须快速失败并返回 JSON
// 否则请求会挂到 nginx 超时,前端只能看到一个没有信息量的 502/504 HTML 页面。
const FETCH_TIMEOUT_MS = 15_000;
router.get('/', async (req, res) => {
const specUrl = req.query.url as string;
if (!specUrl || !specUrl.startsWith('http')) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide a valid URL' } });
let target: URL;
try {
target = new URL(specUrl);
if (target.protocol !== 'http:' && target.protocol !== 'https:') throw new Error('bad protocol');
} catch {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide a valid http(s) URL' } });
return;
}
try {
const response = await fetch(specUrl, {
const response = await fetch(target.toString(), {
headers: { Accept: 'application/json, application/yaml, text/yaml, text/plain, */*' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: `Remote server returned ${response.status}` } });
res.status(502).json({
success: false,
error: { code: 'FETCH_FAILED', message: `Remote server returned ${response.status}` },
});
return;
}
const text = await response.text();
res.json({ success: true, data: { content: text, contentType: response.headers.get('content-type') || '' } });
} catch (err) {
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: err instanceof Error ? err.message : 'Failed to fetch URL' } });
const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
res.status(502).json({
success: false,
error: {
code: timedOut ? 'FETCH_TIMEOUT' : 'FETCH_FAILED',
message: timedOut
? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s — the server cannot reach ${target.host}`
: `${err instanceof Error ? err.message : 'Failed to fetch URL'} (${target.host})`,
},
});
}
});

View File

@@ -29,10 +29,11 @@ router.post('/:id/reimport', async (req, res) => {
await tx.endpoint.deleteMany({ where: { projectId: project.id } });
await tx.module.deleteMany({ where: { projectId: project.id } });
// 只更新文档本体,项目名称与描述由用户自行维护,重新导入不覆盖
await tx.project.update({
where: { id: project.id },
data: {
name: parsed.name, description: parsed.description, baseUrl: parsed.baseUrl,
baseUrl: parsed.baseUrl,
openApiSpec: parsed.spec as any, openApiVersion: parsed.openApiVersion,
},
});

View File

@@ -88,7 +88,7 @@ router.get('/:id', async (req, res) => {
include: { _count: { select: { endpoints: true } } },
orderBy: { sortOrder: 'asc' },
},
_count: { select: { endpoints: true } },
_count: { select: { endpoints: true, modules: true } },
},
});
if (!project) {