diff --git a/src-tauri/data.sql b/src-tauri/data.sql index e03ea45..960f453 100644 Binary files a/src-tauri/data.sql and b/src-tauri/data.sql differ diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 097a4b5..c6cab7c 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -335,7 +335,10 @@ pub async fn oauth_get_authorization_url( use rand::Rng; let mut rng = rand::thread_rng(); let random_bytes: Vec = (0..32).map(|_| rng.gen()).collect(); - base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &random_bytes) + base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + &random_bytes, + ) }); let url = format!( @@ -345,7 +348,10 @@ pub async fn oauth_get_authorization_url( urlencoding::encode(&state) ); - Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { url, state })) + Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { + url, + state, + })) } #[tauri::command] @@ -355,8 +361,11 @@ pub async fn oauth_exchange_code( platform_secret: String, callback_url: String, ) -> Result, String> { - println!("[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", code, platform_id, callback_url); - + println!( + "[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", + code, platform_id, callback_url + ); + let client = reqwest::Client::new(); let params = [ ("grant_type", "authorization_code"), @@ -365,9 +374,9 @@ pub async fn oauth_exchange_code( ("client_secret", &platform_secret), ("redirect_uri", &callback_url), ]; - + println!("[OAuth] 请求参数:{:?}", params); - + let response = client .post("https://sectl.top/api/oauth/token") .form(¶ms) @@ -376,8 +385,11 @@ pub async fn oauth_exchange_code( .map_err(|e| format!("Request failed: {}", e))?; println!("[OAuth] 响应状态:{}", response.status()); - - let response_text = response.text().await.map_err(|e| format!("Failed to read response: {}", e))?; + + let response_text = response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e))?; println!("[OAuth] 响应内容:{}", response_text); if !response_text.is_empty() && response_text.contains("error") { diff --git a/src-tauri/src/commands/filesystem.rs b/src-tauri/src/commands/filesystem.rs index 537c0fb..7a50197 100644 --- a/src-tauri/src/commands/filesystem.rs +++ b/src-tauri/src/commands/filesystem.rs @@ -3,6 +3,7 @@ use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use std::process::Command; use std::sync::Arc; use tauri::State; @@ -284,3 +285,55 @@ pub async fn fs_file_exists( Ok(IpcResponse::success(file_path.exists())) } + +#[tauri::command] +pub async fn fs_open_path( + relative_path: String, + folder: String, + _state: State<'_, Arc>>, +) -> Result, String> { + let folder_type = ConfigFolder::from_str(&folder) + .ok_or_else(|| format!("Invalid folder type: {}", folder))?; + + let folder_path = ensure_folder_exists(&folder_type)?; + let file_path = folder_path.join(&relative_path); + + if !file_path.exists() { + return Ok(IpcResponse::error("File not found")); + } + + open_path_with_system(&file_path)?; + Ok(IpcResponse::success_empty()) +} + +fn open_path_with_system(path: &PathBuf) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", "start", "", path.to_string_lossy().as_ref()]) + .spawn() + .map_err(|e| e.to_string())?; + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(path) + .spawn() + .map_err(|e| e.to_string())?; + return Ok(()); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + Command::new("xdg-open") + .arg(path) + .spawn() + .map_err(|e| e.to_string())?; + return Ok(()); + } + + #[allow(unreachable_code)] + Err("Unsupported platform".to_string()) +} diff --git a/src-tauri/src/commands/oauth_server.rs b/src-tauri/src/commands/oauth_server.rs index a1e2a41..0731bb4 100644 --- a/src-tauri/src/commands/oauth_server.rs +++ b/src-tauri/src/commands/oauth_server.rs @@ -1,342 +1,345 @@ -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use std::net::SocketAddr; -use std::sync::Arc; -use tauri::Emitter; -use tauri::State; -use tokio::sync::Mutex; -use tokio::sync::oneshot; - -use crate::state::AppState; - -use super::response::IpcResponse; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthServerStartResult { - pub url: String, - pub port: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthCallbackResult { - pub code: Option, - pub state: Option, - pub error: Option, - pub error_description: Option, -} - -static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy>>>> = - once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None))); - -#[tauri::command] -pub async fn oauth_start_callback_server( - app_handle: tauri::AppHandle, - _state: State<'_, Arc>>, -) -> Result, String> { - let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; - - // 如果服务器已经在运行,直接返回 URL - if shutdown_tx.is_some() { - let port = 16888u16; - let url = format!("http://127.0.0.1:{}/oauth/callback", port); - return Ok(IpcResponse::success(OAuthServerStartResult { url, port })); - } - - let port = 16888u16; - let addr: SocketAddr = ([127, 0, 0, 1], port).into(); - let url = format!("http://127.0.0.1:{}/oauth/callback", port); - let url_for_spawn = url.clone(); - - let (tx, rx) = oneshot::channel::<()>(); - *shutdown_tx = Some(tx); - drop(shutdown_tx); - - let app_handle_clone = app_handle.clone(); - - tokio::spawn(async move { - let app = axum::Router::new() - .route("/oauth/callback", axum::routing::get(handle_oauth_callback)) - .layer(axum::extract::Extension(app_handle_clone)); - - let listener = match tokio::net::TcpListener::bind(addr).await { - Ok(l) => l, - Err(e) => { - eprintln!("Failed to bind OAuth callback server: {}", e); - return; - } - }; - - println!("OAuth callback server started at {}", url_for_spawn); - - let server = axum::serve(listener, app); - - tokio::select! { - _ = server => {}, - _ = rx => { - println!("OAuth callback server shutting down"); - } - } - }); - - Ok(IpcResponse::success(OAuthServerStartResult { url, port })) -} - -#[tauri::command] -pub async fn oauth_stop_callback_server( - _state: State<'_, Arc>>, -) -> Result, String> { - let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; - - if let Some(tx) = shutdown_tx.take() { - let _ = tx.send(()); - } - - Ok(IpcResponse::success(())) -} - -async fn handle_oauth_callback( - axum::extract::Query(params): axum::extract::Query>, - axum::extract::Extension(app_handle): axum::extract::Extension, -) -> impl axum::response::IntoResponse { - let code = params.get("code").cloned(); - let state = params.get("state").cloned(); - let error = params.get("error").cloned(); - let error_description = params.get("error_description").cloned(); - - println!("[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", code, state, error); - - let result = OAuthCallbackResult { - code: code.clone(), - state: state.clone(), - error: error.clone(), - error_description: error_description.clone(), - }; - - match app_handle.emit("oauth-callback", result) { - Ok(_) => println!("[OAuth Callback] Event 发送成功"), - Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e), - } - - let html = r#" - - - - - OAuth 授权完成 - - - -
-
- - - -
-

