Files
Agent-System/internal/server/project_doc_handlers.go
T
weichen f3f23a3018 feat(errcode): 用稳定错误码记录 HTTP 与异步失败
失败响应此前不写日志,request_id 无法对照原因。注册表不覆盖处理器已经给出的 HTTP 状态。
2026-09-28 02:27:16 +00:00

243 lines
7.9 KiB
Go

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,
})
}