diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 6673b98be..c84aba885 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -261,7 +261,16 @@ export function listAdminTrackingEvents( query: AdminTrackingEventListQuery = {}, ) { return request( - `/admin/api/tracking/events${buildQueryString(query)}`, + `/admin/api/tracking/events${buildQueryString((params) => { + appendQueryParam(params, 'eventKey', query.eventKey); + appendQueryParam(params, 'userId', query.userId); + appendQueryParam(params, 'scopeKind', query.scopeKind); + appendQueryParam(params, 'scopeId', query.scopeId); + appendQueryParam(params, 'startDate', query.startDate); + appendQueryParam(params, 'endDate', query.endDate); + appendNumericQueryParam(params, 'limit', query.limit); + if (query.exportAll) params.set('exportAll', 'true'); + })}`, { token }, ); } @@ -844,22 +853,10 @@ async function postAdminDirectUploadFile( } } -function buildQueryString(query: AdminTrackingEventListQuery) { +function buildQueryString(append: (params: URLSearchParams) => void) { const params = new URLSearchParams(); - appendQueryParam(params, 'eventKey', query.eventKey); - appendQueryParam(params, 'userId', query.userId); - appendQueryParam(params, 'scopeKind', query.scopeKind); - appendQueryParam(params, 'scopeId', query.scopeId); - appendQueryParam(params, 'startDate', query.startDate); - appendQueryParam(params, 'endDate', query.endDate); - if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { - params.set('limit', String(query.limit)); - } - if (query.exportAll) { - params.set('exportAll', 'true'); - } - const queryString = params.toString(); - return queryString ? `?${queryString}` : ''; + append(params); + return formatQueryString(params); } function buildErrorReportQueryString(query: { @@ -869,39 +866,31 @@ function buildErrorReportQueryString(query: { limit?: number; offset?: number; }) { - const params = new URLSearchParams(); - appendQueryParam(params, 'status', query.status); - appendQueryParam(params, 'fingerprint', query.fingerprint); - appendQueryParam(params, 'source', query.source); - if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { - params.set('limit', String(query.limit)); - } - if (typeof query.offset === 'number' && Number.isFinite(query.offset)) { - params.set('offset', String(query.offset)); - } - const queryString = params.toString(); - return queryString ? `?${queryString}` : ''; + return buildQueryString((params) => { + appendQueryParam(params, 'status', query.status); + appendQueryParam(params, 'fingerprint', query.fingerprint); + appendQueryParam(params, 'source', query.source); + appendNumericQueryParam(params, 'limit', query.limit); + appendNumericQueryParam(params, 'offset', query.offset); + }); } function buildAdminRechargeOrderListQuery(query: AdminRechargeOrderListQuery) { - const params = new URLSearchParams(); - appendQueryParam(params, 'orderId', query.orderId); - appendQueryParam( - params, - 'providerTransactionId', - query.providerTransactionId, - ); - appendQueryParam(params, 'userId', query.userId); - appendQueryParam(params, 'publicUserCode', query.publicUserCode); - appendQueryParam(params, 'paymentChannel', query.paymentChannel); - appendQueryParam(params, 'status', query.status); - appendQueryParam(params, 'createdAfter', query.createdAfter); - appendQueryParam(params, 'createdBefore', query.createdBefore); - if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { - params.set('limit', String(query.limit)); - } - const queryString = params.toString(); - return queryString ? `?${queryString}` : ''; + return buildQueryString((params) => { + appendQueryParam(params, 'orderId', query.orderId); + appendQueryParam( + params, + 'providerTransactionId', + query.providerTransactionId, + ); + appendQueryParam(params, 'userId', query.userId); + appendQueryParam(params, 'publicUserCode', query.publicUserCode); + appendQueryParam(params, 'paymentChannel', query.paymentChannel); + appendQueryParam(params, 'status', query.status); + appendQueryParam(params, 'createdAfter', query.createdAfter); + appendQueryParam(params, 'createdBefore', query.createdBefore); + appendNumericQueryParam(params, 'limit', query.limit); + }); } function buildAdminUserDetailQuery(query: AdminUserDetailQuery) { @@ -977,6 +966,21 @@ function appendQueryParam( } } +function appendNumericQueryParam( + params: URLSearchParams, + key: string, + value: number | null | undefined, +) { + if (typeof value === 'number' && Number.isFinite(value)) { + params.set(key, String(value)); + } +} + +function formatQueryString(params: URLSearchParams) { + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + function parseJsonResponse(responseText: string): unknown { if (!responseText.trim()) { return null; diff --git a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx index 4c2f323e8..eca55562d 100644 --- a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx +++ b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx @@ -22,6 +22,7 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { const [reports, setReports] = useState([]); const [selected, setSelected] = useState(null); const [status, setStatus] = useState(''); + const [downloading, setDownloading] = useState(false); const [busy, setBusy] = useState(false); const [filterStatus, setFilterStatus] = useState(''); const [pageOffset, setPageOffset] = useState(0); @@ -50,6 +51,11 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { } }, [filterStatus, onUnauthorized, pageOffset, token]); + const loadRef = useRef(load); + useEffect(() => { + loadRef.current = load; + }, [load]); + useEffect(() => { void load(); }, [load]); @@ -79,7 +85,7 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { note: selected.note, }), ); - await load(); + await loadRef.current(); } catch (error) { if (isAdminApiError(error) && error.status === 401) onUnauthorized(); else setStatus(formatAdminApiError(error)); @@ -89,6 +95,8 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { } async function download(batchId: string) { + if (downloading) return; + setDownloading(true); setStatus(''); try { const blob = await downloadAdminErrorReport(token, batchId); @@ -99,10 +107,12 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { document.body.appendChild(anchor); anchor.click(); anchor.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); + setTimeout(() => URL.revokeObjectURL(url), 10_000); } catch (error) { if (isAdminApiError(error) && error.status === 401) onUnauthorized(); else setStatus(formatAdminApiError(error)); + } finally { + setDownloading(false); } } @@ -236,8 +246,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 636e9c990..15d5b94da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2366,7 +2366,7 @@ async fn read_game_creator_codex_app_server_stdout( .and_then(serde_json::Value::as_object) .map(|params| params.keys().cloned().collect::>()) .unwrap_or_default(); - app_log!( + eprintln!( "agent.direct_codex.event method={} paramsKeys={:?}", method, params_keys ); @@ -2376,7 +2376,7 @@ async fn read_game_creator_codex_app_server_stdout( .map(serde_json::Value::to_string) .map(|value| value.len()) .unwrap_or_default(); - app_log!( + eprintln!( "agent.direct_codex.event.safeDetails method={} detailBytes={}", method, detail_bytes ); @@ -2396,7 +2396,7 @@ async fn read_game_creator_codex_app_server_stdout( .pointer("/turn/status") .and_then(serde_json::Value::as_str) .unwrap_or(""); - app_log!( + eprintln!( "agent.direct_codex.event notification={} status={}", event_name, status ); @@ -2415,7 +2415,7 @@ async fn read_game_creator_codex_app_server_stdout( .and_then(serde_json::Value::as_array) .map(Vec::len) .unwrap_or_default(); - app_log!( + eprintln!( "agent.direct_codex.item event={} type={} commandPresent={} changeCount={}", event_name, item_type, @@ -2441,7 +2441,7 @@ async fn read_game_creator_codex_app_server_stdout( .and_then(|params| params.get("grantRoot")) .and_then(serde_json::Value::as_str); if direct_workspace && direct_debug { - app_log!( + eprintln!( "agent.direct_codex.server_request method={method} grantRootPresent={}", requested_grant_root.is_some() ); @@ -2632,7 +2632,7 @@ async fn read_game_creator_codex_app_server_stderr( if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() && upgraded.workspace_mode.uses_direct_conversation() { - app_log!("agent.direct_codex.stderr bytes={count}"); + eprintln!("agent.direct_codex.stderr bytes={count}"); } let oversized_record = upgraded .stderr_summary diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c885413d5..28efa7749 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7867,3 +7867,4 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 2026-09-01 review 收口:错误报告修复详情请求竞态、下载 anchor 生命周期、客户端采集脱敏/指纹降级与 4xx 噪声、用户级幂等隔离、`agc` 私有 OSS 前缀越权、日志读取链接检查、ZIP 同名日志和元数据/归档清理一致性;同步在 `review.txt` 标注仍需产品/运维决定的架构项。 - 2026-09-01 追加:错误报告不依赖 api-server 本地文件、目录锁或同步文件 I/O;ZIP 只在请求内存构建后上传 OSS。管理员详情路由不属于 External OpenAPI;不存在返回 404,归档损坏返回 500。 - 2026-09-01 追加:错误报告不落本地文件;请求内存构建 ZIP 后直接上传 OSS,成功后写入 SpacetimeDB `error_report` 元数据。OSS key 固定为 `agc/error-reports/v1/{batchId}.zip`,不含日期;同一用户 `userId + submissionId` 幂等。管理员查询 DB,详情/下载按 object key 读取 OSS;每日清理先删 OSS,再删 DB,失败留待下次重试。 +- 2026-09-01 review minor 修复:错误报告每日清理改为完整分页扫描,DB 删除失败显式返回以便下周期重试;OSS 读取按 `Content-Length` 与流式累计执行大小上限;管理员归档解析限制解压后 `events.jsonl` 为 24 MiB / 100 条事件。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index ad49e6c68..ecdd3a800 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -23,10 +23,10 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 归档构建只在请求生命周期内使用受 20 MiB 上限约束的内存 `Vec`,随后直接 PUT 到私有 OSS;服务端不写本地报告文件,也不保留本地索引。OSS 上传失败不写入数据库,调用方可稍后重新提交。 - 归档对象使用固定私有 OSS key:`agc/error-reports/v1/{batchId}.zip`;key 只由报告 UUID 决定,不包含时间戳。上传成功后才写入 SpacetimeDB `error_report` 元数据表;`userId + submissionId` 由唯一幂等键保证重放返回已有记录。完整事件、说明和日志只存在 OSS ZIP。 - `agc` 是服务端专用私有前缀;公共直传票据、通用 object-key 规范化和 legacy 公开路径均拒绝该前缀。归档内同名日志会自动加数字后缀,读取本机诊断日志时拒绝符号链接/非普通文件。 -- 后台接口:`GET/PATCH /admin/api/error-reports/{batchId}`、`GET /admin/api/error-reports` 和受保护的 `/download`。列表支持 `limit`/`offset` 分页并返回 `total`、`hasMore`。 +- 后台接口:`GET/PATCH /admin/api/error-reports/{batchId}`、`GET /admin/api/error-reports` 和受保护的 `/download`。列表支持 `limit`/`offset` 分页并返回 `total`、`hasMore`;OSS 读取先检查 `Content-Length` 并在流式累计超过上限时立即中止,不把超限对象完整缓存在内存中。 - 这些是 api-server 内部登录/管理员路由,不属于 `/api/external/v1`,不纳入 External OpenAPI;管理员详情对不存在返回 404,对归档/元数据损坏返回 500。 - admin viewer 仅接受 error-reports Tab 权限,支持列表筛选、分页、详情、状态 `new/in-progress/resolved`、处理备注和受控下载;不存在的更新目标返回 404,存储损坏返回 500。列表行支持键盘 Enter/Space 打开详情,详情事件预览最多显示 20 条,完整内容通过诊断包下载获取。 -- 管理员列表、筛选、状态和备注全部读取/更新 SpacetimeDB;详情先读表再从 OSS 下载并解析 ZIP,下载接口直接从 OSS 返回 ZIP。无需新增管理员 DELETE HTTP 接口。每日清理任务删除过期 OSS 对象(成功或对象不存在后再删 DB;失败保留 DB 供下次重试)。旧本地报告不迁移。 +- 管理员列表、筛选、状态和备注全部读取/更新 SpacetimeDB;详情先读表再从 OSS 下载并解析 ZIP,下载接口直接从 OSS 返回 ZIP。无需新增管理员 DELETE HTTP 接口。每日清理任务按分页扫描全部报告,删除过期 OSS 对象后再删 DB;任一 DB 删除失败会保留错误并在后续周期重试。详情解析对解压后的 `events.jsonl` 设置 24 MiB(与请求体上限一致)与 100 条事件上限。旧本地报告不迁移。 SpacetimeDB `error_report` 表字段:`batch_id` 主键、`user_id`、`submission_id`、`idempotency_key` 唯一键、`object_key`、`archive_sha256`、`archive_size_bytes`、`event_count`、`log_count`、首个 fingerprint/source、`review_status`、`admin_note`、`created_at`、`updated_at`;索引为 `(user_id, submission_id)`、`created_at`、`review_status`。 diff --git a/server-rs/crates/api-server/src/error_reports.rs b/server-rs/crates/api-server/src/error_reports.rs index 5f6a58d3b..0968d4dee 100644 --- a/server-rs/crates/api-server/src/error_reports.rs +++ b/server-rs/crates/api-server/src/error_reports.rs @@ -45,6 +45,7 @@ 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; +const MAX_EVENTS_JSONL_BYTES: usize = MAX_REQUEST_BODY_BYTES; #[derive(Clone, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -251,7 +252,7 @@ pub async fn admin_list_error_reports( total, offset, limit, - has_more: offset.saturating_add(limit) < total as u32, + has_more: (offset as u64).saturating_add(limit as u64) < total, }, )) } @@ -391,7 +392,8 @@ pub async fn admin_download_error_report( ); resp.headers_mut().insert( header::CONTENT_DISPOSITION, - HeaderValue::from_str(&format!("attachment; filename=\"{batch_id}.zip\"")).unwrap(), + HeaderValue::from_str(&format!("attachment; filename=\"{batch_id}.zip\"")) + .map_err(internal)?, ); Ok(resp) } @@ -513,15 +515,26 @@ pub(crate) fn build_error_report_archive( fn parse_archive(bytes: &[u8]) -> Result<(Vec, Option, Vec), String> { let mut a = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| e.to_string())?; let mut t = String::new(); - a.by_name("events.jsonl") - .map_err(|_| "归档缺少 events.jsonl".to_string())? - .read_to_string(&mut t) - .map_err(|e| e.to_string())?; + { + let mut events_file = a + .by_name("events.jsonl") + .map_err(|_| "归档缺少 events.jsonl".to_string())? + .take((MAX_EVENTS_JSONL_BYTES + 1) as u64); + events_file + .read_to_string(&mut t) + .map_err(|e| e.to_string())?; + } + if t.len() > MAX_EVENTS_JSONL_BYTES { + return Err("events.jsonl 解压后超过大小上限".to_string()); + } let events = t .lines() .filter(|l| !l.trim().is_empty()) .map(|l| serde_json::from_str(l).map_err(|e| e.to_string())) .collect::, _>>()?; + if events.len() > MAX_EVENTS { + return Err("归档事件数量超过上限".to_string()); + } let desc = if let Ok(mut f) = a.by_name("user-description.txt") { let mut s = String::new(); f.read_to_string(&mut s).map_err(|e| e.to_string())?; @@ -590,23 +603,32 @@ pub fn spawn_cleanup_worker(state: AppState) { } async fn cleanup_expired(state: &AppState) -> Result<(), String> { let cutoff = OffsetDateTime::now_utc() - time::Duration::days(30); - let (rows, _) = state - .spacetime_client() - .list_error_reports(ErrorReportListRecordInput { - status: None, - fingerprint: None, - source: None, - limit: 500, - offset: 0, - }) - .await - .map_err(|e| e.to_string())?; - for r in rows { - if let Ok(ts) = OffsetDateTime::parse(&r.created_at, &Rfc3339) { - if ts < cutoff { - let Some(oss) = state.oss_client() else { - continue; - }; + let Some(oss) = state.oss_client() else { + return Ok(()); + }; + let mut offset = 0_u32; + let mut first_error = None; + loop { + let (rows, _) = state + .spacetime_client() + .list_error_reports(ErrorReportListRecordInput { + status: None, + fingerprint: None, + source: None, + limit: 500, + offset, + }) + .await + .map_err(|e| e.to_string())?; + if rows.is_empty() { + break; + } + let page_len = rows.len() as u32; + let mut deleted = 0_u32; + for r in rows { + if let Ok(ts) = OffsetDateTime::parse(&r.created_at, &Rfc3339) + && ts < cutoff + { if oss .delete_object( state.editor_oss_http_client(), @@ -619,12 +641,23 @@ async fn cleanup_expired(state: &AppState) -> Result<(), String> { { continue; } - let _ = state + if let Err(error) = state .spacetime_client() .delete_error_report(r.batch_id) - .await; + .await + { + first_error.get_or_insert_with(|| error.to_string()); + } else { + deleted += 1; + } } } + if page_len < 500 { + break; + } + if deleted == 0 { + offset = offset.saturating_add(page_len); + } } - Ok(()) + first_error.map_or(Ok(()), Err) } diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 88401b799..9173e20bb 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -867,7 +867,7 @@ impl OssClient { let key = normalize_internal_object_key(&request.object_key)?; let target = build_object_url(&self.config.bucket, &self.config.endpoint, &key) .map_err(|e| request_error(OssRequestOperation::Get, &e.to_string()))?; - let response = send_signed_request( + let mut response = send_signed_request( client, &self.config, Method::GET, @@ -886,14 +886,24 @@ impl OssClient { format!("OSS GET Object 失败,状态码:{}", response.status()), )); } - let bytes = response - .bytes() - .await - .map_err(|e| request_error_from_reqwest(OssRequestOperation::Get, e))?; - if bytes.len() > request.max_bytes { + if response + .content_length() + .is_some_and(|length| length > request.max_bytes as u64) + { return Err(OssError::InvalidRequest("OSS 对象超过读取上限".to_string())); } - Ok(bytes.to_vec()) + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| request_error_from_reqwest(OssRequestOperation::Get, e))? + { + if bytes.len().saturating_add(chunk.len()) > request.max_bytes { + return Err(OssError::InvalidRequest("OSS 对象超过读取上限".to_string())); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) } pub async fn delete_object( diff --git a/server-rs/crates/spacetime-client/src/mapper/error_reports.rs b/server-rs/crates/spacetime-client/src/mapper/error_reports.rs index acb85c0ef..a505acfad 100644 --- a/server-rs/crates/spacetime-client/src/mapper/error_reports.rs +++ b/server-rs/crates/spacetime-client/src/mapper/error_reports.rs @@ -19,6 +19,7 @@ pub struct ErrorReportRecord { pub updated_at: String, } +#[derive(Clone, Debug, PartialEq, Eq)] pub struct ErrorReportCreateRecordInput { pub batch_id: String, pub user_id: String, @@ -33,6 +34,7 @@ pub struct ErrorReportCreateRecordInput { pub first_source: Option, pub now_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] pub struct ErrorReportListRecordInput { pub status: Option, pub fingerprint: Option, @@ -40,6 +42,7 @@ pub struct ErrorReportListRecordInput { pub limit: u32, pub offset: u32, } +#[derive(Clone, Debug, PartialEq, Eq)] pub struct ErrorReportUpdateRecordInput { pub batch_id: String, pub status: String,