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
- runtime: 移除未使用的 SessionId/Datelike import,修复 unused variable - intelligence: 模块级 #![allow(dead_code)] 抑制 Hermes 预留代码警告 - mcp.rs/persist.rs/nl_schedule.rs: 标注 #[allow(dead_code)] 保留接口
173 lines
6.0 KiB
Rust
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)
|
|
}
|
|
}
|