feat(documents): rebuild project document workbench

This commit is contained in:
2026-07-24 12:16:02 +08:00
parent 366136cee9
commit 77beaebb30
73 changed files with 5867 additions and 1264 deletions

View File

@@ -59,8 +59,8 @@ func (a StaticAnalyzer) Analyze(item models.SaInboxItem, userID uint) ([]Suggest
// 默认分析器只把用户主动收集的内容整理成可审阅草稿;它不调用 provider也不创建正式对象。
return []Suggestion{
{Kind: "task", Title: "跟进:" + subject, Body: body},
{Kind: "note", Title: subject + "整理笔记", Body: body},
{Kind: "source", Title: subject + "背景资料", Body: body},
{Kind: "document", Title: subject + "整理笔记.md", Body: body},
{Kind: "document", Title: subject + "背景资料.md", Body: body},
}, nil
}
@@ -222,7 +222,7 @@ func normalizeSuggestions(suggestions []Suggestion) ([]Suggestion, error) {
for _, suggestion := range suggestions {
kind := strings.ToLower(strings.TrimSpace(suggestion.Kind))
title := strings.TrimSpace(suggestion.Title)
if title == "" || (kind != "task" && kind != "note" && kind != "source") {
if title == "" || (kind != "task" && kind != "document") {
return nil, ErrInvalidSuggestion
}
result = append(result, Suggestion{Kind: kind, Title: title, Body: strings.TrimSpace(suggestion.Body)})
@@ -274,17 +274,21 @@ func createConfirmedObject(tx *gorm.DB, item models.SaInboxItem, suggestion mode
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body, Status: "open",
}).Error
case "note":
return tx.Create(&models.SaNote{
case "document":
name := strings.TrimSpace(suggestion.Title)
if !strings.HasSuffix(strings.ToLower(name), ".md") {
name += ".md"
}
document := models.SaDocument{
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body,
}).Error
case "source":
// 文本型 Inbox 资料不是上传文件,不构造或伪造任何本地路径。
return tx.Create(&models.SaSource{
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: suggestion.Title, ContentText: suggestion.Body,
}).Error
SourceInboxItemID: &sourceInboxItemID, Kind: models.DocumentKindFile, Name: name,
NormalizedName: strings.ToLower(name), Extension: ".md", MimeType: "text/markdown; charset=utf-8",
Size: int64(len([]byte(suggestion.Body))), Revision: 1, ScanStatus: "not_scanned",
}
if err := tx.Create(&document).Error; err != nil {
return err
}
return tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: suggestion.Body}).Error
default:
return ErrInvalidSuggestion
}

View File

