feat: M2-02 — Workspace + KnowledgeBase + Folder management
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 11s
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:
parent
292e7e5638
commit
052cd5cba8
@ -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;
|
||||||
@ -132,6 +132,34 @@ model UserConsent {
|
|||||||
@@index([consentType])
|
@@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 {
|
model KnowledgeBase {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@ -39,4 +39,30 @@ export class KnowledgeBaseController {
|
|||||||
async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
||||||
return this.service.remove(String(user?.id || 'anonymous'), id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
|
|||||||
import { KnowledgeBaseController } from './knowledge-base.controller';
|
import { KnowledgeBaseController } from './knowledge-base.controller';
|
||||||
import { KnowledgeBaseService } from './knowledge-base.service';
|
import { KnowledgeBaseService } from './knowledge-base.service';
|
||||||
import { KnowledgeBaseRepository } from './knowledge-base.repository';
|
import { KnowledgeBaseRepository } from './knowledge-base.repository';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [KnowledgeBaseController],
|
controllers: [KnowledgeBaseController],
|
||||||
providers: [KnowledgeBaseService, KnowledgeBaseRepository],
|
providers: [KnowledgeBaseService, KnowledgeBaseRepository, PrismaService],
|
||||||
exports: [KnowledgeBaseService],
|
exports: [KnowledgeBaseService],
|
||||||
})
|
})
|
||||||
export class KnowledgeBaseModule {}
|
export class KnowledgeBaseModule {}
|
||||||
|
|||||||
@ -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 { 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';
|
import { MAX_KNOWLEDGE_BASE_COUNT } from './constants/knowledge-base.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KnowledgeBaseService {
|
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) {
|
async create(userId: string, dto: any) {
|
||||||
const count = await this.repository.countByUserId(userId);
|
const count = await this.repository.countByUserId(userId);
|
||||||
if (count >= MAX_KNOWLEDGE_BASE_COUNT) {
|
if (count >= MAX_KNOWLEDGE_BASE_COUNT) {
|
||||||
throw new BadRequestException('知识库数量已达到上限');
|
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);
|
return this.repository.create(userId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +41,10 @@ export class KnowledgeBaseService {
|
|||||||
if (!kb || String(kb.userId) !== userId) {
|
if (!kb || String(kb.userId) !== userId) {
|
||||||
throw new NotFoundException('知识库不存在');
|
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);
|
return this.repository.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,4 +55,32 @@ export class KnowledgeBaseService {
|
|||||||
}
|
}
|
||||||
return this.repository.softDelete(id);
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,4 +92,57 @@ describe('M2 E2E Tests', () => {
|
|||||||
expect(Array.isArray(res.body.data)).toBe(true);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -91,6 +91,7 @@ const modelNames = [
|
|||||||
'userMembership', 'quotaUsage', 'costDailySummary', 'secretRecord',
|
'userMembership', 'quotaUsage', 'costDailySummary', 'secretRecord',
|
||||||
'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent',
|
'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent',
|
||||||
'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest',
|
'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest',
|
||||||
|
'workspace', 'knowledgeFolder',
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const name of modelNames) {
|
for (const name of modelNames) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user