fix: make inbox confirmation retry safe
This commit is contained in:
@@ -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<ApiSession | null>(null)
|
||||
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
|
||||
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
||||
const activeProjectIDRef = useRef('')
|
||||
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
||||
const [activeTaskID, setActiveTaskID] = useState<string | null>(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)
|
||||
|
||||
@@ -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
|
||||
<Button
|
||||
type="primary"
|
||||
loading={confirming}
|
||||
disabled={analyzing || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) || draftSuggestions.length === 0}
|
||||
disabled={confirming || analyzing || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) || draftSuggestions.length === 0}
|
||||
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[]) {
|
||||
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 状态变更位于同一事务中。
|
||||
func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionIdentities []string) (ConfirmResult, error) {
|
||||
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
@@ -198,6 +200,18 @@ func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionId
|
||||
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) {
|
||||
var item models.SenlinAgentInboxItem
|
||||
query := tx.Model(&models.SenlinAgentInboxItem{}).
|
||||
|
||||
@@ -251,23 +251,30 @@ func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.T) {
|
||||
require.Equal(t, "open", reloaded.Status)
|
||||
}
|
||||
|
||||
func TestRepeatedConfirmReturnsConflictWithoutDuplicateObjects(t *testing.T) {
|
||||
func TestRepeatedConfirmReturnsExistingResultWithoutDuplicateObjects(t *testing.T) {
|
||||
fixture := newInboxTestFixture(t)
|
||||
item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID)
|
||||
router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{
|
||||
{Kind: "task", Title: "只创建一次", Body: "重复确认不能复制"},
|
||||
{Kind: "note", Title: "不能追加创建", Body: "已确认后忽略不同建议"},
|
||||
}})
|
||||
analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil))
|
||||
body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID}}
|
||||
|
||||
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)
|
||||
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.StatusConflict, second.Code)
|
||||
var payload httpx.ErrorEnvelope
|
||||
require.NoError(t, json.Unmarshal(second.Body.Bytes(), &payload))
|
||||
require.Equal(t, "conflict", payload.Error.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
|
||||
require.Equal(t, http.StatusOK, differentSelection.Code, differentSelection.Body.String())
|
||||
for _, response := range []*httptest.ResponseRecorder{first, second, differentSelection} {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user