fix: make inbox confirmation retry safe

This commit is contained in:
2026-07-21 18:42:47 +08:00
parent 85a25ca017
commit 7c363bb473
4 changed files with 83 additions and 25 deletions

View File

@@ -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{}).

View File

@@ -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)
}