Files
massive-cloud/server/fileRoutes.ts

601 lines
25 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, 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<RequestHandler>[1], batch: Awaited<ReturnType<typeof samplePointcloudFiles>>) {
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/<taskId>/analysis/<resultId>。
// 前端拿到 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<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', 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<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', 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 };