后端通用错误处理

This commit is contained in:
xintaofei
2026-03-07 12:51:23 +08:00
parent 6c48be023c
commit 07963e9706
3 changed files with 208 additions and 90 deletions

View File

@@ -1,5 +1,6 @@
use std::collections::{HashMap, HashSet};
use crate::app_error::{AppCommandError, AppErrorCode};
use crate::db::entities::conversation;
use crate::db::service::{conversation_service, folder_service, import_service};
use crate::db::AppDatabase;
@@ -8,7 +9,7 @@ use crate::parsers::claude::ClaudeParser;
use crate::parsers::codex::CodexParser;
use crate::parsers::gemini::GeminiParser;
use crate::parsers::opencode::OpenCodeParser;
use crate::parsers::{path_eq_for_matching, AgentParser};
use crate::parsers::{path_eq_for_matching, AgentParser, ParseError};
#[tauri::command]
pub async fn list_folder_conversations(
@@ -18,10 +19,10 @@ pub async fn list_folder_conversations(
search: Option<String>,
sort_by: Option<String>,
status: Option<String>,
) -> Result<Vec<DbConversationSummary>, String> {
) -> Result<Vec<DbConversationSummary>, AppCommandError> {
conversation_service::list_by_folder(&db.conn, folder_id, agent_type, search, sort_by, status)
.await
.map_err(|e| e.to_string())
.map_err(AppCommandError::from)
}
/// Synchronous implementation shared by list_conversations, list_folders, and get_stats.
@@ -30,7 +31,7 @@ fn list_conversations_sync(
search: Option<String>,
sort_by: Option<String>,
folder_path: Option<String>,
) -> Result<Vec<ConversationSummary>, String> {
) -> Vec<ConversationSummary> {
let mut all_conversations = Vec::new();
let mut seen_keys = HashSet::new();
@@ -103,7 +104,7 @@ fn list_conversations_sync(
_ => all_conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at)), // default: newest first
}
Ok(all_conversations)
all_conversations
}
#[tauri::command]
@@ -112,70 +113,89 @@ pub async fn list_conversations(
search: Option<String>,
sort_by: Option<String>,
folder_path: Option<String>,
) -> Result<Vec<ConversationSummary>, String> {
) -> Result<Vec<ConversationSummary>, AppCommandError> {
tokio::task::spawn_blocking(move || {
list_conversations_sync(agent_type, search, sort_by, folder_path)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| {
AppCommandError::new(AppErrorCode::Unknown, "Failed to list conversations")
.with_detail(e.to_string())
})
}
#[tauri::command]
pub async fn get_conversation(
agent_type: AgentType,
conversation_id: String,
) -> Result<ConversationDetail, String> {
tokio::task::spawn_blocking(move || {
) -> Result<ConversationDetail, AppCommandError> {
tokio::task::spawn_blocking(move || -> Result<ConversationDetail, AppCommandError> {
let parser: Box<dyn AgentParser> = match agent_type {
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
AgentType::Codex => Box::new(CodexParser::new()),
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
AgentType::Gemini => Box::new(GeminiParser::new()),
_ => {
return Err(format!(
"conversation parsing not supported for {agent_type}"
))
return Err(
AppCommandError::new(
AppErrorCode::InvalidInput,
"Conversation parsing is not supported for this agent",
)
.with_detail(format!("agent_type={agent_type}")),
)
}
};
parser
.get_conversation(&conversation_id)
.map_err(|e| e.to_string())
.map_err(parse_error_to_app_error)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| {
AppCommandError::new(AppErrorCode::Unknown, "Failed to load conversation")
.with_detail(e.to_string())
})?
}
#[tauri::command]
pub async fn list_folders() -> Result<Vec<FolderInfo>, String> {
tokio::task::spawn_blocking(move || {
let all_conversations = list_conversations_sync(None, None, None, None)?;
pub async fn list_folders() -> Result<Vec<FolderInfo>, AppCommandError> {
tokio::task::spawn_blocking(move || -> Result<Vec<FolderInfo>, AppCommandError> {
let all_conversations = list_conversations_sync(None, None, None, None);
Ok(compute_folders(&all_conversations))
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| {
AppCommandError::new(AppErrorCode::Unknown, "Failed to list folders")
.with_detail(e.to_string())
})?
}
#[tauri::command]
pub async fn get_stats() -> Result<AgentStats, String> {
tokio::task::spawn_blocking(move || {
let all_conversations = list_conversations_sync(None, None, None, None)?;
compute_stats(&all_conversations)
pub async fn get_stats() -> Result<AgentStats, AppCommandError> {
tokio::task::spawn_blocking(move || -> Result<AgentStats, AppCommandError> {
let all_conversations = list_conversations_sync(None, None, None, None);
Ok(compute_stats(&all_conversations))
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| {
AppCommandError::new(AppErrorCode::Unknown, "Failed to compute conversation stats")
.with_detail(e.to_string())
})?
}
#[tauri::command]
pub async fn get_sidebar_data() -> Result<SidebarData, String> {
tokio::task::spawn_blocking(move || {
let all_conversations = list_conversations_sync(None, None, None, None)?;
pub async fn get_sidebar_data() -> Result<SidebarData, AppCommandError> {
tokio::task::spawn_blocking(move || -> Result<SidebarData, AppCommandError> {
let all_conversations = list_conversations_sync(None, None, None, None);
let folders = compute_folders(&all_conversations);
let stats = compute_stats(&all_conversations)?;
let stats = compute_stats(&all_conversations);
Ok(SidebarData { folders, stats })
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| {
AppCommandError::new(AppErrorCode::Unknown, "Failed to build sidebar data")
.with_detail(e.to_string())
})?
}
fn compute_folders(all_conversations: &[ConversationSummary]) -> Vec<FolderInfo> {
@@ -215,30 +235,33 @@ fn compute_folders(all_conversations: &[ConversationSummary]) -> Vec<FolderInfo>
pub async fn import_local_conversations(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
) -> Result<ImportResult, String> {
) -> Result<ImportResult, AppCommandError> {
let folder = folder_service::get_folder_by_id(&db.conn, folder_id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Folder not found: {folder_id}"))?;
.map_err(AppCommandError::from)?
.ok_or_else(|| {
AppCommandError::new(AppErrorCode::NotFound, "Folder not found")
.with_detail(format!("folder_id={folder_id}"))
})?;
import_service::import_local_conversations(&db.conn, folder_id, &folder.path)
.await
.map_err(|e| e.to_string())
.map_err(AppCommandError::from)
}
#[tauri::command]
pub async fn get_folder_conversation(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
) -> Result<DbConversationDetail, String> {
) -> Result<DbConversationDetail, AppCommandError> {
let summary = conversation_service::get_by_id(&db.conn, conversation_id)
.await
.map_err(|e| e.to_string())?;
.map_err(AppCommandError::from)?;
let (turns, session_stats) = if let Some(ref ext_id) = summary.external_id {
let at = summary.agent_type;
let eid = ext_id.clone();
tokio::task::spawn_blocking(move || -> Result<_, String> {
tokio::task::spawn_blocking(move || -> Result<_, AppCommandError> {
let parser: Box<dyn AgentParser> = match at {
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
AgentType::Codex => Box::new(CodexParser::new()),
@@ -251,12 +274,17 @@ pub async fn get_folder_conversation(
match parser.get_conversation(&eid) {
Ok(d) => Ok((d.turns, d.session_stats)),
Err(crate::parsers::ParseError::ConversationNotFound(_)) => Ok((vec![], None)),
Err(e) => Err(e.to_string()),
Err(e) => Err(parse_error_to_app_error(e)),
}
})
.await
.map_err(|e| e.to_string())?
.map_err(|e: String| e)?
.map_err(|e| {
AppCommandError::new(
AppErrorCode::Unknown,
"Failed to read conversation turns from session file",
)
.with_detail(e.to_string())
})??
} else {
(vec![], None)
};
@@ -277,11 +305,11 @@ pub async fn create_conversation(
folder_id: i32,
agent_type: AgentType,
title: Option<String>,
) -> Result<i32, String> {
) -> Result<i32, AppCommandError> {
// Detect current git branch from the folder path
let git_branch = if let Some(folder) = folder_service::get_folder_by_id(&db.conn, folder_id)
.await
.map_err(|e| e.to_string())?
.map_err(AppCommandError::from)?
{
detect_git_branch(&folder.path).await
} else {
@@ -290,7 +318,7 @@ pub async fn create_conversation(
let model = conversation_service::create(&db.conn, folder_id, agent_type, title, git_branch)
.await
.map_err(|e| e.to_string())?;
.map_err(AppCommandError::from)?;
Ok(model.id)
}
@@ -318,12 +346,17 @@ pub async fn update_conversation_status(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
status: String,
) -> Result<(), String> {
let status_enum: conversation::ConversationStatus =
serde_json::from_value(serde_json::Value::String(status)).map_err(|e| e.to_string())?;
) -> Result<(), AppCommandError> {
let status_enum: conversation::ConversationStatus = serde_json::from_value(
serde_json::Value::String(status),
)
.map_err(|e| {
AppCommandError::new(AppErrorCode::InvalidInput, "Invalid conversation status")
.with_detail(e.to_string())
})?;
conversation_service::update_status(&db.conn, conversation_id, status_enum)
.await
.map_err(|e| e.to_string())
.map_err(AppCommandError::from)
}
#[tauri::command]
@@ -331,10 +364,10 @@ pub async fn update_conversation_title(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
title: String,
) -> Result<(), String> {
) -> Result<(), AppCommandError> {
conversation_service::update_title(&db.conn, conversation_id, title)
.await
.map_err(|e| e.to_string())
.map_err(AppCommandError::from)
}
#[tauri::command]
@@ -342,23 +375,23 @@ pub async fn update_conversation_external_id(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
external_id: String,
) -> Result<(), String> {
) -> Result<(), AppCommandError> {
conversation_service::update_external_id(&db.conn, conversation_id, external_id)
.await
.map_err(|e| e.to_string())
.map_err(AppCommandError::from)
}
#[tauri::command]
pub async fn delete_conversation(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
) -> Result<(), String> {
) -> Result<(), AppCommandError> {
conversation_service::soft_delete(&db.conn, conversation_id)
.await
.map_err(|e| e.to_string())
.map_err(AppCommandError::from)
}
fn compute_stats(all_conversations: &[ConversationSummary]) -> Result<AgentStats, String> {
fn compute_stats(all_conversations: &[ConversationSummary]) -> AgentStats {
let mut total_messages: u32 = 0;
let mut counts: HashMap<AgentType, u32> = HashMap::new();
@@ -376,9 +409,32 @@ fn compute_stats(all_conversations: &[ConversationSummary]) -> Result<AgentStats
.collect();
by_agent.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
Ok(AgentStats {
AgentStats {
total_conversations: all_conversations.len() as u32,
total_messages,
by_agent,
})
}
}
fn parse_error_to_app_error(error: ParseError) -> AppCommandError {
match error {
ParseError::ConversationNotFound(id) => {
AppCommandError::new(AppErrorCode::NotFound, "Conversation not found")
.with_detail(id)
}
ParseError::InvalidData(message) => {
AppCommandError::new(AppErrorCode::InvalidInput, "Invalid conversation data")
.with_detail(message)
}
ParseError::Io(err) => AppCommandError::new(AppErrorCode::IoError, "I/O operation failed")
.with_detail(err.to_string()),
ParseError::Json(err) => {
AppCommandError::new(AppErrorCode::InvalidInput, "Failed to parse conversation file")
.with_detail(err.to_string())
}
ParseError::Db(err) => {
AppCommandError::new(AppErrorCode::DatabaseError, "Database operation failed")
.with_detail(err.to_string())
}
}
}

View File

@@ -1,6 +1,7 @@
use sea_orm::DatabaseConnection;
use tauri::State;
use crate::app_error::{AppCommandError, AppErrorCode};
use crate::db::service::app_metadata_service;
use crate::db::AppDatabase;
use crate::models::{SystemLanguageSettings, SystemProxySettings};
@@ -9,7 +10,9 @@ use crate::network::proxy;
const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings";
const SYSTEM_LANGUAGE_SETTINGS_KEY: &str = "system_language_settings";
fn normalize_proxy_settings(settings: SystemProxySettings) -> Result<SystemProxySettings, String> {
fn normalize_proxy_settings(
settings: SystemProxySettings,
) -> Result<SystemProxySettings, AppCommandError> {
if !settings.enabled {
let proxy_url = settings
.proxy_url
@@ -29,9 +32,17 @@ fn normalize_proxy_settings(settings: SystemProxySettings) -> Result<SystemProxy
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "proxy url is required when proxy is enabled".to_string())?;
.ok_or_else(|| {
AppCommandError::new(
AppErrorCode::InvalidInput,
"Proxy URL is required when proxy is enabled",
)
})?;
reqwest::Proxy::all(proxy_url).map_err(|e| format!("invalid proxy url: {e}"))?;
reqwest::Proxy::all(proxy_url).map_err(|e| {
AppCommandError::new(AppErrorCode::InvalidInput, "Invalid proxy URL")
.with_detail(e.to_string())
})?;
Ok(SystemProxySettings {
enabled: true,
@@ -41,39 +52,49 @@ fn normalize_proxy_settings(settings: SystemProxySettings) -> Result<SystemProxy
pub(crate) async fn load_system_proxy_settings(
conn: &DatabaseConnection,
) -> Result<SystemProxySettings, String> {
) -> Result<SystemProxySettings, AppCommandError> {
let raw = app_metadata_service::get_value(conn, SYSTEM_PROXY_SETTINGS_KEY)
.await
.map_err(|e| e.to_string())?;
.map_err(AppCommandError::from)?;
let Some(raw) = raw else {
return Ok(SystemProxySettings::default());
};
let parsed = serde_json::from_str::<SystemProxySettings>(&raw)
.map_err(|e| format!("failed to parse stored proxy settings: {e}"))?;
let parsed = serde_json::from_str::<SystemProxySettings>(&raw).map_err(|e| {
AppCommandError::new(
AppErrorCode::InvalidInput,
"Failed to parse stored proxy settings",
)
.with_detail(e.to_string())
})?;
normalize_proxy_settings(parsed)
}
pub(crate) async fn load_system_language_settings(
conn: &DatabaseConnection,
) -> Result<SystemLanguageSettings, String> {
) -> Result<SystemLanguageSettings, AppCommandError> {
let raw = app_metadata_service::get_value(conn, SYSTEM_LANGUAGE_SETTINGS_KEY)
.await
.map_err(|e| e.to_string())?;
.map_err(AppCommandError::from)?;
let Some(raw) = raw else {
return Ok(SystemLanguageSettings::default());
};
serde_json::from_str::<SystemLanguageSettings>(&raw)
.map_err(|e| format!("failed to parse stored language settings: {e}"))
serde_json::from_str::<SystemLanguageSettings>(&raw).map_err(|e| {
AppCommandError::new(
AppErrorCode::InvalidInput,
"Failed to parse stored language settings",
)
.with_detail(e.to_string())
})
}
#[tauri::command]
pub async fn get_system_proxy_settings(
db: State<'_, AppDatabase>,
) -> Result<SystemProxySettings, String> {
) -> Result<SystemProxySettings, AppCommandError> {
load_system_proxy_settings(&db.conn).await
}
@@ -81,22 +102,34 @@ pub async fn get_system_proxy_settings(
pub async fn update_system_proxy_settings(
settings: SystemProxySettings,
db: State<'_, AppDatabase>,
) -> Result<SystemProxySettings, String> {
) -> Result<SystemProxySettings, AppCommandError> {
let normalized = normalize_proxy_settings(settings)?;
let serialized = serde_json::to_string(&normalized).map_err(|e| e.to_string())?;
let serialized = serde_json::to_string(&normalized).map_err(|e| {
AppCommandError::new(
AppErrorCode::InvalidInput,
"Failed to serialize proxy settings",
)
.with_detail(e.to_string())
})?;
app_metadata_service::upsert_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY, &serialized)
.await
.map_err(|e| e.to_string())?;
.map_err(AppCommandError::from)?;
proxy::apply_system_proxy_settings(&normalized)?;
proxy::apply_system_proxy_settings(&normalized).map_err(|e| {
AppCommandError::new(
AppErrorCode::ExternalCommandFailed,
"Failed to apply system proxy settings",
)
.with_detail(e)
})?;
Ok(normalized)
}
#[tauri::command]
pub async fn get_system_language_settings(
db: State<'_, AppDatabase>,
) -> Result<SystemLanguageSettings, String> {
) -> Result<SystemLanguageSettings, AppCommandError> {
load_system_language_settings(&db.conn).await
}
@@ -104,12 +137,18 @@ pub async fn get_system_language_settings(
pub async fn update_system_language_settings(
settings: SystemLanguageSettings,
db: State<'_, AppDatabase>,
) -> Result<SystemLanguageSettings, String> {
let serialized = serde_json::to_string(&settings).map_err(|e| e.to_string())?;
) -> Result<SystemLanguageSettings, AppCommandError> {
let serialized = serde_json::to_string(&settings).map_err(|e| {
AppCommandError::new(
AppErrorCode::InvalidInput,
"Failed to serialize language settings",
)
.with_detail(e.to_string())
})?;
app_metadata_service::upsert_value(&db.conn, SYSTEM_LANGUAGE_SETTINGS_KEY, &serialized)
.await
.map_err(|e| e.to_string())?;
.map_err(AppCommandError::from)?;
Ok(settings)
}

View File

@@ -3,7 +3,7 @@ use std::sync::Mutex;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use crate::app_error::AppCommandError;
use crate::app_error::{AppCommandError, AppErrorCode};
use crate::db::AppDatabase;
use crate::models::FolderHistoryEntry;
@@ -152,7 +152,7 @@ fn resolve_settings_target(section: Option<&str>, agent_type: Option<&str>) -> S
pub async fn list_open_folders(
app: AppHandle,
db: tauri::State<'_, AppDatabase>,
) -> Result<Vec<FolderHistoryEntry>, String> {
) -> Result<Vec<FolderHistoryEntry>, AppCommandError> {
let windows = app.webview_windows();
let mut folder_ids: Vec<i32> = Vec::new();
@@ -166,7 +166,7 @@ pub async fn list_open_folders(
let all_folders = crate::db::service::folder_service::list_folders(&db.conn)
.await
.map_err(|e| e.to_string())?;
.map_err(AppCommandError::from)?;
let open_folders: Vec<FolderHistoryEntry> = all_folders
.into_iter()
@@ -177,19 +177,27 @@ pub async fn list_open_folders(
}
#[tauri::command]
pub async fn focus_folder_window(app: AppHandle, folder_id: i32) -> Result<(), String> {
pub async fn focus_folder_window(app: AppHandle, folder_id: i32) -> Result<(), AppCommandError> {
let windows = app.webview_windows();
for (label, window) in &windows {
if label.starts_with("folder-") {
if let Some(id) = get_folder_id_from_window(window) {
if id == folder_id {
window.set_focus().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| {
AppCommandError::window("Failed to focus folder window", e.to_string())
})?;
return Ok(());
}
}
}
}
Err(format!("No open window for folder {}", folder_id))
Err(
AppCommandError::new(
AppErrorCode::NotFound,
format!("No open window for folder {folder_id}"),
)
.with_detail(format!("folder_id={folder_id}")),
)
}
#[tauri::command]
@@ -231,24 +239,34 @@ pub async fn open_commit_window(
db: tauri::State<'_, AppDatabase>,
state: tauri::State<'_, CommitWindowState>,
folder_id: i32,
) -> Result<(), String> {
) -> Result<(), AppCommandError> {
let owner_label = window.label().to_string();
let label = format!("commit-{folder_id}");
if let Some(existing) = app.get_webview_window(&label) {
if let Some(owner_window) = app.get_webview_window(&owner_label) {
owner_window.set_enabled(false).map_err(|e| e.to_string())?;
owner_window.set_enabled(false).map_err(|e| {
AppCommandError::window("Failed to disable owner window", e.to_string())
})?;
}
state.set_owner(label.clone(), owner_label);
let _ = existing.unminimize();
existing.set_focus().map_err(|e| e.to_string())?;
existing.set_focus().map_err(|e| {
AppCommandError::window("Failed to focus commit window", e.to_string())
})?;
return Ok(());
}
let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Folder {} not found", folder_id))?;
.map_err(AppCommandError::from)?
.ok_or_else(|| {
AppCommandError::new(
AppErrorCode::NotFound,
format!("Folder {folder_id} not found"),
)
.with_detail(format!("folder_id={folder_id}"))
})?;
let url = WebviewUrl::App(format!("commit?folderId={folder_id}").into());
let builder = WebviewWindowBuilder::new(&app, &label, url)
@@ -259,16 +277,21 @@ pub async fn open_commit_window(
.center();
let commit_window = apply_platform_window_style(builder)
.build()
.map_err(|e| e.to_string())?;
.map_err(|e| AppCommandError::window("Failed to open commit window", e.to_string()))?;
ensure_windows_undecorated(&commit_window);
if let Some(owner_window) = app.get_webview_window(&owner_label) {
if let Err(err) = owner_window.set_enabled(false) {
let _ = commit_window.close();
return Err(err.to_string());
return Err(AppCommandError::window(
"Failed to disable owner window",
err.to_string(),
));
}
}
state.set_owner(label, owner_label);
commit_window.set_focus().map_err(|e| e.to_string())?;
commit_window
.set_focus()
.map_err(|e| AppCommandError::window("Failed to focus commit window", e.to_string()))?;
Ok(())
}
@@ -359,7 +382,7 @@ pub fn restore_window_after_commit(
}
}
pub fn open_welcome_window(app: &AppHandle) -> Result<(), String> {
pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> {
if let Some(existing) = app.get_webview_window("welcome") {
ensure_windows_undecorated(&existing);
return Ok(());
@@ -372,7 +395,7 @@ pub fn open_welcome_window(app: &AppHandle) -> Result<(), String> {
.center();
let welcome_window = apply_platform_window_style(builder)
.build()
.map_err(|e| e.to_string())?;
.map_err(|e| AppCommandError::window("Failed to open welcome window", e.to_string()))?;
ensure_windows_undecorated(&welcome_window);
Ok(())
}