feat(service): 实现文档编制审核批准生命周期、权限控制与认领闭环引擎

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:19 +00:00
committed by weichen
co-authored by Cursor
parent 6c875ffbeb
commit 4b00a98d4f
5 changed files with 1535 additions and 0 deletions
+739
View File
@@ -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)
}
}
+99
View File
@@ -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")
}
}
+251
View File
@@ -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))
}
}
+282
View File
@@ -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
}
+164
View File
@@ -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"`
}