per-doc 锁只串行同一文档,不同 docID 仍裸读写 memDocs 等 map。GetDocument 与另一文档写入撞上时运行时 fatal,代理把全部 API 变成 500。
237 lines
6.7 KiB
Go
237 lines
6.7 KiB
Go
package docgov
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CreateOrGetDocument 创建或获取受控文档记录
|
|
func (e *GovernanceEngine) CreateOrGetDocument(
|
|
ctx context.Context,
|
|
docID string,
|
|
projectID int64,
|
|
taskKey string,
|
|
title string,
|
|
stage string,
|
|
workDir string,
|
|
authorID int64, authorName string,
|
|
reviewerID int64, reviewerName string,
|
|
approverID int64, approverName string,
|
|
) (*DocumentRecord, error) {
|
|
// T20 B3: doc 级锁;创建/装载只阻塞同文档。
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
now := time.Now()
|
|
if doc, exists := e.memDocsGet(docID); exists {
|
|
return doc, nil
|
|
}
|
|
|
|
// 内存没有查 DB
|
|
if e.pool != nil {
|
|
if docFromDB, err := e.loadDocumentFromDBLocked(ctx, docID); err == nil && docFromDB != nil {
|
|
return docFromDB, nil
|
|
}
|
|
}
|
|
|
|
doc := &DocumentRecord{
|
|
DocID: docID,
|
|
ProjectID: projectID,
|
|
TaskKey: taskKey,
|
|
Title: title,
|
|
Stage: stage,
|
|
Version: 1,
|
|
CurrentState: StateDrafting,
|
|
IsLocked: false,
|
|
IsTemplateOnly: true,
|
|
WorkDir: workDir,
|
|
AuthorID: authorID,
|
|
AuthorName: authorName,
|
|
ReviewerID: reviewerID,
|
|
ReviewerName: reviewerName,
|
|
ApproverID: approverID,
|
|
ApproverName: approverName,
|
|
Config: DefaultDocumentConfig(),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
// 若有 DB 连接,写 DB 并同步
|
|
if e.pool != nil {
|
|
if projectID > 0 {
|
|
_, _ = e.pool.Exec(ctx, `
|
|
INSERT INTO projects (id, code, name)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (id) DO NOTHING
|
|
`, projectID, fmt.Sprintf("PROJ_%d", projectID), "受控工程研发项目")
|
|
}
|
|
|
|
ensureUser := func(uid int64, uname, urole string) {
|
|
if uid <= 0 {
|
|
return
|
|
}
|
|
if _, err := e.pool.Exec(ctx, `
|
|
INSERT INTO users (id, account_id, name, role)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (id) DO NOTHING
|
|
`, uid, fmt.Sprintf("acc_%d", uid), uname, urole); err != nil {
|
|
log.Printf("[docgov] ensureUser id=%d role=%s failed: %v", uid, urole, err)
|
|
}
|
|
}
|
|
ensureUser(doc.AuthorID, doc.AuthorName, "engineer")
|
|
ensureUser(doc.ReviewerID, doc.ReviewerName, "regulatory")
|
|
ensureUser(doc.ApproverID, doc.ApproverName, "director")
|
|
|
|
cfgJSON, _ := json.Marshal(doc.Config)
|
|
query := `
|
|
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, created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
|
ON CONFLICT (doc_id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
stage = EXCLUDED.stage,
|
|
updated_at = EXCLUDED.updated_at
|
|
RETURNING id, current_state, is_locked, is_template_only, author_claimed, reviewer_claimed, approver_claimed;
|
|
`
|
|
var id int64
|
|
var currState string
|
|
var isLocked, isTmpl, authClaim, revClaim, appClaim bool
|
|
err := e.pool.QueryRow(ctx, query,
|
|
doc.DocID, doc.ProjectID, doc.TaskKey, doc.Title, doc.Stage, doc.Version,
|
|
string(doc.CurrentState), doc.IsLocked, doc.IsTemplateOnly, doc.WorkDir,
|
|
nullIfZero(doc.AuthorID), doc.AuthorName, nullIfZero(doc.ReviewerID), doc.ReviewerName,
|
|
nullIfZero(doc.ApproverID), doc.ApproverName,
|
|
cfgJSON, doc.CreatedAt, doc.UpdatedAt,
|
|
).Scan(&id, &currState, &isLocked, &isTmpl, &authClaim, &revClaim, &appClaim)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("persist document ledger: %w", err)
|
|
}
|
|
doc.ID = id
|
|
doc.CurrentState = DocumentState(currState)
|
|
doc.IsLocked = isLocked
|
|
doc.IsTemplateOnly = isTmpl
|
|
doc.AuthorClaimed = authClaim
|
|
doc.ReviewerClaimed = revClaim
|
|
doc.ApproverClaimed = appClaim
|
|
}
|
|
|
|
e.memDocsPut(docID, doc)
|
|
return doc, nil
|
|
}
|
|
|
|
// LockDocumentDuty 将文档职责钉住为实例治理结论;heal/revoke 跳过 duty_locked 行。
|
|
// 不改写 author/reviewer/approver 列——那些在 CreateOrGetDocument 时已由调用方写入。
|
|
func (e *GovernanceEngine) LockDocumentDuty(ctx context.Context, docID string, dutyPolicy string) error {
|
|
docID = strings.TrimSpace(docID)
|
|
if docID == "" {
|
|
return fmt.Errorf("docgov: empty doc_id")
|
|
}
|
|
dutyPolicy = strings.TrimSpace(dutyPolicy)
|
|
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
doc, err := e.ensureDocLockedWithDB(ctx, docID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
doc.DutyLocked = true
|
|
doc.DutyPolicy = dutyPolicy
|
|
doc.UpdatedAt = now
|
|
|
|
if e.pool != nil {
|
|
tag, execErr := e.pool.Exec(ctx, `
|
|
UPDATE document_records
|
|
SET duty_locked = true, duty_policy = $2, updated_at = $3
|
|
WHERE doc_id = $1
|
|
`, docID, dutyPolicy, now)
|
|
if execErr != nil {
|
|
return fmt.Errorf("docgov: lock document duty: %w", execErr)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrDocumentNotFound
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkDocumentContentUpdated 当正文生成或更新时标记正文哈希及是否脱离模板状态。
|
|
// 未预加载时从 DB 补偿装入;已是目标哈希则幂等成功,保证 repair 重试也能同步记录。
|
|
func (e *GovernanceEngine) MarkDocumentContentUpdated(ctx context.Context, docID string, contentSHA256 string, isTemplateOnly bool) error {
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
doc, err := e.ensureDocLockedWithDB(ctx, docID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
doc.ContentSHA256 = contentSHA256
|
|
doc.IsTemplateOnly = isTemplateOnly
|
|
doc.UpdatedAt = time.Now()
|
|
|
|
if e.pool != nil {
|
|
tag, execErr := e.pool.Exec(ctx, `
|
|
UPDATE document_records
|
|
SET content_sha256 = $1, is_template_only = $2, work_dir = $3, updated_at = $4
|
|
WHERE doc_id = $5
|
|
`, doc.ContentSHA256, doc.IsTemplateOnly, doc.WorkDir, doc.UpdatedAt, docID)
|
|
if execErr != nil {
|
|
return fmt.Errorf("docgov: mark content updated: %w", execErr)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *GovernanceEngine) ensureDocumentLocked(ctx context.Context, docID string) (*DocumentRecord, error) {
|
|
if doc, exists := e.memDocsGet(docID); exists {
|
|
return doc, nil
|
|
}
|
|
if e.pool != nil {
|
|
doc, err := e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err == nil && doc != nil {
|
|
return doc, nil
|
|
}
|
|
}
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
|
|
// SetDocumentWorkDir 更新文档的工作区路径
|
|
func (e *GovernanceEngine) SetDocumentWorkDir(ctx context.Context, docID string, workDir string) error {
|
|
g := e.acquireDocLock(docID)
|
|
defer g.Release()
|
|
|
|
doc, exists := e.memDocsGet(docID)
|
|
if !exists {
|
|
return ErrDocumentNotFound
|
|
}
|
|
|
|
doc.WorkDir = workDir
|
|
doc.UpdatedAt = time.Now()
|
|
|
|
if e.pool != nil {
|
|
if _, err := e.pool.Exec(ctx, `
|
|
UPDATE document_records
|
|
SET work_dir = $1, updated_at = $2
|
|
WHERE doc_id = $3
|
|
`, doc.WorkDir, doc.UpdatedAt, docID); err != nil {
|
|
slog.Error("docgov_workdir_persist_failed", "doc_id", docID, "error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|