授权成功

-

请返回 SecScore 应用查看登录结果

-

此窗口可以关闭

-
- - - - "#; - - let error_html = r#" - - - - - OAuth 授权失败 - - - -
-
- - - -
-

授权失败

-

请返回 SecScore 应用查看错误信息

-

此窗口可以关闭

-
- - - "#; - - let response_html = if error.is_some() { error_html } else { html }; - - ( - axum::http::StatusCode::OK, - [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], - response_html, - ) -} +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::sync::Arc; +use tauri::Emitter; +use tauri::State; +use tokio::sync::oneshot; +use tokio::sync::Mutex; + +use crate::state::AppState; + +use super::response::IpcResponse; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthServerStartResult { + pub url: String, + pub port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthCallbackResult { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy>>>> = + once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None))); + +#[tauri::command] +pub async fn oauth_start_callback_server( + app_handle: tauri::AppHandle, + _state: State<'_, Arc>>, +) -> Result, String> { + let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; + + // 如果服务器已经在运行,直接返回 URL + if shutdown_tx.is_some() { + let port = 16888u16; + let url = format!("http://127.0.0.1:{}/oauth/callback", port); + return Ok(IpcResponse::success(OAuthServerStartResult { url, port })); + } + + let port = 16888u16; + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let url = format!("http://127.0.0.1:{}/oauth/callback", port); + let url_for_spawn = url.clone(); + + let (tx, rx) = oneshot::channel::<()>(); + *shutdown_tx = Some(tx); + drop(shutdown_tx); + + let app_handle_clone = app_handle.clone(); + + tokio::spawn(async move { + let app = axum::Router::new() + .route("/oauth/callback", axum::routing::get(handle_oauth_callback)) + .layer(axum::extract::Extension(app_handle_clone)); + + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("Failed to bind OAuth callback server: {}", e); + return; + } + }; + + println!("OAuth callback server started at {}", url_for_spawn); + + let server = axum::serve(listener, app); + + tokio::select! { + _ = server => {}, + _ = rx => { + println!("OAuth callback server shutting down"); + } + } + }); + + Ok(IpcResponse::success(OAuthServerStartResult { url, port })) +} + +#[tauri::command] +pub async fn oauth_stop_callback_server( + _state: State<'_, Arc>>, +) -> Result, String> { + let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; + + if let Some(tx) = shutdown_tx.take() { + let _ = tx.send(()); + } + + Ok(IpcResponse::success(())) +} + +async fn handle_oauth_callback( + axum::extract::Query(params): axum::extract::Query>, + axum::extract::Extension(app_handle): axum::extract::Extension, +) -> impl axum::response::IntoResponse { + let code = params.get("code").cloned(); + let state = params.get("state").cloned(); + let error = params.get("error").cloned(); + let error_description = params.get("error_description").cloned(); + + println!( + "[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", + code, state, error + ); + + let result = OAuthCallbackResult { + code: code.clone(), + state: state.clone(), + error: error.clone(), + error_description: error_description.clone(), + }; + + match app_handle.emit("oauth-callback", result) { + Ok(_) => println!("[OAuth Callback] Event 发送成功"), + Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e), + } + + let html = r#" + + + + + OAuth 授权完成 + + + +
+
+ + + +
+

