Surface the document duty mode on submit/review/approve transitions for consistent logs under author_only. Co-authored-by: Cursor <cursoragent@cursor.com>
286 lines
9.6 KiB
Go
286 lines
9.6 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
|
|
}
|