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) { g := e.acquireDocLock(docID) defer g.Release() doc, err := e.ensureDocLockedWithDB(ctx, docID) if err != nil { return nil, err } 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.memPropsPut(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.memAuditsAppend(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) { // 提案级锁:按 proposalID 串行化拍板决策。 g := e.acquireDocLock("*proposal:" + proposalID) defer g.Release() proposal, exists := e.memPropsGet(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.memPropsPut(proposalID, &p) exists = true } } if !exists { return nil, ErrProposalNotFound } if proposal.Status != ProposalPendingAuthorApproval { return nil, ErrProposalAlreadyDecided } doc, docExists := e.memDocsGet(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.memAuditsAppend(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 } // roleAllowedToComment enforces the stage-scoped comment matrix. func roleAllowedToComment(state DocumentState, role UserRole) bool { switch state { case StateDrafting: return role == RoleAuthor || role == RoleReviewer || role == RoleSystem case StateReviewing: return role == RoleReviewer || role == RoleAuthor || role == RoleSystem case StateApproving: return role == RoleApprover || role == RoleReviewer || role == RoleSystem case StateApproved: return false default: return false } } // 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 } g := e.acquireDocLock(docID) defer g.Release() doc, exists := e.memDocsGet(docID) if !exists { var err error doc, err = e.loadDocumentFromDBLocked(ctx, docID) if err != nil || doc == nil { return nil, ErrDocumentNotFound } } if doc.CurrentState == StateApproved { return nil, fmt.Errorf("%w: 已批准归档文档禁止新增批注", ErrDocumentLocked) } if !roleAllowedToComment(doc.CurrentState, userRole) { return nil, fmt.Errorf("%w: 当前阶段【%s】不允许角色【%s】新增批注", ErrUnauthorizedActor, doc.CurrentState, userRole) } 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, } if e.pool == nil { e.memCommsAppend(docID, comment, true) } else { e.memCommsAppend(docID, comment, false) } 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 } // ResolveComment marks a comment resolved (author in drafting) or reopens it (reviewer). func (e *GovernanceEngine) ResolveComment(ctx context.Context, commentID int64, actorID int64, actorRole UserRole, resolve bool) (*DocumentComment, error) { if e == nil || commentID <= 0 { return nil, fmt.Errorf("docgov: comment id required") } e.mu.Lock() defer e.mu.Unlock() found, docID := e.memCommsFind(commentID) if found == nil && e.pool != nil { var c DocumentComment var role, cType string err := e.pool.QueryRow(ctx, ` SELECT id, doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at FROM document_comments WHERE id = $1 `, commentID).Scan( &c.ID, &c.DocID, &c.UserID, &c.UserName, &role, &c.TargetSection, &cType, &c.Content, &c.Status, &c.CreatedAt, ) if err != nil { return nil, fmt.Errorf("docgov: comment not found") } c.UserRole = UserRole(role) c.CommentType = CommentType(cType) found = &c docID = c.DocID e.memCommsAppend(docID, found, false) } if found == nil { return nil, fmt.Errorf("docgov: comment not found") } doc, exists := e.memDocsGet(docID) if !exists { var err error doc, err = e.loadDocumentFromDBLocked(ctx, docID) if err != nil || doc == nil { return nil, ErrDocumentNotFound } } newStatus := "resolved" if !resolve { newStatus = "open" if actorRole != RoleReviewer && actorRole != RoleSystem { return nil, fmt.Errorf("%w: 仅审核人可重开批注", ErrUnauthorizedActor) } } else { switch { case actorRole == RoleAuthor && doc.CurrentState == StateDrafting: case actorRole == RoleReviewer || actorRole == RoleSystem: default: return nil, fmt.Errorf("%w: 当前阶段不允许将该批注标记为已处理", ErrUnauthorizedActor) } } found.Status = newStatus if e.pool != nil { _, err := e.pool.Exec(ctx, `UPDATE document_comments SET status = $1 WHERE id = $2`, newStatus, commentID) if err != nil { return nil, fmt.Errorf("docgov: resolve comment: %w", err) } } _ = actorID return found, 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.memCommsAppend(docID, comment, false) return nil }