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:
@@ -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
|
||||
|
||||
@@ -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 层返回可操作的错误信息。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 供测试注入项目元数据。
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user