feat(industry): Phase 1 行业配置基础 — 数据模型 + 四行业内置配置 + ButlerRouter 动态关键词
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

- 新增 SaaS industry 模块 (types/service/handlers/mod/builtin)
- 4 行业内置配置: healthcare/education/garment/ecommerce
- 数据库迁移: industries + account_industries 表
- 8 个 API 端点 (CRUD + 用户行业关联)
- ButlerRouter 改造: 支持 IndustryKeywordConfig 动态注入
- 12 个测试全通过 (含动态行业分类测试)
This commit is contained in:
iven
2026-04-12 15:42:35 +08:00
parent 5599cefc41
commit 5d1050bf6f
13 changed files with 886 additions and 32 deletions

View File

@@ -0,0 +1,241 @@
//! 行业配置业务逻辑层
use sqlx::PgPool;
use crate::error::{SaasError, SaasResult};
use crate::common::{normalize_pagination, PaginatedResponse};
use super::types::*;
use super::builtin::builtin_industries;
// ============ 行业 CRUD ============
/// 列表查询
pub async fn list_industries(
pool: &PgPool,
query: &ListIndustriesQuery,
) -> SaasResult<PaginatedResponse<IndustryListItem>> {
let (page, page_size, offset) = normalize_pagination(query.page, query.page_size);
let mut where_clauses = vec!["1=1".to_string()];
if let Some(ref status) = query.status {
where_clauses.push(format!("status = '{}'", status.replace('\'', "''")));
}
if let Some(ref source) = query.source {
where_clauses.push(format!("source = '{}'", source.replace('\'', "''")));
}
let where_sql = where_clauses.join(" AND ");
let count_sql = format!("SELECT COUNT(*) FROM industries WHERE {}", where_sql);
let total: (i64,) = sqlx::query_as(&count_sql)
.fetch_one(pool)
.await?;
let items_sql = format!(
"SELECT id, name, icon, description, status, source FROM industries WHERE {} ORDER BY source, id LIMIT $1 OFFSET $2",
where_sql
);
let items: Vec<IndustryListItem> = sqlx::query_as(&items_sql)
.bind(page_size as i64)
.bind(offset)
.fetch_all(pool)
.await?;
Ok(PaginatedResponse { items, total: total.0, page, page_size })
}
/// 获取行业详情
pub async fn get_industry(pool: &PgPool, id: &str) -> SaasResult<Industry> {
let industry: Option<Industry> = sqlx::query_as(
"SELECT * FROM industries WHERE id = $1"
)
.bind(id)
.fetch_optional(pool)
.await?;
industry.ok_or_else(|| SaasError::NotFound(format!("行业 {} 不存在", id)))
}
/// 创建行业
pub async fn create_industry(
pool: &PgPool,
req: &CreateIndustryRequest,
) -> SaasResult<Industry> {
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!([]));
let skill_priorities = serde_json::to_value(&req.skill_priorities).unwrap_or(serde_json::json!([]));
sqlx::query(
r#"INSERT INTO industries (id, name, icon, description, keywords, system_prompt, cold_start_template, pain_seed_categories, skill_priorities, status, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', 'admin', $10, $10)"#
)
.bind(&req.id).bind(&req.name).bind(&req.icon).bind(&req.description)
.bind(&keywords).bind(&req.system_prompt).bind(&req.cold_start_template)
.bind(&pain_categories).bind(&skill_priorities).bind(&now)
.execute(pool).await
.map_err(|e| SaasError::from_sqlx_unique(e, "行业"))?;
get_industry(pool, &req.id).await
}
/// 更新行业
pub async fn update_industry(
pool: &PgPool,
id: &str,
req: &UpdateIndustryRequest,
) -> SaasResult<Industry> {
// 先确认存在
let existing = get_industry(pool, id).await?;
let now = chrono::Utc::now();
let name = req.name.as_deref().unwrap_or(&existing.name);
let icon = req.icon.as_deref().unwrap_or(&existing.icon);
let description = req.description.as_deref().unwrap_or(&existing.description);
let status = req.status.as_deref().unwrap_or(&existing.status);
let system_prompt = req.system_prompt.as_deref().unwrap_or(&existing.system_prompt);
let cold_start = req.cold_start_template.as_deref().unwrap_or(&existing.cold_start_template);
let keywords = req.keywords.as_ref()
.map(|k| serde_json::to_value(k).unwrap_or(serde_json::json!([])))
.unwrap_or(existing.keywords.clone());
let pain_cats = req.pain_seed_categories.as_ref()
.map(|c| serde_json::to_value(c).unwrap_or(serde_json::json!([])))
.unwrap_or(existing.pain_seed_categories.clone());
let skill_prios = req.skill_priorities.as_ref()
.map(|s| serde_json::to_value(s).unwrap_or(serde_json::json!([])))
.unwrap_or(existing.skill_priorities.clone());
sqlx::query(
r#"UPDATE industries SET name=$1, icon=$2, description=$3, keywords=$4,
system_prompt=$5, cold_start_template=$6, pain_seed_categories=$7,
skill_priorities=$8, status=$9, source='admin', updated_at=$10 WHERE id=$11"#
)
.bind(name).bind(icon).bind(description).bind(&keywords)
.bind(system_prompt).bind(cold_start).bind(&pain_cats)
.bind(&skill_prios).bind(status).bind(&now).bind(id)
.execute(pool).await?;
get_industry(pool, id).await
}
/// 获取行业完整配置
pub async fn get_industry_full_config(pool: &PgPool, id: &str) -> SaasResult<IndustryFullConfig> {
let industry = get_industry(pool, id).await?;
let keywords: Vec<String> = serde_json::from_value(industry.keywords.clone())
.unwrap_or_default();
let pain_categories: Vec<String> = serde_json::from_value(industry.pain_seed_categories.clone())
.unwrap_or_default();
let skill_priorities: Vec<SkillPriority> = serde_json::from_value(industry.skill_priorities.clone())
.unwrap_or_default();
Ok(IndustryFullConfig {
id: industry.id,
name: industry.name,
icon: industry.icon,
description: industry.description,
keywords,
system_prompt: industry.system_prompt,
cold_start_template: industry.cold_start_template,
pain_seed_categories: pain_categories,
skill_priorities,
status: industry.status,
source: industry.source,
})
}
// ============ 用户-行业关联 ============
/// 获取用户授权行业列表
pub async fn list_account_industries(
pool: &PgPool,
account_id: &str,
) -> SaasResult<Vec<AccountIndustryItem>> {
let items: Vec<AccountIndustryItem> = sqlx::query_as(
r#"SELECT ai.industry_id, ai.is_primary, i.name as industry_name, i.icon as industry_icon
FROM account_industries ai
JOIN industries i ON i.id = ai.industry_id
WHERE ai.account_id = $1 AND i.status = 'active'
ORDER BY ai.is_primary DESC, ai.industry_id"#
)
.bind(account_id)
.fetch_all(pool)
.await?;
Ok(items)
}
/// 设置用户行业(全量替换)
pub async fn set_account_industries(
pool: &PgPool,
account_id: &str,
req: &SetAccountIndustriesRequest,
) -> SaasResult<Vec<AccountIndustryItem>> {
let now = chrono::Utc::now();
// 验证行业存在且启用
for entry in &req.industries {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM industries WHERE id = $1 AND status = 'active')"
)
.bind(&entry.industry_id)
.fetch_one(pool)
.await
.unwrap_or(false);
if !exists {
return Err(SaasError::InvalidInput(format!("行业 {} 不存在或已禁用", entry.industry_id)));
}
}
// 清除旧关联
sqlx::query("DELETE FROM account_industries WHERE account_id = $1")
.bind(account_id)
.execute(pool)
.await?;
// 插入新关联
for entry in &req.industries {
sqlx::query(
r#"INSERT INTO account_industries (account_id, industry_id, is_primary, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4)"#
)
.bind(account_id)
.bind(&entry.industry_id)
.bind(entry.is_primary)
.bind(&now)
.execute(pool)
.await?;
}
list_account_industries(pool, account_id).await
}
// ============ Seed ============
/// 插入内置行业配置(幂等 ON CONFLICT DO NOTHING
pub async fn seed_builtin_industries(pool: &PgPool) -> SaasResult<()> {
let now = chrono::Utc::now();
for def in builtin_industries() {
let keywords = serde_json::to_value(def.keywords).unwrap_or(serde_json::json!([]));
let pain_cats = serde_json::to_value(def.pain_seed_categories).unwrap_or(serde_json::json!([]));
let skill_prios: Vec<serde_json::Value> = def.skill_priorities.iter()
.map(|(skill_id, priority)| serde_json::json!({"skill_id": skill_id, "priority": priority}))
.collect();
let skill_prios = serde_json::Value::Array(skill_prios);
sqlx::query(
r#"INSERT INTO industries (id, name, icon, description, keywords, system_prompt, cold_start_template, pain_seed_categories, skill_priorities, status, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', 'builtin', $10, $10)
ON CONFLICT (id) DO NOTHING"#
)
.bind(def.id).bind(def.name).bind(def.icon).bind(def.description)
.bind(&keywords).bind(def.system_prompt).bind(def.cold_start_template)
.bind(&pain_cats).bind(&skill_prios).bind(&now)
.execute(pool)
.await?;
}
tracing::info!("Seeded {} builtin industries", builtin_industries().len());
Ok(())
}