package server import ( "context" "errors" "log/slog" "net/http" "path/filepath" "strconv" "time" "chat-agent-system/internal/app/docflow" "chat-agent-system/internal/artifactvault" "chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/docgov" "chat-agent-system/internal/docquery" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/workflowscope" ) func (s *Server) docflowQuery() *docflow.Query { if s == nil || s.svc() == nil { return nil } return &docflow.Query{Docs: &docquery.Service{ Gov: s.gov(), Rev: s.docRev(), RepoRoot: s.repoRoot(), Authorize: func(ctx context.Context, p docquery.Principal, rec *docgov.DocumentRecord) error { return s.authorizeControlledRead(ctx, p, rec) }, FavDegradedRead: s.cfg != nil && s.cfg.FAVDegradedRead, }} } func (s *Server) authorizeControlledRead(ctx context.Context, p docquery.Principal, rec *docgov.DocumentRecord) error { if rec == nil || p.UserID == 0 { return docquery.ErrClosed } if err := ctx.Err(); err != nil { return docquery.ErrClosed } admin := false if s.authzSvc() != nil { ok, _ := s.authzSvc().IsSystemAdmin(ctx, p.UserID, "") admin = ok } if s.authorizer() != nil { if err := s.authorizer().RequireProjectAccessOrAdmin(ctx, rec.ProjectID, p.UserID, admin); err != nil { return docquery.ErrClosed } } if rec.TaskKey != "" && !s.allowWorkflowDocTaskAccess(ctx, rec.ProjectID, p.UserID, rec.TaskKey) { return docquery.ErrClosed } return nil } func (s *Server) repoRoot() string { if s.cfg != nil && s.cfg.DataDir != "" { // DataDir 形如 ./data/dev;工作区路径形如 data/dev/workspaces/...,相对仓库根目录 return filepath.Clean(filepath.Join(s.cfg.DataDir, "..", "..")) } return "." } // handleListProjectWorkflowDocuments 列出项目工作流受控文档。 func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *http.Request) { projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || projectID <= 0 { writeSafeError(w, r, http.StatusBadRequest, "invalid project id") return } category := r.URL.Query().Get("category") if category != "" && category != "workflow" { writeSafeError(w, r, http.StatusBadRequest, "unsupported category") return } user, ok := s.requireProjectAccess(w, r, projectID) if !ok { return } if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } if s.instSvc() != nil { if err := s.instSvc().MaterializeLiveDocs(r.Context(), projectID); err != nil { slog.Warn("materialize live docs", "project_id", projectID, "err", err) } } docs, err := s.gov().ListWorkflowDocumentsByProject(r.Context(), projectID) if err != nil { writeDomainError(w, r, http.StatusInternalServerError, err) return } docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs) activeKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(r.Context(), projectID) for i := range docs { docs[i].WorkflowKey = workflowKeyForDocument(docs[i].TaskKey, activeKey, scopeTaskKeys) } writeJSONResponse(w, http.StatusOK, map[string]any{ "documents": docs, "active_workflow_key": activeKey, "workflow_scope_mode": string(workflowscope.CurrentMode()), }) } // handleDocGovGetDocumentContent 读取受控文档 Markdown 正文。 func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.Request) { docID := r.PathValue("doc_id") if docID == "" { writeSafeError(w, r, http.StatusBadRequest, "doc_id required") return } user, ok := s.requireAuthenticatedUser(w, r) if !ok { return } q := s.docflowQuery() if q == nil || q.Docs == nil || q.Docs.Gov == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } view, err := q.Get(r.Context(), user.ID, docID) if err != nil { if errors.Is(err, docquery.ErrLedgerMissing) && view != nil { writeJSONResponse(w, http.StatusOK, view) return } if errors.Is(err, docquery.ErrClosed) { writeJSONResponse(w, http.StatusNotFound, map[string]any{ "error_code": vocabulary.ErrCodeDocumentNotFound, "message": "document not found", }) return } if s.cfg != nil && s.cfg.FAVDegradedRead && s.gov() != nil { docRec, docErr := s.gov().GetDocument(r.Context(), docID) if docErr == nil && docRec != nil { env := s.cfg.Env if env == "" { env = "dev" } if payload, sha, verr := s.tryVaultDegraded(r.Context(), env, docID, artifactvault.ProjectKeyFromID(docRec.ProjectID)); verr == nil && len(payload) > 0 { content := &docgov.DocumentContent{ DocID: docID, Version: docRec.Version, ContentSHA256: sha, Markdown: string(payload), IsTemplateOnly: docRec.IsTemplateOnly, Source: "vault/degraded", } w.Header().Set("ETag", content.ContentSHA256) writeJSONResponse(w, http.StatusOK, content) return } } } writeControlledContentError(w, r, err) return } s.maybeAutoClaimOnView(r.Context(), user, docID) if inm := r.Header.Get("If-None-Match"); inm != "" && inm == view.ContentSHA256 { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("ETag", view.ContentSHA256) writeJSONResponse(w, http.StatusOK, view) } func (s *Server) loadControlledDocumentContent(ctx context.Context, docID string) (*docgov.DocumentContent, error) { if s == nil || s.gov() == nil { return nil, vocabulary.ErrDocumentNotFound } svc := &docquery.Service{Gov: s.gov(), Rev: s.docRev(), RepoRoot: s.repoRoot()} view, err := svc.Get(ctx, docquery.Principal{UserID: 1}, docquery.DocRef{DocID: docID}) if err != nil && !errors.Is(err, docquery.ErrLedgerMissing) { return nil, err } if view != nil && (view.Markdown != "" || view.IsTemplateOnly) { return view.ToDocumentContent(), nil } return s.gov().ReadDocumentContent(ctx, docID, s.repoRoot()) } // serveLedgerHeadOrConflict keeps the historical helper name. Projection is a // consistency cache; ledger head is served for missing/empty/drift. func serveLedgerHeadOrConflict(docID string, version int, tmpl bool, headMD, headSHA, recordSHA string, class docrevision.ProjectionClass) (*docgov.DocumentContent, error) { consistency := docquery.ConsistencyAligned switch class.Class { case "", docrevision.ProjectionAligned, docrevision.ProjectionNoLedger: consistency = docquery.ConsistencyAligned case docrevision.ProjectionMissing: consistency = docquery.ConsistencyMissing case docrevision.ProjectionEmpty: consistency = docquery.ConsistencyEmpty default: consistency = docquery.ConsistencyDrift } return &docgov.DocumentContent{ DocID: docID, Version: version, ContentSHA256: headSHA, Markdown: headMD, IsTemplateOnly: tmpl, Consistency: consistency, RecordSHA256: recordSHA, FileSHA256: class.RawSHA, }, nil } func writeControlledContentError(w http.ResponseWriter, r *http.Request, err error) { corr := r.Header.Get("X-Request-ID") if corr == "" { corr = strconv.FormatInt(time.Now().UnixNano(), 10) } var ce *docgov.ContentError if errors.As(err, &ce) { status := http.StatusConflict if mapped, ok := domainHTTPStatus(err); ok { status = mapped } writeJSONResponse(w, status, map[string]any{ "error_code": ce.Code, "message": ce.Error(), "retryable": ce.Retryable, "needs_admin": ce.NeedsAdmin, "correlation_id": corr, "record_sha256": ce.RecordSHA, "file_sha256": ce.FileSHA, "head_sha256": ce.HeadSHA, "projection_class": ce.Class, }) return } if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound { writeJSONResponse(w, status, map[string]any{ "error_code": docgov.ErrCodeDocumentNotFound, "message": err.Error(), "retryable": false, "needs_admin": false, "correlation_id": corr, }) return } writeJSONResponse(w, http.StatusConflict, map[string]any{ "error_code": "DOCUMENT_CONTENT_READ_FAILED", "message": err.Error(), "retryable": true, "needs_admin": false, "correlation_id": corr, }) }