feat: 新增班优导入并修复班级头像显示

This commit is contained in:
JSR
2026-03-25 21:40:20 +08:00
parent a651fd672a
commit 8b8ff9c916
8 changed files with 955 additions and 33 deletions
+218 -4
View File
@@ -1463,8 +1463,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1474,9 +1476,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1827,6 +1831,23 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls 0.23.37",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.6",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2338,6 +2359,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -3342,6 +3369,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.37",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls 0.23.37",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3394,6 +3476,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.0"
@@ -3425,6 +3517,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3443,6 +3545,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.0"
@@ -3560,6 +3671,44 @@ dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.37",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.6",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3752,10 +3901,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"ring",
"rustls-webpki",
"rustls-webpki 0.101.7",
"sct",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@@ -3765,6 +3928,16 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@@ -3775,6 +3948,17 @@ dependencies = [
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3985,6 +4169,7 @@ dependencies = [
"once_cell",
"parking_lot",
"rand 0.8.5",
"reqwest 0.12.28",
"rmcp",
"sea-orm",
"serde",
@@ -4494,7 +4679,7 @@ dependencies = [
"paste",
"percent-encoding",
"rust_decimal",
"rustls",
"rustls 0.21.12",
"rustls-pemfile",
"serde",
"serde_json",
@@ -4508,7 +4693,7 @@ dependencies = [
"tracing",
"url",
"uuid",
"webpki-roots",
"webpki-roots 0.25.4",
]
[[package]]
@@ -4929,7 +5114,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -5318,6 +5503,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.37",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -5969,6 +6164,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.3"
@@ -6031,6 +6236,15 @@ version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
+1
View File
@@ -38,6 +38,7 @@ tracing-appender = "0.2"
once_cell = "1"
parking_lot = { version = "0.12", features = ["send_guard"] }
dirs = "6.0.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
[profile.release]
panic = "abort"
+389 -1
View File
@@ -2,12 +2,13 @@ use parking_lot::RwLock;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
};
use serde::Deserialize;
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;
@@ -25,6 +26,49 @@ 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();
@@ -39,6 +83,80 @@ fn check_view_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>)
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>>>,
@@ -362,3 +480,273 @@ pub async fn student_import_from_xlsx(
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&timestamp={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))
}
+1
View File
@@ -38,6 +38,7 @@ pub fn run() {
student_update,
student_delete,
student_import_from_xlsx,
student_fetch_banyou_classrooms,
tags_get_all,
tags_get_by_student,
tags_create,