From 0a9f24d8c607f16f7f05e1ae44b3957a66f518a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 10:10:22 +0800 Subject: [PATCH 01/59] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AGC=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8A=A5=E5=91=8A=E5=85=B1=E4=BA=AB=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增错误事件、日志和批量提交 DTO。 增加 AGC 错误报告模块导出。 增加 agc OSS 前缀以生成私有诊断包对象 key。 --- server-rs/crates/platform-oss/src/lib.rs | 5 +- .../shared-contracts/src/error_reports.rs | 85 +++++++++++++++++++ server-rs/crates/shared-contracts/src/lib.rs | 1 + 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 server-rs/crates/shared-contracts/src/error_reports.rs diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index c563ee158..2b52e973c 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -52,6 +52,7 @@ pub enum OssObjectAccess { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LegacyAssetPrefix { EditorAgent, + AgcErrorReports, CharacterDrafts, Characters, Animations, @@ -291,6 +292,7 @@ impl LegacyAssetPrefix { match normalized { "editor-agent" => Some(Self::EditorAgent), + "agc" => Some(Self::AgcErrorReports), "generated-character-drafts" => Some(Self::CharacterDrafts), "generated-characters" => Some(Self::Characters), "generated-animations" => Some(Self::Animations), @@ -312,6 +314,7 @@ impl LegacyAssetPrefix { pub fn as_str(&self) -> &'static str { match self { Self::EditorAgent => "editor-agent", + Self::AgcErrorReports => "agc", Self::CharacterDrafts => "generated-character-drafts", Self::Characters => "generated-characters", Self::Animations => "generated-animations", @@ -1648,7 +1651,7 @@ fn normalize_object_key(raw: &str) -> Result { match LegacyAssetPrefix::from_object_key(&normalized) { Some(LegacyAssetPrefix::EditorAgent) | None => { return Err(OssError::InvalidRequest( - "objectKey 必须落在受支持的 generated-* 前缀下".to_string(), + "objectKey 必须落在受支持的 OSS 前缀下".to_string(), )); } Some(_) => {} diff --git a/server-rs/crates/shared-contracts/src/error_reports.rs b/server-rs/crates/shared-contracts/src/error_reports.rs new file mode 100644 index 000000000..fb5adcabb --- /dev/null +++ b/server-rs/crates/shared-contracts/src/error_reports.rs @@ -0,0 +1,85 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Event { + pub event_id: String, + pub fingerprint: String, + pub source: String, + pub message: String, + pub stack: Option, + pub occurred_at: String, + pub count: u32, +} + +pub type ErrorReportEventInput = Event; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ErrorReportLogInput { + pub name: String, + pub content: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreateErrorReportBatchRequest { + pub schema_version: u32, + pub submission_id: String, + pub events: Vec, + pub user_description: Option, + pub logs: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreateErrorReportBatchResponse { + pub batch_id: String, + pub submission_id: String, + pub status: String, + pub accepted_event_ids: Vec, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminErrorReportListQuery { + pub status: Option, + pub fingerprint: Option, + pub source: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminErrorReportEntry { + pub batch_id: String, + pub event_count: u32, + pub log_count: u32, + pub fingerprint: Option, + pub source: Option, + pub status: String, + pub user_id: String, + pub created_at: String, + pub updated_at: String, + pub user_description: Option, + pub attachment_size_bytes: u64, + pub submission_id: Option, + pub oss_object_key: Option, + pub archive_sha256: Option, + pub upload_status: Option, + pub review_status: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminErrorReportListResponse { + pub reports: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpdateErrorReportRequest { + pub status: String, + pub note: Option, +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 8ad2b7140..bc32ee4b9 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -19,6 +19,7 @@ pub mod creation_entry_config; pub mod creative_agent; pub mod editor_agent; pub mod editor_generation; +pub mod error_reports; pub use editor_generation::{ EDITOR_GENERATION_ASSET_BINDING_ID_PREFIX, EDITOR_GENERATION_ASSET_ID_PREFIX, EDITOR_GENERATION_ASSET_OBJECT_ID_PREFIX, EDITOR_GENERATION_RESOURCE_ID_PREFIX, -- 2.52.0 From 1f252ef86d5096eed4c21e71bdbbb6184866d840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 10:10:32 +0800 Subject: [PATCH 02/59] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20AGC=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8A=A5=E5=91=8A=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增登录态错误报告批量提交与管理员查询接口。 生成 JSONL 诊断归档并支持私有 OSS 上传及状态记录。 接入 AppState、路由和后台 Tab 权限审计。 --- server-rs/crates/api-server/src/admin.rs | 3 + server-rs/crates/api-server/src/app.rs | 1 + .../crates/api-server/src/error_reports.rs | 915 ++++++++++++++++++ server-rs/crates/api-server/src/main.rs | 1 + server-rs/crates/api-server/src/state.rs | 13 + .../crates/shared-contracts/src/admin.rs | 3 +- 6 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 server-rs/crates/api-server/src/error_reports.rs 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 自动授予的后台高风险操作权限。 -- 2.52.0 From 5a4ac4857569f2697781e82df2fd16484ee664a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 10:10:48 +0800 Subject: [PATCH 03/59] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20AGC=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E9=94=99=E8=AF=AF=E9=87=87=E9=9B=86=E4=B8=8E?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在当前进程按指纹合并客户端错误并持久化应用诊断日志。 新增登录态批量提交、用户说明和可选日志上传面板。 将 /bug-report 快捷入口改为打开报告问题面板并更新端到端断言。 --- .../src-tauri/src/main.rs | 53 +++++- .../src/app/AuthenticatedClient.tsx | 68 +++++++- .../error-report/ErrorReportDialog.tsx | 133 +++++++++++++++ .../projectGuidanceSummaries.ts | 2 +- .../projectSummaryConstants.ts | 2 +- .../projectSummaryCommands.ts | 12 +- .../src/services/clientApi.ts | 25 ++- .../src/services/errorReporting.ts | 161 ++++++++++++++++++ apps/ai-game-creator-shell/src/styles.css | 32 ++++ .../developer-agent-window.suite.ts | 2 +- .../assert-playtest-and-release-shortcuts.ts | 38 +---- .../tests/errorReporting.test.ts | 79 +++++++++ 12 files changed, 556 insertions(+), 51 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx create mode 100644 apps/ai-game-creator-shell/src/services/errorReporting.ts create mode 100644 apps/ai-game-creator-shell/tests/errorReporting.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ad1499c0b..114d1c189 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -39,6 +39,7 @@ use shared_contracts::game_creation_app::{ GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, }; +use shared_contracts::error_reports::{ErrorReportEventInput, ErrorReportLogInput}; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; @@ -1563,6 +1564,49 @@ static DIAGNOSTIC_LOG_LOCK: OnceLock> = OnceLock::new(); static STARTUP_PANIC_LOG_PATH: OnceLock = OnceLock::new(); static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false); +#[tauri::command] +fn append_diagnostic_event(event: ErrorReportEventInput) -> Result<(), String> { + let config_dir = game_creator_runtime_config_dir() + .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; + let path = config_dir.join("diagnostics/application.log"); + let mut event = event; + event.event_id = sanitize_diagnostic_message(&event.event_id, Some(&config_dir)); + event.fingerprint = sanitize_diagnostic_message(&event.fingerprint, Some(&config_dir)); + event.source = sanitize_diagnostic_message(&event.source, Some(&config_dir)); + event.message = sanitize_diagnostic_message(&event.message, Some(&config_dir)); + event.stack = event + .stack + .take() + .map(|value| sanitize_diagnostic_message(&value, Some(&config_dir))); + event.occurred_at = sanitize_diagnostic_message(&event.occurred_at, Some(&config_dir)); + let line = serde_json::to_string(&event).map_err(|error| error.to_string())?; + append_bounded_diagnostic_line(&path, &line).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn read_diagnostic_logs() -> Result, String> { + let Some(config_dir) = game_creator_runtime_config_dir() else { + return Ok(Vec::new()); + }; + let _guard = DIAGNOSTIC_LOG_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let directory = config_dir.join("diagnostics"); + let mut files = Vec::new(); + for name in ["application.log", "application.previous.log", "startup.log"] { + let path = directory.join(name); + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + files.push(ErrorReportLogInput { + name: name.to_string(), + content, + }); + } + Ok(files) +} + fn diagnostic_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2059,6 +2103,11 @@ fn main() { show_startup_error_dialog(path); } })?; + let startup_log = game_creator_runtime_config_dir() + .map(|directory| directory.join("diagnostics/startup.log")); + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete"); + } if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete"); } @@ -2292,7 +2341,9 @@ fn main() { commit_local_project_asset, commit_local_project_asset_canvas_candidate, get_local_game_project_revision, - get_local_game_manifest + get_local_game_manifest, + append_diagnostic_event, + read_diagnostic_logs ]) .build(tauri_context); let app = match app { diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 290a06cc1..06d541e6b 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -9,6 +9,7 @@ import { import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; +import { ErrorReportDialog } from '../components/error-report/ErrorReportDialog'; import { clearStoredAuthAccessToken, getClientAuthErrorMessage, @@ -29,6 +30,7 @@ import { normalizeClientServerBaseUrl, setClientServerSelection, } from '../services/clientHttp'; +import { captureClientError } from '../services/errorReporting'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, @@ -43,6 +45,7 @@ import { type ClientRuntimeErrorBoundaryProps = { children: ReactNode; onLogout: () => void; + onReport?: () => void; }; type ClientRuntimeErrorBoundaryState = { @@ -66,6 +69,7 @@ export class ClientRuntimeErrorBoundary extends Component< componentDidCatch(error: unknown, errorInfo: ErrorInfo) { console.error('AGC authenticated client render failed', error, errorInfo); + void captureClientError(error, { source: 'react-render' }); } render() { @@ -86,6 +90,11 @@ export class ClientRuntimeErrorBoundary extends Component< + {this.props.onReport ? ( + + ) : null} ); @@ -109,6 +118,7 @@ export function AuthenticatedClient({ const [loginBusy, setLoginBusy] = useState(false); const [codeBusy, setCodeBusy] = useState(false); const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0); + const [errorReportOpen, setErrorReportOpen] = useState(false); const initialServerSelection = getClientServerSelection(); const [serverSelection, setServerSelection] = useState( initialServerSelection, @@ -117,6 +127,35 @@ export function AuthenticatedClient({ initialServerSelection.customBaseUrl, ); + useEffect(() => { + const handleError = (event: ErrorEvent) => { + void captureClientError(event.error ?? event.message, { + source: 'window.onerror', + }); + }; + const handleRejection = (event: PromiseRejectionEvent) => { + void captureClientError(event.reason, { source: 'unhandledrejection' }); + }; + window.addEventListener('error', handleError); + window.addEventListener('unhandledrejection', handleRejection); + return () => { + window.removeEventListener('error', handleError); + window.removeEventListener('unhandledrejection', handleRejection); + }; + }, []); + + useEffect(() => { + const openReport = () => { + void captureClientError(new Error('用户主动报告问题'), { + source: 'manual', + }); + setErrorReportOpen(true); + }; + window.addEventListener('agc-open-error-report', openReport); + return () => + window.removeEventListener('agc-open-error-report', openReport); + }, []); + function persistServerSelection() { try { const next = setClientServerSelection({ @@ -126,6 +165,10 @@ export function AuthenticatedClient({ setServerSelection(next); return next; } catch (error) { + void captureClientError(error, { + source: 'auth-hydrate', + action: 'restore-session', + }); setLoginStatus(error instanceof Error ? error.message : String(error)); return null; } @@ -304,6 +347,10 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { + void captureClientError(error, { + source: 'auth', + action: 'send-login-code', + }); setLoginStatus(error instanceof Error ? error.message : String(error)); } finally { setCodeBusy(false); @@ -362,6 +409,7 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { + void captureClientError(error, { source: 'auth', action: 'login' }); setLoginStatus(error instanceof Error ? error.message : String(error)); } finally { setLoginBusy(false); @@ -530,8 +578,22 @@ export function AuthenticatedClient({ } return ( - - {children({ user: authUser, logout })} - + <> + { + void captureClientError(new Error('用户主动报告问题'), { + source: 'manual', + }); + setErrorReportOpen(true); + }} + > + {children({ user: authUser, logout })} + + setErrorReportOpen(false)} + /> + ); } diff --git a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx new file mode 100644 index 000000000..f96c667cc --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { + type ClientErrorEvent, + type DiagnosticLogFile, + getPendingClientErrorEvents, + markClientErrorEventsSubmitted, + readApplicationDiagnosticLogs, + submitErrorReportBatch, + subscribeClientErrorEvents, +} from '../../services/errorReporting'; +import { ThemedModal } from '../modal/ThemedModal'; + +type ErrorReportDialogProps = { + open: boolean; + onClose: () => void; +}; + +export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) { + const [events, setEvents] = useState([]); + const [logs, setLogs] = useState([]); + const [includeLogs, setIncludeLogs] = useState(true); + const [description, setDescription] = useState(''); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(''); + + useEffect( + () => + subscribeClientErrorEvents(() => + setEvents(getPendingClientErrorEvents()), + ), + [], + ); + + useEffect(() => { + if (!open) return; + setEvents(getPendingClientErrorEvents()); + setStatus(''); + void readApplicationDiagnosticLogs().then(setLogs); + }, [open]); + + const submit = useCallback(async () => { + if (!events.length || busy) return; + setBusy(true); + setStatus('正在提交…'); + try { + await submitErrorReportBatch({ + events, + logs: includeLogs ? logs : [], + userDescription: description, + }); + markClientErrorEventsSubmitted(events); + setStatus('已提交,感谢你的反馈'); + window.setTimeout(onClose, 700); + } catch (error) { + setStatus( + error instanceof Error ? error.message : '提交失败,请稍后再试', + ); + } finally { + setBusy(false); + } + }, [busy, description, events, includeLogs, logs, onClose]); + + return ( + +
+
+

报告问题

+
+ +
+
+
+ 错误事件({events.length}) + {events.length ? ( +
    + {events.map((event) => ( +
  • + {event.message} + + {event.source} · {event.count} 次 + +
  • + ))} +
+ ) : ( +

当前没有待报告的错误。

+ )} +
+