package docgov import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "path/filepath" "strings" "time" "chat-agent-system/internal/docduty" ) // 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"` LeaseHolderType LeaseHolderType `json:"lease_holder_type,omitempty"` LeaseHolderID string `json:"lease_holder_id,omitempty"` LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"` LeaseHeartbeatAt *time.Time `json:"lease_heartbeat_at,omitempty"` AuthorID int64 `json:"author_id"` AuthorName string `json:"author_name"` ReviewerID int64 `json:"reviewer_id"` ReviewerName string `json:"reviewer_name"` ApproverID int64 `json:"approver_id"` ApproverName string `json:"approver_name"` } // 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 { if _, _, err := docduty.HealProjectDocuments(ctx, e.pool, projectID); err != nil { return nil, fmt.Errorf("heal project document duties: %w", err) } 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, COALESCE(lease_holder_type, ''), COALESCE(lease_holder_id, ''), lease_expires_at, lease_heartbeat_at, COALESCE(author_id, 0), COALESCE(author_name, ''), COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''), COALESCE(approver_id, 0), COALESCE(approver_name, '') 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, &item.LeaseHolderType, &item.LeaseHolderID, &item.LeaseExpiresAt, &item.LeaseHeartbeatAt, &item.AuthorID, &item.AuthorName, &item.ReviewerID, &item.ReviewerName, &item.ApproverID, &item.ApproverName, ); 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, LeaseHolderType: doc.LeaseHolderType, LeaseHolderID: doc.LeaseHolderID, LeaseExpiresAt: doc.LeaseExpiresAt, LeaseHeartbeatAt: doc.LeaseHeartbeatAt, AuthorID: doc.AuthorID, AuthorName: doc.AuthorName, ReviewerID: doc.ReviewerID, ReviewerName: doc.ReviewerName, ApproverID: doc.ApproverID, ApproverName: doc.ApproverName, }) } } return out, nil } // ReadDocumentContent 读取受控文档 output.md 并返回 typed ContentError。 // 记录不存在才是 DOCUMENT_NOT_FOUND;缺文件/空文件/哈希冲突不得伪装成 404。 func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string, repoRoot string) (*DocumentContent, error) { doc, err := e.GetDocument(ctx, docID) if err != nil { if errors.Is(err, ErrDocumentNotFound) { return nil, NewContentError(ErrCodeDocumentNotFound, err, false, false) } return nil, err } if doc.WorkDir == "" { return nil, NewContentError(ErrCodeProjectionMissing, ErrContentProjectionMissing, false, true) } 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 { ce := NewContentError(ErrCodeProjectionMissing, fmt.Errorf("%w: %v", ErrContentProjectionMissing, err), true, true) ce.RecordSHA = doc.ContentSHA256 return nil, ce } if len(data) == 0 { ce := NewContentError(ErrCodeProjectionEmpty, ErrContentProjectionEmpty, false, true) ce.RecordSHA = doc.ContentSHA256 return nil, ce } sum := sha256.Sum256(data) shaStr := hex.EncodeToString(sum[:]) if doc.ContentSHA256 != "" && !strings.EqualFold(doc.ContentSHA256, shaStr) { ce := NewContentError(ErrCodeIntegrityMismatch, fmt.Errorf("%w: record=%s file=%s", ErrContentIntegrityMismatch, doc.ContentSHA256, shaStr), false, true) ce.RecordSHA = doc.ContentSHA256 ce.FileSHA = shaStr return nil, ce } return &DocumentContent{ DocID: doc.DocID, Version: doc.Version, ContentSHA256: shaStr, Markdown: string(data), IsTemplateOnly: doc.IsTemplateOnly, }, nil }