重构: crud.rs按表拆分(SMELL-P1-9) + F-09批1 PerConvState数据结构

- SMELL-P1-9: crud.rs 2212行→crud/6文件(mod/settings/project_repo/task_repo/conversation_repo/idea_repo)
  re-export pub use *_repo::* 零调用方改动,宏 pub(crate) use + 子模块 use super::impl_repo
  基线测试锁12表白名单+13Repo构造
- F-09 批1: PerConvState struct(9字段对齐AiSession::new)+AiSession.per_conv HashMap+conv()/conv_read()访问器+3单测
  纯新增无行为变化,b-1主代自主裁决采纳,批2迁移承接
主代统一兜底: cargo check --workspace 0 + df-storage 35+11 + devflow 96 passed
This commit is contained in:
2026-06-19 01:34:59 +08:00
parent d817386418
commit 2c8764abee
11 changed files with 2592 additions and 2214 deletions

View File

@@ -0,0 +1,206 @@
//! 通用键值设置 — app_settings 表(KV,不适合 impl_repo! 宏)+ 列白名单(防 SQL 注入)
use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension};
use tokio::sync::Mutex;
use df_types::error::{Error, Result};
use crate::db::Database;
use super::{now_millis_str, storage_err};
// ============================================================
// 通用键值设置 — app_settings 表
// ============================================================
/// 通用应用设置 KV Repo(表 `app_settings`)
///
/// 前端 localStorage 迁移目标:存主题/语言/AI 偏好/连接配置等,`value` 为 JSON 字符串。
/// KV 无固定 schema 记录结构,故不走 `impl_repo!` 宏,手写更直白。
pub struct SettingsRepo {
conn: Arc<Mutex<Connection>>,
}
impl SettingsRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 取单个 key 的值(JSON 字符串),不存在返回 None
pub async fn get(&self, key: &str) -> Result<Option<String>> {
let conn = self.conn.clone();
let key = key.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let v = guard
.query_row(
"SELECT value FROM app_settings WHERE key = ?1",
params![key],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(storage_err)?;
Ok(v)
})
.await
.map_err(storage_err)?
}
/// 写 key/value(`INSERT OR REPLACE`),刷新 updated_at
pub async fn set(&self, key: &str, value: &str) -> Result<bool> {
let conn = self.conn.clone();
let key = key.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?1, ?2, ?3)",
params![key, value, now],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 取全部 key/value
pub async fn get_all(&self) -> Result<Vec<(String, String)>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT key, value FROM app_settings")
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 删除 key,返回是否实际删除
pub async fn delete(&self, key: &str) -> Result<bool> {
let conn = self.conn.clone();
let key = key.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute("DELETE FROM app_settings WHERE key = ?1", params![key])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// 列名白名单 — 防止 SQL 注入
// ============================================================
/// 按表返回允许用于 query / update_field 的列名。
///
/// 白名单两重作用:① 防注入(列名参数化前校验);② 按表隔离(update_task 误传
/// projects 的 "name" 会在校验阶段拒绝,而非靠 SQLite "no such column" 兜底报错)。
/// 专用更新路径的列不列入:knowledges.embedding(set_embedding)、projects.deleted_at
/// (soft_delete/restore)、tasks.deleted_at(soft_delete/restore)。未登记的表返回 None
/// → 放行(仅靠参数化防注入,向后兼容)。
pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
Some(match table {
"ideas" => &[
"id", "title", "description", "status", "priority", "score", "tags", "source",
"promoted_to", "ai_analysis", "scores", "created_at", "updated_at",
],
"projects" => &[
"id", "name", "description", "status", "idea_id", "path", "stack", "created_at",
"updated_at",
],
"tasks" => &[
// id/created_at 不列入:主键与创建时间不可通过通用 update_field 改写
// (防篡改主键/伪造创建时间/跨项目移动)。
// status 不列入(D-260616-04 status 收口):所有 status 改动走
// advance_status_atomic(CAS SQL `WHERE id AND status=expected`,唯一 status 写入路径),
// 不经通用 update_field。防 update_task IPC / AI 工具旁路改 status 绕过状态机
// (can_transition 校验)与 review_rounds 累加。后人勿把 status 补回白名单。
// review_rounds 不列入:它仅 advance_status_atomic 退回转换
// (in_review→in_progress / testing→in_review)时原子 +1(收口:仅该专用路径可改,
// update_task/update_field 白名单均不含)。后人勿把 review_rounds 补进白名单,
// 否则破坏「review_rounds 唯一写入路径」收口、引入旁路写导致计数错乱。
"project_id", "title", "description", "priority", "branch_name",
"assignee", "workflow_def_id", "base_branch",
// output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象
// (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。
"output_json",
"updated_at",
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。
// project_id 当前可在白名单内改写,合法目标存在性由上层命令层校验。
],
"releases" => &[
"id", "project_id", "version", "status", "task_ids", "changelog", "created_at",
"released_at",
],
"branches" => &[
"id", "project_id", "task_id", "name", "base", "status", "created_at", "updated_at",
"merged_at",
],
"workflow_executions" => &[
"id", "name", "dag_json", "status", "triggered_by", "project_id", "task_id",
"created_at", "completed_at",
],
"node_executions" => &[
"id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json",
"error_message", "started_at", "completed_at",
],
"ai_providers" => &[
"id", "name", "provider_type", "api_key", "base_url", "default_model", "models",
"is_default", "config", "created_at", "updated_at",
],
"ai_conversations" => &[
"id", "title", "messages", "provider_id", "model", "models", "archived",
"prompt_tokens", "completion_tokens", "created_at", "updated_at",
],
"ai_tool_executions" => &[
"id", "conversation_id", "tool_call_id", "tool_name", "arguments", "result",
"status", "risk_level", "requested_at", "executed_at", "decided_by",
],
"knowledges" => &[
"id", "kind", "title", "content", "tags", "status", "confidence", "reuse_count",
"verified", "source_project", "source_ref", "reasoning", "created_at", "updated_at",
],
"knowledge_events" => &[
"id", "knowledge_id", "event_type", "source_ref", "context_json", "timestamp",
],
_ => return None,
})
}
pub(crate) fn validate_column_name(field: &str, table: &str) -> Result<()> {
match allowed_columns_for(table) {
Some(cols) if cols.contains(&field) => Ok(()),
Some(_) => Err(Error::Storage(format!("{} 不允许的字段名: {}", table, field))),
// 未登记表保守拒绝(FR-S6: 原放行 Ok,若未来未登记表走通用查询路径,列名直进字符串拼接即 SQL 注入;与 is_allowed_column 的 None=>false 对齐)
None => Err(Error::Storage(format!("{} 未登记列白名单,拒绝防注入", table))),
}
}
/// 是否允许更新某表的某列(按表隔离白名单)。未登记表返回 false(保守拒绝,防误传)。
///
/// 供 AI 工具层等外部调用方复用 CRUD 白名单,避免双份字段列表不同步。
pub fn is_allowed_column(table: &str, field: &str) -> bool {
match allowed_columns_for(table) {
Some(cols) => cols.contains(&field),
None => false,
}
}