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 38a75ccc2..357b5cfac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1598,6 +1598,12 @@ fn read_diagnostic_logs() -> Result, String> { let mut files = Vec::new(); for name in ["application.log", "application.previous.log", "startup.log"] { let path = directory.join(name); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } let Ok(content) = fs::read_to_string(&path) else { continue; }; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4217ab900..72c0f187d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7864,3 +7864,4 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - `/bug-report` 仅作为打开该面板的快捷入口,追加简短提示,不再生成包含项目、run 或截图口径的缺陷模板。 - 2026-08-31 追加:事件 DTO 精简为 `eventId/fingerprint/source/message/stack/occurredAt/count`,提交请求携带 `submissionId` 做幂等。归档固定为 `events.jsonl`,服务端使用 `agc/error-reports/v1/{yyyy}/{mm}/{dd}/{batchId}.zip` 私有 OSS key;元数据只保留 batch、用户、状态、大小、SHA-256 和 OSS key,事件正文/说明/日志从归档读取。OSS 不可用时状态为 `failed`,不自动重试。 - 2026-09-01 追加:`application.log` 不再写结构化错误事件;Rust `app_log!` 和 WebView console 都写入普通文本 raw log,结构化事件仅保留在当前进程内,提交时才生成 ZIP 内的 `events.jsonl`。 +- 2026-09-01 review 收口:错误报告修复详情请求竞态、下载 anchor 生命周期、客户端采集脱敏/指纹降级与 4xx 噪声、用户级幂等隔离、`agc` 私有 OSS 前缀越权、日志读取链接检查、ZIP 同名日志和元数据/归档清理一致性;同步在 `review.txt` 标注仍需产品/运维决定的架构项。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 822d5eea1..967451394 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -8,6 +8,8 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 捕获 React render error、`window.onerror`、`unhandledrejection` 以及显式标记的 Tauri/API/Agent 错误。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 +- 指纹计算可使用调用方的 page/action 作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 +- 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log;`read_diagnostic_logs` 只读取应用级日志。 - 报告面板允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 - 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。 @@ -17,6 +19,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 登录态客户端使用 `POST /api/error-reports`,请求 DTO 位于 `shared-contracts::error_reports`。 - api-server 对请求体设置 24 MiB 上限,并校验 schemaVersion、submissionId、事件/日志数量和 20 MiB 压缩包上限;事件字段、用户说明和日志名/内容均做长度限制与基础脱敏,归档使用 `events.jsonl`(每行一个事件)。结构化事件只保存在当前进程内,用户提交时才生成 `events.jsonl`,不在磁盘单独持久化。submissionId 提供重放幂等。 - 归档对象使用固定私有 OSS key:`agc/error-reports/v1/{yyyy}/{mm}/{dd}/{batchId}.zip`;api-server 先写 `uploading` 元数据,上传成功后记录 `ossObjectKey`、SHA-256、大小和 `ready` 状态。完整事件、说明和日志不进入元数据记录。 +- `agc` 是服务端专用私有前缀;公共直传票据、通用 object-key 规范化和 legacy 公开路径均拒绝该前缀。归档内同名日志会自动加数字后缀,读取本机诊断日志时拒绝符号链接/非普通文件。 - 后台接口:`GET/PATCH /admin/api/error-reports/{batchId}`、`GET /admin/api/error-reports` 和受保护的 `/download`。 - admin viewer 仅接受 error-reports Tab 权限,支持列表筛选、详情、状态 `new/in-progress/resolved`、处理备注和受控下载。 - 当前兼容实现仍在 api-server 配置目录旁保留元数据与本地归档副本,便于无 OSS 配置的开发环境运行;生产配置启用 OSS 后以 OSS 对象为完整内容来源。SpacetimeDB `error_report` 私有表接入及 30 天 OSS/元数据清理 worker 为后续门禁,HTTP DTO 与管理员权限保持不变。 diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index cfd70d42e..d1d3280e4 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -105,7 +105,10 @@ pub(crate) async fn create_direct_upload_ticket_for_owner( "supported": platform_oss::LEGACY_PUBLIC_PREFIXES, })) })?; - if matches!(legacy_prefix, LegacyAssetPrefix::EditorAgent) { + if matches!( + legacy_prefix, + LegacyAssetPrefix::EditorAgent | LegacyAssetPrefix::AgcErrorReports + ) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "legacyPrefix", diff --git a/server-rs/crates/api-server/src/error_reports.rs b/server-rs/crates/api-server/src/error_reports.rs index 191d45dd1..25c6d1b4a 100644 --- a/server-rs/crates/api-server/src/error_reports.rs +++ b/server-rs/crates/api-server/src/error_reports.rs @@ -7,36 +7,38 @@ use std::{ }; use axum::{ + Json, Router, extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, State}, - http::{header, HeaderValue, StatusCode}, + http::{HeaderValue, StatusCode, header}, 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 time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::sync::Mutex; use uuid::Uuid; -use zip::{write::FileOptions, ZipWriter}; +use zip::{ZipWriter, write::FileOptions}; use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest}; +#[cfg(test)] +use shared_contracts::error_reports::ErrorReportLogInput; use shared_contracts::error_reports::{ AdminErrorReportEntry, AdminErrorReportListQuery, AdminErrorReportListResponse, AdminUpdateErrorReportRequest, CreateErrorReportBatchRequest, CreateErrorReportBatchResponse, - ErrorReportLogInput, Event, + Event, }; use crate::{ - admin::{require_admin_auth, AuthenticatedAdmin}, + admin::{AuthenticatedAdmin, require_admin_auth}, api_response::json_success_body, - auth::{require_bearer_auth, AuthenticatedAccessToken}, + auth::{AuthenticatedAccessToken, require_bearer_auth}, http_error::AppError, request_context::RequestContext, state::AppState, - tracking::{record_tracking_event_after_success, TrackingEventDraft}, + tracking::{TrackingEventDraft, record_tracking_event_after_success}, }; const MAX_EVENTS: usize = 100; @@ -363,6 +365,7 @@ pub(crate) fn build_error_report_archive( zip.write_all(description.as_bytes()) .map_err(|error| error.to_string())?; } + let mut used_log_names = HashSet::new(); for (name, content) in logs { let safe_name = name .chars() @@ -373,7 +376,13 @@ pub(crate) fn build_error_report_archive( if safe_name.is_empty() { continue; } - zip.start_file(format!("system-logs/{safe_name}"), options) + let mut archive_name = safe_name.clone(); + let mut suffix = 2; + while !used_log_names.insert(archive_name.clone()) { + archive_name = format!("{safe_name}.{suffix}"); + suffix += 1; + } + zip.start_file(format!("system-logs/{archive_name}"), options) .map_err(|error| error.to_string())?; zip.write_all(content.as_bytes()) .map_err(|error| error.to_string())?; @@ -498,7 +507,9 @@ impl ErrorReportStore { } if let Ok(bytes) = fs::read(item.path()) { if let Ok(existing) = serde_json::from_slice::(&bytes) { - if existing.submission_id == stored.submission_id { + if existing.user_id == stored.user_id + && existing.submission_id == stored.submission_id + { return Ok(existing); } } @@ -624,6 +635,7 @@ impl ErrorReportStore { &path, &serde_json::to_vec_pretty(&report).map_err(|error| error.to_string())?, )?; + refresh_archive_mtime(&self.archive_path(batch_id)); Ok(report) } @@ -655,6 +667,7 @@ impl ErrorReportStore { &path, &serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?, )?; + refresh_archive_mtime(&self.archive_path(batch_id)); Ok(report) } @@ -673,6 +686,7 @@ impl ErrorReportStore { &path, &serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?, )?; + refresh_archive_mtime(&self.archive_path(batch_id)); Ok(report) } @@ -690,21 +704,42 @@ impl ErrorReportStore { 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 { + let path = item.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Ok(metadata) = fs::symlink_metadata(&path) else { continue; }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } let Some(cutoff) = cutoff else { continue }; if metadata .modified() .ok() .is_some_and(|modified| modified < cutoff) { - let _ = fs::remove_file(item.path()); + let _ = fs::remove_file(&path); + let _ = fs::remove_file(path.with_extension("zip")); } } } } +fn refresh_archive_mtime(path: &Path) { + let Ok(metadata) = fs::symlink_metadata(path) else { + return; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return; + } + let Ok(file) = fs::OpenOptions::new().append(true).open(path) else { + return; + }; + let _ = file.set_modified(std::time::SystemTime::now()); +} + 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())?; @@ -838,6 +873,22 @@ mod tests { assert!(zip.by_name("system-logs/application.log").is_ok()); } + #[test] + fn archive_disambiguates_duplicate_log_names() { + let archive = build_error_report_archive( + &[], + None, + &[ + ("application.log".to_string(), "one".to_string()), + ("application.log".to_string(), "two".to_string()), + ], + ) + .expect("archive should build"); + let mut zip = zip::ZipArchive::new(Cursor::new(archive)).expect("zip should open"); + assert!(zip.by_name("system-logs/application.log").is_ok()); + assert!(zip.by_name("system-logs/application.log.2").is_ok()); + } + #[test] fn sanitize_report_text_redacts_credentials_and_paths() { assert_eq!( @@ -912,4 +963,41 @@ mod tests { assert_eq!(log.len(), 3_000); let _ = fs::remove_dir_all(directory); } + + #[tokio::test] + async fn store_scopes_submission_idempotency_to_user() { + let directory = + std::env::temp_dir().join(format!("agc-error-reports-idempotency-{}", Uuid::new_v4())); + let store = ErrorReportStore::new(&directory); + let payload = || CreateErrorReportBatchRequest { + schema_version: 1, + submission_id: "same-submission".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::new(), + }; + let first = store + .create("user-1".to_string(), payload()) + .await + .expect("first report should be stored"); + let second = store + .create("user-2".to_string(), payload()) + .await + .expect("second report should be stored"); + assert_ne!(first.batch_id, second.batch_id); + let replay = store + .create("user-1".to_string(), payload()) + .await + .expect("replay should be idempotent"); + assert_eq!(replay.batch_id, first.batch_id); + let _ = fs::remove_dir_all(directory); + } } diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 2b52e973c..7ff931278 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -339,7 +339,11 @@ impl LegacyAssetPrefix { pub fn from_object_key(raw: &str) -> Option { let normalized = raw.trim().trim_start_matches('/').trim(); let prefix = normalized.split('/').next()?; - Self::parse(prefix) + match Self::parse(prefix) { + // agc/error-reports is a server-only write prefix, never a caller-supplied object key. + Some(Self::AgcErrorReports) => None, + other => other, + } } }