511 lines
21 KiB
TypeScript
511 lines
21 KiB
TypeScript
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 && 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
|
||
});
|
||
|
||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||
|
||
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,
|
||
},
|
||
});
|
||
});
|
||
|
||
// ─── 车型点云文件上传 ────────────────────────────────────────────────────────
|
||
// POST /api/files/upload/model-pointcloud/:modelId
|
||
// multipart/form-data field: file
|
||
|
||
router.post('/upload/model-pointcloud/: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 record = await db.createModelPointcloudFile({
|
||
modelId,
|
||
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 文件上传 ────────────────────────────────────────────────────────
|
||
// 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/model-pointcloud/:modelId/:filename
|
||
|
||
router.get('/read/model-pointcloud/:modelId/:filename', (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', (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 };
|