merge: candidate free-collab claim-notify + free-lane into local_dev
This commit is contained in:
@@ -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 补充影响批次 + 确认卡 + 分流执行
|
||||
@@ -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 表
|
||||
- 索引缺失不得显示为无影响
|
||||
@@ -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)
|
||||
|
||||
| 项 | 链接 |
|
||||
|
||||
@@ -39,6 +39,7 @@ imports:
|
||||
- kb
|
||||
- materialindex
|
||||
- maturity
|
||||
- notification
|
||||
- projectcontext
|
||||
- projectworkflow
|
||||
- quote
|
||||
@@ -114,6 +115,7 @@ imports:
|
||||
- projectinstance
|
||||
- projectworkflow
|
||||
- storage
|
||||
- supplementimpact
|
||||
- sysadmin
|
||||
- taskrun
|
||||
- traceimpact
|
||||
@@ -257,6 +259,7 @@ imports:
|
||||
- workflowdef
|
||||
- workflowscope
|
||||
- workspace
|
||||
- supplementimpact
|
||||
squad:
|
||||
- agent
|
||||
- config
|
||||
@@ -307,6 +310,10 @@ imports:
|
||||
sysadmin:
|
||||
- config
|
||||
traceimpact: []
|
||||
supplementimpact:
|
||||
- docgov
|
||||
- notification
|
||||
- traceimpact
|
||||
|
||||
table_owners:
|
||||
document_records:
|
||||
@@ -375,6 +382,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 +479,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 +522,10 @@ event_consumers:
|
||||
intent.plan.completed:
|
||||
- projection
|
||||
- notify
|
||||
document.proposal_required:
|
||||
- notify
|
||||
document.unclaimed_edit_applied:
|
||||
- notify
|
||||
|
||||
unwrap_owners:
|
||||
- docgov
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+6
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "", "", ""
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+82
-14
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+32
-27
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package supplementimpact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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 time.Time `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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<UserNotification[]> {
|
||||
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<void> {
|
||||
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<SupplementImpactBatch> {
|
||||
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<SupplementImpactBatch> {
|
||||
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();
|
||||
}
|
||||
@@ -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('影响');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<InteractiveCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.cardType === 'SUPPLEMENT_IMPACT_CONFIRM' && Array.isArray(card.impactItems) && (
|
||||
<div
|
||||
data-testid="supplement-impact-checklist"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
background: 'var(--bg-subtle)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
{card.coverageStatus && (
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: 2 }}>
|
||||
覆盖状态:{card.coverageStatus}
|
||||
</div>
|
||||
)}
|
||||
{card.impactItems.map((item) => (
|
||||
<label
|
||||
key={item.docId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: item.busy ? 'var(--text-muted)' : 'var(--text-strong)',
|
||||
opacity: item.busy ? 0.55 : 1,
|
||||
cursor: item.busy || !canClick ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={item.busy || !canClick}
|
||||
checked={!!item.selected}
|
||||
onChange={(e) => {
|
||||
if (!onExecuteAction || item.busy) return;
|
||||
onExecuteAction(card.cardId, 'toggle_impact_doc', {
|
||||
docId: item.docId,
|
||||
selected: e.target.checked,
|
||||
});
|
||||
}}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span style={{ flex: 1 }}>
|
||||
<span style={{ fontWeight: 600 }}>{item.title || item.docId}</span>
|
||||
{item.authorClaimed && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontSize: '10.5px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--amber-600)',
|
||||
background: 'var(--amber-50)',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
}}
|
||||
>
|
||||
已认领
|
||||
</span>
|
||||
)}
|
||||
{item.busy && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontSize: '10.5px',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
忙碌中
|
||||
</span>
|
||||
)}
|
||||
{item.changeSummary && (
|
||||
<div style={{ color: 'var(--text-muted)', marginTop: 2, lineHeight: 1.4 }}>
|
||||
{item.changeSummary}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拟建议修改内容 / 批注展示 */}
|
||||
{card.proposedContent && (
|
||||
<div
|
||||
|
||||
@@ -33,6 +33,7 @@ interface DocGovActionBarProps {
|
||||
|
||||
export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
|
||||
ast,
|
||||
currentRole,
|
||||
currentUserId,
|
||||
canOverrideVisibility = true,
|
||||
onOpenVisibilityModal,
|
||||
@@ -345,6 +346,34 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
|
||||
• 已被编制人认领
|
||||
</span>
|
||||
)}
|
||||
{ast.isClaimed &&
|
||||
currentRole === 'author' &&
|
||||
(ast.authorId === undefined || currentUserId === undefined || ast.authorId === currentUserId) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
background: 'rgba(33, 166, 106, 0.12)',
|
||||
color: 'var(--success, #21a66a)',
|
||||
maxWidth: '280px',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
title="认领后他人发起的修改需您确认"
|
||||
>
|
||||
您已于{' '}
|
||||
{ast.authorClaimedAt
|
||||
? new Date(ast.authorClaimedAt).toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '此前'}{' '}
|
||||
认领本文档;此后他人发起的修改需您确认。
|
||||
</span>
|
||||
)}
|
||||
{ast.visibilityRule && ast.visibilityRule.mode !== 'inherit' && (
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Sun, Moon, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Sparkles } from 'lucide-react';
|
||||
import { ProjectSession } from '../../types/workbench';
|
||||
import { ProjectWorkflowSwitcher } from '../workflow/ProjectWorkflowSwitcher';
|
||||
import { NotificationInbox } from '../notifications/NotificationInbox';
|
||||
|
||||
interface TopbarProps {
|
||||
currentSession: ProjectSession;
|
||||
@@ -165,6 +166,8 @@ export const Topbar: React.FC<TopbarProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NotificationInbox />
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Bell, CheckCircle2, ExternalLink, X } from 'lucide-react';
|
||||
import {
|
||||
fetchAllNotifications,
|
||||
isProposalType,
|
||||
patchNotificationStatus,
|
||||
type UserNotification,
|
||||
} from '../../api/notifications';
|
||||
import { decideModificationProposal } from '../../api/docgov';
|
||||
|
||||
const POLL_MS = 20_000;
|
||||
|
||||
export interface NotificationInboxProps {
|
||||
onJumpProject?: (projectId: number, payload?: UserNotification['payload']) => void;
|
||||
}
|
||||
|
||||
export const NotificationInbox: React.FC<NotificationInboxProps> = ({ onJumpProject }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<UserNotification[]>([]);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const next = await fetchAllNotifications({ limit: 50, scope: 'all' });
|
||||
setItems(next);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载通知失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = window.setInterval(() => {
|
||||
void load();
|
||||
}, POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const unread = useMemo(
|
||||
() => items.filter((n) => n.status === 'unread').length,
|
||||
[items]
|
||||
);
|
||||
const actionItems = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(n) => n.severity === 'action_required' || isProposalType(n.type)
|
||||
),
|
||||
[items]
|
||||
);
|
||||
const infoItems = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(n) => !(n.severity === 'action_required' || isProposalType(n.type))
|
||||
),
|
||||
[items]
|
||||
);
|
||||
|
||||
const markRead = async (n: UserNotification) => {
|
||||
if (n.status !== 'unread') return;
|
||||
try {
|
||||
await patchNotificationStatus(n.id, 'read');
|
||||
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, status: 'read' } : x)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const decideProposal = async (n: UserNotification, approve: boolean) => {
|
||||
const proposalId = n.payload?.proposal_id;
|
||||
if (!proposalId) return;
|
||||
setBusyId(n.id);
|
||||
try {
|
||||
await decideModificationProposal({ proposal_id: proposalId, approve });
|
||||
await patchNotificationStatus(n.id, 'acted');
|
||||
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, status: 'acted' } : x)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '提案决策失败');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="通知收件箱"
|
||||
data-testid="notification-inbox-bell"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
color: 'var(--text-muted)',
|
||||
background: open ? 'var(--brand-50)' : 'var(--bg-subtle)',
|
||||
border: '1px solid var(--border)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Bell size={16} />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
data-testid="notification-unread-badge"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
minWidth: '16px',
|
||||
height: '16px',
|
||||
padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--danger, #dc2626)',
|
||||
color: '#fff',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
lineHeight: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
data-testid="notification-inbox-panel"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40px',
|
||||
right: 0,
|
||||
width: '360px',
|
||||
maxHeight: '480px',
|
||||
overflow: 'auto',
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
boxShadow: 'var(--shadow-md, 0 8px 24px rgba(15,23,42,0.12))',
|
||||
zIndex: 50,
|
||||
padding: '10px 0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px 8px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-strong)' }}>
|
||||
通知
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: '8px 12px', fontSize: '12px', color: 'var(--danger, #dc2626)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionItems.length > 0 && (
|
||||
<section style={{ padding: '8px 0' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--amber-600, #d97706)',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
需要处理
|
||||
</div>
|
||||
{actionItems.map((n) => (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
n={n}
|
||||
busy={busyId === n.id}
|
||||
onOpen={() => {
|
||||
void markRead(n);
|
||||
onJumpProject?.(n.project_id, n.payload);
|
||||
}}
|
||||
onDecide={
|
||||
isProposalType(n.type) && n.payload?.proposal_id
|
||||
? (approve) => void decideProposal(n, approve)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ padding: '8px 0' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
摘要
|
||||
</div>
|
||||
{infoItems.length === 0 && actionItems.length === 0 ? (
|
||||
<div style={{ padding: '16px 12px', fontSize: '12px', color: 'var(--text-muted)' }}>
|
||||
暂无通知
|
||||
</div>
|
||||
) : (
|
||||
infoItems.map((n) => (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
n={n}
|
||||
busy={false}
|
||||
onOpen={() => {
|
||||
void markRead(n);
|
||||
onJumpProject?.(n.project_id, n.payload);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function NotificationRow({
|
||||
n,
|
||||
busy,
|
||||
onOpen,
|
||||
onDecide,
|
||||
}: {
|
||||
n: UserNotification;
|
||||
busy: boolean;
|
||||
onOpen: () => void;
|
||||
onDecide?: (approve: boolean) => void;
|
||||
}) {
|
||||
const unread = n.status === 'unread';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: unread ? 'var(--brand-50, rgba(79,107,255,0.06))' : 'transparent',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12.5px',
|
||||
fontWeight: unread ? 700 : 600,
|
||||
color: 'var(--text-strong)',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{n.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11.5px',
|
||||
color: 'var(--text-muted)',
|
||||
lineHeight: 1.45,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{n.body}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
title="跳转"
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--brand-600)',
|
||||
cursor: 'pointer',
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{onDecide && n.status !== 'acted' && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(true)}
|
||||
style={{
|
||||
fontSize: '11.5px',
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--brand-100)',
|
||||
background: 'var(--brand-500)',
|
||||
color: '#fff',
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<CheckCircle2 size={12} /> 同意修改
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(false)}
|
||||
style={{
|
||||
fontSize: '11.5px',
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-subtle)',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ResolvedDocumentTask['wr
|
||||
return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`;
|
||||
}
|
||||
|
||||
/** 只读动词优先;无明确写动作则不启动文档任务。 */
|
||||
/** 只读动词优先于写动词:含「总结/分析/查看」等时即使夹杂写动词也不启动文档任务。 */
|
||||
export function hasDocumentWriteIntent(text: string): boolean {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) return false;
|
||||
if (READ_ONLY_INTENT.test(normalized)) return false;
|
||||
return WRITE_INTENT.test(normalized);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
fetchIntentPlan,
|
||||
submitIntentPlanDecision,
|
||||
} from '../api/intentPlans';
|
||||
import { decideSupplementImpact } from '../api/notifications';
|
||||
import {
|
||||
ALL_MOCK_DOCUMENTS,
|
||||
} from '@demo-seeds';
|
||||
@@ -72,6 +73,107 @@ export function useCardActions(input: CardActionsInput) {
|
||||
|
||||
if (!targetCard) return;
|
||||
|
||||
if (targetCard.cardType === 'SUPPLEMENT_IMPACT_CONFIRM') {
|
||||
const batchId = String(payload?.batch_id || targetCard.supplementBatchId || '');
|
||||
if (actionId === 'toggle_impact_doc') {
|
||||
const docId = String(payload?.docId || '');
|
||||
const selected = !!payload?.selected;
|
||||
setMessages((prev) =>
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, DocumentAST> = {};
|
||||
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<string, DocumentAST> = {};
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -35,6 +35,22 @@ const fatalDenialCopy: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user