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
@@ -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 归档链路。
@@ -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。
@@ -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;
+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"`
+3
View File
@@ -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}
+42
View File
@@ -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<DocGovComment[]> {
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<DocGovComment> {
return postDocGov('/api/v1/docgov/comments', payload);
}
export async function resolveDocGovComment(
commentId: number,
payload: { doc_id: string; resolve?: boolean },
): Promise<DocGovComment> {
return postDocGov(`/api/v1/docgov/comments/${commentId}/resolve`, payload);
}
+53
View File
@@ -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<ReopenRequestRecord> {
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<ReopenRequestRecord> {
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();
}
+4 -2
View File
@@ -8,6 +8,7 @@ interface CommentSidePanelProps {
block: DocumentBlock;
currentRole: UserRole;
currentUserName: string;
docLifecycleState?: string;
onAddComment: (comment: Omit<BlockComment, 'id' | 'createdAt'>) => void;
onClose: () => void;
}
@@ -16,6 +17,7 @@ export const CommentSidePanel: React.FC<CommentSidePanelProps> = ({
block,
currentRole,
currentUserName,
docLifecycleState = 'drafting',
onAddComment,
onClose,
}) => {
@@ -23,7 +25,7 @@ export const CommentSidePanel: React.FC<CommentSidePanelProps> = ({
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<CommentSidePanelProps> = ({
onAddComment({
authorName: currentUserName,
authorRole,
authorRole: authorRole === 'author' ? 'reviewer' : authorRole,
type,
content: content.trim(),
resolved: false,
+25 -1
View File
@@ -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<DocGovActionBarProps> = ({
@@ -45,6 +46,7 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
onRejectApprove,
onShareToProject,
onRevokeShare,
onRequestReopen,
}) => {
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
@@ -637,7 +639,7 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
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<DocGovActionBarProps> = ({
>
<Award size={16} />
<span>受控归档交付物 (已沉淀为全局标杆经验)</span>
{onRequestReopen &&
(currentUserId === ast.authorId ||
currentUserId === ast.approverId ||
currentUserId === ast.creatorId) && (
<button
onClick={onRequestReopen}
style={{
marginLeft: '8px',
padding: '5px 10px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '11.5px',
fontWeight: 600,
cursor: 'pointer',
}}
title="向原批准人申请将文档重开至编制期"
>
申请再修订
</button>
)}
</div>
)}
</div>
+4
View File
@@ -70,6 +70,7 @@ export interface DocumentPaneProps {
handleRejectReview: (reason: string) => void | Promise<void>;
handleApprove: () => void | Promise<void>;
handleRejectApprove: (reason: string) => void | Promise<void>;
handleRequestReopen?: () => void | Promise<void>;
editorSession: BlockEditorSession | null;
handleSaveBlockEdit: (updated: DocumentBlock) => void | Promise<void>;
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)}
/>
+24 -8
View File
@@ -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<typeof createEditorHydrationGate>;
@@ -124,14 +125,29 @@ export function useBlockEditing(input: BlockEditingInput): BlockEditingActions {
};
const handleAddComment = (comment: Omit<BlockComment, 'id' | 'createdAt'>) => {
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 {
@@ -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<SetStateAction<DeliverableItem[]>>;
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
}
@@ -28,6 +32,7 @@ export interface DocumentLifecycleActions {
handleRejectReview: (reason: string) => Promise<void>;
handleApprove: () => Promise<void>;
handleRejectApprove: (reason: string) => Promise<void>;
handleRequestReopen: () => Promise<void>;
}
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,
};
}
+23 -8
View File
@@ -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);
});
});
+33 -6
View File
@@ -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';
}