Files
labweb/routes/api.js
2025-12-16 11:39:15 +08:00

1688 lines
71 KiB
JavaScript
Raw Permalink 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.
const express = require('express');
const router = express.Router();
const multer = require('multer');
const { nanoid } = require('nanoid');
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
const nodemailer = require('nodemailer');
const { pool } = require('../config/db');
// 配置常量
const config = {
emailHost: process.env.EMAIL_HOST,
emailPort: process.env.EMAIL_PORT,
emailUser: process.env.EMAIL_USER,
emailPass: process.env.EMAIL_PASS,
htmlDir: process.env.HTML_BASE,
mediaModel: process.env.MEDIA_MODEL
};
// --- Multer 配置 ---
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const jobId = req.jobId;
// 注意:这里的 __dirname 现在是 routes 文件夹,需要往上退一级
const dynamicUploadDir = path.join(__dirname, '..', 'results', jobId, 'uploads');
fs.mkdirSync(dynamicUploadDir, { recursive: true });
cb(null, dynamicUploadDir)
},
filename: (req, file, cb) => {
cb(null, file.originalname);
}
});
const upload = multer({ storage }).array('files', 30);
// --- 任务管理 (内存中) ---
const jobs = new Map();
// --- 辅助函数 ---
async function ensureDirectoryExists(dirPath) {
if (!fs.existsSync(dirPath)) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
}
// 邮件发送功能
async function sendResultsEmail(analysisId, email, jobId, analysisType) {
try {
const resultFileMap = {
'ph': 'ph_predict/pHpredict.csv',
'nutrition': 'media_predict/generateMedia.csv',
'tempOxygen': 'tempo2predict/tempo2predict.csv',
'growth': 'growth_predict/growthPrediction.csv'
};
// Configure the email transmitter
const transporter = nodemailer.createTransport({
host: config.emailHost,
port: config.emailPort,
secure: true,
auth: {
user: config.emailUser,
pass: config.emailPass
}
});
// Email content
const mailOptions = {
from: config.emailUser,
to: email,
subject: `Analysis Results - Job ID: ${analysisId}`,
text: `Your analysis has been completed. Please find the results attached.\n\nJob ID: ${analysisId}`,
html: `
<h2>Analysis Results</h2>
<p>Your analysis has been completed successfully.</p>
<p><strong>Job ID:</strong> ${analysisId}</p>
<p>Please find the results attached as a CSV file.</p>
`,
attachments: [
{
filename: `analysis_result_${analysisId}.csv`,
path: path.join(__dirname, 'results', jobId, resultFileMap[analysisType]),
contentType: 'text/csv'
}
]
};
// Send an email
const info = await transporter.sendMail(mailOptions);
console.log('Email sent successfully:', info.messageId);
return { success: true, messageId: info.messageId };
} catch (error) {
console.error('Error sending email:', error);
throw error;
}
}
// --- Docker 执行脚本逻辑 ---
// ... (保留 executePhpredictScripts, executeTempOxygenScripts 等函数) ...
// 务必将这些函数中的 __dirname 替换为 path.join(__dirname, '..') 以指向项目根目录
// Execute pH prediction script (ph-pred image)
async function executePhpredictScripts(jobId) {
const inputDir = path.join(__dirname, 'results', jobId);
// const outputDir = path.join(__dirname, 'results', jobId, 'ph_predict');
await ensureDirectoryExists(inputDir);
// await ensureDirectoryExists(outputDir);
// Docker command: mount input/output directories and run script with specified image
const command = `docker run --rm \
-v "${config.htmlDir}/results/${jobId}:${inputDir}" \
-v "${config.htmlDir}/scripts":/app/scripts \
media-ph-pred:v1 \
bash -c "/opt/conda/bin/conda run -n ph_model_jupyter bash /app/scripts/pHPredict.sh ${inputDir}/prokka_annotation ${inputDir}/ph_predict"`;
console.log(`Execute pH prediction command: ${command}`);
const result = await execPromise(command);
// Check result file existence
const resultFile = `${inputDir}/ph_predict/pHpredict.csv`;
if (!fs.existsSync(resultFile)) {
throw new Error(`The pH prediction result file does not exist: ${resultFile}`);
}
return {
script: 'pHPredict.sh',
inputDir,
resultFile,
...result
};
}
// Execute temperature and oxygen prediction script (bacdive-ai image)
async function executeTempOxygenScripts(jobId) {
const inputDir = path.join(__dirname, 'results', jobId);
await ensureDirectoryExists(inputDir);
// Docker command: mount directories and use bacdive-ai image
const command = `docker run --rm \
-v "${config.htmlDir}/results/${jobId}:${inputDir}" \
-v "${config.htmlDir}/scripts":/app/scripts \
-w /app \
media-bacdiveai:v1 \
bash -c "source /etc/profile && source /opt/conda/etc/profile.d/conda.sh && conda activate bacdiveai && pip install tqdm && bash /app/scripts/TempO2Predict.sh ${inputDir}/prokka_annotation ${inputDir}/tempo2predict"`;
console.log(`Execute temperature and oxygen prediction command: ${command}`);
const result = await execPromise(command);
// Check result file existence
const resultFile = `${inputDir}/tempo2predict/tempo2predict.csv`;
if (!fs.existsSync(resultFile)) {
throw new Error(`The temperature and oxygen prediction result file does not exist: ${resultFile}`);
}
return {
script: 'TempO2Predict.sh',
inputDir,
resultFile,
...result
};
}
// Execute growth prediction script (shengwei/grodon2 image)
async function executeGrowthScripts(jobId) {
const inputDir = path.join(__dirname, 'results', jobId);
await ensureDirectoryExists(inputDir);
// Docker command: run script with grodon2 image
const command = `docker run --rm \
-e PATH='/usr/local/bin:/usr/bin:/bin:/opt/conda/lib/R/bin' \
-v "${config.htmlDir}/results/${jobId}:${inputDir}" \
-v "${config.htmlDir}/scripts":/app/scripts \
-w /app \
media-grodon2:v1 \
bash -c "bash /app/scripts/GrowthPredict.sh ${inputDir}/prokka_annotation ${inputDir}/growth_predict"`
console.log(`Execute growth rate prediction command: ${command}`);
const result = await execPromise(command);
// Check result file existence
const resultFile = `${inputDir}/growth_predict/growthPrediction.csv`;
if (!fs.existsSync(resultFile)) {
throw new Error(`The growth rate prediction result file does not exist: ${resultFile}`);
}
return {
script: 'GrowthPredict.sh',
resultFile,
...result
};
}
// Execute culture medium prediction script
async function executeNutritionScripts(jobId) {
const inputDir = path.join(__dirname, 'results', jobId);
await ensureDirectoryExists(inputDir);
// Docker command: start miniconda3, activate specified environment and run script
const command = `docker run --rm \
-v "${config.htmlDir}/results/${jobId}:${inputDir}" \
-v "${config.mediaModel}":/app/scripts \
-w /app \
media-transformer:v2 \
bash -c "source /opt/conda/etc/profile.d/conda.sh && conda activate uncultured_pytorch && pip install sentencepiece --root-user-action=ignore && bash /app/scripts/mediaPredict.sh ${inputDir}/prokka_annotation ${inputDir}/media_predict"`
console.log(`Execute culture medium prediction command: ${command}`);
const result = await execPromise(command);
// Check result file existence
const resultFile = `${inputDir}/media_predict/generateMedia.csv`;
if (!fs.existsSync(resultFile)) {
throw new Error(`The culture medium prediction result file does not exist: ${resultFile}`);
}
return {
script: 'mediaPredict.sh',
inputDir,
resultFile,
...result
};
}
// 主执行函数 (简化版展示结构)
// async function executePythonScript(job) {
// // ... (保留原有逻辑) ...
// }
// Main execution function
async function executePythonScript(job) {
try {
const inputDir = path.join(__dirname, 'results', job.id);
const hostInputDir = `${config.htmlDir}/results/${job.id}`;
const hostDir = `${config.htmlDir}`;
await ensureDirectoryExists(inputDir);
await ensureDirectoryExists(hostInputDir);
const analysisType = job.analysis_type;
// Update job status
job.status = 'analyzing';
job.progress = 10; // Reduced initial progress to account for Docker step
console.log(`Starting Prokka annotation for task ID: ${job.id}`);
// Execute Docker Prokka command
const dockerCommand = `docker run --rm \
-v "${hostInputDir}:${inputDir}" \
-v "${hostDir}/scripts/:/app/scripts" \
-w /app \
media-prokka:v1 \
./scripts/prokka.sh --output ${inputDir}/prokka_annotation --input ${inputDir}/uploads`;
console.log(`Executing Docker command: ${dockerCommand}`);
const { stdout, stderr } = await execPromise(dockerCommand);
// Log Docker execution results
if (stdout) console.log(`Prokka Docker output: ${stdout}`);
if (stderr) console.warn(`Prokka Docker warnings: ${stderr}`);
// Update progress after Docker completion
job.progress = 60;
console.log(`Starting analysis [${analysisType}] for task ID: ${job.id}`);
// Mapping of analysis types to execution functions
const executionMap = {
'ph': executePhpredictScripts,
'nutrition': executeNutritionScripts,
'tempOxygen': executeTempOxygenScripts,
'growth': executeGrowthScripts
};
// Check if analysis type is supported
if (!executionMap.hasOwnProperty(analysisType)) {
throw new Error(`Unsupported analysis type: ${analysisType}`);
}
// Execute corresponding script
const scriptResult = await executionMap[analysisType](job.id);
console.log(`${analysisType} script execution completed, result file: ${scriptResult.resultFile}`);
// Read result file content (parse according to CSV format)
let parsedResult = {};
try {
const csvContent = await fs.promises.readFile(scriptResult.resultFile, 'utf8');
const lines = csvContent.trim().split('\n');
const headers = lines[0].split(',').map(h => h.trim());
const values = lines[1]?.split(',') || [];
// Convert to key-value pairs
headers.forEach((header, index) => {
parsedResult[header] = values[index]?.trim() || '';
});
} catch (readError) {
console.error(`Failed to read result file:`, readError);
parsedResult = { error: 'Result file parsing failed' };
}
// Update progress
job.progress = 80;
// Build job result
job.result = {
analysis_id: job.id,
analysis_type: analysisType,
input_files_count: job.files.length,
input_files: job.files.map(f => f.fileName),
output_directory: scriptResult.outputDir,
result_file: scriptResult.resultFile,
analysis_time: Date.now(),
script_output: scriptResult.stdout.trim(),
docker_output: stdout.trim(), // Add Docker output to results
...(scriptResult.stderr && { script_stderr: scriptResult.stderr.trim() }),
...(stderr && { docker_stderr: stderr.trim() }), // Add Docker errors to results
...parsedResult // Merge CSV parsing results
};
if (job.email) {
try {
await sendResultsEmail(job.id, job.email, job.id, job.analysis_type);
console.log(`Result email sent to ${job.email}`);
} catch (emailError) {
console.error('Email sending failed:', emailError);
}
}
// Complete job
job.status = 'completed';
job.progress = 100;
return job;
} catch (error) {
console.error(`Task ${job.id} failed:`, error);
job.status = 'failed';
job.error = error.message || 'Unknown error';
job.progress = 0;
return job;
}
}
async function startJob(job) {
job.status = 'queued';
job.progress = 0;
job.eta_seconds = 10;
setTimeout(async () => { await executePythonScript(job); }, 1000);
}
// --- API 路由定义 ---
// 1. 上传 API
router.post('/upload', (req, res, next) => {
req.jobId = nanoid(12);
next();
}, upload, async (req, res) => {
// ... (保留原有 /api/upload 逻辑) ...
// 注意:引用 jobs Map
});
router.post('/upload', (req, res, next) => {
req.jobId = nanoid(12);
next();
}, upload, async (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ success: false, error: 'No files uploaded' });
}
const analysisType = req.body.analysis_type || 'nutrition';
const email = req.body.email || null;
const id = req.jobId;
const job = {
id,
analysis_type: analysisType,
files: req.files.map(file => ({
filePath: file.path,
fileName: file.originalname,
fileSize: file.size,
mimeType:file.mimetype
})),
email: email,
created_at: Date.now(),
status: 'created',
progress: 0,
file_count: req.files.length
};
jobs.set(id, job);
startJob(job);
res.json({
success: true,
analysis_id: id,
file_count: req.files.length,
message: `Analysis job started successfully!`
});
} catch (e) {
console.error(e);
res.status(500).json({ success: false, error: e.message || 'Upload failed' });
}
});
// 2. 状态 API
router.get('/status/:id', (req, res) => {
const id = req.params.id;
const job = jobs.get(id);
if (!job) return res.status(404).json({ success: false, error: 'Job does not exist' });
const response = {
success: true,
status: job.status,
progress: job.progress,
queue_position: job.status === 'queued' ? 1 : 0,
eta_seconds: job.eta_seconds,
file_count: job.file_count || 0,
files:job.files ? job.files.map(f => f.fileName) : []
};
if (job.status === 'completed' && job.result) response.result = job.result;
if (job.status === 'failed' && job.error) response.error = job.error;
res.json(response);
});
// 3. 停止任务 API
router.post('/stop/:id', async (req, res) => {
const jobId = req.params.id;
const job = jobs.get(jobId);
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
// No need to stop if task is already completed or failed
if (job.status === 'completed' || job.status === 'failed') {
return res.json({
success: true,
message: `Job ${jobId} is already ${job.status}`
});
}
try {
// 1. Update task status
job.status = 'failed';
job.error = 'Analysis stopped by user';
job.progress = 0;
jobs.set(jobId, job);
// 2. Terminate related Docker containers (critical step)
// Get container IDs related to current task
const getContainersCmd = `docker ps --filter "volume=${config.htmlDir}/results/${jobId}" --format "{{.ID}}"`;
const { stdout: containerIds } = await execPromise(getContainersCmd);
if (containerIds) {
const containers = containerIds.trim().split('\n');
for (const containerId of containers) {
if (containerId) {
console.log(`Stopping container ${containerId} for job ${jobId}`);
await execPromise(`docker stop ${containerId}`);
console.log(`Container ${containerId} stopped`);
}
}
}
res.json({
success: true,
message: `Job ${jobId} stopped successfully`
});
} catch (error) {
console.error(`Error stopping job ${jobId}:`, error);
res.status(500).json({
success: false,
error: `Failed to stop job: ${error.message}`
});
}
});
// 4. 下载 API
router.get('/download/:id', (req, res) => {
const id = req.params.id;
const job = jobs.get(id);
if (!job) return res.status(404).json({ success: false, error: 'Job does not exist' });
if (job.status !== 'completed') return res.status(400).json({ success: false, error: 'Job not completed' });
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=analysis_result_${id}_${job.file_count}files.csv`);
res.send(job.csv || '');
});
// 5. 邮件 API
router.post('/send-results-email', async (req, res) => {
try {
const { analysis_id, email } = req.body;
if (!analysis_id || !email) {
return res.status(400).json({ success: false, error: 'analysis_id and email are required' });
}
const job = jobs.get(analysis_id);
if (!job) {
return res.status(404).json({ success: false, error: 'Job does not exist' });
}
if (job.status !== 'completed') {
return res.status(400).json({ success: false, error: 'Job not completed yet' });
}
if (!job.csv) {
return res.status(400).json({ success: false, error: 'No results available' });
}
await sendResultsEmail(analysis_id, email, job.id, job.analysis_type);
res.json({ success: true, message: 'Email sent successfully' });
} catch (error) {
console.error('Error in send-results-email endpoint:', error);
res.status(500).json({ success: false, error: error.message || 'Failed to send email' });
}
});
// 6. 浏览数据 API (Browse)
router.get('/browse', async (req, res) => {
try {
console.log('Received a browsing request:', req.query);
const { page = 1, pageSize = 20, search = '', ph = '', temperature = '', o2 = '', taxonomy = '', taxonomyValue = '', microbial = '', chartData = false, cultured_type = 'cultured' } = req.query;
const offset = (page - 1) * pageSize;
// Build query conditions
let whereConditions = [];
let queryParams = [];
// Add cultured_type filter (default to 'cultured' if not specified)
if (cultured_type) {
whereConditions.push('m.cultured_type = ?');
queryParams.push(cultured_type);
}
// Search conditions
if (search) {
whereConditions.push('(m.microbial_name LIKE ? OR m.taxonomy LIKE ?)');
queryParams.push(`%${search}%`, `%${search}%`);
}
// pH range filtering - use predict_pH, separate predicted value and probability by the last space
if (ph) {
// Extract the predicted value part of predict_pH (content before the last space; use the entire string if no space exists)
const phPredictionExpr = `TRIM(CASE
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' THEN
SUBSTRING(m.predict_pH, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) ELSE LENGTH(m.predict_pH) END)
ELSE NULL
END)`;
if (ph.includes('-')) {
const [phMin, phMax] = ph.split('-').map(Number);
whereConditions.push(`${phPredictionExpr} IS NOT NULL AND CAST(${phPredictionExpr} AS DECIMAL(4,2)) >= ? AND CAST(${phPredictionExpr} AS DECIMAL(4,2)) <= ?`);
queryParams.push(phMin, phMax);
} else {
const targetPh = parseFloat(ph);
whereConditions.push(`${phPredictionExpr} IS NOT NULL AND CAST(${phPredictionExpr} AS DECIMAL(4,2)) >= ? AND CAST(${phPredictionExpr} AS DECIMAL(4,2)) <= ?`);
queryParams.push(targetPh - 0.5, targetPh + 0.5);
}
}
// Temperature category filtering - use predict_temperature, separate predicted value and probability by the last space
if (temperature) {
// Extract the predicted value part of predict_temperature (content before the last space; use the entire string if no space exists)
const tempPredictionExpr = `TRIM(CASE
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' THEN
SUBSTRING(m.predict_temperature, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) ELSE LENGTH(m.predict_temperature) END)
ELSE NULL
END)`;
whereConditions.push(`${tempPredictionExpr} = ?`);
queryParams.push(temperature);
}
// O2 type filtering - use predict_o2, separate predicted value and probability by the last space
if (o2) {
// Extract the predicted value part of predict_o2 (content before the last space; use the entire string if no space exists)
const o2PredictionExpr = `TRIM(CASE
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' THEN
SUBSTRING(m.predict_o2, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) ELSE LENGTH(m.predict_o2) END)
ELSE NULL
END)`;
whereConditions.push(`${o2PredictionExpr} LIKE ?`);
queryParams.push(`%${o2}%`);
}
// Taxonomy classification filtering
if (taxonomy && taxonomy !== 'ALL' && taxonomy !== '') {
const validTaxonomyLevels = ['Bacteria', 'Domain', 'Phylum', 'Class', 'Order', 'Family', 'Genus', 'Species'];
if (validTaxonomyLevels.includes(taxonomy)) {
let taxonomyField = '';
switch(taxonomy) {
case 'Bacteria':
case 'Domain':
taxonomyField = 'm.domain';
break;
case 'Phylum':
taxonomyField = 'm.phylum';
break;
case 'Class':
taxonomyField = 'm.class';
break;
case 'Order':
taxonomyField = 'm.order';
break;
case 'Family':
taxonomyField = 'm.family';
break;
case 'Genus':
taxonomyField = 'm.genus';
break;
case 'Species':
taxonomyField = 'm.species';
break;
}
if (taxonomyField) {
// If taxonomyValue is provided, filter by value; otherwise only check non-empty
if (taxonomyValue && taxonomyValue.trim() !== '') {
whereConditions.push(`${taxonomyField} LIKE ?`);
queryParams.push(`%${taxonomyValue.trim()}%`);
} else {
whereConditions.push(`${taxonomyField} IS NOT NULL AND ${taxonomyField} != ''`);
}
}
}
}
// Microbial name filtering
if (microbial && microbial.trim() !== '') {
whereConditions.push('m.microbial_name LIKE ?');
queryParams.push(`%${microbial.trim()}%`);
}
// Build WHERE clause
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
// Get total count
const countQuery = `
SELECT COUNT(DISTINCT m.species_dive_id) as total
FROM cultured_data m
${whereClause}
`;
console.log('Execute count query:', countQuery, 'parameter:', queryParams);
const [countResult] = await pool.execute(countQuery, queryParams);
const total = countResult[0].total;
console.log('Total number of records:', total);
// Get paginated data
// Extract predicted values and probabilities from predict_pH, predict_temperature, predict_o2 (separated by the last space)
const dataQuery = `
SELECT
m.species_dive_id,
m.microbial_name,
m.nutrition,
CONCAT(m domain) as domain,
m.pH as pH,
m.temperature as temperature,
m.o2 as o2,
m.predict_pH as ppH_raw,
m.predict_temperature as ptemperature_raw,
m.predict_o2 as po2_raw,
-- Extract predicted value of predict_pH (before the last space)
CASE
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' THEN
TRIM(SUBSTRING(m.predict_pH, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) ELSE LENGTH(m.predict_pH) END))
ELSE NULL
END as ppH,
-- Extract probability of predict_pH (after the last space)
CASE
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' AND LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN
TRIM(SUBSTRING(m.predict_pH, LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) + 2))
ELSE NULL
END as ppH_probability,
-- Extract predicted value of predict_temperature (before the last space)
CASE
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' THEN
TRIM(SUBSTRING(m.predict_temperature, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) ELSE LENGTH(m.predict_temperature) END))
ELSE NULL
END as ptemperature,
-- Extract probability of predict_temperature (after the last space)
CASE
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' AND LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN
TRIM(SUBSTRING(m.predict_temperature, LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) + 2))
ELSE NULL
END as ptemperature_probability,
-- Extract predicted value of predict_o2 (before the last space)
CASE
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' THEN
TRIM(SUBSTRING(m.predict_o2, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) ELSE LENGTH(m.predict_o2) END))
ELSE NULL
END as po2,
-- Extract probability of predict_o2 (after the last space)
CASE
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' AND LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN
TRIM(SUBSTRING(m.predict_o2, LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) + 2))
ELSE NULL
END as po2_probability,
m.genome
FROM cultured_data m
${whereClause}
GROUP BY m.species_dive_id, m.microbial_name, m.pH, m.temperature, m.o2, m domain, m.phylum, m.class, m.order, m.family, m.genus, m.species, m.nutrition, m.genome, m.predict_pH, m.predict_temperature, m.predict_o2
ORDER BY m.species_dive_id
LIMIT ${parseInt(pageSize)} OFFSET ${parseInt(offset)}
`;
console.log('Execute data query:', dataQuery, 'Parameters:', queryParams);
const [rows] = await pool.execute(dataQuery, queryParams);
console.log('Query result row count:', rows.length);
// Format data to match frontend expected format
// Use extracted predicted values; if predicted values don't exist, use original values
// Also return predicted value fields (ppH, ptemperature, po2) for frontend direct use
const formattedData = rows.map((row, index) => ({
id: row.species_dive_id,
microbial_name: row.microbial_name || '',
nutrition: row.nutrition || '',
domain: row.domain || '',
pH: row.ppH ? row.ppH.toString() : (row.pH ? row.pH.toString() : ''),
pH_probability: row.ppH_probability || null,
temperature: row.ptemperature ? extractTemperatureCategory(row.ptemperature.split(',')[0].trim()) : (row.temperature ? extractTemperatureCategory(row.temperature.split(',')[0].trim()) : ''),
temperature_probability: row.ptemperature_probability || null,
o2: row.po2 ? extractO2Category(row.po2) : (row.o2 ? extractO2Category(row.o2) : ''),
o2_probability: row.po2_probability || null,
// Return predicted value fields for frontend direct use
ppH: row.ppH || null,
ptemperature: row.ptemperature || null,
po2: row.po2 || null
}));
// If Sankey chart data is needed, build Sankey chart data
let sankeyData = null;
if (chartData === 'true') {
try {
console.log('Start building Sankey chart data, whereClause:', whereClause, 'queryParams:', queryParams);
sankeyData = await buildSankeyData(whereClause, queryParams);
console.log('Sankey chart data built successfully');
} catch (error) {
console.error('Error building Sankey chart data:', error);
console.error('Error stack:', error.stack);
// Return empty Sankey chart data instead of null
sankeyData = { nodes: [], links: [] };
}
}
res.json({
success: true,
data: formattedData,
pagination: {
currentPage: parseInt(page),
pageSize: parseInt(pageSize),
total: total,
totalPages: Math.ceil(total / pageSize)
},
sankeyData: sankeyData
});
} catch (error) {
console.error('Error getting data:', error);
console.error('Error stack:', error.stack);
// If it is a database connection error, return more detailed error information
if (error.code === 'ECONNREFUSED' || error.code === 'ER_ACCESS_DENIED_ERROR') {
res.status(500).json({
success: false,
message: 'Database connection failed',
error: error.message,
suggestion: 'Please check if MySQL service is running and database connection configuration is correct'
});
} else if (error.code === 'ER_NO_SUCH_TABLE') {
res.status(500).json({
success: false,
message: 'Database table does not exist',
error: error.message,
suggestion: 'Please check if the medium table exists in the transformerMedia database'
});
} else {
res.status(500).json({
success: false,
message: 'Failed to get data',
error: error.message,
code: error.code
});
}
}
});
// 7. 详情 API (Microbial Detail)
router.get('/microbial-detail', async (req, res) => {
try {
const { name, level = 'genus' } = req.query;
if (!name) {
return res.status(400).json({ success: false, message: 'Microbial name cannot be empty' });
}
console.log('Get microbial detail information:', name, 'Taxonomy Level:', level);
// Define allowed levels for safety
const allowedLevels = ['genus', 'family', 'order', 'class', 'phylum'];
const safeLevel = allowedLevels.includes(level.toLowerCase()) ? level.toLowerCase() : 'genus';
// First try exact match
let detailQuery = `
SELECT
m.species_dive_id,
m.microbial_name,
m.domain,
m.phylum,
m.class,
m.\`order\`,
m.family,
m.genus,
m.species,
m.taxonomy,
m.pH as pH,
m.temperature as temperature,
m.o2 as o2,
m.gram_stain,
m.synonym,
m.genome,
m.nutrition,
TRIM(CASE
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' THEN
SUBSTRING(m.predict_pH, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) ELSE LENGTH(m.predict_pH) END)
ELSE NULL
END) as ppH,
CASE
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' AND LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN
TRIM(SUBSTRING(m.predict_pH, LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) + 2))
ELSE NULL
END as ppH_probability,
TRIM(CASE
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' THEN
SUBSTRING(m.predict_temperature, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) ELSE LENGTH(m.predict_temperature) END)
ELSE NULL
END) as ptemperature,
CASE
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' AND LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN
TRIM(SUBSTRING(m.predict_temperature, LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) + 2))
ELSE NULL
END as ptemperature_probability,
TRIM(CASE
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' THEN
SUBSTRING(m.predict_o2, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) ELSE LENGTH(m.predict_o2) END)
ELSE NULL
END) as po2,
CASE
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' AND LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN
TRIM(SUBSTRING(m.predict_o2, LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) + 2))
ELSE NULL
END as po2_probability
FROM cultured_data m
WHERE m.microbial_name = ?
LIMIT 1
`;
let [rows] = await pool.execute(detailQuery, [name]);
// If exact match fails, try fuzzy match
if (rows.length === 0) {
detailQuery = detailQuery.replace('m.microbial_name = ?', 'm.microbial_name LIKE ?');
[rows] = await pool.execute(detailQuery, [`%${name}%`]);
}
if (rows.length === 0) {
return res.json({ success: false, message: 'Microbial details not found' });
}
const microbialData = rows[0];
const levelValue = microbialData[safeLevel]; // Get the value for the requested taxonomy level (e.g., the family name)
let nutritionInfo = microbialData.nutrition || '';
let relatedRows = [];
// Check if the taxonomy level value exists (e.g., if we want family, ensure family is not null)
if (levelValue) {
try {
// Use the safeLevel to build the query dynamically.
// Since safeLevel is checked against a whitelist, this is safe from SQL injection.
// We handle 'order' specially because it is a reserved keyword in SQL.
const columnRef = safeLevel === 'order' ? '`order`' : safeLevel;
const relatedQuery = `
SELECT
m.species_dive_id,
m.microbial_name,
m.domain,
m.phylum,
m.class,
m.\`order\`,
m.family,
m.genus,
m.species,
m.taxonomy,
m.pH as pH,
m.temperature as temperature,
m.o2 as o2,
m.gram_stain,
m.synonym,
m.genome,
m.nutrition,
m.cultured_type,
TRIM(CASE
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' THEN
SUBSTRING(m.predict_pH, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) ELSE LENGTH(m.predict_pH) END)
ELSE NULL
END) as ppH,
TRIM(CASE
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' THEN
SUBSTRING(m.predict_temperature, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) ELSE LENGTH(m.predict_temperature) END)
ELSE NULL
END) as ptemperature,
TRIM(CASE
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' THEN
SUBSTRING(m.predict_o2, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) ELSE LENGTH(m.predict_o2) END)
ELSE NULL
END) as po2
FROM cultured_data m
WHERE m.${columnRef} = ? AND m.microbial_name != ?
ORDER BY m.microbial_name
`;
// console.log(`Executing related query for level ${safeLevel}: ${levelValue}`);
const [relatedResult] = await pool.execute(relatedQuery, [levelValue, microbialData.microbial_name]);
relatedRows = relatedResult;
// console.log('Related microbes found:', relatedRows.length);
} catch (relatedError) {
console.error('Related query failed:', relatedError);
}
}
const responseData = {
...microbialData,
pH: microbialData.pH,
temperature: microbialData.temperature,
o2: microbialData.o2,
ppH: microbialData.ppH || null,
ptemperature: microbialData.ptemperature || null,
po2: microbialData.po2 || null,
ppH_probability: microbialData.ppH_probability || null,
ptemperature_probability: microbialData.ptemperature_probability || null,
po2_probability: microbialData.po2_probability || null,
nutrition: nutritionInfo,
culture_type: microbialData.cultured_type,
related_microbes: relatedRows.map(row => ({
...row,
pH: row.pH,
temperature: row.temperature,
o2: row.o2,
ppH: row.ppH || null,
ptemperature: row.ptemperature || null,
po2: row.po2 || null
}))
};
res.json({
success: true,
data: responseData
});
} catch (error) {
console.error('Error fetching details:', error);
res.status(500).json({
success: false,
message: 'Internal Server Error',
error: error.message
});
}
});
// 8. 统计数据 API (Charts)
router.get('/taxonomy-stats', async (req, res) => {
try {
const { level = 'phylum', cultured_type = 'cultured' } = req.query;
// Validate taxonomy level
const validLevels = ['phylum', 'class', 'order', 'family', 'genus', 'species'];
if (!validLevels.includes(level)) {
return res.status(400).json({
success: false,
message: 'Invalid taxonomy level',
validLevels: validLevels
});
}
// First get the total number of classifications for this taxonomy level
const countQuery = `
SELECT COUNT(DISTINCT \`${level}\`) as total_count
FROM cultured_data m
WHERE \`${level}\` IS NOT NULL AND \`${level}\` != '' AND m.cultured_type = ?
`;
console.log('Execute taxonomy total count query:', countQuery);
const [countResult] = await pool.execute(countQuery, [cultured_type]);
const totalCount = countResult[0].total_count;
// Build query to get detailed information for all classifications
const query = `
SELECT
\`${level}\` as name,
COUNT(DISTINCT m.species_dive_id) as value
FROM cultured_data m
WHERE \`${level}\` IS NOT NULL AND \`${level}\` != '' AND m.cultured_type = ?
GROUP BY \`${level}\`
ORDER BY value DESC
`;
console.log('Execute taxonomy statistics query:', query);
const [rows] = await pool.execute(query, [cultured_type]);
const data = rows.map(row => ({
name: row.name,
value: parseInt(row.value)
}));
res.json({
success: true,
data: data,
level: level,
total: totalCount, // Return the total number of classifications for this taxonomy level
displayed: data.length // Return the actual number of classifications displayed (now equals total)
});
} catch (error) {
console.error('Error getting taxonomy statistics:', error);
res.status(500).json({
success: false,
message: 'Failed to get taxonomy statistics',
error: error.message
});
}
});
router.get('/physchem-stats', async (req, res) => {
try {
const { type = 'o2', cultured_type = 'cultured' } = req.query;
// Validate type
const validTypes = ['o2', 'ph', 'temperature'];
if (!validTypes.includes(type)) {
return res.status(400).json({
success: false,
message: 'Invalid physical and chemical properties type',
validTypes: validTypes
});
}
let data = {};
if (type === 'o2') {
// O2 type statistics (extract category only)
const query = `
SELECT
CASE
WHEN m.o2 IS NOT NULL AND m.o2 != '' THEN
SUBSTRING(m.o2, 1, LOCATE(' ', CONCAT(m.o2, ' ')) - 1)
ELSE m.o2
END as name,
COUNT(DISTINCT m.species_dive_id) as count
FROM cultured_data m
WHERE m.o2 IS NOT NULL AND m.o2 != '' AND m.cultured_type = ?
GROUP BY name
ORDER BY count DESC
`;
const [rows] = await pool.execute(query, [cultured_type]);
const total = rows.reduce((sum, row) => sum + parseInt(row.count), 0);
data = {
aerobe: 0,
anaerobe: 0,
facultative: 0,
total: total
};
rows.forEach(row => {
const name = row.name.toLowerCase();
const count = parseInt(row.count);
if (name.includes('aerobe') && !name.includes('anaerobe')) {
data.aerobe += count;
} else if (name.includes('anaerobe')) {
data.anaerobe += count;
} else if (name.includes('facultative')) {
data.facultative += count;
}
});
} else if (type === 'ph') {
// pH distribution statistics
const query = `
SELECT
CASE
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 1 AND 2 THEN 1
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 2 AND 3 THEN 2
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 3 AND 4 THEN 3
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 4 AND 5 THEN 4
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 5 AND 6 THEN 5
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 6 AND 7 THEN 6
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 7 AND 8 THEN 7
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 8 AND 9 THEN 8
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 9 AND 10 THEN 9
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 10 AND 11 THEN 10
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 11 AND 12 THEN 11
ELSE 12
END as ph_range,
COUNT(DISTINCT m.species_dive_id) as count
FROM cultured_data m
WHERE m.pH IS NOT NULL AND m.pH != '' AND m.pH != 'Unknown' AND m.cultured_type = ?
GROUP BY ph_range
ORDER BY ph_range
`;
const [rows] = await pool.execute(query, [cultured_type]);
data = rows.map(row => ({
x: parseInt(row.ph_range),
count: parseInt(row.count)
}));
} else if (type === 'temperature') {
// Temperature category distribution statistics
const query = `
SELECT
CASE
WHEN m.temperature IS NOT NULL AND m.temperature != '' THEN
SUBSTRING(m.temperature, 1, LOCATE(' ', CONCAT(m.temperature, ' ')) - 1)
ELSE m.temperature
END as temp_category,
COUNT(DISTINCT m.species_dive_id) as count
FROM cultured_data m
WHERE m.temperature IS NOT NULL AND m.temperature != '' AND m.temperature != 'Unknown' AND m.cultured_type = ?
GROUP BY temp_category
ORDER BY count DESC
`;
const [rows] = await pool.execute(query, [cultured_type]);
data = rows.map(row => ({
category: row.temp_category,
count: parseInt(row.count)
}));
}
res.json({
success: true,
data: data,
type: type
});
} catch (error) {
console.error('Error getting physical and chemical properties statistics:', error);
res.status(500).json({
success: false,
message: 'Failed to get physical and chemical properties statistics',
error: error.message
});
}
});
router.get('/sunburst-stats', async (req, res) => {
try {
const { taxonomy = '', microbial = '', ph = '', temperature = '', o2 = '', cultured_type = 'cultured' } = req.query;
// Build query conditions (same logic as browse interface)
let whereConditions = [];
let queryParams = [];
// Add cultured_type filter
if (cultured_type) {
whereConditions.push('m.cultured_type = ?');
queryParams.push(cultured_type);
}
// Search conditions
if (taxonomy) {
whereConditions.push('m.taxonomy LIKE ?');
queryParams.push(`%${taxonomy}%`);
}
if (microbial) {
whereConditions.push('m.microbial_name LIKE ?');
queryParams.push(`%${microbial}%`);
}
// pH range filtering
if (ph) {
if (ph.includes('-')) {
const [phMin, phMax] = ph.split('-').map(Number);
whereConditions.push('CAST(m.pH AS DECIMAL(4,2)) >= ? AND CAST(m.pH AS DECIMAL(4,2)) <= ?');
queryParams.push(phMin, phMax);
} else {
whereConditions.push('CAST(m.pH AS DECIMAL(4,2)) = ?');
queryParams.push(Number(ph));
}
}
// Temperature category filtering (extract category only)
if (temperature) {
whereConditions.push(`(
CASE
WHEN m.temperature IS NOT NULL AND m.temperature != '' THEN
SUBSTRING(m.temperature, 1, LOCATE(' ', CONCAT(m.temperature, ' ')) - 1)
ELSE m.temperature
END
) = ?`);
queryParams.push(temperature);
}
// O2 type filtering (extract category only)
if (o2) {
whereConditions.push(`(
CASE
WHEN m.o2 IS NOT NULL AND m.o2 != '' THEN
SUBSTRING(m.o2, 1, LOCATE(' ', CONCAT(m.o2, ' ')) - 1)
ELSE m.o2
END
) LIKE ?`);
queryParams.push(o2);
}
// Build WHERE clause
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
// Get all data for sunburst chart
const query = `
SELECT
CASE
WHEN m.o2 IS NOT NULL AND m.o2 != '' THEN
SUBSTRING(m.o2, 1, LOCATE(' ', CONCAT(m.o2, ' ')) - 1)
ELSE m.o2
END as o2,
CASE
WHEN m.temperature IS NOT NULL AND m.temperature != '' THEN
SUBSTRING(m.temperature, 1, LOCATE(' ', CONCAT(m.temperature, ' ')) - 1)
ELSE NULL
END as temperature,
CASE
WHEN m.pH IS NOT NULL THEN
CASE
WHEN CAST(m.pH AS DECIMAL(4,2)) >= 1 AND CAST(m.pH AS DECIMAL(4,2)) < 6 THEN 'acidic'
WHEN CAST(m.pH AS DECIMAL(4,2)) >= 6 AND CAST(m.pH AS DECIMAL(4,2)) <= 8 THEN 'neutral'
WHEN CAST(m.pH AS DECIMAL(4,2)) > 8 AND CAST(m.pH AS DECIMAL(4,2)) <= 12 THEN 'alkaline'
ELSE m.pH
END
ELSE m.pH
END as ph,
COUNT(*) as count
FROM cultured_data m
${whereClause}
GROUP BY o2, temperature, ph
ORDER BY count DESC
`;
console.log('Execute sunburst query:', query, 'Parameters:', queryParams);
const [rows] = await pool.execute(query, queryParams);
res.json({
success: true,
data: rows,
total: rows.length
});
} catch (error) {
console.error('Error getting sunburst chart data:', error);
res.status(500).json({
success: false,
message: 'Failed to get sunburst chart data',
error: error.message
});
}
});
router.get('/nutrition-stats', async (req, res) => {
try {
const { cultured_type = 'cultured' } = req.query;
// First get nutrition data for all culture media
const query = `
SELECT
m.nutrition,
COUNT(DISTINCT m.species_dive_id) as species_count
FROM cultured_data m
WHERE m.nutrition IS NOT NULL AND m.nutrition != '' AND m.cultured_type = ?
GROUP BY m.nutrition
`;
console.log('Execute nutrition statistics query:', query);
const [rows] = await pool.execute(query, [cultured_type]);
// Parse JSON format nutrition data and count frequency
const nutritionFrequency = {};
let totalSpecies = 0;
rows.forEach(row => {
const nutritionStr = row.nutrition;
const speciesCount = parseInt(row.species_count);
totalSpecies += speciesCount;
try {
// Parse Python list format string
let nutritionList = [];
if (nutritionStr && nutritionStr.trim()) {
// Remove square brackets and split string
const cleanStr = nutritionStr.trim().replace(/^\[|\]$/g, '');
if (cleanStr) {
// Split string, handle content within quotes
nutritionList = cleanStr.split(',').map(item => {
// Remove quotes and spaces
return item.trim().replace(/^['"]|['"]$/g, '');
}).filter(item => item.length > 0);
}
}
// Count usage frequency of each nutrient
nutritionList.forEach(nutrient => {
if (nutrient && nutrient.trim()) {
const cleanNutrient = nutrient.trim();
// Each culture medium is counted as many times as the number of species using it
nutritionFrequency[cleanNutrient] = (nutritionFrequency[cleanNutrient] || 0) + speciesCount;
}
});
} catch (error) {
console.warn('Failed to parse nutrition data:', nutritionStr, error);
}
});
// Convert to array format and sort by frequency
const data = Object.entries(nutritionFrequency)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
console.log(`Parsing completed, found ${Object.keys(nutritionFrequency).length} different nutrients`);
console.log(`Total species count: ${totalSpecies}`);
res.json({
success: true,
data: data,
total: data.length,
totalNutrients: Object.keys(nutritionFrequency).length,
totalSpecies: totalSpecies
});
} catch (error) {
console.error('Error getting nutrition statistics:', error);
res.status(500).json({
success: false,
message: 'Failed to get nutrition statistics',
error: error.message
});
}
});
router.get('/taxonomy-sankey', async (req, res) => {
try {
const { taxonomy = '', microbial = '', ph = '', temperature = '', o2 = '', cultured_type = 'cultured' } = req.query;
// Build query conditions (same logic as browse interface)
let whereConditions = [];
let queryParams = [];
// Add cultured_type filter
if (cultured_type) {
whereConditions.push('m.cultured_type = ?');
queryParams.push(cultured_type);
}
// Taxonomy classification filtering
if (taxonomy && taxonomy !== 'ALL') {
const validTaxonomyLevels = ['Domain', 'Phylum', 'Class', 'Order', 'Family', 'Genus', 'Species'];
if (validTaxonomyLevels.includes(taxonomy)) {
let taxonomyField = '';
switch(taxonomy) {
case 'Domain':
taxonomyField = 'm.domain';
break;
case 'Phylum':
taxonomyField = 'm.phylum';
break;
case 'Class':
taxonomyField = 'm.class';
break;
case 'Order':
taxonomyField = 'm.order';
break;
case 'Family':
taxonomyField = 'm.family';
break;
case 'Genus':
taxonomyField = 'm.genus';
break;
case 'Species':
taxonomyField = 'm.species';
break;
}
if (taxonomyField) {
whereConditions.push(`${taxonomyField} IS NOT NULL AND ${taxonomyField} != ''`);
}
}
}
// Microbial name filtering
if (microbial) {
whereConditions.push('m.microbial_name LIKE ?');
queryParams.push(`%${microbial}%`);
}
// pH range filtering
if (ph) {
if (ph.includes('-')) {
const [phMin, phMax] = ph.split('-').map(Number);
whereConditions.push('CAST(m.pH AS DECIMAL(4,2)) >= ? AND CAST(m.pH AS DECIMAL(4,2)) <= ?');
queryParams.push(phMin, phMax);
} else {
const targetPh = parseFloat(ph);
whereConditions.push('CAST(m.pH AS DECIMAL(4,2)) >= ? AND CAST(m.pH AS DECIMAL(4,2)) <= ?');
queryParams.push(targetPh - 0.5, targetPh + 0.5);
}
}
// Temperature category filtering (extract category only)
if (temperature) {
whereConditions.push(`(
CASE
WHEN m.temperature IS NOT NULL AND m.temperature != '' THEN
SUBSTRING(m.temperature, 1, LOCATE(' ', CONCAT(m.temperature, ' ')) - 1)
ELSE m.temperature
END
) = ?`);
queryParams.push(temperature);
}
// O2 type filtering (extract category only)
if (o2) {
whereConditions.push(`(
CASE
WHEN m.o2 IS NOT NULL AND m.o2 != '' THEN
SUBSTRING(m.o2, 1, LOCATE(' ', CONCAT(m.o2, ' ')) - 1)
ELSE m.o2
END
) LIKE ?`);
queryParams.push(`%${o2}%`);
}
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
const sankeyData = await buildSankeyData(whereClause, queryParams);
res.json({
success: true,
data: sankeyData
});
} catch (error) {
console.error('Error getting Sankey chart data:', error);
res.status(500).json({
success: false,
message: 'Failed to get Sankey chart data',
error: error.message
});
}
});
// 9. 健康检查
router.get('/health', async (req, res) => {
try {
const connection = await pool.getConnection();
await connection.ping();
connection.release();
res.json({ success: true, message: 'Database connection normal' });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 10. 下载数据 API
router.get('/download-data/:type', async (req, res) => {
try {
const { type } = req.params;
const { cultured_type = 'cultured' } = req.query;
// 定义允许的下载类型
const allowedTypes = ['all_data', 'ph', 'temperature', 'oxygen', 'culture_medium', 'max_growth_rate'];
if (!allowedTypes.includes(type)) {
return res.status(400).json({
success: false,
message: 'Invalid download type',
allowedTypes: allowedTypes
});
}
// 根据类型定义需要查询的字段
let selectFields = '';
let headers = [];
switch (type) {
case 'all_data':
selectFields = `
m.species_dive_id,
m.microbial_name,
m.genome,
m.domain,
m.phylum,
m.class,
m.\`order\`,
m.family,
m.genus,
m.species,
m.taxonomy,
m.pH as pH,
m.temperature as temperature,
m.o2 as o2,
m.nutrition,
m.gram_stain,
m.synonym,
m.media_name,
m.media_source,
m.mediadive_id,
TRIM(CASE
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' THEN
SUBSTRING(m.predict_pH, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) ELSE LENGTH(m.predict_pH) END)
ELSE NULL
END) as ppH,
TRIM(CASE
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' THEN
SUBSTRING(m.predict_temperature, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) ELSE LENGTH(m.predict_temperature) END)
ELSE NULL
END) as ptemperature,
TRIM(CASE
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' THEN
SUBSTRING(m.predict_o2, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) ELSE LENGTH(m.predict_o2) END)
ELSE NULL
END) as po2
`;
headers = ['species_dive_id', 'microbial_name', 'genome', 'domain', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'taxonomy', 'pH', 'temperature', 'o2', 'nutrition', 'gram_stain', 'synonym', 'media_name', 'media_source', 'mediadive_id', 'predict_pH', 'predict_temperature', 'predict_o2'];
break;
case 'ph':
selectFields = `
m.species_dive_id,
m.microbial_name,
m.genome,
m.domain,
m.phylum,
m.class,
m.\`order\`,
m.family,
m.genus,
m.species,
m.pH as pH,
TRIM(CASE
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' THEN
SUBSTRING(m.predict_pH, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) ELSE LENGTH(m.predict_pH) END)
ELSE NULL
END) as ppH
`;
headers = ['species_dive_id', 'microbial_name', 'genome', 'domain', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'pH', 'predict_pH'];
break;
case 'temperature':
selectFields = `
m.species_dive_id,
m.microbial_name,
m.genome,
m.domain,
m.phylum,
m.class,
m.\`order\`,
m.family,
m.genus,
m.species,
m.temperature as temperature,
TRIM(CASE
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' THEN
SUBSTRING(m.predict_temperature, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) ELSE LENGTH(m.predict_temperature) END)
ELSE NULL
END) as ptemperature
`;
headers = ['species_dive_id', 'microbial_name', 'genome', 'domain', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'temperature', 'predict_temperature'];
break;
case 'oxygen':
selectFields = `
m.species_dive_id,
m.microbial_name,
m.genome,
m.domain,
m.phylum,
m.class,
m.\`order\`,
m.family,
m.genus,
m.species,
m.o2 as o2,
TRIM(CASE
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' THEN
SUBSTRING(m.predict_o2, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) ELSE LENGTH(m.predict_o2) END)
ELSE NULL
END) as po2
`;
headers = ['species_dive_id', 'microbial_name', 'genome', 'domain', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'o2', 'predict_o2'];
break;
case 'culture_medium':
selectFields = `
m.species_dive_id,
m.microbial_name,
m.genome,
m.domain,
m.phylum,
m.class,
m.\`order\`,
m.family,
m.genus,
m.species,
m.nutrition,
m.media_name,
m.media_source,
m.mediadive_id
`;
headers = ['species_dive_id', 'microbial_name', 'genome', 'domain', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'nutrition', 'media_name', 'media_source', 'mediadive_id'];
break;
case 'max_growth_rate':
// 注意:如果数据库中没有 max_growth_rate 字段,这里需要根据实际情况调整
selectFields = `
m.species_dive_id,
m.microbial_name,
m.genome,
m.domain,
m.phylum,
m.class,
m.\`order\`,
m.family,
m.genus,
m.species
`;
headers = ['species_dive_id', 'microbial_name', 'genome', 'domain', 'phylum', 'class', 'order', 'family', 'genus', 'species'];
break;
}
// 构建查询
const query = `
SELECT ${selectFields}
FROM cultured_data m
WHERE m.cultured_type = ?
GROUP BY ${selectFields.split(',').map(f => f.trim().split(' as ')[0].replace('m.', 'm.')).join(', ')}
ORDER BY m.species_dive_id
`;
console.log('Execute download query for type:', type);
const [rows] = await pool.execute(query, [cultured_type]);
console.log('Download query result row count:', rows.length);
// 将数据转换为 CSV 格式
const escapeCSVValue = (value) => {
if (value === null || value === undefined) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const csvLines = [
headers.join(','),
...rows.map(row => {
return headers.map(header => {
// 处理字段名映射(如 pH -> pH, ppH -> predict_pH
let value = row[header];
if (value === undefined && header === 'predict_pH') {
value = row['ppH'];
} else if (value === undefined && header === 'predict_temperature') {
value = row['ptemperature'];
} else if (value === undefined && header === 'predict_o2') {
value = row['po2'];
}
return escapeCSVValue(value);
}).join(',');
})
];
const csvContent = csvLines.join('\n');
// 设置响应头
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${type}.csv"`);
res.send(csvContent);
} catch (error) {
console.error('Error downloading data:', error);
res.status(500).json({
success: false,
message: 'Failed to download data',
error: error.message
});
}
});
module.exports = router;