授权成功

+

请返回 SecScore 应用查看登录结果

+

此窗口可以关闭

+
+ + + + "#; + + let error_html = r#" + + + + + OAuth 授权失败 + + + +
+
+ + + +
+

授权失败

+

请返回 SecScore 应用查看错误信息

+

此窗口可以关闭

+
+ + + "#; + + let response_html = if error.is_some() { error_html } else { html }; + + ( + axum::http::StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + response_html, + ) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72273fd..06a194e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -126,6 +126,7 @@ pub fn run() { fs_delete_file, fs_list_files, fs_file_exists, + fs_open_path, http_server_start, http_server_stop, http_server_status, diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index b3dcdf2..2ecb225 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -1,10 +1,23 @@ use chrono::{DateTime, Months, Utc}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, EntityTrait, + QueryFilter, Set, Statement, TransactionTrait, +}; use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; +use serde_json::{json, Value as JsonValue}; use std::collections::HashSet; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::time::{interval, Duration}; +use uuid::Uuid; + +use crate::db::entities::{score_events, student_tags, students, tags}; +use crate::services::settings::{SettingsKey, SettingsValue}; +use crate::state::SafeAppState; const DEFAULT_INTERVAL_MINUTES: i64 = 30; +const AUTO_SCORE_TICK_SECONDS: u64 = 15; +const AUTO_SCORE_SQL_LIMIT: u64 = 5000; +const AUTO_SCORE_REASON_PREFIX: &str = "自动化"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AutoScoreTrigger { @@ -45,6 +58,37 @@ pub struct AutoScoreRule { pub last_executed: Option, } +#[derive(Debug, Clone)] +enum PlannedAction { + AddScore(i32), + AddTags(Vec), +} + +#[derive(Debug, Default, Clone)] +struct StudentRefs { + ids: HashSet, + names: HashSet, +} + +#[derive(Debug, Default, Clone, Copy)] +struct RuleExecutionStats { + affected_students: usize, + created_events: usize, + added_tags: usize, +} + +impl StudentRefs { + fn is_empty(&self) -> bool { + self.ids.is_empty() && self.names.is_empty() + } + + fn intersect(mut self, other: StudentRefs) -> StudentRefs { + self.ids.retain(|id| other.ids.contains(id)); + self.names.retain(|name| other.names.contains(name)); + self + } +} + impl Default for AutoScoreRule { fn default() -> Self { Self { @@ -87,12 +131,14 @@ impl AutoScoreService { pub async fn initialize( &mut self, - _app_handle: &AppHandle, + app_handle: &AppHandle, ) -> Result<(), Box> { if self.initialized { return Ok(()); } + self.initialized = true; + Self::spawn_scheduler(app_handle.clone()); Ok(()) } @@ -211,38 +257,137 @@ impl AutoScoreService { } pub fn check_interval_trigger(&self, rule: &AutoScoreRule) -> Option { - let now = Utc::now(); - - for trigger in &rule.triggers { - if trigger.event != "interval_time_passed" { - continue; - } - - let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or( - IntervalTriggerValue { - amount: DEFAULT_INTERVAL_MINUTES, - unit: IntervalUnit::Minute, - }, - ); - - let base_time = rule - .last_executed - .as_ref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - .unwrap_or(now); - - let next_execute_time = add_interval_to_time(base_time, &interval)?; - let delay_ms = (next_execute_time - now).num_milliseconds(); - return Some(delay_ms.max(0)); - } - - None + check_interval_trigger(rule) } pub async fn notify_rules_changed(&self, app_handle: &AppHandle) { let _ = app_handle.emit("auto-score:rulesChanged", &self.rules); } + + fn spawn_scheduler(app_handle: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut ticker = interval(Duration::from_secs(AUTO_SCORE_TICK_SECONDS)); + loop { + ticker.tick().await; + if let Err(error) = Self::run_scheduler_tick(&app_handle).await { + eprintln!("auto score scheduler tick failed: {}", error); + } + } + }); + } + + async fn run_scheduler_tick(app_handle: &AppHandle) -> Result<(), String> { + let state = app_handle.state::().inner().clone(); + + let rules_snapshot = { + let state_guard = state.read(); + let auto_score = state_guard.auto_score.read(); + auto_score.get_rules().to_vec() + }; + + if !rules_snapshot.iter().any(|rule| rule.enabled) { + return Ok(()); + } + + let conn = { + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + db_conn + } + .ok_or_else(|| "Database not connected".to_string())?; + + let mut next_rules = rules_snapshot.clone(); + let mut changed = false; + + for rule in next_rules.iter_mut().filter(|rule| rule.enabled) { + let Some(delay_ms) = check_interval_trigger(rule) else { + continue; + }; + if delay_ms > 0 { + continue; + } + + match execute_rule(&conn, rule).await { + Ok(stats) => { + if stats.affected_students == 0 { + Self::log_rule_skipped(&state, rule, "no matched students"); + continue; + } + rule.last_executed = Some(Utc::now().to_rfc3339()); + changed = true; + Self::log_rule_executed(&state, rule, stats); + } + Err(error) => { + Self::log_rule_failed(&state, rule, &error); + } + } + } + + if !changed { + return Ok(()); + } + + persist_rules_to_settings(&state, &next_rules).await?; + + { + let state_guard = state.read(); + let mut auto_score = state_guard.auto_score.write(); + auto_score.replace_rules(next_rules.clone()); + } + + let enabled = next_rules.iter().any(|rule| rule.enabled); + let _ = app_handle.emit("auto-score:rulesChanged", &next_rules); + let _ = app_handle.emit( + "settings:changed", + json!({ + "key": "auto_score_enabled", + "value": enabled, + }), + ); + + Ok(()) + } + + fn log_rule_executed(state: &SafeAppState, rule: &AutoScoreRule, stats: RuleExecutionStats) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.info_with_meta( + "auto_score:rule_executed", + json!({ + "rule_id": rule.id, + "rule_name": rule.name, + "affected_students": stats.affected_students, + "created_events": stats.created_events, + "added_tags": stats.added_tags, + }), + ); + } + + fn log_rule_failed(state: &SafeAppState, rule: &AutoScoreRule, error: &str) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.warn_with_meta( + "auto_score:rule_failed", + json!({ + "rule_id": rule.id, + "rule_name": rule.name, + "error": error, + }), + ); + } + + fn log_rule_skipped(state: &SafeAppState, rule: &AutoScoreRule, reason: &str) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.info_with_meta( + "auto_score:rule_skipped", + json!({ + "rule_id": rule.id, + "rule_name": rule.name, + "reason": reason, + }), + ); + } } fn normalize_rule(mut rule: AutoScoreRule) -> Result { @@ -296,6 +441,21 @@ fn normalize_trigger(trigger: AutoScoreTrigger) -> Result { + let raw_sql = trigger + .value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "SQL trigger requires non-empty SQL".to_string())?; + if !is_valid_auto_score_sql(&raw_sql) { + return Err("Only read-only SQL expression/query is allowed".to_string()); + } + + Ok(AutoScoreTrigger { + event, + value: Some(raw_sql), + }) + } _ => Err(format!("Unsupported trigger event: {}", event)), } } @@ -305,8 +465,8 @@ fn normalize_action(action: AutoScoreAction) -> Result match event.as_str() { "add_score" => { - let value = normalize_non_zero_numeric_string(action.value.as_deref()) - .ok_or_else(|| "Add score action requires a non-zero numeric value".to_string())?; + let value = normalize_non_zero_integer_string(action.value.as_deref()) + .ok_or_else(|| "Add score action requires a non-zero integer value".to_string())?; Ok(AutoScoreAction { event, @@ -384,18 +544,18 @@ fn stringify_tag_values(values: &[String]) -> Option { serde_json::to_string(values).ok() } -fn normalize_non_zero_numeric_string(value: Option<&str>) -> Option { +fn normalize_non_zero_integer_string(value: Option<&str>) -> Option { let normalized = value?.trim(); if normalized.is_empty() { return None; } - let parsed = normalized.parse::().ok()?; - if !parsed.is_finite() || parsed == 0.0 { + let parsed = normalized.parse::().ok()?; + if parsed == 0 { return None; } - Some(normalized.to_string()) + Some(parsed.to_string()) } fn normalize_last_executed(value: Option) -> Option { @@ -454,3 +614,519 @@ fn add_interval_to_time( } } } + +fn check_interval_trigger(rule: &AutoScoreRule) -> Option { + let now = Utc::now(); + + for trigger in &rule.triggers { + if trigger.event != "interval_time_passed" { + continue; + } + + let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or( + IntervalTriggerValue { + amount: DEFAULT_INTERVAL_MINUTES, + unit: IntervalUnit::Minute, + }, + ); + + let Some(base_time) = rule + .last_executed + .as_ref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)) + else { + return Some(0); + }; + + let next_execute_time = add_interval_to_time(base_time, &interval)?; + let delay_ms = (next_execute_time - now).num_milliseconds(); + return Some(delay_ms.max(0)); + } + + None +} + +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 starts_with_select_or_with(sql: &str) -> bool { + sql.split_whitespace() + .next() + .map(|first| { + let first_lower = first.to_ascii_lowercase(); + first_lower == "select" || first_lower == "with" + }) + .unwrap_or(false) +} + +fn has_sql_comment_or_multi_statement(sql: &str) -> bool { + sql.contains(';') || sql.contains("--") || sql.contains("/*") || sql.contains("*/") +} + +fn is_readonly_query(sql: &str) -> bool { + let trimmed = sql.trim(); + if trimmed.is_empty() { + return false; + } + + if has_sql_comment_or_multi_statement(trimmed) { + return false; + } + + if !starts_with_select_or_with(trimmed) { + return false; + } + + !contains_forbidden_keyword(&trimmed.to_ascii_lowercase()) +} + +fn is_safe_sql_expression(sql: &str) -> bool { + let trimmed = sql.trim(); + if trimmed.is_empty() { + return false; + } + + if has_sql_comment_or_multi_statement(trimmed) { + return false; + } + + !contains_forbidden_keyword(&trimmed.to_ascii_lowercase()) +} + +fn is_valid_auto_score_sql(sql: &str) -> bool { + let trimmed = sql.trim(); + if trimmed.is_empty() { + return false; + } + + if starts_with_select_or_with(trimmed) { + is_readonly_query(trimmed) + } else { + is_safe_sql_expression(trimmed) + } +} + +fn build_readonly_student_query(sql_or_expression: &str) -> Result { + let trimmed = sql_or_expression.trim(); + if trimmed.is_empty() { + return Err("Empty SQL".to_string()); + } + + let normalized = if starts_with_select_or_with(trimmed) { + if !is_readonly_query(trimmed) { + return Err("Only read-only SELECT/CTE query is allowed".to_string()); + } + trimmed.to_string() + } else { + if !is_safe_sql_expression(trimmed) { + return Err("Only safe SQL expression is allowed".to_string()); + } + format!("SELECT id, name FROM students WHERE ({})", trimmed) + }; + + Ok(format!( + "SELECT * FROM ({}) AS ss_auto_score_query LIMIT {}", + normalized, AUTO_SCORE_SQL_LIMIT + )) +} + +async fn persist_rules_to_settings( + state: &SafeAppState, + rules: &[AutoScoreRule], +) -> Result<(), String> { + let rules_json = AutoScoreService::serialize_rules(rules)?; + let enabled = rules.iter().any(|rule| rule.enabled); + + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + let mut settings = state_guard.settings.write(); + settings.attach_db(db_conn); + settings.initialize().await?; + settings + .set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json)) + .await?; + settings + .set_value( + SettingsKey::AutoScoreEnabled, + SettingsValue::Boolean(enabled), + ) + .await?; + + Ok(()) +} + +async fn execute_rule( + conn: &DatabaseConnection, + rule: &AutoScoreRule, +) -> Result { + let mut target_students = resolve_target_students(conn, rule).await?; + + if !rule.student_names.is_empty() { + let whitelist: HashSet = rule + .student_names + .iter() + .map(|name| name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(); + target_students.retain(|student| whitelist.contains(&student.name)); + } + + if target_students.is_empty() { + return Ok(RuleExecutionStats::default()); + } + + let planned_actions = plan_actions(&rule.actions)?; + if planned_actions.is_empty() { + return Err("No executable action".to_string()); + } + + let txn = conn.begin().await.map_err(|e| e.to_string())?; + let mut stats = RuleExecutionStats { + affected_students: target_students.len(), + ..RuleExecutionStats::default() + }; + + let mut all_action_tags = Vec::new(); + for action in &planned_actions { + if let PlannedAction::AddTags(tags) = action { + all_action_tags.extend(tags.iter().cloned()); + } + } + let all_action_tags = dedupe_trimmed_strings(all_action_tags); + + let mut tag_name_to_id = std::collections::HashMap::new(); + for tag_name in all_action_tags { + let tag_id = ensure_tag_exists_in_txn(&txn, &tag_name).await?; + tag_name_to_id.insert(tag_name, tag_id); + } + + for mut student in target_students { + for action in &planned_actions { + match action { + PlannedAction::AddScore(delta) => { + let now = now_iso(); + let val_prev = student.score; + let val_curr = val_prev + delta; + let reward_points = student.reward_points + delta; + + let event = score_events::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + uuid: Set(Uuid::new_v4().to_string()), + student_name: Set(student.name.clone()), + reason_content: Set(build_auto_score_reason(&rule.name, *delta)), + delta: Set(*delta), + val_prev: Set(val_prev), + val_curr: Set(val_curr), + event_time: Set(now.clone()), + settlement_id: Set(None), + }; + event.insert(&txn).await.map_err(|e| e.to_string())?; + stats.created_events += 1; + + let mut student_active: students::ActiveModel = student.clone().into(); + student_active.score = Set(val_curr); + student_active.reward_points = Set(reward_points); + student_active.updated_at = Set(now); + student_active + .update(&txn) + .await + .map_err(|e| e.to_string())?; + + student.score = val_curr; + student.reward_points = reward_points; + } + PlannedAction::AddTags(tag_names) => { + for tag_name in tag_names { + let Some(tag_id) = tag_name_to_id.get(tag_name).copied() else { + continue; + }; + let inserted = + attach_tag_to_student_if_missing(&txn, student.id, tag_id).await?; + if inserted { + stats.added_tags += 1; + } + } + } + } + } + } + + txn.commit().await.map_err(|e| e.to_string())?; + Ok(stats) +} + +fn plan_actions(actions: &[AutoScoreAction]) -> Result, String> { + let mut planned = Vec::new(); + for action in actions { + match action.event.as_str() { + "add_score" => { + let delta = action + .value + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value != 0) + .ok_or_else(|| "Invalid add_score value".to_string())?; + planned.push(PlannedAction::AddScore(delta)); + } + "add_tag" => { + let tags = parse_tag_values(action.value.as_deref()); + if tags.is_empty() { + return Err("Invalid add_tag value".to_string()); + } + planned.push(PlannedAction::AddTags(tags)); + } + _ => {} + } + } + Ok(planned) +} + +async fn resolve_target_students( + conn: &DatabaseConnection, + rule: &AutoScoreRule, +) -> Result, String> { + let explicit_sql_values: Vec = rule + .triggers + .iter() + .filter(|trigger| { + matches!( + trigger.event.as_str(), + "query_sql" | "student_query_sql" | "student_sql" + ) + }) + .filter_map(|trigger| trigger.value.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect(); + + if !explicit_sql_values.is_empty() { + let mut refs: Option = None; + for sql_value in explicit_sql_values { + let next_refs = query_student_refs_by_sql(conn, &sql_value).await?; + refs = Some(match refs { + None => next_refs, + Some(existing) => existing.intersect(next_refs), + }); + } + + let refs = refs.unwrap_or_default(); + if refs.is_empty() { + return Ok(vec![]); + } + return load_students_from_refs(conn, refs).await; + } + + let tag_filters: Vec = rule + .triggers + .iter() + .filter(|trigger| trigger.event == "student_has_tag") + .flat_map(|trigger| parse_tag_values(trigger.value.as_deref())) + .collect(); + + let tag_filters = dedupe_trimmed_strings(tag_filters); + if tag_filters.is_empty() { + return students::Entity::find() + .all(conn) + .await + .map_err(|e| e.to_string()); + } + + let tag_models = tags::Entity::find() + .filter(tags::Column::Name.is_in(tag_filters)) + .all(conn) + .await + .map_err(|e| e.to_string())?; + if tag_models.is_empty() { + return Ok(vec![]); + } + + let tag_ids: Vec = tag_models.iter().map(|tag| tag.id).collect(); + let links = student_tags::Entity::find() + .filter(student_tags::Column::TagId.is_in(tag_ids)) + .all(conn) + .await + .map_err(|e| e.to_string())?; + if links.is_empty() { + return Ok(vec![]); + } + + let student_ids: HashSet = links.iter().map(|link| link.student_id).collect(); + students::Entity::find() + .filter(students::Column::Id.is_in(student_ids)) + .all(conn) + .await + .map_err(|e| e.to_string()) +} + +async fn query_student_refs_by_sql( + conn: &DatabaseConnection, + sql_or_expression: &str, +) -> Result { + let wrapped_sql = build_readonly_student_query(sql_or_expression)?; + let rows = conn + .query_all(Statement::from_string( + conn.get_database_backend(), + wrapped_sql, + )) + .await + .map_err(|e| e.to_string())?; + + let mut refs = StudentRefs::default(); + for row in &rows { + if let Some(id) = try_get_i32(row, "id").or_else(|| try_get_i32(row, "student_id")) { + refs.ids.insert(id); + } + if let Some(name) = + try_get_string(row, "name").or_else(|| try_get_string(row, "student_name")) + { + refs.names.insert(name); + } + } + + Ok(refs) +} + +async fn load_students_from_refs( + conn: &DatabaseConnection, + refs: StudentRefs, +) -> Result, String> { + if refs.is_empty() { + return Ok(vec![]); + } + + let mut condition = Condition::any(); + if !refs.ids.is_empty() { + condition = condition.add(students::Column::Id.is_in(refs.ids)); + } + if !refs.names.is_empty() { + condition = condition.add(students::Column::Name.is_in(refs.names)); + } + + students::Entity::find() + .filter(condition) + .all(conn) + .await + .map_err(|e| e.to_string()) +} + +fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option { + row.try_get::("", column) + .ok() + .or_else(|| { + row.try_get::("", column) + .ok() + .and_then(|value| i32::try_from(value).ok()) + }) + .or_else(|| { + row.try_get::("", column) + .ok() + .and_then(|value| value.trim().parse::().ok()) + }) +} + +fn try_get_string(row: &sea_orm::QueryResult, column: &str) -> Option { + row.try_get::("", column).ok().and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +async fn ensure_tag_exists_in_txn( + txn: &sea_orm::DatabaseTransaction, + tag_name: &str, +) -> Result { + if let Some(existing) = tags::Entity::find() + .filter(tags::Column::Name.eq(tag_name)) + .one(txn) + .await + .map_err(|e| e.to_string())? + { + return Ok(existing.id); + } + + let now = now_iso(); + let inserted = tags::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + name: Set(tag_name.to_string()), + created_at: Set(now.clone()), + updated_at: Set(now), + } + .insert(txn) + .await + .map_err(|e| e.to_string())?; + + Ok(inserted.id) +} + +async fn attach_tag_to_student_if_missing( + txn: &sea_orm::DatabaseTransaction, + student_id: i32, + tag_id: i32, +) -> Result { + let existing = student_tags::Entity::find() + .filter(student_tags::Column::StudentId.eq(student_id)) + .filter(student_tags::Column::TagId.eq(tag_id)) + .one(txn) + .await + .map_err(|e| e.to_string())?; + + if existing.is_some() { + return Ok(false); + } + + let now = now_iso(); + student_tags::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + student_id: Set(student_id), + tag_id: Set(tag_id), + created_at: Set(now), + } + .insert(txn) + .await + .map_err(|e| e.to_string())?; + + Ok(true) +} + +fn now_iso() -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +fn build_auto_score_reason(rule_name: &str, delta: i32) -> String { + if delta > 0 { + format!("{}: {} (+{})", AUTO_SCORE_REASON_PREFIX, rule_name, delta) + } else { + format!("{}: {} ({})", AUTO_SCORE_REASON_PREFIX, rule_name, delta) + } +} diff --git a/src/components/AutoScore/AutoScoreUtils.ts b/src/components/AutoScore/AutoScoreUtils.ts index ccce73a..8498ea6 100644 --- a/src/components/AutoScore/AutoScoreUtils.ts +++ b/src/components/AutoScore/AutoScoreUtils.ts @@ -51,6 +51,7 @@ export type ActionDraftError = "action_required" | "score_required" | "tag_requi const TRIGGER_FIELD_INTERVAL = "interval_minutes" const TRIGGER_FIELD_TAG = "student_tag" +const TRIGGER_FIELD_SQL = "student_sql" const TRIGGER_TYPE_INTERVAL = "interval_duration" const TRIGGER_WIDGET_INTERVAL = "interval_duration" const OP_EQUAL = "equal" @@ -146,6 +147,24 @@ const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => { } } + if ( + trigger.event === "query_sql" || + trigger.event === "student_query_sql" || + trigger.event === "student_sql" + ) { + const sql = toStringValue(trigger.value).trim() + if (!sql) return null + return { + id: QbUtils.uuid(), + type: "rule", + properties: { + field: TRIGGER_FIELD_SQL, + operator: OP_EQUAL, + value: [sql], + }, + } + } + return null } @@ -175,6 +194,15 @@ const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => { } } + if (field === TRIGGER_FIELD_SQL) { + const sql = toStringValue(value).trim() + if (!sql) return null + return { + event: "query_sql", + value: sql, + } + } + return null } @@ -197,6 +225,25 @@ const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigg export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagOption[]): Config => ({ ...AntdConfig, + conjunctions: { + ...AntdConfig.conjunctions, + AND: { + ...AntdConfig.conjunctions.AND, + label: t("autoScore.relationAnd"), + }, + OR: { + ...AntdConfig.conjunctions.OR, + label: t("autoScore.relationOr"), + }, + }, + operators: { + ...AntdConfig.operators, + [OP_MULTISELECT_CONTAINS]: { + ...AntdConfig.operators[OP_MULTISELECT_CONTAINS], + label: t("autoScore.operatorContains"), + labelForFormat: t("autoScore.operatorContains"), + }, + }, widgets: { ...AntdConfig.widgets, [TRIGGER_WIDGET_INTERVAL]: { @@ -250,6 +297,15 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO showSearch: true, }, }, + [TRIGGER_FIELD_SQL]: { + label: t("autoScore.triggerStudentSql"), + type: "text", + operators: [OP_EQUAL], + valueSources: ["value"], + fieldSettings: { + placeholder: t("autoScore.triggerStudentSqlPlaceholder"), + }, + }, }, settings: { ...AntdConfig.settings, @@ -257,6 +313,7 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO compactMode: false, renderSize: "medium", showNot: true, + notLabel: t("autoScore.relationNot"), forceShowConj: true, canLeaveEmptyGroup: false, canReorder: true, @@ -267,6 +324,9 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO export const createEmptyTriggerTree = (config: Config): ImmutableTree => QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config) +export const normalizeTriggerTree = (tree: ImmutableTree, config: Config): ImmutableTree => + QbUtils.checkTree(tree, config) + export const triggersToQueryTree = ( config: Config, triggers: AutoScoreTrigger[] diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx index a4e1871..8cbbc19 100644 --- a/src/components/AutoScoreManager.tsx +++ b/src/components/AutoScoreManager.tsx @@ -27,6 +27,7 @@ import { createEmptyTriggerTree, createTriggerQueryConfig, hasUnsupportedTriggerLogic, + normalizeTriggerTree, normalizeActionDrafts, queryTreeToTriggers, triggersToQueryTree, @@ -54,6 +55,8 @@ interface AutoScoreManagerProps { canEdit: boolean } +const getRuleFileRelativePath = (ruleId: number) => `auto-score/rule-${ruleId}.json` + function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { const { t, i18n } = useTranslation() const [form] = Form.useForm() @@ -92,6 +95,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } }, [currentPage, pageSize, rules.length]) + useEffect(() => { + setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig)) + }, [triggerConfig]) + const resetEditor = () => { setEditingRuleId(null) form.setFieldsValue({ name: "", studentNames: [] }) @@ -262,6 +269,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element const res = await api.autoScoreDeleteRule(ruleId) if (res.success) { + await api.fsDeleteFile(getRuleFileRelativePath(ruleId), "automatic").catch(() => void 0) messageApi.success(t("autoScore.deleteSuccess")) if (editingRuleId === ruleId) { resetEditor() @@ -295,6 +303,38 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } } + const handleOpenRuleFile = async (rule: AutoScoreRule) => { + const api = (window as any).api + if (!api) return + + try { + const relativePath = getRuleFileRelativePath(rule.id) + const fileContent = { + id: rule.id, + name: rule.name, + enabled: rule.enabled, + studentNames: rule.studentNames, + triggers: rule.triggers, + actions: rule.actions, + lastExecuted: rule.lastExecuted ?? null, + exportedAt: new Date().toISOString(), + } + + const writeRes = await api.fsWriteJson(relativePath, fileContent, "automatic") + + if (!writeRes?.success) { + throw new Error("prepare rule file failed") + } + + const openRes = await api.fsOpenPath(relativePath, "automatic") + if (!openRes?.success) { + throw new Error(openRes?.message || "open rule file failed") + } + } catch { + messageApi.error(t("autoScore.openFileFailed")) + } + } + const formatLastExecuted = (value?: string | null) => { if (!value) return t("autoScore.notExecuted") const date = new Date(value) @@ -358,12 +398,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { title: t("common.operation"), key: "operation", - width: 140, + width: 220, render: (_, row) => ( + { @@ -391,7 +434,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element type="warning" showIcon style={{ marginBottom: "16px" }} - message={t("autoScore.adminRequired")} + title={t("autoScore.adminRequired")} /> )} @@ -403,7 +446,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element name="name" rules={[{ required: true, message: t("autoScore.nameRequired") }]} > - +