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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user