mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
新增看板模块:支持多看板多SQL学生列表与预设模板
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
use sqlx::{Column, Row, SqlitePool};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use crate::services::settings::{SettingsKey, SettingsValue};
|
||||
use crate::services::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BoardSqlQueryParams {
|
||||
pub sql: String,
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum QueryBackend {
|
||||
Sqlite,
|
||||
Postgres,
|
||||
}
|
||||
|
||||
fn check_view_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>) -> bool {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
let id = sender_id.unwrap_or(0);
|
||||
permissions.require_permission(id, PermissionLevel::View)
|
||||
}
|
||||
|
||||
fn contains_forbidden_keyword(sql: &str) -> bool {
|
||||
let forbidden: HashSet<&str> = [
|
||||
"insert", "update", "delete", "drop", "alter", "create", "truncate", "reindex", "vacuum",
|
||||
"grant", "revoke", "commit", "rollback", "begin", "attach", "detach", "pragma", "analyze",
|
||||
"merge", "call", "execute",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut token = String::new();
|
||||
for ch in sql.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
token.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !token.is_empty() {
|
||||
if forbidden.contains(token.as_str()) {
|
||||
return true;
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
}
|
||||
|
||||
!token.is_empty() && forbidden.contains(token.as_str())
|
||||
}
|
||||
|
||||
fn is_readonly_query(sql: &str) -> bool {
|
||||
let trimmed = sql.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if trimmed.contains(';')
|
||||
|| trimmed.contains("--")
|
||||
|| trimmed.contains("/*")
|
||||
|| trimmed.contains("*/")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if !(lower.starts_with("select ") || lower.starts_with("with ")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!contains_forbidden_keyword(&lower)
|
||||
}
|
||||
|
||||
fn parse_limit(limit: Option<u64>) -> u64 {
|
||||
match limit {
|
||||
Some(v) if v > 0 => v.min(500),
|
||||
_ => 200,
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_db_path(app_handle: &AppHandle) -> Result<String, String> {
|
||||
if cfg!(all(debug_assertions, desktop)) {
|
||||
return Ok("data.sql".to_string());
|
||||
}
|
||||
|
||||
let app_data_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Failed to get app data directory: {}", e))?;
|
||||
let data_dir = app_data_dir.join("data");
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
let db_path = data_dir.join("data.sql");
|
||||
db_path
|
||||
.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid sqlite database path".to_string())
|
||||
}
|
||||
|
||||
fn decode_cell_sqlite(row: &sqlx::sqlite::SqliteRow, index: usize) -> JsonValue {
|
||||
if let Ok(v) = row.try_get::<Option<i64>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<i32>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<f64>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<bool>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<String>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
|
||||
JsonValue::Null
|
||||
}
|
||||
|
||||
fn row_to_json_sqlite(row: &sqlx::sqlite::SqliteRow) -> JsonValue {
|
||||
let mut map = Map::new();
|
||||
for (index, column) in row.columns().iter().enumerate() {
|
||||
map.insert(column.name().to_string(), decode_cell_sqlite(row, index));
|
||||
}
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
|
||||
fn decode_cell_pg(row: &sqlx::postgres::PgRow, index: usize) -> JsonValue {
|
||||
if let Ok(v) = row.try_get::<Option<i64>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<i32>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<f64>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<bool>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<String>, _>(index) {
|
||||
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||
}
|
||||
|
||||
JsonValue::Null
|
||||
}
|
||||
|
||||
fn row_to_json_pg(row: &sqlx::postgres::PgRow) -> JsonValue {
|
||||
let mut map = Map::new();
|
||||
for (index, column) in row.columns().iter().enumerate() {
|
||||
map.insert(column.name().to_string(), decode_cell_pg(row, index));
|
||||
}
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
|
||||
async fn query_sqlite(sql: &str, sqlite_path: &str) -> Result<Vec<JsonValue>, String> {
|
||||
let sqlite_url = format!("sqlite://{}?mode=rwc", sqlite_path);
|
||||
let pool = SqlitePool::connect(&sqlite_url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect sqlite: {}", e))?;
|
||||
|
||||
let rows = sqlx::query(sql)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("SQL query failed: {}", e))?;
|
||||
|
||||
let data = rows.iter().map(row_to_json_sqlite).collect();
|
||||
pool.close().await;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
async fn query_postgres(sql: &str, pg_url: &str) -> Result<Vec<JsonValue>, String> {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(pg_url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect PostgreSQL: {}", e))?;
|
||||
|
||||
let rows = sqlx::query(sql)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("SQL query failed: {}", e))?;
|
||||
|
||||
let data = rows.iter().map(row_to_json_pg).collect();
|
||||
pool.close().await;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn board_query_sql(
|
||||
params: BoardSqlQueryParams,
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<JsonValue>>, String> {
|
||||
if !check_view_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: view required"));
|
||||
}
|
||||
|
||||
if !is_readonly_query(¶ms.sql) {
|
||||
return Ok(IpcResponse::error(
|
||||
"Only single read-only SELECT/CTE query is allowed",
|
||||
));
|
||||
}
|
||||
|
||||
let limit = parse_limit(params.limit);
|
||||
let wrapped_sql = format!(
|
||||
"SELECT * FROM ({}) AS ss_board_query LIMIT {}",
|
||||
params.sql.trim(),
|
||||
limit
|
||||
);
|
||||
|
||||
let (backend, sqlite_path, pg_url) = {
|
||||
let state_guard = state.read();
|
||||
let db_conn = state_guard.db.read().clone();
|
||||
let app_handle = state_guard.app_handle.clone();
|
||||
let mut settings = state_guard.settings.write();
|
||||
|
||||
settings.attach_db(db_conn);
|
||||
settings.initialize().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let status = match settings.get_value(SettingsKey::PgConnectionStatus) {
|
||||
SettingsValue::Json(v) => v,
|
||||
_ => serde_json::json!({"connected": false, "type": "sqlite"}),
|
||||
};
|
||||
|
||||
let connected = status
|
||||
.get("connected")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let db_type = status
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("sqlite");
|
||||
|
||||
let backend = if connected && db_type == "postgresql" {
|
||||
QueryBackend::Postgres
|
||||
} else {
|
||||
QueryBackend::Sqlite
|
||||
};
|
||||
|
||||
let pg_url = match settings.get_value(SettingsKey::PgConnectionString) {
|
||||
SettingsValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let sqlite_path = sqlite_db_path(&app_handle)?;
|
||||
(backend, sqlite_path, pg_url)
|
||||
};
|
||||
|
||||
let rows = match backend {
|
||||
QueryBackend::Postgres if !pg_url.trim().is_empty() => {
|
||||
query_postgres(&wrapped_sql, &pg_url).await
|
||||
}
|
||||
_ => query_sqlite(&wrapped_sql, &sqlite_path).await,
|
||||
};
|
||||
|
||||
match rows {
|
||||
Ok(data) => Ok(IpcResponse::success(data)),
|
||||
Err(err) => Ok(IpcResponse::error(&err)),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod auto_score;
|
||||
pub mod board;
|
||||
pub mod data;
|
||||
pub mod database;
|
||||
pub mod event;
|
||||
@@ -8,8 +9,8 @@ pub mod filesystem;
|
||||
pub mod http_server;
|
||||
pub mod log;
|
||||
pub mod reason;
|
||||
pub mod reward;
|
||||
pub mod response;
|
||||
pub mod reward;
|
||||
pub mod settings;
|
||||
pub mod settlement;
|
||||
pub mod student;
|
||||
@@ -20,6 +21,7 @@ pub mod window;
|
||||
pub use app::*;
|
||||
pub use auth::*;
|
||||
pub use auto_score::*;
|
||||
pub use board::*;
|
||||
pub use data::*;
|
||||
pub use database::*;
|
||||
pub use event::*;
|
||||
@@ -27,8 +29,8 @@ pub use filesystem::*;
|
||||
pub use http_server::*;
|
||||
pub use log::*;
|
||||
pub use reason::*;
|
||||
pub use reward::*;
|
||||
pub use response::*;
|
||||
pub use reward::*;
|
||||
pub use settings::*;
|
||||
pub use settlement::*;
|
||||
pub use student::*;
|
||||
|
||||
+15
-11
@@ -5,15 +5,13 @@ pub mod services;
|
||||
pub mod state;
|
||||
pub mod utils;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use crate::db::connection::{create_postgres_connection, create_sqlite_connection};
|
||||
use crate::db::connection::DatabaseType;
|
||||
use crate::db::connection::{create_postgres_connection, create_sqlite_connection};
|
||||
use crate::db::migration::run_migration;
|
||||
use crate::services::settings::{SettingsKey, SettingsValue};
|
||||
use crate::{commands::*, state::AppState};
|
||||
use tauri::{App, Manager};
|
||||
use tokio::time::{timeout, Duration};
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
#[cfg(desktop)]
|
||||
use tauri::{
|
||||
image::Image,
|
||||
@@ -21,6 +19,8 @@ use tauri::{
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
WindowEvent,
|
||||
};
|
||||
use tauri::{App, Manager};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -83,6 +83,7 @@ pub fn run() {
|
||||
auto_score_toggle_rule,
|
||||
auto_score_get_status,
|
||||
auto_score_sort_rules,
|
||||
board_query_sql,
|
||||
log_query,
|
||||
log_clear,
|
||||
log_set_level,
|
||||
@@ -149,10 +150,7 @@ fn setup_database(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
data_dir.join("data.sql")
|
||||
};
|
||||
|
||||
let db_path_str = db_path
|
||||
.to_str()
|
||||
.ok_or("Invalid database path")?
|
||||
.to_string();
|
||||
let db_path_str = db_path.to_str().ok_or("Invalid database path")?.to_string();
|
||||
|
||||
let db_result = tauri::async_runtime::block_on(async {
|
||||
let sqlite_conn = create_sqlite_connection(&db_path_str).await?;
|
||||
@@ -237,7 +235,10 @@ fn setup_database(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.map_err(|err| format!("Failed to save pg status: {}", err))?;
|
||||
} else if let Ok(Err(e)) = migration_result {
|
||||
eprintln!("PostgreSQL migration failed on startup, fallback to sqlite: {}", e);
|
||||
eprintln!(
|
||||
"PostgreSQL migration failed on startup, fallback to sqlite: {}",
|
||||
e
|
||||
);
|
||||
settings
|
||||
.set_value(
|
||||
SettingsKey::PgConnectionStatus,
|
||||
@@ -290,7 +291,10 @@ fn setup_database(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Err(e) = db_result {
|
||||
eprintln!("Failed to connect to database: {}", e);
|
||||
} else {
|
||||
eprintln!("Database bootstrap completed, sqlite settings db: {}", db_path_str);
|
||||
eprintln!(
|
||||
"Database bootstrap completed, sqlite settings db: {}",
|
||||
db_path_str
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -14,6 +14,7 @@ pub struct SettingsSpec {
|
||||
pub auto_score_enabled: bool,
|
||||
pub auto_score_rules: JsonValue,
|
||||
pub current_theme_id: String,
|
||||
pub dashboards_config: JsonValue,
|
||||
pub pg_connection_string: String,
|
||||
pub pg_connection_status: JsonValue,
|
||||
}
|
||||
@@ -30,6 +31,7 @@ impl Default for SettingsSpec {
|
||||
auto_score_enabled: false,
|
||||
auto_score_rules: JsonValue::Array(vec![]),
|
||||
current_theme_id: "light-default".to_string(),
|
||||
dashboards_config: JsonValue::Array(vec![]),
|
||||
pg_connection_string: String::new(),
|
||||
pg_connection_status: serde_json::json!({"connected": false, "type": "sqlite"}),
|
||||
}
|
||||
@@ -47,6 +49,7 @@ pub enum SettingsKey {
|
||||
AutoScoreEnabled,
|
||||
AutoScoreRules,
|
||||
CurrentThemeId,
|
||||
DashboardsConfig,
|
||||
PgConnectionString,
|
||||
PgConnectionStatus,
|
||||
}
|
||||
@@ -63,6 +66,7 @@ impl SettingsKey {
|
||||
SettingsKey::AutoScoreEnabled => "auto_score_enabled",
|
||||
SettingsKey::AutoScoreRules => "auto_score_rules",
|
||||
SettingsKey::CurrentThemeId => "current_theme_id",
|
||||
SettingsKey::DashboardsConfig => "dashboards_config",
|
||||
SettingsKey::PgConnectionString => "pg_connection_string",
|
||||
SettingsKey::PgConnectionStatus => "pg_connection_status",
|
||||
}
|
||||
@@ -79,6 +83,7 @@ impl SettingsKey {
|
||||
"auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled),
|
||||
"auto_score_rules" => Some(SettingsKey::AutoScoreRules),
|
||||
"current_theme_id" => Some(SettingsKey::CurrentThemeId),
|
||||
"dashboards_config" => Some(SettingsKey::DashboardsConfig),
|
||||
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
||||
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
|
||||
_ => None,
|
||||
@@ -346,6 +351,16 @@ impl SettingsService {
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::DashboardsConfig,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::Json,
|
||||
default_value: SettingsValue::Json(JsonValue::Array(vec![])),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::PgConnectionString,
|
||||
SettingDefinition {
|
||||
@@ -494,6 +509,10 @@ impl SettingsService {
|
||||
SettingsValue::String(s) => s,
|
||||
_ => "light-default".to_string(),
|
||||
},
|
||||
dashboards_config: match self.get_value(SettingsKey::DashboardsConfig) {
|
||||
SettingsValue::Json(j) => j,
|
||||
_ => JsonValue::Array(vec![]),
|
||||
},
|
||||
pg_connection_string: match self.get_value(SettingsKey::PgConnectionString) {
|
||||
SettingsValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
|
||||
Reference in New Issue
Block a user