From 7c363bb4730f334f8b411cfa9e92136c1384e331 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/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 ++++-- 4 files changed, 83 insertions(+), 25 deletions(-) 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