Files
weichenandCursor 4a7b8cc26f feat(workflow): reopen a completed stage without pausing the later one
Selected documents return to drafting, and advance asks the creator to acknowledge an open recheck mark.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 15:35:07 +00:00

310 lines
11 KiB
Go

package docgov
import (
"fmt"
"strings"
"time"
)
// StateMachine 文档编制/审核/批准生命周期状态机与权限守卫
type StateMachine struct{}
func NewStateMachine() *StateMachine {
return &StateMachine{}
}
// CanEditDocumentContent 权限守卫 1:校验当前操作人是否有权修改文档正文
// 规则:
// 1. 文档处于锁定状态 (reviewing, approving, approved) 时严禁直接修改
// 2. 只有编制负责人 (Author) 有权修改文档正文;审核人和批准人严禁直接修改
func (sm *StateMachine) CanEditDocumentContent(doc *DocumentRecord, actorID int64, actorRole UserRole) 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 RoleAuthor:
if doc.AuthorID > 0 && actorID > 0 && doc.AuthorID != actorID {
return fmt.Errorf("%w: 文档编制负责人为用户ID %d,非操作人 %d", ErrOnlyAuthorCanEdit, doc.AuthorID, actorID)
}
return nil
case RoleSystem:
// 系统或智能体代为编辑:在编制中且未锁定阶段放行 (认领检查在外部业务层联动)
return nil
default:
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
}