feat(service): 实现文档编制审核批准生命周期、权限控制与认领闭环引擎
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user