diff --git a/internal/app/app.go b/internal/app/app.go index 22d6ee19..9eb16531 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, diff --git a/internal/app/duty_reassign_adapters.go b/internal/app/duty_reassign_adapters.go new file mode 100644 index 00000000..4783a8df --- /dev/null +++ b/internal/app/duty_reassign_adapters.go @@ -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 +} diff --git a/internal/docgov/duty_project.go b/internal/docgov/duty_project.go new file mode 100644 index 00000000..66e1d471 --- /dev/null +++ b/internal/docgov/duty_project.go @@ -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 +} diff --git a/internal/projectinstance/duty_reassign.go b/internal/projectinstance/duty_reassign.go new file mode 100644 index 00000000..9394666e --- /dev/null +++ b/internal/projectinstance/duty_reassign.go @@ -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 +} diff --git a/internal/projectinstance/duty_reassign_test.go b/internal/projectinstance/duty_reassign_test.go new file mode 100644 index 00000000..01bb13d3 --- /dev/null +++ b/internal/projectinstance/duty_reassign_test.go @@ -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) + } +} diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index 5f2b69ac..c82981b1 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -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 diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 2433ca6a..1fbd2942 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -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 { diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index b43ba424..c532211e 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -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] = ©Inst + return ©Inst, nil +} + func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/server/duty_reassign_handlers.go b/internal/server/duty_reassign_handlers.go new file mode 100644 index 00000000..2eeaa154 --- /dev/null +++ b/internal/server/duty_reassign_handlers.go @@ -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) +} diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index b88c90c0..dbd26293 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -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()+`"}`) } } diff --git a/internal/server/server.go b/internal/server/server.go index c75c3ecf..dfe01d57 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)