fix(docgov): materialize enabled workflow docs as template records

Activation and list healing need a server-side document row per enabled task, without overwriting documents that already have content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 05:20:01 +00:00
co-authored by Cursor
parent e91ffbca1d
commit b74441fb4f
2 changed files with 224 additions and 0 deletions
+157
View File
@@ -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 ""
}
+67
View File
@@ -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)
}
}