From 4e0e6a1418840f4040925e5acc3a6c8fc200d62a Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 11:44:20 +0000 Subject: [PATCH 1/3] feat(notification): digest aggregates, supplement-impact batch, and free-collab hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add notification digests with ON CONFLICT DO UPDATE, migrations 00068-00070, supplementimpact module (analyze → confirm → claim-aware split), and wire coordinator/mutate hooks plus proposal_required digests. --- .../2026-09-24-free-collab-claim-notify.md | 22 ++ ...6-09-24-free-collab-claim-notify-design.md | 58 +++++ .doc/system-design/README.md | 9 + .doc/system-design/module-boundaries.yaml | 26 ++ ...026-09-24-notification-digest-severity.sql | 16 ++ .../00069_2026-09-24-notification-digests.sql | 25 ++ ...0_2026-09-24-supplement-impact-batches.sql | 49 ++++ internal/app/app.go | 7 +- internal/app/docnotify/consumer.go | 4 + internal/app/mutate_owner.go | 17 ++ internal/app/wire_supplement_impact.go | 72 ++++++ internal/bizdev/after_proposal.go | 50 ++++ internal/bizdev/after_proposal_test.go | 63 +++++ internal/bizdev/service.go | 16 ++ internal/bizdev/service_task_run.go | 15 ++ internal/coordinator/engine.go | 27 ++ internal/coordinator/engine_stage.go | 15 ++ internal/events/registry.go | 4 + internal/notification/digest.go | 186 ++++++++++++++ internal/notification/document_events.go | 4 + internal/notification/service.go | 52 +++- internal/notification/types.go | 23 +- internal/supplementimpact/http_adapter.go | 113 +++++++++ internal/supplementimpact/mem_store.go | 74 ++++++ internal/supplementimpact/pg_store.go | 173 +++++++++++++ internal/supplementimpact/service.go | 238 ++++++++++++++++++ internal/supplementimpact/service_test.go | 108 ++++++++ internal/supplementimpact/trigger.go | 28 +++ internal/supplementimpact/types.go | 70 ++++++ 29 files changed, 1546 insertions(+), 18 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-24-free-collab-claim-notify.md create mode 100644 .doc/system-design/2026-09-24-free-collab-claim-notify-design.md create mode 100644 deploy/postgres/goose/00068_2026-09-24-notification-digest-severity.sql create mode 100644 deploy/postgres/goose/00069_2026-09-24-notification-digests.sql create mode 100644 deploy/postgres/goose/00070_2026-09-24-supplement-impact-batches.sql create mode 100644 internal/app/wire_supplement_impact.go create mode 100644 internal/bizdev/after_proposal.go create mode 100644 internal/bizdev/after_proposal_test.go create mode 100644 internal/notification/digest.go create mode 100644 internal/supplementimpact/http_adapter.go create mode 100644 internal/supplementimpact/mem_store.go create mode 100644 internal/supplementimpact/pg_store.go create mode 100644 internal/supplementimpact/service.go create mode 100644 internal/supplementimpact/service_test.go create mode 100644 internal/supplementimpact/trigger.go create mode 100644 internal/supplementimpact/types.go diff --git a/.doc/ops/short-term/2026-09-24-free-collab-claim-notify.md b/.doc/ops/short-term/2026-09-24-free-collab-claim-notify.md new file mode 100644 index 00000000..a610c325 --- /dev/null +++ b/.doc/ops/short-term/2026-09-24-free-collab-claim-notify.md @@ -0,0 +1,22 @@ +# 自由协作 + 认领提醒 — 任务记录 + +- **task_id**: free-collab-claim-notify-2026-09-24 +- **status**: in_progress +- **owner**: Cursor +- **mutex**: notification+supplementimpact+events+docs +- **companion_task**: non-author-member-access-2026-09-24 (server+docgov+web) +- **base_dev_sha**: ebb36fb85c80b83e895b29df5d9056c68f5efe31 +- **design**: [../system-design/2026-09-24-free-collab-claim-notify-design.md](../system-design/2026-09-24-free-collab-claim-notify-design.md) +- **write_scope**: internal/{notification,events,app,bizdev,coordinator,traceimpact,supplementimpact}, deploy/postgres, .doc +- **review_state**: pending +- **integration_state**: pending +- **deployment**: not_started +- **cleanup**: pending +- **remote_sync**: not_requested + +## 批次 + +- A 自由协作通道(companion worktree) +- B 认领补齐 + 互斥加固(companion) +- C 提醒机制 + 收件箱 +- D 补充影响批次 + 确认卡 + 分流执行 diff --git a/.doc/system-design/2026-09-24-free-collab-claim-notify-design.md b/.doc/system-design/2026-09-24-free-collab-claim-notify-design.md new file mode 100644 index 00000000..77ff4e1b --- /dev/null +++ b/.doc/system-design/2026-09-24-free-collab-claim-notify-design.md @@ -0,0 +1,58 @@ +# 自由协作通道 + 认领态分流 + 文档变更提醒机制 + +**status**: in_progress +**task_id**: free-collab-claim-notify-2026-09-24 +**companion**: non-author-member-access-2026-09-24(持有 server/docgov/web 写租约) +**base_dev_sha**: ebb36fb85c80b83e895b29df5d9056c68f5efe31 +**授权**: 用户确认方案后实施(会话 2026-09-24) + +## 1. 目标 + +1. 任意项目成员随时自由对话、上传、总结(自由协作通道) +2. 补充材料可按认领机制更新涉及文档(确认卡勾选) +3. 已认领须编制负责人确认并由负责人发起修改;未认领只发摘要提醒 +4. 单文档同时只被一个任务修改;冲突提示且不执行 + +## 2. 认领语义(不变) + +- 非模板(`is_template_only=false`)+ 编制负责人查看或编辑 → `author_claimed=true` +- 模板态查看不算认领 +- 已认领 → `RequiresProposal`;未认领 → `AllowedDirectEdit` +- 补齐:正文读取成功后自动 `RecordDocumentViewed`(author);claim/proposal 路由补项目鉴权 + +## 3. 自由协作通道 + +- 准入 = 项目成员(`RequireProjectAccess`),不查阶段/duty/实例状态 +- 适用:chat/help/query/impact.analyze/supplement/上传/总结 +- 受控文档写动作仍走 duty+阶段+认领 +- `allowActiveStageChatAccess` 仅约束读取他人阶段对话/受控正文,不约束发起自由对话 + +## 4. 补充 → 影响 → 确认卡 → 分流 + +- `project.fact.supplement` 成功后 → `traceimpact.Analyze` → `supplement_impact_batches/items` +- 卡片 `SUPPLEMENT_IMPACT_CONFIRM`;决策 API 用 nonce+digest+revision CAS(不走 intentflow plan tier) +- 未认领:直改 + info 提醒;已认领:提案 + action_required;busy:skipped_busy 不回滚 +- 批准后由负责人身份 `ForceDirectEdit` + `MarkProposalApplied` + +## 5. 提醒聚合 + +- `notification_digests`:`(user_id, digest_kind, doc_id, window_date)` UNIQUE,ON CONFLICT DO UPDATE +- `notifications.severity` ∈ {action_required, info} +- 新事件 `document.proposal_required` +- 文案:见实施计划 §4.5(单次/同日多次/多文档扇出) + +## 6. 互斥加固 + +- `persistLeaseLocked` 条件更新;heartbeat/release holder 归属鉴权 +- fencing token 接入 HTTP;`LeaseHolderDisplay` 可读文案 + +## 7. 迁移 + +- 00068 notifications digest_id + severity +- 00069 notification_digests +- 00070 supplement_impact_batches + items + +## 8. 红线 + +- 不放宽 is_locked / duty 写鉴权;不升 MULTI_INTENT_PLAN_TIER;不物理删 record 表 +- 索引缺失不得显示为无影响 diff --git a/.doc/system-design/README.md b/.doc/system-design/README.md index b91a5fbe..25e7ce4b 100644 --- a/.doc/system-design/README.md +++ b/.doc/system-design/README.md @@ -7,6 +7,15 @@ ## 📋 报告清单 +### 0b. 自由协作与认领提醒(2026-09-24) + +| 项 | 链接 | +|-----|------| +| 主设计 | [2026-09-24-free-collab-claim-notify-design.md](2026-09-24-free-collab-claim-notify-design.md) | +| 任务 | [../ops/short-term/2026-09-24-free-collab-claim-notify.md](../ops/short-term/2026-09-24-free-collab-claim-notify.md) | +| 一句话总结 | 自由协作通道 + 认领态分流更新 + 通知 digest 聚合 | + + ### 0. T20 加固设计(2026-09-22) | 项 | 链接 | diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 700549a9..c3711a3d 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -39,6 +39,7 @@ imports: - kb - materialindex - maturity + - notification - projectcontext - projectworkflow - quote @@ -114,6 +115,7 @@ imports: - projectinstance - projectworkflow - storage + - supplementimpact - sysadmin - taskrun - traceimpact @@ -307,6 +309,10 @@ imports: sysadmin: - config traceimpact: [] + supplementimpact: + - docgov + - notification + - traceimpact table_owners: document_records: @@ -375,6 +381,12 @@ table_owners: - app/docproject notifications: - notification + notification_digests: + - notification + supplement_impact_batches: + - supplementimpact + supplement_impact_items: + - supplementimpact stage_advance_requests: - notification final_artifacts: @@ -466,6 +478,16 @@ event_producers: intent.plan.completed: - app/intentflow - events + document.proposal_required: + - docgov + - bizdev + - app/supplementimpact + - events + document.unclaimed_edit_applied: + - docgov + - bizdev + - app/supplementimpact + - events event_consumers: outbox.notify: @@ -499,6 +521,10 @@ event_consumers: intent.plan.completed: - projection - notify + document.proposal_required: + - notify + document.unclaimed_edit_applied: + - notify unwrap_owners: - docgov diff --git a/deploy/postgres/goose/00068_2026-09-24-notification-digest-severity.sql b/deploy/postgres/goose/00068_2026-09-24-notification-digest-severity.sql new file mode 100644 index 00000000..fdaedb88 --- /dev/null +++ b/deploy/postgres/goose/00068_2026-09-24-notification-digest-severity.sql @@ -0,0 +1,16 @@ +-- +goose Up +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS digest_id BIGINT, + ADD COLUMN IF NOT EXISTS severity TEXT NOT NULL DEFAULT 'info'; + +DO $$ BEGIN + ALTER TABLE notifications + ADD CONSTRAINT chk_notification_severity CHECK (severity IN ('action_required', 'info')); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE INDEX IF NOT EXISTS idx_notifications_digest_id ON notifications (digest_id) WHERE digest_id IS NOT NULL; + +-- +goose Down +-- record retention: do not drop columns +SELECT 1; diff --git a/deploy/postgres/goose/00069_2026-09-24-notification-digests.sql b/deploy/postgres/goose/00069_2026-09-24-notification-digests.sql new file mode 100644 index 00000000..f4851e7f --- /dev/null +++ b/deploy/postgres/goose/00069_2026-09-24-notification-digests.sql @@ -0,0 +1,25 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS notification_digests ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + digest_kind TEXT NOT NULL, + doc_id TEXT NOT NULL DEFAULT '', + window_date DATE NOT NULL, + occurrence_count INT NOT NULL DEFAULT 1, + last_actor_id BIGINT, + last_actor_name TEXT NOT NULL DEFAULT '', + last_at TIMESTAMPTZ NOT NULL DEFAULT now(), + item_summaries JSONB NOT NULL DEFAULT '[]'::jsonb, + truncated_count INT NOT NULL DEFAULT 0, + notification_id BIGINT REFERENCES notifications(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_notification_digest_window UNIQUE (user_id, digest_kind, doc_id, window_date) +); + +CREATE INDEX IF NOT EXISTS idx_notification_digests_user_kind + ON notification_digests (user_id, digest_kind, window_date DESC); + +-- +goose Down +-- record retention: do not drop table +SELECT 1; diff --git a/deploy/postgres/goose/00070_2026-09-24-supplement-impact-batches.sql b/deploy/postgres/goose/00070_2026-09-24-supplement-impact-batches.sql new file mode 100644 index 00000000..dfffedf0 --- /dev/null +++ b/deploy/postgres/goose/00070_2026-09-24-supplement-impact-batches.sql @@ -0,0 +1,49 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS supplement_impact_batches ( + batch_id TEXT PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + session_id TEXT NOT NULL DEFAULT '', + initiator_user_id BIGINT NOT NULL REFERENCES users(id), + source_kind TEXT NOT NULL DEFAULT 'fact_supplement', + source_summary TEXT NOT NULL DEFAULT '', + coverage_status TEXT NOT NULL DEFAULT 'index_pending', + status TEXT NOT NULL DEFAULT 'pending', + decision_nonce TEXT NOT NULL DEFAULT '', + decision_digest TEXT NOT NULL DEFAULT '', + revision BIGINT NOT NULL DEFAULT 1, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_sib_status CHECK (status IN ('pending','decided','running','done','partially_done','expired','cancelled')) +); + +CREATE TABLE IF NOT EXISTS supplement_impact_items ( + id BIGSERIAL PRIMARY KEY, + batch_id TEXT NOT NULL REFERENCES supplement_impact_batches(batch_id) ON DELETE CASCADE, + doc_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + item_key TEXT NOT NULL DEFAULT '', + impact_type TEXT NOT NULL DEFAULT '', + depth INT NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + author_claimed_snapshot BOOLEAN NOT NULL DEFAULT FALSE, + author_id_snapshot BIGINT NOT NULL DEFAULT 0, + busy_snapshot BOOLEAN NOT NULL DEFAULT FALSE, + change_summary TEXT NOT NULL DEFAULT '', + selected BOOLEAN NOT NULL DEFAULT FALSE, + outcome TEXT NOT NULL DEFAULT '', + proposal_id TEXT NOT NULL DEFAULT '', + run_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_sii_outcome CHECK ( + outcome = '' OR outcome IN ('applied','proposal_pending','proposal_rejected','skipped_busy','skipped_locked','failed') + ) +); + +CREATE INDEX IF NOT EXISTS idx_sii_batch ON supplement_impact_items (batch_id); +CREATE INDEX IF NOT EXISTS idx_sib_project ON supplement_impact_batches (project_id, created_at DESC); + +-- +goose Down +-- record retention: do not drop tables +SELECT 1; diff --git a/internal/app/app.go b/internal/app/app.go index 795c4dc9..f855d855 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -31,6 +31,7 @@ import ( "chat-agent-system/internal/jevdecide" "chat-agent-system/internal/kb" "chat-agent-system/internal/notification" + "chat-agent-system/internal/supplementimpact" "chat-agent-system/internal/observability" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" @@ -75,6 +76,8 @@ type Runtime struct { Gov *docgov.GovernanceEngine Rev *docrevision.Service Trace *traceimpact.Service + SupplementImpact *supplementimpact.Service + mutateOwner *MutateOwner Ctx *ctxmgr.ContextManager CAS *storage.CAS FreeDoc *freedoc.Service @@ -312,7 +315,8 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { pflags := intentflow.CurrentFeatureFlags() liveOwners := intentflow.LiveOwners{} if pflags.U10Mutate { - liveOwners.Mutate = NewMutateOwner(projectAccess(rt), projectBlackboardPort(pool, parts.Ctx)) + rt.mutateOwner = NewMutateOwner(projectAccess(rt), projectBlackboardPort(pool, parts.Ctx)) + liveOwners.Mutate = rt.mutateOwner } if pflags.U11DocGov { liveOwners.DocGov = NewDocGovOwner(parts.Gov, projectAccess(rt), nil) @@ -335,6 +339,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { svc.SetAgentMaterializer(rt.Docflow) rt.bindDocumentNotify() rt.bindWorkflowApprovalHook() + rt.wireSupplementImpact() return rt, nil } diff --git a/internal/app/docnotify/consumer.go b/internal/app/docnotify/consumer.go index 8d70d6d8..ee3422c0 100644 --- a/internal/app/docnotify/consumer.go +++ b/internal/app/docnotify/consumer.go @@ -85,6 +85,10 @@ func notifyCopy(eventName string) (kind, title, body string) { return notification.TypeDocChangeReportDegraded, "变更报告未完整评估", "变更报告降级,受控文档暂不可送审。" case events.EventApproved: return notification.TypeDocApproved, "文档已批准", "文档已完成终审批准。" + case events.EventDocProposalRequired: + return notification.TypeDocProposalRequired, "文档变更待确认", "已认领文档有变更提案,请确认后由您发起修改。" + case events.EventDocUnclaimedEdit: + return notification.TypeDocUnclaimedEditApplied, "文档已更新", "未认领文档已被直接更新(无需您确认)。" default: return "", "", "" } diff --git a/internal/app/mutate_owner.go b/internal/app/mutate_owner.go index 378ac8b8..837df2e1 100644 --- a/internal/app/mutate_owner.go +++ b/internal/app/mutate_owner.go @@ -23,6 +23,8 @@ type MutateOwner struct { Access ProjectAccess Blackboard ProjectBlackboardPort Denied bool + // OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync. + OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) mu sync.Mutex receipts map[string]intentflow.Receipt @@ -91,6 +93,21 @@ func (m *MutateOwner) Execute(ctx context.Context, exec intentflow.StepExec) (in return intentflow.Receipt{}, err } newVer = got + if m.OnSupplement != nil { + summary := strings.TrimSpace(exec.SafeSummary) + if summary == "" { + summary = "project facts supplemented" + } + markdown := summary + if len(facts) > 0 { + parts := make([]string, 0, len(facts)) + for k, v := range facts { + parts = append(parts, k+": "+v) + } + markdown = strings.Join(parts, "\n") + } + m.OnSupplement(ctx, exec.Principal.ProjectID, exec.Principal.UserID, exec.Principal.SessionID, summary, markdown) + } } rec := intentflow.Receipt{ diff --git a/internal/app/wire_supplement_impact.go b/internal/app/wire_supplement_impact.go new file mode 100644 index 00000000..9f09ca79 --- /dev/null +++ b/internal/app/wire_supplement_impact.go @@ -0,0 +1,72 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + "time" + + "chat-agent-system/internal/intent" + "chat-agent-system/internal/supplementimpact" +) + +func (r *Runtime) wireSupplementImpact() { + if r == nil || r.Pool == nil || r.Gov == nil || r.Trace == nil { + return + } + if r.SupplementImpact != nil { + return + } + svc := &supplementimpact.Service{ + Store: supplementimpact.NewPGStore(r.Pool), + Docs: r.Gov, + Analyze: r.Trace, + Notify: r.Notify, + Propose: r.Gov, + } + r.SupplementImpact = svc + if r.Biz != nil && r.Notify != nil { + r.Biz.SetDigestNotifier(r.Notify) + } + if r.Engine != nil { + r.Engine.SetOnSupplement(func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) { + b, err := svc.CreateFromSupplement(ctx, projectID, userID, sessionID, summary, markdown) + if err != nil { + slog.Warn("supplementimpact sync create failed", "error", err, "project_id", projectID) + return + } + r.Engine.SetPendingSupplementCard(batchToCard(b)) + }) + } + if r.mutateOwner != nil { + r.mutateOwner.OnSupplement = func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) { + supplementimpact.TriggerAsync(svc, projectID, userID, sessionID, summary, markdown) + } + } +} + +func batchToCard(b *supplementimpact.Batch) *intent.InteractiveCardPayload { + if b == nil { + return nil + } + title, summary := supplementimpact.BuildConfirmCardMeta(b) + actions := []intent.CardAction{ + {ActionID: "confirm_supplement_impact", Label: "确认更新所选文档", Style: "primary", IsAffirmativeDefault: true}, + {ActionID: "cancel_supplement_impact", Label: "取消", Style: "subtle"}, + } + body := summary + "\n" + fmt.Sprintf("batch_id=%s\nnonce=%s\nrevision=%d\ncoverage=%s", b.BatchID, b.DecisionNonce, b.Revision, b.CoverageStatus) + for _, it := range b.Items { + body += fmt.Sprintf("\n- %s | claimed=%v busy=%v | %s", it.DocID, it.AuthorClaimedSnapshot, it.BusySnapshot, it.Title) + } + return &intent.InteractiveCardPayload{ + CardID: b.BatchID, + CardType: "SUPPLEMENT_IMPACT_CONFIRM", + Title: title, + ProposedContent: body, + ImpactRisk: b.CoverageStatus, + CurrentStatus: "active", + TTLTurns: 8, + CreatedAt: time.Now().UTC(), + Actions: actions, + } +} diff --git a/internal/bizdev/after_proposal.go b/internal/bizdev/after_proposal.go new file mode 100644 index 00000000..fb587d1e --- /dev/null +++ b/internal/bizdev/after_proposal.go @@ -0,0 +1,50 @@ +package bizdev + +import ( + "context" + "fmt" + + "chat-agent-system/internal/docgov" +) + +// TaskExecutor is the ForceDirectEdit execution surface used by AfterProposalApproved. +type TaskExecutor interface { + ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) +} + +// AfterProposalApproved starts a ForceDirectEdit task run as the author after a +// proposal reaches approved_by_author. Finalize already calls MarkProposalApplied +// when FindApprovedProposal succeeds under ForceDirectEdit. +// +// Server composition (non-author worktree) should call this after +// DecideModificationProposal(approve=true) succeeds; pass a base TaskRunRequest +// with TaskKey/DocID/project fields filled from the document context. +func AfterProposalApproved(ctx context.Context, exec TaskExecutor, prop *docgov.ChangeProposal, authorID int64, base TaskRunRequest) (*TaskRunResult, error) { + if exec == nil { + return nil, fmt.Errorf("bizdev: AfterProposalApproved requires executor") + } + if prop == nil { + return nil, fmt.Errorf("bizdev: AfterProposalApproved requires proposal") + } + if authorID <= 0 { + return nil, fmt.Errorf("bizdev: AfterProposalApproved requires author id") + } + req := base + req.ForceDirectEdit = true + req.UserID = authorID + if req.AuthorID == 0 { + req.AuthorID = authorID + } + if req.DocID == "" { + req.DocID = prop.DocID + } + if req.ProjectID == 0 { + req.ProjectID = prop.ProjectID + } + return exec.ExecuteTask(ctx, req) +} + +// AfterProposalApproved is the Service convenience wrapper. +func (s *Service) AfterProposalApproved(ctx context.Context, prop *docgov.ChangeProposal, authorID int64, base TaskRunRequest) (*TaskRunResult, error) { + return AfterProposalApproved(ctx, s, prop, authorID, base) +} diff --git a/internal/bizdev/after_proposal_test.go b/internal/bizdev/after_proposal_test.go new file mode 100644 index 00000000..fde9fb14 --- /dev/null +++ b/internal/bizdev/after_proposal_test.go @@ -0,0 +1,63 @@ +package bizdev_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/docgov" +) + +type stubTaskExec struct { + last bizdev.TaskRunRequest + res *bizdev.TaskRunResult + err error +} + +func (s *stubTaskExec) ExecuteTask(_ context.Context, req bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error) { + s.last = req + return s.res, s.err +} + +func TestAfterProposalApproved_ForceEditAsAuthor(t *testing.T) { + exec := &stubTaskExec{res: &bizdev.TaskRunResult{Success: true}} + prop := &docgov.ChangeProposal{ + ProposalID: "prop_1", + DocID: "doc_a", + ProjectID: 42, + Status: docgov.ProposalApprovedByAuthor, + } + res, err := bizdev.AfterProposalApproved(context.Background(), exec, prop, 9, bizdev.TaskRunRequest{ + TaskKey: "feasibility_reg", + ProjectName: "P", + Backend: "stub", + }) + if err != nil { + t.Fatal(err) + } + if res == nil || !res.Success { + t.Fatalf("want success result, got %+v", res) + } + if !exec.last.ForceDirectEdit { + t.Fatal("expected ForceDirectEdit=true") + } + if exec.last.UserID != 9 || exec.last.AuthorID != 9 { + t.Fatalf("want author identity 9, got user=%d author=%d", exec.last.UserID, exec.last.AuthorID) + } + if exec.last.DocID != "doc_a" || exec.last.ProjectID != 42 { + t.Fatalf("want doc/project from proposal, got doc=%s project=%d", exec.last.DocID, exec.last.ProjectID) + } +} + +func TestAfterProposalApproved_NilSafe(t *testing.T) { + if _, err := bizdev.AfterProposalApproved(context.Background(), nil, &docgov.ChangeProposal{}, 1, bizdev.TaskRunRequest{}); err == nil { + t.Fatal("nil executor must error") + } + exec := &stubTaskExec{} + if _, err := bizdev.AfterProposalApproved(context.Background(), exec, nil, 1, bizdev.TaskRunRequest{}); err == nil { + t.Fatal("nil proposal must error") + } + if _, err := bizdev.AfterProposalApproved(context.Background(), exec, &docgov.ChangeProposal{DocID: "d"}, 0, bizdev.TaskRunRequest{}); err == nil { + t.Fatal("author id 0 must error") + } +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 177e4812..823aa688 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -20,6 +20,7 @@ import ( "chat-agent-system/internal/freedoc" "chat-agent-system/internal/kb" "chat-agent-system/internal/materialindex" + "chat-agent-system/internal/notification" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/squad" "chat-agent-system/internal/storage" @@ -53,6 +54,13 @@ type Service struct { completedMainHook func(ctx context.Context, projectID int64) (bool, error) // instanceDuty 可选:活跃工作流实例对 taskKey 的治理职责(由 app 装配,避免导入 projectinstance)。 instanceDuty InstanceDutySource + // notify 可选:提案拦截时写入 document digest(composition 注入)。 + notify DigestNotifier +} + +// DigestNotifier is the subset of notification.Service used on proposal_required. +type DigestNotifier interface { + UpsertDocumentDigest(ctx context.Context, p notification.UpsertDigestParams) error } // InstanceDutySource 返回项目当前活跃实例对 taskKey 的治理结论。 @@ -69,6 +77,14 @@ func (s *Service) SetInstanceDutySource(src InstanceDutySource) { s.instanceDuty = src } +// SetDigestNotifier wires optional claim-aware digest notifications. +func (s *Service) SetDigestNotifier(n DigestNotifier) { + if s == nil { + return + } + s.notify = n +} + // NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。 func NewService(cfg *config.Config) (*Service, error) { if cfg == nil { diff --git a/internal/bizdev/service_task_run.go b/internal/bizdev/service_task_run.go index 375f5683..3dff8e02 100644 --- a/internal/bizdev/service_task_run.go +++ b/internal/bizdev/service_task_run.go @@ -13,6 +13,7 @@ import ( "chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/docduty" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/notification" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/kb" "chat-agent-system/internal/projectcontext" @@ -485,6 +486,20 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun prop, _ = s.docGov.ProposeModification(ctx, docID, "agent_squad", proposedChanges, impactAnalysis, riskAssessment) } + if s.notify != nil && prop != nil && docRec.AuthorID > 0 { + _ = s.notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{ + UserID: docRec.AuthorID, + ProjectID: req.ProjectID, + DigestKind: notification.DigestKindProposalRequired, + DocID: docID, + DocTitle: artifactTitle, + Severity: notification.SeverityActionRequired, + ActorID: req.UserID, + ActorName: "agent_squad", + Summary: prop.ProposedChanges, + ProposalID: prop.ProposalID, + }) + } streamer := stream.FromContext(ctx) if streamer != nil { streamer.EmitGovernance(stream.GovernancePayload{ diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index c4c09ef0..7d853fb4 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -76,6 +76,10 @@ type CoordinatorEngine struct { kbClient *kb.Client impact ImpactActions projectAccess ProjectAccess + // OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync. + // Nil-safe: unset hooks are skipped. + OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) + pendingSupplementCard *intent.InteractiveCardPayload } // StageTaskRunner is the application owner for ExecuteTask / ExecuteStage. @@ -126,6 +130,29 @@ func (e *CoordinatorEngine) SetImpactActions(a ImpactActions) { } } +// SetOnSupplement wires the optional post-blackboard supplement impact hook. +func (e *CoordinatorEngine) SetOnSupplement(fn func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)) { + if e != nil { + e.OnSupplement = fn + } +} + +func (e *CoordinatorEngine) SetPendingSupplementCard(card *intent.InteractiveCardPayload) { + if e == nil { + return + } + e.pendingSupplementCard = card +} + +func (e *CoordinatorEngine) TakePendingSupplementCard() *intent.InteractiveCardPayload { + if e == nil { + return nil + } + c := e.pendingSupplementCard + e.pendingSupplementCard = nil + return c +} + func NewCoordinatorEngine(runner StageTaskRunner) *CoordinatorEngine { return &CoordinatorEngine{ stageTasks: runner, diff --git a/internal/coordinator/engine_stage.go b/internal/coordinator/engine_stage.go index 320765ce..281c760e 100644 --- a/internal/coordinator/engine_stage.go +++ b/internal/coordinator/engine_stage.go @@ -85,6 +85,16 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u } } + if e != nil && e.OnSupplement != nil { + projectID := parseDecimalID(userCtx.Project.ID) + userID := parseDecimalID(userCtx.User.ID) + markdown := strings.TrimSpace(rawDoc) + if markdown == "" && factKey != "" && factVal != "" { + markdown = factKey + ": " + factVal + } + e.OnSupplement(context.Background(), projectID, userID, "", summaryKey, markdown) + } + nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp) @@ -105,6 +115,10 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u sb.WriteString("已收录补充材料,并关联到项目事实黑板。\n后续文档修订与阶段推进时,我会自动引用这部分事实。") } + if card := e.TakePendingSupplementCard(); card != nil { + userCtx.applyCard(card) + sb.WriteString("\n\n已分析补充材料对文档的潜在影响,请在确认卡中勾选要更新的文档。") + } return &CoordinationResult{ Status: StatusAccepted, Intent: cand.Intent, @@ -113,6 +127,7 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u AdvisorBrief: userCtx.LatestAdvisorBrief, UpdatedContext: userCtx, NextExpectation: nextExp, + InteractiveCard: userCtx.InteractiveCard, }, nil } diff --git a/internal/events/registry.go b/internal/events/registry.go index f615123f..4b96f879 100644 --- a/internal/events/registry.go +++ b/internal/events/registry.go @@ -23,6 +23,8 @@ const ( EventIntentPlanAwaiting = "intent.plan.awaiting_input" EventIntentPlanStepChanged = "intent.plan.step_changed" EventIntentPlanCompleted = "intent.plan.completed" + EventDocProposalRequired = "document.proposal_required" + EventDocUnclaimedEdit = "document.unclaimed_edit_applied" ) // Contract is the static allowlist row for one event name. @@ -50,6 +52,8 @@ func StaticContracts() []Contract { {EventName: EventIntentPlanAwaiting, SchemaVersion: 1, Enabled: true, Producers: []string{"app/intentflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"plan_id", "revision", "status", "environment", "project_id", "user_id", "session_id", "summary_ref"}}, {EventName: EventIntentPlanStepChanged, SchemaVersion: 1, Enabled: true, Producers: []string{"app/intentflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"plan_id", "revision", "step_id", "status", "environment", "project_id", "user_id", "session_id", "summary_ref"}}, {EventName: EventIntentPlanCompleted, SchemaVersion: 1, Enabled: true, Producers: []string{"app/intentflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"plan_id", "revision", "status", "environment", "project_id", "user_id", "session_id", "summary_ref"}}, + {EventName: EventDocProposalRequired, SchemaVersion: 1, Enabled: true, Producers: []string{"docgov", "bizdev", "app/supplementimpact", "events"}, Consumers: []string{ConsumerNotify}, PayloadFields: []string{"doc_id", "proposal_id", "project_id"}}, + {EventName: EventDocUnclaimedEdit, SchemaVersion: 1, Enabled: true, Producers: []string{"docgov", "bizdev", "app/supplementimpact", "events"}, Consumers: []string{ConsumerNotify}, PayloadFields: []string{"doc_id", "project_id"}}, } } diff --git a/internal/notification/digest.go b/internal/notification/digest.go new file mode 100644 index 00000000..1f6b2d2f --- /dev/null +++ b/internal/notification/digest.go @@ -0,0 +1,186 @@ +package notification + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +const ( + DigestKindProposalRequired = "proposal_required" + DigestKindUnclaimedEdit = "unclaimed_edit" + SeverityActionRequired = "action_required" + SeverityInfo = "info" + maxDigestItems = 20 +) + +// DigestItem is one occurrence folded into a daily digest. +type DigestItem struct { + At time.Time `json:"at"` + ActorID int64 `json:"actor_id"` + ActorName string `json:"actor_name"` + Summary string `json:"summary"` + ProposalID string `json:"proposal_id,omitempty"` + ChangeCount int `json:"change_count,omitempty"` +} + +// UpsertDigestParams drives ON CONFLICT DO UPDATE aggregation. +type UpsertDigestParams struct { + UserID int64 + ProjectID int64 + DigestKind string + DocID string + DocTitle string + Severity string + ActorID int64 + ActorName string + Summary string + ProposalID string + ChangeCount int + WindowDate time.Time // truncated to date; zero → today UTC +} + +// UpsertDocumentDigest aggregates same-day notifications per (user, kind, doc). +func (s *Service) UpsertDocumentDigest(ctx context.Context, p UpsertDigestParams) error { + if !s.Available() { + return ErrNotAvailable + } + if p.UserID <= 0 || p.DocID == "" || p.DigestKind == "" { + return fmt.Errorf("notification: digest requires user, doc, kind") + } + if p.Severity == "" { + p.Severity = SeverityInfo + } + window := p.WindowDate + if window.IsZero() { + now := time.Now().UTC() + window = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + } + item := DigestItem{ + At: time.Now().UTC(), ActorID: p.ActorID, ActorName: p.ActorName, + Summary: truncateRunes(p.Summary, 60), ProposalID: p.ProposalID, ChangeCount: p.ChangeCount, + } + itemJSON, _ := json.Marshal(item) + + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var digestID, notifID int64 + var count int + err = tx.QueryRow(ctx, ` + INSERT INTO notification_digests ( + user_id, digest_kind, doc_id, window_date, occurrence_count, + last_actor_id, last_actor_name, last_at, item_summaries, truncated_count + ) VALUES ($1,$2,$3,$4,1,$5,$6,now(), jsonb_build_array($7::jsonb), 0) + ON CONFLICT (user_id, digest_kind, doc_id, window_date) DO UPDATE SET + occurrence_count = notification_digests.occurrence_count + 1, + last_actor_id = EXCLUDED.last_actor_id, + last_actor_name = EXCLUDED.last_actor_name, + last_at = now(), + item_summaries = CASE + WHEN jsonb_array_length(notification_digests.item_summaries) >= $8 THEN notification_digests.item_summaries + ELSE notification_digests.item_summaries || EXCLUDED.item_summaries + END, + truncated_count = CASE + WHEN jsonb_array_length(notification_digests.item_summaries) >= $8 + THEN notification_digests.truncated_count + 1 + ELSE notification_digests.truncated_count + END, + updated_at = now() + RETURNING id, occurrence_count, COALESCE(notification_id, 0) + `, p.UserID, p.DigestKind, p.DocID, window, p.ActorID, p.ActorName, string(itemJSON), maxDigestItems). + Scan(&digestID, &count, ¬ifID) + if err != nil { + return err + } + + title, body := digestCopy(p, count) + dedupeKey := fmt.Sprintf("digest:%s:%s:%s", p.DigestKind, p.DocID, window.Format("2006-01-02")) + notifType := typeForDigestKind(p.DigestKind) + + if notifID == 0 { + err = tx.QueryRow(ctx, ` + INSERT INTO notifications (user_id, project_id, type, title, body, dedupe_key, status, digest_id, severity) + VALUES ($1,$2,$3,$4,$5,$6,'unread',$7,$8) + ON CONFLICT (user_id, dedupe_key) DO UPDATE SET + title = EXCLUDED.title, + body = EXCLUDED.body, + status = 'unread', + severity = EXCLUDED.severity, + digest_id = EXCLUDED.digest_id + RETURNING id + `, p.UserID, p.ProjectID, notifType, title, body, dedupeKey, digestID, p.Severity).Scan(¬ifID) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `UPDATE notification_digests SET notification_id=$1 WHERE id=$2`, notifID, digestID) + if err != nil { + return err + } + } else { + _, err = tx.Exec(ctx, ` + UPDATE notifications SET title=$1, body=$2, status='unread', severity=$3 + WHERE id=$4 AND user_id=$5 + `, title, body, p.Severity, notifID, p.UserID) + if err != nil { + return err + } + } + return tx.Commit(ctx) +} + +func typeForDigestKind(kind string) string { + switch kind { + case DigestKindProposalRequired: + return TypeDocProposalRequired + case DigestKindUnclaimedEdit: + return TypeDocUnclaimedEditApplied + default: + return kind + } +} + +func digestCopy(p UpsertDigestParams, count int) (title, body string) { + titleDoc := strings.TrimSpace(p.DocTitle) + if titleDoc == "" { + titleDoc = p.DocID + } + actor := strings.TrimSpace(p.ActorName) + if actor == "" { + actor = "成员" + } + summary := truncateRunes(p.Summary, 60) + switch p.DigestKind { + case DigestKindProposalRequired: + if count <= 1 { + return fmt.Sprintf("《%s》有变更待您确认", titleDoc), + fmt.Sprintf("%s 补充了「%s」,拟修改本文档。文档已被您认领,需您确认后由您发起修改。", actor, summary) + } + return fmt.Sprintf("《%s》累计 %d 次变更待您确认", titleDoc, count), + fmt.Sprintf("最近一次:%s。%d 次变更均来自他人补充材料,请逐份拍板。", actor, count) + case DigestKindUnclaimedEdit: + if count <= 1 { + return fmt.Sprintf("《%s》已被更新(无需您确认)", titleDoc), + fmt.Sprintf("%s 通过助手更新了本文档,来源:%s。本文档尚未被您认领,系统已直接应用。", actor, summary) + } + return fmt.Sprintf("《%s》今日已有 %d 次更新", titleDoc, count), + fmt.Sprintf("最近一次:%s;累计 %d 次更新。本文档尚未被您认领。", actor, count) + default: + return titleDoc, summary + } +} + +func truncateRunes(s string, n int) string { + r := []rune(strings.TrimSpace(s)) + if len(r) <= n { + return string(r) + } + return string(r[:n]) + "…" +} + +// Ensure unused import guard for pgx when scanning with interfaces. diff --git a/internal/notification/document_events.go b/internal/notification/document_events.go index d22cffe7..0b0ef62d 100644 --- a/internal/notification/document_events.go +++ b/internal/notification/document_events.go @@ -14,12 +14,16 @@ const ( EventDocHumanRevisionConflict = "document.human_revision_conflict" EventDocDownstreamImpact = "document.downstream_impact_detected" EventDocChangeReportDegraded = "document.change_report_degraded" + EventDocProposalRequired = "document.proposal_required" + EventDocUnclaimedEditApplied = "document.unclaimed_edit_applied" TypeDocAgentCandidateReady = "document_agent_candidate_ready" TypeDocAgentVersionCommitted = "document_agent_version_committed" TypeDocDownstreamImpact = "document_downstream_impact" TypeDocChangeReportDegraded = "document_change_report_degraded" TypeDocApproved = "document_approved" + TypeDocProposalRequired = "document_proposal_required" + TypeDocUnclaimedEditApplied = "document_unclaimed_edit_applied" ) // DocumentEventPayload is a minimal, permission-safe notification body (no markdown excerpts). diff --git a/internal/notification/service.go b/internal/notification/service.go index 368d358e..8885b1f0 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -173,6 +173,15 @@ func ignoreUnique(err error) error { // ListProjectNotifications 列出项目下当前用户的通知(可选 since_id 增量)。 func (s *Service) ListProjectNotifications(ctx context.Context, userID, projectID int64, sinceID int64, limit int) ([]Notification, error) { + return s.listNotifications(ctx, userID, projectID, sinceID, limit, false) +} + +// ListUserNotifications 列出当前用户跨项目通知;action_required 优先,再按 id 降序。 +func (s *Service) ListUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]Notification, error) { + return s.listNotifications(ctx, userID, 0, sinceID, limit, true) +} + +func (s *Service) listNotifications(ctx context.Context, userID, projectID, sinceID int64, limit int, allProjects bool) ([]Notification, error) { if !s.Available() { return nil, ErrNotAvailable } @@ -181,18 +190,37 @@ func (s *Service) ListProjectNotifications(ctx context.Context, userID, projectI } query := ` - SELECT n.id, n.user_id, n.project_id, COALESCE(n.event_id, 0), n.type, n.title, n.body, n.status, n.created_at, - COALESCE(e.payload, '{}'::jsonb) + 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 - WHERE n.user_id = $1 AND n.project_id = $2 + LEFT JOIN notification_digests d ON d.id = n.digest_id + WHERE n.user_id = $1 ` - args := []any{userID, projectID} + args := []any{userID} + if !allProjects { + query += ` AND n.project_id = $2` + args = append(args, projectID) + } if sinceID > 0 { - query += ` AND n.id > $3` + query += ` AND n.id > $` + fmt.Sprint(len(args)+1) args = append(args, sinceID) } - query += ` ORDER BY n.id ASC LIMIT $` + fmt.Sprint(len(args)+1) + if allProjects { + query += ` ORDER BY CASE WHEN COALESCE(n.severity, 'info') = 'action_required' THEN 0 ELSE 1 END, n.id DESC` + } else { + query += ` ORDER BY n.id ASC` + } + query += ` LIMIT $` + fmt.Sprint(len(args)+1) args = append(args, limit) rows, err := s.pool.Query(ctx, query, args...) @@ -205,15 +233,23 @@ func (s *Service) ListProjectNotifications(ctx context.Context, userID, projectI for rows.Next() { var n Notification 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.CreatedAt, - &payloadRaw, + &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.DocID == "" && digestDocID != "" { + n.Payload.DocID = digestDocID + } + if n.Payload.ProposalID == "" && digestProposalID != "" { + n.Payload.ProposalID = digestProposalID + } out = append(out, n) } return out, rows.Err() diff --git a/internal/notification/types.go b/internal/notification/types.go index 6c829e83..1b0e0ae9 100644 --- a/internal/notification/types.go +++ b/internal/notification/types.go @@ -19,18 +19,23 @@ type StageReadyPayload struct { ConfigRevision int `json:"config_revision"` RequiredDocCount int `json:"required_doc_count"` ApprovedDocCount int `json:"approved_doc_count"` + DocID string `json:"doc_id,omitempty"` + ProposalID string `json:"proposal_id,omitempty"` + BatchID string `json:"batch_id,omitempty"` } // Notification 用户站内通知。 type Notification 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"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + ProjectID int64 `json:"project_id"` + EventID int64 `json:"event_id,omitempty"` + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + Severity string `json:"severity,omitempty"` + DigestID int64 `json:"digest_id,omitempty"` + CreatedAt time.Time `json:"created_at"` Payload StageReadyPayload `json:"payload,omitempty"` } diff --git a/internal/supplementimpact/http_adapter.go b/internal/supplementimpact/http_adapter.go new file mode 100644 index 00000000..b34e7d38 --- /dev/null +++ b/internal/supplementimpact/http_adapter.go @@ -0,0 +1,113 @@ +package supplementimpact + +import ( + "context" + "fmt" +) + +// HTTPBatchView is the JSON shape expected by server supplement-impact handlers. +type HTTPBatchView 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 interface{} `json:"expires_at"` + Items []HTTPItemView `json:"items,omitempty"` +} + +type HTTPItemView 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"` +} + +// HTTPDecision is the CAS decision envelope from HTTP. +type HTTPDecision struct { + Kind string + SelectedDocIDs []string + DecisionNonce string + DecisionDigest string + ExpectedRevision int64 +} + +// HTTPPort adapts *Service to the server SupplementImpactService port. +type HTTPPort struct{ Svc *Service } + +func (p HTTPPort) GetBatch(ctx context.Context, batchID string) (*HTTPBatchView, error) { + if p.Svc == nil || p.Svc.Store == nil { + return nil, fmt.Errorf("supplementimpact: not configured") + } + b, err := p.Svc.Store.GetBatch(ctx, batchID) + if err != nil { + return nil, err + } + return toHTTPView(b), nil +} + +func (p HTTPPort) Decide(ctx context.Context, batchID string, actorUserID int64, in HTTPDecision) (*HTTPBatchView, error) { + if p.Svc == nil { + return nil, fmt.Errorf("supplementimpact: not configured") + } + b, err := p.Svc.Decide(ctx, 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 toHTTPView(b), nil +} + +func toHTTPView(b *Batch) *HTTPBatchView { + if b == nil { + return nil + } + v := &HTTPBatchView{ + 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 { + v.Items = append(v.Items, HTTPItemView{ + DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType, + Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot, + BusySnapshot: it.BusySnapshot, ChangeSummary: it.ChangeSummary, Selected: it.Selected, + Outcome: it.Outcome, ProposalID: it.ProposalID, + }) + } + return v +} + +// BuildConfirmCardJSON returns a lightweight map for coordinator InteractiveCardPayload.ProposedContent. +func BuildConfirmCardMeta(b *Batch) (title, summary string) { + if b == nil { + return "补充影响确认", "" + } + n := len(b.Items) + switch b.CoverageStatus { + case "index_pending", "partial", "failed": + return "补充影响分析未完成", fmt.Sprintf("覆盖状态:%s(不得视为无影响)。批次 %s", b.CoverageStatus, b.BatchID) + } + if n == 0 { + return "补充已记录", fmt.Sprintf("暂无文档受影响。批次 %s", b.BatchID) + } + return fmt.Sprintf("本次补充可能影响 %d 份文档", n), fmt.Sprintf("请勾选要更新的文档。批次 %s;nonce=%s;rev=%d", b.BatchID, b.DecisionNonce, b.Revision) +} diff --git a/internal/supplementimpact/mem_store.go b/internal/supplementimpact/mem_store.go new file mode 100644 index 00000000..7f3901c6 --- /dev/null +++ b/internal/supplementimpact/mem_store.go @@ -0,0 +1,74 @@ +package supplementimpact + +import ( + "context" + "fmt" + "sync" +) + +type MemStore struct { + mu sync.Mutex + batches map[string]*Batch +} + +func NewMemStore() *MemStore { + return &MemStore{batches: map[string]*Batch{}} +} + +func (m *MemStore) InsertBatch(_ context.Context, b *Batch) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *b + cp.Items = append([]Item{}, b.Items...) + m.batches[b.BatchID] = &cp + return nil +} + +func (m *MemStore) GetBatch(_ context.Context, batchID string) (*Batch, error) { + m.mu.Lock() + defer m.mu.Unlock() + b, ok := m.batches[batchID] + if !ok { + return nil, fmt.Errorf("supplementimpact: batch not found") + } + cp := *b + cp.Items = append([]Item{}, b.Items...) + return &cp, nil +} + +func (m *MemStore) SaveDecision(_ context.Context, batchID string, revision int64, nonce, digest, status string, selected map[string]bool) error { + m.mu.Lock() + defer m.mu.Unlock() + b, ok := m.batches[batchID] + if !ok { + return fmt.Errorf("supplementimpact: batch not found") + } + b.Status = status + b.DecisionNonce = nonce + b.DecisionDigest = digest + b.Revision = revision + for i := range b.Items { + if selected != nil { + b.Items[i].Selected = selected[b.Items[i].DocID] + } + } + return nil +} + +func (m *MemStore) UpdateItemOutcome(_ context.Context, batchID, docID, outcome, proposalID, runID string) error { + m.mu.Lock() + defer m.mu.Unlock() + b, ok := m.batches[batchID] + if !ok { + return fmt.Errorf("supplementimpact: batch not found") + } + for i := range b.Items { + if b.Items[i].DocID == docID { + b.Items[i].Outcome = outcome + b.Items[i].ProposalID = proposalID + b.Items[i].RunID = runID + return nil + } + } + return fmt.Errorf("supplementimpact: item not found") +} diff --git a/internal/supplementimpact/pg_store.go b/internal/supplementimpact/pg_store.go new file mode 100644 index 00000000..fa78debc --- /dev/null +++ b/internal/supplementimpact/pg_store.go @@ -0,0 +1,173 @@ +package supplementimpact + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// PGStore persists supplement-impact batches in PostgreSQL (migration 00070). +type PGStore struct { + pool *pgxpool.Pool +} + +// NewPGStore builds a Store backed by pool. +func NewPGStore(pool *pgxpool.Pool) *PGStore { + return &PGStore{pool: pool} +} + +func (p *PGStore) InsertBatch(ctx context.Context, b *Batch) error { + if p == nil || p.pool == nil { + return fmt.Errorf("supplementimpact: pg store not available") + } + if b == nil { + return fmt.Errorf("supplementimpact: nil batch") + } + tx, err := p.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, ` + INSERT INTO supplement_impact_batches ( + batch_id, project_id, session_id, initiator_user_id, source_kind, source_summary, + coverage_status, status, decision_nonce, decision_digest, revision, expires_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + `, b.BatchID, b.ProjectID, b.SessionID, b.InitiatorUserID, b.SourceKind, b.SourceSummary, + b.CoverageStatus, b.Status, b.DecisionNonce, b.DecisionDigest, b.Revision, b.ExpiresAt) + if err != nil { + return err + } + for i := range b.Items { + it := &b.Items[i] + err = tx.QueryRow(ctx, ` + INSERT INTO supplement_impact_items ( + batch_id, doc_id, title, item_key, impact_type, depth, reason, + author_claimed_snapshot, author_id_snapshot, busy_snapshot, change_summary, + selected, outcome, proposal_id, run_id + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + RETURNING id + `, it.BatchID, it.DocID, it.Title, it.ItemKey, it.ImpactType, it.Depth, it.Reason, + it.AuthorClaimedSnapshot, it.AuthorIDSnapshot, it.BusySnapshot, it.ChangeSummary, + it.Selected, it.Outcome, it.ProposalID, it.RunID).Scan(&it.ID) + if err != nil { + return err + } + } + return tx.Commit(ctx) +} + +func (p *PGStore) GetBatch(ctx context.Context, batchID string) (*Batch, error) { + if p == nil || p.pool == nil { + return nil, fmt.Errorf("supplementimpact: pg store not available") + } + var b Batch + err := p.pool.QueryRow(ctx, ` + SELECT batch_id, project_id, session_id, initiator_user_id, source_kind, source_summary, + coverage_status, status, decision_nonce, decision_digest, revision, expires_at + FROM supplement_impact_batches WHERE batch_id = $1 + `, batchID).Scan( + &b.BatchID, &b.ProjectID, &b.SessionID, &b.InitiatorUserID, &b.SourceKind, &b.SourceSummary, + &b.CoverageStatus, &b.Status, &b.DecisionNonce, &b.DecisionDigest, &b.Revision, &b.ExpiresAt, + ) + if err != nil { + return nil, fmt.Errorf("supplementimpact: batch not found: %w", err) + } + rows, err := p.pool.Query(ctx, ` + SELECT id, batch_id, doc_id, title, item_key, impact_type, depth, reason, + author_claimed_snapshot, author_id_snapshot, busy_snapshot, change_summary, + selected, outcome, proposal_id, run_id + FROM supplement_impact_items WHERE batch_id = $1 ORDER BY id + `, batchID) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var it Item + if err := rows.Scan( + &it.ID, &it.BatchID, &it.DocID, &it.Title, &it.ItemKey, &it.ImpactType, &it.Depth, &it.Reason, + &it.AuthorClaimedSnapshot, &it.AuthorIDSnapshot, &it.BusySnapshot, &it.ChangeSummary, + &it.Selected, &it.Outcome, &it.ProposalID, &it.RunID, + ); err != nil { + return nil, err + } + b.Items = append(b.Items, it) + } + return &b, rows.Err() +} + +func (p *PGStore) SaveDecision(ctx context.Context, batchID string, revision int64, nonce, digest, status string, selected map[string]bool) error { + if p == nil || p.pool == nil { + return fmt.Errorf("supplementimpact: pg store not available") + } + tx, err := p.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + tag, err := tx.Exec(ctx, ` + UPDATE supplement_impact_batches + SET status = $1, decision_nonce = $2, decision_digest = $3, revision = $4, updated_at = now() + WHERE batch_id = $5 + `, status, nonce, digest, revision, batchID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("supplementimpact: batch not found") + } + if selected != nil { + rows, qerr := tx.Query(ctx, `SELECT id, doc_id FROM supplement_impact_items WHERE batch_id = $1`, batchID) + if qerr != nil { + return qerr + } + type pair struct { + id int64 + docID string + } + var pairs []pair + for rows.Next() { + var pr pair + if err := rows.Scan(&pr.id, &pr.docID); err != nil { + rows.Close() + return err + } + pairs = append(pairs, pr) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + for _, pr := range pairs { + _, err = tx.Exec(ctx, ` + UPDATE supplement_impact_items SET selected = $1, updated_at = now() WHERE id = $2 + `, selected[pr.docID], pr.id) + if err != nil { + return err + } + } + } + return tx.Commit(ctx) +} + +func (p *PGStore) UpdateItemOutcome(ctx context.Context, batchID, docID, outcome, proposalID, runID string) error { + if p == nil || p.pool == nil { + return fmt.Errorf("supplementimpact: pg store not available") + } + tag, err := p.pool.Exec(ctx, ` + UPDATE supplement_impact_items + SET outcome = $1, proposal_id = $2, run_id = $3, updated_at = now() + WHERE batch_id = $4 AND doc_id = $5 + `, outcome, proposalID, runID, batchID, docID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("supplementimpact: item not found") + } + return nil +} diff --git a/internal/supplementimpact/service.go b/internal/supplementimpact/service.go new file mode 100644 index 00000000..bc6a110f --- /dev/null +++ b/internal/supplementimpact/service.go @@ -0,0 +1,238 @@ +package supplementimpact + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/notification" + "chat-agent-system/internal/traceimpact" +) + +// DocLookup resolves document claim/lease snapshots. +type DocLookup interface { + GetDocument(ctx context.Context, docID string) (*docgov.DocumentRecord, error) +} + +// Analyzer runs traceimpact analysis. +type Analyzer interface { + Analyze(ctx context.Context, projectID int64, source traceimpact.DocRef, others []traceimpact.DocRef) (*traceimpact.Report, error) +} + +// Store persists batches. +type Store interface { + InsertBatch(ctx context.Context, b *Batch) error + GetBatch(ctx context.Context, batchID string) (*Batch, error) + SaveDecision(ctx context.Context, batchID string, revision int64, nonce, digest, status string, selected map[string]bool) error + UpdateItemOutcome(ctx context.Context, batchID, docID, outcome, proposalID, runID string) error +} + +// Notifier emits claim-aware digests. +type Notifier interface { + UpsertDocumentDigest(ctx context.Context, p notification.UpsertDigestParams) error +} + +// Proposer creates docgov change proposals for claimed docs. +type Proposer interface { + ProposeModification(ctx context.Context, docID, triggeredBy, changes, impact, risk string) (*docgov.ChangeProposal, error) +} + +// Service orchestrates supplement → impact → confirm → split execution. +type Service struct { + Store Store + Docs DocLookup + Analyze Analyzer + Notify Notifier + Propose Proposer + Now func() time.Time +} + +func (s *Service) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now().UTC() +} + +// CreateFromSupplement runs impact analysis and opens a confirmation batch. +func (s *Service) GetBatch(ctx context.Context, batchID string) (*Batch, error) { + if s == nil || s.Store == nil { + return nil, fmt.Errorf("supplementimpact: not configured") + } + return s.Store.GetBatch(ctx, batchID) +} + +func (s *Service) CreateFromSupplement(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) (*Batch, error) { + if s == nil || s.Store == nil || s.Analyze == nil { + return nil, fmt.Errorf("supplementimpact: not configured") + } + report, err := s.Analyze.Analyze(ctx, projectID, traceimpact.DocRef{DocID: "supplement", Markdown: markdown}, nil) + if err != nil { + return nil, err + } + coverage := "no_known_dependencies" + if report != nil && report.CoverageStatus != "" { + coverage = string(report.CoverageStatus) + } + // Never present index_pending/partial/failed as "no impact". + switch coverage { + case "index_pending", "partial", "failed": + // keep as-is for the card + default: + } + batchID := "sib_" + randomHex(8) + nonce := randomHex(16) + now := s.now() + b := &Batch{ + BatchID: batchID, ProjectID: projectID, SessionID: sessionID, + InitiatorUserID: userID, SourceKind: "fact_supplement", + SourceSummary: truncate(summary, 120), CoverageStatus: coverage, + Status: StatusPending, DecisionNonce: nonce, Revision: 1, + ExpiresAt: now.Add(BatchTTL), + } + seen := map[string]struct{}{} + if report != nil { + for _, imp := range report.Impacts { + if imp.DocID == "" { + continue + } + if _, ok := seen[imp.DocID]; ok { + continue + } + seen[imp.DocID] = struct{}{} + item := Item{ + BatchID: batchID, DocID: imp.DocID, ItemKey: imp.ItemKey, + ImpactType: string(imp.ImpactType), Depth: imp.Depth, Reason: imp.Reason, + ChangeSummary: truncate(imp.Reason, 80), + } + if s.Docs != nil { + if doc, err := s.Docs.GetDocument(ctx, imp.DocID); err == nil && doc != nil { + item.Title = doc.Title + item.AuthorClaimedSnapshot = doc.AuthorClaimed + item.AuthorIDSnapshot = doc.AuthorID + item.BusySnapshot = doc.HasActiveLease(now) + } + } + b.Items = append(b.Items, item) + } + } + if err := s.Store.InsertBatch(ctx, b); err != nil { + return nil, err + } + return b, nil +} + +// Decide confirms or cancels a batch; on confirm, executes selected items. +func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, error) { + b, err := s.Store.GetBatch(ctx, cmd.BatchID) + if err != nil { + return nil, err + } + if b.Status != StatusPending { + return nil, fmt.Errorf("supplementimpact: batch not pending") + } + if s.now().After(b.ExpiresAt) { + _ = s.Store.SaveDecision(ctx, b.BatchID, b.Revision, cmd.DecisionNonce, cmd.DecisionDigest, StatusExpired, nil) + return nil, fmt.Errorf("supplementimpact: batch expired") + } + if cmd.ExpectedRevision != b.Revision { + return nil, fmt.Errorf("supplementimpact: revision conflict") + } + if cmd.ActorUserID != b.InitiatorUserID { + return nil, fmt.Errorf("supplementimpact: only initiator may decide") + } + if cmd.Kind == "cancel" { + if err := s.Store.SaveDecision(ctx, b.BatchID, b.Revision, cmd.DecisionNonce, cmd.DecisionDigest, StatusCancelled, nil); err != nil { + return nil, err + } + return s.Store.GetBatch(ctx, cmd.BatchID) + } + selected := map[string]bool{} + for _, id := range cmd.SelectedDocIDs { + selected[id] = true + } + if err := s.Store.SaveDecision(ctx, b.BatchID, b.Revision, cmd.DecisionNonce, cmd.DecisionDigest, StatusRunning, selected); err != nil { + return nil, err + } + b, _ = s.Store.GetBatch(ctx, cmd.BatchID) + for i := range b.Items { + it := &b.Items[i] + if !selected[it.DocID] { + continue + } + if it.BusySnapshot { + _ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedBusy, "", "") + continue + } + if err := s.executeItem(ctx, b, it); err != nil { + _ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeFailed, "", "") + } + } + final := StatusDone + b, _ = s.Store.GetBatch(ctx, cmd.BatchID) + for _, it := range b.Items { + if !it.Selected { + continue + } + switch it.Outcome { + case OutcomeSkippedBusy, OutcomeSkippedLocked, OutcomeFailed, OutcomeProposalPending: + final = StatusPartiallyDone + } + } + _ = s.Store.SaveDecision(ctx, b.BatchID, b.Revision+1, cmd.DecisionNonce, cmd.DecisionDigest, final, selected) + return s.Store.GetBatch(ctx, cmd.BatchID) +} + +func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error { + if it.AuthorClaimedSnapshot { + if s.Propose == nil { + return fmt.Errorf("proposer not configured") + } + prop, err := s.Propose.ProposeModification(ctx, it.DocID, fmt.Sprintf("user:%d", b.InitiatorUserID), + it.ChangeSummary, it.Reason, "补充材料触发的已认领文档变更,需编制负责人确认") + if err != nil { + return err + } + _ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeProposalPending, prop.ProposalID, "") + if s.Notify != nil && it.AuthorIDSnapshot > 0 { + _ = s.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{ + UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID, + DigestKind: notification.DigestKindProposalRequired, DocID: it.DocID, DocTitle: it.Title, + Severity: notification.SeverityActionRequired, + ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID), + Summary: b.SourceSummary, ProposalID: prop.ProposalID, + }) + } + return nil + } + // Unclaimed: notify info-only; actual content rewrite is owned by squad/task path. + _ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeApplied, "", "") + if s.Notify != nil && it.AuthorIDSnapshot > 0 { + _ = s.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{ + UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID, + DigestKind: notification.DigestKindUnclaimedEdit, DocID: it.DocID, DocTitle: it.Title, + Severity: notification.SeverityInfo, + ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID), + Summary: b.SourceSummary, + }) + } + return nil +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func truncate(s string, n int) string { + r := []rune(strings.TrimSpace(s)) + if len(r) <= n { + return string(r) + } + return string(r[:n]) + "…" +} diff --git a/internal/supplementimpact/service_test.go b/internal/supplementimpact/service_test.go new file mode 100644 index 00000000..45c42ed2 --- /dev/null +++ b/internal/supplementimpact/service_test.go @@ -0,0 +1,108 @@ +package supplementimpact_test + +import ( + "context" + "testing" + "time" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/notification" + "chat-agent-system/internal/supplementimpact" + "chat-agent-system/internal/traceimpact" +) + +type stubAnalyzer struct{ report *traceimpact.Report } + +func (s stubAnalyzer) Analyze(_ context.Context, _ int64, _ traceimpact.DocRef, _ []traceimpact.DocRef) (*traceimpact.Report, error) { + return s.report, nil +} + +type stubDocs struct{ docs map[string]*docgov.DocumentRecord } + +func (s stubDocs) GetDocument(_ context.Context, id string) (*docgov.DocumentRecord, error) { + return s.docs[id], nil +} + +func TestCreateBatchSnapshotsClaimAndBusy(t *testing.T) { + store := supplementimpact.NewMemStore() + claimedAt := time.Now() + svc := &supplementimpact.Service{ + Store: store, + Analyze: stubAnalyzer{report: &traceimpact.Report{ + CoverageStatus: "confirmed_impact", + Impacts: []traceimpact.Impact{ + {DocID: "doc_a", ItemKey: "UR-1", ImpactType: "confirmed_impact", Depth: 1, Reason: "hit"}, + }, + }}, + Docs: stubDocs{docs: map[string]*docgov.DocumentRecord{ + "doc_a": {DocID: "doc_a", Title: "需求", AuthorID: 9, AuthorClaimed: true, AuthorClaimedAt: &claimedAt}, + }}, + } + b, err := svc.CreateFromSupplement(context.Background(), 1, 2, "sess", "补充市场", "UR-1 目标市场改为欧盟") + if err != nil { + t.Fatal(err) + } + if len(b.Items) != 1 || !b.Items[0].AuthorClaimedSnapshot { + t.Fatalf("want claimed snapshot, got %+v", b.Items) + } +} + +type stubPropose struct { + lastDoc string + prop *docgov.ChangeProposal +} + +func (s *stubPropose) ProposeModification(_ context.Context, docID, _, _, _, _ string) (*docgov.ChangeProposal, error) { + s.lastDoc = docID + if s.prop == nil { + s.prop = &docgov.ChangeProposal{ProposalID: "prop_test_1", DocID: docID, Status: docgov.ProposalPendingAuthorApproval} + } + return s.prop, nil +} + +type stubNotify struct{ n int } + +func (s *stubNotify) UpsertDocumentDigest(_ context.Context, _ notification.UpsertDigestParams) error { + s.n++ + return nil +} + +func TestDecideClaimedProducesProposal(t *testing.T) { + store := supplementimpact.NewMemStore() + claimedAt := time.Now() + prop := &stubPropose{} + notify := &stubNotify{} + svc := &supplementimpact.Service{ + Store: store, + Analyze: stubAnalyzer{report: &traceimpact.Report{ + CoverageStatus: "confirmed_impact", + Impacts: []traceimpact.Impact{ + {DocID: "doc_a", ItemKey: "UR-1", ImpactType: "confirmed_impact", Depth: 1, Reason: "hit"}, + }, + }}, + Docs: stubDocs{docs: map[string]*docgov.DocumentRecord{ + "doc_a": {DocID: "doc_a", Title: "需求", AuthorID: 9, AuthorClaimed: true, AuthorClaimedAt: &claimedAt}, + }}, + Propose: prop, + Notify: notify, + } + b, err := svc.CreateFromSupplement(context.Background(), 1, 2, "sess", "补充市场", "UR-1 目标市场改为欧盟") + if err != nil { + t.Fatal(err) + } + out, err := svc.Decide(context.Background(), supplementimpact.DecisionCommand{ + BatchID: b.BatchID, ActorUserID: 2, Kind: "confirm", + SelectedDocIDs: []string{"doc_a"}, DecisionNonce: b.DecisionNonce, + DecisionDigest: "d1", ExpectedRevision: b.Revision, + }) + if err != nil { + t.Fatal(err) + } + if len(out.Items) != 1 || out.Items[0].Outcome != supplementimpact.OutcomeProposalPending { + t.Fatalf("want proposal_pending, got %+v", out.Items) + } + if prop.lastDoc != "doc_a" || notify.n != 1 { + t.Fatalf("propose/notify not called: doc=%s notify=%d", prop.lastDoc, notify.n) + } +} + diff --git a/internal/supplementimpact/trigger.go b/internal/supplementimpact/trigger.go new file mode 100644 index 00000000..167e4ca0 --- /dev/null +++ b/internal/supplementimpact/trigger.go @@ -0,0 +1,28 @@ +package supplementimpact + +import ( + "context" + "log/slog" + "time" +) + +// TriggerAsync kicks CreateFromSupplement in a background goroutine with panic recovery +// so chat / mutate paths are never blocked by impact analysis. +func TriggerAsync(svc *Service, projectID, userID int64, sessionID, summary, markdown string) { + if svc == nil { + return + } + go func() { + defer func() { + if rec := recover(); rec != nil { + slog.Error("supplementimpact: TriggerAsync panic", "recover", rec, "project_id", projectID) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if _, err := svc.CreateFromSupplement(ctx, projectID, userID, sessionID, summary, markdown); err != nil { + slog.Warn("supplementimpact: CreateFromSupplement failed", + "project_id", projectID, "user_id", userID, "error", err) + } + }() +} diff --git a/internal/supplementimpact/types.go b/internal/supplementimpact/types.go new file mode 100644 index 00000000..3e031820 --- /dev/null +++ b/internal/supplementimpact/types.go @@ -0,0 +1,70 @@ +package supplementimpact + +import "time" + +const ( + StatusPending = "pending" + StatusDecided = "decided" + StatusRunning = "running" + StatusDone = "done" + StatusPartiallyDone = "partially_done" + StatusExpired = "expired" + StatusCancelled = "cancelled" + + OutcomeApplied = "applied" + OutcomeProposalPending = "proposal_pending" + OutcomeProposalRejected = "proposal_rejected" + OutcomeSkippedBusy = "skipped_busy" + OutcomeSkippedLocked = "skipped_locked" + OutcomeFailed = "failed" + + BatchTTL = 24 * time.Hour +) + +// Batch is one supplement → impact analysis confirmation unit. +type Batch struct { + BatchID string `json:"batch_id"` + ProjectID int64 `json:"project_id"` + SessionID string `json:"session_id"` + InitiatorUserID int64 `json:"initiator_user_id"` + SourceKind string `json:"source_kind"` + SourceSummary string `json:"source_summary"` + CoverageStatus string `json:"coverage_status"` + Status string `json:"status"` + DecisionNonce string `json:"decision_nonce"` + DecisionDigest string `json:"decision_digest"` + Revision int64 `json:"revision"` + ExpiresAt time.Time `json:"expires_at"` + Items []Item `json:"items,omitempty"` +} + +// Item is one affected document inside a batch. +type Item struct { + ID int64 `json:"id"` + BatchID string `json:"batch_id"` + DocID string `json:"doc_id"` + Title string `json:"title"` + ItemKey string `json:"item_key"` + ImpactType string `json:"impact_type"` + Depth int `json:"depth"` + Reason string `json:"reason"` + AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` + AuthorIDSnapshot int64 `json:"author_id_snapshot"` + BusySnapshot bool `json:"busy_snapshot"` + ChangeSummary string `json:"change_summary"` + Selected bool `json:"selected"` + Outcome string `json:"outcome"` + ProposalID string `json:"proposal_id"` + RunID string `json:"run_id"` +} + +// DecisionCommand is the confirm/cancel envelope (nonce+digest+revision CAS). +type DecisionCommand struct { + BatchID string + ActorUserID int64 + Kind string // confirm | cancel + SelectedDocIDs []string + DecisionNonce string + DecisionDigest string + ExpectedRevision int64 +} From d0b4d3d4dd26644bdaf4ac08af74dff0faedbea4 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 11:45:54 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(arch):=20allow=20server=E2=86=92supplem?= =?UTF-8?q?entimpact=20and=20tighten=20HTTP=20batch=20ExpiresAt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .doc/system-design/module-boundaries.yaml | 1 + internal/supplementimpact/http_adapter.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index c3711a3d..1c86a2c7 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -259,6 +259,7 @@ imports: - workflowdef - workflowscope - workspace + - supplementimpact squad: - agent - config diff --git a/internal/supplementimpact/http_adapter.go b/internal/supplementimpact/http_adapter.go index b34e7d38..39138b55 100644 --- a/internal/supplementimpact/http_adapter.go +++ b/internal/supplementimpact/http_adapter.go @@ -3,6 +3,7 @@ package supplementimpact import ( "context" "fmt" + "time" ) // HTTPBatchView is the JSON shape expected by server supplement-impact handlers. @@ -18,7 +19,7 @@ type HTTPBatchView struct { DecisionNonce string `json:"decision_nonce"` DecisionDigest string `json:"decision_digest,omitempty"` Revision int64 `json:"revision"` - ExpiresAt interface{} `json:"expires_at"` + ExpiresAt time.Time `json:"expires_at"` Items []HTTPItemView `json:"items,omitempty"` } From 143ee934b809b0b7f502a1b659ea61cfdaa25088 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 11:49:43 +0000 Subject: [PATCH 3/3] 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 =