per-doc 锁只串行同一文档,不同 docID 仍裸读写 memDocs 等 map。GetDocument 与另一文档写入撞上时运行时 fatal,代理把全部 API 变成 500。
223 lines
6.7 KiB
Go
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
|
|
}
|