fix: make inbox confirmation retry safe
This commit is contained in:
@@ -19,6 +19,7 @@ const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, de
|
|||||||
const errors = []
|
const errors = []
|
||||||
const failures = []
|
const failures = []
|
||||||
const projectId = '019b0000-0000-7000-8000-000000000001'
|
const projectId = '019b0000-0000-7000-8000-000000000001'
|
||||||
|
const secondProjectId = '019b0000-0000-7000-8000-000000000012'
|
||||||
const taskSearchResultId = '019b0000-0000-7000-8000-000000000003'
|
const taskSearchResultId = '019b0000-0000-7000-8000-000000000003'
|
||||||
const inboxItemId = '019b0000-0000-7000-8000-000000000004'
|
const inboxItemId = '019b0000-0000-7000-8000-000000000004'
|
||||||
const inboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000005'
|
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 secondInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000009'
|
||||||
const secondInboxNoteSuggestionId = '019b0000-0000-7000-8000-000000000010'
|
const secondInboxNoteSuggestionId = '019b0000-0000-7000-8000-000000000010'
|
||||||
const secondInboxSourceSuggestionId = '019b0000-0000-7000-8000-000000000011'
|
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 unknownProjectId = '019b0000-0000-7000-8000-000000000099'
|
||||||
const externalProjectId = '019b0000-0000-7000-8000-000000000088'
|
const externalProjectId = '019b0000-0000-7000-8000-000000000088'
|
||||||
const externalTaskId = '019b0000-0000-7000-8000-000000000089'
|
const externalTaskId = '019b0000-0000-7000-8000-000000000089'
|
||||||
@@ -43,8 +46,11 @@ let expectingInboxConfirmError = false
|
|||||||
let expectedInboxConfirmConsoleErrorCount = 0
|
let expectedInboxConfirmConsoleErrorCount = 0
|
||||||
let expectingInboxRefreshError = false
|
let expectingInboxRefreshError = false
|
||||||
let expectedInboxRefreshConsoleErrorCount = 0
|
let expectedInboxRefreshConsoleErrorCount = 0
|
||||||
|
let expectingInboxConflict = false
|
||||||
|
let expectedInboxConflictConsoleErrorCount = 0
|
||||||
let failNextInboxConfirm = true
|
let failNextInboxConfirm = true
|
||||||
let failNextInboxWorkspaceRefresh = false
|
let failNextInboxWorkspaceRefresh = false
|
||||||
|
let conflictNextInboxConfirm = false
|
||||||
page.on('console', (message) => {
|
page.on('console', (message) => {
|
||||||
if (message.type() !== 'error') return
|
if (message.type() !== 'error') return
|
||||||
if (
|
if (
|
||||||
@@ -58,7 +64,7 @@ page.on('console', (message) => {
|
|||||||
if (
|
if (
|
||||||
expectingInboxConfirmError &&
|
expectingInboxConfirmError &&
|
||||||
expectedInboxConfirmConsoleErrorCount === 0 &&
|
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
|
expectedInboxConfirmConsoleErrorCount += 1
|
||||||
return
|
return
|
||||||
@@ -71,6 +77,14 @@ page.on('console', (message) => {
|
|||||||
expectedInboxRefreshConsoleErrorCount += 1
|
expectedInboxRefreshConsoleErrorCount += 1
|
||||||
return
|
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())
|
errors.push(message.text())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,7 +101,7 @@ const visualCheckWorkspace = {
|
|||||||
},
|
},
|
||||||
channels: [
|
channels: [
|
||||||
{ id: 'overview', projectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
|
{ 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: '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: '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 },
|
{ id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 4 },
|
||||||
@@ -118,6 +132,16 @@ const visualCheckWorkspace = {
|
|||||||
tag: '待处理',
|
tag: '待处理',
|
||||||
time: '2026-07-21T02:20:00Z',
|
time: '2026-07-21T02:20:00Z',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: thirdInboxItemId,
|
||||||
|
projectId,
|
||||||
|
source: '手动收集',
|
||||||
|
title: '延迟确认测试',
|
||||||
|
summary: '确认期间切换项目时必须保留最新选择。',
|
||||||
|
status: 'open',
|
||||||
|
tag: '待处理',
|
||||||
|
time: '2026-07-21T02:10:00Z',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
aiSessions: [],
|
aiSessions: [],
|
||||||
@@ -125,6 +149,29 @@ const visualCheckWorkspace = {
|
|||||||
cronPlans: [],
|
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) => {
|
await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
const method = route.request().method()
|
const method = route.request().method()
|
||||||
@@ -137,7 +184,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (url.pathname === '/api/v1/projects') {
|
if (url.pathname === '/api/v1/projects') {
|
||||||
await route.fulfill({ json: [visualCheckWorkspace.project] })
|
await route.fulfill({ json: [visualCheckWorkspace.project, secondVisualCheckWorkspace.project] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (url.pathname === `/api/v1/projects/${projectId}/workspace`) {
|
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 })
|
await route.fulfill({ json: visualCheckWorkspace })
|
||||||
return
|
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') {
|
if (url.pathname === `/api/v1/inbox/${inboxItemId}/analyze` && method === 'POST') {
|
||||||
inboxAnalyzeRequests.push(inboxItemId)
|
inboxAnalyzeRequests.push(inboxItemId)
|
||||||
if (inboxAnalyzeRequests.length === 1) await new Promise((resolve) => setTimeout(resolve, 250))
|
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
|
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())
|
inboxConfirmRequests.push(route.request().postDataJSON())
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
if (failNextInboxConfirm) {
|
if (failNextInboxConfirm) {
|
||||||
failNextInboxConfirm = false
|
failNextInboxConfirm = false
|
||||||
|
await route.abort('failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (conflictNextInboxConfirm) {
|
||||||
|
conflictNextInboxConfirm = false
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 500,
|
status: 409,
|
||||||
json: { error: { code: 'internal_error', message: '确认创建失败,请稍后重试' } },
|
json: { error: { code: 'conflict', message: '确认状态发生冲突' } },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -639,9 +707,12 @@ if (await inboxChannelButton.count() === 0) {
|
|||||||
if (!await confirmButton.evaluate((button) => button.classList.contains('arco-btn-loading'))) {
|
if (!await confirmButton.evaluate((button) => button.classList.contains('arco-btn-loading'))) {
|
||||||
failures.push('Inbox confirmation must show loading while the write is pending')
|
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)
|
await page.waitForTimeout(300)
|
||||||
if (!await inboxPage.getByText('确认创建失败,请稍后重试', { exact: true }).isVisible()) {
|
if (!await inboxPage.getByText('确认结果未知,服务端支持幂等处理,可安全重试', { exact: true }).isVisible()) {
|
||||||
failures.push('failed Inbox confirmation must show the API Chinese error')
|
failures.push('a network-failed Inbox confirmation must explain that an idempotent retry is safe')
|
||||||
}
|
}
|
||||||
expectingInboxConfirmError = false
|
expectingInboxConfirmError = false
|
||||||
if (expectedInboxConfirmConsoleErrorCount !== 1) {
|
if (expectedInboxConfirmConsoleErrorCount !== 1) {
|
||||||
@@ -704,6 +775,53 @@ if (await inboxChannelButton.count() === 0) {
|
|||||||
failures.push(`expected one simulated Inbox refresh console error, got ${expectedInboxRefreshConsoleErrorCount}`)
|
failures.push(`expected one simulated Inbox refresh console error, got ${expectedInboxRefreshConsoleErrorCount}`)
|
||||||
}
|
}
|
||||||
await page.screenshot({ path: 'test-results/project-inbox-refresh-warning.png', fullPage: true })
|
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}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
|
import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
|
||||||
import '@arco-design/web-react/dist/css/arco.css'
|
import '@arco-design/web-react/dist/css/arco.css'
|
||||||
import '../App.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 { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
|
||||||
import { mapWorkspace } from '../api/mappers'
|
import { mapWorkspace } from '../api/mappers'
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +33,7 @@ function App() {
|
|||||||
const [session, setSession] = useState<ApiSession | null>(null)
|
const [session, setSession] = useState<ApiSession | null>(null)
|
||||||
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
|
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
|
||||||
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
||||||
|
const activeProjectIDRef = useRef('')
|
||||||
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
||||||
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
||||||
const [, setSelectedItem] = useState('探索采集')
|
const [, setSelectedItem] = useState('探索采集')
|
||||||
@@ -46,14 +47,24 @@ function App() {
|
|||||||
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
|
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
|
||||||
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
|
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 backendProjects = await fetchProjects(nextSession)
|
||||||
const backendWorkspaces = await Promise.all(
|
const backendWorkspaces = await Promise.all(
|
||||||
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))),
|
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))),
|
||||||
)
|
)
|
||||||
setWorkspaces(backendWorkspaces)
|
setWorkspaces(backendWorkspaces)
|
||||||
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === preferredProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
|
const latestProjectID = activeProjectIDRef.current
|
||||||
setActiveProjectID(nextProjectID)
|
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
|
return backendWorkspaces
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +75,7 @@ function App() {
|
|||||||
const nextSession = await login(input.email, input.password)
|
const nextSession = await login(input.email, input.password)
|
||||||
const backendWorkspaces = await loadWorkspaces(nextSession, '')
|
const backendWorkspaces = await loadWorkspaces(nextSession, '')
|
||||||
setSession(nextSession)
|
setSession(nextSession)
|
||||||
setActiveProjectID(backendWorkspaces[0]?.project.id ?? '')
|
selectActiveProject(backendWorkspaces[0]?.project.id ?? '')
|
||||||
setActiveChannel('overview')
|
setActiveChannel('overview')
|
||||||
setActiveView('workspace')
|
setActiveView('workspace')
|
||||||
setScreen('workbench')
|
setScreen('workbench')
|
||||||
@@ -75,7 +86,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAfterAction(nextProjectID = activeProjectID) {
|
async function refreshAfterAction(nextProjectID?: string) {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
await loadWorkspaces(session, nextProjectID)
|
await loadWorkspaces(session, nextProjectID)
|
||||||
}
|
}
|
||||||
@@ -124,7 +135,7 @@ function App() {
|
|||||||
const nextProjectID = created.id
|
const nextProjectID = created.id
|
||||||
await refreshAfterAction(nextProjectID)
|
await refreshAfterAction(nextProjectID)
|
||||||
if (nextProjectID) {
|
if (nextProjectID) {
|
||||||
setActiveProjectID(nextProjectID)
|
selectActiveProject(nextProjectID)
|
||||||
setActiveView('project')
|
setActiveView('project')
|
||||||
setActiveChannel('overview')
|
setActiveChannel('overview')
|
||||||
}
|
}
|
||||||
@@ -198,7 +209,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openTask(project: Project, taskID: string) {
|
function openTask(project: Project, taskID: string) {
|
||||||
setActiveProjectID(project.id)
|
selectActiveProject(project.id)
|
||||||
setActiveView('project')
|
setActiveView('project')
|
||||||
setActiveChannel('tasks')
|
setActiveChannel('tasks')
|
||||||
setActiveTaskID(taskID)
|
setActiveTaskID(taskID)
|
||||||
@@ -236,7 +247,25 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) {
|
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 {
|
try {
|
||||||
await refreshAfterAction()
|
await refreshAfterAction()
|
||||||
return { createdCount: response.createdCount }
|
return { createdCount: response.createdCount }
|
||||||
@@ -263,7 +292,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setActiveProjectID(owningWorkspace.project.id)
|
selectActiveProject(owningWorkspace.project.id)
|
||||||
setActiveView('project')
|
setActiveView('project')
|
||||||
setActiveChannel(target.channel)
|
setActiveChannel(target.channel)
|
||||||
setActiveTaskID(target.openTask ? result.id : null)
|
setActiveTaskID(target.openTask ? result.id : null)
|
||||||
@@ -288,7 +317,7 @@ function App() {
|
|||||||
onSelectWorkspace={() => setActiveView('workspace')}
|
onSelectWorkspace={() => setActiveView('workspace')}
|
||||||
onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
|
onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
|
||||||
onSelectProject={(project) => {
|
onSelectProject={(project) => {
|
||||||
setActiveProjectID(project.id)
|
selectActiveProject(project.id)
|
||||||
setActiveView('project')
|
setActiveView('project')
|
||||||
setActiveChannel('overview')
|
setActiveChannel('overview')
|
||||||
setActiveTaskID(null)
|
setActiveTaskID(null)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useRef, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import { Alert, Button, Card, Checkbox, Empty, Space, Tag, Typography } from '@arco-design/web-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 { IconCheckCircle, IconRobot } from '@arco-design/web-react/icon'
|
||||||
|
import { ApiError } from '../../api/client'
|
||||||
import type { InboxSuggestionDTO } from '../../api/inbox'
|
import type { InboxSuggestionDTO } from '../../api/inbox'
|
||||||
import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types'
|
import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types'
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
setSuccess(`已创建 ${outcome.createdCount} 个对象`)
|
setSuccess(`已创建 ${outcome.createdCount} 个对象`)
|
||||||
setRefreshWarning(outcome.refreshError ?? '')
|
setRefreshWarning(outcome.refreshError ?? '')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : '确认创建失败,请稍后重试')
|
setError(confirmationErrorMessage(reason))
|
||||||
} finally {
|
} finally {
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
}
|
}
|
||||||
@@ -191,7 +192,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
loading={confirming}
|
loading={confirming}
|
||||||
disabled={analyzing || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) || draftSuggestions.length === 0}
|
disabled={confirming || analyzing || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) || draftSuggestions.length === 0}
|
||||||
onClick={() => void confirmSelectedSuggestions()}
|
onClick={() => void confirmSelectedSuggestions()}
|
||||||
>
|
>
|
||||||
确认创建
|
确认创建
|
||||||
@@ -206,6 +207,13 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmationErrorMessage(reason: unknown) {
|
||||||
|
if (reason instanceof ApiError && (reason.code === 'network_error' || reason.code === 'invalid_response')) {
|
||||||
|
return '确认结果未知,服务端支持幂等处理,可安全重试'
|
||||||
|
}
|
||||||
|
return reason instanceof Error ? reason.message : '确认创建失败,请稍后重试'
|
||||||
|
}
|
||||||
|
|
||||||
function isInboxItemProcessed(item: InboxItem, locallyConfirmedItemIds: string[]) {
|
function isInboxItemProcessed(item: InboxItem, locallyConfirmedItemIds: string[]) {
|
||||||
return item.status !== 'open' || locallyConfirmedItemIds.includes(item.id)
|
return item.status !== 'open' || locallyConfirmedItemIds.includes(item.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,18 +158,20 @@ func (s *Service) Analyze(itemIdentity string, userID uint) ([]Suggestion, error
|
|||||||
|
|
||||||
// Confirm 只按服务端保存的建议 identity 创建对象;全部写入和 Inbox 状态变更位于同一事务中。
|
// Confirm 只按服务端保存的建议 identity 创建对象;全部写入和 Inbox 状态变更位于同一事务中。
|
||||||
func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionIdentities []string) (ConfirmResult, error) {
|
func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionIdentities []string) (ConfirmResult, error) {
|
||||||
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
|
|
||||||
if err != nil {
|
|
||||||
return ConfirmResult{}, err
|
|
||||||
}
|
|
||||||
result := ConfirmResult{}
|
result := ConfirmResult{}
|
||||||
err = s.database().Transaction(func(tx *gorm.DB) error {
|
err := s.database().Transaction(func(tx *gorm.DB) error {
|
||||||
item, err := findOwnedInboxItem(tx, itemIdentity, userID, true)
|
item, err := findOwnedInboxItem(tx, itemIdentity, userID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if item.Status != "open" {
|
if item.Status != "open" {
|
||||||
return ErrInboxAlreadyConfirmed
|
result.CreatedCount, err = countConfirmedObjects(tx, item.ID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var saved []models.SenlinAgentInboxSuggestion
|
var saved []models.SenlinAgentInboxSuggestion
|
||||||
@@ -198,6 +200,18 @@ func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionId
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func countConfirmedObjects(tx *gorm.DB, inboxItemID, userID uint) (int, error) {
|
||||||
|
total := int64(0)
|
||||||
|
for _, model := range []any{&models.SenlinAgentTask{}, &models.SenlinAgentNote{}, &models.SenlinAgentSource{}} {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(model).Where("source_inbox_item_id = ? AND created_by = ?", inboxItemID, userID).Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
total += count
|
||||||
|
}
|
||||||
|
return int(total), nil
|
||||||
|
}
|
||||||
|
|
||||||
func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SenlinAgentInboxItem, error) {
|
func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SenlinAgentInboxItem, error) {
|
||||||
var item models.SenlinAgentInboxItem
|
var item models.SenlinAgentInboxItem
|
||||||
query := tx.Model(&models.SenlinAgentInboxItem{}).
|
query := tx.Model(&models.SenlinAgentInboxItem{}).
|
||||||
|
|||||||
@@ -251,23 +251,30 @@ func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.T) {
|
|||||||
require.Equal(t, "open", reloaded.Status)
|
require.Equal(t, "open", reloaded.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRepeatedConfirmReturnsConflictWithoutDuplicateObjects(t *testing.T) {
|
func TestRepeatedConfirmReturnsExistingResultWithoutDuplicateObjects(t *testing.T) {
|
||||||
fixture := newInboxTestFixture(t)
|
fixture := newInboxTestFixture(t)
|
||||||
item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID)
|
item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID)
|
||||||
router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{
|
router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{
|
||||||
{Kind: "task", Title: "只创建一次", Body: "重复确认不能复制"},
|
{Kind: "task", Title: "只创建一次", Body: "重复确认不能复制"},
|
||||||
|
{Kind: "note", Title: "不能追加创建", Body: "已确认后忽略不同建议"},
|
||||||
}})
|
}})
|
||||||
analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil))
|
analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil))
|
||||||
body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID}}
|
body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID}}
|
||||||
|
|
||||||
first := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body)
|
first := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body)
|
||||||
second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body)
|
second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body)
|
||||||
|
differentSelection := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{
|
||||||
|
"suggestionIds": []string{analysis.Suggestions[1].ID},
|
||||||
|
})
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, first.Code)
|
require.Equal(t, http.StatusOK, first.Code)
|
||||||
require.Equal(t, http.StatusConflict, second.Code)
|
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
|
||||||
var payload httpx.ErrorEnvelope
|
require.Equal(t, http.StatusOK, differentSelection.Code, differentSelection.Body.String())
|
||||||
require.NoError(t, json.Unmarshal(second.Body.Bytes(), &payload))
|
for _, response := range []*httptest.ResponseRecorder{first, second, differentSelection} {
|
||||||
require.Equal(t, "conflict", payload.Error.Code)
|
var result ConfirmResult
|
||||||
|
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result))
|
||||||
|
require.Equal(t, 1, result.CreatedCount)
|
||||||
|
}
|
||||||
requireFormalObjectCounts(t, fixture.database, 1, 0, 0)
|
requireFormalObjectCounts(t, fixture.database, 1, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user