621 lines
26 KiB
TypeScript
621 lines
26 KiB
TypeScript
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';
|
||
import {
|
||
inspectionObjectContextFromName,
|
||
logCompatibilityWarnings,
|
||
normalizeMeasurements,
|
||
} from './measurementCompatibility.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 task = await db.getInspectionTaskById(taskId);
|
||
const inspectionObject = task?.inspectionObjectId
|
||
? await db.getInspectionObjectById(task.inspectionObjectId)
|
||
: undefined;
|
||
const normalized = normalizeMeasurements({
|
||
pluginId: row.pluginId ?? (typeof existingRd.pluginId === 'string' ? existingRd.pluginId : undefined),
|
||
measurements: result.measurements ?? existingRd.measurements,
|
||
extra: result.extra ?? existingRd.extra,
|
||
context: inspectionObjectContextFromName(inspectionObject?.name),
|
||
algorithmProtocolVersion: result.schemaVersion ?? row.schemaVersion,
|
||
});
|
||
logCompatibilityWarnings(resultId, normalized.warnings);
|
||
const mergedRd = {
|
||
...existingRd,
|
||
message: result.message ?? existingRd.message,
|
||
measurements: normalized.measurements,
|
||
errorStats: result.errorStats ?? existingRd.errorStats,
|
||
overlay3d: result.overlay3d ?? existingRd.overlay3d,
|
||
images: result.images ?? existingRd.images,
|
||
files: result.files ?? existingRd.files,
|
||
extra: normalized.extra,
|
||
schemaVersion: result.schemaVersion ?? existingRd.schemaVersion,
|
||
};
|
||
await db.updateTaskAnalysisResult(resultId, {
|
||
measurements: normalized.measurements,
|
||
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 };
|