feat(projectworkflow): controlled post-activation document duty reassign

Add PATCH /doc-tasks/{taskKey}/duties that updates live snapshot,
assignments, and document_records while blocking when a squad run holds
the document lease. Planning PUT remains PLAN_FROZEN.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 09:48:27 +00:00
co-authored by Cursor
parent a82bfdb6d1
commit 1497473186
11 changed files with 668 additions and 0 deletions
+6
View File
@@ -161,11 +161,17 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
if pool != nil {
instSvc.SetActiveMemberLookup(projectinstance.NewPGMemberLookup(pool))
}
if parts.Gov != nil {
instSvc.SetDocumentDutyProjector(&documentDutyProjectorAdapter{gov: parts.Gov})
}
}
taskMgr := taskrun.NewManager(pwSvc, parts.Ctx)
if pool != nil {
taskMgr.SetStore(taskrun.NewPGStore(pool))
}
if instSvc != nil && parts.Gov != nil {
instSvc.SetDocTaskBusyChecker(&docTaskBusyCheckerAdapter{gov: parts.Gov, taskRuns: taskMgr})
}
wsCap := workspace.NewCapability(cfg.DataDir, workspace.NewLeaseTable())
cmds := &taskrun.Commands{
Ctx: parts.Ctx,
+61
View File
@@ -0,0 +1,61 @@
package app
import (
"context"
"fmt"
"time"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/taskrun"
)
type documentDutyProjectorAdapter struct {
gov *docgov.GovernanceEngine
}
func (a *documentDutyProjectorAdapter) ProjectDocTaskDuties(
ctx context.Context,
projectID int64,
taskKey string,
duties projectinstance.DocDuty,
dutyPolicy string,
names projectinstance.DutyDisplayNames,
) error {
if a == nil || a.gov == nil {
return nil
}
return a.gov.ProjectDocTaskDuties(
ctx, projectID, taskKey,
duties.AuthorUserID, duties.ReviewerUserID, duties.ApproverUserID,
dutyPolicy, names.AuthorName, names.ReviewerName, names.ApproverName,
)
}
type docTaskBusyCheckerAdapter struct {
gov *docgov.GovernanceEngine
taskRuns *taskrun.Manager
}
func (a *docTaskBusyCheckerAdapter) DocTaskSquadBusy(ctx context.Context, projectID int64, taskKey string) (bool, string, error) {
if a == nil || a.gov == nil || projectID <= 0 || taskKey == "" {
return false, "", nil
}
doc, err := a.gov.FindByProjectTaskKey(ctx, projectID, taskKey)
if err != nil {
if err == docgov.ErrDocumentNotFound {
return false, "", nil
}
return false, "", err
}
if doc == nil || !doc.HasActiveLease(time.Now()) {
return false, "", nil
}
if doc.LeaseHolderType == docgov.LeaseHolderSquad && a.taskRuns != nil {
if run, runErr := a.taskRuns.GetRun(doc.LeaseHolderID); runErr == nil && run.Status != "running" {
return false, "", nil
}
}
reason := fmt.Sprintf("该文档小队任务进行中(%s/%s),完成后可更换负责人", doc.LeaseHolderType, doc.LeaseHolderID)
return true, reason, nil
}
+111
View File
@@ -0,0 +1,111 @@
package docgov
import (
"context"
"fmt"
"strings"
"time"
)
// FindByProjectTaskKey 按项目 + task_key 查找文档(无则 ErrDocumentNotFound)。
func (e *GovernanceEngine) FindByProjectTaskKey(ctx context.Context, projectID int64, taskKey string) (*DocumentRecord, error) {
if e == nil || projectID <= 0 {
return nil, ErrDocumentNotFound
}
taskKey = strings.TrimSpace(taskKey)
if taskKey == "" {
return nil, ErrDocumentNotFound
}
e.mu.Lock()
defer e.mu.Unlock()
if e.pool != nil {
var docID string
err := e.pool.QueryRow(ctx, `
SELECT doc_id FROM document_records
WHERE project_id = $1 AND task_key = $2
ORDER BY updated_at DESC
LIMIT 1
`, projectID, taskKey).Scan(&docID)
if err != nil {
// fall through to mem
} else if docID != "" {
return e.ensureDocumentLocked(ctx, docID)
}
}
for _, doc := range e.memDocs {
if doc != nil && doc.ProjectID == projectID && doc.TaskKey == taskKey {
cp := *doc
return &cp, nil
}
}
return nil, ErrDocumentNotFound
}
// ProjectDocTaskDuties 受控改派:更新 document_records 三职投影(含 duty_locked)。
func (e *GovernanceEngine) ProjectDocTaskDuties(
ctx context.Context,
projectID int64,
taskKey string,
authorID, reviewerID, approverID int64,
dutyPolicy string,
authorName, reviewerName, approverName string,
) error {
if e == nil || projectID <= 0 {
return nil
}
taskKey = strings.TrimSpace(taskKey)
if taskKey == "" {
return nil
}
if authorName == "" {
authorName = fmt.Sprintf("user-%d", authorID)
}
if reviewerName == "" {
reviewerName = fmt.Sprintf("user-%d", reviewerID)
}
if approverName == "" {
approverName = fmt.Sprintf("user-%d", approverID)
}
e.mu.Lock()
defer e.mu.Unlock()
now := time.Now()
if e.pool != nil {
tag, err := e.pool.Exec(ctx, `
UPDATE document_records SET
author_id = NULLIF($3, 0), author_name = $4,
reviewer_id = NULLIF($5, 0), reviewer_name = $6,
approver_id = NULLIF($7, 0), approver_name = $8,
duty_policy = $9,
updated_at = $10
WHERE project_id = $1 AND task_key = $2
`, projectID, taskKey,
authorID, authorName, reviewerID, reviewerName, approverID, approverName,
strings.TrimSpace(dutyPolicy), now)
if err != nil {
return fmt.Errorf("docgov: project doc task duties: %w", err)
}
if tag.RowsAffected() == 0 {
return nil // 文档尚未生成,仅实例侧已更新
}
}
for _, doc := range e.memDocs {
if doc == nil || doc.ProjectID != projectID || doc.TaskKey != taskKey {
continue
}
doc.AuthorID = authorID
doc.AuthorName = authorName
doc.ReviewerID = reviewerID
doc.ReviewerName = reviewerName
doc.ApproverID = approverID
doc.ApproverName = approverName
doc.DutyPolicy = strings.TrimSpace(dutyPolicy)
doc.UpdatedAt = now
}
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package projectinstance
import (
"context"
"fmt"
"chat-agent-system/internal/workflowdef"
)
// ReassignDocTaskDutiesInput 激活后受控改派单文档三职。
type ReassignDocTaskDutiesInput struct {
TaskKey string `json:"task_key"`
Duties DocDuty `json:"duties"`
DutyPolicy string `json:"duty_policy,omitempty"`
}
// DocumentDutyProjector 将实例职责投影到 document_records(含 duty_locked 行)。
type DocumentDutyProjector interface {
ProjectDocTaskDuties(ctx context.Context, projectID int64, taskKey string, duties DocDuty, dutyPolicy string, names DutyDisplayNames) error
}
// DutyDisplayNames 投影用显示名;空则保留原名或写占位。
type DutyDisplayNames struct {
AuthorName string
ReviewerName string
ApproverName string
}
// DocTaskBusyChecker 改派前检查小队任务是否占用文档。
type DocTaskBusyChecker interface {
DocTaskSquadBusy(ctx context.Context, projectID int64, taskKey string) (busy bool, reason string, err error)
}
// ReassignDocTaskDuties 在 live 实例上改派单个文档任务职责。
// 不放开规划冻结:不修改 lifecycle / selection,仅更新 snapshot 与 assignments。
func (s *Service) ReassignDocTaskDuties(
ctx context.Context,
projectID, userID int64,
allowed bool,
in ReassignDocTaskDutiesInput,
) (*ProjectSettings, error) {
if !allowed {
return nil, ErrForbiddenDutyReassign
}
if projectID <= 0 || in.TaskKey == "" {
return nil, ErrInvalidPlan
}
if !s.Available() {
return nil, ErrNotAvailable
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil {
return nil, err
}
if live == nil {
return nil, ErrInstanceNotFound
}
plan := cloneWorkflowPlan(live.ConfigSnapshot)
dt := findDocTaskPlanMutable(&plan, in.TaskKey)
if dt == nil || !dt.Enabled {
return nil, ErrDocTaskNotFound
}
if in.DutyPolicy != "" {
dt.DutyPolicy = in.DutyPolicy
}
dt.Duties = in.Duties
*dt = normalizeDocTaskDuties(*dt)
version, verErr := s.resolvePublishedVersion(ctx, plan)
if verErr != nil {
return nil, verErr
}
td := workflowdef.DocTaskDef{Key: dt.TaskKey, Required: true}
if def := findDocTaskDefInVersion(version.Definition, dt.TaskKey); def != nil {
td = *def
}
issues := validateDocTaskDuties(*dt, td)
if len(issues) > 0 {
return nil, newValidationError(workflowdef.ValidationResult{Valid: false, Issues: issues})
}
plan = normalizeWorkflowPlanDuties(plan)
if planUsesAuthorOnly(plan) {
if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil {
return nil, err
}
}
if err := s.checkActiveMembers(ctx, projectID, plan); err != nil {
return nil, err
}
if s.busyChecker != nil {
busy, reason, busyErr := s.busyChecker.DocTaskSquadBusy(ctx, projectID, in.TaskKey)
if busyErr != nil {
return nil, busyErr
}
if busy {
if reason == "" {
reason = fmt.Sprintf("文档任务 %s 小队执行中,完成后可更换负责人", in.TaskKey)
}
return nil, &DocTaskBusyError{TaskKey: in.TaskKey, Reason: reason}
}
}
updated, err := s.store.UpdateLiveDocTaskDuties(ctx, projectID, in.TaskKey, plan, userID)
if err != nil {
return nil, err
}
updatedDT := findDocTaskPlan(updated.ConfigSnapshot, in.TaskKey)
if updatedDT != nil && s.projector != nil {
mode, effective, _ := ResolveDocTaskDuty(*updatedDT)
if mode != DutyModeNone {
_ = s.projector.ProjectDocTaskDuties(ctx, projectID, in.TaskKey, effective, updatedDT.DutyPolicy, DutyDisplayNames{})
}
}
return s.GetSettings(ctx, projectID, userID, true)
}
func (s *Service) SetDocumentDutyProjector(p DocumentDutyProjector) {
if s == nil {
return
}
s.projector = p
}
func (s *Service) SetDocTaskBusyChecker(c DocTaskBusyChecker) {
if s == nil {
return
}
s.busyChecker = c
}
func findDocTaskPlanMutable(plan *WorkflowPlan, taskKey string) *DocTaskPlan {
if plan == nil {
return nil
}
for i := range plan.Stages {
for j := range plan.Stages[i].DocTasks {
if plan.Stages[i].DocTasks[j].TaskKey == taskKey {
return &plan.Stages[i].DocTasks[j]
}
}
}
return nil
}
func findDocTaskDefInVersion(def workflowdef.VersionDefinition, taskKey string) *workflowdef.DocTaskDef {
for _, st := range def.Stages {
for i := range st.DocTasks {
if st.DocTasks[i].Key == taskKey {
return &st.DocTasks[i]
}
}
}
return nil
}
func cloneWorkflowPlan(in WorkflowPlan) WorkflowPlan {
out := in
out.Stages = make([]StagePlan, len(in.Stages))
for i, st := range in.Stages {
out.Stages[i] = st
out.Stages[i].DocTasks = append([]DocTaskPlan(nil), st.DocTasks...)
}
return out
}
@@ -0,0 +1,110 @@
package projectinstance
import (
"context"
"errors"
"testing"
)
type stubBusyChecker struct {
busy bool
reason string
}
func (s *stubBusyChecker) DocTaskSquadBusy(context.Context, int64, string) (bool, string, error) {
return s.busy, s.reason, nil
}
type stubProjector struct {
calls int
last DocDuty
}
func (s *stubProjector) ProjectDocTaskDuties(_ context.Context, _ int64, _ string, duties DocDuty, _ string, _ DutyDisplayNames) error {
s.calls++
s.last = duties
return nil
}
func TestReassignDocTaskDuties_Success(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 80, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}, 2: {}, 3: {}, 4: {}}})
mustActivateTestPlan(t, svc, 80, 1)
proj := &stubProjector{}
svc.SetDocumentDutyProjector(proj)
svc.SetDocTaskBusyChecker(&stubBusyChecker{busy: false})
settings, err := svc.ReassignDocTaskDuties(ctx, 80, 1, true, ReassignDocTaskDutiesInput{
TaskKey: "intake",
Duties: DocDuty{AuthorUserID: 4, ReviewerUserID: 2, ApproverUserID: 3},
})
if err != nil {
t.Fatalf("reassign: %v", err)
}
if settings == nil || settings.LiveInstance == nil {
t.Fatal("expected live settings")
}
dt := findDocTaskPlan(settings.LiveInstance.ConfigSnapshot, "intake")
if dt == nil || dt.Duties.AuthorUserID != 4 {
t.Fatalf("duties not updated: %+v", dt)
}
if proj.calls != 1 || proj.last.AuthorUserID != 4 {
t.Fatalf("projector not called: %+v", proj)
}
}
func TestReassignDocTaskDuties_Busy(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 81, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}, 2: {}, 3: {}}})
mustActivateTestPlan(t, svc, 81, 1)
svc.SetDocTaskBusyChecker(&stubBusyChecker{busy: true, reason: "squad running"})
_, err := svc.ReassignDocTaskDuties(ctx, 81, 1, true, ReassignDocTaskDutiesInput{
TaskKey: "intake",
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
})
var busy *DocTaskBusyError
if !errors.As(err, &busy) {
t.Fatalf("expected DocTaskBusyError, got %v", err)
}
}
func TestReassignDocTaskDuties_Forbidden(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 82, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
mustActivateTestPlan(t, svc, 82, 1)
_, err := svc.ReassignDocTaskDuties(ctx, 82, 1, false, ReassignDocTaskDutiesInput{
TaskKey: "intake",
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
})
if !errors.Is(err, ErrForbiddenDutyReassign) {
t.Fatalf("expected forbidden, got %v", err)
}
}
func TestReassignDocTaskDuties_PlanStillFrozen(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 83, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
mustActivateTestPlan(t, svc, 83, 1)
_, err := svc.SaveWorkflowPlan(ctx, 83, 1, true, SavePlanInput{
Plan: validPlan(),
ExpectedPlanRevision: 1,
ExpectedSelectionVersion: 2,
})
if !errors.Is(err, ErrPlanFrozen) {
t.Fatalf("expected plan frozen after activate, got %v", err)
}
}
+25
View File
@@ -30,8 +30,33 @@ var (
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")
ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties")
ErrDocTaskNotFound = errors.New("document task not found or disabled")
)
// DocTaskBusyError 文档小队任务运行中,禁止改派。
type DocTaskBusyError struct {
TaskKey string
Reason string
}
func (e *DocTaskBusyError) Error() string {
if e == nil {
return "document task busy"
}
if e.Reason != "" {
return e.Reason
}
return "document task busy: squad run in progress"
}
func (e *DocTaskBusyError) Is(target error) bool {
_, ok := target.(*DocTaskBusyError)
return ok
}
var ErrDocTaskBusy = &DocTaskBusyError{}
// ValidationError 保留具体校验问题,供 HTTP 层返回可操作的错误信息。
type ValidationError struct {
Issues []workflowdef.ValidationIssue
+5
View File
@@ -26,6 +26,8 @@ type Service struct {
docs DocumentReadinessChecker
members ActiveMemberLookup
authorOnlyPolicy AuthorOnlyPolicySource
projector DocumentDutyProjector
busyChecker DocTaskBusyChecker
}
func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service {
@@ -79,6 +81,9 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64
settings.LiveInstance = liveInst
settings.CanEditPlan = false
settings.CanActivate = false
// 激活后权威规划在 live snapshot(planning 行已转为 active)。
snap := liveInst.ConfigSnapshot
settings.Plan = &snap
if detail, detailErr := s.store.GetInstanceByID(ctx, liveInst.ID); detailErr == nil && detail != nil {
settings.ActiveStageKey = detail.ActiveStageKey
if detail.ActiveStageKey != "" && s.docs != nil {
+108
View File
@@ -58,6 +58,8 @@ type Store interface {
GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error)
AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int, operatorID int64) (*InstanceDetail, error)
MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error
// UpdateLiveDocTaskDuties 更新 live 实例 config_snapshot 与 document_duty_assignments(单任务)。
UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error)
}
// NewStore 构建 PG 或内存 Store。
@@ -684,6 +686,89 @@ func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, proj
return &plan, nil
}
func (s *pgStore) UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
live, err := s.GetLiveInstance(ctx, projectID)
if err != nil {
return nil, err
}
if live == nil {
return nil, ErrInstanceNotFound
}
planHash, err := computePlanHash(plan)
if err != nil {
return nil, err
}
snapRaw, err := json.Marshal(plan)
if err != nil {
return nil, err
}
now := time.Now()
row := tx.QueryRow(ctx, `
UPDATE project_workflow_instances SET
config_snapshot = $2, plan_hash = $3, config_revision = config_revision + 1, updated_at = $4
WHERE id = $1 AND status IN ('active', 'awaiting_stage_confirmation')
RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash,
config_snapshot, activated_by, activated_at, created_by, created_at, updated_at
`, live.ID, snapRaw, planHash, now)
inst, err := scanInstance(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInstanceNotFound
}
if err != nil {
return nil, err
}
var docTaskID int64
err = tx.QueryRow(ctx, `
SELECT dti.id FROM project_doc_task_instances dti
JOIN project_stage_instances si ON si.id = dti.stage_instance_id
WHERE si.workflow_instance_id = $1 AND dti.task_key = $2
`, inst.ID, taskKey).Scan(&docTaskID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDocTaskNotFound
}
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `DELETE FROM document_duty_assignments WHERE doc_task_instance_id = $1`, docTaskID); err != nil {
return nil, err
}
dt := findDocTaskPlan(plan, taskKey)
if dt == nil {
return nil, ErrDocTaskNotFound
}
mode, effective, _ := ResolveDocTaskDuty(*dt)
if mode != DutyModeNone {
for role, uid := range map[string]int64{
DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID,
} {
if uid <= 0 {
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO document_duty_assignments (doc_task_instance_id, duty_role, user_id, assigned_by)
VALUES ($1, $2, $3, $4)
`, docTaskID, role, uid, assignedBy); err != nil {
return nil, err
}
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return inst, nil
}
func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -1107,6 +1192,29 @@ func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskK
return nil
}
func (m *memStore) UpdateLiveDocTaskDuties(_ context.Context, projectID int64, taskKey string, plan WorkflowPlan, _ int64) (*WorkflowInstance, error) {
m.mu.Lock()
defer m.mu.Unlock()
live := m.live[projectID]
if live == nil {
return nil, ErrInstanceNotFound
}
if findDocTaskPlan(plan, taskKey) == nil {
return nil, ErrDocTaskNotFound
}
planHash, err := computePlanHash(plan)
if err != nil {
return nil, err
}
copyInst := *live
copyInst.ConfigSnapshot = plan
copyInst.PlanHash = planHash
copyInst.ConfigRevision++
copyInst.UpdatedAt = time.Now()
m.live[projectID] = &copyInst
return &copyInst, nil
}
func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
m.mu.Lock()
defer m.mu.Unlock()
+55
View File
@@ -0,0 +1,55 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"chat-agent-system/internal/projectinstance"
)
func (s *Server) handleReassignDocTaskDuties(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
taskKey := r.PathValue("taskKey")
if taskKey == "" {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid task key"}`)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
canAssign, permErr := s.userHasSystemPermission(r.Context(), user, "project:assign_lead")
if permErr != nil {
if s.failClosedAuthz() {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"无权改派文档职责"}`)
return
}
canAssign = false
}
allowed := isCreator || canAssign
var req projectinstance.ReassignDocTaskDutiesInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
req.TaskKey = taskKey
settings, err := s.instSvc.ReassignDocTaskDuties(r.Context(), projectID, user.ID, allowed, req)
if err != nil {
writeProjectInstanceError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, settings)
}
@@ -204,7 +204,23 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
writeSafeError(w, nil, http.StatusConflict, `{"error":"AUTHOR_ONLY_POLICY_CHANGED","message":"兼任总授权已变更,请重新预览并确认激活"}`)
case errors.Is(err, projectinstance.ErrAuthorOnlyPolicyUnavailable):
writeSafeError(w, nil, http.StatusServiceUnavailable, `{"error":"AUTHOR_ONLY_POLICY_UNAVAILABLE","message":"兼任总授权存储不可用"}`)
case errors.Is(err, projectinstance.ErrForbiddenDutyReassign):
writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"无权改派文档职责"}`)
case errors.Is(err, projectinstance.ErrDocTaskNotFound):
writeSafeError(w, nil, http.StatusNotFound, `{"error":"DOC_TASK_NOT_FOUND","message":"文档任务不存在或已禁用"}`)
default:
var busy *projectinstance.DocTaskBusyError
if errors.As(err, &busy) {
msg := busy.Reason
if msg == "" {
msg = "该文档小队任务进行中,完成后可更换负责人"
}
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error": "DUTY_REASSIGN_BUSY",
"message": msg,
})
return
}
writeSafeError(w, nil, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
}
}
+1
View File
@@ -198,6 +198,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/projects/{id}/settings", s.handleGetProjectSettings)
mux.HandleFunc("GET /api/v1/projects/{id}/workflow-plan/default", s.handleGetDefaultWorkflowPlan)
mux.HandleFunc("PUT /api/v1/projects/{id}/workflow-plan", s.handleSaveWorkflowPlan)
mux.HandleFunc("PATCH /api/v1/projects/{id}/doc-tasks/{taskKey}/duties", s.handleReassignDocTaskDuties)
mux.HandleFunc("POST /api/v1/projects/{id}/activation-preview", s.handleActivationPreview)
mux.HandleFunc("POST /api/v1/projects/{id}/workflow-instances/activate", s.handleActivateWorkflowInstance)
mux.HandleFunc("GET /api/v1/workflow-instances/{id}", s.handleGetWorkflowInstance)