新增: 消息级溯源 P0 地基(ChatMessage.id + ai_messages 拆表 + 迁移 + Repo)

依据消息拆分存储设计 + 消息级溯源设计 P0(地基,P1 溯源/P2 切读待后续):
- df-ai-core ChatMessage 加 id 字段(Option<String> serde 向前兼容)+ new_message_id(AtomicU64+ts 并发安全)
- 6 构造器生成 id,老 JSON 无 id → None 兼容
- df-storage V21 一次原子迁移:建 ai_messages 表 + ai_tool_executions.message_id 列 + 全量数据迁移(分批+坏数据容错+COUNT 幂等)
- AiMessageRecord + AiMessageRepo(insert_batch/list_by_conversation/delete_range/update_status/update_content_by_tool_call_id)
- audit message_id 列补建(conversation_repo/settings 白名单)
- src-tauri title.rs/finalize.rs 字面量占位(P1 接真值)

自验: df-storage 45 passed + df-ai-core 28 passed + workspace EXIT 0
This commit is contained in:
2026-06-19 19:24:02 +08:00
parent 44d1c6a00c
commit e981c1492a
10 changed files with 953 additions and 16 deletions

View File

@@ -93,6 +93,9 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
Ok(AiToolExecutionRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
// F-260619-04:message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
// 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。
message_id: row.get("message_id").unwrap_or(None),
tool_call_id: row.get("tool_call_id")?,
tool_name: row.get("tool_name")?,
arguments: row.get("arguments")?,
@@ -187,10 +190,10 @@ impl_repo!(
from_row => |row| ai_tool_execution_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ai_tool_executions (id, conversation_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
rec.id, rec.conversation_id, rec.tool_call_id, rec.tool_name,
rec.id, rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by
],
@@ -198,9 +201,9 @@ impl_repo!(
},
update => |conn, rec| {
conn.execute(
"UPDATE ai_tool_executions SET conversation_id = ?1, tool_call_id = ?2, tool_name = ?3, arguments = ?4, result = ?5, status = ?6, risk_level = ?7, requested_at = ?8, executed_at = ?9, decided_by = ?10 WHERE id = ?11",
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12",
params![
rec.conversation_id, rec.tool_call_id, rec.tool_name,
rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by, rec.id
],

View File

@@ -0,0 +1,368 @@
//! AI 消息 Repo — ai_messages 表(F-260619-03 消息拆分存储)
//!
//! 每条 ChatMessage 一行的独立表,替代 `ai_conversations.messages` 整对话 JSON 列存。
//! 全专用方法(insert_batch / list_by_conversation / delete_range / update_status /
//! update_content_by_tool_call_id),不走 `impl_repo!` 宏 —— 原因:
//! ① 无标准 created_at 通用排序(query 宏硬编码 ORDER BY created_at,本表用 seq);
//! ② insert_batch 批量语义特殊(单事务多条,非逐条 insert);
//! ③ update_status / update_content_by_tool_call_id 是单列定点更新,非全行 update_full。
//!
//! P0 阶段(本文件):仅建 Repo + CRUD 方法,不接 ContextManager 读写路径
//! (切读策略待用户决策,P2)。ContextManager 仍走旧 messages JSON 列双写期未启动。
use std::sync::Arc;
use rusqlite::{params, Connection, Row};
use tokio::sync::Mutex;
use df_types::error::Result;
use crate::db::Database;
use crate::models::AiMessageRecord;
use super::storage_err;
// ============================================================
// from_row 辅助
// ============================================================
fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, rusqlite::Error> {
Ok(AiMessageRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
seq: row.get("seq")?,
role: row.get("role")?,
content: row.get("content")?,
parts: row.get("parts")?,
tool_call_id: row.get("tool_call_id")?,
tool_calls: row.get("tool_calls")?,
model: row.get("model")?,
status: row.get("status")?,
reasoning_content: row.get("reasoning_content")?,
timestamp: row.get("timestamp")?,
created_at: row.get("created_at")?,
})
}
// ============================================================
// AiMessageRepo
// ============================================================
/// ai_messages 表 CRUD Repo。
///
/// 方法语义对照消息拆分存储设计 §三/§Phase3:
/// - `insert_batch`:批量插入一批消息(单事务,保证对话内 seq 连续原子写入)
/// - `list_by_conversation`:按对话加载,ORDER BY seq(游标分页基础)
/// - `delete_range`:删除对话内 [min_seq, max_seq) 范围(compress 压缩/编辑重生成用)
/// - `update_status`:单条状态更新(compress → 'compressed' / 编辑 → 'truncated')
/// - `update_content_by_tool_call_id`:按工具调用 ID 定点改 content(replace_tool_result_content)
pub struct AiMessageRepo {
conn: Arc<Mutex<Connection>>,
}
impl AiMessageRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 批量插入消息(单事务原子提交)。
///
/// INSERT OR IGNORE 幂等:id 主键冲突(同消息重复写)跳过,不报错。
/// 适配双写场景(save_conversation 重写 dirty 范围时,旧消息先 delete_range 再 insert)。
pub async fn insert_batch(&self, records: Vec<AiMessageRecord>) -> Result<()> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut guard = conn.blocking_lock();
// transaction() 返回 rusqlite::Error,需 map_err 转为 df_types::error::Error
let tx = guard.transaction().map_err(storage_err)?;
{
let mut stmt = tx.prepare(
"INSERT OR IGNORE INTO ai_messages
(id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
)
.map_err(storage_err)?;
for rec in &records {
stmt.execute(params![
rec.id, rec.conversation_id, rec.seq, rec.role, rec.content,
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at
])
.map_err(storage_err)?;
}
}
tx.commit().map_err(storage_err)?;
Ok(())
})
.await
.map_err(storage_err)?
}
/// 按对话加载全部消息,ORDER BY seq ASC(对话内时间序)。
///
/// 切读路径(restore_from_messages)用此替代反序列化 messages JSON。
/// P0 阶段未接切读(P2),此方法先就绪供未来调用 + 测试验证。
pub async fn list_by_conversation(&self, conversation_id: &str) -> Result<Vec<AiMessageRecord>> {
let conn = self.conn.clone();
let conv_id = conversation_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![conv_id], ai_message_from_row)
.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)?
}
/// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
///
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。
/// 返回删除条数(0 表示无匹配,合法)。max_seq=None 表示删到末尾。
pub async fn delete_range(
&self,
conversation_id: &str,
min_seq: i64,
max_seq: Option<i64>,
) -> Result<usize> {
let conn = self.conn.clone();
let conv_id = conversation_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = match max_seq {
Some(max) => guard.execute(
"DELETE FROM ai_messages WHERE conversation_id = ?1 AND seq >= ?2 AND seq < ?3",
params![conv_id, min_seq, max],
),
None => guard.execute(
"DELETE FROM ai_messages WHERE conversation_id = ?1 AND seq >= ?2",
params![conv_id, min_seq],
),
}
.map_err(storage_err)?;
Ok(affected)
})
.await
.map_err(storage_err)?
}
/// 更新单条消息状态(compress → 'compressed' / 编辑 → 'truncated' / 恢复 → 'active')。
///
/// 返回是否实际更新(0 = 消息不存在/已是该状态)。同步刷新 created_at 不需要
/// (状态变更不改创建时间)。
pub async fn update_status(
&self,
conversation_id: &str,
seq: i64,
status: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let conv_id = conversation_id.to_owned();
let status = status.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_messages SET status = ?1 WHERE conversation_id = ?2 AND seq = ?3",
params![status, conv_id, seq],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 按 tool_call_id 定点更新消息 content(replace_tool_result_content 用)。
///
/// 返回是否实际更新(0 = 该 tool_call_id 在此对话无对应消息)。
/// 仅改 content,不动其他字段(状态/时间戳不变)。
pub async fn update_content_by_tool_call_id(
&self,
conversation_id: &str,
tool_call_id: &str,
content: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let conv_id = conversation_id.to_owned();
let tcid = tool_call_id.to_owned();
let content = content.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_messages SET content = ?1 WHERE conversation_id = ?2 AND tool_call_id = ?3",
params![content, conv_id, tcid],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// 单元测试 — V21 迁移幂等 + AiMessageRepo CRUD
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::models::AiMessageRecord;
use super::super::now_millis_str;
/// 构造测试消息记录
fn mk_msg(id: &str, conv: &str, seq: i64, role: &str, content: &str) -> AiMessageRecord {
AiMessageRecord {
id: id.into(),
conversation_id: conv.into(),
seq,
role: role.into(),
content: content.into(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: "active".into(),
reasoning_content: None,
timestamp: None,
created_at: now_millis_str(),
}
}
/// insert_batch + list_by_conversation round-trip
#[tokio::test]
async fn insert_batch_and_list_roundtrip() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
let msgs = vec![
mk_msg("msg_1", "conv_a", 0, "user", "你好"),
mk_msg("msg_2", "conv_a", 1, "assistant", "你好,有什么可以帮你?"),
mk_msg("msg_3", "conv_a", 2, "tool", "工具结果"),
];
repo.insert_batch(msgs).await.expect("insert_batch");
let got = repo.list_by_conversation("conv_a").await.expect("list");
assert_eq!(got.len(), 3);
// ORDER BY seq ASC
assert_eq!(got[0].seq, 0);
assert_eq!(got[0].content, "你好");
assert_eq!(got[1].seq, 1);
assert_eq!(got[2].seq, 2);
assert_eq!(got[2].role, "tool");
// 对话隔离:另一对话查不到
let other = repo.list_by_conversation("conv_b").await.expect("list b");
assert!(other.is_empty());
}
/// insert_batch 幂等:同 id 重复 INSERT OR IGNORE 不报错(双写场景)
#[tokio::test]
async fn insert_batch_idempotent() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
let m = mk_msg("msg_dup", "conv", 0, "user", "v1");
repo.insert_batch(vec![m.clone()]).await.expect("insert 1");
// 重复同 id 不同内容 → IGNORE,保留第一次
let m2 = mk_msg("msg_dup", "conv", 0, "user", "v2");
repo.insert_batch(vec![m2]).await.expect("insert 2 dup");
let got = repo.list_by_conversation("conv").await.expect("list");
assert_eq!(got.len(), 1);
assert_eq!(got[0].content, "v1", "IGNORE 应保留首次写入");
}
/// delete_range 左闭右开 + None 到末尾
#[tokio::test]
async fn delete_range_half_open_and_open_end() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
repo.insert_batch(vec![
mk_msg("m0", "c", 0, "user", "0"),
mk_msg("m1", "c", 1, "user", "1"),
mk_msg("m2", "c", 2, "user", "2"),
mk_msg("m3", "c", 3, "user", "3"),
])
.await
.expect("insert");
// 左闭右开 [1, 3):删 seq 1, 2
let n = repo.delete_range("c", 1, Some(3)).await.expect("delete");
assert_eq!(n, 2);
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got.len(), 2);
assert_eq!(got[0].seq, 0);
assert_eq!(got[1].seq, 3);
// None 到末尾:删 seq >= 3
let n = repo.delete_range("c", 3, None).await.expect("delete end");
assert_eq!(n, 1);
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got.len(), 1);
assert_eq!(got[0].seq, 0);
}
/// update_status 单条状态更新
#[tokio::test]
async fn update_status_single() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
repo.insert_batch(vec![mk_msg("m0", "c", 0, "user", "hi")])
.await
.expect("insert");
let ok = repo.update_status("c", 0, "truncated").await.expect("update");
assert!(ok, "应实际更新");
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got[0].status, "truncated");
// 不存在的 seq → false
let ok = repo.update_status("c", 99, "active").await.expect("update");
assert!(!ok, "不存在 seq 应返回 false");
}
/// update_content_by_tool_call_id 定点改 content
#[tokio::test]
async fn update_content_by_tool_call_id_targeted() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
let mut m = mk_msg("m0", "c", 0, "tool", "原始结果");
m.tool_call_id = Some("call_abc".into());
repo.insert_batch(vec![m]).await.expect("insert");
let ok = repo
.update_content_by_tool_call_id("c", "call_abc", "替换后的结果")
.await
.expect("update");
assert!(ok);
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got[0].content, "替换后的结果");
// 不存在的 tool_call_id → false,不改其他
let ok = repo
.update_content_by_tool_call_id("c", "call_xxx", "nope")
.await
.expect("update");
assert!(!ok);
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got[0].content, "替换后的结果", "其他消息不应被改");
}
}

