feat(docgov): complete review lifecycle gaps for lock/notify/comment/reopen
Defer locked-period supplements to a pending ledger, route duty-holder notifications, persist stage-scoped comments, and prefer Approver for reopen.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/events"
|
||||
"chat-agent-system/internal/notification"
|
||||
|
||||
@@ -44,16 +45,21 @@ func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx
|
||||
}
|
||||
var recipient int64
|
||||
projectID := lease.ProjectID
|
||||
var doc *docgov.DocumentRecord
|
||||
if c.Docs != nil && docID != "" {
|
||||
doc, err := c.Docs.GetDocument(ctx, docID)
|
||||
if err != nil || doc == nil {
|
||||
d, err := c.Docs.GetDocument(ctx, docID)
|
||||
if err != nil || d == nil {
|
||||
return events.ErrQuarantine
|
||||
}
|
||||
if doc.ProjectID != lease.ProjectID {
|
||||
if d.ProjectID != lease.ProjectID {
|
||||
return events.ErrQuarantine
|
||||
}
|
||||
doc = d
|
||||
projectID = d.ProjectID
|
||||
}
|
||||
recipient = RecipientForDocEvent(doc, lease.EventName)
|
||||
if recipient <= 0 && doc != nil {
|
||||
recipient = doc.AuthorID
|
||||
projectID = doc.ProjectID
|
||||
}
|
||||
if recipient <= 0 {
|
||||
return events.ErrQuarantine
|
||||
@@ -89,6 +95,14 @@ func notifyCopy(eventName string) (kind, title, body string) {
|
||||
return notification.TypeDocProposalRequired, "文档变更待确认", "已认领文档有变更提案,请确认后由您发起修改。"
|
||||
case events.EventDocUnclaimedEdit:
|
||||
return notification.TypeDocUnclaimedEditApplied, "文档已更新", "未认领文档已被直接更新(无需您确认)。"
|
||||
case notification.EventDocReviewRequested:
|
||||
return notification.TypeDocReviewRequested, "文档待审核", "有文档已送审,请审核。"
|
||||
case notification.EventDocApprovalRequested:
|
||||
return notification.TypeDocApprovalRequested, "文档待批准", "审核已通过,请批准。"
|
||||
case notification.EventDocRejected:
|
||||
return notification.TypeDocRejected, "文档已驳回", "文档已被驳回,请查看原因并修订。"
|
||||
case notification.EventDocReopened:
|
||||
return notification.TypeDocReopened, "文档已重开", "文档已重开修订,请继续编制。"
|
||||
default:
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package docnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/notification"
|
||||
"chat-agent-system/internal/stream"
|
||||
)
|
||||
|
||||
// LifecycleAction identifies a governance transition for routing.
|
||||
type LifecycleAction string
|
||||
|
||||
const (
|
||||
LifecycleSubmitReview LifecycleAction = "submit_review"
|
||||
LifecycleReviewApprove LifecycleAction = "review_approve"
|
||||
LifecycleReviewReject LifecycleAction = "review_reject"
|
||||
LifecycleApproverApprove LifecycleAction = "approver_approve"
|
||||
LifecycleApproverReject LifecycleAction = "approver_reject"
|
||||
LifecycleReopen LifecycleAction = "reopen"
|
||||
)
|
||||
|
||||
// RecipientForDocEvent resolves the duty-holder inbox recipient for a document event.
|
||||
func RecipientForDocEvent(doc *docgov.DocumentRecord, eventType string) int64 {
|
||||
if doc == nil {
|
||||
return 0
|
||||
}
|
||||
switch eventType {
|
||||
case notification.EventDocReviewRequested:
|
||||
if doc.ReviewerID > 0 {
|
||||
return doc.ReviewerID
|
||||
}
|
||||
case notification.EventDocApprovalRequested:
|
||||
if doc.ApproverID > 0 {
|
||||
return doc.ApproverID
|
||||
}
|
||||
case notification.EventDocRejected, notification.EventDocReopened, notification.EventDocPendingSupplements:
|
||||
if doc.AuthorID > 0 {
|
||||
return doc.AuthorID
|
||||
}
|
||||
case notification.EventDocApproved:
|
||||
if doc.AuthorID > 0 {
|
||||
return doc.AuthorID
|
||||
}
|
||||
}
|
||||
if doc.AuthorID > 0 {
|
||||
return doc.AuthorID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// EmitLifecycleParams carries optional chat/SSE side channels.
|
||||
type EmitLifecycleParams struct {
|
||||
Notify notificationEmitter
|
||||
Digest digestUpserter
|
||||
Chat *ctxmgr.ContextManager
|
||||
Streamer *stream.Streamer
|
||||
Action LifecycleAction
|
||||
Doc *docgov.DocumentRecord
|
||||
ActorID int64
|
||||
ActorName string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type digestUpserter interface {
|
||||
UpsertDocumentDigest(ctx context.Context, p notification.UpsertDigestParams) error
|
||||
}
|
||||
|
||||
// EmitLifecycle notifies the duty holder for a governance transition.
|
||||
func EmitLifecycle(ctx context.Context, p EmitLifecycleParams) {
|
||||
if p.Doc == nil || p.Notify == nil || !p.Notify.Available() {
|
||||
return
|
||||
}
|
||||
eventType, notifType, title, body, severity := lifecycleCopy(p.Action, p.Doc, p.ActorName, p.Detail)
|
||||
if eventType == "" {
|
||||
return
|
||||
}
|
||||
recipient := RecipientForDocEvent(p.Doc, eventType)
|
||||
if recipient <= 0 {
|
||||
return
|
||||
}
|
||||
payload := notification.DocumentEventPayload{
|
||||
ProjectID: p.Doc.ProjectID, DocID: p.Doc.DocID, DocTitle: p.Doc.Title,
|
||||
Summary: truncateLifecycle(p.Detail, 80),
|
||||
}
|
||||
dedupe := fmt.Sprintf("doc_lifecycle:%s:%s:%d:%s", p.Action, p.Doc.DocID, recipient, p.Doc.CurrentState)
|
||||
_, _ = p.Notify.EmitDocumentEvent(ctx, eventType, notifType, title, body, dedupe, recipient, payload)
|
||||
|
||||
if p.Digest != nil && severity == notification.SeverityActionRequired {
|
||||
kind := digestKindForEvent(eventType)
|
||||
if kind != "" {
|
||||
_ = p.Digest.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
|
||||
UserID: recipient, ProjectID: p.Doc.ProjectID, DigestKind: kind,
|
||||
DocID: p.Doc.DocID, DocTitle: p.Doc.Title, Severity: severity,
|
||||
ActorID: p.ActorID, ActorName: p.ActorName, Summary: truncateLifecycle(p.Detail, 60),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if p.Chat != nil {
|
||||
_ = PostProjectNotice(ctx, p.Chat, p.Doc.ProjectID, recipient, body, map[string]any{
|
||||
"doc_id": p.Doc.DocID, "action": string(p.Action), "event": eventType,
|
||||
})
|
||||
}
|
||||
|
||||
if p.Streamer != nil {
|
||||
p.Streamer.EmitGovernance(stream.GovernancePayload{
|
||||
DocID: p.Doc.DocID, Action: string(p.Action), CurrentState: string(p.Doc.CurrentState),
|
||||
IsLocked: p.Doc.IsLocked, AuthorClaimed: p.Doc.AuthorClaimed,
|
||||
NotificationTitle: title, NotificationMsg: body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func digestKindForEvent(eventType string) string {
|
||||
switch eventType {
|
||||
case notification.EventDocReviewRequested:
|
||||
return notification.DigestKindReviewRequested
|
||||
case notification.EventDocApprovalRequested:
|
||||
return notification.DigestKindApprovalRequested
|
||||
case notification.EventDocPendingSupplements:
|
||||
return notification.DigestKindPendingSupplements
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func lifecycleCopy(action LifecycleAction, doc *docgov.DocumentRecord, actorName, detail string) (eventType, notifType, title, body, severity string) {
|
||||
titleDoc := strings.TrimSpace(doc.Title)
|
||||
if titleDoc == "" {
|
||||
titleDoc = doc.DocID
|
||||
}
|
||||
actor := strings.TrimSpace(actorName)
|
||||
if actor == "" {
|
||||
actor = "成员"
|
||||
}
|
||||
reason := strings.TrimSpace(detail)
|
||||
switch action {
|
||||
case LifecycleSubmitReview:
|
||||
return notification.EventDocReviewRequested, notification.TypeDocReviewRequested,
|
||||
fmt.Sprintf("《%s》待您审核", titleDoc),
|
||||
fmt.Sprintf("%s 已送审,请审核本文档。", actor),
|
||||
notification.SeverityActionRequired
|
||||
case LifecycleReviewApprove:
|
||||
return notification.EventDocApprovalRequested, notification.TypeDocApprovalRequested,
|
||||
fmt.Sprintf("《%s》待您批准", titleDoc),
|
||||
fmt.Sprintf("%s 审核已通过,请批准本文档。", actor),
|
||||
notification.SeverityActionRequired
|
||||
case LifecycleReviewReject, LifecycleApproverReject:
|
||||
msg := fmt.Sprintf("%s 驳回了《%s》。", actor, titleDoc)
|
||||
if reason != "" {
|
||||
msg += "原因:" + reason
|
||||
}
|
||||
return notification.EventDocRejected, notification.TypeDocRejected,
|
||||
fmt.Sprintf("《%s》已被驳回", titleDoc), msg,
|
||||
notification.SeverityActionRequired
|
||||
case LifecycleApproverApprove:
|
||||
return notification.EventDocApproved, notification.TypeDocApproved,
|
||||
fmt.Sprintf("《%s》已批准归档", titleDoc),
|
||||
fmt.Sprintf("%s 已终审批准,《%s》已归档锁定。", actor, titleDoc),
|
||||
notification.SeverityInfo
|
||||
case LifecycleReopen:
|
||||
return notification.EventDocReopened, notification.TypeDocReopened,
|
||||
fmt.Sprintf("《%s》已重开修订", titleDoc),
|
||||
fmt.Sprintf("%s 批准重开修订,《%s》已回编制期。", actor, titleDoc),
|
||||
notification.SeverityActionRequired
|
||||
default:
|
||||
return "", "", "", "", ""
|
||||
}
|
||||
}
|
||||
|
||||
func truncateLifecycle(s string, n int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) <= n {
|
||||
return string(r)
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package docnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/notification"
|
||||
)
|
||||
|
||||
func TestRecipientForDocEvent(t *testing.T) {
|
||||
doc := &docgov.DocumentRecord{AuthorID: 1, ReviewerID: 2, ApproverID: 3}
|
||||
cases := []struct {
|
||||
event string
|
||||
want int64
|
||||
}{
|
||||
{notification.EventDocReviewRequested, 2},
|
||||
{notification.EventDocApprovalRequested, 3},
|
||||
{notification.EventDocRejected, 1},
|
||||
{notification.EventDocReopened, 1},
|
||||
{notification.EventDocApproved, 1},
|
||||
{notification.EventDocAgentCandidateReady, 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := RecipientForDocEvent(doc, tc.event); got != tc.want {
|
||||
t.Fatalf("%s: got %d want %d", tc.event, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReviewRequestedRoutesToReviewer(t *testing.T) {
|
||||
n := &stubNotify{available: true}
|
||||
sub := &Subscriber{
|
||||
Notify: n,
|
||||
Docs: stubDocs{doc: &docgov.DocumentRecord{
|
||||
AuthorID: 1, ReviewerID: 7, ApproverID: 9, ProjectID: 4,
|
||||
}},
|
||||
}
|
||||
if err := sub.Handle(context.Background(), notification.EventDocReviewRequested, map[string]any{
|
||||
"doc_id": "d1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.calls != 1 || n.lastType != notification.EventDocReviewRequested {
|
||||
t.Fatalf("calls=%d type=%s", n.calls, n.lastType)
|
||||
}
|
||||
}
|
||||
@@ -54,13 +54,17 @@ func (s *Subscriber) Handle(ctx context.Context, eventType string, payload map[s
|
||||
docID, _ := payload["doc_id"].(string)
|
||||
projectID, _ := payload["project_id"].(int64)
|
||||
risk, _ := payload["risk"].(string)
|
||||
var recipient int64
|
||||
var doc *docgov.DocumentRecord
|
||||
if s.Docs != nil && docID != "" {
|
||||
if doc, err := s.Docs.GetDocument(ctx, docID); err == nil && doc != nil && doc.AuthorID > 0 {
|
||||
recipient = doc.AuthorID
|
||||
projectID = doc.ProjectID
|
||||
if d, err := s.Docs.GetDocument(ctx, docID); err == nil && d != nil {
|
||||
doc = d
|
||||
projectID = d.ProjectID
|
||||
}
|
||||
}
|
||||
recipient := RecipientForDocEvent(doc, eventType)
|
||||
if recipient == 0 && doc != nil {
|
||||
recipient = doc.AuthorID
|
||||
}
|
||||
if recipient == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -102,6 +106,26 @@ func (s *Subscriber) Handle(ctx context.Context, eventType string, payload map[s
|
||||
"变更报告未完整评估", "变更报告降级,受控文档暂不可送审。",
|
||||
fmt.Sprintf("doc_report_degraded:%s:%d:%d", docID, p.ReportID, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocReviewRequested:
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocReviewRequested,
|
||||
"文档待审核", "有文档已送审,请审核。",
|
||||
fmt.Sprintf("doc_review_requested:%s:%d", docID, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocApprovalRequested:
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocApprovalRequested,
|
||||
"文档待批准", "审核已通过,请批准。",
|
||||
fmt.Sprintf("doc_approval_requested:%s:%d", docID, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocRejected:
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocRejected,
|
||||
"文档已驳回", "文档已被驳回,请查看原因并修订。",
|
||||
fmt.Sprintf("doc_rejected:%s:%d", docID, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocReopened:
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocReopened,
|
||||
"文档已重开", "文档已重开修订,请继续编制。",
|
||||
fmt.Sprintf("doc_reopened:%s:%d", docID, recipient), recipient, p)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -47,9 +47,13 @@ func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, do
|
||||
if t.gov == nil {
|
||||
return nil
|
||||
}
|
||||
actorName := "reopen"
|
||||
if actorID > 0 {
|
||||
actorName = fmt.Sprintf("user:%d", actorID)
|
||||
}
|
||||
for _, key := range docTaskKeys {
|
||||
docID := fmt.Sprintf("doc_proj%d_%s", projectID, key)
|
||||
_, err := t.gov.ReopenForRevision(ctx, docID, actorID, "creator", reason)
|
||||
_, err := t.gov.ReopenForRevision(ctx, docID, actorID, actorName, reason)
|
||||
if err == nil || errors.Is(err, vocabulary.ErrDocumentNotFound) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/docnotify"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/notification"
|
||||
"chat-agent-system/internal/supplementimpact"
|
||||
)
|
||||
|
||||
@@ -23,11 +26,37 @@ func (r *Runtime) wireSupplementImpact() {
|
||||
Analyze: r.Trace,
|
||||
Notify: r.Notify,
|
||||
Propose: r.Gov,
|
||||
Pending: r.Gov,
|
||||
}
|
||||
r.SupplementImpact = svc
|
||||
if r.Biz != nil && r.Notify != nil {
|
||||
r.Biz.SetDigestNotifier(r.Notify)
|
||||
}
|
||||
if r.Gov != nil && r.Notify != nil {
|
||||
r.Gov.SetPendingDigestNotify(func(ctx context.Context, doc *docgov.DocumentRecord, pending []docgov.PendingSupplement) {
|
||||
if doc == nil || doc.AuthorID <= 0 || len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
summary := pending[0].SourceSummary
|
||||
if summary == "" {
|
||||
summary = fmt.Sprintf("%d 条积压补充", len(pending))
|
||||
}
|
||||
_ = r.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
|
||||
UserID: doc.AuthorID, ProjectID: doc.ProjectID,
|
||||
DigestKind: notification.DigestKindPendingSupplements,
|
||||
DocID: doc.DocID, DocTitle: doc.Title,
|
||||
Severity: notification.SeverityActionRequired,
|
||||
Summary: summary,
|
||||
})
|
||||
})
|
||||
r.Gov.SetLifecycleNotify(func(ctx context.Context, action string, doc *docgov.DocumentRecord, actorID int64, actorName, detail string) {
|
||||
docnotify.EmitLifecycle(ctx, docnotify.EmitLifecycleParams{
|
||||
Notify: r.Notify, Digest: r.Notify, Chat: r.Ctx,
|
||||
Action: docnotify.LifecycleAction(action), Doc: doc,
|
||||
ActorID: actorID, ActorName: actorName, Detail: detail,
|
||||
})
|
||||
})
|
||||
}
|
||||
if r.Engine != nil {
|
||||
r.Engine.SetOnSupplement(func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) {
|
||||
b, err := svc.CreateFromSupplement(ctx, projectID, userID, sessionID, summary, markdown)
|
||||
@@ -56,7 +85,7 @@ func batchToCard(b *supplementimpact.Batch) *intent.InteractiveCardPayload {
|
||||
}
|
||||
body := summary + "\n" + fmt.Sprintf("batch_id=%s\nnonce=%s\nrevision=%d\ncoverage=%s", b.BatchID, b.DecisionNonce, b.Revision, b.CoverageStatus)
|
||||
for _, it := range b.Items {
|
||||
body += fmt.Sprintf("\n- %s | claimed=%v busy=%v | %s", it.DocID, it.AuthorClaimedSnapshot, it.BusySnapshot, it.Title)
|
||||
body += fmt.Sprintf("\n- %s | claimed=%v busy=%v locked=%v | %s", it.DocID, it.AuthorClaimedSnapshot, it.BusySnapshot, it.LockedSnapshot, it.Title)
|
||||
}
|
||||
return &intent.InteractiveCardPayload{
|
||||
CardID: b.BatchID,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package docgov_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
)
|
||||
|
||||
func TestAddCommentStageMatrix(t *testing.T) {
|
||||
eng := docgov.NewGovernanceEngine(nil)
|
||||
doc, err := eng.CreateOrGetDocument(context.Background(), "doc_cmt_matrix", 1, "feasibility_reg", "可行性", "V0.5", "", 1, "A", 2, "R", 3, "P")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng.AddComment(context.Background(), doc.DocID, 1, "A", docgov.RoleAuthor, "s1", docgov.CommentSuggestion, "作者意见"); err != nil {
|
||||
t.Fatalf("author drafting: %v", err)
|
||||
}
|
||||
if _, err := eng.AddComment(context.Background(), doc.DocID, 9, "V", docgov.UserRole("viewer"), "s1", docgov.CommentSuggestion, "观者"); err == nil {
|
||||
t.Fatal("viewer should be forbidden in drafting")
|
||||
}
|
||||
if _, err := eng.SubmitForReview(context.Background(), doc.DocID, 1, docgov.RoleAuthor, "A"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng.AddComment(context.Background(), doc.DocID, 3, "P", docgov.RoleApprover, "s1", docgov.CommentSuggestion, "批准人"); err == nil {
|
||||
t.Fatal("approver should be forbidden in reviewing")
|
||||
}
|
||||
if _, err := eng.AddComment(context.Background(), doc.DocID, 1, "A", docgov.RoleAuthor, "s1", docgov.CommentSuggestion, "回应"); err != nil {
|
||||
t.Fatalf("author reviewing: %v", err)
|
||||
}
|
||||
if _, err := eng.ReviewApprove(context.Background(), doc.DocID, 2, docgov.RoleReviewer, "R", "ok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng.AddComment(context.Background(), doc.DocID, 1, "A", docgov.RoleAuthor, "s1", docgov.CommentSuggestion, "作者"); err == nil {
|
||||
t.Fatal("author should be forbidden in approving")
|
||||
}
|
||||
if _, err := eng.AddComment(context.Background(), doc.DocID, 3, "P", docgov.RoleApprover, "s1", docgov.CommentSuggestion, "批准意见"); err != nil {
|
||||
t.Fatalf("approver approving: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type GovernanceEngine struct {
|
||||
memAudits map[string][]*AuditLog
|
||||
onApprovedHook func(ctx context.Context, doc *DocumentRecord)
|
||||
onApprovedHooks []func(ctx context.Context, doc *DocumentRecord)
|
||||
onPendingDigest PendingSupplementNotify
|
||||
onLifecycleNotify LifecycleNotify
|
||||
OnFinalArtifact func(ctx context.Context, doc *DocumentRecord, markdown []byte, snapshotID int64)
|
||||
OnFinalArtifactTX func(ctx context.Context, tx pgx.Tx, doc *DocumentRecord, markdown []byte, snapshotID int64) error
|
||||
|
||||
@@ -27,6 +29,29 @@ type GovernanceEngine struct {
|
||||
docLocks *docLockIndex
|
||||
}
|
||||
|
||||
// LifecycleNotify is invoked after a successful governance state transition (outside engine lock).
|
||||
type LifecycleNotify func(ctx context.Context, action string, doc *DocumentRecord, actorID int64, actorName, detail string)
|
||||
|
||||
// SetLifecycleNotify registers the post-transition notification hook.
|
||||
func (e *GovernanceEngine) SetLifecycleNotify(hook LifecycleNotify) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.onLifecycleNotify = hook
|
||||
}
|
||||
|
||||
// SetPendingDigestNotify registers a callback when pending supplements are digested on return to drafting.
|
||||
func (e *GovernanceEngine) SetPendingDigestNotify(hook PendingSupplementNotify) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.onPendingDigest = hook
|
||||
}
|
||||
|
||||
// SetOnApprovedHook 注册文档终审批准时的回调钩子
|
||||
func (e *GovernanceEngine) SetOnApprovedHook(hook func(ctx context.Context, doc *DocumentRecord)) {
|
||||
e.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package docgov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PendingSupplementPending = "pending"
|
||||
PendingSupplementDigested = "digested"
|
||||
PendingSupplementDiscarded = "discarded"
|
||||
)
|
||||
|
||||
// PendingSupplement is one locked-period fact supplement deferred for later digestion.
|
||||
type PendingSupplement struct {
|
||||
ID int64 `json:"id"`
|
||||
DocID string `json:"doc_id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
SourceSummary string `json:"source_summary"`
|
||||
ImpactReason string `json:"impact_reason"`
|
||||
InitiatorUserID int64 `json:"initiator_user_id"`
|
||||
BatchID string `json:"batch_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
DigestedAt *time.Time `json:"digested_at,omitempty"`
|
||||
DecidedBy int64 `json:"decided_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PendingSupplementNotify is invoked after pending rows are marked digested.
|
||||
type PendingSupplementNotify func(ctx context.Context, doc *DocumentRecord, pending []PendingSupplement)
|
||||
|
||||
// RecordPendingSupplementInput captures a supplement that cannot be applied while locked.
|
||||
type RecordPendingSupplementInput struct {
|
||||
DocID string
|
||||
ProjectID int64
|
||||
InitiatorUserID int64
|
||||
SourceSummary string
|
||||
ImpactReason string
|
||||
BatchID string
|
||||
}
|
||||
|
||||
// RecordPendingSupplement persists a deferred supplement for a locked document.
|
||||
func (e *GovernanceEngine) RecordPendingSupplement(ctx context.Context, in RecordPendingSupplementInput) (*PendingSupplement, error) {
|
||||
if e == nil {
|
||||
return nil, fmt.Errorf("docgov: engine required")
|
||||
}
|
||||
docID := strings.TrimSpace(in.DocID)
|
||||
if docID == "" || in.ProjectID <= 0 {
|
||||
return nil, fmt.Errorf("docgov: pending supplement requires doc_id and project_id")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
row := &PendingSupplement{
|
||||
DocID: docID, ProjectID: in.ProjectID, InitiatorUserID: in.InitiatorUserID,
|
||||
SourceSummary: strings.TrimSpace(in.SourceSummary),
|
||||
ImpactReason: strings.TrimSpace(in.ImpactReason),
|
||||
BatchID: strings.TrimSpace(in.BatchID),
|
||||
Status: PendingSupplementPending,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if e.pool == nil {
|
||||
return row, nil
|
||||
}
|
||||
err := e.pool.QueryRow(ctx, `
|
||||
INSERT INTO document_pending_supplements (
|
||||
doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id, status, created_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8)
|
||||
RETURNING id, created_at
|
||||
`, row.DocID, row.ProjectID, row.SourceSummary, row.ImpactReason, row.InitiatorUserID, row.BatchID, row.Status, now).
|
||||
Scan(&row.ID, &row.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docgov: record pending supplement: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ListPendingSupplements returns pending rows for a document.
|
||||
func (e *GovernanceEngine) ListPendingSupplements(ctx context.Context, docID string) ([]PendingSupplement, error) {
|
||||
if e == nil || e.pool == nil {
|
||||
return nil, nil
|
||||
}
|
||||
docID = strings.TrimSpace(docID)
|
||||
if docID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := e.pool.Query(ctx, `
|
||||
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
|
||||
status, digested_at, decided_by, created_at
|
||||
FROM document_pending_supplements
|
||||
WHERE doc_id = $1 AND status = $2
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`, docID, PendingSupplementPending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]PendingSupplement, 0)
|
||||
for rows.Next() {
|
||||
var p PendingSupplement
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.DocID, &p.ProjectID, &p.SourceSummary, &p.ImpactReason, &p.InitiatorUserID, &p.BatchID,
|
||||
&p.Status, &p.DigestedAt, &p.DecidedBy, &p.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DigestPendingSupplements marks all pending rows digested when a document returns to drafting.
|
||||
// It returns the digested rows so callers can notify the author.
|
||||
func (e *GovernanceEngine) DigestPendingSupplements(ctx context.Context, docID string, decidedBy int64) ([]PendingSupplement, error) {
|
||||
if e == nil {
|
||||
return nil, fmt.Errorf("docgov: engine required")
|
||||
}
|
||||
docID = strings.TrimSpace(docID)
|
||||
if docID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pending, err := e.ListPendingSupplements(ctx, docID)
|
||||
if err != nil || len(pending) == 0 {
|
||||
return pending, err
|
||||
}
|
||||
if e.pool == nil {
|
||||
for i := range pending {
|
||||
pending[i].Status = PendingSupplementDigested
|
||||
now := time.Now().UTC()
|
||||
pending[i].DigestedAt = &now
|
||||
pending[i].DecidedBy = decidedBy
|
||||
}
|
||||
return pending, nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err = e.pool.Exec(ctx, `
|
||||
UPDATE document_pending_supplements
|
||||
SET status = $2, digested_at = $3, decided_by = $4, updated_at = $3
|
||||
WHERE doc_id = $1 AND status = $5
|
||||
`, docID, PendingSupplementDigested, now, decidedBy, PendingSupplementPending)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docgov: digest pending supplements: %w", err)
|
||||
}
|
||||
for i := range pending {
|
||||
pending[i].Status = PendingSupplementDigested
|
||||
pending[i].DigestedAt = &now
|
||||
pending[i].DecidedBy = decidedBy
|
||||
}
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
// DigestPendingOnReturnToDrafting digests pending supplements after a transition back to drafting.
|
||||
func (e *GovernanceEngine) DigestPendingOnReturnToDrafting(ctx context.Context, doc *DocumentRecord, actorID int64, notify PendingSupplementNotify) {
|
||||
if e == nil || doc == nil || doc.CurrentState != StateDrafting {
|
||||
return
|
||||
}
|
||||
pending, err := e.DigestPendingSupplements(ctx, doc.DocID, actorID)
|
||||
if err != nil || len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
if notify != nil {
|
||||
notify(ctx, doc, pending)
|
||||
}
|
||||
}
|
||||
@@ -196,8 +196,23 @@ func (e *GovernanceEngine) DecideModificationProposal(
|
||||
return proposal, nil
|
||||
}
|
||||
|
||||
// AddComment 添加批注与修改意见 (审核人/批准人/编制人均可批注,但审核批准不能直接改正文)
|
||||
// 有效编辑租约期间禁止批注;生命周期锁定下仍允许批注。
|
||||
// roleAllowedToComment enforces the stage-scoped comment matrix.
|
||||
func roleAllowedToComment(state DocumentState, role UserRole) bool {
|
||||
switch state {
|
||||
case StateDrafting:
|
||||
return role == RoleAuthor || role == RoleReviewer || role == RoleSystem
|
||||
case StateReviewing:
|
||||
return role == RoleReviewer || role == RoleAuthor || role == RoleSystem
|
||||
case StateApproving:
|
||||
return role == RoleApprover || role == RoleReviewer || role == RoleSystem
|
||||
case StateApproved:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AddComment 添加批注与修改意见。有效编辑租约期间禁止批注;生命周期锁定下仍允许批注(按阶段矩阵)。
|
||||
func (e *GovernanceEngine) AddComment(
|
||||
ctx context.Context,
|
||||
docID string,
|
||||
@@ -215,6 +230,21 @@ func (e *GovernanceEngine) AddComment(
|
||||
g := e.acquireDocLock(docID)
|
||||
defer g.Release()
|
||||
|
||||
doc, exists := e.memDocs[docID]
|
||||
if !exists {
|
||||
var err error
|
||||
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
||||
if err != nil || doc == nil {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
}
|
||||
if doc.CurrentState == StateApproved {
|
||||
return nil, fmt.Errorf("%w: 已批准归档文档禁止新增批注", ErrDocumentLocked)
|
||||
}
|
||||
if !roleAllowedToComment(doc.CurrentState, userRole) {
|
||||
return nil, fmt.Errorf("%w: 当前阶段【%s】不允许角色【%s】新增批注", ErrUnauthorizedActor, doc.CurrentState, userRole)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("comment content cannot be empty")
|
||||
@@ -253,6 +283,84 @@ func (e *GovernanceEngine) AddComment(
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// ResolveComment marks a comment resolved (author in drafting) or reopens it (reviewer).
|
||||
func (e *GovernanceEngine) ResolveComment(ctx context.Context, commentID int64, actorID int64, actorRole UserRole, resolve bool) (*DocumentComment, error) {
|
||||
if e == nil || commentID <= 0 {
|
||||
return nil, fmt.Errorf("docgov: comment id required")
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
var found *DocumentComment
|
||||
var docID string
|
||||
for id, list := range e.memComms {
|
||||
for _, c := range list {
|
||||
if c != nil && c.ID == commentID {
|
||||
found = c
|
||||
docID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
if found != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil && e.pool != nil {
|
||||
var c DocumentComment
|
||||
var role, cType string
|
||||
err := e.pool.QueryRow(ctx, `
|
||||
SELECT id, doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at
|
||||
FROM document_comments WHERE id = $1
|
||||
`, commentID).Scan(
|
||||
&c.ID, &c.DocID, &c.UserID, &c.UserName, &role, &c.TargetSection, &cType, &c.Content, &c.Status, &c.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docgov: comment not found")
|
||||
}
|
||||
c.UserRole = UserRole(role)
|
||||
c.CommentType = CommentType(cType)
|
||||
found = &c
|
||||
docID = c.DocID
|
||||
e.memComms[docID] = append(e.memComms[docID], found)
|
||||
}
|
||||
if found == nil {
|
||||
return nil, fmt.Errorf("docgov: comment not found")
|
||||
}
|
||||
|
||||
doc, exists := e.memDocs[docID]
|
||||
if !exists {
|
||||
var err error
|
||||
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
||||
if err != nil || doc == nil {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
}
|
||||
|
||||
newStatus := "resolved"
|
||||
if !resolve {
|
||||
newStatus = "open"
|
||||
if actorRole != RoleReviewer && actorRole != RoleSystem {
|
||||
return nil, fmt.Errorf("%w: 仅审核人可重开批注", ErrUnauthorizedActor)
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case actorRole == RoleAuthor && doc.CurrentState == StateDrafting:
|
||||
case actorRole == RoleReviewer || actorRole == RoleSystem:
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: 当前阶段不允许将该批注标记为已处理", ErrUnauthorizedActor)
|
||||
}
|
||||
}
|
||||
found.Status = newStatus
|
||||
if e.pool != nil {
|
||||
_, err := e.pool.Exec(ctx, `UPDATE document_comments SET status = $1 WHERE id = $2`, newStatus, commentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docgov: resolve comment: %w", err)
|
||||
}
|
||||
}
|
||||
_ = actorID
|
||||
return found, 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{
|
||||
|
||||
@@ -9,6 +9,18 @@ import (
|
||||
"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)
|
||||
@@ -42,6 +54,8 @@ func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, ac
|
||||
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
|
||||
}
|
||||
|
||||
@@ -68,6 +82,8 @@ func (e *GovernanceEngine) ReviewApprove(ctx context.Context, docID string, acto
|
||||
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
|
||||
}
|
||||
|
||||
@@ -93,6 +109,12 @@ func (e *GovernanceEngine) ReviewReject(ctx context.Context, docID string, actor
|
||||
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
|
||||
}
|
||||
|
||||
@@ -130,8 +152,13 @@ func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, ac
|
||||
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
|
||||
@@ -158,6 +185,14 @@ func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, act
|
||||
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
|
||||
}
|
||||
|
||||
@@ -177,5 +212,11 @@ func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// 全局锁只保护「分锁表」本身(短临界区);文档间操作互不阻塞。
|
||||
// 锁序约定:docLocks.meta → docID(如需多文档,按 docID 字典序)。
|
||||
type docLockIndex struct {
|
||||
meta sync.Mutex
|
||||
meta sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,16 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DigestKindProposalRequired = "proposal_required"
|
||||
DigestKindUnclaimedEdit = "unclaimed_edit"
|
||||
SeverityActionRequired = "action_required"
|
||||
SeverityInfo = "info"
|
||||
maxDigestItems = 20
|
||||
)
|
||||
const (
|
||||
DigestKindProposalRequired = "proposal_required"
|
||||
DigestKindUnclaimedEdit = "unclaimed_edit"
|
||||
DigestKindPendingSupplements = "pending_supplements"
|
||||
DigestKindReviewRequested = "review_requested"
|
||||
DigestKindApprovalRequested = "approval_requested"
|
||||
SeverityActionRequired = "action_required"
|
||||
SeverityInfo = "info"
|
||||
maxDigestItems = 20
|
||||
)
|
||||
|
||||
// DigestItem is one occurrence folded into a daily digest.
|
||||
type DigestItem struct {
|
||||
@@ -140,6 +143,12 @@ func typeForDigestKind(kind string) string {
|
||||
return TypeDocProposalRequired
|
||||
case DigestKindUnclaimedEdit:
|
||||
return TypeDocUnclaimedEditApplied
|
||||
case DigestKindPendingSupplements:
|
||||
return TypeDocPendingSupplements
|
||||
case DigestKindReviewRequested:
|
||||
return TypeDocReviewRequested
|
||||
case DigestKindApprovalRequested:
|
||||
return TypeDocApprovalRequested
|
||||
default:
|
||||
return kind
|
||||
}
|
||||
@@ -170,6 +179,19 @@ func digestCopy(p UpsertDigestParams, count int) (title, body string) {
|
||||
}
|
||||
return fmt.Sprintf("《%s》今日已有 %d 次更新", titleDoc, count),
|
||||
fmt.Sprintf("最近一次:%s;累计 %d 次更新。本文档尚未被您认领。", actor, count)
|
||||
case DigestKindPendingSupplements:
|
||||
if count <= 1 {
|
||||
return fmt.Sprintf("《%s》有补充信息待消化", titleDoc),
|
||||
fmt.Sprintf("%s 在文档锁定期间补充了「%s」。文档已回编制期,请审阅并消化积压补充。", actor, summary)
|
||||
}
|
||||
return fmt.Sprintf("《%s》累计 %d 条补充待消化", titleDoc, count),
|
||||
fmt.Sprintf("最近一次:%s。文档已回编制期,请逐条审阅积压补充。", actor)
|
||||
case DigestKindReviewRequested:
|
||||
return fmt.Sprintf("《%s》待您审核", titleDoc),
|
||||
fmt.Sprintf("%s 已送审,请审核本文档。", actor)
|
||||
case DigestKindApprovalRequested:
|
||||
return fmt.Sprintf("《%s》待您批准", titleDoc),
|
||||
fmt.Sprintf("%s 审核已通过,请批准本文档。", actor)
|
||||
default:
|
||||
return titleDoc, summary
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ const (
|
||||
EventDocChangeReportDegraded = "document.change_report_degraded"
|
||||
EventDocProposalRequired = "document.proposal_required"
|
||||
EventDocUnclaimedEditApplied = "document.unclaimed_edit_applied"
|
||||
EventDocReviewRequested = "document.review_requested"
|
||||
EventDocApprovalRequested = "document.approval_requested"
|
||||
EventDocRejected = "document.rejected"
|
||||
EventDocReopened = "document.reopened"
|
||||
EventDocPendingSupplements = "document.pending_supplements"
|
||||
EventDocApproved = "document.approved"
|
||||
|
||||
TypeDocAgentCandidateReady = "document_agent_candidate_ready"
|
||||
TypeDocAgentVersionCommitted = "document_agent_version_committed"
|
||||
@@ -24,6 +30,11 @@ const (
|
||||
TypeDocApproved = "document_approved"
|
||||
TypeDocProposalRequired = "document_proposal_required"
|
||||
TypeDocUnclaimedEditApplied = "document_unclaimed_edit_applied"
|
||||
TypeDocReviewRequested = "document_review_requested"
|
||||
TypeDocApprovalRequested = "document_approval_requested"
|
||||
TypeDocRejected = "document_rejected"
|
||||
TypeDocReopened = "document_reopened"
|
||||
TypeDocPendingSupplements = "document_pending_supplements"
|
||||
)
|
||||
|
||||
// DocumentEventPayload is a minimal, permission-safe notification body (no markdown excerpts).
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestChatStream_GetMessageQueryRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestChatStream_GetWithoutMessageNotLeak 无 message 的 GET 不触发 B11 分支
|
||||
//(进入后续 message cannot be empty 的统一拒绝,同样不产生 SSE 头)。
|
||||
// (进入后续 message cannot be empty 的统一拒绝,同样不产生 SSE 头)。
|
||||
func TestChatStream_GetWithoutMessageNotLeak(t *testing.T) {
|
||||
cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}
|
||||
svc := bizdev.MustNewService(t, cfg)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/app/docflow"
|
||||
@@ -383,6 +384,47 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request)
|
||||
writeJSONResponse(w, http.StatusOK, comment)
|
||||
}
|
||||
|
||||
// handleDocGovResolveComment marks a comment resolved or reopens it.
|
||||
func (s *Server) handleDocGovResolveComment(w http.ResponseWriter, r *http.Request) {
|
||||
if s.gov == nil {
|
||||
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
|
||||
return
|
||||
}
|
||||
commentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || commentID <= 0 {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "invalid comment id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DocID string `json:"doc_id"`
|
||||
Resolve *bool `json:"resolve"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
resolve := true
|
||||
if req.Resolve != nil {
|
||||
resolve = *req.Resolve
|
||||
}
|
||||
user, ok := s.requireAuthenticatedUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
docID := strings.TrimSpace(req.DocID)
|
||||
if docID == "" {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "doc_id required")
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
|
||||
return
|
||||
}
|
||||
role := s.resolveDocGovRole(r.Context(), docID, user)
|
||||
comment, err := s.gov().ResolveComment(r.Context(), commentID, user.ID, role, resolve)
|
||||
if err != nil {
|
||||
writeDocGovError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, comment)
|
||||
}
|
||||
|
||||
// handleDocGovListAuditLogs 查询文档全生命周期审计轨迹流
|
||||
func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
docID := r.URL.Query().Get("doc_id")
|
||||
|
||||
@@ -43,17 +43,17 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R
|
||||
|
||||
// userNotificationView is the all-scope inbox row (includes severity / digest when migrated).
|
||||
type userNotificationView struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
EventID int64 `json:"event_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
DigestID int64 `json:"digest_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
EventID int64 `json:"event_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
DigestID int64 `json:"digest_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -30,28 +30,28 @@ import (
|
||||
// 生产路径恒为空。显式置 nil 用 ptr 形(authorizerPtr 等)以区分「未设置」与「设为 nil」。
|
||||
|
||||
type serverTestOverrides struct {
|
||||
svc *bizdev.Service
|
||||
pwSvc *projectworkflow.Service
|
||||
engine *coordinator.CoordinatorEngine
|
||||
authSvc *auth.Service
|
||||
taskRuns *taskrun.Manager
|
||||
authorizer *authz.Authorizer
|
||||
authzSvc *authz.Service
|
||||
wfDefSvc *workflowdef.Service
|
||||
instSvc *projectinstance.Service
|
||||
notifySvc *notification.Service
|
||||
resolver *workspace.Resolver
|
||||
wsCap *workspace.Capability
|
||||
cmds *taskrun.Commands
|
||||
stageflow *stageflow.UseCase
|
||||
gov *docgov.GovernanceEngine
|
||||
rev *docrevision.Service
|
||||
ctxMgr *ctxmgr.ContextManager
|
||||
cas *storage.CAS
|
||||
freeDoc *freedoc.Service
|
||||
kb *kb.Client
|
||||
sysadmin *sysadmin.Service
|
||||
maintenance *maintenance.Service
|
||||
svc *bizdev.Service
|
||||
pwSvc *projectworkflow.Service
|
||||
engine *coordinator.CoordinatorEngine
|
||||
authSvc *auth.Service
|
||||
taskRuns *taskrun.Manager
|
||||
authorizer *authz.Authorizer
|
||||
authzSvc *authz.Service
|
||||
wfDefSvc *workflowdef.Service
|
||||
instSvc *projectinstance.Service
|
||||
notifySvc *notification.Service
|
||||
resolver *workspace.Resolver
|
||||
wsCap *workspace.Capability
|
||||
cmds *taskrun.Commands
|
||||
stageflow *stageflow.UseCase
|
||||
gov *docgov.GovernanceEngine
|
||||
rev *docrevision.Service
|
||||
ctxMgr *ctxmgr.ContextManager
|
||||
cas *storage.CAS
|
||||
freeDoc *freedoc.Service
|
||||
kb *kb.Client
|
||||
sysadmin *sysadmin.Service
|
||||
maintenance *maintenance.Service
|
||||
|
||||
// 显式置 nil 的位标记(区分「未设置」与「设为 nil」,fail-closed 测试用)。
|
||||
authorizerNil bool
|
||||
|
||||
+15
-14
@@ -34,10 +34,10 @@ import (
|
||||
// Pool 与领域服务装配归属 app.Runtime;本结构仅保留 HTTP 适配所需句柄。
|
||||
// 残留具体领域字段见 short-term 任务记录(增量收口,本批先去掉 *pgxpool.Pool)。
|
||||
type Server struct {
|
||||
rt *app.Runtime
|
||||
cfg *config.Config
|
||||
sessions map[string]*chat.Session
|
||||
sessionsMu sync.RWMutex
|
||||
rt *app.Runtime
|
||||
cfg *config.Config
|
||||
sessions map[string]*chat.Session
|
||||
sessionsMu sync.RWMutex
|
||||
supplementImpact SupplementImpactService
|
||||
// intentPlansOverride 仅测试注入用(SetIntentPlansForTest);为空回落 rt.IntentPlans。
|
||||
intentPlansOverride intentflow.Service
|
||||
@@ -48,12 +48,12 @@ type Server struct {
|
||||
reminderLoop backgroundLoop
|
||||
vaultLoop backgroundLoop
|
||||
chatTurnSweepLoop backgroundLoop
|
||||
chatLog chatflow.ChatLog
|
||||
assembler chatflow.ContextAssembler
|
||||
listener net.Listener
|
||||
lastMaintToken string
|
||||
wsReg *workspace.FileRegistry
|
||||
httpServer *http.Server
|
||||
chatLog chatflow.ChatLog
|
||||
assembler chatflow.ContextAssembler
|
||||
listener net.Listener
|
||||
lastMaintToken string
|
||||
wsReg *workspace.FileRegistry
|
||||
httpServer *http.Server
|
||||
planAccessFn func(ctx context.Context, projectID, userID int64) error
|
||||
planSessionOwnerFn func(ctx context.Context, sessionID string) (int64, error)
|
||||
|
||||
@@ -83,10 +83,10 @@ func NewFromRuntime(rt *app.Runtime) *Server {
|
||||
panic("server: nil runtime")
|
||||
}
|
||||
s := &Server{
|
||||
rt: rt,
|
||||
cfg: rt.Cfg,
|
||||
sessions: make(map[string]*chat.Session),
|
||||
wsReg: workspaceRegistryFor(rt.Cfg),
|
||||
rt: rt,
|
||||
cfg: rt.Cfg,
|
||||
sessions: make(map[string]*chat.Session),
|
||||
wsReg: workspaceRegistryFor(rt.Cfg),
|
||||
// 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口
|
||||
loginLimiter: newLoginRateLimiter(10, 5*time.Minute),
|
||||
}
|
||||
@@ -288,6 +288,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/docgov/proposal/decide", s.handleDocGovDecideProposal)
|
||||
mux.HandleFunc("GET /api/v1/docgov/comments", s.handleDocGovListComments)
|
||||
mux.HandleFunc("POST /api/v1/docgov/comments", s.handleDocGovAddComment)
|
||||
mux.HandleFunc("POST /api/v1/docgov/comments/{id}/resolve", s.handleDocGovResolveComment)
|
||||
mux.HandleFunc("GET /api/v1/docgov/audit-logs", s.handleDocGovListAuditLogs)
|
||||
mux.HandleFunc("POST /api/v1/docgov/content", s.handleDocGovSaveContent)
|
||||
mux.HandleFunc("POST /api/v1/docgov/lease/acquire", s.handleDocGovAcquireLease)
|
||||
|
||||
@@ -50,8 +50,8 @@ func mapSupplementBatch(b *supplementimpact.Batch) *SupplementImpactBatchView {
|
||||
out.Items = append(out.Items, SupplementImpactItemView{
|
||||
DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType,
|
||||
Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot,
|
||||
BusySnapshot: it.BusySnapshot, ChangeSummary: it.ChangeSummary, Selected: it.Selected,
|
||||
Outcome: it.Outcome, ProposalID: it.ProposalID,
|
||||
BusySnapshot: it.BusySnapshot, LockedSnapshot: it.LockedSnapshot, ChangeSummary: it.ChangeSummary,
|
||||
Selected: it.Selected, Outcome: it.Outcome, ProposalID: it.ProposalID,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -42,6 +42,7 @@ type SupplementImpactItemView struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
|
||||
BusySnapshot bool `json:"busy_snapshot"`
|
||||
LockedSnapshot bool `json:"locked_snapshot"`
|
||||
ChangeSummary string `json:"change_summary,omitempty"`
|
||||
Selected bool `json:"selected"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
|
||||
@@ -455,11 +455,11 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu
|
||||
expiresAt = *doc.LeaseExpiresAt
|
||||
}
|
||||
display := docgov.LeaseHolderDisplay(doc.LeaseHolderType, doc.LeaseHolderID, "")
|
||||
exp := time.Time{}
|
||||
if doc.LeaseExpiresAt != nil {
|
||||
exp = *doc.LeaseExpiresAt
|
||||
}
|
||||
reason := docgov.FormatDocumentBusyReason(doc.Title, display, exp)
|
||||
exp := time.Time{}
|
||||
if doc.LeaseExpiresAt != nil {
|
||||
exp = *doc.LeaseExpiresAt
|
||||
}
|
||||
reason := docgov.FormatDocumentBusyReason(doc.Title, display, exp)
|
||||
return &docgov.DocumentBusyError{
|
||||
DocID: doc.DocID,
|
||||
HolderType: doc.LeaseHolderType,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/auth"
|
||||
"chat-agent-system/internal/traceimpact"
|
||||
@@ -148,11 +151,6 @@ func (s *Server) handleProjectTraceReopenApprove(w http.ResponseWriter, r *http.
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID)
|
||||
if !creator {
|
||||
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可批准再打开"}`)
|
||||
return
|
||||
}
|
||||
requestID, err := strconv.ParseInt(r.PathValue("requestID"), 10, 64)
|
||||
if err != nil || requestID <= 0 {
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid request id"}`)
|
||||
@@ -163,6 +161,11 @@ func (s *Server) handleProjectTraceReopenApprove(w http.ResponseWriter, r *http.
|
||||
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND","message":"申请不属于该项目"}`)
|
||||
return
|
||||
}
|
||||
creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID)
|
||||
if !s.callerMayApproveReopen(r.Context(), projectID, user.ID, creator, existing) {
|
||||
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"仅原批准人或(批准人缺位时)项目创建人可批准再打开"}`)
|
||||
return
|
||||
}
|
||||
stored, err := s.rt.Trace.ApproveReopen(r.Context(), requestID, user.ID)
|
||||
if err != nil {
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]string{"error": "TRACE_REOPEN_FAILED", "message": err.Error()})
|
||||
@@ -258,6 +261,47 @@ func (s *Server) callerMayRequestReopen(r *http.Request, projectID, userID int64
|
||||
return traceimpact.MayRequestReopen(userID, false, req.DocTasks, seats)
|
||||
}
|
||||
|
||||
// callerMayApproveReopen: document ApproverID first; if all approvers are missing/exited, fall back to project creator.
|
||||
func (s *Server) callerMayApproveReopen(ctx context.Context, projectID, userID int64, creator bool, req traceimpact.ReopenRequest) bool {
|
||||
hasActiveApprover := false
|
||||
userIsApprover := false
|
||||
if s.gov != nil {
|
||||
for _, taskKey := range req.DocTasks {
|
||||
docID := fmt.Sprintf("doc_proj%d_%s", projectID, taskKey)
|
||||
doc, err := s.gov().GetDocument(ctx, docID)
|
||||
if err != nil || doc == nil || doc.ApproverID <= 0 {
|
||||
continue
|
||||
}
|
||||
if !s.projectMemberActive(ctx, projectID, doc.ApproverID) {
|
||||
continue
|
||||
}
|
||||
hasActiveApprover = true
|
||||
if doc.ApproverID == userID {
|
||||
userIsApprover = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if userIsApprover {
|
||||
return true
|
||||
}
|
||||
if !hasActiveApprover && creator {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) projectMemberActive(ctx context.Context, projectID, userID int64) bool {
|
||||
if s.authorizer == nil || userID <= 0 {
|
||||
return userID > 0
|
||||
}
|
||||
rec, err := s.authorizer().GetProjectMember(ctx, projectID, userID)
|
||||
if err != nil || rec == nil {
|
||||
return false
|
||||
}
|
||||
status := strings.TrimSpace(rec.Status)
|
||||
return status == "" || status == "active"
|
||||
}
|
||||
|
||||
func (s *Server) traceDocVisible(r *http.Request, projectID, userID int64, docID string) bool {
|
||||
if docID == "" || docID == "chat" {
|
||||
return true
|
||||
|
||||
@@ -32,6 +32,7 @@ type HTTPItemView struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
|
||||
BusySnapshot bool `json:"busy_snapshot"`
|
||||
LockedSnapshot bool `json:"locked_snapshot"`
|
||||
ChangeSummary string `json:"change_summary,omitempty"`
|
||||
Selected bool `json:"selected"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
@@ -90,8 +91,8 @@ func toHTTPView(b *Batch) *HTTPBatchView {
|
||||
v.Items = append(v.Items, HTTPItemView{
|
||||
DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType,
|
||||
Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot,
|
||||
BusySnapshot: it.BusySnapshot, ChangeSummary: it.ChangeSummary, Selected: it.Selected,
|
||||
Outcome: it.Outcome, ProposalID: it.ProposalID,
|
||||
BusySnapshot: it.BusySnapshot, LockedSnapshot: it.LockedSnapshot, ChangeSummary: it.ChangeSummary,
|
||||
Selected: it.Selected, Outcome: it.Outcome, ProposalID: it.ProposalID,
|
||||
})
|
||||
}
|
||||
return v
|
||||
|
||||
@@ -45,12 +45,12 @@ func (p *PGStore) InsertBatch(ctx context.Context, b *Batch) error {
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO supplement_impact_items (
|
||||
batch_id, doc_id, title, item_key, impact_type, depth, reason,
|
||||
author_claimed_snapshot, author_id_snapshot, busy_snapshot, change_summary,
|
||||
author_claimed_snapshot, author_id_snapshot, busy_snapshot, locked_snapshot, change_summary,
|
||||
selected, outcome, proposal_id, run_id
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
RETURNING id
|
||||
`, it.BatchID, it.DocID, it.Title, it.ItemKey, it.ImpactType, it.Depth, it.Reason,
|
||||
it.AuthorClaimedSnapshot, it.AuthorIDSnapshot, it.BusySnapshot, it.ChangeSummary,
|
||||
it.AuthorClaimedSnapshot, it.AuthorIDSnapshot, it.BusySnapshot, it.LockedSnapshot, it.ChangeSummary,
|
||||
it.Selected, it.Outcome, it.ProposalID, it.RunID).Scan(&it.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -77,7 +77,7 @@ func (p *PGStore) GetBatch(ctx context.Context, batchID string) (*Batch, error)
|
||||
}
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, batch_id, doc_id, title, item_key, impact_type, depth, reason,
|
||||
author_claimed_snapshot, author_id_snapshot, busy_snapshot, change_summary,
|
||||
author_claimed_snapshot, author_id_snapshot, busy_snapshot, locked_snapshot, change_summary,
|
||||
selected, outcome, proposal_id, run_id
|
||||
FROM supplement_impact_items WHERE batch_id = $1 ORDER BY id
|
||||
`, batchID)
|
||||
@@ -89,7 +89,7 @@ func (p *PGStore) GetBatch(ctx context.Context, batchID string) (*Batch, error)
|
||||
var it Item
|
||||
if err := rows.Scan(
|
||||
&it.ID, &it.BatchID, &it.DocID, &it.Title, &it.ItemKey, &it.ImpactType, &it.Depth, &it.Reason,
|
||||
&it.AuthorClaimedSnapshot, &it.AuthorIDSnapshot, &it.BusySnapshot, &it.ChangeSummary,
|
||||
&it.AuthorClaimedSnapshot, &it.AuthorIDSnapshot, &it.BusySnapshot, &it.LockedSnapshot, &it.ChangeSummary,
|
||||
&it.Selected, &it.Outcome, &it.ProposalID, &it.RunID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -41,14 +41,20 @@ type Proposer interface {
|
||||
ProposeModification(ctx context.Context, docID, triggeredBy, changes, impact, risk string) (*docgov.ChangeProposal, error)
|
||||
}
|
||||
|
||||
// PendingLedger records supplements deferred while a document is lifecycle-locked.
|
||||
type PendingLedger interface {
|
||||
RecordPendingSupplement(ctx context.Context, in docgov.RecordPendingSupplementInput) (*docgov.PendingSupplement, error)
|
||||
}
|
||||
|
||||
// Service orchestrates supplement → impact → confirm → split execution.
|
||||
type Service struct {
|
||||
Store Store
|
||||
Docs DocLookup
|
||||
Analyze Analyzer
|
||||
Notify Notifier
|
||||
Propose Proposer
|
||||
Now func() time.Time
|
||||
Store Store
|
||||
Docs DocLookup
|
||||
Analyze Analyzer
|
||||
Notify Notifier
|
||||
Propose Proposer
|
||||
Pending PendingLedger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
@@ -114,7 +120,11 @@ func (s *Service) CreateFromSupplement(ctx context.Context, projectID, userID in
|
||||
item.Title = doc.Title
|
||||
item.AuthorClaimedSnapshot = doc.AuthorClaimed
|
||||
item.AuthorIDSnapshot = doc.AuthorID
|
||||
item.ReviewerIDSnapshot = doc.ReviewerID
|
||||
item.ApproverIDSnapshot = doc.ApproverID
|
||||
item.CurrentStateSnapshot = string(doc.CurrentState)
|
||||
item.BusySnapshot = doc.HasActiveLease(now)
|
||||
item.LockedSnapshot = docIsLifecycleLocked(doc)
|
||||
}
|
||||
}
|
||||
b.Items = append(b.Items, item)
|
||||
@@ -126,6 +136,16 @@ func (s *Service) CreateFromSupplement(ctx context.Context, projectID, userID in
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func docIsLifecycleLocked(doc *docgov.DocumentRecord) bool {
|
||||
if doc == nil {
|
||||
return false
|
||||
}
|
||||
if doc.IsLocked {
|
||||
return true
|
||||
}
|
||||
return doc.CurrentState != "" && doc.CurrentState != docgov.StateDrafting
|
||||
}
|
||||
|
||||
// Decide confirms or cancels a batch; on confirm, executes selected items.
|
||||
func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, error) {
|
||||
b, err := s.Store.GetBatch(ctx, cmd.BatchID)
|
||||
@@ -164,6 +184,12 @@ func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, erro
|
||||
if !selected[it.DocID] {
|
||||
continue
|
||||
}
|
||||
if it.LockedSnapshot {
|
||||
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedLocked, "", "")
|
||||
s.recordPendingLocked(ctx, b, it)
|
||||
s.notifyLockedDutyHolder(ctx, b, it)
|
||||
continue
|
||||
}
|
||||
if it.BusySnapshot {
|
||||
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedBusy, "", "")
|
||||
continue
|
||||
@@ -187,6 +213,57 @@ func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, erro
|
||||
return s.Store.GetBatch(ctx, cmd.BatchID)
|
||||
}
|
||||
|
||||
func (s *Service) recordPendingLocked(ctx context.Context, b *Batch, it *Item) {
|
||||
if s == nil || s.Pending == nil || b == nil || it == nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.Pending.RecordPendingSupplement(ctx, docgov.RecordPendingSupplementInput{
|
||||
DocID: it.DocID,
|
||||
ProjectID: b.ProjectID,
|
||||
InitiatorUserID: b.InitiatorUserID,
|
||||
SourceSummary: b.SourceSummary,
|
||||
ImpactReason: it.Reason,
|
||||
BatchID: b.BatchID,
|
||||
})
|
||||
}
|
||||
|
||||
func dutyHolderForLockedItem(it *Item) int64 {
|
||||
if it == nil {
|
||||
return 0
|
||||
}
|
||||
switch it.CurrentStateSnapshot {
|
||||
case string(docgov.StateReviewing):
|
||||
if it.ReviewerIDSnapshot > 0 {
|
||||
return it.ReviewerIDSnapshot
|
||||
}
|
||||
case string(docgov.StateApproving), string(docgov.StateApproved):
|
||||
if it.ApproverIDSnapshot > 0 {
|
||||
return it.ApproverIDSnapshot
|
||||
}
|
||||
}
|
||||
if it.AuthorIDSnapshot > 0 {
|
||||
return it.AuthorIDSnapshot
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) notifyLockedDutyHolder(ctx context.Context, b *Batch, it *Item) {
|
||||
if s == nil || s.Notify == nil || b == nil || it == nil {
|
||||
return
|
||||
}
|
||||
userID := dutyHolderForLockedItem(it)
|
||||
if userID <= 0 {
|
||||
return
|
||||
}
|
||||
_ = s.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
|
||||
UserID: userID, ProjectID: b.ProjectID,
|
||||
DigestKind: notification.DigestKindPendingSupplements, DocID: it.DocID, DocTitle: it.Title,
|
||||
Severity: notification.SeverityInfo,
|
||||
ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID),
|
||||
Summary: b.SourceSummary,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error {
|
||||
if it.AuthorClaimedSnapshot {
|
||||
if s.Propose == nil {
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestDecideClaimedProducesProposal(t *testing.T) {
|
||||
},
|
||||
}},
|
||||
Docs: stubDocs{docs: map[string]*docgov.DocumentRecord{
|
||||
"doc_a": {DocID: "doc_a", Title: "需求", AuthorID: 9, AuthorClaimed: true, AuthorClaimedAt: &claimedAt},
|
||||
"doc_a": {DocID: "doc_a", Title: "需求", AuthorID: 9, AuthorClaimed: true, AuthorClaimedAt: &claimedAt, CurrentState: docgov.StateDrafting},
|
||||
}},
|
||||
Propose: prop,
|
||||
Notify: notify,
|
||||
@@ -106,3 +106,73 @@ func TestDecideClaimedProducesProposal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type stubPending struct {
|
||||
n int
|
||||
last docgov.RecordPendingSupplementInput
|
||||
}
|
||||
|
||||
func (s *stubPending) RecordPendingSupplement(_ context.Context, in docgov.RecordPendingSupplementInput) (*docgov.PendingSupplement, error) {
|
||||
s.n++
|
||||
s.last = in
|
||||
return &docgov.PendingSupplement{DocID: in.DocID, Status: docgov.PendingSupplementPending}, nil
|
||||
}
|
||||
|
||||
type capturingNotify struct {
|
||||
params []notification.UpsertDigestParams
|
||||
}
|
||||
|
||||
func (s *capturingNotify) UpsertDocumentDigest(_ context.Context, p notification.UpsertDigestParams) error {
|
||||
s.params = append(s.params, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDecideLockedSkipsAndRecordsPending(t *testing.T) {
|
||||
store := supplementimpact.NewMemStore()
|
||||
pending := &stubPending{}
|
||||
notify := &capturingNotify{}
|
||||
svc := &supplementimpact.Service{
|
||||
Store: store,
|
||||
Analyze: stubAnalyzer{report: &traceimpact.Report{
|
||||
CoverageStatus: "confirmed_impact",
|
||||
Impacts: []traceimpact.Impact{
|
||||
{DocID: "doc_locked", ItemKey: "UR-1", ImpactType: "confirmed_impact", Depth: 1, Reason: "locked hit"},
|
||||
},
|
||||
}},
|
||||
Docs: stubDocs{docs: map[string]*docgov.DocumentRecord{
|
||||
"doc_locked": {
|
||||
DocID: "doc_locked", Title: "锁定文档", AuthorID: 1, ReviewerID: 7,
|
||||
CurrentState: docgov.StateReviewing, IsLocked: true, AuthorClaimed: true,
|
||||
},
|
||||
}},
|
||||
Pending: pending,
|
||||
Notify: notify,
|
||||
}
|
||||
b, err := svc.CreateFromSupplement(context.Background(), 1, 2, "sess", "锁定期补充", "UR-1 变更")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(b.Items) != 1 || !b.Items[0].LockedSnapshot || b.Items[0].BusySnapshot {
|
||||
t.Fatalf("want locked snapshot without busy, got %+v", b.Items[0])
|
||||
}
|
||||
out, err := svc.Decide(context.Background(), supplementimpact.DecisionCommand{
|
||||
BatchID: b.BatchID, ActorUserID: 2, Kind: "confirm",
|
||||
SelectedDocIDs: []string{"doc_locked"}, DecisionNonce: b.DecisionNonce,
|
||||
DecisionDigest: "d1", ExpectedRevision: b.Revision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Items[0].Outcome != supplementimpact.OutcomeSkippedLocked {
|
||||
t.Fatalf("want skipped_locked, got %s", out.Items[0].Outcome)
|
||||
}
|
||||
if pending.n != 1 || pending.last.DocID != "doc_locked" {
|
||||
t.Fatalf("pending ledger not recorded: %+v", pending)
|
||||
}
|
||||
if len(notify.params) != 1 || notify.params[0].UserID != 7 {
|
||||
t.Fatalf("want reviewer notified, got %+v", notify.params)
|
||||
}
|
||||
if notify.params[0].DigestKind != notification.DigestKindPendingSupplements {
|
||||
t.Fatalf("want pending_supplements digest, got %s", notify.params[0].DigestKind)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ type Item struct {
|
||||
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
|
||||
AuthorIDSnapshot int64 `json:"author_id_snapshot"`
|
||||
BusySnapshot bool `json:"busy_snapshot"`
|
||||
LockedSnapshot bool `json:"locked_snapshot"`
|
||||
ReviewerIDSnapshot int64 `json:"reviewer_id_snapshot,omitempty"`
|
||||
ApproverIDSnapshot int64 `json:"approver_id_snapshot,omitempty"`
|
||||
CurrentStateSnapshot string `json:"current_state_snapshot,omitempty"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
Selected bool `json:"selected"`
|
||||
Outcome string `json:"outcome"`
|
||||
|
||||
Reference in New Issue
Block a user