diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b9db9e3..c4352bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -166,6 +166,13 @@ model KnowledgeBase { title String @db.VarChar(255) description String? @db.Text coverKey String? @db.VarChar(100) + coverType String @default("custom") @db.VarChar(32) + coverIcon String? @db.VarChar(50) + coverColor String? @db.VarChar(20) + visibility String @default("private") @db.VarChar(16) + isPinned Boolean @default(false) + ownerType String @default("user") @db.VarChar(16) + isVerified Boolean @default(false) status String @default("active") @db.VarChar(32) itemCount Int @default(0) lastStudiedAt DateTime? @@ -173,16 +180,33 @@ model KnowledgeBase { updatedAt DateTime @updatedAt deletedAt DateTime? - user User @relation(fields: [userId], references: [id]) - items KnowledgeItem[] - sources KnowledgeSource[] - candidates ImportCandidate[] - chunks KnowledgeChunk[] - focusItems FocusItem[] - folders KnowledgeFolder[] + user User @relation(fields: [userId], references: [id]) + items KnowledgeItem[] + sources KnowledgeSource[] + candidates ImportCandidate[] + chunks KnowledgeChunk[] + focusItems FocusItem[] + folders KnowledgeFolder[] + subscriptions KnowledgeBaseSubscription[] @@index([userId]) @@index([status]) + @@index([visibility]) + @@index([ownerType]) +} + +model KnowledgeBaseSubscription { + id String @id @default(cuid()) + userId String + knowledgeBaseId String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) + + @@unique([userId, knowledgeBaseId]) + @@index([userId]) + @@index([knowledgeBaseId]) } model Artifact { diff --git a/src/modules/knowledge-base/knowledge-base.controller.ts b/src/modules/knowledge-base/knowledge-base.controller.ts index 112cf79..de26358 100644 --- a/src/modules/knowledge-base/knowledge-base.controller.ts +++ b/src/modules/knowledge-base/knowledge-base.controller.ts @@ -65,4 +65,56 @@ export class KnowledgeBaseController { async deleteFolder(@Param('folderId') folderId: string) { return this.service.deleteFolder(folderId); } + + // ── Pin ── + + @Post(':id/pin') + @ApiOperation({ summary: '切换置顶状态' }) + async togglePin(@CurrentUser() user: UserPayload, @Param('id') id: string) { + return this.service.togglePin(String(user?.id || 'anonymous'), id); + } + + // ── Visibility ── + + @Patch(':id/visibility') + @ApiOperation({ summary: '切换公开/私有' }) + async setVisibility( + @CurrentUser() user: UserPayload, + @Param('id') id: string, + @Body() dto: { visibility: string }, + ) { + return this.service.setVisibility(String(user?.id || 'anonymous'), id, dto.visibility); + } + + // ── Subscribe ── + + @Post(':id/subscribe') + @ApiOperation({ summary: '订阅知识库' }) + async subscribe(@CurrentUser() user: UserPayload, @Param('id') id: string) { + return this.service.subscribe(String(user?.id || 'anonymous'), id); + } + + @Delete(':id/subscribe') + @ApiOperation({ summary: '取消订阅' }) + async unsubscribe(@CurrentUser() user: UserPayload, @Param('id') id: string) { + return this.service.unsubscribe(String(user?.id || 'anonymous'), id); + } + + @Get('subscribed') + @ApiOperation({ summary: '已订阅知识库列表' }) + async listSubscribed(@CurrentUser() user: UserPayload, @Query() pagination: PaginationDto) { + return this.service.listSubscribed(String(user?.id || 'anonymous'), pagination); + } + + // ── Discover ── + + @Get('discover') + @ApiOperation({ summary: '发现公开知识库' }) + async discover(@Query('search') search?: string, @Query('page') page?: string, @Query('limit') limit?: string) { + return this.service.discoverPublic({ + page: Number(page) || 1, + limit: Math.min(Number(limit) || 20, 100), + search, + }); + } } diff --git a/src/modules/knowledge-base/knowledge-base.repository.ts b/src/modules/knowledge-base/knowledge-base.repository.ts index 76fdd04..199944c 100644 --- a/src/modules/knowledge-base/knowledge-base.repository.ts +++ b/src/modules/knowledge-base/knowledge-base.repository.ts @@ -5,13 +5,25 @@ import { PrismaService } from '../../infrastructure/database/prisma.service'; export class KnowledgeBaseRepository { constructor(private readonly prisma: PrismaService) {} - async create(userId: string, dto: { title: string; description?: string; coverKey?: string }) { + async create(userId: string, dto: { + title: string; + description?: string; + coverKey?: string; + coverType?: string; + coverIcon?: string; + coverColor?: string; + visibility?: string; + }) { return this.prisma.knowledgeBase.create({ data: { userId, title: dto.title, description: dto.description ?? '', coverKey: dto.coverKey ?? null, + coverType: dto.coverType ?? 'custom', + coverIcon: dto.coverIcon ?? null, + coverColor: dto.coverColor ?? null, + visibility: dto.visibility ?? 'private', status: 'active', itemCount: 0, }, @@ -22,12 +34,41 @@ export class KnowledgeBaseRepository { return this.prisma.knowledgeBase.findUnique({ where: { id } }); } - async findAllByUserId(userId: string, pagination?: { page?: number; limit?: number }) { - const page = pagination?.page ?? 1; - const limit = pagination?.limit ?? 20; + async findAllByUserId( + userId: string, + opts?: { + page?: number; + limit?: number; + visibility?: string; + ownerType?: string; + }, + ) { + const page = opts?.page ?? 1; + const limit = opts?.limit ?? 20; + const where: any = { userId, deletedAt: null }; + if (opts?.visibility) where.visibility = opts.visibility; + if (opts?.ownerType) where.ownerType = opts.ownerType; return this.prisma.knowledgeBase.findMany({ - where: { userId, deletedAt: null }, - orderBy: { updatedAt: 'desc' }, + where, + orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, + }); + } + + async findAllPublic(opts?: { page?: number; limit?: number; search?: string }) { + const page = opts?.page ?? 1; + const limit = opts?.limit ?? 20; + const where: any = { visibility: 'public', deletedAt: null }; + if (opts?.search) { + where.OR = [ + { title: { contains: opts.search } }, + { description: { contains: opts.search } }, + ]; + } + return this.prisma.knowledgeBase.findMany({ + where, + orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }], skip: (page - 1) * limit, take: limit, }); @@ -39,13 +80,36 @@ export class KnowledgeBaseRepository { }); } - async update(id: string, dto: { title?: string; description?: string }) { + async update(id: string, dto: { + title?: string; + description?: string; + coverKey?: string; + coverType?: string; + coverIcon?: string; + coverColor?: string; + visibility?: string; + isPinned?: boolean; + }) { return this.prisma.knowledgeBase.update({ where: { id }, data: dto, }); } + async togglePin(id: string, isPinned: boolean) { + return this.prisma.knowledgeBase.update({ + where: { id }, + data: { isPinned }, + }); + } + + async setVisibility(id: string, visibility: string) { + return this.prisma.knowledgeBase.update({ + where: { id }, + data: { visibility }, + }); + } + async softDelete(id: string) { await this.prisma.knowledgeBase.update({ where: { id }, @@ -53,4 +117,40 @@ export class KnowledgeBaseRepository { }); return true; } + + // ── Subscription ── + + async subscribe(userId: string, knowledgeBaseId: string) { + return this.prisma.knowledgeBaseSubscription.create({ + data: { userId, knowledgeBaseId }, + }); + } + + async unsubscribe(userId: string, knowledgeBaseId: string) { + await this.prisma.knowledgeBaseSubscription.deleteMany({ + where: { userId, knowledgeBaseId }, + }); + return true; + } + + async findSubscribed(userId: string, opts?: { page?: number; limit?: number }) { + const page = opts?.page ?? 1; + const limit = opts?.limit ?? 20; + return this.prisma.knowledgeBase.findMany({ + where: { + subscriptions: { some: { userId } }, + deletedAt: null, + }, + orderBy: { updatedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }); + } + + async isSubscribed(userId: string, knowledgeBaseId: string): Promise { + const sub = await this.prisma.knowledgeBaseSubscription.findUnique({ + where: { userId_knowledgeBaseId: { userId, knowledgeBaseId } }, + }); + return !!sub; + } } diff --git a/src/modules/knowledge-base/knowledge-base.service.ts b/src/modules/knowledge-base/knowledge-base.service.ts index ad65878..eac095c 100644 --- a/src/modules/knowledge-base/knowledge-base.service.ts +++ b/src/modules/knowledge-base/knowledge-base.service.ts @@ -26,8 +26,8 @@ export class KnowledgeBaseService { return this.enrichWithCoverUrl(await this.repository.create(userId, dto)); } - async findAll(userId: string, pagination: { page?: number; limit?: number }) { - const kbs = await this.repository.findAllByUserId(userId, pagination); + async findAll(userId: string, opts?: { page?: number; limit?: number; visibility?: string; ownerType?: string }) { + const kbs = await this.repository.findAllByUserId(userId, opts); return Promise.all(kbs.map(kb => this.enrichWithCoverUrl(kb))); } @@ -46,9 +46,11 @@ export class KnowledgeBaseService { async findOne(userId: string, id: string) { const kb = await this.repository.findById(id); - if (!kb || String(kb.userId) !== userId) { - throw new NotFoundException('知识库不存在'); - } + if (!kb || kb.deletedAt) throw new NotFoundException('知识库不存在'); + // 公开库允许任何人查看 + if (kb.visibility === 'public') return kb; + // 私有库只能 owner 查看 + if (String(kb.userId) !== userId) throw new NotFoundException('知识库不存在'); return kb; } @@ -72,6 +74,49 @@ export class KnowledgeBaseService { return this.repository.softDelete(id); } + // ── Pin ── + + async togglePin(userId: string, id: string) { + const kb = await this.repository.findById(id); + if (!kb || String(kb.userId) !== userId) throw new NotFoundException('知识库不存在'); + return this.repository.togglePin(id, !kb.isPinned); + } + + // ── Visibility ── + + async setVisibility(userId: string, id: string, visibility: string) { + const kb = await this.repository.findById(id); + if (!kb || String(kb.userId) !== userId) throw new NotFoundException('知识库不存在'); + if (!['private', 'public'].includes(visibility)) throw new BadRequestException('visibility 必须为 private 或 public'); + return this.repository.setVisibility(id, visibility); + } + + // ── Subscribe ── + + async subscribe(userId: string, knowledgeBaseId: string) { + const kb = await this.repository.findById(knowledgeBaseId); + if (!kb || kb.deletedAt) throw new NotFoundException('知识库不存在'); + if (kb.visibility !== 'public') throw new BadRequestException('只能订阅公开知识库'); + if (kb.userId === userId) throw new BadRequestException('不能订阅自己的知识库'); + return this.repository.subscribe(userId, knowledgeBaseId); + } + + async unsubscribe(userId: string, knowledgeBaseId: string) { + return this.repository.unsubscribe(userId, knowledgeBaseId); + } + + async listSubscribed(userId: string, opts?: { page?: number; limit?: number }) { + const kbs = await this.repository.findSubscribed(userId, opts); + return Promise.all(kbs.map(kb => this.enrichWithCoverUrl(kb))); + } + + // ── Discover ── + + async discoverPublic(opts?: { page?: number; limit?: number; search?: string }) { + const kbs = await this.repository.findAllPublic(opts); + return Promise.all(kbs.map(kb => this.enrichWithCoverUrl(kb))); + } + // ── Folder CRUD ── async createFolder(kbId: string, dto: { name: string; parentId?: string }) {