fix: stabilize inbox confirmation retries
This commit is contained in:
@@ -13,6 +13,11 @@ type ProjectInboxProps = {
|
|||||||
onConfirm: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
onConfirm: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UncertainConfirmation = {
|
||||||
|
inboxItemId: string
|
||||||
|
suggestionIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) {
|
export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) {
|
||||||
const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '')
|
const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '')
|
||||||
const [draftSuggestions, setDraftSuggestions] = useState<InboxSuggestionDTO[]>([])
|
const [draftSuggestions, setDraftSuggestions] = useState<InboxSuggestionDTO[]>([])
|
||||||
@@ -23,6 +28,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
const [success, setSuccess] = useState('')
|
const [success, setSuccess] = useState('')
|
||||||
const [refreshWarning, setRefreshWarning] = useState('')
|
const [refreshWarning, setRefreshWarning] = useState('')
|
||||||
const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState<string[]>([])
|
const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState<string[]>([])
|
||||||
|
const [confirmationUncertain, setConfirmationUncertain] = useState<UncertainConfirmation | null>(null)
|
||||||
const analysisGeneration = useRef(0)
|
const analysisGeneration = useRef(0)
|
||||||
const selectedItem = useMemo(
|
const selectedItem = useMemo(
|
||||||
() => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0],
|
() => 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) {
|
function selectItem(item: InboxItem) {
|
||||||
|
if (confirmationUncertain) return
|
||||||
analysisGeneration.current += 1
|
analysisGeneration.current += 1
|
||||||
setSelectedItemId(item.id)
|
setSelectedItemId(item.id)
|
||||||
setDraftSuggestions([])
|
setDraftSuggestions([])
|
||||||
@@ -41,7 +48,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeSelectedItem() {
|
async function analyzeSelectedItem() {
|
||||||
if (!selectedItem || selectedItem.status !== 'open') return
|
if (!selectedItem || selectedItem.status !== 'open' || confirmationUncertain) return
|
||||||
setAnalyzing(true)
|
setAnalyzing(true)
|
||||||
setError('')
|
setError('')
|
||||||
setSuccess('')
|
setSuccess('')
|
||||||
@@ -64,7 +71,11 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
|
|
||||||
async function confirmSelectedSuggestions() {
|
async function confirmSelectedSuggestions() {
|
||||||
if (!selectedItem) return
|
if (!selectedItem) return
|
||||||
if (selectedSuggestionIds.length === 0) {
|
const confirmation = confirmationUncertain ?? {
|
||||||
|
inboxItemId: selectedItem.id,
|
||||||
|
suggestionIds: [...selectedSuggestionIds],
|
||||||
|
}
|
||||||
|
if (confirmation.suggestionIds.length === 0) {
|
||||||
setError('请至少勾选一条建议')
|
setError('请至少勾选一条建议')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -72,11 +83,13 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
setError('')
|
setError('')
|
||||||
setSuccess('')
|
setSuccess('')
|
||||||
try {
|
try {
|
||||||
const outcome = await onConfirm(selectedItem.id, selectedSuggestionIds)
|
const outcome = await onConfirm(confirmation.inboxItemId, confirmation.suggestionIds)
|
||||||
setLocallyConfirmedItemIds((current) => current.includes(selectedItem.id) ? current : [...current, selectedItem.id])
|
setConfirmationUncertain(null)
|
||||||
|
setLocallyConfirmedItemIds((current) => current.includes(confirmation.inboxItemId) ? current : [...current, confirmation.inboxItemId])
|
||||||
setSuccess(`已创建 ${outcome.createdCount} 个对象`)
|
setSuccess(`已创建 ${outcome.createdCount} 个对象`)
|
||||||
setRefreshWarning(outcome.refreshError ?? '')
|
setRefreshWarning(outcome.refreshError ?? '')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
setConfirmationUncertain(isUncertainConfirmationError(reason) ? confirmation : null)
|
||||||
setError(confirmationErrorMessage(reason))
|
setError(confirmationErrorMessage(reason))
|
||||||
} finally {
|
} finally {
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
@@ -84,6 +97,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleSuggestion(identity: string, checked: boolean) {
|
function toggleSuggestion(identity: string, checked: boolean) {
|
||||||
|
if (confirmationUncertain) return
|
||||||
setSelectedSuggestionIds((current) => checked
|
setSelectedSuggestionIds((current) => checked
|
||||||
? current.includes(identity) ? current : [...current, identity]
|
? current.includes(identity) ? current : [...current, identity]
|
||||||
: current.filter((value) => value !== identity))
|
: current.filter((value) => value !== identity))
|
||||||
@@ -113,7 +127,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={selectedItem?.id === item.id ? 'mail-item active' : 'mail-item'}
|
className={selectedItem?.id === item.id ? 'mail-item active' : 'mail-item'}
|
||||||
disabled={confirming}
|
disabled={confirming || confirmationUncertain !== null}
|
||||||
onClick={() => selectItem(item)}
|
onClick={() => selectItem(item)}
|
||||||
>
|
>
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
@@ -142,7 +156,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
<Button
|
<Button
|
||||||
icon={<IconRobot />}
|
icon={<IconRobot />}
|
||||||
loading={analyzing}
|
loading={analyzing}
|
||||||
disabled={confirming || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
|
disabled={confirming || confirmationUncertain !== null || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
|
||||||
onClick={() => void analyzeSelectedItem()}
|
onClick={() => void analyzeSelectedItem()}
|
||||||
>
|
>
|
||||||
分析内容
|
分析内容
|
||||||
@@ -167,7 +181,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
key={suggestion.id}
|
key={suggestion.id}
|
||||||
checked={selectedSuggestionIds.includes(suggestion.id)}
|
checked={selectedSuggestionIds.includes(suggestion.id)}
|
||||||
disabled={confirming || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
|
disabled={confirming || confirmationUncertain !== null || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
|
||||||
onChange={(checked) => toggleSuggestion(suggestion.id, checked)}
|
onChange={(checked) => toggleSuggestion(suggestion.id, checked)}
|
||||||
>
|
>
|
||||||
<span className="inbox-suggestion-copy">
|
<span className="inbox-suggestion-copy">
|
||||||
@@ -195,7 +209,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
|||||||
disabled={confirming || analyzing || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) || draftSuggestions.length === 0}
|
disabled={confirming || analyzing || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) || draftSuggestions.length === 0}
|
||||||
onClick={() => void confirmSelectedSuggestions()}
|
onClick={() => void confirmSelectedSuggestions()}
|
||||||
>
|
>
|
||||||
确认创建
|
{confirmationUncertain ? '重试确认' : '确认创建'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -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) {
|
function confirmationErrorMessage(reason: unknown) {
|
||||||
if (reason instanceof ApiError && (reason.code === 'network_error' || reason.code === 'invalid_response')) {
|
if (isUncertainConfirmationError(reason)) {
|
||||||
return '确认结果未知,服务端支持幂等处理,可安全重试'
|
return '确认结果未知,服务端支持幂等处理,可安全重试'
|
||||||
}
|
}
|
||||||
return reason instanceof Error ? reason.message : '确认创建失败,请稍后重试'
|
return reason instanceof Error ? reason.message : '确认创建失败,请稍后重试'
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionId
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if item.Status != "open" {
|
if item.Status != "open" {
|
||||||
result.CreatedCount, err = countConfirmedObjects(tx, item.ID, userID)
|
result.CreatedCount = item.ConfirmedCount
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
|
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
|
||||||
@@ -195,23 +195,14 @@ func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionId
|
|||||||
}
|
}
|
||||||
result.CreatedCount++
|
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
|
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,31 +251,42 @@ func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.T) {
|
|||||||
require.Equal(t, "open", reloaded.Status)
|
require.Equal(t, "open", reloaded.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRepeatedConfirmReturnsExistingResultWithoutDuplicateObjects(t *testing.T) {
|
func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(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: "已确认后忽略不同建议"},
|
{Kind: "note", Title: "确认结果", Body: "原始确认创建两个对象"},
|
||||||
|
{Kind: "source", 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, analysis.Suggestions[1].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)
|
|
||||||
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.StatusOK, second.Code, second.Body.String())
|
requireConfirmCreatedCount(t, first, 2)
|
||||||
require.Equal(t, http.StatusOK, differentSelection.Code, differentSelection.Body.String())
|
requireFormalObjectCounts(t, fixture.database, 1, 1, 0)
|
||||||
for _, response := range []*httptest.ResponseRecorder{first, second, differentSelection} {
|
|
||||||
var result ConfirmResult
|
sourceInboxItemID := item.ID
|
||||||
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result))
|
extraSource := models.SenlinAgentSource{
|
||||||
require.Equal(t, 1, result.CreatedCount)
|
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 {
|
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) {
|
func requireSourceInboxIdentity(t *testing.T, item models.SenlinAgentInboxItem, sourceID *uint, sourceIdentity *string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
require.NotNil(t, sourceID)
|
require.NotNil(t, sourceID)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type SenlinAgentInboxItem struct {
|
|||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
Status string `gorm:"not null;default:open"`
|
Status string `gorm:"not null;default:open"`
|
||||||
|
ConfirmedCount int `gorm:"not null;default:0"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user