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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 09:39:59 +00:00
co-authored by Cursor
parent ba57c9a4de
commit a2aa3d41bd
7 changed files with 358 additions and 67 deletions
+85 -7
View File
@@ -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
+5 -2
View File
@@ -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 层返回可操作的错误信息。
+95
View File
@@ -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
}
+29 -6
View File
@@ -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
+87 -23
View File
@@ -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 &copyInst, 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 供测试注入项目元数据。
+38 -20
View File
@@ -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"`
}
+19 -9
View File
@@ -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++