Selected documents return to drafting, and advance asks the creator to acknowledge an open recheck mark. Co-authored-by: Cursor <cursoragent@cursor.com>
1231 lines
38 KiB
Go
1231 lines
38 KiB
Go
package docgov
|
|
|
|
import (
|
|
"log/slog"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/app/uow"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// nullIfZero maps unset duty holders (0) to SQL NULL so nullable FK columns
|
|
// (author_id/reviewer_id/approver_id → users.id) accept optional duties.
|
|
func nullIfZero(id int64) any {
|
|
if id <= 0 {
|
|
return nil
|
|
}
|
|
return id
|
|
}
|
|
|
|
// ModificationCheckResult 智能体或人员发起修改前的认领与授权判定结果
|
|
type ModificationCheckResult struct {
|
|
AllowedDirectEdit bool `json:"allowed_direct_edit"` // 是否允许直接执行修改
|
|
RequiresProposal bool `json:"requires_proposal"` // 是否需要生成变更提案待编制人拍板
|
|
AuthorClaimed bool `json:"author_claimed"` // 编制人是否已认领
|
|
PendingProposal *ChangeProposal `json:"pending_proposal,omitempty"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// GovernanceEngine 受控文档编制/审核/批准与认领治理引擎
|
|
type GovernanceEngine struct {
|
|
pool *pgxpool.Pool
|
|
sm *StateMachine
|
|
|
|
mu sync.RWMutex
|
|
memDocs map[string]*DocumentRecord
|
|
memComms map[string][]*DocumentComment
|
|
memProps map[string]*ChangeProposal
|
|
memAudits map[string][]*AuditLog
|
|
onApprovedHook func(ctx context.Context, doc *DocumentRecord)
|
|
onApprovedHooks []func(ctx context.Context, doc *DocumentRecord)
|
|
}
|
|
|
|
// SetOnApprovedHook 注册文档终审批准时的回调钩子
|
|
func (e *GovernanceEngine) SetOnApprovedHook(hook func(ctx context.Context, doc *DocumentRecord)) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.onApprovedHook = hook
|
|
}
|
|
|
|
// AddOnApprovedHook 追加文档终审批准回调(与 SetOnApprovedHook 并存)。
|
|
func (e *GovernanceEngine) AddOnApprovedHook(hook func(ctx context.Context, doc *DocumentRecord)) {
|
|
if hook == nil {
|
|
return
|
|
}
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.onApprovedHooks = append(e.onApprovedHooks, hook)
|
|
}
|
|
|
|
func (e *GovernanceEngine) runApprovedHooks(ctx context.Context, doc *DocumentRecord) {
|
|
var primary func(context.Context, *DocumentRecord)
|
|
var extra []func(context.Context, *DocumentRecord)
|
|
e.mu.Lock()
|
|
primary = e.onApprovedHook
|
|
extra = append([]func(context.Context, *DocumentRecord){}, e.onApprovedHooks...)
|
|
e.mu.Unlock()
|
|
|
|
if primary != nil {
|
|
primary(ctx, doc)
|
|
}
|
|
for _, hook := range extra {
|
|
if hook != nil {
|
|
hook(ctx, doc)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *GovernanceEngine) UsingPG() bool {
|
|
return e != nil && e.pool != nil
|
|
}
|
|
|
|
func (e *GovernanceEngine) RunApprovedHooksAfterCommit(ctx context.Context, doc *DocumentRecord) {
|
|
e.runApprovedHooks(ctx, doc)
|
|
}
|
|
|
|
// NewGovernanceEngine 初始化治理引擎
|
|
func NewGovernanceEngine(pool *pgxpool.Pool) *GovernanceEngine {
|
|
return &GovernanceEngine{
|
|
pool: pool,
|
|
sm: NewStateMachine(),
|
|
memDocs: make(map[string]*DocumentRecord),
|
|
memComms: make(map[string][]*DocumentComment),
|
|
memProps: make(map[string]*ChangeProposal),
|
|
memAudits: make(map[string][]*AuditLog),
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
// 先查内存
|
|
if doc, exists := e.memDocs[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.memDocs[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)
|
|
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, err := e.ensureDocumentLocked(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 {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, err := e.ensureDocumentLocked(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.memDocs[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 {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[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
|
|
}
|
|
|
|
// RecordDocumentViewed 记录查看文档行为并触发认领机制
|
|
// 核心规则:
|
|
// 1. 只有当文档处于【非模板状态】(is_template_only = false) 时,编制负责人查看才标记为 AuthorClaimed = true;模板状态查看不算认领!
|
|
// 2. 审核人查看且处于审核阶段,标记 ReviewerClaimed = true
|
|
// 3. 批准人查看且处于批准阶段,标记 ApproverClaimed = true
|
|
func (e *GovernanceEngine) RecordDocumentViewed(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
var auditAction string
|
|
|
|
switch actorRole {
|
|
case RoleAuthor:
|
|
doc.AuthorLastViewedAt = &now
|
|
auditAction = "author_view"
|
|
// 关键认领判定:只限非模板状态
|
|
if !doc.IsTemplateOnly && !doc.AuthorClaimed {
|
|
doc.AuthorClaimed = true
|
|
doc.AuthorClaimedAt = &now
|
|
auditAction = "author_claim_by_view"
|
|
}
|
|
case RoleReviewer:
|
|
doc.ReviewerClaimed = true
|
|
doc.ReviewerClaimedAt = &now
|
|
auditAction = "reviewer_claim_by_view"
|
|
case RoleApprover:
|
|
doc.ApproverClaimed = true
|
|
doc.ApproverClaimedAt = &now
|
|
auditAction = "approver_claim_by_view"
|
|
default:
|
|
auditAction = "view"
|
|
}
|
|
|
|
doc.UpdatedAt = now
|
|
|
|
// 记录审计日志
|
|
audit := &AuditLog{
|
|
DocID: docID,
|
|
Action: auditAction,
|
|
ActorID: actorID,
|
|
ActorName: actorName,
|
|
ActorRole: actorRole,
|
|
FromState: doc.CurrentState,
|
|
ToState: doc.CurrentState,
|
|
Details: map[string]any{
|
|
"is_template_only": doc.IsTemplateOnly,
|
|
"author_claimed": doc.AuthorClaimed,
|
|
"timestamp": now,
|
|
},
|
|
CreatedAt: now,
|
|
}
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
|
|
if e.pool != nil {
|
|
if _, err := e.pool.Exec(ctx, `
|
|
UPDATE document_records
|
|
SET author_claimed = $1, author_claimed_at = $2, author_last_viewed_at = $3,
|
|
reviewer_claimed = $4, reviewer_claimed_at = $5,
|
|
approver_claimed = $6, approver_claimed_at = $7,
|
|
updated_at = $8
|
|
WHERE doc_id = $9
|
|
`, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastViewedAt,
|
|
doc.ReviewerClaimed, doc.ReviewerClaimedAt,
|
|
doc.ApproverClaimed, doc.ApproverClaimedAt,
|
|
doc.UpdatedAt, docID); err != nil {
|
|
slog.Error("docgov_viewed_persist_failed", "doc_id", docID, "error", err)
|
|
}
|
|
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
if _, err := e.pool.Exec(ctx, `
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
|
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
|
|
}
|
|
}
|
|
|
|
return doc, nil
|
|
}
|
|
|
|
// RecordDocumentEdited 编制人编辑文档 (标记认领与编辑时间)
|
|
func (e *GovernanceEngine) RecordDocumentEdited(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
if doc.HasActiveLease(now) {
|
|
holderID := fmt.Sprintf("%d", actorID)
|
|
sameHuman := doc.LeaseHolderType == LeaseHolderHuman && (doc.LeaseHolderID == holderID || (actorID <= 0 && doc.LeaseHolderID == "human_anon"))
|
|
if !sameHuman {
|
|
return nil, &DocumentBusyError{
|
|
DocID: doc.DocID,
|
|
HolderType: doc.LeaseHolderType,
|
|
HolderID: doc.LeaseHolderID,
|
|
WorkDir: doc.LeaseWorkDir,
|
|
ExpiresAt: *doc.LeaseExpiresAt,
|
|
Reason: fmt.Sprintf("文档正在由 %s 编辑/生成中,禁止人工编辑", doc.LeaseHolderType),
|
|
}
|
|
}
|
|
}
|
|
|
|
doc.AuthorLastEditedAt = &now
|
|
doc.AuthorClaimed = true
|
|
if doc.AuthorClaimedAt == nil {
|
|
doc.AuthorClaimedAt = &now
|
|
}
|
|
doc.UpdatedAt = now
|
|
|
|
audit := &AuditLog{
|
|
DocID: docID,
|
|
Action: "author_edit",
|
|
ActorID: actorID,
|
|
ActorName: actorName,
|
|
ActorRole: actorRole,
|
|
FromState: doc.CurrentState,
|
|
ToState: doc.CurrentState,
|
|
Details: map[string]any{"timestamp": now},
|
|
CreatedAt: now,
|
|
}
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
|
|
if e.pool != nil {
|
|
if _, err := e.pool.Exec(ctx, `
|
|
UPDATE document_records
|
|
SET author_claimed = $1, author_claimed_at = $2, author_last_edited_at = $3, updated_at = $4
|
|
WHERE doc_id = $5
|
|
`, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastEditedAt, doc.UpdatedAt, docID); err != nil {
|
|
slog.Error("docgov_edited_persist_failed", "doc_id", docID, "error", err)
|
|
}
|
|
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
if _, err := e.pool.Exec(ctx, `
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
|
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
|
|
}
|
|
}
|
|
|
|
return doc, nil
|
|
}
|
|
|
|
// CheckModificationPermission 阶段发起修改编制状态文档前,检查认领与权限
|
|
// 核心逻辑:
|
|
// 1. 若文档未认领 (author_claimed == false),系统不用告知负责人,直接放行智能体小队编辑 (AllowedDirectEdit = true)
|
|
// 2. 若文档已认领 (author_claimed == true),系统拦截直接编辑,必须通知编制负责人并生成变更提案 (RequiresProposal = true)
|
|
func (e *GovernanceEngine) CheckModificationPermission(ctx context.Context, docID string, actorRole UserRole) (*ModificationCheckResult, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
// 1. 检查锁定状态
|
|
if doc.IsLocked {
|
|
return &ModificationCheckResult{
|
|
AllowedDirectEdit: false,
|
|
RequiresProposal: false,
|
|
AuthorClaimed: doc.AuthorClaimed,
|
|
Reason: fmt.Sprintf("文档处于【%s】阶段已锁定,禁止任何修改", doc.CurrentState),
|
|
}, ErrDocumentLocked
|
|
}
|
|
|
|
// 1b. 运行时编辑租约占用(正交于 IsLocked)
|
|
now := time.Now()
|
|
if doc.HasActiveLease(now) {
|
|
return &ModificationCheckResult{
|
|
AllowedDirectEdit: false,
|
|
RequiresProposal: false,
|
|
AuthorClaimed: doc.AuthorClaimed,
|
|
Reason: fmt.Sprintf("文档正在被 %s(%s) 编辑中", doc.LeaseHolderType, doc.LeaseHolderID),
|
|
}, &DocumentBusyError{
|
|
DocID: doc.DocID,
|
|
HolderType: doc.LeaseHolderType,
|
|
HolderID: doc.LeaseHolderID,
|
|
WorkDir: doc.LeaseWorkDir,
|
|
ExpiresAt: *doc.LeaseExpiresAt,
|
|
Reason: fmt.Sprintf("文档正在被 %s(%s) 编辑中,禁止并行修改", doc.LeaseHolderType, doc.LeaseHolderID),
|
|
}
|
|
}
|
|
|
|
// 2. 检查认领状态
|
|
if !doc.AuthorClaimed {
|
|
// 未认领:智能体小队可静默直接修改
|
|
return &ModificationCheckResult{
|
|
AllowedDirectEdit: true,
|
|
RequiresProposal: false,
|
|
AuthorClaimed: false,
|
|
Reason: "文档尚未被编制负责人认领(未查看正式稿),允许智能体小队直接自动编辑",
|
|
}, nil
|
|
}
|
|
|
|
// 3. 已认领:检查是否有待编制人处理的挂起提案
|
|
var pendingProp *ChangeProposal
|
|
for _, p := range e.memProps {
|
|
if p.DocID == docID && p.Status == ProposalPendingAuthorApproval {
|
|
pendingProp = p
|
|
break
|
|
}
|
|
}
|
|
if pendingProp == nil && e.pool != nil {
|
|
var p ChangeProposal
|
|
var stat string
|
|
err := e.pool.QueryRow(ctx, `
|
|
SELECT id, proposal_id, doc_id, project_id, triggered_by, proposed_changes, impact_analysis, risk_assessment, status, author_decision_reason, decided_at, created_at
|
|
FROM document_change_proposals
|
|
WHERE doc_id = $1 AND status = 'pending_author_approval'
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, docID).Scan(&p.ID, &p.ProposalID, &p.DocID, &p.ProjectID, &p.TriggeredBy, &p.ProposedChanges, &p.ImpactAnalysis, &p.RiskAssessment, &stat, &p.AuthorDecisionReason, &p.DecidedAt, &p.CreatedAt)
|
|
if err == nil {
|
|
p.Status = ProposalStatus(stat)
|
|
pendingProp = &p
|
|
e.memProps[p.ProposalID] = &p
|
|
}
|
|
}
|
|
|
|
return &ModificationCheckResult{
|
|
AllowedDirectEdit: false,
|
|
RequiresProposal: true,
|
|
AuthorClaimed: true,
|
|
PendingProposal: pendingProp,
|
|
Reason: "文档已被编制负责人认领,必须生成变更提案告知修改点、影响与风险,并由编制人确认授权",
|
|
}, nil
|
|
}
|
|
|
|
// ProposeModification 当文档处于已认领状态时,由智能体或系统提出变更提案
|
|
func (e *GovernanceEngine) ProposeModification(
|
|
ctx context.Context,
|
|
docID string,
|
|
triggeredBy string,
|
|
proposedChanges string,
|
|
impactAnalysis string,
|
|
riskAssessment string,
|
|
) (*ChangeProposal, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
proposalID := fmt.Sprintf("prop_%s_%d", doc.TaskKey, time.Now().UnixNano())
|
|
now := time.Now()
|
|
|
|
proposal := &ChangeProposal{
|
|
ProposalID: proposalID,
|
|
DocID: docID,
|
|
ProjectID: doc.ProjectID,
|
|
TriggeredBy: triggeredBy,
|
|
ProposedChanges: proposedChanges,
|
|
ImpactAnalysis: impactAnalysis,
|
|
RiskAssessment: riskAssessment,
|
|
Status: ProposalPendingAuthorApproval,
|
|
CreatedAt: now,
|
|
}
|
|
|
|
e.memProps[proposalID] = proposal
|
|
|
|
// 记录审计
|
|
audit := &AuditLog{
|
|
DocID: docID,
|
|
Action: "propose_change",
|
|
ActorID: 0,
|
|
ActorName: triggeredBy,
|
|
ActorRole: RoleSystem,
|
|
FromState: doc.CurrentState,
|
|
ToState: doc.CurrentState,
|
|
Details: map[string]any{
|
|
"proposal_id": proposalID,
|
|
"proposed_changes": proposedChanges,
|
|
"impact": impactAnalysis,
|
|
"risk": riskAssessment,
|
|
},
|
|
CreatedAt: now,
|
|
}
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
|
|
if e.pool != nil {
|
|
query := `
|
|
INSERT INTO document_change_proposals (
|
|
proposal_id, doc_id, project_id, triggered_by, proposed_changes, impact_analysis, risk_assessment, status, created_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id;
|
|
`
|
|
var id int64
|
|
if err := e.pool.QueryRow(ctx, query,
|
|
proposal.ProposalID, proposal.DocID, proposal.ProjectID,
|
|
proposal.TriggeredBy, proposal.ProposedChanges, proposal.ImpactAnalysis, proposal.RiskAssessment,
|
|
string(proposal.Status), proposal.CreatedAt,
|
|
).Scan(&id); err == nil {
|
|
proposal.ID = id
|
|
}
|
|
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
if _, err := e.pool.Exec(ctx, `
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
|
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
|
|
}
|
|
}
|
|
|
|
return proposal, nil
|
|
}
|
|
|
|
// DecideModificationProposal 编制负责人拍板决定:同意修改或拒绝修改
|
|
func (e *GovernanceEngine) DecideModificationProposal(
|
|
ctx context.Context,
|
|
proposalID string,
|
|
authorID int64,
|
|
approve bool,
|
|
reason string,
|
|
) (*ChangeProposal, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
proposal, exists := e.memProps[proposalID]
|
|
if !exists && e.pool != nil {
|
|
var p ChangeProposal
|
|
var stat string
|
|
err := e.pool.QueryRow(ctx, `
|
|
SELECT id, proposal_id, doc_id, project_id, triggered_by, proposed_changes, impact_analysis, risk_assessment, status, author_decision_reason, decided_at, created_at
|
|
FROM document_change_proposals
|
|
WHERE proposal_id = $1
|
|
`, proposalID).Scan(&p.ID, &p.ProposalID, &p.DocID, &p.ProjectID, &p.TriggeredBy, &p.ProposedChanges, &p.ImpactAnalysis, &p.RiskAssessment, &stat, &p.AuthorDecisionReason, &p.DecidedAt, &p.CreatedAt)
|
|
if err == nil {
|
|
p.Status = ProposalStatus(stat)
|
|
proposal = &p
|
|
e.memProps[proposalID] = &p
|
|
exists = true
|
|
}
|
|
}
|
|
if !exists {
|
|
return nil, ErrProposalNotFound
|
|
}
|
|
if proposal.Status != ProposalPendingAuthorApproval {
|
|
return nil, ErrProposalAlreadyDecided
|
|
}
|
|
|
|
doc, docExists := e.memDocs[proposal.DocID]
|
|
if !docExists && e.pool != nil {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, proposal.DocID)
|
|
if err == nil && doc != nil {
|
|
docExists = true
|
|
}
|
|
}
|
|
if !docExists {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
|
|
// 校验只有编制人有权拍板
|
|
if doc.AuthorID > 0 && authorID > 0 && doc.AuthorID != authorID {
|
|
return nil, fmt.Errorf("%w: 只有编制负责人 (ID: %d) 有权拍板该修改提案", ErrUnauthorizedActor, doc.AuthorID)
|
|
}
|
|
|
|
now := time.Now()
|
|
proposal.AuthorDecisionReason = strings.TrimSpace(reason)
|
|
proposal.DecidedAt = &now
|
|
|
|
var action string
|
|
if approve {
|
|
proposal.Status = ProposalApprovedByAuthor
|
|
action = "author_approve_proposal"
|
|
} else {
|
|
proposal.Status = ProposalRejectedByAuthor
|
|
action = "author_reject_proposal"
|
|
}
|
|
|
|
audit := &AuditLog{
|
|
DocID: proposal.DocID,
|
|
Action: action,
|
|
ActorID: authorID,
|
|
ActorName: doc.AuthorName,
|
|
ActorRole: RoleAuthor,
|
|
FromState: doc.CurrentState,
|
|
ToState: doc.CurrentState,
|
|
Details: map[string]any{
|
|
"proposal_id": proposalID,
|
|
"decision": approve,
|
|
"reason": proposal.AuthorDecisionReason,
|
|
"timestamp": now,
|
|
},
|
|
CreatedAt: now,
|
|
}
|
|
e.memAudits[proposal.DocID] = append(e.memAudits[proposal.DocID], audit)
|
|
|
|
if e.pool != nil {
|
|
if _, err := e.pool.Exec(ctx, `
|
|
UPDATE document_change_proposals
|
|
SET status = $1, author_decision_reason = $2, decided_at = $3
|
|
WHERE proposal_id = $4
|
|
`, string(proposal.Status), proposal.AuthorDecisionReason, proposal.DecidedAt, proposalID); err != nil {
|
|
slog.Error("docgov_proposal_decide_persist_failed", "proposal_id", proposalID, "error", err)
|
|
}
|
|
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
if _, err := e.pool.Exec(ctx, `
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
|
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
|
|
}
|
|
}
|
|
|
|
return proposal, nil
|
|
}
|
|
|
|
// AddComment 添加批注与修改意见 (审核人/批准人/编制人均可批注,但审核批准不能直接改正文)
|
|
// 有效编辑租约期间禁止批注;生命周期锁定下仍允许批注。
|
|
func (e *GovernanceEngine) AddComment(
|
|
ctx context.Context,
|
|
docID string,
|
|
userID int64,
|
|
userName string,
|
|
userRole UserRole,
|
|
targetSection string,
|
|
commentType CommentType,
|
|
content string,
|
|
) (*DocumentComment, error) {
|
|
if err := e.AssertWritableForComment(ctx, docID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
trimmed := strings.TrimSpace(content)
|
|
if trimmed == "" {
|
|
return nil, fmt.Errorf("comment content cannot be empty")
|
|
}
|
|
|
|
now := time.Now()
|
|
comment := &DocumentComment{
|
|
DocID: docID,
|
|
UserID: userID,
|
|
UserName: userName,
|
|
UserRole: userRole,
|
|
TargetSection: targetSection,
|
|
CommentType: commentType,
|
|
Content: trimmed,
|
|
Status: "open",
|
|
CreatedAt: now,
|
|
}
|
|
|
|
e.memComms[docID] = append(e.memComms[docID], comment)
|
|
|
|
if e.pool != nil {
|
|
query := `
|
|
INSERT INTO document_comments (doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id;
|
|
`
|
|
var id int64
|
|
if err := e.pool.QueryRow(ctx, query,
|
|
comment.DocID, comment.UserID, comment.UserName, string(comment.UserRole),
|
|
comment.TargetSection, string(comment.CommentType), comment.Content, comment.Status, comment.CreatedAt,
|
|
).Scan(&id); err == nil {
|
|
comment.ID = id
|
|
}
|
|
}
|
|
|
|
return comment, nil
|
|
}
|
|
|
|
// SubmitForReview 编制完成提交审核 (drafting -> reviewing)
|
|
func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
if doc.HasActiveLease(time.Now()) {
|
|
return nil, &DocumentBusyError{
|
|
DocID: doc.DocID,
|
|
HolderType: doc.LeaseHolderType,
|
|
HolderID: doc.LeaseHolderID,
|
|
WorkDir: doc.LeaseWorkDir,
|
|
ExpiresAt: *doc.LeaseExpiresAt,
|
|
Reason: "文档编辑租约占用中,暂不可送审",
|
|
}
|
|
}
|
|
|
|
audit, err := e.sm.TransitionSubmitForReview(doc, actorID, actorRole, actorName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
|
return nil, err
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// ReviewApprove 审核通过 (reviewing -> approving)
|
|
func (e *GovernanceEngine) ReviewApprove(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, comment string) (*DocumentRecord, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
audit, err := e.sm.TransitionReviewApprove(doc, actorID, actorRole, actorName, comment)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if strings.TrimSpace(comment) != "" {
|
|
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentSuggestion, comment)
|
|
}
|
|
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
|
return nil, err
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// ReviewReject 审核驳回 (reviewing -> drafting, 驳回原因必填)
|
|
func (e *GovernanceEngine) ReviewReject(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, rejectReason string) (*DocumentRecord, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
audit, err := e.sm.TransitionReviewReject(doc, actorID, actorRole, actorName, rejectReason)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 驳回原因自动持久化为批注记录
|
|
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason)
|
|
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
|
return nil, err
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// ApproverApprove 批准通过 (approving -> approved, 终态归档锁定)
|
|
func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, comment string) (*DocumentRecord, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
audit, err := e.sm.TransitionApproverApprove(doc, actorID, actorRole, actorName, comment)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if strings.TrimSpace(comment) != "" {
|
|
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentSuggestion, comment)
|
|
}
|
|
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, joined := uow.ScopeFrom(ctx); !joined {
|
|
e.mu.Unlock()
|
|
e.runApprovedHooks(ctx, doc)
|
|
e.mu.Lock()
|
|
}
|
|
|
|
return doc, nil
|
|
}
|
|
|
|
// ApproverReject 批准驳回 (approving -> drafting 或 reviewing, 驳回原因必填)
|
|
func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, rejectReason string, rollbackTo DocumentState) (*DocumentRecord, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
audit, err := e.sm.TransitionApproverReject(doc, actorID, actorRole, actorName, rejectReason, rollbackTo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason)
|
|
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
|
return nil, err
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// ReopenForRevision moves an approved document back to drafting without recording a rejection.
|
|
func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string, actorID int64, actorName, reason string) (*DocumentRecord, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
audit, err := e.sm.TransitionReopenForRevision(doc, actorID, actorName, reason)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
|
return nil, err
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// GetDocument 获取文档实体 (优先查内存,未命中查 DB)
|
|
func (e *GovernanceEngine) GetDocument(ctx context.Context, docID string) (*DocumentRecord, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if doc, exists := e.memDocs[docID]; exists {
|
|
return doc, nil
|
|
}
|
|
if e.pool != nil {
|
|
if doc, err := e.loadDocumentFromDBLocked(ctx, docID); err == nil && doc != nil {
|
|
return doc, nil
|
|
}
|
|
}
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
|
|
// ListAuditLogs 获取文档审计日志
|
|
func (e *GovernanceEngine) ListAuditLogs(ctx context.Context, docID string) ([]*AuditLog, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if len(e.memAudits[docID]) == 0 && e.pool != nil {
|
|
rows, err := e.pool.Query(ctx, `
|
|
SELECT id, doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at
|
|
FROM document_audit_logs
|
|
WHERE doc_id = $1
|
|
ORDER BY created_at ASC
|
|
`, docID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var a AuditLog
|
|
var detJSON []byte
|
|
var role, fromSt, toSt string
|
|
var actID *int64
|
|
if err := rows.Scan(&a.ID, &a.DocID, &a.Action, &actID, &a.ActorName, &role, &fromSt, &toSt, &detJSON, &a.CreatedAt); err == nil {
|
|
if actID != nil {
|
|
a.ActorID = *actID
|
|
}
|
|
a.ActorRole = UserRole(role)
|
|
a.FromState = DocumentState(fromSt)
|
|
a.ToState = DocumentState(toSt)
|
|
if len(detJSON) > 0 {
|
|
_ = json.Unmarshal(detJSON, &a.Details)
|
|
}
|
|
e.memAudits[docID] = append(e.memAudits[docID], &a)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("docgov_audit_scan_incomplete", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
logs := e.memAudits[docID]
|
|
res := make([]*AuditLog, len(logs))
|
|
copy(res, logs)
|
|
return res, nil
|
|
}
|
|
|
|
// ListComments 列出文档的所有批注
|
|
func (e *GovernanceEngine) ListComments(ctx context.Context, docID string) ([]*DocumentComment, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if len(e.memComms[docID]) == 0 && e.pool != nil {
|
|
rows, err := e.pool.Query(ctx, `
|
|
SELECT id, doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at
|
|
FROM document_comments
|
|
WHERE doc_id = $1
|
|
ORDER BY created_at ASC
|
|
`, docID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var c DocumentComment
|
|
var uID *int64
|
|
var role, cType string
|
|
if err := rows.Scan(&c.ID, &c.DocID, &uID, &c.UserName, &role, &c.TargetSection, &cType, &c.Content, &c.Status, &c.CreatedAt); err == nil {
|
|
if uID != nil {
|
|
c.UserID = *uID
|
|
}
|
|
c.UserRole = UserRole(role)
|
|
c.CommentType = CommentType(cType)
|
|
e.memComms[docID] = append(e.memComms[docID], &c)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("docgov_comments_scan_incomplete", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
comms := e.memComms[docID]
|
|
res := make([]*DocumentComment, len(comms))
|
|
copy(res, comms)
|
|
return res, nil
|
|
}
|
|
|
|
func (e *GovernanceEngine) loadDocumentFromDBLocked(ctx context.Context, docID string) (*DocumentRecord, error) {
|
|
if e.pool == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
row := e.pool.QueryRow(ctx, `
|
|
SELECT id, doc_id, project_id, task_key, title, stage, version,
|
|
current_state, is_locked, is_template_only, content_sha256, work_dir,
|
|
author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name,
|
|
author_claimed, author_claimed_at, author_last_viewed_at, author_last_edited_at,
|
|
reviewer_claimed, reviewer_claimed_at, approver_claimed, approver_claimed_at,
|
|
last_rejected_reason, last_rejected_by_role, last_rejected_by_name, last_rejected_at,
|
|
approved_at, config, created_at, updated_at,
|
|
COALESCE(lease_holder_type, ''), COALESCE(lease_holder_id, ''), COALESCE(lease_work_dir, ''),
|
|
lease_expires_at, lease_heartbeat_at, COALESCE(lease_fencing_token, ''),
|
|
COALESCE(duty_locked, false), COALESCE(duty_policy, '')
|
|
FROM document_records
|
|
WHERE doc_id = $1
|
|
`, docID)
|
|
|
|
var doc DocumentRecord
|
|
var cfgJSON []byte
|
|
var currState string
|
|
var authorID, reviewerID, approverID *int64
|
|
var leaseType, leaseID, leaseWD string
|
|
err := row.Scan(
|
|
&doc.ID, &doc.DocID, &doc.ProjectID, &doc.TaskKey, &doc.Title, &doc.Stage, &doc.Version,
|
|
&currState, &doc.IsLocked, &doc.IsTemplateOnly, &doc.ContentSHA256, &doc.WorkDir,
|
|
&authorID, &doc.AuthorName, &reviewerID, &doc.ReviewerName, &approverID, &doc.ApproverName,
|
|
&doc.AuthorClaimed, &doc.AuthorClaimedAt, &doc.AuthorLastViewedAt, &doc.AuthorLastEditedAt,
|
|
&doc.ReviewerClaimed, &doc.ReviewerClaimedAt, &doc.ApproverClaimed, &doc.ApproverClaimedAt,
|
|
&doc.LastRejectedReason, &doc.LastRejectedByRole, &doc.LastRejectedByName, &doc.LastRejectedAt,
|
|
&doc.ApprovedAt, &cfgJSON, &doc.CreatedAt, &doc.UpdatedAt,
|
|
&leaseType, &leaseID, &leaseWD, &doc.LeaseExpiresAt, &doc.LeaseHeartbeatAt, &doc.LeaseFencingToken,
|
|
&doc.DutyLocked, &doc.DutyPolicy,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
doc.CurrentState = DocumentState(currState)
|
|
doc.LeaseHolderType = LeaseHolderType(leaseType)
|
|
doc.LeaseHolderID = leaseID
|
|
doc.LeaseWorkDir = leaseWD
|
|
if authorID != nil {
|
|
doc.AuthorID = *authorID
|
|
}
|
|
if reviewerID != nil {
|
|
doc.ReviewerID = *reviewerID
|
|
}
|
|
if approverID != nil {
|
|
doc.ApproverID = *approverID
|
|
}
|
|
if len(cfgJSON) > 0 {
|
|
_ = json.Unmarshal(cfgJSON, &doc.Config)
|
|
}
|
|
e.memDocs[docID] = &doc
|
|
return &doc, nil
|
|
}
|
|
|
|
func (e *GovernanceEngine) addCommentInternal(docID string, userID int64, userName string, userRole UserRole, targetSection string, commentType CommentType, content string) error {
|
|
now := time.Now()
|
|
comment := &DocumentComment{
|
|
DocID: docID,
|
|
UserID: userID,
|
|
UserName: userName,
|
|
UserRole: userRole,
|
|
TargetSection: targetSection,
|
|
CommentType: commentType,
|
|
Content: content,
|
|
Status: "open",
|
|
CreatedAt: now,
|
|
}
|
|
e.memComms[docID] = append(e.memComms[docID], comment)
|
|
return nil
|
|
}
|
|
|
|
func (e *GovernanceEngine) syncDocAndAuditToDB(ctx context.Context, doc *DocumentRecord, audit *AuditLog) error {
|
|
if e.pool == nil {
|
|
return nil
|
|
}
|
|
exec := func(sql string, args ...any) error {
|
|
if scope, ok := uow.ScopeFrom(ctx); ok {
|
|
tx, err := uow.AsPG(scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(ctx, sql, args...)
|
|
return err
|
|
}
|
|
_, err := e.pool.Exec(ctx, sql, args...)
|
|
return err
|
|
}
|
|
if err := exec(`
|
|
UPDATE document_records
|
|
SET current_state = $1, is_locked = $2,
|
|
last_rejected_reason = $3, last_rejected_by_role = $4, last_rejected_by_name = $5, last_rejected_at = $6,
|
|
approved_at = $7, updated_at = $8
|
|
WHERE doc_id = $9
|
|
`, string(doc.CurrentState), doc.IsLocked,
|
|
doc.LastRejectedReason, doc.LastRejectedByRole, doc.LastRejectedByName, doc.LastRejectedAt,
|
|
doc.ApprovedAt, doc.UpdatedAt, doc.DocID); err != nil {
|
|
return fmt.Errorf("docgov: sync document: %w", err)
|
|
}
|
|
if audit != nil {
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
if err := exec(`
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
|
return fmt.Errorf("docgov: sync audit: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|