明确错误报告单实例与详情错误边界
补充单实例文件锁和内存 ZIP 生命周期注释 区分管理员详情 404 与归档存储 500 同步错误报告技术方案与项目决策记录
This commit is contained in:
@@ -7865,3 +7865,4 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 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` 标注仍需产品/运维决定的架构项。
|
||||
- 2026-09-01 追加:api-server 按单实例部署,错误报告 store 保留进程内 Mutex 和同步本地文件 I/O,不引入跨进程锁或内存幂等索引;ZIP 仅在构建/上传阶段短暂驻留受 20 MiB 上限约束的内存 Vec,随后写入私有本地归档。管理员详情路由不属于 External OpenAPI;不存在返回 404,元数据/ZIP 损坏返回 500。
|
||||
|
||||
@@ -18,9 +18,11 @@ 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 提供重放幂等。
|
||||
- 归档构建会短暂使用一个受 20 MiB 上限约束的内存 `Vec<u8>`,随后立即写入私有本地归档;不把 ZIP 长期留在内存。这样既控制峰值,又支持进程重启后后台查看/下载和 OSS 上传失败后的本地取证。
|
||||
- 归档对象使用固定私有 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`。
|
||||
- 这些是 api-server 内部登录/管理员路由,不属于 `/api/external/v1`,不纳入 External OpenAPI;管理员详情对不存在返回 404,对归档/元数据损坏返回 500。
|
||||
- admin viewer 仅接受 error-reports Tab 权限,支持列表筛选、详情、状态 `new/in-progress/resolved`、处理备注和受控下载。
|
||||
- 当前兼容实现仍在 api-server 配置目录旁保留元数据与本地归档副本,便于无 OSS 配置的开发环境运行;生产配置启用 OSS 后以 OSS 对象为完整内容来源。SpacetimeDB `error_report` 私有表接入及 30 天 OSS/元数据清理 worker 为后续门禁,HTTP DTO 与管理员权限保持不变。
|
||||
|
||||
|
||||
@@ -51,9 +51,27 @@ const MAX_EVENT_FIELD_CHARS: usize = 512;
|
||||
|
||||
pub struct ErrorReportStore {
|
||||
directory: PathBuf,
|
||||
// 当前 api-server 按单实例部署;进程内锁足以串行化本地元数据/归档操作,
|
||||
// 不引入跨进程锁或内存索引,避免把单实例低频诊断路径复杂化。
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ErrorReportGetError {
|
||||
InvalidId,
|
||||
NotFound(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ErrorReportGetError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidId => formatter.write_str("报告标识无效"),
|
||||
Self::NotFound(message) | Self::Internal(message) => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoredErrorReport {
|
||||
@@ -240,7 +258,14 @@ pub async fn admin_get_error_report(
|
||||
.error_report_store()
|
||||
.get(&batch_id)
|
||||
.await
|
||||
.map_err(|_| AppError::from_status(StatusCode::NOT_FOUND).with_message("报告不存在"))?;
|
||||
.map_err(|error| match error {
|
||||
ErrorReportGetError::InvalidId | ErrorReportGetError::NotFound(_) => {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("报告不存在")
|
||||
}
|
||||
ErrorReportGetError::Internal(message) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
|
||||
}
|
||||
})?;
|
||||
record_admin_report_audit(
|
||||
&state,
|
||||
&request_context,
|
||||
@@ -268,7 +293,7 @@ pub async fn admin_update_error_report(
|
||||
.error_report_store()
|
||||
.get(&batch_id)
|
||||
.await
|
||||
.map_err(internal_store_error)?;
|
||||
.map_err(|error| internal_store_error(error.to_string()))?;
|
||||
record_admin_report_audit(
|
||||
&state,
|
||||
&request_context,
|
||||
@@ -588,14 +613,27 @@ impl ErrorReportStore {
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn get(&self, batch_id: &str) -> Result<ErrorReportDetail, String> {
|
||||
validate_batch_id(batch_id)?;
|
||||
async fn get(&self, batch_id: &str) -> Result<ErrorReportDetail, ErrorReportGetError> {
|
||||
validate_batch_id(batch_id).map_err(|_| ErrorReportGetError::InvalidId)?;
|
||||
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 bytes = fs::read(self.metadata_path(batch_id)).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
ErrorReportGetError::NotFound(error.to_string())
|
||||
} else {
|
||||
ErrorReportGetError::Internal(error.to_string())
|
||||
}
|
||||
})?;
|
||||
let metadata: StoredErrorReport = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| ErrorReportGetError::Internal(format!("报告元数据损坏:{error}")))?;
|
||||
let archive = fs::read(self.archive_path(batch_id)).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
ErrorReportGetError::NotFound(error.to_string())
|
||||
} else {
|
||||
ErrorReportGetError::Internal(error.to_string())
|
||||
}
|
||||
})?;
|
||||
let (events, user_description, log_names) = parse_archive(&archive)
|
||||
.map_err(|error| ErrorReportGetError::Internal(format!("报告归档损坏:{error}")))?;
|
||||
let status = metadata.review_status.clone();
|
||||
let note = metadata.admin_note.clone();
|
||||
let attachment_size_bytes = metadata.archive_size_bytes;
|
||||
@@ -1000,4 +1038,26 @@ mod tests {
|
||||
assert_eq!(replay.batch_id, first.batch_id);
|
||||
let _ = fs::remove_dir_all(directory);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_distinguishes_missing_and_corrupt_storage() {
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("agc-error-reports-get-{}", Uuid::new_v4()));
|
||||
let store = ErrorReportStore::new(&directory);
|
||||
let missing = store
|
||||
.get(&Uuid::new_v4().to_string())
|
||||
.await
|
||||
.expect_err("missing report should fail");
|
||||
assert!(matches!(missing, ErrorReportGetError::NotFound(_)));
|
||||
|
||||
fs::create_dir_all(&store.directory).expect("store directory should exist");
|
||||
let batch_id = Uuid::new_v4().to_string();
|
||||
fs::write(store.metadata_path(&batch_id), b"not-json").expect("metadata should write");
|
||||
let corrupt = store
|
||||
.get(&batch_id)
|
||||
.await
|
||||
.expect_err("corrupt metadata should fail");
|
||||
assert!(matches!(corrupt, ErrorReportGetError::Internal(_)));
|
||||
let _ = fs::remove_dir_all(directory);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user