mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
feat: 自动化更新
This commit is contained in:
Generated
+12
-1
@@ -1079,7 +1079,7 @@ dependencies = [
|
||||
"rustc_version",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4242,6 +4242,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"winreg 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6913,6 +6914,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
||||
@@ -42,6 +42,9 @@ dirs = "6.0.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
urlencoding = "2.1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
|
||||
Binary file not shown.
@@ -5,8 +5,9 @@ use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::services::{
|
||||
AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger, PermissionLevel,
|
||||
SettingsKey, SettingsValue,
|
||||
query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch,
|
||||
AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService,
|
||||
AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -28,6 +29,10 @@ pub struct CreateAutoScoreRule {
|
||||
pub student_names: Vec<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
#[serde(default)]
|
||||
pub execution: AutoScoreExecutionConfig,
|
||||
#[serde(default)]
|
||||
pub filters: AutoScoreFilterConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -39,6 +44,16 @@ pub struct UpdateAutoScoreRule {
|
||||
pub student_names: Vec<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
#[serde(default)]
|
||||
pub execution: AutoScoreExecutionConfig,
|
||||
#[serde(default)]
|
||||
pub filters: AutoScoreFilterConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RollbackBatchParams {
|
||||
#[serde(rename = "batchId")]
|
||||
pub batch_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -61,6 +76,8 @@ fn build_rule_from_create(rule: CreateAutoScoreRule) -> AutoScoreRule {
|
||||
student_names: rule.student_names,
|
||||
triggers: rule.triggers,
|
||||
actions: rule.actions,
|
||||
execution: rule.execution,
|
||||
filters: rule.filters,
|
||||
last_executed: None,
|
||||
}
|
||||
}
|
||||
@@ -73,6 +90,8 @@ fn build_rule_from_update(rule: UpdateAutoScoreRule) -> AutoScoreRule {
|
||||
student_names: rule.student_names,
|
||||
triggers: rule.triggers,
|
||||
actions: rule.actions,
|
||||
execution: rule.execution,
|
||||
filters: rule.filters,
|
||||
last_executed: None,
|
||||
}
|
||||
}
|
||||
@@ -336,3 +355,38 @@ pub async fn auto_score_sort_rules(
|
||||
|
||||
Ok(IpcResponse::success(true))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auto_score_query_batches(
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<AutoScoreExecutionBatch>>, String> {
|
||||
{
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
if !check_admin_permission(&mut permissions, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
}
|
||||
|
||||
let batches = query_execution_batches(state.inner()).await?;
|
||||
Ok(IpcResponse::success(batches))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auto_score_rollback_batch(
|
||||
params: RollbackBatchParams,
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<AutoScoreExecutionBatch>, String> {
|
||||
{
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
if !check_admin_permission(&mut permissions, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
}
|
||||
|
||||
let batch = rollback_execution_batch(state.inner(), ¶ms.batch_id).await?;
|
||||
Ok(IpcResponse::success(batch))
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@ use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeSet;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use std::process::Command;
|
||||
#[cfg(target_os = "windows")]
|
||||
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||
#[cfg(target_os = "windows")]
|
||||
use winreg::RegKey;
|
||||
|
||||
use crate::services::{
|
||||
settings::{
|
||||
@@ -161,20 +166,6 @@ pub async fn settings_set(
|
||||
Ok(IpcResponse::success(()))
|
||||
}
|
||||
|
||||
fn fallback_font_families() -> Vec<String> {
|
||||
vec![
|
||||
"系统默认".to_string(),
|
||||
"Microsoft YaHei UI".to_string(),
|
||||
"Microsoft YaHei".to_string(),
|
||||
"PingFang SC".to_string(),
|
||||
"Noto Sans CJK SC".to_string(),
|
||||
"Source Han Sans SC".to_string(),
|
||||
"Segoe UI".to_string(),
|
||||
"Arial".to_string(),
|
||||
"Helvetica Neue".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn normalize_font_name(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
@@ -188,33 +179,20 @@ fn normalize_font_name(value: &str) -> String {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn query_system_fonts() -> Vec<String> {
|
||||
let output = Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts",
|
||||
])
|
||||
.output();
|
||||
let mut families = BTreeSet::new();
|
||||
|
||||
let Ok(output) = output else {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts");
|
||||
let Ok(key) = key else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut families = BTreeSet::new();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if !line.contains("REG_") {
|
||||
for (value_name, _) in key.enum_values().flatten() {
|
||||
let name = normalize_font_name(&value_name);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((left, _)) = line.split_once("REG_") else {
|
||||
continue;
|
||||
};
|
||||
let name = normalize_font_name(left);
|
||||
if !name.is_empty() {
|
||||
families.insert(name);
|
||||
}
|
||||
families.insert(name);
|
||||
}
|
||||
|
||||
families.into_iter().collect()
|
||||
@@ -276,19 +254,5 @@ fn query_system_fonts() -> Vec<String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn settings_get_system_fonts() -> Result<IpcResponse<Vec<String>>, String> {
|
||||
let mut fonts = query_system_fonts();
|
||||
if fonts.is_empty() {
|
||||
fonts = fallback_font_families();
|
||||
} else {
|
||||
let mut merged = BTreeSet::new();
|
||||
for item in fallback_font_families() {
|
||||
merged.insert(item);
|
||||
}
|
||||
for item in fonts {
|
||||
merged.insert(item);
|
||||
}
|
||||
fonts = merged.into_iter().collect();
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(fonts))
|
||||
Ok(IpcResponse::success(query_system_fonts()))
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ pub fn run() {
|
||||
auto_score_toggle_rule,
|
||||
auto_score_get_status,
|
||||
auto_score_sort_rules,
|
||||
auto_score_query_batches,
|
||||
auto_score_rollback_batch,
|
||||
board_get_configs,
|
||||
board_save_configs,
|
||||
board_query_sql,
|
||||
|
||||
@@ -31,6 +31,42 @@ pub struct AutoScoreAction {
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AutoScoreExecutionConfig {
|
||||
pub cooldown_minutes: Option<i64>,
|
||||
pub max_runs_per_day: Option<i64>,
|
||||
pub max_score_delta_per_day: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AutoScoreFilterConfig {
|
||||
pub groups: Vec<String>,
|
||||
pub grades: Vec<String>,
|
||||
pub min_score: Option<i32>,
|
||||
pub max_score: Option<i32>,
|
||||
pub recent_event_days: Option<i64>,
|
||||
pub min_recent_event_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AutoScoreExecutionBatch {
|
||||
pub id: String,
|
||||
pub rule_id: i32,
|
||||
pub rule_name: String,
|
||||
pub run_at: String,
|
||||
pub affected_students: usize,
|
||||
pub affected_student_names: Vec<String>,
|
||||
pub created_event_ids: Vec<i32>,
|
||||
pub added_student_tag_ids: Vec<i32>,
|
||||
pub score_delta_total: i64,
|
||||
pub settled: bool,
|
||||
pub rolled_back: bool,
|
||||
pub rollback_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum IntervalUnit {
|
||||
@@ -54,6 +90,10 @@ pub struct AutoScoreRule {
|
||||
pub student_names: Vec<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
#[serde(default)]
|
||||
pub execution: AutoScoreExecutionConfig,
|
||||
#[serde(default)]
|
||||
pub filters: AutoScoreFilterConfig,
|
||||
#[serde(rename = "lastExecuted")]
|
||||
pub last_executed: Option<String>,
|
||||
}
|
||||
@@ -62,6 +102,7 @@ pub struct AutoScoreRule {
|
||||
enum PlannedAction {
|
||||
AddScore(i32),
|
||||
AddTags(Vec<String>),
|
||||
SettleScore,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
@@ -70,11 +111,16 @@ struct StudentRefs {
|
||||
names: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct RuleExecutionStats {
|
||||
affected_students: usize,
|
||||
affected_student_names: Vec<String>,
|
||||
created_events: usize,
|
||||
added_tags: usize,
|
||||
score_delta_total: i64,
|
||||
created_event_ids: Vec<i32>,
|
||||
added_student_tag_ids: Vec<i32>,
|
||||
settled: bool,
|
||||
}
|
||||
|
||||
impl StudentRefs {
|
||||
@@ -98,6 +144,8 @@ impl Default for AutoScoreRule {
|
||||
student_names: Vec::new(),
|
||||
triggers: Vec::new(),
|
||||
actions: Vec::new(),
|
||||
execution: AutoScoreExecutionConfig::default(),
|
||||
filters: AutoScoreFilterConfig::default(),
|
||||
last_executed: None,
|
||||
}
|
||||
}
|
||||
@@ -278,6 +326,7 @@ impl AutoScoreService {
|
||||
|
||||
async fn run_scheduler_tick(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let state = app_handle.state::<SafeAppState>().inner().clone();
|
||||
let mut execution_batches = load_batches_from_settings(&state).await?;
|
||||
|
||||
let rules_snapshot = {
|
||||
let state_guard = state.read();
|
||||
@@ -307,7 +356,7 @@ impl AutoScoreService {
|
||||
continue;
|
||||
}
|
||||
|
||||
match execute_rule(&conn, rule).await {
|
||||
match execute_rule(&conn, rule, &execution_batches).await {
|
||||
Ok(stats) => {
|
||||
if stats.affected_students == 0 {
|
||||
Self::log_rule_skipped(&state, rule, "no matched students");
|
||||
@@ -315,7 +364,22 @@ impl AutoScoreService {
|
||||
}
|
||||
rule.last_executed = Some(Utc::now().to_rfc3339());
|
||||
changed = true;
|
||||
Self::log_rule_executed(&state, rule, stats);
|
||||
Self::log_rule_executed(&state, rule, &stats);
|
||||
let batch = AutoScoreExecutionBatch {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
rule_id: rule.id,
|
||||
rule_name: rule.name.clone(),
|
||||
run_at: now_iso(),
|
||||
affected_students: stats.affected_students,
|
||||
affected_student_names: stats.affected_student_names.clone(),
|
||||
created_event_ids: stats.created_event_ids.clone(),
|
||||
added_student_tag_ids: stats.added_student_tag_ids.clone(),
|
||||
score_delta_total: stats.score_delta_total,
|
||||
settled: stats.settled,
|
||||
rolled_back: false,
|
||||
rollback_at: None,
|
||||
};
|
||||
execution_batches.push(batch);
|
||||
}
|
||||
Err(error) => {
|
||||
Self::log_rule_failed(&state, rule, &error);
|
||||
@@ -328,6 +392,7 @@ impl AutoScoreService {
|
||||
}
|
||||
|
||||
persist_rules_to_settings(&state, &next_rules).await?;
|
||||
save_batches_to_settings(&state, &execution_batches).await?;
|
||||
|
||||
{
|
||||
let state_guard = state.read();
|
||||
@@ -348,7 +413,7 @@ impl AutoScoreService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_rule_executed(state: &SafeAppState, rule: &AutoScoreRule, stats: RuleExecutionStats) {
|
||||
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(
|
||||
@@ -359,6 +424,8 @@ impl AutoScoreService {
|
||||
"affected_students": stats.affected_students,
|
||||
"created_events": stats.created_events,
|
||||
"added_tags": stats.added_tags,
|
||||
"score_delta_total": stats.score_delta_total,
|
||||
"settled": stats.settled,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -411,11 +478,35 @@ fn normalize_rule(mut rule: AutoScoreRule) -> Result<AutoScoreRule, String> {
|
||||
.into_iter()
|
||||
.map(normalize_action)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rule.execution = normalize_execution_config(rule.execution)?;
|
||||
rule.filters = normalize_filter_config(rule.filters)?;
|
||||
rule.last_executed = normalize_last_executed(rule.last_executed);
|
||||
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
fn normalize_execution_config(
|
||||
mut config: AutoScoreExecutionConfig,
|
||||
) -> Result<AutoScoreExecutionConfig, String> {
|
||||
config.cooldown_minutes = config.cooldown_minutes.filter(|value| *value > 0);
|
||||
config.max_runs_per_day = config.max_runs_per_day.filter(|value| *value > 0);
|
||||
config.max_score_delta_per_day = config.max_score_delta_per_day.filter(|value| *value > 0);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn normalize_filter_config(mut config: AutoScoreFilterConfig) -> Result<AutoScoreFilterConfig, String> {
|
||||
config.groups = dedupe_trimmed_strings(config.groups);
|
||||
config.grades = dedupe_trimmed_strings(config.grades);
|
||||
if let (Some(min_score), Some(max_score)) = (config.min_score, config.max_score) {
|
||||
if min_score > max_score {
|
||||
return Err("minScore cannot be greater than maxScore".to_string());
|
||||
}
|
||||
}
|
||||
config.recent_event_days = config.recent_event_days.filter(|value| *value > 0);
|
||||
config.min_recent_event_count = config.min_recent_event_count.filter(|value| *value >= 0);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn normalize_trigger(trigger: AutoScoreTrigger) -> Result<AutoScoreTrigger, String> {
|
||||
let event = normalize_required_string(trigger.event, "trigger event")?;
|
||||
|
||||
@@ -483,6 +574,7 @@ fn normalize_action(action: AutoScoreAction) -> Result<AutoScoreAction, String>
|
||||
value: Some(value),
|
||||
})
|
||||
}
|
||||
"settle_score" => Ok(AutoScoreAction { event, value: None }),
|
||||
_ => Err(format!("Unsupported action event: {}", event)),
|
||||
}
|
||||
}
|
||||
@@ -780,9 +872,135 @@ async fn persist_rules_to_settings(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deserialize_batches(value: &JsonValue) -> Vec<AutoScoreExecutionBatch> {
|
||||
let JsonValue::Array(items) = value else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| serde_json::from_value::<AutoScoreExecutionBatch>(item.clone()).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn load_batches_from_settings(state: &SafeAppState) -> Result<Vec<AutoScoreExecutionBatch>, String> {
|
||||
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?;
|
||||
let raw = match settings.get_value(SettingsKey::AutoScoreBatches) {
|
||||
SettingsValue::Json(value) => value,
|
||||
_ => JsonValue::Array(vec![]),
|
||||
};
|
||||
Ok(deserialize_batches(&raw))
|
||||
}
|
||||
|
||||
async fn save_batches_to_settings(
|
||||
state: &SafeAppState,
|
||||
batches: &[AutoScoreExecutionBatch],
|
||||
) -> Result<(), String> {
|
||||
let encoded = serde_json::to_value(batches).map_err(|e| e.to_string())?;
|
||||
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::AutoScoreBatches, SettingsValue::Json(encoded))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn query_execution_batches(state: &SafeAppState) -> Result<Vec<AutoScoreExecutionBatch>, String> {
|
||||
let mut batches = load_batches_from_settings(state).await?;
|
||||
batches.sort_by(|a, b| b.run_at.cmp(&a.run_at));
|
||||
Ok(batches)
|
||||
}
|
||||
|
||||
pub async fn rollback_execution_batch(
|
||||
state: &SafeAppState,
|
||||
batch_id: &str,
|
||||
) -> Result<AutoScoreExecutionBatch, String> {
|
||||
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 batches = load_batches_from_settings(state).await?;
|
||||
let Some(index) = batches.iter().position(|batch| batch.id == batch_id) else {
|
||||
return Err("Execution batch not found".to_string());
|
||||
};
|
||||
|
||||
if batches[index].rolled_back {
|
||||
return Err("Execution batch already rolled back".to_string());
|
||||
}
|
||||
if batches[index].settled {
|
||||
return Err("Cannot rollback settled execution batch".to_string());
|
||||
}
|
||||
|
||||
let mut batch = batches[index].clone();
|
||||
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
for event_id in &batch.created_event_ids {
|
||||
let event = score_events::Entity::find_by_id(*event_id)
|
||||
.one(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(event) = event else {
|
||||
continue;
|
||||
};
|
||||
if event.settlement_id.is_some() {
|
||||
return Err("Cannot rollback batch containing settled events".to_string());
|
||||
}
|
||||
score_events::Entity::delete_by_id(*event_id)
|
||||
.exec(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(student) = students::Entity::find()
|
||||
.filter(students::Column::Name.eq(event.student_name.clone()))
|
||||
.one(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
let current_score = student.score;
|
||||
let current_reward_points = student.reward_points;
|
||||
let mut student_active: students::ActiveModel = student.into();
|
||||
let next_score = current_score - event.delta;
|
||||
let next_reward = current_reward_points - event.delta;
|
||||
student_active.score = Set(next_score);
|
||||
student_active.reward_points = Set(next_reward);
|
||||
student_active.updated_at = Set(now_iso());
|
||||
student_active
|
||||
.update(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
for link_id in &batch.added_student_tag_ids {
|
||||
student_tags::Entity::delete_by_id(*link_id)
|
||||
.exec(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
batch.rolled_back = true;
|
||||
batch.rollback_at = Some(now_iso());
|
||||
batches[index] = batch.clone();
|
||||
save_batches_to_settings(state, &batches).await?;
|
||||
Ok(batch)
|
||||
}
|
||||
|
||||
async fn execute_rule(
|
||||
conn: &DatabaseConnection,
|
||||
rule: &AutoScoreRule,
|
||||
execution_batches: &[AutoScoreExecutionBatch],
|
||||
) -> Result<RuleExecutionStats, String> {
|
||||
let mut target_students = resolve_target_students(conn, rule).await?;
|
||||
|
||||
@@ -801,16 +1019,32 @@ async fn execute_rule(
|
||||
return Ok(RuleExecutionStats::default());
|
||||
}
|
||||
|
||||
if let Some(max_runs) = rule.execution.max_runs_per_day {
|
||||
let today_runs = execution_batches
|
||||
.iter()
|
||||
.filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at))
|
||||
.count() as i64;
|
||||
if today_runs >= max_runs {
|
||||
return Ok(RuleExecutionStats::default());
|
||||
}
|
||||
}
|
||||
|
||||
let planned_actions = plan_actions(&rule.actions)?;
|
||||
if planned_actions.is_empty() {
|
||||
return Err("No executable action".to_string());
|
||||
}
|
||||
let should_settle = planned_actions
|
||||
.iter()
|
||||
.any(|action| matches!(action, PlannedAction::SettleScore));
|
||||
|
||||
let mut daily_score_delta_used: i64 = execution_batches
|
||||
.iter()
|
||||
.filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at))
|
||||
.map(|batch| batch.score_delta_total.abs())
|
||||
.sum();
|
||||
|
||||
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||
let mut stats = RuleExecutionStats {
|
||||
affected_students: target_students.len(),
|
||||
..RuleExecutionStats::default()
|
||||
};
|
||||
let mut stats = RuleExecutionStats::default();
|
||||
|
||||
let mut all_action_tags = Vec::new();
|
||||
for action in &planned_actions {
|
||||
@@ -827,9 +1061,28 @@ async fn execute_rule(
|
||||
}
|
||||
|
||||
for mut student in target_students {
|
||||
if !is_student_pass_cooldown(
|
||||
conn,
|
||||
execution_batches,
|
||||
rule,
|
||||
student.id,
|
||||
student.name.as_str(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut touched = false;
|
||||
for action in &planned_actions {
|
||||
match action {
|
||||
PlannedAction::AddScore(delta) => {
|
||||
if let Some(max_delta) = rule.execution.max_score_delta_per_day {
|
||||
let next = daily_score_delta_used + (*delta as i64).abs();
|
||||
if next > max_delta {
|
||||
continue;
|
||||
}
|
||||
daily_score_delta_used = next;
|
||||
}
|
||||
let now = now_iso();
|
||||
let val_prev = student.score;
|
||||
let val_curr = val_prev + delta;
|
||||
@@ -839,15 +1092,17 @@ async fn execute_rule(
|
||||
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)),
|
||||
reason_content: Set(build_auto_score_reason(rule.id, &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())?;
|
||||
let inserted_event = event.insert(&txn).await.map_err(|e| e.to_string())?;
|
||||
stats.created_events += 1;
|
||||
stats.created_event_ids.push(inserted_event.id);
|
||||
stats.score_delta_total += *delta as i64;
|
||||
|
||||
let mut student_active: students::ActiveModel = student.clone().into();
|
||||
student_active.score = Set(val_curr);
|
||||
@@ -860,6 +1115,7 @@ async fn execute_rule(
|
||||
|
||||
student.score = val_curr;
|
||||
student.reward_points = reward_points;
|
||||
touched = true;
|
||||
}
|
||||
PlannedAction::AddTags(tag_names) => {
|
||||
for tag_name in tag_names {
|
||||
@@ -868,23 +1124,34 @@ async fn execute_rule(
|
||||
};
|
||||
let inserted =
|
||||
attach_tag_to_student_if_missing(&txn, student.id, tag_id).await?;
|
||||
if inserted {
|
||||
if let Some(link_id) = inserted {
|
||||
stats.added_tags += 1;
|
||||
stats.added_student_tag_ids.push(link_id);
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
PlannedAction::SettleScore => {}
|
||||
}
|
||||
}
|
||||
if touched {
|
||||
stats.affected_students += 1;
|
||||
stats.affected_student_names.push(student.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
if should_settle {
|
||||
execute_settlement(conn).await?;
|
||||
stats.settled = true;
|
||||
}
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
fn plan_actions(actions: &[AutoScoreAction]) -> Result<Vec<PlannedAction>, String> {
|
||||
let mut planned = Vec::new();
|
||||
for action in actions {
|
||||
match action.event.as_str() {
|
||||
let mut planned = Vec::new();
|
||||
for action in actions {
|
||||
match action.event.as_str() {
|
||||
"add_score" => {
|
||||
let delta = action
|
||||
.value
|
||||
@@ -901,15 +1168,142 @@ fn plan_actions(actions: &[AutoScoreAction]) -> Result<Vec<PlannedAction>, Strin
|
||||
}
|
||||
planned.push(PlannedAction::AddTags(tags));
|
||||
}
|
||||
"settle_score" => {
|
||||
planned.push(PlannedAction::SettleScore);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(planned)
|
||||
}
|
||||
Ok(planned)
|
||||
}
|
||||
|
||||
async fn execute_settlement(conn: &DatabaseConnection) -> Result<(), String> {
|
||||
let backend = conn.get_database_backend();
|
||||
let count_stmt = Statement::from_string(
|
||||
backend,
|
||||
"SELECT COUNT(*) AS cnt FROM score_events WHERE settlement_id IS NULL",
|
||||
);
|
||||
let count_row = conn.query_one(count_stmt).await.map_err(|e| e.to_string())?;
|
||||
let Some(count_row) = count_row else {
|
||||
return Ok(());
|
||||
};
|
||||
let unsettled_count = try_get_i64(&count_row, "cnt").unwrap_or(0);
|
||||
if unsettled_count <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let last_end_sql = match backend {
|
||||
sea_orm::DatabaseBackend::Sqlite => {
|
||||
"SELECT end_time AS end_time FROM settlements ORDER BY julianday(end_time) DESC LIMIT 1"
|
||||
}
|
||||
sea_orm::DatabaseBackend::Postgres => {
|
||||
"SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1"
|
||||
}
|
||||
_ => "SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1",
|
||||
};
|
||||
let min_event_sql =
|
||||
"SELECT MIN(event_time) AS min_event_time FROM score_events WHERE settlement_id IS NULL";
|
||||
|
||||
let last_end = conn
|
||||
.query_one(Statement::from_string(backend, last_end_sql))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.and_then(|row| try_get_string(&row, "end_time"));
|
||||
|
||||
let min_event_time = conn
|
||||
.query_one(Statement::from_string(backend, min_event_sql))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.and_then(|row| try_get_string(&row, "min_event_time"));
|
||||
|
||||
let end_time = now_iso();
|
||||
let start_time = last_end.or(min_event_time).unwrap_or_else(|| end_time.clone());
|
||||
let created_at = end_time.clone();
|
||||
|
||||
match backend {
|
||||
sea_orm::DatabaseBackend::Postgres => {
|
||||
let insert_stmt = Statement::from_string(
|
||||
backend,
|
||||
format!(
|
||||
"INSERT INTO settlements (start_time, end_time, created_at) VALUES ('{}', '{}', '{}') RETURNING id",
|
||||
start_time.replace('\'', "''"),
|
||||
end_time.replace('\'', "''"),
|
||||
created_at.replace('\'', "''")
|
||||
),
|
||||
);
|
||||
let row = conn
|
||||
.query_one(insert_stmt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Failed to create settlement".to_string())?;
|
||||
let settlement_id = try_get_i32(&row, "id")
|
||||
.ok_or_else(|| "Failed to read settlement id".to_string())?;
|
||||
|
||||
let update_events_stmt = Statement::from_string(
|
||||
backend,
|
||||
format!(
|
||||
"UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL",
|
||||
settlement_id
|
||||
),
|
||||
);
|
||||
conn.execute(update_events_stmt).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let update_students_stmt = Statement::from_string(
|
||||
backend,
|
||||
format!(
|
||||
"UPDATE students SET score = 0, updated_at = '{}' ",
|
||||
end_time.replace('\'', "''")
|
||||
),
|
||||
);
|
||||
conn.execute(update_students_stmt).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => {
|
||||
let insert_stmt = Statement::from_string(
|
||||
backend,
|
||||
format!(
|
||||
"INSERT INTO settlements (start_time, end_time, created_at) VALUES ('{}', '{}', '{}')",
|
||||
start_time.replace('\'', "''"),
|
||||
end_time.replace('\'', "''"),
|
||||
created_at.replace('\'', "''")
|
||||
),
|
||||
);
|
||||
conn.execute(insert_stmt).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let id_stmt = Statement::from_string(backend, "SELECT last_insert_rowid() AS id");
|
||||
let row = conn
|
||||
.query_one(id_stmt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Failed to read settlement id".to_string())?;
|
||||
let settlement_id = try_get_i32(&row, "id")
|
||||
.ok_or_else(|| "Failed to read settlement id".to_string())?;
|
||||
|
||||
let update_events_stmt = Statement::from_string(
|
||||
backend,
|
||||
format!(
|
||||
"UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL",
|
||||
settlement_id
|
||||
),
|
||||
);
|
||||
conn.execute(update_events_stmt).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let update_students_stmt = Statement::from_string(
|
||||
backend,
|
||||
format!(
|
||||
"UPDATE students SET score = 0, updated_at = '{}'",
|
||||
end_time.replace('\'', "''")
|
||||
),
|
||||
);
|
||||
conn.execute(update_students_stmt).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_target_students(
|
||||
conn: &DatabaseConnection,
|
||||
rule: &AutoScoreRule,
|
||||
conn: &DatabaseConnection,
|
||||
rule: &AutoScoreRule,
|
||||
) -> Result<Vec<students::Model>, String> {
|
||||
let explicit_sql_values: Vec<String> = rule
|
||||
.triggers
|
||||
@@ -985,6 +1379,57 @@ async fn resolve_target_students(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
|
||||
async fn is_student_pass_cooldown(
|
||||
conn: &DatabaseConnection,
|
||||
execution_batches: &[AutoScoreExecutionBatch],
|
||||
rule: &AutoScoreRule,
|
||||
student_id: i32,
|
||||
student_name: &str,
|
||||
) -> Result<bool, String> {
|
||||
let Some(cooldown_minutes) = rule.execution.cooldown_minutes else {
|
||||
return Ok(true);
|
||||
};
|
||||
if cooldown_minutes <= 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let _ = student_id;
|
||||
let cutoff = Utc::now() - chrono::Duration::minutes(cooldown_minutes);
|
||||
for batch in execution_batches
|
||||
.iter()
|
||||
.filter(|batch| batch.rule_id == rule.id && !batch.rolled_back)
|
||||
{
|
||||
let run_at = DateTime::parse_from_rfc3339(batch.run_at.as_str())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now() - chrono::Duration::days(3650));
|
||||
if run_at >= cutoff && batch.affected_student_names.iter().any(|name| name == student_name)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let prefix = format!("{}#{}:", AUTO_SCORE_REASON_PREFIX, rule.id);
|
||||
let since = cutoff
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
|
||||
let exists = score_events::Entity::find()
|
||||
.filter(score_events::Column::StudentName.eq(student_name.to_string()))
|
||||
.filter(score_events::Column::ReasonContent.like(format!("{}%", prefix)))
|
||||
.filter(score_events::Column::EventTime.gte(since))
|
||||
.one(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(exists.is_none())
|
||||
}
|
||||
|
||||
fn is_same_utc_day(timestamp: &str) -> bool {
|
||||
DateTime::parse_from_rfc3339(timestamp)
|
||||
.map(|value| value.with_timezone(&Utc).date_naive() == Utc::now().date_naive())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn query_student_refs_by_sql(
|
||||
conn: &DatabaseConnection,
|
||||
sql_or_expression: &str,
|
||||
@@ -1051,6 +1496,21 @@ fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option<i32> {
|
||||
})
|
||||
}
|
||||
|
||||
fn try_get_i64(row: &sea_orm::QueryResult, column: &str) -> Option<i64> {
|
||||
row.try_get::<i64>("", column)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
row.try_get::<i32>("", column)
|
||||
.ok()
|
||||
.map(|value| value as i64)
|
||||
})
|
||||
.or_else(|| {
|
||||
row.try_get::<String>("", column)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<i64>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn try_get_string(row: &sea_orm::QueryResult, column: &str) -> Option<String> {
|
||||
row.try_get::<String>("", column).ok().and_then(|value| {
|
||||
let trimmed = value.trim().to_string();
|
||||
@@ -1093,7 +1553,7 @@ async fn attach_tag_to_student_if_missing(
|
||||
txn: &sea_orm::DatabaseTransaction,
|
||||
student_id: i32,
|
||||
tag_id: i32,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<Option<i32>, String> {
|
||||
let existing = student_tags::Entity::find()
|
||||
.filter(student_tags::Column::StudentId.eq(student_id))
|
||||
.filter(student_tags::Column::TagId.eq(tag_id))
|
||||
@@ -1102,11 +1562,11 @@ async fn attach_tag_to_student_if_missing(
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let now = now_iso();
|
||||
student_tags::ActiveModel {
|
||||
let inserted = student_tags::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
student_id: Set(student_id),
|
||||
tag_id: Set(tag_id),
|
||||
@@ -1116,17 +1576,23 @@ async fn attach_tag_to_student_if_missing(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
Ok(Some(inserted.id))
|
||||
}
|
||||
|
||||
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 {
|
||||
fn build_auto_score_reason(rule_id: i32, rule_name: &str, delta: i32) -> String {
|
||||
if delta > 0 {
|
||||
format!("{}: {} (+{})", AUTO_SCORE_REASON_PREFIX, rule_name, delta)
|
||||
format!(
|
||||
"{}#{}: {} (+{})",
|
||||
AUTO_SCORE_REASON_PREFIX, rule_id, rule_name, delta
|
||||
)
|
||||
} else {
|
||||
format!("{}: {} ({})", AUTO_SCORE_REASON_PREFIX, rule_name, delta)
|
||||
format!(
|
||||
"{}#{}: {} ({})",
|
||||
AUTO_SCORE_REASON_PREFIX, rule_id, rule_name, delta
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ pub mod settings;
|
||||
pub mod theme;
|
||||
|
||||
pub use auth::AuthService;
|
||||
pub use auto_score::{AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger};
|
||||
pub use auto_score::{
|
||||
query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch,
|
||||
AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService,
|
||||
AutoScoreTrigger,
|
||||
};
|
||||
pub use data::DataService;
|
||||
pub use logger::LoggerService;
|
||||
pub use permission::{PermissionLevel, PermissionService};
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct SettingsSpec {
|
||||
pub themes_custom: JsonValue,
|
||||
pub auto_score_enabled: bool,
|
||||
pub auto_score_rules: JsonValue,
|
||||
pub auto_score_batches: JsonValue,
|
||||
pub current_theme_id: String,
|
||||
pub dashboards_config: JsonValue,
|
||||
pub pg_connection_string: String,
|
||||
@@ -33,6 +34,7 @@ impl Default for SettingsSpec {
|
||||
themes_custom: JsonValue::Array(vec![]),
|
||||
auto_score_enabled: false,
|
||||
auto_score_rules: JsonValue::Array(vec![]),
|
||||
auto_score_batches: JsonValue::Array(vec![]),
|
||||
current_theme_id: "light-default".to_string(),
|
||||
dashboards_config: JsonValue::Array(vec![]),
|
||||
pg_connection_string: String::new(),
|
||||
@@ -64,6 +66,7 @@ pub enum SettingsKey {
|
||||
ThemesCustom,
|
||||
AutoScoreEnabled,
|
||||
AutoScoreRules,
|
||||
AutoScoreBatches,
|
||||
CurrentThemeId,
|
||||
DashboardsConfig,
|
||||
PgConnectionString,
|
||||
@@ -83,6 +86,7 @@ impl SettingsKey {
|
||||
SettingsKey::ThemesCustom => "themes_custom",
|
||||
SettingsKey::AutoScoreEnabled => "auto_score_enabled",
|
||||
SettingsKey::AutoScoreRules => "auto_score_rules",
|
||||
SettingsKey::AutoScoreBatches => "auto_score_batches",
|
||||
SettingsKey::CurrentThemeId => "current_theme_id",
|
||||
SettingsKey::DashboardsConfig => "dashboards_config",
|
||||
SettingsKey::PgConnectionString => "pg_connection_string",
|
||||
@@ -102,6 +106,7 @@ impl SettingsKey {
|
||||
"themes_custom" => Some(SettingsKey::ThemesCustom),
|
||||
"auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled),
|
||||
"auto_score_rules" => Some(SettingsKey::AutoScoreRules),
|
||||
"auto_score_batches" => Some(SettingsKey::AutoScoreBatches),
|
||||
"current_theme_id" => Some(SettingsKey::CurrentThemeId),
|
||||
"dashboards_config" => Some(SettingsKey::DashboardsConfig),
|
||||
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
||||
@@ -378,6 +383,16 @@ impl SettingsService {
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::AutoScoreBatches,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::Json,
|
||||
default_value: SettingsValue::Json(JsonValue::Array(vec![])),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::CurrentThemeId,
|
||||
SettingDefinition {
|
||||
@@ -586,6 +601,10 @@ impl SettingsService {
|
||||
SettingsValue::Json(j) => j,
|
||||
_ => JsonValue::Array(vec![]),
|
||||
},
|
||||
auto_score_batches: match self.get_value(SettingsKey::AutoScoreBatches) {
|
||||
SettingsValue::Json(j) => j,
|
||||
_ => JsonValue::Array(vec![]),
|
||||
},
|
||||
current_theme_id: match self.get_value(SettingsKey::CurrentThemeId) {
|
||||
SettingsValue::String(s) => s,
|
||||
_ => "light-default".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user