diff --git a/internal/server/handlers.go b/internal/server/handlers.go index ddf4d15b..74e4187b 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1,27 +1,8 @@ package server import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "path/filepath" - "strconv" - "strings" - "time" - - "chat-agent-system/internal/app/chatflow" - "chat-agent-system/internal/app/docflow" - "chat-agent-system/internal/app/uow" "chat-agent-system/internal/bizdev" - "chat-agent-system/internal/chat" - "chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/ctxmgr" - "chat-agent-system/internal/docgov" - "chat-agent-system/internal/httpx" - "chat-agent-system/internal/projectinstance" - "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/squad" ) @@ -86,401 +67,6 @@ type TaskRunRequestDTO struct { Materials []bizdev.MaterialInput `json:"materials,omitempty"` } -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - resp, code := s.buildHealthResponse(r.Context(), false) - writeJSONResponse(w, code, resp) -} - -func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) { - resp, code := s.buildHealthResponse(r.Context(), true) - writeJSONResponse(w, code, resp) -} - -type healthSnapshot struct { - at time.Time - strict bool - resp HealthResponse - code int - ok bool -} - -func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthResponse, int) { - const healthCacheTTL = 2 * time.Second - s.healthMu.Lock() - if s.healthCache.ok && s.healthCache.strict == strict && time.Since(s.healthCache.at) < healthCacheTTL { - resp, code := s.healthCache.resp, s.healthCache.code - s.healthMu.Unlock() - return resp, code - } - s.healthMu.Unlock() - - dbCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - - dbReady := s.svc != nil && s.svc.DatabaseReady() - schemaVersion := int64(0) - if s.svc != nil { - if dbReady { - schemaVersion = s.svc.LiveSchemaVersion(dbCtx) - } else { - schemaVersion = s.svc.SchemaVersion() - } - } - schemaReady := schemaVersion > 0 || (s.cfg != nil && s.cfg.AllowInMemory) - taskStoreReady := s.rt != nil && s.rt.TaskStoreReady(dbCtx) - - status := "ok" - code := http.StatusOK - if s.cfg != nil && s.cfg.ContextDbHost != "" && !s.cfg.AllowInMemory { - if !dbReady { - status = "degraded" - code = http.StatusServiceUnavailable - } else if strict && (!schemaReady || !taskStoreReady) { - status = "not_ready" - code = http.StatusServiceUnavailable - } - } - - kbReady := s.kb != nil && s.kb.Online() - - resp := HealthResponse{ - Status: status, - Env: s.cfg.Env, - AgentBackend: s.cfg.AgentBackend, - Version: "v1.0", - SharedKBPort: s.cfg.KbDbPort, - KBReady: kbReady, - DatabaseReady: dbReady, - SchemaVersion: schemaVersion, - SchemaReady: schemaReady, - TaskStoreReady: taskStoreReady, - } - s.healthMu.Lock() - s.healthCache = healthSnapshot{at: time.Now(), strict: strict, resp: resp, code: code, ok: true} - s.healthMu.Unlock() - return resp, code -} - -func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - sessionID := r.URL.Query().Get("session_id") - if sessionID == "" { - writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param") - return - } - - // 传入真实 caller,触发 getOrCreateSession 归属校验;无 owner 的遗留会话仍可读。 - sess, err := s.getOrCreateSession(r.Context(), user.ID, sessionID, "", "", "", "", "", "", "", "") - if err != nil { - if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "SESSION_FORBIDDEN", - "message": "无权访问该会话", - }) - return - } - writeSafeError(w, r, http.StatusNotFound, "session or project context not found") - return - } - if sess == nil || sess.UserCtx == nil { - writeSafeError(w, r, http.StatusNotFound, "session or project context not found") - return - } - - writeJSONResponse(w, http.StatusOK, sess.UserCtx) -} - -func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { - user, ok := s.requirePrincipal(w, r) - if !ok { - return - } - - var req ChatRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - if req.Message == "" { - writeSafeError(w, r, http.StatusBadRequest, "message cannot be empty") - return - } - req.WorkDir = "" - - sess, err := s.getOrCreateSession( - r.Context(), - user.ID, - req.SessionID, - req.ProjectName, - req.CustomerName, - req.DeviceType, - req.IntendedUse, - req.TargetMarket, - req.WorkDir, - req.UserName, - req.UserRole, - ) - if err != nil { - if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "SESSION_FORBIDDEN", - "message": "无权访问该会话", - }) - return - } - writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) - return - } - s.hydrateVisibleHistory(r.Context(), sess, user.ID) - if req.ProjectID > 0 && req.ProjectID != sess.Project() { - sess.SetProjectID(req.ProjectID) - } - if !s.enforceChatRequiresProject(w, r, user, sess.Project()) { - return - } - if sess.Project() > 0 { - if !s.enforceChatProjectAccess(w, r, user, sess.Project()) { - return - } - } - s.enrichSessionWorkflowStage(r.Context(), sess, user.ID) - if sess.Project() > 0 && !s.allowActiveStageChatAccess(r.Context(), sess.Project(), user.ID) { - s.writeWorkflowAccessDenied(w) - return - } - s.bindSessionDocumentSummaryReader(sess, user) - - var resp *chat.ChatResponse - err = sess.WithExecLock(func() error { - var sendErr error - resp, sendErr = sess.SendMessage(r.Context(), req.Message) - return sendErr - }) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "process chat message failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, resp) -} - -func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { - user, ok := s.requirePrincipal(w, r) - if !ok { - return - } - - var req TaskRunRequestDTO - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - req.UserID = user.ID - - if req.ProjectID > 0 { - if !s.isSystemAdminUser(r.Context(), user) { - if err := s.authorizer.RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "ERR_FORBIDDEN", - "message": "无权访问该项目", - }) - return - } - } - // 工作流作用域:任务键不属于当前激活工作流时拦截并引导切换(不执行)。 - if blocked, guidance := s.taskKeyOutsideActiveWorkflow(r.Context(), req.ProjectID, req.TaskKey); blocked { - writeJSONResponse(w, http.StatusConflict, map[string]string{ - "error_code": "ERR_DOC_WRONG_WORKFLOW", - "message": guidance, - }) - return - } - } - - // 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例 - var runRecord *projectworkflow.WorkflowRun - if s.pwSvc != nil && req.ProjectID > 0 { - wfKey := req.WorkflowKey - if wfKey == "" { - if p, _ := s.pwSvc.GetProject(r.Context(), req.ProjectID, req.UserID); p != nil { - wfKey = p.ActiveWorkflowKey - } - } - if wfKey == "" { - wfKey = "main" - } - manifest := map[string]interface{}{ - "fields": req.Fields, - "doc_id": req.DocID, - } - runRecord, _ = s.pwSvc.CreateWorkflowRun(r.Context(), &projectworkflow.WorkflowRun{ - ProjectID: req.ProjectID, - WorkflowKey: wfKey, - Status: "running", - StartedBy: &req.UserID, - StageKey: req.Stage, - TaskKey: req.TaskKey, - InputManifest: manifest, - }) - } - - // 同步任务可能远超全局 WriteTimeout(120s)。先把本响应的写期限拉长, - // 否则业务已执行完但客户端收不到结果、重试会表现为重复执行。 - extendSyncTaskWriteDeadline(w) - - // 1. 如果指定了 stage,则执行成熟度阶段编排 - if req.Stage != "" { - stageRes, err := s.taskUseCase().ExecuteStage(r.Context(), bizdev.StageRunRequest{ - Stage: bizdev.MaturityStage(req.Stage), - ProjectName: req.ProjectName, - CustomerName: req.CustomerName, - DeviceType: req.DeviceType, - IntendedUse: req.IntendedUse, - TargetMarket: req.TargetMarket, - Fields: req.Fields, - Backend: resolveTaskBackend(s, req.Backend), - AutoRepair: req.AutoRepair, - UserID: req.UserID, - ProjectID: req.ProjectID, - DocID: req.DocID, - AuthorID: req.AuthorID, - AuthorName: req.AuthorName, - ReviewerID: req.ReviewerID, - ReviewerName: req.ReviewerName, - ApproverID: req.ApproverID, - ApproverName: req.ApproverName, - ForceDirectEdit: req.ForceDirectEdit, - }) - if err != nil { - if runRecord != nil { - _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) - } - writeTaskError(w, err, stageRes) - return - } - if runRecord != nil && stageRes != nil && stageRes.PrimaryArtifact != nil { - status := resolveTerminalStatus(stageRes.PrimaryArtifact) - outDocID := "" - if stageRes.PrimaryArtifact.DocRecord != nil { - outDocID = stageRes.PrimaryArtifact.DocRecord.DocID - } - outSummary := stageRes.PrimaryArtifact.ArtifactTitle - if outSummary == "" { - outSummary = stageRes.StageTitle - } - _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, status, outDocID, outSummary, "") - } - writeJSONResponse(w, http.StatusOK, stageRes) - return - } - - // 2. 执行单任务 - if req.TaskKey == "" { - req.TaskKey = "intake" - } - taskRes, err := s.taskUseCase().ExecuteTask(r.Context(), bizdev.TaskRunRequest{ - TaskKey: req.TaskKey, - ProjectName: req.ProjectName, - CustomerName: req.CustomerName, - DeviceType: req.DeviceType, - IntendedUse: req.IntendedUse, - TargetMarket: req.TargetMarket, - Fields: req.Fields, - Materials: req.Materials, - Backend: resolveTaskBackend(s, req.Backend), - AutoRepair: req.AutoRepair, - UserID: req.UserID, - ProjectID: req.ProjectID, - DocID: req.DocID, - AuthorID: req.AuthorID, - AuthorName: req.AuthorName, - ReviewerID: req.ReviewerID, - ReviewerName: req.ReviewerName, - ApproverID: req.ApproverID, - ApproverName: req.ApproverName, - ForceDirectEdit: req.ForceDirectEdit, - CustomSteps: req.CustomSteps, - }) - if err != nil { - if runRecord != nil { - _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) - } - writeTaskError(w, err, taskRes) - return - } - - if runRecord != nil && taskRes != nil { - status := resolveTerminalStatus(taskRes) - outDocID := "" - if taskRes.DocRecord != nil { - outDocID = taskRes.DocRecord.DocID - } - _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, status, outDocID, taskRes.ArtifactTitle, "") - } - - // 隐藏不必要的中间大体积字节 - if taskRes.WorkDir != "" { - taskRes.WorkDir = filepath.Clean(taskRes.WorkDir) - } - - writeJSONResponse(w, http.StatusOK, taskRes) -} - -func writeJSONResponse(w http.ResponseWriter, status int, data any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(data) -} - -func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy string) { - httpx.Write(w, status, httpx.CodeFromStatus(status), legacy, httpx.RequestID(r), nil) -} - -// ----------------------------------------------------------------------------- -// 多级上下文与偏好记忆 RESTful 控制器 -// ----------------------------------------------------------------------------- - -// handleGetUserContext 获取单用户私有上下文与个性化偏好 -// user_id 仅允许等于当前认证用户本人(或系统管理员),不再默认回落 user id=1。 -func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) { - user, ok := s.requirePrincipal(w, r) - if !ok { - return - } - userIDStr := r.URL.Query().Get("user_id") - projectIDStr := r.URL.Query().Get("project_id") - sessionID := r.URL.Query().Get("session_id") - - userID, _ := strconv.ParseInt(userIDStr, 10, 64) - if userID <= 0 { - userID = user.ID - } - if userID != user.ID && !s.isSystemAdminUser(r.Context(), user) { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "ERR_FORBIDDEN", - "message": "无权查询其他用户的上下文", - }) - return - } - projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) - - if s.ctxMgr == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") - return - } - - uCtx, err := s.ctxMgr.GetUserContext(r.Context(), projectID, userID, sessionID) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "get user context failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, uCtx) -} - // UpdateUserPreferenceRequest 更新用户工作偏好请求 type UpdateUserPreferenceRequest struct { UserID int64 `json:"user_id"` @@ -490,118 +76,6 @@ type UpdateUserPreferenceRequest struct { CustomInstructions string `json:"custom_instructions"` } -// handleUpdateUserPreference 定期整理或前端保存用户习惯偏好 -func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - var req UpdateUserPreferenceRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - if req.UserID <= 0 { - req.UserID = user.ID - } - if req.UserID != user.ID && !s.isSystemAdminUser(r.Context(), user) { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "ERR_FORBIDDEN", - "message": "无权修改其他用户的偏好", - }) - return - } - - if s.ctxMgr == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") - return - } - - pref := &ctxmgr.UserPreference{ - UserID: req.UserID, - CommunicationStyle: req.CommunicationStyle, - FocusAreas: req.FocusAreas, - OutputFormatPref: req.OutputFormatPref, - CustomInstructions: req.CustomInstructions, - } - - if err := s.ctxMgr.UpdateUserPreference(r.Context(), pref); err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "save user preference failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, map[string]any{"status": "ok", "user_id": req.UserID}) -} - -// handleGetProjectContext 获取项目级多方协同事实黑板与里程碑决策流 -func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - - projectIDStr := r.URL.Query().Get("project_id") - projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) - if projectID <= 0 { - writeSafeError(w, r, http.StatusBadRequest, "missing or invalid project_id query param") - return - } - - // Membership first: allowAnyStageAccess assumes the caller already passed - // project membership (see ResolveStageAccess). Without this guard any - // authenticated user can read an activated project's collaborative context. - if !s.enforceChatProjectAccess(w, r, user, projectID) { - return - } - if !s.allowAnyStageAccess(r.Context(), projectID, user.ID) { - s.writeWorkflowAccessDenied(w) - return - } - - if s.ctxMgr == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") - return - } - - pCtx, err := s.ctxMgr.GetProjectContext(r.Context(), projectID) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "get project context failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, pCtx) -} - -// ----------------------------------------------------------------------------- -// 受控文档编制、审核、批准与认领生命周期 RESTful 控制器 -// ----------------------------------------------------------------------------- - -// handleDocGovGetDocument 查询指定受控文档详情与治理状态 -func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) { - docID := r.URL.Query().Get("doc_id") - if docID == "" { - writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param") - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - - if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { - return - } - - doc, err := s.gov.GetDocument(r.Context(), docID) - if err != nil { - writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, doc) -} - // DocGovClaimRequest 认领文档请求 type DocGovClaimRequest struct { DocID string `json:"doc_id"` @@ -610,37 +84,6 @@ type DocGovClaimRequest struct { UserRole string `json:"user_role"` // author, reviewer, approver } -// handleDocGovClaim 查阅并认领受控文档 (非模板状态查看方可认领) -func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - - var req DocGovClaimRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - if req.DocID == "" { - writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty") - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - - doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, doc) -} - // DocGovSubmitReviewRequest 送审请求 type DocGovSubmitReviewRequest struct { DocID string `json:"doc_id"` @@ -648,36 +91,6 @@ type DocGovSubmitReviewRequest struct { UserName string `json:"user_name"` } -// handleDocGovSubmitReview 编制人将文档送审 (drafting -> reviewing, 正文受控锁定) -func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - - var req DocGovSubmitReviewRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionSubmit) { - return - } - - doc, err := s.gov.SubmitForReview(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, user.Name) - if err != nil { - writeSafeError(w, r, http.StatusBadRequest, "submit review failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, doc) -} - // DocGovDecisionRequest 审核/批准决议请求 type DocGovDecisionRequest struct { DocID string `json:"doc_id"` @@ -689,114 +102,6 @@ type DocGovDecisionRequest struct { RollbackTo string `json:"rollback_to,omitempty"` // 批准人驳回时可选: drafting 或 reviewing } -// handleDocGovReviewDecision 审核人决议 (通过流转至 approving,驳回必须填理由并回退 drafting 解锁) -func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - - var req DocGovDecisionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionReview) { - return - } - - action := strings.ToLower(strings.TrimSpace(req.Action)) - var doc *docgov.DocumentRecord - var err error - - if action == "approve" { - doc, err = s.gov.ReviewApprove(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, req.Comment) - } else if action == "reject" { - reason := req.Reason - if reason == "" { - reason = req.Comment - } - doc, err = s.gov.ReviewReject(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, reason) - } else { - writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject") - return - } - - if err != nil { - writeSafeError(w, r, http.StatusBadRequest, "review decision failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, doc) -} - -// handleDocGovApproverDecision 批准人决议 (通过流转至 approved 终态归档,驳回必填理由并回退) -func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - - var req DocGovDecisionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionApprove) { - return - } - - action := strings.ToLower(strings.TrimSpace(req.Action)) - cmds := s.docflowCommands() - if cmds == nil || cmds.Gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleApprover} - var doc *docgov.DocumentRecord - var err error - - if action == "approve" { - doc, err = cmds.Approve(r.Context(), p, docflow.DecisionCommand{DocID: req.DocID, Comment: req.Comment}) - } else if action == "reject" { - reason := req.Reason - if reason == "" { - reason = req.Comment - } - doc, err = cmds.Reject(r.Context(), p, docflow.DecisionCommand{ - DocID: req.DocID, RejectReason: reason, RollbackTo: vocabulary.DocumentState(req.RollbackTo), - }) - } else { - writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject") - return - } - - if err != nil { - if errors.Is(err, uow.ErrPermissionDenied) { - writeSafeError(w, r, http.StatusForbidden, "approver decision failed: "+err.Error()) - return - } - if errors.Is(err, uow.ErrBusy) { - writeSafeError(w, r, http.StatusConflict, "approver decision failed: "+err.Error()) - return - } - writeSafeError(w, r, http.StatusBadRequest, "approver decision failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, doc) -} - // ProposeModificationRequest 发起变更提案请求 type ProposeModificationRequest struct { DocID string `json:"doc_id"` @@ -806,57 +111,6 @@ type ProposeModificationRequest struct { RiskAssessment string `json:"risk_assessment"` } -// handleDocGovProposeModification 对已认领文档发起变更提案(写库 + 审计) -func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - - var req ProposeModificationRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - if strings.TrimSpace(req.DocID) == "" { - writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty") - return - } - if strings.TrimSpace(req.ProposedChanges) == "" { - writeSafeError(w, r, http.StatusBadRequest, "proposed_changes cannot be empty") - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - - triggeredBy := strings.TrimSpace(req.TriggeredBy) - if triggeredBy == "" { - triggeredBy = fmt.Sprintf("user:%d", user.ID) - } - - proposal, err := s.gov.ProposeModification( - r.Context(), - req.DocID, - triggeredBy, - req.ProposedChanges, - req.ImpactAnalysis, - req.RiskAssessment, - ) - if err != nil { - if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound { - writeSafeError(w, r, status, "document not found") - return - } - writeSafeError(w, r, http.StatusBadRequest, "propose modification failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, proposal) -} - // DecideProposalRequest 拍板变更提案请求 type DecideProposalRequest struct { ProposalID string `json:"proposal_id"` @@ -865,74 +119,6 @@ type DecideProposalRequest struct { Reason string `json:"reason"` } -// handleDocGovDecideProposal 编制负责人拍板决定是否采纳变更提案 -func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - - var req DecideProposalRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - if strings.TrimSpace(req.ProposalID) == "" { - writeSafeError(w, r, http.StatusBadRequest, "proposal_id cannot be empty") - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - - proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason) - if err != nil { - if status, ok := domainHTTPStatus(err); ok { - switch status { - case http.StatusNotFound: - writeSafeError(w, r, status, "proposal not found") - case http.StatusForbidden: - writeSafeError(w, r, status, "decide proposal failed: "+err.Error()) - default: - writeSafeError(w, r, status, err.Error()) - } - return - } - writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, proposal) -} - -// handleDocGovListComments 查询批注与修改建议清单 -func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request) { - docID := r.URL.Query().Get("doc_id") - if docID == "" { - writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param") - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - - if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { - return - } - - comments, err := s.gov.ListComments(r.Context(), docID) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, comments) -} - // AddCommentRequestDTO 新增批注请求 type AddCommentRequestDTO struct { DocID string `json:"doc_id"` @@ -944,126 +130,6 @@ type AddCommentRequestDTO struct { Content string `json:"content"` } -// handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议 -// actor 身份一律取认证 principal,禁止请求体伪造 user_id/user_name/user_role。 -func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) { - var req AddCommentRequestDTO - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - - user, ok := s.requireDocGovDocAccess(w, r, req.DocID) - if !ok { - return - } - - cType := vocabulary.CommentType(req.CommentType) - if cType == "" { - cType = vocabulary.CommentGeneral - } - - comment, err := s.gov.AddComment( - r.Context(), req.DocID, user.ID, user.Name, - s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content, - ) - if err != nil { - writeDocGovError(w, err) - return - } - - writeJSONResponse(w, http.StatusOK, comment) -} - -// handleDocGovListAuditLogs 查询文档全生命周期审计轨迹流 -func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Request) { - docID := r.URL.Query().Get("doc_id") - if docID == "" { - writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param") - return - } - - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - - if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { - return - } - - logs, err := s.gov.ListAuditLogs(r.Context(), docID) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, logs) -} - -// ----------------------------------------------------------------------------- -// 历史多轮会话管理与标杆案例语义检索 RESTful 控制器 -// ----------------------------------------------------------------------------- - -// handleListChatSessions 分页查询用户/项目关联的历史会话列表 -// user_id 仅允许等于当前认证用户本人(或系统管理员),防止水平越权枚举他人会话。 -func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) { - if s.ctxMgr == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") - return - } - - user, ok := s.requirePrincipal(w, r) - if !ok { - return - } - - userIDStr := r.URL.Query().Get("user_id") - userID, _ := strconv.ParseInt(userIDStr, 10, 64) - if userID > 0 && userID != user.ID && !s.isSystemAdminUser(r.Context(), user) { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "ERR_FORBIDDEN", - "message": "无权查询其他用户的会话列表", - }) - return - } - // 未显式传 user_id 时默认查本人,而不是任意/默认用户 - if userID <= 0 { - userID = user.ID - } - projectIDStr := r.URL.Query().Get("project_id") - projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) - - limitStr := r.URL.Query().Get("limit") - limit, _ := strconv.Atoi(limitStr) - if limit <= 0 { - limit = 20 - } - offsetStr := r.URL.Query().Get("offset") - offset, _ := strconv.Atoi(offsetStr) - if offset < 0 { - offset = 0 - } - - sessions, err := s.ctxMgr.ListChatSessions(r.Context(), ctxmgr.SessionFilter{ - UserID: userID, - ProjectID: projectID, - Limit: limit, - Offset: offset, - }) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "list chat sessions failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, sessions) -} - type upsertChatSessionRequest struct { SessionID string `json:"session_id"` Title string `json:"title"` @@ -1071,298 +137,15 @@ type upsertChatSessionRequest struct { ProjectID int64 `json:"project_id"` } -// handleUpsertChatSession 注册或更新会话元数据(立项/成员加入时同步)。 -// 默认归属认证用户本人;项目管理员可为其他成员代注册(防水平越权)。 -func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) { - if s.ctxMgr == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") - return - } - user, ok := s.requirePrincipal(w, r) - if !ok { - return - } - - var req upsertChatSessionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body") - return - } - if req.SessionID == "" { - writeSafeError(w, r, http.StatusBadRequest, "missing session_id") - return - } - - ownerID := user.ID - if req.UserID > 0 && req.UserID != user.ID { - if req.ProjectID <= 0 { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "ERR_FORBIDDEN", - "message": "无权为其他用户注册项目会话", - }) - return - } - can, err := s.authzSvc.CanManageProjectMembers(r.Context(), req.ProjectID, user.ID, user.RoleID) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, err.Error()) - return - } - if !can { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "ERR_FORBIDDEN", - "message": "无权为其他用户注册项目会话", - }) - return - } - ownerID = req.UserID - } - - summary, err := s.ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, ownerID, req.ProjectID, req.Title) - if err != nil { - if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "SESSION_FORBIDDEN", - "message": "无权操作该会话", - }) - return - } - writeSafeError(w, r, http.StatusInternalServerError, "upsert chat session failed: "+err.Error()) - return - } - - writeJSONResponse(w, http.StatusOK, summary) -} - -// handleGetChatMessages 分页获取指定会话的历史多轮消息记录 -func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) { - if s.ctxMgr == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") - return - } - - sessionID := r.URL.Query().Get("session_id") - if sessionID == "" { - writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param") - return - } - - limitStr := r.URL.Query().Get("limit") - limit, _ := strconv.Atoi(limitStr) - if limit <= 0 { - limit = 50 - } - offsetStr := r.URL.Query().Get("offset") - offset, _ := strconv.Atoi(offsetStr) - if offset < 0 { - offset = 0 - } - - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - - if s.chatLog == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") - return - } - - msgs, err := s.chatLog.History(r.Context(), s.chatPrincipal(user, chatflow.ActionReadWindow), chatflow.HistoryQuery{ - SessionID: sessionID, - Limit: limit, - Offset: offset, - }) - if err != nil { - s.writeChatLogError(w, err) - return - } - - writeJSONResponse(w, http.StatusOK, msgs) -} - -// handleRecommendCases 基于语义向量检索往期相似标杆案例 -func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) { - user, ok := s.requireAuthenticatedUser(w, r) - if !ok { - return - } - if s.ctxMgr == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") - return - } - - projectID, _ := strconv.ParseInt(r.URL.Query().Get("project_id"), 10, 64) - query := r.URL.Query().Get("query") - if query == "" { - writeSafeError(w, r, http.StatusBadRequest, "missing query param") - return - } - - taskKey := r.URL.Query().Get("task_key") - excludeDoc := r.URL.Query().Get("exclude_doc") - limitStr := r.URL.Query().Get("limit") - limit, _ := strconv.Atoi(limitStr) - if limit <= 0 { - limit = 3 - } - - minScoreStr := r.URL.Query().Get("min_score") - minScore, _ := strconv.ParseFloat(minScoreStr, 64) - if minScore <= 0 { - minScore = 0.50 - } - - cases, err := s.ctxMgr.SearchSimilarCases(r.Context(), ctxmgr.CaseSearchOptions{ - Query: query, - TaskKey: taskKey, - ExcludeDoc: excludeDoc, - Limit: limit, - MinScore: minScore, - }) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "search similar cases failed: "+err.Error()) - return - } - - if projectID > 0 && len(cases) > 0 { - filtered := make([]ctxmgr.CaseRecommendationItem, 0, len(cases)) - for _, c := range cases { - if s.allowWorkflowDocTaskAccess(r.Context(), projectID, user.ID, c.TaskKey) { - filtered = append(filtered, c) - } - } - cases = filtered - } - - writeJSONResponse(w, http.StatusOK, cases) -} - -// ----------------------------------------------------------------------------- -// 文档正文保存与编辑租约 -// ----------------------------------------------------------------------------- - type SaveContentRequestDTO struct { DocID string `json:"doc_id"` MarkdownBody string `json:"markdown_body"` ExpectedSHA256 string `json:"expected_sha256,omitempty"` } -func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request) { - user, ok := s.requirePrincipal(w, r) - if !ok { - return - } - var req SaveContentRequestDTO - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - role := vocabulary.RoleAuthor - res, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{ - DocID: req.DocID, - ActorID: user.ID, - ActorRole: role, - ActorName: user.Name, - MarkdownBody: req.MarkdownBody, - ExpectedSHA256: req.ExpectedSHA256, - }) - if err != nil { - writeDocGovError(w, err) - return - } - writeJSONResponse(w, http.StatusOK, res) -} - type LeaseRequestDTO struct { DocID string `json:"doc_id"` HolderType string `json:"holder_type"` HolderID string `json:"holder_id"` TTLSeconds int `json:"ttl_seconds,omitempty"` } - -func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request) { - user, ok := s.requirePrincipal(w, r) - if !ok { - return - } - var req LeaseRequestDTO - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman { - if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor); err != nil { - writeDocGovError(w, err) - return - } - req.HolderID = fmt.Sprintf("%d", user.ID) - } - workDir, werr := s.resolver.ResolveDocWorkDir(r.Context(), req.DocID) - if werr != nil { - workDir = "" - } - ttl := time.Duration(req.TTLSeconds) * time.Second - doc, err := s.gov.AcquireLease(r.Context(), docgov.LeaseAcquireRequest{ - DocID: req.DocID, - HolderType: vocabulary.LeaseHolderType(req.HolderType), - HolderID: req.HolderID, - WorkDir: workDir, - TTL: ttl, - }) - if err != nil { - writeDocGovError(w, err) - return - } - writeJSONResponse(w, http.StatusOK, doc) -} - -func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requirePrincipal(w, r); !ok { - return - } - var req LeaseRequestDTO - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - ttl := time.Duration(req.TTLSeconds) * time.Second - doc, err := s.gov.HeartbeatLease(r.Context(), req.DocID, req.HolderType, req.HolderID, ttl) - if err != nil { - writeDocGovError(w, err) - return - } - writeJSONResponse(w, http.StatusOK, doc) -} - -func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requirePrincipal(w, r); !ok { - return - } - var req LeaseRequestDTO - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - if s.gov == nil { - writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") - return - } - doc, err := s.gov.ReleaseLease(r.Context(), req.DocID, req.HolderType, req.HolderID) - if err != nil { - writeDocGovError(w, err) - return - } - writeJSONResponse(w, http.StatusOK, doc) -} diff --git a/internal/server/handlers_chat_history.go b/internal/server/handlers_chat_history.go new file mode 100644 index 00000000..c00e3820 --- /dev/null +++ b/internal/server/handlers_chat_history.go @@ -0,0 +1,235 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "chat-agent-system/internal/app/chatflow" + "chat-agent-system/internal/ctxmgr" +) + +// ----------------------------------------------------------------------------- +// 历史多轮会话管理与标杆案例语义检索 RESTful 控制器 +// ----------------------------------------------------------------------------- + +// handleListChatSessions 分页查询用户/项目关联的历史会话列表 +// user_id 仅允许等于当前认证用户本人(或系统管理员),防止水平越权枚举他人会话。 +func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) { + if s.ctxMgr == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") + return + } + + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + + userIDStr := r.URL.Query().Get("user_id") + userID, _ := strconv.ParseInt(userIDStr, 10, 64) + if userID > 0 && userID != user.ID && !s.isSystemAdminUser(r.Context(), user) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权查询其他用户的会话列表", + }) + return + } + // 未显式传 user_id 时默认查本人,而不是任意/默认用户 + if userID <= 0 { + userID = user.ID + } + projectIDStr := r.URL.Query().Get("project_id") + projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) + + limitStr := r.URL.Query().Get("limit") + limit, _ := strconv.Atoi(limitStr) + if limit <= 0 { + limit = 20 + } + offsetStr := r.URL.Query().Get("offset") + offset, _ := strconv.Atoi(offsetStr) + if offset < 0 { + offset = 0 + } + + sessions, err := s.ctxMgr.ListChatSessions(r.Context(), ctxmgr.SessionFilter{ + UserID: userID, + ProjectID: projectID, + Limit: limit, + Offset: offset, + }) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "list chat sessions failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, sessions) +} + +// handleUpsertChatSession 注册或更新会话元数据(立项/成员加入时同步)。 +// 默认归属认证用户本人;项目管理员可为其他成员代注册(防水平越权)。 +func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) { + if s.ctxMgr == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") + return + } + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + + var req upsertChatSessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body") + return + } + if req.SessionID == "" { + writeSafeError(w, r, http.StatusBadRequest, "missing session_id") + return + } + + ownerID := user.ID + if req.UserID > 0 && req.UserID != user.ID { + if req.ProjectID <= 0 { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权为其他用户注册项目会话", + }) + return + } + can, err := s.authzSvc.CanManageProjectMembers(r.Context(), req.ProjectID, user.ID, user.RoleID) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + return + } + if !can { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权为其他用户注册项目会话", + }) + return + } + ownerID = req.UserID + } + + summary, err := s.ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, ownerID, req.ProjectID, req.Title) + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权操作该会话", + }) + return + } + writeSafeError(w, r, http.StatusInternalServerError, "upsert chat session failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, summary) +} + +// handleGetChatMessages 分页获取指定会话的历史多轮消息记录 +func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) { + if s.ctxMgr == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") + return + } + + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param") + return + } + + limitStr := r.URL.Query().Get("limit") + limit, _ := strconv.Atoi(limitStr) + if limit <= 0 { + limit = 50 + } + offsetStr := r.URL.Query().Get("offset") + offset, _ := strconv.Atoi(offsetStr) + if offset < 0 { + offset = 0 + } + + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + + if s.chatLog == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") + return + } + + msgs, err := s.chatLog.History(r.Context(), s.chatPrincipal(user, chatflow.ActionReadWindow), chatflow.HistoryQuery{ + SessionID: sessionID, + Limit: limit, + Offset: offset, + }) + if err != nil { + s.writeChatLogError(w, err) + return + } + + writeJSONResponse(w, http.StatusOK, msgs) +} + +// handleRecommendCases 基于语义向量检索往期相似标杆案例 +func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + if s.ctxMgr == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") + return + } + + projectID, _ := strconv.ParseInt(r.URL.Query().Get("project_id"), 10, 64) + query := r.URL.Query().Get("query") + if query == "" { + writeSafeError(w, r, http.StatusBadRequest, "missing query param") + return + } + + taskKey := r.URL.Query().Get("task_key") + excludeDoc := r.URL.Query().Get("exclude_doc") + limitStr := r.URL.Query().Get("limit") + limit, _ := strconv.Atoi(limitStr) + if limit <= 0 { + limit = 3 + } + + minScoreStr := r.URL.Query().Get("min_score") + minScore, _ := strconv.ParseFloat(minScoreStr, 64) + if minScore <= 0 { + minScore = 0.50 + } + + cases, err := s.ctxMgr.SearchSimilarCases(r.Context(), ctxmgr.CaseSearchOptions{ + Query: query, + TaskKey: taskKey, + ExcludeDoc: excludeDoc, + Limit: limit, + MinScore: minScore, + }) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "search similar cases failed: "+err.Error()) + return + } + + if projectID > 0 && len(cases) > 0 { + filtered := make([]ctxmgr.CaseRecommendationItem, 0, len(cases)) + for _, c := range cases { + if s.allowWorkflowDocTaskAccess(r.Context(), projectID, user.ID, c.TaskKey) { + filtered = append(filtered, c) + } + } + cases = filtered + } + + writeJSONResponse(w, http.StatusOK, cases) +} diff --git a/internal/server/handlers_chat_task.go b/internal/server/handlers_chat_task.go new file mode 100644 index 00000000..c6d963e1 --- /dev/null +++ b/internal/server/handlers_chat_task.go @@ -0,0 +1,281 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "path/filepath" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/chat" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/projectworkflow" +) + +func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param") + return + } + + // 传入真实 caller,触发 getOrCreateSession 归属校验;无 owner 的遗留会话仍可读。 + sess, err := s.getOrCreateSession(r.Context(), user.ID, sessionID, "", "", "", "", "", "", "", "") + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + writeSafeError(w, r, http.StatusNotFound, "session or project context not found") + return + } + if sess == nil || sess.UserCtx == nil { + writeSafeError(w, r, http.StatusNotFound, "session or project context not found") + return + } + + writeJSONResponse(w, http.StatusOK, sess.UserCtx) +} + +func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + + var req ChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if req.Message == "" { + writeSafeError(w, r, http.StatusBadRequest, "message cannot be empty") + return + } + req.WorkDir = "" + + sess, err := s.getOrCreateSession( + r.Context(), + user.ID, + req.SessionID, + req.ProjectName, + req.CustomerName, + req.DeviceType, + req.IntendedUse, + req.TargetMarket, + req.WorkDir, + req.UserName, + req.UserRole, + ) + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) + return + } + s.hydrateVisibleHistory(r.Context(), sess, user.ID) + if req.ProjectID > 0 && req.ProjectID != sess.Project() { + sess.SetProjectID(req.ProjectID) + } + if !s.enforceChatRequiresProject(w, r, user, sess.Project()) { + return + } + if sess.Project() > 0 { + if !s.enforceChatProjectAccess(w, r, user, sess.Project()) { + return + } + } + s.enrichSessionWorkflowStage(r.Context(), sess, user.ID) + if sess.Project() > 0 && !s.allowActiveStageChatAccess(r.Context(), sess.Project(), user.ID) { + s.writeWorkflowAccessDenied(w) + return + } + s.bindSessionDocumentSummaryReader(sess, user) + + var resp *chat.ChatResponse + err = sess.WithExecLock(func() error { + var sendErr error + resp, sendErr = sess.SendMessage(r.Context(), req.Message) + return sendErr + }) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "process chat message failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, resp) +} + +func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + + var req TaskRunRequestDTO + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + req.UserID = user.ID + + if req.ProjectID > 0 { + if !s.isSystemAdminUser(r.Context(), user) { + if err := s.authorizer.RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权访问该项目", + }) + return + } + } + // 工作流作用域:任务键不属于当前激活工作流时拦截并引导切换(不执行)。 + if blocked, guidance := s.taskKeyOutsideActiveWorkflow(r.Context(), req.ProjectID, req.TaskKey); blocked { + writeJSONResponse(w, http.StatusConflict, map[string]string{ + "error_code": "ERR_DOC_WRONG_WORKFLOW", + "message": guidance, + }) + return + } + } + + // 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例 + var runRecord *projectworkflow.WorkflowRun + if s.pwSvc != nil && req.ProjectID > 0 { + wfKey := req.WorkflowKey + if wfKey == "" { + if p, _ := s.pwSvc.GetProject(r.Context(), req.ProjectID, req.UserID); p != nil { + wfKey = p.ActiveWorkflowKey + } + } + if wfKey == "" { + wfKey = "main" + } + manifest := map[string]interface{}{ + "fields": req.Fields, + "doc_id": req.DocID, + } + runRecord, _ = s.pwSvc.CreateWorkflowRun(r.Context(), &projectworkflow.WorkflowRun{ + ProjectID: req.ProjectID, + WorkflowKey: wfKey, + Status: "running", + StartedBy: &req.UserID, + StageKey: req.Stage, + TaskKey: req.TaskKey, + InputManifest: manifest, + }) + } + + // 同步任务可能远超全局 WriteTimeout(120s)。先把本响应的写期限拉长, + // 否则业务已执行完但客户端收不到结果、重试会表现为重复执行。 + extendSyncTaskWriteDeadline(w) + + // 1. 如果指定了 stage,则执行成熟度阶段编排 + if req.Stage != "" { + stageRes, err := s.taskUseCase().ExecuteStage(r.Context(), bizdev.StageRunRequest{ + Stage: bizdev.MaturityStage(req.Stage), + ProjectName: req.ProjectName, + CustomerName: req.CustomerName, + DeviceType: req.DeviceType, + IntendedUse: req.IntendedUse, + TargetMarket: req.TargetMarket, + Fields: req.Fields, + Backend: resolveTaskBackend(s, req.Backend), + AutoRepair: req.AutoRepair, + UserID: req.UserID, + ProjectID: req.ProjectID, + DocID: req.DocID, + AuthorID: req.AuthorID, + AuthorName: req.AuthorName, + ReviewerID: req.ReviewerID, + ReviewerName: req.ReviewerName, + ApproverID: req.ApproverID, + ApproverName: req.ApproverName, + ForceDirectEdit: req.ForceDirectEdit, + }) + if err != nil { + if runRecord != nil { + _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) + } + writeTaskError(w, err, stageRes) + return + } + if runRecord != nil && stageRes != nil && stageRes.PrimaryArtifact != nil { + status := resolveTerminalStatus(stageRes.PrimaryArtifact) + outDocID := "" + if stageRes.PrimaryArtifact.DocRecord != nil { + outDocID = stageRes.PrimaryArtifact.DocRecord.DocID + } + outSummary := stageRes.PrimaryArtifact.ArtifactTitle + if outSummary == "" { + outSummary = stageRes.StageTitle + } + _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, status, outDocID, outSummary, "") + } + writeJSONResponse(w, http.StatusOK, stageRes) + return + } + + // 2. 执行单任务 + if req.TaskKey == "" { + req.TaskKey = "intake" + } + taskRes, err := s.taskUseCase().ExecuteTask(r.Context(), bizdev.TaskRunRequest{ + TaskKey: req.TaskKey, + ProjectName: req.ProjectName, + CustomerName: req.CustomerName, + DeviceType: req.DeviceType, + IntendedUse: req.IntendedUse, + TargetMarket: req.TargetMarket, + Fields: req.Fields, + Materials: req.Materials, + Backend: resolveTaskBackend(s, req.Backend), + AutoRepair: req.AutoRepair, + UserID: req.UserID, + ProjectID: req.ProjectID, + DocID: req.DocID, + AuthorID: req.AuthorID, + AuthorName: req.AuthorName, + ReviewerID: req.ReviewerID, + ReviewerName: req.ReviewerName, + ApproverID: req.ApproverID, + ApproverName: req.ApproverName, + ForceDirectEdit: req.ForceDirectEdit, + CustomSteps: req.CustomSteps, + }) + if err != nil { + if runRecord != nil { + _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) + } + writeTaskError(w, err, taskRes) + return + } + + if runRecord != nil && taskRes != nil { + status := resolveTerminalStatus(taskRes) + outDocID := "" + if taskRes.DocRecord != nil { + outDocID = taskRes.DocRecord.DocID + } + _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, status, outDocID, taskRes.ArtifactTitle, "") + } + + // 隐藏不必要的中间大体积字节 + if taskRes.WorkDir != "" { + taskRes.WorkDir = filepath.Clean(taskRes.WorkDir) + } + + writeJSONResponse(w, http.StatusOK, taskRes) +} diff --git a/internal/server/handlers_context.go b/internal/server/handlers_context.go new file mode 100644 index 00000000..0b6b2dc6 --- /dev/null +++ b/internal/server/handlers_context.go @@ -0,0 +1,133 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + + "chat-agent-system/internal/ctxmgr" +) + +// ----------------------------------------------------------------------------- +// 多级上下文与偏好记忆 RESTful 控制器 +// ----------------------------------------------------------------------------- + +// handleGetUserContext 获取单用户私有上下文与个性化偏好 +// user_id 仅允许等于当前认证用户本人(或系统管理员),不再默认回落 user id=1。 +func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + userIDStr := r.URL.Query().Get("user_id") + projectIDStr := r.URL.Query().Get("project_id") + sessionID := r.URL.Query().Get("session_id") + + userID, _ := strconv.ParseInt(userIDStr, 10, 64) + if userID <= 0 { + userID = user.ID + } + if userID != user.ID && !s.isSystemAdminUser(r.Context(), user) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权查询其他用户的上下文", + }) + return + } + projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) + + if s.ctxMgr == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") + return + } + + uCtx, err := s.ctxMgr.GetUserContext(r.Context(), projectID, userID, sessionID) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "get user context failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, uCtx) +} + +// handleUpdateUserPreference 定期整理或前端保存用户习惯偏好 +func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + var req UpdateUserPreferenceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if req.UserID <= 0 { + req.UserID = user.ID + } + if req.UserID != user.ID && !s.isSystemAdminUser(r.Context(), user) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权修改其他用户的偏好", + }) + return + } + + if s.ctxMgr == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") + return + } + + pref := &ctxmgr.UserPreference{ + UserID: req.UserID, + CommunicationStyle: req.CommunicationStyle, + FocusAreas: req.FocusAreas, + OutputFormatPref: req.OutputFormatPref, + CustomInstructions: req.CustomInstructions, + } + + if err := s.ctxMgr.UpdateUserPreference(r.Context(), pref); err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "save user preference failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, map[string]any{"status": "ok", "user_id": req.UserID}) +} + +// handleGetProjectContext 获取项目级多方协同事实黑板与里程碑决策流 +func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + + projectIDStr := r.URL.Query().Get("project_id") + projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) + if projectID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, "missing or invalid project_id query param") + return + } + + // Membership first: allowAnyStageAccess assumes the caller already passed + // project membership (see ResolveStageAccess). Without this guard any + // authenticated user can read an activated project's collaborative context. + if !s.enforceChatProjectAccess(w, r, user, projectID) { + return + } + if !s.allowAnyStageAccess(r.Context(), projectID, user.ID) { + s.writeWorkflowAccessDenied(w) + return + } + + if s.ctxMgr == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") + return + } + + pCtx, err := s.ctxMgr.GetProjectContext(r.Context(), projectID) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "get project context failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, pCtx) +} diff --git a/internal/server/handlers_docgov.go b/internal/server/handlers_docgov.go new file mode 100644 index 00000000..09d96b40 --- /dev/null +++ b/internal/server/handlers_docgov.go @@ -0,0 +1,395 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "chat-agent-system/internal/app/docflow" + "chat-agent-system/internal/app/uow" + "chat-agent-system/internal/contracts/vocabulary" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectinstance" +) + +// ----------------------------------------------------------------------------- +// 受控文档编制、审核、批准与认领生命周期 RESTful 控制器 +// ----------------------------------------------------------------------------- + +// handleDocGovGetDocument 查询指定受控文档详情与治理状态 +func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) { + docID := r.URL.Query().Get("doc_id") + if docID == "" { + writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param") + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + + if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { + return + } + + doc, err := s.gov.GetDocument(r.Context(), docID) + if err != nil { + writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, doc) +} + +// handleDocGovClaim 查阅并认领受控文档 (非模板状态查看方可认领) +func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + + var req DocGovClaimRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if req.DocID == "" { + writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty") + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + + doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, doc) +} + +// handleDocGovSubmitReview 编制人将文档送审 (drafting -> reviewing, 正文受控锁定) +func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + + var req DocGovSubmitReviewRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionSubmit) { + return + } + + doc, err := s.gov.SubmitForReview(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, user.Name) + if err != nil { + writeSafeError(w, r, http.StatusBadRequest, "submit review failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, doc) +} + +// handleDocGovReviewDecision 审核人决议 (通过流转至 approving,驳回必须填理由并回退 drafting 解锁) +func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + + var req DocGovDecisionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionReview) { + return + } + + action := strings.ToLower(strings.TrimSpace(req.Action)) + var doc *docgov.DocumentRecord + var err error + + if action == "approve" { + doc, err = s.gov.ReviewApprove(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, req.Comment) + } else if action == "reject" { + reason := req.Reason + if reason == "" { + reason = req.Comment + } + doc, err = s.gov.ReviewReject(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, reason) + } else { + writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject") + return + } + + if err != nil { + writeSafeError(w, r, http.StatusBadRequest, "review decision failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, doc) +} + +// handleDocGovApproverDecision 批准人决议 (通过流转至 approved 终态归档,驳回必填理由并回退) +func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + + var req DocGovDecisionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionApprove) { + return + } + + action := strings.ToLower(strings.TrimSpace(req.Action)) + cmds := s.docflowCommands() + if cmds == nil || cmds.Gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleApprover} + var doc *docgov.DocumentRecord + var err error + + if action == "approve" { + doc, err = cmds.Approve(r.Context(), p, docflow.DecisionCommand{DocID: req.DocID, Comment: req.Comment}) + } else if action == "reject" { + reason := req.Reason + if reason == "" { + reason = req.Comment + } + doc, err = cmds.Reject(r.Context(), p, docflow.DecisionCommand{ + DocID: req.DocID, RejectReason: reason, RollbackTo: vocabulary.DocumentState(req.RollbackTo), + }) + } else { + writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject") + return + } + + if err != nil { + if errors.Is(err, uow.ErrPermissionDenied) { + writeSafeError(w, r, http.StatusForbidden, "approver decision failed: "+err.Error()) + return + } + if errors.Is(err, uow.ErrBusy) { + writeSafeError(w, r, http.StatusConflict, "approver decision failed: "+err.Error()) + return + } + writeSafeError(w, r, http.StatusBadRequest, "approver decision failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, doc) +} + +// handleDocGovProposeModification 对已认领文档发起变更提案(写库 + 审计) +func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + + var req ProposeModificationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if strings.TrimSpace(req.DocID) == "" { + writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty") + return + } + if strings.TrimSpace(req.ProposedChanges) == "" { + writeSafeError(w, r, http.StatusBadRequest, "proposed_changes cannot be empty") + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + + triggeredBy := strings.TrimSpace(req.TriggeredBy) + if triggeredBy == "" { + triggeredBy = fmt.Sprintf("user:%d", user.ID) + } + + proposal, err := s.gov.ProposeModification( + r.Context(), + req.DocID, + triggeredBy, + req.ProposedChanges, + req.ImpactAnalysis, + req.RiskAssessment, + ) + if err != nil { + if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound { + writeSafeError(w, r, status, "document not found") + return + } + writeSafeError(w, r, http.StatusBadRequest, "propose modification failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, proposal) +} + +// handleDocGovDecideProposal 编制负责人拍板决定是否采纳变更提案 +func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + + var req DecideProposalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if strings.TrimSpace(req.ProposalID) == "" { + writeSafeError(w, r, http.StatusBadRequest, "proposal_id cannot be empty") + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + + proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason) + if err != nil { + if status, ok := domainHTTPStatus(err); ok { + switch status { + case http.StatusNotFound: + writeSafeError(w, r, status, "proposal not found") + case http.StatusForbidden: + writeSafeError(w, r, status, "decide proposal failed: "+err.Error()) + default: + writeSafeError(w, r, status, err.Error()) + } + return + } + writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, proposal) +} + +// handleDocGovListComments 查询批注与修改建议清单 +func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request) { + docID := r.URL.Query().Get("doc_id") + if docID == "" { + writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param") + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + + if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { + return + } + + comments, err := s.gov.ListComments(r.Context(), docID) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, comments) +} + +// handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议 +// actor 身份一律取认证 principal,禁止请求体伪造 user_id/user_name/user_role。 +func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) { + var req AddCommentRequestDTO + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + + user, ok := s.requireDocGovDocAccess(w, r, req.DocID) + if !ok { + return + } + + cType := vocabulary.CommentType(req.CommentType) + if cType == "" { + cType = vocabulary.CommentGeneral + } + + comment, err := s.gov.AddComment( + r.Context(), req.DocID, user.ID, user.Name, + s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content, + ) + if err != nil { + writeDocGovError(w, err) + return + } + + writeJSONResponse(w, http.StatusOK, comment) +} + +// handleDocGovListAuditLogs 查询文档全生命周期审计轨迹流 +func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Request) { + docID := r.URL.Query().Get("doc_id") + if docID == "" { + writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param") + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + + if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { + return + } + + logs, err := s.gov.ListAuditLogs(r.Context(), docID) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, logs) +} diff --git a/internal/server/handlers_docgov_lease.go b/internal/server/handlers_docgov_lease.go new file mode 100644 index 00000000..a36238e8 --- /dev/null +++ b/internal/server/handlers_docgov_lease.go @@ -0,0 +1,126 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "chat-agent-system/internal/contracts/vocabulary" + "chat-agent-system/internal/docgov" +) + +// ----------------------------------------------------------------------------- +// 文档正文保存与编辑租约 +// ----------------------------------------------------------------------------- +func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + var req SaveContentRequestDTO + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + role := vocabulary.RoleAuthor + res, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{ + DocID: req.DocID, + ActorID: user.ID, + ActorRole: role, + ActorName: user.Name, + MarkdownBody: req.MarkdownBody, + ExpectedSHA256: req.ExpectedSHA256, + }) + if err != nil { + writeDocGovError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, res) +} +func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + var req LeaseRequestDTO + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman { + if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor); err != nil { + writeDocGovError(w, err) + return + } + req.HolderID = fmt.Sprintf("%d", user.ID) + } + workDir, werr := s.resolver.ResolveDocWorkDir(r.Context(), req.DocID) + if werr != nil { + workDir = "" + } + ttl := time.Duration(req.TTLSeconds) * time.Second + doc, err := s.gov.AcquireLease(r.Context(), docgov.LeaseAcquireRequest{ + DocID: req.DocID, + HolderType: vocabulary.LeaseHolderType(req.HolderType), + HolderID: req.HolderID, + WorkDir: workDir, + TTL: ttl, + }) + if err != nil { + writeDocGovError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, doc) +} + +func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + var req LeaseRequestDTO + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + ttl := time.Duration(req.TTLSeconds) * time.Second + doc, err := s.gov.HeartbeatLease(r.Context(), req.DocID, req.HolderType, req.HolderID, ttl) + if err != nil { + writeDocGovError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, doc) +} + +func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + var req LeaseRequestDTO + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + doc, err := s.gov.ReleaseLease(r.Context(), req.DocID, req.HolderType, req.HolderID) + if err != nil { + writeDocGovError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, doc) +} diff --git a/internal/server/handlers_health.go b/internal/server/handlers_health.go new file mode 100644 index 00000000..f41ae52c --- /dev/null +++ b/internal/server/handlers_health.go @@ -0,0 +1,82 @@ +package server + +import ( + "context" + "net/http" + "time" +) + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + resp, code := s.buildHealthResponse(r.Context(), false) + writeJSONResponse(w, code, resp) +} + +func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) { + resp, code := s.buildHealthResponse(r.Context(), true) + writeJSONResponse(w, code, resp) +} + +type healthSnapshot struct { + at time.Time + strict bool + resp HealthResponse + code int + ok bool +} + +func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthResponse, int) { + const healthCacheTTL = 2 * time.Second + s.healthMu.Lock() + if s.healthCache.ok && s.healthCache.strict == strict && time.Since(s.healthCache.at) < healthCacheTTL { + resp, code := s.healthCache.resp, s.healthCache.code + s.healthMu.Unlock() + return resp, code + } + s.healthMu.Unlock() + + dbCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + dbReady := s.svc != nil && s.svc.DatabaseReady() + schemaVersion := int64(0) + if s.svc != nil { + if dbReady { + schemaVersion = s.svc.LiveSchemaVersion(dbCtx) + } else { + schemaVersion = s.svc.SchemaVersion() + } + } + schemaReady := schemaVersion > 0 || (s.cfg != nil && s.cfg.AllowInMemory) + taskStoreReady := s.rt != nil && s.rt.TaskStoreReady(dbCtx) + + status := "ok" + code := http.StatusOK + if s.cfg != nil && s.cfg.ContextDbHost != "" && !s.cfg.AllowInMemory { + if !dbReady { + status = "degraded" + code = http.StatusServiceUnavailable + } else if strict && (!schemaReady || !taskStoreReady) { + status = "not_ready" + code = http.StatusServiceUnavailable + } + } + + kbReady := s.kb != nil && s.kb.Online() + + resp := HealthResponse{ + Status: status, + Env: s.cfg.Env, + AgentBackend: s.cfg.AgentBackend, + Version: "v1.0", + SharedKBPort: s.cfg.KbDbPort, + KBReady: kbReady, + DatabaseReady: dbReady, + SchemaVersion: schemaVersion, + SchemaReady: schemaReady, + TaskStoreReady: taskStoreReady, + } + s.healthMu.Lock() + s.healthCache = healthSnapshot{at: time.Now(), strict: strict, resp: resp, code: code, ok: true} + s.healthMu.Unlock() + return resp, code +} diff --git a/internal/server/handlers_http.go b/internal/server/handlers_http.go new file mode 100644 index 00000000..bfd755ae --- /dev/null +++ b/internal/server/handlers_http.go @@ -0,0 +1,18 @@ +package server + +import ( + "encoding/json" + "net/http" + + "chat-agent-system/internal/httpx" +) + +func writeJSONResponse(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy string) { + httpx.Write(w, status, httpx.CodeFromStatus(status), legacy, httpx.RequestID(r), nil) +}