Features: - Backend statistics API (cloud-native Prisma aggregation) - Results page with hybrid solution (AI consensus + human final decision) - Excel export (frontend generation, zero disk write, cloud-native) - PRISMA-style exclusion reason analysis with bar chart - Batch selection and export (3 export methods) - Fixed logic contradiction (inclusion does not show exclusion reason) - Optimized table width (870px, no horizontal scroll) Components: - Backend: screeningController.ts - add getProjectStatistics API - Frontend: ScreeningResults.tsx - complete results page (hybrid solution) - Frontend: excelExport.ts - Excel export utility (40 columns full info) - Frontend: ScreeningWorkbench.tsx - add navigation button - Utils: get-test-projects.mjs - quick test tool Architecture: - Cloud-native: backend aggregation reduces network transfer - Cloud-native: frontend Excel generation (zero file persistence) - Reuse platform: global prisma instance, logger - Performance: statistics API < 500ms, Excel export < 3s (1000 records) Documentation: - Update module status guide (add Week 4 features) - Update task breakdown (mark Week 4 completed) - Update API design spec (add statistics API) - Update database design (add field usage notes) - Create Week 4 development plan - Create Week 4 completion report - Create technical debt list Test: - End-to-end flow test passed - All features verified - Performance test passed - Cloud-native compliance verified Ref: Week 4 Development Plan Scope: ASL Module MVP - Title Abstract Screening Results Cloud-Native: Backend aggregation + Frontend Excel generation
419 lines
11 KiB
JavaScript
419 lines
11 KiB
JavaScript
/**
|
||
* 稿件审查API测试脚本
|
||
* 测试5个API端点的功能
|
||
*/
|
||
|
||
import axios from 'axios';
|
||
import FormData from 'form-data';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
const API_BASE_URL = 'http://localhost:3001/api/v1';
|
||
const TEST_FILE_PATH = path.join(__dirname, '../docs/稿约规范性评估标准.txt'); // 使用现有文本文件作为测试
|
||
|
||
// 颜色输出
|
||
const colors = {
|
||
reset: '\x1b[0m',
|
||
green: '\x1b[32m',
|
||
red: '\x1b[31m',
|
||
yellow: '\x1b[33m',
|
||
blue: '\x1b[34m',
|
||
cyan: '\x1b[36m',
|
||
};
|
||
|
||
function log(message, color = 'reset') {
|
||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||
}
|
||
|
||
function separator() {
|
||
console.log('\n' + '='.repeat(80) + '\n');
|
||
}
|
||
|
||
// 延迟函数
|
||
function delay(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
// ==================== 测试函数 ====================
|
||
|
||
/**
|
||
* 测试1: 上传稿件
|
||
*/
|
||
async function testUploadManuscript() {
|
||
log('📤 测试1: 上传稿件 (POST /review/upload)', 'cyan');
|
||
|
||
try {
|
||
// 检查测试文件是否存在
|
||
if (!fs.existsSync(TEST_FILE_PATH)) {
|
||
log(`❌ 测试文件不存在: ${TEST_FILE_PATH}`, 'red');
|
||
log('💡 提示:请准备一个Word文档(.doc或.docx)作为测试文件', 'yellow');
|
||
return null;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', fs.createReadStream(TEST_FILE_PATH));
|
||
formData.append('modelType', 'deepseek-v3');
|
||
|
||
log(`📄 测试文件: ${path.basename(TEST_FILE_PATH)}`, 'blue');
|
||
log(`🤖 使用模型: deepseek-v3`, 'blue');
|
||
|
||
const response = await axios.post(
|
||
`${API_BASE_URL}/review/upload`,
|
||
formData,
|
||
{
|
||
headers: formData.getHeaders(),
|
||
timeout: 30000,
|
||
}
|
||
);
|
||
|
||
if (response.data.success) {
|
||
log('✅ 上传成功!', 'green');
|
||
console.log('返回数据:', JSON.stringify(response.data, null, 2));
|
||
return response.data.data.taskId;
|
||
} else {
|
||
log('❌ 上传失败', 'red');
|
||
console.log('错误信息:', response.data.message);
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
log('❌ 请求失败', 'red');
|
||
if (error.response) {
|
||
console.log('状态码:', error.response.status);
|
||
console.log('错误信息:', error.response.data);
|
||
} else {
|
||
console.log('错误:', error.message);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 测试2: 查询任务状态
|
||
*/
|
||
async function testGetTaskStatus(taskId) {
|
||
log('🔍 测试2: 查询任务状态 (GET /review/tasks/:taskId)', 'cyan');
|
||
|
||
try {
|
||
log(`📋 任务ID: ${taskId}`, 'blue');
|
||
|
||
const response = await axios.get(`${API_BASE_URL}/review/tasks/${taskId}`, {
|
||
timeout: 10000,
|
||
});
|
||
|
||
if (response.data.success) {
|
||
log('✅ 查询成功!', 'green');
|
||
console.log('任务状态:', JSON.stringify(response.data.data, null, 2));
|
||
return response.data.data;
|
||
} else {
|
||
log('❌ 查询失败', 'red');
|
||
console.log('错误信息:', response.data.message);
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
log('❌ 请求失败', 'red');
|
||
if (error.response) {
|
||
console.log('状态码:', error.response.status);
|
||
console.log('错误信息:', error.response.data);
|
||
} else {
|
||
console.log('错误:', error.message);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 测试3: 轮询任务直到完成
|
||
*/
|
||
async function pollTaskUntilComplete(taskId, maxAttempts = 60) {
|
||
log('⏳ 测试3: 轮询任务状态直到完成', 'cyan');
|
||
|
||
for (let i = 0; i < maxAttempts; i++) {
|
||
const task = await testGetTaskStatus(taskId);
|
||
|
||
if (!task) {
|
||
log('❌ 查询任务失败,停止轮询', 'red');
|
||
return null;
|
||
}
|
||
|
||
log(`📊 当前状态: ${task.status}`, 'yellow');
|
||
|
||
if (task.status === 'completed') {
|
||
log('✅ 任务已完成!', 'green');
|
||
return task;
|
||
}
|
||
|
||
if (task.status === 'failed') {
|
||
log('❌ 任务失败', 'red');
|
||
console.log('错误信息:', task.errorMessage);
|
||
return null;
|
||
}
|
||
|
||
log(`⏱️ 等待5秒后重试... (${i + 1}/${maxAttempts})`, 'blue');
|
||
await delay(5000);
|
||
}
|
||
|
||
log('⚠️ 超过最大等待时间,任务仍未完成', 'yellow');
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 测试4: 获取审查报告
|
||
*/
|
||
async function testGetReport(taskId) {
|
||
log('📊 测试4: 获取审查报告 (GET /review/tasks/:taskId/report)', 'cyan');
|
||
|
||
try {
|
||
log(`📋 任务ID: ${taskId}`, 'blue');
|
||
|
||
const response = await axios.get(`${API_BASE_URL}/review/tasks/${taskId}/report`, {
|
||
timeout: 10000,
|
||
});
|
||
|
||
if (response.data.success) {
|
||
log('✅ 获取报告成功!', 'green');
|
||
console.log('\n📄 完整报告:');
|
||
console.log(JSON.stringify(response.data.data, null, 2));
|
||
|
||
// 显示关键指标
|
||
const report = response.data.data;
|
||
separator();
|
||
log('📈 评估结果摘要:', 'cyan');
|
||
log(`总分: ${report.overallScore?.toFixed(1) || 'N/A'}`, 'green');
|
||
log(`稿约规范性评分: ${report.editorialReview?.overall_score || 'N/A'}`, 'blue');
|
||
log(`方法学评分: ${report.methodologyReview?.overall_score || 'N/A'}`, 'blue');
|
||
log(`字数: ${report.wordCount || 'N/A'}`, 'blue');
|
||
log(`耗时: ${report.durationSeconds || 'N/A'}秒`, 'blue');
|
||
separator();
|
||
|
||
return response.data.data;
|
||
} else {
|
||
log('❌ 获取报告失败', 'red');
|
||
console.log('错误信息:', response.data.message);
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
log('❌ 请求失败', 'red');
|
||
if (error.response) {
|
||
console.log('状态码:', error.response.status);
|
||
console.log('错误信息:', error.response.data);
|
||
} else {
|
||
console.log('错误:', error.message);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 测试5: 获取任务列表
|
||
*/
|
||
async function testGetTaskList() {
|
||
log('📋 测试5: 获取任务列表 (GET /review/tasks)', 'cyan');
|
||
|
||
try {
|
||
const response = await axios.get(`${API_BASE_URL}/review/tasks`, {
|
||
params: { page: 1, limit: 10 },
|
||
timeout: 10000,
|
||
});
|
||
|
||
if (response.data.success) {
|
||
log('✅ 获取列表成功!', 'green');
|
||
console.log(`找到 ${response.data.data.length} 个任务`);
|
||
console.log('任务列表:', JSON.stringify(response.data.data, null, 2));
|
||
console.log('分页信息:', JSON.stringify(response.data.pagination, null, 2));
|
||
return response.data.data;
|
||
} else {
|
||
log('❌ 获取列表失败', 'red');
|
||
console.log('错误信息:', response.data.message);
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
log('❌ 请求失败', 'red');
|
||
if (error.response) {
|
||
console.log('状态码:', error.response.status);
|
||
console.log('错误信息:', error.response.data);
|
||
} else {
|
||
console.log('错误:', error.message);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 测试6: 删除任务(可选)
|
||
*/
|
||
async function testDeleteTask(taskId) {
|
||
log('🗑️ 测试6: 删除任务 (DELETE /review/tasks/:taskId)', 'cyan');
|
||
|
||
try {
|
||
log(`📋 任务ID: ${taskId}`, 'blue');
|
||
|
||
const response = await axios.delete(`${API_BASE_URL}/review/tasks/${taskId}`, {
|
||
timeout: 10000,
|
||
});
|
||
|
||
if (response.data.success) {
|
||
log('✅ 删除成功!', 'green');
|
||
console.log('返回数据:', JSON.stringify(response.data, null, 2));
|
||
return true;
|
||
} else {
|
||
log('❌ 删除失败', 'red');
|
||
console.log('错误信息:', response.data.message);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
log('❌ 请求失败', 'red');
|
||
if (error.response) {
|
||
console.log('状态码:', error.response.status);
|
||
console.log('错误信息:', error.response.data);
|
||
} else {
|
||
console.log('错误:', error.message);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ==================== 主测试流程 ====================
|
||
|
||
async function runAllTests() {
|
||
log('🚀 开始稿件审查API测试', 'green');
|
||
separator();
|
||
|
||
// 测试1: 上传稿件
|
||
const taskId = await testUploadManuscript();
|
||
if (!taskId) {
|
||
log('❌ 上传失败,终止测试', 'red');
|
||
return;
|
||
}
|
||
separator();
|
||
|
||
// 等待2秒
|
||
await delay(2000);
|
||
|
||
// 测试2: 查询任务状态
|
||
await testGetTaskStatus(taskId);
|
||
separator();
|
||
|
||
// 测试3: 轮询直到完成
|
||
const completedTask = await pollTaskUntilComplete(taskId);
|
||
if (!completedTask) {
|
||
log('❌ 任务未能完成,跳过报告测试', 'red');
|
||
separator();
|
||
|
||
// 但仍然测试任务列表
|
||
await testGetTaskList();
|
||
separator();
|
||
return;
|
||
}
|
||
separator();
|
||
|
||
// 测试4: 获取报告
|
||
await testGetReport(taskId);
|
||
separator();
|
||
|
||
// 测试5: 获取任务列表
|
||
await testGetTaskList();
|
||
separator();
|
||
|
||
// 测试6: 删除任务(可选,取消注释以启用)
|
||
// log('⚠️ 是否删除测试任务?(取消注释testDeleteTask以启用)', 'yellow');
|
||
// await testDeleteTask(taskId);
|
||
// separator();
|
||
|
||
log('✅ 所有测试完成!', 'green');
|
||
}
|
||
|
||
// ==================== 健康检查 ====================
|
||
|
||
async function checkHealth() {
|
||
log('🔍 检查后端服务健康状态...', 'cyan');
|
||
|
||
try {
|
||
const response = await axios.get('http://localhost:3001/health', {
|
||
timeout: 5000,
|
||
});
|
||
|
||
log('✅ 后端服务正常', 'green');
|
||
console.log('健康状态:', response.data);
|
||
return true;
|
||
} catch (error) {
|
||
log('❌ 后端服务不可用', 'red');
|
||
console.log('错误:', error.message);
|
||
log('💡 请先启动后端服务: npm run dev 或 启动后端.bat', 'yellow');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ==================== 入口 ====================
|
||
|
||
async function main() {
|
||
console.log('\n');
|
||
log('═══════════════════════════════════════════════════════════════════════════════', 'cyan');
|
||
log(' 稿件审查API自动化测试脚本 ', 'cyan');
|
||
log('═══════════════════════════════════════════════════════════════════════════════', 'cyan');
|
||
console.log('\n');
|
||
|
||
// 健康检查
|
||
const healthy = await checkHealth();
|
||
if (!healthy) {
|
||
process.exit(1);
|
||
}
|
||
separator();
|
||
|
||
// 运行所有测试
|
||
await runAllTests();
|
||
|
||
console.log('\n');
|
||
log('═══════════════════════════════════════════════════════════════════════════════', 'cyan');
|
||
log(' 测试完成 ', 'cyan');
|
||
log('═══════════════════════════════════════════════════════════════════════════════', 'cyan');
|
||
console.log('\n');
|
||
}
|
||
|
||
main().catch(error => {
|
||
console.error('测试脚本执行失败:', error);
|
||
process.exit(1);
|
||
});
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|