feat: M2-02 — Workspace + KnowledgeBase + Folder management
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 11s

- 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 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-24 11:23:58 +08:00
parent 292e7e5638
commit 052cd5cba8
7 changed files with 177 additions and 3 deletions

View File

@ -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;

View File

@ -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

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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 };
}
}

View File

@ -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');
});
});
});

View File

@ -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) {