Files
aurak/server/src/knowledge-base/knowledge-base.controller.ts
T
Developer 5c974c50de feat: knowledge-base code review fixes + question bank cleanup
- 🔴 searchKnowledge: 移除随机mock向量,使用真实embedding
- 🔴 userId: 改为NOT NULL,清理遗留调试注释
- 🟡 文件移动事务安全:先移文件再创DB记录
- 🟡 Ollama嵌入并行化:串行→Promise.allSettled
- 🟡 三处重复降级代码提取为processChunksOneByOne(~200行→30行)
- 🟡 Chunk换算根据CJK比例动态调整(英4x/中2x/日2x)
- 🟡 findAll添加分页参数
- 🔵 清理冗余动态import、findByIds→findBy、日文标点补充
- chore: question-bank cleanup (删除47道概念/重复/ADV题)
- chore: qa-assessment-flow (Phase 1+2全量测试14项通过)
- fix: shuffleArray接收返回值(三处调用点)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 11:27:16 +08:00

372 lines
9.8 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
Request,
UseGuards,
Res,
NotFoundException,
InternalServerErrorException,
} from '@nestjs/common';
import { Response } from 'express';
import * as path from 'path';
import { Logger } from '@nestjs/common';
import { KnowledgeBaseService } from './knowledge-base.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { UserRole } from '../user/user-role.enum';
import { Public } from '../auth/public.decorator';
import { KnowledgeBase } from './knowledge-base.entity';
import { ChunkConfigService } from './chunk-config.service';
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
import { I18nService } from '../i18n/i18n.service';
@Controller('knowledge-bases')
@UseGuards(CombinedAuthGuard, RolesGuard)
export class KnowledgeBaseController {
private readonly logger = new Logger(KnowledgeBaseController.name);
constructor(
private readonly knowledgeBaseService: KnowledgeBaseService,
private readonly chunkConfigService: ChunkConfigService,
private readonly knowledgeGroupService: KnowledgeGroupService,
private readonly i18nService: I18nService,
) {}
@Get()
@UseGuards(CombinedAuthGuard)
async findAll(
@Request() req,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.knowledgeBaseService.findAll(
req.user.id,
req.user.tenantId,
page ? Number(page) : undefined,
limit ? Number(limit) : undefined,
);
}
@Get('stats')
@UseGuards(CombinedAuthGuard)
async getStats(
@Request() req,
): Promise<{ total: number; uncategorized: number }> {
return this.knowledgeBaseService.getStats(req.user.id, req.user.tenantId);
}
@Delete('clear')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async clearAll(@Request() req): Promise<{ message: string }> {
await this.knowledgeBaseService.clearAll(req.user.id, req.user.tenantId);
return { message: this.i18nService.getMessage('kbCleared') };
}
@Post('search')
async search(@Request() req, @Body() body: { query: string; topK?: number }) {
return this.knowledgeBaseService.searchKnowledge(
req.user.id,
req.user.tenantId, // New
body.query,
body.topK || 5,
);
}
@Post('rag-search')
async ragSearch(
@Request() req,
@Body() body: { query: string; settings: any },
) {
return this.knowledgeBaseService.ragSearch(
req.user.id,
req.user.tenantId, // New
body.query,
body.settings,
);
}
@Delete(':id')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async deleteFile(
@Request() req,
@Param('id') fileId: string,
): Promise<{ message: string }> {
await this.knowledgeBaseService.deleteFile(
fileId,
req.user.id,
req.user.tenantId,
);
return { message: this.i18nService.getMessage('fileDeleted') };
}
@Post(':id/retry')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async retryFile(
@Request() req,
@Param('id') fileId: string,
): Promise<KnowledgeBase> {
return this.knowledgeBaseService.retryFailedFile(
fileId,
req.user.id,
req.user.tenantId,
);
}
@Get(':id/chunks')
async getFileChunks(@Request() req, @Param('id') fileId: string) {
return this.knowledgeBaseService.getFileChunks(
fileId,
req.user.id,
req.user.tenantId,
);
}
/**
* Get chunk config limits (for frontend slider settings)
* Query parameter: embeddingModelId - embedding model ID
*/
@Get('chunk-config/limits')
async getChunkConfigLimits(
@Request() req,
@Query('embeddingModelId') embeddingModelId: string,
) {
if (!embeddingModelId) {
return {
maxChunkSize: parseInt(process.env.MAX_CHUNK_SIZE || '8191'),
maxOverlapSize: parseInt(process.env.MAX_OVERLAP_SIZE || '2000'),
minOverlapSize: 25,
defaultChunkSize: 200,
defaultOverlapSize: 40,
modelInfo: {
name: this.i18nService.getMessage('modelNotConfigured'),
maxInputTokens: parseInt(process.env.MAX_CHUNK_SIZE || '8191'),
maxBatchSize: 2048,
expectedDimensions: parseInt(
process.env.DEFAULT_VECTOR_DIMENSIONS || '2560',
),
},
};
}
return await this.chunkConfigService.getFrontendLimits(
embeddingModelId,
req.user.id,
req.user.tenantId,
);
}
// File group management - requires admin permission
@Post(':id/groups')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async addFileToGroups(
@Param('id') fileId: string,
@Body() body: { groupIds: string[] },
@Request() req,
) {
await this.knowledgeGroupService.addFilesToGroup(
fileId,
body.groupIds,
req.user.id,
req.user.tenantId,
);
return { message: this.i18nService.getMessage('groupSyncSuccess') };
}
@Delete(':id/groups/:groupId')
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async removeFileFromGroup(
@Param('id') fileId: string,
@Param('groupId') groupId: string,
@Request() req,
) {
await this.knowledgeGroupService.removeFileFromGroup(
fileId,
groupId,
req.user.id,
req.user.tenantId,
);
return { message: this.i18nService.getMessage('fileDeletedFromGroup') };
}
// PDF preview - public access
@Public()
@Get(':id/pdf')
async getPDFPreview(
@Param('id') fileId: string,
@Query('token') token: string,
@Res() res: Response,
) {
try {
if (!token) {
throw new NotFoundException(
this.i18nService.getMessage('accessDeniedNoToken'),
);
}
const jwt = await import('jsonwebtoken');
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new InternalServerErrorException(
this.i18nService.getMessage('jwtSecretRequired'),
);
}
let decoded;
try {
decoded = jwt.verify(token, secret) as any;
} catch {
throw new NotFoundException(
this.i18nService.getMessage('invalidToken'),
);
}
if (decoded.type !== 'pdf-access' || decoded.fileId !== fileId) {
throw new NotFoundException(
this.i18nService.getMessage('invalidToken'),
);
}
const pdfPath = await this.knowledgeBaseService.ensurePDFExists(
fileId,
decoded.userId,
decoded.tenantId, // New
);
const fs = await import('fs');
const path = await import('path');
if (!fs.existsSync(pdfPath)) {
throw new NotFoundException(
this.i18nService.getMessage('pdfFileNotFound'),
);
}
const stat = fs.statSync(pdfPath);
const fileName = path.basename(pdfPath);
if (stat.size === 0) {
this.logger.warn(`PDF file is empty: ${pdfPath}`);
try {
fs.unlinkSync(pdfPath); // Delete empty file
} catch (e) {}
throw new NotFoundException(
this.i18nService.getMessage('pdfFileEmpty'),
);
}
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Length', stat.size);
const stream = fs.createReadStream(pdfPath);
stream.pipe(res);
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`PDF preview error: ${error.message}`);
throw new NotFoundException(
this.i18nService.getMessage('pdfConversionFailed'),
);
}
}
// Get PDF preview URL
@Get(':id/pdf-url')
async getPDFUrl(
@Param('id') fileId: string,
@Query('force') force: string,
@Request() req,
) {
try {
// Trigger PDF conversion
await this.knowledgeBaseService.ensurePDFExists(
fileId,
req.user.id,
req.user.tenantId,
force === 'true',
);
// Generate temporary access token
const jwt = await import('jsonwebtoken');
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new InternalServerErrorException(
this.i18nService.getMessage('jwtSecretRequired'),
);
}
const token = jwt.sign(
{
fileId,
userId: req.user.id,
tenantId: req.user.tenantId,
type: 'pdf-access',
},
secret,
{ expiresIn: '1h' },
);
return {
url: `/api/knowledge-bases/${fileId}/pdf?token=${token}`,
};
} catch (error) {
if (error.message.includes('LibreOffice')) {
throw new InternalServerErrorException(
this.i18nService.formatMessage('pdfServiceUnavailable', {
message: error.message,
}),
);
}
throw new InternalServerErrorException(error.message);
}
}
@Get(':id/pdf-status')
async getPDFStatus(@Param('id') fileId: string, @Request() req) {
return await this.knowledgeBaseService.getPDFStatus(
fileId,
req.user.id,
req.user.tenantId,
);
}
// Get specific page of PDF as image
@Get(':id/page/:index')
async getPageImage(
@Param('id') fileId: string,
@Param('index') index: number,
@Request() req,
@Res() res: Response,
) {
try {
const imagePath = await this.knowledgeBaseService.getPageAsImage(
fileId,
Number(index),
req.user.id,
req.user.tenantId,
);
const fs = await import('fs');
if (!fs.existsSync(imagePath)) {
throw new NotFoundException(
this.i18nService.getMessage('pageImageNotFound'),
);
}
res.sendFile(path.resolve(imagePath));
} catch (error) {
this.logger.error(`Failed to get PDF page image: ${error.message}`);
throw new NotFoundException(
this.i18nService.getMessage('pdfPageImageFailed'),
);
}
}
}