diff --git a/apps/web_v1/src/pages/projects/project-inbox.tsx b/apps/web_v1/src/pages/projects/project-inbox.tsx index 8e63558..5808b0d 100644 --- a/apps/web_v1/src/pages/projects/project-inbox.tsx +++ b/apps/web_v1/src/pages/projects/project-inbox.tsx @@ -13,6 +13,11 @@ type ProjectInboxProps = { onConfirm: (inboxId: string, suggestionIds: string[]) => Promise } +type UncertainConfirmation = { + inboxItemId: string + suggestionIds: string[] +} + export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) { const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '') const [draftSuggestions, setDraftSuggestions] = useState([]) @@ -23,6 +28,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI const [success, setSuccess] = useState('') const [refreshWarning, setRefreshWarning] = useState('') const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState([]) + const [confirmationUncertain, setConfirmationUncertain] = useState(null) const analysisGeneration = useRef(0) const selectedItem = useMemo( () => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0], @@ -30,6 +36,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI ) function selectItem(item: InboxItem) { + if (confirmationUncertain) return analysisGeneration.current += 1 setSelectedItemId(item.id) setDraftSuggestions([]) @@ -41,7 +48,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI } async function analyzeSelectedItem() { - if (!selectedItem || selectedItem.status !== 'open') return + if (!selectedItem || selectedItem.status !== 'open' || confirmationUncertain) return setAnalyzing(true) setError('') setSuccess('') @@ -64,7 +71,11 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI async function confirmSelectedSuggestions() { if (!selectedItem) return - if (selectedSuggestionIds.length === 0) { + const confirmation = confirmationUncertain ?? { + inboxItemId: selectedItem.id, + suggestionIds: [...selectedSuggestionIds], + } + if (confirmation.suggestionIds.length === 0) { setError('请至少勾选一条建议') return } @@ -72,11 +83,13 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI setError('') setSuccess('') try { - const outcome = await onConfirm(selectedItem.id, selectedSuggestionIds) - setLocallyConfirmedItemIds((current) => current.includes(selectedItem.id) ? current : [...current, selectedItem.id]) + const outcome = await onConfirm(confirmation.inboxItemId, confirmation.suggestionIds) + setConfirmationUncertain(null) + setLocallyConfirmedItemIds((current) => current.includes(confirmation.inboxItemId) ? current : [...current, confirmation.inboxItemId]) setSuccess(`已创建 ${outcome.createdCount} 个对象`) setRefreshWarning(outcome.refreshError ?? '') } catch (reason) { + setConfirmationUncertain(isUncertainConfirmationError(reason) ? confirmation : null) setError(confirmationErrorMessage(reason)) } finally { setConfirming(false) @@ -84,6 +97,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI } function toggleSuggestion(identity: string, checked: boolean) { + if (confirmationUncertain) return setSelectedSuggestionIds((current) => checked ? current.includes(identity) ? current : [...current, identity] : current.filter((value) => value !== identity)) @@ -113,7 +127,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI key={item.id} type="button" className={selectedItem?.id === item.id ? 'mail-item active' : 'mail-item'} - disabled={confirming} + disabled={confirming || confirmationUncertain !== null} onClick={() => selectItem(item)} > {item.title} @@ -142,7 +156,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI @@ -207,8 +221,12 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI ) } +function isUncertainConfirmationError(reason: unknown) { + return reason instanceof ApiError && (reason.code === 'network_error' || reason.code === 'invalid_response') +} + function confirmationErrorMessage(reason: unknown) { - if (reason instanceof ApiError && (reason.code === 'network_error' || reason.code === 'invalid_response')) { + if (isUncertainConfirmationError(reason)) { return '确认结果未知,服务端支持幂等处理,可安全重试' } return reason instanceof Error ? reason.message : '确认创建失败,请稍后重试' diff --git a/backend/internal/logic/inbox/service.go b/backend/internal/logic/inbox/service.go index 96f1ee8..7e3d777 100644 --- a/backend/internal/logic/inbox/service.go +++ b/backend/internal/logic/inbox/service.go @@ -165,8 +165,8 @@ func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionId return err } if item.Status != "open" { - result.CreatedCount, err = countConfirmedObjects(tx, item.ID, userID) - return err + result.CreatedCount = item.ConfirmedCount + return nil } selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities) @@ -195,23 +195,14 @@ func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionId } result.CreatedCount++ } - return tx.Model(item).Update("status", "processed").Error + return tx.Model(item).Updates(map[string]any{ + "status": "processed", + "confirmed_count": result.CreatedCount, + }).Error }) 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{}). diff --git a/backend/internal/logic/inbox/service_test.go b/backend/internal/logic/inbox/service_test.go index 327bef0..7220d22 100644 --- a/backend/internal/logic/inbox/service_test.go +++ b/backend/internal/logic/inbox/service_test.go @@ -251,31 +251,42 @@ func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.T) { require.Equal(t, "open", reloaded.Status) } -func TestRepeatedConfirmReturnsExistingResultWithoutDuplicateObjects(t *testing.T) { +func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(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: "已确认后忽略不同建议"}, + {Kind: "note", Title: "确认结果", Body: "原始确认创建两个对象"}, + {Kind: "source", 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}} + body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].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.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) + requireConfirmCreatedCount(t, first, 2) + requireFormalObjectCounts(t, fixture.database, 1, 1, 0) + + sourceInboxItemID := item.ID + extraSource := models.SenlinAgentSource{ + ProjectID: item.ProjectID, CreatedBy: fixture.owner.ID, SourceInboxItemID: &sourceInboxItemID, + Kind: "text", Title: "后续关联资料", ContentText: "不得改变历史确认结果", } - requireFormalObjectCounts(t, fixture.database, 1, 0, 0) + require.NoError(t, fixture.database.Create(&extraSource).Error) + second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body) + require.Equal(t, http.StatusOK, second.Code, second.Body.String()) + requireConfirmCreatedCount(t, second, 2) + + var createdTask models.SenlinAgentTask + require.NoError(t, fixture.database.Where("source_inbox_item_id = ?", item.ID).First(&createdTask).Error) + require.NoError(t, fixture.database.Delete(&createdTask).Error) + require.NoError(t, fixture.database.Delete(&extraSource).Error) + differentSelection := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestionIds": []string{analysis.Suggestions[2].ID}, + }) + require.Equal(t, http.StatusOK, differentSelection.Code, differentSelection.Body.String()) + requireConfirmCreatedCount(t, differentSelection, 2) + requireFormalObjectCounts(t, fixture.database, 0, 1, 0) } func (fixture inboxTestFixture) router(userID uint, analyzer Analyzer) http.Handler { @@ -347,6 +358,13 @@ func requireFormalObjectCounts(t *testing.T, database *gorm.DB, tasks, notes, so } } +func requireConfirmCreatedCount(t *testing.T, response *httptest.ResponseRecorder, want int) { + t.Helper() + var result ConfirmResult + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result)) + require.Equal(t, want, result.CreatedCount) +} + func requireSourceInboxIdentity(t *testing.T, item models.SenlinAgentInboxItem, sourceID *uint, sourceIdentity *string) { t.Helper() require.NotNil(t, sourceID) diff --git a/backend/internal/models/inbox_item.go b/backend/internal/models/inbox_item.go index baa66d3..d88fd31 100644 --- a/backend/internal/models/inbox_item.go +++ b/backend/internal/models/inbox_item.go @@ -13,6 +13,7 @@ type SenlinAgentInboxItem struct { Title string Body string Status string `gorm:"not null;default:open"` + ConfirmedCount int `gorm:"not null;default:0"` CreatedAt time.Time UpdatedAt time.Time }