Files
zclaw_openfang/desktop/src-tauri/src/classroom_commands/persist.rs
iven ee1c9ef3ea
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
chore: Cargo warnings 清零 — 39→0 (仅剩 sqlx-postgres 外部依赖警告)
- runtime: 移除未使用的 SessionId/Datelike import,修复 unused variable
- intelligence: 模块级 #![allow(dead_code)] 抑制 Hermes 预留代码警告
- mcp.rs/persist.rs/nl_schedule.rs: 标注 #[allow(dead_code)] 保留接口
2026-04-15 01:53:11 +08:00

173 lines
6.0 KiB
Rust

//! Classroom SQLite persistence
//!
//! Persists Classroom + ChatState to a local SQLite database so that
//! classroom data survives app restarts. Uses JSON blobs for simplicity
//! since the data is always loaded/written as a whole unit.
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use sqlx::SqliteConnection;
use sqlx::Connection;
use zclaw_kernel::generation::{Classroom, ClassroomChatMessage, ClassroomChatState};
use super::{ClassroomStore, chat::ChatStore};
/// SQLite-backed persistence for classrooms and chat history.
pub struct ClassroomPersistence {
conn: Arc<Mutex<SqliteConnection>>,
}
impl ClassroomPersistence {
/// Open an in-memory database (no persistence across restarts, used as fallback).
pub async fn open_in_memory() -> Result<Self, String> {
let conn = SqliteConnection::connect("sqlite::memory:")
.await
.map_err(|e| format!("Failed to open in-memory classroom DB: {}", e))?;
let persist = Self {
conn: Arc::new(Mutex::new(conn)),
};
persist.init_schema().await?;
Ok(persist)
}
/// Open (or create) the classroom database at the given path.
pub async fn open(db_path: impl Into<PathBuf>) -> Result<Self, String> {
let db_path = db_path.into();
let db_url = format!("sqlite:{}?mode=rwc", db_path.display());
let conn = SqliteConnection::connect(&db_url)
.await
.map_err(|e| format!("Failed to open classroom DB: {}", e))?;
let persist = Self {
conn: Arc::new(Mutex::new(conn)),
};
persist.init_schema().await?;
Ok(persist)
}
async fn init_schema(&self) -> Result<(), String> {
let mut conn = self.conn.lock().await;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS classrooms (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS classroom_chats (
classroom_id TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"#,
)
.execute(&mut *conn)
.await
.map_err(|e| format!("Failed to create classroom schema: {}", e))?;
Ok(())
}
/// Save a classroom to SQLite.
pub async fn save_classroom(&self, classroom: &Classroom) -> Result<(), String> {
let mut conn = self.conn.lock().await;
let data = serde_json::to_string(classroom)
.map_err(|e| format!("Failed to serialize classroom: {}", e))?;
sqlx::query(
"INSERT OR REPLACE INTO classrooms (id, data) VALUES (?, ?)"
)
.bind(&classroom.id)
.bind(&data)
.execute(&mut *conn)
.await
.map_err(|e| format!("Failed to save classroom: {}", e))?;
Ok(())
}
/// Save chat state for a classroom.
pub async fn save_chat(&self, classroom_id: &str, messages: &[ClassroomChatMessage]) -> Result<(), String> {
let mut conn = self.conn.lock().await;
let data = serde_json::to_string(messages)
.map_err(|e| format!("Failed to serialize chat messages: {}", e))?;
sqlx::query(
"INSERT OR REPLACE INTO classroom_chats (classroom_id, data) VALUES (?, ?)"
)
.bind(classroom_id)
.bind(&data)
.execute(&mut *conn)
.await
.map_err(|e| format!("Failed to save chat: {}", e))?;
Ok(())
}
/// Delete a classroom and its chat history.
#[allow(dead_code)]
pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> {
let mut conn = self.conn.lock().await;
sqlx::query("DELETE FROM classrooms WHERE id = ?")
.bind(classroom_id)
.execute(&mut *conn)
.await
.map_err(|e| format!("Failed to delete classroom: {}", e))?;
sqlx::query("DELETE FROM classroom_chats WHERE classroom_id = ?")
.bind(classroom_id)
.execute(&mut *conn)
.await
.map_err(|e| format!("Failed to delete chat: {}", e))?;
Ok(())
}
/// Load all persisted classrooms into the in-memory store.
pub async fn load_all(&self, store: &ClassroomStore, chat_store: &ChatStore) -> Result<usize, String> {
let mut conn = self.conn.lock().await;
// Load classrooms
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT id, data FROM classrooms"
)
.fetch_all(&mut *conn)
.await
.map_err(|e| format!("Failed to load classrooms: {}", e))?;
let mut loaded = 0;
let mut s = store.lock().await;
for (id, data) in &rows {
match serde_json::from_str::<Classroom>(data) {
Ok(classroom) => {
s.insert(id.clone(), classroom);
loaded += 1;
}
Err(e) => {
tracing::warn!("Failed to deserialize classroom {}: {}", id, e);
}
}
}
drop(s);
// Load chat history
let chat_rows: Vec<(String, String)> = sqlx::query_as(
"SELECT classroom_id, data FROM classroom_chats"
)
.fetch_all(&mut *conn)
.await
.map_err(|e| format!("Failed to load chats: {}", e))?;
let mut cs = chat_store.lock().await;
for (classroom_id, data) in &chat_rows {
match serde_json::from_str::<Vec<ClassroomChatMessage>>(data) {
Ok(messages) => {
cs.insert(classroom_id.clone(), ClassroomChatState {
messages,
active: true,
});
}
Err(e) => {
tracing::warn!("Failed to deserialize chat for {}: {}", classroom_id, e);
}
}
}
Ok(loaded)
}
}