From bcfb9f415bfb2bc75bc1364a3d2538d76355c067 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Fri, 11 Sep 2026 16:46:53 +0800 Subject: [PATCH] feat(api): add workflow document list, content read, and backfill script Expose project controlled documents from DocGov, serve authenticated output.md content by doc_id, and add idempotent backfill for run_1789111889557885000. Co-authored-by: Cursor --- internal/docgov/project_docs.go | 132 ++++++++++++++++++++++++ internal/server/project_doc_handlers.go | 68 ++++++++++++ internal/server/server.go | 2 + scripts/backfill_task_run_projection.go | 83 +++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 internal/docgov/project_docs.go create mode 100644 internal/server/project_doc_handlers.go create mode 100644 scripts/backfill_task_run_projection.go diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go new file mode 100644 index 00000000..06498985 --- /dev/null +++ b/internal/docgov/project_docs.go @@ -0,0 +1,132 @@ +package docgov + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// WorkflowDocumentSummary 项目工作流受控文档列表项。 +type WorkflowDocumentSummary struct { + DocID string `json:"doc_id"` + ProjectID int64 `json:"project_id"` + TaskKey string `json:"task_key"` + Title string `json:"title"` + LifecycleState DocumentState `json:"lifecycle_state"` + IsTemplateOnly bool `json:"is_template_only"` + ContentSHA256 string `json:"content_sha256"` + WorkDir string `json:"work_dir,omitempty"` + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// DocumentContent 受控文档正文读取结果。 +type DocumentContent struct { + DocID string `json:"doc_id"` + Version int `json:"version"` + ContentSHA256 string `json:"content_sha256"` + Markdown string `json:"markdown"` + IsTemplateOnly bool `json:"is_template_only"` +} + +// ListWorkflowDocumentsByProject 列出项目下工作流受控文档(task_key 非空)。 +func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, projectID int64) ([]WorkflowDocumentSummary, error) { + if projectID <= 0 { + return nil, fmt.Errorf("invalid project_id") + } + + if e.pool != nil { + rows, err := e.pool.Query(ctx, ` + SELECT doc_id, project_id, task_key, title, current_state, is_template_only, + COALESCE(content_sha256, ''), COALESCE(work_dir, ''), version, updated_at + FROM document_records + WHERE project_id = $1 AND task_key <> '' + ORDER BY task_key ASC, doc_id ASC + `, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]WorkflowDocumentSummary, 0) + for rows.Next() { + var item WorkflowDocumentSummary + var state string + var updatedAt time.Time + if err := rows.Scan( + &item.DocID, &item.ProjectID, &item.TaskKey, &item.Title, &state, + &item.IsTemplateOnly, &item.ContentSHA256, &item.WorkDir, &item.Version, &updatedAt, + ); err != nil { + return nil, err + } + item.LifecycleState = DocumentState(state) + if !updatedAt.IsZero() { + item.UpdatedAt = updatedAt.Format(time.RFC3339) + } + out = append(out, item) + } + return out, rows.Err() + } + + e.mu.Lock() + defer e.mu.Unlock() + out := make([]WorkflowDocumentSummary, 0) + for _, doc := range e.memDocs { + if doc.ProjectID == projectID && doc.TaskKey != "" { + out = append(out, WorkflowDocumentSummary{ + DocID: doc.DocID, + ProjectID: doc.ProjectID, + TaskKey: doc.TaskKey, + Title: doc.Title, + LifecycleState: doc.CurrentState, + IsTemplateOnly: doc.IsTemplateOnly, + ContentSHA256: doc.ContentSHA256, + WorkDir: doc.WorkDir, + Version: doc.Version, + }) + } + } + return out, nil +} + +// ReadDocumentContent 读取受控文档 output.md 正文并校验 SHA-256。 +func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string, repoRoot string) (*DocumentContent, error) { + doc, err := e.GetDocument(ctx, docID) + if err != nil { + return nil, err + } + if doc.WorkDir == "" { + return nil, fmt.Errorf("document has no work_dir") + } + + workDir := doc.WorkDir + if !filepath.IsAbs(workDir) && repoRoot != "" { + workDir = filepath.Join(repoRoot, workDir) + } + path := filepath.Join(workDir, "output.md") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read document content: %w", err) + } + if len(data) == 0 { + return nil, fmt.Errorf("document content is empty") + } + + sum := sha256.Sum256(data) + shaStr := hex.EncodeToString(sum[:]) + if doc.ContentSHA256 != "" && !strings.EqualFold(doc.ContentSHA256, shaStr) { + return nil, fmt.Errorf("content sha256 mismatch: record=%s file=%s", doc.ContentSHA256, shaStr) + } + + return &DocumentContent{ + DocID: doc.DocID, + Version: doc.Version, + ContentSHA256: shaStr, + Markdown: string(data), + IsTemplateOnly: doc.IsTemplateOnly, + }, nil +} diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go new file mode 100644 index 00000000..0e5c630d --- /dev/null +++ b/internal/server/project_doc_handlers.go @@ -0,0 +1,68 @@ +package server + +import ( + "net/http" + "path/filepath" + "strconv" +) + +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 { + http.Error(w, "invalid project id", http.StatusBadRequest) + return + } + category := r.URL.Query().Get("category") + if category != "" && category != "workflow" { + http.Error(w, "unsupported category", http.StatusBadRequest) + return + } + + if s.svc == nil || s.svc.DocGov() == nil { + http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) + return + } + + docs, err := s.svc.DocGov().ListWorkflowDocumentsByProject(r.Context(), projectID) + if err != nil { + http.Error(w, "list project documents failed: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, docs) +} + +// handleDocGovGetDocumentContent 读取受控文档 Markdown 正文。 +func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.Request) { + docID := r.PathValue("doc_id") + if docID == "" { + http.Error(w, "doc_id required", http.StatusBadRequest) + return + } + + if s.svc == nil || s.svc.DocGov() == nil { + http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) + return + } + + content, err := s.svc.DocGov().ReadDocumentContent(r.Context(), docID, s.repoRoot()) + if err != nil { + http.Error(w, "read document content failed: "+err.Error(), http.StatusNotFound) + return + } + + if inm := r.Header.Get("If-None-Match"); inm != "" && inm == content.ContentSHA256 { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", content.ContentSHA256) + writeJSONResponse(w, http.StatusOK, content) +} diff --git a/internal/server/server.go b/internal/server/server.go index 6ed521ac..bd368c1f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -102,6 +102,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow) mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns) mux.HandleFunc("GET /api/v1/projects/{id}/lineage", s.handleGetProjectLineage) + mux.HandleFunc("GET /api/v1/projects/{id}/documents", s.handleListProjectWorkflowDocuments) // 3. 对话协同服务 (标准 JSON 阻塞) mux.HandleFunc("POST /api/v1/chat", s.handleChat) @@ -162,6 +163,7 @@ func (s *Server) Routes() http.Handler { // 8. 受控文档编制、审核、批准生命周期与认领治理 mux.HandleFunc("GET /api/v1/docgov/document", s.handleDocGovGetDocument) + mux.HandleFunc("GET /api/v1/docgov/documents/{doc_id}/content", s.handleDocGovGetDocumentContent) mux.HandleFunc("POST /api/v1/docgov/claim", s.handleDocGovClaim) mux.HandleFunc("POST /api/v1/docgov/submit-review", s.handleDocGovSubmitReview) mux.HandleFunc("POST /api/v1/docgov/review-decision", s.handleDocGovReviewDecision) diff --git a/scripts/backfill_task_run_projection.go b/scripts/backfill_task_run_projection.go new file mode 100644 index 00000000..382b710e --- /dev/null +++ b/scripts/backfill_task_run_projection.go @@ -0,0 +1,83 @@ +// 一次性幂等回填:run_1789111889557885000 / 消息 25548 / doc_proj56_intake +// +// 用法(dev 环境,需 Postgres 可达): +// go run ./scripts/backfill_task_run_projection.go +// +//go:build ignore + +package main + +import ( + "context" + "fmt" + "log" + "os" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/server" + "chat-agent-system/internal/squad" + "chat-agent-system/internal/taskrun" +) + +const ( + runID = "run_1789111889557885000" + messageID = int64(25548) + docID = "doc_proj56_intake" + expectedSHA256 = "5329861dadd23ea48baff7079a98fa56e1a1e32a0cf5deda2ca291e57902b94c" + artifactTitle = "用户初始需求收集表" + workDir = "data/dev/workspaces/bizdev/intake_1789111889595658000" +) + +func main() { + cfg, err := config.Load("") + if err != nil { + log.Fatal(err) + } + + svc := bizdev.NewService(cfg) + srv := server.NewServer(cfg, svc) + ctx := context.Background() + + pwSvc := svc.ProjectWorkflow() + if pwSvc == nil { + log.Fatal("project workflow service unavailable") + } + + record := &taskrun.RunRecord{ + RunID: runID, + SessionID: "sess-p56-u3", + ProjectID: 56, + TaskKey: "intake", + AssistantMsgID: messageID, + WorkspaceID: workDir, + } + + result := &bizdev.TaskRunResult{ + Success: true, + ArtifactTitle: artifactTitle, + WorkDir: workDir, + TaskKey: "intake", + QualityReport: &squad.QualityReport{ + Passed: false, + Score: 40, + Violations: []string{"不做展开", "篇幅有限", "从略"}, + }, + DocRecord: &docgov.DocumentRecord{ + DocID: docID, + ContentSHA256: expectedSHA256, + WorkDir: workDir, + }, + } + + status, finErr := srv.FinalizeTaskRunProjection(ctx, record, result) + if finErr != nil { + log.Fatalf("finalize failed: %v", finErr) + } + + fmt.Printf("backfill complete: status=%s run_id=%s message_id=%d doc_id=%s sha=%s\n", + status, runID, messageID, docID, expectedSHA256) + os.Exit(0) +}