Tag messages with stage_key metadata, block sends for unauthorized roles, and filter history/search/case results by allowed stages. Co-authored-by: Cursor <cursoragent@cursor.com>
1054 lines
33 KiB
Go
1054 lines
33 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/bizdev"
|
|
"chat-agent-system/internal/ctxmgr"
|
|
"chat-agent-system/internal/docgov"
|
|
"chat-agent-system/internal/projectworkflow"
|
|
"chat-agent-system/internal/squad"
|
|
)
|
|
|
|
// HealthResponse 健康检查响应
|
|
type HealthResponse struct {
|
|
Status string `json:"status"`
|
|
Env string `json:"env"`
|
|
AgentBackend string `json:"agent_backend"`
|
|
Version string `json:"version"`
|
|
SharedKBPort int `json:"shared_kb_port"`
|
|
DatabaseReady bool `json:"database_ready"`
|
|
SchemaVersion int64 `json:"schema_version,omitempty"`
|
|
}
|
|
|
|
// ChatRequest 对话请求
|
|
type ChatRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
Message string `json:"message"`
|
|
ProjectName string `json:"project_name,omitempty"`
|
|
CustomerName string `json:"customer_name,omitempty"`
|
|
DeviceType string `json:"device_type,omitempty"`
|
|
IntendedUse string `json:"intended_use,omitempty"`
|
|
TargetMarket string `json:"target_market,omitempty"`
|
|
WorkDir string `json:"work_dir,omitempty"`
|
|
UserName string `json:"user_name,omitempty"`
|
|
UserRole string `json:"user_role,omitempty"`
|
|
UserID int64 `json:"user_id,omitempty"`
|
|
ProjectID int64 `json:"project_id,omitempty"`
|
|
}
|
|
|
|
// TaskRunRequestDTO 独立任务运行请求
|
|
type TaskRunRequestDTO struct {
|
|
TaskKey string `json:"task_key"`
|
|
Stage string `json:"stage,omitempty"` // 若指定 stage,则执行成熟度阶段跃迁
|
|
ProjectName string `json:"project_name"`
|
|
CustomerName string `json:"customer_name"`
|
|
DeviceType string `json:"device_type"`
|
|
IntendedUse string `json:"intended_use"`
|
|
TargetMarket string `json:"target_market"`
|
|
Fields map[string]string `json:"fields,omitempty"`
|
|
Backend string `json:"backend,omitempty"`
|
|
AutoRepair bool `json:"auto_repair"`
|
|
UserID int64 `json:"user_id,omitempty"`
|
|
ProjectID int64 `json:"project_id,omitempty"`
|
|
DocID string `json:"doc_id,omitempty"`
|
|
AuthorID int64 `json:"author_id,omitempty"`
|
|
AuthorName string `json:"author_name,omitempty"`
|
|
ReviewerID int64 `json:"reviewer_id,omitempty"`
|
|
ReviewerName string `json:"reviewer_name,omitempty"`
|
|
ApproverID int64 `json:"approver_id,omitempty"`
|
|
ApproverName string `json:"approver_name,omitempty"`
|
|
ForceDirectEdit bool `json:"force_direct_edit,omitempty"`
|
|
CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"`
|
|
WorkflowKey string `json:"workflow_key,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
ClientTurnID string `json:"client_turn_id,omitempty"`
|
|
// Materials 用户上传的参考文档材料,由前端 Base64 编码后传入;后端 json 反序列化自动解码 []byte
|
|
Materials []bizdev.MaterialInput `json:"materials,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
dbReady := s.svc != nil && s.svc.DatabaseReady()
|
|
status := "ok"
|
|
code := http.StatusOK
|
|
if s.cfg != nil && s.cfg.ContextDbHost != "" && !s.cfg.AllowInMemory && !dbReady {
|
|
status = "degraded"
|
|
code = http.StatusServiceUnavailable
|
|
}
|
|
resp := HealthResponse{
|
|
Status: status,
|
|
Env: s.cfg.Env,
|
|
AgentBackend: s.cfg.AgentBackend,
|
|
Version: "v1.0",
|
|
SharedKBPort: s.cfg.KbDbPort,
|
|
DatabaseReady: dbReady,
|
|
}
|
|
if s.svc != nil {
|
|
resp.SchemaVersion = s.svc.SchemaVersion()
|
|
}
|
|
writeJSONResponse(w, code, resp)
|
|
}
|
|
|
|
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
|
sessionID := r.URL.Query().Get("session_id")
|
|
if sessionID == "" {
|
|
http.Error(w, "missing session_id query param", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
sess := s.getOrCreateSession(sessionID, "", "", "", "", "", "", "", "")
|
|
if sess == nil || sess.UserCtx == nil {
|
|
http.Error(w, "session or project context not found", http.StatusNotFound)
|
|
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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Message == "" {
|
|
http.Error(w, "message cannot be empty", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
sess := s.getOrCreateSession(
|
|
req.SessionID,
|
|
req.ProjectName,
|
|
req.CustomerName,
|
|
req.DeviceType,
|
|
req.IntendedUse,
|
|
req.TargetMarket,
|
|
req.WorkDir,
|
|
req.UserName,
|
|
req.UserRole,
|
|
)
|
|
sess.UserID = user.ID
|
|
if req.ProjectID > 0 {
|
|
sess.ProjectID = req.ProjectID
|
|
}
|
|
s.enrichSessionWorkflowStage(r.Context(), sess, user.ID)
|
|
if sess.ProjectID > 0 && !s.allowActiveStageChatAccess(r.Context(), sess.ProjectID, user.ID) {
|
|
s.writeWorkflowAccessDenied(w)
|
|
return
|
|
}
|
|
|
|
resp, err := sess.SendMessage(r.Context(), req.Message)
|
|
if err != nil {
|
|
http.Error(w, "process chat message failed: "+err.Error(), http.StatusInternalServerError)
|
|
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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.UserID = user.ID
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
// 1. 如果指定了 stage,则执行成熟度阶段编排
|
|
if req.Stage != "" {
|
|
stageRes, err := s.svc.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: 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.svc.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: 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 writeTaskError(w http.ResponseWriter, err error, payload any) {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, docgov.ErrDocumentBusy) {
|
|
status = http.StatusConflict
|
|
} else if errors.Is(err, docgov.ErrDocumentLocked) || errors.Is(err, docgov.ErrForceEditNotAllowed) {
|
|
status = http.StatusConflict
|
|
} else if errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) {
|
|
status = http.StatusBadRequest
|
|
}
|
|
if payload != nil {
|
|
writeJSONResponse(w, status, payload)
|
|
return
|
|
}
|
|
http.Error(w, err.Error(), status)
|
|
}
|
|
|
|
func writeDocGovError(w http.ResponseWriter, err error) {
|
|
var busy *docgov.DocumentBusyError
|
|
if errors.As(err, &busy) {
|
|
writeJSONResponse(w, http.StatusConflict, map[string]any{
|
|
"error": "document_busy",
|
|
"detail": busy,
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, docgov.ErrDocumentBusy) {
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
if errors.Is(err, docgov.ErrDocumentLocked) {
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
if errors.Is(err, docgov.ErrDocumentNotFound) {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, docgov.ErrLeaseNotHeld) || errors.Is(err, docgov.ErrLeaseExpired) {
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 多级上下文与偏好记忆 RESTful 控制器
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// handleGetUserContext 获取单用户私有上下文与个性化偏好
|
|
func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
|
|
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 = 1
|
|
}
|
|
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
|
|
|
|
if s.svc == nil || s.svc.ContextManager() == nil {
|
|
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
uCtx, err := s.svc.ContextManager().GetUserContext(r.Context(), projectID, userID, sessionID)
|
|
if err != nil {
|
|
http.Error(w, "get user context failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, uCtx)
|
|
}
|
|
|
|
// UpdateUserPreferenceRequest 更新用户工作偏好请求
|
|
type UpdateUserPreferenceRequest struct {
|
|
UserID int64 `json:"user_id"`
|
|
CommunicationStyle ctxmgr.CommunicationStyle `json:"communication_style"`
|
|
FocusAreas []string `json:"focus_areas"`
|
|
OutputFormatPref string `json:"output_format_pref"`
|
|
CustomInstructions string `json:"custom_instructions"`
|
|
}
|
|
|
|
// handleUpdateUserPreference 定期整理或前端保存用户习惯偏好
|
|
func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Request) {
|
|
var req UpdateUserPreferenceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.UserID <= 0 {
|
|
http.Error(w, "user_id must be positive", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.ContextManager() == nil {
|
|
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
pref := &ctxmgr.UserPreference{
|
|
UserID: req.UserID,
|
|
CommunicationStyle: req.CommunicationStyle,
|
|
FocusAreas: req.FocusAreas,
|
|
OutputFormatPref: req.OutputFormatPref,
|
|
CustomInstructions: req.CustomInstructions,
|
|
}
|
|
|
|
if err := s.svc.ContextManager().UpdateUserPreference(r.Context(), pref); err != nil {
|
|
http.Error(w, "save user preference failed: "+err.Error(), http.StatusInternalServerError)
|
|
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) {
|
|
projectIDStr := r.URL.Query().Get("project_id")
|
|
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
|
|
if projectID <= 0 {
|
|
http.Error(w, "missing or invalid project_id query param", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.ContextManager() == nil {
|
|
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
pCtx, err := s.svc.ContextManager().GetProjectContext(r.Context(), projectID)
|
|
if err != nil {
|
|
http.Error(w, "get project context failed: "+err.Error(), http.StatusInternalServerError)
|
|
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 == "" {
|
|
http.Error(w, "missing doc_id query param", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
doc, err := s.svc.DocGov().GetDocument(r.Context(), docID)
|
|
if err != nil {
|
|
http.Error(w, "get document failed: "+err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, doc)
|
|
}
|
|
|
|
// DocGovClaimRequest 认领文档请求
|
|
type DocGovClaimRequest struct {
|
|
DocID string `json:"doc_id"`
|
|
UserID int64 `json:"user_id"`
|
|
UserName string `json:"user_name"`
|
|
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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.DocID == "" {
|
|
http.Error(w, "doc_id cannot be empty", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
doc, err := s.svc.DocGov().RecordDocumentViewed(r.Context(), req.DocID, user.ID, docgov.UserRole(req.UserRole), user.Name)
|
|
if err != nil {
|
|
http.Error(w, "claim document failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, doc)
|
|
}
|
|
|
|
// DocGovSubmitReviewRequest 送审请求
|
|
type DocGovSubmitReviewRequest struct {
|
|
DocID string `json:"doc_id"`
|
|
UserID int64 `json:"user_id"`
|
|
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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
doc, err := s.svc.DocGov().SubmitForReview(r.Context(), req.DocID, user.ID, docgov.RoleAuthor, user.Name)
|
|
if err != nil {
|
|
http.Error(w, "submit review failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, doc)
|
|
}
|
|
|
|
// DocGovDecisionRequest 审核/批准决议请求
|
|
type DocGovDecisionRequest struct {
|
|
DocID string `json:"doc_id"`
|
|
UserID int64 `json:"user_id"`
|
|
UserName string `json:"user_name"`
|
|
Action string `json:"action"` // approve 或 reject
|
|
Comment string `json:"comment,omitempty"`
|
|
Reason string `json:"reason,omitempty"` // 驳回时必填
|
|
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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
action := strings.ToLower(strings.TrimSpace(req.Action))
|
|
var doc *docgov.DocumentRecord
|
|
var err error
|
|
|
|
if action == "approve" {
|
|
doc, err = s.svc.DocGov().ReviewApprove(r.Context(), req.DocID, user.ID, docgov.RoleReviewer, user.Name, req.Comment)
|
|
} else if action == "reject" {
|
|
reason := req.Reason
|
|
if reason == "" {
|
|
reason = req.Comment
|
|
}
|
|
doc, err = s.svc.DocGov().ReviewReject(r.Context(), req.DocID, user.ID, docgov.RoleReviewer, user.Name, reason)
|
|
} else {
|
|
http.Error(w, "invalid action: must be approve or reject", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
http.Error(w, "review decision failed: "+err.Error(), http.StatusBadRequest)
|
|
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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
action := strings.ToLower(strings.TrimSpace(req.Action))
|
|
var doc *docgov.DocumentRecord
|
|
var err error
|
|
|
|
if action == "approve" {
|
|
doc, err = s.svc.DocGov().ApproverApprove(r.Context(), req.DocID, user.ID, docgov.RoleApprover, user.Name, req.Comment)
|
|
} else if action == "reject" {
|
|
reason := req.Reason
|
|
if reason == "" {
|
|
reason = req.Comment
|
|
}
|
|
rollbackState := docgov.DocumentState(req.RollbackTo)
|
|
doc, err = s.svc.DocGov().ApproverReject(r.Context(), req.DocID, user.ID, docgov.RoleApprover, user.Name, reason, rollbackState)
|
|
} else {
|
|
http.Error(w, "invalid action: must be approve or reject", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
http.Error(w, "approver decision failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, doc)
|
|
}
|
|
|
|
// DecideProposalRequest 拍板变更提案请求
|
|
type DecideProposalRequest struct {
|
|
ProposalID string `json:"proposal_id"`
|
|
AuthorID int64 `json:"author_id"`
|
|
Approve bool `json:"approve"`
|
|
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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
proposal, err := s.svc.DocGov().DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason)
|
|
if err != nil {
|
|
http.Error(w, "decide proposal failed: "+err.Error(), http.StatusBadRequest)
|
|
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 == "" {
|
|
http.Error(w, "missing doc_id query param", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
comments, err := s.svc.DocGov().ListComments(r.Context(), docID)
|
|
if err != nil {
|
|
http.Error(w, "list comments failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, comments)
|
|
}
|
|
|
|
// AddCommentRequestDTO 新增批注请求
|
|
type AddCommentRequestDTO struct {
|
|
DocID string `json:"doc_id"`
|
|
UserID int64 `json:"user_id"`
|
|
UserName string `json:"user_name"`
|
|
UserRole string `json:"user_role"`
|
|
TargetSection string `json:"target_section,omitempty"`
|
|
CommentType string `json:"comment_type,omitempty"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议
|
|
func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) {
|
|
var req AddCommentRequestDTO
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
cType := docgov.CommentType(req.CommentType)
|
|
if cType == "" {
|
|
cType = docgov.CommentGeneral
|
|
}
|
|
|
|
comment, err := s.svc.DocGov().AddComment(
|
|
r.Context(), req.DocID, req.UserID, req.UserName,
|
|
docgov.UserRole(req.UserRole), 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 == "" {
|
|
http.Error(w, "missing doc_id query param", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
logs, err := s.svc.DocGov().ListAuditLogs(r.Context(), docID)
|
|
if err != nil {
|
|
http.Error(w, "list audit logs failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, logs)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 历史多轮会话管理与标杆案例语义检索 RESTful 控制器
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// handleListChatSessions 分页查询用户/项目关联的历史会话列表
|
|
func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) {
|
|
if s.svc == nil || s.svc.ContextManager() == nil {
|
|
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
userIDStr := r.URL.Query().Get("user_id")
|
|
userID, _ := strconv.ParseInt(userIDStr, 10, 64)
|
|
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.svc.ContextManager().ListChatSessions(r.Context(), ctxmgr.SessionFilter{
|
|
UserID: userID,
|
|
ProjectID: projectID,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, "list chat sessions failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, sessions)
|
|
}
|
|
|
|
type upsertChatSessionRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
Title string `json:"title"`
|
|
UserID int64 `json:"user_id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
}
|
|
|
|
// handleUpsertChatSession 注册或更新会话元数据(立项/成员加入时同步)。
|
|
func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) {
|
|
if s.svc == nil || s.svc.ContextManager() == nil {
|
|
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
var req upsertChatSessionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.SessionID == "" {
|
|
http.Error(w, "missing session_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
summary, err := s.svc.ContextManager().EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title)
|
|
if err != nil {
|
|
http.Error(w, "upsert chat session failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, summary)
|
|
}
|
|
|
|
// handleGetChatMessages 分页获取指定会话的历史多轮消息记录
|
|
func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) {
|
|
if s.svc == nil || s.svc.ContextManager() == nil {
|
|
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
sessionID := r.URL.Query().Get("session_id")
|
|
if sessionID == "" {
|
|
http.Error(w, "missing session_id query param", http.StatusBadRequest)
|
|
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
|
|
}
|
|
|
|
msgs, err := s.svc.ContextManager().GetChatHistory(r.Context(), sessionID, limit, offset)
|
|
if err != nil {
|
|
http.Error(w, "get chat messages failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if projectID, pidErr := s.svc.ContextManager().GetSessionProjectID(r.Context(), sessionID); pidErr == nil && projectID > 0 {
|
|
msgs = s.filterChatMessagesByStageAccess(r.Context(), projectID, user.ID, msgs)
|
|
}
|
|
|
|
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.svc == nil || s.svc.ContextManager() == nil {
|
|
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
projectID, _ := strconv.ParseInt(r.URL.Query().Get("project_id"), 10, 64)
|
|
query := r.URL.Query().Get("query")
|
|
if query == "" {
|
|
http.Error(w, "missing query param", http.StatusBadRequest)
|
|
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.svc.ContextManager().SearchSimilarCases(r.Context(), ctxmgr.CaseSearchOptions{
|
|
Query: query,
|
|
TaskKey: taskKey,
|
|
ExcludeDoc: excludeDoc,
|
|
Limit: limit,
|
|
MinScore: minScore,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, "search similar cases failed: "+err.Error(), http.StatusInternalServerError)
|
|
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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
role := docgov.RoleAuthor
|
|
res, err := s.svc.DocGov().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) {
|
|
if _, ok := s.requirePrincipal(w, r); !ok {
|
|
return
|
|
}
|
|
var req LeaseRequestDTO
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
workDir, werr := s.resolver.ResolveDocWorkDir(r.Context(), req.DocID)
|
|
if werr != nil {
|
|
workDir = ""
|
|
}
|
|
ttl := time.Duration(req.TTLSeconds) * time.Second
|
|
doc, err := s.svc.DocGov().AcquireLease(r.Context(), docgov.LeaseAcquireRequest{
|
|
DocID: req.DocID,
|
|
HolderType: docgov.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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ttl := time.Duration(req.TTLSeconds) * time.Second
|
|
doc, err := s.svc.DocGov().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 {
|
|
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if s.svc == nil || s.svc.DocGov() == nil {
|
|
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
doc, err := s.svc.DocGov().ReleaseLease(r.Context(), req.DocID, req.HolderType, req.HolderID)
|
|
if err != nil {
|
|
writeDocGovError(w, err)
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, doc)
|
|
}
|