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 }