package docgov import ( "context" "encoding/json" "fmt" "strings" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" ) // 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) OnFinalArtifact func(ctx context.Context, doc *DocumentRecord, markdown []byte, snapshotID int64) } // 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) } } } // 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 { _, _ = 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) } } 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, doc.AuthorID, doc.AuthorName, doc.ReviewerID, doc.ReviewerName, 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 } // 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 { _, _ = e.pool.Exec(ctx, ` UPDATE document_records SET work_dir = $1, updated_at = $2 WHERE doc_id = $3 `, doc.WorkDir, doc.UpdatedAt, docID) } 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 { _, _ = 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) detJSON, _ := json.Marshal(audit.Details) _, _ = 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) } 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 { _, _ = 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) detJSON, _ := json.Marshal(audit.Details) _, _ = 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) } 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) _, _ = 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) } 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 { _, _ = 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) detJSON, _ := json.Marshal(audit.Details) _, _ = 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) } 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.freezeReviewSnapshotLocked(ctx, doc); err != nil { return nil, err } e.syncDocAndAuditToDB(ctx, doc, audit) 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) e.syncDocAndAuditToDB(ctx, doc, audit) 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) e.syncDocAndAuditToDB(ctx, doc, audit) 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 } } if doc.ReviewSnapshotID <= 0 { return nil, fmt.Errorf("docgov: approved requires frozen review_snapshot_id") } 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) e.syncDocAndAuditToDB(ctx, doc, audit) e.mu.Unlock() e.runApprovedHooks(ctx, doc) if e.OnFinalArtifact != nil { md, _ := e.LedgerMarkdown(ctx, doc.ReviewSnapshotID) e.OnFinalArtifact(ctx, doc, []byte(md), doc.ReviewSnapshotID) } 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) e.syncDocAndAuditToDB(ctx, doc, audit) 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) } } } } 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) } } } } 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(review_snapshot_id, 0) 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.ReviewSnapshotID, ) 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) freezeReviewSnapshotLocked(ctx context.Context, doc *DocumentRecord) error { if doc == nil { return ErrDocumentNotFound } if e.pool != nil { var head *int64 if err := e.pool.QueryRow(ctx, `SELECT head_snapshot_id FROM document_records WHERE doc_id=$1`, doc.DocID).Scan(&head); err == nil && head != nil && *head > 0 { doc.ReviewSnapshotID = *head } if doc.ReviewSnapshotID <= 0 { return nil } _, err := e.pool.Exec(ctx, `UPDATE document_records SET review_snapshot_id=$1 WHERE doc_id=$2`, doc.ReviewSnapshotID, doc.DocID) return err } if doc.ReviewSnapshotID <= 0 { doc.ReviewSnapshotID = 1 } return nil } // LedgerMarkdown returns immutable snapshot markdown. It never reads workspace files. func (e *GovernanceEngine) LedgerMarkdown(ctx context.Context, snapshotID int64) (string, error) { if e.pool == nil || snapshotID <= 0 { return "", fmt.Errorf("docgov: ledger snapshot unavailable") } var md string err := e.pool.QueryRow(ctx, ` SELECT b.content_markdown FROM document_snapshots s JOIN document_content_blobs b ON b.content_sha256 = s.content_sha256 WHERE s.id = $1 `, snapshotID).Scan(&md) return md, err } func (e *GovernanceEngine) syncDocAndAuditToDB(ctx context.Context, doc *DocumentRecord, audit *AuditLog) { if e.pool == nil { return } _, _ = e.pool.Exec(ctx, ` 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, review_snapshot_id = NULLIF($9, 0) WHERE doc_id = $10 `, string(doc.CurrentState), doc.IsLocked, doc.LastRejectedReason, doc.LastRejectedByRole, doc.LastRejectedByName, doc.LastRejectedAt, doc.ApprovedAt, doc.UpdatedAt, doc.ReviewSnapshotID, doc.DocID) if audit != nil { detJSON, _ := json.Marshal(audit.Details) _, _ = 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) } }