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 }