package docgov import ( "fmt" "strings" "time" ) // StateMachine 文档编制/审核/批准生命周期状态机与权限守卫 type StateMachine struct{} func NewStateMachine() *StateMachine { return &StateMachine{} } // CanEditDocumentContent 权限守卫 1:校验当前操作人是否有权修改文档正文 // 规则: // 1. 文档处于锁定状态 (reviewing, approving, approved) 时严禁直接修改 // 2. 审核人/批准人角色严禁直接修改 // 3. 编制负责人或已授权项目成员(ActorIsMember,由 server 层校验后传入)可在 drafting 未锁定时写正文 // 4. 不复用 RoleSystem:人工成员编辑不得记成智能体代编 func (sm *StateMachine) CanEditDocumentContent(doc *DocumentRecord, actorID int64, actorRole UserRole, actorIsMember bool) error { if doc == nil { return ErrDocumentNotFound } // 1. 检查锁定状态 if doc.IsLocked { return fmt.Errorf("%w: 当前文档处于【%s】状态并已锁定", ErrDocumentLocked, doc.CurrentState) } // 2. 状态合法性 if doc.CurrentState != StateDrafting { return fmt.Errorf("%w: 只有在编制草稿状态 (drafting) 下才允许修改内容,当前为【%s】", ErrInvalidStateTransition, doc.CurrentState) } // 3. 角色与身份权限校验 switch actorRole { case RoleReviewer: return ErrReviewerCannotEdit case RoleApprover: return ErrApproverCannotEdit case RoleSystem: // 系统或智能体代为编辑:在编制中且未锁定阶段放行 (认领检查在外部业务层联动) return nil case RoleAuthor: if actorIsMember { return nil } if doc.AuthorID > 0 && actorID > 0 && doc.AuthorID != actorID { return fmt.Errorf("%w: 文档编制负责人为用户ID %d,非操作人 %d", ErrOnlyAuthorCanEdit, doc.AuthorID, actorID) } return nil default: if actorIsMember { return nil } return ErrUnauthorizedActor } } // TransitionSubmitForReview 编制完成送审: drafting -> reviewing // 只有编制负责人可以发起送审 func (sm *StateMachine) TransitionSubmitForReview(doc *DocumentRecord, actorID int64, actorRole UserRole, actorName string) (*AuditLog, error) { if doc == nil { return nil, ErrDocumentNotFound } if doc.CurrentState != StateDrafting { return nil, fmt.Errorf("%w: 无法从【%s】阶段提交审核,必须为【drafting】", ErrInvalidStateTransition, doc.CurrentState) } if actorRole != RoleAuthor && actorRole != RoleSystem { return nil, fmt.Errorf("%w: 只有编制人或系统小队有权发起送审", ErrUnauthorizedActor) } if actorRole == RoleAuthor && doc.AuthorID > 0 && actorID > 0 && doc.AuthorID != actorID { return nil, fmt.Errorf("%w: 当前送审操作人 %d 不是该文档指定的编制人 %d", ErrUnauthorizedActor, actorID, doc.AuthorID) } fromState := doc.CurrentState toState := StateReviewing doc.CurrentState = toState doc.IsLocked = true // 审核阶段进入锁定状态 now := time.Now() doc.UpdatedAt = now return &AuditLog{ DocID: doc.DocID, Action: "submit_review", ActorID: actorID, ActorName: actorName, ActorRole: actorRole, FromState: fromState, ToState: toState, Details: map[string]any{"timestamp": now, "duty_policy": doc.DutyPolicy}, CreatedAt: now, }, nil } // TransitionReviewApprove 审核通过: reviewing -> approving // 只有审核人有权执行 func (sm *StateMachine) TransitionReviewApprove(doc *DocumentRecord, actorID int64, actorRole UserRole, actorName string, comment string) (*AuditLog, error) { if doc == nil { return nil, ErrDocumentNotFound } if doc.CurrentState != StateReviewing { return nil, fmt.Errorf("%w: 无法在【%s】状态下执行审核通过,当前应为【reviewing】", ErrInvalidStateTransition, doc.CurrentState) } if actorRole != RoleReviewer && actorRole != RoleSystem { return nil, fmt.Errorf("%w: 只有审核人有权执行审核通过", ErrUnauthorizedActor) } if actorRole == RoleReviewer && doc.ReviewerID > 0 && actorID > 0 && doc.ReviewerID != actorID { return nil, fmt.Errorf("%w: 当前审核人 %d 与文档指定审核人 %d 不匹配", ErrUnauthorizedActor, actorID, doc.ReviewerID) } fromState := doc.CurrentState toState := StateApproving doc.CurrentState = toState doc.IsLocked = true // 批准阶段保持锁定 now := time.Now() doc.UpdatedAt = now return &AuditLog{ DocID: doc.DocID, Action: "review_approve", ActorID: actorID, ActorName: actorName, ActorRole: actorRole, FromState: fromState, ToState: toState, Details: map[string]any{"comment": comment, "timestamp": now, "duty_policy": doc.DutyPolicy}, CreatedAt: now, }, nil } // TransitionReviewReject 审核驳回: reviewing -> drafting // 审核人驳回时必须填写驳回原因,退回编制草稿并解锁,记录驳回上下文 func (sm *StateMachine) TransitionReviewReject(doc *DocumentRecord, actorID int64, actorRole UserRole, actorName string, rejectReason string) (*AuditLog, error) { if doc == nil { return nil, ErrDocumentNotFound } if doc.CurrentState != StateReviewing { return nil, fmt.Errorf("%w: 无法在【%s】状态下执行审核驳回,当前应为【reviewing】", ErrInvalidStateTransition, doc.CurrentState) } if actorRole != RoleReviewer && actorRole != RoleSystem { return nil, fmt.Errorf("%w: 只有审核人有权执行审核驳回", ErrUnauthorizedActor) } if actorRole == RoleReviewer && doc.ReviewerID > 0 && actorID > 0 && doc.ReviewerID != actorID { return nil, fmt.Errorf("%w: 当前审核人 %d 与文档指定审核人 %d 不匹配", ErrUnauthorizedActor, actorID, doc.ReviewerID) } trimmedReason := strings.TrimSpace(rejectReason) if trimmedReason == "" { return nil, ErrRejectReasonRequired } fromState := doc.CurrentState toState := StateDrafting doc.CurrentState = toState doc.IsLocked = false // 退回编制草稿阶段,解锁文档供编制人修订 now := time.Now() doc.LastRejectedReason = trimmedReason doc.LastRejectedByRole = string(actorRole) doc.LastRejectedByName = actorName doc.LastRejectedAt = &now doc.UpdatedAt = now return &AuditLog{ DocID: doc.DocID, Action: "review_reject", ActorID: actorID, ActorName: actorName, ActorRole: actorRole, FromState: fromState, ToState: toState, Details: map[string]any{ "reject_reason": trimmedReason, "timestamp": now, "duty_policy": doc.DutyPolicy, }, CreatedAt: now, }, nil } // TransitionApproverApprove 批准通过: approving -> approved (终态归档) func (sm *StateMachine) TransitionApproverApprove(doc *DocumentRecord, actorID int64, actorRole UserRole, actorName string, comment string) (*AuditLog, error) { if doc == nil { return nil, ErrDocumentNotFound } if doc.CurrentState != StateApproving { return nil, fmt.Errorf("%w: 无法在【%s】状态下执行批准通过,当前应为【approving】", ErrInvalidStateTransition, doc.CurrentState) } if actorRole != RoleApprover && actorRole != RoleSystem { return nil, fmt.Errorf("%w: 只有批准人有权执行最终批准", ErrUnauthorizedActor) } if actorRole == RoleApprover && doc.ApproverID > 0 && actorID > 0 && doc.ApproverID != actorID { return nil, fmt.Errorf("%w: 当前批准人 %d 与文档指定批准人 %d 不匹配", ErrUnauthorizedActor, actorID, doc.ApproverID) } fromState := doc.CurrentState toState := StateApproved doc.CurrentState = toState doc.IsLocked = true // 终态受控归档锁定 now := time.Now() doc.ApprovedAt = &now doc.UpdatedAt = now return &AuditLog{ DocID: doc.DocID, Action: "approver_approve", ActorID: actorID, ActorName: actorName, ActorRole: actorRole, FromState: fromState, ToState: toState, Details: map[string]any{ "comment": comment, "timestamp": now, "duty_policy": doc.DutyPolicy, }, CreatedAt: now, }, nil } // TransitionApproverReject 批准驳回: approving -> drafting 或 reviewing // 驳回时必须填写驳回原因,退回目标根据配置和参数决定 func (sm *StateMachine) TransitionApproverReject(doc *DocumentRecord, actorID int64, actorRole UserRole, actorName string, rejectReason string, rollbackTo DocumentState) (*AuditLog, error) { if doc == nil { return nil, ErrDocumentNotFound } if doc.CurrentState != StateApproving { return nil, fmt.Errorf("%w: 无法在【%s】状态下执行批准驳回,当前应为【approving】", ErrInvalidStateTransition, doc.CurrentState) } if actorRole != RoleApprover && actorRole != RoleSystem { return nil, fmt.Errorf("%w: 只有批准人有权执行批准驳回", ErrUnauthorizedActor) } if actorRole == RoleApprover && doc.ApproverID > 0 && actorID > 0 && doc.ApproverID != actorID { return nil, fmt.Errorf("%w: 当前批准人 %d 与文档指定批准人 %d 不匹配", ErrUnauthorizedActor, actorID, doc.ApproverID) } trimmedReason := strings.TrimSpace(rejectReason) if trimmedReason == "" { return nil, ErrRejectReasonRequired } // 确定退回目标阶段 targetState := rollbackTo if targetState == "" { targetState = doc.Config.RejectRollbackTo } if targetState != StateDrafting && targetState != StateReviewing { targetState = StateDrafting } if targetState == StateReviewing && !doc.Config.AllowApproverToReviewer { targetState = StateDrafting } fromState := doc.CurrentState toState := targetState doc.CurrentState = toState if toState == StateDrafting { doc.IsLocked = false // 退回编制阶段解锁 } else { doc.IsLocked = true // 退回审核阶段保持锁定 } now := time.Now() doc.LastRejectedReason = trimmedReason doc.LastRejectedByRole = string(actorRole) doc.LastRejectedByName = actorName doc.LastRejectedAt = &now doc.UpdatedAt = now return &AuditLog{ DocID: doc.DocID, Action: "approver_reject", ActorID: actorID, ActorName: actorName, ActorRole: actorRole, FromState: fromState, ToState: toState, Details: map[string]any{ "reject_reason": trimmedReason, "rollback_to": toState, "timestamp": now, "duty_policy": doc.DutyPolicy, }, CreatedAt: now, }, nil } // TransitionReopenForRevision unlocks an approved document for another draft. // It is not a rejection: the previous approval stays in the audit as a reopen. func (sm *StateMachine) TransitionReopenForRevision(doc *DocumentRecord, actorID int64, actorName, reason string) (*AuditLog, error) { if doc == nil { return nil, ErrDocumentNotFound } if doc.CurrentState != StateApproved { return nil, fmt.Errorf("%w: 只有已批准文档可以再打开编制,当前为【%s】", ErrInvalidStateTransition, doc.CurrentState) } if strings.TrimSpace(reason) == "" { return nil, fmt.Errorf("%w: 再打开必须填写原因", ErrInvalidStateTransition) } from := doc.CurrentState doc.CurrentState = StateDrafting doc.IsLocked = false now := time.Now() doc.UpdatedAt = now return &AuditLog{ DocID: doc.DocID, Action: "reopen_for_revision", ActorID: actorID, ActorName: actorName, ActorRole: RoleSystem, FromState: from, ToState: StateDrafting, Details: map[string]any{"reason": reason, "timestamp": now}, CreatedAt: now, }, nil }