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