From ea97a658c6b84486f761a9f126c3874149864ce8 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 06:53:21 +0000 Subject: [PATCH] feat(projectinstance): classify optional duties without lowering required gates Co-authored-by: Cursor --- internal/projectinstance/duty_mode.go | 156 ++++++++++++++++++++++++++ internal/projectinstance/members.go | 73 ++++++++++++ internal/projectinstance/service.go | 16 ++- internal/projectinstance/store.go | 6 + internal/projectinstance/validator.go | 45 ++------ internal/server/server.go | 3 + 6 files changed, 262 insertions(+), 37 deletions(-) create mode 100644 internal/projectinstance/duty_mode.go create mode 100644 internal/projectinstance/members.go diff --git a/internal/projectinstance/duty_mode.go b/internal/projectinstance/duty_mode.go new file mode 100644 index 00000000..754705b7 --- /dev/null +++ b/internal/projectinstance/duty_mode.go @@ -0,0 +1,156 @@ +package projectinstance + +import ( + "fmt" + + "chat-agent-system/internal/workflowdef" +) + +type DutyMode string + +const ( + DutyModeNone DutyMode = "none" + DutyModePartial DutyMode = "partial" + DutyModeFull DutyMode = "full" + + issueDutyPartialAssignment = "DUTY_PARTIAL_ASSIGNMENT" + issueDutyAssignmentRequired = "DUTY_ASSIGNMENT_REQUIRED" + issueNoGovernedDocument = "NO_GOVERNED_DOCUMENT" + issueDutyAssigneeNotActive = "DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER" + issueSeparationOfDutyViolation = "SEPARATION_OF_DUTY_VIOLATION" +) + +func classifyDocDuty(d DocDuty) DutyMode { + ids := []int64{d.AuthorUserID, d.ReviewerUserID, d.ApproverUserID} + assigned := 0 + for _, id := range ids { + if id < 0 { + return DutyModePartial + } + if id > 0 { + assigned++ + } + } + switch assigned { + case 0: + return DutyModeNone + case 3: + return DutyModeFull + default: + return DutyModePartial + } +} + +func docTaskRequiresDuty(td workflowdef.DocTaskDef) bool { + return td.Required || td.Blocking || !td.IsCreatorConfigurable() +} + +func normalizeDocTaskDuties(dt DocTaskPlan) DocTaskPlan { + if !dt.Enabled || classifyDocDuty(dt.Duties) == DutyModeNone { + dt.Duties = DocDuty{} + } + return dt +} + +func normalizeWorkflowPlanDuties(plan WorkflowPlan) WorkflowPlan { + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j] = normalizeDocTaskDuties(plan.Stages[i].DocTasks[j]) + } + } + return plan +} + +func validateDocTaskDuties(dt DocTaskPlan, td workflowdef.DocTaskDef) []workflowdef.ValidationIssue { + if !dt.Enabled { + return nil + } + mode := classifyDocDuty(dt.Duties) + field := dt.TaskKey + ".duties" + switch mode { + case DutyModePartial: + return []workflowdef.ValidationIssue{{ + Code: issueDutyPartialAssignment, + Field: field, + Message: fmt.Sprintf("文档「%s」不能只指派部分职责", dutyTaskLabel(dt)), + }} + case DutyModeNone: + if docTaskRequiresDuty(td) { + return []workflowdef.ValidationIssue{{ + Code: issueDutyAssignmentRequired, + Field: field, + Message: fmt.Sprintf("文档「%s」必须完整指派编制、审核、批准", dutyTaskLabel(dt)), + }} + } + return nil + default: + if dt.Duties.AuthorUserID == dt.Duties.ReviewerUserID || + dt.Duties.AuthorUserID == dt.Duties.ApproverUserID || + dt.Duties.ReviewerUserID == dt.Duties.ApproverUserID { + return []workflowdef.ValidationIssue{{ + Code: issueSeparationOfDutyViolation, + Field: dt.TaskKey, + Message: "作者、审核人、批准人必须分离", + }} + } + return nil + } +} + +func countFullDutyDocs(plan WorkflowPlan) int { + n := 0 + for _, st := range plan.Stages { + if !st.Enabled { + continue + } + for _, dt := range st.DocTasks { + if dt.Enabled && classifyDocDuty(dt.Duties) == DutyModeFull { + n++ + } + } + } + return n +} + +func validateActiveMemberDuties(plan WorkflowPlan, active map[int64]struct{}) []workflowdef.ValidationIssue { + if active == nil { + return nil + } + issues := make([]workflowdef.ValidationIssue, 0) + for _, st := range plan.Stages { + if !st.Enabled { + continue + } + for _, dt := range st.DocTasks { + if !dt.Enabled || classifyDocDuty(dt.Duties) != DutyModeFull { + continue + } + for _, item := range []struct { + id int64 + role string + label string + }{ + {dt.Duties.AuthorUserID, "author", "编制"}, + {dt.Duties.ReviewerUserID, "reviewer", "审核"}, + {dt.Duties.ApproverUserID, "approver", "批准"}, + } { + if _, ok := active[item.id]; ok { + continue + } + issues = append(issues, workflowdef.ValidationIssue{ + Code: issueDutyAssigneeNotActive, + Field: dt.TaskKey + "." + item.role, + Message: fmt.Sprintf("文档「%s」的%s负责人不是在任项目成员", dutyTaskLabel(dt), item.label), + }) + } + } + } + return issues +} + +func dutyTaskLabel(dt DocTaskPlan) string { + if dt.TaskTitle != "" { + return dt.TaskTitle + } + return dt.TaskKey +} diff --git a/internal/projectinstance/members.go b/internal/projectinstance/members.go new file mode 100644 index 00000000..3624c565 --- /dev/null +++ b/internal/projectinstance/members.go @@ -0,0 +1,73 @@ +package projectinstance + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/docduty" + "chat-agent-system/internal/workflowdef" +) + +// ActiveMemberLookup 只读在任成员集合。validator 不得直连 DB/HTTP。 +type ActiveMemberLookup interface { + ActiveMemberIDs(ctx context.Context, projectID int64) (map[int64]struct{}, error) +} + +type pgMemberLookup struct { + pool *pgxpool.Pool +} + +func NewPGMemberLookup(pool *pgxpool.Pool) ActiveMemberLookup { + return &pgMemberLookup{pool: pool} +} + +func (l *pgMemberLookup) ActiveMemberIDs(ctx context.Context, projectID int64) (map[int64]struct{}, error) { + if l == nil || l.pool == nil { + return map[int64]struct{}{}, nil + } + members, creator, err := docduty.LoadProjectMembers(ctx, l.pool, projectID) + if err != nil { + return nil, err + } + return docduty.ActiveMemberIDs(members, creator), nil +} + +func (s *Service) SetActiveMemberLookup(lookup ActiveMemberLookup) { + if s == nil { + return + } + s.members = lookup +} + +type activateMemberRecheckKey struct{} + +func withActivateMemberRecheck(ctx context.Context, fn func(context.Context) error) context.Context { + if fn == nil { + return ctx + } + return context.WithValue(ctx, activateMemberRecheckKey{}, fn) +} + +func runActivateMemberRecheck(ctx context.Context) error { + fn, _ := ctx.Value(activateMemberRecheckKey{}).(func(context.Context) error) + if fn == nil { + return nil + } + return fn(ctx) +} + +func (s *Service) checkActiveMembers(ctx context.Context, projectID int64, plan WorkflowPlan) error { + if s == nil || s.members == nil { + return nil + } + ids, err := s.members.ActiveMemberIDs(ctx, projectID) + if err != nil { + return err + } + issues := validateActiveMemberDuties(plan, ids) + if len(issues) == 0 { + return nil + } + return newValidationError(workflowdef.ValidationResult{Valid: false, Issues: issues}) +} diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 9d14ad6a..779f8179 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -24,6 +24,7 @@ type Service struct { wfStore VersionResolver projects projectworkflow.Store docs DocumentReadinessChecker + members ActiveMemberLookup } func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service { @@ -149,10 +150,14 @@ func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID if err != nil { return nil, err } + in.Plan = normalizeWorkflowPlanDuties(in.Plan) validation := validatePlan(in.Plan, version) if !validation.Valid { return nil, newValidationError(validation) } + if err := s.checkActiveMembers(ctx, projectID, in.Plan); err != nil { + return nil, err + } in.Plan.WorkflowVersionID = version.ID applyDefinitionLabels(&in.Plan, version) @@ -206,6 +211,9 @@ func (s *Service) ActivationPreview(ctx context.Context, projectID int64, userID if !validation.Valid { return nil, newValidationError(validation) } + if err := s.checkActiveMembers(ctx, projectID, planInst.ConfigSnapshot); err != nil { + return nil, err + } token, err := randomToken(24) if err != nil { @@ -259,9 +267,15 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i } validation := validateActivationPlan(planInst.ConfigSnapshot, version) if !validation.Valid { - return nil, ErrValidationFailed + return nil, newValidationError(validation) + } + if err := s.checkActiveMembers(ctx, projectID, planInst.ConfigSnapshot); err != nil { + return nil, err } + ctx = withActivateMemberRecheck(ctx, func(ctx context.Context) error { + return s.checkActiveMembers(ctx, projectID, planInst.ConfigSnapshot) + }) activated, err := s.store.ActivateInstanceWithToken(ctx, in.ConfirmationToken, projectID, planInst, version, selVer, userID, planInst.PlanHash) if err != nil { return nil, err diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index f2e0efc5..64e89b42 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -337,6 +337,9 @@ func (s *pgStore) activateInstanceTx(ctx context.Context, db pgxDB, token string } defer tx.Rollback(ctx) + if err := runActivateMemberRecheck(ctx); err != nil { + return nil, err + } if token != "" { if err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash); err != nil { return nil, err @@ -818,6 +821,9 @@ func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInsta } func (m *memStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) { + if err := runActivateMemberRecheck(ctx); err != nil { + return nil, err + } if _, err := m.ConsumeActivationToken(ctx, token, projectID, planHash); err != nil { return nil, err } diff --git a/internal/projectinstance/validator.go b/internal/projectinstance/validator.go index 8a02c720..5edfd4eb 100644 --- a/internal/projectinstance/validator.go +++ b/internal/projectinstance/validator.go @@ -91,7 +91,7 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V }) } if dt.Enabled { - issues = append(issues, validateDuties(dt.Duties, dt.TaskKey)...) + issues = append(issues, validateDocTaskDuties(dt, td)...) } } } @@ -122,6 +122,13 @@ func validateActivationPlan(plan WorkflowPlan, version *workflowdef.Version) wor Code: "EMPTY_WORKFLOW", Message: "至少启用一个文档任务才能激活工作流", }) } + if countFullDutyDocs(plan) == 0 { + base.Valid = false + base.Issues = append(base.Issues, workflowdef.ValidationIssue{ + Code: issueNoGovernedDocument, + Message: "至少一份文档必须纳入人工审批才能激活工作流", + }) + } return base } @@ -141,41 +148,7 @@ func countEnabledDocTasks(plan WorkflowPlan) int { } func validateDuties(d DocDuty, taskKey string) []workflowdef.ValidationIssue { - issues := make([]workflowdef.ValidationIssue, 0) - if d.AuthorUserID <= 0 { - issues = append(issues, workflowdef.ValidationIssue{ - Code: "DUTY_AUTHOR_REQUIRED", Field: taskKey + ".author", - Message: "启用的文档任务必须指定作者", - }) - } - if d.ReviewerUserID <= 0 { - issues = append(issues, workflowdef.ValidationIssue{ - Code: "DUTY_REVIEWER_REQUIRED", Field: taskKey + ".reviewer", - Message: "启用的文档任务必须指定审核人", - }) - } - if d.ApproverUserID <= 0 { - issues = append(issues, workflowdef.ValidationIssue{ - Code: "DUTY_APPROVER_REQUIRED", Field: taskKey + ".approver", - Message: "启用的文档任务必须指定批准人", - }) - } - ids := []int64{d.AuthorUserID, d.ReviewerUserID, d.ApproverUserID} - for i := 0; i < len(ids); i++ { - if ids[i] <= 0 { - continue - } - for j := i + 1; j < len(ids); j++ { - if ids[j] > 0 && ids[i] == ids[j] { - issues = append(issues, workflowdef.ValidationIssue{ - Code: "SEPARATION_OF_DUTY_VIOLATION", Field: taskKey, - Message: "作者、审核人、批准人必须分离", - }) - return issues - } - } - } - return issues + return validateDocTaskDuties(DocTaskPlan{TaskKey: taskKey, Enabled: true, Duties: d}, workflowdef.DocTaskDef{Key: taskKey, Required: true}) } func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan { diff --git a/internal/server/server.go b/internal/server/server.go index 116a81dc..e9731e9f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -85,6 +85,9 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { if wfDefSvc.Available() { docChecker := projectinstance.NewDocGovChecker(svc.DocGov()) instSvc = projectinstance.NewService(instStore, wfDefSvc.Store(), pwSvc.Store(), docChecker) + if pool != nil { + instSvc.SetActiveMemberLookup(projectinstance.NewPGMemberLookup(pool)) + } } taskMgr := taskrun.NewManager(pwSvc, svc.ContextManager()) if pool != nil {