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 index fbaf8d3a5..0b109ca80 100644 --- a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; import { + ackClientErrorEventsWithRetry, type ClientErrorEvent, type DiagnosticLogFile, - markClientErrorEventsSubmitted, + getStableErrorReportSubmissionId, readApplicationDiagnosticLogs, submitErrorReportBatch, } from '../../services/errorReporting'; @@ -60,14 +61,16 @@ export function ErrorReportDialog({ setBusy(true); setStatus('正在提交…'); try { + const submissionId = getStableErrorReportSubmissionId(selectedEvents); await submitErrorReportBatch({ events: selectedEvents, logs: includeLogs ? logs : [], userDescription: description, + submissionId, }); - await markClientErrorEventsSubmitted(selectedEvents); setStatus('已提交,感谢你的反馈'); window.setTimeout(onClose, 700); + void ackClientErrorEventsWithRetry(selectedEvents); } catch (error) { setStatus( error instanceof Error ? error.message : '提交失败,请稍后再试', diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 0a1648098..da130bfcc 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -23,6 +23,15 @@ export type DiagnosticLogFile = { name: string; content: string }; type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log'; +let fallbackSubmissionSequence = 0; + +function createSubmissionId() { + return ( + globalThis.crypto?.randomUUID?.() ?? + `submission-${Date.now()}-${++fallbackSubmissionSequence}` + ); +} + export function shouldCaptureClientError(error: unknown) { if (!error || typeof error !== 'object') return true; const candidate = error as { status?: unknown; networkError?: unknown }; @@ -174,6 +183,7 @@ export async function submitErrorReportBatch( events: ClientErrorEvent[]; userDescription?: string; logs: DiagnosticLogFile[]; + submissionId?: string; }, apiBaseUrl = getClientServerBaseUrl(), ) { @@ -191,8 +201,7 @@ export async function submitErrorReportBatch( }, body: JSON.stringify({ schemaVersion: 1, - submissionId: - globalThis.crypto?.randomUUID?.() ?? `submission-${Date.now()}`, + submissionId: payload.submissionId ?? createSubmissionId(), events: payload.events, userDescription: payload.userDescription?.trim() || null, logs: payload.logs, @@ -207,11 +216,51 @@ export async function submitErrorReportBatch( return result.data ?? result; } -export function markClientErrorEventsSubmitted(events: ClientErrorEvent[]) { - return ackErrorReports(events.map((event) => event.eventId)); +const stableSubmissionIds = new Map(); + +function errorEventKey(events: ClientErrorEvent[]) { + return events + .map((event) => event.eventId) + .sort() + .join('\u0000'); +} + +export function getStableErrorReportSubmissionId(events: ClientErrorEvent[]) { + const key = errorEventKey(events); + const existing = stableSubmissionIds.get(key); + if (existing) return existing; + const submissionId = createSubmissionId(); + stableSubmissionIds.set(key, submissionId); + return submissionId; +} + +export async function markClientErrorEventsSubmitted( + events: ClientErrorEvent[], +) { + await ackErrorReports(events.map((event) => event.eventId)); + stableSubmissionIds.delete(errorEventKey(events)); +} + +export async function ackClientErrorEventsWithRetry( + events: ClientErrorEvent[], +) { + for (const [attempt, delay] of [0, 500, 1_000, 2_000].entries()) { + if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); + try { + await markClientErrorEventsSubmitted(events); + return; + } catch (error) { + console.warn( + `[error-report] 本地 ack 第 ${attempt + 1} 次失败,将继续重试`, + error, + ); + } + } + console.warn('[error-report] 本地 ack 重试耗尽,事件仍保留在本地队列'); } /** 仅供单元测试隔离进程内错误池;生产流程不调用。 */ export function resetClientErrorEventsForTests() { // Rust 队列按进程生命周期管理;测试通过 fake bridge 重建进程内状态。 + stableSubmissionIds.clear(); } diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 4fb1747c9..3518def4a 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -81,9 +81,11 @@ import { invoke } from '@tauri-apps/api/core'; import { fetchClientHttp } from '../src/services/clientHttp'; import { + ackClientErrorEventsWithRetry, captureAgentRuntimeError, captureClientError, getPendingClientErrorEvents, + getStableErrorReportSubmissionId, installWebviewLogBridge, markClientErrorEventsSubmitted, resetClientErrorEventsForTests, @@ -153,6 +155,40 @@ describe('客户端错误报告池', () => { expect(await getPendingClientErrorEvents()).toEqual([second]); }); + it('同一批事件复用稳定 submissionId', async () => { + const event = await captureClientError(new Error('可重试错误'), { + source: 'test', + }); + const firstId = getStableErrorReportSubmissionId([event]); + const secondId = getStableErrorReportSubmissionId([event]); + + expect(secondId).toBe(firstId); + }); + + it('本地 ack 失败时自动重试且不向调用方抛错', async () => { + vi.useFakeTimers(); + const event = await captureClientError(new Error('ack 重试错误'), { + source: 'test', + }); + vi.mocked(invoke) + .mockRejectedValueOnce(new Error('暂时不可用')) + .mockResolvedValueOnce(undefined); + + const pending = ackClientErrorEventsWithRetry([event]); + await vi.advanceTimersByTimeAsync(500); + await pending; + + expect(invoke).toHaveBeenCalledWith('ack_error_reports', { + eventIds: [event.eventId], + }); + expect( + vi + .mocked(invoke) + .mock.calls.filter(([command]) => command === 'ack_error_reports'), + ).toHaveLength(2); + vi.useRealTimers(); + }); + it('未登录时拒绝提交且不发请求', async () => { const auth = await import('../src/services/clientAuth'); vi.mocked(auth.getStoredAuthAccessToken).mockReturnValue(''); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 609c55628..5c061ec3b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7907,3 +7907,4 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 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 条事件。 - 2026-09-02 review breaking 项落地:错误报告 create/update procedure 删除 `user_id`、`now_micros` 输入字段,分别改用 `ctx.sender()`、`ctx.timestamp`;首个 fingerprint/source 强制 512 字符上限,备注超过 2,000 字显式拒绝;内部 OSS 对象键收紧为 `agc/error-reports/v1/`,凭据脱敏覆盖空白/分隔符变体。发布需同步 module、spacetime-client bindings 与 api-server。 +- 2026-09-02 客户端错误报告提交成功与本地队列 ack 解耦:POST 成功即显示提交成功并关闭,ack 使用有限重试且失败只记录日志;同一批 eventId 在进程内复用稳定 submissionId,避免 ack 失败后重新提交产生重复报告。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 752e4db51..2f9a3fc52 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -19,7 +19,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 ## HTTP 与存储 - 登录态客户端使用 `POST /api/error-reports`,请求 DTO 位于 `shared-contracts::error_reports`。 -- api-server 对请求体设置 24 MiB 上限,并校验 schemaVersion、submissionId、事件/日志数量和 20 MiB 压缩包上限;事件字段、用户说明和日志名/内容均做长度限制与凭据脱敏,归档使用 `events.jsonl`(每行一个事件)。结构化事件只保存在当前进程内,用户提交时才生成 `events.jsonl`,不在磁盘单独持久化。submissionId 提供重放幂等;SpacetimeDB procedure 不接受调用方 `user_id` 或 `now_micros`,分别使用 `ctx.sender()` 与 `ctx.timestamp`。 +- api-server 对请求体设置 24 MiB 上限,并校验 schemaVersion、submissionId、事件/日志数量和 20 MiB 压缩包上限;事件字段、用户说明和日志名/内容均做长度限制与凭据脱敏,归档使用 `events.jsonl`(每行一个事件)。结构化事件只保存在当前进程内,用户提交时才生成 `events.jsonl`,不在磁盘单独持久化。submissionId 提供重放幂等;客户端对同一批事件复用稳定 submissionId,服务端提交成功后立即反馈成功,本地事件 ack 独立重试,不因 ack 失败误报提交失败;SpacetimeDB procedure 不接受调用方 `user_id` 或 `now_micros`,分别使用 `ctx.sender()` 与 `ctx.timestamp`。 - 归档构建只在请求生命周期内使用受 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 公开路径均拒绝该前缀。归档内同名日志会自动加数字后缀,读取本机诊断日志时拒绝符号链接/非普通文件。