From 7a7162dcee6b60ae9ef99c93e3974f26823244bd Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 06:15:20 +0000 Subject: [PATCH] feat(api): wire document revision HTTP and agent/freeform hooks Expose revision-state/versions/diff/PATCH content/candidate decision APIs; emit document notification outbox events; bootstrap ledger from bizdev success and freeform updates. Co-authored-by: Cursor --- internal/bizdev/service.go | 37 ++ internal/freedoc/service.go | 74 +++- internal/notification/document_events.go | 100 ++++++ internal/server/document_revision_handlers.go | 337 ++++++++++++++++++ internal/server/server.go | 62 ++++ 5 files changed, 598 insertions(+), 12 deletions(-) create mode 100644 internal/notification/document_events.go create mode 100644 internal/server/document_revision_handlers.go diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 0efafc07..71567b5e 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -17,6 +17,7 @@ import ( "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/db" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/docrevision" "chat-agent-system/internal/freedoc" "chat-agent-system/internal/kb" "chat-agent-system/internal/projectcontext" @@ -36,6 +37,7 @@ type Service struct { ctxMgr *ctxmgr.ContextManager cas *storage.CAS docGov *docgov.GovernanceEngine + docRev *docrevision.Service freeDoc *freedoc.Service projectWorkflow *projectworkflow.Service pool *pgxpool.Pool @@ -89,7 +91,9 @@ func NewService(cfg *config.Config) (*Service, error) { ctxMgr.SetEmbedder(kb.NewEmbedder(cfg.EmbeddingEndpointURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel)) } docGov := docgov.NewGovernanceEngine(pool) + docRev := docrevision.NewService(pool) freeDoc := freedoc.NewService(pool, ctxMgr) + freeDoc.SetDocRevision(docRev) pwStore := projectworkflow.NewStore(pool) fallbackCatalog := projectworkflow.NewDefaultCatalog() wfStore := workflowdef.NewStore(pool) @@ -106,6 +110,7 @@ func NewService(cfg *config.Config) (*Service, error) { ctxMgr: ctxMgr, cas: casStore, docGov: docGov, + docRev: docRev, freeDoc: freeDoc, projectWorkflow: pwSvc, pool: pool, @@ -199,6 +204,13 @@ func (s *Service) DocGov() *docgov.GovernanceEngine { return s.docGov } +func (s *Service) DocRevision() *docrevision.Service { + if s == nil { + return nil + } + return s.docRev +} + func (s *Service) ProjectWorkflow() *projectworkflow.Service { return s.projectWorkflow } @@ -702,6 +714,31 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { docRec = latest } + // DOCREV: bootstrap ledger or open agent candidate against current head. + if s.docRev != nil { + if _, stErr := s.docRev.GetRevisionState(ctx, docID); stErr != nil { + actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed} + _, _ = 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, + 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) { + _, _ = s.docRev.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{ + Decision: "commit", Confirmed: true, ActorUserID: req.UserID, + ExpectedInputSHA: st.HeadSHA256, + }) + } + } + } } if res.Success && req.TaskKey == "test_manager" && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 { diff --git a/internal/freedoc/service.go b/internal/freedoc/service.go index f3a06b50..5afa94b6 100644 --- a/internal/freedoc/service.go +++ b/internal/freedoc/service.go @@ -12,12 +12,14 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docrevision" "chat-agent-system/internal/replyexport" ) type Service struct { pool *pgxpool.Pool ctxMgr *ctxmgr.ContextManager + docRev *docrevision.Service mu sync.RWMutex memDocs map[string]*FreeformDocument @@ -35,6 +37,13 @@ func NewService(pool *pgxpool.Pool, ctxMgr *ctxmgr.ContextManager) *Service { } } +// SetDocRevision attaches the shared revision ledger (Wave 6 freeform compatibility). +func (s *Service) SetDocRevision(rev *docrevision.Service) { + if s != nil { + s.docRev = rev + } +} + func computeSHA256(text string) string { sum := sha256.Sum256([]byte(text)) return hex.EncodeToString(sum[:]) @@ -84,7 +93,7 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF var existingDoc FreeformDocument row := s.pool.QueryRow(ctx, ` SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, current_version_id, current_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, is_deleted, deleted_at, created_at, updated_at FROM freeform_documents WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE @@ -119,7 +128,7 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF _, err = tx.Exec(ctx, ` INSERT INTO freeform_documents ( doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, current_version_id, current_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, is_deleted, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8) `, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now) @@ -233,7 +242,7 @@ func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*F var doc FreeformDocument row := s.pool.QueryRow(ctx, ` SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, current_version_id, current_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, is_deleted, deleted_at, created_at, updated_at FROM freeform_documents WHERE doc_id = $1 @@ -323,14 +332,14 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, // 我的私密:属于当前用户 if projectID > 0 { rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at FROM freeform_documents WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE ORDER BY updated_at DESC `, userID, projectID) } else { rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at FROM freeform_documents WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE ORDER BY updated_at DESC @@ -339,7 +348,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, } else if space == "project_shared" { // 项目共享参考:属于当前项目的所有公开参考 rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at FROM freeform_documents WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE ORDER BY updated_at DESC @@ -347,7 +356,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, } else { // 回收站:当前用户删除的 rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at FROM freeform_documents WHERE owner_user_id = $1 AND is_deleted = TRUE ORDER BY deleted_at DESC @@ -429,7 +438,7 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, var doc FreeformDocument row := tx.QueryRow(ctx, ` SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, current_version_id, current_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, is_deleted, created_at, updated_at FROM freeform_documents WHERE doc_id = $1 FOR UPDATE @@ -496,7 +505,7 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, _, err = tx.Exec(ctx, ` UPDATE freeform_documents - SET title = $1, current_version_id = $2, current_sha256 = $3, updated_at = $4 + SET title = $1, legacy_version_id = $2, current_sha256 = $3, updated_at = $4 WHERE doc_id = $5 `, newTitle, nextVersion, newHash, now, doc.DocID) if err != nil { @@ -554,9 +563,11 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, } now := time.Now() + newTitle := doc.Title if req.Title != nil && *req.Title != "" { - doc.Title = *req.Title + newTitle = *req.Title } + doc.Title = newTitle curContent := "" versions := s.memVersions[docID] @@ -586,10 +597,49 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, }) } doc.UpdatedAt = now - return &FreeformDocDetail{ + detail := &FreeformDocDetail{ Document: *doc, Content: newContent, - }, nil + } + s.syncLedgerLocked(ctx, userID, detail) + return detail, nil +} + +// syncLedgerLocked imports legacy checkpoints then records human revisions on the shared ledger. +func (s *Service) syncLedgerLocked(ctx context.Context, userID int, detail *FreeformDocDetail) { + if s.docRev == nil || detail == nil { + return + } + doc := detail.Document + checkpoints := make([]struct { + VersionNo int + Markdown string + SHA string + }, 0, len(s.memVersions[doc.DocID])) + for _, v := range s.memVersions[doc.DocID] { + checkpoints = append(checkpoints, struct { + VersionNo int + Markdown string + SHA string + }{VersionNo: v.VersionNo, Markdown: v.ContentMarkdown, SHA: v.ContentSHA256}) + } + prior := checkpoints + if len(prior) > 0 { + prior = prior[:len(prior)-1] + } + st, err := s.docRev.ImportFreeformLegacy(ctx, doc.DocID, int64(userID), int64(doc.ProjectID), prior, detail.Content) + if err != nil || st == nil { + return + } + _, _ = s.docRev.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: doc.DocID, Markdown: detail.Content, + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: fmt.Sprintf("freeform:%s:%s", doc.DocID, detail.Document.CurrentSHA256), + ActorUserID: int64(userID), + ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "freeform-owner", Role: "owner"}, + LifecycleState: "drafting", + ProjectID: int64(doc.ProjectID), + }) } // ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作) diff --git a/internal/notification/document_events.go b/internal/notification/document_events.go new file mode 100644 index 00000000..fb994606 --- /dev/null +++ b/internal/notification/document_events.go @@ -0,0 +1,100 @@ +package notification + +import ( + "context" + "encoding/json" + "fmt" +) + +const ( + EventDocAgentCandidateReady = "document.agent_candidate_ready" + EventDocAgentVersionCommitted = "document.agent_version_committed" + EventDocHumanRevisionConflict = "document.human_revision_conflict" + EventDocDownstreamImpact = "document.downstream_impact_detected" + EventDocChangeReportDegraded = "document.change_report_degraded" + + TypeDocAgentCandidateReady = "document_agent_candidate_ready" + TypeDocAgentVersionCommitted = "document_agent_version_committed" + TypeDocDownstreamImpact = "document_downstream_impact" + TypeDocChangeReportDegraded = "document_change_report_degraded" +) + +// DocumentEventPayload is a minimal, permission-safe notification body (no markdown excerpts). +type DocumentEventPayload struct { + ProjectID int64 `json:"project_id"` + DocID string `json:"doc_id"` + DocTitle string `json:"doc_title,omitempty"` + VersionNo int `json:"version_no,omitempty"` + CandidateID int64 `json:"candidate_id,omitempty"` + ReportID int64 `json:"report_id,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + Summary string `json:"summary,omitempty"` + ImpactedDocID string `json:"impacted_doc_id,omitempty"` + ImpactedCount int `json:"impacted_count,omitempty"` + RestrictedN int `json:"restricted_count,omitempty"` +} + +// EmitDocumentEvent writes outbox + per-recipient notifications with dedupe. +func (s *Service) EmitDocumentEvent(ctx context.Context, eventType, notifType, title, body, dedupeKey string, recipientUserID int64, payload DocumentEventPayload) (bool, error) { + if !s.Available() { + return false, ErrNotAvailable + } + if recipientUserID <= 0 || payload.DocID == "" { + return false, nil + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer tx.Rollback(ctx) + + raw, err := json.Marshal(payload) + if err != nil { + return false, err + } + var eventID int64 + aggregateID := fmt.Sprintf("document:%s", payload.DocID) + err = tx.QueryRow(ctx, ` + INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload, project_id, published_at) + VALUES ('document', $1, $2, $3::jsonb, $4, now()) + RETURNING id + `, aggregateID, eventType, string(raw), payload.ProjectID).Scan(&eventID) + if err != nil { + return false, err + } + tag, err := tx.Exec(ctx, ` + INSERT INTO notifications (user_id, project_id, event_id, type, title, body, dedupe_key, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'unread') + ON CONFLICT (user_id, dedupe_key) DO NOTHING + `, recipientUserID, payload.ProjectID, eventID, notifType, title, body, dedupeKey) + if err != nil { + return false, err + } + if err := tx.Commit(ctx); err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// UpdateNotificationStatus marks a notification read/acted/dismissed for the owner. +func (s *Service) UpdateNotificationStatus(ctx context.Context, userID, notifID int64, status string) error { + if !s.Available() { + return ErrNotAvailable + } + switch status { + case "read", "acted", "dismissed", "unread": + default: + return fmt.Errorf("notification: invalid status %q", status) + } + tag, err := s.pool.Exec(ctx, ` + UPDATE notifications SET status = $1 + WHERE id = $2 AND user_id = $3 + `, status, notifID, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("notification: not found") + } + return nil +} diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go new file mode 100644 index 00000000..c98a4f47 --- /dev/null +++ b/internal/server/document_revision_handlers.go @@ -0,0 +1,337 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "chat-agent-system/internal/docrevision" +) + +func (s *Server) registerDocumentRevisionRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/documents/{doc_id}/revision-state", s.handleDocRevisionState) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/versions", s.handleDocRevisionListVersions) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/versions/{version_no}", s.handleDocRevisionGetVersion) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/diff", s.handleDocRevisionDiff) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/manual-overlay", s.handleDocRevisionManualOverlay) + mux.HandleFunc("PATCH /api/v1/documents/{doc_id}/content", s.handleDocRevisionPatchContent) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/agent-candidates/{candidate_id}", s.handleDocRevisionGetCandidate) + mux.HandleFunc("POST /api/v1/documents/{doc_id}/agent-candidates/{candidate_id}/decision", s.handleDocRevisionCandidateDecision) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/change-reports/{report_id}", s.handleDocRevisionGetReport) + mux.HandleFunc("PATCH /api/v1/notifications/{id}", s.handlePatchNotification) +} + +func (s *Server) docRev() *docrevision.Service { + if s == nil || s.svc == nil { + return nil + } + return s.svc.DocRevision() +} + +func (s *Server) handleDocRevisionState(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + 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) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, st) +} + +func (s *Server) handleDocRevisionListVersions(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + items, err := rev.ListVersions(r.Context(), r.PathValue("doc_id")) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"versions": items}) +} + +func (s *Server) handleDocRevisionGetVersion(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + vno, _ := strconv.Atoi(r.PathValue("version_no")) + view := r.URL.Query().Get("view") + if view == "" { + view = "final" + } + md, sha, err := rev.GetVersionContent(r.Context(), r.PathValue("doc_id"), vno, view) + if err != nil { + writeDocRevisionError(w, err) + return + } + w.Header().Set("ETag", `"`+sha+`"`) + writeJSONResponse(w, http.StatusOK, map[string]any{ + "version_no": vno, "view": view, "content_sha256": sha, "content_markdown": md, + }) +} + +func (s *Server) handleDocRevisionDiff(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + fromNo, _ := strconv.Atoi(r.URL.Query().Get("from_version")) + toNo, _ := strconv.Atoi(r.URL.Query().Get("to_version")) + fromView := r.URL.Query().Get("from_view") + toView := r.URL.Query().Get("to_view") + if fromView == "" { + fromView = "final" + } + if toView == "" { + toView = "final" + } + diff, err := rev.DiffVersions(r.Context(), r.PathValue("doc_id"), fromNo, toNo, fromView, toView) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, diff) +} + +func (s *Server) handleDocRevisionManualOverlay(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + diff, err := rev.ManualOverlay(r.Context(), r.PathValue("doc_id")) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, diff) +} + +func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + docID := r.PathValue("doc_id") + var body struct { + Markdown string `json:"markdown"` + EditSessionID string `json:"edit_session_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) + return + } + idemp := r.Header.Get("Idempotency-Key") + if idemp == "" { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "Idempotency-Key required"}) + return + } + var expectedHead *int64 + var expectedRev *int + if match := strings.Trim(r.Header.Get("If-Match"), `"`); match != "" { + parts := strings.Split(match, ":") + if len(parts) == 2 { + if sid, err := strconv.ParseInt(parts[0], 10, 64); err == nil { + expectedHead = &sid + } + if rn, err := strconv.Atoi(parts[1]); err == nil { + expectedRev = &rn + } + } + } + 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 + } + } + } + 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, + ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"}, + LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: true, + }) + if errors.Is(err, docrevision.ErrHeadConflict) && res != nil && res.Conflict != nil { + writeJSONResponse(w, http.StatusConflict, map[string]any{ + "code": docrevision.ErrCodeHeadConflict, "conflict": res.Conflict, + }) + return + } + if err != nil { + 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) + } + writeJSONResponse(w, http.StatusOK, res) +} + +func (s *Server) handleDocRevisionGetCandidate(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + cid, _ := strconv.ParseInt(r.PathValue("candidate_id"), 10, 64) + c, err := rev.GetCandidate(r.Context(), r.PathValue("doc_id"), cid) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, c) +} + +func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + cid, _ := strconv.ParseInt(r.PathValue("candidate_id"), 10, 64) + var body struct { + Decision string `json:"decision"` + Confirmed bool `json:"confirmed"` + ExpectedInputSHA string `json:"expected_input_sha"` + ExpectedOutputSHA string `json:"expected_output_sha"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) + return + } + st, err := rev.CommitAgentVersion(r.Context(), cid, docrevision.CommitAgentDecision{ + Decision: body.Decision, Confirmed: body.Confirmed, + ExpectedInputSHA: body.ExpectedInputSHA, ExpectedOutputSHA: body.ExpectedOutputSHA, + Reason: body.Reason, ActorUserID: user.ID, + ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name}, + }) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, st) +} + +func (s *Server) handleDocRevisionGetReport(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + rid, _ := strconv.ParseInt(r.PathValue("report_id"), 10, 64) + rep, err := rev.GetChangeReport(r.Context(), r.PathValue("doc_id"), rid) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, rep) +} + +func (s *Server) handlePatchNotification(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + if s.notifySvc == nil || !s.notifySvc.Available() { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "notifications unavailable"}) + return + } + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + var body struct { + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) + return + } + if err := s.notifySvc.UpdateNotificationStatus(r.Context(), user.ID, id, body.Status); err != nil { + writeJSONResponse(w, http.StatusNotFound, map[string]any{"error": err.Error()}) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"ok": true}) +} + +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.ErrHeadConflict): + writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeHeadConflict, "error": err.Error()}) + case errors.Is(err, docrevision.ErrLifecycleLocked): + writeJSONResponse(w, http.StatusLocked, map[string]any{"code": docrevision.ErrCodeLifecycleLocked, "error": err.Error()}) + case errors.Is(err, docrevision.ErrCandidateStale): + writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeCandidateStale, "error": err.Error()}) + case errors.Is(err, docrevision.ErrNeedsConfirmation): + writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeNeedsConfirmation, "error": err.Error()}) + case errors.Is(err, docrevision.ErrReportInvalid): + writeJSONResponse(w, http.StatusUnprocessableEntity, map[string]any{"code": docrevision.ErrCodeReportInvalid, "error": err.Error()}) + case errors.Is(err, docrevision.ErrTooLarge): + writeJSONResponse(w, http.StatusRequestEntityTooLarge, map[string]any{"code": docrevision.ErrCodeTooLarge, "error": err.Error()}) + case errors.Is(err, docrevision.ErrForbidden): + writeJSONResponse(w, http.StatusForbidden, map[string]any{"code": docrevision.ErrCodeEditForbidden, "error": err.Error()}) + default: + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8a29c905..2cbfe63a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -114,6 +114,65 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { s.registerWorkflowApprovalHook() s.startNotificationReminderLoop() s.initTaskRunExecutors() + if svc != nil && svc.DocRevision() != nil && s.notifySvc != nil { + rev := svc.DocRevision() + rev.OnOutbox = func(ctx context.Context, eventType string, payload map[string]any) error { + if !s.notifySvc.Available() { + return nil + } + docID, _ := payload["doc_id"].(string) + projectID, _ := payload["project_id"].(int64) + risk, _ := payload["risk"].(string) + var recipient int64 + if s.svc.DocGov() != nil { + if doc, err := s.svc.DocGov().GetDocument(ctx, docID); err == nil && doc != nil && doc.AuthorID > 0 { + recipient = doc.AuthorID + projectID = doc.ProjectID + } + } + if recipient == 0 { + return nil + } + p := notification.DocumentEventPayload{ + ProjectID: projectID, DocID: docID, RiskLevel: risk, + } + switch eventType { + case notification.EventDocAgentCandidateReady: + if v, ok := payload["candidate_id"].(int64); ok { + p.CandidateID = v + } + _, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocAgentCandidateReady, + "智能体变更待确认", "文档有高风险智能体候选变更,请确认后生效。", + fmt.Sprintf("doc_candidate:%s:%d:%d", docID, p.CandidateID, recipient), recipient, p) + return err + case notification.EventDocAgentVersionCommitted: + if v, ok := payload["version_no"].(int); ok { + p.VersionNo = v + } + _, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocAgentVersionCommitted, + "智能体版本已生效", "新的智能体文档版本已提交。", + fmt.Sprintf("doc_agent_version:%s:%d:%d", docID, p.VersionNo, recipient), recipient, p) + return err + case notification.EventDocDownstreamImpact: + impacted, _ := payload["impacted_doc_id"].(string) + p.ImpactedDocID = impacted + _, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocDownstreamImpact, + "下游文档受影响", "上游文档变更,请复核关联文档。", + fmt.Sprintf("doc_impact:%s:%s:%d", docID, impacted, recipient), recipient, p) + return err + case notification.EventDocChangeReportDegraded: + if v, ok := payload["report_id"].(int64); ok { + p.ReportID = v + } + _, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocChangeReportDegraded, + "变更报告未完整评估", "变更报告降级,受控文档暂不可送审。", + fmt.Sprintf("doc_report_degraded:%s:%d:%d", docID, p.ReportID, recipient), recipient, p) + return err + default: + return nil + } + } + } return s } @@ -249,6 +308,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/docgov/lease/heartbeat", s.handleDocGovHeartbeatLease) mux.HandleFunc("POST /api/v1/docgov/lease/release", s.handleDocGovReleaseLease) + // 8.1 文档版本 / 修订 ledger + s.registerDocumentRevisionRoutes(mux) + // 9. 用户认证、安全改密与超级管理员后台接口 mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister)