feat(projectinstance): apply duty mode to readiness access and snapshots

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-14 06:58:06 +00:00
co-authored by Cursor
parent 1dd1cc039e
commit 708b735eea
9 changed files with 105 additions and 32 deletions
@@ -15,6 +15,7 @@ const (
AccessCodeDocTaskDisabled = "DOC_TASK_DISABLED" AccessCodeDocTaskDisabled = "DOC_TASK_DISABLED"
AccessCodeNotCurrentStage = "DOC_TASK_NOT_IN_ACTIVE_STAGE" AccessCodeNotCurrentStage = "DOC_TASK_NOT_IN_ACTIVE_STAGE"
AccessCodeForbiddenDuty = "DOC_DUTY_FORBIDDEN" AccessCodeForbiddenDuty = "DOC_DUTY_FORBIDDEN"
AccessCodeDutyNotManaged = "DOC_DUTY_NOT_MANAGED"
AccessCodeInstanceClosed = "WORKFLOW_INSTANCE_CLOSED" AccessCodeInstanceClosed = "WORKFLOW_INSTANCE_CLOSED"
) )
@@ -112,6 +113,26 @@ func (s *Service) CheckDocTaskAccess(
} }
} }
if classifyDocDuty(planTask.Duties) == DutyModeNone {
switch action {
case DocAccessActionRun, DocAccessActionEdit:
if isCreator {
return allow
}
return DocAccessDecision{
Allowed: false,
Code: AccessCodeForbiddenDuty,
Message: "未纳入人工审批的文档仅创建者可编辑或执行",
}
case DocAccessActionReview, DocAccessActionApprove, DocAccessActionSubmit:
return DocAccessDecision{
Allowed: false,
Code: AccessCodeDutyNotManaged,
Message: "该文档未纳入人工审批,不能审核、批准或提交审批",
}
}
}
if isCreator { if isCreator {
return allow return allow
} }
+5
View File
@@ -20,6 +20,11 @@ const (
issueSeparationOfDutyViolation = "SEPARATION_OF_DUTY_VIOLATION" issueSeparationOfDutyViolation = "SEPARATION_OF_DUTY_VIOLATION"
) )
// ClassifyDocDuty 供运行时/门禁复用,禁止平行再写一套三职判定。
func ClassifyDocDuty(d DocDuty) DutyMode {
return classifyDocDuty(d)
}
func classifyDocDuty(d DocDuty) DutyMode { func classifyDocDuty(d DocDuty) DutyMode {
ids := []int64{d.AuthorUserID, d.ReviewerUserID, d.ApproverUserID} ids := []int64{d.AuthorUserID, d.ReviewerUserID, d.ApproverUserID}
assigned := 0 assigned := 0
+8 -6
View File
@@ -25,18 +25,20 @@ func evaluateStageReadiness(
continue continue
} }
td, hasDef := taskDefMap[dt.TaskKey] td, hasDef := taskDefMap[dt.TaskKey]
required := !hasDef || td.BlocksWhenEnabled() approvalManaged := classifyDocDuty(dt.Duties) != DutyModeNone
required := approvalManaged && (!hasDef || td.BlocksWhenEnabled())
state := docStates[dt.TaskKey] state := docStates[dt.TaskKey]
if state == "" { if state == "" {
state = "missing" state = "missing"
} }
approved := state == "approved" approved := state == "approved"
item := DocTaskReadiness{ item := DocTaskReadiness{
TaskKey: dt.TaskKey, TaskKey: dt.TaskKey,
Required: required, Required: required,
Enabled: true, Enabled: true,
DocState: state, ApprovalManaged: approvalManaged,
Approved: approved, DocState: state,
Approved: approved,
} }
readiness = append(readiness, item) readiness = append(readiness, item)
if required && !approved { if required && !approved {
+16 -3
View File
@@ -59,12 +59,13 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
} }
status := stageStatus[stPlan.StageKey] status := stageStatus[stPlan.StageKey]
hasDuty := stageDutyForUser(stPlan, userID) hasDuty := stageDutyForUser(stPlan, userID)
hasUnmanaged := stageHasUnmanagedDoc(stPlan)
allow := false allow := false
switch status { switch status {
case StageActive: case StageActive:
allow = isCreator || hasDuty allow = isCreator || hasDuty || hasUnmanaged
case StageCompleted: case StageCompleted:
allow = hasDuty || isCreator allow = hasDuty || isCreator || hasUnmanaged
default: default:
allow = false allow = false
} }
@@ -76,7 +77,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
if !dt.Enabled { if !dt.Enabled {
continue continue
} }
if isCreator || docTaskDutyForUser(dt, userID) { if isCreator || docTaskDutyForUser(dt, userID) || classifyDocDuty(dt.Duties) == DutyModeNone {
out.AllowedTaskKeys[dt.TaskKey] = true out.AllowedTaskKeys[dt.TaskKey] = true
} }
} }
@@ -94,11 +95,23 @@ func stageDutyForUser(st StagePlan, userID int64) bool {
} }
func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool { func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool {
if classifyDocDuty(dt.Duties) != DutyModeFull {
return false
}
return dt.Duties.AuthorUserID == userID || return dt.Duties.AuthorUserID == userID ||
dt.Duties.ReviewerUserID == userID || dt.Duties.ReviewerUserID == userID ||
dt.Duties.ApproverUserID == userID dt.Duties.ApproverUserID == userID
} }
func stageHasUnmanagedDoc(st StagePlan) bool {
for _, dt := range st.DocTasks {
if dt.Enabled && classifyDocDuty(dt.Duties) == DutyModeNone {
return true
}
}
return false
}
// TaskKeyAllowed 判断 task_key 是否在允许范围内。 // TaskKeyAllowed 判断 task_key 是否在允许范围内。
func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { func (a *StageAccess) TaskKeyAllowed(taskKey string) bool {
if a == nil || !a.Restricted { if a == nil || !a.Restricted {
+3 -3
View File
@@ -296,7 +296,7 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst *
if err != nil { if err != nil {
return err return err
} }
if !dt.Enabled { if !dt.Enabled || classifyDocDuty(dt.Duties) == DutyModeNone {
continue continue
} }
for role, uid := range map[string]int64{ for role, uid := range map[string]int64{
@@ -960,9 +960,9 @@ func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projec
return nil, ErrInstanceNotFound return nil, ErrInstanceNotFound
} }
now := time.Now() now := time.Now()
inst.Status = "terminated" inst.Status = InstanceTerminated
copyInst := *inst copyInst := *inst
delete(m.live, projectID) m.live[projectID] = &copyInst
pm := m.ensureMeta(projectID) pm := m.ensureMeta(projectID)
pm.lifecycle = LifecyclePlanning pm.lifecycle = LifecyclePlanning
pm.activeKey = "" pm.activeKey = ""
+22 -12
View File
@@ -110,15 +110,24 @@ type ActivationPreview struct {
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
} }
// PreviewDocRef 预览中列出的例外文档。
type PreviewDocRef struct {
TaskKey string `json:"task_key"`
Title string `json:"title"`
}
// PreviewSummary 激活影响摘要。 // PreviewSummary 激活影响摘要。
type PreviewSummary struct { type PreviewSummary struct {
WorkflowName string `json:"workflow_name"` WorkflowName string `json:"workflow_name"`
WorkflowVersion int `json:"workflow_version"` WorkflowVersion int `json:"workflow_version"`
EnabledStages int `json:"enabled_stages"` EnabledStages int `json:"enabled_stages"`
EnabledDocTasks int `json:"enabled_doc_tasks"` EnabledDocTasks int `json:"enabled_doc_tasks"`
AssignedDuties int `json:"assigned_duties"` GovernedDocTasks int `json:"governed_doc_tasks"`
FirstStageKey string `json:"first_stage_key,omitempty"` UnmanagedDocTasks int `json:"unmanaged_doc_tasks"`
FirstStageName string `json:"first_stage_name,omitempty"` AssignedDuties int `json:"assigned_duties"`
UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"`
FirstStageKey string `json:"first_stage_key,omitempty"`
FirstStageName string `json:"first_stage_name,omitempty"`
} }
// ActivateInput 二次确认激活。 // ActivateInput 二次确认激活。
@@ -199,11 +208,12 @@ type GateIssue struct {
// DocTaskReadiness 文档任务就绪状态。 // DocTaskReadiness 文档任务就绪状态。
type DocTaskReadiness struct { type DocTaskReadiness struct {
TaskKey string `json:"task_key"` TaskKey string `json:"task_key"`
Required bool `json:"required"` Required bool `json:"required"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
DocState string `json:"doc_state"` ApprovalManaged bool `json:"approval_managed"`
Approved bool `json:"approved"` DocState string `json:"doc_state"`
Approved bool `json:"approved"`
} }
// StageReadiness 阶段推进就绪评估。 // StageReadiness 阶段推进就绪评估。
+18 -8
View File
@@ -237,14 +237,24 @@ func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSumm
continue continue
} }
sum.EnabledDocTasks++ sum.EnabledDocTasks++
if dt.Duties.AuthorUserID > 0 { switch classifyDocDuty(dt.Duties) {
sum.AssignedDuties++ case DutyModeFull:
} sum.GovernedDocTasks++
if dt.Duties.ReviewerUserID > 0 { if dt.Duties.AuthorUserID > 0 {
sum.AssignedDuties++ sum.AssignedDuties++
} }
if dt.Duties.ApproverUserID > 0 { if dt.Duties.ReviewerUserID > 0 {
sum.AssignedDuties++ sum.AssignedDuties++
}
if dt.Duties.ApproverUserID > 0 {
sum.AssignedDuties++
}
case DutyModeNone:
sum.UnmanagedDocTasks++
sum.UnmanagedDocuments = append(sum.UnmanagedDocuments, PreviewDocRef{
TaskKey: dt.TaskKey,
Title: dutyTaskLabel(dt),
})
} }
} }
} }
@@ -538,7 +538,15 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
</div> </div>
<div>工作流:{preview.summary.workflow_name} v{preview.summary.workflow_version}</div> <div>工作流:{preview.summary.workflow_name} v{preview.summary.workflow_version}</div>
<div>启用阶段:{preview.summary.enabled_stages} · 文档任务:{preview.summary.enabled_doc_tasks}</div> <div>启用阶段:{preview.summary.enabled_stages} · 文档任务:{preview.summary.enabled_doc_tasks}</div>
<div>
纳入人工审批:{preview.summary.governed_doc_tasks ?? preview.summary.enabled_doc_tasks} · 不纳入:{preview.summary.unmanaged_doc_tasks ?? 0}
</div>
<div>职责指派:{preview.summary.assigned_duties} 项</div> <div>职责指派:{preview.summary.assigned_duties} 项</div>
{(preview.summary.unmanaged_documents || []).length > 0 && (
<div>
例外文档:{(preview.summary.unmanaged_documents || []).map((doc) => doc.title || doc.task_key).join('、')}
</div>
)}
{preview.summary.first_stage_name && ( {preview.summary.first_stage_name && (
<div>首阶段:{preview.summary.first_stage_name}</div> <div>首阶段:{preview.summary.first_stage_name}</div>
)} )}
+4
View File
@@ -81,7 +81,10 @@ export interface ActivationPreview {
workflow_version: number; workflow_version: number;
enabled_stages: number; enabled_stages: number;
enabled_doc_tasks: number; enabled_doc_tasks: number;
governed_doc_tasks?: number;
unmanaged_doc_tasks?: number;
assigned_duties: number; assigned_duties: number;
unmanaged_documents?: Array<{ task_key: string; title: string }>;
first_stage_key?: string; first_stage_key?: string;
first_stage_name?: string; first_stage_name?: string;
}; };
@@ -124,6 +127,7 @@ export interface StageReadiness {
task_key: string; task_key: string;
required: boolean; required: boolean;
enabled: boolean; enabled: boolean;
approval_managed?: boolean;
doc_state: string; doc_state: string;
approved: boolean; approved: boolean;
}>; }>;