diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 6a9a69ffe..95c173f19 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -1792,6 +1792,8 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission "/admin/api/debug/http" => AnyTab(&["debug"]), "/admin/api/tracking/events" => AnyTab(&["tracking"]), "/admin/api/tracking/event-keys" => AnyTab(&["tracking", "tasks"]), + "/admin/api/error-reports" => AnyTab(&["error-reports"]), + path if path.starts_with("/admin/api/error-reports/") => AnyTab(&["error-reports"]), "/admin/api/feature-gates" => AnyTab(&["gray-release"]), "/admin/api/editor-generation-pricing" => AnyTab(&["editor-generation-pricing"]), "/admin/api/editor-assets" => AnyTab(&["editor-assets"]), @@ -5646,6 +5648,7 @@ mod tests { ("tables", Method::GET, "/admin/api/database/tables"), ("debug", Method::POST, "/admin/api/debug/http"), ("tracking", Method::GET, "/admin/api/tracking/events"), + ("error-reports", Method::GET, "/admin/api/error-reports"), ("gray-release", Method::GET, "/admin/api/feature-gates"), ("redeem", Method::GET, "/admin/api/profile/redeem-codes"), ("invite", Method::GET, "/admin/api/profile/invite-codes"), diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 0560ab8c9..11e9ca980 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -46,6 +46,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::platform::router(state.clone())) .merge(modules::external_generation::router(state.clone())) .merge(modules::platform_support::router(state.clone())) + .merge(crate::error_reports::router(state.clone())) .route( "/api/profile/recharge/wechat/notify", post(handle_wechat_pay_notify), diff --git a/server-rs/crates/api-server/src/error_reports.rs b/server-rs/crates/api-server/src/error_reports.rs new file mode 100644 index 000000000..191d45dd1 --- /dev/null +++ b/server-rs/crates/api-server/src/error_reports.rs @@ -0,0 +1,915 @@ +use std::{ + collections::HashSet, + fs, + io::{Cursor, Write}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use axum::{ + extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, State}, + http::{header, HeaderValue, StatusCode}, + middleware, + response::Response, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use tokio::sync::Mutex; +use uuid::Uuid; +use zip::{write::FileOptions, ZipWriter}; + +use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest}; +use shared_contracts::error_reports::{ + AdminErrorReportEntry, AdminErrorReportListQuery, AdminErrorReportListResponse, + AdminUpdateErrorReportRequest, CreateErrorReportBatchRequest, CreateErrorReportBatchResponse, + ErrorReportLogInput, Event, +}; + +use crate::{ + admin::{require_admin_auth, AuthenticatedAdmin}, + api_response::json_success_body, + auth::{require_bearer_auth, AuthenticatedAccessToken}, + http_error::AppError, + request_context::RequestContext, + state::AppState, + tracking::{record_tracking_event_after_success, TrackingEventDraft}, +}; + +const MAX_EVENTS: usize = 100; +const MAX_LOGS: usize = 5; +const MAX_DESCRIPTION_CHARS: usize = 2_000; +const MAX_LOG_CHARS: usize = 2_000_000; +const MAX_BATCH_BYTES: usize = 20 * 1024 * 1024; +const MAX_REQUEST_BODY_BYTES: usize = 24 * 1024 * 1024; +const MAX_EVENT_FIELD_CHARS: usize = 512; + +pub struct ErrorReportStore { + directory: PathBuf, + lock: Mutex<()>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredErrorReport { + pub batch_id: String, + pub submission_id: String, + pub user_id: String, + pub oss_object_key: Option, + pub archive_sha256: Option, + pub archive_size_bytes: u64, + pub event_count: u32, + pub upload_status: String, + pub review_status: String, + pub admin_note: Option, + pub log_count: u32, + pub first_fingerprint: Option, + pub first_source: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorReportDetail { + #[serde(flatten)] + metadata: StoredErrorReport, + events: Vec, + log_names: Vec, + user_description: Option, + status: String, + note: Option, + attachment_size_bytes: u64, +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route( + "/api/error-reports", + post(create_error_report) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES)), + ) + .route( + "/admin/api/error-reports", + get(admin_list_error_reports).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/error-reports/{batch_id}", + get(admin_get_error_report) + .patch(admin_update_error_report) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/error-reports/{batch_id}/download", + get(admin_download_error_report) + .route_layer(middleware::from_fn_with_state(state, require_admin_auth)), + ) +} + +pub async fn create_error_report( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let user_id = authenticated.claims().user_id().to_string(); + let accepted_event_ids = payload + .events + .iter() + .map(|event| event.event_id.clone()) + .collect::>(); + let stored = state + .error_report_store() + .create(user_id, payload) + .await + .map_err(|message| AppError::from_status(StatusCode::BAD_REQUEST).with_message(message))?; + let stored = match state.oss_client() { + Some(oss) => { + let archive = state + .error_report_store() + .read_archive(&stored.batch_id) + .await + .map_err(internal_store_error)?; + let digest = format!("{:x}", Sha256::digest(&archive)); + let now = OffsetDateTime::now_utc(); + let date = now.date(); + let key_segments = vec![ + "error-reports".to_string(), + "v1".to_string(), + format!("{:04}", date.year()), + format!("{:02}", u8::from(date.month())), + format!("{:02}", date.day()), + ]; + let response = oss + .put_object( + state.editor_oss_http_client(), + OssPutObjectRequest { + prefix: LegacyAssetPrefix::AgcErrorReports, + path_segments: key_segments, + file_name: format!("{}.zip", stored.batch_id), + content_type: Some("application/zip".to_string()), + access: OssObjectAccess::Private, + metadata: Default::default(), + body: archive, + }, + ) + .await + .map_err(|e| e.to_string()); + let response = match response { + Ok(response) => response, + Err(_error) => { + let _ = state + .error_report_store() + .mark_upload_failed(&stored.batch_id) + .await; + return Err(AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("诊断包上传失败")); + } + }; + state + .error_report_store() + .mark_uploaded(&stored.batch_id, response.object_key, digest) + .await + .map_err(internal_store_error)? + } + None => state + .error_report_store() + .mark_upload_failed(&stored.batch_id) + .await + .map_err(internal_store_error)?, + }; + Ok(json_success_body( + Some(&request_context), + CreateErrorReportBatchResponse { + batch_id: stored.batch_id, + submission_id: stored.submission_id, + status: stored.upload_status, + accepted_event_ids, + created_at: stored.created_at, + }, + )) +} + +pub async fn admin_list_error_reports( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Query(query): Query, +) -> Result, AppError> { + let reports = state + .error_report_store() + .list(query) + .await + .map_err(internal_store_error)?; + record_admin_report_audit( + &state, + &request_context, + admin.session().subject.as_str(), + "list", + None, + ) + .await; + Ok(json_success_body( + Some(&request_context), + AdminErrorReportListResponse { reports }, + )) +} + +pub async fn admin_get_error_report( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + AxumPath(batch_id): AxumPath, +) -> Result, AppError> { + let report = state + .error_report_store() + .get(&batch_id) + .await + .map_err(|_| AppError::from_status(StatusCode::NOT_FOUND).with_message("报告不存在"))?; + record_admin_report_audit( + &state, + &request_context, + admin.session().subject.as_str(), + "view", + Some(&batch_id), + ) + .await; + Ok(json_success_body(Some(&request_context), report)) +} + +pub async fn admin_update_error_report( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + AxumPath(batch_id): AxumPath, + Json(payload): Json, +) -> Result, AppError> { + state + .error_report_store() + .update(&batch_id, payload) + .await + .map_err(|message| AppError::from_status(StatusCode::BAD_REQUEST).with_message(message))?; + let report = state + .error_report_store() + .get(&batch_id) + .await + .map_err(internal_store_error)?; + record_admin_report_audit( + &state, + &request_context, + admin.session().subject.as_str(), + "update", + Some(&batch_id), + ) + .await; + Ok(json_success_body(Some(&request_context), report)) +} + +pub async fn admin_download_error_report( + State(state): State, + Extension(_request_context): Extension, + Extension(admin): Extension, + AxumPath(batch_id): AxumPath, +) -> Result { + let bytes = state + .error_report_store() + .read_archive(&batch_id) + .await + .map_err(|_| AppError::from_status(StatusCode::NOT_FOUND).with_message("报告附件不存在"))?; + record_admin_report_audit( + &state, + &_request_context, + admin.session().subject.as_str(), + "download", + Some(&batch_id), + ) + .await; + let mut response = Response::new(bytes.into()); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/zip"), + ); + response.headers_mut().insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&format!("attachment; filename=\"{batch_id}.zip\"")) + .map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?, + ); + Ok(response) +} + +async fn record_admin_report_audit( + state: &AppState, + request_context: &RequestContext, + admin_subject: &str, + action: &'static str, + batch_id: Option<&str>, +) { + let mut draft = + TrackingEventDraft::user("admin_error_report_action", "error-reports", admin_subject); + draft.metadata = serde_json::json!({ "action": action, "batchId": batch_id }); + record_tracking_event_after_success(state, request_context, draft).await; +} + +fn internal_store_error(error: String) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error) +} + +pub(crate) fn build_error_report_archive( + events: &[Event], + user_description: Option<&str>, + logs: &[(String, String)], +) -> Result, String> { + let cursor = Cursor::new(Vec::new()); + let mut zip = ZipWriter::new(cursor); + let options = FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated); + let manifest = serde_json::json!({ + "schemaVersion": 1, + "eventCount": events.len(), + "logCount": logs.len(), + "userDescriptionIncluded": user_description.is_some(), + }); + zip.start_file("manifest.json", options) + .map_err(|error| error.to_string())?; + zip.write_all( + serde_json::to_string_pretty(&manifest) + .map_err(|error| error.to_string())? + .as_bytes(), + ) + .map_err(|error| error.to_string())?; + zip.start_file("events.jsonl", options) + .map_err(|error| error.to_string())?; + for event in events { + let line = serde_json::to_string(event).map_err(|error| error.to_string())?; + zip.write_all(line.as_bytes()) + .map_err(|error| error.to_string())?; + zip.write_all(b"\n").map_err(|error| error.to_string())?; + } + if let Some(description) = user_description { + zip.start_file("user-description.txt", options) + .map_err(|error| error.to_string())?; + zip.write_all(description.as_bytes()) + .map_err(|error| error.to_string())?; + } + for (name, content) in logs { + let safe_name = name + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) + .collect::(); + if safe_name.is_empty() { + continue; + } + zip.start_file(format!("system-logs/{safe_name}"), options) + .map_err(|error| error.to_string())?; + zip.write_all(content.as_bytes()) + .map_err(|error| error.to_string())?; + } + zip.finish() + .map(|cursor| cursor.into_inner()) + .map_err(|error| error.to_string()) +} + +impl ErrorReportStore { + pub fn new(base_dir: impl Into) -> Arc { + Arc::new(Self { + directory: base_dir.into().join("error-reports"), + lock: Mutex::new(()), + }) + } + + async fn create( + &self, + user_id: String, + mut payload: CreateErrorReportBatchRequest, + ) -> Result { + if payload.schema_version != 1 { + return Err("报告协议版本不支持".to_string()); + } + payload.submission_id = sanitize_report_text_with_limit(&payload.submission_id, 128); + if payload.submission_id.is_empty() { + return Err("缺少提交幂等标识".to_string()); + } + if payload.events.is_empty() || payload.events.len() > MAX_EVENTS { + return Err("报告事件数量必须在 1 到 100 之间".to_string()); + } + if payload.logs.len() > MAX_LOGS { + return Err("日志附件数量超出上限".to_string()); + } + payload.user_description = payload.user_description.map(|value| { + sanitize_report_text_with_limit(&value, MAX_DESCRIPTION_CHARS) + .chars() + .take(MAX_DESCRIPTION_CHARS) + .collect() + }); + for event in &mut payload.events { + event.event_id = + sanitize_report_text_with_limit(&event.event_id, MAX_EVENT_FIELD_CHARS); + event.fingerprint = + sanitize_report_text_with_limit(&event.fingerprint, MAX_EVENT_FIELD_CHARS); + event.source = sanitize_report_text_with_limit(&event.source, MAX_EVENT_FIELD_CHARS); + event.message = sanitize_report_text_with_limit(&event.message, MAX_EVENT_FIELD_CHARS); + event.stack = event + .stack + .take() + .map(|value| sanitize_report_text_with_limit(&value, MAX_LOG_CHARS)); + event.occurred_at = + sanitize_report_text_with_limit(&event.occurred_at, MAX_EVENT_FIELD_CHARS); + if event.event_id.is_empty() || event.fingerprint.is_empty() || event.message.is_empty() + { + return Err("报告事件缺少必要字段".to_string()); + } + if event.count == 0 { + event.count = 1; + } + } + let event_ids = payload + .events + .iter() + .map(|event| event.event_id.as_str()) + .collect::>(); + if event_ids.len() != payload.events.len() { + return Err("报告事件标识不能重复".to_string()); + } + for log in &mut payload.logs { + log.name = sanitize_log_name(&log.name); + log.content = sanitize_report_text_with_limit(&log.content, MAX_LOG_CHARS) + .chars() + .take(MAX_LOG_CHARS) + .collect(); + } + let archive = build_error_report_archive( + &payload.events, + payload.user_description.as_deref(), + &payload + .logs + .iter() + .map(|log| (log.name.clone(), log.content.clone())) + .collect::>(), + )?; + if archive.len() > MAX_BATCH_BYTES { + return Err("报告附件超过 20 MB 上限".to_string()); + } + let now = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| error.to_string())?; + let batch_id = Uuid::new_v4().to_string(); + let stored = StoredErrorReport { + batch_id: batch_id.clone(), + submission_id: payload.submission_id, + user_id, + oss_object_key: None, + archive_sha256: None, + archive_size_bytes: archive.len() as u64, + event_count: payload.events.len() as u32, + upload_status: "uploading".to_string(), + review_status: "new".to_string(), + admin_note: None, + log_count: payload.logs.len() as u32, + first_fingerprint: payload + .events + .first() + .map(|event| event.fingerprint.clone()), + first_source: payload.events.first().map(|event| event.source.clone()), + created_at: now.clone(), + updated_at: now, + }; + let _guard = self.lock.lock().await; + fs::create_dir_all(&self.directory).map_err(|error| error.to_string())?; + set_private_directory_permissions(&self.directory)?; + self.cleanup_expired(); + if let Ok(directory) = fs::read_dir(&self.directory) { + for item in directory.flatten() { + if item.path().extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + if let Ok(bytes) = fs::read(item.path()) { + if let Ok(existing) = serde_json::from_slice::(&bytes) { + if existing.submission_id == stored.submission_id { + return Ok(existing); + } + } + } + } + } + let metadata_path = self.metadata_path(&batch_id); + let archive_path = self.archive_path(&batch_id); + write_atomic( + &metadata_path, + &serde_json::to_vec_pretty(&stored).map_err(|error| error.to_string())?, + )?; + write_atomic(&archive_path, &archive)?; + Ok(stored) + } + + async fn list( + &self, + query: AdminErrorReportListQuery, + ) -> Result, String> { + let _guard = self.lock.lock().await; + self.cleanup_expired(); + let mut entries = Vec::new(); + let limit = query.limit.unwrap_or(100).clamp(1, 500) as usize; + let directory = match fs::read_dir(&self.directory) { + Ok(directory) => directory, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(entries), + Err(error) => return Err(error.to_string()), + }; + for item in directory.flatten() { + if item.path().extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = fs::read(item.path()) else { + continue; + }; + let Ok(report) = serde_json::from_slice::(&bytes) else { + continue; + }; + if query + .status + .as_deref() + .is_some_and(|value| value != report.review_status) + || query + .source + .as_deref() + .is_some_and(|value| report.first_source.as_deref() != Some(value)) + || query + .fingerprint + .as_deref() + .is_some_and(|value| report.first_fingerprint.as_deref() != Some(value)) + { + continue; + } + entries.push(AdminErrorReportEntry { + batch_id: report.batch_id, + event_count: report.event_count, + log_count: report.log_count, + fingerprint: report.first_fingerprint, + source: report.first_source, + status: report.review_status.clone(), + user_id: report.user_id, + created_at: report.created_at, + updated_at: report.updated_at, + user_description: None, + attachment_size_bytes: report.archive_size_bytes, + submission_id: Some(report.submission_id), + oss_object_key: report.oss_object_key, + archive_sha256: report.archive_sha256, + upload_status: Some(report.upload_status), + review_status: Some(report.review_status), + }); + } + entries.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + entries.truncate(limit); + Ok(entries) + } + + async fn get(&self, batch_id: &str) -> Result { + validate_batch_id(batch_id)?; + let _guard = self.lock.lock().await; + let bytes = fs::read(self.metadata_path(batch_id)).map_err(|error| error.to_string())?; + let metadata: StoredErrorReport = + serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + let archive = fs::read(self.archive_path(batch_id)).map_err(|error| error.to_string())?; + let (events, user_description, log_names) = parse_archive(&archive)?; + let status = metadata.review_status.clone(); + let note = metadata.admin_note.clone(); + let attachment_size_bytes = metadata.archive_size_bytes; + Ok(ErrorReportDetail { + metadata, + events, + log_names, + user_description, + status, + note, + attachment_size_bytes, + }) + } + + async fn update( + &self, + batch_id: &str, + payload: AdminUpdateErrorReportRequest, + ) -> Result { + validate_batch_id(batch_id)?; + if !matches!(payload.status.as_str(), "new" | "in-progress" | "resolved") { + return Err("状态必须是 new、in-progress 或 resolved".to_string()); + } + let _guard = self.lock.lock().await; + let path = self.metadata_path(batch_id); + let bytes = fs::read(&path).map_err(|error| error.to_string())?; + let mut report: StoredErrorReport = + serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + report.review_status = payload.status; + report.admin_note = payload + .note + .map(|value| value.chars().take(MAX_DESCRIPTION_CHARS).collect()); + report.updated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| error.to_string())?; + write_atomic( + &path, + &serde_json::to_vec_pretty(&report).map_err(|error| error.to_string())?, + )?; + Ok(report) + } + + async fn read_archive(&self, batch_id: &str) -> Result, String> { + validate_batch_id(batch_id)?; + let _guard = self.lock.lock().await; + fs::read(self.archive_path(batch_id)).map_err(|error| error.to_string()) + } + + async fn mark_uploaded( + &self, + batch_id: &str, + object_key: String, + sha256: String, + ) -> Result { + validate_batch_id(batch_id)?; + let _guard = self.lock.lock().await; + let path = self.metadata_path(batch_id); + let bytes = fs::read(&path).map_err(|error| error.to_string())?; + let mut report: StoredErrorReport = + serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + report.oss_object_key = Some(object_key); + report.archive_sha256 = Some(sha256); + report.upload_status = "ready".to_string(); + report.updated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|e| e.to_string())?; + write_atomic( + &path, + &serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?, + )?; + Ok(report) + } + + async fn mark_upload_failed(&self, batch_id: &str) -> Result { + validate_batch_id(batch_id)?; + let _guard = self.lock.lock().await; + let path = self.metadata_path(batch_id); + let bytes = fs::read(&path).map_err(|error| error.to_string())?; + let mut report: StoredErrorReport = + serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + report.upload_status = "failed".to_string(); + report.updated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|e| e.to_string())?; + write_atomic( + &path, + &serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?, + )?; + Ok(report) + } + + fn metadata_path(&self, batch_id: &str) -> PathBuf { + self.directory.join(format!("{batch_id}.json")) + } + fn archive_path(&self, batch_id: &str) -> PathBuf { + self.directory.join(format!("{batch_id}.zip")) + } + + fn cleanup_expired(&self) { + let Ok(directory) = fs::read_dir(&self.directory) else { + return; + }; + let cutoff = std::time::SystemTime::now() + .checked_sub(std::time::Duration::from_secs(30 * 24 * 60 * 60)); + for item in directory.flatten() { + let Ok(metadata) = item.metadata() else { + continue; + }; + let Some(cutoff) = cutoff else { continue }; + if metadata + .modified() + .ok() + .is_some_and(|modified| modified < cutoff) + { + let _ = fs::remove_file(item.path()); + } + } + } +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let temp_path = path.with_extension(format!("tmp-{}", Uuid::new_v4())); + fs::write(&temp_path, bytes).map_err(|error| error.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .map_err(|error| error.to_string())?; + } + fs::rename(&temp_path, path).map_err(|error| error.to_string()) +} + +fn set_private_directory_permissions(path: &Path) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn validate_batch_id(batch_id: &str) -> Result<(), String> { + if Uuid::parse_str(batch_id).is_ok() { + Ok(()) + } else { + Err("报告标识无效".to_string()) + } +} + +fn sanitize_report_text(value: &str) -> String { + sanitize_report_text_with_limit(value, MAX_EVENT_FIELD_CHARS) +} + +fn sanitize_report_text_with_limit(value: &str, max_chars: usize) -> String { + let mut sanitized = value.replace(['\r', '\n'], " "); + let lowercase = sanitized.to_ascii_lowercase(); + for marker in [ + "authorization:", + "bearer ", + "token=", + "token:", + "api_key=", + "apikey=", + ] { + if lowercase.contains(marker) { + return "[REDACTED]".to_string(); + } + } + sanitized = sanitized.replace("/Users/", "/"); + sanitized = sanitized.replace("/home/", "/"); + sanitized = sanitized.replace("C:\\", "/"); + sanitized.chars().take(max_chars).collect() +} + +fn sanitize_log_name(value: &str) -> String { + let candidate = value + .rsplit(['/', '\\']) + .next() + .unwrap_or("application.log"); + let safe = candidate + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) + .take(128) + .collect::(); + if safe.is_empty() { + "application.log".to_string() + } else { + safe + } +} + +fn parse_archive(bytes: &[u8]) -> Result<(Vec, Option, Vec), String> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| e.to_string())?; + let mut events = Vec::new(); + if let Ok(mut file) = archive.by_name("events.jsonl") { + let mut text = String::new(); + std::io::Read::read_to_string(&mut file, &mut text).map_err(|e| e.to_string())?; + for line in text.lines().filter(|line| !line.trim().is_empty()) { + events.push(serde_json::from_str(line).map_err(|e| e.to_string())?); + } + } + let user_description = if let Ok(mut file) = archive.by_name("user-description.txt") { + let mut text = String::new(); + std::io::Read::read_to_string(&mut file, &mut text).map_err(|e| e.to_string())?; + Some(text) + } else { + None + }; + let mut log_names = Vec::new(); + for index in 0..archive.len() { + let name = archive + .by_index(index) + .map_err(|e| e.to_string())? + .name() + .to_string(); + if let Some(name) = name.strip_prefix("system-logs/") { + log_names.push(name.to_string()); + } + } + Ok((events, user_description, log_names)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn archive_contains_manifest_events_description_and_logs() { + let events = vec![Event { + event_id: "event-1".to_string(), + fingerprint: "fingerprint".to_string(), + source: "react-render".to_string(), + message: "失败".to_string(), + stack: Some("Error: 失败".to_string()), + occurred_at: "2026-08-31T00:00:00Z".to_string(), + count: 1, + }]; + let archive = build_error_report_archive( + &events, + Some("用户描述"), + &[("application.log".to_string(), "safe log".to_string())], + ) + .expect("archive should build"); + let mut zip = zip::ZipArchive::new(Cursor::new(archive)).expect("zip should open"); + assert!(zip.by_name("manifest.json").is_ok()); + assert!(zip.by_name("events.jsonl").is_ok()); + assert!(zip.by_name("user-description.txt").is_ok()); + assert!(zip.by_name("system-logs/application.log").is_ok()); + } + + #[test] + fn sanitize_report_text_redacts_credentials_and_paths() { + assert_eq!( + sanitize_report_text("Authorization: Bearer secret"), + "[REDACTED]" + ); + assert!(sanitize_report_text("failed at /home/user/project").contains("")); + } + + #[test] + fn sanitize_report_text_with_limit_preserves_large_log_content_budget() { + let input = "x".repeat(MAX_LOG_CHARS + 100); + let output = sanitize_report_text_with_limit(&input, MAX_LOG_CHARS); + assert_eq!(output.len(), MAX_LOG_CHARS); + } + + #[test] + fn sanitize_log_name_strips_paths_and_limits_length() { + assert_eq!( + sanitize_log_name("../../application.log"), + "application.log" + ); + assert_eq!( + sanitize_log_name(r"C:\private\application.log"), + "application.log" + ); + assert_eq!(sanitize_log_name("/tmp/秘密"), "application.log"); + assert_eq!(sanitize_log_name(&"a".repeat(200)).len(), 128); + } + + #[tokio::test] + async fn store_create_sanitizes_event_metadata_and_log_names() { + let directory = + std::env::temp_dir().join(format!("agc-error-reports-test-{}", Uuid::new_v4())); + let store = ErrorReportStore::new(&directory); + let report = store + .create( + "user-1".to_string(), + CreateErrorReportBatchRequest { + schema_version: 1, + submission_id: "submission-1".to_string(), + events: vec![Event { + event_id: "event".to_string(), + fingerprint: "fingerprint".to_string(), + source: "source".to_string(), + message: "message".to_string(), + stack: None, + occurred_at: "now".to_string(), + count: 1, + }], + user_description: None, + logs: vec![ErrorReportLogInput { + name: "../../application.log".to_string(), + content: "x".repeat(3_000), + }], + }, + ) + .await + .expect("report should be stored"); + assert_eq!(report.log_count, 1); + let archive = store + .read_archive(&report.batch_id) + .await + .expect("archive should be readable"); + let mut zip = zip::ZipArchive::new(Cursor::new(archive)).expect("zip should open"); + let mut log = String::new(); + std::io::Read::read_to_string( + &mut zip.by_name("system-logs/application.log").unwrap(), + &mut log, + ) + .expect("log should be readable"); + assert_eq!(log.len(), 3_000); + let _ = fs::remove_dir_all(directory); + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 441936e1e..2eb880f48 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -32,6 +32,7 @@ mod editor_screen_background_decision; mod editor_screen_background_filter; mod editor_sound_effect_prompt_assist; mod error_middleware; +mod error_reports; mod external_api_audit; mod external_api_auth; mod external_api_keys; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 284b437cb..fdc715ea0 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -44,6 +44,7 @@ use crate::editor_generation_config::{ EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore, EditorGenerationPricingUnit, }; +use crate::error_reports::ErrorReportStore; use crate::tracking_outbox::TrackingOutbox; use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox}; use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; @@ -296,6 +297,7 @@ pub struct AppStateInner { #[cfg(any())] puzzle_gallery_cache: PuzzleGalleryCache, tracking_outbox: Option>, + error_report_store: Arc, wallet_refund_outbox: Option>, profile_wallet_refund_outbox_worker: Arc, editor_generation_pricing_store: EditorGenerationPricingStore, @@ -589,6 +591,12 @@ impl AppState { let ai_task_service = AiTaskService::new(InMemoryAiTaskStore::default()); let spacetime_client = SpacetimeClient::new(spacetime_client_config_for_process(&config)); let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone()); + let error_report_store = ErrorReportStore::new( + config + .tracking_outbox_dir + .parent() + .unwrap_or_else(|| std::path::Path::new("server-rs/.data")), + ); let wallet_refund_outbox = WalletRefundOutbox::from_config(&config, spacetime_client.clone()); let profile_wallet_refund_outbox_worker = @@ -666,6 +674,7 @@ impl AppState { #[cfg(any())] puzzle_gallery_cache: PuzzleGalleryCache::new(), tracking_outbox, + error_report_store, wallet_refund_outbox, profile_wallet_refund_outbox_worker, editor_generation_pricing_store, @@ -1303,6 +1312,10 @@ impl AppState { self.oss_client.as_ref() } + pub fn error_report_store(&self) -> &Arc { + &self.error_report_store + } + pub fn password_entry_service(&self) -> &PasswordEntryService { &self.password_entry_service } diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index ab8c690c9..3bf04d903 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -10,7 +10,7 @@ use crate::creation_entry_config::{ }; /// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。 -pub const ADMIN_TAB_PERMISSIONS: [&str; 15] = [ +pub const ADMIN_TAB_PERMISSIONS: [&str; 16] = [ "dashboard", "overview", "tables", @@ -26,6 +26,7 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 15] = [ "editor-generation-pricing", "editor-showcase", "editor-assets", + "error-reports", ]; /// 不随一级 Tab 自动授予的后台高风险操作权限。