Files
Agent-System/internal/server/project_handlers.go
T
weichenandCursor 5cd22c7327 fix(authz): harden permissions, chat gate, and project create idempotency
Add project:manage_members, CSP frame-src, task-run member view, CreateUser
tx error handling, ERR_NO_PROJECT chat gate, and creator-scoped project
name/client_key uniqueness to stop duplicate creates and auth gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 07:04:57 +00:00

331 lines
11 KiB
Go

package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/projectworkflow"
)
// handleCreateProject 服务端权威创建项目接口(严格从 Token 获取创建人)
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
user, err := s.authenticateUser(r)
if err != nil || user == nil {
writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
return
}
if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`)
return
} else if !ok {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权创建项目",
})
return
}
var req projectworkflow.CreateProjectInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
proj, err := s.pwSvc.CreateProject(r.Context(), user, req)
if err != nil {
if errors.Is(err, projectworkflow.ErrInvalidInput) {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`)
return
}
if errors.Is(err, projectworkflow.ErrDuplicateProjectName) {
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error_code": "ERR_DUPLICATE_PROJECT_NAME",
"message": "您已创建同名项目,请更换名称后重试",
})
return
}
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"`+err.Error()+`"}`)
return
}
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
if proj != nil && s.authorizer != nil {
_ = s.authorizer.AddOwnerOnCreate(r.Context(), proj.ID, user.ID)
}
writeJSONResponse(w, http.StatusCreated, proj)
}
// handleListProjects 查询项目清单;兼容 session_id 检索旧行为
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
if sessionID := r.URL.Query().Get("session_id"); sessionID != "" {
s.handleGetProject(w, r)
return
}
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
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 {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
if list == nil {
list = make([]*projectworkflow.Project, 0)
}
writeJSONResponse(w, http.StatusOK, list)
}
// handleGetProjectByID 获取指定项目的详细信息
func (s *Server) handleGetProjectByID(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
if _, ok := s.requireProjectAccess(w, r, id); !ok {
return
}
proj, err := s.pwSvc.GetProject(r.Context(), id, 0)
if err != nil {
if errors.Is(err, projectworkflow.ErrProjectNotFound) {
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在或无权限访问"}`)
return
}
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
writeJSONResponse(w, http.StatusOK, proj)
}
// handleListProjectWorkflows 获取项目挂载的工作流清单
func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
if _, ok := s.requireProjectAccess(w, r, id); !ok {
return
}
wfs, err := s.pwSvc.ListProjectWorkflows(r.Context(), id)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
if wfs == nil {
wfs = make([]*projectworkflow.ProjectWorkflow, 0)
}
writeJSONResponse(w, http.StatusOK, wfs)
}
// handleActivateWorkflow 旧式工作流切换 API(已退役)。
func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
user, err := s.authenticateUser(r)
if err != nil || user == nil {
writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
return
}
if s.instSvc != nil && s.instSvc.Available() {
writeSafeError(w, r, http.StatusGone, `{"error":"WORKFLOW_SWITCH_API_RETIRED","message":"请通过项目设置完成工作流规划与激活"}`)
return
}
if s.blockLegacyWorkflowSwitch(w, r, id) {
return
}
var req projectworkflow.ActivateWorkflowInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的请求体"}`)
return
}
res, err := s.pwSvc.ActivateWorkflow(r.Context(), id, user.ID, req)
if err != nil {
if errors.Is(err, projectworkflow.ErrForbiddenNotCreator) {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人有权切换激活工作流"}`)
return
}
if errors.Is(err, projectworkflow.ErrConcurrentConflict) {
writeSafeError(w, r, http.StatusConflict, `{"error":"ERR_CONCURRENT_SWITCH","message":"工作流选择版本落后或并发冲突,请刷新后重试"}`)
return
}
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"工作流定义不存在或未在项目启用"}`)
return
}
if errors.Is(err, projectworkflow.ErrProjectNotFound) {
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`)
return
}
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
writeJSONResponse(w, http.StatusOK, res)
}
// handleListWorkflowDefinitions 获取全局工作流规范目录
func (s *Server) handleListWorkflowDefinitions(w http.ResponseWriter, r *http.Request) {
defs := s.pwSvc.Catalog().List()
writeJSONResponse(w, http.StatusOK, defs)
}
// handleRunProjectWorkflow 针对特定项目与工作流触发运行并落盘运行记录
func (s *Server) handleRunProjectWorkflow(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
wfKey := r.PathValue("workflow_key")
if wfKey == "" {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"workflow_key is required"}`)
return
}
def, ok := s.pwSvc.Catalog().Get(wfKey)
if !ok {
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"unknown workflow"}`)
return
}
user, ok := s.requireProjectAccess(w, r, id)
if !ok {
return
}
var req TaskRunRequestDTO
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
if req.TaskKey != "" && req.TaskKey != def.TaskKey && s.failClosedAuthz() {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"客户端不可覆盖 task_key"}`)
return
}
if !s.allowWorkflowDocTaskAccess(r.Context(), id, user.ID, def.TaskKey) {
s.writeWorkflowAccessDenied(w)
return
}
req.ProjectID = id
req.WorkflowKey = wfKey
req.UserID = user.ID
req.TaskKey = def.TaskKey
bodyBytes, _ := json.Marshal(req)
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
s.handleRunTask(w, r)
}
// handleListProjectWorkflowRuns 查询项目某工作流的历史运行记录
func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
if _, ok := s.requireProjectAccess(w, r, id); !ok {
return
}
wfKey := r.PathValue("workflow_key")
runs, err := s.pwSvc.ListWorkflowRuns(r.Context(), id, wfKey, 20)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
if runs == nil {
runs = make([]*projectworkflow.WorkflowRun, 0)
}
writeJSONResponse(w, http.StatusOK, runs)
}
// handleGetProjectLineage 查询项目文档血缘关系及失效状态
func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
projectID, _ := strconv.ParseInt(idStr, 10, 64)
if projectID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
return
}
docID := strings.TrimSpace(r.URL.Query().Get("doc_id"))
if docID == "" {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
if id > 0 {
docID = fmt.Sprintf("doc_proj%d_test_manager", id)
}
}
if docID == "" {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"doc_id is required"}`)
return
}
lineages, err := s.pwSvc.GetLineageForDownstream(r.Context(), docID, 0)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
if len(lineages) == 0 {
idStr := r.PathValue("id")
if id, _ := strconv.ParseInt(idStr, 10, 64); id > 0 {
hyphenDocID := fmt.Sprintf("doc-proj%d-test_manager", id)
if hyphenDocID != docID {
if fb, fbErr := s.pwSvc.GetLineageForDownstream(r.Context(), hyphenDocID, 0); fbErr == nil && len(fb) > 0 {
lineages = fb
}
}
}
}
if lineages == nil {
lineages = make([]*projectworkflow.DocumentLineage, 0)
}
writeJSONResponse(w, http.StatusOK, lineages)
}