|
| 1 | +//! 知识体系命令层共享校验(v0.13.1 REQ-202~205 系统层)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 本文件收敛命令层入参校验统一契约(AGENTS.md §6/§4):id>0、 |
| 4 | +//! 白名单(kind/type/status/target_type)、名称归一化(trim + 连续空白 |
| 5 | +//! 折叠为一个空格——规格 §二 不可变约束:概念/体系/节点名落库前归一, |
| 6 | +//! 使 `find_concept_by_name` 查重命中同一书写)、name/text 非空且 ≤2000。 |
| 7 | +//! @ai-context: 校验全部纯函数、无 DB、无时间调用,供 commands_knowledge_systems |
| 8 | +//! (体系/问题树)与 commands_knowledge_core(概念/模型/引用/审计)复用。 |
| 9 | +//! @ai-context: 线宽豁免登记:原单文件 commands_knowledge.rs(18 命令 + 校验 + 测试) |
| 10 | +//! 预估超 300 行,按规格 §四拆 commands_knowledge_systems.rs 与 |
| 11 | +//! commands_knowledge_core.rs,本文件仅收敛公共校验(各拆分文件 ≤300 行)。 |
| 12 | +//! @ai-context: 入参出参契约——name/text 归一化后 ≤2000 字符;status/target_type |
| 13 | +//! 白名单拒绝任意字符串入库(Tauri IPC 入参校验红线)。 |
| 14 | +
|
| 15 | +use crate::db_knowledge_links::LinkTarget; |
| 16 | + |
| 17 | +/// 名称/文本最大长度(防超长字符串污染 DB 与 IPC,规格 §四 统一 ≤2000)。 |
| 18 | +pub(crate) const NAME_MAX_CHARS: usize = 2000; |
| 19 | + |
| 20 | +/// 体系类型白名单(global 全库唯一;domain 挂 global)。 |
| 21 | +const KIND_WHITELIST: [&str; 2] = ["global", "domain"]; |
| 22 | +/// 节点类型白名单(问题树三态:问题/场景/领域入口)。 |
| 23 | +const NODE_WHITELIST: [&str; 3] = ["question", "scenario", "domain_entry"]; |
| 24 | +/// 实体通用状态白名单(system/node/model)。 |
| 25 | +const COMMON_STATUS: [&str; 3] = ["active", "watching", "archived"]; |
| 26 | +/// 概念状态白名单(概念三态,与通用态差异在 active→core)。 |
| 27 | +const CONCEPT_STATUS: [&str; 3] = ["core", "watching", "archived"]; |
| 28 | + |
| 29 | +/// 校验 id>0(所有 id 入参统一前置——防 0/负 id 触发空查询或错误级联)。 |
| 30 | +pub(crate) fn require_id(id: i64) -> Result<(), String> { |
| 31 | + if id > 0 { |
| 32 | + Ok(()) |
| 33 | + } else { |
| 34 | + Err(format!("无效的 id: {}", id)) |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +/// 校验体系类型白名单(kind ∈ {global, domain})。 |
| 39 | +pub(crate) fn require_kind(kind: &str) -> Result<(), String> { |
| 40 | + if KIND_WHITELIST.contains(&kind) { |
| 41 | + Ok(()) |
| 42 | + } else { |
| 43 | + Err(format!("不支持的体系类型: {}(支持: {})", kind, KIND_WHITELIST.join("/"))) |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +/// 校验节点类型白名单(type ∈ {question, scenario, domain_entry})。 |
| 48 | +pub(crate) fn require_node_type(t: &str) -> Result<(), String> { |
| 49 | + if NODE_WHITELIST.contains(&t) { |
| 50 | + Ok(()) |
| 51 | + } else { |
| 52 | + Err(format!("不支持的节点类型: {}(支持: {})", t, NODE_WHITELIST.join("/"))) |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +/// 校验实体状态白名单:system/node/model 取 active/watching/archived, |
| 57 | +/// concept 取 core/watching/archived(概念以核心/观望/归档三态区分)。 |
| 58 | +pub(crate) fn require_status(entity: &str, st: &str) -> Result<(), String> { |
| 59 | + let list: &[&str] = if entity == "concept" { &CONCEPT_STATUS } else { &COMMON_STATUS }; |
| 60 | + if list.contains(&st) { |
| 61 | + Ok(()) |
| 62 | + } else { |
| 63 | + Err(format!("不支持的状态: {}(支持: {})", st, list.join("/"))) |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +/// 名称/文本归一化:trim + 连续空白折叠为一个空格;空/超长报中文业务错误。 |
| 68 | +/// |
| 69 | +/// @ai-context: 名称归一化在命令层执行后落库(spec §二 不可变约束)——同一概念的 |
| 70 | +/// 不同书写(多余空格/换行)视为同一名称;`find_concept_by_name` 用 |
| 71 | +/// 归一化名查重,保证概念名全库唯一。split_whitespace 天然处理 trim |
| 72 | +/// 与任意空白折叠。 |
| 73 | +/// @param raw - 原始名称/文本 |
| 74 | +/// @param label - 业务术语("体系名称"/"概念名称"/"节点内容"…),用于错误文案 |
| 75 | +pub(crate) fn normalize_text(raw: &str, label: &str) -> Result<String, String> { |
| 76 | + let norm = raw.split_whitespace().collect::<Vec<_>>().join(" "); |
| 77 | + if norm.is_empty() { |
| 78 | + return Err(format!("{}不能为空", label)); |
| 79 | + } |
| 80 | + if norm.chars().count() > NAME_MAX_CHARS { |
| 81 | + return Err(format!("{}超长(上限 {} 字符)", label, NAME_MAX_CHARS)); |
| 82 | + } |
| 83 | + Ok(norm) |
| 84 | +} |
| 85 | + |
| 86 | +/// 校验引用目标类型白名单并解析为枚举(note_group/note/flashcard/fragment)。 |
| 87 | +pub(crate) fn parse_target_type(s: &str) -> Result<LinkTarget, String> { |
| 88 | + match s { |
| 89 | + "note_group" => Ok(LinkTarget::NoteGroup), |
| 90 | + "note" => Ok(LinkTarget::Note), |
| 91 | + "flashcard" => Ok(LinkTarget::Flashcard), |
| 92 | + "fragment" => Ok(LinkTarget::Fragment), |
| 93 | + _ => Err(format!("不支持的目标类型: {}(支持: note_group/note/flashcard/fragment)", s)), |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +/// 校验并归一化学科 JSON:解析为 Vec<String>,过滤空白项,要求 ≥1 非空,再序列化落库。 |
| 98 | +/// |
| 99 | +/// @ai-context: 前端传 JSON 数组字符串;解析后校验 ≥1 非空学科(模型跨学科命题 |
| 100 | +/// 至少一门学科支撑),空数组/全空白/非法 JSON 拒绝;存储态规范为 |
| 101 | +/// serde_json 紧凑序列化(db 层存 JSON 文本,解析由调用方)。 |
| 102 | +pub(crate) fn normalize_disciplines(json: &str) -> Result<String, String> { |
| 103 | + let parsed: Vec<String> = serde_json::from_str(json) |
| 104 | + .map_err(|_| format!("学科列表格式错误: {}", json))?; |
| 105 | + let trimmed: Vec<String> = parsed |
| 106 | + .into_iter() |
| 107 | + .map(|s| s.split_whitespace().collect::<Vec<_>>().join(" ")) |
| 108 | + .filter(|s| !s.is_empty()) |
| 109 | + .collect(); |
| 110 | + if trimmed.is_empty() { |
| 111 | + return Err("学科列表至少一个非空学科".to_string()); |
| 112 | + } |
| 113 | + serde_json::to_string(&trimmed).map_err(|e| format!("学科序列化失败: {}", e)) |
| 114 | +} |
| 115 | + |
| 116 | +/// 命令层单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。 |
| 117 | +#[cfg(test)] |
| 118 | +#[path = "commands_knowledge_tests.rs"] |
| 119 | +mod tests; |
0 commit comments