diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index ede58738..2a418c0c 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -15,6 +15,7 @@ |-------|------|--------|----------|-------|--------| | Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done | | Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done | +| Cursor | 2026-09-21-dev-multiuser-four-issues | fix/dev-multiuser-four-issues-2026-09-21 | .worktrees/fix-dev-multiuser-four-issues | projectinstance+docgov | done | | Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done | | Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done | | Cursor-Auto | 2026-09-20-stage-backtrack-design | feature/stage-backtrack-design | - | trace-impact-design | done | @@ -174,7 +175,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | 需求与影响面板已从 dev 前端包移除;`/health` schema_version=59;pi-agent 为 MiniMax-M2.7-highspeed | - | +| - | idle | 2026-09-21 | 需求与影响面板已从 dev 前端移除;多用户四问题修复已合入并重建;`/health` schema_version=59 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-21-dev-multiuser-four-issues.md b/.doc/ops/short-term/2026-09-21-dev-multiuser-four-issues.md new file mode 100644 index 00000000..7fd0c1f3 --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-dev-multiuser-four-issues.md @@ -0,0 +1,50 @@ +--- +title: "dev 多用户四问题修复" +status: done +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: fix/dev-multiuser-four-issues-2026-09-21 +worktree: .worktrees/fix-dev-multiuser-four-issues +mutex: projectinstance+docgov +--- + +# dev 多用户四问题修复 + +## 目标 + +项目 6579 上,成员能看到激活后的受控文档,消息能路由到 `bd_*` 任务键,职责矩阵与工作流配置一致,激活后仍可改职责/审批/启用但不能切换工作流。 + +## 范围 + +- 激活与列表时把已启用 doc_task 幂等写入 `document_records`(不覆盖已有正文) +- 语义任务键按项目实际键做后缀归一;同浏览器切换账号时清空工作台本地状态 +- 职责矩阵改走权威职责与 `PATCH .../duties`,去掉演示工号与演练文案 +- `UpdateLivePlan`:激活后可改配置;切换工作流仍拒绝;项目 143 保持冻结 + +## 非目标 + +- 不改写、不终止项目 143 +- 不把 `my_private` 跨用户 403 改成放行 +- 不晋升 test/main + +## 步骤 + +1. [x] 物化模板文档与 6579 回填 +2. [x] 任务键归一、账号隔离、403/409 文案 +3. [x] 职责矩阵接权威数据 +4. [x] 激活后 live 配置编辑 +5. [x] 占槽合入 dev、冒烟与实测报告 + +## 验收标准 + +- [ ] 陈威浏览器复测仍待做;管理员 `GET /projects/6579/documents` 已看到 7 个 `bd_*` 键 +- [x] 「开始编写用户初始需求表」在单测中解析为 `bd_intake` +- [x] 403 文案单测不再落到 NETWORK_ERROR;同浏览器切换清空有单测,未做双账号点击 +- [x] 激活后切换工作流被 409 拒绝,项目 143 为 frozen;职责改写有单测 + +## 相关链接 + +- Checklist: +- Handoff: +- Test report: `.doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md` diff --git a/.doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md b/.doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md new file mode 100644 index 00000000..bb369d41 --- /dev/null +++ b/.doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md @@ -0,0 +1,60 @@ +# dev 多用户四问题修复 测试报告 + +## 一、 测试背景 (Background) +dev 上项目「新的测试项目」(id 6579)出现四类问题:职责矩阵与工作流配置不一致、激活后整表配置被冻结、成员看不到文档、发送「开始编写用户初始需求表」出现 409 并被界面说成网络错误。项目 143 保持只读。 + +## 二、 测试目标 (Test Objectives) +- 激活后的启用文档在服务端 `document_records` 中可见。 +- 语义任务键能对齐到项目里的 `bd_*` 键。 +- 激活后可以改职责、审批和启用,但不能切换工作流;项目 143 仍是冻结。 +- 403 不再被当成 `NETWORK_ERROR`。 + +## 三、 测试方案与环境 (Test Plan & Environment) +- OS:linux;分支 `fix/dev-multiuser-four-issues-2026-09-21`。 +- dev 容器于 2026-09-21 用该分支工作树重建。`/health`:`schema_version=59`,`agent_backend=pi-agent`,`kb_ready=true`。 +- 单元测试覆盖物化、live 规划、任务键后缀和设置模式。 +- 用管理员账号调用 dev `:8080` 核对项目 6579 与 143。未代登陈威(没有使用成员口令)。 + +## 四、 预期结果 (Expected Results) +- 6579 有 7 条 `bd_*` 模板文档;`bd_intake` 编制人为 398、策略 `author_only`。 +- 设置接口 `plan_edit_mode=live`;把工作流键改成别的值返回 409 `WORKFLOW_SWITCH_FROZEN`,版本号不变。 +- 项目 143 的 `plan_edit_mode=frozen`。 +- 「开始编写用户初始需求表」在只有 `bd_intake` 的交付物池里解析为 `bd_intake`。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:6579 文档回填 +- 测试输入:向 dev 库为项目 6579 幂等插入 7 条 `doc_proj6579_bd_*`。 +- 实际输出:`INSERT 0 7`。随后 `GET /api/v1/projects/6579/documents` 返回 200,任务键包含 `bd_cdo_quote`、`bd_feasibility_dev`、`bd_feasibility_reg`、`bd_intake`、`bd_prelim_design`、`bd_research`、`bd_user_requirements`。另有一条更早的本地键 `intake`(`doc_proj6579_intake`),不是本次插入,也没有改项目 143(该项目文档数仍为 1)。 +- 评价判定:PASS。成员列表是否隐藏这条旧 `intake` 行,取决于阶段过滤;7 份 `bd_*` 已在服务端。 +- 性能与耗时:插入语句一次返回。 + +### 用例 2:激活后禁止切换工作流 +- 测试输入:`GET /api/v1/projects/6579/settings`,再 `PUT /workflow-plan` 且 `live_edit=true`、`workflow_definition_key=not-the-live-workflow`。 +- 实际输出:设置 `plan_edit_mode=live`,`can_edit_plan=false`,`workflow_plan_revision=1`。PUT 返回 409,错误码 `WORKFLOW_SWITCH_FROZEN`,消息「运行中不能切换工作流,只能调整职责、审批和文档启用」。再次读取修订号仍为 1,定义键仍为 `main`。 +- 评价判定:PASS。 +- 性能与耗时:两次读取与一次拒绝写,均在 1 秒内。 + +### 用例 3:项目 143 保持冻结 +- 测试输入:`GET /api/v1/projects/143/settings`。 +- 实际输出:`plan_edit_mode=frozen`。 +- 评价判定:PASS。 + +### 用例 4:任务键与错误文案 +- 测试输入:前端单测「开始编写用户初始需求表」对只有 `bd_intake` 的池;`buildNetworkErrorInfo(409, TASK_SCOPE_UNRESOLVED)` 与 403 `ERR_FORBIDDEN`。 +- 实际输出:任务键为 `bd_intake`。409 文案为「无法定位该文档任务的工作流阶段,请检查任务名或联系管理员」,不是网络错误。403 文案保留「无权访问」。 +- 评价判定:PASS(单测)。浏览器里用陈威再发一句消息未在本轮复测。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) +- `go test -short`:`internal/projectinstance`、`internal/app`、`internal/server` 通过。`internal/docgov` 新增物化测试通过;既有 `TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK` 在连到 dev 库时失败,原因是它用随机 `project_id` 插入文档,而建项目语句的错误被忽略,外键失败。该失败不在本次改动路径上。 +- `web`:`npm test` 与 `npm run build` 通过。 +- 重建后健康检查: + +```json +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":59,"schema_ready":true,"task_store_ready":true} +``` + +证据:`.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/`。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) +服务端文档、live 配置和切换拒绝已在 dev 容器上核对。可以合入 `dev`。尚未用陈威浏览器再走一遍发消息;旧的 `doc_proj6579_intake` 行仍在,不影响 `bd_*` 列表,但可以另开任务决定是否清掉。项目 143 未改写。 diff --git a/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/6579-backfill.txt b/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/6579-backfill.txt new file mode 100644 index 00000000..611805f5 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/6579-backfill.txt @@ -0,0 +1,17 @@ + doc_id | task_key | is_template_only | author_id | duty_policy +-----------------------------------+----------------------+------------------+-----------+------------- + doc_proj6579_bd_cdo_quote | bd_cdo_quote | t | 0 | + doc_proj6579_bd_feasibility_dev | bd_feasibility_dev | t | 0 | + doc_proj6579_bd_feasibility_reg | bd_feasibility_reg | t | 0 | + doc_proj6579_bd_intake | bd_intake | t | 398 | author_only + doc_proj6579_bd_prelim_design | bd_prelim_design | t | 0 | + doc_proj6579_bd_research | bd_research | t | 0 | + doc_proj6579_bd_user_requirements | bd_user_requirements | t | 0 | + doc_proj6579_intake | intake | t | 398 | author_only +(8 rows) + + project_143_docs +------------------ + 1 +(1 row) + diff --git a/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/api-6579.txt b/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/api-6579.txt new file mode 100644 index 00000000..b1935c0e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/api-6579.txt @@ -0,0 +1,6 @@ +login 200 user admin 9 +documents 200 count 8 task_keys ['bd_cdo_quote', 'bd_feasibility_dev', 'bd_feasibility_reg', 'bd_intake', 'bd_prelim_design', 'bd_research', 'bd_user_requirements', 'intake'] +settings 200 plan_edit_mode live revision 1 can_edit_plan False definition main +switch_put 409 error {'error': 'WORKFLOW_SWITCH_FROZEN', 'message': '运行中不能切换工作流,只能调整职责、审批和文档启用'} +revision_after_switch_attempt 1 definition main +project_143_mode 200 frozen diff --git a/internal/app/app.go b/internal/app/app.go index af8e8ca5..a089a90d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -165,6 +165,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { } if parts.Gov != nil { instSvc.SetDocumentDutyProjector(&documentDutyProjectorAdapter{gov: parts.Gov}) + instSvc.SetTemplateDocMaterializer(&templateDocMaterializerAdapter{gov: parts.Gov}) } } taskMgr := taskrun.NewManager(pwSvc, parts.Ctx) diff --git a/internal/app/duty_reassign_adapters.go b/internal/app/duty_reassign_adapters.go index 4783a8df..2d6ca872 100644 --- a/internal/app/duty_reassign_adapters.go +++ b/internal/app/duty_reassign_adapters.go @@ -32,6 +32,33 @@ func (a *documentDutyProjectorAdapter) ProjectDocTaskDuties( ) } +type templateDocMaterializerAdapter struct { + gov *docgov.GovernanceEngine +} + +func (a *templateDocMaterializerAdapter) MaterializeTemplateDocs(ctx context.Context, projectID int64, plan projectinstance.WorkflowPlan) error { + if a == nil || a.gov == nil || projectID <= 0 { + return nil + } + specs := make([]docgov.TemplateDocSpec, 0, len(plan.Stages)) + for _, stage := range plan.Stages { + for _, task := range stage.DocTasks { + _, effective, _ := projectinstance.ResolveDocTaskDuty(task) + specs = append(specs, docgov.TemplateDocSpec{ + TaskKey: task.TaskKey, + Title: task.TaskTitle, + StageKey: stage.StageKey, + DutyPolicy: task.DutyPolicy, + Enabled: stage.Enabled && task.Enabled, + AuthorUserID: effective.AuthorUserID, + ReviewerUserID: effective.ReviewerUserID, + ApproverUserID: effective.ApproverUserID, + }) + } + } + return a.gov.MaterializeTemplateDocs(ctx, projectID, specs) +} + type docTaskBusyCheckerAdapter struct { gov *docgov.GovernanceEngine taskRuns *taskrun.Manager diff --git a/internal/docgov/materialize.go b/internal/docgov/materialize.go new file mode 100644 index 00000000..be733c65 --- /dev/null +++ b/internal/docgov/materialize.go @@ -0,0 +1,157 @@ +package docgov + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// TemplateDocSpec 是激活后要落到 document_records 的受控文档槽。 +type TemplateDocSpec struct { + TaskKey string + Title string + StageKey string + DutyPolicy string + Enabled bool + AuthorUserID int64 + ReviewerUserID int64 + ApproverUserID int64 + AuthorName string + ReviewerName string + ApproverName string +} + +// ControlledDocID 是项目受控文档的权威 doc_id。 +func ControlledDocID(projectID int64, taskKey string) string { + return fmt.Sprintf("doc_proj%d_%s", projectID, strings.TrimSpace(taskKey)) +} + +// MaterializeTemplateDocs 为已启用的文档任务幂等写入模板台账。 +// 已脱离模板(is_template_only=false)的正文与职责不被覆盖。 +func (e *GovernanceEngine) MaterializeTemplateDocs(ctx context.Context, projectID int64, specs []TemplateDocSpec) error { + if e == nil || projectID <= 0 { + return nil + } + for _, spec := range specs { + if !spec.Enabled || strings.TrimSpace(spec.TaskKey) == "" { + continue + } + if err := e.upsertTemplateDoc(ctx, projectID, spec); err != nil { + return err + } + } + return nil +} + +func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int64, spec TemplateDocSpec) error { + taskKey := strings.TrimSpace(spec.TaskKey) + docID := ControlledDocID(projectID, taskKey) + title := strings.TrimSpace(spec.Title) + if title == "" { + title = taskKey + } + authorName := dutyDisplayName(spec.AuthorUserID, spec.AuthorName) + reviewerName := dutyDisplayName(spec.ReviewerUserID, spec.ReviewerName) + approverName := dutyDisplayName(spec.ApproverUserID, spec.ApproverName) + policy := strings.TrimSpace(spec.DutyPolicy) + now := time.Now() + + if e.pool != nil { + cfgJSON, err := json.Marshal(DefaultDocumentConfig()) + if err != nil { + return fmt.Errorf("docgov: marshal template config: %w", err) + } + _, err = e.pool.Exec(ctx, ` + INSERT INTO document_records ( + doc_id, project_id, task_key, title, stage, version, + current_state, is_locked, is_template_only, work_dir, + author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name, + config, duty_locked, duty_policy, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, 1, + $6, false, true, '', + $7, $8, $9, $10, $11, $12, + $13, true, $14, $15, $15 + ) + ON CONFLICT (doc_id) DO UPDATE SET + title = CASE WHEN document_records.is_template_only THEN EXCLUDED.title ELSE document_records.title END, + stage = CASE WHEN document_records.is_template_only THEN EXCLUDED.stage ELSE document_records.stage END, + author_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_id ELSE document_records.author_id END, + author_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_name ELSE document_records.author_name END, + reviewer_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_id ELSE document_records.reviewer_id END, + reviewer_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_name ELSE document_records.reviewer_name END, + approver_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_id ELSE document_records.approver_id END, + approver_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_name ELSE document_records.approver_name END, + duty_policy = CASE WHEN document_records.is_template_only THEN EXCLUDED.duty_policy ELSE document_records.duty_policy END, + duty_locked = CASE WHEN document_records.is_template_only THEN TRUE ELSE document_records.duty_locked END, + updated_at = EXCLUDED.updated_at + `, docID, projectID, taskKey, title, strings.TrimSpace(spec.StageKey), + string(StateDrafting), + nullIfZero(spec.AuthorUserID), authorName, + nullIfZero(spec.ReviewerUserID), reviewerName, + nullIfZero(spec.ApproverUserID), approverName, + cfgJSON, policy, now) + if err != nil { + return fmt.Errorf("docgov: materialize template doc %s: %w", docID, err) + } + } + + e.mu.Lock() + defer e.mu.Unlock() + if existing, ok := e.memDocs[docID]; ok && existing != nil { + if !existing.IsTemplateOnly { + return nil + } + existing.Title = title + existing.Stage = strings.TrimSpace(spec.StageKey) + existing.TaskKey = taskKey + existing.AuthorID = spec.AuthorUserID + existing.AuthorName = authorName + existing.ReviewerID = spec.ReviewerUserID + existing.ReviewerName = reviewerName + existing.ApproverID = spec.ApproverUserID + existing.ApproverName = approverName + existing.DutyLocked = true + existing.DutyPolicy = policy + existing.UpdatedAt = now + return nil + } + if e.memDocs == nil { + e.memDocs = map[string]*DocumentRecord{} + } + e.memDocs[docID] = &DocumentRecord{ + DocID: docID, + ProjectID: projectID, + TaskKey: taskKey, + Title: title, + Stage: strings.TrimSpace(spec.StageKey), + Version: 1, + CurrentState: StateDrafting, + IsTemplateOnly: true, + AuthorID: spec.AuthorUserID, + AuthorName: authorName, + ReviewerID: spec.ReviewerUserID, + ReviewerName: reviewerName, + ApproverID: spec.ApproverUserID, + ApproverName: approverName, + DutyLocked: true, + DutyPolicy: policy, + Config: DefaultDocumentConfig(), + CreatedAt: now, + UpdatedAt: now, + } + return nil +} + +func dutyDisplayName(userID int64, name string) string { + name = strings.TrimSpace(name) + if name != "" { + return name + } + if userID > 0 { + return fmt.Sprintf("user-%d", userID) + } + return "" +} diff --git a/internal/docgov/materialize_test.go b/internal/docgov/materialize_test.go new file mode 100644 index 00000000..56cf21b2 --- /dev/null +++ b/internal/docgov/materialize_test.go @@ -0,0 +1,67 @@ +package docgov_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/docgov" +) + +func TestMaterializeTemplateDocsIdempotentAndSkipsRealContent(t *testing.T) { + ctx := context.Background() + engine := docgov.NewGovernanceEngine(nil) + specs := []docgov.TemplateDocSpec{{ + TaskKey: "bd_intake", + Title: "用户初始需求收集表", + StageKey: "V0.1_triage", + Enabled: true, + DutyPolicy: "author_only", + AuthorUserID: 398, + AuthorName: "陈威", + ReviewerUserID: 398, + ApproverUserID: 398, + }, { + TaskKey: "bd_research", + Title: "调研报告", + Enabled: false, + }} + if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil { + t.Fatal(err) + } + specs[0].AuthorName = "陈威" + if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil { + t.Fatal(err) + } + + docs, err := engine.ListWorkflowDocumentsByProject(ctx, 6579) + if err != nil { + t.Fatal(err) + } + if len(docs) != 1 { + t.Fatalf("documents=%d, want 1 enabled template", len(docs)) + } + if docs[0].DocID != "doc_proj6579_bd_intake" || !docs[0].IsTemplateOnly || docs[0].AuthorID != 398 { + t.Fatalf("unexpected template doc: %+v", docs[0]) + } + if docs[0].DutyPolicy != "author_only" { + t.Fatalf("duty policy=%q", docs[0].DutyPolicy) + } + + const realID = "doc_proj6579_bd_intake" + if err := engine.MarkDocumentContentUpdated(ctx, realID, "abc", false); err != nil { + t.Fatal(err) + } + specs[0].AuthorUserID = 9 + specs[0].AuthorName = "系统管理员" + specs[0].Title = "不应覆盖正文标题" + if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil { + t.Fatal(err) + } + got, err := engine.GetDocument(ctx, realID) + if err != nil { + t.Fatal(err) + } + if got.IsTemplateOnly || got.AuthorID != 398 || got.Title != "用户初始需求收集表" { + t.Fatalf("real document was overwritten: %+v", got) + } +} diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index a81bea6a..0a1768df 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -7,33 +7,34 @@ import ( ) var ( - ErrNotAvailable = errors.New("project instance store not available") - ErrProjectNotFound = errors.New("project not found") - ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow") - ErrPlanFrozen = errors.New("plan frozen: workflow already active") - ErrWorkflowAlreadyActive = errors.New("workflow already active for project") - ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required") - ErrInvalidPlan = errors.New("invalid workflow plan") - ErrValidationFailed = errors.New("workflow plan validation failed") - ErrTokenInvalid = errors.New("activation token invalid or expired") - ErrConcurrentConflict = errors.New("conflict: project version mismatch") - ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch") - ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published") - ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation") - ErrInstanceNotFound = errors.New("workflow instance not found") - ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved") - ErrStageNotActive = errors.New("stage is not the active stage") - ErrStageNotCompleted = errors.New("stage is not completed") - ErrRecheckAckRequired = errors.New("recheck acknowledgement required") - ErrInvalidStage = errors.New("invalid stage key for instance") - ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch") - ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") - ErrTerminationNotAllowed = errors.New("termination not allowed for instance state") - ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled") + ErrNotAvailable = errors.New("project instance store not available") + ErrProjectNotFound = errors.New("project not found") + ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow") + ErrPlanFrozen = errors.New("plan frozen: workflow already active") + ErrWorkflowSwitchFrozen = errors.New("workflow switch frozen: definition cannot change after activation") + ErrWorkflowAlreadyActive = errors.New("workflow already active for project") + ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required") + ErrInvalidPlan = errors.New("invalid workflow plan") + ErrValidationFailed = errors.New("workflow plan validation failed") + ErrTokenInvalid = errors.New("activation token invalid or expired") + ErrConcurrentConflict = errors.New("conflict: project version mismatch") + ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch") + ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published") + ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation") + ErrInstanceNotFound = errors.New("workflow instance not found") + ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved") + ErrStageNotActive = errors.New("stage is not the active stage") + ErrStageNotCompleted = errors.New("stage is not completed") + ErrRecheckAckRequired = errors.New("recheck acknowledgement required") + ErrInvalidStage = errors.New("invalid stage key for instance") + ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch") + ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") + ErrTerminationNotAllowed = errors.New("termination not allowed for instance state") + ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled") ErrAuthorOnlyPolicyUnavailable = errors.New("author-only approval policy store unavailable") - ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview") - ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties") - ErrDocTaskNotFound = errors.New("document task not found or disabled") + ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview") + ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties") + ErrDocTaskNotFound = errors.New("document task not found or disabled") ) // DocTaskBusyError 文档小队任务运行中,禁止改派。 diff --git a/internal/projectinstance/live_plan.go b/internal/projectinstance/live_plan.go new file mode 100644 index 00000000..663a6b1c --- /dev/null +++ b/internal/projectinstance/live_plan.go @@ -0,0 +1,132 @@ +package projectinstance + +import ( + "context" + "strings" +) + +// UpdateLivePlan 在已激活实例上更新职责、审批策略和文档/阶段启用。 +// 工作流定义与版本不可变。项目 143 保持冻结。 +func (s *Service) UpdateLivePlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + if projectID == readonlyLivePlanProjectID { + return nil, ErrPlanFrozen + } + if !s.Available() { + return nil, ErrNotAvailable + } + + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil { + return nil, err + } + if live == nil { + return nil, ErrInstanceNotFound + } + if live.Status == "completed" || live.Status == "terminated" { + return nil, ErrPlanFrozen + } + if in.ExpectedPlanRevision != live.ConfigRevision { + return nil, ErrInstanceRevisionConflict + } + + plan := normalizeWorkflowPlanDuties(in.Plan) + if strings.TrimSpace(plan.WorkflowDefinitionKey) == "" { + plan.WorkflowDefinitionKey = live.ConfigSnapshot.WorkflowDefinitionKey + } + if plan.WorkflowVersion == 0 { + plan.WorkflowVersion = live.ConfigSnapshot.WorkflowVersion + } + if plan.WorkflowVersionID == 0 { + plan.WorkflowVersionID = live.WorkflowVersionID + } + if plan.WorkflowDefinitionKey != live.ConfigSnapshot.WorkflowDefinitionKey || + plan.WorkflowVersion != live.ConfigSnapshot.WorkflowVersion || + plan.WorkflowVersionID != live.WorkflowVersionID { + return nil, ErrWorkflowSwitchFrozen + } + plan.WorkflowVersionID = live.WorkflowVersionID + + version, err := s.resolvePublishedVersion(ctx, plan) + if err != nil { + return nil, err + } + validation := validatePlan(plan, version) + if !validation.Valid { + return nil, newValidationError(validation) + } + if err := s.checkActiveMembers(ctx, projectID, plan); err != nil { + return nil, err + } + if planUsesAuthorOnly(plan) { + if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil { + return nil, err + } + } + if err := s.rejectBusyDocTaskChanges(ctx, projectID, live.ConfigSnapshot, plan); err != nil { + return nil, err + } + + updated, err := s.store.UpdateLivePlan(ctx, projectID, plan, live.ConfigRevision, userID) + if err != nil { + return nil, err + } + s.projectLiveDuties(ctx, projectID, updated.ConfigSnapshot) + s.materializePlan(ctx, projectID, updated.ConfigSnapshot) + return s.GetSettings(ctx, projectID, userID, isCreator) +} + +func (s *Service) rejectBusyDocTaskChanges(ctx context.Context, projectID int64, prev, next WorkflowPlan) error { + if s == nil || s.busyChecker == nil { + return nil + } + for _, st := range prev.Stages { + for _, before := range st.DocTasks { + after := findDocTaskPlan(next, before.TaskKey) + if after == nil || !liveDocTaskChanged(before, *after) { + continue + } + busy, reason, err := s.busyChecker.DocTaskSquadBusy(ctx, projectID, before.TaskKey) + if err != nil { + return err + } + if busy { + if reason == "" { + reason = "该文档小队任务进行中,完成后可更换负责人" + } + return &DocTaskBusyError{TaskKey: before.TaskKey, Reason: reason} + } + } + } + return nil +} + +func liveDocTaskChanged(before, after DocTaskPlan) bool { + if before.Enabled != after.Enabled || before.DutyPolicy != after.DutyPolicy { + return true + } + return before.Duties != after.Duties +} + +func (s *Service) projectLiveDuties(ctx context.Context, projectID int64, plan WorkflowPlan) { + if s == nil || s.projector == nil { + return + } + for _, st := range plan.Stages { + if !st.Enabled { + continue + } + for _, dt := range st.DocTasks { + if !dt.Enabled { + continue + } + mode, effective, _ := ResolveDocTaskDuty(dt) + if mode == DutyModeNone { + continue + } + _ = s.projector.ProjectDocTaskDuties(ctx, projectID, dt.TaskKey, effective, dt.DutyPolicy, DutyDisplayNames{}) + } + } +} diff --git a/internal/projectinstance/live_plan_test.go b/internal/projectinstance/live_plan_test.go new file mode 100644 index 00000000..21b7bf68 --- /dev/null +++ b/internal/projectinstance/live_plan_test.go @@ -0,0 +1,115 @@ +package projectinstance + +import ( + "context" + "errors" + "testing" + + "chat-agent-system/internal/workflowdef" +) + +type countingMaterializer struct { + calls int + projectID int64 +} + +func (c *countingMaterializer) MaterializeTemplateDocs(_ context.Context, projectID int64, _ WorkflowPlan) error { + c.calls++ + c.projectID = projectID + return nil +} + +func TestUpdateLivePlanKeepsWorkflowAndMaterializes(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) + mat := &countingMaterializer{} + svc := NewService(store, testResolver(), nil, nil) + svc.SetTemplateDocMaterializer(mat) + + if _, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatal(err) + } + preview, err := svc.ActivationPreview(ctx, 42, 1, true) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Activate(ctx, 42, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatal(err) + } + if mat.calls != 1 || mat.projectID != 42 { + t.Fatalf("activate materialize calls=%d project=%d", mat.calls, mat.projectID) + } + + settings, err := svc.GetSettings(ctx, 42, 1, true) + if err != nil { + t.Fatal(err) + } + if settings.PlanEditMode != PlanEditLive { + t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode) + } + plan := *settings.Plan + plan.Stages[0].DocTasks[0].Duties.ApproverUserID = 4 + updated, err := svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: settings.WorkflowPlanRevision, LiveEdit: true, + }) + if err != nil { + t.Fatal(err) + } + if updated.Plan.Stages[0].DocTasks[0].Duties.ApproverUserID != 4 { + t.Fatalf("duty not updated: %+v", updated.Plan.Stages[0].DocTasks[0].Duties) + } + if updated.Plan.WorkflowDefinitionKey != plan.WorkflowDefinitionKey { + t.Fatal("workflow key changed") + } + if mat.calls != 2 { + t.Fatalf("live update materialize calls=%d", mat.calls) + } + + switched := plan + switched.WorkflowDefinitionKey = "other" + _, err = svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{ + Plan: switched, ExpectedPlanRevision: updated.WorkflowPlanRevision, + }) + if !errors.Is(err, ErrWorkflowSwitchFrozen) { + t.Fatalf("expected switch frozen, got %v", err) + } +} + +func TestUpdateLivePlanRejectsReadonlyProject(t *testing.T) { + svc := NewService(newMemStore(), testResolver(), nil, nil) + _, err := svc.UpdateLivePlan(context.Background(), readonlyLivePlanProjectID, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 1, LiveEdit: true, + }) + if !errors.Is(err, ErrPlanFrozen) { + t.Fatalf("expected frozen, got %v", err) + } +} + +func TestPlanEditModeFrozenForReadonlyLiveProject(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, readonlyLivePlanProjectID, LifecycleActive, workflowdef.OfficialKeyMain, 2, 1) + store.live[readonlyLivePlanProjectID] = &WorkflowInstance{ + ID: 89, + ProjectID: readonlyLivePlanProjectID, + WorkflowVersionID: 1, + Status: InstanceActive, + ConfigRevision: 3, + ConfigSnapshot: validPlan(), + } + svc := NewService(store, testResolver(), nil, nil) + settings, err := svc.GetSettings(ctx, readonlyLivePlanProjectID, 1, true) + if err != nil { + t.Fatal(err) + } + if settings.PlanEditMode != PlanEditFrozen { + t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode) + } +} diff --git a/internal/projectinstance/materialize.go b/internal/projectinstance/materialize.go new file mode 100644 index 00000000..a7b3f483 --- /dev/null +++ b/internal/projectinstance/materialize.go @@ -0,0 +1,43 @@ +package projectinstance + +import ( + "context" + "log/slog" +) + +// readonlyLivePlanProjectID 是历史验收项目。激活后保持只读,禁止改写规划与补文档行。 +const readonlyLivePlanProjectID int64 = 143 + +// TemplateDocMaterializer 把已启用文档任务物化到 document_records。 +// 实现放在 app 适配层,避免 projectinstance 依赖 docgov。 +type TemplateDocMaterializer interface { + MaterializeTemplateDocs(ctx context.Context, projectID int64, plan WorkflowPlan) error +} + +func (s *Service) SetTemplateDocMaterializer(m TemplateDocMaterializer) { + if s == nil { + return + } + s.templateDocs = m +} + +// MaterializeLiveDocs 按 live 快照补齐受控文档台账。列表接口可重复调用。 +func (s *Service) MaterializeLiveDocs(ctx context.Context, projectID int64) error { + if s == nil || s.templateDocs == nil || s.store == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID { + return nil + } + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil { + return err + } + return s.templateDocs.MaterializeTemplateDocs(ctx, projectID, live.ConfigSnapshot) +} + +func (s *Service) materializePlan(ctx context.Context, projectID int64, plan WorkflowPlan) { + if s == nil || s.templateDocs == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID { + return + } + if err := s.templateDocs.MaterializeTemplateDocs(ctx, projectID, plan); err != nil { + slog.Warn("materialize template docs", "project_id", projectID, "err", err) + } +} diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 7eeae54a..112c9582 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -28,6 +28,7 @@ type Service struct { authorOnlyPolicy AuthorOnlyPolicySource projector DocumentDutyProjector busyChecker DocTaskBusyChecker + templateDocs TemplateDocMaterializer recheck RecheckGate } @@ -94,6 +95,7 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64 settings.LiveInstance = liveInst settings.CanEditPlan = false settings.CanActivate = false + settings.WorkflowPlanRevision = liveInst.ConfigRevision // 激活后权威规划在 live snapshot(planning 行已转为 active)。 snap := liveInst.ConfigSnapshot settings.Plan = &snap @@ -137,10 +139,27 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64 } } settings.AuthorOnlyPolicy = s.loadAuthorOnlyCapability(ctx) + settings.PlanEditMode = planEditModeFor(projectID, lifecycle, liveInst) _ = userID return settings, nil } +func planEditModeFor(projectID int64, lifecycle string, live *WorkflowInstance) string { + if projectID == readonlyLivePlanProjectID && (live != nil || lifecycle != LifecyclePlanning) { + return PlanEditFrozen + } + if live != nil { + if live.Status == "completed" || live.Status == "terminated" { + return PlanEditFrozen + } + return PlanEditLive + } + if lifecycle == LifecyclePlanning { + return PlanEditPlanning + } + return PlanEditFrozen +} + func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) { if !isCreator { return nil, ErrForbiddenNotCreator @@ -318,6 +337,9 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i if err != nil { return nil, err } + if activated != nil { + s.materializePlan(ctx, projectID, activated.ConfigSnapshot) + } return &ActivateResult{ ProjectID: projectID, diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index 3918a361..46daa812 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -62,6 +62,8 @@ type Store interface { MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error // UpdateLiveDocTaskDuties 更新 live 实例 config_snapshot 与 document_duty_assignments(单任务)。 UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error) + // UpdateLivePlan 更新 live 快照(职责、审批策略、文档/阶段启用),并同步实例行与职责指派。 + UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error) } // NewStore 构建 PG 或内存 Store。 @@ -771,6 +773,118 @@ func (s *pgStore) UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, return inst, nil } +func (s *pgStore) UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var instanceID int64 + var revision int + err = tx.QueryRow(ctx, ` + SELECT id, config_revision + FROM project_workflow_instances + WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation') + FOR UPDATE + `, projectID).Scan(&instanceID, &revision) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInstanceNotFound + } + if err != nil { + return nil, err + } + if revision != expectedConfigRevision { + return nil, ErrInstanceRevisionConflict + } + + planHash, err := computePlanHash(plan) + if err != nil { + return nil, err + } + snapRaw, err := json.Marshal(plan) + if err != nil { + return nil, err + } + now := time.Now() + row := tx.QueryRow(ctx, ` + UPDATE project_workflow_instances SET + config_snapshot = $2, plan_hash = $3, config_revision = config_revision + 1, updated_at = $4 + WHERE id = $1 AND config_revision = $5 + RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, activated_by, activated_at, created_by, created_at, updated_at + `, instanceID, snapRaw, planHash, now, expectedConfigRevision) + inst, err := scanInstance(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInstanceRevisionConflict + } + if err != nil { + return nil, err + } + + for _, st := range plan.Stages { + if _, err := tx.Exec(ctx, ` + UPDATE project_stage_instances + SET enabled = $3, updated_at = $4 + WHERE workflow_instance_id = $1 AND stage_key = $2 + `, inst.ID, st.StageKey, st.Enabled, now); err != nil { + return nil, err + } + for _, dt := range st.DocTasks { + var docTaskID int64 + err = tx.QueryRow(ctx, ` + UPDATE project_doc_task_instances AS dti SET + enabled = $3, + status = CASE + WHEN $3 = false AND dti.status = 'pending' THEN 'skipped_disabled' + WHEN $3 = true AND dti.status = 'skipped_disabled' THEN 'pending' + ELSE dti.status + END, + updated_at = $4 + FROM project_stage_instances si + WHERE dti.stage_instance_id = si.id + AND si.workflow_instance_id = $1 + AND dti.task_key = $2 + RETURNING dti.id + `, inst.ID, dt.TaskKey, dt.Enabled, now).Scan(&docTaskID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDocTaskNotFound + } + if err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, `DELETE FROM document_duty_assignments WHERE doc_task_instance_id = $1`, docTaskID); err != nil { + return nil, err + } + if !dt.Enabled || !st.Enabled { + continue + } + mode, effective, _ := ResolveDocTaskDuty(dt) + if mode == DutyModeNone { + continue + } + for role, uid := range map[string]int64{ + DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID, + } { + if uid <= 0 { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO document_duty_assignments (doc_task_instance_id, duty_role, user_id, assigned_by) + VALUES ($1, $2, $3, $4) + `, docTaskID, role, uid, assignedBy); err != nil { + return nil, err + } + } + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return inst, nil +} + func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { tx, err := s.pool.Begin(ctx) if err != nil { @@ -834,21 +948,21 @@ type memDocTaskRecord struct { } type memStore struct { - mu sync.Mutex - meta map[int64]projectMeta - plan map[int64]*WorkflowInstance - live map[int64]*WorkflowInstance - tokens map[string]activationTokenRec - stages map[int64][]memStageRecord - nextStageID int64 - nextTaskID int64 + mu sync.Mutex + meta map[int64]projectMeta + plan map[int64]*WorkflowInstance + live map[int64]*WorkflowInstance + tokens map[string]activationTokenRec + stages map[int64][]memStageRecord + nextStageID int64 + nextTaskID int64 } type projectMeta struct { - lifecycle string - activeKey string - selectionVer int - planRevision int + lifecycle string + activeKey string + selectionVer int + planRevision int } type activationTokenRec struct { @@ -1276,6 +1390,52 @@ func (m *memStore) UpdateLiveDocTaskDuties(_ context.Context, projectID int64, t return ©Inst, nil } +func (m *memStore) UpdateLivePlan(_ context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, _ int64) (*WorkflowInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + live := m.live[projectID] + if live == nil { + return nil, ErrInstanceNotFound + } + if live.ConfigRevision != expectedConfigRevision { + return nil, ErrInstanceRevisionConflict + } + planHash, err := computePlanHash(plan) + if err != nil { + return nil, err + } + copyInst := *live + copyInst.ConfigSnapshot = plan + copyInst.PlanHash = planHash + copyInst.ConfigRevision++ + copyInst.UpdatedAt = time.Now() + m.live[projectID] = ©Inst + + records := m.stages[copyInst.ID] + for i := range records { + for _, st := range plan.Stages { + if records[i].stageKey == st.StageKey { + records[i].enabled = st.Enabled + } + } + for j := range records[i].tasks { + dt := findDocTaskPlan(plan, records[i].tasks[j].taskKey) + if dt == nil { + continue + } + records[i].tasks[j].enabled = dt.Enabled + if !dt.Enabled && records[i].tasks[j].status == "pending" { + records[i].tasks[j].status = "skipped_disabled" + } + if dt.Enabled && records[i].tasks[j].status == "skipped_disabled" { + records[i].tasks[j].status = "pending" + } + } + } + m.stages[copyInst.ID] = records + return ©Inst, nil +} + func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index 128f9089..f91129a7 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -10,6 +10,10 @@ const ( LifecyclePlanning = "planning" LifecycleActive = "active" + PlanEditPlanning = "planning" + PlanEditLive = "live" + PlanEditFrozen = "frozen" + InstancePlanning = "planning" InstanceActive = "active" @@ -32,10 +36,10 @@ type DocDuty struct { // DocTaskPlan 文档任务裁剪与职责。 type DocTaskPlan struct { - TaskKey string `json:"task_key"` - TaskTitle string `json:"task_title,omitempty"` - Enabled bool `json:"enabled"` - ApprovalConfigurable bool `json:"approval_configurable,omitempty"` + TaskKey string `json:"task_key"` + TaskTitle string `json:"task_title,omitempty"` + Enabled bool `json:"enabled"` + ApprovalConfigurable bool `json:"approval_configurable,omitempty"` // DutyPolicy 逐文档职责模式;空或缺省按 separated 解读(omitempty 保证旧快照 hash 不变)。 DutyPolicy string `json:"duty_policy,omitempty"` Duties DocDuty `json:"duties"` @@ -83,20 +87,22 @@ type GovernancePolicyCapability struct { // ProjectSettings 项目设置聚合视图。 type ProjectSettings struct { - ProjectID int64 `json:"project_id"` - LifecycleStatus string `json:"lifecycle_status"` - ActiveWorkflowKey string `json:"active_workflow_key"` - WorkflowSelectionVersion int `json:"workflow_selection_version"` - WorkflowPlanRevision int `json:"workflow_plan_revision"` - Plan *WorkflowPlan `json:"plan,omitempty"` - PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"` - LiveInstance *WorkflowInstance `json:"live_instance,omitempty"` - CanEditPlan bool `json:"can_edit_plan"` - CanActivate bool `json:"can_activate"` - ActiveStageKey string `json:"active_stage_key,omitempty"` - StageAdvanceReady bool `json:"stage_advance_ready,omitempty"` - PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"` - AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"` + ProjectID int64 `json:"project_id"` + LifecycleStatus string `json:"lifecycle_status"` + ActiveWorkflowKey string `json:"active_workflow_key"` + WorkflowSelectionVersion int `json:"workflow_selection_version"` + WorkflowPlanRevision int `json:"workflow_plan_revision"` + Plan *WorkflowPlan `json:"plan,omitempty"` + PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"` + LiveInstance *WorkflowInstance `json:"live_instance,omitempty"` + CanEditPlan bool `json:"can_edit_plan"` + CanActivate bool `json:"can_activate"` + // PlanEditMode 是 planning | live | frozen。live 允许改职责/审批/启用,禁止切换工作流。 + PlanEditMode string `json:"plan_edit_mode,omitempty"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + StageAdvanceReady bool `json:"stage_advance_ready,omitempty"` + PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"` + AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"` } // PublishedOption 可选已发布工作流。 @@ -113,6 +119,8 @@ type SavePlanInput struct { Plan WorkflowPlan `json:"plan"` ExpectedPlanRevision int `json:"expected_plan_revision"` ExpectedSelectionVersion int `json:"expected_selection_version"` + // LiveEdit 为 true 时走激活后的配置更新;缺省仍走规划保存(激活后拒绝)。 + LiveEdit bool `json:"live_edit,omitempty"` } // ActivationPreview 激活预览与确认令牌。 @@ -135,18 +143,18 @@ type PreviewDocRef struct { // PreviewSummary 激活影响摘要。 type PreviewSummary struct { - WorkflowName string `json:"workflow_name"` - WorkflowVersion int `json:"workflow_version"` - EnabledStages int `json:"enabled_stages"` - EnabledDocTasks int `json:"enabled_doc_tasks"` - GovernedDocTasks int `json:"governed_doc_tasks"` - UnmanagedDocTasks int `json:"unmanaged_doc_tasks"` - AssignedDuties int `json:"assigned_duties"` - AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"` - UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"` - AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"` - FirstStageKey string `json:"first_stage_key,omitempty"` - FirstStageName string `json:"first_stage_name,omitempty"` + WorkflowName string `json:"workflow_name"` + WorkflowVersion int `json:"workflow_version"` + EnabledStages int `json:"enabled_stages"` + EnabledDocTasks int `json:"enabled_doc_tasks"` + GovernedDocTasks int `json:"governed_doc_tasks"` + UnmanagedDocTasks int `json:"unmanaged_doc_tasks"` + AssignedDuties int `json:"assigned_duties"` + AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"` + UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"` + AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"` + FirstStageKey string `json:"first_stage_key,omitempty"` + FirstStageName string `json:"first_stage_name,omitempty"` } // ActivateInput 二次确认激活。 @@ -170,8 +178,8 @@ const ( StageCompleted = "completed" StageSkipped = "skipped" - InstanceCompleted = "completed" - InstanceTerminated = "terminated" + InstanceCompleted = "completed" + InstanceTerminated = "terminated" InstanceAwaitingStageConfirmation = "awaiting_stage_confirmation" InstanceTerminationRequested = "termination_requested" ) @@ -227,25 +235,25 @@ type GateIssue struct { // DocTaskReadiness 文档任务就绪状态。 type DocTaskReadiness struct { - TaskKey string `json:"task_key"` - Required bool `json:"required"` - Enabled bool `json:"enabled"` - ApprovalManaged bool `json:"approval_managed"` - DocState string `json:"doc_state"` - Approved bool `json:"approved"` + TaskKey string `json:"task_key"` + Required bool `json:"required"` + Enabled bool `json:"enabled"` + ApprovalManaged bool `json:"approval_managed"` + DocState string `json:"doc_state"` + Approved bool `json:"approved"` } // StageReadiness 阶段推进就绪评估。 type StageReadiness struct { - InstanceID int64 `json:"instance_id"` - StageKey string `json:"stage_key"` - StageName string `json:"stage_name"` - Ready bool `json:"ready"` - CanAdvance bool `json:"can_advance"` - RequiresRecheckAck bool `json:"requires_recheck_ack"` - BlockingIssues []GateIssue `json:"blocking_issues"` - DocTasks []DocTaskReadiness `json:"doc_tasks"` - ConfigRevision int `json:"config_revision"` + InstanceID int64 `json:"instance_id"` + StageKey string `json:"stage_key"` + StageName string `json:"stage_name"` + Ready bool `json:"ready"` + CanAdvance bool `json:"can_advance"` + RequiresRecheckAck bool `json:"requires_recheck_ack"` + BlockingIssues []GateIssue `json:"blocking_issues"` + DocTasks []DocTaskReadiness `json:"doc_tasks"` + ConfigRevision int `json:"config_revision"` } // AdvanceStageInput 创建者确认推进。 diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index b0f10ea1..cd787383 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -3,6 +3,7 @@ package server import ( "context" "errors" + "log/slog" "net/http" "path/filepath" "strconv" @@ -83,6 +84,11 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht return } + if s.instSvc != nil { + if err := s.instSvc.MaterializeLiveDocs(r.Context(), projectID); err != nil { + slog.Warn("materialize live docs", "project_id", projectID, "err", err) + } + } docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error()) diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index cf7621d7..4628f7b1 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -98,7 +98,12 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) return } - settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req) + var settings *projectinstance.ProjectSettings + if req.LiveEdit { + settings, err = s.instSvc.UpdateLivePlan(r.Context(), projectID, user.ID, isCreator, req) + } else { + settings, err = s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req) + } if err != nil { writeProjectInstanceError(w, err) return @@ -163,6 +168,8 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`) case errors.Is(err, projectinstance.ErrPlanFrozen): writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`) + case errors.Is(err, projectinstance.ErrWorkflowSwitchFrozen): + writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_SWITCH_FROZEN","message":"运行中不能切换工作流,只能调整职责、审批和文档启用"}`) case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive): writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`) case errors.Is(err, projectinstance.ErrPlanChangedReconfirm): diff --git a/web/e2e/workflow-plan-409-frozen-duties.spec.ts b/web/e2e/workflow-plan-409-frozen-duties.spec.ts index 86743826..87eeb1ab 100644 --- a/web/e2e/workflow-plan-409-frozen-duties.spec.ts +++ b/web/e2e/workflow-plan-409-frozen-duties.spec.ts @@ -169,9 +169,11 @@ test.describe.serial('workflow-plan 409 frozen + optional duties', () => { await expect(page.getByText('运行中')).toBeVisible({ timeout: 20_000 }); await shot(page, '03-project-a-activated.png'); await page.getByRole('button', { name: '查看工作流设置' }).click(); - await expect(page.getByText('当前工作流已冻结,只读查看')).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText('纳入人工审批')).toHaveCount(0); + await expect(page.getByText(/运行中仍可调整/)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('纳入人工审批').first()).toBeVisible(); await expect(page.getByText('校验并预览')).toHaveCount(0); + await expect(page.getByRole('button', { name: '确认激活' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '保存配置' })).toBeVisible(); await shot(page, '03b-project-a-frozen.png'); await page.getByRole('button', { name: '关闭' }).click(); @@ -242,7 +244,7 @@ test.describe.serial('workflow-plan 409 frozen + optional duties', () => { await page.getByRole('button', { name: /校验并预览/ }).click(); await expect(page.getByRole('alert')).toBeVisible({ timeout: 20_000 }); await expect(page.getByText('当前工作流已激活')).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText(/规划(工作流选择与整表配置)已冻结/)).toBeVisible(); + await expect(page.getByText(/运行中仍可调整/)).toBeVisible(); await shot(page, '06-dual-tab-frozen.png'); await page2.close(); diff --git a/web/src/App.tsx b/web/src/App.tsx index fa5d6f48..0250e32f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -35,7 +35,7 @@ import { DocumentLineage } from './types/projectWorkflow'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; import { OnDemand } from './components/lazy/OnDemand'; -import { fetchProjectSettings } from './api/projectSettings'; +import { fetchProjectSettings, reassignDocTaskDuties } from './api/projectSettings'; import { LoginView } from './components/auth/LoginView'; import { authClient } from './api/auth'; import { LoginResult, SystemAuthConfig } from './types/auth'; @@ -85,6 +85,7 @@ import { filterProjectSessionsForUser, getProjectMembership, resetWorkbenchStorage, + adoptWorkbenchUser, } from './data/storage'; import { useChatWindow } from './hooks/useChatWindow'; import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync'; @@ -1211,6 +1212,11 @@ export default function App() { setSessionValidated(false); setProjects([]); resetWorkbenchStorage(); + setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES)); + setSessions(loadSessions()); + setMessages([]); + setDocCache({}); + setActiveDocId(''); }); }; @@ -1429,15 +1435,29 @@ export default function App() { URL.revokeObjectURL(url); }; - // 测试演练:动态修改当前用户在某个文档的权限角色 - const handleRoleChangeForDoc = (docId: string, role: DocPermissionRole) => { - setCurrentUser((prev) => ({ - ...prev, - projectPermissions: { - ...(prev.projectPermissions || {}), - [docId]: role, - }, - })); + const handleRoleChangeForDoc = async (docId: string, role: DocPermissionRole) => { + if (role === 'viewer') return; + const item = deliverables.find((doc) => doc.docId === docId); + if (!item?.projectId || !item.taskKey) { + throw new Error('该文档没有服务端任务,无法改派职责'); + } + const uid = currentUser.id; + let author = role === 'author' ? uid : item.authorId; + let reviewer = role === 'reviewer' ? uid : item.reviewerId; + let approver = role === 'approver' ? uid : item.approverId; + if (item.dutyPolicy === 'author_only') { + const owner = author || uid; + author = owner; + reviewer = owner; + approver = owner; + } + await reassignDocTaskDuties(item.projectId, item.taskKey, { + author_user_id: author, + reviewer_user_id: reviewer, + approver_user_id: approver, + duty_policy: item.dutyPolicy, + }); + setWorkflowDocsEpoch((epoch) => epoch + 1); }; // 未登录拦截门禁:展示现代化医疗合规登录与注册页面 @@ -1477,6 +1497,16 @@ export default function App() { return ( { + const switched = adoptWorkbenchUser(newSession.user.id); + if (switched) { + setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES)); + setSessions(loadSessions()); + setMessages([]); + setDocCache({}); + setProjects([]); + setActiveDocId(''); + setActiveSessionId(loadActiveSessionId('')); + } setSession(newSession); setSessionValidated(true); setCapabilities(newSession.capabilities ?? []); diff --git a/web/src/api/documentRevision.ts b/web/src/api/documentRevision.ts index 53aca539..02528918 100644 --- a/web/src/api/documentRevision.ts +++ b/web/src/api/documentRevision.ts @@ -22,14 +22,18 @@ async function api(path: string, init?: RequestInit): Promise { }); const data = await res.json().catch(() => ({})); if (!res.ok) { - const err = new Error((data as { error?: string }).error || res.statusText) as Error & { + const err = new Error((data as { error?: string; message?: string }).message || (data as { error?: string }).error || res.statusText) as Error & { status: number; + httpStatus: number; code?: string; + errorCode?: string; conflict?: unknown; body?: unknown; }; err.status = res.status; - err.code = (data as { code?: string }).code; + err.httpStatus = res.status; + err.code = (data as { code?: string; error_code?: string }).code || (data as { error_code?: string }).error_code; + err.errorCode = err.code; err.conflict = (data as { conflict?: unknown }).conflict; err.body = data; throw err; diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts index cbda60a2..d1de09d7 100644 --- a/web/src/api/projectSettings.ts +++ b/web/src/api/projectSettings.ts @@ -47,7 +47,8 @@ export async function saveWorkflowPlan( projectId: number, plan: WorkflowPlan, expectedPlanRevision: number, - expectedSelectionVersion: number + expectedSelectionVersion: number, + liveEdit = false ): Promise { const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-plan`, { method: 'PUT', @@ -56,6 +57,7 @@ export async function saveWorkflowPlan( plan, expected_plan_revision: expectedPlanRevision, expected_selection_version: expectedSelectionVersion, + live_edit: liveEdit || undefined, }), }); if (!res.ok) { diff --git a/web/src/api/projectSettingsError.ts b/web/src/api/projectSettingsError.ts index 030cfa36..6b73659b 100644 --- a/web/src/api/projectSettingsError.ts +++ b/web/src/api/projectSettingsError.ts @@ -126,7 +126,7 @@ export function recoverFromProjectSettingsError( err: Pick, ): ProjectSettingsErrorRecovery { if (err.code === 'PLAN_FROZEN') return 'refresh_and_freeze'; - if (err.code === 'PLAN_REVISION_CONFLICT') return 'reload_revision'; + if (err.code === 'PLAN_REVISION_CONFLICT' || err.code === 'INSTANCE_REVISION_CONFLICT') return 'reload_revision'; if (err.code === 'ERR_CONCURRENT_SWITCH') return 'retry_after_switch'; // Envelope-only 409 without nested business code: refresh then reconfirm. if (err.code === 'VERSION_CONFLICT' && err.status === 409) return 'retry_after_switch'; diff --git a/web/src/components/layout/WorkbenchView.tsx b/web/src/components/layout/WorkbenchView.tsx index b8d5c9b2..6b25c6bf 100644 --- a/web/src/components/layout/WorkbenchView.tsx +++ b/web/src/components/layout/WorkbenchView.tsx @@ -57,7 +57,7 @@ export interface WorkbenchViewProps { activeDocId: string; canAssignLead: boolean; handleSelectDoc: (id: string) => void; - handleRoleChangeForDoc: (docId: string, role: DocPermissionRole) => void; + handleRoleChangeForDoc: (docId: string, role: DocPermissionRole) => void | Promise; session: LoginResult | null; setSession: Dispatch>; handleLogout: () => void; diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx index 6cd29c77..d2786e92 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -213,7 +213,8 @@ export const ProjectSettingsModal: React.FC = ({ }; const handleSavePlan = async () => { - if (!plan || !settings || !settings.can_edit_plan) return; + const liveEdit = resolveProjectSettingsMode({ settings }) === 'live_edit'; + if (!plan || !settings || (!settings.can_edit_plan && !liveEdit)) return; const issues = clientDutyIssues(plan, members, requireApprovalKeys); if (issues.length > 0) { @@ -234,10 +235,12 @@ export const ProjectSettingsModal: React.FC = ({ projectId, plan, settings.workflow_plan_revision, - settings.workflow_selection_version + settings.workflow_selection_version, + liveEdit ); setSettings(updated); if (updated.plan) setPlan(updated.plan); + if (liveEdit) return; const pv = await fetchActivationPreview(projectId); setPreview(pv); setStep('confirm'); @@ -332,6 +335,7 @@ export const ProjectSettingsModal: React.FC = ({ settings, loadError: Boolean(error && !settings), }); + const liveEdit = mode === 'live_edit'; const showEditor = shouldShowPlanEditor(mode); const showSave = shouldShowSavePreview(mode); const chrome = projectSettingsChrome(mode); @@ -425,7 +429,7 @@ export const ProjectSettingsModal: React.FC = ({
版本:v{plan?.workflow_version ?? settings.workflow_selection_version}
当前阶段:{settings.active_stage_key || '—'}
- 规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。 + 当前工作流已冻结,只读查看。规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
@@ -571,10 +575,28 @@ export const ProjectSettingsModal: React.FC = ({
)} - {showEditor && step === 'select' && settings && ( + {liveEdit && settings && ( +
+
当前工作流已激活
+
+ 运行中仍可调整职责、审批与文档启用,但不能切换工作流。 +
+
+ )} + + {showEditor && chrome.showWorkflowSelector && step === 'select' && settings && (

- 选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。 + 选择要激活的已发布工作流版本。激活后不可切换工作流;职责、审批与文档启用在运行中仍可调整。

{(settings.published_workflows || []).map((wf) => (
+ {(currentUser.department || currentUser.position) && (
= ({ }} > - {currentUser.department || '机器人与微创内窥镜研发中心'} - · - - {currentUser.position || '资深医疗器械方案工程师'} - + {currentUser.department ? {currentUser.department} : null} + {currentUser.department && currentUser.position ? ( + · + ) : null} + {currentUser.position ? ( + {currentUser.position} + ) : null}
+ )} - - {/* Global Position Pill */} -
-
法定资质有效
-
ISO 13485 / GMP 认证
-
{/* Active Project Banner & Stats */} @@ -317,6 +307,11 @@ export const UserProjectRightsModal: FC = ({ 交付物总计: {deliverables.length} 份 + {dutyError ? ( +
+ {dutyError} +
+ ) : null} {/* Quick Stats Badges */}
@@ -473,15 +468,21 @@ export const UserProjectRightsModal: FC = ({
- {onRoleChangeForDoc ? ( + {onRoleChangeForDoc && canAssignLead && item.projectId && item.taskKey ? ( ) : ( 0 && nextId > 0 && prevId !== nextId; +} + +export function readWorkbenchUserId(): number | null { + if (typeof window === 'undefined' || !window.localStorage) return null; + const raw = window.localStorage.getItem(WORKBENCH_USER_KEY); + const id = Number(raw); + return Number.isFinite(id) && id > 0 ? id : null; +} + +export function rememberWorkbenchUserId(userId: number): void { + if (typeof window === 'undefined' || !window.localStorage || userId <= 0) return; + window.localStorage.setItem(WORKBENCH_USER_KEY, String(userId)); +} + +/** 账号切换时清空工作台缓存,并记下当前用户。返回是否发生了清空。 */ +export function adoptWorkbenchUser(nextUserId: number): boolean { + const prev = readWorkbenchUserId(); + const reset = shouldResetWorkbenchForUser(prev, nextUserId); + if (reset) resetWorkbenchStorage(); + rememberWorkbenchUserId(nextUserId); + return reset; +} + /** Default true = collapsed (user preference for right doc rail). */ export function loadRightRailCollapsed(fallback = true): boolean { return safeGetItem(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, fallback); diff --git a/web/src/data/storageUser.test.ts b/web/src/data/storageUser.test.ts new file mode 100644 index 00000000..fad2239d --- /dev/null +++ b/web/src/data/storageUser.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldResetWorkbenchForUser } from './storage'; + +test('same account and first login do not reset workbench storage', () => { + assert.equal(shouldResetWorkbenchForUser(null, 9), false); + assert.equal(shouldResetWorkbenchForUser(0, 9), false); + assert.equal(shouldResetWorkbenchForUser(9, 9), false); +}); + +test('switching accounts resets workbench storage', () => { + assert.equal(shouldResetWorkbenchForUser(9, 398), true); + assert.equal(shouldResetWorkbenchForUser(398, 9), true); +}); diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 7c396860..024a9edf 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -9,6 +9,7 @@ import { resolveRequirementsTaskKey, resolveTaskKeyFromMessage, resolveTaskTemplateForMessage, + normalizeTaskKeyToProject, } from './templateMatch'; import { DeliverableItem } from '../types/workbench'; @@ -223,3 +224,18 @@ test('current-doc pin still wins when no other deliverable is named', () => { assert.equal(resolved?.taskKey, 'intake'); assert.equal(resolved?.pinnedToActiveDoc, true); }); + +test('v3 bd_ task key wins over unprefixed intake alias', () => { + const bdIntake: DeliverableItem = { + ...base, + docId: 'doc_proj6579_bd_intake', + projectId: 6579, + taskKey: 'bd_intake', + title: '用户初始需求收集表', + isTemplateOnly: true, + }; + assert.equal(normalizeTaskKeyToProject('intake', [bdIntake]), 'bd_intake'); + assert.equal(normalizeTaskKeyToProject('intake', [bdIntake, { ...bdIntake, taskKey: 'intake', docId: 'doc-p6579-intake' }]), 'intake'); + const resolved = resolveTaskTemplateForMessage('开始编写用户初始需求表', [bdIntake]); + assert.equal(resolved?.taskKey, 'bd_intake'); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index c73b9d5c..0a2d0aba 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -122,6 +122,24 @@ function namedAliasTaskKey(text: string, excludeTaskKey?: string): string | unde return undefined; } +/** + * 把语义任务键对齐到当前项目的实际键。 + * 精确命中优先;否则仅当唯一后缀为 `_${semanticKey}` 时改写(intake → bd_intake)。 + */ +export function normalizeTaskKeyToProject(semanticKey: string, deliverables: DeliverableItem[]): string { + const keys = [ + ...new Set( + deliverables + .map((item) => item.taskKey) + .filter((key): key is string => Boolean(key)), + ), + ]; + if (keys.includes(semanticKey)) return semanticKey; + const suffix = `_${semanticKey}`; + const hits = keys.filter((key) => key.endsWith(suffix)); + return hits.length === 1 ? hits[0] : semanticKey; +} + /** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */ export function resolveTaskKeyFromMessage(text: string): string | undefined { const normalized = text.trim(); @@ -153,12 +171,14 @@ export function matchTemplateDeliverable( const pool = templatePool(deliverables); const taskKey = resolveTaskKeyFromMessage(text); if (!taskKey) return undefined; + const aligned = normalizeTaskKeyToProject(taskKey, deliverables); - const hit = findTemplateByTaskKey(pool, taskKey); - if (hit) return hit; + const hit = findTemplateByTaskKey(pool, aligned) || findTemplateByTaskKey(pool, taskKey); + if (hit) return { ...hit, taskKey: aligned }; if (taskKey === 'intake' || taskKey === 'user_requirements') { - return pickRequirementsTask(text, pool); + const picked = pickRequirementsTask(text, pool); + return picked ? { ...picked, taskKey: aligned } : undefined; } return undefined; } @@ -179,8 +199,9 @@ export function resolveTaskTemplateForMessage( const pinnedKey = consistentTaskKeyForControlledDoc(activeDoc!); const named = explicitNamedDeliverableTaskKey(text, deliverables, pinnedKey); if (named) { - const template = matchTemplateDeliverableByKey(named, deliverables, text); - return { taskKey: named, template }; + const taskKey = normalizeTaskKeyToProject(named, deliverables); + const template = matchTemplateDeliverableByKey(taskKey, deliverables, text); + return { taskKey, template }; } if (pinnedKey) { const template = matchTemplateDeliverableByKey(pinnedKey, deliverables, text); @@ -195,12 +216,14 @@ export function resolveTaskTemplateForMessage( const named = explicitNamedDeliverableTaskKey(text, deliverables); if (named) { - const template = matchTemplateDeliverableByKey(named, deliverables, text); - return { taskKey: named, template }; + const taskKey = normalizeTaskKeyToProject(named, deliverables); + const template = matchTemplateDeliverableByKey(taskKey, deliverables, text); + return { taskKey, template }; } - const taskKey = resolveTaskKeyFromMessage(text); - if (!taskKey) return undefined; + const rawKey = resolveTaskKeyFromMessage(text); + if (!rawKey) return undefined; + const taskKey = normalizeTaskKeyToProject(rawKey, deliverables); const template = matchTemplateDeliverable(text, deliverables); return { taskKey, template }; } diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts index 2013dea8..7775f763 100644 --- a/web/src/hooks/useTaskRunAttempts.test.ts +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -56,9 +56,9 @@ test('unknown 4xx business code is not reported as a network error', () => { assert.equal(unknown.userMessage, '字段不合法'); assert.equal(unknown.userMessage.includes('请检查网络'), false); - const bare = buildNetworkErrorInfo(400, { errorCode: 'TASK_SCOPE_UNRESOLVED' }); - assert.equal(bare.code, 'TASK_SCOPE_UNRESOLVED'); - assert.match(bare.userMessage, /工作流阶段作用域/); - assert.equal(bare.userMessage.includes('请检查网络'), false); + const bare = buildNetworkErrorInfo(400, { errorCode: 'TASK_SCOPE_UNRESOLVED' }); + assert.equal(bare.code, 'TASK_SCOPE_UNRESOLVED'); + assert.match(bare.userMessage, /无法定位该文档任务的工作流阶段/); + assert.equal(bare.userMessage.includes('请检查网络'), false); }); diff --git a/web/src/lib/projectSettingsMode.test.ts b/web/src/lib/projectSettingsMode.test.ts index d88e27a5..b9db3ba3 100644 --- a/web/src/lib/projectSettingsMode.test.ts +++ b/web/src/lib/projectSettingsMode.test.ts @@ -24,16 +24,6 @@ describe('projectSettingsMode', () => { }), 'frozen', ); - assert.equal( - resolveProjectSettingsMode({ - settings: { - can_edit_plan: true, - lifecycle_status: 'planning', - live_instance: { id: 89, status: 'active', config_revision: 1 }, - }, - }), - 'frozen', - ); assert.equal(shouldShowPlanEditor('frozen'), false); assert.equal(shouldShowSavePreview('frozen'), false); const chrome = projectSettingsChrome('frozen'); @@ -44,6 +34,39 @@ describe('projectSettingsMode', () => { assert.equal(chrome.showDutyReassign, true); }); + it('keeps an active workflow editable except the definition switch', () => { + assert.equal( + resolveProjectSettingsMode({ + settings: { + can_edit_plan: false, + lifecycle_status: 'active', + plan_edit_mode: 'live', + live_instance: { id: 366, status: 'active', config_revision: 2 }, + }, + }), + 'live_edit', + ); + assert.equal( + resolveProjectSettingsMode({ + settings: { + can_edit_plan: false, + lifecycle_status: 'active', + plan_edit_mode: 'frozen', + live_instance: { id: 89, status: 'active', config_revision: 1 }, + }, + }), + 'frozen', + ); + assert.equal(shouldShowPlanEditor('live_edit'), true); + assert.equal(shouldShowSavePreview('live_edit'), true); + const chrome = projectSettingsChrome('live_edit'); + assert.equal(chrome.showWorkflowSelector, false); + assert.equal(chrome.showDutySelects, true); + assert.equal(chrome.showSavePreview, true); + assert.equal(chrome.showStepPills, false); + assert.equal(chrome.showFrozenSnapshot, false); + }); + it('stays planning only for an editable draft', () => { assert.equal( resolveProjectSettingsMode({ diff --git a/web/src/lib/projectSettingsMode.ts b/web/src/lib/projectSettingsMode.ts index 30f2d38c..82990bba 100644 --- a/web/src/lib/projectSettingsMode.ts +++ b/web/src/lib/projectSettingsMode.ts @@ -1,29 +1,34 @@ import type { ProjectSettings, WorkflowPlan } from '../types/projectSettings'; -export type ProjectSettingsMode = 'loading' | 'planning' | 'frozen' | 'error'; +export type ProjectSettingsMode = 'loading' | 'planning' | 'live_edit' | 'frozen' | 'error'; export function resolveProjectSettingsMode(input: { loading?: boolean; - settings?: Pick | null; + settings?: Pick | null; loadError?: boolean; }): ProjectSettingsMode { if (input.loadError && !input.settings) return 'error'; if (input.loading && !input.settings) return 'loading'; const settings = input.settings; if (!settings) return 'loading'; + if (settings.plan_edit_mode === 'frozen') return 'frozen'; + if (settings.plan_edit_mode === 'live') return 'live_edit'; + if (settings.plan_edit_mode === 'planning') return 'planning'; + const live = settings.live_instance; + const closed = Boolean(live && (live.status === 'completed' || live.status === 'terminated')); + if (closed) return 'frozen'; + if (live) return 'live_edit'; if (!settings.can_edit_plan) return 'frozen'; if (settings.lifecycle_status === 'active') return 'frozen'; - const live = settings.live_instance; - if (live && live.status !== 'completed' && live.status !== 'terminated') return 'frozen'; return 'planning'; } export function shouldShowPlanEditor(mode: ProjectSettingsMode): boolean { - return mode === 'planning'; + return mode === 'planning' || mode === 'live_edit'; } export function shouldShowSavePreview(mode: ProjectSettingsMode): boolean { - return mode === 'planning'; + return mode === 'planning' || mode === 'live_edit'; } export function projectSettingsChrome(mode: ProjectSettingsMode): { @@ -35,14 +40,14 @@ export function projectSettingsChrome(mode: ProjectSettingsMode): { showDutyReassign: boolean; } { const planning = mode === 'planning'; + const live = mode === 'live_edit'; const frozen = mode === 'frozen'; return { showWorkflowSelector: planning, - showDutySelects: planning, - showSavePreview: planning, + showDutySelects: planning || live, + showSavePreview: planning || live, showStepPills: planning, showFrozenSnapshot: frozen, - // 激活后可受控改派(需前端再与 canAssignLead 求交) showDutyReassign: frozen, }; } diff --git a/web/src/lib/taskRunErrorInfo.test.ts b/web/src/lib/taskRunErrorInfo.test.ts new file mode 100644 index 00000000..87a2840b --- /dev/null +++ b/web/src/lib/taskRunErrorInfo.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildNetworkErrorInfo } from './taskRunErrorInfo'; + +test('403 revision denial is not reported as a network error', () => { + const info = buildNetworkErrorInfo(403, { errorCode: 'ERR_FORBIDDEN', detail: '无权访问该文档' }); + assert.equal(info.code, 'ERR_FORBIDDEN'); + assert.notEqual(info.code, 'NETWORK_ERROR'); + 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'); + assert.match(info.userMessage, /无法定位该文档任务的工作流阶段/); + assert.notEqual(info.code, 'NETWORK_ERROR'); +}); diff --git a/web/src/lib/taskRunErrorInfo.ts b/web/src/lib/taskRunErrorInfo.ts index 7524004f..98793bff 100644 --- a/web/src/lib/taskRunErrorInfo.ts +++ b/web/src/lib/taskRunErrorInfo.ts @@ -32,7 +32,7 @@ const fatalDenialCopy: Record = { WORKFLOW_INSTANCE_CLOSED: '工作流实例已关闭,无法执行该文档任务', DOC_DUTY_NOT_MANAGED: '该文档未配置职责,无法执行', SESSION_FORBIDDEN: '无权访问该会话', - TASK_SCOPE_UNRESOLVED: '无法解析文档任务的工作流阶段作用域,当前请求未创建任务', + TASK_SCOPE_UNRESOLVED: '无法定位该文档任务的工作流阶段,请检查任务名或联系管理员', }; export function buildNetworkErrorInfo( diff --git a/web/src/types/projectSettings.ts b/web/src/types/projectSettings.ts index c31e1908..85fbf77f 100644 --- a/web/src/types/projectSettings.ts +++ b/web/src/types/projectSettings.ts @@ -67,6 +67,8 @@ export interface ProjectSettings { plan?: WorkflowPlan; can_edit_plan: boolean; can_activate: boolean; + /** planning=激活前;live=激活后可改配置但不可切换;frozen=关闭或只读项目。 */ + plan_edit_mode?: 'planning' | 'live' | 'frozen'; active_stage_key?: string; stage_advance_ready?: boolean; live_instance?: { id: number; status: string; config_revision: number };