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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 06:15:20 +00:00
co-authored by Cursor
parent 268a9bdb56
commit 7a7162dcee
5 changed files with 598 additions and 12 deletions
+37
View File
@@ -17,6 +17,7 @@ import (
"chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/db" "chat-agent-system/internal/db"
"chat-agent-system/internal/docgov" "chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc" "chat-agent-system/internal/freedoc"
"chat-agent-system/internal/kb" "chat-agent-system/internal/kb"
"chat-agent-system/internal/projectcontext" "chat-agent-system/internal/projectcontext"
@@ -36,6 +37,7 @@ type Service struct {
ctxMgr *ctxmgr.ContextManager ctxMgr *ctxmgr.ContextManager
cas *storage.CAS cas *storage.CAS
docGov *docgov.GovernanceEngine docGov *docgov.GovernanceEngine
docRev *docrevision.Service
freeDoc *freedoc.Service freeDoc *freedoc.Service
projectWorkflow *projectworkflow.Service projectWorkflow *projectworkflow.Service
pool *pgxpool.Pool pool *pgxpool.Pool
@@ -89,7 +91,9 @@ func NewService(cfg *config.Config) (*Service, error) {
ctxMgr.SetEmbedder(kb.NewEmbedder(cfg.EmbeddingEndpointURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel)) ctxMgr.SetEmbedder(kb.NewEmbedder(cfg.EmbeddingEndpointURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel))
} }
docGov := docgov.NewGovernanceEngine(pool) docGov := docgov.NewGovernanceEngine(pool)
docRev := docrevision.NewService(pool)
freeDoc := freedoc.NewService(pool, ctxMgr) freeDoc := freedoc.NewService(pool, ctxMgr)
freeDoc.SetDocRevision(docRev)
pwStore := projectworkflow.NewStore(pool) pwStore := projectworkflow.NewStore(pool)
fallbackCatalog := projectworkflow.NewDefaultCatalog() fallbackCatalog := projectworkflow.NewDefaultCatalog()
wfStore := workflowdef.NewStore(pool) wfStore := workflowdef.NewStore(pool)
@@ -106,6 +110,7 @@ func NewService(cfg *config.Config) (*Service, error) {
ctxMgr: ctxMgr, ctxMgr: ctxMgr,
cas: casStore, cas: casStore,
docGov: docGov, docGov: docGov,
docRev: docRev,
freeDoc: freeDoc, freeDoc: freeDoc,
projectWorkflow: pwSvc, projectWorkflow: pwSvc,
pool: pool, pool: pool,
@@ -199,6 +204,13 @@ func (s *Service) DocGov() *docgov.GovernanceEngine {
return s.docGov return s.docGov
} }
func (s *Service) DocRevision() *docrevision.Service {
if s == nil {
return nil
}
return s.docRev
}
func (s *Service) ProjectWorkflow() *projectworkflow.Service { func (s *Service) ProjectWorkflow() *projectworkflow.Service {
return s.projectWorkflow 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 { if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
docRec = latest 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 { if res.Success && req.TaskKey == "test_manager" && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 {
+62 -12
View File
@@ -12,12 +12,14 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/replyexport" "chat-agent-system/internal/replyexport"
) )
type Service struct { type Service struct {
pool *pgxpool.Pool pool *pgxpool.Pool
ctxMgr *ctxmgr.ContextManager ctxMgr *ctxmgr.ContextManager
docRev *docrevision.Service
mu sync.RWMutex mu sync.RWMutex
memDocs map[string]*FreeformDocument 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 { func computeSHA256(text string) string {
sum := sha256.Sum256([]byte(text)) sum := sha256.Sum256([]byte(text))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
@@ -84,7 +93,7 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF
var existingDoc FreeformDocument var existingDoc FreeformDocument
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, 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 is_deleted, deleted_at, created_at, updated_at
FROM freeform_documents FROM freeform_documents
WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE 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, ` _, err = tx.Exec(ctx, `
INSERT INTO freeform_documents ( INSERT INTO freeform_documents (
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, 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 is_deleted, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8) ) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8)
`, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now) `, 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 var doc FreeformDocument
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, 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 is_deleted, deleted_at, created_at, updated_at
FROM freeform_documents FROM freeform_documents
WHERE doc_id = $1 WHERE doc_id = $1
@@ -323,14 +332,14 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int,
// 我的私密:属于当前用户 // 我的私密:属于当前用户
if projectID > 0 { if projectID > 0 {
rows, err = s.pool.Query(ctx, ` 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 FROM freeform_documents
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
ORDER BY updated_at DESC ORDER BY updated_at DESC
`, userID, projectID) `, userID, projectID)
} else { } else {
rows, err = s.pool.Query(ctx, ` 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 FROM freeform_documents
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
ORDER BY updated_at DESC 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" { } else if space == "project_shared" {
// 项目共享参考:属于当前项目的所有公开参考 // 项目共享参考:属于当前项目的所有公开参考
rows, err = s.pool.Query(ctx, ` 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 FROM freeform_documents
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
ORDER BY updated_at DESC ORDER BY updated_at DESC
@@ -347,7 +356,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int,
} else { } else {
// 回收站:当前用户删除的 // 回收站:当前用户删除的
rows, err = s.pool.Query(ctx, ` 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 FROM freeform_documents
WHERE owner_user_id = $1 AND is_deleted = TRUE WHERE owner_user_id = $1 AND is_deleted = TRUE
ORDER BY deleted_at DESC ORDER BY deleted_at DESC
@@ -429,7 +438,7 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string,
var doc FreeformDocument var doc FreeformDocument
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, 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 is_deleted, created_at, updated_at
FROM freeform_documents FROM freeform_documents
WHERE doc_id = $1 FOR UPDATE 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, ` _, err = tx.Exec(ctx, `
UPDATE freeform_documents 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 WHERE doc_id = $5
`, newTitle, nextVersion, newHash, now, doc.DocID) `, newTitle, nextVersion, newHash, now, doc.DocID)
if err != nil { if err != nil {
@@ -554,9 +563,11 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string,
} }
now := time.Now() now := time.Now()
newTitle := doc.Title
if req.Title != nil && *req.Title != "" { if req.Title != nil && *req.Title != "" {
doc.Title = *req.Title newTitle = *req.Title
} }
doc.Title = newTitle
curContent := "" curContent := ""
versions := s.memVersions[docID] versions := s.memVersions[docID]
@@ -586,10 +597,49 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string,
}) })
} }
doc.UpdatedAt = now doc.UpdatedAt = now
return &FreeformDocDetail{ detail := &FreeformDocDetail{
Document: *doc, Document: *doc,
Content: newContent, 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 可操作) // ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作)
+100
View File
@@ -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
}
@@ -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()})
}
}
+62
View File
@@ -114,6 +114,65 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
s.registerWorkflowApprovalHook() s.registerWorkflowApprovalHook()
s.startNotificationReminderLoop() s.startNotificationReminderLoop()
s.initTaskRunExecutors() 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 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/heartbeat", s.handleDocGovHeartbeatLease)
mux.HandleFunc("POST /api/v1/docgov/lease/release", s.handleDocGovReleaseLease) mux.HandleFunc("POST /api/v1/docgov/lease/release", s.handleDocGovReleaseLease)
// 8.1 文档版本 / 修订 ledger
s.registerDocumentRevisionRoutes(mux)
// 9. 用户认证、安全改密与超级管理员后台接口 // 9. 用户认证、安全改密与超级管理员后台接口
mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin)
mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister) mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister)