From f168e44ce7581e4cb1512f9773e77fc6663f4ddc Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:20:01 +0000 Subject: [PATCH] feat(projectinstance): allow live workflow config edits except switching Activated projects can update duties, approval policy, and document enablement. Changing the workflow definition stays rejected, and project 143 remains frozen. Co-authored-by: Cursor --- internal/app/app.go | 1 + internal/app/duty_reassign_adapters.go | 27 +++ internal/projectinstance/errors.go | 53 +++--- internal/projectinstance/live_plan.go | 132 +++++++++++++ internal/projectinstance/live_plan_test.go | 115 ++++++++++++ internal/projectinstance/materialize.go | 43 +++++ internal/projectinstance/service.go | 22 +++ internal/projectinstance/store.go | 184 +++++++++++++++++-- internal/projectinstance/types.go | 102 +++++----- internal/server/project_doc_handlers.go | 6 + internal/server/project_settings_handlers.go | 9 +- 11 files changed, 608 insertions(+), 86 deletions(-) create mode 100644 internal/projectinstance/live_plan.go create mode 100644 internal/projectinstance/live_plan_test.go create mode 100644 internal/projectinstance/materialize.go diff --git a/internal/app/app.go b/internal/app/app.go index e7a18a86..c414284e 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/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):