diff --git a/server/src/assessment/services/question-bank.service.spec.ts b/server/src/assessment/services/question-bank.service.spec.ts index 5cab47a..7e52937 100644 --- a/server/src/assessment/services/question-bank.service.spec.ts +++ b/server/src/assessment/services/question-bank.service.spec.ts @@ -1,6 +1,11 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { GENERATE_QUESTIONS_SYSTEM_PROMPT, parseGeneratedQuestion, + QuestionBankService, } from './question-bank.service'; import { QuestionBankItem, @@ -9,8 +14,13 @@ import { QuestionDimension, QuestionBankItemStatus, } from '../entities/question-bank-item.entity'; +import { QuestionBank, QuestionBankStatus } from '../entities/question-bank.entity'; +import { ModelConfigService } from '../../model-config/model-config.service'; const BANK_ID = 'test-bank-id'; +const TEMPLATE_ID = 'test-template-id'; +const USER_ID = 'user-1'; +const TENANT_ID = 'default'; describe('GENERATE_QUESTIONS_SYSTEM_PROMPT', () => { it('should require both choice and open question types', () => { @@ -259,3 +269,161 @@ describe('parseGeneratedQuestion', () => { }); }); }); + +describe('QuestionBankService - status guards', () => { + let service: QuestionBankService; + let bankRepo: any; + let itemRepo: any; + + const mockRepository = () => ({ + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn().mockImplementation((entity: any) => Promise.resolve(entity)), + create: jest.fn((dto: any) => dto as any), + remove: jest.fn().mockResolvedValue(undefined), + createQueryBuilder: jest.fn().mockReturnValue({ + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + getMany: jest.fn().mockResolvedValue([]), + }), + }); + + const mockModelConfig = () => ({ + findDefaultByType: jest.fn().mockResolvedValue({ + apiKey: 'sk-test', + modelId: 'deepseek-chat', + baseUrl: 'https://api.deepseek.com/v1', + }), + }); + + const makeBank = (overrides?: Partial): QuestionBank => + ({ + id: 'bank-1', + name: 'Test Bank', + status: QuestionBankStatus.DRAFT, + templateId: TEMPLATE_ID, + tenantId: TENANT_ID, + ...overrides, + }) as QuestionBank; + + const makeItem = (overrides?: Partial): QuestionBankItem => + ({ + id: 'item-1', + bankId: BANK_ID, + questionText: 'Question?', + questionType: QuestionType.SHORT_ANSWER, + keyPoints: ['kp1'], + difficulty: QuestionDifficulty.STANDARD, + dimension: QuestionDimension.PROMPT, + status: QuestionBankItemStatus.PENDING_REVIEW, + ...overrides, + }) as QuestionBankItem; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + QuestionBankService, + { provide: getRepositoryToken(QuestionBank), useFactory: mockRepository }, + { provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository }, + { provide: ModelConfigService, useFactory: mockModelConfig }, + { provide: ConfigService, useFactory: () => ({}) }, + ], + }).compile(); + + service = module.get(QuestionBankService); + bankRepo = module.get(getRepositoryToken(QuestionBank)); + itemRepo = module.get(getRepositoryToken(QuestionBankItem)); + }); + + describe('create', () => { + const createDto = { name: 'New Bank', templateId: TEMPLATE_ID }; + + it('create: should allow cross-tenant when DRAFT exists for another tenant', async () => { + bankRepo.findOne.mockResolvedValue(null); + + const result = await service.create(createDto, USER_ID, TENANT_ID); + expect(result).toBeDefined(); + }); + + it('create: DRAFT exists same tenant → BadRequestException', async () => { + bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.DRAFT })); + + await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException); + }); + + it('create: REJECTED exists same tenant → BadRequestException', async () => { + bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.REJECTED })); + + await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException); + }); + + it('create: PUBLISHED exists same tenant → BadRequestException', async () => { + bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED })); + + await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException); + }); + + it('create: no existing bank → success', async () => { + bankRepo.findOne.mockResolvedValue(null); + + const result = await service.create(createDto, USER_ID, TENANT_ID); + expect(result).toBeDefined(); + }); + }); + + describe('remove', () => { + it('remove: DRAFT → success', async () => { + bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.DRAFT })); + + await expect(service.remove('bank-1')).resolves.toBeUndefined(); + }); + + it('remove: REJECTED → success', async () => { + bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.REJECTED })); + + await expect(service.remove('bank-1')).resolves.toBeUndefined(); + }); + + it('remove: PUBLISHED → ForbiddenException', async () => { + bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED })); + + await expect(service.remove('bank-1')).rejects.toThrow(ForbiddenException); + }); + }); + + describe('removeItem', () => { + it('removeItem: PENDING_REVIEW item → success', async () => { + bankRepo.findOne.mockResolvedValue(makeBank()); + itemRepo.findOne.mockResolvedValue(makeItem({ status: QuestionBankItemStatus.PENDING_REVIEW })); + + await expect(service.removeItem(BANK_ID, 'item-1')).resolves.toBeUndefined(); + }); + + it('removeItem: PUBLISHED item → ForbiddenException', async () => { + bankRepo.findOne.mockResolvedValue(makeBank()); + itemRepo.findOne.mockResolvedValue(makeItem({ status: QuestionBankItemStatus.PUBLISHED })); + + await expect(service.removeItem(BANK_ID, 'item-1')).rejects.toThrow(ForbiddenException); + }); + }); + + describe('generateQuestions', () => { + it('generateQuestions: PUBLISHED bank → ForbiddenException', async () => { + bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED })); + + await expect(service.generateQuestions('bank-1', 1, 'some content', TENANT_ID)) + .rejects.toThrow(ForbiddenException); + }); + + it('generateQuestions: PENDING_REVIEW bank → ForbiddenException', async () => { + bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PENDING_REVIEW })); + + await expect(service.generateQuestions('bank-1', 1, 'some content', TENANT_ID)) + .rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/server/src/assessment/services/question-bank.service.ts b/server/src/assessment/services/question-bank.service.ts index 42de9f1..79f2958 100644 --- a/server/src/assessment/services/question-bank.service.ts +++ b/server/src/assessment/services/question-bank.service.ts @@ -266,13 +266,11 @@ export class QuestionBankService { } if (createDto.templateId) { const existing = await this.bankRepository.findOne({ - where: { templateId: createDto.templateId }, + where: { templateId: createDto.templateId, tenantId: tenantId || undefined as any }, }); if (existing) { - if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED) { - await this.bankRepository.remove(existing); - } else { - throw new BadRequestException('该模板已关联有效题库,请编辑已有题库'); + if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED || existing.status === QuestionBankStatus.PUBLISHED) { + throw new BadRequestException('该模板已关联题库,请编辑已有题库或删除后重建'); } } } @@ -349,6 +347,9 @@ export class QuestionBankService { async remove(id: string): Promise { const bank = await this.findOne(id); + if (bank.status === QuestionBankStatus.PUBLISHED) { + throw new ForbiddenException('已发布的题库不可删除'); + } await this.bankRepository.remove(bank); } @@ -441,6 +442,9 @@ export class QuestionBankService { if (!item) { throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`); } + if (item.status === QuestionBankItemStatus.PUBLISHED) { + throw new ForbiddenException('已发布的题目不可删除'); + } await this.itemRepository.remove(item); } @@ -452,6 +456,10 @@ export class QuestionBankService { ): Promise { const bank = await this.findOne(bankId); + if (bank.status !== QuestionBankStatus.DRAFT) { + throw new ForbiddenException('仅草稿状态的题库可生成题目'); + } + if (count <= 0 || count > 50) { throw new BadRequestException('生成数量必须在 1-50 之间'); } diff --git a/web/components/views/QuestionBankDetailView.tsx b/web/components/views/QuestionBankDetailView.tsx index 231f732..2e6336e 100644 --- a/web/components/views/QuestionBankDetailView.tsx +++ b/web/components/views/QuestionBankDetailView.tsx @@ -335,6 +335,33 @@ export default function QuestionBankDetailView() { {itemStat.icon}{itemStat.label}

{item.questionText}

+ {item.questionType === 'MULTIPLE_CHOICE' && item.options && item.options.length > 0 && ( +
+ {item.options.map((opt, i) => { + const letter = String.fromCharCode(65 + i); + const isCorrect = item.correctAnswer === letter; + return ( +
+ {letter} + {opt} + {isCorrect && } +
+ ); + })} +
+ )} + {item.judgment && ( +
+ {item.questionType === 'MULTIPLE_CHOICE' ? '解析' : '判定依据'} +

{item.judgment}

+
+ )} + {item.questionType === 'SHORT_ANSWER' && item.followupHints && item.followupHints.length > 0 && ( +
+ 追问方向 + {item.followupHints.map((hint, i) => #{i + 1} {hint})} +
+ )} {item.keyPoints.length > 0 && (
{t('gradingPoints')} diff --git a/web/services/questionBankService.ts b/web/services/questionBankService.ts index 25bef9b..4a40ab8 100644 --- a/web/services/questionBankService.ts +++ b/web/services/questionBankService.ts @@ -17,6 +17,8 @@ export interface QuestionBankItem { questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE'; options?: string[] | null; correctAnswer?: string | null; + judgment?: string | null; + followupHints?: string[] | null; keyPoints: string[]; difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST'; dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';