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:
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user