use parking_lot::RwLock; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use crate::db::entities::students; use crate::models::{StudentUpdate, StudentWithTags}; use crate::services::logger::LogLevel; use crate::services::PermissionLevel; use crate::state::AppState; use super::database::realtime_dual_write_sync; use super::response::{ImportResult, IpcResponse}; #[derive(Deserialize)] pub struct CreateStudentData { pub name: String, pub group_name: Option, } #[derive(Deserialize)] pub struct ImportStudentsParams { pub names: Vec, } #[derive(Deserialize)] pub struct FetchBanYouClassroomsParams { pub cookie: String, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct BanYouClassroom { pub class_id: String, #[serde(default)] pub class_nick_name: String, #[serde(default)] pub invitation_code: Option, #[serde(default)] pub master_name: Option, #[serde(default)] pub students_num: Option, #[serde(default)] pub praise_count: Option, #[serde(default)] pub class_avatar_path: Option, #[serde(default)] pub class_avatar_data_url: Option, #[serde(default)] pub is_own: Option, } #[derive(Debug, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct BanYouClassroomFetchData { #[serde(default)] pub classrooms: Vec, #[serde(default)] pub administrative_groups: Vec, } #[derive(Debug, Deserialize)] struct BanYouClassroomFetchApiResponse { pub code: i32, pub data: Option, pub message: Option, } fn check_admin_permission(state: &Arc>, sender_id: Option) -> 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::Admin) } fn check_view_permission(state: &Arc>, sender_id: Option) -> 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 log_banyou( state: &Arc>, level: LogLevel, message: &str, meta: Option, ) { let state_guard = state.read(); let logger = state_guard.logger.read(); logger.log(level, message, Some("student:banyou"), meta); } fn first_n_chars(text: &str, n: usize) -> String { text.chars().take(n).collect() } fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> { let from = text.find(start)?; let from_idx = from + start.len(); let tail = &text[from_idx..]; let to = tail.find(end)?; Some(&tail[..to]) } fn extract_csrf_token_from_html(html: &str) -> Option { let patterns = [ ("name=\"csrf-token\" content=\"", "\""), ("name='csrf-token' content='", "'"), ("\"csrfToken\":\"", "\""), ("'csrfToken':'", "'"), ("csrfToken:\"", "\""), ("csrfToken:'", "'"), ]; for (start, end) in patterns { if let Some(token) = extract_between(html, start, end) { let trimmed = token.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } None } async fn fetch_image_as_data_url( client: &reqwest::Client, image_url: &str, cookie: &str, ) -> Result { let response = client .get(image_url) .header(reqwest::header::REFERER, "https://care.seewo.com/app/") .header(reqwest::header::ORIGIN, "https://care.seewo.com") .header(reqwest::header::COOKIE, cookie) .send() .await .map_err(|e| e.to_string())?; let status = response.status(); if !status.is_success() { return Err(format!("http status {}", status.as_u16())); } let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("image/png") .to_string(); let bytes = response.bytes().await.map_err(|e| e.to_string())?; let base64_data = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); Ok(format!("data:{};base64,{}", content_type, base64_data)) } #[tauri::command] pub async fn student_query( state: State<'_, Arc>>, sender_id: Option, ) -> Result>, String> { if !check_view_permission(&state, sender_id) { return Ok(IpcResponse::error("Permission denied: view required")); } let state_guard = state.read(); let db_guard = state_guard.db.read(); if let Some(conn) = db_guard.as_ref() { let results = students::Entity::find() .order_by_desc(students::Column::Score) .order_by_asc(students::Column::Name) .all(conn) .await; match results { Ok(student_models) => { let students: Vec = student_models .into_iter() .map(|s| StudentWithTags { id: s.id, name: s.name, group_name: s.group_name, score: s.score, reward_points: s.reward_points, tags: serde_json::from_str(&s.tags).unwrap_or_default(), extra_json: s.extra_json, }) .collect(); Ok(IpcResponse::success(students)) } Err(e) => Ok(IpcResponse::error(&format!( "Failed to query students: {}", e ))), } } else { Ok(IpcResponse::error("Database not connected")) } } #[tauri::command] pub async fn student_create( state: State<'_, Arc>>, sender_id: Option, data: CreateStudentData, ) -> Result, String> { if !check_admin_permission(&state, sender_id) { return Ok(IpcResponse::error("Permission denied: admin required")); } let name = data.name.trim(); if name.is_empty() { return Ok(IpcResponse::error("Student name cannot be empty")); } if name.len() > 64 { return Ok(IpcResponse::error( "Student name too long (max 64 characters)", )); } let state_guard = state.read(); let db_guard = state_guard.db.read(); if let Some(conn) = db_guard.as_ref() { let existing = students::Entity::find() .filter(students::Column::Name.eq(name)) .one(conn) .await; match existing { Ok(Some(_)) => Ok(IpcResponse::error("Student with this name already exists")), Ok(None) => { let now = chrono::Utc::now() .format("%Y-%m-%dT%H:%M:%S%.3fZ") .to_string(); let group_name = data .group_name .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .map(|v| v.to_string()); let new_student = students::ActiveModel { id: sea_orm::ActiveValue::NotSet, name: Set(name.to_string()), group_name: Set(group_name), score: Set(0), reward_points: Set(0), tags: Set("[]".to_string()), extra_json: Set(None), created_at: Set(now.clone()), updated_at: Set(now), }; match new_student.insert(conn).await { Ok(inserted) => { realtime_dual_write_sync(state.inner()).await?; Ok(IpcResponse::success(inserted.id)) } Err(e) => Ok(IpcResponse::error(&format!( "Failed to create student: {}", e ))), } } Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))), } } else { Ok(IpcResponse::error("Database not connected")) } } #[tauri::command] pub async fn student_update( state: State<'_, Arc>>, sender_id: Option, id: i32, data: StudentUpdate, ) -> Result, String> { if !check_admin_permission(&state, sender_id) { return Ok(IpcResponse::error("Permission denied: admin required")); } let state_guard = state.read(); let db_guard = state_guard.db.read(); if let Some(conn) = db_guard.as_ref() { let existing = students::Entity::find_by_id(id).one(conn).await; match existing { Ok(Some(student)) => { let now = chrono::Utc::now() .format("%Y-%m-%dT%H:%M:%S%.3fZ") .to_string(); let mut active: students::ActiveModel = student.into(); active.updated_at = Set(now); if let Some(name) = data.name { active.name = Set(name); } if let Some(group_name) = data.group_name { let normalized = group_name.trim(); active.group_name = if normalized.is_empty() { Set(None) } else { Set(Some(normalized.to_string())) }; } if let Some(score) = data.score { active.score = Set(score); } if let Some(reward_points) = data.reward_points { active.reward_points = Set(reward_points); } if let Some(tags) = data.tags { active.tags = Set(serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string())); } if let Some(extra_json) = data.extra_json { active.extra_json = Set(Some(extra_json)); } match active.update(conn).await { Ok(_) => { realtime_dual_write_sync(state.inner()).await?; Ok(IpcResponse::success_empty()) } Err(e) => Ok(IpcResponse::error(&format!( "Failed to update student: {}", e ))), } } Ok(None) => Ok(IpcResponse::error("Student not found")), Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))), } } else { Ok(IpcResponse::error("Database not connected")) } } #[tauri::command] pub async fn student_delete( state: State<'_, Arc>>, sender_id: Option, id: i32, ) -> Result, String> { if !check_admin_permission(&state, sender_id) { return Ok(IpcResponse::error("Permission denied: admin required")); } let state_guard = state.read(); let db_guard = state_guard.db.read(); if let Some(conn) = db_guard.as_ref() { let existing = students::Entity::find_by_id(id).one(conn).await; match existing { Ok(Some(student)) => { let txn = conn.begin().await.map_err(|e| e.to_string())?; students::Entity::delete(students::ActiveModel { id: sea_orm::ActiveValue::Set(student.id), name: sea_orm::ActiveValue::Unchanged(student.name), group_name: sea_orm::ActiveValue::Unchanged(student.group_name), score: sea_orm::ActiveValue::Unchanged(student.score), reward_points: sea_orm::ActiveValue::Unchanged(student.reward_points), tags: sea_orm::ActiveValue::Unchanged(student.tags), extra_json: sea_orm::ActiveValue::Unchanged(student.extra_json), created_at: sea_orm::ActiveValue::Unchanged(student.created_at), updated_at: sea_orm::ActiveValue::Unchanged(student.updated_at), }) .exec(&txn) .await .map_err(|e| e.to_string())?; txn.commit().await.map_err(|e| e.to_string())?; realtime_dual_write_sync(state.inner()).await?; Ok(IpcResponse::success_empty()) } Ok(None) => Ok(IpcResponse::error("Student not found")), Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))), } } else { Ok(IpcResponse::error("Database not connected")) } } #[tauri::command] pub async fn student_import_from_xlsx( state: State<'_, Arc>>, sender_id: Option, params: ImportStudentsParams, ) -> Result, String> { if !check_admin_permission(&state, sender_id) { return Ok(IpcResponse::error("Permission denied: admin required")); } let cleaned: Vec = params .names .into_iter() .map(|n| n.trim().to_string()) .filter(|n| !n.is_empty() && n.len() <= 64) .collect(); let unique_names: std::collections::HashSet = cleaned.into_iter().collect(); let unique_names: Vec = unique_names.into_iter().collect(); if unique_names.is_empty() { return Ok(IpcResponse::success(ImportResult { inserted: 0, skipped: 0, total: 0, })); } let state_guard = state.read(); let db_guard = state_guard.db.read(); if let Some(conn) = db_guard.as_ref() { let existing = students::Entity::find() .filter( students::Column::Name .is_in(unique_names.iter().map(|s| s.as_str()).collect::>()), ) .all(conn) .await; match existing { Ok(existing_students) => { let existing_names: std::collections::HashSet = existing_students.into_iter().map(|s| s.name).collect(); let to_insert: Vec<&String> = unique_names .iter() .filter(|n| !existing_names.contains(*n)) .collect(); let inserted = to_insert.len(); let skipped = unique_names.len() - inserted; if !to_insert.is_empty() { let txn = conn.begin().await.map_err(|e| e.to_string())?; let now = chrono::Utc::now() .format("%Y-%m-%dT%H:%M:%S%.3fZ") .to_string(); for name in &to_insert { let new_student = students::ActiveModel { id: sea_orm::ActiveValue::NotSet, name: Set(name.to_string()), group_name: Set(None), score: Set(0), reward_points: Set(0), tags: Set("[]".to_string()), extra_json: Set(None), created_at: Set(now.clone()), updated_at: Set(now.clone()), }; new_student.insert(&txn).await.map_err(|e| e.to_string())?; } txn.commit().await.map_err(|e| e.to_string())?; realtime_dual_write_sync(state.inner()).await?; } Ok(IpcResponse::success(ImportResult { inserted, skipped, total: unique_names.len(), })) } Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))), } } else { Ok(IpcResponse::error("Database not connected")) } } #[tauri::command] pub async fn student_fetch_banyou_classrooms( state: State<'_, Arc>>, sender_id: Option, params: FetchBanYouClassroomsParams, ) -> Result, String> { if !check_admin_permission(&state, sender_id) { return Ok(IpcResponse::error("Permission denied: admin required")); } let cookie = params.cookie.trim(); if cookie.is_empty() { log_banyou( state.inner(), LogLevel::Warn, "fetch classrooms rejected: empty cookie", None, ); return Ok(IpcResponse::error("Cookie cannot be empty")); } log_banyou( state.inner(), LogLevel::Info, "fetch classrooms started", Some(serde_json::json!({ "cookie_length": cookie.len(), "contains_uid": cookie.contains("uid="), "contains_access_token": cookie.contains("accessToken="), })), ); let timestamp = chrono::Utc::now().timestamp_millis(); let url = format!( "https://care.seewo.com/app/apis.json?action=CLASSROOM_FETCH×tamp={timestamp}&isAjax=1" ); let payload = serde_json::json!({ "action": "CLASSROOM_FETCH", "params": { "originKey": "easicare-web" } }); let client = reqwest::Client::builder() .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") .build() .map_err(|e| { log_banyou( state.inner(), LogLevel::Error, "failed to build reqwest client", Some(serde_json::json!({ "error": e.to_string() })), ); e.to_string() })?; let csrf_token = match client .get("https://care.seewo.com/app/") .header(reqwest::header::COOKIE, cookie) .send() .await { Ok(resp) => { let status = resp.status(); match resp.text().await { Ok(html) => { let token = extract_csrf_token_from_html(&html); log_banyou( state.inner(), LogLevel::Info, "csrf token fetch finished", Some(serde_json::json!({ "status": status.as_u16(), "html_length": html.len(), "csrf_found": token.is_some(), })), ); token } Err(e) => { log_banyou( state.inner(), LogLevel::Warn, "failed to read app html when fetching csrf token", Some(serde_json::json!({ "error": e.to_string() })), ); None } } } Err(e) => { log_banyou( state.inner(), LogLevel::Warn, "failed to request app html for csrf token", Some(serde_json::json!({ "error": e.to_string() })), ); None } }; let mut request = client .post(url) .header( reqwest::header::ACCEPT, "application/json, text/javascript, */*; q=0.01", ) .header(reqwest::header::ACCEPT_LANGUAGE, "zh-CN,zh;q=0.9") .header(reqwest::header::CACHE_CONTROL, "no-cache") .header(reqwest::header::PRAGMA, "no-cache") .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ORIGIN, "https://care.seewo.com") .header(reqwest::header::REFERER, "https://care.seewo.com/app/") .header("X-Requested-With", "XMLHttpRequest") .header(reqwest::header::COOKIE, cookie) .json(&payload); if let Some(token) = csrf_token.clone() { request = request.header("x-csrf-token", token); } log_banyou( state.inner(), LogLevel::Info, "sending classroom fetch request", Some(serde_json::json!({ "csrf_attached": csrf_token.is_some(), })), ); let resp = request.send().await.map_err(|e| { log_banyou( state.inner(), LogLevel::Error, "http request failed", Some(serde_json::json!({ "error": e.to_string() })), ); e.to_string() })?; let status = resp.status(); let body = resp.text().await.map_err(|e| { log_banyou( state.inner(), LogLevel::Error, "failed to read response body", Some(serde_json::json!({ "error": e.to_string() })), ); e.to_string() })?; log_banyou( state.inner(), LogLevel::Info, "http response received", Some(serde_json::json!({ "status": status.as_u16(), "body_length": body.len(), "body_preview": first_n_chars(&body, 500), })), ); if !status.is_success() { log_banyou( state.inner(), LogLevel::Error, "non-success http status from banyou", Some(serde_json::json!({ "status": status.as_u16(), "body_preview": first_n_chars(&body, 500), })), ); return Ok(IpcResponse::error(&format!( "班优接口请求失败(HTTP {})", status.as_u16() ))); } let parsed: BanYouClassroomFetchApiResponse = serde_json::from_str(&body) .map_err(|e| { log_banyou( state.inner(), LogLevel::Error, "failed to parse banyou response json", Some(serde_json::json!({ "error": e.to_string(), "body_preview": first_n_chars(&body, 500), })), ); format!("Failed to parse BanYou response: {}", e) })?; if parsed.code != 200 { let msg = parsed .message .unwrap_or_else(|| format!("BanYou API returned code {}", parsed.code)); log_banyou( state.inner(), LogLevel::Warn, "banyou api returned non-200 business code", Some(serde_json::json!({ "code": parsed.code, "message": msg, })), ); return Ok(IpcResponse::error(&msg)); } let mut data = parsed.data.unwrap_or_default(); for classroom in &mut data.classrooms { classroom.class_avatar_data_url = None; if let Some(url) = classroom.class_avatar_path.clone() { match fetch_image_as_data_url(&client, &url, cookie).await { Ok(data_url) => { classroom.class_avatar_data_url = Some(data_url); } Err(err) => { log_banyou( state.inner(), LogLevel::Warn, "failed to fetch classroom avatar", Some(serde_json::json!({ "class_id": classroom.class_id, "url": url, "error": err, })), ); } } } } for classroom in &mut data.administrative_groups { classroom.class_avatar_data_url = None; if let Some(url) = classroom.class_avatar_path.clone() { match fetch_image_as_data_url(&client, &url, cookie).await { Ok(data_url) => { classroom.class_avatar_data_url = Some(data_url); } Err(err) => { log_banyou( state.inner(), LogLevel::Warn, "failed to fetch administrative group avatar", Some(serde_json::json!({ "class_id": classroom.class_id, "url": url, "error": err, })), ); } } } } log_banyou( state.inner(), LogLevel::Info, "fetch classrooms succeeded", Some(serde_json::json!({ "classrooms": data.classrooms.len(), "administrative_groups": data.administrative_groups.len(), "classrooms_avatar_data_url_count": data .classrooms .iter() .filter(|c| c.class_avatar_data_url.is_some()) .count(), })), ); Ok(IpcResponse::success(data)) }