From 4b00a98d4f89e72692c1bb6976ded65fd4f458cd Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 8 Sep 2026 11:48:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(service):=20=E5=AE=9E=E7=8E=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E7=BC=96=E5=88=B6=E5=AE=A1=E6=A0=B8=E6=89=B9=E5=87=86?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E3=80=81=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E4=B8=8E=E8=AE=A4=E9=A2=86=E9=97=AD=E7=8E=AF?= =?UTF-8?q?=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- internal/docgov/engine.go | 739 ++++++++++++++++++++++++++++++ internal/docgov/engine_db_test.go | 99 ++++ internal/docgov/engine_test.go | 251 ++++++++++ internal/docgov/state_machine.go | 282 ++++++++++++ internal/docgov/types.go | 164 +++++++ 5 files changed, 1535 insertions(+) create mode 100644 internal/docgov/engine.go create mode 100644 internal/docgov/engine_db_test.go create mode 100644 internal/docgov/engine_test.go create mode 100644 internal/docgov/state_machine.go create mode 100644 internal/docgov/types.go diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go new file mode 100644 index 00000000..eb80a19a --- /dev/null +++ b/internal/docgov/engine.go @@ -0,0 +1,739 @@ +package docgov + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// ModificationCheckResult 智能体或人员发起修改前的认领与授权判定结果 +type ModificationCheckResult struct { + AllowedDirectEdit bool `json:"allowed_direct_edit"` // 是否允许直接执行修改 + RequiresProposal bool `json:"requires_proposal"` // 是否需要生成变更提案待编制人拍板 + AuthorClaimed bool `json:"author_claimed"` // 编制人是否已认领 + PendingProposal *ChangeProposal `json:"pending_proposal,omitempty"` + Reason string `json:"reason"` +} + +// GovernanceEngine 受控文档编制/审核/批准与认领治理引擎 +type GovernanceEngine struct { + pool *pgxpool.Pool + sm *StateMachine + + mu sync.RWMutex + memDocs map[string]*DocumentRecord + memComms map[string][]*DocumentComment + memProps map[string]*ChangeProposal + memAudits map[string][]*AuditLog +} + +// NewGovernanceEngine 初始化治理引擎 +func NewGovernanceEngine(pool *pgxpool.Pool) *GovernanceEngine { + return &GovernanceEngine{ + pool: pool, + sm: NewStateMachine(), + memDocs: make(map[string]*DocumentRecord), + memComms: make(map[string][]*DocumentComment), + memProps: make(map[string]*ChangeProposal), + memAudits: make(map[string][]*AuditLog), + } +} + +// CreateOrGetDocument 创建或获取受控文档记录 +func (e *GovernanceEngine) CreateOrGetDocument( + ctx context.Context, + docID string, + projectID int64, + taskKey string, + title string, + stage string, + workDir string, + authorID int64, authorName string, + reviewerID int64, reviewerName string, + approverID int64, approverName string, +) (*DocumentRecord, error) { + e.mu.Lock() + defer e.mu.Unlock() + + now := time.Now() + // 先查内存 + if doc, exists := e.memDocs[docID]; exists { + return doc, nil + } + + doc := &DocumentRecord{ + DocID: docID, + ProjectID: projectID, + TaskKey: taskKey, + Title: title, + Stage: stage, + Version: 1, + CurrentState: StateDrafting, + IsLocked: false, + IsTemplateOnly: true, + WorkDir: workDir, + AuthorID: authorID, + AuthorName: authorName, + ReviewerID: reviewerID, + ReviewerName: reviewerName, + ApproverID: approverID, + ApproverName: approverName, + Config: DefaultDocumentConfig(), + CreatedAt: now, + UpdatedAt: now, + } + + // 若有 DB 连接,写 DB 并同步 + if e.pool != nil { + cfgJSON, _ := json.Marshal(doc.Config) + query := ` + INSERT INTO document_records ( + doc_id, project_id, task_key, title, stage, version, + current_state, is_locked, is_template_only, work_dir, + author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name, + config, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (doc_id) DO UPDATE SET + title = EXCLUDED.title, + stage = EXCLUDED.stage, + updated_at = EXCLUDED.updated_at + RETURNING id, current_state, is_locked, is_template_only, author_claimed, reviewer_claimed, approver_claimed; + ` + var id int64 + var currState string + var isLocked, isTmpl, authClaim, revClaim, appClaim bool + err := e.pool.QueryRow(ctx, query, + doc.DocID, doc.ProjectID, doc.TaskKey, doc.Title, doc.Stage, doc.Version, + string(doc.CurrentState), doc.IsLocked, doc.IsTemplateOnly, doc.WorkDir, + doc.AuthorID, doc.AuthorName, doc.ReviewerID, doc.ReviewerName, doc.ApproverID, doc.ApproverName, + cfgJSON, doc.CreatedAt, doc.UpdatedAt, + ).Scan(&id, &currState, &isLocked, &isTmpl, &authClaim, &revClaim, &appClaim) + if err == nil { + doc.ID = id + doc.CurrentState = DocumentState(currState) + doc.IsLocked = isLocked + doc.IsTemplateOnly = isTmpl + doc.AuthorClaimed = authClaim + doc.ReviewerClaimed = revClaim + doc.ApproverClaimed = appClaim + } + } + + e.memDocs[docID] = doc + return doc, nil +} + +// MarkDocumentContentUpdated 当正文生成或更新时标记正文哈希及是否脱离模板状态 +func (e *GovernanceEngine) MarkDocumentContentUpdated(ctx context.Context, docID string, contentSHA256 string, isTemplateOnly bool) error { + e.mu.Lock() + defer e.mu.Unlock() + + doc, exists := e.memDocs[docID] + if !exists { + return ErrDocumentNotFound + } + + doc.ContentSHA256 = contentSHA256 + doc.IsTemplateOnly = isTemplateOnly + doc.UpdatedAt = time.Now() + + if e.pool != nil { + _, _ = e.pool.Exec(ctx, ` + UPDATE document_records + SET content_sha256 = $1, is_template_only = $2, updated_at = $3 + WHERE doc_id = $4 + `, contentSHA256, isTemplateOnly, doc.UpdatedAt, docID) + } + return nil +} + +// RecordDocumentViewed 记录查看文档行为并触发认领机制 +// 核心规则: +// 1. 只有当文档处于【非模板状态】(is_template_only = false) 时,编制负责人查看才标记为 AuthorClaimed = true;模板状态查看不算认领! +// 2. 审核人查看且处于审核阶段,标记 ReviewerClaimed = true +// 3. 批准人查看且处于批准阶段,标记 ApproverClaimed = true +func (e *GovernanceEngine) RecordDocumentViewed(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) { + e.mu.Lock() + defer e.mu.Unlock() + + doc, exists := e.memDocs[docID] + if !exists { + return nil, ErrDocumentNotFound + } + + now := time.Now() + var auditAction string + + switch actorRole { + case RoleAuthor: + doc.AuthorLastViewedAt = &now + auditAction = "author_view" + // 关键认领判定:只限非模板状态 + if !doc.IsTemplateOnly && !doc.AuthorClaimed { + doc.AuthorClaimed = true + doc.AuthorClaimedAt = &now + auditAction = "author_claim_by_view" + } + case RoleReviewer: + doc.ReviewerClaimed = true + doc.ReviewerClaimedAt = &now + auditAction = "reviewer_claim_by_view" + case RoleApprover: + doc.ApproverClaimed = true + doc.ApproverClaimedAt = &now + auditAction = "approver_claim_by_view" + default: + auditAction = "view" + } + + doc.UpdatedAt = now + + // 记录审计日志 + audit := &AuditLog{ + DocID: docID, + Action: auditAction, + ActorID: actorID, + ActorName: actorName, + ActorRole: actorRole, + FromState: doc.CurrentState, + ToState: doc.CurrentState, + Details: map[string]any{ + "is_template_only": doc.IsTemplateOnly, + "author_claimed": doc.AuthorClaimed, + "timestamp": now, + }, + CreatedAt: now, + } + e.memAudits[docID] = append(e.memAudits[docID], audit) + + if e.pool != nil { + _, _ = e.pool.Exec(ctx, ` + UPDATE document_records + SET author_claimed = $1, author_claimed_at = $2, author_last_viewed_at = $3, + reviewer_claimed = $4, reviewer_claimed_at = $5, + approver_claimed = $6, approver_claimed_at = $7, + updated_at = $8 + WHERE doc_id = $9 + `, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastViewedAt, + doc.ReviewerClaimed, doc.ReviewerClaimedAt, + doc.ApproverClaimed, doc.ApproverClaimedAt, + doc.UpdatedAt, docID) + + detJSON, _ := json.Marshal(audit.Details) + _, _ = 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) + } + + return doc, nil +} + +// RecordDocumentEdited 编制人编辑文档 (标记认领与编辑时间) +func (e *GovernanceEngine) RecordDocumentEdited(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) { + e.mu.Lock() + defer e.mu.Unlock() + + doc, exists := e.memDocs[docID] + if !exists { + return nil, ErrDocumentNotFound + } + + if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole); err != nil { + return nil, err + } + + now := time.Now() + doc.AuthorLastEditedAt = &now + doc.AuthorClaimed = true + if doc.AuthorClaimedAt == nil { + doc.AuthorClaimedAt = &now + } + doc.UpdatedAt = now + + audit := &AuditLog{ + DocID: docID, + Action: "author_edit", + ActorID: actorID, + ActorName: actorName, + ActorRole: actorRole, + FromState: doc.CurrentState, + ToState: doc.CurrentState, + Details: map[string]any{"timestamp": now}, + CreatedAt: now, + } + e.memAudits[docID] = append(e.memAudits[docID], audit) + + if e.pool != nil { + _, _ = e.pool.Exec(ctx, ` + UPDATE document_records + SET author_claimed = $1, author_claimed_at = $2, author_last_edited_at = $3, updated_at = $4 + WHERE doc_id = $5 + `, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastEditedAt, doc.UpdatedAt, docID) + + detJSON, _ := json.Marshal(audit.Details) + _, _ = 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) + } + + return doc, nil +} + +// CheckModificationPermission 阶段发起修改编制状态文档前,检查认领与权限 +// 核心逻辑: +// 1. 若文档未认领 (author_claimed == false),系统不用告知负责人,直接放行智能体小队编辑 (AllowedDirectEdit = true) +// 2. 若文档已认领 (author_claimed == true),系统拦截直接编辑,必须通知编制负责人并生成变更提案 (RequiresProposal = true) +func (e *GovernanceEngine) CheckModificationPermission(ctx context.Context, docID string, actorRole UserRole) (*ModificationCheckResult, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + doc, exists := e.memDocs[docID] + if !exists { + return nil, ErrDocumentNotFound + } + + // 1. 检查锁定状态 + if doc.IsLocked { + return &ModificationCheckResult{ + AllowedDirectEdit: false, + RequiresProposal: false, + AuthorClaimed: doc.AuthorClaimed, + Reason: fmt.Sprintf("文档处于【%s】阶段已锁定,禁止任何修改", doc.CurrentState), + }, ErrDocumentLocked + } + + // 2. 检查认领状态 + if !doc.AuthorClaimed { + // 未认领:智能体小队可静默直接修改 + return &ModificationCheckResult{ + AllowedDirectEdit: true, + RequiresProposal: false, + AuthorClaimed: false, + Reason: "文档尚未被编制负责人认领(未查看正式稿),允许智能体小队直接自动编辑", + }, nil + } + + // 3. 已认领:检查是否有待编制人处理的挂起提案 + var pendingProp *ChangeProposal + for _, p := range e.memProps { + if p.DocID == docID && p.Status == ProposalPendingAuthorApproval { + pendingProp = p + break + } + } + + return &ModificationCheckResult{ + AllowedDirectEdit: false, + RequiresProposal: true, + AuthorClaimed: true, + PendingProposal: pendingProp, + Reason: "文档已被编制负责人认领,必须生成变更提案告知修改点、影响与风险,并由编制人确认授权", + }, nil +} + +// 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 { + 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) + _, _ = 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) + } + + 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 { + return nil, ErrProposalNotFound + } + if proposal.Status != ProposalPendingAuthorApproval { + return nil, ErrProposalAlreadyDecided + } + + doc, docExists := e.memDocs[proposal.DocID] + 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 { + _, _ = 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) + + detJSON, _ := json.Marshal(audit.Details) + _, _ = 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) + } + + 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) { + 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 +} + +// ListComments 列出文档的所有批注 +func (e *GovernanceEngine) ListComments(ctx context.Context, docID string) ([]*DocumentComment, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + comms := e.memComms[docID] + res := make([]*DocumentComment, len(comms)) + copy(res, comms) + return res, nil +} + +// SubmitForReview 编制完成提交审核 (drafting -> reviewing) +func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) { + e.mu.Lock() + defer e.mu.Unlock() + + doc, exists := e.memDocs[docID] + if !exists { + return nil, ErrDocumentNotFound + } + + audit, err := e.sm.TransitionSubmitForReview(doc, actorID, actorRole, actorName) + if err != nil { + return nil, err + } + + e.memAudits[docID] = append(e.memAudits[docID], audit) + e.syncDocAndAuditToDB(ctx, doc, audit) + return doc, nil +} + +// ReviewApprove 审核通过 (reviewing -> approving) +func (e *GovernanceEngine) ReviewApprove(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, comment string) (*DocumentRecord, error) { + e.mu.Lock() + defer e.mu.Unlock() + + doc, exists := e.memDocs[docID] + if !exists { + return nil, ErrDocumentNotFound + } + + audit, err := e.sm.TransitionReviewApprove(doc, actorID, actorRole, actorName, comment) + if err != nil { + return nil, err + } + + if strings.TrimSpace(comment) != "" { + _ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentSuggestion, comment) + } + + e.memAudits[docID] = append(e.memAudits[docID], audit) + e.syncDocAndAuditToDB(ctx, doc, audit) + return doc, nil +} + +// ReviewReject 审核驳回 (reviewing -> drafting, 驳回原因必填) +func (e *GovernanceEngine) ReviewReject(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, rejectReason string) (*DocumentRecord, error) { + e.mu.Lock() + defer e.mu.Unlock() + + doc, exists := e.memDocs[docID] + if !exists { + return nil, ErrDocumentNotFound + } + + audit, err := e.sm.TransitionReviewReject(doc, actorID, actorRole, actorName, rejectReason) + if err != nil { + return nil, err + } + + // 驳回原因自动持久化为批注记录 + _ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason) + + e.memAudits[docID] = append(e.memAudits[docID], audit) + e.syncDocAndAuditToDB(ctx, doc, audit) + return doc, nil +} + +// ApproverApprove 批准通过 (approving -> approved, 终态归档锁定) +func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, comment string) (*DocumentRecord, error) { + e.mu.Lock() + defer e.mu.Unlock() + + doc, exists := e.memDocs[docID] + if !exists { + return nil, ErrDocumentNotFound + } + + audit, err := e.sm.TransitionApproverApprove(doc, actorID, actorRole, actorName, comment) + if err != nil { + return nil, err + } + + if strings.TrimSpace(comment) != "" { + _ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentSuggestion, comment) + } + + e.memAudits[docID] = append(e.memAudits[docID], audit) + e.syncDocAndAuditToDB(ctx, doc, audit) + return doc, nil +} + +// ApproverReject 批准驳回 (approving -> drafting 或 reviewing, 驳回原因必填) +func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, rejectReason string, rollbackTo DocumentState) (*DocumentRecord, error) { + e.mu.Lock() + defer e.mu.Unlock() + + doc, exists := e.memDocs[docID] + if !exists { + return nil, ErrDocumentNotFound + } + + audit, err := e.sm.TransitionApproverReject(doc, actorID, actorRole, actorName, rejectReason, rollbackTo) + if err != nil { + return nil, err + } + + _ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason) + + e.memAudits[docID] = append(e.memAudits[docID], audit) + e.syncDocAndAuditToDB(ctx, doc, audit) + return doc, nil +} + +// GetDocument 获取文档实体 +func (e *GovernanceEngine) GetDocument(ctx context.Context, docID string) (*DocumentRecord, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + doc, exists := e.memDocs[docID] + if !exists { + return nil, ErrDocumentNotFound + } + return doc, nil +} + +// ListAuditLogs 获取文档审计日志 +func (e *GovernanceEngine) ListAuditLogs(ctx context.Context, docID string) ([]*AuditLog, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + logs := e.memAudits[docID] + res := make([]*AuditLog, len(logs)) + copy(res, logs) + return res, 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 +} + +func (e *GovernanceEngine) syncDocAndAuditToDB(ctx context.Context, doc *DocumentRecord, audit *AuditLog) { + if e.pool == nil { + return + } + _, _ = e.pool.Exec(ctx, ` + UPDATE document_records + SET current_state = $1, is_locked = $2, + last_rejected_reason = $3, last_rejected_by_role = $4, last_rejected_by_name = $5, last_rejected_at = $6, + approved_at = $7, updated_at = $8 + WHERE doc_id = $9 + `, string(doc.CurrentState), doc.IsLocked, + doc.LastRejectedReason, doc.LastRejectedByRole, doc.LastRejectedByName, doc.LastRejectedAt, + doc.ApprovedAt, doc.UpdatedAt, doc.DocID) + + if audit != nil { + detJSON, _ := json.Marshal(audit.Details) + _, _ = 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) + } +} diff --git a/internal/docgov/engine_db_test.go b/internal/docgov/engine_db_test.go new file mode 100644 index 00000000..2bed3a44 --- /dev/null +++ b/internal/docgov/engine_db_test.go @@ -0,0 +1,99 @@ +package docgov_test + +import ( + "context" + "fmt" + "testing" + "time" + + "chat-agent-system/internal/config" + "chat-agent-system/internal/docgov" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestDocGov_DatabaseIntegration(t *testing.T) { + cfg, err := config.Load(".env") + if err != nil || cfg == nil || cfg.ContextDbHost == "" { + t.Skip("Skipping DB integration test: context DB not configured") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, cfg.ContextDSN()) + if err != nil || pool.Ping(ctx) != nil { + t.Skipf("Skipping DB integration test: cannot connect to %s: %v", cfg.ContextDSN(), err) + } + defer pool.Close() + + engine := docgov.NewGovernanceEngine(pool) + + testDocID := fmt.Sprintf("doc_test_db_%d", time.Now().UnixNano()) + + // 1. 创建受控文档 + doc, err := engine.CreateOrGetDocument( + ctx, + testDocID, + 1, + "feasibility_reg", + "DB集成测试文档", + "V0.5_feasibility", + "data/dev/workspaces/test_db", + 1, "张工", + 2, "李审核", + 3, "王总监", + ) + if err != nil { + t.Fatalf("CreateOrGetDocument in DB failed: %v", err) + } + if doc.DocID != testDocID { + t.Fatalf("unexpected docID: %s", doc.DocID) + } + + // 2. 标记正文非模板并认领 + _ = engine.MarkDocumentContentUpdated(ctx, testDocID, "db_test_sha256", false) + doc, err = engine.RecordDocumentViewed(ctx, testDocID, 1, docgov.RoleAuthor, "张工") + if err != nil { + t.Fatalf("RecordDocumentViewed failed: %v", err) + } + if !doc.AuthorClaimed { + t.Fatalf("expected author_claimed in DB") + } + + // 3. 生成变更提案 + prop, err := engine.ProposeModification( + ctx, + testDocID, + "agent_squad", + "测试修改提要", + "测试影响分析", + "测试潜在风险", + ) + if err != nil { + t.Fatalf("ProposeModification in DB failed: %v", err) + } + + // 4. 拍板同意提案 + prop, err = engine.DecideModificationProposal(ctx, prop.ProposalID, 1, true, "DB测试同意") + if err != nil { + t.Fatalf("DecideModificationProposal in DB failed: %v", err) + } + if prop.Status != docgov.ProposalApprovedByAuthor { + t.Fatalf("expected proposal approved, got %s", prop.Status) + } + + // 5. 批注与送审流程 + _, err = engine.AddComment(ctx, testDocID, 2, "李审核", docgov.RoleReviewer, "## 结论", docgov.CommentSuggestion, "请增加法规出处") + if err != nil { + t.Fatalf("AddComment in DB failed: %v", err) + } + + doc, err = engine.SubmitForReview(ctx, testDocID, 1, docgov.RoleAuthor, "张工") + if err != nil { + t.Fatalf("SubmitForReview in DB failed: %v", err) + } + if !doc.IsLocked { + t.Fatalf("expected doc locked in reviewing") + } +} diff --git a/internal/docgov/engine_test.go b/internal/docgov/engine_test.go new file mode 100644 index 00000000..9fffb2dd --- /dev/null +++ b/internal/docgov/engine_test.go @@ -0,0 +1,251 @@ +package docgov_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/docgov" +) + +func TestDocGov_AuthorPermissionsAndLocking(t *testing.T) { + ctx := context.Background() + engine := docgov.NewGovernanceEngine(nil) + + // 1. 初始化文档:Author=101(张工), Reviewer=102(李主管), Approver=103(王总监) + doc, err := engine.CreateOrGetDocument( + ctx, + "doc_feasibility_001", + 1, + "feasibility_reg", + "4K超高清腹腔镜注册可行性评估报告", + "V0.5_feasibility", + "data/dev/workspaces/bizdev/feasibility_reg_1", + 101, "张工", + 102, "李主管", + 103, "王总监", + ) + if err != nil { + t.Fatalf("CreateOrGetDocument failed: %v", err) + } + + // 2. 只有编制人有权修改正文 + _, err = engine.RecordDocumentEdited(ctx, doc.DocID, 101, docgov.RoleAuthor, "张工") + if err != nil { + t.Fatalf("Author should be allowed to edit in drafting state, got: %v", err) + } + + // 3. 审核人尝试直接修改正文 -> 应当被拒绝 + _, err = engine.RecordDocumentEdited(ctx, doc.DocID, 102, docgov.RoleReviewer, "李主管") + if err != docgov.ErrReviewerCannotEdit { + t.Fatalf("expected ErrReviewerCannotEdit, got: %v", err) + } + + // 4. 批准人尝试直接修改正文 -> 应当被拒绝 + _, err = engine.RecordDocumentEdited(ctx, doc.DocID, 103, docgov.RoleApprover, "王总监") + if err != docgov.ErrApproverCannotEdit { + t.Fatalf("expected ErrApproverCannotEdit, got: %v", err) + } + + // 5. 非指定的其他人尝试以 Author 角色修改 -> 权限拒绝 + _, err = engine.RecordDocumentEdited(ctx, doc.DocID, 999, docgov.RoleAuthor, "非法用户") + if err == nil { + t.Fatalf("expected unauthorized actor error, got nil") + } + + // 6. 送审后进入锁定状态,此时即使 Author 也不能直接修改正文 + _, err = engine.SubmitForReview(ctx, doc.DocID, 101, docgov.RoleAuthor, "张工") + if err != nil { + t.Fatalf("SubmitForReview failed: %v", err) + } + + _, err = engine.RecordDocumentEdited(ctx, doc.DocID, 101, docgov.RoleAuthor, "张工") + if err == nil { + t.Fatalf("expected document locked error when editing during reviewing state, got nil") + } +} + +func TestDocGov_ClaimingMechanismAndProposal(t *testing.T) { + ctx := context.Background() + engine := docgov.NewGovernanceEngine(nil) + + docID := "doc_intake_002" + doc, err := engine.CreateOrGetDocument( + ctx, + docID, + 1, + "intake", + "立项与技术摸底需求表", + "V0.1_triage", + "workspaces/intake_2", + 201, "刘工", + 202, "赵主管", + 203, "孙总监", + ) + if err != nil { + t.Fatalf("CreateOrGetDocument failed: %v", err) + } + + // 1. 初始状态为模板状态 (is_template_only = true) + if !doc.IsTemplateOnly { + t.Fatalf("initial state should be template only") + } + + // 2. 编制人在模板状态下查看文档 -> 不算认领! + doc, err = engine.RecordDocumentViewed(ctx, docID, 201, docgov.RoleAuthor, "刘工") + if err != nil { + t.Fatalf("RecordDocumentViewed failed: %v", err) + } + if doc.AuthorClaimed { + t.Fatalf("author should NOT be claimed when viewing a template-only document") + } + + // 3. 此时系统或阶段发起修改 -> 允许直接编辑 (未认领) + checkRes, err := engine.CheckModificationPermission(ctx, docID, docgov.RoleSystem) + if err != nil { + t.Fatalf("CheckModificationPermission failed: %v", err) + } + if !checkRes.AllowedDirectEdit || checkRes.RequiresProposal { + t.Fatalf("expected AllowedDirectEdit=true and RequiresProposal=false for unclaimed document") + } + + // 4. 智能体小队生成了正式产出正文,脱离模板状态 + err = engine.MarkDocumentContentUpdated(ctx, docID, "sha256_mock_content_12345", false) + if err != nil { + t.Fatalf("MarkDocumentContentUpdated failed: %v", err) + } + + // 5. 编制人查看已生成的正式产出文档 -> 触发认领! + doc, err = engine.RecordDocumentViewed(ctx, docID, 201, docgov.RoleAuthor, "刘工") + if err != nil { + t.Fatalf("RecordDocumentViewed failed: %v", err) + } + if !doc.AuthorClaimed { + t.Fatalf("author MUST be claimed after viewing non-template document") + } + + // 6. 再次发起阶段修改 -> 拦截直接编辑,必须生成变更提案 + checkRes2, err := engine.CheckModificationPermission(ctx, docID, docgov.RoleSystem) + if err != nil { + t.Fatalf("CheckModificationPermission failed: %v", err) + } + if checkRes2.AllowedDirectEdit || !checkRes2.RequiresProposal { + t.Fatalf("expected AllowedDirectEdit=false and RequiresProposal=true for claimed document") + } + + // 7. 生成变更提案 + proposal, err := engine.ProposeModification( + ctx, + docID, + "agent_squad_upstream_sync", + "根据客户最新电话沟通,将目标市场从【国内】追加【欧盟CE】,并增加YY/T1603标准条款", + "影响法规注册路径,需要从单纯二类补充MDR Annex VIII分类与CER评估", + "可能延展型检周期4周,增加CE技术文档编制预算", + ) + if err != nil { + t.Fatalf("ProposeModification failed: %v", err) + } + if proposal.Status != docgov.ProposalPendingAuthorApproval { + t.Fatalf("proposal status should be pending_author_approval, got %s", proposal.Status) + } + + // 8. 编制人查看并拍板同意该变更 + proposal, err = engine.DecideModificationProposal(ctx, proposal.ProposalID, 201, true, "经评估追加CE符合公司Q3出海战略,同意智能体小队自动增补章节") + if err != nil { + t.Fatalf("DecideModificationProposal failed: %v", err) + } + if proposal.Status != docgov.ProposalApprovedByAuthor { + t.Fatalf("expected status approved_by_author, got %s", proposal.Status) + } +} + +func TestDocGov_FullApprovalLifecycleAndRejectRollback(t *testing.T) { + ctx := context.Background() + engine := docgov.NewGovernanceEngine(nil) + + docID := "doc_cdo_quote_003" + doc, err := engine.CreateOrGetDocument( + ctx, + docID, + 1, + "cdo_quote", + "4K腔镜系统CDO工程拆解与商务报价", + "V1.0_quoting", + "workspaces/cdo_quote_3", + 301, "钱工", // 编制人 + 302, "周主管", // 审核人 + 303, "吴总监", // 批准人 + ) + if err != nil { + t.Fatalf("CreateOrGetDocument failed: %v", err) + } + + // 1. 编制人完成正文并送审 + doc, err = engine.SubmitForReview(ctx, docID, 301, docgov.RoleAuthor, "钱工") + if err != nil { + t.Fatalf("SubmitForReview failed: %v", err) + } + if doc.CurrentState != docgov.StateReviewing || !doc.IsLocked { + t.Fatalf("doc state should be reviewing and locked") + } + + // 2. 审核人查阅并添加批注 + comm, err := engine.AddComment(ctx, docID, 302, "周主管", docgov.RoleReviewer, "## BOM预算与工时", docgov.CommentSuggestion, "请复核EMC整改冗余工时是否按照2轮测算") + if err != nil { + t.Fatalf("AddComment failed: %v", err) + } + if comm.Status != "open" { + t.Fatalf("expected comment status open") + } + + // 3. 审核人驳回测试:原因非空校验 + _, err = engine.ReviewReject(ctx, docID, 302, docgov.RoleReviewer, "周主管", " ") + if err != docgov.ErrRejectReasonRequired { + t.Fatalf("expected ErrRejectReasonRequired for empty rejection reason, got: %v", err) + } + + // 审核人提供明确理由驳回 -> 退回编制草稿并解锁 + doc, err = engine.ReviewReject(ctx, docID, 302, docgov.RoleReviewer, "周主管", "型检EMC整改工时偏低,请补齐风险冗余后重提") + if err != nil { + t.Fatalf("ReviewReject failed: %v", err) + } + if doc.CurrentState != docgov.StateDrafting || doc.IsLocked { + t.Fatalf("doc should rollback to drafting and be unlocked, state=%s locked=%v", doc.CurrentState, doc.IsLocked) + } + if doc.LastRejectedReason != "型检EMC整改工时偏低,请补齐风险冗余后重提" { + t.Fatalf("unexpected last rejected reason: %s", doc.LastRejectedReason) + } + + // 4. 编制人修改后重新送审 + _, _ = engine.RecordDocumentEdited(ctx, docID, 301, docgov.RoleAuthor, "钱工") + doc, err = engine.SubmitForReview(ctx, docID, 301, docgov.RoleAuthor, "钱工") + if err != nil { + t.Fatalf("resubmit review failed: %v", err) + } + + // 5. 审核人通过 -> 进入 approving (批准中) + doc, err = engine.ReviewApprove(ctx, docID, 302, docgov.RoleReviewer, "周主管", "复核无误,工时冗余合理,同意提交批准") + if err != nil { + t.Fatalf("ReviewApprove failed: %v", err) + } + if doc.CurrentState != docgov.StateApproving || !doc.IsLocked { + t.Fatalf("expected doc in approving state and locked") + } + + // 6. 批准人查阅批注并最终批准 -> 进入 approved (受控归档) + doc, err = engine.ApproverApprove(ctx, docID, 303, docgov.RoleApprover, "吴总监", "战略预算与报价符合要求,批准受控下发") + if err != nil { + t.Fatalf("ApproverApprove failed: %v", err) + } + if doc.CurrentState != docgov.StateApproved || !doc.IsLocked || doc.ApprovedAt == nil { + t.Fatalf("doc should be approved and locked with approved_at set") + } + + // 7. 验证审计日志完整记录 + logs, err := engine.ListAuditLogs(ctx, docID) + if err != nil { + t.Fatalf("ListAuditLogs failed: %v", err) + } + if len(logs) < 5 { + t.Fatalf("expected at least 5 audit logs, got %d", len(logs)) + } +} diff --git a/internal/docgov/state_machine.go b/internal/docgov/state_machine.go new file mode 100644 index 00000000..d75ac8ef --- /dev/null +++ b/internal/docgov/state_machine.go @@ -0,0 +1,282 @@ +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}, + 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}, + 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, + }, + 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, + }, + 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, + }, + CreatedAt: now, + }, nil +} diff --git a/internal/docgov/types.go b/internal/docgov/types.go new file mode 100644 index 00000000..eebff927 --- /dev/null +++ b/internal/docgov/types.go @@ -0,0 +1,164 @@ +package docgov + +import ( + "errors" + "time" +) + +var ( + ErrDocumentNotFound = errors.New("docgov: document not found") + ErrDocumentLocked = errors.New("docgov: document is locked, direct modification prohibited") + ErrOnlyAuthorCanEdit = errors.New("docgov: only the designated author is allowed to edit or modify document content") + ErrReviewerCannotEdit = errors.New("docgov: reviewers are not allowed to edit document content, only comments and review decisions are permitted") + ErrApproverCannotEdit = errors.New("docgov: approvers are not allowed to edit document content, only comments and approval decisions are permitted") + ErrInvalidStateTransition = errors.New("docgov: invalid document lifecycle state transition") + ErrRejectReasonRequired = errors.New("docgov: rejection reason must not be empty") + ErrUnauthorizedActor = errors.New("docgov: actor is not authorized for this role or action") + ErrProposalNotFound = errors.New("docgov: change proposal not found") + ErrProposalAlreadyDecided = errors.New("docgov: change proposal has already been decided") + ErrChangeProposalPending = errors.New("docgov: document is claimed and has pending change proposal waiting for author approval") +) + +// DocumentState 文档生命周期流转状态 +type DocumentState string + +const ( + StateDrafting DocumentState = "drafting" // 编制中 (草稿) + StateReviewing DocumentState = "reviewing" // 审核中 (待审核, 锁定) + StateApproving DocumentState = "approving" // 批准中 (待批准, 锁定) + StateApproved DocumentState = "approved" // 已批准 (归档生效, 终态锁定) + StateRejected DocumentState = "rejected" // 已驳回 (中间状态或流转标记) +) + +// UserRole 治理责任人角色 (各有且仅有一个责任人) +type UserRole string + +const ( + RoleAuthor UserRole = "author" // 编制人 + RoleReviewer UserRole = "reviewer" // 审核人 + RoleApprover UserRole = "approver" // 批准人 + RoleSystem UserRole = "system" // 系统/智能体小队 +) + +// DocumentConfig 受控文档流转与退回配置 +type DocumentConfig struct { + RejectRollbackTo DocumentState `json:"reject_rollback_to"` // 驳回退回目标: drafting 或 reviewing + AllowApproverToReviewer bool `json:"allow_approver_to_reviewer"` // 是否允许批准人退回至审核阶段 + RequireAuthorConfirmOnReject bool `json:"require_author_confirm_on_reject"` // 驳回退回编制时是否需编制人确认已获悉 +} + +func DefaultDocumentConfig() DocumentConfig { + return DocumentConfig{ + RejectRollbackTo: StateDrafting, + AllowApproverToReviewer: true, + RequireAuthorConfirmOnReject: true, + } +} + +// DocumentRecord 受控文档治理台账实体 +type DocumentRecord struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` // 业务唯一ID,如 doc_proj1_feasibility_reg_v1 + ProjectID int64 `json:"project_id"` // 项目ID + TaskKey string `json:"task_key"` // 任务标识: feasibility_reg, intake, cdo_quote + Title string `json:"title"` // 标题 + Stage string `json:"stage"` // 阶段: V0.5_feasibility + Version int `json:"version"` // 版本号 + CurrentState DocumentState `json:"current_state"` // drafting, reviewing, approving, approved + IsLocked bool `json:"is_locked"` // reviewing/approving/approved 期间锁定 + IsTemplateOnly bool `json:"is_template_only"` // 是否仅为骨架模板 (模板状态即使查看也不算认领) + ContentSHA256 string `json:"content_sha256"` // 当前版本正文哈希 + WorkDir string `json:"work_dir"` // 关联工作区相对路径 + + // 三级法定责任人 (各有且仅有一个) + AuthorID int64 `json:"author_id"` + AuthorName string `json:"author_name"` + ReviewerID int64 `json:"reviewer_id"` + ReviewerName string `json:"reviewer_name"` + ApproverID int64 `json:"approver_id"` + ApproverName string `json:"approver_name"` + + // 认领状态 (Claiming State) + AuthorClaimed bool `json:"author_claimed"` // 编制人是否已认领 (非模板状态查看/编辑后为 true) + AuthorClaimedAt *time.Time `json:"author_claimed_at"` + AuthorLastViewedAt *time.Time `json:"author_last_viewed_at"` + AuthorLastEditedAt *time.Time `json:"author_last_edited_at"` + ReviewerClaimed bool `json:"reviewer_claimed"` // 审核人是否已认领审核 + ReviewerClaimedAt *time.Time `json:"reviewer_claimed_at"` + ApproverClaimed bool `json:"approver_claimed"` // 批准人是否已认领批准 + ApproverClaimedAt *time.Time `json:"approver_claimed_at"` + + // 驳回与决议记录 + LastRejectedReason string `json:"last_rejected_reason"` + LastRejectedByRole string `json:"last_rejected_by_role"` + LastRejectedByName string `json:"last_rejected_by_name"` + LastRejectedAt *time.Time `json:"last_rejected_at"` + ApprovedAt *time.Time `json:"approved_at"` + + Config DocumentConfig `json:"config"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CommentType 批注类型 +type CommentType string + +const ( + CommentGeneral CommentType = "comment" // 普通批注 + CommentSuggestion CommentType = "suggestion" // 修改建议 + CommentRejection CommentType = "reject_reason" // 驳回原因 +) + +// DocumentComment 批注与修改意见实体 +type DocumentComment struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + UserID int64 `json:"user_id"` + UserName string `json:"user_name"` + UserRole UserRole `json:"user_role"` // author, reviewer, approver + TargetSection string `json:"target_section"` // 目标章节锚点,如 "## 结论" + CommentType CommentType `json:"comment_type"` // comment, suggestion, reject_reason + Content string `json:"content"` // 意见内容 + Status string `json:"status"` // open, resolved, accepted + CreatedAt time.Time `json:"created_at"` +} + +// ProposalStatus 变更提案状态 +type ProposalStatus string + +const ( + ProposalPendingAuthorApproval ProposalStatus = "pending_author_approval" // 待编制人拍板 + ProposalApprovedByAuthor ProposalStatus = "approved_by_author" // 编制人已同意修改 + ProposalRejectedByAuthor ProposalStatus = "rejected_by_author" // 编制人已拒绝修改 + ProposalApplied ProposalStatus = "applied" // 已应用执行 +) + +// ChangeProposal 针对已认领文档的智能体修改变更提案实体 +type ChangeProposal struct { + ID int64 `json:"id"` + ProposalID string `json:"proposal_id"` + DocID string `json:"doc_id"` + ProjectID int64 `json:"project_id"` + TriggeredBy string `json:"triggered_by"` // agent_squad, stage_sync, upstream_change + ProposedChanges string `json:"proposed_changes"` // 有哪些地方要改 (变更内容与对比摘要) + ImpactAnalysis string `json:"impact_analysis"` // 影响会是什么 (范围、上下游依赖) + RiskAssessment string `json:"risk_assessment"` // 可能产生的风险 (合规、工时、预算) + Status ProposalStatus `json:"status"` // pending_author_approval... + AuthorDecisionReason string `json:"author_decision_reason"` // 编制人批准/拒绝原因 + DecidedAt *time.Time `json:"decided_at"` + CreatedAt time.Time `json:"created_at"` +} + +// AuditLog 文档治理生命周期审计跟踪 +type AuditLog struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + Action string `json:"action"` // view, edit, claim, submit_review, review_approve, review_reject... + ActorID int64 `json:"actor_id"` + ActorName string `json:"actor_name"` + ActorRole UserRole `json:"actor_role"` + FromState DocumentState `json:"from_state"` + ToState DocumentState `json:"to_state"` + Details map[string]any `json:"details"` + CreatedAt time.Time `json:"created_at"` +}