Files
AIclinicalresearch/backend/src/modules/dc/tool-c/controllers/SessionController.ts
HaHafeng 097e7920ab feat(admin): add user-level direct permission system and enhance activity tracking
Features:
- Add user_permissions table for direct user-to-permission grants (ops:user-ops)
- Merge role_permissions + user_permissions in auth chain (login, middleware, getCurrentUser)
- Add getUserQueryScope support for USER role with ops:user-ops (cross-tenant access)
- Unify cross-tenant operation checks via getUserQueryScope (remove hardcoded SUPER_ADMIN checks)
- Add 3 new API endpoints: GET/PUT /:id/permissions, GET /options/permissions
- Support ops:user-ops as alternative permission on all user/tenant management routes
- Frontend: add user-ops permission toggle on UserFormPage and UserDetailPage
- Enhance DC module activity tracking (StreamAIController, SessionController, QuickActionController)
- Fix DC AIController user ID extraction and feature name consistency
- Add verify-activity-tracking.ts validation script
- Update deployment checklist and admin module documentation

DB Migration: 20260309_add_user_permissions_table

Made-with: Cursor
2026-03-10 09:02:35 +08:00

605 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Session控制器
*
* API端点
* - POST /sessions/upload 上传Excel文件创建Session
* - GET /sessions/:id 获取Session信息
* - GET /sessions/:id/preview 获取预览数据(⭐ 已改为全量加载)
* - 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';
import { jobQueue } from '../../../../common/jobs/index.js';
import * as xlsx from 'xlsx';
import { activityService } from '../../../../common/services/activity.service.js';
/**
* 获取用户ID从JWT Token中获取
*/
function getUserId(request: FastifyRequest): string {
const userId = (request as any).user?.userId;
if (!userId) {
throw new Error('User not authenticated');
}
return userId;
}
// ==================== 请求参数类型定义 ====================
interface SessionIdParams {
id: string;
}
interface GetUniqueValuesQuery {
column: 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
const userId = getUserId(request);
// 5. 创建SessionPostgres-Only架构 - 异步处理)
const sessionResult = await sessionService.createSession(
userId,
fileName,
fileBuffer
);
logger.info(`[SessionController] Session创建成功: ${sessionResult.id}, jobId: ${sessionResult.jobId}`);
// 埋点DC 上传数据文件
try {
const user = (request as any).user;
if (user) {
activityService.log(
user.tenantId,
user.tenantName || null,
user.userId || user.id,
user.name || null,
'DC',
'智能数据清洗',
'USE',
`upload:${fileName}, session:${sessionResult.id}`,
);
}
} catch { /* 埋点失败不影响主业务 */ }
// 6. 返回Session信息 + jobId用于前端轮询
return reply.code(201).send({
success: true,
message: 'Session创建成功',
data: {
sessionId: sessionResult.id,
jobId: sessionResult.jobId, // ✅ 返回 jobId 供前端轮询
fileName: sessionResult.fileName,
fileSize: dataProcessService.formatFileSize(sessionResult.fileSize),
totalRows: sessionResult.totalRows,
totalCols: sessionResult.totalCols,
columns: sessionResult.columns,
expiresAt: sessionResult.expiresAt,
createdAt: sessionResult.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失败',
});
}
}
/**
* 获取预览数据(⭐ 已改为全量加载)
*
* 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 || '更新心跳失败',
});
}
}
/**
* ✨ 导出Excel文件新增
*
* GET /api/v1/dc/tool-c/sessions/:id/export
*/
async exportData(
request: FastifyRequest<{ Params: SessionIdParams }>,
reply: FastifyReply
) {
try {
const { id } = request.params;
logger.info(`[SessionController] 导出Excel: ${id}`);
// 1. 获取Session信息
const session = await sessionService.getSession(id);
// 2. 获取完整数据
const data = await sessionService.getFullData(id);
// 3. 生成Excel
const workbook = xlsx.utils.book_new();
const worksheet = xlsx.utils.json_to_sheet(data);
// 设置列宽(自动调整)
const colWidths = session.columns.map(col => {
const maxLength = Math.max(
col.length,
...data.slice(0, 100).map(row => String(row[col] || '').length)
);
return { wch: Math.min(maxLength + 2, 50) };
});
worksheet['!cols'] = colWidths;
xlsx.utils.book_append_sheet(workbook, worksheet, 'Data');
// 4. 生成Buffer
const buffer = xlsx.write(workbook, {
type: 'buffer',
bookType: 'xlsx',
compression: true,
});
// 5. 生成文件名加上_cleaned后缀和时间戳
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const baseFileName = session.fileName.replace(/\.[^/.]+$/, ''); // 去除扩展名
const exportFileName = `${baseFileName}_cleaned_${timestamp}.xlsx`;
logger.info(`[SessionController] 导出成功: ${exportFileName}, 大小: ${(buffer.length / 1024).toFixed(2)}KB`);
// 6. 返回文件
reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(exportFileName)}"`);
reply.header('Content-Length', buffer.length);
return reply.send(buffer);
} catch (error: any) {
logger.error(`[SessionController] 导出Excel失败: ${error.message}`);
const statusCode = error.message.includes('不存在') || error.message.includes('过期')
? 404
: 500;
return reply.code(statusCode).send({
success: false,
error: error.message || '导出Excel失败',
});
}
}
/**
* ✨ 获取列的唯一值(用于数值映射)
*
* GET /api/v1/dc/tool-c/sessions/:id/unique-values?column=xxx
*/
async getUniqueValues(
request: FastifyRequest<{ Params: SessionIdParams; Querystring: GetUniqueValuesQuery }>,
reply: FastifyReply
) {
try {
const { id } = request.params;
const { column } = request.query;
if (!column) {
return reply.code(400).send({
success: false,
error: '缺少column参数',
});
}
logger.info(`[SessionController] 获取唯一值: session=${id}, column=${column}`);
// 1. 获取完整数据
const data = await sessionService.getFullData(id);
// 2. 提取唯一值保留NA值但清理字符串
const values = data.map((row) => row[column]);
const cleanedValues = values.map((val) => {
if (val === null || val === undefined || val === '') return null;
// 如果是字符串,去除首尾空格
return typeof val === 'string' ? val.trim() : val;
});
// 3. 去重(✨ 保留null值但用特殊标记表示
const uniqueSet = Array.from(new Set(cleanedValues));
const hasNA = uniqueSet.includes(null);
const naCount = cleanedValues.filter(v => v === null).length; // ✨ 统计NA数量
// 过滤掉null和空白然后排序
const nonNAValues = uniqueSet
.filter((v) => v !== null && v !== '' && v !== '(空白)')
.sort(); // 排序,方便查看
// ✨ 如果有NA添加到数组末尾用特殊字符串标记
const uniqueValues = hasNA
? [...nonNAValues, '<空值/NA>']
: nonNAValues;
logger.info(`[SessionController] 唯一值数量: ${uniqueValues.length} (含NA: ${hasNA}, NA数量: ${naCount})`);
// 4. 返回结果
return reply.send({
success: true,
data: {
column,
uniqueValues,
count: uniqueValues.length,
naCount: hasNA ? naCount : 0, // ✨ 返回NA数量
totalCount: data.length, // ✨ 总行数
},
});
} 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状态Postgres-Only架构
*
* 查询任务状态:
* - 从 pg-boss 查询 job 状态
* - 从 Session 表查询解析结果
* - 合并返回给前端
*
* GET /api/v1/dc/tool-c/sessions/:id/status
* Query: jobId (可选,首次上传时提供)
*/
async getSessionStatus(
request: FastifyRequest<{ Params: SessionIdParams; Querystring: { jobId?: string } }>,
reply: FastifyReply
) {
try {
const { id: sessionId } = request.params;
const { jobId } = request.query;
logger.info(`[SessionController] 获取Session状态: sessionId=${sessionId}, jobId=${jobId}`);
// 1. 查询 Session 信息
const session = await sessionService.getSession(sessionId);
// 2. 判断解析状态
// - 如果 totalRows 不为 null说明解析已完成
// - 否则查询 job 状态
if (session.totalRows !== null && session.totalRows !== undefined) {
// 解析已完成
logger.info(`[SessionController] Session已解析完成: ${sessionId}`);
return reply.code(200).send({
success: true,
data: {
sessionId,
status: 'ready', // ✅ 解析完成
progress: 100,
session,
},
});
}
// 3. 解析中,查询 job 状态
if (!jobId) {
// 没有 jobId可能是旧数据或直接查询
logger.warn(`[SessionController] 没有jobIdSession可能处于pending状态`);
return reply.code(200).send({
success: true,
data: {
sessionId,
status: 'processing', // 处理中
progress: 50, // 估算进度
session: {
...session,
totalRows: null,
totalCols: null,
columns: null,
},
},
});
}
// 4. 从 pg-boss 查询 job 状态
const job = await jobQueue.getJob(jobId);
if (!job) {
logger.warn(`[SessionController] Job不存在: ${jobId}`);
return reply.code(200).send({
success: true,
data: {
sessionId,
status: 'processing',
progress: 50,
session,
},
});
}
// 5. 映射 job 状态到前端状态
let status = 'processing';
let progress = 50;
switch (job.status) {
case 'completed':
status = 'ready';
progress = 100;
break;
case 'failed':
status = 'error';
progress = 0;
break;
case 'processing':
status = 'processing';
progress = 70; // 处理中估算70%
break;
default:
status = 'processing';
progress = 30; // 队列中估算30%
}
logger.info(`[SessionController] Job状态: ${job.status}, 前端状态: ${status}`);
return reply.code(200).send({
success: true,
data: {
sessionId,
jobId,
status,
progress,
session,
},
});
} 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状态失败',
});
}
}
}
// ==================== 导出单例实例 ====================
export const sessionController = new SessionController();