merge: fix/dev-multiuser-four-issues-2026-09-21

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 05:34:30 +00:00
co-authored by Cursor
36 changed files with 1262 additions and 198 deletions
+2 -1
View File
@@ -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`
@@ -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`
@@ -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 未改写。
@@ -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)
@@ -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
+1
View File
@@ -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)
+27
View File
@@ -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
+157
View File
@@ -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 ""
}
+67
View File
@@ -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)
}
}
+27 -26
View File
@@ -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 文档小队任务运行中,禁止改派。
+132
View File
@@ -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{})
}
}
}
+115
View File
@@ -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)
}
}
+43
View File
@@ -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)
}
}
+22
View File
@@ -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,
+172 -12
View File
@@ -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 &copyInst, 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] = &copyInst
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 &copyInst, nil
}
func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
m.mu.Lock()
defer m.mu.Unlock()
+55 -47
View File
@@ -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 创建者确认推进。
+6
View File
@@ -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())
+8 -1
View File
@@ -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):
@@ -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();
+40 -10
View File
@@ -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 (
<LoginView
onLoginSuccess={(newSession) => {
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 ?? []);
+6 -2
View File
@@ -22,14 +22,18 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
});
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;
+3 -1
View File
@@ -47,7 +47,8 @@ export async function saveWorkflowPlan(
projectId: number,
plan: WorkflowPlan,
expectedPlanRevision: number,
expectedSelectionVersion: number
expectedSelectionVersion: number,
liveEdit = false
): Promise<ProjectSettings> {
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) {
+1 -1
View File
@@ -126,7 +126,7 @@ export function recoverFromProjectSettingsError(
err: Pick<ProjectSettingsApiError, 'code' | 'status'>,
): 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';
+1 -1
View File
@@ -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<void>;
session: LoginResult | null;
setSession: Dispatch<SetStateAction<LoginResult | null>>;
handleLogout: () => void;
@@ -213,7 +213,8 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
};
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<ProjectSettingsModalProps> = ({
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<ProjectSettingsModalProps> = ({
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<ProjectSettingsModalProps> = ({
<div>版本:v{plan?.workflow_version ?? settings.workflow_selection_version}</div>
<div>当前阶段:{settings.active_stage_key || '—'}</div>
<div style={{ marginTop: '8px', color: 'var(--text-muted)', fontSize: '12px' }}>
规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
当前工作流已冻结,只读查看。规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
</div>
</div>
<div>
@@ -571,10 +575,28 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
</div>
)}
{showEditor && step === 'select' && settings && (
{liveEdit && settings && (
<div
style={{
padding: '12px',
border: '1px solid var(--border)',
borderRadius: '8px',
background: 'var(--bg-subtle)',
fontSize: '13px',
marginBottom: '16px',
}}
>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>当前工作流已激活</div>
<div style={{ color: 'var(--text-muted)', fontSize: '12px' }}>
运行中仍可调整职责、审批与文档启用,但不能切换工作流。
</div>
</div>
)}
{showEditor && chrome.showWorkflowSelector && step === 'select' && settings && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', margin: 0 }}>
选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。
选择要激活的已发布工作流版本。激活后不可切换工作流;职责、审批与文档启用在运行中仍可调整。
</p>
{(settings.published_workflows || []).map((wf) => (
<label
@@ -881,7 +903,7 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
>
<button
onClick={() => {
if (showEditor && step === 'configure') setStep('select');
if (showEditor && !liveEdit && step === 'configure') setStep('select');
else if (showEditor && step === 'confirm') setStep('configure');
else onClose();
}}
@@ -898,10 +920,10 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
}}
>
<ChevronLeft size={14} />
{!showEditor || step === 'select' ? '关闭' : '上一步'}
{!showEditor || step === 'select' || liveEdit ? '关闭' : '上一步'}
</button>
{showSave && step === 'select' && (
{showSave && chrome.showWorkflowSelector && step === 'select' && (
<button
onClick={handleSelectWorkflow}
disabled={!selectedKey || loading}
@@ -941,7 +963,7 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
}}
>
{loading ? <Loader2 size={14} /> : null}
校验并预览 <ChevronRight size={14} />
{liveEdit ? '保存配置' : '校验并预览'} {!liveEdit && <ChevronRight size={14} />}
</button>
)}
@@ -1,4 +1,4 @@
import { FC, useEffect } from 'react';
import { FC, useEffect, useState } from 'react';
import {
X,
ShieldCheck,
@@ -25,7 +25,7 @@ interface UserProjectRightsModalProps {
activeDocId: string;
canAssignLead?: boolean;
onSelectDoc: (docId: string) => void;
onRoleChangeForDoc?: (docId: string, role: DocPermissionRole) => void;
onRoleChangeForDoc?: (docId: string, role: DocPermissionRole) => void | Promise<void>;
}
export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
@@ -39,6 +39,7 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
onSelectDoc,
onRoleChangeForDoc,
}) => {
const [dutyError, setDutyError] = useState('');
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
@@ -248,19 +249,22 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
<span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text-strong)' }}>
{currentUser.name}
</span>
<span
style={{
fontSize: '11px',
color: 'var(--text-muted)',
backgroundColor: 'rgba(148, 163, 184, 0.1)',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'var(--font-mono)',
}}
>
工号: {currentUser.employeeId || 'SMDF-0821'}
</span>
{currentUser.employeeId ? (
<span
style={{
fontSize: '11px',
color: 'var(--text-muted)',
backgroundColor: 'rgba(148, 163, 184, 0.1)',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'var(--font-mono)',
}}
>
工号: {currentUser.employeeId}
</span>
) : null}
</div>
{(currentUser.department || currentUser.position) && (
<div
style={{
fontSize: '13px',
@@ -272,31 +276,17 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
}}
>
<Briefcase size={14} color="var(--text-muted)" />
<span>{currentUser.department || '机器人与微创内窥镜研发中心'}</span>
<span style={{ color: 'var(--border)' }}>·</span>
<span style={{ fontWeight: 500, color: 'var(--text-strong)' }}>
{currentUser.position || '资深医疗器械方案工程师'}
</span>
{currentUser.department ? <span>{currentUser.department}</span> : null}
{currentUser.department && currentUser.position ? (
<span style={{ color: 'var(--border)' }}>·</span>
) : null}
{currentUser.position ? (
<span style={{ fontWeight: 500, color: 'var(--text-strong)' }}>{currentUser.position}</span>
) : null}
</div>
)}
</div>
</div>
{/* Global Position Pill */}
<div
style={{
fontSize: '11px',
color: 'var(--success)',
backgroundColor: 'var(--success-bg)',
border: '1px solid rgba(53, 199, 126, 0.25)',
padding: '6px 12px',
borderRadius: '6px',
textAlign: 'right',
lineHeight: 1.4,
}}
>
<div>法定资质有效</div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>ISO 13485 / GMP 认证</div>
</div>
</div>
{/* Active Project Banner & Stats */}
@@ -317,6 +307,11 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
交付物总计: {deliverables.length} 份
</span>
</div>
{dutyError ? (
<div role="alert" style={{ fontSize: '12px', color: 'var(--danger, #b91c1c)', marginBottom: '8px' }}>
{dutyError}
</div>
) : null}
{/* Quick Stats Badges */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '10px' }}>
@@ -473,15 +468,21 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
</div>
</td>
<td style={{ padding: '10px 12px' }}>
{onRoleChangeForDoc ? (
{onRoleChangeForDoc && canAssignLead && item.projectId && item.taskKey ? (
<select
data-testid={DOC_DUTY_SELECT_TESTID}
data-doc-id={item.docId}
data-doc-role={role}
data-can-assign={canAssignLead ? 'true' : 'false'}
data-can-assign="true"
value={role}
disabled={!canAssignLead}
onChange={(e) => onRoleChangeForDoc(item.docId, e.target.value as DocPermissionRole)}
onChange={(e) => {
const next = e.target.value as DocPermissionRole;
if (next === 'viewer') return;
setDutyError('');
void Promise.resolve(onRoleChangeForDoc(item.docId, next)).catch((err: unknown) => {
setDutyError(err instanceof Error ? err.message : '改派文档职责失败');
});
}}
style={{
padding: '4px 8px',
borderRadius: '4px',
@@ -490,19 +491,14 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
color: badge.color,
fontWeight: 500,
fontSize: '11px',
cursor: canAssignLead ? 'pointer' : 'not-allowed',
opacity: canAssignLead ? 1 : 0.65,
cursor: 'pointer',
}}
title={
canAssignLead
? '演练测试:支持切换该文档下的负责角色'
: '当前角色无权配置文档法定职责'
}
title="写入工作流职责:把当前账号设为该文档的编制、审核或批准人"
>
<option value="author">✍️ 编制负责人</option>
<option value="reviewer">🔍 审核负责人</option>
<option value="approver">👑 批准负责人</option>
<option value="viewer">👁️ 查阅者 (只读)</option>
<option value="author">设为编制负责人</option>
<option value="reviewer">设为审核负责人</option>
<option value="approver">设为批准负责人</option>
{role === 'viewer' ? <option value="viewer">查阅者(不改派)</option> : null}
</select>
) : (
<span
+28
View File
@@ -364,6 +364,34 @@ export function resetWorkbenchStorage(): void {
window.localStorage.removeItem(DEMO_SEED_PURGE_KEY);
}
const WORKBENCH_USER_KEY = 'shuimu.workbench.userId';
/** 两个已登录账号不同才清空。首次登录(没有上一账号)保留本机缓存。 */
export function shouldResetWorkbenchForUser(prevId: number | null, nextId: number): boolean {
return prevId != null && prevId > 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<boolean>(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, fallback);
+14
View File
@@ -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);
});
+16
View File
@@ -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');
});
+32 -9
View File
@@ -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 };
}
+4 -4
View File
@@ -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);
});
+33 -10
View File
@@ -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({
+14 -9
View File
@@ -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<ProjectSettings, 'can_edit_plan' | 'lifecycle_status' | 'live_instance'> | null;
settings?: Pick<ProjectSettings, 'can_edit_plan' | 'lifecycle_status' | 'live_instance' | 'plan_edit_mode'> | 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,
};
}
+17
View File
@@ -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');
});
+1 -1
View File
@@ -32,7 +32,7 @@ const fatalDenialCopy: Record<string, string> = {
WORKFLOW_INSTANCE_CLOSED: '工作流实例已关闭,无法执行该文档任务',
DOC_DUTY_NOT_MANAGED: '该文档未配置职责,无法执行',
SESSION_FORBIDDEN: '无权访问该会话',
TASK_SCOPE_UNRESOLVED: '无法解析文档任务的工作流阶段作用域,当前请求未创建任务',
TASK_SCOPE_UNRESOLVED: '无法定位该文档任务的工作流阶段,请检查任务名或联系管理员',
};
export function buildNetworkErrorInfo(
+2
View File
@@ -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 };