Files
Agent-System/internal/docgov/engine_review_flow.go
T
weichen a90cce73b9 fix(docgov): 保护共享内存 map,避免并发读写把进程打崩
per-doc 锁只串行同一文档,不同 docID 仍裸读写 memDocs 等 map。GetDocument 与另一文档写入撞上时运行时 fatal,代理把全部 API 变成 500。
2026-09-28 08:52:44 +00:00

223 lines
6.7 KiB
Go

package docgov
import (
"context"
"fmt"
"strings"
"time"
"chat-agent-system/internal/app/uow"
)
func (e *GovernanceEngine) emitLifecycle(ctx context.Context, action string, doc *DocumentRecord, actorID int64, actorName, detail string) {
if e == nil || doc == nil {
return
}
e.mu.Lock()
hook := e.onLifecycleNotify
e.mu.Unlock()
if hook != nil {
hook(ctx, action, doc, actorID, actorName, detail)
}
}
// SubmitForReview 编制完成提交审核 (drafting -> reviewing)
func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
g := e.acquireDocLock(docID)
defer g.Release()
doc, err := e.ensureDocLockedWithDB(ctx, docID)
if err != nil {
return nil, err
}
if doc.HasActiveLease(time.Now()) {
return nil, &DocumentBusyError{
DocID: doc.DocID,
HolderType: doc.LeaseHolderType,
HolderID: doc.LeaseHolderID,
WorkDir: doc.LeaseWorkDir,
ExpiresAt: *doc.LeaseExpiresAt,
Reason: "文档编辑租约占用中,暂不可送审",
}
}
audit, err := e.sm.TransitionSubmitForReview(doc, actorID, actorRole, actorName)
if err != nil {
return nil, err
}
e.memAuditsAppend(docID, audit)
if err := e.freezeReviewSnapshotLocked(ctx, doc); err != nil {
return nil, err
}
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err
}
g.Release()
e.emitLifecycle(ctx, "submit_review", doc, actorID, actorName, "")
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) {
g := e.acquireDocLock(docID)
defer g.Release()
doc, err := e.ensureDocLockedWithDB(ctx, docID)
if err != nil {
return nil, err
}
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.memAuditsAppend(docID, audit)
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err
}
g.Release()
e.emitLifecycle(ctx, "review_approve", doc, actorID, actorName, comment)
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) {
g := e.acquireDocLock(docID)
defer g.Release()
doc, err := e.ensureDocLockedWithDB(ctx, docID)
if err != nil {
return nil, err
}
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.memAuditsAppend(docID, audit)
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err
}
e.mu.RLock()
pendingNotify := e.onPendingDigest
e.mu.RUnlock()
g.Release()
e.DigestPendingOnReturnToDrafting(ctx, doc, actorID, pendingNotify)
e.emitLifecycle(ctx, "review_reject", doc, actorID, actorName, rejectReason)
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) {
g := e.acquireDocLock(docID)
defer g.Release()
doc, err := e.ensureDocLockedWithDB(ctx, docID)
if err != nil {
return nil, err
}
if doc.ReviewSnapshotID <= 0 {
return nil, fmt.Errorf("docgov: approved requires frozen review_snapshot_id")
}
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.memAuditsAppend(docID, audit)
if err := e.persistApproveAndFAV(ctx, doc, audit); err != nil {
return nil, err
}
if _, joined := uow.ScopeFrom(ctx); !joined {
g.Release()
e.runApprovedHooks(ctx, doc)
if e.OnFinalArtifact != nil && (e.pool == nil || e.OnFinalArtifactTX == nil) {
md, _ := e.LedgerMarkdown(ctx, doc.ReviewSnapshotID)
e.OnFinalArtifact(ctx, doc, []byte(md), doc.ReviewSnapshotID)
}
e.emitLifecycle(ctx, "approver_approve", doc, actorID, actorName, comment)
g = e.acquireDocLock(docID)
defer g.Release()
} else {
// Same-tx approve: defer lifecycle until after commit via caller; still emit here for inbox.
g.Release()
e.emitLifecycle(ctx, "approver_approve", doc, actorID, actorName, comment)
}
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) {
g := e.acquireDocLock(docID)
defer g.Release()
doc, err := e.ensureDocLockedWithDB(ctx, docID)
if err != nil {
return nil, err
}
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.memAuditsAppend(docID, audit)
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err
}
e.mu.RLock()
pendingNotify := e.onPendingDigest
e.mu.RUnlock()
g.Release()
if doc.CurrentState == StateDrafting {
e.DigestPendingOnReturnToDrafting(ctx, doc, actorID, pendingNotify)
}
e.emitLifecycle(ctx, "approver_reject", doc, actorID, actorName, rejectReason)
return doc, nil
}
// ReopenForRevision moves an approved document back to drafting without recording a rejection.
func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string, actorID int64, actorName, reason string) (*DocumentRecord, error) {
g := e.acquireDocLock(docID)
defer g.Release()
doc, err := e.ensureDocLockedWithDB(ctx, docID)
if err != nil {
return nil, err
}
audit, err := e.sm.TransitionReopenForRevision(doc, actorID, actorName, reason)
if err != nil {
return nil, err
}
e.memAuditsAppend(docID, audit)
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err
}
e.mu.RLock()
pendingNotify := e.onPendingDigest
e.mu.RUnlock()
g.Release()
e.DigestPendingOnReturnToDrafting(ctx, doc, actorID, pendingNotify)
e.emitLifecycle(ctx, "reopen", doc, actorID, actorName, reason)
return doc, nil
}