diff --git a/backend/src/esim.rs b/backend/src/esim.rs index 28500aa..4d447de 100644 --- a/backend/src/esim.rs +++ b/backend/src/esim.rs @@ -17,8 +17,8 @@ use tokio::sync::Mutex; use crate::config::ConfigManager; use crate::models::{ EsimCommandResponse, EsimDownloadRequest, EsimEuiccInfo, EsimLpacRepairRequest, - EsimLpacRepairResponse, EsimLpacStatusResponse, EsimProfile, EsimProfilesResponse, WorkMode, - WorkModeResponse, + EsimLpacRepairResponse, EsimLpacStatusResponse, EsimProfile, EsimProfilesResponse, + EsimRspNotification, WorkMode, WorkModeResponse, }; const ESIM_SHORT_TIMEOUT_SECS: u64 = 20; @@ -263,6 +263,53 @@ impl EsimSupervisor { Ok(normalize_profiles(response)) } + /// 读取 eUICC 中尚未处理的 RSP Profile 管理通知。 + pub async fn get_rsp_notifications(&self) -> Result, EsimApiError> { + let response = self + .call_lpac( + "notifications", + &["notification", "list"], + ESIM_SHORT_TIMEOUT_SECS, + ) + .await?; + if !command_succeeded(&response) { + return Err(EsimApiError::Command(response.msg)); + } + Ok(normalize_rsp_notifications(response)) + } + + /// 将指定 RSP 通知提交到运营商服务器,但不从 eUICC 删除。 + pub async fn process_rsp_notification(&self, sequence_number: u64) -> Result<(), EsimApiError> { + self.run_rsp_notification_command("process", sequence_number) + .await + } + + /// 从 eUICC 删除已经成功提交的 RSP 通知。 + pub async fn remove_rsp_notification(&self, sequence_number: u64) -> Result<(), EsimApiError> { + self.run_rsp_notification_command("remove", sequence_number) + .await + } + + async fn run_rsp_notification_command( + &self, + subcommand: &str, + sequence_number: u64, + ) -> Result<(), EsimApiError> { + let sequence_number = sequence_number.to_string(); + let response = self + .call_lpac( + "notification", + &["notification", subcommand, sequence_number.as_str()], + ESIM_LONG_TIMEOUT_SECS, + ) + .await?; + if command_succeeded(&response) { + Ok(()) + } else { + Err(EsimApiError::Command(response.msg)) + } + } + pub async fn enable_profile(&self, iccid: String) -> Result { self.enable_profile_with_refresh_flag(iccid, true).await } @@ -340,6 +387,33 @@ fn command_succeeded(response: &EsimCommandResponse) -> bool { || response.status.eq_ignore_ascii_case("ok")) } +fn normalize_rsp_notifications(response: EsimCommandResponse) -> Vec { + response + .data + .as_ref() + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|value| { + let sequence_number = value + .get("seqNumber") + .or_else(|| value.get("sequenceNumber")) + .or_else(|| value.get("sequence_number")) + .and_then(|value| { + value + .as_u64() + .or_else(|| value.as_str().and_then(|value| value.parse().ok())) + })?; + Some(EsimRspNotification { + sequence_number, + operation: string_from(value, &["profileManagementOperation", "operation"]) + .unwrap_or_default(), + iccid: string_from(value, &["iccid", "ICCID"]).unwrap_or_default(), + }) + }) + .collect() +} + struct LpacProbe { installed: bool, usable: bool, @@ -1685,4 +1759,39 @@ mod tests { assert_eq!(profile.disable_allowed, Some(true)); assert_eq!(profile.delete_allowed, Some(true)); } + + #[test] + fn parses_rsp_notification_list_and_sequence_aliases() { + let response = EsimCommandResponse { + data: Some(json!([ + { + "seqNumber": 41, + "profileManagementOperation": "install", + "iccid": "TEST_ICCID_1" + }, + { + "sequenceNumber": "42", + "operation": "enable", + "ICCID": "TEST_ICCID_2" + } + ])), + ..Default::default() + }; + + assert_eq!( + normalize_rsp_notifications(response), + vec![ + EsimRspNotification { + sequence_number: 41, + operation: "install".to_string(), + iccid: "TEST_ICCID_1".to_string(), + }, + EsimRspNotification { + sequence_number: 42, + operation: "enable".to_string(), + iccid: "TEST_ICCID_2".to_string(), + }, + ] + ); + } } diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index 282f419..387614e 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -10,7 +10,7 @@ use axum::{ }; use serde::Deserialize; use serde_json::json; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::process::{Command, Output}; use std::sync::atomic::Ordering; @@ -356,6 +356,162 @@ fn cached_profiles_requested(query: &std::collections::HashMap) // ============ 工作模式 / eSIM ============ +#[derive(Debug)] +enum EsimRspNotificationScope { + Download { + target_iccid: String, + }, + Enable { + target_iccid: String, + previous_iccids: HashSet, + }, +} + +impl EsimRspNotificationScope { + fn target_iccid(&self) -> &str { + match self { + Self::Download { target_iccid } | Self::Enable { target_iccid, .. } => target_iccid, + } + } +} + +fn select_new_rsp_notifications( + before_sequences: &HashSet, + notifications: Vec, + scope: &EsimRspNotificationScope, +) -> Vec { + let mut selected = notifications + .into_iter() + .filter(|notification| !before_sequences.contains(¬ification.sequence_number)) + .filter(|notification| { + let operation = notification.operation.trim().to_ascii_lowercase(); + let iccid = crate::utils::normalize_iccid(¬ification.iccid); + match scope { + EsimRspNotificationScope::Download { target_iccid } => { + operation == "install" && iccid == *target_iccid + } + EsimRspNotificationScope::Enable { + target_iccid, + previous_iccids, + } => { + (operation == "enable" && iccid == *target_iccid) + || (operation == "disable" && previous_iccids.contains(&iccid)) + } + } + }) + .collect::>(); + selected.sort_by_key(|notification| notification.sequence_number); + selected +} + +async fn capture_rsp_notification_sequences(app: &AppState) -> Option> { + match app.esim_supervisor.get_rsp_notifications().await { + Ok(notifications) => Some( + notifications + .into_iter() + .map(|notification| notification.sequence_number) + .collect(), + ), + Err(err) => { + warn!( + error = %err.message(), + "Skipping automatic RSP notification delivery because the pre-operation snapshot failed" + ); + None + } + } +} + +async fn capture_enabled_profile_iccids(app: &AppState) -> HashSet { + match app.esim_supervisor.get_profiles_for_switch().await { + Ok(response) => response + .profiles + .into_iter() + .filter(esim_profile_is_active) + .map(|profile| crate::utils::normalize_iccid(&profile.iccid)) + .filter(|iccid| !iccid.is_empty()) + .collect(), + Err(err) => { + warn!( + error = %err.message(), + "Could not capture the enabled Profile before RSP notification delivery" + ); + HashSet::new() + } + } +} + +async fn deliver_new_rsp_notifications( + app: &AppState, + before_sequences: HashSet, + scope: EsimRspNotificationScope, +) { + let notifications = match app.esim_supervisor.get_rsp_notifications().await { + Ok(notifications) => notifications, + Err(err) => { + warn!( + target = %mask_identifier(scope.target_iccid()), + error = %err.message(), + "Failed to read RSP notifications after a successful Profile operation" + ); + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_RSP_NOTIFICATION_DELIVERY_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + mask_identifier(scope.target_iccid()), + "Profile 操作成功,但无法读取待提交的运营商通知", + ) + .await; + return; + } + }; + + let mut delivery_failed = false; + for notification in select_new_rsp_notifications(&before_sequences, notifications, &scope) { + if let Err(err) = app + .esim_supervisor + .process_rsp_notification(notification.sequence_number) + .await + { + delivery_failed = true; + warn!( + sequence_number = notification.sequence_number, + target = %mask_identifier(scope.target_iccid()), + error = %err.message(), + "Failed to deliver a new RSP notification; keeping it on the eUICC" + ); + continue; + } + + if let Err(err) = app + .esim_supervisor + .remove_rsp_notification(notification.sequence_number) + .await + { + delivery_failed = true; + warn!( + sequence_number = notification.sequence_number, + target = %mask_identifier(scope.target_iccid()), + error = %err.message(), + "RSP notification was delivered but could not be removed from the eUICC" + ); + } + } + + if delivery_failed { + app.system_event_emitter + .emit_code( + system_event_codes::ESIM_RSP_NOTIFICATION_DELIVERY_FAILED, + system_event_severity::WARNING, + system_event_status::FAILED, + mask_identifier(scope.target_iccid()), + "Profile 操作成功,但部分运营商通知仍保留在 eUICC 中", + ) + .await; + } +} + fn live_refresh_requested(query: &std::collections::HashMap) -> bool { query .get("live") @@ -852,6 +1008,8 @@ pub async fn enable_esim_profile_handler( tokio::spawn(async move { let _guard = modem_manager::BasebandRestartRunGuard; + let previous_iccids = capture_enabled_profile_iccids(&bg_app).await; + let rsp_notification_sequences = capture_rsp_notification_sequences(&bg_app).await; match enable_esim_profile_for_switch(&bg_app, &bg_iccid).await { Ok(EsimProfileEnableOutcome::Enabled(data)) => { @@ -908,6 +1066,18 @@ pub async fn enable_esim_profile_handler( } } } + + if let Some(before_sequences) = rsp_notification_sequences { + deliver_new_rsp_notifications( + &bg_app, + before_sequences, + EsimRspNotificationScope::Enable { + target_iccid: crate::utils::normalize_iccid(&bg_iccid), + previous_iccids, + }, + ) + .await; + } } else { modem_manager::record_restart_step( "启用 eSIM Profile", @@ -1096,10 +1266,12 @@ pub async fn download_esim_profile_handler( .map(|p| crate::utils::normalize_iccid(&p.iccid)) .collect() }); + let rsp_notification_sequences = capture_rsp_notification_sequences(&app).await; match app.esim_supervisor.download_profile(payload.clone()).await { Ok(data) => { if esim_command_succeeded(&data) { + let downloaded_iccid; // Attempt to recursively find the downloaded profile details in lpac's response let profile_val = data.data.clone().unwrap_or(serde_json::Value::Null); if let Some(mut profile) = find_and_normalize_profile(&profile_val) { @@ -1135,6 +1307,7 @@ pub async fn download_esim_profile_handler( delete_allowed: profile.delete_allowed, updated_at: chrono::Utc::now().to_rfc3339(), }; + downloaded_iccid = Some(profile.iccid.clone()); if let Err(err) = app.database.upsert_esim_profile_cache(&entry) { warn!(iccid = %entry.iccid, error = %err, "Failed to cache downloaded eSIM profile to database"); @@ -1232,6 +1405,7 @@ pub async fn download_esim_profile_handler( .as_ref() .map(|iccid| mask_identifier(iccid)) .unwrap_or_else(|| "esim".to_string()); + downloaded_iccid = cached_fallback_iccid; app.system_event_emitter .emit_code( @@ -1243,6 +1417,22 @@ pub async fn download_esim_profile_handler( ) .await; } + + if let (Some(before_sequences), Some(target_iccid)) = + (rsp_notification_sequences, downloaded_iccid) + { + let bg_app = app.clone(); + tokio::spawn(async move { + deliver_new_rsp_notifications( + &bg_app, + before_sequences, + EsimRspNotificationScope::Download { + target_iccid: crate::utils::normalize_iccid(&target_iccid), + }, + ) + .await; + }); + } } else { let msg = data.msg.clone(); let is_refused = msg.contains("MatchingID is refused") @@ -4450,6 +4640,69 @@ mod tests { use super::*; use crate::modem_manager::SimIdentity; + fn rsp_notification(sequence_number: u64, operation: &str, iccid: &str) -> EsimRspNotification { + EsimRspNotification { + sequence_number, + operation: operation.to_string(), + iccid: iccid.to_string(), + } + } + + #[test] + fn selects_only_new_install_notification_for_downloaded_profile() { + const TARGET_ICCID: &str = "8900000000000000001"; + const OTHER_ICCID: &str = "8900000000000000002"; + let before_sequences = HashSet::from([10, 11]); + let notifications = vec![ + rsp_notification(10, "install", TARGET_ICCID), + rsp_notification(12, "enable", TARGET_ICCID), + rsp_notification(13, "install", OTHER_ICCID), + rsp_notification(14, "install", TARGET_ICCID), + ]; + let scope = EsimRspNotificationScope::Download { + target_iccid: crate::utils::normalize_iccid(TARGET_ICCID), + }; + + let selected = select_new_rsp_notifications(&before_sequences, notifications, &scope); + + assert_eq!( + selected + .iter() + .map(|notification| notification.sequence_number) + .collect::>(), + vec![14] + ); + } + + #[test] + fn selects_only_new_enable_and_previous_disable_notifications_for_switch() { + const TARGET_ICCID: &str = "8900000000000000001"; + const PREVIOUS_ICCID: &str = "8900000000000000002"; + const UNRELATED_ICCID: &str = "8900000000000000003"; + let before_sequences = HashSet::from([20]); + let notifications = vec![ + rsp_notification(25, "disable", UNRELATED_ICCID), + rsp_notification(24, "enable", TARGET_ICCID), + rsp_notification(23, "disable", PREVIOUS_ICCID), + rsp_notification(22, "install", TARGET_ICCID), + rsp_notification(20, "enable", TARGET_ICCID), + ]; + let scope = EsimRspNotificationScope::Enable { + target_iccid: crate::utils::normalize_iccid(TARGET_ICCID), + previous_iccids: HashSet::from([crate::utils::normalize_iccid(PREVIOUS_ICCID)]), + }; + + let selected = select_new_rsp_notifications(&before_sequences, notifications, &scope); + + assert_eq!( + selected + .iter() + .map(|notification| notification.sequence_number) + .collect::>(), + vec![23, 24] + ); + } + #[test] fn enriches_enabled_esim_profile_from_current_sim_identity() { let mut profiles = vec![ diff --git a/backend/src/models.rs b/backend/src/models.rs index f5c511f..6ed4b02 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -80,6 +80,14 @@ pub struct EsimCommandResponse { pub data: Option, } +/// eUICC 中等待提交到运营商服务器的 Profile 管理通知。 +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct EsimRspNotification { + pub sequence_number: u64, + pub operation: String, + pub iccid: String, +} + #[derive(Debug, Default, Serialize, Clone)] pub struct EsimEuiccInfo { pub eid: String, diff --git a/backend/src/system_event.rs b/backend/src/system_event.rs index 930c320..02f8d70 100644 --- a/backend/src/system_event.rs +++ b/backend/src/system_event.rs @@ -76,6 +76,7 @@ pub mod codes { "esim.profile_switch_baseband_recovery_failed"; pub const ESIM_PROFILE_DOWNLOAD_SUCCEEDED: &str = "esim.profile_download_succeeded"; pub const ESIM_PROFILE_DOWNLOAD_FAILED: &str = "esim.profile_download_failed"; + pub const ESIM_RSP_NOTIFICATION_DELIVERY_FAILED: &str = "esim.rsp_notification_delivery_failed"; pub const RESOURCE_TEMPERATURE_HIGH: &str = "resource.temperature_high"; pub const RESOURCE_TEMPERATURE_RECOVERED: &str = "resource.temperature_recovered"; @@ -342,6 +343,13 @@ pub const SYSTEM_EVENT_DEFINITIONS: &[SystemEventDefinition] = &[ "Profile 写入失败", true, ), + def( + codes::ESIM_RSP_NOTIFICATION_DELIVERY_FAILED, + category::ESIM, + "SIM/eSIM", + "运营商 Profile 通知提交失败", + true, + ), def( codes::RESOURCE_TEMPERATURE_HIGH, category::RESOURCE, diff --git a/frontend/src/pages/notifications/systemEventModel.ts b/frontend/src/pages/notifications/systemEventModel.ts index e144cac..3e86e71 100644 --- a/frontend/src/pages/notifications/systemEventModel.ts +++ b/frontend/src/pages/notifications/systemEventModel.ts @@ -57,6 +57,7 @@ export const SYSTEM_EVENT_GROUPS: SystemEventGroup[] = [ { code: 'esim.profile_enable_failed', label: 'Profile 启用失败', defaultEnabled: true }, { code: 'esim.profile_deleted', label: 'Profile 删除', defaultEnabled: true }, { code: 'esim.profile_switch_baseband_recovery_failed', label: 'Profile 切换后基带恢复失败', defaultEnabled: true }, + { code: 'esim.rsp_notification_delivery_failed', label: '运营商 Profile 通知提交失败', defaultEnabled: true }, ], }, {