refactor(server): split handlers.go by domain
Keep slim handlers.go core; move chat/docgov/health/http/context handlers into domain files ≤800 lines without behavior change.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user