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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-12 14:33:43 +00:00
co-authored by Cursor
parent 9ef217cb4b
commit 812fa6ecdf
8 changed files with 161 additions and 15 deletions
+15
View File
@@ -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 {
+3
View File
@@ -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
}
+21 -3
View File
@@ -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)
}
+3 -11
View File
@@ -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)
+10 -1
View File
@@ -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):
+4
View File
@@ -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)
@@ -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})
}
@@ -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)
}