Files
Agent-System/internal/server/project_settings_handlers.go
T
weichenandCursor f168e44ce7 feat(projectinstance): allow live workflow config edits except switching
Activated projects can update duties, approval policy, and document enablement. Changing the workflow definition stays rejected, and project 143 remains frozen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 05:20:01 +00:00

259 lines
12 KiB
Go

package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"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 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
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 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用,请先执行数据库迁移"}`)
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
}
type settingsResponse struct {
*projectinstance.ProjectSettings
RecentWorkflowEvents []workflowEventSummary `json:"recent_workflow_events,omitempty"`
}
out := settingsResponse{ProjectSettings: settings}
if s.notifySvc != nil && s.notifySvc.Available() {
items, listErr := s.notifySvc.ListProjectNotifications(r.Context(), user.ID, projectID, 0, 20)
if listErr == nil {
for _, n := range items {
out.RecentWorkflowEvents = append(out.RecentWorkflowEvents, workflowEventSummary{
ID: n.ID, Type: n.Type, Title: n.Title, CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
}
}
}
writeJSONResponse(w, http.StatusOK, out)
}
type workflowEventSummary struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
CreatedAt string `json:"created_at"`
}
func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
var req projectinstance.SavePlanInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
var settings *projectinstance.ProjectSettings
if req.LiveEdit {
settings, err = s.instSvc.UpdateLivePlan(r.Context(), projectID, user.ID, isCreator, req)
} else {
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 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
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 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.instSvc == nil || !s.instSvc.Available() {
writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
var req projectinstance.ActivateInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
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):
writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`)
case errors.Is(err, projectinstance.ErrPlanFrozen):
writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`)
case errors.Is(err, projectinstance.ErrWorkflowSwitchFrozen):
writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_SWITCH_FROZEN","message":"运行中不能切换工作流,只能调整职责、审批和文档启用"}`)
case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive):
writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`)
case errors.Is(err, projectinstance.ErrPlanChangedReconfirm):
writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_CHANGED_RECONFIRM","message":"规划已变更,请重新预览并确认"}`)
case errors.Is(err, projectinstance.ErrValidationFailed):
var validationErr *projectinstance.ValidationError
if errors.As(err, &validationErr) {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
"error": "VALIDATION_FAILED",
"message": "工作流配置校验未通过",
"issues": validationErr.Issues,
})
return
}
writeSafeError(w, nil, http.StatusBadRequest, `{"error":"VALIDATION_FAILED","message":"工作流配置校验未通过"}`)
case errors.Is(err, projectinstance.ErrTokenInvalid):
writeSafeError(w, nil, http.StatusBadRequest, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`)
case errors.Is(err, projectinstance.ErrConcurrentConflict):
writeSafeError(w, nil, http.StatusConflict, `{"error":"ERR_CONCURRENT_SWITCH","message":"项目版本冲突,请刷新后重试"}`)
case errors.Is(err, projectinstance.ErrPlanRevisionConflict):
writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_REVISION_CONFLICT","message":"规划版本冲突,请刷新后重试"}`)
case errors.Is(err, projectinstance.ErrProjectNotFound):
writeSafeError(w, nil, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`)
case errors.Is(err, projectinstance.ErrInvalidPlan):
writeSafeError(w, nil, http.StatusBadRequest, `{"error":"ERR_INVALID_PLAN","message":"`+err.Error()+`"}`)
case errors.Is(err, projectinstance.ErrInstanceNotFound):
writeSafeError(w, nil, http.StatusNotFound, `{"error":"ERR_INSTANCE_NOT_FOUND","message":"工作流实例不存在"}`)
case errors.Is(err, projectinstance.ErrStageGateNotReady):
writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_GATE_NOT_READY","message":"阶段门禁未满足,必需文档尚未全部批准"}`)
case errors.Is(err, projectinstance.ErrStageNotActive):
writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_NOT_ACTIVE","message":"目标阶段不是当前活动阶段"}`)
case errors.Is(err, projectinstance.ErrStageNotCompleted):
writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_NOT_COMPLETED","message":"只能再打开已完成的阶段"}`)
case errors.Is(err, projectinstance.ErrRecheckAckRequired):
writeSafeError(w, nil, http.StatusConflict, `{"error":"RECHECK_ACK_REQUIRED","message":"该阶段有待复核标记,推进前须确认已知悉"}`)
case errors.Is(err, projectinstance.ErrInvalidStage):
writeSafeError(w, nil, http.StatusBadRequest, `{"error":"ERR_INVALID_STAGE","message":"无效的阶段"}`)
case errors.Is(err, projectinstance.ErrInstanceRevisionConflict):
writeSafeError(w, nil, http.StatusConflict, `{"error":"INSTANCE_REVISION_CONFLICT","message":"实例版本冲突,请刷新后重试"}`)
case errors.Is(err, projectinstance.ErrAuthorOnlyPolicyDisabled):
writeSafeError(w, nil, http.StatusForbidden, `{"error":"AUTHOR_ONLY_POLICY_DISABLED","message":"系统未授权编制人兼任审核批准"}`)
case errors.Is(err, projectinstance.ErrAuthorOnlyPolicyChanged):
writeSafeError(w, nil, http.StatusConflict, `{"error":"AUTHOR_ONLY_POLICY_CHANGED","message":"兼任总授权已变更,请重新预览并确认激活"}`)
case errors.Is(err, projectinstance.ErrAuthorOnlyPolicyUnavailable):
writeSafeError(w, nil, http.StatusServiceUnavailable, `{"error":"AUTHOR_ONLY_POLICY_UNAVAILABLE","message":"兼任总授权存储不可用"}`)
case errors.Is(err, projectinstance.ErrForbiddenDutyReassign):
writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"无权改派文档职责"}`)
case errors.Is(err, projectinstance.ErrDocTaskNotFound):
writeSafeError(w, nil, http.StatusNotFound, `{"error":"DOC_TASK_NOT_FOUND","message":"文档任务不存在或已禁用"}`)
default:
var busy *projectinstance.DocTaskBusyError
if errors.As(err, &busy) {
msg := busy.Reason
if msg == "" {
msg = "该文档小队任务进行中,完成后可更换负责人"
}
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error": "DUTY_REASSIGN_BUSY",
"message": msg,
})
return
}
writeSafeError(w, nil, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
}
}
func (s *Server) blockLegacyWorkflowSwitch(w http.ResponseWriter, r *http.Request, projectID int64) bool {
proj, err := s.pwSvc.GetProject(r.Context(), projectID, 0)
if err == nil && proj != nil && proj.LifecycleStatus == "planning" && proj.ActiveWorkflowKey == "" {
writeSafeError(w, r, http.StatusConflict, `{"error":"LEGACY_SWITCH_BLOCKED","message":"新项目请通过项目设置完成工作流激活"}`)
return true
}
if s.instSvc == nil || !s.instSvc.Available() {
return false
}
live, err := s.instSvc.HasLiveInstance(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return true
}
if live {
writeSafeError(w, r, http.StatusConflict, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例,请使用项目设置查看流程"}`)
return true
}
return false
}