02d89e6385
Add knowledge document GET/PUT and admin edit UI; auto-create support tickets on client 400 validation errors across user/shop/partner apps; batch create tasks and publish from support tickets; restore mini-user store env single-column layout. Co-authored-by: Cursor <cursoragent@cursor.com>
451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import type {
|
|
CreateKnowledgeBaseRequest,
|
|
CreateKnowledgeDocumentRequest,
|
|
KnowledgeBaseDto,
|
|
KnowledgeBaseOptionDto,
|
|
KnowledgeDocumentDetailDto,
|
|
KnowledgeDocumentDto,
|
|
UpdateKnowledgeBaseRequest,
|
|
UpdateKnowledgeDocumentRequest,
|
|
} from '@dukang/shared-types';
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
|
|
|
type ActorCtx = { actorId: bigint; isSuperAdmin: boolean };
|
|
|
|
const TEXT_EXT = /\.(txt|md|markdown|csv|json|log)$/i;
|
|
|
|
@Injectable()
|
|
export class AdminKnowledgeBasesService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async resolveActor(actorId: bigint): Promise<ActorCtx> {
|
|
const account = await this.prisma.hqAccount.findUnique({
|
|
where: { id: actorId },
|
|
select: { adminRole: true, status: true },
|
|
});
|
|
if (!account || account.status !== 'ACTIVE') {
|
|
throw new ForbiddenException('账号不可用');
|
|
}
|
|
return {
|
|
actorId,
|
|
isSuperAdmin: account.adminRole === 'SUPER_ADMIN',
|
|
};
|
|
}
|
|
|
|
async list(
|
|
actor: ActorCtx,
|
|
query: { name?: string; enabled?: string; page?: number; pageSize?: number },
|
|
) {
|
|
const page = query.page ?? 1;
|
|
const pageSize = query.pageSize ?? 20;
|
|
const where: {
|
|
name?: { contains: string };
|
|
enabled?: boolean;
|
|
createdByHqAccountId?: bigint;
|
|
} = {};
|
|
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
|
if (query.name?.trim()) where.name = { contains: query.name.trim() };
|
|
if (query.enabled === 'true' || query.enabled === 'false') {
|
|
where.enabled = query.enabled === 'true';
|
|
}
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.knowledgeBase.findMany({
|
|
where,
|
|
orderBy: [{ id: 'desc' }],
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
include: { _count: { select: { documents: true } } },
|
|
}),
|
|
this.prisma.knowledgeBase.count({ where }),
|
|
]);
|
|
|
|
const ownerIds = [...new Set(items.map((i) => i.createdByHqAccountId))];
|
|
const owners = ownerIds.length
|
|
? await this.prisma.hqAccount.findMany({
|
|
where: { id: { in: ownerIds } },
|
|
select: { id: true, name: true },
|
|
})
|
|
: [];
|
|
const ownerMap = new Map(owners.map((o) => [o.id.toString(), o.name]));
|
|
|
|
return serializeBigInt({
|
|
items: items.map((row) =>
|
|
this.toKbDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null, row._count.documents),
|
|
),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
});
|
|
}
|
|
|
|
async options(actor: ActorCtx): Promise<KnowledgeBaseOptionDto[]> {
|
|
const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true };
|
|
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
|
const rows = await this.prisma.knowledgeBase.findMany({
|
|
where,
|
|
orderBy: [{ id: 'desc' }],
|
|
include: { _count: { select: { documents: true } } },
|
|
});
|
|
return rows.map((r) => ({
|
|
id: r.id.toString(),
|
|
name: r.name,
|
|
enabled: r.enabled,
|
|
documentCount: r._count.documents,
|
|
}));
|
|
}
|
|
|
|
async detail(actor: ActorCtx, id: bigint) {
|
|
const row = await this.requireKb(actor, id);
|
|
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
|
|
const owner = await this.prisma.hqAccount.findUnique({
|
|
where: { id: row.createdByHqAccountId },
|
|
select: { name: true },
|
|
});
|
|
return this.toKbDto(row, actor, owner?.name ?? null, count);
|
|
}
|
|
|
|
async create(actor: ActorCtx, dto: CreateKnowledgeBaseRequest) {
|
|
const name = dto.name?.trim();
|
|
if (!name) throw new BadRequestException('请填写名称');
|
|
const row = await this.prisma.knowledgeBase.create({
|
|
data: {
|
|
name,
|
|
description: dto.description?.trim() || null,
|
|
enabled: dto.enabled !== false,
|
|
createdByHqAccountId: actor.actorId,
|
|
},
|
|
});
|
|
return this.toKbDto(row, actor, null, 0);
|
|
}
|
|
|
|
async update(actor: ActorCtx, id: bigint, dto: UpdateKnowledgeBaseRequest) {
|
|
const row = await this.requireKb(actor, id);
|
|
this.requireWrite(actor, row);
|
|
|
|
if (!actor.isSuperAdmin) {
|
|
// 创建人可改名称/描述/启用
|
|
const data: {
|
|
name?: string;
|
|
description?: string | null;
|
|
enabled?: boolean;
|
|
} = {};
|
|
if (dto.name !== undefined) {
|
|
const name = dto.name.trim();
|
|
if (!name) throw new BadRequestException('名称不能为空');
|
|
data.name = name;
|
|
}
|
|
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
|
|
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
|
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
|
|
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
|
|
return this.toKbDto(updated, actor, null, count);
|
|
}
|
|
|
|
const data: {
|
|
name?: string;
|
|
description?: string | null;
|
|
enabled?: boolean;
|
|
} = {};
|
|
if (dto.name !== undefined) {
|
|
const name = dto.name.trim();
|
|
if (!name) throw new BadRequestException('名称不能为空');
|
|
data.name = name;
|
|
}
|
|
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
|
|
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
|
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
|
|
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
|
|
return this.toKbDto(updated, actor, null, count);
|
|
}
|
|
|
|
async remove(actor: ActorCtx, id: bigint) {
|
|
const row = await this.requireKb(actor, id);
|
|
this.requireWrite(actor, row);
|
|
await this.prisma.knowledgeBase.delete({ where: { id } });
|
|
return { ok: true };
|
|
}
|
|
|
|
async listDocuments(actor: ActorCtx, kbId: bigint) {
|
|
await this.requireKb(actor, kbId);
|
|
const rows = await this.prisma.knowledgeDocument.findMany({
|
|
where: { knowledgeBaseId: kbId },
|
|
orderBy: [{ id: 'desc' }],
|
|
});
|
|
return serializeBigInt({ items: rows.map((r) => this.toDocDto(r)) });
|
|
}
|
|
|
|
async addDocument(actor: ActorCtx, kbId: bigint, dto: CreateKnowledgeDocumentRequest) {
|
|
const kb = await this.requireKb(actor, kbId);
|
|
this.requireWrite(actor, kb);
|
|
|
|
const title = dto.title?.trim();
|
|
if (!title) throw new BadRequestException('请填写标题');
|
|
|
|
let contentText = dto.contentText?.trim() || '';
|
|
let status: 'READY' | 'EMPTY' | 'FAILED' = 'EMPTY';
|
|
let errorMessage: string | null = null;
|
|
|
|
if (contentText) {
|
|
status = 'READY';
|
|
} else if (dto.fileUrl?.trim()) {
|
|
const fileName = dto.fileName?.trim() || '';
|
|
if (TEXT_EXT.test(fileName) || isLikelyTextMime(dto.mimeType)) {
|
|
try {
|
|
contentText = await fetchText(dto.fileUrl.trim());
|
|
status = contentText.trim() ? 'READY' : 'EMPTY';
|
|
if (!contentText.trim()) errorMessage = '文件内容为空';
|
|
} catch (e) {
|
|
status = 'FAILED';
|
|
errorMessage = e instanceof Error ? e.message : String(e);
|
|
}
|
|
} else {
|
|
status = 'EMPTY';
|
|
errorMessage = '非文本文件未抽取正文,请粘贴文本或上传 .txt/.md';
|
|
}
|
|
} else {
|
|
throw new BadRequestException('请粘贴正文或上传文件');
|
|
}
|
|
|
|
const row = await this.prisma.knowledgeDocument.create({
|
|
data: {
|
|
knowledgeBaseId: kbId,
|
|
title,
|
|
fileName: dto.fileName?.trim() || null,
|
|
fileUrl: dto.fileUrl?.trim() || null,
|
|
mimeType: dto.mimeType?.trim() || null,
|
|
sizeBytes: dto.sizeBytes ?? null,
|
|
contentText: contentText || null,
|
|
status,
|
|
errorMessage,
|
|
},
|
|
});
|
|
return this.toDocDto(row);
|
|
}
|
|
|
|
async getDocument(actor: ActorCtx, kbId: bigint, docId: bigint): Promise<KnowledgeDocumentDetailDto> {
|
|
await this.requireKb(actor, kbId);
|
|
const doc = await this.prisma.knowledgeDocument.findFirst({
|
|
where: { id: docId, knowledgeBaseId: kbId },
|
|
});
|
|
if (!doc) throw new NotFoundException('文档不存在');
|
|
return {
|
|
...this.toDocDto(doc),
|
|
contentText: doc.contentText,
|
|
};
|
|
}
|
|
|
|
async updateDocument(
|
|
actor: ActorCtx,
|
|
kbId: bigint,
|
|
docId: bigint,
|
|
dto: UpdateKnowledgeDocumentRequest,
|
|
) {
|
|
const kb = await this.requireKb(actor, kbId);
|
|
this.requireWrite(actor, kb);
|
|
const doc = await this.prisma.knowledgeDocument.findFirst({
|
|
where: { id: docId, knowledgeBaseId: kbId },
|
|
});
|
|
if (!doc) throw new NotFoundException('文档不存在');
|
|
|
|
const data: {
|
|
title?: string;
|
|
fileName?: string | null;
|
|
fileUrl?: string | null;
|
|
mimeType?: string | null;
|
|
sizeBytes?: number | null;
|
|
contentText?: string | null;
|
|
status?: 'READY' | 'EMPTY' | 'FAILED';
|
|
errorMessage?: string | null;
|
|
} = {};
|
|
|
|
if (dto.title !== undefined) {
|
|
const title = dto.title.trim();
|
|
if (!title) throw new BadRequestException('请填写标题');
|
|
data.title = title;
|
|
}
|
|
|
|
const hasContentInput =
|
|
dto.contentText !== undefined ||
|
|
dto.fileUrl !== undefined ||
|
|
dto.fileName !== undefined ||
|
|
dto.mimeType !== undefined;
|
|
|
|
if (hasContentInput) {
|
|
let contentText = dto.contentText !== undefined ? dto.contentText?.trim() || '' : doc.contentText?.trim() || '';
|
|
let status: 'READY' | 'EMPTY' | 'FAILED' = doc.status as 'READY' | 'EMPTY' | 'FAILED';
|
|
let errorMessage: string | null = doc.errorMessage;
|
|
|
|
if (dto.contentText !== undefined) {
|
|
if (contentText) {
|
|
status = 'READY';
|
|
errorMessage = null;
|
|
} else if (!dto.fileUrl?.trim() && !doc.fileUrl) {
|
|
status = 'EMPTY';
|
|
errorMessage = null;
|
|
}
|
|
data.contentText = contentText || null;
|
|
}
|
|
|
|
if (dto.fileUrl !== undefined) data.fileUrl = dto.fileUrl?.trim() || null;
|
|
if (dto.fileName !== undefined) data.fileName = dto.fileName?.trim() || null;
|
|
if (dto.mimeType !== undefined) data.mimeType = dto.mimeType?.trim() || null;
|
|
if (dto.sizeBytes !== undefined) data.sizeBytes = dto.sizeBytes ?? null;
|
|
|
|
const fileUrl = dto.fileUrl?.trim() || doc.fileUrl;
|
|
const fileName = dto.fileName?.trim() || doc.fileName || '';
|
|
const mimeType = dto.mimeType?.trim() || doc.mimeType;
|
|
|
|
if (dto.fileUrl?.trim() && dto.contentText === undefined) {
|
|
if (TEXT_EXT.test(fileName) || isLikelyTextMime(mimeType)) {
|
|
try {
|
|
contentText = await fetchText(dto.fileUrl.trim());
|
|
data.contentText = contentText || null;
|
|
status = contentText.trim() ? 'READY' : 'EMPTY';
|
|
errorMessage = contentText.trim() ? null : '文件内容为空';
|
|
} catch (e) {
|
|
status = 'FAILED';
|
|
errorMessage = e instanceof Error ? e.message : String(e);
|
|
}
|
|
} else {
|
|
status = 'EMPTY';
|
|
errorMessage = '非文本文件未抽取正文,请粘贴文本或上传 .txt/.md';
|
|
}
|
|
} else if (dto.contentText !== undefined && contentText) {
|
|
status = 'READY';
|
|
errorMessage = null;
|
|
} else if (dto.contentText !== undefined && !contentText && !fileUrl) {
|
|
status = 'EMPTY';
|
|
errorMessage = null;
|
|
}
|
|
|
|
data.status = status;
|
|
data.errorMessage = errorMessage;
|
|
}
|
|
|
|
const updated = await this.prisma.knowledgeDocument.update({
|
|
where: { id: docId },
|
|
data,
|
|
});
|
|
return this.toDocDto(updated);
|
|
}
|
|
|
|
async removeDocument(actor: ActorCtx, kbId: bigint, docId: bigint) {
|
|
const kb = await this.requireKb(actor, kbId);
|
|
this.requireWrite(actor, kb);
|
|
const doc = await this.prisma.knowledgeDocument.findFirst({
|
|
where: { id: docId, knowledgeBaseId: kbId },
|
|
});
|
|
if (!doc) throw new NotFoundException('文档不存在');
|
|
await this.prisma.knowledgeDocument.delete({ where: { id: docId } });
|
|
return { ok: true };
|
|
}
|
|
|
|
private async requireKb(actor: ActorCtx, id: bigint) {
|
|
const row = await this.prisma.knowledgeBase.findUnique({ where: { id } });
|
|
if (!row) throw new NotFoundException('知识库不存在');
|
|
if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) {
|
|
throw new ForbiddenException('无权查看该知识库');
|
|
}
|
|
return row;
|
|
}
|
|
|
|
private requireWrite(
|
|
actor: ActorCtx,
|
|
row: { createdByHqAccountId: bigint },
|
|
) {
|
|
if (actor.isSuperAdmin) return;
|
|
if (row.createdByHqAccountId !== actor.actorId) {
|
|
throw new ForbiddenException('只能操作自己创建的知识库');
|
|
}
|
|
}
|
|
|
|
private toKbDto(
|
|
row: {
|
|
id: bigint;
|
|
name: string;
|
|
description: string | null;
|
|
enabled: boolean;
|
|
createdByHqAccountId: bigint;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
},
|
|
actor: ActorCtx,
|
|
createdByName: string | null,
|
|
documentCount: number,
|
|
): KnowledgeBaseDto {
|
|
const isOwner = row.createdByHqAccountId === actor.actorId;
|
|
return {
|
|
id: row.id.toString(),
|
|
name: row.name,
|
|
description: row.description,
|
|
enabled: row.enabled,
|
|
documentCount,
|
|
createdByHqAccountId: row.createdByHqAccountId.toString(),
|
|
createdByName,
|
|
isOwner,
|
|
canEditFull: actor.isSuperAdmin || isOwner,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
private toDocDto(row: {
|
|
id: bigint;
|
|
knowledgeBaseId: bigint;
|
|
title: string;
|
|
fileName: string | null;
|
|
fileUrl: string | null;
|
|
mimeType: string | null;
|
|
sizeBytes: number | null;
|
|
contentText: string | null;
|
|
status: string;
|
|
errorMessage: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}): KnowledgeDocumentDto {
|
|
const status =
|
|
row.status === 'READY' || row.status === 'FAILED' || row.status === 'EMPTY'
|
|
? row.status
|
|
: 'EMPTY';
|
|
return {
|
|
id: row.id.toString(),
|
|
knowledgeBaseId: row.knowledgeBaseId.toString(),
|
|
title: row.title,
|
|
fileName: row.fileName,
|
|
fileUrl: row.fileUrl,
|
|
mimeType: row.mimeType,
|
|
sizeBytes: row.sizeBytes,
|
|
hasContent: !!(row.contentText && row.contentText.trim()),
|
|
status,
|
|
errorMessage: row.errorMessage,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
}
|
|
|
|
function isLikelyTextMime(mime?: string | null) {
|
|
if (!mime) return false;
|
|
return (
|
|
mime.startsWith('text/') ||
|
|
mime === 'application/json' ||
|
|
mime === 'application/markdown'
|
|
);
|
|
}
|
|
|
|
async function fetchText(url: string): Promise<string> {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`下载文件失败 HTTP ${res.status}`);
|
|
const buf = await res.arrayBuffer();
|
|
if (buf.byteLength > 2 * 1024 * 1024) throw new Error('文本文件超过 2MB');
|
|
return new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
|
}
|