feat: Phase 1 & 2 — KnowledgeItem/KB model补齐 + API增强
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 43s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 43s
#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 <noreply@anthropic.com>
This commit is contained in:
parent
d7a7611b36
commit
9c14bda0c2
@ -244,6 +244,8 @@ model KnowledgeItem {
|
|||||||
sourceTitleSnapshot String? @db.VarChar(255)
|
sourceTitleSnapshot String? @db.VarChar(255)
|
||||||
sourceSnippetSnapshot String? @db.Text
|
sourceSnippetSnapshot String? @db.Text
|
||||||
orderIndex Int @default(0)
|
orderIndex Int @default(0)
|
||||||
|
durationSeconds Int @default(0)
|
||||||
|
fileSize BigInt?
|
||||||
status String @default("active") @db.VarChar(32)
|
status String @default("active") @db.VarChar(32)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@ -18,8 +18,17 @@ export class KnowledgeBaseController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取知识库列表' })
|
@ApiOperation({ summary: '获取知识库列表' })
|
||||||
async findAll(@CurrentUser() user: UserPayload, @Query() pagination: PaginationDto) {
|
async findAll(
|
||||||
return this.service.findAll(String(user?.id || 'anonymous'), pagination);
|
@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')
|
@Get(':id')
|
||||||
|
|||||||
@ -120,9 +120,27 @@ export class KnowledgeBaseService {
|
|||||||
// ── Folder CRUD ──
|
// ── Folder CRUD ──
|
||||||
|
|
||||||
async createFolder(kbId: string, dto: { name: string; parentId?: string }) {
|
async createFolder(kbId: string, dto: { name: string; parentId?: string }) {
|
||||||
return this.prisma.knowledgeFolder.create({
|
// 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 },
|
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) {
|
async getFolders(kbId: string) {
|
||||||
|
|||||||
@ -23,8 +23,12 @@ export class KnowledgeItemsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取知识库下的知识点列表' })
|
@ApiOperation({ summary: '获取知识库下的知识点列表' })
|
||||||
async findByKnowledgeBase(@Query('knowledgeBaseId') knowledgeBaseId: string) {
|
async findByKnowledgeBase(
|
||||||
return this.service.findByKnowledgeBaseId(knowledgeBaseId);
|
@Query('knowledgeBaseId') knowledgeBaseId: string,
|
||||||
|
@Query('sortBy') sortBy?: string,
|
||||||
|
@Query('order') order?: string,
|
||||||
|
) {
|
||||||
|
return this.service.findByKnowledgeBaseId(knowledgeBaseId, { sortBy, order });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
|||||||
@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { KnowledgeItemsController } from './knowledge-items.controller';
|
import { KnowledgeItemsController } from './knowledge-items.controller';
|
||||||
import { KnowledgeItemsService } from './knowledge-items.service';
|
import { KnowledgeItemsService } from './knowledge-items.service';
|
||||||
import { KnowledgeItemsRepository } from './knowledge-items.repository';
|
import { KnowledgeItemsRepository } from './knowledge-items.repository';
|
||||||
|
import { StorageModule } from '../../infrastructure/storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [StorageModule],
|
||||||
controllers: [KnowledgeItemsController],
|
controllers: [KnowledgeItemsController],
|
||||||
providers: [KnowledgeItemsService, KnowledgeItemsRepository],
|
providers: [KnowledgeItemsService, KnowledgeItemsRepository],
|
||||||
exports: [KnowledgeItemsService, KnowledgeItemsRepository],
|
exports: [KnowledgeItemsService, KnowledgeItemsRepository],
|
||||||
|
|||||||
@ -1,6 +1,45 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
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<string, string> = {
|
||||||
|
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()
|
@Injectable()
|
||||||
export class KnowledgeItemsRepository {
|
export class KnowledgeItemsRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@ -10,8 +49,12 @@ export class KnowledgeItemsRepository {
|
|||||||
content?: string;
|
content?: string;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
itemType?: string;
|
itemType?: string;
|
||||||
|
sourceType?: string;
|
||||||
|
durationSeconds?: number;
|
||||||
|
fileSize?: number;
|
||||||
orderIndex?: number;
|
orderIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
|
const sourceType = dto.sourceType || detectSourceType(dto.content, dto.title);
|
||||||
const item = await this.prisma.knowledgeItem.create({
|
const item = await this.prisma.knowledgeItem.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
@ -20,6 +63,9 @@ export class KnowledgeItemsRepository {
|
|||||||
content: dto.content ?? '',
|
content: dto.content ?? '',
|
||||||
parentId: dto.parentId ?? null,
|
parentId: dto.parentId ?? null,
|
||||||
itemType: dto.itemType ?? 'lesson',
|
itemType: dto.itemType ?? 'lesson',
|
||||||
|
sourceType,
|
||||||
|
durationSeconds: dto.durationSeconds ?? 0,
|
||||||
|
fileSize: dto.fileSize ?? null,
|
||||||
orderIndex: dto.orderIndex ?? 0,
|
orderIndex: dto.orderIndex ?? 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -37,10 +83,29 @@ export class KnowledgeItemsRepository {
|
|||||||
return this.prisma.knowledgeItem.findUnique({ where: { id } });
|
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({
|
return this.prisma.knowledgeItem.findMany({
|
||||||
where: { knowledgeBaseId, deletedAt: null },
|
where: { knowledgeBaseId, deletedAt: null },
|
||||||
orderBy: { orderIndex: 'asc' },
|
orderBy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { KnowledgeItemsRepository } from './knowledge-items.repository';
|
||||||
|
import { StorageService } from '../../infrastructure/storage/storage.service';
|
||||||
|
import { CosStorageProvider } from '../../infrastructure/storage/cos-storage.provider';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KnowledgeItemsService {
|
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) {
|
async create(userId: string, knowledgeBaseId: string, dto: any) {
|
||||||
return this.repository.create(userId, knowledgeBaseId, dto);
|
return this.repository.create(userId, knowledgeBaseId, dto);
|
||||||
@ -12,17 +18,59 @@ export class KnowledgeItemsService {
|
|||||||
async findById(id: string) {
|
async findById(id: string) {
|
||||||
const item = await this.repository.findById(id);
|
const item = await this.repository.findById(id);
|
||||||
if (!item) throw new NotFoundException('知识点不存在');
|
if (!item) throw new NotFoundException('知识点不存在');
|
||||||
return item;
|
return this.enrichItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByKnowledgeBaseId(knowledgeBaseId: string) {
|
async findByKnowledgeBaseId(knowledgeBaseId: string, opts?: { sortBy?: string; order?: string }) {
|
||||||
return this.repository.findByKnowledgeBaseId(knowledgeBaseId);
|
// 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://<bucket>.cos.<region>.myqcloud.com/<objectKey>?...
|
||||||
|
*/
|
||||||
|
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) {
|
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('知识点不存在');
|
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) {
|
async softDelete(id: string, userId: string) {
|
||||||
|
|||||||
@ -25,7 +25,16 @@ export class LearningSessionController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取学习会话列表' })
|
@ApiOperation({ summary: '获取学习会话列表' })
|
||||||
async findAll(@CurrentUser() user: UserPayload, @Query() pagination: PaginationDto) {
|
async findAll(
|
||||||
return this.service.findByUserId(String(user?.id || 'anonymous'), pagination);
|
@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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,12 +38,27 @@ export class LearningSessionRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserId(userId: string, pagination?: { page?: number; limit?: number }) {
|
async findByUserId(
|
||||||
const page = pagination?.page ?? 1;
|
userId: string,
|
||||||
const limit = pagination?.limit ?? 20;
|
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({
|
return this.prisma.learningSession.findMany({
|
||||||
where: { userId },
|
where,
|
||||||
orderBy: { startedAt: 'desc' },
|
orderBy,
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { LearningSessionRepository } from './learning-session.repository';
|
import { LearningSessionRepository } from './learning-session.repository';
|
||||||
import type { PaginationDto } from '../../common/dto/pagination.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LearningSessionService {
|
export class LearningSessionService {
|
||||||
@ -16,7 +15,7 @@ export class LearningSessionService {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserId(userId: string, pagination: PaginationDto) {
|
async findByUserId(userId: string, opts: { page?: number; limit?: number; status?: string; sort?: string }) {
|
||||||
return this.repository.findByUserId(userId, pagination);
|
return this.repository.findByUserId(userId, opts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user