From 76f6011e0f867b8038b04da89b9efc5d6fc25927 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 12 Apr 2026 20:13:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(industry):=20=E4=BA=8C=E6=AC=A1=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=202=20CRITICAL=20+=204?= =?UTF-8?q?=20HIGH=20+=202=20MEDIUM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1: Industries.tsx 创建弹窗缺少 id 字段 → 添加 id 输入框 + 自动生成 C-2: Accounts.tsx handleSave 无 try/catch → 包装 + handleClose 统一关闭 V1: viking_commands Mutex 跨 await → 先 clone Arc 再释放 Mutex I1: intelligence_hooks 误导性"相关度" → 移除 access_count 伪分数 I2: pain point 摘要未 XML 转义 → xml_escape() 处理 S1: industry status 无枚举验证 → active/inactive 白名单 S2: create_industry id 无格式验证 → 正则 + 长度检查 H-3: Industries.tsx 编辑模态数据竞争 → data.id === industryId 守卫 H-4: Accounts.tsx useEffect 覆盖用户编辑 → editingId 守卫 --- admin-v2/src/pages/Accounts.tsx | 17 +++++++++++------ admin-v2/src/pages/Industries.tsx | 20 +++++++++++++++++--- crates/zclaw-saas/src/industry/service.rs | 17 +++++++++++++++++ desktop/src-tauri/src/intelligence_hooks.rs | 18 ++++++++++++------ desktop/src-tauri/src/viking_commands.rs | 10 +++++++--- 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/admin-v2/src/pages/Accounts.tsx b/admin-v2/src/pages/Accounts.tsx index ad1ec2e..98ecb76 100644 --- a/admin-v2/src/pages/Accounts.tsx +++ b/admin-v2/src/pages/Accounts.tsx @@ -62,22 +62,21 @@ export default function Accounts() { enabled: !!editingId, }) - // 当账户行业数据加载完,同步到表单 + // 当账户行业数据加载完且正在编辑时,同步到表单 + // Guard: only sync when editingId matches the query key useEffect(() => { - if (accountIndustries) { + if (accountIndustries && editingId) { const ids = accountIndustries.map((item) => item.industry_id) setEditingIndustries(ids) form.setFieldValue('industry_ids', ids) } - }, [accountIndustries, form]) + }, [accountIndustries, editingId, form]) const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: string; data: Partial }) => accountService.update(id, data), onSuccess: () => { - message.success('更新成功') queryClient.invalidateQueries({ queryKey: ['accounts'] }) - setModalOpen(false) }, onError: (err: Error) => message.error(err.message || '更新失败'), }) @@ -185,7 +184,9 @@ export default function Accounts() { const handleSave = async () => { const values = await form.validateFields() - if (editingId) { + if (!editingId) return + + try { // 更新基础信息 const { industry_ids, ...accountData } = values await updateMutation.mutateAsync({ id: editingId, data: accountData }) @@ -201,6 +202,10 @@ export default function Accounts() { message.success('行业授权已更新') queryClient.invalidateQueries({ queryKey: ['account-industries'] }) } + + handleClose() + } catch { + // Errors handled by mutation onError callbacks } } diff --git a/admin-v2/src/pages/Industries.tsx b/admin-v2/src/pages/Industries.tsx index 3d7ad62..10dca2b 100644 --- a/admin-v2/src/pages/Industries.tsx +++ b/admin-v2/src/pages/Industries.tsx @@ -191,7 +191,7 @@ function IndustryEditModal({ open, industryId, onClose }: { }) useEffect(() => { - if (data && open) { + if (data && open && data.id === industryId) { form.setFieldsValue({ name: data.name, icon: data.icon, @@ -202,7 +202,7 @@ function IndustryEditModal({ open, industryId, onClose }: { pain_seed_categories: data.pain_seed_categories, }) } - }, [data, open, form]) + }, [data, open, industryId, form]) const updateMutation = useMutation({ mutationFn: (body: UpdateIndustryRequest) => @@ -306,11 +306,25 @@ function IndustryCreateModal({ open, onClose }: { layout="vertical" className="mt-4" initialValues={{ icon: '🏢' }} - onFinish={(values) => createMutation.mutate(values)} + onFinish={(values) => { + // Auto-generate id from name if not provided + if (!values.id && values.name) { + values.id = values.name.toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-') + .replace(/^-|-$/g, '') + } + createMutation.mutate(values) + }} > + + + diff --git a/crates/zclaw-saas/src/industry/service.rs b/crates/zclaw-saas/src/industry/service.rs index dc1d2cc..69279f6 100644 --- a/crates/zclaw-saas/src/industry/service.rs +++ b/crates/zclaw-saas/src/industry/service.rs @@ -73,6 +73,15 @@ pub async fn create_industry( pool: &PgPool, req: &CreateIndustryRequest, ) -> SaasResult { + // Validate id format: lowercase alphanumeric + hyphen, 1-63 chars + let id = req.id.trim(); + if id.is_empty() || id.len() > 63 { + return Err(SaasError::InvalidInput("行业 ID 长度须 1-63 字符".to_string())); + } + if !id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + return Err(SaasError::InvalidInput("行业 ID 仅限小写字母、数字、连字符".to_string())); + } + let now = chrono::Utc::now(); let keywords = serde_json::to_value(&req.keywords).unwrap_or(serde_json::json!([])); let pain_categories = serde_json::to_value(&req.pain_seed_categories).unwrap_or(serde_json::json!([])); @@ -97,6 +106,14 @@ pub async fn update_industry( id: &str, req: &UpdateIndustryRequest, ) -> SaasResult { + // Validate status enum + if let Some(ref status) = req.status { + match status.as_str() { + "active" | "inactive" => {}, + _ => return Err(SaasError::InvalidInput(format!("无效状态 '{}', 允许: active/inactive", status))), + } + } + // 先确认存在 let existing = get_industry(pool, id).await?; let now = chrono::Utc::now(); diff --git a/desktop/src-tauri/src/intelligence_hooks.rs b/desktop/src-tauri/src/intelligence_hooks.rs index 7ac4440..8b09563 100644 --- a/desktop/src-tauri/src/intelligence_hooks.rs +++ b/desktop/src-tauri/src/intelligence_hooks.rs @@ -344,14 +344,14 @@ async fn build_continuity_context(agent_id: &str, user_message: &str) -> String .collect(); if !high_pains.is_empty() { let pain_lines: Vec = high_pains.iter() - .filter_map(|p| { + .map(|p| { let summary = &p.summary; let count = p.occurrence_count; let conf = (p.confidence * 100.0) as u8; - Some(format!( + format!( "- {} (出现{}次, 置信度 {}%)", - summary, count, conf - )) + xml_escape(summary), count, conf + ) }) .collect(); if !pain_lines.is_empty() { @@ -378,8 +378,7 @@ async fn build_continuity_context(agent_id: &str, user_message: &str) -> String .map(|e| { let overview = e.overview.as_deref().unwrap_or(&e.content); let truncated: String = overview.chars().take(60).collect(); - let score_pct = (e.access_count as f64).min(10.0) / 10.0 * 100.0; - format!("- {} (相关度: {:.0}%)", truncated, score_pct) + format!("- {}", xml_escape(&truncated)) }) .collect(); parts.push(format!("\n{}\n", exp_lines.join("\n"))); @@ -398,6 +397,13 @@ async fn build_continuity_context(agent_id: &str, user_message: &str) -> String ) } +/// Escape XML special characters in content injected into ``. +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + /// Store a lightweight experience entry from a trigger signal. /// /// Uses VikingStorage directly — template-based, no LLM cost. diff --git a/desktop/src-tauri/src/viking_commands.rs b/desktop/src-tauri/src/viking_commands.rs index ddc1280..c55c630 100644 --- a/desktop/src-tauri/src/viking_commands.rs +++ b/desktop/src-tauri/src/viking_commands.rs @@ -719,9 +719,13 @@ pub async fn viking_load_industry_keywords( ); // Update through the Kernel's shared Arc (connected to ButlerRouterMiddleware) - let kernel_guard = kernel_state.lock().await; - if let Some(kernel) = kernel_guard.as_ref() { - let shared = kernel.industry_keywords(); + // Clone the Arc first, then drop the KernelState Mutex before awaiting RwLock. + // This prevents blocking all other kernel commands during the write. + let shared = { + let kernel_guard = kernel_state.lock().await; + kernel_guard.as_ref().map(|k| k.industry_keywords()) + }; + if let Some(shared) = shared { let mut guard = shared.write().await; *guard = industry_configs; tracing::info!("[viking_commands] Industry keywords synced to ButlerRouter middleware");