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) { g := e.acquireDocLock(docID) defer g.Release() doc, err := e.ensureDocLockedWithDB(ctx, docID) if err != nil { return nil, err } 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: auditAction = "reviewer_view" if !doc.IsTemplateOnly && !doc.ReviewerClaimed { doc.ReviewerClaimed = true doc.ReviewerClaimedAt = &now auditAction = "reviewer_claim_by_view" } case RoleApprover: auditAction = "approver_view" if !doc.IsTemplateOnly && !doc.ApproverClaimed { 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.memAuditsAppend(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) { g := e.acquireDocLock(docID) defer g.Release() doc, err := e.ensureDocLockedWithDB(ctx, docID) if err != nil { return nil, err } if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole, false); 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.memAuditsAppend(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) { g := e.acquireDocLock(docID) defer g.Release() doc, err := e.ensureDocLockedWithDB(ctx, docID) if err != nil { return nil, err } // 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. 已认领:检查是否有待编制人处理的挂起提案 pendingProp := e.memPropsFind(func(p *ChangeProposal) bool { return p.DocID == docID && p.Status == ProposalPendingAuthorApproval }) 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.memPropsPut(p.ProposalID, &p) } } return &ModificationCheckResult{ AllowedDirectEdit: false, RequiresProposal: true, AuthorClaimed: true, PendingProposal: pendingProp, Reason: "文档已被编制负责人认领,必须生成变更提案告知修改点、影响与风险,并由编制人确认授权", }, nil }