From 2348234013b2bb5f3d3908df20a1e7892228e6fb Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Sat, 6 Dec 2025 22:12:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(dc/tool-c):=20Day=202=20-=20Session?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心功能: - 数据库: 创建dc_tool_c_sessions表 (12字段, 3索引) - 服务层: SessionService (383行), DataProcessService (303行) - 控制器: SessionController (300行, 6个API端点) - 路由: 新增6个Session管理路由 - 测试: 7个API测试全部通过 (100%) 技术亮点: - 零落盘架构: Excel内存解析, OSS存储 - Session管理: 10分钟过期, 心跳延长机制 - 云原生规范: storage/logger/prisma全平台复用 - 完整测试: 上传/预览/完整数据/删除/心跳 文件清单: - backend/prisma/schema.prisma (新增DcToolCSession模型) - backend/prisma/migrations/create_tool_c_session.sql - backend/scripts/create-tool-c-table.mjs - backend/src/modules/dc/tool-c/services/ (SessionService, DataProcessService) - backend/src/modules/dc/tool-c/controllers/SessionController.ts - backend/src/modules/dc/tool-c/routes/index.ts - backend/test-tool-c-day2.mjs - docs/03-业务模块/DC-数据清洗整理/00-工具C当前状态与开发指南.md - docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md 代码统计: ~1900行 测试结果: 7/7 通过 (100%) 云原生规范: 完全符合 --- .../migrations/create_tool_c_session.sql | 33 + backend/prisma/schema.prisma | 27 + backend/scripts/create-tool-c-table.mjs | 138 ++++ backend/src/modules/dc/tool-c/README.md | 171 ++++ .../tool-c/controllers/SessionController.ts | 299 +++++++ .../dc/tool-c/controllers/TestController.ts | 130 +++ backend/src/modules/dc/tool-c/routes/index.ts | 61 ++ .../dc/tool-c/services/DataProcessService.ts | 302 +++++++ .../tool-c/services/PythonExecutorService.ts | 176 ++++ .../dc/tool-c/services/SessionService.ts | 382 +++++++++ backend/test-tool-c-day2.mjs | 382 +++++++++ .../DC-数据清洗整理/00-工具C当前状态与开发指南.md | 765 ++++++++++++++++++ .../06-开发记录/2025-12-06_工具C_Day2开发完成总结.md | 600 ++++++++++++++ 13 files changed, 3466 insertions(+) create mode 100644 backend/prisma/migrations/create_tool_c_session.sql create mode 100644 backend/scripts/create-tool-c-table.mjs create mode 100644 backend/src/modules/dc/tool-c/README.md create mode 100644 backend/src/modules/dc/tool-c/controllers/SessionController.ts create mode 100644 backend/src/modules/dc/tool-c/controllers/TestController.ts create mode 100644 backend/src/modules/dc/tool-c/routes/index.ts create mode 100644 backend/src/modules/dc/tool-c/services/DataProcessService.ts create mode 100644 backend/src/modules/dc/tool-c/services/PythonExecutorService.ts create mode 100644 backend/src/modules/dc/tool-c/services/SessionService.ts create mode 100644 backend/test-tool-c-day2.mjs create mode 100644 docs/03-业务模块/DC-数据清洗整理/00-工具C当前状态与开发指南.md create mode 100644 docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md diff --git a/backend/prisma/migrations/create_tool_c_session.sql b/backend/prisma/migrations/create_tool_c_session.sql new file mode 100644 index 00000000..76d0aafe --- /dev/null +++ b/backend/prisma/migrations/create_tool_c_session.sql @@ -0,0 +1,33 @@ +-- 创建 Tool C Session 表 +-- 日期: 2025-12-06 +-- 用途: 科研数据编辑器会话管理 + +CREATE TABLE IF NOT EXISTS dc_schema.dc_tool_c_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255) NOT NULL, + file_name VARCHAR(500) NOT NULL, + file_key VARCHAR(500) NOT NULL, + + -- 数据元信息 + total_rows INTEGER NOT NULL, + total_cols INTEGER NOT NULL, + columns JSONB NOT NULL, + encoding VARCHAR(50), + file_size INTEGER NOT NULL, + + -- 时间戳 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_dc_tool_c_sessions_user_id ON dc_schema.dc_tool_c_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_dc_tool_c_sessions_expires_at ON dc_schema.dc_tool_c_sessions(expires_at); + +-- 添加注释 +COMMENT ON TABLE dc_schema.dc_tool_c_sessions IS 'Tool C (科研数据编辑器) Session会话表'; +COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.file_key IS 'OSS存储路径: dc/tool-c/sessions/{timestamp}-{fileName}'; +COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.columns IS '列名数组 ["age", "gender", "diagnosis"]'; +COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.expires_at IS '过期时间(创建后10分钟)'; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7936e8b0..0551cc0e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -846,3 +846,30 @@ model DCExtractionItem { @@map("dc_extraction_items") @@schema("dc_schema") } + +// ==================== DC数据清洗模块 - Tool C (科研数据编辑器) ==================== + +// Tool C Session 会话表 +model DcToolCSession { + id String @id @default(uuid()) + userId String @map("user_id") + fileName String @map("file_name") + fileKey String @map("file_key") // OSS存储key: dc/tool-c/sessions/{timestamp}-{fileName} + + // 数据元信息 + totalRows Int @map("total_rows") + totalCols Int @map("total_cols") + columns Json @map("columns") // ["age", "gender", "diagnosis"] 列名数组 + encoding String? @map("encoding") // 文件编码 utf-8, gbk等 + fileSize Int @map("file_size") // 文件大小(字节) + + // 时间戳 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + expiresAt DateTime @map("expires_at") // 过期时间(创建后10分钟) + + @@index([userId]) + @@index([expiresAt]) + @@map("dc_tool_c_sessions") + @@schema("dc_schema") +} diff --git a/backend/scripts/create-tool-c-table.mjs b/backend/scripts/create-tool-c-table.mjs new file mode 100644 index 00000000..05d794ca --- /dev/null +++ b/backend/scripts/create-tool-c-table.mjs @@ -0,0 +1,138 @@ +/** + * 创建 Tool C Session 表 + * + * 执行方式:node scripts/create-tool-c-table.mjs + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function createToolCTable() { + console.log('========================================'); + console.log('开始创建 Tool C Session 表'); + console.log('========================================\n'); + + try { + // 1. 检查表是否已存在 + console.log('[1/4] 检查表是否已存在...'); + const checkResult = await prisma.$queryRawUnsafe(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'dc_schema' + AND table_name = 'dc_tool_c_sessions' + ) as exists + `); + + const tableExists = checkResult[0].exists; + + if (tableExists) { + console.log('✅ 表已存在: dc_schema.dc_tool_c_sessions'); + console.log('\n如需重新创建,请手动执行: DROP TABLE dc_schema.dc_tool_c_sessions CASCADE;\n'); + return; + } + + console.log('✅ 表不存在,准备创建\n'); + + // 2. 创建表 + console.log('[2/4] 创建表 dc_tool_c_sessions...'); + await prisma.$executeRawUnsafe(` + CREATE TABLE dc_schema.dc_tool_c_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255) NOT NULL, + file_name VARCHAR(500) NOT NULL, + file_key VARCHAR(500) NOT NULL, + + total_rows INTEGER NOT NULL, + total_cols INTEGER NOT NULL, + columns JSONB NOT NULL, + encoding VARCHAR(50), + file_size INTEGER NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL + ) + `); + console.log('✅ 表创建成功\n'); + + // 3. 创建索引 + console.log('[3/4] 创建索引...'); + await prisma.$executeRawUnsafe(` + CREATE INDEX idx_dc_tool_c_sessions_user_id ON dc_schema.dc_tool_c_sessions(user_id) + `); + await prisma.$executeRawUnsafe(` + CREATE INDEX idx_dc_tool_c_sessions_expires_at ON dc_schema.dc_tool_c_sessions(expires_at) + `); + console.log('✅ 索引创建成功\n'); + + // 4. 添加注释 + console.log('[4/4] 添加表注释...'); + await prisma.$executeRawUnsafe(` + COMMENT ON TABLE dc_schema.dc_tool_c_sessions IS 'Tool C (科研数据编辑器) Session会话表' + `); + await prisma.$executeRawUnsafe(` + COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.file_key IS 'OSS存储路径: dc/tool-c/sessions/{timestamp}-{fileName}' + `); + await prisma.$executeRawUnsafe(` + COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.columns IS '列名数组 ["age", "gender", "diagnosis"]' + `); + await prisma.$executeRawUnsafe(` + COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.expires_at IS '过期时间(创建后10分钟)' + `); + console.log('✅ 注释添加成功\n'); + + // 5. 验证表创建 + console.log('========================================'); + console.log('验证表结构'); + console.log('========================================\n'); + + const columns = await prisma.$queryRawUnsafe(` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'dc_schema' + AND table_name = 'dc_tool_c_sessions' + ORDER BY ordinal_position + `); + + console.log('表结构:'); + console.table(columns); + + const indexes = await prisma.$queryRawUnsafe(` + SELECT indexname, indexdef + FROM pg_indexes + WHERE schemaname = 'dc_schema' + AND tablename = 'dc_tool_c_sessions' + `); + + console.log('\n索引:'); + console.table(indexes); + + console.log('\n========================================'); + console.log('🎉 Tool C Session 表创建成功!'); + console.log('========================================\n'); + console.log('表名: dc_schema.dc_tool_c_sessions'); + console.log(`列数: ${columns.length}`); + console.log(`索引数: ${indexes.length}\n`); + + } catch (error) { + console.error('\n❌ 创建表失败:', error.message); + console.error('\n详细错误:'); + console.error(error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +// 执行 +createToolCTable() + .then(() => { + console.log('脚本执行完成'); + process.exit(0); + }) + .catch((error) => { + console.error('脚本执行失败:', error); + process.exit(1); + }); + diff --git a/backend/src/modules/dc/tool-c/README.md b/backend/src/modules/dc/tool-c/README.md new file mode 100644 index 00000000..e89fc591 --- /dev/null +++ b/backend/src/modules/dc/tool-c/README.md @@ -0,0 +1,171 @@ +# 工具C (Tool C) - 科研数据编辑器 + +## 📁 项目结构 + +``` +tool-c/ +├── services/ +│ └── PythonExecutorService.ts # Python代码执行服务 +├── controllers/ +│ └── TestController.ts # 测试控制器(Day 1) +├── routes/ +│ └── index.ts # 路由定义 +└── README.md # 本文件 +``` + +## ⚙️ 环境变量配置 + +在 `backend/.env` 文件中添加以下配置: + +```bash +# Python微服务地址 +EXTRACTION_SERVICE_URL=http://localhost:8000 +``` + +**说明**: +- 默认值:`http://localhost:8000` +- Python微服务需要先启动才能使用工具C +- 启动命令:`cd extraction_service && .\venv\Scripts\activate && uvicorn main:app --host 0.0.0.0 --port 8000` + +## 🚀 API端点(Day 1 测试) + +### 1. 测试Python服务健康检查 + +``` +GET /api/v1/dc/tool-c/test/health +``` + +**响应**: +```json +{ + "success": true, + "message": "Python服务正常", + "healthy": true +} +``` + +### 2. 测试代码验证 + +``` +POST /api/v1/dc/tool-c/test/validate +``` + +**请求体**: +```json +{ + "code": "df['age_group'] = df['age'] > 60" +} +``` + +**响应**: +```json +{ + "success": true, + "data": { + "valid": true, + "errors": [], + "warnings": [] + } +} +``` + +### 3. 测试代码执行 + +``` +POST /api/v1/dc/tool-c/test/execute +``` + +**请求体**: +```json +{ + "data": [ + {"age": 25}, + {"age": 65} + ], + "code": "df['old'] = df['age'] > 60" +} +``` + +**响应**: +```json +{ + "success": true, + "data": { + "success": true, + "result_data": [ + {"age": 25, "old": false}, + {"age": 65, "old": true} + ], + "output": "", + "error": null, + "execution_time": 0.004, + "result_shape": [2, 2] + } +} +``` + +## ✅ Day 1 完成情况 + +- [x] 创建Python微服务(dc_executor.py) +- [x] 添加AST安全检查 +- [x] 实现Pandas代码执行 +- [x] 创建FastAPI端点(/api/dc/validate, /api/dc/execute) +- [x] 创建Node.js服务(PythonExecutorService.ts) +- [x] 创建测试控制器和路由 +- [x] 验证功能正常工作 + +## 📝 使用示例 + +### 启动Python微服务 + +```bash +cd extraction_service +.\venv\Scripts\activate +python main.py +``` + +### 启动Node.js后端 + +```bash +cd backend +npm run dev +``` + +### 测试API + +```bash +# 1. 健康检查 +curl http://localhost:3000/api/v1/dc/tool-c/test/health + +# 2. 代码验证 +curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/validate \ + -H "Content-Type: application/json" \ + -d '{"code":"df[\"x\"] = 1"}' + +# 3. 代码执行 +curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/execute \ + -H "Content-Type: application/json" \ + -d '{"data":[{"age":25},{"age":65}],"code":"df[\"old\"] = df[\"age\"] > 60"}' +``` + +## 🔐 安全特性 + +- **AST静态检查**:拦截危险模块导入(os, sys, subprocess等) +- **超时保护**:代码执行超时30秒自动终止 +- **沙箱环境**:限制可用的内置函数 +- **错误处理**:完整的异常捕获和错误信息 + +## 📚 技术栈 + +- **Python后端**: FastAPI + Pandas + AST +- **Node.js后端**: Fastify + Axios + TypeScript +- **通信方式**: HTTP REST API +- **数据格式**: JSON + +## 🎯 下一步(Day 2) + +- [ ] Session管理(数据库 + OSS) +- [ ] 数据处理服务 +- [ ] AI代码生成服务(LLMFactory集成) +- [ ] 前端基础框架搭建 + diff --git a/backend/src/modules/dc/tool-c/controllers/SessionController.ts b/backend/src/modules/dc/tool-c/controllers/SessionController.ts new file mode 100644 index 00000000..b5a3438b --- /dev/null +++ b/backend/src/modules/dc/tool-c/controllers/SessionController.ts @@ -0,0 +1,299 @@ +/** + * Session控制器 + * + * API端点: + * - POST /sessions/upload 上传Excel文件创建Session + * - GET /sessions/:id 获取Session信息 + * - GET /sessions/:id/preview 获取预览数据(前100行) + * - GET /sessions/:id/full 获取完整数据 + * - DELETE /sessions/:id 删除Session + * - POST /sessions/:id/heartbeat 更新心跳 + * + * @module SessionController + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { MultipartFile } from '@fastify/multipart'; +import { logger } from '../../../../common/logging/index.js'; +import { sessionService } from '../services/SessionService.js'; +import { dataProcessService } from '../services/DataProcessService.js'; + +// ==================== 请求参数类型定义 ==================== + +interface SessionIdParams { + id: string; +} + +// ==================== 控制器 ==================== + +export class SessionController { + /** + * 上传Excel文件创建Session + * + * POST /api/v1/dc/tool-c/sessions/upload + */ + async upload(request: FastifyRequest, reply: FastifyReply) { + try { + logger.info('[SessionController] 收到文件上传请求'); + + // 1. 获取multipart数据 + const data = await request.file(); + + if (!data) { + return reply.code(400).send({ + success: false, + error: '未找到上传的文件', + }); + } + + const file = data as MultipartFile; + const fileName = file.filename; + + logger.info(`[SessionController] 文件名: ${fileName}`); + + // 2. 读取文件到Buffer + const fileBuffer = await file.toBuffer(); + + // 3. 验证文件 + const validation = dataProcessService.validateFile(fileBuffer, fileName); + if (!validation.valid) { + return reply.code(400).send({ + success: false, + error: validation.error, + }); + } + + // 4. 获取用户ID(从请求中提取,实际部署时从JWT获取) + // TODO: 从JWT token中获取userId + const userId = (request as any).userId || 'test-user-001'; + + // 5. 创建Session + const session = await sessionService.createSession( + userId, + fileName, + fileBuffer + ); + + logger.info(`[SessionController] Session创建成功: ${session.id}`); + + // 6. 返回Session信息 + return reply.code(201).send({ + success: true, + message: 'Session创建成功', + data: { + sessionId: session.id, + fileName: session.fileName, + fileSize: dataProcessService.formatFileSize(session.fileSize), + totalRows: session.totalRows, + totalCols: session.totalCols, + columns: session.columns, + expiresAt: session.expiresAt, + createdAt: session.createdAt, + }, + }); + } catch (error: any) { + logger.error(`[SessionController] 文件上传失败: ${error.message}`); + return reply.code(500).send({ + success: false, + error: error.message || '文件上传失败,请重试', + }); + } + } + + /** + * 获取Session信息(只含元数据) + * + * GET /api/v1/dc/tool-c/sessions/:id + */ + async getSession( + request: FastifyRequest<{ Params: SessionIdParams }>, + reply: FastifyReply + ) { + try { + const { id } = request.params; + + logger.info(`[SessionController] 获取Session: ${id}`); + + const session = await sessionService.getSession(id); + + return reply.code(200).send({ + success: true, + data: { + sessionId: session.id, + fileName: session.fileName, + fileSize: dataProcessService.formatFileSize(session.fileSize), + totalRows: session.totalRows, + totalCols: session.totalCols, + columns: session.columns, + encoding: session.encoding, + expiresAt: session.expiresAt, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }, + }); + } catch (error: any) { + logger.error(`[SessionController] 获取Session失败: ${error.message}`); + + const statusCode = error.message.includes('不存在') || error.message.includes('过期') + ? 404 + : 500; + + return reply.code(statusCode).send({ + success: false, + error: error.message || '获取Session失败', + }); + } + } + + /** + * 获取预览数据(前100行) + * + * GET /api/v1/dc/tool-c/sessions/:id/preview + */ + async getPreviewData( + request: FastifyRequest<{ Params: SessionIdParams }>, + reply: FastifyReply + ) { + try { + const { id } = request.params; + + logger.info(`[SessionController] 获取预览数据: ${id}`); + + const result = await sessionService.getPreviewData(id); + + return reply.code(200).send({ + success: true, + data: { + sessionId: result.id, + fileName: result.fileName, + totalRows: result.totalRows, + totalCols: result.totalCols, + columns: result.columns, + previewRows: result.previewData.length, + previewData: result.previewData, + }, + }); + } catch (error: any) { + logger.error(`[SessionController] 获取预览数据失败: ${error.message}`); + + const statusCode = error.message.includes('不存在') || error.message.includes('过期') + ? 404 + : 500; + + return reply.code(statusCode).send({ + success: false, + error: error.message || '获取预览数据失败', + }); + } + } + + /** + * 获取完整数据 + * + * GET /api/v1/dc/tool-c/sessions/:id/full + */ + async getFullData( + request: FastifyRequest<{ Params: SessionIdParams }>, + reply: FastifyReply + ) { + try { + const { id } = request.params; + + logger.info(`[SessionController] 获取完整数据: ${id}`); + + const data = await sessionService.getFullData(id); + + return reply.code(200).send({ + success: true, + data: { + sessionId: id, + totalRows: data.length, + data, + }, + }); + } catch (error: any) { + logger.error(`[SessionController] 获取完整数据失败: ${error.message}`); + + const statusCode = error.message.includes('不存在') || error.message.includes('过期') + ? 404 + : 500; + + return reply.code(statusCode).send({ + success: false, + error: error.message || '获取完整数据失败', + }); + } + } + + /** + * 删除Session + * + * DELETE /api/v1/dc/tool-c/sessions/:id + */ + async deleteSession( + request: FastifyRequest<{ Params: SessionIdParams }>, + reply: FastifyReply + ) { + try { + const { id } = request.params; + + logger.info(`[SessionController] 删除Session: ${id}`); + + await sessionService.deleteSession(id); + + return reply.code(200).send({ + success: true, + message: 'Session删除成功', + }); + } catch (error: any) { + logger.error(`[SessionController] 删除Session失败: ${error.message}`); + return reply.code(500).send({ + success: false, + error: error.message || '删除Session失败', + }); + } + } + + /** + * 更新心跳(延长过期时间) + * + * POST /api/v1/dc/tool-c/sessions/:id/heartbeat + */ + async updateHeartbeat( + request: FastifyRequest<{ Params: SessionIdParams }>, + reply: FastifyReply + ) { + try { + const { id } = request.params; + + logger.info(`[SessionController] 更新心跳: ${id}`); + + const newExpiresAt = await sessionService.updateHeartbeat(id); + + return reply.code(200).send({ + success: true, + message: '心跳更新成功', + data: { + sessionId: id, + expiresAt: newExpiresAt, + }, + }); + } catch (error: any) { + logger.error(`[SessionController] 更新心跳失败: ${error.message}`); + + const statusCode = error.message.includes('不存在') + ? 404 + : 500; + + return reply.code(statusCode).send({ + success: false, + error: error.message || '更新心跳失败', + }); + } + } +} + +// ==================== 导出单例实例 ==================== + +export const sessionController = new SessionController(); + diff --git a/backend/src/modules/dc/tool-c/controllers/TestController.ts b/backend/src/modules/dc/tool-c/controllers/TestController.ts new file mode 100644 index 00000000..bf477a4c --- /dev/null +++ b/backend/src/modules/dc/tool-c/controllers/TestController.ts @@ -0,0 +1,130 @@ +/** + * 工具C测试控制器 + * + * 用于Day 1验证Python服务集成 + * + * @module TestController + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { logger } from '../../../../common/logging/index.js'; +import { pythonExecutorService } from '../services/PythonExecutorService.js'; + +// ==================== 请求体类型定义 ==================== + +interface ValidateRequest { + code: string; +} + +interface ExecuteRequest { + data: Record[]; + code: string; +} + +// ==================== 控制器 ==================== + +export class TestController { + /** + * 测试Python服务健康检查 + * + * GET /api/dc/tool-c/test/health + */ + async testHealth(request: FastifyRequest, reply: FastifyReply) { + try { + logger.info('[Test] 测试Python服务健康检查'); + + const isHealthy = await pythonExecutorService.healthCheck(); + + return reply.code(200).send({ + success: true, + message: isHealthy ? 'Python服务正常' : 'Python服务异常', + healthy: isHealthy, + }); + } catch (error: any) { + logger.error(`[Test] 健康检查失败: ${error.message}`); + return reply.code(500).send({ + success: false, + error: error.message, + }); + } + } + + /** + * 测试代码验证 + * + * POST /api/dc/tool-c/test/validate + */ + async testValidate( + request: FastifyRequest<{ Body: ValidateRequest }>, + reply: FastifyReply + ) { + try { + const { code } = request.body; + + if (!code) { + return reply.code(400).send({ + success: false, + error: '缺少必需参数: code', + }); + } + + logger.info(`[Test] 测试代码验证,长度: ${code.length} 字符`); + + const result = await pythonExecutorService.validateCode(code); + + return reply.code(200).send({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error(`[Test] 代码验证失败: ${error.message}`); + return reply.code(500).send({ + success: false, + error: error.message, + }); + } + } + + /** + * 测试代码执行 + * + * POST /api/dc/tool-c/test/execute + */ + async testExecute( + request: FastifyRequest<{ Body: ExecuteRequest }>, + reply: FastifyReply + ) { + try { + const { data, code } = request.body; + + if (!data || !code) { + return reply.code(400).send({ + success: false, + error: '缺少必需参数: data 或 code', + }); + } + + logger.info( + `[Test] 测试代码执行: 数据行数=${data.length}, 代码长度=${code.length} 字符` + ); + + const result = await pythonExecutorService.executeCode(data, code); + + return reply.code(200).send({ + success: result.success, + data: result, + }); + } catch (error: any) { + logger.error(`[Test] 代码执行失败: ${error.message}`); + return reply.code(500).send({ + success: false, + error: error.message, + }); + } + } +} + +// ==================== 导出单例实例 ==================== + +export const testController = new TestController(); + diff --git a/backend/src/modules/dc/tool-c/routes/index.ts b/backend/src/modules/dc/tool-c/routes/index.ts new file mode 100644 index 00000000..ff154e62 --- /dev/null +++ b/backend/src/modules/dc/tool-c/routes/index.ts @@ -0,0 +1,61 @@ +/** + * 工具C路由定义 + * + * @module routes + */ + +import { FastifyInstance } from 'fastify'; +import { testController } from '../controllers/TestController.js'; +import { sessionController } from '../controllers/SessionController.js'; + +export async function toolCRoutes(fastify: FastifyInstance) { + // ==================== 测试路由(Day 1) ==================== + + // 测试Python服务健康检查 + fastify.get('/test/health', { + handler: testController.testHealth.bind(testController), + }); + + // 测试代码验证 + fastify.post('/test/validate', { + handler: testController.testValidate.bind(testController), + }); + + // 测试代码执行 + fastify.post('/test/execute', { + handler: testController.testExecute.bind(testController), + }); + + // ==================== Session管理路由(Day 2) ==================== + + // 上传Excel文件创建Session + fastify.post('/sessions/upload', { + handler: sessionController.upload.bind(sessionController), + }); + + // 获取Session信息(元数据) + fastify.get('/sessions/:id', { + handler: sessionController.getSession.bind(sessionController), + }); + + // 获取预览数据(前100行) + fastify.get('/sessions/:id/preview', { + handler: sessionController.getPreviewData.bind(sessionController), + }); + + // 获取完整数据 + fastify.get('/sessions/:id/full', { + handler: sessionController.getFullData.bind(sessionController), + }); + + // 删除Session + fastify.delete('/sessions/:id', { + handler: sessionController.deleteSession.bind(sessionController), + }); + + // 更新心跳(延长10分钟) + fastify.post('/sessions/:id/heartbeat', { + handler: sessionController.updateHeartbeat.bind(sessionController), + }); +} + diff --git a/backend/src/modules/dc/tool-c/services/DataProcessService.ts b/backend/src/modules/dc/tool-c/services/DataProcessService.ts new file mode 100644 index 00000000..1eb1f72c --- /dev/null +++ b/backend/src/modules/dc/tool-c/services/DataProcessService.ts @@ -0,0 +1,302 @@ +/** + * 数据处理服务 + * + * 功能: + * - Excel文件解析 + * - 文件验证 + * - 列类型推断(可选) + * + * @module DataProcessService + */ + +import * as xlsx from 'xlsx'; +import { logger } from '../../../../common/logging/index.js'; + +// ==================== 类型定义 ==================== + +interface ParsedExcelData { + data: any[]; + columns: string[]; + totalRows: number; + totalCols: number; +} + +interface ValidationResult { + valid: boolean; + error?: string; +} + +interface ColumnType { + name: string; + type: 'number' | 'string' | 'date' | 'boolean' | 'mixed'; + sampleValues: any[]; +} + +// ==================== 配置常量 ==================== + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const SUPPORTED_EXTENSIONS = ['.xlsx', '.xls', '.csv']; + +// ==================== 数据处理服务 ==================== + +export class DataProcessService { + /** + * 解析Excel文件 + * + * @param buffer - 文件Buffer + * @returns 解析后的数据 + */ + parseExcel(buffer: Buffer): ParsedExcelData { + try { + logger.info('[DataProcessService] 开始解析Excel文件'); + + // 1. 读取Excel文件(内存操作) + const workbook = xlsx.read(buffer, { type: 'buffer' }); + + // 2. 获取第一个工作表 + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { + throw new Error('Excel文件中没有工作表'); + } + + const sheet = workbook.Sheets[sheetName]; + + // 3. 转换为JSON格式 + const data = xlsx.utils.sheet_to_json(sheet); + + if (data.length === 0) { + throw new Error('Excel文件没有数据'); + } + + // 4. 提取元数据 + const totalRows = data.length; + const columns = Object.keys(data[0] || {}); + const totalCols = columns.length; + + if (totalCols === 0) { + throw new Error('Excel文件没有列'); + } + + logger.info( + `[DataProcessService] Excel解析成功: ${totalRows}行 x ${totalCols}列`, + { columns } + ); + + return { + data, + columns, + totalRows, + totalCols, + }; + } catch (error: any) { + logger.error(`[DataProcessService] Excel解析失败: ${error.message}`); + throw new Error(`Excel文件解析失败: ${error.message}`); + } + } + + /** + * 验证文件 + * + * @param buffer - 文件Buffer + * @param fileName - 文件名 + * @returns 验证结果 + */ + validateFile(buffer: Buffer, fileName: string): ValidationResult { + try { + logger.info('[DataProcessService] 验证文件', { fileName, size: buffer.length }); + + // 1. 检查文件大小 + if (buffer.length === 0) { + return { + valid: false, + error: '文件为空', + }; + } + + if (buffer.length > MAX_FILE_SIZE) { + const sizeMB = (buffer.length / 1024 / 1024).toFixed(2); + return { + valid: false, + error: `文件大小超过限制(最大10MB),当前: ${sizeMB}MB`, + }; + } + + // 2. 检查文件扩展名 + const ext = fileName.toLowerCase().substring(fileName.lastIndexOf('.')); + if (!SUPPORTED_EXTENSIONS.includes(ext)) { + return { + valid: false, + error: `不支持的文件格式: ${ext},仅支持 .xlsx, .xls, .csv`, + }; + } + + // 3. 尝试解析文件 + try { + const parsed = this.parseExcel(buffer); + + // 检查行数 + if (parsed.totalRows > 50000) { + logger.warn('[DataProcessService] 文件行数较多,可能影响性能', { + rows: parsed.totalRows, + }); + } + + // 检查列数 + if (parsed.totalCols > 100) { + logger.warn('[DataProcessService] 文件列数较多', { + cols: parsed.totalCols, + }); + } + } catch (error: any) { + return { + valid: false, + error: `文件内容无法解析: ${error.message}`, + }; + } + + logger.info('[DataProcessService] 文件验证通过', { fileName }); + + return { + valid: true, + }; + } catch (error: any) { + logger.error(`[DataProcessService] 文件验证失败: ${error.message}`); + return { + valid: false, + error: `文件验证失败: ${error.message}`, + }; + } + } + + /** + * 推断列类型(可选功能,Day 3优化) + * + * @param data - 数据数组 + * @returns 列类型信息 + */ + inferColumnTypes(data: any[]): ColumnType[] { + try { + logger.info('[DataProcessService] 推断列类型'); + + if (data.length === 0) { + return []; + } + + const columns = Object.keys(data[0] || {}); + const columnTypes: ColumnType[] = []; + + for (const columnName of columns) { + // 取前10行样本值 + const sampleValues = data.slice(0, 10).map((row) => row[columnName]); + + // 推断类型 + const types = new Set(sampleValues.map((val) => this.getValueType(val))); + + let inferredType: ColumnType['type'] = 'string'; + if (types.size === 1) { + inferredType = Array.from(types)[0]; + } else if (types.size > 1) { + inferredType = 'mixed'; + } + + columnTypes.push({ + name: columnName, + type: inferredType, + sampleValues, + }); + } + + logger.info(`[DataProcessService] 列类型推断完成: ${columnTypes.length}列`); + + return columnTypes; + } catch (error: any) { + logger.error(`[DataProcessService] 列类型推断失败: ${error.message}`); + return []; + } + } + + /** + * 获取值类型 + * + * @param value - 值 + * @returns 类型 + */ + private getValueType(value: any): ColumnType['type'] { + if (value === null || value === undefined || value === '') { + return 'string'; + } + + if (typeof value === 'number') { + return 'number'; + } + + if (typeof value === 'boolean') { + return 'boolean'; + } + + if (value instanceof Date) { + return 'date'; + } + + // 尝试解析为日期 + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + if (typeof value === 'string' && datePattern.test(value)) { + return 'date'; + } + + // 尝试解析为数字 + if (typeof value === 'string' && !isNaN(Number(value))) { + return 'number'; + } + + return 'string'; + } + + /** + * 格式化文件大小 + * + * @param bytes - 字节数 + * @returns 格式化后的大小 + */ + formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(2)} KB`; + } else { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + } + } + + /** + * 生成文件摘要信息 + * + * @param buffer - 文件Buffer + * @param fileName - 文件名 + * @returns 文件摘要 + */ + generateFileSummary(buffer: Buffer, fileName: string) { + try { + const parsed = this.parseExcel(buffer); + const columnTypes = this.inferColumnTypes(parsed.data); + + return { + fileName, + fileSize: this.formatFileSize(buffer.length), + totalRows: parsed.totalRows, + totalCols: parsed.totalCols, + columns: parsed.columns, + columnTypes, + sampleData: parsed.data.slice(0, 5), // 前5行样本 + }; + } catch (error: any) { + logger.error(`[DataProcessService] 生成文件摘要失败: ${error.message}`); + throw error; + } + } +} + +// ==================== 导出单例实例 ==================== + +export const dataProcessService = new DataProcessService(); + diff --git a/backend/src/modules/dc/tool-c/services/PythonExecutorService.ts b/backend/src/modules/dc/tool-c/services/PythonExecutorService.ts new file mode 100644 index 00000000..65603c37 --- /dev/null +++ b/backend/src/modules/dc/tool-c/services/PythonExecutorService.ts @@ -0,0 +1,176 @@ +/** + * Python代码执行服务 + * + * 功能: + * - 调用Python微服务执行Pandas代码 + * - AST安全验证 + * - 超时控制 + * - 错误处理 + * + * @module PythonExecutorService + */ + +import axios, { AxiosInstance } from 'axios'; +import { logger } from '../../../../common/logging/index.js'; + +// ==================== 类型定义 ==================== + +interface ValidateCodeRequest { + code: string; +} + +interface ValidateCodeResponse { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +interface ExecuteCodeRequest { + data: Record[]; + code: string; +} + +interface ExecuteCodeResponse { + success: boolean; + result_data: Record[] | null; + output: string; + error: string | null; + execution_time: number; + result_shape?: [number, number]; +} + +// ==================== 配置常量 ==================== + +const EXTRACTION_SERVICE_URL = process.env.EXTRACTION_SERVICE_URL || 'http://localhost:8000'; +const DEFAULT_TIMEOUT = 30000; // 30秒超时 + +// ==================== Python执行器服务 ==================== + +export class PythonExecutorService { + private client: AxiosInstance; + + constructor() { + // 创建axios实例 + this.client = axios.create({ + baseURL: EXTRACTION_SERVICE_URL, + timeout: DEFAULT_TIMEOUT, + headers: { + 'Content-Type': 'application/json', + }, + }); + + logger.info(`PythonExecutorService initialized: ${EXTRACTION_SERVICE_URL}`); + } + + /** + * 验证Pandas代码安全性(AST检查) + * + * @param code - Pandas代码 + * @returns 验证结果 + */ + async validateCode(code: string): Promise { + try { + logger.info(`验证代码安全性,长度: ${code.length} 字符`); + + const response = await this.client.post( + '/api/dc/validate', + { code } as ValidateCodeRequest + ); + + logger.info( + `代码验证完成: valid=${response.data.valid}, ` + + `errors=${response.data.errors.length}, warnings=${response.data.warnings.length}` + ); + + return response.data; + } catch (error: any) { + logger.error(`代码验证失败: ${error.message}`); + + if (axios.isAxiosError(error)) { + if (error.response) { + throw new Error(`验证失败 (${error.response.status}): ${error.response.data?.detail || error.message}`); + } else if (error.code === 'ECONNREFUSED') { + throw new Error('无法连接到Python微服务,请确保服务已启动'); + } else if (error.code === 'ECONNABORTED') { + throw new Error('代码验证超时'); + } + } + + throw new Error(`代码验证失败: ${error.message}`); + } + } + + /** + * 执行Pandas代码 + * + * @param data - JSON格式的数据(数组对象) + * @param code - Pandas代码(操作df变量) + * @returns 执行结果 + */ + async executeCode( + data: Record[], + code: string + ): Promise { + try { + logger.info( + `执行Pandas代码: 数据行数=${data.length}, 代码长度=${code.length} 字符` + ); + + const response = await this.client.post( + '/api/dc/execute', + { data, code } as ExecuteCodeRequest, + { timeout: DEFAULT_TIMEOUT } // 执行可能较慢,使用完整超时 + ); + + if (response.data.success) { + logger.info( + `代码执行成功: ` + + `结果shape=${JSON.stringify(response.data.result_shape)}, ` + + `耗时=${response.data.execution_time.toFixed(3)}秒` + ); + } else { + logger.warn(`代码执行失败: ${response.data.error}`); + } + + return response.data; + } catch (error: any) { + logger.error(`代码执行失败: ${error.message}`); + + if (axios.isAxiosError(error)) { + if (error.response) { + throw new Error(`执行失败 (${error.response.status}): ${error.response.data?.detail || error.message}`); + } else if (error.code === 'ECONNREFUSED') { + throw new Error('无法连接到Python微服务,请确保服务已启动'); + } else if (error.code === 'ECONNABORTED') { + throw new Error('代码执行超时(>30秒)'); + } + } + + throw new Error(`代码执行失败: ${error.message}`); + } + } + + /** + * 健康检查(测试Python服务连接) + * + * @returns 服务是否正常 + */ + async healthCheck(): Promise { + try { + const response = await this.client.get('/api/health', { timeout: 5000 }); + const isHealthy = response.status === 200 && response.data.status === 'healthy'; + + logger.info(`Python服务健康检查: ${isHealthy ? '正常' : '异常'}`); + + return isHealthy; + } catch (error: any) { + logger.error(`Python服务健康检查失败: ${error.message}`); + return false; + } + } +} + +// ==================== 导出单例实例 ==================== + +export const pythonExecutorService = new PythonExecutorService(); + diff --git a/backend/src/modules/dc/tool-c/services/SessionService.ts b/backend/src/modules/dc/tool-c/services/SessionService.ts new file mode 100644 index 00000000..9386e15d --- /dev/null +++ b/backend/src/modules/dc/tool-c/services/SessionService.ts @@ -0,0 +1,382 @@ +/** + * Session管理服务 + * + * 功能: + * - 创建Session(上传Excel到OSS) + * - 获取Session信息 + * - 获取预览/完整数据(从OSS) + * - 删除Session + * - 更新心跳 + * + * @module SessionService + */ + +import { storage } from '../../../../common/storage/index.js'; +import { logger } from '../../../../common/logging/index.js'; +import { prisma } from '../../../../config/database.js'; +import * as xlsx from 'xlsx'; + +// ==================== 类型定义 ==================== + +interface SessionData { + id: string; + userId: string; + fileName: string; + fileKey: string; + totalRows: number; + totalCols: number; + columns: string[]; + encoding: string | null; + fileSize: number; + createdAt: Date; + updatedAt: Date; + expiresAt: Date; +} + +interface PreviewDataResponse extends SessionData { + previewData: any[]; +} + +// ==================== 配置常量 ==================== + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const SESSION_EXPIRE_MINUTES = 10; // Session过期时间:10分钟 +const PREVIEW_ROWS = 100; // 预览行数 + +// ==================== Session管理服务 ==================== + +export class SessionService { + /** + * 创建Session + * + * @param userId - 用户ID + * @param fileName - 原始文件名 + * @param fileBuffer - 文件Buffer + * @returns Session信息 + */ + async createSession( + userId: string, + fileName: string, + fileBuffer: Buffer + ): Promise { + try { + logger.info(`[SessionService] 创建Session: userId=${userId}, fileName=${fileName}`); + + // 1. 验证文件大小 + if (fileBuffer.length > MAX_FILE_SIZE) { + throw new Error(`文件大小超过限制(最大10MB),当前: ${(fileBuffer.length / 1024 / 1024).toFixed(2)}MB`); + } + + // 2. 内存解析Excel(不落盘,符合云原生规范) + logger.info('[SessionService] 解析Excel文件...'); + let workbook: xlsx.WorkBook; + try { + workbook = xlsx.read(fileBuffer, { type: 'buffer' }); + } catch (error: any) { + throw new Error(`Excel文件解析失败: ${error.message}`); + } + + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { + throw new Error('Excel文件中没有工作表'); + } + + const sheet = workbook.Sheets[sheetName]; + const data = xlsx.utils.sheet_to_json(sheet); + + if (data.length === 0) { + throw new Error('Excel文件没有数据'); + } + + // 3. 提取元数据 + const totalRows = data.length; + const totalCols = Object.keys(data[0] || {}).length; + const columns = Object.keys(data[0] || {}); + + logger.info(`[SessionService] 解析完成: ${totalRows}行 x ${totalCols}列`); + + // 4. 上传到OSS(使用平台storage服务) + const timestamp = Date.now(); + const fileKey = `dc/tool-c/sessions/${userId}/${timestamp}-${fileName}`; + + logger.info(`[SessionService] 上传到OSS: ${fileKey}`); + await storage.upload(fileKey, fileBuffer); + logger.info('[SessionService] OSS上传成功'); + + // 5. 保存Session到数据库(只存元数据,符合云原生规范) + const expiresAt = new Date(Date.now() + SESSION_EXPIRE_MINUTES * 60 * 1000); + + const session = await prisma.dcToolCSession.create({ + data: { + userId, + fileName, + fileKey, + totalRows, + totalCols, + columns: columns, // Prisma会自动转换为JSONB + encoding: 'utf-8', // 默认utf-8,后续可扩展检测 + fileSize: fileBuffer.length, + expiresAt, + }, + }); + + logger.info(`[SessionService] Session创建成功: ${session.id}`); + + return this.formatSession(session); + } catch (error: any) { + logger.error(`[SessionService] 创建Session失败: ${error.message}`, { error }); + throw error; + } + } + + /** + * 获取Session信息(只含元数据) + * + * @param sessionId - Session ID + * @returns Session信息 + */ + async getSession(sessionId: string): Promise { + try { + logger.info(`[SessionService] 获取Session: ${sessionId}`); + + const session = await prisma.dcToolCSession.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + throw new Error('Session不存在'); + } + + // 检查是否过期 + if (new Date() > session.expiresAt) { + logger.warn(`[SessionService] Session已过期: ${sessionId}`); + throw new Error('Session已过期,请重新上传文件'); + } + + logger.info(`[SessionService] Session获取成功: ${sessionId}`); + + return this.formatSession(session); + } catch (error: any) { + logger.error(`[SessionService] 获取Session失败: ${error.message}`, { sessionId }); + throw error; + } + } + + /** + * 获取预览数据(前100行) + * + * @param sessionId - Session ID + * @returns Session信息 + 预览数据 + */ + async getPreviewData(sessionId: string): Promise { + try { + logger.info(`[SessionService] 获取预览数据: ${sessionId}`); + + // 1. 获取Session信息 + const session = await this.getSession(sessionId); + + // 2. 从OSS下载文件到内存 + logger.info(`[SessionService] 从OSS下载文件: ${session.fileKey}`); + const buffer = await storage.download(session.fileKey); + + // 3. 内存解析Excel(不落盘) + const workbook = xlsx.read(buffer, { type: 'buffer' }); + const sheetName = workbook.SheetNames[0]; + const sheet = workbook.Sheets[sheetName]; + const data = xlsx.utils.sheet_to_json(sheet); + + // 4. 返回前100行 + const previewData = data.slice(0, PREVIEW_ROWS); + + logger.info(`[SessionService] 预览数据获取成功: ${previewData.length}行`); + + return { + ...session, + previewData, + }; + } catch (error: any) { + logger.error(`[SessionService] 获取预览数据失败: ${error.message}`, { sessionId }); + throw error; + } + } + + /** + * 获取完整数据 + * + * @param sessionId - Session ID + * @returns 完整数据数组 + */ + async getFullData(sessionId: string): Promise { + try { + logger.info(`[SessionService] 获取完整数据: ${sessionId}`); + + // 1. 获取Session信息 + const session = await this.getSession(sessionId); + + // 2. 从OSS下载文件到内存 + logger.info(`[SessionService] 从OSS下载文件: ${session.fileKey}`); + const buffer = await storage.download(session.fileKey); + + // 3. 内存解析Excel + const workbook = xlsx.read(buffer, { type: 'buffer' }); + const sheetName = workbook.SheetNames[0]; + const sheet = workbook.Sheets[sheetName]; + const data = xlsx.utils.sheet_to_json(sheet); + + logger.info(`[SessionService] 完整数据获取成功: ${data.length}行`); + + return data; + } catch (error: any) { + logger.error(`[SessionService] 获取完整数据失败: ${error.message}`, { sessionId }); + throw error; + } + } + + /** + * 删除Session + * + * @param sessionId - Session ID + */ + async deleteSession(sessionId: string): Promise { + try { + logger.info(`[SessionService] 删除Session: ${sessionId}`); + + // 1. 获取Session信息 + const session = await prisma.dcToolCSession.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + logger.warn(`[SessionService] Session不存在: ${sessionId}`); + return; + } + + // 2. 删除OSS文件 + try { + logger.info(`[SessionService] 删除OSS文件: ${session.fileKey}`); + await storage.delete(session.fileKey); + logger.info('[SessionService] OSS文件删除成功'); + } catch (error: any) { + logger.warn(`[SessionService] OSS文件删除失败: ${error.message}`); + // 继续执行,删除数据库记录 + } + + // 3. 删除数据库记录 + await prisma.dcToolCSession.delete({ + where: { id: sessionId }, + }); + + logger.info(`[SessionService] Session删除成功: ${sessionId}`); + } catch (error: any) { + logger.error(`[SessionService] 删除Session失败: ${error.message}`, { sessionId }); + throw error; + } + } + + /** + * 更新心跳(延长过期时间) + * + * @param sessionId - Session ID + * @returns 新的过期时间 + */ + async updateHeartbeat(sessionId: string): Promise { + try { + logger.info(`[SessionService] 更新心跳: ${sessionId}`); + + // 检查Session是否存在 + const session = await prisma.dcToolCSession.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + throw new Error('Session不存在'); + } + + // 更新过期时间 + const newExpiresAt = new Date(Date.now() + SESSION_EXPIRE_MINUTES * 60 * 1000); + + await prisma.dcToolCSession.update({ + where: { id: sessionId }, + data: { + expiresAt: newExpiresAt, + updatedAt: new Date(), + }, + }); + + logger.info(`[SessionService] 心跳更新成功: ${sessionId}, 新过期时间: ${newExpiresAt.toISOString()}`); + + return newExpiresAt; + } catch (error: any) { + logger.error(`[SessionService] 更新心跳失败: ${error.message}`, { sessionId }); + throw error; + } + } + + /** + * 清理过期Session(定时任务使用) + * + * @returns 清理的Session数量 + */ + async cleanExpiredSessions(): Promise { + try { + logger.info('[SessionService] 开始清理过期Session...'); + + // 查询所有过期的Session + const expiredSessions = await prisma.dcToolCSession.findMany({ + where: { + expiresAt: { + lt: new Date(), + }, + }, + }); + + logger.info(`[SessionService] 发现${expiredSessions.length}个过期Session`); + + // 删除过期Session + let cleanedCount = 0; + for (const session of expiredSessions) { + try { + await this.deleteSession(session.id); + cleanedCount++; + } catch (error: any) { + logger.warn(`[SessionService] 清理Session失败: ${session.id}`, { error }); + } + } + + logger.info(`[SessionService] 清理完成: ${cleanedCount}/${expiredSessions.length}个`); + + return cleanedCount; + } catch (error: any) { + logger.error(`[SessionService] 清理过期Session失败: ${error.message}`, { error }); + return 0; + } + } + + /** + * 格式化Session数据 + * + * @param session - Prisma Session对象 + * @returns 格式化的Session数据 + */ + private formatSession(session: any): SessionData { + return { + id: session.id, + userId: session.userId, + fileName: session.fileName, + fileKey: session.fileKey, + totalRows: session.totalRows, + totalCols: session.totalCols, + columns: session.columns as string[], + encoding: session.encoding, + fileSize: session.fileSize, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + expiresAt: session.expiresAt, + }; + } +} + +// ==================== 导出单例实例 ==================== + +export const sessionService = new SessionService(); + diff --git a/backend/test-tool-c-day2.mjs b/backend/test-tool-c-day2.mjs new file mode 100644 index 00000000..941157f9 --- /dev/null +++ b/backend/test-tool-c-day2.mjs @@ -0,0 +1,382 @@ +/** + * Tool C Day 2 API测试脚本 + * + * 测试内容: + * 1. 创建测试Excel文件 + * 2. 上传文件创建Session + * 3. 获取Session信息 + * 4. 获取预览数据 + * 5. 获取完整数据 + * 6. 更新心跳 + * 7. 删除Session + * + * 执行方式:node test-tool-c-day2.mjs + */ + +import FormData from 'form-data'; +import axios from 'axios'; +import * as XLSX from 'xlsx'; +import { Buffer } from 'buffer'; + +const BASE_URL = 'http://localhost:3000'; +const API_PREFIX = '/api/v1/dc/tool-c'; + +// ==================== 辅助函数 ==================== + +function printSection(title) { + console.log('\n' + '='.repeat(70)); + console.log(` ${title}`); + console.log('='.repeat(70) + '\n'); +} + +function printSuccess(message) { + console.log('✅ ' + message); +} + +function printError(message) { + console.log('❌ ' + message); +} + +function printInfo(message) { + console.log('ℹ️ ' + message); +} + +// ==================== 创建测试Excel文件 ==================== + +function createTestExcelFile() { + printSection('创建测试Excel文件'); + + // 创建医疗数据 + const testData = [ + { patient_id: 'P001', name: '张三', age: 25, gender: '男', diagnosis: '感冒', sbp: 120, dbp: 80 }, + { patient_id: 'P002', name: '李四', age: 65, gender: '女', diagnosis: '高血压', sbp: 150, dbp: 95 }, + { patient_id: 'P003', name: '王五', age: 45, gender: '男', diagnosis: '糖尿病', sbp: 135, dbp: 85 }, + { patient_id: 'P004', name: '赵六', age: 70, gender: '女', diagnosis: '冠心病', sbp: 160, dbp: 100 }, + { patient_id: 'P005', name: '钱七', age: 35, gender: '男', diagnosis: '胃炎', sbp: 110, dbp: 70 }, + { patient_id: 'P006', name: '孙八', age: 55, gender: '女', diagnosis: '肺炎', sbp: 125, dbp: 82 }, + { patient_id: 'P007', name: '周九', age: 48, gender: '男', diagnosis: '肝炎', sbp: 130, dbp: 88 }, + { patient_id: 'P008', name: '吴十', age: 62, gender: '女', diagnosis: '关节炎', sbp: 145, dbp: 92 }, + ]; + + // 创建工作簿 + const ws = XLSX.utils.json_to_sheet(testData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1'); + + // 生成Buffer + const excelBuffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); + + printSuccess(`测试文件创建成功: ${testData.length}行 x ${Object.keys(testData[0]).length}列`); + printInfo(`文件大小: ${(excelBuffer.length / 1024).toFixed(2)} KB`); + + return { + buffer: excelBuffer, + fileName: 'test-medical-data.xlsx', + expectedRows: testData.length, + expectedCols: Object.keys(testData[0]).length, + }; +} + +// ==================== API测试函数 ==================== + +async function testUploadFile(excelData) { + printSection('测试1: 上传Excel文件创建Session'); + + try { + const form = new FormData(); + form.append('file', excelData.buffer, { + filename: excelData.fileName, + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + printInfo(`上传文件: ${excelData.fileName}`); + + const response = await axios.post( + `${BASE_URL}${API_PREFIX}/sessions/upload`, + form, + { + headers: form.getHeaders(), + timeout: 10000, + } + ); + + if (response.status === 201 && response.data.success) { + printSuccess('文件上传成功'); + console.log('响应数据:', JSON.stringify(response.data.data, null, 2)); + + const { sessionId, totalRows, totalCols, columns } = response.data.data; + + // 验证数据 + if (totalRows === excelData.expectedRows && totalCols === excelData.expectedCols) { + printSuccess(`数据验证通过: ${totalRows}行 x ${totalCols}列`); + } else { + printError(`数据不匹配: 预期${excelData.expectedRows}行x${excelData.expectedCols}列, 实际${totalRows}行x${totalCols}列`); + } + + printInfo(`Session ID: ${sessionId}`); + printInfo(`列名: ${columns.join(', ')}`); + + return sessionId; + } else { + printError('上传失败: ' + JSON.stringify(response.data)); + return null; + } + } catch (error) { + printError('上传异常: ' + (error.response?.data?.error || error.message)); + if (error.response) { + console.log('错误详情:', JSON.stringify(error.response.data, null, 2)); + } + return null; + } +} + +async function testGetSession(sessionId) { + printSection('测试2: 获取Session信息'); + + try { + printInfo(`Session ID: ${sessionId}`); + + const response = await axios.get( + `${BASE_URL}${API_PREFIX}/sessions/${sessionId}`, + { timeout: 5000 } + ); + + if (response.status === 200 && response.data.success) { + printSuccess('Session信息获取成功'); + console.log('Session信息:', JSON.stringify(response.data.data, null, 2)); + return true; + } else { + printError('获取失败'); + return false; + } + } catch (error) { + printError('获取异常: ' + (error.response?.data?.error || error.message)); + return false; + } +} + +async function testGetPreviewData(sessionId) { + printSection('测试3: 获取预览数据(前100行)'); + + try { + printInfo(`Session ID: ${sessionId}`); + + const response = await axios.get( + `${BASE_URL}${API_PREFIX}/sessions/${sessionId}/preview`, + { timeout: 10000 } + ); + + if (response.status === 200 && response.data.success) { + printSuccess('预览数据获取成功'); + + const { totalRows, previewRows, previewData } = response.data.data; + printInfo(`总行数: ${totalRows}, 预览行数: ${previewRows}`); + printInfo(`预览数据前3行:`); + console.log(JSON.stringify(previewData.slice(0, 3), null, 2)); + + return true; + } else { + printError('获取预览数据失败'); + return false; + } + } catch (error) { + printError('获取预览数据异常: ' + (error.response?.data?.error || error.message)); + return false; + } +} + +async function testGetFullData(sessionId) { + printSection('测试4: 获取完整数据'); + + try { + printInfo(`Session ID: ${sessionId}`); + + const response = await axios.get( + `${BASE_URL}${API_PREFIX}/sessions/${sessionId}/full`, + { timeout: 10000 } + ); + + if (response.status === 200 && response.data.success) { + printSuccess('完整数据获取成功'); + + const { totalRows, data } = response.data.data; + printInfo(`总行数: ${totalRows}`); + printInfo(`完整数据前2行:`); + console.log(JSON.stringify(data.slice(0, 2), null, 2)); + + return true; + } else { + printError('获取完整数据失败'); + return false; + } + } catch (error) { + printError('获取完整数据异常: ' + (error.response?.data?.error || error.message)); + return false; + } +} + +async function testUpdateHeartbeat(sessionId) { + printSection('测试5: 更新心跳'); + + try { + printInfo(`Session ID: ${sessionId}`); + + const response = await axios.post( + `${BASE_URL}${API_PREFIX}/sessions/${sessionId}/heartbeat`, + {}, + { timeout: 5000 } + ); + + if (response.status === 200 && response.data.success) { + printSuccess('心跳更新成功'); + console.log('新过期时间:', response.data.data.expiresAt); + return true; + } else { + printError('心跳更新失败'); + return false; + } + } catch (error) { + printError('心跳更新异常: ' + (error.response?.data?.error || error.message)); + return false; + } +} + +async function testDeleteSession(sessionId) { + printSection('测试6: 删除Session'); + + try { + printInfo(`Session ID: ${sessionId}`); + + const response = await axios.delete( + `${BASE_URL}${API_PREFIX}/sessions/${sessionId}`, + { timeout: 5000 } + ); + + if (response.status === 200 && response.data.success) { + printSuccess('Session删除成功'); + return true; + } else { + printError('Session删除失败'); + return false; + } + } catch (error) { + printError('Session删除异常: ' + (error.response?.data?.error || error.message)); + return false; + } +} + +async function testGetDeletedSession(sessionId) { + printSection('测试7: 验证Session已删除'); + + try { + printInfo(`尝试获取已删除的Session: ${sessionId}`); + + const response = await axios.get( + `${BASE_URL}${API_PREFIX}/sessions/${sessionId}`, + { timeout: 5000 } + ); + + printError('Session仍然存在(不应该)'); + return false; + } catch (error) { + if (error.response?.status === 404) { + printSuccess('Session已正确删除(返回404)'); + return true; + } else { + printError('未预期的错误: ' + error.message); + return false; + } + } +} + +// ==================== 主测试函数 ==================== + +async function runAllTests() { + console.log('\n' + '🚀'.repeat(35)); + console.log(' Tool C Day 2 API测试'); + console.log('🚀'.repeat(35)); + + const results = {}; + + try { + // 创建测试文件 + const excelData = createTestExcelFile(); + + // 测试1: 上传文件 + const sessionId = await testUploadFile(excelData); + results['上传文件'] = !!sessionId; + + if (!sessionId) { + printError('上传失败,后续测试无法继续'); + return results; + } + + // 等待1秒 + await new Promise(resolve => setTimeout(resolve, 1000)); + + // 测试2: 获取Session信息 + results['获取Session'] = await testGetSession(sessionId); + await new Promise(resolve => setTimeout(resolve, 500)); + + // 测试3: 获取预览数据 + results['获取预览数据'] = await testGetPreviewData(sessionId); + await new Promise(resolve => setTimeout(resolve, 500)); + + // 测试4: 获取完整数据 + results['获取完整数据'] = await testGetFullData(sessionId); + await new Promise(resolve => setTimeout(resolve, 500)); + + // 测试5: 更新心跳 + results['更新心跳'] = await testUpdateHeartbeat(sessionId); + await new Promise(resolve => setTimeout(resolve, 500)); + + // 测试6: 删除Session + results['删除Session'] = await testDeleteSession(sessionId); + await new Promise(resolve => setTimeout(resolve, 500)); + + // 测试7: 验证删除 + results['验证删除'] = await testGetDeletedSession(sessionId); + + } catch (error) { + printError('测试过程中发生异常: ' + error.message); + console.error(error); + } + + // 汇总结果 + printSection('测试结果汇总'); + + let passed = 0; + let total = 0; + + for (const [testName, result] of Object.entries(results)) { + total++; + if (result) { + passed++; + console.log(`${testName.padEnd(20)}: ✅ 通过`); + } else { + console.log(`${testName.padEnd(20)}: ❌ 失败`); + } + } + + console.log('\n' + '-'.repeat(70)); + console.log(`总计: ${passed}/${total} 通过 (${((passed/total)*100).toFixed(1)}%)`); + console.log('-'.repeat(70)); + + if (passed === total) { + console.log('\n🎉 所有测试通过!Day 2 Session管理功能完成!\n'); + } else { + console.log(`\n⚠️ 有 ${total - passed} 个测试失败,请检查\n`); + } +} + +// 执行测试 +runAllTests() + .then(() => { + console.log('测试完成'); + process.exit(0); + }) + .catch((error) => { + console.error('测试失败:', error); + process.exit(1); + }); + diff --git a/docs/03-业务模块/DC-数据清洗整理/00-工具C当前状态与开发指南.md b/docs/03-业务模块/DC-数据清洗整理/00-工具C当前状态与开发指南.md new file mode 100644 index 00000000..d544fca4 --- /dev/null +++ b/docs/03-业务模块/DC-数据清洗整理/00-工具C当前状态与开发指南.md @@ -0,0 +1,765 @@ +# 工具C(Tool C)- 科研数据编辑器 - 当前状态与开发指南 + +> **最后更新**: 2025-12-06 +> **当前版本**: Day 2 MVP开发完成 +> **开发进度**: Python微服务 ✅ | Session管理 ✅ | AI代码生成 ⏸️ | 前端开发 ⏸️ + +--- + +## 📊 模块整体进度 + +| 组件 | 进度 | 代码行数 | 状态 | +|------|------|---------|------| +| **Python微服务** | 100% | ~450行 | ✅ Day 1完成 | +| **Node.js后端** | 40% | ~1700行 | 🟡 Day 2完成 | +| **前端界面** | 0% | 0行 | ⏸️ 未开始 | +| **数据库Schema** | 100% | 1表 | ✅ Day 2完成 | +| **总体进度** | **25%** | **~2150行** | 🟡 **MVP阶段** | + +--- + +## ✅ 已完成功能(Day 1-2) + +### Day 1: Python微服务扩展 ✅ + +--- + +### 1. Python微服务扩展 + +#### 文件结构 +``` +extraction_service/ +├── services/ +│ └── dc_executor.py # 427行 ✅ 新增 +├── main.py # 617行(新增2个端点)✅ +├── test_module.py # 27行(测试脚本)✅ +├── quick_test.py # 64行(快速测试)✅ +└── test_execute_simple.py # 51行(简单测试)✅ +``` + +#### 核心功能 + +**1.1 AST静态代码检查** ✅ +- **模块**: `dc_executor.py::validate_code()` +- **功能**: + - 解析Python代码的抽象语法树(AST) + - 检测危险模块导入(os, sys, subprocess等) + - 检测危险函数调用(eval, exec, open等) + - 检测是否操作df变量 +- **安全黑名单**: + ```python + DANGEROUS_MODULES = { + 'os', 'sys', 'subprocess', 'shutil', 'glob', + 'socket', 'urllib', 'requests', 'http', + 'pickle', 'shelve', 'dbm', + 'importlib', '__import__', + 'eval', 'exec', 'compile', + 'open', 'input', 'file', + } + + DANGEROUS_BUILTINS = { + 'eval', 'exec', 'compile', '__import__', + 'open', 'input', 'file', + 'getattr', 'setattr', 'delattr', + 'globals', 'locals', 'vars', + } + ``` + +**1.2 Pandas代码沙箱执行** ✅ +- **模块**: `dc_executor.py::execute_pandas_code()` +- **功能**: + - 创建安全的执行环境(限制可用函数) + - 执行Pandas数据处理代码 + - 捕获print输出 + - 30秒超时保护 + - 返回执行结果和元数据 +- **安全措施**: + - 限制可用内置函数(仅允许安全函数如len, range等) + - 禁止文件操作 + - 禁止网络访问 + - 禁止系统调用 + - 超时自动终止(Unix系统) + +**1.3 FastAPI端点** ✅ +- **端点1**: `POST /api/dc/validate` + - 功能:代码安全验证 + - 请求:`{"code": "..."}` + - 响应:`{"valid": bool, "errors": [], "warnings": []}` + +- **端点2**: `POST /api/dc/execute` + - 功能:Pandas代码执行 + - 请求:`{"data": [...], "code": "..."}` + - 响应:`{"success": bool, "result_data": [...], "output": "", ...}` + +#### 测试验证结果 + +✅ **测试1:正常代码执行** +```python +# 输入 +data = [{"age": 25}, {"age": 65}, {"age": 45}] +code = "df['old'] = df['age'] > 60" + +# 输出 +{ + "success": true, + "result_data": [ + {"age": 25, "old": false}, + {"age": 65, "old": true}, + {"age": 45, "old": false} + ], + "execution_time": 0.004, + "result_shape": [3, 2] +} +``` + +✅ **测试2:危险代码拦截** +```python +# 输入 +code = "import os" + +# 输出 +{ + "valid": false, + "errors": ["🚫 禁止导入危险模块: os (行 1)"], + "warnings": ["⚠️ 代码中未使用 df 变量,可能无法操作数据"] +} +``` + +✅ **测试3:医疗数据清洗** +```python +# 输入 +data = [ + {"patient_id": "P001", "age": 25, "sbp": 120, "dbp": 80}, + {"patient_id": "P002", "age": 65, "sbp": 150, "dbp": 95}, + {"patient_id": "P003", "age": None, "sbp": 160, "dbp": 100} +] +code = """ +import numpy as np +df['age'] = df['age'].apply(lambda x: np.nan if x is None or x > 120 else x) +df['hypertension'] = df.apply( + lambda row: '高血压' if row['sbp'] >= 140 or row['dbp'] >= 90 else '正常', + axis=1 +) +""" + +# 输出 +{ + "success": true, + "result_data": [ + {"patient_id": "P001", "age": 25, "sbp": 120, "dbp": 80, "hypertension": "正常"}, + {"patient_id": "P002", "age": 65, "sbp": 150, "dbp": 95, "hypertension": "高血压"}, + {"patient_id": "P003", "age": null, "sbp": 160, "dbp": 100, "hypertension": "高血压"} + ], + "execution_time": 0.008 +} +``` + +--- + +### Day 2: Session管理 + 数据处理 ✅ + +#### 文件结构(新增) +``` +backend/src/modules/dc/tool-c/ +├── services/ +│ ├── PythonExecutorService.ts # 177行 ✅ Day 1 +│ ├── SessionService.ts # 383行 ✅ Day 2 新增 +│ └── DataProcessService.ts # 303行 ✅ Day 2 新增 +├── controllers/ +│ ├── TestController.ts # 131行 ✅ Day 1 +│ └── SessionController.ts # 300行 ✅ Day 2 新增 +└── routes/ + └── index.ts # 62行 ✅ Day 2 更新 +``` + +#### 核心功能 + +**2.1 SessionService** ✅ +- **功能**: Session生命周期管理 +- **方法**: + ```typescript + class SessionService { + createSession(userId, fileName, buffer): Promise + getSession(sessionId): Promise + getPreviewData(sessionId): Promise + getFullData(sessionId): Promise + deleteSession(sessionId): Promise + updateHeartbeat(sessionId): Promise + cleanExpiredSessions(): Promise + } + ``` +- **特性**: + - ✅ 零落盘:Excel内存解析,直接上传OSS + - ✅ 10分钟过期机制 + - ✅ 心跳延长功能 + - ✅ 自动清理过期Session + - ✅ 完整的错误处理 + +**2.2 DataProcessService** ✅ +- **功能**: Excel文件解析和验证 +- **方法**: + ```typescript + class DataProcessService { + parseExcel(buffer): ParsedExcelData + validateFile(buffer, fileName): ValidationResult + inferColumnTypes(data): ColumnType[] + formatFileSize(bytes): string + } + ``` +- **特性**: + - ✅ 内存解析(零落盘) + - ✅ 10MB文件大小限制 + - ✅ 支持.xlsx, .xls, .csv + - ✅ 列类型推断(可选) + +**2.3 SessionController** ✅ +- **功能**: Session管理API端点 +- **端点**: + - `POST /sessions/upload` - 上传Excel创建Session ✅ + - `GET /sessions/:id` - 获取Session信息 ✅ + - `GET /sessions/:id/preview` - 获取预览数据(前100行)✅ + - `GET /sessions/:id/full` - 获取完整数据 ✅ + - `DELETE /sessions/:id` - 删除Session ✅ + - `POST /sessions/:id/heartbeat` - 更新心跳 ✅ + +**2.4 数据库表** ✅ +- **表名**: `dc_schema.dc_tool_c_sessions` +- **字段**: 12个(id, user_id, file_name, file_key, total_rows, total_cols, columns, encoding, file_size, created_at, updated_at, expires_at) +- **索引**: 3个(主键 + user_id + expires_at) +- **迁移脚本**: `backend/scripts/create-tool-c-table.mjs` + +#### API测试结果(Day 2) + +✅ **测试数据**: +``` +文件名: test-medical-data.xlsx +数据: 8行 x 7列医疗数据 +列名: patient_id, name, age, gender, diagnosis, sbp, dbp +文件大小: 17.42 KB +``` + +✅ **测试结果**: +| 测试项 | 状态 | 说明 | +|--------|------|------| +| 上传文件创建Session | ✅ | 返回201,Session创建成功 | +| 获取Session信息 | ✅ | 元数据正确返回 | +| 获取预览数据(前100行)| ✅ | 8行数据全部返回 | +| 获取完整数据 | ✅ | 从OSS读取成功 | +| 更新心跳 | ✅ | 过期时间延长10分钟 | +| 删除Session | ✅ | OSS+DB清理成功 | +| 验证删除 | ✅ | 返回404确认删除 | +| **总计** | **7/7 (100%)** | **所有测试通过** | + +✅ **云原生规范检查**: +- ✅ 使用 `storage` 服务(零落盘) +- ✅ 使用 `logger` 服务(结构化日志) +- ✅ 使用 `prisma` 全局实例 +- ✅ Excel内存解析,无本地文件存储 +- ✅ 无硬编码配置 + +--- + +### 2. Node.js后端集成(Day 1) + +#### 文件结构 +``` +backend/src/modules/dc/tool-c/ +├── services/ +│ └── PythonExecutorService.ts # 177行 ✅ 新增 +├── controllers/ +│ └── TestController.ts # 131行 ✅ 新增 +├── routes/ +│ └── index.ts # 29行 ✅ 新增 +└── README.md # 172行(技术文档)✅ +``` + +#### 核心服务 + +**2.1 PythonExecutorService** ✅ +- **功能**: 封装Python微服务HTTP调用 +- **方法**: + ```typescript + class PythonExecutorService { + validateCode(code: string): Promise + executeCode(data: any[], code: string): Promise + healthCheck(): Promise + } + ``` +- **特性**: + - 完整的错误处理和重试机制 + - 30秒超时控制 + - 连接状态检测 + - 详细的日志记录 + +**2.2 TestController** ✅ +- **功能**: Day 1测试端点控制器 +- **端点**: + - `GET /test/health` - 测试Python服务健康 + - `POST /test/validate` - 测试代码验证 + - `POST /test/execute` - 测试代码执行 + +**2.3 路由注册** ✅ +- **前缀**: `/api/v1/dc/tool-c` +- **状态**: 已在 `dc/index.ts` 中注册 +- **可访问**: ✅ 服务启动后即可调用 + +--- + +## ⏸️ 待开发功能(Day 2-15) + +### Week 1: 基础架构(Day 2-5) + +#### Day 2: 数据库 + Session管理 ✅ + +**数据库Schema**(已创建): +```prisma +// dc_tool_c_sessions 表 ✅ +model DcToolCSession { + id String @id @default(uuid()) + userId String + fileName String + fileKey String // OSS存储key + totalRows Int + totalCols Int + columns Json // 列名数组 + encoding String? // 编码格式 + fileSize Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + expiresAt DateTime // 过期时间 + + @@index([userId]) + @@index([expiresAt]) + @@map("dc_tool_c_sessions") + @@schema("dc_schema") +} +``` + +**已完成服务**: +- [x] `SessionService.ts` - Session管理(383行)✅ + - [x] `createSession()` - 创建会话(上传Excel到OSS) + - [x] `getSession()` - 获取会话元数据 + - [x] `getPreviewData()` - 获取预览数据(前100行) + - [x] `getFullData()` - 获取完整数据(从OSS) + - [x] `deleteSession()` - 删除会话(OSS+DB) + - [x] `updateHeartbeat()` - 更新心跳(延长10分钟) + - [x] `cleanExpiredSessions()` - 清理过期Session +- [x] `DataProcessService.ts` - 数据处理(303行)✅ + - [x] Excel文件解析(xlsx库,内存解析) + - [x] 文件验证(大小、格式、内容) + - [x] 列类型推断(可选) + - [x] 文件大小限制(10MB) +- [x] `SessionController.ts` - Session控制器(300行)✅ + - [x] 6个API端点全部实现 + +#### Day 3: AI代码生成服务 ⏸️ + +**待开发服务**: +- [ ] `AICodeService.ts` - AI代码生成 + - [ ] 集成LLMFactory + - [ ] System Prompt设计(含10个Few-shot示例) + - [ ] 代码生成和自我修正 + - [ ] 上下文管理(Session元数据) + +**System Prompt要点**: +```typescript +const systemPrompt = ` +你是一个医疗科研数据清洗专家,负责生成Pandas代码。 + +数据集信息: +- 文件名:${fileName} +- 行数:${totalRows} +- 列数:${totalCols} +- 列名:${columns.join(', ')} + +安全规则: +1. 只能操作df变量 +2. 禁止导入os、sys等危险模块 +3. 禁止使用eval、exec等危险函数 +4. 必须进行异常处理 + +Few-shot示例: +[示例1] 标记老年组 +用户: 把年龄大于60的标记为老年组 +代码: df['age_group'] = df['age'].apply(lambda x: '老年' if x > 60 else '非老年') +... +`; +``` + +#### Day 3-5: AI代码生成和控制器 ⏸️ + +**待开发控制器**: +- [ ] `AIController.ts` + - [ ] POST `/ai/chat` - AI对话生成代码 + - [ ] POST `/ai/execute` - 执行AI生成的代码 + - [ ] GET `/ai/history/:sessionId` - 获取对话历史 + +**待开发服务**: +- [ ] `AICodeService.ts` - AI代码生成 + - [ ] 集成LLMFactory + - [ ] System Prompt设计(10个Few-shot) + - [ ] 上下文管理 + - [ ] 自我修正机制 + +### Week 2: 前端开发(Day 6-10) + +**待开发组件**: +- [ ] `ToolCEditor.tsx` - 主编辑器页面 +- [ ] `DataGrid.tsx` - AG Grid数据表格 +- [ ] `AICopilot.tsx` - AI助手侧边栏 +- [ ] `FileUpload.tsx` - 文件上传组件 +- [ ] `CodeBlock.tsx` - 代码显示组件 +- [ ] `ActionCard.tsx` - AI操作卡片 + +### Week 3: 测试优化(Day 11-15) + +**测试任务**: +- [ ] 15个医疗数据清洗场景测试 +- [ ] 性能测试(10MB文件) +- [ ] 并发测试(多用户) +- [ ] 安全测试(代码沙箱) +- [ ] UI/UX测试 + +--- + +## 🗄️ 数据库状态 + +### 当前表结构 + +**dc_schema 中的表**: +```sql +-- Tool B相关表(已存在) +dc_schema.dc_templates -- 预设模板 ✅ +dc_schema.dc_extraction_tasks -- 提取任务 ✅ +dc_schema.dc_extraction_items -- 提取记录 ✅ +dc_schema.dc_health_checks -- 健康检查 ✅ + +-- Tool C相关表 +dc_schema.dc_tool_c_sessions -- ✅ 已创建(Day 2) +dc_schema.dc_tool_c_ai_history -- ⏸️ 待创建(Day 3) +``` + +**创建方式**(已完成): +```bash +# Day 2 已执行 +cd backend +node scripts/create-tool-c-table.mjs # ✅ 成功 +npx prisma generate # ✅ 成功 +``` + +**表结构详情**: +```sql +-- dc_tool_c_sessions 表(12字段,3索引) +CREATE TABLE dc_schema.dc_tool_c_sessions ( + id UUID PRIMARY KEY, + user_id VARCHAR(255), + file_name VARCHAR(500), + file_key VARCHAR(500), -- OSS路径 + total_rows INTEGER, + total_cols INTEGER, + columns JSONB, -- ["age", "gender", ...] + encoding VARCHAR(50), + file_size INTEGER, + created_at TIMESTAMP, + updated_at TIMESTAMP, + expires_at TIMESTAMP -- 10分钟过期 +); +``` + +--- + +## 🔌 API端点清单 + +### Python微服务 (http://localhost:8000) + +| 方法 | 端点 | 功能 | 状态 | 说明 | +|------|------|------|------|------| +| GET | `/api/health` | 健康检查 | ✅ | 检查服务状态 | +| POST | `/api/dc/validate` | 代码验证 | ✅ | AST安全检查 | +| POST | `/api/dc/execute` | 代码执行 | ✅ | Pandas代码执行 | + +### Node.js后端 (http://localhost:3000) + +#### 测试端点(Day 1) +| 方法 | 端点 | 功能 | 状态 | 说明 | +|------|------|------|------|------| +| GET | `/api/v1/dc/tool-c/test/health` | 测试Python服务 | ✅ | Day 1测试用 | +| POST | `/api/v1/dc/tool-c/test/validate` | 测试代码验证 | ✅ | Day 1测试用 | +| POST | `/api/v1/dc/tool-c/test/execute` | 测试代码执行 | ✅ | Day 1测试用 | + +#### Session管理端点(Day 2 已完成)✅ +| 方法 | 端点 | 功能 | 状态 | 测试 | +|------|------|------|------|------| +| POST | `/api/v1/dc/tool-c/sessions/upload` | 上传Excel | ✅ | 201 成功 | +| GET | `/api/v1/dc/tool-c/sessions/:id` | 获取Session | ✅ | 200 成功 | +| GET | `/api/v1/dc/tool-c/sessions/:id/preview` | 获取预览数据 | ✅ | 200 成功 | +| GET | `/api/v1/dc/tool-c/sessions/:id/full` | 获取完整数据 | ✅ | 200 成功 | +| DELETE | `/api/v1/dc/tool-c/sessions/:id` | 删除Session | ✅ | 200 成功 | +| POST | `/api/v1/dc/tool-c/sessions/:id/heartbeat` | 心跳更新 | ✅ | 200 成功 | + +#### AI功能端点(待开发) +| 方法 | 端点 | 功能 | 状态 | 计划 | +|------|------|------|------|------| +| POST | `/api/v1/dc/tool-c/ai/chat` | AI对话生成代码 | ⏸️ | Day 3 | +| POST | `/api/v1/dc/tool-c/ai/execute` | 执行AI代码 | ⏸️ | Day 3 | +| GET | `/api/v1/dc/tool-c/ai/history/:sessionId` | 获取历史 | ⏸️ | Day 3 | + +--- + +## ⚙️ 环境配置 + +### 必需环境变量 + +在 `backend/.env` 中配置: +```bash +# Python微服务地址(必需) +EXTRACTION_SERVICE_URL=http://localhost:8000 + +# OSS存储(Day 2使用) +OSS_REGION=your-region +OSS_BUCKET=your-bucket +OSS_ACCESS_KEY_ID=your-key-id +OSS_ACCESS_KEY_SECRET=your-secret + +# LLM配置(Day 3使用) +LLM_PROVIDER=openai +LLM_API_KEY=your-api-key +LLM_MODEL=gpt-4 +``` + +### 服务启动顺序 + +1. **启动Python微服务** (必需) +```bash +cd extraction_service +.\venv\Scripts\activate +python main.py +# 服务运行在 http://localhost:8000 +``` + +2. **启动Node.js后端** (必需) +```bash +cd backend +npm run dev +# 服务运行在 http://localhost:3000 +``` + +3. **启动前端** (Day 6后) +```bash +cd frontend-v2 +npm run dev +# 服务运行在 http://localhost:5173 +``` + +--- + +## 📂 代码结构 + +### Python微服务 +``` +extraction_service/ +├── services/ +│ ├── dc_executor.py # DC代码执行模块 ✅ +│ ├── pdf_extractor.py # PDF提取 +│ ├── docx_extractor.py # Docx提取 +│ └── txt_extractor.py # Txt提取 +├── main.py # FastAPI主文件 ✅ +├── requirements.txt # Python依赖 +└── venv/ # 虚拟环境 +``` + +### Node.js后端 +``` +backend/src/modules/dc/tool-c/ +├── services/ +│ ├── PythonExecutorService.ts # Python调用服务 ✅ +│ ├── SessionService.ts # Session管理 ⏸️ +│ ├── AICodeService.ts # AI代码生成 ⏸️ +│ └── DataProcessService.ts # 数据处理 ⏸️ +├── controllers/ +│ ├── TestController.ts # 测试控制器 ✅ +│ ├── SessionController.ts # Session控制器 ⏸️ +│ └── AIController.ts # AI控制器 ⏸️ +├── routes/ +│ └── index.ts # 路由定义 ✅ +└── utils/ + └── (待添加) +``` + +### 前端 +``` +frontend-v2/src/modules/dc/tool-c/ +├── pages/ +│ └── ToolCEditor.tsx # 主编辑器页面 ⏸️ +├── components/ +│ ├── DataGrid.tsx # AG Grid表格 ⏸️ +│ ├── AICopilot.tsx # AI助手 ⏸️ +│ ├── FileUpload.tsx # 文件上传 ⏸️ +│ └── CodeBlock.tsx # 代码块 ⏸️ +├── hooks/ +│ ├── useSession.ts # Session Hook ⏸️ +│ └── useAI.ts # AI Hook ⏸️ +└── types/ + └── index.ts # 类型定义 ⏸️ +``` + +--- + +## 🧪 测试清单 + +### Day 1 测试(已完成)✅ + +- [x] Python服务健康检查 +- [x] AST代码验证(正常代码) +- [x] AST代码验证(危险代码拦截) +- [x] Pandas代码执行(简单场景) +- [x] Pandas代码执行(医疗数据清洗) +- [x] Node.js服务集成 +- [x] HTTP通信正常 + +### Day 2-15 测试(待执行)⏸️ + +#### 基础功能测试 +- [ ] Excel文件上传(<10MB) +- [ ] 文件编码检测 +- [ ] Session创建和删除 +- [ ] OSS存储读写 +- [ ] 心跳机制 + +#### AI功能测试 +- [ ] LLM代码生成 +- [ ] 代码自我修正 +- [ ] Few-shot效果验证 +- [ ] 上下文理解准确性 + +#### 15个医疗数据清洗场景 +**基础场景(成功率>90%)**: +- [ ] 场景1:标记老年组(age > 60) +- [ ] 场景2:删除缺失患者ID的行 +- [ ] 场景3:性别编码(男1女0) +- [ ] 场景4:计算BMI +- [ ] 场景5:删除缺失率>50%的列 + +**中等场景(成功率>80%)**: +- [ ] 场景6:血压分类(正常/高血压) +- [ ] 场景7:计算住院天数 +- [ ] 场景8:删除重复患者ID +- [ ] 场景9:清理异常年龄值(>120) +- [ ] 场景10:按性别分组统计 + +**高级场景(成功率>60%)**: +- [ ] 场景11:复杂分组聚合 +- [ ] 场景12:时间序列分析 +- [ ] 场景13:医学规则验证 +- [ ] 场景14:多列联合清洗 +- [ ] 场景15:缺失值智能填充 + +--- + +## 🚀 快速开始 + +### 开发者快速上手 + +1. **启动Python微服务** +```bash +cd extraction_service +.\venv\Scripts\activate +python main.py +``` + +2. **测试Python服务** +```bash +# PowerShell测试 +Invoke-WebRequest -Uri "http://localhost:8000/api/health" +``` + +3. **启动Node.js后端** +```bash +cd backend +npm install # 首次运行 +npm run dev +``` + +4. **测试Node.js集成** +```bash +curl http://localhost:3000/api/v1/dc/tool-c/test/health +``` + +### API调用示例 + +**代码验证**: +```bash +curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/validate \ + -H "Content-Type: application/json" \ + -d '{"code":"df[\"age_group\"] = df[\"age\"] > 60"}' +``` + +**代码执行**: +```bash +curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/execute \ + -H "Content-Type: application/json" \ + -d '{ + "data": [{"age": 25}, {"age": 65}], + "code": "df[\"old\"] = df[\"age\"] > 60" + }' +``` + +--- + +## 📝 开发记录 + +| 日期 | 里程碑 | 详细记录 | +|------|--------|---------| +| 2025-12-06 | Day 1完成 | [2025-12-06_工具C_Day1开发完成总结.md](./06-开发记录/2025-12-06_工具C_Day1开发完成总结.md) | +| 2025-12-06 | Day 2完成 | [2025-12-06_工具C_Day2开发完成总结.md](./06-开发记录/2025-12-06_工具C_Day2开发完成总结.md) | + +--- + +## 🎯 下一步行动 + +### 立即执行(Day 2) +1. [ ] 创建 `dc_tool_c_sessions` 数据库表 +2. [ ] 实现 `SessionService.ts` +3. [ ] 实现 `DataProcessService.ts` +4. [ ] 集成OSS存储服务 +5. [ ] 创建 `SessionController.ts` + +### 本周目标(Week 1) +- [ ] 完成Session管理和数据处理 +- [ ] 完成AI代码生成服务 +- [ ] 完成后端所有API端点 +- [ ] 通过Postman测试所有API + +--- + +## 📚 相关文档 + +- **需求文档**: [PRD:Tool C - 科研数据编辑器 (MVP V1.1).md](./01-需求分析/PRD:Tool%20C%20-%20科研数据编辑器%20(MVP%20V1.1).md) +- **技术设计**: [技术设计文档:工具 C - 科研数据编辑器 (V7 云端沙箱抗风险版).md](./02-技术设计/技术设计文档:工具%20C%20-%20科研数据编辑器%20(V7%20云端沙箱抗风险版).md) +- **开发计划**: [工具C_MVP开发计划_V1.0.md](./04-开发计划/工具C_MVP开发计划_V1.0.md) +- **TODO清单**: [工具C_MVP开发_TODO清单.md](./04-开发计划/工具C_MVP开发_TODO清单.md) +- **UI原型**: [工具C_原型设计V6.html](./03-UI设计/工具C_原型设计V6%20.html) + +--- + +## 🔐 安全说明 + +### 代码执行安全 +- ✅ AST静态检查拦截危险操作 +- ✅ 沙箱环境限制可用函数 +- ✅ 30秒超时保护 +- ✅ 禁止文件和网络操作 +- ⏸️ 资源使用限制(待实现) + +### 数据安全 +- ✅ 10MB文件大小限制 +- ⏸️ OSS加密存储(待实现) +- ⏸️ 10分钟Session过期(待实现) +- ⏸️ 用户隔离(待实现) + +--- + +**维护者**: AI Assistant +**联系方式**: 请查看项目README +**最后更新**: 2025-12-06 + diff --git a/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md new file mode 100644 index 00000000..5982f96a --- /dev/null +++ b/docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md @@ -0,0 +1,600 @@ +# 工具C Day 2 开发完成总结 + +> **日期**: 2025-12-06 +> **开发目标**: Session管理 + 数据处理 +> **开发状态**: ✅ 全部完成 + +--- + +## 📊 完成情况概览 + +| 任务类别 | 完成任务数 | 总任务数 | 完成率 | +|---------|-----------|---------|--------| +| **数据库Schema** | 1 | 1 | 100% | +| **服务层开发** | 2 | 2 | 100% | +| **控制器开发** | 1 | 1 | 100% | +| **路由配置** | 1 | 1 | 100% | +| **API测试** | 7 | 7 | 100% | +| **总计** | **12** | **12** | **100%** ✅ | + +--- + +## ✅ 已完成任务清单 + +### 1. 数据库Schema设计与创建 + +#### 任务1.1: 设计Prisma模型 ✅ +- **文件**: `backend/prisma/schema.prisma` +- **新增模型**: `DcToolCSession` +- **字段数**: 12个 + - id, userId, fileName, fileKey + - totalRows, totalCols, columns, encoding, fileSize + - createdAt, updatedAt, expiresAt + +**关键设计决策**: +- ✅ 符合云原生规范:DB只存元数据,不存大数据 +- ✅ 删除了previewData字段(从OSS实时读取) +- ✅ 添加expiresAt支持10分钟过期 +- ✅ 使用JSONB存储columns数组 + +#### 任务1.2: 创建数据库表 ✅ +- **方式**: Node.js脚本直接执行SQL(避免prisma db push冲突) +- **脚本**: `backend/scripts/create-tool-c-table.mjs` (139行) +- **结果**: + - ✅ 表创建成功 + - ✅ 3个索引创建成功 + - ✅ 表注释添加成功 + - ✅ Prisma Client重新生成 + +**创建的索引**: +1. `dc_tool_c_sessions_pkey` - 主键索引 +2. `idx_dc_tool_c_sessions_user_id` - 用户查询索引 +3. `idx_dc_tool_c_sessions_expires_at` - 过期清理索引 + +--- + +### 2. 服务层开发 + +#### 任务2.1: SessionService实现 ✅ +- **文件**: `backend/src/modules/dc/tool-c/services/SessionService.ts` (383行) +- **功能**: + - ✅ `createSession()` - 创建会话 + - 文件大小验证(<10MB) + - Excel内存解析(零落盘) + - 上传到OSS(platform storage服务) + - 保存元数据到DB + - 返回Session信息 + + - ✅ `getSession()` - 获取会话 + - 从DB查询Session + - 检查是否过期 + - 返回元数据 + + - ✅ `getPreviewData()` - 获取预览数据 + - 从OSS下载文件到内存 + - 内存解析Excel + - 返回前100行 + + - ✅ `getFullData()` - 获取完整数据 + - 从OSS下载完整文件 + - 内存解析 + - 返回所有数据 + + - ✅ `deleteSession()` - 删除会话 + - 删除OSS文件 + - 删除DB记录 + - 错误容错(OSS删除失败不影响DB) + + - ✅ `updateHeartbeat()` - 更新心跳 + - 延长expiresAt 10分钟 + - 更新updatedAt时间戳 + + - ✅ `cleanExpiredSessions()` - 清理过期会话 + - 查询过期Session + - 批量删除 + - 返回清理数量 + +**代码示例**: +```typescript +async createSession(userId: string, fileName: string, fileBuffer: Buffer) { + // 1. 验证文件大小 + if (fileBuffer.length > 10 * 1024 * 1024) { + throw new Error('文件大小超过10MB'); + } + + // 2. 内存解析Excel(零落盘) + const workbook = xlsx.read(fileBuffer, { type: 'buffer' }); + const data = xlsx.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]); + + // 3. 上传到OSS + const fileKey = `dc/tool-c/sessions/${userId}/${Date.now()}-${fileName}`; + await storage.upload(fileKey, fileBuffer); + + // 4. 保存到DB(只存元数据) + const session = await prisma.dcToolCSession.create({ + data: { userId, fileName, fileKey, totalRows, totalCols, columns, ... } + }); + + return session; +} +``` + +#### 任务2.2: DataProcessService实现 ✅ +- **文件**: `backend/src/modules/dc/tool-c/services/DataProcessService.ts` (303行) +- **功能**: + - ✅ `parseExcel()` - 解析Excel文件 + - 内存读取(零落盘) + - 转换为JSON格式 + - 提取行数、列数、列名 + + - ✅ `validateFile()` - 验证文件 + - 文件大小检查(<10MB) + - 文件格式检查(.xlsx, .xls, .csv) + - 内容完整性检查 + - 返回友好错误信息 + + - ✅ `inferColumnTypes()` - 推断列类型(可选) + - 取前10行样本 + - 推断类型:number, string, date, boolean, mixed + - 返回类型信息 + + - ✅ `formatFileSize()` - 格式化文件大小 + - 自动转换单位(B, KB, MB) + + - ✅ `generateFileSummary()` - 生成文件摘要 + - 包含所有元信息 + - 前5行样本数据 + +**代码示例**: +```typescript +parseExcel(buffer: Buffer): ParsedExcelData { + // 内存解析(零落盘) + const workbook = xlsx.read(buffer, { type: 'buffer' }); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const data = xlsx.utils.sheet_to_json(sheet); + + return { + data, + columns: Object.keys(data[0] || {}), + totalRows: data.length, + totalCols: Object.keys(data[0] || {}).length, + }; +} + +validateFile(buffer: Buffer, fileName: string): ValidationResult { + // 1. 文件大小 + if (buffer.length > 10 * 1024 * 1024) { + return { valid: false, error: '文件超过10MB' }; + } + + // 2. 文件格式 + const ext = fileName.substring(fileName.lastIndexOf('.')); + if (!['.xlsx', '.xls', '.csv'].includes(ext)) { + return { valid: false, error: '不支持的格式' }; + } + + // 3. 内容完整性 + try { + this.parseExcel(buffer); + } catch (error) { + return { valid: false, error: '文件无法解析' }; + } + + return { valid: true }; +} +``` + +--- + +### 3. 控制器层开发 + +#### 任务3.1: SessionController实现 ✅ +- **文件**: `backend/src/modules/dc/tool-c/controllers/SessionController.ts` (300行) +- **功能**: 6个API端点 + + - ✅ `upload()` - POST /sessions/upload + - 接收multipart/form-data文件上传 + - 调用SessionService.createSession() + - 返回201 + Session信息 + + - ✅ `getSession()` - GET /sessions/:id + - 获取Session元数据 + - 检查过期 + - 返回200 + Session信息 + + - ✅ `getPreviewData()` - GET /sessions/:id/preview + - 获取前100行数据 + - 从OSS实时读取 + - 返回200 + 预览数据 + + - ✅ `getFullData()` - GET /sessions/:id/full + - 获取完整数据 + - 从OSS下载 + - 返回200 + 完整数据 + + - ✅ `deleteSession()` - DELETE /sessions/:id + - 删除OSS文件 + - 删除DB记录 + - 返回200 + 成功信息 + + - ✅ `updateHeartbeat()` - POST /sessions/:id/heartbeat + - 延长过期时间 + - 返回200 + 新过期时间 + +**错误处理**: +- Session不存在 → 404 +- Session过期 → 404 +- 服务器错误 → 500 +- 参数错误 → 400 + +#### 任务3.2: 路由配置更新 ✅ +- **文件**: `backend/src/modules/dc/tool-c/routes/index.ts` (62行) +- **新增路由**: 6个Session管理路由 +- **路由前缀**: `/api/v1/dc/tool-c` + +--- + +### 4. API测试验收 + +#### 测试数据 +```javascript +// 8行 x 7列医疗数据 +[ + { patient_id: 'P001', name: '张三', age: 25, gender: '男', diagnosis: '感冒', sbp: 120, dbp: 80 }, + { patient_id: 'P002', name: '李四', age: 65, gender: '女', diagnosis: '高血压', sbp: 150, dbp: 95 }, + // ... 共8条记录 +] +``` + +#### 测试结果(7/7 通过)✅ + +**测试1: 上传文件创建Session** ✅ +- 请求:POST /sessions/upload(multipart) +- 响应:201 Created +- Session ID: `e7abe493-009d-4f97-8342-7a00c09c39fc` +- 数据验证:✅ 8行 x 7列 完全匹配 +- 列名识别:✅ 7个列名全部正确 + +**测试2: 获取Session信息** ✅ +- 请求:GET /sessions/:id +- 响应:200 OK +- 返回数据:元数据完整(文件名、行数、列数、列名、过期时间) + +**测试3: 获取预览数据** ✅ +- 请求:GET /sessions/:id/preview +- 响应:200 OK +- 返回数据:前100行(实际8行全部返回) +- 数据完整性:✅ 中文字段正确解析 + +**测试4: 获取完整数据** ✅ +- 请求:GET /sessions/:id/full +- 响应:200 OK +- 从OSS读取:✅ 正常 +- 数据一致性:✅ 与上传数据完全一致 + +**测试5: 更新心跳** ✅ +- 请求:POST /sessions/:id/heartbeat +- 响应:200 OK +- 过期时间:✅ 延长10分钟 + +**测试6: 删除Session** ✅ +- 请求:DELETE /sessions/:id +- 响应:200 OK +- OSS文件:✅ 删除成功 +- DB记录:✅ 删除成功 + +**测试7: 验证删除** ✅ +- 请求:GET /sessions/:id(已删除) +- 响应:404 Not Found +- 验证结果:✅ Session已正确删除 + +--- + +## 📂 新增文件清单 + +### 数据库 +1. `backend/prisma/schema.prisma` - 新增DcToolCSession模型 +2. `backend/prisma/migrations/create_tool_c_session.sql` - SQL迁移文件 +3. `backend/scripts/create-tool-c-table.mjs` - 表创建脚本(139行) + +### 服务层 +4. `backend/src/modules/dc/tool-c/services/SessionService.ts` - 383行 ✅ +5. `backend/src/modules/dc/tool-c/services/DataProcessService.ts` - 303行 ✅ + +### 控制器层 +6. `backend/src/modules/dc/tool-c/controllers/SessionController.ts` - 300行 ✅ + +### 路由层 +7. `backend/src/modules/dc/tool-c/routes/index.ts` - 更新,62行 ✅ + +### 测试脚本 +8. `backend/test-tool-c-day2.mjs` - 383行 ✅ + +### 文档 +9. `docs/03-业务模块/DC-数据清洗整理/06-开发记录/2025-12-06_工具C_Day2开发完成总结.md` - 本文件 + +**新增代码总计**: ~1,900+ 行 + +--- + +## 🎯 核心功能实现 + +### 功能1: Excel文件上传 ✅ + +**流程**: +``` +用户上传Excel → 文件验证 → 内存解析 → 上传OSS → 保存元数据到DB → 返回Session +``` + +**技术亮点**: +- ✅ 零落盘:Excel全程内存处理 +- ✅ OSS存储:文件上传到云存储 +- ✅ 元数据分离:DB只存最小必要信息 + +**代码片段**: +```typescript +// 内存解析(不落盘) +const workbook = xlsx.read(fileBuffer, { type: 'buffer' }); +const data = xlsx.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]); + +// 上传到OSS +await storage.upload(fileKey, fileBuffer); + +// 保存元数据到DB(不存大数据) +await prisma.dcToolCSession.create({ data: { userId, fileName, fileKey, ... } }); +``` + +--- + +### 功能2: Session管理 ✅ + +**流程**: +``` +创建Session → 10分钟过期 → 心跳延长 → 自动清理 +``` + +**技术亮点**: +- ✅ 10分钟过期机制 +- ✅ 心跳延长(前端定时发送) +- ✅ 自动清理过期Session +- ✅ 过期检查索引优化 + +**代码片段**: +```typescript +// 创建时设置过期时间 +const expiresAt = new Date(Date.now() + 10 * 60 * 1000); + +// 心跳延长 +const newExpiresAt = new Date(Date.now() + 10 * 60 * 1000); +await prisma.dcToolCSession.update({ + where: { id: sessionId }, + data: { expiresAt: newExpiresAt } +}); + +// 清理过期Session +const expiredSessions = await prisma.dcToolCSession.findMany({ + where: { expiresAt: { lt: new Date() } } +}); +``` + +--- + +### 功能3: 数据获取(预览/完整)✅ + +**流程**: +``` +前端请求 → 检查Session → 从OSS下载 → 内存解析 → 返回数据 +``` + +**技术亮点**: +- ✅ 按需读取:预览100行,完整读取全部 +- ✅ 内存解析:不落盘 +- ✅ 缓存优化:前端可缓存预览数据 + +**代码片段**: +```typescript +async getPreviewData(sessionId: string) { + const session = await this.getSession(sessionId); + + // 从OSS下载到内存 + const buffer = await storage.download(session.fileKey); + + // 内存解析 + const workbook = xlsx.read(buffer, { type: 'buffer' }); + const data = xlsx.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]); + + // 返回前100行 + return { ...session, previewData: data.slice(0, 100) }; +} +``` + +--- + +## 🔐 云原生规范遵守情况 + +### ✅ 必须遵守的规范(100%符合) + +| 规范 | 要求 | 实现 | 状态 | +|------|------|------|------| +| **文件存储** | 使用storage服务 | ✅ 所有文件上传到OSS | ✅ | +| **零落盘** | Excel内存解析 | ✅ xlsx.read(buffer) | ✅ | +| **日志系统** | 使用logger | ✅ 所有日志使用platform logger | ✅ | +| **数据库** | 使用全局prisma | ✅ import from config/database | ✅ | +| **禁止本地存储** | 无fs.writeFile | ✅ 无本地文件操作 | ✅ | +| **禁止硬编码** | 使用环境变量 | ✅ 所有配置可配置 | ✅ | + +### 代码审查清单(通过) + +- [x] 是否使用 `storage.upload()` 而非 `fs.writeFile()`? ✅ +- [x] Excel 是否从内存解析,而非保存到本地? ✅ +- [x] 是否使用全局 `prisma` 实例? ✅ +- [x] 是否所有配置都从 `process.env` 读取? ✅ +- [x] 是否使用 `logger` 而非 `console.log`? ✅ +- [x] 所有 async 函数是否有 try-catch? ✅ +- [x] 是否记录了详细错误日志? ✅ +- [x] 是否返回了友好的错误信息? ✅ + +--- + +## 📈 代码质量指标 + +| 指标 | Day 1 | Day 2 | 增长 | +|------|-------|-------|------| +| **新增代码行数** | ~1,300 | ~1,900 | +46% | +| **API端点数** | 3个测试 | +6个正式 | +200% | +| **服务类数** | 1个 | +2个 | +200% | +| **控制器数** | 1个 | +1个 | +100% | +| **数据库表** | 0个 | +1个 | 新增 | +| **测试通过率** | 100% | 100% | 保持 | + +--- + +## 🚀 API端点汇总(Day 2更新) + +### Python微服务 (http://localhost:8000) +| 方法 | 端点 | 功能 | 状态 | +|------|------|------|------| +| GET | `/api/health` | 健康检查 | ✅ Day 1 | +| POST | `/api/dc/validate` | 代码验证 | ✅ Day 1 | +| POST | `/api/dc/execute` | 代码执行 | ✅ Day 1 | + +### Node.js后端 (http://localhost:3000) + +#### 测试端点(Day 1) +| 方法 | 端点 | 功能 | 状态 | +|------|------|------|------| +| GET | `/api/v1/dc/tool-c/test/health` | 测试Python服务 | ✅ | +| POST | `/api/v1/dc/tool-c/test/validate` | 测试代码验证 | ✅ | +| POST | `/api/v1/dc/tool-c/test/execute` | 测试代码执行 | ✅ | + +#### Session管理端点(Day 2)✅ +| 方法 | 端点 | 功能 | 状态 | 测试 | +|------|------|------|------|------| +| POST | `/api/v1/dc/tool-c/sessions/upload` | 上传Excel | ✅ | 201 | +| GET | `/api/v1/dc/tool-c/sessions/:id` | 获取Session | ✅ | 200 | +| GET | `/api/v1/dc/tool-c/sessions/:id/preview` | 获取预览 | ✅ | 200 | +| GET | `/api/v1/dc/tool-c/sessions/:id/full` | 获取完整 | ✅ | 200 | +| DELETE | `/api/v1/dc/tool-c/sessions/:id` | 删除Session | ✅ | 200 | +| POST | `/api/v1/dc/tool-c/sessions/:id/heartbeat` | 心跳更新 | ✅ | 200 | + +--- + +## 🔍 技术难点解决 + +### 难点1: Prisma db push 冲突 +**问题**: 执行`npx prisma db push`时提示要删除现有表 + +**原因**: Prisma Schema与数据库实际结构不同步 + +**解决方案**: +- 创建独立的Node.js脚本(create-tool-c-table.mjs) +- 使用`prisma.$executeRawUnsafe()`直接执行SQL +- 只创建Tool C的表,不影响其他表 + +**代码**: +```javascript +await prisma.$executeRawUnsafe(` + CREATE TABLE dc_schema.dc_tool_c_sessions (...) +`); +``` + +**结果**: ✅ 表创建成功,无数据丢失 + +--- + +### 难点2: 路径别名导入失败 +**问题**: `import { logger } from '@/common/logging'` 报错 `ERR_MODULE_NOT_FOUND` + +**原因**: TSX在运行时不识别路径别名 + +**解决方案**: 使用相对路径导入 +```typescript +// ❌ 错误 +import { logger } from '@/common/logging'; + +// ✅ 正确 +import { logger } from '../../../../common/logging/index.js'; +``` + +**修复文件**: +- TestController.ts ✅ +- PythonExecutorService.ts ✅ +- SessionService.ts ✅ +- DataProcessService.ts ✅ +- SessionController.ts ✅ + +--- + +### 难点3: 零落盘架构设计 +**问题**: 是否在DB存储previewData以提升性能? + +**分析**: +- 方案A:DB存previewData → 性能好,但违反"零落盘"规范 +- 方案B:实时从OSS读取 → 性能稍差,但符合规范 + +**决策**: 方案B(规范优先) +- ✅ 遵守云原生规范 +- ✅ DB存储量小 +- ✅ 数据一致性强 +- ⚠️ 每次预览需读OSS(前端可缓存,影响可控) + +--- + +## 📝 待办事项(Day 3) + +### AI代码生成服务 +- [ ] 创建 `AICodeService.ts` +- [ ] 集成LLMFactory +- [ ] 设计System Prompt(10个Few-shot示例) +- [ ] 实现AI与Python执行服务集成 +- [ ] 添加自我修正机制 + +### AI控制器 +- [ ] 创建 `AIController.ts` +- [ ] POST `/ai/chat` - AI对话 +- [ ] POST `/ai/execute` - 执行AI代码 +- [ ] GET `/ai/history/:sessionId` - 对话历史 + +### 测试场景 +- [ ] 测试AI生成简单代码 +- [ ] 测试AI生成医疗清洗代码 +- [ ] 测试自我修正机制 +- [ ] 端到端测试(上传 → AI处理 → 结果导出) + +--- + +## 🎉 Day 2 总结 + +### 成果 +- ✅ **Session管理完整实现**: 6个API端点,7/7测试通过 +- ✅ **零落盘架构**: 完全符合云原生规范 +- ✅ **数据库集成**: 表创建成功,索引优化 +- ✅ **OSS集成**: 文件上传/下载/删除正常 +- ✅ **错误处理完善**: 所有异常场景都有处理 + +### 技术亮点 +1. **零落盘架构**: Excel全程内存处理,无临时文件 +2. **按需加载**: 预览100行,完整数据按需获取 +3. **Session过期**: 10分钟自动过期,心跳延长 +4. **错误容错**: OSS删除失败不影响DB清理 +5. **完整日志**: 所有操作都有结构化日志 + +### 开发效率 +- **计划工时**: 5.5小时 +- **实际工时**: ~5小时 +- **任务完成率**: 100% (6/6) +- **代码质量**: 高(完整注释+测试) +- **测试通过率**: 100% (7/7) + +### 下一步重点 +1. 实现AI代码生成服务(LLMFactory) +2. 设计System Prompt(10个Few-shot) +3. 集成AI与Python执行 +4. 端到端功能测试 + +--- + +**开发者**: AI Assistant +**审核状态**: ✅ 待用户验收 +**下一步**: Day 3 - AI代码生成服务 +