mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
753 lines
25 KiB
Rust
753 lines
25 KiB
Rust
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<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct ImportStudentsParams {
|
||
pub names: Vec<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
#[serde(default)]
|
||
pub master_name: Option<String>,
|
||
#[serde(default)]
|
||
pub students_num: Option<i32>,
|
||
#[serde(default)]
|
||
pub praise_count: Option<i32>,
|
||
#[serde(default)]
|
||
pub class_avatar_path: Option<String>,
|
||
#[serde(default)]
|
||
pub class_avatar_data_url: Option<String>,
|
||
#[serde(default)]
|
||
pub is_own: Option<bool>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct BanYouClassroomFetchData {
|
||
#[serde(default)]
|
||
pub classrooms: Vec<BanYouClassroom>,
|
||
#[serde(default)]
|
||
pub administrative_groups: Vec<BanYouClassroom>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct BanYouClassroomFetchApiResponse {
|
||
pub code: i32,
|
||
pub data: Option<BanYouClassroomFetchData>,
|
||
pub message: Option<String>,
|
||
}
|
||
|
||
fn check_admin_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>) -> bool {
|
||
let state_guard = state.read();
|
||
let mut permissions = state_guard.permissions.write();
|
||
let id = sender_id.unwrap_or(0);
|
||
permissions.require_permission(id, PermissionLevel::Admin)
|
||
}
|
||
|
||
fn check_view_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>) -> bool {
|
||
let state_guard = state.read();
|
||
let mut permissions = state_guard.permissions.write();
|
||
let id = sender_id.unwrap_or(0);
|
||
permissions.require_permission(id, PermissionLevel::View)
|
||
}
|
||
|
||
fn log_banyou(
|
||
state: &Arc<RwLock<AppState>>,
|
||
level: LogLevel,
|
||
message: &str,
|
||
meta: Option<serde_json::Value>,
|
||
) {
|
||
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<String> {
|
||
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<String, String> {
|
||
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<RwLock<AppState>>>,
|
||
sender_id: Option<u32>,
|
||
) -> Result<IpcResponse<Vec<StudentWithTags>>, 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<StudentWithTags> = 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<RwLock<AppState>>>,
|
||
sender_id: Option<u32>,
|
||
data: CreateStudentData,
|
||
) -> Result<IpcResponse<i32>, 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<RwLock<AppState>>>,
|
||
sender_id: Option<u32>,
|
||
id: i32,
|
||
data: StudentUpdate,
|
||
) -> Result<IpcResponse<()>, 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<RwLock<AppState>>>,
|
||
sender_id: Option<u32>,
|
||
id: i32,
|
||
) -> Result<IpcResponse<()>, 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<RwLock<AppState>>>,
|
||
sender_id: Option<u32>,
|
||
params: ImportStudentsParams,
|
||
) -> Result<IpcResponse<ImportResult>, String> {
|
||
if !check_admin_permission(&state, sender_id) {
|
||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||
}
|
||
|
||
let cleaned: Vec<String> = 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<String> = cleaned.into_iter().collect();
|
||
let unique_names: Vec<String> = 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::<Vec<_>>()),
|
||
)
|
||
.all(conn)
|
||
.await;
|
||
|
||
match existing {
|
||
Ok(existing_students) => {
|
||
let existing_names: std::collections::HashSet<String> =
|
||
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<RwLock<AppState>>>,
|
||
sender_id: Option<u32>,
|
||
params: FetchBanYouClassroomsParams,
|
||
) -> Result<IpcResponse<BanYouClassroomFetchData>, 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))
|
||
}
|