fix(industry): 审计收尾 — MEDIUM + LOW 全部清零
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

M-1: Industries 创建弹窗添加 cold_start_template + pain_seed_categories
M-3: industryStore console.warn → createLogger 结构化日志
B2: classify_with_industries 平局打破 + 归一化因子 3.0 文档化
S3: set_account_industries 验证移入事务内消除 TOCTOU
T1: 4 个 SaaS 请求类型添加 deny_unknown_fields
I3: store_trigger_experience Debug 格式 → signal_name 描述名
L-1: 删除 Accounts.tsx 死代码 editingIndustries
L-3: Industries.tsx filters 类型补全 source 字段
This commit is contained in:
iven
2026-04-12 20:37:48 +08:00
parent 3cff31ec03
commit f8c5a76ce6
7 changed files with 28 additions and 13 deletions

View File

@@ -42,7 +42,6 @@ export default function Accounts() {
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [searchParams, setSearchParams] = useState<Record<string, string>>({}) const [searchParams, setSearchParams] = useState<Record<string, string>>({})
const [editingIndustries, setEditingIndustries] = useState<string[]>([])
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['accounts', searchParams], queryKey: ['accounts', searchParams],
@@ -67,7 +66,6 @@ export default function Accounts() {
useEffect(() => { useEffect(() => {
if (accountIndustries && editingId) { if (accountIndustries && editingId) {
const ids = accountIndustries.map((item) => item.industry_id) const ids = accountIndustries.map((item) => item.industry_id)
setEditingIndustries(ids)
form.setFieldValue('industry_ids', ids) form.setFieldValue('industry_ids', ids)
} }
}, [accountIndustries, editingId, form]) }, [accountIndustries, editingId, form])
@@ -212,7 +210,6 @@ export default function Accounts() {
const handleClose = () => { const handleClose = () => {
setModalOpen(false) setModalOpen(false)
setEditingId(null) setEditingId(null)
setEditingIndustries([])
form.resetFields() form.resetFields()
} }

View File

@@ -31,7 +31,7 @@ function IndustryListPanel() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [filters, setFilters] = useState<{ status?: string }>({}) const [filters, setFilters] = useState<{ status?: string; source?: string }>({})
const [editId, setEditId] = useState<string | null>(null) const [editId, setEditId] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
@@ -337,6 +337,12 @@ function IndustryCreateModal({ open, onClose }: {
<Form.Item name="system_prompt" label="系统提示词"> <Form.Item name="system_prompt" label="系统提示词">
<TextArea rows={4} placeholder="行业专属系统提示词" /> <TextArea rows={4} placeholder="行业专属系统提示词" />
</Form.Item> </Form.Item>
<Form.Item name="cold_start_template" label="冷启动模板" extra="新用户首次对话时使用的引导模板">
<TextArea rows={3} placeholder="如:您好!我是您的{行业}管家,可以帮您处理..." />
</Form.Item>
<Form.Item name="pain_seed_categories" label="痛点种子类别" extra="预置的痛点分类,用逗号或回车分隔">
<Select mode="tags" placeholder="如:库存管理、客户服务、合规" />
</Form.Item>
</Form> </Form>
</Modal> </Modal>
) )

View File

@@ -130,11 +130,15 @@ impl KeywordClassifier {
if hits == 0 { if hits == 0 {
return 0.0; return 0.0;
} }
// Normalize: more hits = higher score, capped at 1.0 // Normalize: 3 keyword hits → score 1.0 (saturated). Threshold 0.2 ≈ 0.6 hits.
(hits as f32 / 3.0).min(1.0) (hits as f32 / 3.0).min(1.0)
} }
/// Classify against dynamic industry keyword configs. /// Classify against dynamic industry keyword configs.
///
/// Tie-breaking: when two industries score equally, the *first* entry wins
/// (keeps existing best on `<=`). Industries should be ordered by priority
/// in the config array if specific tie-breaking is desired.
fn classify_with_industries(query: &str, industries: &[IndustryKeywordConfig]) -> Option<RoutingHint> { fn classify_with_industries(query: &str, industries: &[IndustryKeywordConfig]) -> Option<RoutingHint> {
let lower = query.to_lowercase(); let lower = query.to_lowercase();

View File

@@ -204,24 +204,25 @@ pub async fn set_account_industries(
req: &SetAccountIndustriesRequest, req: &SetAccountIndustriesRequest,
) -> SaasResult<Vec<AccountIndustryItem>> { ) -> SaasResult<Vec<AccountIndustryItem>> {
let now = chrono::Utc::now(); let now = chrono::Utc::now();
// 批量验证:一次查询所有行业是否存在且启用
let ids: Vec<&str> = req.industries.iter().map(|e| e.industry_id.as_str()).collect(); let ids: Vec<&str> = req.industries.iter().map(|e| e.industry_id.as_str()).collect();
// 事务:验证 + DELETE + INSERT 原子执行,消除 TOCTOU
let mut tx = pool.begin().await.map_err(SaasError::Database)?;
// 验证:所有行业必须存在且启用
let valid_count: (i64,) = sqlx::query_as( let valid_count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM industries WHERE id = ANY($1) AND status = 'active'" "SELECT COUNT(*) FROM industries WHERE id = ANY($1) AND status = 'active'"
) )
.bind(&ids) .bind(&ids)
.fetch_one(pool) .fetch_one(&mut *tx)
.await .await
.map_err(SaasError::Database)?; .map_err(SaasError::Database)?;
if valid_count.0 != ids.len() as i64 { if valid_count.0 != ids.len() as i64 {
tx.rollback().await.ok();
return Err(SaasError::InvalidInput("部分行业不存在或已禁用".to_string())); return Err(SaasError::InvalidInput("部分行业不存在或已禁用".to_string()));
} }
// 事务性 DELETE + INSERT
let mut tx = pool.begin().await.map_err(SaasError::Database)?;
sqlx::query("DELETE FROM account_industries WHERE account_id = $1") sqlx::query("DELETE FROM account_industries WHERE account_id = $1")
.bind(account_id) .bind(account_id)
.execute(&mut *tx) .execute(&mut *tx)

View File

@@ -36,6 +36,7 @@ pub struct IndustryListItem {
/// 创建行业请求 /// 创建行业请求
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateIndustryRequest { pub struct CreateIndustryRequest {
pub id: String, pub id: String,
pub name: String, pub name: String,
@@ -57,6 +58,7 @@ pub struct CreateIndustryRequest {
/// 更新行业请求 /// 更新行业请求
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateIndustryRequest { pub struct UpdateIndustryRequest {
pub name: Option<String>, pub name: Option<String>,
pub icon: Option<String>, pub icon: Option<String>,
@@ -99,12 +101,14 @@ pub struct AccountIndustryItem {
/// 设置用户行业请求 /// 设置用户行业请求
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SetAccountIndustriesRequest { pub struct SetAccountIndustriesRequest {
pub industries: Vec<AccountIndustryEntry>, pub industries: Vec<AccountIndustryEntry>,
} }
/// 用户行业条目 /// 用户行业条目
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AccountIndustryEntry { pub struct AccountIndustryEntry {
pub industry_id: String, pub industry_id: String,
#[serde(default)] #[serde(default)]

View File

@@ -426,7 +426,7 @@ async fn store_trigger_experience(
let entry = zclaw_growth::MemoryEntry::new( let entry = zclaw_growth::MemoryEntry::new(
agent_id, agent_id,
zclaw_growth::MemoryType::Experience, zclaw_growth::MemoryType::Experience,
&format!("trigger/{:?}", signal), &format!("trigger/{}", signal_name),
content, content,
); );

View File

@@ -9,6 +9,9 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import type { IndustryFullConfig, AccountIndustryItem } from '../lib/saas-client'; import type { IndustryFullConfig, AccountIndustryItem } from '../lib/saas-client';
import { saasClient } from '../lib/saas-client'; import { saasClient } from '../lib/saas-client';
import { createLogger } from '../lib/logger';
const log = createLogger('industryStore');
// ============ Types ============ // ============ Types ============
@@ -61,7 +64,7 @@ export const useIndustryStore = create<IndustryStore>()(
configs[item.industry_id] = fullConfig; configs[item.industry_id] = fullConfig;
} catch (err) { } catch (err) {
// Non-fatal: one industry failing shouldn't block others // Non-fatal: one industry failing shouldn't block others
console.warn(`[industryStore] Failed to fetch config for ${item.industry_id}:`, err); log.warn(`Failed to fetch config for ${item.industry_id}:`, err);
} }
} }