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:
weichen
2026-09-25 20:08:56 +00:00
parent 6adf694f74
commit 2e56ba9719
41 changed files with 1473 additions and 119 deletions
+18 -4
View File
@@ -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 "", "", ""
}
+181
View File
@@ -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]) + "…"
}
+47
View File
@@ -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)
}
}
+28 -4
View File
@@ -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
}
+5 -1
View File
@@ -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
}
+30 -1
View File
@@ -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,
+40
View File
@@ -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)
}
}
+25
View File
@@ -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()
+164
View File
@@ -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)
}
}
+110 -2
View File
@@ -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{
+41
View File
@@ -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
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
// 全局锁只保护「分锁表」本身(短临界区);文档间操作互不阻塞。
// 锁序约定:docLocks.meta → docID(如需多文档,按 docID 字典序)。
type docLockIndex struct {
meta sync.Mutex
meta sync.Mutex
locks map[string]*sync.Mutex
}
+29 -7
View File
@@ -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
}
+11
View File
@@ -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)
+42
View File
@@ -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")
+11 -11
View File
@@ -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"`
}
+22 -22
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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"`
+5 -5
View File
@@ -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,
+49 -5
View File
@@ -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
+3 -2
View File
@@ -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
+5 -5
View File
@@ -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
+83 -6
View File
@@ -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 {
+71 -1
View File
@@ -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)
}
}
+4
View File
@@ -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"`