From a2aa3d41bd26de8b9c208290b2ff36d7e2be2286 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 09:39:59 +0000 Subject: [PATCH] feat(projectinstance): support per-doc author_only duty policy Add ResolveDocTaskDuty, require the system policy on save/preview, lock the policy row during activation, and derive A/A/A assignments. Co-authored-by: Cursor --- internal/projectinstance/duty_mode.go | 92 +++++++++++++++++++-- internal/projectinstance/errors.go | 7 +- internal/projectinstance/policy.go | 95 ++++++++++++++++++++++ internal/projectinstance/service.go | 35 ++++++-- internal/projectinstance/store.go | 110 ++++++++++++++++++++------ internal/projectinstance/types.go | 58 +++++++++----- internal/projectinstance/validator.go | 28 ++++--- 7 files changed, 358 insertions(+), 67 deletions(-) create mode 100644 internal/projectinstance/policy.go diff --git a/internal/projectinstance/duty_mode.go b/internal/projectinstance/duty_mode.go index 09bdbe51..6026f351 100644 --- a/internal/projectinstance/duty_mode.go +++ b/internal/projectinstance/duty_mode.go @@ -18,9 +18,13 @@ const ( issueNoGovernedDocument = "NO_GOVERNED_DOCUMENT" issueDutyAssigneeNotActive = "DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER" issueSeparationOfDutyViolation = "SEPARATION_OF_DUTY_VIOLATION" + issueUnknownDutyPolicy = "UNKNOWN_DUTY_POLICY" + issueAuthorOnlyAuthorRequired = "AUTHOR_ONLY_AUTHOR_REQUIRED" + issueAuthorOnlyDutyConflict = "AUTHOR_ONLY_DUTY_CONFLICT" ) // ClassifyDocDuty 供运行时/门禁复用,禁止平行再写一套三职判定。 +// 仅按三职 ID 完整度分类;治理调用应改用 ResolveDocTaskDuty。 func ClassifyDocDuty(d DocDuty) DutyMode { return classifyDocDuty(d) } @@ -46,12 +50,49 @@ func classifyDocDuty(d DocDuty) DutyMode { } } +// ResolveDocTaskDuty 按逐文档 duty_policy 解析完整度、有效三职与是否兼任。 +// author_only 的规范存储可只含 author;有效三职派生为 A/A/A。 +func ResolveDocTaskDuty(dt DocTaskPlan) (mode DutyMode, effective DocDuty, isAuthorOnly bool) { + if !dt.Enabled { + return DutyModeNone, DocDuty{}, false + } + switch dt.DutyPolicy { + case "", DutyPolicySeparated: + return classifyDocDuty(dt.Duties), dt.Duties, false + case DutyPolicyAuthorOnly: + if dt.Duties.AuthorUserID <= 0 { + return DutyModePartial, DocDuty{}, true + } + a := dt.Duties.AuthorUserID + return DutyModeFull, DocDuty{AuthorUserID: a, ReviewerUserID: a, ApproverUserID: a}, true + default: + return DutyModePartial, DocDuty{}, false + } +} + 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 { + if !dt.Enabled { + dt.Duties = DocDuty{} + dt.DutyPolicy = "" + return dt + } + if dt.DutyPolicy == DutyPolicyAuthorOnly { + a := dt.Duties.AuthorUserID + if a > 0 { + if dt.Duties.ReviewerUserID == a { + dt.Duties.ReviewerUserID = 0 + } + if dt.Duties.ApproverUserID == a { + dt.Duties.ApproverUserID = 0 + } + } + return dt + } + if classifyDocDuty(dt.Duties) == DutyModeNone { dt.Duties = DocDuty{} } return dt @@ -70,8 +111,37 @@ func validateDocTaskDuties(dt DocTaskPlan, td workflowdef.DocTaskDef) []workflow if !dt.Enabled { return nil } - mode := classifyDocDuty(dt.Duties) field := dt.TaskKey + ".duties" + switch dt.DutyPolicy { + case "", DutyPolicySeparated: + // fall through to separated rules below + case DutyPolicyAuthorOnly: + if dt.Duties.AuthorUserID <= 0 { + return []workflowdef.ValidationIssue{{ + Code: issueAuthorOnlyAuthorRequired, + Field: field, + Message: fmt.Sprintf("文档「%s」兼任模式必须指定编制人", dutyTaskLabel(dt)), + }} + } + a := dt.Duties.AuthorUserID + if (dt.Duties.ReviewerUserID > 0 && dt.Duties.ReviewerUserID != a) || + (dt.Duties.ApproverUserID > 0 && dt.Duties.ApproverUserID != a) { + return []workflowdef.ValidationIssue{{ + Code: issueAuthorOnlyDutyConflict, + Field: field, + Message: fmt.Sprintf("文档「%s」兼任模式不能指定与编制人不同的审核人或批准人", dutyTaskLabel(dt)), + }} + } + return nil + default: + return []workflowdef.ValidationIssue{{ + Code: issueUnknownDutyPolicy, + Field: dt.TaskKey + ".duty_policy", + Message: fmt.Sprintf("文档「%s」的职责模式无效", dutyTaskLabel(dt)), + }} + } + + mode := classifyDocDuty(dt.Duties) switch mode { case DutyModePartial: return []workflowdef.ValidationIssue{{ @@ -109,7 +179,11 @@ func countFullDutyDocs(plan WorkflowPlan) int { continue } for _, dt := range st.DocTasks { - if dt.Enabled && classifyDocDuty(dt.Duties) == DutyModeFull { + if !dt.Enabled { + continue + } + mode, _, _ := ResolveDocTaskDuty(dt) + if mode == DutyModeFull { n++ } } @@ -127,7 +201,11 @@ func validateActiveMemberDuties(plan WorkflowPlan, active map[int64]struct{}) [] continue } for _, dt := range st.DocTasks { - if !dt.Enabled || classifyDocDuty(dt.Duties) != DutyModeFull { + if !dt.Enabled { + continue + } + mode, effective, _ := ResolveDocTaskDuty(dt) + if mode != DutyModeFull { continue } for _, item := range []struct { @@ -135,9 +213,9 @@ func validateActiveMemberDuties(plan WorkflowPlan, active map[int64]struct{}) [] role string label string }{ - {dt.Duties.AuthorUserID, "author", "编制"}, - {dt.Duties.ReviewerUserID, "reviewer", "审核"}, - {dt.Duties.ApproverUserID, "approver", "批准"}, + {effective.AuthorUserID, "author", "编制"}, + {effective.ReviewerUserID, "reviewer", "审核"}, + {effective.ApproverUserID, "approver", "批准"}, } { if _, ok := active[item.id]; ok { continue diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index 13147ace..5f2b69ac 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -25,8 +25,11 @@ var ( ErrStageNotActive = errors.New("stage is not the active stage") 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") + 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") ) // ValidationError 保留具体校验问题,供 HTTP 层返回可操作的错误信息。 diff --git a/internal/projectinstance/policy.go b/internal/projectinstance/policy.go new file mode 100644 index 00000000..b8d5ee54 --- /dev/null +++ b/internal/projectinstance/policy.go @@ -0,0 +1,95 @@ +package projectinstance + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// AuthorOnlyPolicySnapshot 系统级「允许编制人兼任审核批准」总授权快照。 +// 字段语义对齐 sysadmin.AuthorOnlyApprovalPolicy;本包不导入 sysadmin +// (module-boundaries.yaml 未授权),由组合根提供适配实现。 +type AuthorOnlyPolicySnapshot struct { + Allowed bool + Revision int64 +} + +// AuthorOnlyPolicySource 组合根注入的窄只读/事务锁接口。 +type AuthorOnlyPolicySource interface { + GetAuthorOnlyApprovalPolicy(ctx context.Context) (AuthorOnlyPolicySnapshot, error) + LockAuthorOnlyApprovalPolicy(ctx context.Context, tx pgx.Tx) (AuthorOnlyPolicySnapshot, error) +} + +func (s *Service) SetAuthorOnlyPolicySource(src AuthorOnlyPolicySource) { + if s == nil { + return + } + s.authorOnlyPolicy = src +} + +func planUsesAuthorOnly(plan WorkflowPlan) bool { + for _, st := range plan.Stages { + if !st.Enabled { + continue + } + for _, dt := range st.DocTasks { + if dt.Enabled && dt.DutyPolicy == DutyPolicyAuthorOnly { + return true + } + } + } + return false +} + +func (s *Service) requireAuthorOnlyAllowed(ctx context.Context) (AuthorOnlyPolicySnapshot, error) { + if s == nil || s.authorOnlyPolicy == nil { + return AuthorOnlyPolicySnapshot{}, ErrAuthorOnlyPolicyUnavailable + } + snap, err := s.authorOnlyPolicy.GetAuthorOnlyApprovalPolicy(ctx) + if err != nil { + return AuthorOnlyPolicySnapshot{}, fmt.Errorf("%w: %v", ErrAuthorOnlyPolicyUnavailable, err) + } + if !snap.Allowed { + return snap, ErrAuthorOnlyPolicyDisabled + } + return snap, nil +} + +func (s *Service) loadAuthorOnlyCapability(ctx context.Context) GovernancePolicyCapability { + cap := GovernancePolicyCapability{Allowed: false, Revision: 0} + if s == nil || s.authorOnlyPolicy == nil { + cap.BlockedReason = "unavailable" + return cap + } + snap, err := s.authorOnlyPolicy.GetAuthorOnlyApprovalPolicy(ctx) + if err != nil { + cap.BlockedReason = "unavailable" + return cap + } + cap.Allowed = snap.Allowed + cap.Revision = snap.Revision + if !snap.Allowed { + cap.BlockedReason = "policy_disabled" + } + return cap +} + +type activateAuthorOnlyLockKey struct{} + +func withActivateAuthorOnlyLock(ctx context.Context, fn func(context.Context, pgx.Tx) (AuthorOnlyPolicySnapshot, error)) context.Context { + if fn == nil { + return ctx + } + return context.WithValue(ctx, activateAuthorOnlyLockKey{}, fn) +} + +// runActivateAuthorOnlyLock 执行激活事务内策略行锁;第三个返回值表示是否注册过闭包。 +func runActivateAuthorOnlyLock(ctx context.Context, tx pgx.Tx) (AuthorOnlyPolicySnapshot, error, bool) { + fn, _ := ctx.Value(activateAuthorOnlyLockKey{}).(func(context.Context, pgx.Tx) (AuthorOnlyPolicySnapshot, error)) + if fn == nil { + return AuthorOnlyPolicySnapshot{}, nil, false + } + snap, err := fn(ctx, tx) + return snap, err, true +} diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 779f8179..168bd3bf 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -20,11 +20,12 @@ type VersionResolver interface { // Service 项目工作流实例与激活领域服务。 type Service struct { - store Store - wfStore VersionResolver - projects projectworkflow.Store - docs DocumentReadinessChecker - members ActiveMemberLookup + store Store + wfStore VersionResolver + projects projectworkflow.Store + docs DocumentReadinessChecker + members ActiveMemberLookup + authorOnlyPolicy AuthorOnlyPolicySource } func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service { @@ -117,6 +118,7 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64 } } } + settings.AuthorOnlyPolicy = s.loadAuthorOnlyCapability(ctx) _ = userID return settings, nil } @@ -151,6 +153,11 @@ func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID return nil, err } in.Plan = normalizeWorkflowPlanDuties(in.Plan) + if planUsesAuthorOnly(in.Plan) { + if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil { + return nil, err + } + } validation := validatePlan(in.Plan, version) if !validation.Valid { return nil, newValidationError(validation) @@ -215,12 +222,22 @@ func (s *Service) ActivationPreview(ctx context.Context, projectID int64, userID return nil, err } + var policyRevision *int64 + if planUsesAuthorOnly(planInst.ConfigSnapshot) { + snap, err := s.requireAuthorOnlyAllowed(ctx) + if err != nil { + return nil, err + } + rev := snap.Revision + policyRevision = &rev + } + token, err := randomToken(24) if err != nil { return nil, err } expiresAt := time.Now().Add(15 * time.Minute) - if err := s.store.SaveActivationToken(ctx, token, projectID, planInst.PlanHash, selVer, planInst.ConfigSnapshot, userID, expiresAt); err != nil { + if err := s.store.SaveActivationToken(ctx, token, projectID, planInst.PlanHash, selVer, planInst.ConfigSnapshot, userID, expiresAt, policyRevision); err != nil { return nil, err } @@ -276,6 +293,12 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i ctx = withActivateMemberRecheck(ctx, func(ctx context.Context) error { return s.checkActiveMembers(ctx, projectID, planInst.ConfigSnapshot) }) + if planUsesAuthorOnly(planInst.ConfigSnapshot) { + if s.authorOnlyPolicy == nil { + return nil, ErrAuthorOnlyPolicyUnavailable + } + ctx = withActivateAuthorOnlyLock(ctx, s.authorOnlyPolicy.LockAuthorOnlyApprovalPolicy) + } 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 d5eb30c1..0bfd5863 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -49,7 +49,7 @@ type Store interface { CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) - SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error + SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time, policyRevision *int64) error ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error) @@ -296,11 +296,15 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst * if err != nil { return err } - if !dt.Enabled || classifyDocDuty(dt.Duties) == DutyModeNone { + if !dt.Enabled { + continue + } + mode, effective, _ := ResolveDocTaskDuty(dt) + if mode == DutyModeNone { continue } for role, uid := range map[string]int64{ - DutyAuthor: dt.Duties.AuthorUserID, DutyReviewer: dt.Duties.ReviewerUserID, DutyApprover: dt.Duties.ApproverUserID, + DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID, } { if uid <= 0 { continue @@ -337,13 +341,35 @@ func (s *pgStore) activateInstanceTx(ctx context.Context, db pgxDB, token string } defer tx.Rollback(ctx) + usesAuthorOnly := planUsesAuthorOnly(inst.ConfigSnapshot) + var currentPolicyRev int64 + if usesAuthorOnly { + snap, lockErr, ok := runActivateAuthorOnlyLock(ctx, tx) + if !ok { + return nil, ErrAuthorOnlyPolicyUnavailable + } + if lockErr != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthorOnlyPolicyUnavailable, lockErr) + } + if !snap.Allowed { + return nil, ErrAuthorOnlyPolicyDisabled + } + currentPolicyRev = snap.Revision + } + if err := runActivateMemberRecheck(ctx); err != nil { return nil, err } if token != "" { - if err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash); err != nil { + tokenPolicyRev, err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash) + if err != nil { return nil, err } + if usesAuthorOnly { + if tokenPolicyRev == nil || *tokenPolicyRev != currentPolicyRev { + return nil, ErrAuthorOnlyPolicyChanged + } + } } tag, err := tx.Exec(ctx, ` @@ -407,34 +433,35 @@ type pgxDB interface { Begin(ctx context.Context) (pgx.Tx, error) } -func consumeActivationTokenTx(ctx context.Context, tx pgx.Tx, token string, projectID int64, planHash string) error { +func consumeActivationTokenTx(ctx context.Context, tx pgx.Tx, token string, projectID int64, planHash string) (*int64, error) { tokenHash := hashActivationToken(token) var storedHash string var usedAt *time.Time var expiresAt time.Time + var policyRev *int64 err := tx.QueryRow(ctx, ` - SELECT plan_hash, used_at, expires_at + SELECT plan_hash, used_at, expires_at, author_only_policy_revision FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2 - `, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt) + `, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt, &policyRev) if errors.Is(err, pgx.ErrNoRows) { - return ErrTokenInvalid + return nil, ErrTokenInvalid } if err != nil { - return err + return nil, err } if usedAt != nil || time.Now().After(expiresAt) || storedHash != planHash { - return ErrTokenInvalid + return nil, ErrTokenInvalid } tag, err := tx.Exec(ctx, ` UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL `, tokenHash) if err != nil { - return err + return nil, err } if tag.RowsAffected() == 0 { - return ErrTokenInvalid + return nil, ErrTokenInvalid } - return nil + return policyRev, nil } func (s *pgStore) CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) { @@ -521,13 +548,13 @@ func hashActivationToken(token string) string { return hex.EncodeToString(sum[:]) } -func (s *pgStore) SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error { +func (s *pgStore) SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time, policyRevision *int64) error { raw, _ := json.Marshal(snapshot) tokenHash := hashActivationToken(token) _, err := s.pool.Exec(ctx, ` - INSERT INTO workflow_activation_tokens (token, project_id, plan_hash, expected_project_version, config_snapshot, created_by, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, tokenHash, projectID, planHash, expectedVersion, raw, createdBy, expiresAt) + INSERT INTO workflow_activation_tokens (token, project_id, plan_hash, expected_project_version, config_snapshot, created_by, expires_at, author_only_policy_revision) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, tokenHash, projectID, planHash, expectedVersion, raw, createdBy, expiresAt, policyRevision) return err } @@ -651,6 +678,7 @@ type activationTokenRec struct { snapshot WorkflowPlan expiresAt time.Time used bool + policyRevision *int64 } func newMemStore() *memStore { @@ -821,12 +849,33 @@ 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) { + usesAuthorOnly := planUsesAuthorOnly(inst.ConfigSnapshot) + var currentPolicyRev int64 + if usesAuthorOnly { + snap, lockErr, ok := runActivateAuthorOnlyLock(ctx, nil) + if !ok { + return nil, ErrAuthorOnlyPolicyUnavailable + } + if lockErr != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthorOnlyPolicyUnavailable, lockErr) + } + if !snap.Allowed { + return nil, ErrAuthorOnlyPolicyDisabled + } + currentPolicyRev = snap.Revision + } if err := runActivateMemberRecheck(ctx); err != nil { return nil, err } - if _, err := m.ConsumeActivationToken(ctx, token, projectID, planHash); err != nil { + tokenPolicyRev, err := m.consumeActivationTokenLocked(ctx, token, projectID, planHash) + if err != nil { return nil, err } + if usesAuthorOnly { + if tokenPolicyRev == nil || *tokenPolicyRev != currentPolicyRev { + return nil, ErrAuthorOnlyPolicyChanged + } + } return m.ActivateInstance(ctx, projectID, inst, version, expectedVersion, activatedBy) } @@ -973,28 +1022,43 @@ func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projec return ©Inst, nil } -func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time) error { +func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time, policyRevision *int64) error { m.mu.Lock() defer m.mu.Unlock() + var revCopy *int64 + if policyRevision != nil { + v := *policyRevision + revCopy = &v + } m.tokens[hashActivationToken(token)] = activationTokenRec{ projectID: projectID, planHash: planHash, expectedVer: expectedVersion, - snapshot: snapshot, expiresAt: expiresAt, + snapshot: snapshot, expiresAt: expiresAt, policyRevision: revCopy, } return nil } -func (m *memStore) ConsumeActivationToken(_ context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { +func (m *memStore) ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { + _, plan, err := m.consumeActivationTokenWithRev(ctx, token, projectID, planHash) + return plan, err +} + +func (m *memStore) consumeActivationTokenLocked(ctx context.Context, token string, projectID int64, planHash string) (*int64, error) { + rev, _, err := m.consumeActivationTokenWithRev(ctx, token, projectID, planHash) + return rev, err +} + +func (m *memStore) consumeActivationTokenWithRev(_ context.Context, token string, projectID int64, planHash string) (*int64, *WorkflowPlan, error) { m.mu.Lock() defer m.mu.Unlock() tokenHash := hashActivationToken(token) rec, ok := m.tokens[tokenHash] if !ok || rec.used || rec.projectID != projectID || rec.planHash != planHash || time.Now().After(rec.expiresAt) { - return nil, ErrTokenInvalid + return nil, nil, ErrTokenInvalid } rec.used = true m.tokens[tokenHash] = rec plan := rec.snapshot - return &plan, nil + return rec.policyRevision, &plan, nil } // SeedProjectMeta 供测试注入项目元数据。 diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index cd649c2a..ae84dab8 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -16,6 +16,11 @@ const ( DutyAuthor = "author" DutyReviewer = "reviewer" DutyApprover = "approver" + + // DutyPolicySeparated 标准三职分离(缺省/空值等价)。 + DutyPolicySeparated = "separated" + // DutyPolicyAuthorOnly 编制人兼任审核与批准。 + DutyPolicyAuthorOnly = "author_only" ) // DocDuty 文档三权职责指派。 @@ -27,11 +32,13 @@ 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"` - Duties DocDuty `json:"duties"` + 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"` } // StagePlan 阶段裁剪与文档任务。 @@ -67,21 +74,29 @@ type WorkflowInstance struct { UpdatedAt time.Time `json:"updated_at"` } +// GovernancePolicyCapability 系统级编制人兼任总授权对项目设置的精简投影。 +type GovernancePolicyCapability struct { + Allowed bool `json:"allowed"` + Revision int64 `json:"revision"` + BlockedReason string `json:"blocked_reason,omitempty"` +} + // 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"` + 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"` } // PublishedOption 可选已发布工作流。 @@ -113,8 +128,9 @@ type ActivationPreview struct { // PreviewDocRef 预览中列出的例外文档。 type PreviewDocRef struct { - TaskKey string `json:"task_key"` - Title string `json:"title"` + TaskKey string `json:"task_key"` + Title string `json:"title"` + AuthorUserID int64 `json:"author_user_id,omitempty"` } // PreviewSummary 激活影响摘要。 @@ -126,7 +142,9 @@ type PreviewSummary struct { 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"` } diff --git a/internal/projectinstance/validator.go b/internal/projectinstance/validator.go index fb33db0e..1924b27f 100644 --- a/internal/projectinstance/validator.go +++ b/internal/projectinstance/validator.go @@ -239,17 +239,27 @@ func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSumm continue } sum.EnabledDocTasks++ - switch classifyDocDuty(dt.Duties) { + mode, effective, isAuthorOnly := ResolveDocTaskDuty(dt) + switch mode { case DutyModeFull: sum.GovernedDocTasks++ - if dt.Duties.AuthorUserID > 0 { - sum.AssignedDuties++ - } - if dt.Duties.ReviewerUserID > 0 { - sum.AssignedDuties++ - } - if dt.Duties.ApproverUserID > 0 { - sum.AssignedDuties++ + if isAuthorOnly { + sum.AuthorOnlyDocTasks++ + sum.AuthorOnlyDocuments = append(sum.AuthorOnlyDocuments, PreviewDocRef{ + TaskKey: dt.TaskKey, + Title: dutyTaskLabel(dt), + AuthorUserID: effective.AuthorUserID, + }) + } else { + if effective.AuthorUserID > 0 { + sum.AssignedDuties++ + } + if effective.ReviewerUserID > 0 { + sum.AssignedDuties++ + } + if effective.ApproverUserID > 0 { + sum.AssignedDuties++ + } } case DutyModeNone: sum.UnmanagedDocTasks++