diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 71567b5e..8472da13 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -92,6 +92,7 @@ func NewService(cfg *config.Config) (*Service, error) { } docGov := docgov.NewGovernanceEngine(pool) docRev := docrevision.NewService(pool) + docRev.DataDir = cfg.DataDir freeDoc := freedoc.NewService(pool, ctxMgr) freeDoc.SetDocRevision(docRev) pwStore := projectworkflow.NewStore(pool) @@ -102,6 +103,33 @@ func NewService(cfg *config.Config) (*Service, error) { } catalog := workflowdef.NewCatalog(context.Background(), wfStore, fallbackCatalog) pwSvc := projectworkflow.NewService(pwStore, catalog) + docRev.ImpactResolver = func(ctx context.Context, docID string) []docrevision.DocImpact { + if pwSvc == nil { + return nil + } + lineages, err := pwSvc.Store().GetLineagesForUpstream(ctx, docID) + if err != nil || len(lineages) == 0 { + return nil + } + down := make([]struct { + DocID string + Reason string + }, 0, len(lineages)) + for _, ln := range lineages { + if ln == nil { + continue + } + reason := ln.StaleReason + if reason == "" { + reason = "上游文档内容变更,下游需复核" + } + down = append(down, struct { + DocID string + Reason string + }{DocID: ln.DownstreamDocID, Reason: reason}) + } + return docrevision.ImpactFromLineage(down) + } svc := &Service{ cfg: cfg, @@ -721,14 +749,10 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun _, _ = s.docRev.OpenTemplateVersion(ctx, docID, res.MarkdownBody, actor, req.ProjectID, res.WorkDir) } else { st, _ := s.docRev.GetRevisionState(ctx, docID) - reportJSON := `{"schema_version":1,"summary":["智能体交付物已生成"],"risk":{"level":"medium","items":[]},"document_impacts":[],"open_questions":[]}` - if req.TaskKey == "system_reqs" { - reportJSON = `{"schema_version":1,"summary":["系统需求规格已更新"],"risk":{"level":"high","items":[{"category":"verification","description":"可能影响下游验证"}]},"document_impacts":[],"open_questions":[]}` - } cand, rep, _ := s.docRev.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey), ProposalID: appliedProposalID, InputSnapshotID: st.HeadSnapshotID, - OutputMarkdown: res.MarkdownBody, ReportJSON: reportJSON, + OutputMarkdown: res.MarkdownBody, ReportJSON: "", // deterministic report from diff+lineage ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed}, }) if cand != nil && cand.Status != docrevision.CandidateStale && rep != nil && !docrevision.NeedsConfirmation(rep.RiskLevel) { diff --git a/internal/server/document_revision_access.go b/internal/server/document_revision_access.go new file mode 100644 index 00000000..e2691db5 --- /dev/null +++ b/internal/server/document_revision_access.go @@ -0,0 +1,135 @@ +package server + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/authz" + "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/freedoc" +) + +type docRevisionRef struct { + Kind string // controlled|freeform|orphan + ProjectID int64 + WorkDir string + LifecycleState string + Markdown string + Title string +} + +func (s *Server) resolveDocRevisionRef(ctx context.Context, user *auth.UserAccount, docID string) (*docRevisionRef, error) { + if s.svc != nil && s.svc.DocGov() != nil { + if doc, err := s.svc.DocGov().GetDocument(ctx, docID); err == nil && doc != nil { + ref := &docRevisionRef{ + Kind: "controlled", ProjectID: doc.ProjectID, WorkDir: doc.WorkDir, + LifecycleState: string(doc.CurrentState), + } + if content, cErr := s.svc.DocGov().ReadDocumentContent(ctx, docID, s.dataRoot()); cErr == nil && content != nil { + ref.Markdown = content.Markdown + } else if doc.WorkDir != "" { + md, _ := readOutputMarkdown(s.dataRoot(), doc.WorkDir) + ref.Markdown = md + } + return ref, nil + } + } + if s.svc != nil && s.svc.FreeformDoc() != nil && user != nil { + if detail, err := s.svc.FreeformDoc().GetDocument(ctx, int(user.ID), docID); err == nil && detail != nil { + return &docRevisionRef{ + Kind: "freeform", ProjectID: int64(detail.Document.ProjectID), + LifecycleState: "drafting", Markdown: detail.Content, Title: detail.Document.Title, + }, nil + } else if errors.Is(err, freedoc.ErrForbidden) { + return nil, docrevision.ErrForbidden + } + } + return &docRevisionRef{Kind: "orphan", LifecycleState: "drafting"}, nil +} + +func (s *Server) authorizeDocRevision(ctx context.Context, user *auth.UserAccount, ref *docRevisionRef) error { + if user == nil || ref == nil { + return authz.ErrForbidden + } + if ref.Kind == "orphan" { + // Orphan upload: allow authenticated read/bootstrap only when client supplies content on PATCH. + return nil + } + if ref.ProjectID <= 0 { + return nil + } + if s.authorizer == nil { + if s.failClosedAuthz() { + return authz.ErrForbidden + } + return nil + } + return s.authorizer.RequireProjectAccessOrAdmin(ctx, ref.ProjectID, user.ID, s.isSystemAdminUser(ctx, user)) +} + +func (s *Server) dataRoot() string { + if s != nil && s.cfg != nil && s.cfg.DataDir != "" { + return s.cfg.DataDir + } + return "." +} + +func readOutputMarkdown(dataRoot, workDir string) (string, error) { + path := workDir + if !filepath.IsAbs(path) { + path = filepath.Join(dataRoot, workDir) + } + b, err := os.ReadFile(filepath.Join(path, "output.md")) + if err != nil { + return "", err + } + return string(b), nil +} + +func (s *Server) bootstrapRevisionIfNeeded(ctx context.Context, user *auth.UserAccount, docID string, ref *docRevisionRef) (*docrevision.RevisionState, error) { + rev := s.docRev() + if rev == nil { + return nil, errors.New("docrevision unavailable") + } + st, err := rev.GetRevisionState(ctx, docID) + if err == nil { + return st, nil + } + if !errors.Is(err, docrevision.ErrNotFound) { + return nil, err + } + md := "" + if ref != nil { + md = ref.Markdown + } + if strings.TrimSpace(md) == "" { + return nil, docrevision.ErrNeedsContent + } + kind := "controlled" + life := "drafting" + var projectID int64 + workDir := "" + if ref != nil { + if ref.Kind != "" { + kind = ref.Kind + } + if ref.Kind == "orphan" { + kind = "freeform" + } + life = ref.LifecycleState + projectID = ref.ProjectID + workDir = ref.WorkDir + } + actor := docrevision.ActorSnapshot{DisplayName: "bootstrap", Role: "system"} + if user != nil { + actor = docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"} + } + return rev.EnsureBootstrap(ctx, docrevision.BootstrapRequest{ + DocID: docID, Kind: kind, Markdown: md, ProjectID: projectID, + WorkDir: workDir, LifecycleState: life, Actor: actor, + }) +} diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index c98a4f47..ec18c94b 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -8,6 +8,7 @@ import ( "strings" "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/freedoc" ) func (s *Server) registerDocumentRevisionRoutes(mux *http.ServeMux) { @@ -35,14 +36,22 @@ func (s *Server) handleDocRevisionState(w http.ResponseWriter, r *http.Request) if !ok { return } - _ = user docID := r.PathValue("doc_id") rev := s.docRev() if rev == nil { writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) return } - st, err := rev.GetRevisionState(r.Context(), docID) + ref, err := s.resolveDocRevisionRef(r.Context(), user, docID) + if err != nil { + writeDocRevisionError(w, err) + return + } + if err := s.authorizeDocRevision(r.Context(), user, ref); err != nil { + writeAuthzError(w, err) + return + } + st, err := s.bootstrapRevisionIfNeeded(r.Context(), user, docID, ref) if err != nil { writeDocRevisionError(w, err) return @@ -173,27 +182,45 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re } } } + + ref, err := s.resolveDocRevisionRef(r.Context(), user, docID) + if err != nil { + writeDocRevisionError(w, err) + return + } + if err := s.authorizeDocRevision(r.Context(), user, ref); err != nil { + writeAuthzError(w, err) + return + } + lifecycle := "drafting" workDir := "" projectID := int64(0) - if s.svc != nil && s.svc.DocGov() != nil { - if doc, err := s.svc.DocGov().GetDocument(r.Context(), docID); err == nil && doc != nil { - lifecycle = string(doc.CurrentState) - workDir = doc.WorkDir - projectID = doc.ProjectID - // Ensure ledger baseline exists from current content when first edit arrives. - if st, stErr := rev.GetRevisionState(r.Context(), docID); stErr != nil { - md := body.Markdown - if content, cErr := s.svc.DocGov().ReadDocumentContent(r.Context(), docID, "."); cErr == nil && content != nil && content.Markdown != "" { - md = content.Markdown - } - _, _ = rev.OpenTemplateVersion(r.Context(), docID, md, docrevision.ActorSnapshot{ - DisplayName: user.Name, Role: "author", Duty: "author", - }, projectID, workDir) - _ = st - } + kind := "controlled" + if ref != nil { + lifecycle = ref.LifecycleState + workDir = ref.WorkDir + projectID = ref.ProjectID + kind = ref.Kind + if kind == "orphan" { + kind = "freeform" } } + if _, stErr := rev.GetRevisionState(r.Context(), docID); stErr != nil { + md := body.Markdown + if ref != nil && strings.TrimSpace(ref.Markdown) != "" { + md = ref.Markdown + } + if strings.TrimSpace(md) == "" { + writeDocRevisionError(w, docrevision.ErrNeedsContent) + return + } + _, _ = rev.EnsureBootstrap(r.Context(), docrevision.BootstrapRequest{ + DocID: docID, Kind: kind, Markdown: md, ProjectID: projectID, WorkDir: workDir, + LifecycleState: lifecycle, + Actor: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"}, + }) + } res, err := rev.CommitHumanRevision(r.Context(), docrevision.HumanRevisionRequest{ DocID: docID, Markdown: body.Markdown, ExpectedHeadSnapshot: expectedHead, ExpectedRevisionNo: expectedRev, IdempotencyKey: idemp, EditSessionID: body.EditSessionID, ActorUserID: user.ID, @@ -210,10 +237,19 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re writeDocRevisionError(w, err) return } - _ = rev.ProjectOutputMarkdown(docID, ".") - // Compatibility projection into docgov current hash. - if s.svc != nil && s.svc.DocGov() != nil { - _ = s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false) + // Project to the user-visible fact source. + if kind == "controlled" || workDir != "" { + _ = rev.ProjectOutputMarkdown(docID, s.dataRoot()) + if s.svc != nil && s.svc.DocGov() != nil { + _ = s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false) + } + } + if kind == "freeform" && s.svc != nil && s.svc.FreeformDoc() != nil { + md := body.Markdown + _, _ = s.svc.FreeformDoc().UpdateDocument(r.Context(), int(user.ID), docID, freedoc.UpdateFreeformDocRequest{ + ContentMarkdown: &md, + ChangeSummary: "revision ledger human save", + }) } writeJSONResponse(w, http.StatusOK, res) } @@ -317,6 +353,12 @@ func writeDocRevisionError(w http.ResponseWriter, err error) { switch { case errors.Is(err, docrevision.ErrNotFound): writeJSONResponse(w, http.StatusNotFound, map[string]any{"code": docrevision.ErrCodeNotFound, "error": err.Error()}) + case errors.Is(err, docrevision.ErrNeedsContent): + writeJSONResponse(w, http.StatusConflict, map[string]any{ + "code": docrevision.ErrCodeNeedsContent, + "error": err.Error(), + "hint": "文档尚无服务端正文事实源;请先保存正文或注册自由文档后再读取版本账本", + }) case errors.Is(err, docrevision.ErrHeadConflict): writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeHeadConflict, "error": err.Error()}) case errors.Is(err, docrevision.ErrLifecycleLocked):