per-doc 锁只串行同一文档,不同 docID 仍裸读写 memDocs 等 map。GetDocument 与另一文档写入撞上时运行时 fatal,代理把全部 API 变成 500。
215 lines
6.9 KiB
Go
215 lines
6.9 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.memDocsGet(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
|
|
}
|
|
e.memDocsPut(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()
|
|
found := e.memDocsFind(func(doc *DocumentRecord) bool {
|
|
if doc.ProjectID != projectID || doc.DocID == docID {
|
|
return false
|
|
}
|
|
for _, key := range siblings {
|
|
if doc.TaskKey == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
return found != nil, nil
|
|
}
|