From 812fa6ecdf468d1afa8ceaf84a9d416f0434ee57 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 14:33:43 +0000 Subject: [PATCH] feat(api): add workflow catalog routes and public auth config endpoint Expose governance APIs for workflow selection/termination and allow login pages to read registration policy without admin credentials. Co-authored-by: Cursor --- internal/server/auth_handlers.go | 15 ++++++ internal/server/auth_middleware.go | 3 ++ internal/server/project_handlers.go | 24 +++++++-- internal/server/project_members_handlers.go | 14 ++--- internal/server/project_settings_handlers.go | 11 +++- internal/server/server.go | 4 ++ internal/server/workflow_catalog_handlers.go | 54 +++++++++++++++++++ .../server/workflow_terminate_handlers.go | 51 ++++++++++++++++++ 8 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 internal/server/workflow_catalog_handlers.go create mode 100644 internal/server/workflow_terminate_handlers.go diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 08146671..a75862d4 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -307,6 +307,21 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(updated) } +// handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权) +func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) + return + } + cfg, err := s.authSvc.GetConfig(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(cfg) +} + // handleAdminConfig 获取或更新系统准入配置 func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemAdmin(w, r); !ok { diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go index 1eb44384..0ba7961b 100644 --- a/internal/server/auth_middleware.go +++ b/internal/server/auth_middleware.go @@ -27,6 +27,9 @@ func isPublicRoute(method, path string) bool { if method == http.MethodPost && path == "/api/v1/auth/register" { return true } + if method == http.MethodGet && path == "/api/v1/auth/public-config" { + return true + } if !strings.HasPrefix(path, "/api/") { return true } diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 102105d7..f6bcab14 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -70,7 +70,21 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { return } - list, err := s.pwSvc.ListProjects(r.Context(), user.ID) + isAdmin, err := s.authzSvc.IsSystemAdmin(r.Context(), user.ID, user.RoleID) + if err != nil { + writeJSONResponse(w, http.StatusInternalServerError, map[string]string{ + "error_code": "ERR_AUTHZ", + "message": "权限判定失败", + }) + return + } + + var list []*projectworkflow.Project + if isAdmin { + list, err = s.pwSvc.ListAllProjects(r.Context()) + } else { + list, err = s.pwSvc.ListProjects(r.Context(), user.ID) + } if err != nil { http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) return @@ -132,7 +146,7 @@ func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Reque writeJSONResponse(w, http.StatusOK, wfs) } -// handleActivateWorkflow 切换项目当前激活的工作流焦点(创建人权限受控) +// handleActivateWorkflow 旧式工作流切换 API(已退役)。 func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) @@ -147,6 +161,11 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) return } + if s.instSvc != nil && s.instSvc.Available() { + http.Error(w, `{"error":"WORKFLOW_SWITCH_API_RETIRED","message":"请通过项目设置完成工作流规划与激活"}`, http.StatusGone) + return + } + if s.blockLegacyWorkflowSwitch(w, r, id) { return } @@ -302,4 +321,3 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) } writeJSONResponse(w, http.StatusOK, lineages) } - diff --git a/internal/server/project_members_handlers.go b/internal/server/project_members_handlers.go index c683ad22..8825291b 100644 --- a/internal/server/project_members_handlers.go +++ b/internal/server/project_members_handlers.go @@ -29,7 +29,7 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - s.handleListProjectMembers(w, r, user, projectID) + s.handleListProjectMembers(w, r, projectID) case http.MethodPost: s.handleAddProjectMember(w, r, user, projectID) case http.MethodPut: @@ -41,16 +41,8 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) { - if err := s.authorizer.RequireProjectAccess(r.Context(), projectID, user.ID); err != nil { - if err == authz.ErrForbidden { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "ERR_FORBIDDEN", - "message": "无权访问该项目", - }) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) +func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request, projectID int64) { + if _, ok := s.requireProjectAccess(w, r, projectID); !ok { return } members, err := s.authorizer.ListProjectMembers(r.Context(), projectID) diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index 59913737..7d97cb95 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -145,7 +145,16 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { 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) + var validationErr *projectinstance.ValidationError + if errors.As(err, &validationErr) { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{ + "error": "VALIDATION_FAILED", + "message": "工作流配置校验未通过", + "issues": validationErr.Issues, + }) + return + } + http.Error(w, `{"error":"VALIDATION_FAILED","message":"工作流配置校验未通过"}`, http.StatusBadRequest) case errors.Is(err, projectinstance.ErrTokenInvalid): http.Error(w, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`, http.StatusBadRequest) case errors.Is(err, projectinstance.ErrConcurrentConflict): diff --git a/internal/server/server.go b/internal/server/server.go index 3dd4a94d..5f5a258f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -109,6 +109,7 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { } if instSvc != nil && instSvc.Available() { svc.SetStageAccessGate(newBizdevStageGate(instSvc, pwSvc)) + coordEngine.SetInstanceService(instSvc) } s.registerWorkflowApprovalHook() s.startNotificationReminderLoop() @@ -164,7 +165,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /api/v1/workflow-instances/{id}", s.handleGetWorkflowInstance) mux.HandleFunc("GET /api/v1/workflow-instances/{id}/stages/{stage}/readiness", s.handleGetStageReadiness) mux.HandleFunc("POST /api/v1/workflow-instances/{id}/stages/{stage}/advance", s.handleAdvanceStage) + mux.HandleFunc("POST /api/v1/workflow-instances/{id}/terminate", s.handleTerminateWorkflowInstance) mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions) + mux.HandleFunc("GET /api/v1/workflows/catalog", s.handleWorkflowCatalog) 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) mux.HandleFunc("GET /api/v1/projects/{id}/lineage", s.handleGetProjectLineage) @@ -248,6 +251,7 @@ func (s *Server) Routes() http.Handler { // 9. 用户认证、安全改密与超级管理员后台接口 mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister) + mux.HandleFunc("GET /api/v1/auth/public-config", s.handleAuthPublicConfig) mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout) mux.HandleFunc("POST /api/v1/auth/change-password", s.handleAuthChangePassword) mux.HandleFunc("GET /api/v1/auth/me", s.handleAuthMe) diff --git a/internal/server/workflow_catalog_handlers.go b/internal/server/workflow_catalog_handlers.go new file mode 100644 index 00000000..ad33845e --- /dev/null +++ b/internal/server/workflow_catalog_handlers.go @@ -0,0 +1,54 @@ +package server + +import ( + "net/http" + + "chat-agent-system/internal/workflowdef" +) + +type workflowCatalogItem struct { + Key string `json:"key"` + Name string `json:"name"` + LatestVersion int `json:"latest_version"` + ProductDomain string `json:"product_domain,omitempty"` +} + +func (s *Server) handleWorkflowCatalog(w http.ResponseWriter, r *http.Request) { + if s.wfDefSvc == nil || !s.wfDefSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"workflow catalog unavailable"}`, http.StatusServiceUnavailable) + return + } + defs, err := s.wfDefSvc.Store().ListProjectSelectableDefinitions(r.Context()) + if err != nil { + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + return + } + items := make([]workflowCatalogItem, 0, len(workflowdef.OfficialWorkflowKeys())) + seen := map[string]bool{} + for _, d := range defs { + if !workflowdef.IsOfficialWorkflowKey(d.Key) || seen[d.Key] { + continue + } + seen[d.Key] = true + name := d.Name + if name == "" { + name = workflowdef.OfficialWorkflowName(d.Key) + } + items = append(items, workflowCatalogItem{ + Key: d.Key, + Name: name, + LatestVersion: d.LatestPublishedVersion, + ProductDomain: d.ProductDomain, + }) + } + for _, key := range workflowdef.OfficialWorkflowKeys() { + if seen[key] { + continue + } + items = append(items, workflowCatalogItem{ + Key: key, + Name: workflowdef.OfficialWorkflowName(key), + }) + } + writeJSONResponse(w, http.StatusOK, map[string]any{"workflows": items}) +} diff --git a/internal/server/workflow_terminate_handlers.go b/internal/server/workflow_terminate_handlers.go new file mode 100644 index 00000000..5cd592d1 --- /dev/null +++ b/internal/server/workflow_terminate_handlers.go @@ -0,0 +1,51 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "chat-agent-system/internal/projectinstance" +) + +func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http.Request) { + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable) + return + } + instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || instanceID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) + return + } + user, err := s.authenticateUser(r) + if err != nil || user == nil { + http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized) + return + } + detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + if err != nil { + http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound) + return + } + var in projectinstance.TerminateInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + res, err := s.instSvc.TerminateInstance(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator, in) + if err != nil { + switch { + case errors.Is(err, projectinstance.ErrForbiddenNotCreator): + http.Error(w, `{"error":"ERR_FORBIDDEN"}`, http.StatusForbidden) + case errors.Is(err, projectinstance.ErrTerminationNotAllowed): + http.Error(w, `{"error":"ERR_TERMINATION_NOT_ALLOWED"}`, http.StatusConflict) + default: + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + } + return + } + writeJSONResponse(w, http.StatusOK, res) +}