@@ -44,7 +44,7 @@ func TestStaticAnalyzerBuildsReviewableDraftsFromInboxContent(t *testing.T) {
require.NoError(t, err)
require.Len(t, suggestions, 3)
require.Equal(t, []string{"task", "note", "source"}, []string{suggestions[0].Kind, suggestions[1].Kind, suggestions[2].Kind})
require.Equal(t, []string{"task", "document", "document"}, []string{suggestions[0].Kind, suggestions[1].Kind, suggestions[2].Kind})
for _, suggestion := range suggestions {
require.Contains(t, suggestion.Title, "客户访谈")
require.Equal(t, item.Body, suggestion.Body)
@@ -126,8 +126,8 @@ func TestConfirmCreatesOnlySelectedSavedSuggestionsWithInboxIdentity(t *testing.
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: "source", Title: "背景资料", Body: "这是一段收集内容,不是上传文件"},
{Kind: "document", Title: "会议纪要.md", Body: "保留讨论结论"},
{Kind: "document", Title: "背景资料.md", Body: "这是一段收集内容,不是上传文件"},
{Kind: "task", Title: "不创建的任务", Body: "未被勾选"},
}})
analysisResponse := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)
@@ -155,22 +155,19 @@ func TestConfirmCreatesOnlySelectedSavedSuggestionsWithInboxIdentity(t *testing.
require.Equal(t, fixture.owner.ID, task.CreatedBy)
requireSourceInboxIdentity(t, item, task.SourceInboxItemID, task.SourceInboxItemIdentity)
var note models.SaNote
require.NoError(t, fixture.database.First(&note).Error)
require.Equal(t, "会议纪要", note.Title)
require.Equal(t, fixture.project.ID, note.ProjectID)
require.Equal(t, fixture.owner.ID, note.CreatedBy)
requireSourceInboxIdentity(t, item, note.SourceInboxItemID, note.SourceInboxItemIdentity)
var source models.SaSource
require.NoError(t, fixture.database.First(&source).Error)
require.Equal(t, "背景资料", source.Title)
require.Equal(t, "text", source.Kind)
require.Empty(t, source.FilePath)
require.Equal(t, "这是一段收集内容,不是上传文件", source.ContentText)
require.Equal(t, fixture.project.ID, source.ProjectID)
require.Equal(t, fixture.owner.ID, source.CreatedBy)
requireSourceInboxIdentity(t, item, source.SourceInboxItemID, source.SourceInboxItemIdentity)
var documents []models.SaDocument
require.NoError(t, fixture.database.Order("id asc").Find(&documents).Error)
require.Len(t, documents, 2)
require.Equal(t, "会议纪要.md", documents[0].Name)
require.Equal(t, "背景资料.md", documents[1].Name)
for _, document := range documents {
require.Equal(t, fixture.project.ID, document.ProjectID)
require.Equal(t, fixture.owner.ID, document.CreatedBy)
requireSourceInboxIdentity(t, item, document.SourceInboxItemID, document.SourceInboxItemIdentity)
}
var secondContent models.SaDocumentContent
require.NoError(t, fixture.database.Where("document_id = ?", documents[1].ID).First(&secondContent).Error)
require.Equal(t, "这是一段收集内容,不是上传文件", secondContent.Markdown)
var skipped int64
require.NoError(t, fixture.database.Model(&models.SaTask{}).Where("title = ?", "不创建的任务").Count(&skipped).Error)
@@ -227,14 +224,15 @@ func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.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: "source", Title: "失败资料", Body: "模拟持久化失败"},
{Kind: "document", Title: "失败资料.md", Body: "模拟持久化失败"},
}})
analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil))
require.Len(t, analysis.Suggestions, 2)
const callbackName = "test:fail_inbox_source_create"
const callbackName = "test:fail_inbox_document_create"
require.NoError(t, fixture.database.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "SaSource" {
document, ok := tx.Statement.Dest.(*models.SaDocument)
if ok && document.Name == "失败资料.md" {
tx.AddError(errors.New("simulated source write failure"))
}
}))
@@ -256,8 +254,8 @@ func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testi
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: "source", Title: "不能追加创建", Body: "已确认后忽略不同建议"},
{Kind: "document", Title: "确认结果.md", Body: "原始确认创建两个对象"},
{Kind: "document", Title: "不能追加创建.md", 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, analysis.Suggestions[1].ID}}
@@ -268,11 +266,12 @@ func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testi
requireFormalObjectCounts(t, fixture.database, 1, 1, 0)
sourceInboxItemID := item.ID
extraSource := models.SaSource{
extraDocument := models.SaDocument{
ProjectID: item.ProjectID, CreatedBy: fixture.owner.ID, SourceInboxItemID: &sourceInboxItemID,
Kind: "text", Title: "后续关联资料", ContentText: "不得改变历史确认结果",
Kind: models.DocumentKindFile, Name: "后续关联资料.md", NormalizedName: "后续关联资料.md",
Extension: ".md", Revision: 1,
}
require.NoError(t, fixture.database.Create(&extraSource).Error)
require.NoError(t, fixture.database.Create(&extraDocument).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)
@@ -280,7 +279,7 @@ func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testi
var createdTask models.SaTask
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)
require.NoError(t, fixture.database.Delete(&extraDocument).Error)
differentSelection := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{
"suggestionIds": []string{analysis.Suggestions[2].ID},
})
@@ -349,8 +348,7 @@ func requireFormalObjectCounts(t *testing.T, database *gorm.DB, tasks, notes, so
want int64
}{
{model: &models.SaTask{}, want: tasks},
{model: &models.SaNote{}, want: notes},
{model: &models.SaSource{}, want: sources},
{model: &models.SaDocument{}, want: notes + sources},
} {
var count int64
require.NoError(t, database.Model(check.model).Count(&count).Error)