diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index e6c468e9..810dc34c 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -137,6 +137,10 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) return } + if s.blockLegacyWorkflowSwitch(w, r, id) { + return + } + var req projectworkflow.ActivateWorkflowInput if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的请求体"}`, http.StatusBadRequest) diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go new file mode 100644 index 00000000..2432257b --- /dev/null +++ b/internal/server/project_settings_handlers.go @@ -0,0 +1,186 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "chat-agent-system/internal/projectinstance" +) + +func (s *Server) handleGetDefaultWorkflowPlan(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + if _, ok := s.requireProjectAccess(w, r, projectID); !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + workflowKey := r.URL.Query().Get("workflow_key") + version, _ := strconv.Atoi(r.URL.Query().Get("version")) + plan, err := s.instSvc.BuildDefaultPlan(r.Context(), workflowKey, version) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, plan) +} + +func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用,请先执行数据库迁移"}`, http.StatusServiceUnavailable) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + settings, err := s.instSvc.GetSettings(r.Context(), projectID, user.ID, isCreator) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, settings) +} + +func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + var req projectinstance.SavePlanInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest) + return + } + settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, settings) +} + +func (s *Server) handleActivationPreview(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + preview, err := s.instSvc.ActivationPreview(r.Context(), projectID, user.ID, isCreator) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, preview) +} + +func (s *Server) handleActivateWorkflowInstance(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + var req projectinstance.ActivateInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest) + return + } + result, err := s.instSvc.Activate(r.Context(), projectID, user.ID, isCreator, req) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, result) +} + +func writeProjectInstanceError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, projectinstance.ErrForbiddenNotCreator): + http.Error(w, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`, http.StatusForbidden) + case errors.Is(err, projectinstance.ErrPlanFrozen): + http.Error(w, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive): + http.Error(w, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrPlanChangedReconfirm): + http.Error(w, `{"error":"PLAN_CHANGED_RECONFIRM","message":"规划已变更,请重新预览并确认"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrValidationFailed): + http.Error(w, `{"error":"VALIDATION_FAILED","message":"`+err.Error()+`"}`, http.StatusBadRequest) + case errors.Is(err, projectinstance.ErrTokenInvalid): + http.Error(w, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`, http.StatusBadRequest) + case errors.Is(err, projectinstance.ErrConcurrentConflict): + http.Error(w, `{"error":"ERR_CONCURRENT_SWITCH","message":"项目版本冲突,请刷新后重试"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrPlanRevisionConflict): + http.Error(w, `{"error":"PLAN_REVISION_CONFLICT","message":"规划版本冲突,请刷新后重试"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrProjectNotFound): + http.Error(w, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`, http.StatusNotFound) + case errors.Is(err, projectinstance.ErrInvalidPlan): + http.Error(w, `{"error":"ERR_INVALID_PLAN","message":"`+err.Error()+`"}`, http.StatusBadRequest) + default: + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + } +} + +func (s *Server) blockLegacyWorkflowSwitch(w http.ResponseWriter, r *http.Request, projectID int64) bool { + if s.instSvc == nil || !s.instSvc.Available() { + return false + } + live, err := s.instSvc.HasLiveInstance(r.Context(), projectID) + if err != nil { + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + return true + } + if live { + http.Error(w, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例,请使用项目设置查看流程"}`, http.StatusConflict) + return true + } + proj, err := s.pwSvc.GetProject(r.Context(), projectID, 0) + if err != nil { + return false + } + if proj != nil && proj.LifecycleStatus == "planning" && proj.ActiveWorkflowKey == "" { + http.Error(w, `{"error":"LEGACY_SWITCH_BLOCKED","message":"新项目请通过项目设置完成工作流激活"}`, http.StatusConflict) + return true + } + return false +} diff --git a/internal/server/project_workflow_api_test.go b/internal/server/project_workflow_api_test.go index f4fa240f..94dbc2b2 100644 --- a/internal/server/project_workflow_api_test.go +++ b/internal/server/project_workflow_api_test.go @@ -58,7 +58,8 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) { err := json.Unmarshal(recCreate.Body.Bytes(), &createdProj) require.NoError(t, err) assert.Equal(t, int64(2), createdProj.CreatorUserID) - assert.Equal(t, "main", createdProj.ActiveWorkflowKey) + assert.Equal(t, "planning", createdProj.LifecycleStatus) + assert.Equal(t, "", createdProj.ActiveWorkflowKey) assert.Equal(t, 1, createdProj.WorkflowSelectionVersion) // 3. 列出项目列表 -> 包含刚创建的项目 diff --git a/internal/server/server.go b/internal/server/server.go index 628c86ba..e881cac0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,6 +20,7 @@ import ( "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/intent" "chat-agent-system/internal/observability" + "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/taskrun" "chat-agent-system/internal/workflowdef" @@ -40,6 +41,7 @@ type Server struct { authorizer *authz.Authorizer authzSvc *authz.Service wfDefSvc *workflowdef.Service + instSvc *projectinstance.Service resolver *workspace.Resolver httpServer *http.Server } @@ -76,6 +78,11 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { authzSvc := authz.NewService(pool) wfDefSvc := workflowdef.NewService(pool, svc.CAS()) + instStore := projectinstance.NewStore(pool) + var instSvc *projectinstance.Service + if wfDefSvc.Available() { + instSvc = projectinstance.NewService(instStore, wfDefSvc.Store(), pwSvc.Store()) + } s := &Server{ cfg: cfg, svc: svc, @@ -88,6 +95,7 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { authorizer: authz.NewAuthorizer(pool), authzSvc: authzSvc, wfDefSvc: wfDefSvc, + instSvc: instSvc, resolver: workspace.NewResolver(cfg.DataDir, pool), } s.initTaskRunExecutors() @@ -127,6 +135,11 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("DELETE /api/v1/projects/{id}/members", s.handleProjectMembers) mux.HandleFunc("GET /api/v1/projects/{id}/workflows", s.handleListProjectWorkflows) mux.HandleFunc("PUT /api/v1/projects/{id}/active-workflow", s.handleActivateWorkflow) + 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("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/workflows", s.handleListWorkflowDefinitions) mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow) mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns)