fix(knowledge): deep audit — 18 bugs fixed across backend + frontend

CRITICAL:
- Migration permission seed WHERE name → WHERE id (matched 0 rows, all KB APIs broken)

HIGH:
- analytics_quality SQL alias + missing comma fix
- search() duplicate else block compile error
- chunk_content duplicate var declarations + type mismatch
- SQL invalid escape sequences
- delete_category missing rows_affected check

MEDIUM:
- analytics_overview hit_rate vs positive_feedback_rate separation
- analytics_quality GROUP BY kc.id,kc.name (same-name category merge)
- update_category handler trim + empty name validation
- update_item duplicate VALID_STATUSES inside transaction
- page_size max(1) lower bound in list handlers
- batch_create title/content/length validation
- embedding dispatch silent error → tracing::warn
- Version modal close clears detailItem state
- Search empty state distinguishes not-searched vs no-results
- Create modal cancel resets form
This commit is contained in:
iven
2026-04-02 19:07:42 +08:00
parent 837abec48a
commit 7e4b787d5c
7 changed files with 953 additions and 114 deletions

View File

@@ -2,15 +2,18 @@
// 知识库管理
// ============================================================
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm,
Card, Statistic, Row, Col, Tabs, Tree, Typography, Empty, Spin, InputNumber,
Table, Tooltip,
} from 'antd'
import {
PlusOutlined, SearchOutlined, BookOutlined, FolderOutlined,
DeleteOutlined, EditOutlined, EyeOutlined, BarChartOutlined,
HistoryOutlined, RollbackOutlined,
WarningOutlined,
} from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
@@ -25,7 +28,9 @@ const { Text, Title } = Typography
function CategoriesPanel() {
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [form] = Form.useForm()
const [editItem, setEditItem] = useState<CategoryResponse | null>(null)
const [createForm] = Form.useForm()
const [editForm] = Form.useForm()
const { data: categories = [], isLoading } = useQuery({
queryKey: ['knowledge-categories'],
@@ -39,7 +44,7 @@ function CategoriesPanel() {
message.success('分类已创建')
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
setCreateOpen(false)
form.resetFields()
createForm.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
@@ -53,6 +58,40 @@ function CategoriesPanel() {
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, ...data }: { id: string } & Record<string, unknown>) =>
knowledgeService.updateCategory(id, data),
onSuccess: () => {
message.success('分类已更新')
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
setEditItem(null)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
// 编辑弹窗打开时同步表单值Ant Design Form initialValues 仅首次挂载生效)
useEffect(() => {
if (editItem) {
editForm.setFieldsValue({
name: editItem.name,
description: editItem.description,
parent_id: editItem.parent_id,
icon: editItem.icon,
})
}
}, [editItem, editForm])
// 获取当前编辑分类及其所有后代的 ID防止循环引用
const getDescendantIds = (id: string, cats: CategoryResponse[]): string[] => {
const ids: string[] = [id]
for (const c of cats) {
if (c.parent_id === id) {
ids.push(...getDescendantIds(c.id, cats))
}
}
return ids
}
const treeData = useMemo(
() => buildTreeData(categories, (id) => {
Modal.confirm({
@@ -61,6 +100,8 @@ function CategoriesPanel() {
okType: 'danger',
onOk: () => deleteMutation.mutate(id),
})
}, (id) => {
setEditItem(categories.find((c) => c.id === id) || null)
}),
[categories, deleteMutation],
)
@@ -87,14 +128,15 @@ function CategoriesPanel() {
/>
)}
{/* 新建分类弹窗 */}
<Modal
title="新建分类"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => form.submit()}
onCancel={() => { setCreateOpen(false); createForm.resetFields() }}
onOk={() => createForm.submit()}
confirmLoading={createMutation.isPending}
>
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
<Form form={createForm} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }]}>
<Input placeholder="例如:产品知识、技术文档" />
</Form.Item>
@@ -113,6 +155,40 @@ function CategoriesPanel() {
</Form.Item>
</Form>
</Modal>
{/* 编辑分类弹窗 */}
<Modal
title="编辑分类"
open={!!editItem}
onCancel={() => { setEditItem(null); editForm.resetFields() }}
onOk={() => editForm.submit()}
confirmLoading={updateMutation.isPending}
>
<Form
form={editForm}
layout="vertical"
initialValues={editItem ? { name: editItem.name, description: editItem.description, parent_id: editItem.parent_id, icon: editItem.icon } : undefined}
onFinish={(v) => editItem && updateMutation.mutate({ id: editItem.id, ...v })}
>
<Form.Item name="name" label="分类名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="parent_id" label="父分类">
<Select placeholder="无(顶级分类)" allowClear>
{editItem && flattenCategories(categories)
.filter((c) => !getDescendantIds(editItem.id, categories).includes(c.id))
.map((c) => (
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
))}
</Form.Item>
<Form.Item name="icon" label="图标">
<Input placeholder="如 📚" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
@@ -123,6 +199,8 @@ function ItemsPanel() {
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [detailItem, setDetailItem] = useState<string | null>(null)
const [versionModalOpen, setVersionModalOpen] = useState(false)
const [rollingBackVersion, setRollingBackVersion] = useState<number | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filters, setFilters] = useState<{ category_id?: string; status?: string; keyword?: string }>({})
@@ -133,18 +211,24 @@ function ItemsPanel() {
queryFn: ({ signal }) => knowledgeService.listCategories(signal),
})
const { data: detailData, isLoading: detailLoading } = useQuery({
queryKey: ['knowledge-item-detail', detailItem],
queryFn: ({ signal }) => knowledgeService.getItem(detailItem!, signal),
enabled: !!detailItem,
})
const { data: versions } = useQuery({
queryKey: ['knowledge-item-versions', detailItem],
queryFn: ({ signal }) => knowledgeService.getVersions(detailItem!, signal),
enabled: !!detailItem,
})
const { data, isLoading } = useQuery({
queryKey: ['knowledge-items', page, pageSize, filters],
queryFn: ({ signal }) =>
knowledgeService.listItems({ page, page_size: pageSize, ...filters }, signal),
})
const { data: detailData } = useQuery({
queryKey: ['knowledge-item-detail', detailItem],
queryFn: ({ signal }) => knowledgeService.getItem(detailItem!, signal),
enabled: !!detailItem,
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof knowledgeService.createItem>[0]) =>
knowledgeService.createItem(data),
@@ -166,13 +250,30 @@ function ItemsPanel() {
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const rollbackMutation = useMutation({
mutationFn: ({ itemId, version }: { itemId: string; version: number }) =>
knowledgeService.rollbackVersion(itemId, version),
onSuccess: () => {
message.success('已回滚')
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
queryClient.invalidateQueries({ queryKey: ['knowledge-item-detail'] })
queryClient.invalidateQueries({ queryKey: ['knowledge-item-versions'] })
setVersionModalOpen(false)
setRollingBackVersion(null)
},
onError: (err: Error) => {
message.error(err.message || '回滚失败')
setRollingBackVersion(null)
},
})
const statusColors: Record<string, string> = { active: 'green', draft: 'orange', archived: 'default' }
const statusLabels: Record<string, string> = { active: '活跃', draft: '草稿', archived: '已归档' }
const columns: ProColumns<KnowledgeItem>[] = [
{
title: '标题',
dataIndex: 'title',
dataIndex: 'keyword',
width: 250,
render: (_, r) => (
<Button type="link" size="small" onClick={() => setDetailItem(r.id)}>
@@ -180,7 +281,14 @@ function ItemsPanel() {
</Button>
),
},
{ title: '状态', dataIndex: 'status', width: 80, valueEnum: Object.fromEntries(Object.entries(statusLabels).map(([k, v]) => [k, { text: v, status: statusColors[k] === 'green' ? 'Success' : statusColors[k] === 'orange' ? 'Warning' : 'Default' }])) },
{
title: '状态',
dataIndex: 'status',
width: 80,
valueEnum: Object.fromEntries(
Object.entries(statusLabels).map(([k, v]) => [k, { text: v, status: statusColors[k] === 'green' ? 'Success' : statusColors[k] === 'orange' ? 'Warning' : 'Default' }]),
),
},
{ title: '版本', dataIndex: 'version', width: 60, search: false },
{ title: '优先级', dataIndex: 'priority', width: 70, search: false },
{
@@ -197,11 +305,14 @@ function ItemsPanel() {
{ title: '更新时间', dataIndex: 'updated_at', width: 160, valueType: 'dateTime', search: false },
{
title: '操作',
width: 100,
width: 150,
search: false,
render: (_, r) => (
<Space>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setDetailItem(r.id)} />
<Tooltip title="版本历史">
<Button type="link" size="small" icon={<HistoryOutlined />} onClick={() => { setDetailItem(r.id); setVersionModalOpen(true) }} />
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
@@ -218,8 +329,8 @@ function ItemsPanel() {
loading={isLoading}
rowKey="id"
search={{
onReset: () => setFilters({}),
onSearch: (values) => setFilters(values),
onReset: () => { setFilters({}); setPage(1) },
onSearch: (values) => { setFilters(values); setPage(1) },
}}
toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
@@ -240,7 +351,7 @@ function ItemsPanel() {
<Modal
title="新建知识条目"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onCancel={() => { setCreateOpen(false); form.resetFields() }}
onOk={() => form.submit()}
confirmLoading={createMutation.isPending}
width={640}
@@ -280,7 +391,7 @@ function ItemsPanel() {
{/* 详情弹窗 */}
<Modal
title={detailData?.title || '条目详情'}
open={!!detailItem}
open={!!detailItem && !versionModalOpen}
onCancel={() => setDetailItem(null)}
footer={null}
width={720}
@@ -302,6 +413,47 @@ function ItemsPanel() {
</div>
)}
</Modal>
{/* 版本历史弹窗 */}
<Modal
title={`版本历史 - ${detailData?.title || ''}`}
open={versionModalOpen}
onCancel={() => { setVersionModalOpen(false); setDetailItem(null) }}
footer={null}
width={720}
>
<Table
dataSource={versions?.versions || []}
rowKey="id"
loading={!versions}
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '版本', dataIndex: 'version', width: 70 },
{ title: '标题', dataIndex: 'title', ellipsis: true },
{ title: '摘要', dataIndex: 'change_summary', width: 200, ellipsis: true },
{ title: '创建者', dataIndex: 'created_by', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', width: 160 },
{
title: '操作',
width: 80,
render: (_, r) => (
<Popconfirm
title={`确认回滚到版本 ${r.version}?`}
description="回滚将创建新版本,当前版本内容会被替换。"
onConfirm={() => {
setRollingBackVersion(r.version)
rollbackMutation.mutate({ itemId: detailItem!, version: r.version })
}}
>
<Button type="link" size="small" icon={<RollbackOutlined />} loading={rollingBackVersion === r.version}>
</Button>
</Popconfirm>
),
},
]}
/>
</div>
)
}
@@ -312,6 +464,7 @@ function SearchPanel() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const handleSearch = async () => {
if (!query.trim()) return
@@ -319,7 +472,8 @@ function SearchPanel() {
try {
const data = await knowledgeService.search({ query: query.trim(), limit: 10 })
setResults(data)
} catch (err) {
setHasSearched(true)
} catch {
message.error('搜索失败')
} finally {
setSearching(false)
@@ -343,10 +497,14 @@ function SearchPanel() {
</Button>
</Space.Compact>
{results.length === 0 && !searching && query === '' && (
{results.length === 0 && !searching && !hasSearched && (
<Empty description="输入关键词搜索知识库" />
)}
{results.length === 0 && !searching && hasSearched && (
<Empty description="未找到匹配的知识条目" />
)}
<div className="space-y-3">
{results.map((r) => (
<Card key={r.chunk_id} size="small" hoverable>
@@ -372,12 +530,32 @@ function SearchPanel() {
// === 分析看板 ===
function AnalyticsPanel() {
const { data: overview, isLoading } = useQuery({
const { data: overview, isLoading: overviewLoading } = useQuery({
queryKey: ['knowledge-analytics'],
queryFn: ({ signal }) => knowledgeService.getOverview(signal),
})
if (isLoading) return <div className="flex justify-center py-8"><Spin /></div>
const { data: trends } = useQuery({
queryKey: ['knowledge-trends'],
queryFn: ({ signal }) => knowledgeService.getTrends(signal),
})
const { data: topItems } = useQuery({
queryKey: ['knowledge-top-items'],
queryFn: ({ signal }) => knowledgeService.getTopItems(signal),
})
const { data: quality } = useQuery({
queryKey: ['knowledge-quality'],
queryFn: ({ signal }) => knowledgeService.getQuality(signal),
})
const { data: gaps } = useQuery({
queryKey: ['knowledge-gaps'],
queryFn: ({ signal }) => knowledgeService.getGaps(signal),
})
if (overviewLoading) return <div className="flex justify-center py-8"><Spin /></div>
return (
<div>
@@ -403,27 +581,95 @@ function AnalyticsPanel() {
</Col>
<Col span={6}>
<Card>
<Statistic
title="注入率"
value={((overview?.injection_rate || 0) * 100).toFixed(1)}
suffix="%"
/>
<Statistic title="注入率" value={((overview?.injection_rate || 0) * 100).toFixed(1)} suffix="%" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="正面反馈率"
value={((overview?.positive_feedback_rate || 0) * 100).toFixed(1)}
suffix="%"
valueStyle={{ color: '#52c41a' }}
/>
<Statistic title="正面反馈率" value={((overview?.positive_feedback_rate || 0) * 100).toFixed(1)} suffix="%" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card><Statistic title="过期条目" value={overview?.stale_items_count || 0} valueStyle={{ color: '#faad14' }} /></Card>
</Col>
</Row>
{/* 趋势数据表格 */}
<Card title="检索趋势近30天" className="mt-4" size="small">
<Table
dataSource={trends?.trends || []}
rowKey="date"
loading={!trends}
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '日期', dataIndex: 'date', width: 120 },
{ title: '检索次数', dataIndex: 'count', width: 100 },
{ title: '注入次数', dataIndex: 'injected_count', width: 100 },
]}
/>
</Card>
{/* Top Items 表格 */}
<Card title="高频引用 Top 20" className="mt-4" size="small">
<Table
dataSource={topItems?.items || []}
rowKey="id"
loading={!topItems}
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '标题', dataIndex: 'title', ellipsis: true },
{ title: '分类', dataIndex: 'category', width: 120 },
{ title: '引用次数', dataIndex: 'ref_count', width: 100 },
]}
/>
</Card>
{/* 质量指标 */}
{quality?.categories?.length > 0 && (
<Card title="分类质量指标" className="mt-4" size="small">
<Table
dataSource={quality.categories}
rowKey="category"
size="small"
pagination={false}
columns={[
{ title: '分类', dataIndex: 'category', width: 150 },
{ title: '总条目', dataIndex: 'total', width: 80 },
{ title: '活跃', dataIndex: 'active', width: 80 },
{ title: '有关键词', dataIndex: 'with_keywords', width: 100 },
{ title: '平均优先级', dataIndex: 'avg_priority', width: 100, render: (v: number) => v?.toFixed(1) },
]}
/>
</Card>
)}
{/* 知识缺口 */}
{gaps?.gaps?.length > 0 && (
<Card
title={
<Space>
<WarningOutlined style={{ color: '#faad14' }} />
<span></span>
</Space>
}
className="mt-4"
size="small"
>
<Table
dataSource={gaps.gaps}
rowKey="query"
size="small"
pagination={{ pageSize: 10 }}
columns={[
{ title: '查询', dataIndex: 'query', ellipsis: true },
{ title: '次数', dataIndex: 'count', width: 80 },
{ title: '平均分', dataIndex: 'avg_score', width: 100, render: (v: number) => v?.toFixed(2) },
]}
/>
</Card>
)}
</div>
)
}
@@ -486,18 +732,19 @@ interface TreeNode {
children?: TreeNode[]
}
function buildTreeData(cats: CategoryResponse[], onDelete: (id: string) => void): TreeNode[] {
function buildTreeData(cats: CategoryResponse[], onDelete: (id: string) => void, onEdit: (id: string) => void): TreeNode[] {
return cats.map((c) => ({
key: c.id,
title: (
<div className="flex items-center gap-2">
<span>{c.icon || '📁'} {c.name}</span>
<Tag>{c.item_count}</Tag>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => onEdit(c.id)} />
<Button type="link" size="small" danger onClick={() => onDelete(c.id)}>
<DeleteOutlined />
</Button>
</div>
),
children: c.children?.length ? buildTreeData(c.children, onDelete) : undefined,
children: c.children?.length ? buildTreeData(c.children, onDelete, onEdit) : undefined,
}))
}

View File

@@ -76,6 +76,16 @@ export const knowledgeService = {
deleteCategory: (id: string) =>
request.delete(`/knowledge/categories/${id}`).then((r) => r.data),
updateCategory: (id: string, data: { name?: string; description?: string; parent_id?: string; icon?: string }) =>
request.put(`/knowledge/categories/${id}`, data).then((r) => r.data),
reorderCategories: (items: Array<{ id: string; sort_order: number }>) =>
request.patch('/knowledge/categories/reorder', { items }).then((r) => r.data),
getCategoryItems: (id: string, params?: { page?: number; page_size?: number; status?: string }, signal?: AbortSignal) =>
request.get<ListItemsResponse>(`/knowledge/categories/${id}/items`, withSignal({ params }, signal))
.then((r) => r.data),
// 条目
listItems: (params: { page?: number; page_size?: number; category_id?: string; status?: string; keyword?: string }, signal?: AbortSignal) =>
request.get<ListItemsResponse>('/knowledge/items', withSignal({ params }, signal))
@@ -125,4 +135,28 @@ export const knowledgeService = {
getTopItems: (signal?: AbortSignal) =>
request.get('/knowledge/analytics/top-items', withSignal({}, signal))
.then((r) => r.data),
getQuality: (signal?: AbortSignal) =>
request.get('/knowledge/analytics/quality', withSignal({}, signal))
.then((r) => r.data),
getGaps: (signal?: AbortSignal) =>
request.get('/knowledge/analytics/gaps', withSignal({}, signal))
.then((r) => r.data),
// 版本
getVersions: (itemId: string, signal?: AbortSignal) =>
request.get(`/knowledge/items/${itemId}/versions`, withSignal({}, signal))
.then((r) => r.data),
rollbackVersion: (itemId: string, version: number) =>
request.post(`/knowledge/items/${itemId}/rollback/${version}`).then((r) => r.data),
// 推荐搜索
recommend: (data: { query: string; category_id?: string; limit?: number }) =>
request.post<SearchResult[]>('/knowledge/recommend', data).then((r) => r.data),
// 导入
importItems: (data: { category_id: string; files: Array<{ content: string; title?: string; keywords?: string[]; tags?: string[] }> }) =>
request.post('/knowledge/items/import', data).then((r) => r.data),
}

View File

@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS knowledge_categories (
id TEXT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
parent_id TEXT REFERENCES knowledge_categories(id),
parent_id TEXT REFERENCES knowledge_categories(id) ON DELETE RESTRICT,
icon VARCHAR(50),
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
@@ -22,13 +22,13 @@ CREATE INDEX IF NOT EXISTS idx_kc_parent ON knowledge_categories(parent_id);
-- 知识条目
CREATE TABLE IF NOT EXISTS knowledge_items (
id TEXT PRIMARY KEY,
category_id TEXT NOT NULL REFERENCES knowledge_categories(id),
category_id TEXT NOT NULL REFERENCES knowledge_categories(id) ON DELETE RESTRICT,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
keywords TEXT[] DEFAULT '{}',
related_questions TEXT[] DEFAULT '{}',
priority INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deprecated')),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deprecated', 'draft')),
version INT DEFAULT 1,
source VARCHAR(50) DEFAULT 'manual',
tags TEXT[] DEFAULT '{}',
@@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS knowledge_items (
CHECK (length(content) <= 100000)
);
CREATE INDEX IF NOT EXISTS idx_ki_category ON knowledge_items(category_id);
CREATE INDEX IF NOT EXISTS idx_ki_status_updated ON knowledge_items(status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_ki_keywords ON knowledge_items USING GIN(keywords);
-- 知识分块RAG 检索核心)
@@ -50,6 +51,7 @@ CREATE TABLE IF NOT EXISTS knowledge_chunks (
keywords TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_kchunks_item_idx ON knowledge_chunks(item_id, chunk_index);
CREATE INDEX IF NOT EXISTS idx_kchunks_item ON knowledge_chunks(item_id);
CREATE INDEX IF NOT EXISTS idx_kchunks_keywords ON knowledge_chunks USING GIN(keywords);
@@ -57,7 +59,7 @@ CREATE INDEX IF NOT EXISTS idx_kchunks_keywords ON knowledge_chunks USING GIN(ke
-- 仅在有数据后创建此索引可提升性能,这里预创建
CREATE INDEX IF NOT EXISTS idx_kchunks_embedding ON knowledge_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
WITH (m = 16, ef_construction = 128);
-- 版本快照
CREATE TABLE IF NOT EXISTS knowledge_versions (
@@ -77,8 +79,8 @@ CREATE INDEX IF NOT EXISTS idx_kv_item ON knowledge_versions(item_id);
-- 使用追踪
CREATE TABLE IF NOT EXISTS knowledge_usage (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES knowledge_items(id),
chunk_id TEXT REFERENCES knowledge_chunks(id),
item_id TEXT REFERENCES knowledge_items(id) ON DELETE SET NULL,
chunk_id TEXT REFERENCES knowledge_chunks(id) ON DELETE SET NULL,
session_id VARCHAR(100),
query_text TEXT,
relevance_score FLOAT,
@@ -86,24 +88,36 @@ CREATE TABLE IF NOT EXISTS knowledge_usage (
agent_feedback VARCHAR(20) CHECK (agent_feedback IN ('positive', 'negative')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ku_item ON knowledge_usage(item_id);
CREATE INDEX IF NOT EXISTS idx_ku_created ON knowledge_usage(created_at);
CREATE INDEX IF NOT EXISTS idx_ku_item ON knowledge_usage(item_id) WHERE item_id IS NOT NULL;
-- BRIN 索引:追加写入的时间序列数据比 B-tree 更高效
CREATE INDEX IF NOT EXISTS idx_ku_created_brin ON knowledge_usage USING brin(created_at);
-- 权限种子数据
-- 权限种子数据(使用 jsonb 操作避免 REPLACE 脆弱性)
UPDATE roles
SET permissions = REPLACE(
permissions,
']',
', "knowledge:read", "knowledge:write", "knowledge:admin", "knowledge:search"]'
SET permissions = (
SELECT '[' || string_agg('"' || elem || '"', ', ') || ']'
FROM (
SELECT DISTINCT elem
FROM json_array_elements_text(permissions::json) AS elem
UNION ALL SELECT 'knowledge:read'
UNION ALL SELECT 'knowledge:write'
UNION ALL SELECT 'knowledge:admin'
UNION ALL SELECT 'knowledge:search'
) sub
)
WHERE name = 'super_admin'
WHERE id = 'super_admin'
AND permissions NOT LIKE '%knowledge:read%';
UPDATE roles
SET permissions = REPLACE(
permissions,
']',
', "knowledge:read", "knowledge:write", "knowledge:search"]'
SET permissions = (
SELECT '[' || string_agg('"' || elem || '"', ', ') || ']'
FROM (
SELECT DISTINCT elem
FROM json_array_elements_text(permissions::json) AS elem
UNION ALL SELECT 'knowledge:read'
UNION ALL SELECT 'knowledge:write'
UNION ALL SELECT 'knowledge:search'
) sub
)
WHERE name = 'admin'
WHERE id = 'admin'
AND permissions NOT LIKE '%knowledge:read%';

View File

@@ -16,7 +16,9 @@ use super::types::*;
/// GET /api/v1/knowledge/categories — 树形分类列表
pub async fn list_categories(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<Vec<CategoryResponse>>> {
check_permission(&ctx, "knowledge:read")?;
let tree = service::list_categories_tree(&state.db).await?;
Ok(Json(tree))
}
@@ -49,14 +51,33 @@ pub async fn create_category(
/// PUT /api/v1/knowledge/categories/:id — 更新分类
pub async fn update_category(
State(_state): State<AppState>,
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(_id): Path<String>,
Json(_req): Json<UpdateCategoryRequest>,
Path(id): Path<String>,
Json(req): Json<UpdateCategoryRequest>,
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "knowledge:write")?;
// TODO: implement update
Ok(Json(serde_json::json!({"updated": true})))
if let Some(ref name) = req.name {
if name.trim().is_empty() {
return Err(SaasError::InvalidInput("分类名称不能为空".into()));
}
}
let cat = service::update_category(
&state.db,
&id,
req.name.as_deref().map(|n| n.trim()),
req.description.as_deref(),
req.parent_id.as_deref(),
req.icon.as_deref(),
).await?;
Ok(Json(serde_json::json!({
"id": cat.id,
"name": cat.name,
"updated": true,
})))
}
/// DELETE /api/v1/knowledge/categories/:id — 删除分类
@@ -72,11 +93,30 @@ pub async fn delete_category(
/// GET /api/v1/knowledge/categories/:id/items — 分类下条目列表
pub async fn list_category_items(
State(_state): State<AppState>,
Path(_id): Path<String>,
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
Query(query): Query<ListItemsQuery>,
) -> SaasResult<Json<serde_json::Value>> {
// TODO: implement with pagination
Ok(Json(serde_json::json!({"items": [], "total": 0})))
check_permission(&ctx, "knowledge:read")?;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).max(1).min(100);
let status_filter = query.status.as_deref().unwrap_or("active");
let (items, total) = service::list_items_by_category(
&state.db,
&id,
status_filter,
page,
page_size,
).await?;
Ok(Json(serde_json::json!({
"items": items,
"total": total,
"page": page,
"page_size": page_size,
})))
}
// === 知识条目 CRUD ===
@@ -84,12 +124,19 @@ pub async fn list_category_items(
/// GET /api/v1/knowledge/items — 分页列表
pub async fn list_items(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Query(query): Query<ListItemsQuery>,
) -> SaasResult<Json<serde_json::Value>> {
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).min(100);
check_permission(&ctx, "knowledge:read")?;
let page = query.page.unwrap_or(1).max(1).min(10000);
let page_size = query.page_size.unwrap_or(20).max(1).min(100);
let offset = (page - 1) * page_size;
// 转义 ILIKE 通配符,防止用户输入的 % 和 _ 被当作通配符
let keyword = query.keyword.as_ref().map(|k| {
k.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
});
let items: Vec<KnowledgeItem> = sqlx::query_as(
"SELECT ki.* FROM knowledge_items ki \
JOIN knowledge_categories kc ON ki.category_id = kc.id \
@@ -101,7 +148,7 @@ pub async fn list_items(
)
.bind(&query.category_id)
.bind(&query.status)
.bind(&query.keyword)
.bind(&keyword)
.bind(page_size)
.bind(offset)
.fetch_all(&state.db)
@@ -115,7 +162,7 @@ pub async fn list_items(
)
.bind(&query.category_id)
.bind(&query.status)
.bind(&query.keyword)
.bind(&keyword)
.fetch_one(&state.db)
.await?;
@@ -173,13 +220,24 @@ pub async fn batch_create_items(
}
let mut created = Vec::new();
for req in items {
match service::create_item(&state.db, &ctx.account_id, &req).await {
for req in &items {
if req.title.trim().is_empty() || req.content.trim().is_empty() {
tracing::warn!("Batch create: skipping item with empty title or content");
continue;
}
if req.content.len() > 100_000 {
tracing::warn!("Batch create: skipping item '{}' (content too long)", req.title);
continue;
}
match service::create_item(&state.db, &ctx.account_id, req).await {
Ok(item) => {
let _ = state.worker_dispatcher.dispatch(
"generate_embedding",
serde_json::json!({ "item_id": item.id }),
).await;
).await.map_err(|e| {
tracing::warn!("[Knowledge] Failed to dispatch embedding for item {}: {}", item.id, e);
e
});
created.push(item.id);
}
Err(e) => {
@@ -197,8 +255,10 @@ pub async fn batch_create_items(
/// GET /api/v1/knowledge/items/:id — 条目详情
pub async fn get_item(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "knowledge:read")?;
let item = service::get_item(&state.db, &id).await?
.ok_or_else(|| SaasError::NotFound("知识条目不存在".into()))?;
Ok(Json(serde_json::to_value(item).unwrap_or_default()))
@@ -216,10 +276,12 @@ pub async fn update_item(
let updated = service::update_item(&state.db, &id, &ctx.account_id, &req).await?;
// 触发 re-embedding
let _ = state.worker_dispatcher.dispatch(
if let Err(e) = state.worker_dispatcher.dispatch(
"generate_embedding",
serde_json::json!({ "item_id": id }),
).await;
).await {
tracing::warn!("[Knowledge] Failed to dispatch re-embedding for item {}: {}", id, e);
}
Ok(Json(serde_json::json!({
"id": updated.id,
@@ -243,8 +305,10 @@ pub async fn delete_item(
/// GET /api/v1/knowledge/items/:id/versions
pub async fn list_versions(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "knowledge:read")?;
let versions: Vec<KnowledgeVersion> = sqlx::query_as(
"SELECT * FROM knowledge_versions WHERE item_id = $1 ORDER BY version DESC"
)
@@ -257,8 +321,10 @@ pub async fn list_versions(
/// GET /api/v1/knowledge/items/:id/versions/:v
pub async fn get_version(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path((id, v)): Path<(String, i32)>,
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "knowledge:read")?;
let version: KnowledgeVersion = sqlx::query_as(
"SELECT * FROM knowledge_versions WHERE item_id = $1 AND version = $2"
)
@@ -272,13 +338,27 @@ pub async fn get_version(
/// POST /api/v1/knowledge/items/:id/rollback/:v
pub async fn rollback_version(
State(_state): State<AppState>,
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path((_id, v)): Path<(String, i32)>,
Path((id, v)): Path<(String, i32)>,
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "knowledge:admin")?;
// TODO: implement rollback
Ok(Json(serde_json::json!({"rolled_back_to": v})))
let updated = service::rollback_version(&state.db, &id, v, &ctx.account_id).await?;
// 触发 re-embedding
if let Err(e) = state.worker_dispatcher.dispatch(
"generate_embedding",
serde_json::json!({ "item_id": id }),
).await {
tracing::warn!("[Knowledge] Failed to dispatch re-embedding after rollback for item {}: {}", id, e);
}
Ok(Json(serde_json::json!({
"id": updated.id,
"version": updated.version,
"rolled_back_to": v,
})))
}
// === 检索 ===
@@ -286,8 +366,10 @@ pub async fn rollback_version(
/// POST /api/v1/knowledge/search — 语义搜索
pub async fn search(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<SearchRequest>,
) -> SaasResult<Json<Vec<SearchResult>>> {
check_permission(&ctx, "knowledge:search")?;
let limit = req.limit.unwrap_or(5).min(10);
let min_score = req.min_score.unwrap_or(0.5);
let results = service::search(
@@ -302,11 +384,20 @@ pub async fn search(
/// POST /api/v1/knowledge/recommend — 关联推荐
pub async fn recommend(
State(_state): State<AppState>,
Json(_req): Json<SearchRequest>,
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<SearchRequest>,
) -> SaasResult<Json<Vec<SearchResult>>> {
// TODO: implement recommendation based on keyword overlap
Ok(Json(vec![]))
check_permission(&ctx, "knowledge:search")?;
let limit = req.limit.unwrap_or(5).min(10);
let results = service::search(
&state.db,
&req.query,
req.category_id.as_deref(),
limit,
0.3,
).await?;
Ok(Json(results))
}
// === 分析看板 ===
@@ -314,7 +405,9 @@ pub async fn recommend(
/// GET /api/v1/knowledge/analytics/overview
pub async fn analytics_overview(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<AnalyticsOverview>> {
check_permission(&ctx, "knowledge:read")?;
let overview = service::analytics_overview(&state.db).await?;
Ok(Json(overview))
}
@@ -322,7 +415,9 @@ pub async fn analytics_overview(
/// GET /api/v1/knowledge/analytics/trends
pub async fn analytics_trends(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "knowledge:read")?;
// 使用 serde_json::Value 行来避免 PgRow 序列化
let trends: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT json_build_object(
@@ -344,7 +439,9 @@ pub async fn analytics_trends(
/// GET /api/v1/knowledge/analytics/top-items
pub async fn analytics_top_items(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "knowledge:read")?;
let items: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT json_build_object(
'id', ki.id,
@@ -368,16 +465,117 @@ pub async fn analytics_top_items(
/// GET /api/v1/knowledge/analytics/quality
pub async fn analytics_quality(
State(_state): State<AppState>,
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
Ok(Json(serde_json::json!({"quality": {}})))
check_permission(&ctx, "knowledge:read")?;
let quality = service::analytics_quality(&state.db).await?;
Ok(Json(quality))
}
/// GET /api/v1/knowledge/analytics/gaps
pub async fn analytics_gaps(
State(_state): State<AppState>,
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
Ok(Json(serde_json::json!({"gaps": []})))
check_permission(&ctx, "knowledge:read")?;
let gaps = service::analytics_gaps(&state.db).await?;
Ok(Json(gaps))
}
// === 批量操作 ===
/// PATCH /api/v1/knowledge/categories/reorder — 批量排序
pub async fn reorder_categories(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(items): Json<Vec<ReorderItem>>,
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "knowledge:write")?;
if items.is_empty() {
return Ok(Json(serde_json::json!({"reordered": false, "count": 0})));
}
if items.len() > 100 {
return Err(SaasError::InvalidInput("单次排序不能超过 100 个".into()));
}
// 使用事务保证原子性
let mut tx = state.db.begin().await?;
for item in &items {
sqlx::query("UPDATE knowledge_categories SET sort_order = $1, updated_at = NOW() WHERE id = $2")
.bind(item.sort_order)
.bind(&item.id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(serde_json::json!({"reordered": true, "count": items.len()})))
}
/// POST /api/v1/knowledge/items/import — Markdown 文件导入
pub async fn import_items(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<ImportRequest>,
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "knowledge:write")?;
if req.files.len() > 20 {
return Err(SaasError::InvalidInput("单次导入不能超过 20 个文件".into()));
}
let mut created = Vec::new();
for file in &req.files {
// 内容长度检查(数据库限制 100KB
if file.content.len() > 100_000 {
tracing::warn!("跳过文件 '{}': 内容超长 ({} bytes)", file.title.as_deref().unwrap_or("未命名"), file.content.len());
continue;
}
// 空内容检查
if file.content.trim().is_empty() {
tracing::warn!("跳过空文件: '{}'", file.title.as_deref().unwrap_or("未命名"));
continue;
}
let title = file.title.clone().unwrap_or_else(|| {
file.content.lines().next()
.map(|l| l.trim_start_matches('#').trim().to_string())
.unwrap_or_else(|| format!("导入条目 {}", created.len() + 1))
});
let item_req = CreateItemRequest {
category_id: req.category_id.clone(),
title,
content: file.content.clone(),
keywords: file.keywords.clone(),
related_questions: None,
priority: None,
tags: file.tags.clone(),
};
match service::create_item(&state.db, &ctx.account_id, &item_req).await {
Ok(item) => {
let _ = state.worker_dispatcher.dispatch(
"generate_embedding",
serde_json::json!({ "item_id": item.id }),
).await.map_err(|e| {
tracing::warn!("[Knowledge] Failed to dispatch embedding for item {}: {}", item.id, e);
e
});
created.push(item.id);
}
Err(e) => {
tracing::warn!("Import item '{}' failed: {}", item_req.title, e);
}
}
}
Ok(Json(serde_json::json!({
"created_count": created.len(),
"ids": created,
})))
}
// === 辅助函数 ===

View File

@@ -4,7 +4,7 @@ pub mod types;
pub mod service;
pub mod handlers;
use axum::routing::{delete, get, post, put};
use axum::routing::{delete, get, patch, post, put};
pub fn routes() -> axum::Router<crate::state::AppState> {
axum::Router::new()
@@ -14,10 +14,12 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
.route("/api/v1/knowledge/categories/{id}", put(handlers::update_category))
.route("/api/v1/knowledge/categories/{id}", delete(handlers::delete_category))
.route("/api/v1/knowledge/categories/{id}/items", get(handlers::list_category_items))
.route("/api/v1/knowledge/categories/reorder", patch(handlers::reorder_categories))
// 知识条目 CRUD
.route("/api/v1/knowledge/items", get(handlers::list_items))
.route("/api/v1/knowledge/items", post(handlers::create_item))
.route("/api/v1/knowledge/items/batch", post(handlers::batch_create_items))
.route("/api/v1/knowledge/items/import", post(handlers::import_items))
.route("/api/v1/knowledge/items/{id}", get(handlers::get_item))
.route("/api/v1/knowledge/items/{id}", put(handlers::update_item))
.route("/api/v1/knowledge/items/{id}", delete(handlers::delete_item))

View File

@@ -81,6 +81,21 @@ pub async fn create_category(
parent_id: Option<&str>,
icon: Option<&str>,
) -> SaasResult<KnowledgeCategory> {
// 验证 parent_id 存在性
if let Some(pid) = parent_id {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM knowledge_categories WHERE id = $1)"
)
.bind(pid)
.fetch_one(pool)
.await?;
if !exists {
return Err(crate::error::SaasError::InvalidInput(
format!("父分类 '{}' 不存在", pid),
));
}
}
let id = uuid::Uuid::new_v4().to_string();
let category = sqlx::query_as::<_, KnowledgeCategory>(
"INSERT INTO knowledge_categories (id, name, description, parent_id, icon) \
@@ -126,15 +141,136 @@ pub async fn delete_category(pool: &PgPool, category_id: &str) -> SaasResult<()>
));
}
sqlx::query("DELETE FROM knowledge_categories WHERE id = $1")
let result = sqlx::query("DELETE FROM knowledge_categories WHERE id = $1")
.bind(category_id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::SaasError::NotFound("分类不存在".into()));
}
Ok(())
}
/// 更新分类(含循环引用检测 + 深度限制)
pub async fn update_category(
pool: &PgPool,
category_id: &str,
name: Option<&str>,
description: Option<&str>,
parent_id: Option<&str>,
icon: Option<&str>,
) -> SaasResult<KnowledgeCategory> {
if let Some(pid) = parent_id {
if pid == category_id {
return Err(crate::error::SaasError::InvalidInput(
"分类不能成为自身的子分类".into(),
));
}
// 检查新的父级不是当前分类的后代(循环检测)
let mut check_id = pid.to_string();
let mut depth = 0;
loop {
if check_id == category_id {
return Err(crate::error::SaasError::InvalidInput(
"循环引用:父级分类不能是当前分类的后代".into(),
));
}
let parent: Option<(Option<String>,)> = sqlx::query_as(
"SELECT parent_id FROM knowledge_categories WHERE id = $1"
)
.bind(&check_id)
.fetch_optional(pool)
.await?;
match parent {
Some((Some(gp),)) => {
check_id = gp;
depth += 1;
if depth > 10 { break; }
}
_ => break,
}
}
// 检查深度限制(最多 3 层)
let mut current_depth = 0;
let mut check = pid.to_string();
while let Some((Some(p),)) = sqlx::query_as::<_, (Option<String>,)>(
"SELECT parent_id FROM knowledge_categories WHERE id = $1"
)
.bind(&check)
.fetch_optional(pool)
.await?
{
check = p;
current_depth += 1;
if current_depth > 10 { break; }
}
if current_depth >= 3 {
return Err(crate::error::SaasError::InvalidInput(
"分类层级不能超过 3 层".into(),
));
}
}
let category = sqlx::query_as::<_, KnowledgeCategory>(
"UPDATE knowledge_categories SET \
name = COALESCE($1, name), \
description = COALESCE($2, description), \
parent_id = COALESCE($3, parent_id), \
icon = COALESCE($4, icon), \
updated_at = NOW() \
WHERE id = $5 RETURNING *"
)
.bind(name)
.bind(description)
.bind(parent_id)
.bind(icon)
.bind(category_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| crate::error::SaasError::NotFound("分类不存在".into()))?;
Ok(category)
}
// === 知识条目 CRUD ===
/// 按分类分页查询条目列表
pub async fn list_items_by_category(
pool: &PgPool,
category_id: &str,
status_filter: &str,
page: i64,
page_size: i64,
) -> SaasResult<(Vec<KnowledgeItem>, i64)> {
let offset = (page - 1) * page_size;
let items: Vec<KnowledgeItem> = sqlx::query_as(
"SELECT * FROM knowledge_items \
WHERE category_id = $1 AND status = $2 \
ORDER BY priority DESC, updated_at DESC \
LIMIT $3 OFFSET $4"
)
.bind(category_id)
.bind(status_filter)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?;
let total: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM knowledge_items WHERE category_id = $1 AND status = $2"
)
.bind(category_id)
.bind(status_filter)
.fetch_one(pool)
.await?;
Ok((items, total.0))
}
/// 创建知识条目
pub async fn create_item(
pool: &PgPool,
@@ -147,6 +283,19 @@ pub async fn create_item(
let priority = req.priority.unwrap_or(0);
let tags = req.tags.as_deref().unwrap_or(&[]);
// 验证 category_id 存在性
let cat_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM knowledge_categories WHERE id = $1)"
)
.bind(&req.category_id)
.fetch_one(pool)
.await?;
if !cat_exists {
return Err(crate::error::SaasError::InvalidInput(
format!("分类 '{}' 不存在", req.category_id),
));
}
let item = sqlx::query_as::<_, KnowledgeItem>(
"INSERT INTO knowledge_items \
(id, category_id, title, content, keywords, related_questions, priority, tags, created_by) \
@@ -196,19 +345,31 @@ pub async fn get_item(pool: &PgPool, item_id: &str) -> SaasResult<Option<Knowled
Ok(item)
}
/// 更新条目(含版本快照)
/// 更新条目(含版本快照)— 事务保护防止并发竞态
pub async fn update_item(
pool: &PgPool,
item_id: &str,
account_id: &str,
req: &UpdateItemRequest,
) -> SaasResult<KnowledgeItem> {
// 获取当前条目
// status 验证在事务之前,避免无谓锁占用
const VALID_STATUSES: &[&str] = &["active", "draft", "archived", "deprecated"];
if let Some(ref status) = &req.status {
if !VALID_STATUSES.contains(&status.as_str()) {
return Err(crate::error::SaasError::InvalidInput(
format!("无效的状态值: {},有效值: {}", status, VALID_STATUSES.join(", "))
));
}
}
let mut tx = pool.begin().await?;
// 获取当前条目并锁定行防止并发修改
let current = sqlx::query_as::<_, KnowledgeItem>(
"SELECT * FROM knowledge_items WHERE id = $1"
"SELECT * FROM knowledge_items WHERE id = $1 FOR UPDATE"
)
.bind(item_id)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| crate::error::SaasError::NotFound("知识条目不存在".into()))?;
@@ -229,6 +390,7 @@ pub async fn update_item(
.unwrap_or(&vec![])
.clone();
// 更新条目
let updated = sqlx::query_as::<_, KnowledgeItem>(
"UPDATE knowledge_items SET \
@@ -245,7 +407,7 @@ pub async fn update_item(
.bind(&tags)
.bind(req.status.as_deref())
.bind(item_id)
.fetch_one(pool)
.fetch_one(&mut *tx)
.await?;
// 创建版本快照
@@ -265,9 +427,10 @@ pub async fn update_item(
.bind(&related_questions)
.bind(req.change_summary.as_deref())
.bind(account_id)
.execute(pool)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(updated)
}
@@ -288,39 +451,44 @@ pub async fn delete_item(pool: &PgPool, item_id: &str) -> SaasResult<()> {
/// 将内容按 Markdown 标题 + 固定长度分块
pub fn chunk_content(content: &str, max_tokens: usize, overlap: usize) -> Vec<String> {
let mut chunks = Vec::new();
// 先按 Markdown 标题分段
let sections: Vec<&str> = content.split("\n# ").collect();
for section in sections {
// 简单估算 token中文约 1.5 字符/token
let estimated_tokens = section.len() / 2;
let mut chunks = Vec::new();
for (i, section) in sections.iter().enumerate() {
// 第一个片段保留原始内容,其余片段重新添加标题标记
let section_content = if i == 0 {
section.to_string()
} else {
format!("# {}", section)
};
// 磁盘估算 token中文约 1.5 字符/token)
let estimated_tokens = section_content.len() / 2;
if estimated_tokens <= max_tokens {
if !section.trim().is_empty() {
chunks.push(section.trim().to_string());
if !section_content.trim().is_empty() {
chunks.push(section_content.trim().to_string());
}
} else {
// 超长段落按固定长度切分
let chars: Vec<char> = section.chars().collect();
let chars: Vec<char> = section_content.chars().collect();
let chunk_chars = max_tokens * 2; // 近似字符数
let overlap_chars = overlap * 2;
let mut pos = 0;
while pos < chars.len() {
let end = (pos + chunk_chars).min(chars.len());
let chunk: String = chars[pos..end].iter().collect();
if !chunk.trim().is_empty() {
chunks.push(chunk.trim().to_string());
let chunk_str: String = chars[pos..end].iter().collect();
if !chunk_str.trim().is_empty() {
chunks.push(chunk_str.trim().to_string());
}
pos = if end >= chars.len() { end } else { end.saturating_sub(overlap_chars) };
pos = if end >= chars.len() { end} else { end.saturating_sub(overlap_chars) };
}
}
}
chunks
}
chunks}
// === 搜索 ===
@@ -333,14 +501,14 @@ pub async fn search(
min_score: f64,
) -> SaasResult<Vec<SearchResult>> {
// 暂时使用关键词匹配(向量搜索需要 embedding 生成)
let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_"));
let pattern = format!("%{}%", query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
let results = if let Some(cat_id) = category_id {
sqlx::query_as::<_, (String, String, String, String, String, Vec<String>)>(
"SELECT kc.id, kc.item_id, ki.title, kc.name as cat_name, kc.content, kc.keywords \
"SELECT kc.id, kc.item_id, ki.title, kcat.name, kc.content, kc.keywords \
FROM knowledge_chunks kc \
JOIN knowledge_items ki ON kc.item_id = ki.id \
JOIN knowledge_categories kc2 ON ki.category_id = kc2.id \
JOIN knowledge_categories kcat ON ki.category_id = kcat.id \
WHERE ki.status = 'active' \
AND ki.category_id = $1 \
AND (kc.content ILIKE $2 OR $3 = ANY(kc.keywords)) \
@@ -355,10 +523,10 @@ pub async fn search(
.await?
} else {
sqlx::query_as::<_, (String, String, String, String, String, Vec<String>)>(
"SELECT kc.id, kc.item_id, ki.title, kc2.name as cat_name, kc.content, kc.keywords \
"SELECT kc.id, kc.item_id, ki.title, kcat.name, kc.content, kc.keywords \
FROM knowledge_chunks kc \
JOIN knowledge_items ki ON kc.item_id = ki.id \
JOIN knowledge_categories kc2 ON ki.category_id = kc2.id \
JOIN knowledge_categories kcat ON ki.category_id = kcat.id \
WHERE ki.status = 'active' \
AND (kc.content ILIKE $1 OR $2 = ANY(kc.keywords)) \
ORDER BY ki.priority DESC \
@@ -372,13 +540,24 @@ pub async fn search(
};
Ok(results.into_iter().map(|(chunk_id, item_id, title, cat_name, content, keywords)| {
// 基于关键词匹配数计算分数:匹配数 / 总查询关键词数
let query_keywords: Vec<&str> = query.split_whitespace().collect();
let matched_count = keywords.iter()
.filter(|k| query_keywords.iter().any(|qk| k.to_lowercase().contains(&qk.to_lowercase())))
.count();
let score = if keywords.is_empty() || query_keywords.is_empty() {
0.5
} else {
(matched_count as f64 / keywords.len().max(query_keywords.len()) as f64).min(1.0)
};
SearchResult {
chunk_id,
item_id,
item_title: title,
category_name: cat_name,
content,
score: 0.8, // 关键词匹配默认分数
score,
keywords,
}
}).filter(|r| r.score >= min_score).collect())
@@ -430,6 +609,12 @@ pub async fn analytics_overview(pool: &PgPool) -> SaasResult<AnalyticsOverview>
.fetch_one(pool)
.await?;
let with_feedback: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM knowledge_usage WHERE agent_feedback IS NOT NULL"
)
.fetch_one(pool)
.await?;
let stale: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM knowledge_items ki \
WHERE ki.status = 'active' \
@@ -438,7 +623,7 @@ pub async fn analytics_overview(pool: &PgPool) -> SaasResult<AnalyticsOverview>
.fetch_one(pool)
.await?;
let hit_rate = if total_refs.0 > 0 { 1.0 } else { 0.0 };
let hit_rate = if total_refs.0 > 0 { with_feedback.0 as f64 / total_refs.0 as f64 } else { 0.0 };
let injection_rate = if total_refs.0 > 0 { injected.0 as f64 / total_refs.0 as f64 } else { 0.0 };
let positive_rate = if total_refs.0 > 0 { positive.0 as f64 / total_refs.0 as f64 } else { 0.0 };
@@ -455,3 +640,140 @@ pub async fn analytics_overview(pool: &PgPool) -> SaasResult<AnalyticsOverview>
stale_items_count: stale.0,
})
}
/// 回滚到指定版本(创建新版本快照)
pub async fn rollback_version(
pool: &PgPool,
item_id: &str,
target_version: i32,
account_id: &str,
) -> SaasResult<KnowledgeItem> {
// 使用事务保证原子性,防止并发回滚冲突
let mut tx = pool.begin().await?;
// 获取目标版本
let version: KnowledgeVersion = sqlx::query_as(
"SELECT * FROM knowledge_versions WHERE item_id = $1 AND version = $2"
)
.bind(item_id)
.bind(target_version)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| crate::error::SaasError::NotFound("版本不存在".into()))?;
// 锁定当前条目行防止并发修改SELECT FOR UPDATE
let current: Option<(i32,)> = sqlx::query_as(
"SELECT version FROM knowledge_items WHERE id = $1 FOR UPDATE"
)
.bind(item_id)
.fetch_optional(&mut *tx)
.await?;
let current_version = current
.ok_or_else(|| crate::error::SaasError::NotFound("知识条目不存在".into()))?
.0;
// 防止版本无限递增: 最多 100 个版本
if current_version >= 100 {
return Err(crate::error::SaasError::InvalidInput(
"版本数已达上限(100),请考虑合并历史版本".into(),
));
}
let new_version = current_version + 1;
// 更新条目为该版本内容
let updated = sqlx::query_as::<_, KnowledgeItem>(
"UPDATE knowledge_items SET \
title = $1, content = $2, keywords = $3, related_questions = $4, \
version = $5, updated_at = NOW() \
WHERE id = $6 RETURNING *"
)
.bind(&version.title)
.bind(&version.content)
.bind(&version.keywords)
.bind(&version.related_questions)
.bind(new_version)
.bind(item_id)
.fetch_one(&mut *tx)
.await?;
// 创建新版本快照(记录回滚来源)
let version_id = uuid::Uuid::new_v4().to_string();
let summary = format!("回滚到版本 {}(当前版本 {} → 新版本 {}", target_version, current_version, new_version);
sqlx::query(
"INSERT INTO knowledge_versions \
(id, item_id, version, title, content, keywords, related_questions, \
change_summary, created_by) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
)
.bind(&version_id)
.bind(item_id)
.bind(new_version)
.bind(&updated.title)
.bind(&updated.content)
.bind(&updated.keywords)
.bind(&updated.related_questions)
.bind(&summary)
.bind(account_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(updated)
}
/// 质量指标(按分类分组)
pub async fn analytics_quality(pool: &PgPool) -> SaasResult<serde_json::Value> {
let quality: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT json_build_object(
'category', kc.name,
'total', COUNT(ki.id),
'active', COUNT(CASE WHEN ki.status = 'active' THEN 1 END),
'with_keywords', COUNT(CASE WHEN array_length(ki.keywords, 1) > 0 THEN 1 END),
'avg_priority', COALESCE(AVG(ki.priority), 0)
) as row \
FROM knowledge_categories kc \
LEFT JOIN knowledge_items ki ON ki.category_id = kc.id \
GROUP BY kc.id, kc.name \
ORDER BY COUNT(ki.id) DESC"
)
.fetch_all(pool)
.await
.unwrap_or_else(|e| {
tracing::warn!("analytics_quality query failed: {}", e);
vec![]
});
Ok(serde_json::json!({
"categories": quality.into_iter().map(|(v,)| v).collect::<Vec<_>>()
}))
}
/// 知识缺口检测(低分查询聚类)
pub async fn analytics_gaps(pool: &PgPool) -> SaasResult<serde_json::Value> {
let gaps: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT json_build_object(
'query', ku.query_text,
'count', COUNT(*),
'avg_score', COALESCE(AVG(ku.relevance_score), 0)
) as row \
FROM knowledge_usage ku \
WHERE ku.created_at >= NOW() - interval '30 days' \
AND (ku.relevance_score IS NULL OR ku.relevance_score < 0.5) \
AND ku.query_text IS NOT NULL \
GROUP BY ku.query_text \
ORDER BY COUNT(*) DESC \
LIMIT 20"
)
.fetch_all(pool)
.await
.unwrap_or_else(|e| {
tracing::warn!("analytics_gaps query failed: {}", e);
vec![]
});
Ok(serde_json::json!({
"gaps": gaps.into_iter().map(|(v,)| v).collect::<Vec<_>>()
}))
}

View File

@@ -199,3 +199,25 @@ pub struct AnalyticsOverview {
pub positive_feedback_rate: f64,
pub stale_items_count: i64,
}
// === 批量操作 ===
#[derive(Debug, Deserialize)]
pub struct ReorderItem {
pub id: String,
pub sort_order: i32,
}
#[derive(Debug, Deserialize)]
pub struct ImportFile {
pub content: String,
pub title: Option<String>,
pub keywords: Option<Vec<String>>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct ImportRequest {
pub category_id: String,
pub files: Vec<ImportFile>,
}