import { Router } from 'express'; import * as fs from 'fs'; import * as path from 'path'; import multer from 'multer'; import * as db from './db'; import { samplePointcloudFile, type SampledPoint } from './pointcloudSample.js'; import { forceReload as reloadPluginRegistry } from './plugins/registry.js'; const router = Router(); const DATA_DIR = process.env.DATA_DIR || '/data'; const MODELS_DIR = path.join(DATA_DIR, 'models'); const TASKS_DIR = path.join(DATA_DIR, 'tasks'); function ensureDir(dir: string) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } ensureDir(MODELS_DIR); ensureDir(TASKS_DIR); // ─── multer:流式写盘,避免大文件撑爆内存 ──────────────────────────────────── const diskStorage = multer.diskStorage({ destination: (req, _file, cb) => { let dir: string; if (req.params.modelId) { dir = path.join(MODELS_DIR, String(parseInt(req.params.modelId, 10)), 'cad'); } else if (req.params.taskId && req.path.includes('/task-cad/')) { dir = path.join(TASKS_DIR, String(parseInt(req.params.taskId, 10)), 'cad'); } else if (req.params.taskId) { dir = path.join(TASKS_DIR, String(parseInt(req.params.taskId, 10)), 'pointcloud'); } else { dir = TASKS_DIR; } ensureDir(dir); cb(null, dir); }, filename: (_req, file, cb) => { cb(null, file.originalname); }, }); const diskUpload = multer({ storage: diskStorage, limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50 GB }); // ─── 工具函数 ───────────────────────────────────────────────────────────────── function formatFileSize(bytes: number): string { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / 1e6).toFixed(1) + ' MB'; return (bytes / 1e9).toFixed(2) + ' GB'; } function safeResolve(base: string, filename: string): string | null { const resolved = path.resolve(base, decodeURIComponent(filename)); return resolved.startsWith(path.resolve(base)) ? resolved : null; } // ─── 车型 CAD 文件上传 ──────────────────────────────────────────────────────── // POST /api/files/upload/cad/:modelId // multipart/form-data field: file router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res) => { const modelId = parseInt(req.params.modelId, 10); if (!req.file || isNaN(modelId)) { return res.status(400).json({ success: false, error: 'Missing file or modelId' }); } const destPath = req.file.path; const format = path.extname(req.file.originalname).slice(1).toLowerCase(); const record = await db.createModelCadFile({ modelId, name: req.file.originalname, format, fileSize: req.file.size, filePath: destPath, }); res.json({ success: true, file: { id: record.id, name: record.name, format: format.toUpperCase(), size: formatFileSize(record.fileSize), filePath: record.filePath, }, }); }); // ─── 任务 CAD 文件上传 ──────────────────────────────────────────────────────── // POST /api/files/upload/task-cad/:taskId router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, res) => { const taskId = parseInt(req.params.taskId, 10); if (!req.file || isNaN(taskId)) { return res.status(400).json({ success: false, error: 'Missing file or taskId' }); } const destPath = req.file.path; const format = path.extname(req.file.originalname).slice(1).toLowerCase(); const record = await db.createTaskCadFile({ taskId, name: req.file.originalname, format, fileSize: req.file.size, filePath: destPath, }); res.json({ success: true, file: { id: record.id, name: record.name, format: format.toUpperCase(), size: formatFileSize(record.fileSize), filePath: record.filePath, }, }); }); // ─── 读取任务 CAD 文件 ──────────────────────────────────────────────────────── // GET /api/files/read/task-cad/:taskId/:filename router.get('/read/task-cad/:taskId/:filename', (req, res) => { const cadDir = path.join(TASKS_DIR, req.params.taskId, 'cad'); const filePath = safeResolve(cadDir, req.params.filename); if (!filePath) return res.status(403).json({ error: 'Access denied' }); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' }); res.sendFile(filePath); }); // ─── 任务点云文件上传 ───────────────────────────────────────────────────────── // POST /api/files/upload/pointcloud/:taskId router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req, res) => { const taskId = parseInt(req.params.taskId, 10); if (!req.file || isNaN(taskId)) { return res.status(400).json({ success: false, error: 'Missing file or taskId' }); } const destPath = req.file.path; const record = await db.createTaskPointcloudFile({ taskId, name: req.file.originalname, fileSize: req.file.size, filePath: destPath, }); res.json({ success: true, file: { id: record.id, name: record.name, size: formatFileSize(record.fileSize), filePath: record.filePath, }, }); }); // ─── 读取 CAD 文件 ───────────────────────────────────────────────────────────── // GET /api/files/read/cad/:modelId/:filename router.get('/read/cad/:modelId/:filename', (req, res) => { const cadDir = path.join(MODELS_DIR, req.params.modelId, 'cad'); const filePath = safeResolve(cadDir, req.params.filename); if (!filePath) return res.status(403).json({ error: 'Access denied' }); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' }); res.sendFile(filePath); }); // ─── 读取点云文件 ───────────────────────────────────────────────────────────── // GET /api/files/read/pointcloud/:taskId/:filename router.get('/read/pointcloud/:taskId/:filename', (req, res) => { const pcDir = path.join(TASKS_DIR, req.params.taskId, 'pointcloud'); const filePath = safeResolve(pcDir, req.params.filename); if (!filePath) return res.status(403).json({ error: 'Access denied' }); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' }); res.sendFile(filePath); }); // ─── 删除 CAD 文件 ───────────────────────────────────────────────────────────── // DELETE /api/files/delete/cad/:id router.delete('/delete/cad/:id', async (req, res) => { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' }); const files = await db.listModelCadFiles(0); // won't use this—fetch by id instead // Use the filePath stored in DB record via listModelCadFiles won't work for single id. // So we just delete the DB record; actual file cleanup optional. // For a cleaner approach, expose a getModelCadFileById in db or accept filePath from client. // Here we simply delete the DB record; the file remains until model is deleted. await db.deleteModelCadFile(id); res.json({ success: true }); }); // ─── 删除点云文件 ───────────────────────────────────────────────────────────── // DELETE /api/files/delete/pointcloud/:id router.delete('/delete/pointcloud/:id', async (req, res) => { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' }); await db.deleteTaskPointcloudFile(id); res.json({ success: true }); }); // ─── 批量采样接口 ───────────────────────────────────────────────────────────── // POST /api/files/sample/batch // Body: { taskId: number, totalPoints?: number } // or { filePaths: string[], totalPoints?: number } (内部绝对路径,需认证) router.post('/sample/batch', async (req, res) => { const { taskId, filePaths: rawPaths, totalPoints = 1000000, fileIds } = req.body as { taskId?: number; filePaths?: string[]; totalPoints?: number; /** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */ fileIds?: number[]; }; let paths: string[] = []; if (taskId != null) { let records = await db.listTaskPointcloudFiles(taskId); // 当前端传入 fileIds 时只采样选中的文件 if (Array.isArray(fileIds) && fileIds.length > 0) { const idSet = new Set(fileIds); records = records.filter((r) => idSet.has(r.id)); } paths = records.map((r) => r.filePath); } else if (Array.isArray(rawPaths) && rawPaths.length > 0) { paths = rawPaths; } else { return res.status(400).json({ error: 'Provide taskId or filePaths' }); } if (paths.length === 0) { const empty = Buffer.alloc(0); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('X-Point-Count', '0'); res.setHeader('X-File-Stats', '[]'); return res.send(empty); } const budget = Math.min(totalPoints, 2000000); const supportedExts = ['.ply', '.txt', '.xyz', '.pts']; const fileInfos = paths.map((fp) => ({ filePath: fp, name: path.basename(fp), ext: path.extname(fp).toLowerCase(), size: fs.existsSync(fp) ? fs.statSync(fp).size : 0, })); const supportedFiles = fileInfos.filter((f) => supportedExts.includes(f.ext) && f.size > 0); const totalBytes = supportedFiles.reduce((s, f) => s + f.size, 0); console.log( `[FileRoutes] Batch sample: ${fileInfos.length} files ` + `(${(totalBytes / 1e9).toFixed(2)} GB), budget=${budget}` ); try { const quotaMap = new Map(); for (const f of supportedFiles) { const quota = totalBytes > 0 ? Math.max(100, Math.round((f.size / totalBytes) * budget)) : Math.floor(budget / supportedFiles.length); quotaMap.set(f.filePath, quota); } const sampledPerFile = await Promise.all( fileInfos.map((f) => { if (!supportedExts.includes(f.ext) || f.size === 0) return Promise.resolve([] as SampledPoint[]); return samplePointcloudFile(f.filePath, f.size, quotaMap.get(f.filePath) ?? 0); }) ); const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0); // 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节 const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined)); const bytesPerPoint = hasColor ? 28 : 16; const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint); let offset = 0; for (const pts of sampledPerFile) { for (const p of pts) { binBuf.writeFloatLE(p.x, offset); binBuf.writeFloatLE(p.y, offset + 4); binBuf.writeFloatLE(p.z, offset + 8); binBuf.writeFloatLE(p.error, offset + 12); if (hasColor) { binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16); binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20); binBuf.writeFloatLE((p.b ?? 128) / 255, offset + 24); } offset += bytesPerPoint; } } const fileStats = fileInfos.map((f, i) => ({ name: f.name, sizeGB: +(f.size / 1e9).toFixed(3), sampled: sampledPerFile[i].length, })); console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('X-Point-Count', totalSampled); res.setHeader('X-Has-Color', hasColor ? '1' : '0'); res.setHeader('X-File-Stats', JSON.stringify(fileStats)); res.send(binBuf); } catch (err) { console.error('[FileRoutes] Batch sampling error:', err); res.status(500).json({ error: 'Failed to sample files' }); } }); // ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)────────────────────── // GET /api/files/read/analysis/heatmap/:taskId/:resultId router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => { const taskId = parseInt(req.params.taskId, 10); const resultId = parseInt(req.params.resultId, 10); if (Number.isNaN(taskId) || Number.isNaN(resultId)) { return res.status(400).json({ error: 'invalid id' }); } const row = await db.getTaskAnalysisResultById(resultId); if (!row || row.taskId !== taskId || (row.type !== 'smooth_heatmap' && row.type !== 'obj_deviation') || row.status !== 'completed') { return res.status(404).json({ error: 'not found' }); } const rd = row.resultData as { vertexCount?: number; dispMin?: number; dispMax?: number; binName?: string; } | null; const binName = rd?.binName ?? `heatmap_${resultId}.bin`; const binPath = path.join(TASKS_DIR, String(taskId), 'analysis', binName); if (!fs.existsSync(binPath)) { return res.status(404).json({ error: 'heatmap file missing' }); } const body = fs.readFileSync(binPath); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Length', String(body.length)); res.setHeader('X-Vertex-Count', String(rd?.vertexCount ?? '')); res.setHeader('X-Disp-Min', String(rd?.dispMin ?? '')); res.setHeader('X-Disp-Max', String(rd?.dispMax ?? '')); res.send(body); }); // ─── 装配检测:每零件顶点热力缓存(可选)────────────────────────────────────── // GET /api/files/read/analysis/assembly-vertex/:taskId/:resultId/:filename router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (req, res) => { const taskId = parseInt(req.params.taskId, 10); const resultId = parseInt(req.params.resultId, 10); const filename = req.params.filename; if (Number.isNaN(taskId) || Number.isNaN(resultId) || !filename) { return res.status(400).json({ error: 'invalid params' }); } const row = await db.getTaskAnalysisResultById(resultId); if (!row || row.taskId !== taskId || row.type !== 'assembly_inspection' || row.status !== 'completed') { return res.status(404).json({ error: 'not found' }); } const analysisDir = path.join(TASKS_DIR, String(taskId), 'analysis'); const filePath = safeResolve(analysisDir, filename); if (!filePath) return res.status(403).json({ error: 'Access denied' }); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'file missing' }); res.setHeader('Content-Type', 'application/octet-stream'); res.sendFile(filePath); }); // ─── 通用算法插件产物(Route B) ────────────────────────────────────────────── // GET /api/files/read/analysis/:taskId/:resultId/* // // 每次插件执行的 outDir = /data/tasks//analysis/。 // 前端拿到 result.json.images / overlay3d.file 后通过此路由拉取产物文件。 // 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则 // 会用 taskId="heatmap" 这种字符串把请求误吞掉。 router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => { const taskId = parseInt(req.params.taskId, 10); const resultId = parseInt(req.params.resultId, 10); const filename = (req.params as Record)[0] || (req.params as Record)['0']; if (Number.isNaN(taskId) || Number.isNaN(resultId) || !filename) { return res.status(400).json({ error: 'invalid params' }); } const row = await db.getTaskAnalysisResultById(resultId); if (!row || row.taskId !== taskId) { return res.status(404).json({ error: 'not found' }); } const baseDir = path.join(TASKS_DIR, String(taskId), 'analysis', String(resultId)); const filePath = safeResolve(baseDir, filename); if (!filePath) return res.status(403).json({ error: 'Access denied' }); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'file missing' }); res.sendFile(filePath); }); // ─── 分析结果重新同步 ──────────────────────────────────────────────────────── // POST /api/files/resync/analysis/:taskId/:resultId // 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。 router.post('/resync/analysis/:taskId/:resultId', async (req, res) => { const taskId = parseInt(req.params.taskId, 10); const resultId = parseInt(req.params.resultId, 10); if (Number.isNaN(taskId) || Number.isNaN(resultId)) { return res.status(400).json({ error: 'invalid params' }); } const row = await db.getTaskAnalysisResultById(resultId); if (!row || row.taskId !== taskId) { return res.status(404).json({ error: 'not found' }); } const resultJsonPath = path.join(TASKS_DIR, String(taskId), 'analysis', String(resultId), 'result.json'); if (!fs.existsSync(resultJsonPath)) { return res.status(404).json({ error: 'result.json missing' }); } try { const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8')); const existingRd = (row.resultData as Record) ?? {}; const mergedRd = { ...existingRd, message: result.message ?? existingRd.message, measurements: result.measurements ?? existingRd.measurements, errorStats: result.errorStats ?? existingRd.errorStats, overlay3d: result.overlay3d ?? existingRd.overlay3d, images: result.images ?? existingRd.images, files: result.files ?? existingRd.files, extra: result.extra ?? existingRd.extra, schemaVersion: result.schemaVersion ?? existingRd.schemaVersion, }; await db.updateTaskAnalysisResult(resultId, { resultData: mergedRd }); res.json({ success: true, overlay3d: result.overlay3d }); } catch (e) { res.status(500).json({ error: String(e) }); } }); // POST /api/files/plugins/reload // 重新从磁盘扫描 manifests/*.json,无需重启服务器。 // 用于更新算法版本后立即生效。 router.post('/plugins/reload', (_req, res) => { try { const plugins = reloadPluginRegistry(); res.json({ success: true, count: plugins.length, plugins: plugins.map((p) => ({ id: p.id, version: p.version })), }); } catch (e) { res.status(500).json({ success: false, error: String(e) }); } }); export { router as fileRouter };