forked from hangshuo652/aurak
P0-1/P0-2/P1-1: dimensions form + E2E tests + PDF export
P0-1 Backend: dimensions column on template entity + validation P0-1 Frontend: dimensions edit UI in TemplateManager P0-2: routeAfterGrading unit tests (10 cases), service spec fix + certificate tests, jest-e2e.json P1-1: proper PDF generation with embedded CJK font via pdf-lib low-level API
This commit is contained in:
@@ -6,6 +6,7 @@ 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';
|
||||
import { generateAssessmentPdf } from './pdf-generator';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
@@ -155,73 +156,55 @@ export class ExportService {
|
||||
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?.displayName || session.user?.username || 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]));
|
||||
|
||||
const detailLines: string[] = [];
|
||||
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.questionText || '-'}`);
|
||||
lines.push(` 用户回答: ${a?.userAnswer || '-'}`);
|
||||
lines.push(` 得分: ${a?.score ?? '-'}`);
|
||||
lines.push(` 反馈: ${a?.feedback || '-'}`);
|
||||
lines.push(` 追问: ${a?.isFollowUp ? '是' : '否'}`);
|
||||
detailLines.push(`Q${i + 1}: ${q.questionText || '-'}`);
|
||||
detailLines.push(` A: ${a?.userAnswer || '-'}`);
|
||||
detailLines.push(` Score: ${a?.score ?? '-'}`);
|
||||
detailLines.push(` Feedback: ${a?.feedback || '-'}`);
|
||||
}
|
||||
|
||||
if (session.finalReport) {
|
||||
lines.push('');
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push('综合评估报告');
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push(session.finalReport);
|
||||
const certificateLines: string[] = [];
|
||||
if (certificate) {
|
||||
certificateLines.push(`Level: ${certificate.level}`);
|
||||
certificateLines.push(`Total Score: ${certificate.totalScore}`);
|
||||
certificateLines.push(`Passed: ${certificate.passed ? 'Yes' : 'No'}`);
|
||||
certificateLines.push(`Issued: ${certificate.issuedAt ? new Date(certificate.issuedAt).toLocaleString() : '-'}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('='.repeat(60));
|
||||
lines.push(' 报告结束');
|
||||
lines.push('='.repeat(60));
|
||||
const userName = session.user?.displayName || session.user?.username || session.userId;
|
||||
|
||||
return lines.join('\n');
|
||||
return generateAssessmentPdf({
|
||||
title: 'Assessment Report',
|
||||
subtitle: `${userName} — ${new Date(session.createdAt).toLocaleDateString()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Summary',
|
||||
lines: [
|
||||
`Session: ${session.id}`,
|
||||
`User: ${userName}`,
|
||||
`Status: ${session.status}`,
|
||||
`Score: ${session.finalScore ?? '-'}`,
|
||||
`Template: ${session.template?.name || session.templateJson?.name || '-'}`,
|
||||
],
|
||||
},
|
||||
...(certificate ? [{
|
||||
title: 'Certificate',
|
||||
lines: certificateLines,
|
||||
}] : []),
|
||||
{
|
||||
title: 'Question Details',
|
||||
lines: detailLines,
|
||||
},
|
||||
...(session.finalReport ? [{
|
||||
title: 'Final Report',
|
||||
lines: [session.finalReport],
|
||||
}] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PDFDocument, rgb } from 'pdf-lib';
|
||||
|
||||
const FONT_SEARCH_PATHS = [
|
||||
path.join(__dirname, '..', '..', '..', 'assets', 'fonts', 'NotoSansSC-VF.ttf'),
|
||||
'C:/Windows/Fonts/NotoSansSC-VF.ttf',
|
||||
'C:/Windows/Fonts/msyh.ttc',
|
||||
'C:/Windows/Fonts/simsun.ttc',
|
||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
||||
];
|
||||
|
||||
let cachedFontBytes: Buffer | null = null;
|
||||
|
||||
function findFont(): Buffer {
|
||||
if (cachedFontBytes) return cachedFontBytes;
|
||||
for (const p of FONT_SEARCH_PATHS) {
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
cachedFontBytes = fs.readFileSync(p);
|
||||
return cachedFontBytes;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
interface PdfReportOptions {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
sections: PdfSection[];
|
||||
}
|
||||
|
||||
interface PdfSection {
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function textToHex(str: string): string {
|
||||
let hex = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hex += str.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
export async function generateAssessmentPdf(options: PdfReportOptions): Promise<Buffer> {
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([595.28, 841.89]);
|
||||
const ctx = doc.context;
|
||||
|
||||
const fontBytes = findFont();
|
||||
if (fontBytes.length === 0) {
|
||||
throw new Error('No CJK font found. Install Noto Sans SC or similar.');
|
||||
}
|
||||
|
||||
const fontProgRef = ctx.nextRef();
|
||||
ctx.assign(fontProgRef, ctx.flateStream(fontBytes));
|
||||
|
||||
const fontDescRef = ctx.nextRef();
|
||||
ctx.assign(fontDescRef, ctx.obj({
|
||||
Type: 'FontDescriptor',
|
||||
FontName: 'CJKFont',
|
||||
Flags: 4,
|
||||
FontBBox: [0, -300, 1000, 1000],
|
||||
ItalicAngle: 0,
|
||||
Ascent: 900,
|
||||
Descent: -200,
|
||||
CapHeight: 800,
|
||||
StemV: 80,
|
||||
FontFile2: fontProgRef,
|
||||
}));
|
||||
|
||||
const cidFontRef = ctx.nextRef();
|
||||
ctx.assign(cidFontRef, ctx.obj({
|
||||
Type: 'Font',
|
||||
Subtype: 'CIDFontType2',
|
||||
BaseFont: 'CJKFont',
|
||||
CIDSystemInfo: ctx.obj({
|
||||
Registry: 'Adobe',
|
||||
Ordering: 'Identity',
|
||||
Supplement: 0,
|
||||
}),
|
||||
FontDescriptor: fontDescRef,
|
||||
W: [0, [500]],
|
||||
}));
|
||||
|
||||
const fontRef = ctx.nextRef();
|
||||
ctx.assign(fontRef, ctx.obj({
|
||||
Type: 'Font',
|
||||
Subtype: 'Type0',
|
||||
BaseFont: 'CJKFont',
|
||||
Encoding: 'Identity-H',
|
||||
DescendantFonts: [cidFontRef],
|
||||
}));
|
||||
|
||||
const fontKey = page.node.newFontDictionaryKey('F1');
|
||||
page.node.setFontDictionary(fontKey, fontRef);
|
||||
|
||||
let contentOps = '';
|
||||
let y = 800;
|
||||
const margin = 50;
|
||||
const pageWidth = 595.28;
|
||||
|
||||
function addLine(text: string, size: number, bold: boolean = false) {
|
||||
const hex = textToHex(text);
|
||||
contentOps += `BT\n/F1 ${size} Tf\n1 0 0 1 ${margin} ${y} Tm\n<${hex}> Tj\nET\n`;
|
||||
y -= size * 1.5;
|
||||
}
|
||||
|
||||
function addSeparator() {
|
||||
let hex = '';
|
||||
for (let i = 0; i < 55; i++) hex += '002D';
|
||||
contentOps += `BT\n/F1 8 Tf\n1 0 0 1 ${margin} ${y} Tm\n<${hex}> Tj\nET\n`;
|
||||
y -= 12;
|
||||
}
|
||||
|
||||
addLine(options.title, 22);
|
||||
y -= 4;
|
||||
if (options.subtitle) {
|
||||
addLine(options.subtitle, 10);
|
||||
y -= 4;
|
||||
}
|
||||
addSeparator();
|
||||
|
||||
for (const section of options.sections) {
|
||||
if (y < 60) break;
|
||||
addLine(section.title, 12);
|
||||
for (const line of section.lines) {
|
||||
if (y < 40) break;
|
||||
addLine(line, 10);
|
||||
}
|
||||
y -= 6;
|
||||
}
|
||||
|
||||
addSeparator();
|
||||
addLine('--- End of Report ---', 9);
|
||||
|
||||
const contentObj = ctx.flateStream(contentOps);
|
||||
const contentRef = ctx.nextRef();
|
||||
ctx.assign(contentRef, contentObj);
|
||||
page.node.addContentStream(contentRef);
|
||||
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { TemplateService } from './template.service';
|
||||
import { AssessmentTemplate } from '../entities/assessment-template.entity';
|
||||
import { TenantService } from '../../tenant/tenant.service';
|
||||
|
||||
describe('TemplateService', () => {
|
||||
let service: TemplateService;
|
||||
let repo: any;
|
||||
|
||||
const mockTenantService = () => ({
|
||||
canAccessTenant: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TemplateService,
|
||||
{
|
||||
provide: getRepositoryToken(AssessmentTemplate),
|
||||
useFactory: () => ({
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{ provide: TenantService, useFactory: mockTenantService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TemplateService>(TemplateService);
|
||||
repo = module.get(getRepositoryToken(AssessmentTemplate));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should throw BadRequestException when linkedGroupIds is empty', async () => {
|
||||
const dto = { name: 'Test', linkedGroupIds: [] };
|
||||
await expect(
|
||||
service.create(dto as any, 'user-id', 'tenant-id'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when dimensions is empty array', async () => {
|
||||
const dto = { name: 'Test', linkedGroupIds: ['g-1'], dimensions: [] };
|
||||
await expect(
|
||||
service.create(dto as any, 'user-id', 'tenant-id'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create template with valid data', async () => {
|
||||
const dto = {
|
||||
name: 'Valid Template',
|
||||
linkedGroupIds: ['g-1'],
|
||||
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 0.5 }],
|
||||
};
|
||||
repo.create.mockReturnValue({ id: 'new-id', ...dto });
|
||||
repo.save.mockResolvedValue({ id: 'new-id', ...dto });
|
||||
|
||||
const result = await service.create(dto as any, 'user-id', 'tenant-id');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('new-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should throw BadRequestException when clearing linkedGroupIds', async () => {
|
||||
repo.findOne.mockResolvedValue({
|
||||
id: 'tpl-1', name: 'Existing',
|
||||
linkedGroupIds: ['g-1'],
|
||||
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('tpl-1', { linkedGroupIds: [] } as any, 'user-id', 'tenant-id'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when clearing dimensions', async () => {
|
||||
repo.findOne.mockResolvedValue({
|
||||
id: 'tpl-1', name: 'Existing',
|
||||
linkedGroupIds: ['g-1'],
|
||||
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('tpl-1', { dimensions: [] } as any, 'user-id', 'tenant-id'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -18,11 +19,33 @@ export class TemplateService {
|
||||
private readonly tenantService: TenantService,
|
||||
) {}
|
||||
|
||||
private validateRequiredFields(data: {
|
||||
linkedGroupIds?: string[];
|
||||
dimensions?: Array<{ name: string; label?: string; weight?: number }>;
|
||||
}) {
|
||||
if (data.linkedGroupIds !== undefined) {
|
||||
if (!Array.isArray(data.linkedGroupIds) || data.linkedGroupIds.length === 0) {
|
||||
throw new BadRequestException('At least one knowledge group must be linked');
|
||||
}
|
||||
}
|
||||
if (data.dimensions !== undefined) {
|
||||
if (!Array.isArray(data.dimensions) || data.dimensions.length === 0) {
|
||||
throw new BadRequestException('At least one dimension must be defined');
|
||||
}
|
||||
for (const d of data.dimensions) {
|
||||
if (!d.name) {
|
||||
throw new BadRequestException('Each dimension must have a name');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
createDto: CreateTemplateDto,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<AssessmentTemplate> {
|
||||
this.validateRequiredFields(createDto);
|
||||
const { ...data } = createDto;
|
||||
const template = this.templateRepository.create({
|
||||
...data,
|
||||
@@ -76,6 +99,8 @@ export class TemplateService {
|
||||
tenantId: string,
|
||||
): Promise<AssessmentTemplate> {
|
||||
const template = await this.findOne(id, userId, tenantId);
|
||||
const merged = { ...template, ...updateDto };
|
||||
this.validateRequiredFields(merged);
|
||||
Object.assign(template, updateDto);
|
||||
return this.templateRepository.save(template);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user