8686d101cd
## 已实现功能 - 题库管理后端API完整实现 - 模板管理页面(Settings-测评模板) - 评估统计页面 - 人才测评页面(AssessmentView) - QuestionBank前端服务层 ## 技术栈 - 后端: Node.js + NestJS + TypeORM - 前端: React + TypeScript - 容器化: Docker Compose ## 已知待完善 - 题库列表页缺少删除按钮 - 题库详情页未实现(题目管理/AI生成/审核)
413 lines
11 KiB
TypeScript
413 lines
11 KiB
TypeScript
import {
|
|
Injectable,
|
|
NotFoundException,
|
|
ForbiddenException,
|
|
Inject,
|
|
forwardRef,
|
|
} from '@nestjs/common';
|
|
import { I18nService } from '../i18n/i18n.service';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, In } from 'typeorm';
|
|
import { KnowledgeGroup } from './knowledge-group.entity';
|
|
import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
|
|
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
|
import { TenantService } from '../tenant/tenant.service';
|
|
|
|
export interface CreateGroupDto {
|
|
name: string;
|
|
description?: string;
|
|
color?: string;
|
|
parentId?: string | null;
|
|
}
|
|
|
|
export interface UpdateGroupDto {
|
|
name?: string;
|
|
description?: string;
|
|
color?: string;
|
|
parentId?: string | null;
|
|
}
|
|
|
|
export interface GroupWithFileCount {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
color: string;
|
|
fileCount: number;
|
|
parentId?: string | null;
|
|
children?: GroupWithFileCount[];
|
|
createdAt: Date;
|
|
}
|
|
|
|
export interface PaginatedGroups {
|
|
items: GroupWithFileCount[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
}
|
|
|
|
@Injectable()
|
|
export class KnowledgeGroupService {
|
|
constructor(
|
|
@InjectRepository(KnowledgeGroup)
|
|
private groupRepository: Repository<KnowledgeGroup>,
|
|
@InjectRepository(KnowledgeBase)
|
|
private knowledgeBaseRepository: Repository<KnowledgeBase>,
|
|
@Inject(forwardRef(() => KnowledgeBaseService))
|
|
private knowledgeBaseService: KnowledgeBaseService,
|
|
private tenantService: TenantService,
|
|
private i18nService: I18nService,
|
|
) {}
|
|
|
|
async findAll(
|
|
userId: string,
|
|
tenantId: string,
|
|
): Promise<GroupWithFileCount[]> {
|
|
console.log('[KnowledgeGroup findAll] userId:', userId, 'tenantId:', tenantId);
|
|
// Return all groups for the tenant with file counts
|
|
const queryBuilder = this.groupRepository
|
|
.createQueryBuilder('group')
|
|
.leftJoin('group.knowledgeBases', 'kb');
|
|
|
|
if (tenantId === null) {
|
|
queryBuilder.where('group.tenantId IS NULL');
|
|
} else {
|
|
queryBuilder.where('group.tenantId = :tenantId', { tenantId });
|
|
}
|
|
|
|
const groups = await queryBuilder
|
|
.addSelect('COUNT(kb.id)', 'fileCount')
|
|
.groupBy('group.id')
|
|
.orderBy('group.createdAt', 'ASC')
|
|
.getRawAndEntities();
|
|
|
|
const flatList: GroupWithFileCount[] = groups.entities.map(
|
|
(group, index) => ({
|
|
id: group.id,
|
|
name: group.name,
|
|
description: group.description,
|
|
color: group.color,
|
|
parentId: group.parentId ?? null,
|
|
fileCount: parseInt(groups.raw[index].fileCount) || 0,
|
|
createdAt: group.createdAt,
|
|
children: [],
|
|
}),
|
|
);
|
|
|
|
// Build tree structure
|
|
return this.buildTree(flatList);
|
|
}
|
|
|
|
/** Build a nested tree from a flat list */
|
|
private buildTree(items: GroupWithFileCount[]): GroupWithFileCount[] {
|
|
const map = new Map<string, GroupWithFileCount>();
|
|
items.forEach((item) => map.set(item.id, { ...item, children: [] }));
|
|
|
|
const roots: GroupWithFileCount[] = [];
|
|
map.forEach((item) => {
|
|
if (item.parentId && map.has(item.parentId)) {
|
|
map.get(item.parentId)!.children!.push(item);
|
|
} else {
|
|
roots.push(item);
|
|
}
|
|
});
|
|
return roots;
|
|
}
|
|
|
|
async findOne(
|
|
id: string,
|
|
userId: string,
|
|
tenantId: string,
|
|
): Promise<KnowledgeGroup> {
|
|
const group = await this.groupRepository.findOne({
|
|
where: { id },
|
|
relations: ['knowledgeBases'],
|
|
});
|
|
|
|
if (!group) {
|
|
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
|
}
|
|
|
|
// Check permission using TenantService
|
|
const hasAccess = await this.tenantService.canAccessTenant(
|
|
userId,
|
|
group.tenantId,
|
|
tenantId,
|
|
);
|
|
if (!hasAccess) {
|
|
throw new ForbiddenException(
|
|
`You do not have permission to access this knowledge group`,
|
|
);
|
|
}
|
|
|
|
return group;
|
|
}
|
|
|
|
async create(
|
|
userId: string,
|
|
tenantId: string,
|
|
createGroupDto: CreateGroupDto,
|
|
): Promise<KnowledgeGroup> {
|
|
console.log('[KnowledgeGroup create] userId:', userId, 'tenantId:', tenantId);
|
|
const group = this.groupRepository.create({
|
|
...createGroupDto,
|
|
parentId: createGroupDto.parentId ?? null,
|
|
tenantId,
|
|
});
|
|
|
|
const saved = await this.groupRepository.save(group);
|
|
console.log('[KnowledgeGroup create] saved group tenantId:', saved.tenantId);
|
|
return saved;
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
userId: string,
|
|
tenantId: string,
|
|
updateGroupDto: UpdateGroupDto,
|
|
): Promise<KnowledgeGroup> {
|
|
// Update group within the tenant
|
|
const group = await this.groupRepository.findOne({
|
|
where: { id, tenantId },
|
|
});
|
|
|
|
if (!group) {
|
|
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
|
}
|
|
|
|
Object.assign(group, updateGroupDto);
|
|
return await this.groupRepository.save(group);
|
|
}
|
|
|
|
async remove(id: string, userId: string, tenantId: string): Promise<void> {
|
|
const group = await this.groupRepository.findOne({
|
|
where: { id, tenantId },
|
|
});
|
|
|
|
if (!group) {
|
|
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
|
}
|
|
|
|
// Recursively delete this group and all its descendants
|
|
await this.removeGroupRecursive(id, userId, tenantId);
|
|
}
|
|
|
|
/** Recursively delete a group, all its children, and all associated files */
|
|
private async removeGroupRecursive(
|
|
id: string,
|
|
userId: string,
|
|
tenantId: string,
|
|
): Promise<void> {
|
|
// 1. Find all direct children of this group
|
|
const children = await this.groupRepository.find({
|
|
where: { parentId: id, tenantId },
|
|
});
|
|
|
|
// 2. Recurse into each child first (depth-first)
|
|
for (const child of children) {
|
|
await this.removeGroupRecursive(child.id, userId, tenantId);
|
|
}
|
|
|
|
// 3. Delete all files belonging to this group
|
|
const files = await this.knowledgeBaseRepository
|
|
.createQueryBuilder('kb')
|
|
.innerJoin('kb.groups', 'group')
|
|
.where('group.id = :groupId', { groupId: id })
|
|
.select('kb.id')
|
|
.getMany();
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const fullFile = await this.knowledgeBaseRepository.findOne({
|
|
where: { id: file.id },
|
|
select: ['id', 'userId', 'tenantId'],
|
|
});
|
|
if (fullFile) {
|
|
await this.knowledgeBaseService.deleteFile(
|
|
fullFile.id,
|
|
fullFile.userId,
|
|
fullFile.tenantId,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to delete file ${file.id} when deleting group ${id}`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 4. Delete the group itself
|
|
const group = await this.groupRepository.findOne({ where: { id } });
|
|
if (group) {
|
|
await this.groupRepository.remove(group);
|
|
}
|
|
}
|
|
|
|
async getGroupFiles(
|
|
groupId: string,
|
|
userId: string,
|
|
tenantId: string,
|
|
): Promise<KnowledgeBase[]> {
|
|
const group = await this.groupRepository.findOne({
|
|
where: { id: groupId },
|
|
relations: ['knowledgeBases'],
|
|
});
|
|
|
|
if (!group) {
|
|
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
|
}
|
|
|
|
// Check permission using TenantService
|
|
const hasAccess = await this.tenantService.canAccessTenant(
|
|
userId,
|
|
group.tenantId,
|
|
tenantId,
|
|
);
|
|
if (!hasAccess) {
|
|
throw new ForbiddenException(
|
|
`You do not have permission to access this knowledge group`,
|
|
);
|
|
}
|
|
|
|
return group.knowledgeBases;
|
|
}
|
|
|
|
async addFilesToGroup(
|
|
fileId: string,
|
|
groupIds: string[],
|
|
userId: string,
|
|
tenantId: string,
|
|
): Promise<void> {
|
|
const file = await this.knowledgeBaseRepository.findOne({
|
|
where: { id: fileId },
|
|
relations: ['groups'],
|
|
});
|
|
|
|
if (!file) {
|
|
throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
|
|
}
|
|
|
|
// Check permission for file
|
|
const fileAccess = await this.tenantService.canAccessTenant(
|
|
userId,
|
|
file.tenantId,
|
|
tenantId,
|
|
);
|
|
if (!fileAccess) {
|
|
throw new ForbiddenException(
|
|
`You do not have permission to modify this knowledge base`,
|
|
);
|
|
}
|
|
|
|
// Load all groups by ID without user restriction
|
|
const groups = await this.groupRepository.findBy({ id: In(groupIds) });
|
|
// Verify each group access
|
|
for (const g of groups) {
|
|
const gAccess = await this.tenantService.canAccessTenant(
|
|
userId,
|
|
g.tenantId,
|
|
tenantId,
|
|
);
|
|
if (!gAccess) {
|
|
throw new ForbiddenException(
|
|
`You do not have permission to access group ${g.name}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (groupIds.length > 0 && groups.length !== groupIds.length) {
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('someGroupsNotFound'),
|
|
);
|
|
}
|
|
|
|
file.groups = groups;
|
|
await this.knowledgeBaseRepository.save(file);
|
|
}
|
|
|
|
async removeFileFromGroup(
|
|
fileId: string,
|
|
groupId: string,
|
|
userId: string,
|
|
tenantId: string,
|
|
): Promise<void> {
|
|
const file = await this.knowledgeBaseRepository.findOne({
|
|
where: { id: fileId },
|
|
relations: ['groups'],
|
|
});
|
|
|
|
if (!file) {
|
|
throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
|
|
}
|
|
|
|
// Check permission for file
|
|
const fileAccess = await this.tenantService.canAccessTenant(
|
|
userId,
|
|
file.tenantId,
|
|
tenantId,
|
|
);
|
|
if (!fileAccess) {
|
|
throw new ForbiddenException(
|
|
`You do not have permission to modify this knowledge base`,
|
|
);
|
|
}
|
|
|
|
file.groups = file.groups.filter((group) => group.id !== groupId);
|
|
await this.knowledgeBaseRepository.save(file);
|
|
}
|
|
|
|
async getFileIdsByGroups(
|
|
groupIds: string[],
|
|
userId: string,
|
|
tenantId: string,
|
|
): Promise<string[]> {
|
|
if (!groupIds || groupIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Security check: Verify user has access to these groups
|
|
const groups = await this.groupRepository.findBy({ id: In(groupIds) });
|
|
for (const g of groups) {
|
|
const hasAccess = await this.tenantService.canAccessTenant(
|
|
userId,
|
|
g.tenantId,
|
|
tenantId,
|
|
);
|
|
if (!hasAccess) {
|
|
throw new ForbiddenException(
|
|
`You do not have permission to access group ${g.name}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const result = await this.knowledgeBaseRepository
|
|
.createQueryBuilder('kb')
|
|
.innerJoin('kb.groups', 'group')
|
|
.where('group.id IN (:...groupIds)', { groupIds })
|
|
// No extra tenantId check here because we verified the groups above
|
|
.select('DISTINCT kb.id', 'id')
|
|
.getRawMany();
|
|
|
|
return result.map((row) => row.id);
|
|
}
|
|
|
|
/**
|
|
* Find or create a group by name and parentId within a tenant.
|
|
* Used by import tasks to build folder hierarchy.
|
|
*/
|
|
async findOrCreate(
|
|
userId: string,
|
|
tenantId: string,
|
|
name: string,
|
|
parentId: string | null,
|
|
description?: string,
|
|
): Promise<KnowledgeGroup> {
|
|
const existing = await this.groupRepository.findOne({
|
|
where: { name, tenantId, parentId: parentId ?? undefined },
|
|
});
|
|
if (existing) return existing;
|
|
|
|
return this.create(userId, tenantId, { name, description, parentId });
|
|
}
|
|
}
|