收紧错误报告归档与私有对象边界
按用户隔离提交幂等并处理重复日志名 拒绝 agc 私有前缀的公共对象读写与链接日志读取 保持归档和元数据清理时间一致并补充回归测试 同步错误报告技术文档与项目决策记录
This commit is contained in:
@@ -1598,6 +1598,12 @@ fn read_diagnostic_logs() -> Result<Vec<ErrorReportLogInput>, 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;
|
||||
};
|
||||
|
||||
@@ -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` 标注仍需产品/运维决定的架构项。
|
||||
|
||||
@@ -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 与管理员权限保持不变。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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::<StoredErrorReport>(&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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +339,11 @@ impl LegacyAssetPrefix {
|
||||
pub fn from_object_key(raw: &str) -> Option<Self> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user