Files
massive-cloud/server/fileRoutes.ts

469 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number>();
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/<taskId>/analysis/<resultId>。
// 前端拿到 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<string, string>)[0] || (req.params as Record<string, string>)['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<string, unknown>) ?? {};
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 };