From 39e115b4d37dd6f90ef2bfef9f678fc1d42edb7f Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 09:54:19 +0800 Subject: [PATCH] refactor(docgov): split engine.go into use-case files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move claim/document/persist/proposal/read/review_flow into ≤800-line siblings without behavior change; short tests green. --- internal/docgov/engine.go | 1153 ------------------------- internal/docgov/engine_claim.go | 274 ++++++ internal/docgov/engine_document.go | 236 +++++ internal/docgov/engine_persist.go | 58 ++ internal/docgov/engine_proposal.go | 274 ++++++ internal/docgov/engine_read.go | 165 ++++ internal/docgov/engine_review_flow.go | 193 +++++ 7 files changed, 1200 insertions(+), 1153 deletions(-) create mode 100644 internal/docgov/engine_claim.go create mode 100644 internal/docgov/engine_document.go create mode 100644 internal/docgov/engine_persist.go create mode 100644 internal/docgov/engine_proposal.go create mode 100644 internal/docgov/engine_read.go create mode 100644 internal/docgov/engine_review_flow.go diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index ab5ad78c..cc7ddc58 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -1,38 +1,12 @@ 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 @@ -101,1130 +75,3 @@ func NewGovernanceEngine(pool *pgxpool.Pool) *GovernanceEngine { 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 -} diff --git a/internal/docgov/engine_claim.go b/internal/docgov/engine_claim.go new file mode 100644 index 00000000..03492936 --- /dev/null +++ b/internal/docgov/engine_claim.go @@ -0,0 +1,274 @@ +package docgov + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" +) + +// 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"` +} + +// 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 +} diff --git a/internal/docgov/engine_document.go b/internal/docgov/engine_document.go new file mode 100644 index 00000000..6bc38b7a --- /dev/null +++ b/internal/docgov/engine_document.go @@ -0,0 +1,236 @@ +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) { + 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 +} diff --git a/internal/docgov/engine_persist.go b/internal/docgov/engine_persist.go new file mode 100644 index 00000000..1dea16b2 --- /dev/null +++ b/internal/docgov/engine_persist.go @@ -0,0 +1,58 @@ +package docgov + +import ( + "context" + "encoding/json" + "fmt" + + "chat-agent-system/internal/app/uow" +) + +// 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 +} + +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 +} diff --git a/internal/docgov/engine_proposal.go b/internal/docgov/engine_proposal.go new file mode 100644 index 00000000..7157726a --- /dev/null +++ b/internal/docgov/engine_proposal.go @@ -0,0 +1,274 @@ +package docgov + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" +) + +// 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 +} + +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 +} diff --git a/internal/docgov/engine_read.go b/internal/docgov/engine_read.go new file mode 100644 index 00000000..7d73b84e --- /dev/null +++ b/internal/docgov/engine_read.go @@ -0,0 +1,165 @@ +package docgov + +import ( + "context" + "encoding/json" + "log/slog" +) + +// 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 +} diff --git a/internal/docgov/engine_review_flow.go b/internal/docgov/engine_review_flow.go new file mode 100644 index 00000000..2a792fb7 --- /dev/null +++ b/internal/docgov/engine_review_flow.go @@ -0,0 +1,193 @@ +package docgov + +import ( + "context" + "strings" + "time" + + "chat-agent-system/internal/app/uow" +) + +// 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 +}