Files
Agent-System/internal/docgov/materialize.go
T
weichenandCursor 51d4390bd6 fix(docgov): collapse legacy and published task keys in the document list
列表只返回发布键那一行;物化时若旧键行已在,就不再插入兄弟行,避免撞上唯一索引。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 06:37:10 +00:00

217 lines
7.0 KiB
Go

package docgov
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"chat-agent-system/internal/workflowdef"
"github.com/jackc/pgx/v5"
)
// 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)
if taken, err := e.hasCanonicalSibling(ctx, projectID, taskKey, docID); err != nil {
return err
} else if taken {
// 同一发布键已有另一行(常见于旧键 intake 仍是唯一数据)。不再插入 bd_* 兄弟行。
return nil
}
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 ""
}
func siblingTaskKeys(taskKey string) []string {
canon := workflowdef.CanonicalTaskKey(taskKey)
legacy := workflowdef.ImplementationTaskKey(canon)
var out []string
if canon != "" && canon != taskKey {
out = append(out, canon)
}
if legacy != "" && legacy != taskKey && legacy != canon {
out = append(out, legacy)
}
return out
}
func (e *GovernanceEngine) hasCanonicalSibling(ctx context.Context, projectID int64, taskKey, docID string) (bool, error) {
siblings := siblingTaskKeys(taskKey)
if e == nil || len(siblings) == 0 {
return false, nil
}
if e.pool != nil {
var existing string
err := e.pool.QueryRow(ctx, `
SELECT doc_id FROM document_records
WHERE project_id = $1 AND task_key = ANY($2::text[]) AND doc_id <> $3
LIMIT 1
`, projectID, siblings, docID).Scan(&existing)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("docgov: lookup canonical sibling: %w", err)
}
return existing != "", nil
}
e.mu.RLock()
defer e.mu.RUnlock()
for _, doc := range e.memDocs {
if doc == nil || doc.ProjectID != projectID || doc.DocID == docID {
continue
}
for _, key := range siblings {
if doc.TaskKey == key {
return true, nil
}
}
}
return false, nil
}