diff --git a/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md b/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md new file mode 100644 index 00000000..f29ad3b4 --- /dev/null +++ b/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md @@ -0,0 +1,66 @@ +# 受控文档三权流转控制完善(审核/批准/归档/重开 + 锁定期补充消化) + +> 落地版 · `task_id=doc-review-lifecycle-2026-09-25` · 2026-09-25 + +## 1. 目标 + +在既有四态状态机(`drafting→reviewing→approving→approved`)上补齐四条缺口,不重造状态机: + +| ID | 缺口 | 处理 | +|---|---|---| +| G1 | 锁定期补充误生成提案后卡死 | 积压账本 + `skipped_locked` | +| G2 | 职责人流转零通知 | 生命周期事件 + 收件人按槽位路由 | +| G3 | 批注不落库 / 权限过宽 | 阶段化矩阵 + resolve API + 前端落库 | +| G4 | 重开无 UI / 审批人仅创建人 | Approver 优先 + 创建人兜底 + 前端入口 | + +## 2. 状态机(语义不变,正交积压) + +```mermaid +stateDiagram-v2 + drafting --> reviewing : submit_review + reviewing --> approving : review_approve + reviewing --> drafting : review_reject + approving --> approved : approver_approve + approving --> reviewing : approver_reject + approving --> drafting : approver_reject + approved --> drafting : reopen_approve +``` + +积压表 `document_pending_supplements`(goose `00072`)独立于 `document_records` 状态列。回 drafting 时 `DigestPendingOnReturnToDrafting` 标记 digested 并通知 author。 + +## 3. 锁定期补充闭环 + +1. `CreateFromSupplement`:`LockedSnapshot = IsLocked || state != drafting`;`BusySnapshot` 仍为编辑租约。 +2. `Decide`:锁定项 → `OutcomeSkippedLocked` + `PendingLedger.Record` + 知会当前职责人(reviewing→reviewer,approving/approved→approver,缺位→author)。 +3. 回 drafting(驳回/重开)→ 消化积压 + `pending_supplements` digest。 + +## 4. 流转通知收件人 + +| 事件 | 收件人 | +|---|---| +| `document.review_requested` | ReviewerID | +| `document.approval_requested` | ApproverID | +| `document.rejected` / `reopened` / `pending_supplements` | AuthorID | +| `document.approved` | AuthorID | + +`docnotify.RecipientForDocEvent` 统一解析;lifecycle hook 在 `SubmitForReview` / `Review*` / `Approver*` / `ReopenForRevision` 成功后触发。 + +## 5. 批注权限矩阵 + +| 状态 | 可新增批注 | +|---|---| +| drafting | author, reviewer | +| reviewing | reviewer, author(回应) | +| approving | approver, reviewer | +| approved | 禁止新增(`ErrDocumentLocked`) | +| viewer | 恒只读 | + +`POST /api/v1/docgov/comments/{id}/resolve`:author 在 drafting 可 resolved;reviewer 可 reopen。 + +## 6. 重开审批 + +`handleProjectTraceReopenApprove`:文档 `ApproverID` 且成员仍 active → 可批;全部批准人缺位/退出 → 项目创建人兜底。前端 approved 态提供「申请再修订」。 + +## 7. 明确不做 + +- 不新增第五状态、不改 `IsLocked` 语义、不引入批量审批委托、不动 FAV 归档链路。 diff --git a/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md b/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md new file mode 100644 index 00000000..4440891d --- /dev/null +++ b/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md @@ -0,0 +1,69 @@ +# 受控文档三权流转控制完善 测试报告 + +## 一、测试背景 (Background) + +补齐受控文档审核/批准/归档/重开链路与锁定期补充积压闭环(G1–G4),对应设计见 `.doc/system-design/2026-09-25-doc-review-lifecycle-design.md`。 + +## 二、测试目标 (Test Objectives) + +1. 锁定态补充 → `skipped_locked` + 积压落账,不生成提案。 +2. 流转通知收件人按职责槽位路由。 +3. 批注阶段化权限矩阵(负例:viewer / approving 期 author)。 +4. 重开审批:Approver 优先,缺位回退创建人(代码路径;HTTP 集成待环境)。 +5. 前端批注策略与 API 形状。 + +## 三、测试方案与环境 (Test Plan & Environment) + +- OS: Linux;Go 短测;前端 `tsx --test`。 +- 层级:工程单元 / 包级短测(验证契约「工程工具验收补充」)。 +- **真实 PI E2E**:本回合未获授权共享环境与产品 Runner 槽位,记为 **未运行**(不得冒充 PASS)。 + +## 四、预期结果 (Expected Results) + +见各用例断言。 + +## 五、实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 Go:`internal/docgov` / `supplementimpact` / `app/docnotify` + +- **测试输入**:`go test ./internal/docgov/... ./internal/supplementimpact/... ./internal/app/docnotify/... -count=1` +- **实际输出**: + +```text +ok chat-agent-system/internal/docgov 0.003s +ok chat-agent-system/internal/supplementimpact 0.002s +ok chat-agent-system/internal/app/docnotify 0.002s +``` + +- **评价判定**:PASS(含 `TestDecideLockedSkipsAndRecordsPending`、`TestAddCommentStageMatrix`、`TestRecipientForDocEvent`、`TestHandleReviewRequestedRoutesToReviewer`) +- **性能与耗时**:约 1–2s 量级冷启动后单包毫秒级 + +### 5.2 Go:`internal/server` short + +- **测试输入**:`go test ./internal/server/ -count=1 -short` +- **实际输出**:`ok chat-agent-system/internal/server`(约 19s) +- **评价判定**:PASS + +### 5.3 前端 commentPolicy + +- **测试输入**:`./node_modules/.bin/tsx --test src/lib/commentPolicy.test.ts` +- **实际输出**:4 tests pass(stage matrix / viewer / resolve gates) +- **评价判定**:PASS + +### 5.4 真实 PI E2E 全链 + +- **评价判定**:未运行(待授权环境:编制→送审→锁定批注→驳回→积压消化→再送审→批准→归档→重开) + +## 六、自动化测试套件汇总 (Test Suite Execution) + +| 套件 | 结果 | +|---|---| +| `go test` docgov / supplementimpact / docnotify | PASS | +| `go test ./internal/server -short` | PASS | +| `commentPolicy.test.ts` | PASS | +| 产品 PI E2E | 未运行 | + +## 七、测试结论与后续优化建议 (Conclusion & Next Steps) + +- 单元与短测覆盖 G1–G3 主路径与负例;G4 审批人逻辑已落代码,需在获授权环境用真实成员/退出成员补 HTTP 验收。 +- 合入后建议:跑 goose `00072`、再跑一次真实 PI 全链并回填本节 5.4。 diff --git a/deploy/postgres/goose/00072_2026-09-25-document-pending-supplements.sql b/deploy/postgres/goose/00072_2026-09-25-document-pending-supplements.sql new file mode 100644 index 00000000..ee092f5b --- /dev/null +++ b/deploy/postgres/goose/00072_2026-09-25-document-pending-supplements.sql @@ -0,0 +1,26 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS document_pending_supplements ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + source_summary TEXT NOT NULL DEFAULT '', + impact_reason TEXT NOT NULL DEFAULT '', + initiator_user_id BIGINT NOT NULL DEFAULT 0, + batch_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + digested_at TIMESTAMPTZ, + decided_by BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_dps_status CHECK (status IN ('pending', 'digested', 'discarded')) +); + +CREATE INDEX IF NOT EXISTS idx_dps_doc_status ON document_pending_supplements (doc_id, status); +CREATE INDEX IF NOT EXISTS idx_dps_project_status ON document_pending_supplements (project_id, status); + +ALTER TABLE supplement_impact_items + ADD COLUMN IF NOT EXISTS locked_snapshot BOOLEAN NOT NULL DEFAULT FALSE; + +-- +goose Down +-- record retention: do not drop tables +SELECT 1; diff --git a/internal/app/docnotify/consumer.go b/internal/app/docnotify/consumer.go index ee3422c0..46f0d24e 100644 --- a/internal/app/docnotify/consumer.go +++ b/internal/app/docnotify/consumer.go @@ -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 "", "", "" } diff --git a/internal/app/docnotify/lifecycle.go b/internal/app/docnotify/lifecycle.go new file mode 100644 index 00000000..c152bf87 --- /dev/null +++ b/internal/app/docnotify/lifecycle.go @@ -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]) + "…" +} diff --git a/internal/app/docnotify/lifecycle_test.go b/internal/app/docnotify/lifecycle_test.go new file mode 100644 index 00000000..ea60529d --- /dev/null +++ b/internal/app/docnotify/lifecycle_test.go @@ -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) + } +} diff --git a/internal/app/docnotify/subscriber.go b/internal/app/docnotify/subscriber.go index 15f45dfc..cd7f1c9a 100644 --- a/internal/app/docnotify/subscriber.go +++ b/internal/app/docnotify/subscriber.go @@ -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 } diff --git a/internal/app/trace_bridge.go b/internal/app/trace_bridge.go index 1a2aa2f8..cda42815 100644 --- a/internal/app/trace_bridge.go +++ b/internal/app/trace_bridge.go @@ -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 } diff --git a/internal/app/wire_supplement_impact.go b/internal/app/wire_supplement_impact.go index 9f09ca79..0ffdbb6d 100644 --- a/internal/app/wire_supplement_impact.go +++ b/internal/app/wire_supplement_impact.go @@ -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, diff --git a/internal/docgov/comment_matrix_test.go b/internal/docgov/comment_matrix_test.go new file mode 100644 index 00000000..6b73bb3b --- /dev/null +++ b/internal/docgov/comment_matrix_test.go @@ -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) + } +} diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index 3204a648..489476f0 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -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() diff --git a/internal/docgov/engine_pending.go b/internal/docgov/engine_pending.go new file mode 100644 index 00000000..5be11892 --- /dev/null +++ b/internal/docgov/engine_pending.go @@ -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) + } +} diff --git a/internal/docgov/engine_proposal.go b/internal/docgov/engine_proposal.go index 65f5450e..801da377 100644 --- a/internal/docgov/engine_proposal.go +++ b/internal/docgov/engine_proposal.go @@ -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{ diff --git a/internal/docgov/engine_review_flow.go b/internal/docgov/engine_review_flow.go index b7ac06c3..7108f50b 100644 --- a/internal/docgov/engine_review_flow.go +++ b/internal/docgov/engine_review_flow.go @@ -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 } diff --git a/internal/docgov/lock_split.go b/internal/docgov/lock_split.go index 217aea66..8cff6749 100644 --- a/internal/docgov/lock_split.go +++ b/internal/docgov/lock_split.go @@ -9,7 +9,7 @@ import ( // 全局锁只保护「分锁表」本身(短临界区);文档间操作互不阻塞。 // 锁序约定:docLocks.meta → docID(如需多文档,按 docID 字典序)。 type docLockIndex struct { - meta sync.Mutex + meta sync.Mutex locks map[string]*sync.Mutex } diff --git a/internal/notification/digest.go b/internal/notification/digest.go index 1f6b2d2f..44cdbb2e 100644 --- a/internal/notification/digest.go +++ b/internal/notification/digest.go @@ -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 } diff --git a/internal/notification/document_events.go b/internal/notification/document_events.go index 0b0ef62d..8f56a339 100644 --- a/internal/notification/document_events.go +++ b/internal/notification/document_events.go @@ -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). diff --git a/internal/server/chat_stream_get_message_test.go b/internal/server/chat_stream_get_message_test.go index 37182501..70f5e163 100644 --- a/internal/server/chat_stream_get_message_test.go +++ b/internal/server/chat_stream_get_message_test.go @@ -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) diff --git a/internal/server/handlers_docgov.go b/internal/server/handlers_docgov.go index 6d24524a..75ead6ab 100644 --- a/internal/server/handlers_docgov.go +++ b/internal/server/handlers_docgov.go @@ -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") diff --git a/internal/server/notification_handlers.go b/internal/server/notification_handlers.go index 2ce03cee..09abe4da 100644 --- a/internal/server/notification_handlers.go +++ b/internal/server/notification_handlers.go @@ -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"` } diff --git a/internal/server/runtime_accessors.go b/internal/server/runtime_accessors.go index 50876a38..d576782f 100644 --- a/internal/server/runtime_accessors.go +++ b/internal/server/runtime_accessors.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index a7eabba8..492442b8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/supplement_impact_adapter.go b/internal/server/supplement_impact_adapter.go index cff92cc2..7044b71a 100644 --- a/internal/server/supplement_impact_adapter.go +++ b/internal/server/supplement_impact_adapter.go @@ -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 diff --git a/internal/server/supplement_impact_handlers.go b/internal/server/supplement_impact_handlers.go index ab749290..37c53bac 100644 --- a/internal/server/supplement_impact_handlers.go +++ b/internal/server/supplement_impact_handlers.go @@ -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"` diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index cac46819..f59a2756 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -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, diff --git a/internal/server/trace_handlers.go b/internal/server/trace_handlers.go index c9af3dd3..21c1c0c7 100644 --- a/internal/server/trace_handlers.go +++ b/internal/server/trace_handlers.go @@ -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 diff --git a/internal/supplementimpact/http_adapter.go b/internal/supplementimpact/http_adapter.go index 39138b55..56e1d96d 100644 --- a/internal/supplementimpact/http_adapter.go +++ b/internal/supplementimpact/http_adapter.go @@ -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 diff --git a/internal/supplementimpact/pg_store.go b/internal/supplementimpact/pg_store.go index fa78debc..577b0768 100644 --- a/internal/supplementimpact/pg_store.go +++ b/internal/supplementimpact/pg_store.go @@ -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 diff --git a/internal/supplementimpact/service.go b/internal/supplementimpact/service.go index bc6a110f..9ad4cc0b 100644 --- a/internal/supplementimpact/service.go +++ b/internal/supplementimpact/service.go @@ -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 { diff --git a/internal/supplementimpact/service_test.go b/internal/supplementimpact/service_test.go index 45c42ed2..c124b364 100644 --- a/internal/supplementimpact/service_test.go +++ b/internal/supplementimpact/service_test.go @@ -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) + } +} + diff --git a/internal/supplementimpact/types.go b/internal/supplementimpact/types.go index 3e031820..be4b4b6f 100644 --- a/internal/supplementimpact/types.go +++ b/internal/supplementimpact/types.go @@ -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"` diff --git a/web/src/App.tsx b/web/src/App.tsx index 72ef1ad3..312d26de 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1393,12 +1393,14 @@ export default function App() { handleRejectReview, handleApprove, handleRejectApprove, + handleRequestReopen, } = useDocumentLifecycleActions({ docAST, setDocAST, activeDocId, currentUser, activeSessionId, + projectId: currentProjectId, setDeliverables, setMessages, }); @@ -1698,6 +1700,7 @@ export default function App() { handleRejectReview={handleRejectReview} handleApprove={handleApprove} handleRejectApprove={handleRejectApprove} + handleRequestReopen={handleRequestReopen} editorSession={editorSession} handleSaveBlockEdit={handleSaveBlockEdit} closeBlockEditor={closeBlockEditor} diff --git a/web/src/api/docgov.ts b/web/src/api/docgov.ts index 411b5797..970d1e66 100644 --- a/web/src/api/docgov.ts +++ b/web/src/api/docgov.ts @@ -180,3 +180,45 @@ export function mapLeaseToAstEditLease(doc: { expiresAt: doc.lease_expires_at, }; } + +export interface DocGovComment { + id: number; + doc_id: string; + user_id?: number; + user_name?: string; + user_role?: string; + target_section?: string; + comment_type?: string; + content: string; + status: string; + created_at?: string; +} + +export async function listDocGovComments(docId: string): Promise { + const res = await apiFetch( + `${API_BASE}/api/v1/docgov/comments?doc_id=${encodeURIComponent(docId)}`, + { headers: getBearerAuthHeaders() }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `list comments failed: ${res.status}`); + } + const data = await res.json(); + return Array.isArray(data) ? (data as DocGovComment[]) : []; +} + +export async function addDocGovComment(payload: { + doc_id: string; + target_section?: string; + comment_type?: string; + content: string; +}): Promise { + return postDocGov('/api/v1/docgov/comments', payload); +} + +export async function resolveDocGovComment( + commentId: number, + payload: { doc_id: string; resolve?: boolean }, +): Promise { + return postDocGov(`/api/v1/docgov/comments/${commentId}/resolve`, payload); +} diff --git a/web/src/api/projectTrace.ts b/web/src/api/projectTrace.ts index 2320e7dc..cc239be4 100644 --- a/web/src/api/projectTrace.ts +++ b/web/src/api/projectTrace.ts @@ -63,3 +63,56 @@ export async function clearRecheckMark( throw new Error(`关闭待复核失败: ${res.status}`); } } + +export interface ReopenRequestPayload { + instance_id: number; + stage_key: string; + doc_task_keys: string[]; + reason: string; +} + +export interface ReopenRequestRecord { + id: number; + project_id: number; + instance_id?: number; + stage_key: string; + doc_task_keys?: string[]; + reason?: string; + status: string; + requested_by?: number; +} + +export async function requestReopen( + projectId: number, + body: ReopenRequestPayload, +): Promise { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/trace/reopen`, { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `申请再修订失败: ${res.status}`); + } + return res.json(); +} + +export async function approveReopen( + projectId: number, + requestId: number, +): Promise { + const res = await apiFetch( + `${API_BASE}/api/v1/projects/${projectId}/trace/reopen/${requestId}/approve`, + { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: '{}', + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `批准再修订失败: ${res.status}`); + } + return res.json(); +} diff --git a/web/src/components/doc/CommentSidePanel.tsx b/web/src/components/doc/CommentSidePanel.tsx index b0fb95b4..2f558d0c 100644 --- a/web/src/components/doc/CommentSidePanel.tsx +++ b/web/src/components/doc/CommentSidePanel.tsx @@ -8,6 +8,7 @@ interface CommentSidePanelProps { block: DocumentBlock; currentRole: UserRole; currentUserName: string; + docLifecycleState?: string; onAddComment: (comment: Omit) => void; onClose: () => void; } @@ -16,6 +17,7 @@ export const CommentSidePanel: React.FC = ({ block, currentRole, currentUserName, + docLifecycleState = 'drafting', onAddComment, onClose, }) => { @@ -23,7 +25,7 @@ export const CommentSidePanel: React.FC = ({ const [type, setType] = useState<'suggestion' | 'issue' | 'approval'>('suggestion'); const comments = block.comments || []; - const canCompose = canAddBlockComment(currentRole); + const canCompose = canAddBlockComment(currentRole, docLifecycleState); const handleSend = () => { if (!canCompose || !content.trim()) return; @@ -32,7 +34,7 @@ export const CommentSidePanel: React.FC = ({ onAddComment({ authorName: currentUserName, - authorRole, + authorRole: authorRole === 'author' ? 'reviewer' : authorRole, type, content: content.trim(), resolved: false, diff --git a/web/src/components/doc/DocGovActionBar.tsx b/web/src/components/doc/DocGovActionBar.tsx index d11b8284..c7ade995 100644 --- a/web/src/components/doc/DocGovActionBar.tsx +++ b/web/src/components/doc/DocGovActionBar.tsx @@ -29,6 +29,7 @@ interface DocGovActionBarProps { onRejectApprove: (reason: string) => void; onShareToProject?: (docId: string) => void; onRevokeShare?: (docId: string) => void; + onRequestReopen?: () => void; } export const DocGovActionBar: React.FC = ({ @@ -45,6 +46,7 @@ export const DocGovActionBar: React.FC = ({ onRejectApprove, onShareToProject, onRevokeShare, + onRequestReopen, }) => { const [rejectModalOpen, setRejectModalOpen] = useState(false); const [rejectReason, setRejectReason] = useState(''); @@ -637,7 +639,7 @@ export const DocGovActionBar: React.FC = ({ style={{ display: 'flex', alignItems: 'center', - gap: '6px', + gap: '8px', color: 'var(--success)', fontSize: '12.5px', fontWeight: 600, @@ -645,6 +647,28 @@ export const DocGovActionBar: React.FC = ({ > 受控归档交付物 (已沉淀为全局标杆经验) + {onRequestReopen && + (currentUserId === ast.authorId || + currentUserId === ast.approverId || + currentUserId === ast.creatorId) && ( + + )} )} diff --git a/web/src/components/doc/DocumentPane.tsx b/web/src/components/doc/DocumentPane.tsx index e1ccf26c..accb8a69 100644 --- a/web/src/components/doc/DocumentPane.tsx +++ b/web/src/components/doc/DocumentPane.tsx @@ -70,6 +70,7 @@ export interface DocumentPaneProps { handleRejectReview: (reason: string) => void | Promise; handleApprove: () => void | Promise; handleRejectApprove: (reason: string) => void | Promise; + handleRequestReopen?: () => void | Promise; editorSession: BlockEditorSession | null; handleSaveBlockEdit: (updated: DocumentBlock) => void | Promise; closeBlockEditor: () => void; @@ -122,6 +123,7 @@ export function DocumentPane(props: DocumentPaneProps) { handleRejectReview, handleApprove, handleRejectApprove, + handleRequestReopen, editorSession, handleSaveBlockEdit, closeBlockEditor, @@ -390,6 +392,7 @@ isCurrentProjectExited ? ( onRejectApprove={handleRejectApprove} onShareToProject={handleShareToProject} onRevokeShare={handleRevokeShare} + onRequestReopen={handleRequestReopen} /> )} @@ -413,6 +416,7 @@ isCurrentProjectExited ? ( block={commentingBlock} currentRole={currentDocRole} currentUserName={currentUser.name} + docLifecycleState={docAST.lifecycleState} onAddComment={handleAddComment} onClose={() => setCommentingBlock(null)} /> diff --git a/web/src/hooks/useBlockEditing.ts b/web/src/hooks/useBlockEditing.ts index 2015ac19..5c8a99cd 100644 --- a/web/src/hooks/useBlockEditing.ts +++ b/web/src/hooks/useBlockEditing.ts @@ -10,6 +10,7 @@ import type { BlockEditorSession } from '../lib/blockSavePolicy'; import type { BlockComment, DocumentAST, DocumentBlock } from '../types/block'; import type { UserProfile, UserRole } from '../types/docgov'; import type { DeliverableItem } from '../types/workbench'; +import { addDocGovComment } from '../api/docgov'; type Gate = ReturnType; @@ -124,14 +125,29 @@ export function useBlockEditing(input: BlockEditingInput): BlockEditingActions { }; const handleAddComment = (comment: Omit) => { - if (!commentingBlock || !canAddBlockComment(currentDocRole)) return; - const newCmt: BlockComment = { - ...comment, - id: 'cmt_' + Math.random().toString(36).substring(2, 8), - createdAt: new Date().toTimeString().substring(0, 5), - }; - commentingBlock.comments = [...(commentingBlock.comments || []), newCmt]; - setDocAST({ ...docAST }); + if (!commentingBlock || !canAddBlockComment(currentDocRole, docAST.lifecycleState || 'drafting')) return; + void (async () => { + try { + const saved = await addDocGovComment({ + doc_id: activeDocId, + target_section: commentingBlock.id, + comment_type: comment.type || 'suggestion', + content: comment.content, + }); + const newCmt: BlockComment = { + ...comment, + id: String(saved.id || 'cmt_' + Math.random().toString(36).substring(2, 8)), + createdAt: saved.created_at + ? new Date(saved.created_at).toTimeString().substring(0, 5) + : new Date().toTimeString().substring(0, 5), + resolved: saved.status === 'resolved', + }; + commentingBlock.comments = [...(commentingBlock.comments || []), newCmt]; + setDocAST({ ...docAST }); + } catch (err) { + console.error('add comment failed', err); + } + })(); }; return { diff --git a/web/src/hooks/useDocumentLifecycleActions.ts b/web/src/hooks/useDocumentLifecycleActions.ts index 948178e4..8c0848f1 100644 --- a/web/src/hooks/useDocumentLifecycleActions.ts +++ b/web/src/hooks/useDocumentLifecycleActions.ts @@ -7,6 +7,8 @@ import { submitForReview, type DocGovRecord, } from '../api/docgov'; +import { requestReopen } from '../api/projectTrace'; +import { fetchProjectSettings } from '../api/projectSettings'; import type { DocumentAST } from '../types/block'; import type { DocLifecycleState, UserProfile } from '../types/docgov'; import type { ChatMessage, DeliverableItem } from '../types/workbench'; @@ -17,6 +19,8 @@ export interface DocumentLifecycleInput { activeDocId: string; currentUser: UserProfile; activeSessionId: string; + projectId?: number; + workflowInstanceId?: number; setDeliverables: Dispatch>; setMessages: Dispatch>; } @@ -28,6 +32,7 @@ export interface DocumentLifecycleActions { handleRejectReview: (reason: string) => Promise; handleApprove: () => Promise; handleRejectApprove: (reason: string) => Promise; + handleRequestReopen: () => Promise; } export function useDocumentLifecycleActions(input: DocumentLifecycleInput): DocumentLifecycleActions { @@ -37,6 +42,8 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu activeDocId, currentUser, activeSessionId, + projectId, + workflowInstanceId, setDeliverables, setMessages, } = input; @@ -226,6 +233,55 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu } }; + const handleRequestReopen = async () => { + if (!projectId || projectId <= 0) { + pushDocGovError('申请再修订', new Error('缺少项目上下文')); + return; + } + const reason = window.prompt('请输入再修订原因(将通知原批准人审批)'); + if (!reason || !reason.trim()) return; + try { + let instanceId = workflowInstanceId || 0; + let stageKey = docAST.version || ''; + try { + const settings = await fetchProjectSettings(projectId); + if (settings?.live_instance?.id) { + instanceId = settings.live_instance.id; + } + if (settings?.active_stage_key) { + stageKey = settings.active_stage_key; + } + } catch { + /* keep fallbacks */ + } + if (!stageKey) stageKey = 'V0.5'; + const stored = await requestReopen(projectId, { + instance_id: instanceId, + stage_key: stageKey, + doc_task_keys: [docAST.taskKey], + reason: reason.trim(), + }); + setMessages((prev) => [ + ...prev, + { + id: 'reopen-' + Date.now(), + sessionId: activeSessionId, + role: 'system', + content: + stored.status === 'approved' + ? `《${docAST.title}》再修订申请已自动批准,文档已回编制期。` + : `已提交《${docAST.title}》再修订申请(#${stored.id}),等待原批准人审批。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + if (stored.status === 'approved') { + setDocAST({ ...docAST, lifecycleState: 'drafting' as DocLifecycleState, isClaimed: docAST.isClaimed }); + } + } catch (err) { + pushDocGovError('申请再修订', err); + } + }; + return { handleClaim, handleSubmitReview, @@ -233,5 +289,6 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu handleRejectReview, handleApprove, handleRejectApprove, + handleRequestReopen, }; } diff --git a/web/src/lib/commentPolicy.test.ts b/web/src/lib/commentPolicy.test.ts index 17881a73..28c3dd0e 100644 --- a/web/src/lib/commentPolicy.test.ts +++ b/web/src/lib/commentPolicy.test.ts @@ -2,21 +2,36 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { canAddBlockComment, + canResolveComment, resolveCommentAuthorRole, } from './commentPolicy'; describe('commentPolicy', () => { - it('lets author/reviewer/approver annotate and keeps their comment role', () => { - assert.equal(canAddBlockComment('author'), true); - assert.equal(canAddBlockComment('reviewer'), true); - assert.equal(canAddBlockComment('approver'), true); - assert.equal(resolveCommentAuthorRole('author'), 'reviewer'); + it('applies stage matrix for add', () => { + assert.equal(canAddBlockComment('author', 'drafting'), true); + assert.equal(canAddBlockComment('reviewer', 'drafting'), true); + assert.equal(canAddBlockComment('approver', 'drafting'), false); + assert.equal(canAddBlockComment('author', 'reviewing'), true); + assert.equal(canAddBlockComment('approver', 'reviewing'), false); + assert.equal(canAddBlockComment('approver', 'approving'), true); + assert.equal(canAddBlockComment('author', 'approving'), false); + assert.equal(canAddBlockComment('reviewer', 'approved'), false); + }); + + it('keeps viewer read-only', () => { + assert.equal(canAddBlockComment('viewer', 'drafting'), false); + assert.equal(resolveCommentAuthorRole('viewer'), null); + }); + + it('resolves author role literally', () => { + assert.equal(resolveCommentAuthorRole('author'), 'author'); assert.equal(resolveCommentAuthorRole('reviewer'), 'reviewer'); assert.equal(resolveCommentAuthorRole('approver'), 'approver'); }); - it('keeps viewer read-only: no add entry and no reviewer/approver cast', () => { - assert.equal(canAddBlockComment('viewer'), false); - assert.equal(resolveCommentAuthorRole('viewer'), null); + it('gates resolve by stage', () => { + assert.equal(canResolveComment('author', 'drafting'), true); + assert.equal(canResolveComment('author', 'reviewing'), false); + assert.equal(canResolveComment('reviewer', 'reviewing'), true); }); }); diff --git a/web/src/lib/commentPolicy.ts b/web/src/lib/commentPolicy.ts index de1d1a83..4d45b01b 100644 --- a/web/src/lib/commentPolicy.ts +++ b/web/src/lib/commentPolicy.ts @@ -1,15 +1,42 @@ import type { UserRole } from '../types/docgov'; -export type CommentAuthorRole = 'reviewer' | 'approver'; +export type CommentAuthorRole = 'reviewer' | 'approver' | 'author'; +export type DocLifecycleState = 'drafting' | 'reviewing' | 'approving' | 'approved' | string; -/** viewer 只能读批注,不能打开新增入口或提交。 */ -export function canAddBlockComment(role: UserRole | string): boolean { - return role === 'author' || role === 'reviewer' || role === 'approver'; +/** Stage-scoped comment matrix (viewer always read-only). */ +export function canAddBlockComment( + role: UserRole | string, + state: DocLifecycleState = 'drafting', +): boolean { + if (role === 'viewer' || role === 'system') { + return role === 'system'; + } + switch (state) { + case 'drafting': + return role === 'author' || role === 'reviewer'; + case 'reviewing': + return role === 'reviewer' || role === 'author'; + case 'approving': + return role === 'approver' || role === 'reviewer'; + case 'approved': + return false; + default: + return role === 'author' || role === 'reviewer' || role === 'approver'; + } } -/** author 的批注按既有语义记为 reviewer;viewer 不得被 cast。 */ +/** Persist author role as-is when author comments; otherwise reviewer/approver. */ export function resolveCommentAuthorRole(role: UserRole | string): CommentAuthorRole | null { if (role === 'approver') return 'approver'; - if (role === 'reviewer' || role === 'author') return 'reviewer'; + if (role === 'reviewer') return 'reviewer'; + if (role === 'author') return 'author'; return null; } + +export function canResolveComment( + role: UserRole | string, + state: DocLifecycleState, +): boolean { + if (role === 'reviewer') return true; + return role === 'author' && state === 'drafting'; +}