fix: 补齐知识库元数据管理与生命周期校验
This commit is contained in:
@@ -242,6 +242,11 @@ async function switchMenu(menu: string) {
|
|||||||
await loadCurrentMenu();
|
await loadCurrentMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function previewKnowledge(knowledgeId: number) {
|
||||||
|
await switchMenu("prompt");
|
||||||
|
agentForm.knowledgeIds = [knowledgeId];
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end);
|
stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end);
|
||||||
}
|
}
|
||||||
@@ -955,7 +960,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="activeMenu === 'knowledge'">
|
<template v-if="activeMenu === 'knowledge'">
|
||||||
<KnowledgeManagementView />
|
<KnowledgeManagementView @preview="previewKnowledge" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="activeMenu === 'prompt'">
|
<template v-if="activeMenu === 'prompt'">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
MigrationSummary,
|
MigrationSummary,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
|
const emit = defineEmits<{ preview: [knowledgeId: number] }>();
|
||||||
|
|
||||||
const rows = ref<KnowledgeItem[]>([]);
|
const rows = ref<KnowledgeItem[]>([]);
|
||||||
const migration = ref<MigrationSummary | null>(null);
|
const migration = ref<MigrationSummary | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -22,6 +24,11 @@ const versions = ref<KnowledgeVersion[]>([]);
|
|||||||
const jobs = ref<KnowledgeSyncJob[]>([]);
|
const jobs = ref<KnowledgeSyncJob[]>([]);
|
||||||
const versionContent = ref<KnowledgeVersionContent | null>(null);
|
const versionContent = ref<KnowledgeVersionContent | null>(null);
|
||||||
const activeTab = ref("overview");
|
const activeTab = ref("overview");
|
||||||
|
const selectedRows = ref<KnowledgeItem[]>([]);
|
||||||
|
const editOpen = ref(false);
|
||||||
|
const editSaving = ref(false);
|
||||||
|
const batchOpen = ref(false);
|
||||||
|
const batchSaving = ref(false);
|
||||||
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
|
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -32,6 +39,8 @@ const form = reactive({
|
|||||||
status: 0,
|
status: 0,
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" });
|
||||||
|
const batchForm = reactive({ knowledgeType: "", reviewMode: "" });
|
||||||
|
|
||||||
const filteredRows = computed(() => rows.value.filter((item) => {
|
const filteredRows = computed(() => rows.value.filter((item) => {
|
||||||
if (filter.keyword && !`${item.name}${item.sourceTitle || ""}`.includes(filter.keyword)) return false;
|
if (filter.keyword && !`${item.name}${item.sourceTitle || ""}`.includes(filter.keyword)) return false;
|
||||||
@@ -49,27 +58,131 @@ async function load() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
[rows.value, migration.value] = await Promise.all([api.knowledge(), api.migrationSummary()]);
|
[rows.value, migration.value] = await Promise.all([api.knowledge(), api.migrationSummary()]);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "知识库列表加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
Object.assign(editForm, {
|
||||||
|
id: detail.value.knowledge.id,
|
||||||
|
name: detail.value.knowledge.name,
|
||||||
|
knowledgeType: detail.value.knowledge.knowledgeType,
|
||||||
|
reviewMode: detail.value.knowledge.reviewMode,
|
||||||
|
});
|
||||||
|
editOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMetadata() {
|
||||||
|
if (!editForm.name.trim()) {
|
||||||
|
ElMessage.warning("请输入后台知识库名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editSaving.value = true;
|
||||||
|
try {
|
||||||
|
await api.updateKnowledgeMetadata(editForm.id, {
|
||||||
|
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType, reviewMode: editForm.reviewMode,
|
||||||
|
});
|
||||||
|
ElMessage.success("知识库基本信息已更新");
|
||||||
|
editOpen.value = false;
|
||||||
|
await Promise.all([load(), refreshDetail(editForm.id)]);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "基本信息保存失败"));
|
||||||
|
} finally {
|
||||||
|
editSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchEdit() {
|
||||||
|
if (!selectedRows.value.length) {
|
||||||
|
ElMessage.warning("请先勾选需要修改的知识库");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.assign(batchForm, { knowledgeType: "", reviewMode: "" });
|
||||||
|
batchOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBatchMetadata() {
|
||||||
|
if (!batchForm.knowledgeType && !batchForm.reviewMode) {
|
||||||
|
ElMessage.warning("请至少选择一个修改项");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await confirmAction(
|
||||||
|
`将修改已选择的 ${selectedRows.value.length} 个知识库。该操作不影响正式版本和开放状态。`,
|
||||||
|
"确认批量修改",
|
||||||
|
{ confirmButtonText: "确认修改", cancelButtonText: "取消", type: "warning" },
|
||||||
|
)) return;
|
||||||
|
batchSaving.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.batchUpdateKnowledgeMetadata({
|
||||||
|
knowledgeIds: selectedRows.value.map((item) => item.id),
|
||||||
|
knowledgeType: batchForm.knowledgeType || undefined,
|
||||||
|
reviewMode: batchForm.reviewMode || undefined,
|
||||||
|
});
|
||||||
|
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed} 个`);
|
||||||
|
batchOpen.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "批量修改失败"));
|
||||||
|
} finally {
|
||||||
|
batchSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMore(command: string, item: KnowledgeItem) {
|
||||||
|
try {
|
||||||
|
if (command === "detail") return await openDetail(item);
|
||||||
|
if (command === "preview") return emit("preview", item.id);
|
||||||
|
if (command === "versions") {
|
||||||
|
await openDetail(item);
|
||||||
|
if (detailOpen.value) activeTab.value = "versions";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "archive") return await archive(item);
|
||||||
|
if (command === "restore") return await restore(item);
|
||||||
|
if (command === "delete") return await remove(item);
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== "cancel" && error !== "close") ElMessage.error(errorMessage(error, "操作失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore(item: KnowledgeItem) {
|
||||||
|
if (!await confirmAction("恢复后知识库仍保持关闭,需要单独确认开放。", "恢复知识库", {
|
||||||
|
confirmButtonText: "确认恢复", cancelButtonText: "取消", type: "warning",
|
||||||
|
})) return;
|
||||||
|
await api.setKnowledgeLifecycle(item.id, "active");
|
||||||
|
ElMessage.success("知识库已恢复,当前仍为关闭状态");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
async function createKnowledge() {
|
async function createKnowledge() {
|
||||||
if (!form.name || !form.feishuSpaceId || !form.feishuNodeId) {
|
if (!form.name || !form.feishuSpaceId || !form.feishuNodeId) {
|
||||||
ElMessage.warning("请填写名称、SpaceID 和 NodeID");
|
ElMessage.warning("请填写名称、SpaceID 和 NodeID");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await api.createKnowledge({ ...form });
|
try {
|
||||||
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
|
await api.createKnowledge({ ...form });
|
||||||
ElMessage.success("知识库已新增,请进行首次同步");
|
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
|
||||||
await load();
|
ElMessage.success("知识库已新增,请进行首次同步");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "知识库新增失败"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openDetail(item: KnowledgeItem) {
|
async function openDetail(item: KnowledgeItem) {
|
||||||
detailOpen.value = true;
|
detailOpen.value = true;
|
||||||
activeTab.value = "overview";
|
activeTab.value = "overview";
|
||||||
versionContent.value = null;
|
versionContent.value = null;
|
||||||
await refreshDetail(item.id);
|
try {
|
||||||
|
await refreshDetail(item.id);
|
||||||
|
} catch (error) {
|
||||||
|
detailOpen.value = false;
|
||||||
|
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDetail(id: number) {
|
async function refreshDetail(id: number) {
|
||||||
@@ -81,26 +194,35 @@ async function refreshDetail(id: number) {
|
|||||||
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
|
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
|
||||||
operatingId.value = item.id;
|
operatingId.value = item.id;
|
||||||
try {
|
try {
|
||||||
await api.syncKnowledge(item.id, mode);
|
const result = await api.syncKnowledge(item.id, mode);
|
||||||
ElMessage.success("同步处理完成,已生成待审核版本");
|
const message = result.status === "unchanged"
|
||||||
|
? "飞书内容未变化,未生成新版本"
|
||||||
|
: result.status === "published"
|
||||||
|
? "同步、质检和自动发布已完成"
|
||||||
|
: "同步处理完成,已生成待审核版本";
|
||||||
|
ElMessage.success(message);
|
||||||
await load();
|
await load();
|
||||||
if (detailOpen.value) await refreshDetail(item.id);
|
if (detailOpen.value) await refreshDetail(item.id);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "知识库同步失败"));
|
||||||
} finally {
|
} finally {
|
||||||
operatingId.value = null;
|
operatingId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runMigration() {
|
async function runMigration() {
|
||||||
await ElMessageBox.confirm(
|
if (!await confirmAction(
|
||||||
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。",
|
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。",
|
||||||
"开始知识迁移",
|
"开始知识迁移",
|
||||||
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
||||||
);
|
)) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await api.runKnowledgeMigration();
|
const result = await api.runKnowledgeMigration();
|
||||||
ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`);
|
ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`);
|
||||||
await load();
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "知识迁移失败"));
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -108,80 +230,104 @@ async function runMigration() {
|
|||||||
|
|
||||||
async function confirmManifest() {
|
async function confirmManifest() {
|
||||||
if (!detail.value?.pendingVersion) return;
|
if (!detail.value?.pendingVersion) return;
|
||||||
await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id);
|
try {
|
||||||
ElMessage.success("知识库说明卡已确认");
|
await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id);
|
||||||
await refreshDetail(detail.value.knowledge.id);
|
ElMessage.success("知识库说明卡已确认");
|
||||||
|
await refreshDetail(detail.value.knowledge.id);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "说明卡确认失败"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function review(action: "approve" | "reject") {
|
async function review(action: "approve" | "reject") {
|
||||||
if (!detail.value?.pendingVersion) return;
|
if (!detail.value?.pendingVersion) return;
|
||||||
let reason = "";
|
let reason = "";
|
||||||
if (action === "reject") {
|
if (action === "reject") {
|
||||||
const result = await ElMessageBox.prompt(
|
try {
|
||||||
"请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。",
|
const result = await ElMessageBox.prompt(
|
||||||
"拒绝版本",
|
"请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。",
|
||||||
{ confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" },
|
"拒绝版本",
|
||||||
);
|
{ confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" },
|
||||||
reason = result.value;
|
);
|
||||||
|
reason = result.value;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.reviewKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, action, reason);
|
||||||
|
ElMessage.success(action === "approve" ? "版本已审核通过" : "版本已拒绝");
|
||||||
|
await refreshDetail(detail.value.knowledge.id);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "版本审核失败"));
|
||||||
}
|
}
|
||||||
await api.reviewKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, action, reason);
|
|
||||||
ElMessage.success(action === "approve" ? "版本已审核通过" : "版本已拒绝");
|
|
||||||
await refreshDetail(detail.value.knowledge.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish(keepOpen: boolean) {
|
async function publish(keepOpen: boolean) {
|
||||||
if (!detail.value?.pendingVersion) return;
|
if (!detail.value?.pendingVersion) return;
|
||||||
await ElMessageBox.confirm(
|
if (!await confirmAction(
|
||||||
`将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}。`,
|
`将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}。`,
|
||||||
"发布知识版本",
|
"发布知识版本",
|
||||||
{ confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" },
|
{ confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" },
|
||||||
);
|
)) return;
|
||||||
await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen);
|
try {
|
||||||
ElMessage.success("正式版本已发布");
|
await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen);
|
||||||
await load();
|
ElMessage.success("正式版本已发布");
|
||||||
await refreshDetail(detail.value.knowledge.id);
|
await load();
|
||||||
|
await refreshDetail(detail.value.knowledge.id);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "版本发布失败"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rollback(version: KnowledgeVersion) {
|
async function rollback(version: KnowledgeVersion) {
|
||||||
if (!detail.value) return;
|
if (!detail.value) return;
|
||||||
await ElMessageBox.confirm(
|
if (!await confirmAction(
|
||||||
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
|
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
|
||||||
"回滚知识版本",
|
"回滚知识版本",
|
||||||
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
|
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
|
||||||
);
|
)) return;
|
||||||
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
|
try {
|
||||||
ElMessage.success("知识版本已回滚");
|
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
|
||||||
await load();
|
ElMessage.success("知识版本已回滚");
|
||||||
await refreshDetail(detail.value.knowledge.id);
|
await load();
|
||||||
|
await refreshDetail(detail.value.knowledge.id);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "版本回滚失败"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleOpen(item: KnowledgeItem) {
|
async function toggleOpen(item: KnowledgeItem) {
|
||||||
const next = item.status !== 1;
|
const next = item.status !== 1;
|
||||||
await ElMessageBox.confirm(
|
if (!await confirmAction(
|
||||||
next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。",
|
next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。",
|
||||||
next ? "开放知识库" : "关闭知识库",
|
next ? "开放知识库" : "关闭知识库",
|
||||||
{ confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" },
|
{ confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" },
|
||||||
);
|
)) return;
|
||||||
await api.setKnowledgeOpen(item.id, next);
|
try {
|
||||||
ElMessage.success(next ? "知识库已开放" : "知识库已关闭");
|
await api.setKnowledgeOpen(item.id, next);
|
||||||
await load();
|
ElMessage.success(next ? "知识库已开放" : "知识库已关闭");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, next ? "知识库开放失败" : "知识库关闭失败"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archive(item: KnowledgeItem) {
|
async function archive(item: KnowledgeItem) {
|
||||||
await ElMessageBox.confirm("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", {
|
if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", {
|
||||||
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
|
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
|
||||||
});
|
})) return;
|
||||||
await api.setKnowledgeLifecycle(item.id, "archived");
|
await api.setKnowledgeLifecycle(item.id, "archived");
|
||||||
ElMessage.success("知识库已归档");
|
ElMessage.success("知识库已归档");
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(item: KnowledgeItem) {
|
async function remove(item: KnowledgeItem) {
|
||||||
await ElMessageBox.confirm(
|
if (!await confirmAction(
|
||||||
`将物理删除“${item.name}”及关联数据。该操作不可恢复,并可能影响历史检索记录定位。`,
|
`将物理删除“${item.name}”及关联数据。该操作不可恢复,并可能影响历史检索记录定位。`,
|
||||||
"物理删除知识库",
|
"物理删除知识库",
|
||||||
{ confirmButtonText: "确认删除", cancelButtonText: "取消", type: "error" },
|
{ confirmButtonText: "确认删除", cancelButtonText: "取消", type: "error" },
|
||||||
);
|
)) return;
|
||||||
await api.deleteKnowledge(item.id);
|
await api.deleteKnowledge(item.id);
|
||||||
ElMessage.success("知识库已删除");
|
ElMessage.success("知识库已删除");
|
||||||
await load();
|
await load();
|
||||||
@@ -202,6 +348,23 @@ function statusType(status: string) {
|
|||||||
function typeLabel(value: string) {
|
function typeLabel(value: string) {
|
||||||
return { course: "课程知识库", qa: "答疑知识库", general: "通用知识库" }[value] || value;
|
return { course: "课程知识库", qa: "答疑知识库", general: "通用知识库" }[value] || value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAction(
|
||||||
|
message: string,
|
||||||
|
title: string,
|
||||||
|
options: Parameters<typeof ElMessageBox.confirm>[2],
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(message, title, options);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -242,13 +405,19 @@ function typeLabel(value: string) {
|
|||||||
<el-select v-model="filter.pending" placeholder="待发布版本" clearable><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
|
<el-select v-model="filter.pending" placeholder="待发布版本" clearable><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="filteredRows" stripe>
|
<div class="knowledge-batch-bar">
|
||||||
|
<span>已选择 {{ selectedRows.length }} 个知识库</span>
|
||||||
|
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="filteredRows" stripe @selection-change="selectedRows = $event">
|
||||||
|
<el-table-column type="selection" width="48" />
|
||||||
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
|
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
|
||||||
<el-table-column label="类型/审核" width="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column>
|
<el-table-column label="类型/审核" width="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column>
|
||||||
<el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></template></el-table-column>
|
<el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></template></el-table-column>
|
||||||
<el-table-column label="状态" width="160"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag class="status-gap" :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></template></el-table-column>
|
<el-table-column label="状态" width="160"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag class="status-gap" :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></template></el-table-column>
|
||||||
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
|
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
|
||||||
<el-table-column label="操作" width="270" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :loading="operatingId === row.id" @click="sync(row, 'review')">同步</el-button><el-button link @click="toggleOpen(row)">{{ row.status === 1 ? '关闭' : '开放' }}</el-button><el-dropdown trigger="click"><el-button link>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item @click="sync(row, 'replace')">同步并准备替换</el-dropdown-item><el-dropdown-item @click="archive(row)">归档</el-dropdown-item><el-dropdown-item divided @click="remove(row)">物理删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
|
<el-table-column label="操作" width="270" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row, 'review')">同步</el-button><el-button link :disabled="row.lifecycleStatus === 'archived'" @click="toggleOpen(row)">{{ row.status === 1 ? '关闭' : '开放' }}</el-button><el-dropdown trigger="click" @command="handleMore($event, row)"><el-button link>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item command="versions">查看版本</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
|
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
|
||||||
@@ -260,9 +429,12 @@ function typeLabel(value: string) {
|
|||||||
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
|
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
|
||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
<el-tab-pane label="基本信息" name="overview">
|
<el-tab-pane label="基本信息" name="overview">
|
||||||
<dl class="knowledge-meta"><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>正式版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd><dt>待发布版本</dt><dd>{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }}</dd></dl>
|
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
|
||||||
|
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>审核方式</dt><dd>{{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核发布' : '允许自动处理后发布' }}</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>正式版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd><dt>待发布版本</dt><dd>{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }}</dd></dl>
|
||||||
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></section>
|
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></section>
|
||||||
<div v-if="detail.pendingVersion" class="version-actions"><el-button type="success" @click="review('approve')">审核通过</el-button><el-button type="danger" @click="review('reject')">审核拒绝</el-button><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
|
<div v-if="detail.pendingVersion?.status === 'pending_review'" class="version-actions"><el-button type="success" @click="review('approve')">审核通过</el-button><el-button type="danger" @click="review('reject')">审核拒绝</el-button></div>
|
||||||
|
<div v-else-if="detail.pendingVersion?.status === 'approved'" class="version-actions"><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
|
||||||
|
<el-alert v-else-if="detail.pendingVersion?.status === 'rejected'" title="该版本已被拒绝,请在飞书修改源文章后重新同步。" type="warning" :closable="false" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="内容结构" name="content">
|
<el-tab-pane label="内容结构" name="content">
|
||||||
<el-empty v-if="!versionContent" description="请从版本记录中选择一个版本查看" />
|
<el-empty v-if="!versionContent" description="请从版本记录中选择一个版本查看" />
|
||||||
@@ -273,4 +445,47 @@ function typeLabel(value: string) {
|
|||||||
</el-tabs>
|
</el-tabs>
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="editOpen" title="编辑知识库基本信息" width="min(520px, 92vw)" destroy-on-close>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="后台知识库名称" required><el-input v-model="editForm.name" maxlength="100" show-word-limit /></el-form-item>
|
||||||
|
<el-form-item label="知识库类型" required><el-select v-model="editForm.knowledgeType"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="审核方式" required><el-select v-model="editForm.reviewMode"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
|
||||||
|
<el-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="batchOpen" title="批量修改知识库" width="min(520px, 92vw)" destroy-on-close>
|
||||||
|
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
|
||||||
|
<el-form label-position="top" class="batch-edit-form">
|
||||||
|
<el-form-item label="知识库类型"><el-select v-model="batchForm.knowledgeType" clearable placeholder="不修改"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="审核方式"><el-select v-model="batchForm.reviewMode" clearable placeholder="不修改"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.knowledge-batch-bar,
|
||||||
|
.knowledge-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-batch-bar {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 0;
|
||||||
|
color: #66756f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-section-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-edit-form {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
KnowledgeVersion,
|
KnowledgeVersion,
|
||||||
KnowledgeVersionContent,
|
KnowledgeVersionContent,
|
||||||
MigrationSummary,
|
MigrationSummary,
|
||||||
|
KnowledgeBatchUpdateResult,
|
||||||
RetrievalLogItem,
|
RetrievalLogItem,
|
||||||
AttentionItem,
|
AttentionItem,
|
||||||
ModelItem,
|
ModelItem,
|
||||||
@@ -42,9 +43,9 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||||
const body = (await response.json()) as ApiResponse<T>;
|
const body = (await response.json()) as ApiResponse<T> & { detail?: string };
|
||||||
if (!response.ok || body.code !== 0) {
|
if (!response.ok || body.code !== 0) {
|
||||||
throw new Error(body.message || `${response.status} ${response.statusText}`);
|
throw new Error(body.detail || body.message || `${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
return body.data;
|
return body.data;
|
||||||
}
|
}
|
||||||
@@ -119,6 +120,10 @@ export const api = {
|
|||||||
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
|
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
||||||
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string; reviewMode: string }) =>
|
||||||
|
request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }),
|
||||||
|
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string; reviewMode?: string }) =>
|
||||||
|
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }),
|
||||||
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
|
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
|
||||||
importKnowledgeFromSpace: (body: { node_token: string }) =>
|
importKnowledgeFromSpace: (body: { node_token: string }) =>
|
||||||
request<{ imported: number; skipped: number; id: number; name: string }>(
|
request<{ imported: number; skipped: number; id: number; name: string }>(
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ export interface MigrationSummary {
|
|||||||
items: KnowledgeItem[];
|
items: KnowledgeItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBatchUpdateResult {
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{ knowledgeId: number; ok: boolean; error?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetrievalLogItem {
|
export interface RetrievalLogItem {
|
||||||
id: number;
|
id: number;
|
||||||
sessionId?: number | null;
|
sessionId?: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0008_unique_knowledge_feishu_node"
|
||||||
|
down_revision = "0007_knowledge_agent_rebuild"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_knowledge_feishu_node",
|
||||||
|
"sys_knowledge",
|
||||||
|
["feishu_space_id", "feishu_node_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("uq_knowledge_feishu_node", "sys_knowledge", type_="unique")
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -24,6 +25,7 @@ from app.models.knowledge import (
|
|||||||
from app.schemas.admin import KnowledgeSaveRequest
|
from app.schemas.admin import KnowledgeSaveRequest
|
||||||
from app.services.admin_service import OperationLogService
|
from app.services.admin_service import OperationLogService
|
||||||
from app.services.feishu_service import FeishuKnowledgeService
|
from app.services.feishu_service import FeishuKnowledgeService
|
||||||
|
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ def import_knowledge_from_space(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: Admin = Depends(get_current_admin),
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
node_token = payload.get("node_token", "").strip()
|
node_token = payload.get("node_token", "").strip()
|
||||||
if not node_token:
|
if not node_token:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="NodeToken 不能为空")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="NodeToken 不能为空")
|
||||||
@@ -61,7 +64,7 @@ def import_knowledge_from_space(
|
|||||||
name=info.get("title", node_token),
|
name=info.get("title", node_token),
|
||||||
feishu_space_id=space_id,
|
feishu_space_id=space_id,
|
||||||
feishu_node_id=node_token,
|
feishu_node_id=node_token,
|
||||||
status=1,
|
status=0,
|
||||||
knowledge_type="general",
|
knowledge_type="general",
|
||||||
review_mode="manual",
|
review_mode="manual",
|
||||||
remark=f"obj_type={info.get('obj_type', '')}",
|
remark=f"obj_type={info.get('obj_type', '')}",
|
||||||
@@ -71,8 +74,13 @@ def import_knowledge_from_space(
|
|||||||
OperationLogService.write(
|
OperationLogService.write(
|
||||||
db, admin_id=current_admin.id, module="knowledge", action="import", target_id=knowledge.id
|
db, admin_id=current_admin.id, module="knowledge", action="import", target_id=knowledge.id
|
||||||
)
|
)
|
||||||
db.commit()
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
|
||||||
db.refresh(knowledge)
|
db.refresh(knowledge)
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
return api_success({"imported": 1, "skipped": 0, "id": knowledge.id, "name": knowledge.name})
|
return api_success({"imported": 1, "skipped": 0, "id": knowledge.id, "name": knowledge.name})
|
||||||
|
|
||||||
|
|
||||||
@@ -82,11 +90,20 @@ def create_knowledge(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: Admin = Depends(get_current_admin),
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
existing = db.scalar(
|
||||||
|
select(Knowledge).where(
|
||||||
|
Knowledge.feishu_space_id == payload.feishuSpaceId,
|
||||||
|
Knowledge.feishu_node_id == payload.feishuNodeId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库")
|
||||||
knowledge = Knowledge(
|
knowledge = Knowledge(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
feishu_space_id=payload.feishuSpaceId,
|
feishu_space_id=payload.feishuSpaceId,
|
||||||
feishu_node_id=payload.feishuNodeId,
|
feishu_node_id=payload.feishuNodeId,
|
||||||
status=payload.status,
|
status=0,
|
||||||
remark=payload.remark,
|
remark=payload.remark,
|
||||||
knowledge_type=payload.knowledgeType,
|
knowledge_type=payload.knowledgeType,
|
||||||
review_mode=payload.reviewMode,
|
review_mode=payload.reviewMode,
|
||||||
@@ -94,8 +111,13 @@ def create_knowledge(
|
|||||||
db.add(knowledge)
|
db.add(knowledge)
|
||||||
db.flush()
|
db.flush()
|
||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="create", target_id=knowledge.id)
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="create", target_id=knowledge.id)
|
||||||
db.commit()
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
|
||||||
db.refresh(knowledge)
|
db.refresh(knowledge)
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
return api_success(_knowledge_dict(knowledge))
|
return api_success(_knowledge_dict(knowledge))
|
||||||
|
|
||||||
|
|
||||||
@@ -106,11 +128,9 @@ def update_knowledge(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: Admin = Depends(get_current_admin),
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
knowledge = _get_knowledge(db, knowledge_id)
|
knowledge = _get_knowledge(db, knowledge_id)
|
||||||
knowledge.name = payload.name
|
knowledge.name = payload.name
|
||||||
knowledge.feishu_space_id = payload.feishuSpaceId
|
|
||||||
knowledge.feishu_node_id = payload.feishuNodeId
|
|
||||||
knowledge.status = payload.status
|
|
||||||
knowledge.remark = payload.remark
|
knowledge.remark = payload.remark
|
||||||
knowledge.knowledge_type = payload.knowledgeType
|
knowledge.knowledge_type = payload.knowledgeType
|
||||||
knowledge.review_mode = payload.reviewMode
|
knowledge.review_mode = payload.reviewMode
|
||||||
@@ -118,6 +138,7 @@ def update_knowledge(
|
|||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(knowledge)
|
db.refresh(knowledge)
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
return api_success(_knowledge_dict(knowledge))
|
return api_success(_knowledge_dict(knowledge))
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +148,13 @@ def delete_knowledge(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: Admin = Depends(get_current_admin),
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
knowledge = _get_knowledge(db, knowledge_id)
|
knowledge = _get_knowledge(db, knowledge_id)
|
||||||
|
if knowledge.lifecycle_status != "archived" or knowledge.status != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="物理删除前必须先关闭并归档知识库",
|
||||||
|
)
|
||||||
# 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。
|
# 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。
|
||||||
permissions = db.scalars(
|
permissions = db.scalars(
|
||||||
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
|
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
|
||||||
@@ -146,6 +173,7 @@ def delete_knowledge(
|
|||||||
db.delete(knowledge)
|
db.delete(knowledge)
|
||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
return api_success()
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +184,11 @@ def _get_knowledge(db: Session, knowledge_id: int) -> Knowledge:
|
|||||||
return knowledge
|
return knowledge
|
||||||
|
|
||||||
|
|
||||||
|
def _require_super_admin(admin: Admin) -> None:
|
||||||
|
if admin.role is not None and admin.role.code != "super_admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以管理知识库")
|
||||||
|
|
||||||
|
|
||||||
def _knowledge_dict(knowledge: Knowledge) -> dict:
|
def _knowledge_dict(knowledge: Knowledge) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": knowledge.id,
|
"id": knowledge.id,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from app.models.knowledge import (
|
|||||||
)
|
)
|
||||||
from app.schemas.knowledge import (
|
from app.schemas.knowledge import (
|
||||||
KnowledgeLifecycleRequest,
|
KnowledgeLifecycleRequest,
|
||||||
|
KnowledgeBatchMetadataUpdateRequest,
|
||||||
|
KnowledgeMetadataUpdateRequest,
|
||||||
KnowledgePublishRequest,
|
KnowledgePublishRequest,
|
||||||
KnowledgeReviewRequest,
|
KnowledgeReviewRequest,
|
||||||
KnowledgeStatusRequest,
|
KnowledgeStatusRequest,
|
||||||
@@ -28,10 +30,74 @@ from app.schemas.knowledge import (
|
|||||||
)
|
)
|
||||||
from app.services.admin_service import OperationLogService
|
from app.services.admin_service import OperationLogService
|
||||||
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
||||||
|
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/knowledge/batch/metadata")
|
||||||
|
def batch_update_metadata(
|
||||||
|
payload: KnowledgeBatchMetadataUpdateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
if payload.knowledgeType is None and payload.reviewMode is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少选择一个待修改字段")
|
||||||
|
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
|
||||||
|
rows = {
|
||||||
|
item.id: item
|
||||||
|
for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))).all()
|
||||||
|
}
|
||||||
|
results: list[dict] = []
|
||||||
|
for knowledge_id in knowledge_ids:
|
||||||
|
item = rows.get(knowledge_id)
|
||||||
|
if item is None:
|
||||||
|
results.append({"knowledgeId": knowledge_id, "ok": False, "error": "知识库不存在"})
|
||||||
|
continue
|
||||||
|
if payload.knowledgeType is not None:
|
||||||
|
item.knowledge_type = payload.knowledgeType
|
||||||
|
if payload.reviewMode is not None:
|
||||||
|
item.review_mode = payload.reviewMode
|
||||||
|
db.add(item)
|
||||||
|
OperationLogService.write(
|
||||||
|
db,
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
module="knowledge",
|
||||||
|
action="batch_update_metadata",
|
||||||
|
target_id=item.id,
|
||||||
|
)
|
||||||
|
results.append({"knowledgeId": knowledge_id, "ok": True})
|
||||||
|
db.commit()
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
|
success = sum(1 for item in results if item["ok"])
|
||||||
|
return api_success(
|
||||||
|
{"total": len(results), "success": success, "failed": len(results) - success, "results": results}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/knowledge/{knowledge_id}/metadata")
|
||||||
|
def update_metadata(
|
||||||
|
knowledge_id: int,
|
||||||
|
payload: KnowledgeMetadataUpdateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
|
knowledge.name = payload.name.strip()
|
||||||
|
knowledge.knowledge_type = payload.knowledgeType
|
||||||
|
knowledge.review_mode = payload.reviewMode
|
||||||
|
db.add(knowledge)
|
||||||
|
OperationLogService.write(
|
||||||
|
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(knowledge)
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/knowledge/{knowledge_id}/detail")
|
@router.get("/knowledge/{knowledge_id}/detail")
|
||||||
def detail(
|
def detail(
|
||||||
knowledge_id: int,
|
knowledge_id: int,
|
||||||
@@ -159,6 +225,7 @@ async def synchronize(
|
|||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc
|
||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="sync", target_id=knowledge.id)
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="sync", target_id=knowledge.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
return api_success(_job_dict(job))
|
return api_success(_job_dict(job))
|
||||||
|
|
||||||
|
|
||||||
@@ -171,6 +238,8 @@ def confirm_manifest(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
_require_super_admin(current_admin)
|
_require_super_admin(current_admin)
|
||||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||||
|
if knowledge.pending_version_id != version.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能确认当前待发布版本的说明卡")
|
||||||
try:
|
try:
|
||||||
KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id)
|
KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -190,6 +259,8 @@ def review(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
_require_super_admin(current_admin)
|
_require_super_admin(current_admin)
|
||||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||||
|
if knowledge.pending_version_id != version.id or version.status != "pending_review":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能审核当前待审核版本")
|
||||||
if version.quality_status == "blocked" and payload.action == "approve":
|
if version.quality_status == "blocked" and payload.action == "approve":
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过")
|
||||||
KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason)
|
KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason)
|
||||||
@@ -208,12 +279,15 @@ def publish(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
_require_super_admin(current_admin)
|
_require_super_admin(current_admin)
|
||||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||||
|
if knowledge.pending_version_id != version.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能发布当前待发布版本")
|
||||||
try:
|
try:
|
||||||
KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen)
|
KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="publish", target_id=knowledge.id)
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="publish", target_id=knowledge.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
return api_success(_knowledge_detail(knowledge))
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
|
||||||
|
|
||||||
@@ -227,12 +301,17 @@ def rollback(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
_require_super_admin(current_admin)
|
_require_super_admin(current_admin)
|
||||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||||
|
if knowledge.current_version_id == version.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="目标版本已是当前正式版本")
|
||||||
|
if payload.keepOpen and knowledge.lifecycle_status != "active":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能在回滚后开放")
|
||||||
try:
|
try:
|
||||||
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
|
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
return api_success(_knowledge_detail(knowledge))
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
|
||||||
|
|
||||||
@@ -258,6 +337,7 @@ def open_status(
|
|||||||
db.add(knowledge)
|
db.add(knowledge)
|
||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="open" if payload.open else "close", target_id=knowledge.id)
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="open" if payload.open else "close", target_id=knowledge.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
return api_success(_knowledge_detail(knowledge))
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
|
||||||
|
|
||||||
@@ -270,12 +350,15 @@ def lifecycle(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
_require_super_admin(current_admin)
|
_require_super_admin(current_admin)
|
||||||
knowledge = _knowledge(db, knowledge_id)
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
|
if knowledge.lifecycle_status == payload.status:
|
||||||
|
return api_success(_knowledge_detail(knowledge))
|
||||||
knowledge.lifecycle_status = payload.status
|
knowledge.lifecycle_status = payload.status
|
||||||
if payload.status == "archived":
|
if payload.status == "archived":
|
||||||
knowledge.status = 0
|
knowledge.status = 0
|
||||||
db.add(knowledge)
|
db.add(knowledge)
|
||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=payload.status, target_id=knowledge.id)
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=payload.status, target_id=knowledge.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
KnowledgeCatalogCacheService.invalidate()
|
||||||
return api_success(_knowledge_detail(knowledge))
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
|
||||||
|
|
||||||
@@ -347,6 +430,8 @@ def _knowledge_detail(item: Knowledge) -> dict:
|
|||||||
return {
|
return {
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
"name": item.name,
|
"name": item.name,
|
||||||
|
"status": item.status,
|
||||||
|
"remark": item.remark,
|
||||||
"sourceTitle": item.source_title,
|
"sourceTitle": item.source_title,
|
||||||
"feishuSpaceId": item.feishu_space_id,
|
"feishuSpaceId": item.feishu_space_id,
|
||||||
"feishuNodeId": item.feishu_node_id,
|
"feishuNodeId": item.feishu_node_id,
|
||||||
@@ -362,6 +447,8 @@ def _knowledge_detail(item: Knowledge) -> dict:
|
|||||||
"lastSyncedAt": item.last_synced_at,
|
"lastSyncedAt": item.last_synced_at,
|
||||||
"lastPublishedAt": item.last_published_at,
|
"lastPublishedAt": item.last_published_at,
|
||||||
"migrationStatus": item.migration_status,
|
"migrationStatus": item.migration_status,
|
||||||
|
"createdAt": item.created_at,
|
||||||
|
"updatedAt": item.updated_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
|||||||
|
|
||||||
class Knowledge(Base, TimestampMixin):
|
class Knowledge(Base, TimestampMixin):
|
||||||
__tablename__ = "sys_knowledge"
|
__tablename__ = "sys_knowledge"
|
||||||
|
__table_args__ = (UniqueConstraint("feishu_space_id", "feishu_node_id", name="uq_knowledge_feishu_node"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|||||||
|
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
|
|
||||||
|
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||||
|
|
||||||
|
|
||||||
class AiRequestLog(Base):
|
class AiRequestLog(Base):
|
||||||
__tablename__ = "sys_ai_request_log"
|
__tablename__ = "sys_ai_request_log"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
@@ -32,7 +34,7 @@ class AiRequestLog(Base):
|
|||||||
class OperationLog(Base):
|
class OperationLog(Base):
|
||||||
__tablename__ = "sys_operation_log"
|
__tablename__ = "sys_operation_log"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
admin_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
admin_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
module: Mapped[str] = mapped_column(String(50), nullable=False)
|
module: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ class KnowledgeLifecycleRequest(BaseModel):
|
|||||||
status: str = Field(pattern="^(active|archived)$")
|
status: str = Field(pattern="^(active|archived)$")
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeMetadataUpdateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
knowledgeType: str = Field(pattern="^(course|qa|general)$")
|
||||||
|
reviewMode: str = Field(pattern="^(manual|automatic)$")
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBatchMetadataUpdateRequest(BaseModel):
|
||||||
|
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
|
||||||
|
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
|
||||||
|
reviewMode: str | None = Field(default=None, pattern="^(manual|automatic)$")
|
||||||
|
|
||||||
|
|
||||||
class AttentionUpdateRequest(BaseModel):
|
class AttentionUpdateRequest(BaseModel):
|
||||||
status: str = Field(pattern="^(pending|processing|resolved|ignored)$")
|
status: str = Field(pattern="^(pending|processing|resolved|ignored)$")
|
||||||
note: str | None = Field(default=None, max_length=2000)
|
note: str | None = Field(default=None, max_length=2000)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.redis_client import get_sync_redis_client
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeCatalogCacheService:
|
||||||
|
"""Central invalidation point for current and future KnowledgeList caches."""
|
||||||
|
|
||||||
|
VERSION_KEY = "knowledge:catalog:version"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def invalidate(cls) -> None:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client.incr(cls.VERSION_KEY)
|
||||||
|
except Exception:
|
||||||
|
# The catalog currently reads MySQL on every request, so Redis failure
|
||||||
|
# must not make a committed metadata change look unsuccessful.
|
||||||
|
return
|
||||||
@@ -210,7 +210,7 @@ class KnowledgePipelineService:
|
|||||||
boundaries=manifest_data["boundaries"],
|
boundaries=manifest_data["boundaries"],
|
||||||
content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)),
|
content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)),
|
||||||
change_level=change_level,
|
change_level=change_level,
|
||||||
confirmed=1 if previous_manifest and change_level == "minor" else 0,
|
confirmed=1 if previous_manifest and previous_manifest.confirmed == 1 and change_level == "minor" else 0,
|
||||||
)
|
)
|
||||||
db.add(manifest)
|
db.add(manifest)
|
||||||
|
|
||||||
@@ -236,6 +236,26 @@ class KnowledgePipelineService:
|
|||||||
{"versionId": version.id, "versionNo": version.version_no, **quality}, ensure_ascii=False
|
{"versionId": version.id, "versionNo": version.version_no, **quality}, ensure_ascii=False
|
||||||
)
|
)
|
||||||
job.finished_at = _now()
|
job.finished_at = _now()
|
||||||
|
if (
|
||||||
|
knowledge.review_mode == "automatic"
|
||||||
|
and version.quality_status == "passed"
|
||||||
|
and not quality["highRisk"]
|
||||||
|
and manifest.confirmed == 1
|
||||||
|
):
|
||||||
|
cls.review_version(
|
||||||
|
db,
|
||||||
|
knowledge,
|
||||||
|
version,
|
||||||
|
admin_id,
|
||||||
|
"approve",
|
||||||
|
"自动审核方式:质检通过且说明卡可继承,自动发布。",
|
||||||
|
)
|
||||||
|
cls.publish_version(db, knowledge, version, admin_id, keep_open=knowledge.status == 1)
|
||||||
|
job.status = "published"
|
||||||
|
job.result_summary = json.dumps(
|
||||||
|
{"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
db.add_all([knowledge, version, job])
|
db.add_all([knowledge, version, job])
|
||||||
db.commit()
|
db.commit()
|
||||||
return job
|
return job
|
||||||
@@ -299,6 +319,10 @@ class KnowledgePipelineService:
|
|||||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
|
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
|
||||||
if manifest is None or manifest.confirmed != 1:
|
if manifest is None or manifest.confirmed != 1:
|
||||||
raise ValueError("知识库说明卡尚未确认")
|
raise ValueError("知识库说明卡尚未确认")
|
||||||
|
if keep_open and knowledge.lifecycle_status != "active":
|
||||||
|
raise ValueError("归档知识库不能在发布后开放")
|
||||||
|
if keep_open and knowledge.source_status != "normal":
|
||||||
|
raise ValueError("飞书源文章异常,不能在发布后开放")
|
||||||
previous_version_id = knowledge.current_version_id
|
previous_version_id = knowledge.current_version_id
|
||||||
if previous_version_id:
|
if previous_version_id:
|
||||||
previous = db.get(KnowledgeVersion, previous_version_id)
|
previous = db.get(KnowledgeVersion, previous_version_id)
|
||||||
@@ -334,12 +358,19 @@ class KnowledgePipelineService:
|
|||||||
if version.status not in {"published", "superseded"}:
|
if version.status not in {"published", "superseded"}:
|
||||||
raise ValueError("只能回滚到历史正式版本")
|
raise ValueError("只能回滚到历史正式版本")
|
||||||
previous_id = knowledge.current_version_id
|
previous_id = knowledge.current_version_id
|
||||||
|
if previous_id:
|
||||||
|
previous = db.get(KnowledgeVersion, previous_id)
|
||||||
|
if previous:
|
||||||
|
previous.status = "superseded"
|
||||||
|
db.add(previous)
|
||||||
|
version.status = "published"
|
||||||
knowledge.current_version_id = version.id
|
knowledge.current_version_id = version.id
|
||||||
knowledge.status = 1 if keep_open else 0
|
knowledge.status = 1 if keep_open else 0
|
||||||
knowledge.last_published_at = _now()
|
knowledge.last_published_at = _now()
|
||||||
db.add_all(
|
db.add_all(
|
||||||
[
|
[
|
||||||
knowledge,
|
knowledge,
|
||||||
|
version,
|
||||||
KnowledgePublishLog(
|
KnowledgePublishLog(
|
||||||
knowledge_id=knowledge.id,
|
knowledge_id=knowledge.id,
|
||||||
from_version_id=previous_id,
|
from_version_id=previous_id,
|
||||||
|
|||||||
142
ai_knowledge_base_v2/apps/backend/tests/test_knowledge_admin.py
Normal file
142
ai_knowledge_base_v2/apps/backend/tests/test_knowledge_admin.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.admin_knowledge_lifecycle import batch_update_metadata, update_metadata
|
||||||
|
from app.models import Base
|
||||||
|
from app.models.admin import Admin, Role
|
||||||
|
from app.models.knowledge import Knowledge, KnowledgeManifest, KnowledgeSourceSnapshot, KnowledgeVersion
|
||||||
|
from app.schemas.knowledge import KnowledgeBatchMetadataUpdateRequest, KnowledgeMetadataUpdateRequest
|
||||||
|
from app.services.knowledge_agent_service import KnowledgeAgentService
|
||||||
|
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
||||||
|
|
||||||
|
|
||||||
|
def _database() -> Session:
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
return Session(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin(role_code: str | None = None) -> Admin:
|
||||||
|
role = Role(id=10, code=role_code, name=role_code) if role_code else None
|
||||||
|
return Admin(id=1, username="admin", password="hash", name="管理员", status=1, role=role)
|
||||||
|
|
||||||
|
|
||||||
|
def _knowledge(db: Session, knowledge_id: int = 1) -> Knowledge:
|
||||||
|
item = Knowledge(
|
||||||
|
id=knowledge_id,
|
||||||
|
name="原名称",
|
||||||
|
feishu_space_id="space-1",
|
||||||
|
feishu_node_id="node-1",
|
||||||
|
status=1,
|
||||||
|
knowledge_type="general",
|
||||||
|
review_mode="manual",
|
||||||
|
source_status="normal",
|
||||||
|
manifest_confirmed=1,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
snapshot = KnowledgeSourceSnapshot(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
source_title="源标题",
|
||||||
|
source_content="源内容",
|
||||||
|
content_hash=f"hash-{knowledge_id}",
|
||||||
|
source_identifier="space-1/node-1",
|
||||||
|
)
|
||||||
|
db.add(snapshot)
|
||||||
|
db.flush()
|
||||||
|
version = KnowledgeVersion(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
snapshot_id=snapshot.id,
|
||||||
|
version_no=1,
|
||||||
|
status="published",
|
||||||
|
quality_status="passed",
|
||||||
|
processing_rule_version="test-v1",
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
db.flush()
|
||||||
|
item.current_version_id = version.id
|
||||||
|
db.add(
|
||||||
|
KnowledgeManifest(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
version_id=version.id,
|
||||||
|
purpose="原用途",
|
||||||
|
applicable_questions="课程问题",
|
||||||
|
inapplicable_questions="天气",
|
||||||
|
core_topics="课程",
|
||||||
|
content_hash=f"manifest-{knowledge_id}",
|
||||||
|
confirmed=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_update_preserves_source_version_and_open_state(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
monkeypatch.setattr("app.api.admin_knowledge_lifecycle.KnowledgeCatalogCacheService.invalidate", lambda: None)
|
||||||
|
with _database() as db:
|
||||||
|
item = _knowledge(db)
|
||||||
|
original = (item.feishu_space_id, item.feishu_node_id, item.current_version_id, item.status)
|
||||||
|
update_metadata(
|
||||||
|
item.id,
|
||||||
|
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course", reviewMode="automatic"),
|
||||||
|
db,
|
||||||
|
_admin(),
|
||||||
|
)
|
||||||
|
db.refresh(item)
|
||||||
|
assert (item.name, item.knowledge_type, item.review_mode) == ("新后台名称", "course", "automatic")
|
||||||
|
assert (item.feishu_space_id, item.feishu_node_id, item.current_version_id, item.status) == original
|
||||||
|
assert KnowledgeAgentService.get_knowledge_catalog(db)[0]["name"] == "新后台名称"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_update_requires_super_admin():
|
||||||
|
with _database() as db:
|
||||||
|
item = _knowledge(db)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
update_metadata(
|
||||||
|
item.id,
|
||||||
|
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa", reviewMode="manual"),
|
||||||
|
db,
|
||||||
|
_admin("operator"),
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_metadata_update_reports_missing_items(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
monkeypatch.setattr("app.api.admin_knowledge_lifecycle.KnowledgeCatalogCacheService.invalidate", lambda: None)
|
||||||
|
with _database() as db:
|
||||||
|
item = _knowledge(db)
|
||||||
|
response = batch_update_metadata(
|
||||||
|
KnowledgeBatchMetadataUpdateRequest(knowledgeIds=[item.id, 999], knowledgeType="qa"),
|
||||||
|
db,
|
||||||
|
_admin(),
|
||||||
|
)
|
||||||
|
assert response["data"]["success"] == 1
|
||||||
|
assert response["data"]["failed"] == 1
|
||||||
|
assert db.scalar(select(Knowledge).where(Knowledge.id == item.id)).knowledge_type == "qa"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollback_switches_published_version_statuses():
|
||||||
|
with _database() as db:
|
||||||
|
item = _knowledge(db)
|
||||||
|
old_version = db.get(KnowledgeVersion, item.current_version_id)
|
||||||
|
snapshot = db.scalar(select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == item.id))
|
||||||
|
target = KnowledgeVersion(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
snapshot_id=snapshot.id,
|
||||||
|
version_no=0,
|
||||||
|
status="superseded",
|
||||||
|
quality_status="passed",
|
||||||
|
processing_rule_version="test-v1",
|
||||||
|
)
|
||||||
|
db.add(target)
|
||||||
|
db.flush()
|
||||||
|
KnowledgePipelineService.rollback(db, item, target, admin_id=1, keep_open=True)
|
||||||
|
db.commit()
|
||||||
|
assert item.current_version_id == target.id
|
||||||
|
assert target.status == "published"
|
||||||
|
assert old_version.status == "superseded"
|
||||||
Reference in New Issue
Block a user