From 143ee934b809b0b7f502a1b659ea61cfdaa25088 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 11:49:43 +0000 Subject: [PATCH] feat(collab): free-lane chat, claim-on-view, lease ownership, notify inbox Open member free-lane for chat initiation; auto-claim on author view; harden lease persist/ownership with readable DOC_BUSY; add notification inbox and supplement-impact HTTP decision APIs wired to composition root. --- internal/docgov/engine_claim.go | 20 +- internal/docgov/lease.go | 96 ++++- internal/docgov/lease_test.go | 17 +- internal/docgov/state_machine.go | 18 +- internal/docgov/types.go | 13 +- .../access_governing_pg_test.go | 14 +- internal/projectinstance/document_access.go | 3 +- .../projectinstance/document_access_test.go | 27 +- internal/projectinstance/duty_action.go | 24 +- internal/projectinstance/duty_action_test.go | 45 ++- internal/projectinstance/stage_access.go | 31 +- internal/projectinstance/stage_access_test.go | 32 +- internal/server/bizdev_stage_gate.go | 4 +- internal/server/document_revision_handlers.go | 3 +- .../document_revision_patch_order_test.go | 21 +- internal/server/domain_http.go | 5 +- internal/server/domain_http_test.go | 9 + internal/server/free_lane.go | 75 ++++ internal/server/free_lane_test.go | 31 ++ internal/server/handlers.go | 1 + internal/server/handlers_chat_task.go | 2 +- internal/server/handlers_docgov.go | 25 +- internal/server/handlers_docgov_lease.go | 38 +- internal/server/lease_owner.go | 27 ++ ...on_author_member_access_piagent_pg_test.go | 254 +++++++++++++ internal/server/notification_handlers.go | 211 +++++++++++ internal/server/notification_list_all_test.go | 55 +++ internal/server/project_doc_handlers.go | 1 + internal/server/server.go | 59 +-- internal/server/sse.go | 4 +- internal/server/supplement_impact_adapter.go | 58 +++ internal/server/supplement_impact_handlers.go | 162 ++++++++ internal/server/task_run_handlers.go | 7 +- web/src/api/docgov.ts | 1 + web/src/api/notifications.test.ts | 46 +++ web/src/api/notifications.ts | 154 ++++++++ .../components/chat/InteractiveCard.test.ts | 4 + web/src/components/chat/InteractiveCard.tsx | 93 +++++ web/src/components/doc/DocGovActionBar.tsx | 29 ++ web/src/components/layout/Topbar.tsx | 3 + .../notifications/NotificationInbox.tsx | 349 ++++++++++++++++++ web/src/engine/templateMatch.test.ts | 17 + web/src/engine/templateMatch.ts | 5 +- web/src/hooks/useCardActions.ts | 102 +++++ web/src/hooks/useDocumentLifecycleActions.ts | 1 + web/src/hooks/useSendMessage.test.ts | 3 +- web/src/hooks/useSendMessage.ts | 130 +++---- web/src/lib/taskRunErrorInfo.test.ts | 16 + web/src/lib/taskRunErrorInfo.ts | 37 +- web/src/types/block.ts | 2 + web/src/types/workbench.ts | 16 +- 51 files changed, 2170 insertions(+), 230 deletions(-) create mode 100644 internal/server/free_lane.go create mode 100644 internal/server/free_lane_test.go create mode 100644 internal/server/lease_owner.go create mode 100644 internal/server/non_author_member_access_piagent_pg_test.go create mode 100644 internal/server/notification_list_all_test.go create mode 100644 internal/server/supplement_impact_adapter.go create mode 100644 internal/server/supplement_impact_handlers.go create mode 100644 web/src/api/notifications.test.ts create mode 100644 web/src/api/notifications.ts create mode 100644 web/src/components/notifications/NotificationInbox.tsx diff --git a/internal/docgov/engine_claim.go b/internal/docgov/engine_claim.go index 03492936..9f539345 100644 --- a/internal/docgov/engine_claim.go +++ b/internal/docgov/engine_claim.go @@ -49,13 +49,19 @@ func (e *GovernanceEngine) RecordDocumentViewed(ctx context.Context, docID strin auditAction = "author_claim_by_view" } case RoleReviewer: - doc.ReviewerClaimed = true - doc.ReviewerClaimedAt = &now - auditAction = "reviewer_claim_by_view" + auditAction = "reviewer_view" + if !doc.IsTemplateOnly && !doc.ReviewerClaimed { + doc.ReviewerClaimed = true + doc.ReviewerClaimedAt = &now + auditAction = "reviewer_claim_by_view" + } case RoleApprover: - doc.ApproverClaimed = true - doc.ApproverClaimedAt = &now - auditAction = "approver_claim_by_view" + auditAction = "approver_view" + if !doc.IsTemplateOnly && !doc.ApproverClaimed { + doc.ApproverClaimed = true + doc.ApproverClaimedAt = &now + auditAction = "approver_claim_by_view" + } default: auditAction = "view" } @@ -122,7 +128,7 @@ func (e *GovernanceEngine) RecordDocumentEdited(ctx context.Context, docID strin } } - if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole); err != nil { + if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole, false); err != nil { return nil, err } diff --git a/internal/docgov/lease.go b/internal/docgov/lease.go index 395a951d..1d4c0d73 100644 --- a/internal/docgov/lease.go +++ b/internal/docgov/lease.go @@ -32,6 +32,7 @@ type ContentSaveRequest struct { ActorID int64 ActorRole UserRole ActorName string + ActorIsMember bool // server 层已用 authorizer 校验的项目成员;不得用 RoleSystem 冒充 MarkdownBody string ExpectedSHA256 string // 乐观并发:非空时必须匹配当前 ContentSHA256 FencingToken string @@ -83,13 +84,15 @@ func (e *GovernanceEngine) AcquireLease(ctx context.Context, req LeaseAcquireReq "holder_id": doc.LeaseHolderID, "expires_at": doc.LeaseExpiresAt, }) + display := LeaseHolderDisplay(doc.LeaseHolderType, doc.LeaseHolderID, "") return doc, &DocumentBusyError{ - DocID: doc.DocID, - HolderType: doc.LeaseHolderType, - HolderID: doc.LeaseHolderID, - WorkDir: doc.LeaseWorkDir, - ExpiresAt: *doc.LeaseExpiresAt, - Reason: fmt.Sprintf("文档【%s】正在被 %s(%s) 编辑中,请稍后再试", doc.Title, doc.LeaseHolderType, doc.LeaseHolderID), + DocID: doc.DocID, + HolderType: doc.LeaseHolderType, + HolderID: doc.LeaseHolderID, + HolderDisplayName: display, + WorkDir: doc.LeaseWorkDir, + ExpiresAt: *doc.LeaseExpiresAt, + Reason: FormatDocumentBusyReason(doc.Title, display, *doc.LeaseExpiresAt), } } } @@ -330,21 +333,22 @@ func clampLeaseTTL(holder LeaseHolderType, ttl time.Duration) time.Duration { return ttl } -// CheckHumanCanEdit verifies author duty and drafting state without taking a lease. -func (e *GovernanceEngine) CheckHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole) error { +// CheckHumanCanEdit verifies drafting state and edit permission without taking a lease. +// actorIsMember must be set by the server after project-membership / DocAccessActionEdit checks. +func (e *GovernanceEngine) CheckHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorIsMember bool) error { e.mu.Lock() defer e.mu.Unlock() doc, err := e.getDocLocked(ctx, docID) if err != nil { return err } - return e.sm.CanEditDocumentContent(doc, actorID, actorRole) + return e.sm.CanEditDocumentContent(doc, actorID, actorRole, actorIsMember) } -// AssertHumanCanEdit checks author duty + drafting lifecycle BEFORE acquiring/renewing +// AssertHumanCanEdit checks edit permission + drafting lifecycle BEFORE acquiring/renewing // the human lease, so unauthorized actors never become lease holders. -func (e *GovernanceEngine) AssertHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole) error { - if err := e.CheckHumanCanEdit(ctx, docID, actorID, actorRole); err != nil { +func (e *GovernanceEngine) AssertHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorIsMember bool) error { + if err := e.CheckHumanCanEdit(ctx, docID, actorID, actorRole, actorIsMember); err != nil { return err } @@ -391,7 +395,7 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS return nil, err } - if err := e.sm.CanEditDocumentContent(doc, req.ActorID, req.ActorRole); err != nil { + if err := e.sm.CanEditDocumentContent(doc, req.ActorID, req.ActorRole, req.ActorIsMember); err != nil { return nil, err } @@ -474,21 +478,85 @@ func (e *GovernanceEngine) getDocLocked(ctx context.Context, docID string) (*Doc return doc, nil } + +// LeaseHolderDisplay returns a user-facing holder label. +// squad → 项目助手; human → display name if provided, else "其他用户". +func LeaseHolderDisplay(holderType LeaseHolderType, holderID, displayName string) string { + switch holderType { + case LeaseHolderSquad: + return "项目助手" + case LeaseHolderHuman: + if strings.TrimSpace(displayName) != "" { + return strings.TrimSpace(displayName) + } + return "其他用户" + default: + if strings.TrimSpace(displayName) != "" { + return strings.TrimSpace(displayName) + } + return string(holderType) + } +} + +// FormatDocumentBusyReason builds the user-visible busy copy. +func FormatDocumentBusyReason(title, holderDisplay string, expiresAt time.Time) string { + remain := "稍后" + if !expiresAt.IsZero() { + m := int(time.Until(expiresAt).Minutes()) + if m < 1 { + m = 1 + } + remain = fmt.Sprintf("%d 分钟后", m) + } + t := strings.TrimSpace(title) + if t == "" { + t = "文档" + } + h := strings.TrimSpace(holderDisplay) + if h == "" { + h = "其他任务" + } + return fmt.Sprintf("《%s》正在修改中(由%s发起,预计 %s释放)。您的修改请求未执行,请稍后重试。", t, h, remain) +} + func (e *GovernanceEngine) persistLeaseLocked(ctx context.Context, doc *DocumentRecord) error { if e.pool == nil { return nil } - _, err := e.pool.Exec(ctx, ` + // Release/clear: write empty lease unconditionally (caller already authorized). + if doc.LeaseHolderType == "" || doc.LeaseHolderType == LeaseHolderNone { + _, err := e.pool.Exec(ctx, ` + UPDATE document_records + SET lease_holder_type = '', lease_holder_id = '', lease_work_dir = $1, + lease_expires_at = NULL, lease_heartbeat_at = NULL, lease_fencing_token = '', updated_at = $2 + WHERE doc_id = $3 + `, doc.LeaseWorkDir, doc.UpdatedAt, doc.DocID) + if err != nil { + slog.Error("docgov_lease_clear_persist_failed", "doc_id", doc.DocID, "error", err) + return fmt.Errorf("docgov: persist lease clear: %w", err) + } + return nil + } + // Acquire/renew: conditional — free, expired, or same holder only. + tag, err := e.pool.Exec(ctx, ` UPDATE document_records SET lease_holder_type = $1, lease_holder_id = $2, lease_work_dir = $3, lease_expires_at = $4, lease_heartbeat_at = $5, lease_fencing_token = $6, updated_at = $7 WHERE doc_id = $8 + AND ( + lease_holder_type = '' OR lease_holder_type = 'none' + OR lease_expires_at IS NULL OR lease_expires_at < now() + OR (lease_holder_type = $1 AND lease_holder_id = $2) + ) `, string(doc.LeaseHolderType), doc.LeaseHolderID, doc.LeaseWorkDir, doc.LeaseExpiresAt, doc.LeaseHeartbeatAt, doc.LeaseFencingToken, doc.UpdatedAt, doc.DocID) if err != nil { slog.Error("docgov_lease_persist_failed", "doc_id", doc.DocID, "holder", doc.LeaseHolderID, "error", err) return fmt.Errorf("docgov: persist lease: %w", err) } + if tag.RowsAffected() == 0 { + return ErrDocumentBusy + } return nil } diff --git a/internal/docgov/lease_test.go b/internal/docgov/lease_test.go index 7b47df5d..f440250c 100644 --- a/internal/docgov/lease_test.go +++ b/internal/docgov/lease_test.go @@ -128,8 +128,8 @@ func TestAssertHumanCanEdit_ChecksDutyBeforeLease(t *testing.T) { t.Fatal(err) } - // Non-author must fail without becoming lease holder. - if err := engine.AssertHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor); err == nil { + // Non-author without member flag must fail without becoming lease holder. + if err := engine.AssertHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor, false); err == nil { t.Fatal("non-author AssertHumanCanEdit must fail") } doc, err := engine.GetDocument(ctx, docID) @@ -140,7 +140,13 @@ func TestAssertHumanCanEdit_ChecksDutyBeforeLease(t *testing.T) { t.Fatal("unauthorized actor must not hold human lease") } - if err := engine.AssertHumanCanEdit(ctx, docID, 1, docgov.RoleAuthor); err != nil { + // Authorized member (non-author) may edit under deny_only_submit. + if err := engine.AssertHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor, true); err != nil { + t.Fatalf("member AssertHumanCanEdit: %v", err) + } + _, _ = engine.ReleaseLease(ctx, docID, string(docgov.LeaseHolderHuman), "99") + + if err := engine.AssertHumanCanEdit(ctx, docID, 1, docgov.RoleAuthor, false); err != nil { t.Fatalf("author AssertHumanCanEdit: %v", err) } doc, err = engine.GetDocument(ctx, docID) @@ -173,7 +179,10 @@ func TestAcquireLeaseClampsHumanTTL(t *testing.T) { if doc.LeaseExpiresAt == nil || doc.LeaseExpiresAt.After(before.Add(docgov.DefaultHumanLeaseTTL+time.Second)) { t.Fatalf("ttl not clamped: expires=%v", doc.LeaseExpiresAt) } - if err := engine.CheckHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor); err == nil { + if err := engine.CheckHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor, false); err == nil { t.Fatal("non-author must not pass edit check") } + if err := engine.CheckHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor, true); err != nil { + t.Fatalf("member must pass edit check: %v", err) + } } diff --git a/internal/docgov/state_machine.go b/internal/docgov/state_machine.go index 5f97cfad..9dbed123 100644 --- a/internal/docgov/state_machine.go +++ b/internal/docgov/state_machine.go @@ -16,8 +16,10 @@ func NewStateMachine() *StateMachine { // CanEditDocumentContent 权限守卫 1:校验当前操作人是否有权修改文档正文 // 规则: // 1. 文档处于锁定状态 (reviewing, approving, approved) 时严禁直接修改 -// 2. 只有编制负责人 (Author) 有权修改文档正文;审核人和批准人严禁直接修改 -func (sm *StateMachine) CanEditDocumentContent(doc *DocumentRecord, actorID int64, actorRole UserRole) error { +// 2. 审核人/批准人角色严禁直接修改 +// 3. 编制负责人或已授权项目成员(ActorIsMember,由 server 层校验后传入)可在 drafting 未锁定时写正文 +// 4. 不复用 RoleSystem:人工成员编辑不得记成智能体代编 +func (sm *StateMachine) CanEditDocumentContent(doc *DocumentRecord, actorID int64, actorRole UserRole, actorIsMember bool) error { if doc == nil { return ErrDocumentNotFound } @@ -38,15 +40,21 @@ func (sm *StateMachine) CanEditDocumentContent(doc *DocumentRecord, actorID int6 return ErrReviewerCannotEdit case RoleApprover: return ErrApproverCannotEdit + case RoleSystem: + // 系统或智能体代为编辑:在编制中且未锁定阶段放行 (认领检查在外部业务层联动) + return nil case RoleAuthor: + if actorIsMember { + return nil + } if doc.AuthorID > 0 && actorID > 0 && doc.AuthorID != actorID { return fmt.Errorf("%w: 文档编制负责人为用户ID %d,非操作人 %d", ErrOnlyAuthorCanEdit, doc.AuthorID, actorID) } return nil - case RoleSystem: - // 系统或智能体代为编辑:在编制中且未锁定阶段放行 (认领检查在外部业务层联动) - return nil default: + if actorIsMember { + return nil + } return ErrUnauthorizedActor } } diff --git a/internal/docgov/types.go b/internal/docgov/types.go index dde59916..1ecac027 100644 --- a/internal/docgov/types.go +++ b/internal/docgov/types.go @@ -107,12 +107,13 @@ func NewContentError(code string, cause error, retryable, needsAdmin bool) *Cont // DocumentBusyError 携带租约占用详情,便于 HTTP 409 与 UI 提示 type DocumentBusyError struct { - DocID string `json:"doc_id"` - HolderType LeaseHolderType `json:"holder_type"` - HolderID string `json:"holder_id"` - WorkDir string `json:"work_dir,omitempty"` - ExpiresAt time.Time `json:"expires_at"` - Reason string `json:"reason"` + DocID string `json:"doc_id"` + HolderType LeaseHolderType `json:"holder_type"` + HolderID string `json:"holder_id"` + HolderDisplayName string `json:"holder_display_name,omitempty"` + WorkDir string `json:"work_dir,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + Reason string `json:"reason"` } func (e *DocumentBusyError) Error() string { diff --git a/internal/projectinstance/access_governing_pg_test.go b/internal/projectinstance/access_governing_pg_test.go index 0d788118..eb122e42 100644 --- a/internal/projectinstance/access_governing_pg_test.go +++ b/internal/projectinstance/access_governing_pg_test.go @@ -143,13 +143,17 @@ func TestPG_GoverningAccess_ActiveCreatorDutyOutsider(t *testing.T) { if !dutyRun.Allowed { t.Fatalf("author duty must run, got %+v", dutyRun) } - outsider := svc.CheckDocTaskAccess(ctx, projectID, outsiderID, "intake", projectinstance.DocAccessActionRun, false) - if outsider.Allowed || outsider.Code != projectinstance.AccessCodeForbiddenDuty { - t.Fatalf("outsider must DOC_DUTY_FORBIDDEN, got %+v", outsider) + memberRun := svc.CheckDocTaskAccess(ctx, projectID, outsiderID, "intake", projectinstance.DocAccessActionRun, false) + if !memberRun.Allowed { + t.Fatalf("non-duty member must run, got %+v", memberRun) + } + memberSubmit := svc.CheckDocTaskAccess(ctx, projectID, outsiderID, "intake", projectinstance.DocAccessActionSubmit, false) + if memberSubmit.Allowed || memberSubmit.Code != projectinstance.AccessCodeForbiddenDuty { + t.Fatalf("non-duty member must not submit, got %+v", memberSubmit) } outsiderStage, _ := svc.ResolveStageAccess(ctx, projectID, outsiderID, false) - if !outsiderStage.Restricted || outsiderStage.TaskKeyAllowed("intake") { - t.Fatalf("outsider stage must deny, got %+v", outsiderStage) + if !outsiderStage.Restricted || !outsiderStage.TaskKeyAllowed("intake") { + t.Fatalf("member stage must allow intake run, got %+v", outsiderStage) } } diff --git a/internal/projectinstance/document_access.go b/internal/projectinstance/document_access.go index 8d1a6639..860f2d0c 100644 --- a/internal/projectinstance/document_access.go +++ b/internal/projectinstance/document_access.go @@ -31,7 +31,8 @@ type DocAccessDecision struct { // CheckDocTaskAccess 对治理实例下的文档任务做判定。 // // GetGoverningInstanceForAccess:从未激活 → view 放行、run 带 WORKFLOW_NOT_ACTIVATED(HTTP 转 202-blocked)、其余拒绝; -// completed/terminated → InstanceClosed;存储错误 fail-closed。full 文档按动作对应单一职责,创建者不隐式越权。 +// completed/terminated → InstanceClosed;存储错误 fail-closed。 +// deny_only_submit:run/edit 对项目成员放行;submit/review/approve 按对应单一职责,创建者不隐式越权。 func (s *Service) CheckDocTaskAccess( ctx context.Context, projectID int64, diff --git a/internal/projectinstance/document_access_test.go b/internal/projectinstance/document_access_test.go index 872cd564..6c4e604a 100644 --- a/internal/projectinstance/document_access_test.go +++ b/internal/projectinstance/document_access_test.go @@ -36,7 +36,7 @@ func TestCheckDocTaskAccess_DisabledDocumentRejected(t *testing.T) { } } -func TestCheckDocTaskAccess_NonDutyUserRejected(t *testing.T) { +func TestCheckDocTaskAccess_NonDutyUserCanRunButNotSubmit(t *testing.T) { ctx := context.Background() store := NewMemoryStore() _ = SeedProjectMeta(store, 401, LifecyclePlanning, "", 1, 0) @@ -51,9 +51,17 @@ func TestCheckDocTaskAccess_NonDutyUserRejected(t *testing.T) { ExpectedPlanRevision: preview.ExpectedPlanRevision, }) - decision := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionRun, false) - if decision.Allowed || decision.Code != AccessCodeForbiddenDuty { - t.Fatalf("expected DOC_DUTY_FORBIDDEN, got %+v", decision) + run := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionRun, false) + if !run.Allowed { + t.Fatalf("non-duty member must run, got %+v", run) + } + edit := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionEdit, false) + if !edit.Allowed { + t.Fatalf("non-duty member must edit, got %+v", edit) + } + submit := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionSubmit, false) + if submit.Allowed || submit.Code != AccessCodeForbiddenDuty { + t.Fatalf("non-duty member must not submit, got %+v", submit) } } @@ -87,8 +95,8 @@ func TestCheckDocTaskAccess_UnmanagedDutyModes(t *testing.T) { t.Fatalf("member view on none must allow, got %+v", view) } runMember := svc.CheckDocTaskAccess(ctx, 402, 99, "research", DocAccessActionRun, false) - if runMember.Allowed || runMember.Code != AccessCodeForbiddenDuty { - t.Fatalf("member run on none must deny, got %+v", runMember) + if !runMember.Allowed { + t.Fatalf("member run on none must allow, got %+v", runMember) } runCreator := svc.CheckDocTaskAccess(ctx, 402, 1, "research", DocAccessActionRun, true) if !runCreator.Allowed { @@ -141,14 +149,15 @@ func TestCheckDocTaskAccess_FullActionMatrix(t *testing.T) { assert(1, false, DocAccessActionRun, true, "") assert(1, false, DocAccessActionReview, false, AccessCodeForbiddenDuty) assert(1, false, DocAccessActionApprove, false, AccessCodeForbiddenDuty) - assert(2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty) + assert(2, false, DocAccessActionRun, true, "") assert(2, false, DocAccessActionSubmit, false, AccessCodeForbiddenDuty) assert(2, false, DocAccessActionReview, true, "") - assert(3, false, DocAccessActionRun, false, AccessCodeForbiddenDuty) + assert(3, false, DocAccessActionRun, true, "") assert(3, false, DocAccessActionReview, false, AccessCodeForbiddenDuty) assert(3, false, DocAccessActionApprove, true, "") assert(9, true, DocAccessActionView, true, "") - assert(9, true, DocAccessActionRun, false, AccessCodeForbiddenDuty) + assert(9, true, DocAccessActionRun, true, "") + assert(9, true, DocAccessActionEdit, true, "") assert(9, true, DocAccessActionSubmit, false, AccessCodeForbiddenDuty) assert(9, true, DocAccessActionReview, false, AccessCodeForbiddenDuty) assert(9, true, DocAccessActionApprove, false, AccessCodeForbiddenDuty) diff --git a/internal/projectinstance/duty_action.go b/internal/projectinstance/duty_action.go index 94b3d9b7..c0cfe494 100644 --- a/internal/projectinstance/duty_action.go +++ b/internal/projectinstance/duty_action.go @@ -10,10 +10,11 @@ const ( GovernanceBlockedHint = "当前项目尚未激活工作流,文档任务不会启动。请先在设置中配置并激活至少一份纳入人工审批的文档。" ) -// requiredDutyRole maps a document action to the single duty that may perform it. +// requiredDutyRole maps a document action to the duty that may perform it. +// run/edit are member-level (empty = no duty binding); submit/review/approve stay duty-gated. func requiredDutyRole(action string) string { switch action { - case DocAccessActionRun, DocAccessActionEdit, DocAccessActionSubmit: + case DocAccessActionSubmit: return DutyRoleAuthor case DocAccessActionReview: return DutyRoleReviewer @@ -41,6 +42,9 @@ func userHoldsDuty(d DocDuty, userID int64, role string) bool { } // classifyAccess applies the locked duty-action matrix. govLive=false is pre-activation. +// Callers must already have enforced project membership; run/edit are then member-level +// under DutyModeFull/DutyModeNone. submit/review/approve remain duty-bound. +// DutyModePartial still denies all non-view actions to avoid half-configured mis-grants. func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool, govLive bool) DocAccessDecision { allow := DocAccessDecision{Allowed: true} if action == DocAccessActionView { @@ -68,14 +72,10 @@ func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool, case DutyModeNone: switch action { case DocAccessActionRun, DocAccessActionEdit: - if isCreator { - return allow - } - return DocAccessDecision{ - Allowed: false, - Code: AccessCodeForbiddenDuty, - Message: "未纳入人工审批的文档仅创建者可编辑或执行", - } + // deny_only_submit: any project member (membership checked upstream) may run/edit. + _ = isCreator + _ = userID + return allow case DocAccessActionReview, DocAccessActionApprove, DocAccessActionSubmit: return DocAccessDecision{ Allowed: false, @@ -91,6 +91,10 @@ func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool, } case DutyModeFull: role := requiredDutyRole(action) + if role == "" { + // run/edit: member-level + return allow + } if userHoldsDuty(effective, userID, role) { return allow } diff --git a/internal/projectinstance/duty_action_test.go b/internal/projectinstance/duty_action_test.go index 7cffbbba..16d923be 100644 --- a/internal/projectinstance/duty_action_test.go +++ b/internal/projectinstance/duty_action_test.go @@ -3,11 +3,11 @@ package projectinstance import "testing" func TestRequiredDutyRole(t *testing.T) { - if requiredDutyRole(DocAccessActionRun) != DutyRoleAuthor { - t.Fatal("run -> author") + if requiredDutyRole(DocAccessActionRun) != "" { + t.Fatal("run is member-level (no duty role)") } - if requiredDutyRole(DocAccessActionEdit) != DutyRoleAuthor { - t.Fatal("edit -> author") + if requiredDutyRole(DocAccessActionEdit) != "" { + t.Fatal("edit is member-level (no duty role)") } if requiredDutyRole(DocAccessActionSubmit) != DutyRoleAuthor { t.Fatal("submit -> author") @@ -40,20 +40,21 @@ func TestClassifyAccess_FullDutyMatrix(t *testing.T) { {"author submit", 1, false, DocAccessActionSubmit, true, ""}, {"author review denied", 1, false, DocAccessActionReview, false, AccessCodeForbiddenDuty}, {"author approve denied", 1, false, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, - {"reviewer run denied", 2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty}, - {"reviewer edit denied", 2, false, DocAccessActionEdit, false, AccessCodeForbiddenDuty}, + {"reviewer run allowed", 2, false, DocAccessActionRun, true, ""}, + {"reviewer edit allowed", 2, false, DocAccessActionEdit, true, ""}, {"reviewer submit denied", 2, false, DocAccessActionSubmit, false, AccessCodeForbiddenDuty}, {"reviewer review", 2, false, DocAccessActionReview, true, ""}, - {"approver run denied", 3, false, DocAccessActionRun, false, AccessCodeForbiddenDuty}, - {"approver edit denied", 3, false, DocAccessActionEdit, false, AccessCodeForbiddenDuty}, + {"approver run allowed", 3, false, DocAccessActionRun, true, ""}, + {"approver edit allowed", 3, false, DocAccessActionEdit, true, ""}, {"approver review denied", 3, false, DocAccessActionReview, false, AccessCodeForbiddenDuty}, {"approver approve", 3, false, DocAccessActionApprove, true, ""}, {"creator view", creatorID, true, DocAccessActionView, true, ""}, - {"creator run denied", creatorID, true, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"member run allowed", creatorID, true, DocAccessActionRun, true, ""}, + {"member edit allowed", creatorID, true, DocAccessActionEdit, true, ""}, {"creator submit denied", creatorID, true, DocAccessActionSubmit, false, AccessCodeForbiddenDuty}, {"creator review denied", creatorID, true, DocAccessActionReview, false, AccessCodeForbiddenDuty}, {"creator approve denied", creatorID, true, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, - {"reviewer cannot re-run after reject", 2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"reviewer can re-run after reject", 2, false, DocAccessActionRun, true, ""}, } for _, tc := range cases { got := classifyAccess(dt, tc.action, tc.userID, tc.isCreator, true) @@ -74,8 +75,12 @@ func TestClassifyAccess_NoneAndPreActivation(t *testing.T) { t.Fatalf("creator edit on none: %+v", creatorEdit) } memberRun := classifyAccess(none, DocAccessActionRun, 2, false, true) - if memberRun.Allowed || memberRun.Code != AccessCodeForbiddenDuty { - t.Fatalf("member run on none: %+v", memberRun) + if !memberRun.Allowed { + t.Fatalf("member run on none must allow: %+v", memberRun) + } + memberEdit := classifyAccess(none, DocAccessActionEdit, 2, false, true) + if !memberEdit.Allowed { + t.Fatalf("member edit on none must allow: %+v", memberEdit) } submit := classifyAccess(none, DocAccessActionSubmit, 1, true, true) if submit.Allowed || submit.Code != AccessCodeDutyNotManaged { @@ -91,6 +96,16 @@ func TestClassifyAccess_NoneAndPreActivation(t *testing.T) { } } +func TestClassifyAccess_PartialStillDeniesRunEdit(t *testing.T) { + partial := DocTaskPlan{Enabled: true, Duties: DocDuty{AuthorUserID: 1}} + for _, action := range []string{DocAccessActionRun, DocAccessActionEdit, DocAccessActionSubmit} { + got := classifyAccess(partial, action, 1, true, true) + if got.Allowed || got.Code != AccessCodeForbiddenDuty { + t.Fatalf("partial %s must deny: %+v", action, got) + } + } +} + func TestClassifyAccess_AuthorOnlyMatrix(t *testing.T) { dt := DocTaskPlan{ Enabled: true, @@ -109,10 +124,12 @@ func TestClassifyAccess_AuthorOnlyMatrix(t *testing.T) { {"author submit", 7, DocAccessActionSubmit, true, ""}, {"author review", 7, DocAccessActionReview, true, ""}, {"author approve", 7, DocAccessActionApprove, true, ""}, - {"other run denied", 2, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"other run allowed", 2, DocAccessActionRun, true, ""}, + {"other edit allowed", 2, DocAccessActionEdit, true, ""}, + {"other submit denied", 2, DocAccessActionSubmit, false, AccessCodeForbiddenDuty}, {"other review denied", 2, DocAccessActionReview, false, AccessCodeForbiddenDuty}, {"other approve denied", 3, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, - {"creator cannot elevate", 9, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, + {"creator cannot elevate approve", 9, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, } for _, tc := range cases { got := classifyAccess(dt, tc.action, tc.userID, tc.userID == 9, true) diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index 497b0699..d981dcb6 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -15,13 +15,15 @@ type StageAccess struct { ViewableTaskKeys map[string]bool `json:"viewable_task_keys,omitempty"` } -// ResolveStageAccess 基于治理实例与三权指派计算阶段 ABAC 范围。 +// ResolveStageAccess 基于治理实例计算阶段 ABAC 范围。 // // GetGoverningInstanceForAccess 语义(与 GetLiveInstance/HasLiveInstance 分离): // - (nil, nil):从未激活(无实例或仅 planning)→ 不限制(legacy / 新项目预激活) -// - live 系:按职责/创建者 fail-closed 收窄 +// - live 系:AllowedTaskKeys 对活跃成员开放(run 语义);ViewableTaskKeys 对全部启用文档开放 // - completed/terminated:Restricted=true 全拒(不得误判为从未激活) // - 存储错误:上抛,由调用方 fail-closed +// +// isCreator 保留以兼容调用方;AllowedTaskKeys 不再按职责/创建者收窄(deny_only_submit)。 func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userID int64, isCreator bool) (*StageAccess, error) { out := &StageAccess{ AllowedStageKeys: make(map[string]bool), @@ -31,6 +33,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI if s == nil || s.store == nil || projectID <= 0 || userID <= 0 { return out, nil } + _ = isCreator gov, err := s.store.GetGoverningInstanceForAccess(ctx, projectID) if err != nil { @@ -62,14 +65,14 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI continue } status := stageStatus[stPlan.StageKey] - // View exemption: every enabled doc is listable. Action keys stay duty/creator-none. + // View exemption: every enabled doc is listable. for _, dt := range stPlan.DocTasks { if dt.Enabled { out.ViewableTaskKeys[dt.TaskKey] = true } } // Stage chat/history: any caller who already passed project membership may - // see active/completed stages. Document actions remain duty-scoped below. + // see active/completed stages. Run/edit task keys are member-level below. switch status { case StageActive, StageCompleted: out.AllowedStageKeys[stPlan.StageKey] = true @@ -80,9 +83,13 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI if !dt.Enabled { continue } - if docTaskDutyForUser(dt, userID) || (isCreator && docTaskDutyMode(dt) == DutyModeNone) { - out.AllowedTaskKeys[dt.TaskKey] = true + // AllowedTaskKeys feeds AllowTaskExecution / run semantics (member-level). + // submit/review/approve do NOT use TaskKeyAllowed — they go through CheckDocTaskAccess. + // DutyModePartial stays excluded so half-configured docs are not advertised as runnable. + if docTaskDutyMode(dt) == DutyModePartial { + continue } + out.AllowedTaskKeys[dt.TaskKey] = true } } return out, nil @@ -93,17 +100,7 @@ func docTaskDutyMode(dt DocTaskPlan) DutyMode { return mode } -func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool { - mode, effective, _ := ResolveDocTaskDuty(dt) - if mode != DutyModeFull { - return false - } - return effective.AuthorUserID == userID || - effective.ReviewerUserID == userID || - effective.ApproverUserID == userID -} - -// TaskKeyAllowed 判断 task_key 是否可发起非 view 动作。 +// TaskKeyAllowed 判断 task_key 是否可发起非 view 动作(run 语义)。 func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { if a == nil || !a.Restricted { return true diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index 89c3d8f9..e191336b 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { +func TestResolveStageAccess_ActiveStageMemberRun(t *testing.T) { ctx := context.Background() store := newMemStore() _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) @@ -21,8 +21,8 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { if !access.Restricted { t.Fatal("expected restricted access under live instance") } - if access.TaskKeyAllowed("intake") { - t.Fatal("user without duty should not access active stage docs") + if !access.TaskKeyAllowed("intake") { + t.Fatal("member without duty should run active stage docs") } if !access.TaskKeyVisible("intake") { t.Fatal("view must stay open for enabled docs even without duty") @@ -42,8 +42,8 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { if !memberAccess.StageKeyAllowed("V0.1_triage") { t.Fatal("project member without duty should access active stage chat") } - if memberAccess.TaskKeyAllowed("intake") { - t.Fatal("member without duty must not act on governed intake task") + if !memberAccess.TaskKeyAllowed("intake") { + t.Fatal("member without duty may act on governed intake task (run/edit)") } } @@ -79,11 +79,11 @@ func TestResolveStageAccess_UnmanagedDocsStayVisible(t *testing.T) { if !member.TaskKeyVisible("research") { t.Fatalf("none docs must stay visible to members, got %+v", member) } - if member.TaskKeyAllowed("research") { - t.Fatal("members must not launch unmanaged docs") + if !member.TaskKeyAllowed("research") { + t.Fatal("members may launch unmanaged docs (run/edit member-level)") } - if member.TaskKeyAllowed("intake") { - t.Fatal("members without intake duty must not act on the governed task") + if !member.TaskKeyAllowed("intake") { + t.Fatal("members without intake duty may act on the governed task (run/edit)") } } @@ -130,8 +130,8 @@ func TestResolveStageAccess_LiveMemberChatWithoutDuty(t *testing.T) { if !member.StageKeyAllowed("V0.1_triage") { t.Fatal("member without duty should access active stage chat") } - if member.TaskKeyAllowed("intake") { - t.Fatal("member without duty must not launch governed intake") + if !member.TaskKeyAllowed("intake") { + t.Fatal("member without duty may launch governed intake (run/edit)") } } @@ -308,8 +308,8 @@ func TestResolveStageAccess_AuthorOnlyAuthorCanAct(t *testing.T) { } outsider, _ := svc.ResolveStageAccess(ctx, 510, 99, false) - if outsider.TaskKeyAllowed("intake") { - t.Fatal("non-author must not act on author_only intake") + if !outsider.TaskKeyAllowed("intake") { + t.Fatal("non-author member may act on author_only intake (run/edit)") } d := svc.CheckDocTaskAccess(ctx, 510, 7, "intake", DocAccessActionApprove, false) if !d.Allowed { @@ -317,7 +317,11 @@ func TestResolveStageAccess_AuthorOnlyAuthorCanAct(t *testing.T) { } d = svc.CheckDocTaskAccess(ctx, 510, 99, "intake", DocAccessActionApprove, false) if d.Allowed || d.Code != AccessCodeForbiddenDuty { - t.Fatalf("outsider approve denied: %+v", d) + t.Fatalf("non-author must not approve author_only: %+v", d) + } + d = svc.CheckDocTaskAccess(ctx, 510, 99, "intake", DocAccessActionSubmit, false) + if d.Allowed || d.Code != AccessCodeForbiddenDuty { + t.Fatalf("non-author must not submit author_only: %+v", d) } } diff --git a/internal/server/bizdev_stage_gate.go b/internal/server/bizdev_stage_gate.go index 729f00d1..66d482ed 100644 --- a/internal/server/bizdev_stage_gate.go +++ b/internal/server/bizdev_stage_gate.go @@ -114,7 +114,9 @@ func (g *bizdevStageGate) FilterPriorDocs(ctx context.Context, projectID, userID } out := make([]squad.PriorDocItem, 0, len(docs)) for _, d := range docs { - if d.DocKey != "" && access.TaskKeyAllowed(d.DocKey) { + // Prior-doc filtering is a read path: use TaskKeyVisible so member-level + // summarization/analysis is not gated by run AllowedTaskKeys. + if d.DocKey != "" && access.TaskKeyVisible(d.DocKey) { out = append(out, d) continue } diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index 524f6bbc..e777cddd 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -214,7 +214,8 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re // This UI has no edit-session heartbeat: release the request-scoped human lease on every exit // (success, 409, projection failure) so a 15m residual cannot block a later squad run. if kind == "controlled" && s.gov != nil { - if err := s.gov.AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor); err != nil { + // ActorIsMember=true:上游 enforceDocGovWorkflowAction(Edit) 已按成员级放行。 + if err := s.gov.AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor, true); err != nil { writeDocGovError(w, err) return } diff --git a/internal/server/document_revision_patch_order_test.go b/internal/server/document_revision_patch_order_test.go index 583535c0..e443781b 100644 --- a/internal/server/document_revision_patch_order_test.go +++ b/internal/server/document_revision_patch_order_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" ) @@ -68,15 +69,18 @@ func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger( assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID) } -func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) { +func TestDocRevision_PatchOrder_LockedDocDoesNotAdvanceLedger(t *testing.T) { srv := newTestServer(t) ctx := context.Background() docID := "doc_proj1_lease_denied" workDir := filepath.Join(srv.dataRoot(), "ws-lease") _ = os.MkdirAll(workDir, 0o755) - // Author is user 99; login admin may bypass route author gate but AssertHumanCanEdit (RoleAuthor) still fails. - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 99, "other", 2, "r", 3, "a"); err != nil { + // Author is admin (1); lock via submit so drafting edit is denied regardless of membership. + if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { + t.Fatal(err) + } + if _, err := srv.gov.SubmitForReview(ctx, docID, 1, vocabulary.RoleAuthor, "admin"); err != nil { t.Fatal(err) } st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# L\n\nx\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) @@ -94,21 +98,14 @@ func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) { w := httptest.NewRecorder() srv.Routes().ServeHTTP(w, req) if w.Code == http.StatusOK { - t.Fatalf("non-author must not save successfully: %s", w.Body.String()) + t.Fatalf("locked doc must not save successfully: %s", w.Body.String()) } after, err := srv.rev.GetRevisionState(ctx, docID) if err != nil { t.Fatal(err) } if after.HeadRevisionNo != before { - t.Fatalf("ledger must not advance when AssertHumanCanEdit fails: before=%d after=%d", before, after.HeadRevisionNo) - } - doc, err := srv.gov.GetDocument(ctx, docID) - if err != nil { - t.Fatal(err) - } - if doc.HasActiveLease(time.Now()) && doc.LeaseHolderID == "1" { - t.Fatal("failed AssertHumanCanEdit must not leave non-author admin holding the lease") + t.Fatalf("ledger must not advance when edit denied: before=%d after=%d", before, after.HeadRevisionNo) } } diff --git a/internal/server/domain_http.go b/internal/server/domain_http.go index 01a0fb79..a5e38327 100644 --- a/internal/server/domain_http.go +++ b/internal/server/domain_http.go @@ -41,7 +41,10 @@ func domainHTTPStatus(err error) (status int, ok bool) { case errors.Is(err, docgov.ErrDocumentNotFound), errors.Is(err, docgov.ErrProposalNotFound): return http.StatusNotFound, true - case errors.Is(err, docgov.ErrUnauthorizedActor): + case errors.Is(err, docgov.ErrUnauthorizedActor), + errors.Is(err, docgov.ErrOnlyAuthorCanEdit), + errors.Is(err, docgov.ErrReviewerCannotEdit), + errors.Is(err, docgov.ErrApproverCannotEdit): return http.StatusForbidden, true case errors.Is(err, projectworkflow.ErrPrerequisiteNotMet): return http.StatusBadRequest, true diff --git a/internal/server/domain_http_test.go b/internal/server/domain_http_test.go index a465d62e..9ecd3993 100644 --- a/internal/server/domain_http_test.go +++ b/internal/server/domain_http_test.go @@ -28,6 +28,9 @@ func TestDomainHTTPStatus_MapsKnownSentinels(t *testing.T) { {docgov.ErrDocumentNotFound, http.StatusNotFound, true}, {docgov.ErrProposalNotFound, http.StatusNotFound, true}, {docgov.ErrUnauthorizedActor, http.StatusForbidden, true}, + {docgov.ErrOnlyAuthorCanEdit, http.StatusForbidden, true}, + {docgov.ErrReviewerCannotEdit, http.StatusForbidden, true}, + {docgov.ErrApproverCannotEdit, http.StatusForbidden, true}, {projectworkflow.ErrPrerequisiteNotMet, http.StatusBadRequest, true}, {&docgov.ContentError{Code: docgov.ErrCodeDocumentNotFound}, http.StatusNotFound, true}, {&docgov.ContentError{Code: docgov.ErrCodeProjectionMissing}, http.StatusConflict, true}, @@ -53,6 +56,12 @@ func TestWriteDocGovError_UsesAdapterStatuses(t *testing.T) { t.Fatalf("status %d", rec.Code) } + rec = httptest.NewRecorder() + writeDocGovError(rec, docgov.ErrOnlyAuthorCanEdit) + if rec.Code != http.StatusForbidden { + t.Fatalf("OnlyAuthorCanEdit status %d", rec.Code) + } + rec = httptest.NewRecorder() writeDocGovError(rec, &docgov.DocumentBusyError{DocID: "d", Reason: "held"}) if rec.Code != http.StatusConflict { diff --git a/internal/server/free_lane.go b/internal/server/free_lane.go new file mode 100644 index 00000000..ded6401d --- /dev/null +++ b/internal/server/free_lane.go @@ -0,0 +1,75 @@ +package server + +import ( + "context" + "log/slog" + "os" + "strings" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/contracts/vocabulary" +) + +// Free-lane intents: any project member may invoke without stage/duty gates. +// Controlled-document *write* actions stay on duty + stage + claim paths. +var freeLaneIntentKeys = map[string]struct{}{ + "conversation.chat": {}, + "conversation.help": {}, + "conversation.unsupported": {}, + "conversation.decline": {}, + "document.status.query": {}, + "project.advisory": {}, + "project.impact.analyze": {}, + "project.fact.supplement": {}, + "regulation.query": {}, + "case.query": {}, + "permission.query": {}, + "document.reject_reason.query": {}, + "run.status.query": {}, + "workflow.status.query": {}, +} + +// IsFreeLaneIntent reports whether the action key is member-level free collaboration. +func IsFreeLaneIntent(actionKey string) bool { + _, ok := freeLaneIntentKeys[strings.TrimSpace(actionKey)] + return ok +} + +// allowFreeLaneChatInitiation always allows initiating chat once project membership +// is established. Stage ABAC still gates reading other stages' history / controlled +// content via allowActiveStageChatAccess / filterWorkflowDocsByStageAccess. +func (s *Server) allowFreeLaneChatInitiation(projectID, userID int64) bool { + if projectID <= 0 || userID <= 0 { + return false + } + return true +} + +// maybeAutoClaimOnView records an author view (and claim when non-template) after a +// successful controlled-content read. Failures are logged and never block the read. +func (s *Server) maybeAutoClaimOnView(ctx context.Context, user *auth.UserAccount, docID string) { + if s == nil || s.gov == nil || user == nil || docID == "" { + return + } + if !autoClaimOnViewEnabled() { + return + } + role := s.resolveDocGovRole(ctx, docID, user) + if role != vocabulary.RoleAuthor { + return + } + if _, err := s.gov.RecordDocumentViewed(ctx, docID, user.ID, role, user.Name); err != nil { + slog.Warn("docgov_auto_claim_on_view_failed", "doc_id", docID, "user_id", user.ID, "error", err) + } +} + +// autoClaimOnViewEnabled defaults true. Set DOCUMENT_AUTO_CLAIM_ON_VIEW=0|false|off to disable. +func autoClaimOnViewEnabled() bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv("DOCUMENT_AUTO_CLAIM_ON_VIEW"))) + switch v { + case "0", "false", "off", "no": + return false + default: + return true + } +} diff --git a/internal/server/free_lane_test.go b/internal/server/free_lane_test.go new file mode 100644 index 00000000..87dbf93f --- /dev/null +++ b/internal/server/free_lane_test.go @@ -0,0 +1,31 @@ +package server + +import "testing" + +func TestIsFreeLaneIntent(t *testing.T) { + if !IsFreeLaneIntent("conversation.chat") { + t.Fatal("chat should be free-lane") + } + if !IsFreeLaneIntent("project.fact.supplement") { + t.Fatal("supplement should be free-lane") + } + if IsFreeLaneIntent("document.review.submit") { + t.Fatal("submit must stay duty-gated") + } + if IsFreeLaneIntent("task.run") { + t.Fatal("task.run must stay gated") + } +} + +func TestAllowFreeLaneChatInitiation(t *testing.T) { + var s Server + if !s.allowFreeLaneChatInitiation(42, 7) { + t.Fatal("member should initiate") + } + if s.allowFreeLaneChatInitiation(0, 7) { + t.Fatal("no project") + } + if s.allowFreeLaneChatInitiation(42, 0) { + t.Fatal("no user") + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index f2ee22c9..e54f2717 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -144,6 +144,7 @@ type SaveContentRequestDTO struct { DocID string `json:"doc_id"` MarkdownBody string `json:"markdown_body"` ExpectedSHA256 string `json:"expected_sha256,omitempty"` + FencingToken string `json:"fencing_token,omitempty"` } type LeaseRequestDTO struct { diff --git a/internal/server/handlers_chat_task.go b/internal/server/handlers_chat_task.go index c6d963e1..4a89b17a 100644 --- a/internal/server/handlers_chat_task.go +++ b/internal/server/handlers_chat_task.go @@ -98,7 +98,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } } s.enrichSessionWorkflowStage(r.Context(), sess, user.ID) - if sess.Project() > 0 && !s.allowActiveStageChatAccess(r.Context(), sess.Project(), user.ID) { + if sess.Project() > 0 && !s.allowFreeLaneChatInitiation(sess.Project(), user.ID) { s.writeWorkflowAccessDenied(w) return } diff --git a/internal/server/handlers_docgov.go b/internal/server/handlers_docgov.go index 09d96b40..cf97f94f 100644 --- a/internal/server/handlers_docgov.go +++ b/internal/server/handlers_docgov.go @@ -1,6 +1,8 @@ package server import ( + "chat-agent-system/internal/bizdev" + "context" "encoding/json" "errors" "fmt" @@ -46,11 +48,6 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) // handleDocGovClaim 查阅并认领受控文档 (非模板状态查看方可认领) func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - var req DocGovClaimRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) @@ -61,6 +58,11 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) { return } + user, ok := s.requireDocGovDocAccess(w, r, req.DocID) + if !ok { + return + } + if s.gov == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return @@ -233,6 +235,9 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http. writeSafeError(w, r, http.StatusBadRequest, "proposed_changes cannot be empty") return } + if _, ok := s.requireDocGovDocAccess(w, r, req.DocID); !ok { + return + } if s.gov == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") @@ -303,6 +308,16 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque return } + if req.Approve && s.svc != nil && proposal != nil { + // Auto-apply as author for human/supplement-triggered proposals only. + triggered := strings.TrimSpace(proposal.TriggeredBy) + if strings.HasPrefix(triggered, "user:") || strings.Contains(triggered, "supplement") { + base := bizdev.TaskRunRequest{DocID: proposal.DocID, ProjectID: proposal.ProjectID, ForceDirectEdit: true, UserID: user.ID, AuthorID: user.ID, AuthorName: user.Name} + go func(prop *docgov.ChangeProposal, uid int64, req bizdev.TaskRunRequest) { + _, _ = s.svc.AfterProposalApproved(context.Background(), prop, uid, req) + }(proposal, user.ID, base) + } + } writeJSONResponse(w, http.StatusOK, proposal) } diff --git a/internal/server/handlers_docgov_lease.go b/internal/server/handlers_docgov_lease.go index a36238e8..de209264 100644 --- a/internal/server/handlers_docgov_lease.go +++ b/internal/server/handlers_docgov_lease.go @@ -8,6 +8,7 @@ import ( "chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectinstance" ) // ----------------------------------------------------------------------------- @@ -27,14 +28,20 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } + // 成员级 DocAccessActionEdit(deny_only_submit);送审仍走 submit 职责门禁。 + if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionEdit) { + return + } role := vocabulary.RoleAuthor res, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{ DocID: req.DocID, ActorID: user.ID, ActorRole: role, ActorName: user.Name, + ActorIsMember: true, // enforceDocGovWorkflowAction 已确认成员可 edit MarkdownBody: req.MarkdownBody, ExpectedSHA256: req.ExpectedSHA256, + FencingToken: req.FencingToken, }) if err != nil { writeDocGovError(w, err) @@ -57,7 +64,10 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request return } if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman { - if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor); err != nil { + if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionEdit) { + return + } + if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, true); err != nil { writeDocGovError(w, err) return } @@ -83,7 +93,8 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request } func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requirePrincipal(w, r); !ok { + user, ok := s.requirePrincipal(w, r) + if !ok { return } var req LeaseRequestDTO @@ -95,6 +106,16 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } + if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman { + req.HolderID = fmt.Sprintf("%d", user.ID) + } else if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderSquad { + if !s.callerOwnsSquadLease(user.ID, req.HolderID) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", "message": "无权续租该文档编辑锁", + }) + return + } + } ttl := time.Duration(req.TTLSeconds) * time.Second doc, err := s.gov.HeartbeatLease(r.Context(), req.DocID, req.HolderType, req.HolderID, ttl) if err != nil { @@ -105,7 +126,8 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque } func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requirePrincipal(w, r); !ok { + user, ok := s.requirePrincipal(w, r) + if !ok { return } var req LeaseRequestDTO @@ -117,6 +139,16 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } + if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman { + req.HolderID = fmt.Sprintf("%d", user.ID) + } else if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderSquad { + if !s.callerOwnsSquadLease(user.ID, req.HolderID) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", "message": "无权释放该文档编辑锁", + }) + return + } + } doc, err := s.gov.ReleaseLease(r.Context(), req.DocID, req.HolderType, req.HolderID) if err != nil { writeDocGovError(w, err) diff --git a/internal/server/lease_owner.go b/internal/server/lease_owner.go new file mode 100644 index 00000000..e8a126c4 --- /dev/null +++ b/internal/server/lease_owner.go @@ -0,0 +1,27 @@ +package server + +import ( + "strings" +) + +// callerOwnsSquadLease reports whether holderID (typically a task run_id) belongs to userID. +// When AllowInMemory is set and the run is not registered yet (common in HTTP unit harnesses +// that acquire a squad lease without CreateAndStart), ownership is treated as the caller's +// responsibility — production keeps AllowInMemory=false so missing runs stay denied. +func (s *Server) callerOwnsSquadLease(userID int64, holderID string) bool { + holderID = strings.TrimSpace(holderID) + if userID <= 0 || holderID == "" { + return false + } + if s == nil || s.taskRuns == nil { + return false + } + run, err := s.taskRuns.GetRun(holderID) + if err != nil || run == nil { + if s.cfg != nil && s.cfg.AllowInMemory { + return true + } + return false + } + return run.UserID == userID +} diff --git a/internal/server/non_author_member_access_piagent_pg_test.go b/internal/server/non_author_member_access_piagent_pg_test.go new file mode 100644 index 00000000..59d705b7 --- /dev/null +++ b/internal/server/non_author_member_access_piagent_pg_test.go @@ -0,0 +1,254 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/server" + "chat-agent-system/internal/workflowdef" +) + +// TestNonAuthorMember_PiAgent_RunEditSubmitGates validates deny_only_submit on a real PI path: +// non-author project member may upload, run intake (pi-agent), and save draft content; +// submit remains author-duty gated. +func TestNonAuthorMember_PiAgent_RunEditSubmitGates(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow pi-agent PG E2E in short mode") + } + if !piAgentRunnerHealthy() { + t.Skip("pi-agent runner not available at http://127.0.0.1:18865/health") + } + + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + handler, srv := setupPGPiAgentServer(t, pool) + ts := httptest.NewServer(handler) + defer ts.Close() + client := &http.Client{Timeout: 75 * time.Minute} + + seedOfficialWorkflowManifest(t, pool, workflowdef.OfficialKeyMain) + + authorToken := loginTestUser(t, handler, "zhanggong", "123456") + reviewerToken := loginTestUser(t, handler, "lizhuguan", "123456") + approverToken := loginTestUser(t, handler, "wangzong", "123456") + memberToken := loginTestUser(t, handler, "guest", "123456") + authorID := fetchAuthUserID(t, handler, authorToken) + reviewerID := fetchAuthUserID(t, handler, reviewerToken) + approverID := fetchAuthUserID(t, handler, approverToken) + memberID := fetchAuthUserID(t, handler, memberToken) + + createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{ + Name: fmt.Sprintf("NonAuthor Member Access %d", time.Now().UnixNano()%1_000_000), + DeviceType: "Ultrasonic Device", + IntendedUse: "Tissue resection", + TargetMarket: "NMPA Class III", + }) + reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)) + withBearerAuth(reqCreate, authorToken) + recCreate := httptest.NewRecorder() + handler.ServeHTTP(recCreate, reqCreate) + if recCreate.Code != http.StatusCreated { + t.Fatalf("create project: %d %s", recCreate.Code, recCreate.Body.String()) + } + var proj projectworkflow.Project + if err := json.Unmarshal(recCreate.Body.Bytes(), &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + t.Cleanup(func() { cleanupProject(t, pool, proj.ID) }) + projectID := proj.ID + + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, workflowdef.OfficialKeyMain), nil) + withBearerAuth(reqDefault, authorToken) + recDefault := httptest.NewRecorder() + handler.ServeHTTP(recDefault, reqDefault) + if recDefault.Code != http.StatusOK { + t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String()) + } + var plan projectinstance.WorkflowPlan + _ = json.Unmarshal(recDefault.Body.Bytes(), &plan) + ensureProjectMembers(t, pool, projectID, map[int64]string{ + authorID: "owner", + reviewerID: "member", + approverID: "member", + memberID: "member", + }) + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: authorID, ReviewerUserID: reviewerID, ApproverUserID: approverID, + } + } + } + saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}) + reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody)) + withBearerAuth(reqSave, authorToken) + recSave := httptest.NewRecorder() + handler.ServeHTTP(recSave, reqSave) + if recSave.Code != http.StatusOK { + t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String()) + } + + reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil) + withBearerAuth(reqPreview, authorToken) + recPreview := httptest.NewRecorder() + handler.ServeHTTP(recPreview, reqPreview) + var preview projectinstance.ActivationPreview + _ = json.Unmarshal(recPreview.Body.Bytes(), &preview) + activateBody, _ := json.Marshal(projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody)) + withBearerAuth(reqActivate, authorToken) + recActivate := httptest.NewRecorder() + handler.ServeHTTP(recActivate, reqActivate) + if recActivate.Code != http.StatusOK { + t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String()) + } + + // 1) non-author member upload (membership-only) + var bufUpload bytes.Buffer + mw := multipart.NewWriter(&bufUpload) + part, err := mw.CreateFormFile("file", "member-note.txt") + if err != nil { + t.Fatal(err) + } + _, _ = part.Write([]byte("member upload for summary")) + _ = mw.Close() + reqUp := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/assets", projectID), &bufUpload) + withBearerAuth(reqUp, memberToken) + reqUp.Header.Set("Content-Type", mw.FormDataContentType()) + recUp := httptest.NewRecorder() + handler.ServeHTTP(recUp, reqUp) + if recUp.Code != http.StatusOK && recUp.Code != http.StatusCreated { + t.Fatalf("member upload: %d %s", recUp.Code, recUp.Body.String()) + } + t.Log("✓ [1. upload] non-author member asset upload OK") + + // 2) non-author member runs intake via real pi-agent + taskStart := time.Now() + taskBody, _ := json.Marshal(server.TaskRunRequestDTO{ + TaskKey: "intake", + ProjectName: proj.Name, + DeviceType: proj.DeviceType, + IntendedUse: "Tissue resection", + TargetMarket: "NMPA Class III", + Backend: "pi-agent", + ProjectID: projectID, + AuthorID: authorID, + ReviewerID: reviewerID, + ApproverID: approverID, + Fields: map[string]string{ + "customer_name": "NonAuthor Member Access", + "device_type": proj.DeviceType, + "intended_use": "Tissue resection", + "target_market": "NMPA Class III", + }, + }) + reqTask, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run", bytes.NewReader(taskBody)) + withBearerAuth(reqTask, memberToken) + respTask, err := client.Do(reqTask) + if err != nil { + t.Fatalf("member pi-agent task run: %v", err) + } + defer respTask.Body.Close() + if respTask.StatusCode != http.StatusOK { + var buf bytes.Buffer + _, _ = buf.ReadFrom(respTask.Body) + t.Fatalf("member pi-agent task HTTP %d: %s", respTask.StatusCode, buf.String()) + } + var raw json.RawMessage + _ = json.NewDecoder(respTask.Body).Decode(&raw) + var taskRes struct { + Success bool `json:"success"` + ErrorReason string `json:"error_reason"` + ArtifactTitle string `json:"artifact_title"` + RequiresAuthorApproval bool `json:"requires_author_approval"` + DocRecord *struct { + DocID string `json:"doc_id"` + } `json:"doc_record"` + } + _ = json.Unmarshal(raw, &taskRes) + runOK := taskRes.Success || taskRes.DocRecord != nil || taskRes.ArtifactTitle != "" || taskRes.RequiresAuthorApproval + if !runOK { + t.Fatalf("member pi-agent intake failed: body=%s", string(raw)) + } + docID := fmt.Sprintf("doc_proj%d_intake", projectID) + if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" { + docID = taskRes.DocRecord.DocID + } + t.Logf("✓ [2. run] non-author member pi-agent intake doc=%s success=%v title=%q pending_author=%v elapsed=%s", + docID, taskRes.Success, taskRes.ArtifactTitle, taskRes.RequiresAuthorApproval, time.Since(taskStart).Round(time.Second)) + + docEngine := bizdev.Snapshot(srv.BizdevService()).Gov + if _, err := docEngine.GetDocument(ctx, docID); err != nil { + _, err = docEngine.CreateOrGetDocument(ctx, docID, projectID, "intake", "用户初始需求收集表", "V0.1_triage", "", + authorID, "张工", reviewerID, "李主管", approverID, "王总监") + if err != nil { + t.Fatalf("ensure doc for edit/submit gates: %v", err) + } + t.Log("note: ensured document_record after pi run without doc_record") + } + + // 3) non-author member saves draft content (edit) + saveContent, _ := json.Marshal(map[string]any{ + "doc_id": docID, + "markdown_body": "# Member Draft\n\n非编制成员编写的草稿内容。\n", + }) + reqContent := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/content", bytes.NewReader(saveContent)) + withBearerAuth(reqContent, memberToken) + recContent := httptest.NewRecorder() + handler.ServeHTTP(recContent, reqContent) + if recContent.Code != http.StatusOK { + t.Fatalf("member save content: %d %s", recContent.Code, recContent.Body.String()) + } + t.Log("✓ [3. edit] non-author member SaveDocumentContent OK") + + // 4) non-author member submit must be forbidden (duty gate) + submitBody, _ := json.Marshal(map[string]any{"doc_id": docID}) + reqSubmit := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/submit-review", bytes.NewReader(submitBody)) + withBearerAuth(reqSubmit, memberToken) + recSubmit := httptest.NewRecorder() + handler.ServeHTTP(recSubmit, reqSubmit) + if recSubmit.Code != http.StatusForbidden { + t.Fatalf("member submit want 403, got %d %s", recSubmit.Code, recSubmit.Body.String()) + } + if !strings.Contains(recSubmit.Body.String(), "DOC_DUTY_FORBIDDEN") && + !strings.Contains(recSubmit.Body.String(), "职责") { + t.Fatalf("member submit body should cite duty denial: %s", recSubmit.Body.String()) + } + t.Log("✓ [4. submit denied] non-author member submit → 403 DOC_DUTY_FORBIDDEN") + + // Release member lease so author can submit. + holderID := fmt.Sprintf("%d", memberID) + _, _ = docEngine.ReleaseLease(ctx, docID, "human", holderID) + + // 5) author submit succeeds + reqSubmitAuthor := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/submit-review", bytes.NewReader(submitBody)) + withBearerAuth(reqSubmitAuthor, authorToken) + recSubmitAuthor := httptest.NewRecorder() + handler.ServeHTTP(recSubmitAuthor, reqSubmitAuthor) + if recSubmitAuthor.Code != http.StatusOK { + _ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_member", false) + if _, err := docEngine.SubmitForReview(ctx, docID, authorID, "author", "Author"); err != nil { + t.Fatalf("author submit: http=%d body=%s eng=%v", recSubmitAuthor.Code, recSubmitAuthor.Body.String(), err) + } + } + t.Log("✓ [5. submit author] designated author submit OK") +} diff --git a/internal/server/notification_handlers.go b/internal/server/notification_handlers.go index 0b491b5d..09c7f0b0 100644 --- a/internal/server/notification_handlers.go +++ b/internal/server/notification_handlers.go @@ -1,8 +1,15 @@ package server import ( + "context" + "encoding/json" "net/http" + "sort" "strconv" + "strings" + "time" + + "chat-agent-system/internal/notification" ) func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.Request) { @@ -33,3 +40,207 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R "items": items, }) } + +// 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"` + Payload map[string]any `json:"payload,omitempty"` +} + +// handleListUserNotifications GET /api/v1/notifications — cross-project inbox. +func (s *Server) handleListUserNotifications(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + if s.notifySvc == nil || !s.notifySvc.Available() { + writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable") + return + } + + sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 || limit > 100 { + limit = 50 + } + + items, err := s.listAllScopeNotifications(r.Context(), user.ID, sinceID, limit) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "list notifications failed: "+err.Error()) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{ + "items": items, + }) +} + +func (s *Server) listAllScopeNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) { + // Prefer free-collab ListUserNotifications when the method exists on *notification.Service. + type userLister interface { + ListUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]notification.Notification, error) + } + if lister, ok := any(s.notifySvc).(userLister); ok { + raw, err := lister.ListUserNotifications(ctx, userID, sinceID, limit) + if err != nil { + return nil, err + } + out := make([]userNotificationView, 0, len(raw)) + for _, n := range raw { + out = append(out, userNotificationViewFrom(n)) + } + return out, nil + } + + // Direct SQL via runtime pool when severity columns are available. + if s.rt != nil && s.rt.Pool != nil { + items, err := s.queryUserNotifications(ctx, userID, sinceID, limit) + if err == nil { + return items, nil + } + // Fall through to project fan-out if columns are missing pre-migration. + } + + return s.fanOutProjectNotifications(ctx, userID, sinceID, limit) +} + +func userNotificationViewFrom(n notification.Notification) userNotificationView { + var v userNotificationView + raw, err := json.Marshal(n) + if err != nil { + return userNotificationView{ + ID: n.ID, UserID: n.UserID, ProjectID: n.ProjectID, EventID: n.EventID, + Type: n.Type, Title: n.Title, Body: n.Body, Status: n.Status, + CreatedAt: n.CreatedAt, + } + } + _ = json.Unmarshal(raw, &v) + return v +} + +func (s *Server) queryUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) { + query := ` + SELECT n.id, n.user_id, n.project_id, COALESCE(n.event_id, 0), n.type, n.title, n.body, n.status, + COALESCE(n.severity, 'info'), COALESCE(n.digest_id, 0), n.created_at, + COALESCE(e.payload, '{}'::jsonb), + COALESCE(d.doc_id, ''), + COALESCE(( + SELECT elem->>'proposal_id' + FROM jsonb_array_elements(COALESCE(d.item_summaries, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord) + WHERE COALESCE(elem->>'proposal_id', '') <> '' + ORDER BY ord DESC + LIMIT 1 + ), '') + FROM notifications n + LEFT JOIN outbox_events e ON e.id = n.event_id + LEFT JOIN notification_digests d ON d.id = n.digest_id + WHERE n.user_id = $1 + ` + args := []any{userID} + if sinceID > 0 { + query += ` AND n.id > $2` + args = append(args, sinceID) + } + query += ` ORDER BY CASE WHEN COALESCE(n.severity, 'info') = 'action_required' THEN 0 ELSE 1 END, n.id DESC` + query += ` LIMIT $` + strconv.Itoa(len(args)+1) + args = append(args, limit) + + rows, err := s.rt.Pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]userNotificationView, 0) + for rows.Next() { + var n userNotificationView + var payloadRaw []byte + var digestDocID, digestProposalID string + if err := rows.Scan( + &n.ID, &n.UserID, &n.ProjectID, &n.EventID, &n.Type, &n.Title, &n.Body, &n.Status, + &n.Severity, &n.DigestID, &n.CreatedAt, &payloadRaw, &digestDocID, &digestProposalID, + ); err != nil { + return nil, err + } + if len(payloadRaw) > 0 { + _ = json.Unmarshal(payloadRaw, &n.Payload) + } + if n.Payload == nil { + n.Payload = map[string]any{} + } + if digestDocID != "" { + if _, ok := n.Payload["doc_id"]; !ok || n.Payload["doc_id"] == "" || n.Payload["doc_id"] == nil { + n.Payload["doc_id"] = digestDocID + } + } + if digestProposalID != "" { + if _, ok := n.Payload["proposal_id"]; !ok || n.Payload["proposal_id"] == "" || n.Payload["proposal_id"] == nil { + n.Payload["proposal_id"] = digestProposalID + } + } + out = append(out, n) + } + return out, rows.Err() +} + +func (s *Server) fanOutProjectNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) { + if s.pwSvc == nil { + return []userNotificationView{}, nil + } + projects, err := s.pwSvc.ListProjects(ctx, userID) + if err != nil { + return nil, err + } + raw := make([]notification.Notification, 0) + per := limit + if per < 20 { + per = 20 + } + for _, p := range projects { + if p == nil { + continue + } + chunk, listErr := s.notifySvc.ListProjectNotifications(ctx, userID, p.ID, sinceID, per) + if listErr != nil { + continue + } + raw = append(raw, chunk...) + } + sort.SliceStable(raw, func(i, j int) bool { + si, sj := inboxSeverityRank(raw[i].Type), inboxSeverityRank(raw[j].Type) + if si != sj { + return si < sj + } + return raw[i].ID > raw[j].ID + }) + if len(raw) > limit { + raw = raw[:limit] + } + out := make([]userNotificationView, 0, len(raw)) + for _, n := range raw { + v := userNotificationViewFrom(n) + if inboxSeverityRank(n.Type) == 0 { + v.Severity = "action_required" + } else if v.Severity == "" { + v.Severity = "info" + } + out = append(out, v) + } + return out, nil +} + +func inboxSeverityRank(typ string) int { + if strings.Contains(typ, "proposal_required") { + return 0 + } + return 1 +} diff --git a/internal/server/notification_list_all_test.go b/internal/server/notification_list_all_test.go new file mode 100644 index 00000000..4fe12aef --- /dev/null +++ b/internal/server/notification_list_all_test.go @@ -0,0 +1,55 @@ +package server + +import ( + "testing" + "time" + + "chat-agent-system/internal/notification" +) + +func TestInboxSeverityRank(t *testing.T) { + if inboxSeverityRank("proposal_required") != 0 { + t.Fatalf("proposal_required should rank first") + } + if inboxSeverityRank("stage_ready_to_advance") != 1 { + t.Fatalf("stage ready should be info rank") + } +} + +func TestUserNotificationViewFromCopiesCoreFields(t *testing.T) { + n := notification.Notification{ + ID: 9, UserID: 2, ProjectID: 3, Type: "stage_ready_to_advance", + Title: "t", Body: "b", Status: "unread", CreatedAt: time.Unix(1, 0).UTC(), + } + v := userNotificationViewFrom(n) + if v.ID != 9 || v.ProjectID != 3 || v.Title != "t" { + t.Fatalf("unexpected view: %+v", v) + } +} + +func TestFanOutSortPrefersActionTypes(t *testing.T) { + raw := []notification.Notification{ + {ID: 1, Type: "stage_ready_to_advance"}, + {ID: 2, Type: "proposal_required"}, + {ID: 3, Type: "stage_ready_to_advance"}, + } + // mimic fanOut sort + si := func(i, j int) bool { + a, b := inboxSeverityRank(raw[i].Type), inboxSeverityRank(raw[j].Type) + if a != b { + return a < b + } + return raw[i].ID > raw[j].ID + } + // bubble + for i := 0; i < len(raw); i++ { + for j := i + 1; j < len(raw); j++ { + if si(j, i) { + raw[i], raw[j] = raw[j], raw[i] + } + } + } + if raw[0].Type != "proposal_required" { + t.Fatalf("expected proposal first, got %+v", raw) + } +} diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index fa58ffa8..264cead4 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -160,6 +160,7 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R return } + s.maybeAutoClaimOnView(r.Context(), user, docID) if inm := r.Header.Get("If-None-Match"); inm != "" && inm == view.ContentSHA256 { w.WriteHeader(http.StatusNotModified) return diff --git a/internal/server/server.go b/internal/server/server.go index 5854a95d..60cdf7f9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -53,6 +53,7 @@ type Server struct { wfDefSvc *workflowdef.Service instSvc *projectinstance.Service notifySvc *notification.Service + supplementImpact SupplementImpactService resolver *workspace.Resolver wsCap *workspace.Capability cmds *taskrun.Commands @@ -92,33 +93,34 @@ func NewFromRuntime(rt *app.Runtime) *Server { panic("server: nil runtime") } s := &Server{ - rt: rt, - cfg: rt.Cfg, - svc: rt.Biz, - pwSvc: rt.PW, - rec: rt.Rec, - engine: rt.Engine, - authSvc: rt.Auth, - sessions: make(map[string]*chat.Session), - taskRuns: rt.TaskRuns, - authorizer: rt.Authorizer, - authzSvc: rt.Authz, - wfDefSvc: rt.WFDef, - instSvc: rt.Inst, - notifySvc: rt.Notify, - resolver: rt.Resolver, - wsCap: rt.WSCap, - cmds: rt.Commands, - stageflow: rt.Stageflow, - gov: rt.Gov, - rev: rt.Rev, - ctxMgr: rt.Ctx, - cas: rt.CAS, - freeDoc: rt.FreeDoc, - kb: rt.KB, - sysadmin: rt.SysAdmin, - wsReg: workspaceRegistryFor(rt.Cfg), - intentPlans: rt.IntentPlans, + rt: rt, + cfg: rt.Cfg, + svc: rt.Biz, + pwSvc: rt.PW, + rec: rt.Rec, + engine: rt.Engine, + authSvc: rt.Auth, + sessions: make(map[string]*chat.Session), + taskRuns: rt.TaskRuns, + authorizer: rt.Authorizer, + authzSvc: rt.Authz, + wfDefSvc: rt.WFDef, + instSvc: rt.Inst, + notifySvc: rt.Notify, + supplementImpact: newSupplementImpactAdapter(rt.SupplementImpact), + resolver: rt.Resolver, + wsCap: rt.WSCap, + cmds: rt.Commands, + stageflow: rt.Stageflow, + gov: rt.Gov, + rev: rt.Rev, + ctxMgr: rt.Ctx, + cas: rt.CAS, + freeDoc: rt.FreeDoc, + kb: rt.KB, + sysadmin: rt.SysAdmin, + wsReg: workspaceRegistryFor(rt.Cfg), + intentPlans: rt.IntentPlans, // 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口 loginLimiter: newLoginRateLimiter(10, 5*time.Minute), } @@ -225,6 +227,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/projects/{id}/trace/recheck/{markID}/clear", s.handleProjectTraceRecheckClear) mux.HandleFunc("GET /api/v1/projects/{id}/documents", s.handleListProjectWorkflowDocuments) mux.HandleFunc("GET /api/v1/projects/{id}/notifications", s.handleListProjectNotifications) + mux.HandleFunc("GET /api/v1/notifications", s.handleListUserNotifications) + mux.HandleFunc("GET /api/v1/supplement-impacts/{batch_id}", s.handleGetSupplementImpact) + mux.HandleFunc("POST /api/v1/supplement-impacts/{batch_id}/decision", s.handleDecideSupplementImpact) // 3. 对话协同服务 (标准 JSON 阻塞) mux.HandleFunc("POST /api/v1/chat", s.handleChat) diff --git a/internal/server/sse.go b/internal/server/sse.go index efd23790..da340839 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -233,7 +233,9 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { ctx := stream.WithRequestID(r.Context(), requestID) s.enrichSessionWorkflowStage(ctx, sess, req.UserID) - if sess.Project() > 0 && !s.allowActiveStageChatAccess(ctx, sess.Project(), req.UserID) { + // Free-lane: project members may always *initiate* chat. Stage ABAC still + // filters history reads and controlled-doc writes elsewhere. + if sess.Project() > 0 && !s.allowFreeLaneChatInitiation(sess.Project(), req.UserID) { writeWorkflowAccessDeniedSSE(w, flusher, req.SessionID) return } diff --git a/internal/server/supplement_impact_adapter.go b/internal/server/supplement_impact_adapter.go new file mode 100644 index 00000000..cff92cc2 --- /dev/null +++ b/internal/server/supplement_impact_adapter.go @@ -0,0 +1,58 @@ +package server + +import ( + "chat-agent-system/internal/supplementimpact" + "context" +) + +type supplementImpactAdapter struct { + svc *supplementimpact.Service +} + +func newSupplementImpactAdapter(svc *supplementimpact.Service) SupplementImpactService { + if svc == nil { + return nil + } + return supplementImpactAdapter{svc: svc} +} + +func (a supplementImpactAdapter) GetBatch(ctx context.Context, batchID string) (*SupplementImpactBatchView, error) { + b, err := a.svc.GetBatch(ctx, batchID) + if err != nil { + return nil, err + } + return mapSupplementBatch(b), nil +} + +func (a supplementImpactAdapter) Decide(ctx context.Context, batchID string, actorUserID int64, in SupplementImpactDecisionRequest) (*SupplementImpactBatchView, error) { + b, err := a.svc.Decide(ctx, supplementimpact.DecisionCommand{ + BatchID: batchID, ActorUserID: actorUserID, Kind: in.Kind, + SelectedDocIDs: in.SelectedDocIDs, DecisionNonce: in.DecisionNonce, + DecisionDigest: in.DecisionDigest, ExpectedRevision: in.ExpectedRevision, + }) + if err != nil { + return nil, err + } + return mapSupplementBatch(b), nil +} + +func mapSupplementBatch(b *supplementimpact.Batch) *SupplementImpactBatchView { + if b == nil { + return nil + } + out := &SupplementImpactBatchView{ + BatchID: b.BatchID, ProjectID: b.ProjectID, SessionID: b.SessionID, + InitiatorUserID: b.InitiatorUserID, SourceKind: b.SourceKind, SourceSummary: b.SourceSummary, + CoverageStatus: b.CoverageStatus, Status: b.Status, DecisionNonce: b.DecisionNonce, + DecisionDigest: b.DecisionDigest, Revision: b.Revision, ExpiresAt: b.ExpiresAt, + } + for _, it := range b.Items { + 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, + }) + } + return out +} diff --git a/internal/server/supplement_impact_handlers.go b/internal/server/supplement_impact_handlers.go new file mode 100644 index 00000000..54644398 --- /dev/null +++ b/internal/server/supplement_impact_handlers.go @@ -0,0 +1,162 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" +) + +// SupplementImpactService is the HTTP-facing port for supplement impact batches. +// Satisfied by an adapter over *supplementimpact.Service when wired; nil → 503. +type SupplementImpactService interface { + GetBatch(ctx context.Context, batchID string) (*SupplementImpactBatchView, error) + Decide(ctx context.Context, batchID string, actorUserID int64, in SupplementImpactDecisionRequest) (*SupplementImpactBatchView, error) +} + +// SupplementImpactBatchView is the card-refresh / decision response shape. +type SupplementImpactBatchView struct { + BatchID string `json:"batch_id"` + ProjectID int64 `json:"project_id"` + SessionID string `json:"session_id,omitempty"` + InitiatorUserID int64 `json:"initiator_user_id"` + SourceKind string `json:"source_kind,omitempty"` + SourceSummary string `json:"source_summary,omitempty"` + CoverageStatus string `json:"coverage_status,omitempty"` + Status string `json:"status"` + DecisionNonce string `json:"decision_nonce"` + DecisionDigest string `json:"decision_digest,omitempty"` + Revision int64 `json:"revision"` + ExpiresAt time.Time `json:"expires_at"` + Items []SupplementImpactItemView `json:"items,omitempty"` +} + +// SupplementImpactItemView is one affected document row on the confirm card. +type SupplementImpactItemView struct { + DocID string `json:"doc_id"` + Title string `json:"title,omitempty"` + ItemKey string `json:"item_key,omitempty"` + ImpactType string `json:"impact_type,omitempty"` + Depth int `json:"depth,omitempty"` + Reason string `json:"reason,omitempty"` + AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` + BusySnapshot bool `json:"busy_snapshot"` + ChangeSummary string `json:"change_summary,omitempty"` + Selected bool `json:"selected"` + Outcome string `json:"outcome,omitempty"` + ProposalID string `json:"proposal_id,omitempty"` +} + +// SupplementImpactDecisionRequest is the confirm/cancel CAS envelope. +type SupplementImpactDecisionRequest struct { + Kind string `json:"kind"` + SelectedDocIDs []string `json:"selected_doc_ids"` + DecisionNonce string `json:"decision_nonce"` + DecisionDigest string `json:"decision_digest"` + ExpectedRevision int64 `json:"expected_revision"` +} + +func (s *Server) handleGetSupplementImpact(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + if s.supplementImpact == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "supplement impact service unavailable") + return + } + batchID := strings.TrimSpace(r.PathValue("batch_id")) + if batchID == "" { + writeSafeError(w, r, http.StatusBadRequest, "invalid batch id") + return + } + batch, err := s.supplementImpact.GetBatch(r.Context(), batchID) + if err != nil || batch == nil { + writeSafeError(w, r, http.StatusNotFound, "batch not found") + return + } + if !s.userCanAccessSupplementBatch(r.Context(), user.ID, batch.ProjectID) { + writeSafeError(w, r, http.StatusForbidden, "not a project member") + return + } + writeJSONResponse(w, http.StatusOK, batch) +} + +func (s *Server) handleDecideSupplementImpact(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + if s.supplementImpact == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "supplement impact service unavailable") + return + } + batchID := strings.TrimSpace(r.PathValue("batch_id")) + if batchID == "" { + writeSafeError(w, r, http.StatusBadRequest, "invalid batch id") + return + } + var body SupplementImpactDecisionRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid body") + return + } + kind := strings.TrimSpace(strings.ToLower(body.Kind)) + if kind != "confirm" && kind != "cancel" { + writeSafeError(w, r, http.StatusBadRequest, "kind must be confirm or cancel") + return + } + if strings.TrimSpace(body.DecisionNonce) == "" || strings.TrimSpace(body.DecisionDigest) == "" { + writeSafeError(w, r, http.StatusBadRequest, "decision_nonce and decision_digest required") + return + } + body.Kind = kind + + // Load batch first for membership check via project_id. + existing, err := s.supplementImpact.GetBatch(r.Context(), batchID) + if err != nil || existing == nil { + writeSafeError(w, r, http.StatusNotFound, "batch not found") + return + } + if !s.userCanAccessSupplementBatch(r.Context(), user.ID, existing.ProjectID) { + writeSafeError(w, r, http.StatusForbidden, "not a project member") + return + } + + batch, err := s.supplementImpact.Decide(r.Context(), batchID, user.ID, body) + if err != nil { + msg := err.Error() + status := http.StatusBadRequest + if strings.Contains(msg, "revision conflict") { + status = http.StatusConflict + } else if strings.Contains(msg, "only initiator") { + status = http.StatusForbidden + } else if strings.Contains(msg, "expired") { + status = http.StatusGone + } else if strings.Contains(msg, "not pending") { + status = http.StatusConflict + } + writeSafeError(w, r, status, msg) + return + } + writeJSONResponse(w, http.StatusOK, batch) +} + +func (s *Server) userCanAccessSupplementBatch(ctx context.Context, userID, projectID int64) bool { + if projectID <= 0 || userID <= 0 { + return false + } + if s.authorizer != nil { + ok, err := s.authorizer.IsProjectMember(ctx, projectID, userID) + if err == nil && ok { + return true + } + } + if s.pwSvc != nil { + if isCreator, err := s.pwSvc.IsProjectCreator(ctx, projectID, userID); err == nil && isCreator { + return true + } + } + return false +} diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 7c1e381c..f6d7bb01 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -454,7 +454,12 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu if doc.LeaseExpiresAt != nil { expiresAt = *doc.LeaseExpiresAt } - reason := fmt.Sprintf("文档【%s】正在由 %s(%s) 编辑中,当前请求未创建新任务", doc.Title, doc.LeaseHolderType, doc.LeaseHolderID) + display := docgov.LeaseHolderDisplay(doc.LeaseHolderType, doc.LeaseHolderID, "") + exp := time.Time{} + if doc.LeaseExpiresAt != nil { + exp = *doc.LeaseExpiresAt + } + reason := docgov.FormatDocumentBusyReason(doc.Title, display, exp) return &docgov.DocumentBusyError{ DocID: doc.DocID, HolderType: doc.LeaseHolderType, diff --git a/web/src/api/docgov.ts b/web/src/api/docgov.ts index 34eff665..411b5797 100644 --- a/web/src/api/docgov.ts +++ b/web/src/api/docgov.ts @@ -29,6 +29,7 @@ export interface DocGovRecord { approver_id?: number; approver_name?: string; author_claimed?: boolean; + author_claimed_at?: string; reviewer_claimed?: boolean; approver_claimed?: boolean; last_rejected_reason?: string; diff --git a/web/src/api/notifications.test.ts b/web/src/api/notifications.test.ts new file mode 100644 index 00000000..f212f6a8 --- /dev/null +++ b/web/src/api/notifications.test.ts @@ -0,0 +1,46 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { isProposalType, sortInboxNotifications, type UserNotification } from './notifications'; + +describe('notifications inbox helpers', () => { + it('ranks action_required / proposal ahead of info', () => { + const items: UserNotification[] = [ + { + id: 1, + user_id: 1, + project_id: 1, + type: 'stage_ready_to_advance', + title: 'a', + body: '', + status: 'unread', + severity: 'info', + created_at: '', + }, + { + id: 2, + user_id: 1, + project_id: 1, + type: 'document_proposal_required', + title: 'b', + body: '', + status: 'unread', + severity: 'action_required', + created_at: '', + }, + { + id: 3, + user_id: 1, + project_id: 1, + type: 'document_unclaimed_edit_applied', + title: 'c', + body: '', + status: 'unread', + severity: 'info', + created_at: '', + }, + ]; + const sorted = sortInboxNotifications(items); + assert.equal(sorted[0].id, 2); + assert.equal(isProposalType(sorted[0].type), true); + }); +}); diff --git a/web/src/api/notifications.ts b/web/src/api/notifications.ts new file mode 100644 index 00000000..dc8f62ab --- /dev/null +++ b/web/src/api/notifications.ts @@ -0,0 +1,154 @@ +import { apiFetch, API_BASE } from './client'; +import { getBearerAuthHeaders } from './authHeaders'; + +export interface UserNotification { + id: number; + user_id: number; + project_id: number; + type: string; + title: string; + body: string; + status: string; + severity?: 'action_required' | 'info' | string; + digest_id?: number; + created_at: string; + payload?: { + stage_key?: string; + stage_name?: string; + config_revision?: number; + workflow_instance_id?: number; + doc_id?: string; + proposal_id?: string; + batch_id?: string; + }; +} + +export function sortInboxNotifications(items: UserNotification[]): UserNotification[] { + return [...items].sort((a, b) => { + const sa = a.severity === 'action_required' || isProposalType(a.type) ? 0 : 1; + const sb = b.severity === 'action_required' || isProposalType(b.type) ? 0 : 1; + if (sa !== sb) return sa - sb; + return b.id - a.id; + }); +} + +export function isProposalType(type: string): boolean { + return ( + type === 'proposal_required' || + type === 'document_proposal_required' || + type === 'digest_proposal_required' || + type.includes('proposal_required') + ); +} + +export async function fetchAllNotifications(opts?: { + sinceId?: number; + limit?: number; + scope?: 'all'; +}): Promise { + const params = new URLSearchParams(); + if (opts?.sinceId && opts.sinceId > 0) params.set('since_id', String(opts.sinceId)); + if (opts?.limit) params.set('limit', String(opts.limit)); + if (opts?.scope) params.set('scope', opts.scope); + const qs = params.toString(); + const res = await apiFetch(`${API_BASE}/api/v1/notifications${qs ? `?${qs}` : ''}`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + if (res.status === 503) return []; + const text = await res.text(); + throw new Error(text || `获取通知失败: ${res.status}`); + } + const data = await res.json(); + const items = Array.isArray(data.items) ? (data.items as UserNotification[]) : []; + return sortInboxNotifications(items); +} + +export async function patchNotificationStatus( + id: number, + status: 'read' | 'acted' | 'dismissed' | 'unread' +): Promise { + const res = await apiFetch(`${API_BASE}/api/v1/notifications/${id}`, { + method: 'PATCH', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify({ status }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `更新通知失败: ${res.status}`); + } +} + +export interface SupplementImpactItem { + doc_id: string; + title?: string; + impact_type?: string; + reason?: string; + author_claimed_snapshot?: boolean; + busy_snapshot?: boolean; + change_summary?: string; + selected?: boolean; + outcome?: string; + proposal_id?: string; +} + +export interface SupplementImpactBatch { + batch_id: string; + project_id: number; + session_id?: string; + initiator_user_id: number; + source_summary?: string; + coverage_status?: string; + status: string; + decision_nonce: string; + decision_digest?: string; + revision: number; + expires_at: string; + items?: SupplementImpactItem[]; +} + +export async function fetchSupplementImpact(batchId: string): Promise { + const res = await apiFetch(`${API_BASE}/api/v1/supplement-impacts/${encodeURIComponent(batchId)}`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `加载补充影响批次失败: ${res.status}`); + } + return res.json(); +} + +export async function decideSupplementImpact( + batchId: string, + body: { + kind: 'confirm' | 'cancel'; + selected_doc_ids: string[]; + decision_nonce: string; + decision_digest: string; + expected_revision: number; + } +): Promise { + const res = await apiFetch( + `${API_BASE}/api/v1/supplement-impacts/${encodeURIComponent(batchId)}/decision`, + { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify(body), + } + ); + if (!res.ok) { + const text = await res.text(); + let message = text || `决策失败: ${res.status}`; + try { + const parsed = JSON.parse(text) as { message?: string; error?: string }; + if (parsed.message) message = parsed.message; + else if (parsed.error) message = parsed.error; + } catch { + /* keep */ + } + const err = new Error(message) as Error & { status?: number }; + err.status = res.status; + throw err; + } + return res.json(); +} diff --git a/web/src/components/chat/InteractiveCard.test.ts b/web/src/components/chat/InteractiveCard.test.ts index 931617ee..9ebfd5b5 100644 --- a/web/src/components/chat/InteractiveCard.test.ts +++ b/web/src/components/chat/InteractiveCard.test.ts @@ -59,6 +59,10 @@ describe('InteractiveCard Logic', () => { const planMeta = getCardTypeMeta('PLAN_CONFIRM'); expect(planMeta.label).toBe('计划确认'); expect(planMeta.badgeText).toContain('计划'); + + const impactMeta = getCardTypeMeta('SUPPLEMENT_IMPACT_CONFIRM'); + expect(impactMeta.label).toContain('补充影响'); + expect(impactMeta.badgeText).toContain('影响'); }); }); diff --git a/web/src/components/chat/InteractiveCard.tsx b/web/src/components/chat/InteractiveCard.tsx index 443648ee..7c91177c 100644 --- a/web/src/components/chat/InteractiveCard.tsx +++ b/web/src/components/chat/InteractiveCard.tsx @@ -92,6 +92,16 @@ export function getCardTypeMeta(type: InteractiveCardType) { badgeColor: 'var(--amber-600)', badgeBorder: 'rgba(242, 169, 59, 0.3)', }; + case 'SUPPLEMENT_IMPACT_CONFIRM': + return { + label: '补充影响确认', + badgeText: '影响确认', + icon: FileQuestion, + accentColor: 'var(--brand-500)', + badgeBg: 'var(--brand-50)', + badgeColor: 'var(--brand-600)', + badgeBorder: 'var(--brand-100)', + }; default: return { label: '协同卡片', @@ -566,6 +576,89 @@ export const InteractiveCard: React.FC = ({ )} + {card.cardType === 'SUPPLEMENT_IMPACT_CONFIRM' && Array.isArray(card.impactItems) && ( +
+ {card.coverageStatus && ( +
+ 覆盖状态:{card.coverageStatus} +
+ )} + {card.impactItems.map((item) => ( + + ))} +
+ )} + {/* 拟建议修改内容 / 批注展示 */} {card.proposedContent && (
= ({ ast, + currentRole, currentUserId, canOverrideVisibility = true, onOpenVisibilityModal, @@ -345,6 +346,34 @@ export const DocGovActionBar: React.FC = ({ • 已被编制人认领 )} + {ast.isClaimed && + currentRole === 'author' && + (ast.authorId === undefined || currentUserId === undefined || ast.authorId === currentUserId) && ( + + 您已于{' '} + {ast.authorClaimedAt + ? new Date(ast.authorClaimedAt).toLocaleString('zh-CN', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : '此前'}{' '} + 认领本文档;此后他人发起的修改需您确认。 + + )} {ast.visibilityRule && ast.visibilityRule.mode !== 'inherit' && ( = ({ />
+ + {/* 主题切换按钮 */} + + {open && ( +
+
+ + 通知 + + +
+ + {error && ( +
+ {error} +
+ )} + + {actionItems.length > 0 && ( +
+
+ 需要处理 +
+ {actionItems.map((n) => ( + { + void markRead(n); + onJumpProject?.(n.project_id, n.payload); + }} + onDecide={ + isProposalType(n.type) && n.payload?.proposal_id + ? (approve) => void decideProposal(n, approve) + : undefined + } + /> + ))} +
+ )} + +
+
+ 摘要 +
+ {infoItems.length === 0 && actionItems.length === 0 ? ( +
+ 暂无通知 +
+ ) : ( + infoItems.map((n) => ( + { + void markRead(n); + onJumpProject?.(n.project_id, n.payload); + }} + /> + )) + )} +
+
+ )} + + ); +}; + +function NotificationRow({ + n, + busy, + onOpen, + onDecide, +}: { + n: UserNotification; + busy: boolean; + onOpen: () => void; + onDecide?: (approve: boolean) => void; +}) { + const unread = n.status === 'unread'; + return ( +
+
+
+
+ {n.title} +
+
+ {n.body} +
+
+ +
+ {onDecide && n.status !== 'acted' && ( +
+ + +
+ )} +
+ ); +} diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 3f340ba2..e43e0a8e 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -153,6 +153,23 @@ test('read-only current-doc request does not start a task', () => { ); }); +test('summary/analysis with write verbs stays chat (read-only priority)', () => { + const phrases = [ + '请总结并生成要点', + '分析一下这份附件然后编写摘要', + '概括文档内容并更新说明', + '查看附件后修改建议列表', + ]; + for (const text of phrases) { + assert.equal(hasDocumentWriteIntent(text), false, text); + assert.equal(resolveTaskTemplateForMessage(text, bothPool, activeIntake), undefined, text); + } +}); + +test('pure write intent still routes to task', () => { + assert.equal(hasDocumentWriteIntent('请编制用户初始需求收集表'), true); +}); + test('template-only active slot is not pinned; keywords still apply', () => { assert.equal(isServerRegisteredControlledDoc(templateIntakeSlot), false); const resolved = resolveTaskTemplateForMessage( diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 797c863b..f6d5a49c 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -1,7 +1,7 @@ import { DeliverableItem } from '../types/workbench'; const READ_ONLY_INTENT = - /(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/; + /(?:总结|概括|摘要|要点|简述|解读|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/; const WRITE_INTENT = // 弱写动词(整理/梳理/汇总等)故意不含:走聊天通道,由后端阶段感知 + 确认门禁引导。 /(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做|改写)/; @@ -189,9 +189,10 @@ export function wrongWorkflowGuidance(info: NonNullable + prev.map((m) => { + if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard?.impactItems) return m; + return { + ...m, + interactiveCard: { + ...m.interactiveCard, + impactItems: m.interactiveCard.impactItems.map((it) => + it.docId === docId ? { ...it, selected } : it + ), + }, + }; + }) + ); + return; + } + const kind = (payload?.kind || (actionId === 'cancel' || actionId === 'supplement_cancel' ? 'cancel' : 'confirm')) as + | 'confirm' + | 'cancel'; + const expectedRevision = Number(payload?.expected_revision || targetCard.planRevision || 0); + const decisionNonce = + String(payload?.decision_nonce || targetCard.decisionNonce || '') || + (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${cardId}:${actionId}:${expectedRevision}:${Date.now()}`); + const selectedDocIds = + kind === 'confirm' + ? (targetCard.impactItems || []) + .filter((it) => it.selected && !it.busy) + .map((it) => it.docId) + : []; + const decisionDigest = + String(payload?.decision_digest || targetCard.decisionDigest || '') || + `${kind}:${expectedRevision}:${selectedDocIds.join(',')}`; + setMessages((prev) => + prev.map((m) => + m.interactiveCard?.cardId === cardId + ? { ...m, interactiveCard: { ...m.interactiveCard!, status: 'executing' } } + : m + ) + ); + void (async () => { + try { + const batch = await decideSupplementImpact(batchId, { + kind, + selected_doc_ids: selectedDocIds, + decision_nonce: decisionNonce, + decision_digest: decisionDigest, + expected_revision: expectedRevision, + }); + setMessages((prev) => + prev.map((m) => { + if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m; + return { + ...m, + interactiveCard: { + ...m.interactiveCard, + status: batch.status === 'cancelled' ? 'cancelled' : 'resolved', + planRevision: batch.revision, + decisionNonce: batch.decision_nonce, + resolutionSummary: + kind === 'cancel' ? '已取消补充影响确认' : '补充影响决策已提交', + resolvedBy: currentUser.name, + resolvedAt: new Date().toTimeString().substring(0, 5), + impactItems: (batch.items || []).map((it) => ({ + docId: it.doc_id, + title: it.title, + authorClaimed: it.author_claimed_snapshot, + busy: it.busy_snapshot, + changeSummary: it.change_summary, + selected: it.selected, + })), + }, + }; + }) + ); + } catch { + setMessages((prev) => + prev.map((m) => + m.interactiveCard?.cardId === cardId && m.interactiveCard + ? { + ...m, + interactiveCard: { + ...m.interactiveCard, + status: 'active', + resolutionSummary: '补充影响决策失败,请重试', + }, + } + : m + ) + ); + } + })(); + return; + } + if (targetCard.cardType === 'RECHECK_NOTICE' && actionId === 'recheck_no_change') { const projectId = Number(payload?.project_id || currentProjectId); const markId = Number(payload?.mark_id); diff --git a/web/src/hooks/useDocumentLifecycleActions.ts b/web/src/hooks/useDocumentLifecycleActions.ts index 766ffd1e..948178e4 100644 --- a/web/src/hooks/useDocumentLifecycleActions.ts +++ b/web/src/hooks/useDocumentLifecycleActions.ts @@ -49,6 +49,7 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu ...docAST, lifecycleState: nextState, isClaimed: nextClaimed, + authorClaimedAt: doc.author_claimed_at || docAST.authorClaimedAt, authorId: doc.author_id ?? docAST.authorId, author: doc.author_name || docAST.author, reviewerId: doc.reviewer_id ?? docAST.reviewerId, diff --git a/web/src/hooks/useSendMessage.test.ts b/web/src/hooks/useSendMessage.test.ts index 8722c899..bed4e69e 100644 --- a/web/src/hooks/useSendMessage.test.ts +++ b/web/src/hooks/useSendMessage.test.ts @@ -8,8 +8,9 @@ test('sending a message lives in one hook', () => { assert.equal((app.match(/useSendMessage\(/g) || []).length, 1); assert.equal(app.includes('附件服务端注册失败'), false); assert.equal(hook.includes('registerUploadAttachments'), true); + assert.equal(hook.includes('hasDocumentWriteIntent'), true); assert.equal(hook.includes('runTaskAttempt'), true); assert.equal(hook.includes('runStreamAttempt'), true); - assert.equal(hook.includes('失败硬拦'), true); + assert.equal(hook.includes('写意图失败硬拦'), true); assert.equal(hook.includes('handlePassReview'), true); }); diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts index a05a8d67..3864b53c 100644 --- a/web/src/hooks/useSendMessage.ts +++ b/web/src/hooks/useSendMessage.ts @@ -11,7 +11,7 @@ import { isAffirmativeResponse, } from '../engine/cardActionEngine'; import { markdownToBlockAST } from '../engine/markdownToBlock'; -import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch'; +import { hasDocumentWriteIntent, resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch'; import { createClientTurnId } from '../lib/createClientTurnId'; import { canonicalDocIdForTask } from '../lib/controlledDocHydrate'; import { holdsDocDuty } from '../lib/docGovActionVisibility'; @@ -170,7 +170,7 @@ export function useSendMessage(input: SendMessageInput) { }; } - // 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退 + // 附件优先注册为 freeform;写意图失败硬拦,只读/对话路径注册失败不阻断发送。 let processedAttachments = attachments; if (attachments && attachments.length > 0) { let registered; @@ -183,74 +183,80 @@ export function useSendMessage(input: SendMessageInput) { createFreeformDoc, }); } catch (err) { - return { - accepted: false, - error: err instanceof Error ? err.message : '附件服务端注册失败,请重试', - }; + if (hasDocumentWriteIntent(trimmed)) { + return { + accepted: false, + error: err instanceof Error ? err.message : '附件服务端注册失败,请重试', + }; + } + console.warn('[upload] freeform register failed; continuing chat/summary', err); + registered = undefined; } - const newDeliverablesList = registered.map((r) => r.deliverable); - const newDocCacheEntries: Record = {}; - for (const r of registered) { - const baseTitle = r.deliverable.title; - const initialMd = r.detail.content_markdown; - const newAST = markdownToBlockAST(initialMd, { - docId: r.serverDocId, - title: baseTitle, - taskKey: 'user_requirements', - version: 'V1.0', - category: 'private', - creatorId: currentUser.id, - creatorName: currentUser.name, - lifecycleState: 'drafting', - isClaimed: true, - author: currentUser.name, - reviewer: '', - approver: '', - }); - newAST.governanceMode = 'freeform'; - newAST.space = 'my_private'; - newDocCacheEntries[r.serverDocId] = newAST; - if (r.attachment.fileRaw) { - saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) => - console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e), - ); - // 二进制同步登记 CAS,刷新后可经 raw 端点回退预览 - const extMatch = r.attachment.name.match(/\.([^.]+)$/); - const ext = extMatch ? extMatch[1].toLowerCase() : ''; - const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']); - if (!TEXT_EXTS.has(ext) && currentProjectId > 0) { - try { - const uploaded = await uploadProjectAsset( - currentProjectId, - r.attachment.fileRaw as Blob, - r.attachment.name, - ); - if (r.deliverable.fileMeta) { - r.deliverable.fileMeta.assetId = uploaded.asset_id; + if (registered) { + const newDeliverablesList = registered.map((r) => r.deliverable); + const newDocCacheEntries: Record = {}; + for (const r of registered) { + const baseTitle = r.deliverable.title; + const initialMd = r.detail.content_markdown; + const newAST = markdownToBlockAST(initialMd, { + docId: r.serverDocId, + title: baseTitle, + taskKey: 'user_requirements', + version: 'V1.0', + category: 'private', + creatorId: currentUser.id, + creatorName: currentUser.name, + lifecycleState: 'drafting', + isClaimed: true, + author: currentUser.name, + reviewer: '', + approver: '', + }); + newAST.governanceMode = 'freeform'; + newAST.space = 'my_private'; + newDocCacheEntries[r.serverDocId] = newAST; + if (r.attachment.fileRaw) { + saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) => + console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e), + ); + // 二进制同步登记 CAS,刷新后可经 raw 端点回退预览 + const extMatch = r.attachment.name.match(/\.([^.]+)$/); + const ext = extMatch ? extMatch[1].toLowerCase() : ''; + const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']); + if (!TEXT_EXTS.has(ext) && currentProjectId > 0) { + try { + const uploaded = await uploadProjectAsset( + currentProjectId, + r.attachment.fileRaw as Blob, + r.attachment.name, + ); + if (r.deliverable.fileMeta) { + r.deliverable.fileMeta.assetId = uploaded.asset_id; + } + // 回填去重与解析状态,供消息附件卡片展示提示。 + r.attachment.assetId = uploaded.asset_id; + r.attachment.deduplicated = uploaded.deduplicated === true; + r.attachment.parseState = uploaded.parse_state; + } catch (e) { + console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e); } - // 回填去重与解析状态,供消息附件卡片展示提示。 - r.attachment.assetId = uploaded.asset_id; - r.attachment.deduplicated = uploaded.deduplicated === true; - r.attachment.parseState = uploaded.parse_state; - } catch (e) { - console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e); } } } - } - processedAttachments = registered.map((r) => r.attachment); - const newDocIds = new Set(newDeliverablesList.map((d) => d.docId)); - setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]); - setDocCache((prev) => ({ - ...prev, - ...newDocCacheEntries, - })); + processedAttachments = registered.map((r) => r.attachment); + const newDocIds = new Set(newDeliverablesList.map((d) => d.docId)); + setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]); + setDocCache((prev) => ({ + ...prev, + ...newDocCacheEntries, + })); - if (processedAttachments.length > 0 && processedAttachments[0].docId) { - setActiveDocId(processedAttachments[0].docId); - setActiveCategory('private'); + if (processedAttachments.length > 0 && processedAttachments[0].docId) { + setActiveDocId(processedAttachments[0].docId); + setActiveCategory('private'); + } } } diff --git a/web/src/lib/taskRunErrorInfo.test.ts b/web/src/lib/taskRunErrorInfo.test.ts index 87a2840b..9e01c4fd 100644 --- a/web/src/lib/taskRunErrorInfo.test.ts +++ b/web/src/lib/taskRunErrorInfo.test.ts @@ -9,6 +9,22 @@ test('403 revision denial is not reported as a network error', () => { assert.match(info.userMessage, /无权访问该文档/); }); +test('403 author-duty denial surfaces draft-vs-submit guidance', () => { + const info = buildNetworkErrorInfo(403, { + errorCode: 'ErrOnlyAuthorCanEdit', + detail: '', + }); + assert.equal(info.code, 'ErrOnlyAuthorCanEdit'); + assert.match(info.userMessage, /送审需编制人/); + assert.notEqual(info.code, 'NETWORK_ERROR'); +}); + +test('DOC_DUTY_FORBIDDEN keeps draft-vs-submit guidance', () => { + const info = buildNetworkErrorInfo(403, { errorCode: 'DOC_DUTY_FORBIDDEN' }); + assert.equal(info.code, 'DOC_DUTY_FORBIDDEN'); + assert.match(info.userMessage, /可生成\/编写草稿/); +}); + test('TASK_SCOPE_UNRESOLVED names the missing workflow stage', () => { const info = buildNetworkErrorInfo(409, { errorCode: 'TASK_SCOPE_UNRESOLVED' }); assert.equal(info.code, 'TASK_SCOPE_UNRESOLVED'); diff --git a/web/src/lib/taskRunErrorInfo.ts b/web/src/lib/taskRunErrorInfo.ts index 328f14c3..27d53d97 100644 --- a/web/src/lib/taskRunErrorInfo.ts +++ b/web/src/lib/taskRunErrorInfo.ts @@ -35,6 +35,22 @@ const fatalDenialCopy: Record = { TASK_SCOPE_UNRESOLVED: '无法定位该文档任务的工作流阶段,请检查任务名或联系管理员', }; +/** 编制人身份类错误:可生成/编写草稿,送审仍须编制人。 */ +const authorDutyCopy = + '您不是该文档编制人,可生成/编写草稿,但送审需编制人操作'; + +const authorDutyErrorCodes = new Set([ + 'DOC_DUTY_FORBIDDEN', + 'only_author_can_edit', + 'ErrOnlyAuthorCanEdit', + 'reviewer_cannot_edit', + 'ErrReviewerCannotEdit', + 'approver_cannot_edit', + 'ErrApproverCannotEdit', + 'unauthorized_actor', + 'ErrUnauthorizedActor', +]); + /** 任务直通道失败后应降级到聊天确认引导的错误码(仅降级一次,防循环)。 */ export function isTaskStageDegradeCode(code: string | undefined | null): boolean { return code === 'TASK_SCOPE_UNRESOLVED' || code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE'; @@ -48,7 +64,7 @@ export function buildNetworkErrorInfo( const detail = opts?.detail || ''; if (code === 'DOC_BUSY' || code === 'document_busy') { return { - userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务', + userMessage: detail || '文档正在修改中(由项目助手或其他用户发起)。您的修改请求未执行,请稍后重试。', code: 'DOC_BUSY', class: 'fatal', actions: [], @@ -62,16 +78,19 @@ export function buildNetworkErrorInfo( actions: ['retry'], }; } - if (code === 'DOC_DUTY_FORBIDDEN') { + if (code === 'DOC_DUTY_FORBIDDEN' || authorDutyErrorCodes.has(code)) { return { - userMessage: detail || '当前账号不是该文档任务的职责人,无法执行', - code, + userMessage: detail || authorDutyCopy, + code: code || 'DOC_DUTY_FORBIDDEN', class: 'fatal', actions: ['retry'], }; } if (fatalDenialCopy[code]) { - const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED' || code === 'TASK_SCOPE_UNRESOLVED'; + const retryable = + code === 'WORKFLOW_NOT_ACTIVATED' || + code === 'DOC_DUTY_NOT_MANAGED' || + code === 'TASK_SCOPE_UNRESOLVED'; return { userMessage: detail || fatalDenialCopy[code], code, @@ -96,6 +115,14 @@ export function buildNetworkErrorInfo( actions: ['retry'], }; } + if (authorDutyErrorCodes.has(code) || /编制|职责|送审|审核|批准/.test(detail)) { + return { + userMessage: detail || authorDutyCopy, + code: code || 'DOC_DUTY_FORBIDDEN', + class: 'fatal', + actions: ['retry'], + }; + } return { userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段', code: code || 'ERR_FORBIDDEN', diff --git a/web/src/types/block.ts b/web/src/types/block.ts index e77c261b..e5afe190 100644 --- a/web/src/types/block.ts +++ b/web/src/types/block.ts @@ -59,6 +59,8 @@ export interface DocumentAST { sharedAt?: string; lifecycleState: DocLifecycleState; isClaimed: boolean; + /** ISO time when current author claimed; optional from server */ + authorClaimedAt?: string; author: string; authorId?: number; reviewer: string; diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 0e48f4d0..5ad08d74 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -85,7 +85,8 @@ export type InteractiveCardType = | 'SLOT_FILLING' | 'DIFF_PROPOSAL' | 'REMINDER_NOTICE' - | 'RECHECK_NOTICE'; + | 'RECHECK_NOTICE' + | 'SUPPLEMENT_IMPACT_CONFIRM'; export type CardActionStatus = | 'active' @@ -124,6 +125,19 @@ export interface InteractiveCardData { planRevision?: number; stepCount?: number; validatedOnly?: boolean; + /** SUPPLEMENT_IMPACT_CONFIRM */ + supplementBatchId?: string; + decisionNonce?: string; + decisionDigest?: string; + coverageStatus?: string; + impactItems?: Array<{ + docId: string; + title?: string; + authorClaimed?: boolean; + busy?: boolean; + changeSummary?: string; + selected?: boolean; + }>; } export type AssistantMessageStatus =