import { Router, type RequestHandler } from 'express'; import * as fs from 'fs'; import * as path from 'path'; import multer from 'multer'; import * as db from './db'; import { samplePointcloudFiles } from './pointcloudSample.js'; import { forceReload as reloadPluginRegistry } from './plugins/registry.js'; import { authenticateRequest } from './_core/auth.js'; import { inspectPointcloudMetadata } from './pointcloudMetadata.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 && req.path.includes('/model-pointcloud/')) { dir = path.join(MODELS_DIR, String(parseInt(req.params.modelId, 10)), 'pointcloud'); } else 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 }); const requireFileRead: RequestHandler = async (req, res, next) => { try { const user = await authenticateRequest(req); if (!user) return res.status(401).json({ error: '请先登录' }); if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' }); res.locals.user = user; next(); } catch (error) { next(error); } }; const requireFileWrite: RequestHandler = async (req, res, next) => { try { const user = await authenticateRequest(req); if (!user) return res.status(401).json({ error: '请先登录' }); if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' }); if (user.role !== 'admin' && user.role !== 'super_admin') { return res.status(403).json({ error: '无权执行此操作' }); } res.locals.user = user; next(); } catch (error) { next(error); } }; // ─── 工具函数 ───────────────────────────────────────────────────────────────── 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)); const relative = path.relative(path.resolve(base), resolved); return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative) ? resolved : null; } function sendSampledPointcloudBatch(res: Parameters[1], batch: Awaited>) { res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('X-Point-Count', batch.pointCount); res.setHeader('X-Has-Color', batch.hasColor ? '1' : '0'); res.setHeader('X-Has-Error', batch.hasError ? '1' : '0'); res.setHeader('X-File-Stats', JSON.stringify(batch.fileStats)); res.send(batch.buffer); } // ─── 车型 CAD 文件上传 ──────────────────────────────────────────────────────── // POST /api/files/upload/cad/:modelId // multipart/form-data field: file router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => { const modelId = parseInt(req.params.modelId, 10); const inspectionObjectIds = String(req.body.inspectionObjectIds ?? '').split(',').map(Number).filter(Number.isFinite); if (!req.file || isNaN(modelId) || inspectionObjectIds.length === 0) { return res.status(400).json({ success: false, error: '上传车型 CAD 时必须选择适用检测对象' }); } const objects = await Promise.all(inspectionObjectIds.map(id => db.getInspectionObjectById(id))); if (objects.some(object => !object || object.modelId !== modelId)) { await fs.promises.rm(req.file.path, { force: true }); return res.status(400).json({ success: false, error: '适用检测对象必须属于当前车型' }); } 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, }); await db.setCadInspectionObjects(record.id, inspectionObjectIds); res.json({ success: true, file: { id: record.id, name: record.name, format: format.toUpperCase(), size: formatFileSize(record.fileSize), filePath: record.filePath, }, }); }); // ─── 车型点云文件上传 ──────────────────────────────────────────────────────── // POST /api/files/upload/model-pointcloud/:modelId // multipart/form-data field: file router.post('/upload/model-pointcloud/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => { const modelId = parseInt(req.params.modelId, 10); const inspectionObjectId = Number(req.body.inspectionObjectId); if (!req.file || isNaN(modelId) || !Number.isFinite(inspectionObjectId)) { return res.status(400).json({ success: false, error: '上传车型点云时必须选择检测对象' }); } const object = await db.getInspectionObjectById(inspectionObjectId); if (!object || object.modelId !== modelId) { await fs.promises.rm(req.file.path, { force: true }); return res.status(400).json({ success: false, error: '检测对象必须属于当前车型' }); } const destPath = req.file.path; const metadata = inspectPointcloudMetadata(destPath, req.file.originalname); const record = await db.createModelPointcloudFile({ modelId, name: req.file.originalname, fileSize: req.file.size, filePath: destPath, inspectionObjectId, ...metadata, }); res.json({ success: true, file: { id: record.id, name: record.name, size: formatFileSize(record.fileSize), filePath: record.filePath, }, }); }); // ─── 任务 CAD 文件上传 ──────────────────────────────────────────────────────── // POST /api/files/upload/task-cad/:taskId router.post('/upload/task-cad/:taskId', requireFileWrite, 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', requireFileRead, (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', requireFileWrite, 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 metadata = inspectPointcloudMetadata(destPath, req.file.originalname); const record = await db.createTaskPointcloudFile({ taskId, name: req.file.originalname, fileSize: req.file.size, filePath: destPath, ...metadata, }); 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', requireFileRead, (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/model-pointcloud/:modelId/:filename router.get('/read/model-pointcloud/:modelId/:filename', requireFileRead, (req, res) => { const pcDir = path.join(MODELS_DIR, req.params.modelId, '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); }); // ─── 读取点云文件 ───────────────────────────────────────────────────────────── // GET /api/files/read/pointcloud/:taskId/:filename router.get('/read/pointcloud/:taskId/:filename', requireFileRead, (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', requireFileWrite, 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', requireFileWrite, 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, fileIds?: number[], totalPoints?: number } router.post('/sample/batch', requireFileRead, async (req, res) => { const { taskId, totalPoints = 1000000, fileIds } = req.body as { taskId?: number; totalPoints?: number; /** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */ fileIds?: number[]; }; let paths: string[] = []; if (taskId != null && Number.isInteger(taskId) && taskId > 0) { 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 { return res.status(400).json({ error: '请提供有效的 taskId' }); } 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 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=${totalPoints}` ); try { const batch = await samplePointcloudFiles(supportedFiles.map((file) => file.filePath), totalPoints); console.log(`[FileRoutes] Batch done: ${batch.pointCount} pts, ${(batch.buffer.length / 1e6).toFixed(1)} MB`); sendSampledPointcloudBatch(res, batch); } catch (err) { console.error('[FileRoutes] Batch sampling error:', err); res.status(500).json({ error: 'Failed to sample files' }); } }); // ─── 分析结果 PLY 批量采样接口 ─────────────────────────────────────────────── // POST /api/files/sample/analysis // Body: { taskId: number, resultId: number, files: string[], totalPoints?: number } router.post('/sample/analysis', requireFileRead, async (req, res) => { const { taskId, resultId, files, totalPoints = 300_000 } = req.body as { taskId?: number; resultId?: number; files?: unknown; totalPoints?: number; }; if (!Number.isSafeInteger(taskId) || Number(taskId) <= 0 || !Number.isSafeInteger(resultId) || Number(resultId) <= 0 || !Number.isSafeInteger(totalPoints) || totalPoints <= 0) { return res.status(400).json({ error: '分析结果采样参数无效' }); } if (!Array.isArray(files) || files.length === 0 || files.some((file) => typeof file !== 'string' || file.length === 0)) { return res.status(400).json({ error: '请提供 PLY 文件列表' }); } const validTaskId = taskId as number; const validResultId = resultId as number; const validTotalPoints = totalPoints as number; const row = await db.getTaskAnalysisResultById(validResultId); if (!row || row.taskId !== validTaskId) { return res.status(404).json({ error: '分析结果不存在或不属于当前任务' }); } const baseDir = path.join(TASKS_DIR, String(validTaskId), 'analysis', String(validResultId)); let realBaseDir: string; try { realBaseDir = fs.realpathSync(baseDir); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return res.status(404).json({ error: '分析结果目录不存在' }); } throw error; } const resolvedFiles: string[] = []; for (const file of files as string[]) { if (path.isAbsolute(file) || path.extname(file).toLowerCase() !== '.ply') { return res.status(400).json({ error: '只允许读取分析结果目录中的相对 PLY 路径' }); } let filePath: string | null; try { filePath = safeResolve(baseDir, file); } catch { return res.status(400).json({ error: 'PLY 文件路径编码无效' }); } if (!filePath) return res.status(403).json({ error: 'PLY 文件路径越界' }); let stat: fs.Stats; try { stat = fs.lstatSync(filePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return res.status(404).json({ error: `PLY 文件不存在: ${file}` }); } throw error; } if (!stat.isFile() || stat.isSymbolicLink()) { return res.status(400).json({ error: `PLY 路径不是普通文件: ${file}` }); } const realFilePath = fs.realpathSync(filePath); const realRelative = path.relative(realBaseDir, realFilePath); if (realRelative === '' || realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) { return res.status(403).json({ error: 'PLY 文件路径越界' }); } resolvedFiles.push(realFilePath); } try { const batch = await samplePointcloudFiles(resolvedFiles, validTotalPoints, 300_000); sendSampledPointcloudBatch(res, batch); } catch (error) { console.error('[FileRoutes] Analysis PLY sampling error:', error); return res.status(500).json({ error: '分析结果 PLY 采样失败' }); } }); // ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)────────────────────── // GET /api/files/read/analysis/heatmap/:taskId/:resultId router.get('/read/analysis/heatmap/:taskId/:resultId', requireFileRead, 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', requireFileRead, 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/*', requireFileRead, 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', requireFileWrite, 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', requireFileWrite, (_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 };