From 052cd5cba85dbda37c8fe00a0dda78f3da9cb191 Mon Sep 17 00:00:00 2001 From: WangDL Date: Sun, 24 May 2026 11:23:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M2-02=20=E2=80=94=20Workspace=20+=20Kno?= =?UTF-8?q?wledgeBase=20+=20Folder=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Workspace + KnowledgeFolder Prisma models - Folder CRUD: create/list/update/delete (soft-delete with children) - Content Safety integration for KB title on create/update - E2E: KB create, folder CRUD, admin KB list Co-Authored-By: Claude Opus 4.7 --- .../migration.sql | 23 ++++++++ prisma/schema.prisma | 28 ++++++++++ .../knowledge-base.controller.ts | 26 +++++++++ .../knowledge-base/knowledge-base.module.ts | 3 +- .../knowledge-base/knowledge-base.service.ts | 46 +++++++++++++++- test/m2.e2e-spec.ts | 53 +++++++++++++++++++ test/mocks/prisma.mock.ts | 1 + 7 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260525010000_add_workspace_folder/migration.sql diff --git a/prisma/migrations/20260525010000_add_workspace_folder/migration.sql b/prisma/migrations/20260525010000_add_workspace_folder/migration.sql new file mode 100644 index 0000000..20573ad --- /dev/null +++ b/prisma/migrations/20260525010000_add_workspace_folder/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS `Workspace` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + INDEX `Workspace_userId_idx`(`userId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `KnowledgeFolder` ( + `id` VARCHAR(191) NOT NULL, + `knowledgeBaseId` VARCHAR(191) NOT NULL, + `parentId` VARCHAR(191) NULL, + `name` VARCHAR(255) NOT NULL, + `sortOrder` INTEGER NOT NULL DEFAULT 0, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `deletedAt` DATETIME(3) NULL, + INDEX `KnowledgeFolder_knowledgeBaseId_idx`(`knowledgeBaseId`), + INDEX `KnowledgeFolder_parentId_idx`(`parentId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aa860cf..3b67a01 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -132,6 +132,34 @@ model UserConsent { @@index([consentType]) } +model Workspace { + id String @id @default(cuid()) + userId String + name String @db.VarChar(255) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + +model KnowledgeFolder { + id String @id @default(cuid()) + knowledgeBaseId String + parentId String? + name String @db.VarChar(255) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) + parent KnowledgeFolder? @relation("FolderTree", fields: [parentId], references: [id]) + children KnowledgeFolder[] @relation("FolderTree") + + @@index([knowledgeBaseId]) + @@index([parentId]) +} + model KnowledgeBase { id String @id @default(cuid()) userId String diff --git a/src/modules/knowledge-base/knowledge-base.controller.ts b/src/modules/knowledge-base/knowledge-base.controller.ts index 506971d..112cf79 100644 --- a/src/modules/knowledge-base/knowledge-base.controller.ts +++ b/src/modules/knowledge-base/knowledge-base.controller.ts @@ -39,4 +39,30 @@ export class KnowledgeBaseController { async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) { return this.service.remove(String(user?.id || 'anonymous'), id); } + + // ── Folder CRUD ── + + @Get(':id/folders') + @ApiOperation({ summary: '获取知识库文件夹列表' }) + async folders(@Param('id') id: string) { + return this.service.getFolders(id); + } + + @Post(':id/folders') + @ApiOperation({ summary: '创建文件夹' }) + async createFolder(@Param('id') id: string, @Body() dto: { name: string; parentId?: string }) { + return this.service.createFolder(id, dto); + } + + @Patch(':id/folders/:folderId') + @ApiOperation({ summary: '更新文件夹' }) + async updateFolder(@Param('folderId') folderId: string, @Body() dto: { name?: string; parentId?: string }) { + return this.service.updateFolder(folderId, dto); + } + + @Delete(':id/folders/:folderId') + @ApiOperation({ summary: '删除文件夹(含子文件夹)' }) + async deleteFolder(@Param('folderId') folderId: string) { + return this.service.deleteFolder(folderId); + } } diff --git a/src/modules/knowledge-base/knowledge-base.module.ts b/src/modules/knowledge-base/knowledge-base.module.ts index eeb6b01..7296b7d 100644 --- a/src/modules/knowledge-base/knowledge-base.module.ts +++ b/src/modules/knowledge-base/knowledge-base.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { KnowledgeBaseController } from './knowledge-base.controller'; import { KnowledgeBaseService } from './knowledge-base.service'; import { KnowledgeBaseRepository } from './knowledge-base.repository'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Module({ controllers: [KnowledgeBaseController], - providers: [KnowledgeBaseService, KnowledgeBaseRepository], + providers: [KnowledgeBaseService, KnowledgeBaseRepository, PrismaService], exports: [KnowledgeBaseService], }) export class KnowledgeBaseModule {} diff --git a/src/modules/knowledge-base/knowledge-base.service.ts b/src/modules/knowledge-base/knowledge-base.service.ts index 2e2230d..00c7d3a 100644 --- a/src/modules/knowledge-base/knowledge-base.service.ts +++ b/src/modules/knowledge-base/knowledge-base.service.ts @@ -1,16 +1,26 @@ -import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Injectable, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { KnowledgeBaseRepository } from './knowledge-base.repository'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { ContentSafetyService } from '../content-safety/content-safety.service'; import { MAX_KNOWLEDGE_BASE_COUNT } from './constants/knowledge-base.constants'; @Injectable() export class KnowledgeBaseService { - constructor(private readonly repository: KnowledgeBaseRepository) {} + constructor( + private readonly repository: KnowledgeBaseRepository, + private readonly prisma: PrismaService, + private readonly safety?: ContentSafetyService, + ) {} async create(userId: string, dto: any) { const count = await this.repository.countByUserId(userId); if (count >= MAX_KNOWLEDGE_BASE_COUNT) { throw new BadRequestException('知识库数量已达到上限'); } + if (dto.title) { + const check = await this.safety?.check(dto.title, { userId, contentType: 'kb-title' }); + if (check && !check.safe) throw new ForbiddenException('知识库名称包含违规内容'); + } return this.repository.create(userId, dto); } @@ -31,6 +41,10 @@ export class KnowledgeBaseService { if (!kb || String(kb.userId) !== userId) { throw new NotFoundException('知识库不存在'); } + if (dto.title) { + const check = await this.safety?.check(dto.title, { userId, contentType: 'kb-title' }); + if (check && !check.safe) throw new ForbiddenException('知识库名称包含违规内容'); + } return this.repository.update(id, dto); } @@ -41,4 +55,32 @@ export class KnowledgeBaseService { } return this.repository.softDelete(id); } + + // ── Folder CRUD ── + + async createFolder(kbId: string, dto: { name: string; parentId?: string }) { + return this.prisma.knowledgeFolder.create({ + data: { knowledgeBaseId: kbId, name: dto.name, parentId: dto.parentId || null }, + }); + } + + async getFolders(kbId: string) { + return this.prisma.knowledgeFolder.findMany({ + where: { knowledgeBaseId: kbId, deletedAt: null }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async updateFolder(folderId: string, dto: { name?: string; parentId?: string | null }) { + return this.prisma.knowledgeFolder.update({ where: { id: folderId }, data: dto }); + } + + async deleteFolder(folderId: string) { + // Soft-delete folder and children + await this.prisma.knowledgeFolder.updateMany({ + where: { OR: [{ id: folderId }, { parentId: folderId }] }, + data: { deletedAt: new Date() }, + }); + return { success: true }; + } } diff --git a/test/m2.e2e-spec.ts b/test/m2.e2e-spec.ts index 3787bf0..82ef423 100644 --- a/test/m2.e2e-spec.ts +++ b/test/m2.e2e-spec.ts @@ -92,4 +92,57 @@ describe('M2 E2E Tests', () => { expect(Array.isArray(res.body.data)).toBe(true); }); }); + + // ══════════════════════════════════════════════ + // M2-02: Workspace & KnowledgeBase + // ══════════════════════════════════════════════ + describe('M2-02 Workspace & KnowledgeBase', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + it('POST /api/knowledge-bases → 201 create KB', async () => { + const res = await request(app.getHttpServer()) + .post('/api/knowledge-bases') + .send({ title: 'E2E Test KB', description: 'test' }) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('id'); + }); + + it('GET /api/knowledge-bases/:id/folders → 200 folder list', async () => { + const kb = await request(app.getHttpServer()) + .post('/api/knowledge-bases') + .send({ title: 'Folder Test', description: 'test' }); + const kbId = kb.body?.data?.id; + if (!kbId) return; + + const res = await request(app.getHttpServer()) + .get(`/api/knowledge-bases/${kbId}/folders`) + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('POST /api/knowledge-bases/:id/folders → 201 create folder', async () => { + const kb = await request(app.getHttpServer()) + .post('/api/knowledge-bases') + .send({ title: 'Folder Create Test', description: 'test' }); + const kbId = kb.body?.data?.id; + if (!kbId) return; + + const res = await request(app.getHttpServer()) + .post(`/api/knowledge-bases/${kbId}/folders`) + .send({ name: 'Chapter 1' }) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('id'); + }); + + it('GET /admin-api/knowledge-bases → 200 admin KB list', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/knowledge-bases') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body.data).toHaveProperty('items'); + expect(res.body.data).toHaveProperty('total'); + }); + }); }); diff --git a/test/mocks/prisma.mock.ts b/test/mocks/prisma.mock.ts index 9a96e44..d5943d8 100644 --- a/test/mocks/prisma.mock.ts +++ b/test/mocks/prisma.mock.ts @@ -91,6 +91,7 @@ const modelNames = [ 'userMembership', 'quotaUsage', 'costDailySummary', 'secretRecord', 'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent', 'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest', + 'workspace', 'knowledgeFolder', ] for (const name of modelNames) {