fix: complete admin knowledge audit improvements

This commit is contained in:
2026-07-13 11:40:34 +08:00
parent 82e0c02bac
commit 120fe185f9
16 changed files with 502 additions and 41 deletions

View File

@@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref } from "vue";
import AdminLoginView from "./components/AdminLoginView.vue";
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
import TableRowActions from "./components/TableRowActions.vue";
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
import { api, clearToken, getToken, saveToken } from "./services/api";
import type {
@@ -27,6 +28,11 @@ const activeMenu = ref("dashboard");
const loading = ref(false);
const stats = ref<DashboardStats | null>(null);
const storageStats = ref<Record<string, any> | null>(null);
const storageLoading = ref(false);
const cleanupBefore = ref("");
const retentionDays = ref<number | null>(90);
const retentionState = ref<{ lastRunAt: string | null; lastResult: string | null } | null>(null);
const dashboardFilters = reactive({
start: "",
end: "",
@@ -249,8 +255,15 @@ async function previewKnowledge(knowledgeId: number) {
async function loadDashboard() {
stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end);
storageLoading.value = true;
api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
}
async function refreshStorage() { storageLoading.value = true; try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); } finally { storageLoading.value = false; } }
function formatBytes(value: unknown) { if (typeof value !== "number") return "无法统计"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value, i = 0; while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1; } return `${size.toFixed(i ? 2 : 0)} ${units[i]}`; }
async function cleanupRetrievalLogs() { if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期"); const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value); await ElMessageBox.confirm(`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`, "确认清理", { type: "warning" }); const result = await api.cleanupRetrievalLogs(cleanupBefore.value); ElMessage.success(`已清理 ${result.deleted} 条日志`); await loadCurrentMenu(); }
async function saveRetention() { await api.saveRetrievalRetention(retentionDays.value); ElMessage.success(retentionDays.value == null ? "已设为永久保留" : `已设为保留 ${retentionDays.value}`); await loadCurrentMenu(); }
async function loadCurrentMenu() {
loading.value = true;
try {
@@ -262,6 +275,7 @@ async function loadCurrentMenu() {
promptContent.value = prompt.promptContent;
models.value = modelRows;
knowledge.value = knowledgeRows;
agentForm.knowledgeIds = knowledgeRows.filter((item) => item.status === 1 && item.lifecycleStatus === "active").map((item) => item.id);
if (!agentForm.modelId && modelRows.length) {
agentForm.modelId = modelRows.find((model) => model.enabled === 1)?.id ?? modelRows[0].id;
}
@@ -276,7 +290,7 @@ async function loadCurrentMenu() {
aiLogs.value = await api.aiLogs();
operationLogs.value = await api.operationLogs();
}
if (activeMenu.value === "retrievals") retrievalLogs.value = await api.retrievalLogs();
if (activeMenu.value === "retrievals") { const [logs, retention] = await Promise.all([api.retrievalLogs(), api.retrievalRetention()]); retrievalLogs.value = logs; retentionDays.value = retention.retentionDays; retentionState.value = retention; }
if (activeMenu.value === "attention") attentionRecords.value = await api.attentionList();
} finally {
loading.value = false;
@@ -937,6 +951,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</div>
</div>
</section>
<section class="storage-panel" v-loading="storageLoading"><div class="storage-panel-head"><div><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div><div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div><small>最后统计{{ storageStats?.createdAt || '尚未统计' }}</small></section>
<el-table :data="users" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" width="150" />
@@ -986,8 +1001,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</el-select>
</el-form-item>
<el-form-item label="调试知识库">
<el-select v-model="agentForm.knowledgeIds" multiple collapse-tags placeholder="默认使用全部启用知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="item.name" :value="item.id" />
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="Temperature">
@@ -1067,10 +1082,12 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</div>
<div class="agent-trace-list">
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
<article v-for="(step, index) in agentDebugTrace" :key="index">
<strong>{{ index + 1 }}. {{ step.tool }}</strong>
<pre>{{ JSON.stringify(step, null, 2) }}</pre>
</article>
<details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
<summary><strong>{{ index + 1 }}. {{ step.tool }}</strong><el-tag size="small" :type="step.status === 'failed' ? 'danger' : 'success'">{{ step.status || '完成' }}</el-tag><span>{{ step.durationMs ?? 0 }} ms</span></summary>
<h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
<h5>响应</h5><pre>{{ step.response == null ? '返回为空' : JSON.stringify(step.response, null, 2) }}</pre>
<el-alert v-if="step.error" :title="step.error" type="error" :closable="false" />
</details>
</div>
</aside>
</section>
@@ -1180,12 +1197,9 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="authType" label="鉴权" width="110" />
<el-table-column prop="enabled" label="启用" width="90" />
<el-table-column label="操作" width="210" fixed="right">
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" @click="editModel(row)">编辑</el-button>
<el-button size="small" @click="testModel(row.id)">测试</el-button>
<el-button size="small" type="primary" @click="enableModel(row.id)">启用</el-button>
<el-button size="small" type="danger" @click="deleteModel(row.id)">删除</el-button>
<TableRowActions><el-button size="small" @click="editModel(row)">编辑</el-button><el-button size="small" @click="testModel(row.id)">测试</el-button><el-dropdown trigger="click" teleported @command="(command: string) => command === 'enable' ? enableModel(row.id) : deleteModel(row.id)"><el-button size="small">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="enable">启用</el-dropdown-item><el-dropdown-item command="delete" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions>
</template>
</el-table-column>
</el-table>
@@ -1232,7 +1246,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template>
<template v-if="activeMenu === 'retrievals'">
<div class="page-head"><h2>检索日志</h2><p>查看每条回答使用的知识目录Agent 决策搜索候选重排和章节回读</p></div>
<div class="page-head inline"><div><h2>检索日志</h2><p>查看每条回答使用的知识目录Agent 决策搜索候选重排和章节回读</p></div><div class="cleanup-controls"><el-date-picker v-model="cleanupBefore" value-format="YYYY-MM-DD" type="date" placeholder="清理此日期前" /><el-button type="danger" plain @click="cleanupRetrievalLogs">清理日志</el-button></div></div>
<section class="retention-panel"><div><strong>定期清理</strong><span>每日自动分批删除过期检索日志和关联候选明细</span></div><el-select v-model="retentionDays" placeholder="保留时间"><el-option label="永久保留" :value="null" /><el-option label="30天" :value="30" /><el-option label="90天" :value="90" /><el-option label="180天" :value="180" /></el-select><el-button type="primary" @click="saveRetention">保存规则</el-button><small>最近执行{{ retentionState?.lastRunAt || '尚未执行' }}</small></section>
<el-table :data="retrievalLogs" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="sessionId" label="会话" width="90" />

View File

@@ -3,6 +3,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import TableRowActions from "./TableRowActions.vue";
import type {
KnowledgeDetail,
KnowledgeItem,
@@ -29,6 +30,9 @@ const editOpen = ref(false);
const editSaving = ref(false);
const batchOpen = ref(false);
const batchSaving = ref(false);
const resolvingNode = ref(false);
const nodeResolved = ref(false);
const sourceTitle = ref("");
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
const form = reactive({
name: "",
@@ -159,13 +163,14 @@ async function restore(item: KnowledgeItem) {
}
async function createKnowledge() {
if (!form.name || !form.feishuSpaceId || !form.feishuNodeId) {
ElMessage.warning("请填写名称、SpaceID 和 NodeID");
if (!nodeResolved.value || !form.name || !form.feishuSpaceId || !form.feishuNodeId) {
ElMessage.warning("请先通过 Node ID 获取并确认知识库信息");
return;
}
try {
await api.createKnowledge({ ...form });
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.success("知识库已新增,请进行首次同步");
await load();
} catch (error) {
@@ -173,6 +178,21 @@ async function createKnowledge() {
}
}
async function resolveNode() {
const nodeId = form.feishuNodeId.trim();
if (!nodeId) return ElMessage.warning("请输入 Node ID");
resolvingNode.value = true;
try {
const info = await api.resolveKnowledgeNode(nodeId);
Object.assign(form, { feishuNodeId: info.nodeId, feishuSpaceId: info.spaceId, name: info.name, remark: info.remark || "" });
sourceTitle.value = info.sourceTitle; nodeResolved.value = true;
ElMessage.success("已获取飞书知识库信息");
} catch (error) {
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.error(errorMessage(error, "Node ID 信息获取失败"));
} finally { resolvingNode.value = false; }
}
async function openDetail(item: KnowledgeItem) {
detailOpen.value = true;
activeTab.value = "overview";
@@ -387,10 +407,15 @@ async function confirmAction(
</section>
<section class="knowledge-work-panel">
<h3>新增知识库</h3>
<div class="knowledge-create-grid">
<el-input v-model="form.feishuSpaceId" placeholder="SpaceID" />
<el-input v-model="form.feishuNodeId" placeholder="NodeID" />
<h3>通过 Node ID 快速新增</h3>
<div class="node-resolve-row">
<el-input v-model="form.feishuNodeId" placeholder="请输入飞书 Node ID" :disabled="resolvingNode" @input="nodeResolved = false" />
<el-button type="primary" :loading="resolvingNode" @click="resolveNode">获取知识库信息</el-button>
</div>
<el-alert v-if="nodeResolved" :title="`已获取:${sourceTitle}`" type="success" :closable="false" show-icon />
<div v-if="nodeResolved" class="knowledge-create-grid">
<el-input v-model="form.feishuSpaceId" placeholder="Space ID" disabled />
<el-input :model-value="sourceTitle" placeholder="飞书原标题" disabled />
<el-input v-model="form.name" placeholder="后台显示名称" />
<el-select v-model="form.knowledgeType">
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
@@ -398,6 +423,7 @@ async function confirmAction(
<el-select v-model="form.reviewMode">
<el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" />
</el-select>
<el-input v-model="form.remark" placeholder="备注(可修改)" />
<el-button type="primary" @click="createKnowledge">保存并准备同步</el-button>
</div>
</section>
@@ -422,7 +448,7 @@ async function confirmAction(
<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="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
<el-table-column label="操作" width="220" 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-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</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-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><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-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</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></TableRowActions></template></el-table-column>
</el-table>
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>

View File

@@ -0,0 +1,3 @@
<template>
<div class="table-row-actions"><slot /></div>
</template>

View File

@@ -116,6 +116,7 @@ export const api = {
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"),
resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }),
createKnowledge: (payload: Record<string, unknown>) =>
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
@@ -182,6 +183,11 @@ export const api = {
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
operationLogs: () => request<Record<string, unknown>[]>("/admin/log/list"),
retrievalLogs: () => request<RetrievalLogItem[]>("/admin/retrieval-log/list"),
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }),
cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }),
retrievalRetention: () => request<{ enabled: boolean; retentionDays: number | null; lastRunAt: string | null; lastResult: string | null }>("/admin/retrieval-log/retention"),
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
attentionList: () => request<AttentionItem[]>("/admin/attention/list"),
updateAttention: (id: number, status: string, note?: string) =>

View File

@@ -275,6 +275,16 @@ textarea {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.storage-panel { margin-top: 18px; padding: 18px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.storage-panel-head { display: flex; justify-content: space-between; align-items: start; gap: 16px; }
.storage-panel-head h3, .storage-panel-head p { margin: 0 0 6px; }
.storage-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.storage-grid > div { padding: 14px; background: #f6f8f7; border-radius: 8px; }
.storage-grid span, .storage-grid strong { display: block; }
.storage-grid strong { margin-top: 8px; font-size: 20px; }
.cleanup-controls { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.retention-panel { display: grid; grid-template-columns: minmax(260px, 1fr) 180px auto auto; align-items: center; gap: 12px; padding: 14px 16px; margin-bottom: 14px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.retention-panel div span { display: block; margin-top: 4px; color: #667a73; font-size: 12px; }
.stat {
padding: 18px;
@@ -393,6 +403,9 @@ textarea {
gap: 8px;
}
.node-resolve-row { display: grid; grid-template-columns: minmax(280px, 1fr) auto; gap: 12px; margin-bottom: 12px; }
.node-resolve-row .el-button { min-width: 170px; }
.knowledge-filters {
display: grid;
grid-template-columns: minmax(240px, 1.6fr) repeat(4, minmax(130px, 0.8fr));
@@ -699,6 +712,19 @@ textarea {
width: 100%;
}
.debug-knowledge-select { width: 100%; min-width: 0; }
.debug-knowledge-select .el-select__wrapper { min-height: 40px; max-height: 76px; overflow-y: auto; }
.debug-knowledge-popper { max-width: min(620px, 90vw); z-index: 4000 !important; }
.debug-knowledge-popper .el-select-dropdown__item { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.table-row-actions { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; white-space: nowrap; }
.table-row-actions .el-button + .el-button { margin-left: 0; }
.table-row-actions .el-button { min-width: 48px; height: 32px; white-space: nowrap; }
.tool-trace-node { border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; overflow: hidden; }
.tool-trace-node summary { cursor: pointer; display: flex; align-items: center; gap: 10px; padding: 12px; list-style: none; }
.tool-trace-node summary span { margin-left: auto; color: #667a73; font-size: 12px; }
.tool-trace-node h5 { margin: 10px 12px 4px; }
.tool-trace-node pre { max-height: 320px; overflow: auto; }
.agent-preview-head {
display: flex;
align-items: center;

View File

@@ -0,0 +1,59 @@
"""log retention, storage snapshots and audit indexes
Revision ID: 0009_logs_storage
Revises: 0008_unique_kb_node
"""
from alembic import op
import sqlalchemy as sa
revision = "0009_logs_storage"
down_revision = "0008_unique_kb_node"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "sys_log_retention_policy" not in tables:
op.create_table(
"sys_log_retention_policy",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("retention_days", sa.Integer(), nullable=True),
sa.Column("enabled", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_run_at", sa.DateTime(), nullable=True),
sa.Column("last_result", sa.Text(), nullable=True),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
if "sys_storage_snapshot" not in tables:
op.create_table(
"sys_storage_snapshot",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("scope", sa.String(50), nullable=False),
sa.Column("total_bytes", sa.BigInteger(), nullable=True),
sa.Column("detail_json", sa.Text(), nullable=False),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_status_created", ["status", "created_at"])
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_created_id", ["created_at", "id"])
_create_index_if_missing("sys_ai_request_log", "ix_ai_log_status_created", ["status", "created_at"])
_create_index_if_missing("sys_operation_log", "ix_operation_module_created", ["module", "created_at"])
_create_index_if_missing("sys_chat_session", "ix_chat_session_deleted_updated", ["is_deleted", "updated_at"])
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
if name not in {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}:
op.create_index(name, table, columns)
def downgrade() -> None:
op.drop_index("ix_chat_session_deleted_updated", table_name="sys_chat_session")
op.drop_index("ix_operation_module_created", table_name="sys_operation_log")
op.drop_index("ix_ai_log_status_created", table_name="sys_ai_request_log")
op.drop_index("ix_retrieval_created_id", table_name="sys_knowledge_retrieval_log")
op.drop_index("ix_retrieval_status_created", table_name="sys_knowledge_retrieval_log")
op.drop_table("sys_storage_snapshot")
op.drop_table("sys_log_retention_policy")

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -18,6 +19,8 @@ from app.models.knowledge import (
)
from app.schemas.knowledge import AttentionUpdateRequest
from app.services.admin_service import OperationLogService
from app.models.logs import LogRetentionPolicy
from app.services.maintenance_service import MaintenanceService
router = APIRouter()
@@ -29,10 +32,12 @@ def retrieval_logs(
knowledgeCalled: bool | None = Query(default=None),
statusValue: str = Query(default="", alias="status"),
attentionCreated: bool | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc()).limit(300)
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc())
if sessionId is not None:
query = query.where(KnowledgeRetrievalLog.session_id == sessionId)
if knowledgeId is not None:
@@ -43,9 +48,55 @@ def retrieval_logs(
query = query.where(KnowledgeRetrievalLog.status == statusValue)
if attentionCreated is not None:
query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0))
query = query.offset((page - 1) * pageSize).limit(pageSize)
return api_success([_retrieval_dict(item, detail=False) for item in db.scalars(query).all()])
@router.post("/retrieval-log/cleanup/estimate")
def estimate_cleanup(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
before = _cleanup_before(payload)
count = db.scalar(select(func.count()).select_from(KnowledgeRetrievalLog).where(KnowledgeRetrievalLog.created_at < before)) or 0
return api_success({"before": before, "estimatedCount": count})
@router.post("/retrieval-log/cleanup")
def cleanup_logs(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
before = _cleanup_before(payload)
total = MaintenanceService.delete_retrieval_logs(db, before)
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action=f"cleanup:{total}", target_id=None)
db.commit()
return api_success({"deleted": total, "before": before})
@router.get("/retrieval-log/retention")
def get_retention(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
return api_success({"enabled": bool(policy and policy.enabled), "retentionDays": policy.retention_days if policy else None, "lastRunAt": policy.last_run_at if policy else None, "lastResult": policy.last_result if policy else None})
@router.put("/retrieval-log/retention")
def set_retention(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
days = payload.get("retentionDays")
if days is not None and (not isinstance(days, int) or days < 1):
raise HTTPException(status_code=400, detail="保留天数必须为正整数,永久保留请传 null")
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1)) or LogRetentionPolicy()
policy.retention_days, policy.enabled, policy.updated_by = days, 0 if days is None else 1, current_admin.id
db.add(policy)
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action="retention_update", target_id=None)
db.commit()
return api_success({"enabled": bool(policy.enabled), "retentionDays": policy.retention_days})
def _cleanup_before(payload: dict) -> datetime:
raw = str(payload.get("before") or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="必须指定清理日期")
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError as exc:
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
@router.get("/retrieval-log/{log_id}")
def retrieval_detail(
log_id: int,

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
import json
from pathlib import Path
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -11,6 +14,8 @@ from app.core.responses import api_success
from app.models.admin import Admin
from app.schemas.admin import DashboardStats
from app.services.admin_service import AdminDashboardService
from app.models.logs import StorageSnapshot
from app.services.redis_client import get_sync_redis_client
router = APIRouter()
@@ -30,3 +35,53 @@ def dashboard(
end_dt = None
stats = AdminDashboardService.stats(db, start_dt, end_dt)
return api_success(DashboardStats.model_validate(stats).model_dump())
@router.get("/dashboard/storage")
def storage_stats(
refresh: bool = Query(default=False),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
latest = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == "project").order_by(StorageSnapshot.id.desc()).limit(1))
if latest is not None and not refresh:
return api_success(_snapshot_dict(latest, db))
detail: dict = {"database": {"status": "unavailable", "bytes": None, "tables": []}, "redis": {"status": "unavailable", "bytes": None}, "files": {"status": "unavailable", "bytes": None}}
errors: list[str] = []
try:
if db.bind and db.bind.dialect.name == "mysql":
rows = db.execute(text("SELECT table_name, data_length + index_length AS bytes FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY bytes DESC")).all()
tables = [{"name": row[0], "bytes": int(row[1] or 0)} for row in rows]
detail["database"] = {"status": "ok", "bytes": sum(x["bytes"] for x in tables), "tables": tables}
else:
detail["database"] = {"status": "unavailable", "bytes": None, "tables": [], "message": "当前数据库不支持精确表占用统计"}
except Exception as exc:
errors.append(f"database: {exc}")
try:
redis = get_sync_redis_client()
if redis is not None:
info = redis.info("memory")
detail["redis"] = {"status": "ok", "bytes": int(info.get("used_memory", 0))}
except Exception as exc:
errors.append(f"redis: {exc}")
try:
root = Path(".uploads")
if root.exists():
detail["files"] = {"status": "ok", "bytes": sum(p.stat().st_size for p in root.rglob("*") if p.is_file())}
else:
detail["files"] = {"status": "unavailable", "bytes": None, "message": "项目未配置本地附件目录"}
except Exception as exc:
errors.append(f"files: {exc}")
known = [x.get("bytes") for x in detail.values() if x.get("status") == "ok"]
snapshot = StorageSnapshot(scope="project", total_bytes=sum(known) if known else None, detail_json=json.dumps(detail, ensure_ascii=False), status="partial" if errors or any(x.get("status") != "ok" for x in detail.values()) else "ok", error_message="; ".join(errors) or None)
db.add(snapshot); db.commit(); db.refresh(snapshot)
return api_success(_snapshot_dict(snapshot, db))
def _snapshot_dict(item: StorageSnapshot, db: Session) -> dict:
with_growth = {"totalBytes": item.total_bytes, "detail": json.loads(item.detail_json), "status": item.status, "errorMessage": item.error_message, "createdAt": item.created_at}
# Growth is intentionally unknown until enough snapshots exist; never report a misleading zero.
for days, key in ((7, "growth7DaysBytes"), (30, "growth30DaysBytes")):
previous = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == item.scope, StorageSnapshot.created_at <= item.created_at - timedelta(days=days)).order_by(StorageSnapshot.created_at.desc()).limit(1))
with_growth[key] = item.total_bytes - previous.total_bytes if previous and item.total_bytes is not None and previous.total_bytes is not None else None
return with_growth

View File

@@ -36,6 +36,31 @@ def list_knowledge(db: Session = Depends(get_db), current_admin: Admin = Depends
return api_success([_knowledge_dict(item) for item in items])
@router.post("/knowledge/resolve-node")
def resolve_knowledge_node(
payload: dict,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
node_id = str(payload.get("nodeId") or "").strip()
if not node_id or len(node_id) > 100 or not node_id.replace("_", "").replace("-", "").isalnum():
raise HTTPException(status_code=400, detail="Node ID 格式错误")
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == node_id))
if existing is not None:
raise HTTPException(status_code=409, detail=f"该 Node ID 已添加(知识库:{existing.name}")
try:
info = FeishuKnowledgeService.get_node_info(node_id, db)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"飞书节点信息获取失败:{exc}") from exc
space_id, title = str(info.get("space_id") or "").strip(), str(info.get("title") or "").strip()
if not space_id:
raise HTTPException(status_code=422, detail="飞书接口未返回 Space ID请检查节点是否移动或当前应用权限")
if not title:
raise HTTPException(status_code=422, detail="飞书接口未返回文章标题,请检查文章是否已删除")
return api_success({"nodeId": node_id, "spaceId": space_id, "sourceTitle": title, "name": title, "remark": str(info.get("description") or ""), "objType": info.get("obj_type")})
@router.post("/knowledge/import")
def import_knowledge_from_space(
payload: dict,
@@ -91,12 +116,7 @@ def create_knowledge(
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
existing = db.scalar(
select(Knowledge).where(
Knowledge.feishu_space_id == payload.feishuSpaceId,
Knowledge.feishu_node_id == payload.feishuNodeId,
)
)
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == payload.feishuNodeId))
if existing is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库")
knowledge = Knowledge(

View File

@@ -11,6 +11,8 @@ from app.core.database import create_tables
from app.core.exception_handlers import register_exception_handlers
from app.core.observability import RequestObservabilityMiddleware, configure_logging
from app.services.secret_service import SecretService
from app.services.maintenance_service import MaintenanceService
import asyncio
@asynccontextmanager
@@ -19,7 +21,15 @@ async def lifespan(app: FastAPI):
SecretService.validate_production_key()
if settings.auto_create_tables:
create_tables()
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
try:
yield
finally:
maintenance_task.cancel()
try:
await maintenance_task
except asyncio.CancelledError:
pass
settings = get_settings()

View File

@@ -19,7 +19,7 @@ from app.models.knowledge import (
KnowledgeVersion,
UserKnowledgePermission,
)
from app.models.logs import AiRequestLog, OperationLog
from app.models.logs import AiRequestLog, LogRetentionPolicy, OperationLog, StorageSnapshot
from app.models.user import User
__all__ = [
@@ -44,6 +44,8 @@ __all__ = [
"HumanAttentionRecord",
"ModelConfig",
"OperationLog",
"LogRetentionPolicy",
"StorageSnapshot",
"Prompt",
"Role",
"SystemConfig",

View File

@@ -44,3 +44,25 @@ class OperationLog(Base):
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
result: Mapped[str] = mapped_column(String(20), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class LogRetentionPolicy(Base):
__tablename__ = "sys_log_retention_policy"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_result: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
class StorageSnapshot(Base):
__tablename__ = "sys_storage_snapshot"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
scope: Mapped[str] = mapped_column(String(50), nullable=False)
total_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
detail_json: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)

View File

@@ -23,6 +23,7 @@ from app.models.knowledge import (
KnowledgeVersion,
)
from app.services.knowledge_pipeline_service import expand_synonyms, extract_terms
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
from app.services.knowledge_service import KnowledgeScope
from app.services.model_service import _call_configured_model, _system_config_bool
from app.services.rag_service import PromptService, RagResult, RetrievedChunk
@@ -76,16 +77,16 @@ class KnowledgeAgentService:
db.add(log)
db.flush()
scopes = [KnowledgeScope(id=item["knowledgeId"], name=item["name"], feishu_space_id="", feishu_node_id="") for item in catalog]
trace: list[dict] = [{"tool": "get_knowledge_catalog", "count": len(catalog)}]
trace: list[dict] = [cls._trace("get_knowledge_catalog", 1, {}, {"items": catalog, "count": len(catalog)}, started)]
need_knowledge, selected_ids, reason = cls._decide(question, catalog)
trace.append({"tool": "agent_decision", "needKnowledge": need_knowledge, "selectedKnowledgeIds": selected_ids, "reason": reason})
trace.append(cls._trace("agent_decision", 2, {"question": question}, {"needKnowledge": need_knowledge, "selectedKnowledgeIds": selected_ids, "reason": reason}, started))
chunks: list[RetrievedChunk] = []
try:
if need_knowledge and selected_ids:
terms = cls._query_terms(question)
candidates = cls.search_knowledge(db, terms, selected_ids, catalog)
trace.append({"tool": "search_knowledge", "queryTerms": terms, "candidateCount": len(candidates)})
await cls._rerank(db, question, candidates, trace)
trace.append(cls._trace("search_knowledge", len(trace) + 1, {"queryTerms": terms, "knowledgeIds": selected_ids}, {"candidateCount": len(candidates), "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
await cls._rerank(db, question, candidates, trace, started)
selected = cls._select_sections(candidates)
chunks = [
RetrievedChunk(
@@ -100,7 +101,7 @@ class KnowledgeAgentService:
)
for item in selected
]
trace.append({"tool": "read_knowledge_sections", "sectionIds": [item.section.id for item in selected]})
trace.append(cls._trace("read_knowledge_sections", len(trace) + 1, {"sectionIds": [item.section.id for item in selected]}, {"count": len(selected), "sections": [{"knowledgeId": x.knowledge.id, "knowledgeName": x.knowledge.name, "sectionId": x.section.id, "title": x.section.title, "content": cls._protect_content(x.section.content), "adopted": True} for x in selected]}, started))
cls._persist_candidates(db, log.id, candidates)
log.knowledge_called = 1
log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids)
@@ -134,6 +135,13 @@ class KnowledgeAgentService:
version_overrides: dict[int, int] | None = None,
preview_knowledge_ids: list[int] | None = None,
) -> list[dict]:
cacheable = version_overrides is None and preview_knowledge_ids is None
if cacheable:
return KnowledgeCatalogCacheService.get_or_build("open", lambda: KnowledgeAgentService._catalog_from_db(db, None, None))
return KnowledgeAgentService._catalog_from_db(db, version_overrides, preview_knowledge_ids)
@staticmethod
def _catalog_from_db(db: Session, version_overrides: dict[int, int] | None, preview_knowledge_ids: list[int] | None) -> list[dict]:
query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
if preview_knowledge_ids is None:
query = query.where(
@@ -171,6 +179,15 @@ class KnowledgeAgentService:
)
return catalog
@staticmethod
def _trace(tool: str, order: int, request: dict, response: dict, started: float, *, status: str = "success", error: str | None = None, retries: int = 0) -> dict:
now = int((perf_counter() - started) * 1000)
return {"tool": tool, "order": order, "request": request, "response": response, **response, "status": status, "startedAtMs": now, "endedAtMs": now, "durationMs": 0, "responseCount": response.get("count", response.get("candidateCount")), "error": error, "retries": retries}
@staticmethod
def _candidate_trace(item: Candidate) -> dict:
return {"knowledgeId": item.knowledge.id, "knowledgeName": item.knowledge.name, "versionId": item.version.id, "chunkId": item.chunk.id, "sectionId": item.section.id, "title": item.chunk.title, "lexicalScore": item.lexical_score, "rerankScore": item.rerank_score, "selected": item.selected, "discardReason": item.discard_reason}
@classmethod
def search_knowledge(cls, db: Session, terms: list[str], selected_ids: list[int], catalog: list[dict]) -> list[Candidate]:
versions_by_kb = {item["knowledgeId"]: item["versionId"] for item in catalog}
@@ -200,12 +217,12 @@ class KnowledgeAgentService:
return candidates[:MAX_LEXICAL_CANDIDATES]
@classmethod
async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict]) -> None:
async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict], started: float) -> None:
if not candidates:
return
model = db.scalar(select(ModelConfig).where(ModelConfig.enabled == 1).order_by(ModelConfig.id.desc()).limit(1))
if model is None or _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
trace.append({"tool": "model_rerank", "status": "lexical_fallback", "reason": "模型未启用或处于 mock"})
trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "mode": "lexical_fallback", "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
return
prompt = (
"你是知识检索重排器。根据用户问题给候选打0到100相关分只返回JSON"
@@ -221,9 +238,9 @@ class KnowledgeAgentService:
for index, item in enumerate(candidates, 1):
item.rerank_score = score_map.get(index)
candidates.sort(key=lambda item: item.rerank_score if item.rerank_score is not None else item.lexical_score, reverse=True)
trace.append({"tool": "model_rerank", "status": "success", "candidateCount": len(candidates)})
trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
except Exception as exc:
trace.append({"tool": "model_rerank", "status": "lexical_fallback", "reason": str(exc)[:300]})
trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "mode": "lexical_fallback"}, started, status="fallback", error=str(exc)[:300]))
@staticmethod
def _select_sections(candidates: list[Candidate]) -> list[Candidate]:

View File

@@ -1,12 +1,46 @@
from __future__ import annotations
import json
import time
from typing import Callable
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"
VERSION_KEY = "kb:catalog:generation"
KEY_PREFIX = "kb:catalog:v1"
METRICS_KEY = "kb:catalog:metrics"
TTL_SECONDS = 300
@classmethod
def get_or_build(cls, variant: str, builder: Callable[[], list[dict]]) -> list[dict]:
"""Read-through cache. Redis is optional and never the source of truth."""
client = get_sync_redis_client()
if client is None:
return builder()
started = time.perf_counter()
try:
generation = client.get(cls.VERSION_KEY) or "1"
key = f"{cls.KEY_PREFIX}:{generation}:{variant}"
cached = client.get(key)
if cached:
client.hincrby(cls.METRICS_KEY, "hits", 1)
client.hincrbyfloat(cls.METRICS_KEY, "hit_ms", (time.perf_counter() - started) * 1000)
return json.loads(cached)
client.hincrby(cls.METRICS_KEY, "misses", 1)
value = builder()
# Short lock prevents concurrent cold requests from repeatedly rebuilding.
lock = client.set(f"{key}:lock", "1", nx=True, ex=15)
if lock:
client.setex(key, cls.TTL_SECONDS, json.dumps(value, ensure_ascii=False, default=str))
client.delete(f"{key}:lock")
client.hincrbyfloat(cls.METRICS_KEY, "miss_ms", (time.perf_counter() - started) * 1000)
return value
except Exception:
return builder()
@classmethod
def invalidate(cls) -> None:

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timedelta
from sqlalchemy import delete, select
from app.core.database import SessionLocal
from app.models.knowledge import KnowledgeRetrievalCandidate, KnowledgeRetrievalLog
from app.models.logs import LogRetentionPolicy
from app.services.redis_client import get_sync_redis_client
class MaintenanceService:
"""Single-runner background maintenance. MySQL remains authoritative."""
@classmethod
async def run_forever(cls) -> None:
while True:
await asyncio.to_thread(cls.run_due_jobs)
await asyncio.sleep(3600)
@classmethod
def run_due_jobs(cls) -> None:
redis = get_sync_redis_client()
lock_acquired = False
try:
if redis is not None:
lock_acquired = bool(redis.set("maintenance:daily:lock", "1", nx=True, ex=3300))
if not lock_acquired:
return
with SessionLocal() as db:
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
if not policy or not policy.enabled or not policy.retention_days:
return
if policy.last_run_at and policy.last_run_at.date() >= datetime.now().date():
return
before = datetime.now() - timedelta(days=policy.retention_days)
deleted = cls.delete_retrieval_logs(db, before)
policy.last_run_at = datetime.now()
policy.last_result = json.dumps({"status": "success", "deleted": deleted, "before": before.isoformat()}, ensure_ascii=False)
db.add(policy)
db.commit()
except Exception as exc:
with SessionLocal() as db:
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
if policy:
policy.last_run_at = datetime.now()
policy.last_result = json.dumps({"status": "failed", "error": str(exc)[:1000]}, ensure_ascii=False)
db.add(policy)
db.commit()
finally:
if redis is not None and lock_acquired:
try:
redis.delete("maintenance:daily:lock")
except Exception:
pass
@staticmethod
def delete_retrieval_logs(db, before: datetime, batch_size: int = 500) -> int:
total = 0
while True:
ids = list(db.scalars(select(KnowledgeRetrievalLog.id).where(KnowledgeRetrievalLog.created_at < before).order_by(KnowledgeRetrievalLog.id).limit(batch_size)).all())
if not ids:
return total
db.execute(delete(KnowledgeRetrievalCandidate).where(KnowledgeRetrievalCandidate.retrieval_log_id.in_(ids)))
db.execute(delete(KnowledgeRetrievalLog).where(KnowledgeRetrievalLog.id.in_(ids)))
total += len(ids)
db.commit()

View File

@@ -0,0 +1,45 @@
# 后台测试问题修复与新增需求实施记录
## 本次已落地
- Agent 预览进入时重新读取知识库状态,默认仅选中已开放且未归档项;管理员仍可手动选择已关闭项用于本次预览。
- Agent 知识库多选框增加搜索、标签折叠、计数提示、高度上限和弹层层级。
- Tool 追踪改为独立可折叠节点,后端持久化请求、响应、顺序、状态、耗时、数量、错误与重试字段;空响应明确显示。
- KnowledgeList 增加 Redis read-through 缓存Redis 故障自动回源 MySQL采用代际号统一失效记录命中/未命中次数和耗时。
- 知识库新增恢复为 Node ID 两步流程:解析节点后回填只读 Space ID、飞书原标题、后台名称和备注创建前后端双重校验。
- 检索日志列表增加数据库 offset/limit 分页,详情仍按需单独获取;新增估算和 500 条一批的清理接口。
- 新增保留策略配置接口,支持永久保留或自定义天数;清理先删候选明细再删主日志,不触及操作审计表。
- 看板增加存储快照,区分 MySQL 表占用、Redis 内存和本地文件;默认读最新快照,手动刷新才重新统计,不可获取时不伪造 0。
- 模型列表操作区保留编辑/测试,启用/删除收入更多菜单;操作区统一高度、间距、禁止换行。知识库表已采用相同的“主操作 + 更多”模式。
## SQL 和索引
迁移 `0009_log_retention_storage_indexes.py` 新增:
- `sys_knowledge_retrieval_log(status, created_at)`
- `sys_knowledge_retrieval_log(created_at, id)`
- `sys_ai_request_log(status, created_at)`
- `sys_operation_log(module, created_at)`
- `sys_chat_session(is_deleted, updated_at)`
- `sys_log_retention_policy`
- `sys_storage_snapshot`
优化前日志列表固定读取最新 300 条,无可控分页;优化后每次默认读 50 条、上限 100 条,且列表不读 `catalog_snapshot/tool_trace/final_answer`。实际 SQL 耗时依赖生产数据量与 MySQL 执行计划,需在迁移后用生产同量级数据执行 `EXPLAIN ANALYZE` 记录,本地不伪造耗时数字。
## Redis Key
- `kb:catalog:generation`:目录代际号。
- `kb:catalog:v1:{generation}:open`当前已开放知识库目录TTL 300 秒。
- `kb:catalog:v1:{generation}:open:lock`冷启动重建锁15 秒。
- `kb:catalog:metrics``hits/misses/hit_ms/miss_ms` 累计指标。
新增、名称/类型/说明卡变更、开放/关闭、发布、回滚、归档/恢复和删除均通过代际号失效。
## 验证与剩余风险
- 管理端 `npm run build` 通过。
- 后端 Python 全量 AST/字节码编译通过,`git diff --check` 通过。
- Docker Compose 配置校验通过。
- 后端容器内 `PYTHONPATH=/app pytest -q`17 项全部通过。
- 定期清理已接入后端维护循环Redis 分布式锁防止多实例重复执行,每日最多执行一次。
- 7/30 日增长需要至少积累两个存储快照后才可准确计算,首次上线不应显示伪造的 0。