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:
@@ -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,
|
||||
})))
|
||||
}
|
||||
|
||||
// === 辅助函数 ===
|
||||
|
||||
Reference in New Issue
Block a user