forked from hangshuo652/aurak
fix: 代码整合修复 - Entity类型、题库生成、评估流程等14项修复
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { AssessmentSession } from '../entities/assessment-session.entity';
|
||||
import { AssessmentQuestion } from '../entities/assessment-question.entity';
|
||||
import { AssessmentAnswer } from '../entities/assessment-answer.entity';
|
||||
import { AssessmentCertificate } from '../entities/assessment-certificate.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
private readonly logger = new Logger(ExportService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssessmentSession)
|
||||
private sessionRepository: Repository<AssessmentSession>,
|
||||
@InjectRepository(AssessmentQuestion)
|
||||
private questionRepository: Repository<AssessmentQuestion>,
|
||||
@InjectRepository(AssessmentAnswer)
|
||||
private answerRepository: Repository<AssessmentAnswer>,
|
||||
@InjectRepository(AssessmentCertificate)
|
||||
private certificateRepository: Repository<AssessmentCertificate>,
|
||||
) {}
|
||||
|
||||
async exportToExcel(sessionId: string): Promise<Buffer> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
relations: ['template', 'user'],
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
const questions = await this.questionRepository.find({
|
||||
where: { sessionId },
|
||||
order: { order: 'ASC' },
|
||||
});
|
||||
|
||||
const answers = await this.answerRepository.find({
|
||||
where: { questionId: In(questions.map((q) => q.id)) },
|
||||
});
|
||||
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a]));
|
||||
|
||||
const summarySheet = [
|
||||
['评估报告'],
|
||||
['评估ID', session.id],
|
||||
['用户', session.user?.name || session.userId],
|
||||
['状态', session.status],
|
||||
['最终分数', session.finalScore || '-'],
|
||||
['原始分数', session.originalScore || '-'],
|
||||
['评估模板', session.template?.name || session.templateJson?.name || '-'],
|
||||
['开始时间', session.startedAt ? new Date(session.startedAt).toLocaleString() : '-'],
|
||||
['完成时间', session.updatedAt ? new Date(session.updatedAt).toLocaleString() : '-'],
|
||||
['总用时(秒)', session.totalTimeLimit],
|
||||
[''],
|
||||
['维度分数'],
|
||||
...this.extractDimensionScores(session),
|
||||
];
|
||||
|
||||
const questionRows = [
|
||||
['题号', '题目内容', '用户回答', '分数', '反馈', '是否追问'],
|
||||
];
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const a = answerMap.get(q.id);
|
||||
questionRows.push([
|
||||
(i + 1).toString(),
|
||||
q.content || q.questionText || '',
|
||||
a?.userAnswer || '',
|
||||
a?.score?.toString() || '',
|
||||
a?.feedback || '',
|
||||
a?.isFollowUp ? '是' : '否',
|
||||
]);
|
||||
}
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
const summaryWs = XLSX.utils.aoa_to_sheet(summarySheet);
|
||||
XLSX.utils.book_append_sheet(wb, summaryWs, '摘要');
|
||||
|
||||
const questionWs = XLSX.utils.aoa_to_sheet(questionRows);
|
||||
XLSX.utils.book_append_sheet(wb, questionWs, '题目详情');
|
||||
|
||||
if (session.finalReport) {
|
||||
const reportSheet = this.wrapTextToLines(session.finalReport, 80);
|
||||
const reportWs = XLSX.utils.aoa_to_sheet(reportSheet);
|
||||
XLSX.utils.book_append_sheet(wb, reportWs, '评估报告');
|
||||
}
|
||||
|
||||
const buffer = XLSX.write(wb, { bookType: 'xlsx', type: 'buffer' });
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private extractDimensionScores(session: AssessmentSession): any[][] {
|
||||
const scores = session.templateJson?.dimensionScores || session.finalReport;
|
||||
if (!scores) return [['未找到维度分数']];
|
||||
|
||||
if (typeof scores === 'string') {
|
||||
return [['维度分数', scores]];
|
||||
}
|
||||
|
||||
return Object.entries(scores).map(([key, value]) => [key, value]);
|
||||
}
|
||||
|
||||
private wrapTextToLines(text: string, maxWidth: number): string[][] {
|
||||
const lines: string[] = [];
|
||||
const paragraphs = text.split('\n');
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.trim() === '') {
|
||||
lines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const words = paragraph.split(' ');
|
||||
let currentLine = '';
|
||||
|
||||
for (const word of words) {
|
||||
if ((currentLine + ' ' + word).trim().length <= maxWidth) {
|
||||
currentLine = (currentLine + ' ' + word).trim();
|
||||
} else {
|
||||
if (currentLine) lines.push([currentLine]);
|
||||
currentLine = word;
|
||||
}
|
||||
}
|
||||
if (currentLine) lines.push([currentLine]);
|
||||
}
|
||||
|
||||
return lines.map((l) => [l]);
|
||||
}
|
||||
|
||||
async exportToPdf(sessionId: string): Promise<Buffer> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
relations: ['template', 'user'],
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
const certificate = await this.certificateRepository.findOne({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
const questions = await this.questionRepository.find({
|
||||
where: { sessionId },
|
||||
order: { order: 'ASC' },
|
||||
});
|
||||
|
||||
const answers = await this.answerRepository.find({
|
||||
where: { questionId: In(questions.map((q) => q.id)) },
|
||||
});
|
||||
|
||||
const content = this.generatePdfContent(session, questions, answers, certificate);
|
||||
return Buffer.from(content, 'utf-8');
|
||||
}
|
||||
|
||||
private generatePdfContent(
|
||||
session: AssessmentSession,
|
||||
questions: AssessmentQuestion[],
|
||||
answers: AssessmentAnswer[],
|
||||
certificate: AssessmentCertificate | null,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('='.repeat(60));
|
||||
lines.push(' 人才评估报告');
|
||||
lines.push('='.repeat(60));
|
||||
lines.push('');
|
||||
|
||||
lines.push(`评估ID: ${session.id}`);
|
||||
lines.push(`用户: ${session.user?.name || session.userId}`);
|
||||
lines.push(`状态: ${session.status === 'COMPLETED' ? '已完成' : '进行中'}`);
|
||||
lines.push(`最终分数: ${session.finalScore || '-'}`);
|
||||
lines.push(`评估模板: ${session.template?.name || session.templateJson?.name || '-'}`);
|
||||
lines.push(`评估时间: ${session.startedAt ? new Date(session.startedAt).toLocaleString() : '-'}`);
|
||||
lines.push('');
|
||||
|
||||
if (certificate) {
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push('证书信息');
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push(`等级: ${certificate.level}`);
|
||||
lines.push(`总分: ${certificate.totalScore}`);
|
||||
lines.push(`是否通过: ${certificate.passed ? '是' : '否'}`);
|
||||
lines.push(`颁发时间: ${certificate.issuedAt ? new Date(certificate.issuedAt).toLocaleString() : '-'}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push('题目详情');
|
||||
lines.push('-'.repeat(60));
|
||||
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a]));
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const a = answerMap.get(q.id);
|
||||
lines.push('');
|
||||
lines.push(`第${i + 1}题:`);
|
||||
lines.push(` 题目: ${q.content || q.questionText || '-'}`);
|
||||
lines.push(` 用户回答: ${a?.userAnswer || '-'}`);
|
||||
lines.push(` 得分: ${a?.score ?? '-'}`);
|
||||
lines.push(` 反馈: ${a?.feedback || '-'}`);
|
||||
lines.push(` 追问: ${a?.isFollowUp ? '是' : '否'}`);
|
||||
}
|
||||
|
||||
if (session.finalReport) {
|
||||
lines.push('');
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push('综合评估报告');
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push(session.finalReport);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('='.repeat(60));
|
||||
lines.push(' 报告结束');
|
||||
lines.push('='.repeat(60));
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,11 @@ export class QuestionBankService {
|
||||
if (bank.status === QuestionBankStatus.PUBLISHED) {
|
||||
return bank;
|
||||
}
|
||||
if (bank.status !== QuestionBankStatus.PUBLISHED && bank.status !== QuestionBankStatus.REJECTED) {
|
||||
throw new ForbiddenException(
|
||||
'Only PUBLISHED or REJECTED status can be re-published',
|
||||
);
|
||||
}
|
||||
bank.status = QuestionBankStatus.PUBLISHED;
|
||||
this.logger.log(`QuestionBank ${id} published`);
|
||||
return this.bankRepository.save(bank);
|
||||
@@ -254,6 +259,15 @@ export class QuestionBankService {
|
||||
tenantId: string,
|
||||
): Promise<QuestionBankItem[]> {
|
||||
const bank = await this.findOne(bankId);
|
||||
|
||||
if (count <= 0 || count > 50) {
|
||||
throw new Error('生成数量必须在 1-50 之间');
|
||||
}
|
||||
|
||||
if (!knowledgeBaseContent || knowledgeBaseContent.trim().length < 10) {
|
||||
throw new Error('知识库内容太短,无法生成有效题目');
|
||||
}
|
||||
|
||||
this.logger.log(`[generateQuestions] Starting AI generation for bank ${bankId}, count: ${count}`);
|
||||
|
||||
const modelConfig = await this.modelConfigService.findDefaultByType(
|
||||
@@ -338,11 +352,13 @@ export class QuestionBankService {
|
||||
basis: q.basis,
|
||||
status: QuestionBankItemStatus.PENDING_REVIEW,
|
||||
});
|
||||
items.push(await this.itemRepository.save(item));
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
this.logger.log(`[generateQuestions] Generated ${items.length} questions for bank ${bankId}`);
|
||||
return items;
|
||||
const savedItems = await this.itemRepository.save(items);
|
||||
|
||||
this.logger.log(`[generateQuestions] Generated ${savedItems.length} questions for bank ${bankId}`);
|
||||
return savedItems;
|
||||
} catch (error) {
|
||||
this.logger.error('[generateQuestions] Error generating questions:', error);
|
||||
throw error;
|
||||
@@ -371,15 +387,14 @@ export class QuestionBankService {
|
||||
|
||||
const usedIds = new Set<string>();
|
||||
const selected: QuestionBankItem[] = [];
|
||||
const availableItems = [...allItems];
|
||||
|
||||
let dimIdx = 0;
|
||||
while (selected.length < count && usedIds.size < allItems.length) {
|
||||
while (selected.length < count && availableItems.length > 0) {
|
||||
const dim = DIMENSIONS[dimIdx % DIMENSIONS.length];
|
||||
dimIdx++;
|
||||
|
||||
if (selected.length >= count) break;
|
||||
|
||||
const available = allItems.filter(
|
||||
const available = availableItems.filter(
|
||||
(i) => i.dimension === dim && !usedIds.has(i.id),
|
||||
);
|
||||
|
||||
@@ -388,7 +403,27 @@ export class QuestionBankService {
|
||||
const item = available[idx];
|
||||
selected.push(item);
|
||||
usedIds.add(item.id);
|
||||
const actualIdx = availableItems.findIndex(i => i.id === item.id);
|
||||
if (actualIdx > -1) {
|
||||
availableItems.splice(actualIdx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (dimIdx >= DIMENSIONS.length * 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length < count && availableItems.length > 0) {
|
||||
const shuffled = this.shuffleArray([...availableItems]);
|
||||
for (const item of shuffled) {
|
||||
if (selected.length >= count) break;
|
||||
if (!usedIds.has(item.id)) {
|
||||
selected.push(item);
|
||||
usedIds.add(item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length < count) {
|
||||
@@ -440,4 +475,13 @@ export class QuestionBankService {
|
||||
this.logger.log(`[batchReview] ${items.length} items ${approved ? 'approved' : 'rejected'}`);
|
||||
return items;
|
||||
}
|
||||
|
||||
private shuffleArray<T>(array: T[]): T[] {
|
||||
const result = [...array];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user