From 9c14bda0c2daa199157e61c65e8a892433802ad9 Mon Sep 17 00:00:00 2001 From: wangdl Date: Fri, 5 Jun 2026 20:01:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20&=202=20=E2=80=94=20Knowled?= =?UTF-8?q?geItem/KB=20model=E8=A1=A5=E9=BD=90=20+=20API=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #61 KnowledgeItem sourceType 自动检测(COS URL/HTML/Markdown/扩展名) #59 KnowledgeItem 新增 durationSeconds 字段 #66 KnowledgeItem 新增 fileSize 字段,enrichItem 同步填充 COS 文件大小 #53 KnowledgeBase GET /knowledge-bases 支持 visibility/ownerType 查询筛选 #63 GET /knowledge-items 新增 sortBy/order 排序参数 #65 PATCH /knowledge-items/:id 支持 parentId 校验 #64 POST /knowledge-bases/:id/folders 同步创建 KnowledgeItem(itemType:folder) #62 GET /learning-sessions 新增 status/sort 筛选参数 #69 KnowledgeItem detail 动态刷新 COS 预签名 URL(7天有效) #60 GET /quizzes 跨知识库列表已实现,关 issue Co-Authored-By: Claude Opus 4.7 --- prisma/schema.prisma | 2 + .../knowledge-base.controller.ts | 13 +++- .../knowledge-base/knowledge-base.service.ts | 24 ++++++- .../knowledge-items.controller.ts | 8 ++- .../knowledge-items/knowledge-items.module.ts | 2 + .../knowledge-items.repository.ts | 69 ++++++++++++++++++- .../knowledge-items.service.ts | 62 +++++++++++++++-- .../learning-session.controller.ts | 13 +++- .../learning-session.repository.ts | 25 +++++-- .../learning-session.service.ts | 5 +- 10 files changed, 197 insertions(+), 26 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70ec18f..eea0bab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -244,6 +244,8 @@ model KnowledgeItem { sourceTitleSnapshot String? @db.VarChar(255) sourceSnippetSnapshot String? @db.Text orderIndex Int @default(0) + durationSeconds Int @default(0) + fileSize BigInt? status String @default("active") @db.VarChar(32) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/modules/knowledge-base/knowledge-base.controller.ts b/src/modules/knowledge-base/knowledge-base.controller.ts index de26358..c843341 100644 --- a/src/modules/knowledge-base/knowledge-base.controller.ts +++ b/src/modules/knowledge-base/knowledge-base.controller.ts @@ -18,8 +18,17 @@ export class KnowledgeBaseController { @Get() @ApiOperation({ summary: '获取知识库列表' }) - async findAll(@CurrentUser() user: UserPayload, @Query() pagination: PaginationDto) { - return this.service.findAll(String(user?.id || 'anonymous'), pagination); + async findAll( + @CurrentUser() user: UserPayload, + @Query() pagination: PaginationDto, + @Query('visibility') visibility?: string, + @Query('ownerType') ownerType?: string, + ) { + return this.service.findAll(String(user?.id || 'anonymous'), { + ...pagination, + visibility, + ownerType, + }); } @Get(':id') diff --git a/src/modules/knowledge-base/knowledge-base.service.ts b/src/modules/knowledge-base/knowledge-base.service.ts index eac095c..aafc01c 100644 --- a/src/modules/knowledge-base/knowledge-base.service.ts +++ b/src/modules/knowledge-base/knowledge-base.service.ts @@ -120,9 +120,27 @@ export class KnowledgeBaseService { // ── 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 }, - }); + // Also create a KnowledgeItem with itemType='folder' for iOS list compatibility + const kb = await this.repository.findById(kbId); + if (!kb) throw new NotFoundException('知识库不存在'); + + const [folder] = await Promise.all([ + this.prisma.knowledgeFolder.create({ + data: { knowledgeBaseId: kbId, name: dto.name, parentId: dto.parentId || null }, + }), + // Create corresponding KnowledgeItem so it shows in the iOS item list + this.prisma.knowledgeItem.create({ + data: { + userId: kb.userId, + knowledgeBaseId: kbId, + title: dto.name, + itemType: 'folder', + parentId: dto.parentId || null, + orderIndex: 0, + }, + }), + ]); + return folder; } async getFolders(kbId: string) { diff --git a/src/modules/knowledge-items/knowledge-items.controller.ts b/src/modules/knowledge-items/knowledge-items.controller.ts index b3ad39f..318ba8a 100644 --- a/src/modules/knowledge-items/knowledge-items.controller.ts +++ b/src/modules/knowledge-items/knowledge-items.controller.ts @@ -23,8 +23,12 @@ export class KnowledgeItemsController { @Get() @ApiOperation({ summary: '获取知识库下的知识点列表' }) - async findByKnowledgeBase(@Query('knowledgeBaseId') knowledgeBaseId: string) { - return this.service.findByKnowledgeBaseId(knowledgeBaseId); + async findByKnowledgeBase( + @Query('knowledgeBaseId') knowledgeBaseId: string, + @Query('sortBy') sortBy?: string, + @Query('order') order?: string, + ) { + return this.service.findByKnowledgeBaseId(knowledgeBaseId, { sortBy, order }); } @Patch(':id') diff --git a/src/modules/knowledge-items/knowledge-items.module.ts b/src/modules/knowledge-items/knowledge-items.module.ts index f559aff..91be161 100644 --- a/src/modules/knowledge-items/knowledge-items.module.ts +++ b/src/modules/knowledge-items/knowledge-items.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { KnowledgeItemsController } from './knowledge-items.controller'; import { KnowledgeItemsService } from './knowledge-items.service'; import { KnowledgeItemsRepository } from './knowledge-items.repository'; +import { StorageModule } from '../../infrastructure/storage/storage.module'; @Module({ + imports: [StorageModule], controllers: [KnowledgeItemsController], providers: [KnowledgeItemsService, KnowledgeItemsRepository], exports: [KnowledgeItemsService, KnowledgeItemsRepository], diff --git a/src/modules/knowledge-items/knowledge-items.repository.ts b/src/modules/knowledge-items/knowledge-items.repository.ts index 5d47693..acdf88a 100644 --- a/src/modules/knowledge-items/knowledge-items.repository.ts +++ b/src/modules/knowledge-items/knowledge-items.repository.ts @@ -1,6 +1,45 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; +function detectSourceType(content?: string | null, title?: string): string | null { + if (!content) return null; + + // COS presigned URL: extract extension from the path + if (content.includes('.cos.') && content.includes('myqcloud.com')) { + try { + const pathname = new URL(content).pathname.toLowerCase(); + const ext = pathname.split('.').pop(); + const extMap: Record = { + pdf: 'pdf', md: 'markdown', markdown: 'markdown', + txt: 'text', html: 'html', htm: 'html', + png: 'image', jpg: 'image', jpeg: 'image', webp: 'image', gif: 'image', + doc: 'word', docx: 'word', xls: 'excel', xlsx: 'excel', + ppt: 'powerpoint', pptx: 'powerpoint', epub: 'epub', + }; + if (ext && extMap[ext]) return extMap[ext]; + } catch { /* URL parse fail, fall through */ } + } + + // HTML content + if (content.trimStart().startsWith('<')) return 'html'; + + // Markdown patterns + if (/^#{1,6}\s/.test(content) || /^[-*]\s/.test(content) || /\[.+\]\(.+\)/.test(content)) { + return 'markdown'; + } + + // Title-based hint + if (title) { + const ext = title.split('.').pop()?.toLowerCase(); + if (ext === 'md') return 'markdown'; + if (ext === 'txt') return 'text'; + if (ext === 'pdf') return 'pdf'; + if (ext === 'html') return 'html'; + } + + return 'text'; +} + @Injectable() export class KnowledgeItemsRepository { constructor(private readonly prisma: PrismaService) {} @@ -10,8 +49,12 @@ export class KnowledgeItemsRepository { content?: string; parentId?: string; itemType?: string; + sourceType?: string; + durationSeconds?: number; + fileSize?: number; orderIndex?: number; }) { + const sourceType = dto.sourceType || detectSourceType(dto.content, dto.title); const item = await this.prisma.knowledgeItem.create({ data: { userId, @@ -20,6 +63,9 @@ export class KnowledgeItemsRepository { content: dto.content ?? '', parentId: dto.parentId ?? null, itemType: dto.itemType ?? 'lesson', + sourceType, + durationSeconds: dto.durationSeconds ?? 0, + fileSize: dto.fileSize ?? null, orderIndex: dto.orderIndex ?? 0, }, }); @@ -37,10 +83,29 @@ export class KnowledgeItemsRepository { return this.prisma.knowledgeItem.findUnique({ where: { id } }); } - async findByKnowledgeBaseId(knowledgeBaseId: string) { + async findByKnowledgeBaseId(knowledgeBaseId: string, opts?: { sortBy?: string; order?: string }) { + const sortBy = opts?.sortBy ?? 'default'; + const order = opts?.order === 'asc' ? 'asc' : 'desc'; + + let orderBy: any; + switch (sortBy) { + case 'createdAt': + orderBy = { createdAt: order }; + break; + case 'updatedAt': + orderBy = { updatedAt: order }; + break; + case 'fileSize': + orderBy = { fileSize: order }; + break; + default: + orderBy = { orderIndex: 'asc' }; + break; + } + return this.prisma.knowledgeItem.findMany({ where: { knowledgeBaseId, deletedAt: null }, - orderBy: { orderIndex: 'asc' }, + orderBy, }); } diff --git a/src/modules/knowledge-items/knowledge-items.service.ts b/src/modules/knowledge-items/knowledge-items.service.ts index f6c8975..9d6f492 100644 --- a/src/modules/knowledge-items/knowledge-items.service.ts +++ b/src/modules/knowledge-items/knowledge-items.service.ts @@ -1,9 +1,15 @@ -import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; import { KnowledgeItemsRepository } from './knowledge-items.repository'; +import { StorageService } from '../../infrastructure/storage/storage.service'; +import { CosStorageProvider } from '../../infrastructure/storage/cos-storage.provider'; @Injectable() export class KnowledgeItemsService { - constructor(private readonly repository: KnowledgeItemsRepository) {} + constructor( + private readonly repository: KnowledgeItemsRepository, + private readonly storage: StorageService, + private readonly cos: CosStorageProvider, + ) {} async create(userId: string, knowledgeBaseId: string, dto: any) { return this.repository.create(userId, knowledgeBaseId, dto); @@ -12,17 +18,59 @@ export class KnowledgeItemsService { async findById(id: string) { const item = await this.repository.findById(id); if (!item) throw new NotFoundException('知识点不存在'); - return item; + return this.enrichItem(item); } - async findByKnowledgeBaseId(knowledgeBaseId: string) { - return this.repository.findByKnowledgeBaseId(knowledgeBaseId); + async findByKnowledgeBaseId(knowledgeBaseId: string, opts?: { sortBy?: string; order?: string }) { + // List queries: return raw items (no URL refresh to avoid N COS API calls) + return this.repository.findByKnowledgeBaseId(knowledgeBaseId, opts); + } + + /** + * If the item's content is a COS pre-signed URL, refresh it with a new signature. + * COS objectKey format: https://.cos..myqcloud.com/?... + */ + private async enrichItem(item: any) { + if (!item?.content || !item.content.includes('.cos.') || !item.content.includes('myqcloud.com')) { + return item; + } + try { + const url = new URL(item.content); + const objectKey = url.pathname.slice(1); // remove leading '/' + if (!objectKey) return item; + + const [freshUrl, headInfo] = await Promise.all([ + this.storage.getDownloadUrl(objectKey, 7 * 86400), + this.cos.headObject(objectKey).catch(() => null), + ]); + + const enriched = { ...item, content: freshUrl }; + if (headInfo) { + enriched.fileSize = Number(headInfo.size); + enriched.sourceType = enriched.sourceType || headInfo.contentType; + } + return enriched; + } catch { + // If URL parsing fails, return original item unchanged + } + return item; } async update(id: string, dto: any) { - const item = await this.repository.update(id, dto); + const item = await this.repository.findById(id); if (!item) throw new NotFoundException('知识点不存在'); - return item; + + // Validate parentId if provided + if (dto.parentId !== undefined) { + if (dto.parentId !== null) { + const parent = await this.repository.findById(dto.parentId); + if (!parent || parent.knowledgeBaseId !== item.knowledgeBaseId) { + throw new BadRequestException('目标父节点不存在或不属于同一知识库'); + } + } + } + + return this.repository.update(id, dto); } async softDelete(id: string, userId: string) { diff --git a/src/modules/learning-session/learning-session.controller.ts b/src/modules/learning-session/learning-session.controller.ts index 74340e7..7c78086 100644 --- a/src/modules/learning-session/learning-session.controller.ts +++ b/src/modules/learning-session/learning-session.controller.ts @@ -25,7 +25,16 @@ export class LearningSessionController { @Get() @ApiOperation({ summary: '获取学习会话列表' }) - async findAll(@CurrentUser() user: UserPayload, @Query() pagination: PaginationDto) { - return this.service.findByUserId(String(user?.id || 'anonymous'), pagination); + async findAll( + @CurrentUser() user: UserPayload, + @Query() pagination: PaginationDto, + @Query('status') status?: string, + @Query('sort') sort?: string, + ) { + return this.service.findByUserId(String(user?.id || 'anonymous'), { + ...pagination, + status, + sort, + }); } } diff --git a/src/modules/learning-session/learning-session.repository.ts b/src/modules/learning-session/learning-session.repository.ts index 142f205..fb3ffb7 100644 --- a/src/modules/learning-session/learning-session.repository.ts +++ b/src/modules/learning-session/learning-session.repository.ts @@ -38,12 +38,27 @@ export class LearningSessionRepository { }); } - async findByUserId(userId: string, pagination?: { page?: number; limit?: number }) { - const page = pagination?.page ?? 1; - const limit = pagination?.limit ?? 20; + async findByUserId( + userId: string, + opts?: { page?: number; limit?: number; status?: string; sort?: string }, + ) { + const page = opts?.page ?? 1; + const limit = opts?.limit ?? 20; + const where: any = { userId }; + if (opts?.status) where.status = opts.status; + + // sort: startedAt:desc (default) | startedAt:asc | durationSeconds:desc + let orderBy: any = { startedAt: 'desc' }; + if (opts?.sort) { + const [field, dir] = opts.sort.split(':'); + if (['startedAt', 'durationSeconds', 'endedAt'].includes(field)) { + orderBy = { [field]: dir === 'asc' ? 'asc' : 'desc' }; + } + } + return this.prisma.learningSession.findMany({ - where: { userId }, - orderBy: { startedAt: 'desc' }, + where, + orderBy, skip: (page - 1) * limit, take: limit, }); diff --git a/src/modules/learning-session/learning-session.service.ts b/src/modules/learning-session/learning-session.service.ts index 0cecf12..324e134 100644 --- a/src/modules/learning-session/learning-session.service.ts +++ b/src/modules/learning-session/learning-session.service.ts @@ -1,6 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { LearningSessionRepository } from './learning-session.repository'; -import type { PaginationDto } from '../../common/dto/pagination.dto'; @Injectable() export class LearningSessionService { @@ -16,7 +15,7 @@ export class LearningSessionService { return session; } - async findByUserId(userId: string, pagination: PaginationDto) { - return this.repository.findByUserId(userId, pagination); + async findByUserId(userId: string, opts: { page?: number; limit?: number; status?: string; sort?: string }) { + return this.repository.findByUserId(userId, opts); } }