View File

@@ -1,6 +1,6 @@
//! Repository 模式的 CRUD 操作
//!
//! 为 7 张表各提供一个 Repo 结构体,统一实现 insert / get_by_id / list_all / query / update_field / delete。
//! 为各表提供 Repo 结构体,统一实现 insert / get_by_id / list_all / query / update_field / delete。
//!
//! 按表域拆分(SMELL-P1-9,对齐 SMELL-P0-2 拆分思路降 2212 行单文件复杂度):
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
@@ -8,18 +8,21 @@
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@message_repo`]:AiMessageRepo(F-260619-03 消息拆分存储,全专用方法不走宏)
//!
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
mod conversation_repo;
mod idea_repo;
mod message_repo;
mod project_repo;
mod settings;
mod task_repo;
pub use conversation_repo::*;
pub use idea_repo::*;
pub use message_repo::*;
pub use project_repo::*;
pub use settings::*;
pub use task_repo::*;
@@ -252,6 +255,13 @@ mod baseline_tests {
"表 {t} 应登记列白名单(防 SQL 注入 + 按表隔离);拆分后白名单单一来源在 settings.rs"
);
}
// ai_messages 表走全专用 AiMessageRepo(无标准 created_at / 批量语义特殊),
// 不走通用 query/update_field 路径,故不登记白名单(allowed_columns_for 返回 None)。
// 此处显式断言其为 None,防止后人误登记破坏"专用 Repo 不进白名单"约定。
assert!(
allowed_columns_for("ai_messages").is_none(),
"ai_messages 走专用 AiMessageRepo,不应登记通用列白名单"
);
}
/// re-export 完整性:所有 Repo 类型经 `df_storage::crud::XxxRepo` 路径可访问 + new 不 panic。
@@ -273,5 +283,6 @@ mod baseline_tests {
let _ = AiToolExecutionRepo::new(&db);
let _ = KnowledgeRepo::new(&db);
let _ = KnowledgeEventsRepo::new(&db);
let _ = AiMessageRepo::new(&db);
}
}

View File

@@ -172,8 +172,8 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"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",
"id", "conversation_id", "message_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",