fix(industry): 二次审计修复 — 2 CRITICAL + 4 HIGH + 2 MEDIUM
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
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 守卫
This commit is contained in:
@@ -62,22 +62,21 @@ export default function Accounts() {
|
|||||||
enabled: !!editingId,
|
enabled: !!editingId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当账户行业数据加载完,同步到表单
|
// 当账户行业数据加载完且正在编辑时,同步到表单
|
||||||
|
// Guard: only sync when editingId matches the query key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (accountIndustries) {
|
if (accountIndustries && editingId) {
|
||||||
const ids = accountIndustries.map((item) => item.industry_id)
|
const ids = accountIndustries.map((item) => item.industry_id)
|
||||||
setEditingIndustries(ids)
|
setEditingIndustries(ids)
|
||||||
form.setFieldValue('industry_ids', ids)
|
form.setFieldValue('industry_ids', ids)
|
||||||
}
|
}
|
||||||
}, [accountIndustries, form])
|
}, [accountIndustries, editingId, form])
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
||||||
accountService.update(id, data),
|
accountService.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success('更新成功')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||||
setModalOpen(false)
|
|
||||||
},
|
},
|
||||||
onError: (err: Error) => message.error(err.message || '更新失败'),
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
})
|
})
|
||||||
@@ -185,7 +184,9 @@ export default function Accounts() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
if (editingId) {
|
if (!editingId) return
|
||||||
|
|
||||||
|
try {
|
||||||
// 更新基础信息
|
// 更新基础信息
|
||||||
const { industry_ids, ...accountData } = values
|
const { industry_ids, ...accountData } = values
|
||||||
await updateMutation.mutateAsync({ id: editingId, data: accountData })
|
await updateMutation.mutateAsync({ id: editingId, data: accountData })
|
||||||
@@ -201,6 +202,10 @@ export default function Accounts() {
|
|||||||
message.success('行业授权已更新')
|
message.success('行业授权已更新')
|
||||||
queryClient.invalidateQueries({ queryKey: ['account-industries'] })
|
queryClient.invalidateQueries({ queryKey: ['account-industries'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleClose()
|
||||||
|
} catch {
|
||||||
|
// Errors handled by mutation onError callbacks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ function IndustryEditModal({ open, industryId, onClose }: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && open) {
|
if (data && open && data.id === industryId) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
icon: data.icon,
|
icon: data.icon,
|
||||||
@@ -202,7 +202,7 @@ function IndustryEditModal({ open, industryId, onClose }: {
|
|||||||
pain_seed_categories: data.pain_seed_categories,
|
pain_seed_categories: data.pain_seed_categories,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [data, open, form])
|
}, [data, open, industryId, form])
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (body: UpdateIndustryRequest) =>
|
mutationFn: (body: UpdateIndustryRequest) =>
|
||||||
@@ -306,11 +306,25 @@ function IndustryCreateModal({ open, onClose }: {
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
initialValues={{ icon: '🏢' }}
|
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)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}>
|
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}>
|
||||||
<Input placeholder="如:医疗健康、教育培训" />
|
<Input placeholder="如:医疗健康、教育培训" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="id" label="行业标识" extra="唯一标识,留空则从名称自动生成。仅限小写字母、数字、连字符" rules={[
|
||||||
|
{ pattern: /^[a-z0-9-]*$/, message: '仅限小写字母、数字、连字符' },
|
||||||
|
{ max: 63, message: '最长 63 字符' },
|
||||||
|
]}>
|
||||||
|
<Input placeholder="如:healthcare、education" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="icon" label="图标">
|
<Form.Item name="icon" label="图标">
|
||||||
<Input placeholder="行业图标 emoji" className="w-32" />
|
<Input placeholder="行业图标 emoji" className="w-32" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ pub async fn create_industry(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
req: &CreateIndustryRequest,
|
req: &CreateIndustryRequest,
|
||||||
) -> SaasResult<Industry> {
|
) -> SaasResult<Industry> {
|
||||||
|
// 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 now = chrono::Utc::now();
|
||||||
let keywords = serde_json::to_value(&req.keywords).unwrap_or(serde_json::json!([]));
|
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!([]));
|
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,
|
id: &str,
|
||||||
req: &UpdateIndustryRequest,
|
req: &UpdateIndustryRequest,
|
||||||
) -> SaasResult<Industry> {
|
) -> SaasResult<Industry> {
|
||||||
|
// 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 existing = get_industry(pool, id).await?;
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
|||||||
@@ -344,14 +344,14 @@ async fn build_continuity_context(agent_id: &str, user_message: &str) -> String
|
|||||||
.collect();
|
.collect();
|
||||||
if !high_pains.is_empty() {
|
if !high_pains.is_empty() {
|
||||||
let pain_lines: Vec<String> = high_pains.iter()
|
let pain_lines: Vec<String> = high_pains.iter()
|
||||||
.filter_map(|p| {
|
.map(|p| {
|
||||||
let summary = &p.summary;
|
let summary = &p.summary;
|
||||||
let count = p.occurrence_count;
|
let count = p.occurrence_count;
|
||||||
let conf = (p.confidence * 100.0) as u8;
|
let conf = (p.confidence * 100.0) as u8;
|
||||||
Some(format!(
|
format!(
|
||||||
"- {} (出现{}次, 置信度 {}%)",
|
"- {} (出现{}次, 置信度 {}%)",
|
||||||
summary, count, conf
|
xml_escape(summary), count, conf
|
||||||
))
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if !pain_lines.is_empty() {
|
if !pain_lines.is_empty() {
|
||||||
@@ -378,8 +378,7 @@ async fn build_continuity_context(agent_id: &str, user_message: &str) -> String
|
|||||||
.map(|e| {
|
.map(|e| {
|
||||||
let overview = e.overview.as_deref().unwrap_or(&e.content);
|
let overview = e.overview.as_deref().unwrap_or(&e.content);
|
||||||
let truncated: String = overview.chars().take(60).collect();
|
let truncated: String = overview.chars().take(60).collect();
|
||||||
let score_pct = (e.access_count as f64).min(10.0) / 10.0 * 100.0;
|
format!("- {}", xml_escape(&truncated))
|
||||||
format!("- {} (相关度: {:.0}%)", truncated, score_pct)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
parts.push(format!("<experience>\n{}\n</experience>", exp_lines.join("\n")));
|
parts.push(format!("<experience>\n{}\n</experience>", 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 `<butler-context>`.
|
||||||
|
fn xml_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
/// Store a lightweight experience entry from a trigger signal.
|
/// Store a lightweight experience entry from a trigger signal.
|
||||||
///
|
///
|
||||||
/// Uses VikingStorage directly — template-based, no LLM cost.
|
/// Uses VikingStorage directly — template-based, no LLM cost.
|
||||||
|
|||||||
@@ -719,9 +719,13 @@ pub async fn viking_load_industry_keywords(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Update through the Kernel's shared Arc (connected to ButlerRouterMiddleware)
|
// Update through the Kernel's shared Arc (connected to ButlerRouterMiddleware)
|
||||||
let kernel_guard = kernel_state.lock().await;
|
// Clone the Arc first, then drop the KernelState Mutex before awaiting RwLock.
|
||||||
if let Some(kernel) = kernel_guard.as_ref() {
|
// This prevents blocking all other kernel commands during the write.
|
||||||
let shared = kernel.industry_keywords();
|
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;
|
let mut guard = shared.write().await;
|
||||||
*guard = industry_configs;
|
*guard = industry_configs;
|
||||||
tracing::info!("[viking_commands] Industry keywords synced to ButlerRouter middleware");
|
tracing::info!("[viking_commands] Industry keywords synced to ButlerRouter middleware");
|
||||||
|
|||||||
Reference in New Issue
Block a user