diff --git a/internal/docgov/materialize.go b/internal/docgov/materialize.go new file mode 100644 index 00000000..be733c65 --- /dev/null +++ b/internal/docgov/materialize.go @@ -0,0 +1,157 @@ +package docgov + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// TemplateDocSpec 是激活后要落到 document_records 的受控文档槽。 +type TemplateDocSpec struct { + TaskKey string + Title string + StageKey string + DutyPolicy string + Enabled bool + AuthorUserID int64 + ReviewerUserID int64 + ApproverUserID int64 + AuthorName string + ReviewerName string + ApproverName string +} + +// ControlledDocID 是项目受控文档的权威 doc_id。 +func ControlledDocID(projectID int64, taskKey string) string { + return fmt.Sprintf("doc_proj%d_%s", projectID, strings.TrimSpace(taskKey)) +} + +// MaterializeTemplateDocs 为已启用的文档任务幂等写入模板台账。 +// 已脱离模板(is_template_only=false)的正文与职责不被覆盖。 +func (e *GovernanceEngine) MaterializeTemplateDocs(ctx context.Context, projectID int64, specs []TemplateDocSpec) error { + if e == nil || projectID <= 0 { + return nil + } + for _, spec := range specs { + if !spec.Enabled || strings.TrimSpace(spec.TaskKey) == "" { + continue + } + if err := e.upsertTemplateDoc(ctx, projectID, spec); err != nil { + return err + } + } + return nil +} + +func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int64, spec TemplateDocSpec) error { + taskKey := strings.TrimSpace(spec.TaskKey) + docID := ControlledDocID(projectID, taskKey) + title := strings.TrimSpace(spec.Title) + if title == "" { + title = taskKey + } + authorName := dutyDisplayName(spec.AuthorUserID, spec.AuthorName) + reviewerName := dutyDisplayName(spec.ReviewerUserID, spec.ReviewerName) + approverName := dutyDisplayName(spec.ApproverUserID, spec.ApproverName) + policy := strings.TrimSpace(spec.DutyPolicy) + now := time.Now() + + if e.pool != nil { + cfgJSON, err := json.Marshal(DefaultDocumentConfig()) + if err != nil { + return fmt.Errorf("docgov: marshal template config: %w", err) + } + _, err = e.pool.Exec(ctx, ` + INSERT INTO document_records ( + doc_id, project_id, task_key, title, stage, version, + current_state, is_locked, is_template_only, work_dir, + author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name, + config, duty_locked, duty_policy, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, 1, + $6, false, true, '', + $7, $8, $9, $10, $11, $12, + $13, true, $14, $15, $15 + ) + ON CONFLICT (doc_id) DO UPDATE SET + title = CASE WHEN document_records.is_template_only THEN EXCLUDED.title ELSE document_records.title END, + stage = CASE WHEN document_records.is_template_only THEN EXCLUDED.stage ELSE document_records.stage END, + author_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_id ELSE document_records.author_id END, + author_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_name ELSE document_records.author_name END, + reviewer_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_id ELSE document_records.reviewer_id END, + reviewer_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_name ELSE document_records.reviewer_name END, + approver_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_id ELSE document_records.approver_id END, + approver_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_name ELSE document_records.approver_name END, + duty_policy = CASE WHEN document_records.is_template_only THEN EXCLUDED.duty_policy ELSE document_records.duty_policy END, + duty_locked = CASE WHEN document_records.is_template_only THEN TRUE ELSE document_records.duty_locked END, + updated_at = EXCLUDED.updated_at + `, docID, projectID, taskKey, title, strings.TrimSpace(spec.StageKey), + string(StateDrafting), + nullIfZero(spec.AuthorUserID), authorName, + nullIfZero(spec.ReviewerUserID), reviewerName, + nullIfZero(spec.ApproverUserID), approverName, + cfgJSON, policy, now) + if err != nil { + return fmt.Errorf("docgov: materialize template doc %s: %w", docID, err) + } + } + + e.mu.Lock() + defer e.mu.Unlock() + if existing, ok := e.memDocs[docID]; ok && existing != nil { + if !existing.IsTemplateOnly { + return nil + } + existing.Title = title + existing.Stage = strings.TrimSpace(spec.StageKey) + existing.TaskKey = taskKey + existing.AuthorID = spec.AuthorUserID + existing.AuthorName = authorName + existing.ReviewerID = spec.ReviewerUserID + existing.ReviewerName = reviewerName + existing.ApproverID = spec.ApproverUserID + existing.ApproverName = approverName + existing.DutyLocked = true + existing.DutyPolicy = policy + existing.UpdatedAt = now + return nil + } + if e.memDocs == nil { + e.memDocs = map[string]*DocumentRecord{} + } + e.memDocs[docID] = &DocumentRecord{ + DocID: docID, + ProjectID: projectID, + TaskKey: taskKey, + Title: title, + Stage: strings.TrimSpace(spec.StageKey), + Version: 1, + CurrentState: StateDrafting, + IsTemplateOnly: true, + AuthorID: spec.AuthorUserID, + AuthorName: authorName, + ReviewerID: spec.ReviewerUserID, + ReviewerName: reviewerName, + ApproverID: spec.ApproverUserID, + ApproverName: approverName, + DutyLocked: true, + DutyPolicy: policy, + Config: DefaultDocumentConfig(), + CreatedAt: now, + UpdatedAt: now, + } + return nil +} + +func dutyDisplayName(userID int64, name string) string { + name = strings.TrimSpace(name) + if name != "" { + return name + } + if userID > 0 { + return fmt.Sprintf("user-%d", userID) + } + return "" +} diff --git a/internal/docgov/materialize_test.go b/internal/docgov/materialize_test.go new file mode 100644 index 00000000..56cf21b2 --- /dev/null +++ b/internal/docgov/materialize_test.go @@ -0,0 +1,67 @@ +package docgov_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/docgov" +) + +func TestMaterializeTemplateDocsIdempotentAndSkipsRealContent(t *testing.T) { + ctx := context.Background() + engine := docgov.NewGovernanceEngine(nil) + specs := []docgov.TemplateDocSpec{{ + TaskKey: "bd_intake", + Title: "用户初始需求收集表", + StageKey: "V0.1_triage", + Enabled: true, + DutyPolicy: "author_only", + AuthorUserID: 398, + AuthorName: "陈威", + ReviewerUserID: 398, + ApproverUserID: 398, + }, { + TaskKey: "bd_research", + Title: "调研报告", + Enabled: false, + }} + if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil { + t.Fatal(err) + } + specs[0].AuthorName = "陈威" + if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil { + t.Fatal(err) + } + + docs, err := engine.ListWorkflowDocumentsByProject(ctx, 6579) + if err != nil { + t.Fatal(err) + } + if len(docs) != 1 { + t.Fatalf("documents=%d, want 1 enabled template", len(docs)) + } + if docs[0].DocID != "doc_proj6579_bd_intake" || !docs[0].IsTemplateOnly || docs[0].AuthorID != 398 { + t.Fatalf("unexpected template doc: %+v", docs[0]) + } + if docs[0].DutyPolicy != "author_only" { + t.Fatalf("duty policy=%q", docs[0].DutyPolicy) + } + + const realID = "doc_proj6579_bd_intake" + if err := engine.MarkDocumentContentUpdated(ctx, realID, "abc", false); err != nil { + t.Fatal(err) + } + specs[0].AuthorUserID = 9 + specs[0].AuthorName = "系统管理员" + specs[0].Title = "不应覆盖正文标题" + if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil { + t.Fatal(err) + } + got, err := engine.GetDocument(ctx, realID) + if err != nil { + t.Fatal(err) + } + if got.IsTemplateOnly || got.AuthorID != 398 || got.Title != "用户初始需求收集表" { + t.Fatalf("real document was overwritten: %+v", got) + } +}