feat(projectinstance): classify optional duties without lowering required gates
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user