//! 会话映射存储(IM chat_id ↔ DevFlow conv_id,SQLite + WAL) //! //! 建表即带 owner_user_id/platform 列(P0 预留项 ⑤,当前恒默认值, //! S2 多人场景免迁表)。 //! //! 表结构: //! ```sql //! CREATE TABLE IF NOT EXISTS session_map ( //! platform TEXT NOT NULL, -- feishu/wecom/dingtalk/web //! chat_id TEXT NOT NULL, -- IM 侧会话(单聊 user_id / 群聊 chat_id) //! conv_id TEXT NOT NULL, -- DevFlow 会话 ID //! owner_user_id TEXT NOT NULL DEFAULT '',-- 预留 S2 多人归属(当前恒 '') //! channel_id TEXT NOT NULL DEFAULT '',-- 路由到的桌面端 channel(预留项 ⑥) //! created_at INTEGER NOT NULL, //! updated_at INTEGER NOT NULL, //! PRIMARY KEY (platform, chat_id) //! ); //! ``` use std::path::Path; use std::sync::Arc; use rusqlite::Connection; /// 单条会话映射(字段预留 S2 多人/多桌面端,当前核心只读 conv_id) #[derive(Debug, Clone)] #[allow(dead_code)] pub struct SessionMap { pub platform: String, pub chat_id: String, pub conv_id: String, pub owner_user_id: String, pub channel_id: String, } #[derive(Clone)] pub struct SessionStore { conn: Arc>, } impl SessionStore { /// 打开(或创建)库文件并确保表存在。WAL 模式(与桌面端一致)。 pub fn open(path: &Path) -> anyhow::Result { let conn = Connection::open(path)?; conn.pragma_update(None, "journal_mode", "WAL")?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS session_map ( platform TEXT NOT NULL, chat_id TEXT NOT NULL, conv_id TEXT NOT NULL, owner_user_id TEXT NOT NULL DEFAULT '', channel_id TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (platform, chat_id) );", )?; Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) }) } /// 内存库(测试/演示) #[allow(dead_code)] pub fn open_in_memory() -> anyhow::Result { let conn = Connection::open_in_memory()?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS session_map ( platform TEXT NOT NULL, chat_id TEXT NOT NULL, conv_id TEXT NOT NULL, owner_user_id TEXT NOT NULL DEFAULT '', channel_id TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (platform, chat_id) );", )?; Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) }) } /// 查 IM 会话映射的 conv_id(未映射返回 None) pub fn get_conv(&self, platform: &str, chat_id: &str) -> Option { let conn = self.conn.lock().ok()?; conn.query_row( "SELECT platform, chat_id, conv_id, owner_user_id, channel_id FROM session_map \ WHERE platform = ?1 AND chat_id = ?2", [platform, chat_id], |row| { Ok(SessionMap { platform: row.get(0)?, chat_id: row.get(1)?, conv_id: row.get(2)?, owner_user_id: row.get(3)?, channel_id: row.get(4)?, }) }, ) .ok() } /// 写/更新映射(upsert) pub fn set_conv(&self, platform: &str, chat_id: &str, conv_id: &str, channel_id: &str) -> anyhow::Result<()> { let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0); conn.execute( "INSERT INTO session_map (platform, chat_id, conv_id, owner_user_id, channel_id, created_at, updated_at) \ VALUES (?1, ?2, ?3, '', ?4, ?5, ?5) \ ON CONFLICT(platform, chat_id) DO UPDATE SET conv_id = ?3, channel_id = ?4, updated_at = ?5", rusqlite::params![platform, chat_id, conv_id, channel_id, now], )?; Ok(()) } /// 删除映射(桌面端会话已删,悬空清理) pub fn remove(&self, platform: &str, chat_id: &str) -> anyhow::Result<()> { let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?; conn.execute("DELETE FROM session_map WHERE platform = ?1 AND chat_id = ?2", [platform, chat_id])?; Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn upsert_and_get() { let store = SessionStore::open_in_memory().unwrap(); store.set_conv("feishu", "oc_123", "conv_abc", "chan-work").unwrap(); let m = store.get_conv("feishu", "oc_123").unwrap(); assert_eq!(m.conv_id, "conv_abc"); assert_eq!(m.channel_id, "chan-work"); assert_eq!(m.owner_user_id, ""); // upsert 覆盖 store.set_conv("feishu", "oc_123", "conv_new", "chan-home").unwrap(); let m = store.get_conv("feishu", "oc_123").unwrap(); assert_eq!(m.conv_id, "conv_new"); // 删除后悬空 store.remove("feishu", "oc_123").unwrap(); assert!(store.get_conv("feishu", "oc_123").is_none()); } #[test] fn platform_isolated() { let store = SessionStore::open_in_memory().unwrap(); store.set_conv("feishu", "c1", "conv1", "ch").unwrap(); store.set_conv("wecom", "c1", "conv2", "ch").unwrap(); assert_eq!(store.get_conv("feishu", "c1").unwrap().conv_id, "conv1"); assert_eq!(store.get_conv("wecom", "c1").unwrap().conv_id, "conv2"); } }