From c604423f0f1b27ae1dd487d79edb9ae34fb874cc Mon Sep 17 00:00:00 2001 From: yanweidong Date: Tue, 21 Jul 2026 18:42:47 +0800 Subject: [PATCH] fix: make inbox confirmation retry safe --- apps/web_v1/scripts/visual-check.mjs | 134 ++++++++++++++++-- apps/web_v1/src/app/App.tsx | 53 +++++-- .../src/pages/projects/project-inbox.tsx | 12 +- backend/internal/logic/inbox/service.go | 26 +++- backend/internal/logic/inbox/service_test.go | 17 ++- 5 files changed, 209 insertions(+), 33 deletions(-) diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index db7e42d..8b3d40c 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -19,6 +19,7 @@ const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, de const errors = [] const failures = [] const projectId = '019b0000-0000-7000-8000-000000000001' +const secondProjectId = '019b0000-0000-7000-8000-000000000012' const taskSearchResultId = '019b0000-0000-7000-8000-000000000003' const inboxItemId = '019b0000-0000-7000-8000-000000000004' const inboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000005' @@ -28,6 +29,8 @@ const secondInboxItemId = '019b0000-0000-7000-8000-000000000008' const secondInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000009' const secondInboxNoteSuggestionId = '019b0000-0000-7000-8000-000000000010' const secondInboxSourceSuggestionId = '019b0000-0000-7000-8000-000000000011' +const thirdInboxItemId = '019b0000-0000-7000-8000-000000000013' +const thirdInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000014' const unknownProjectId = '019b0000-0000-7000-8000-000000000099' const externalProjectId = '019b0000-0000-7000-8000-000000000088' const externalTaskId = '019b0000-0000-7000-8000-000000000089' @@ -43,8 +46,11 @@ let expectingInboxConfirmError = false let expectedInboxConfirmConsoleErrorCount = 0 let expectingInboxRefreshError = false let expectedInboxRefreshConsoleErrorCount = 0 +let expectingInboxConflict = false +let expectedInboxConflictConsoleErrorCount = 0 let failNextInboxConfirm = true let failNextInboxWorkspaceRefresh = false +let conflictNextInboxConfirm = false page.on('console', (message) => { if (message.type() !== 'error') return if ( @@ -58,7 +64,7 @@ page.on('console', (message) => { if ( expectingInboxConfirmError && expectedInboxConfirmConsoleErrorCount === 0 && - message.text() === 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)' + message.text().includes('Failed to load resource') ) { expectedInboxConfirmConsoleErrorCount += 1 return @@ -71,6 +77,14 @@ page.on('console', (message) => { expectedInboxRefreshConsoleErrorCount += 1 return } + if ( + expectingInboxConflict && + expectedInboxConflictConsoleErrorCount === 0 && + message.text() === 'Failed to load resource: the server responded with a status of 409 (Conflict)' + ) { + expectedInboxConflictConsoleErrorCount += 1 + return + } errors.push(message.text()) }) @@ -87,7 +101,7 @@ const visualCheckWorkspace = { }, channels: [ { id: 'overview', projectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 }, - { id: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'mail', count: 2, url: '', sortOrder: 1 }, + { id: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'mail', count: 3, url: '', sortOrder: 1 }, { id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 1, url: '', sortOrder: 2 }, { id: 'ai', projectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 3 }, { id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 4 }, @@ -118,6 +132,16 @@ const visualCheckWorkspace = { tag: '待处理', time: '2026-07-21T02:20:00Z', }, + { + id: thirdInboxItemId, + projectId, + source: '手动收集', + title: '延迟确认测试', + summary: '确认期间切换项目时必须保留最新选择。', + status: 'open', + tag: '待处理', + time: '2026-07-21T02:10:00Z', + }, ], tasks: [], aiSessions: [], @@ -125,6 +149,29 @@ const visualCheckWorkspace = { cronPlans: [], } +const secondVisualCheckWorkspace = { + project: { + id: secondProjectId, + name: '并行项目', + identifier: 'parallel', + icon: '并', + background: '#722ED1', + description: '用于验证异步操作不会恢复旧项目选择。', + initials: '并行', + unreadCount: 0, + }, + channels: [ + { id: 'second-overview', projectId: secondProjectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 }, + ], + tags: [], + recentSessions: [], + inbox: [], + tasks: [], + aiSessions: [], + notesSources: [], + cronPlans: [], +} + await page.route('http://localhost:9150/api/v1/**', async (route) => { const url = new URL(route.request().url()) const method = route.request().method() @@ -137,7 +184,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { return } if (url.pathname === '/api/v1/projects') { - await route.fulfill({ json: [visualCheckWorkspace.project] }) + await route.fulfill({ json: [visualCheckWorkspace.project, secondVisualCheckWorkspace.project] }) return } if (url.pathname === `/api/v1/projects/${projectId}/workspace`) { @@ -153,6 +200,11 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { await route.fulfill({ json: visualCheckWorkspace }) return } + if (url.pathname === `/api/v1/projects/${secondProjectId}/workspace`) { + workspaceRequests.push(url.pathname) + await route.fulfill({ json: secondVisualCheckWorkspace }) + return + } if (url.pathname === `/api/v1/inbox/${inboxItemId}/analyze` && method === 'POST') { inboxAnalyzeRequests.push(inboxItemId) if (inboxAnalyzeRequests.length === 1) await new Promise((resolve) => setTimeout(resolve, 250)) @@ -180,14 +232,30 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { }) return } - if ([`/api/v1/inbox/${inboxItemId}/confirm`, `/api/v1/inbox/${secondInboxItemId}/confirm`].includes(url.pathname) && method === 'POST') { + if (url.pathname === `/api/v1/inbox/${thirdInboxItemId}/analyze` && method === 'POST') { + inboxAnalyzeRequests.push(thirdInboxItemId) + await route.fulfill({ + json: { + suggestions: [ + { id: thirdInboxTaskSuggestionId, kind: 'task', title: '延迟确认任务', body: '验证跨项目异步选择。' }, + ], + }, + }) + return + } + if ([`/api/v1/inbox/${inboxItemId}/confirm`, `/api/v1/inbox/${secondInboxItemId}/confirm`, `/api/v1/inbox/${thirdInboxItemId}/confirm`].includes(url.pathname) && method === 'POST') { inboxConfirmRequests.push(route.request().postDataJSON()) await new Promise((resolve) => setTimeout(resolve, 250)) if (failNextInboxConfirm) { failNextInboxConfirm = false + await route.abort('failed') + return + } + if (conflictNextInboxConfirm) { + conflictNextInboxConfirm = false await route.fulfill({ - status: 500, - json: { error: { code: 'internal_error', message: '确认创建失败,请稍后重试' } }, + status: 409, + json: { error: { code: 'conflict', message: '确认状态发生冲突' } }, }) return } @@ -639,9 +707,12 @@ if (await inboxChannelButton.count() === 0) { if (!await confirmButton.evaluate((button) => button.classList.contains('arco-btn-loading'))) { failures.push('Inbox confirmation must show loading while the write is pending') } + if (!await confirmButton.isDisabled()) { + failures.push('Inbox confirmation must remain disabled while the write is pending') + } await page.waitForTimeout(300) - if (!await inboxPage.getByText('确认创建失败,请稍后重试', { exact: true }).isVisible()) { - failures.push('failed Inbox confirmation must show the API Chinese error') + if (!await inboxPage.getByText('确认结果未知,服务端支持幂等处理,可安全重试', { exact: true }).isVisible()) { + failures.push('a network-failed Inbox confirmation must explain that an idempotent retry is safe') } expectingInboxConfirmError = false if (expectedInboxConfirmConsoleErrorCount !== 1) { @@ -704,6 +775,53 @@ if (await inboxChannelButton.count() === 0) { failures.push(`expected one simulated Inbox refresh console error, got ${expectedInboxRefreshConsoleErrorCount}`) } await page.screenshot({ path: 'test-results/project-inbox-refresh-warning.png', fullPage: true }) + + const thirdInboxRow = inboxPage.locator('.mail-item', { hasText: '延迟确认测试' }) + await thirdInboxRow.click() + await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click() + await inboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {}) + const thirdConfirmButton = inboxPage.getByRole('button', { name: '确认创建', exact: true }) + await thirdConfirmButton.click() + await page.waitForTimeout(50) + if (!await thirdConfirmButton.isDisabled()) { + failures.push('delayed Inbox confirmation must stay disabled before its response') + } + await page.locator('.project-button[title="并行项目"]').click() + await page.waitForTimeout(700) + const projectAfterDelayedConfirm = await page.locator('.project-title h5').textContent() + if (projectAfterDelayedConfirm !== '并行项目') { + failures.push(`delayed Inbox confirmation must preserve the latest project selection, got ${projectAfterDelayedConfirm}`) + } + if (!await page.locator('.project-button[title="并行项目"]').evaluate((button) => button.classList.contains('active'))) { + failures.push('the project selected during Inbox confirmation must remain active after the request settles') + } + await page.locator('.project-button').first().click() + await page.waitForTimeout(250) + + await page.locator('.channel-button', { hasText: 'Inbox 消息流' }).click() + const conflictInboxPage = page.locator('.project-inbox-page') + await conflictInboxPage.locator('.mail-item', { hasText: '延迟确认测试' }).click() + await conflictInboxPage.getByRole('button', { name: '分析内容', exact: true }).click() + await conflictInboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {}) + conflictNextInboxConfirm = true + expectingInboxConflict = true + const workspaceRequestsBeforeConflict = workspaceRequests.length + const conflictConfirmButton = conflictInboxPage.getByRole('button', { name: '确认创建', exact: true }) + await conflictConfirmButton.click() + await conflictInboxPage.getByText('确认状态发生冲突,工作区已刷新,请重新核对', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}) + if (workspaceRequests.length <= workspaceRequestsBeforeConflict) { + failures.push('a non-idempotency 409 must refresh the current workspace before reporting the conflict') + } + if (!await conflictConfirmButton.isDisabled()) { + failures.push('a conflict refresh that finds the item processed must prevent another confirmation') + } + if (await conflictInboxPage.getByText('确认结果未知,服务端支持幂等处理,可安全重试', { exact: true }).count() !== 0) { + failures.push('a 409 conflict must not be presented as a safe retryable network uncertainty') + } + expectingInboxConflict = false + if (expectedInboxConflictConsoleErrorCount !== 1) { + failures.push(`expected one simulated Inbox conflict console error, got ${expectedInboxConflictConsoleErrorCount}`) + } } } diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index 44cfc71..df6399a 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { ConfigProvider, Message, Spin } from '@arco-design/web-react' import '@arco-design/web-react/dist/css/arco.css' import '../App.css' -import { login, setApiBaseUrl, type ApiSession } from '../api/client' +import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client' import { analyzeInboxItem, confirmInboxItem } from '../api/inbox' import { mapWorkspace } from '../api/mappers' import { @@ -33,6 +33,7 @@ function App() { const [session, setSession] = useState(null) const [workspaces, setWorkspaces] = useState([]) const [activeProjectID, setActiveProjectID] = useState('') + const activeProjectIDRef = useRef('') const [activeChannel, setActiveChannel] = useState('overview') const [activeTaskID, setActiveTaskID] = useState(null) const [, setSelectedItem] = useState('探索采集') @@ -46,14 +47,24 @@ function App() { const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0] const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? [] - async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) { + function selectActiveProject(projectID: string) { + activeProjectIDRef.current = projectID + setActiveProjectID(projectID) + } + + async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) { + const projectIDWhenStarted = activeProjectIDRef.current const backendProjects = await fetchProjects(nextSession) const backendWorkspaces = await Promise.all( backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))), ) setWorkspaces(backendWorkspaces) - const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === preferredProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? '' - setActiveProjectID(nextProjectID) + const latestProjectID = activeProjectIDRef.current + const requestedProjectID = latestProjectID !== projectIDWhenStarted + ? latestProjectID + : preferredProjectID ?? latestProjectID + const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? '' + selectActiveProject(nextProjectID) return backendWorkspaces } @@ -64,7 +75,7 @@ function App() { const nextSession = await login(input.email, input.password) const backendWorkspaces = await loadWorkspaces(nextSession, '') setSession(nextSession) - setActiveProjectID(backendWorkspaces[0]?.project.id ?? '') + selectActiveProject(backendWorkspaces[0]?.project.id ?? '') setActiveChannel('overview') setActiveView('workspace') setScreen('workbench') @@ -75,7 +86,7 @@ function App() { } } - async function refreshAfterAction(nextProjectID = activeProjectID) { + async function refreshAfterAction(nextProjectID?: string) { if (!session) return await loadWorkspaces(session, nextProjectID) } @@ -124,7 +135,7 @@ function App() { const nextProjectID = created.id await refreshAfterAction(nextProjectID) if (nextProjectID) { - setActiveProjectID(nextProjectID) + selectActiveProject(nextProjectID) setActiveView('project') setActiveChannel('overview') } @@ -198,7 +209,7 @@ function App() { } function openTask(project: Project, taskID: string) { - setActiveProjectID(project.id) + selectActiveProject(project.id) setActiveView('project') setActiveChannel('tasks') setActiveTaskID(taskID) @@ -236,7 +247,25 @@ function App() { } async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) { - const response = await confirmInboxItem(requireSession(), inboxId, suggestionIds) + const confirmationProjectID = activeProjectIDRef.current + let response + try { + response = await confirmInboxItem(requireSession(), inboxId, suggestionIds) + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 409) throw error + if (activeProjectIDRef.current !== confirmationProjectID) { + throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新核对当前工作区') + } + try { + await refreshAfterAction() + } catch { + throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入项目核对') + } + throw new ApiError(409, 'conflict', '确认状态发生冲突,工作区已刷新,请重新核对') + } + if (activeProjectIDRef.current !== confirmationProjectID) { + return { createdCount: response.createdCount } + } try { await refreshAfterAction() return { createdCount: response.createdCount } @@ -263,7 +292,7 @@ function App() { } return } - setActiveProjectID(owningWorkspace.project.id) + selectActiveProject(owningWorkspace.project.id) setActiveView('project') setActiveChannel(target.channel) setActiveTaskID(target.openTask ? result.id : null) @@ -288,7 +317,7 @@ function App() { onSelectWorkspace={() => setActiveView('workspace')} onSelectWorkspaceExplore={() => setActiveView('workspace-explore')} onSelectProject={(project) => { - setActiveProjectID(project.id) + selectActiveProject(project.id) setActiveView('project') setActiveChannel('overview') setActiveTaskID(null) diff --git a/apps/web_v1/src/pages/projects/project-inbox.tsx b/apps/web_v1/src/pages/projects/project-inbox.tsx index ea86031..8e63558 100644 --- a/apps/web_v1/src/pages/projects/project-inbox.tsx +++ b/apps/web_v1/src/pages/projects/project-inbox.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState } from 'react' import { Alert, Button, Card, Checkbox, Empty, Space, Tag, Typography } from '@arco-design/web-react' import { IconCheckCircle, IconRobot } from '@arco-design/web-react/icon' +import { ApiError } from '../../api/client' import type { InboxSuggestionDTO } from '../../api/inbox' import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types' @@ -76,7 +77,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI setSuccess(`已创建 ${outcome.createdCount} 个对象`) setRefreshWarning(outcome.refreshError ?? '') } catch (reason) { - setError(reason instanceof Error ? reason.message : '确认创建失败,请稍后重试') + setError(confirmationErrorMessage(reason)) } finally { setConfirming(false) } @@ -191,7 +192,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI