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:
@@ -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 {
|
||||
|
||||
+62
-12
@@ -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 可操作)
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user