Files
Agent-System/internal/server/document_revision_handlers.go
weichen f3f23a3018 feat(errcode): 用稳定错误码记录 HTTP 与异步失败
失败响应此前不写日志,request_id 无法对照原因。注册表不覆盖处理器已经给出的 HTTP 状态。
2026-09-28 02:27:16 +00:00

504 lines
19 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/app/docflow"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
)
func (s *Server) docflowCommands() *docflow.Commands {
if s != nil && s.rt != nil && s.rt.Docflow != nil {
return s.rt.Docflow
}
return nil
}
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.rev()
}
func (s *Server) handleDocRevisionState(w http.ResponseWriter, r *http.Request) {
user, docID, ref, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
st, err := s.bootstrapRevisionIfNeeded(r.Context(), user, docID, ref)
if err != nil {
writeDocRevisionError(w, err)
return
}
if st != nil && st.PendingCandidateID != nil {
// Read path already authorized; decide capability requires write auth (author duty).
st.CanDecidePending = s.authorizeDocRevision(r.Context(), user, ref, true) == nil
}
writeJSONResponse(w, http.StatusOK, st)
}
func (s *Server) handleDocRevisionListVersions(w http.ResponseWriter, r *http.Request) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !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(), docID)
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) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !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(), docID, 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) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !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(), docID, 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) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !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(), docID)
if err != nil {
writeDocRevisionError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, diff)
}
func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Request) {
user, docID, ref, ok := s.requireDocRevisionAccess(w, r, true)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
var body struct {
Markdown string `json:"markdown"`
EditSessionID string `json:"edit_session_id"`
ClientBlockMap []docrevision.BlockNode `json:"client_block_map"`
}
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)
kind := "controlled"
if ref != nil {
lifecycle = ref.LifecycleState
workDir = ref.WorkDir
projectID = ref.ProjectID
kind = ref.Kind
}
if kind == "orphan" {
writeDocRevisionError(w, docrevision.ErrNotFound)
return
}
// 1) Controlled: author duty + lease WITHOUT writing body (fail-closed before ledger).
// This UI has no edit-session heartbeat: release the request-scoped human lease on every exit
// (success, 409, projection failure) so a 15m residual cannot block a later squad run.
if kind == "controlled" && s.gov() != nil {
// ActorIsMember=true:上游 enforceDocGovWorkflowAction(Edit) 已按成员级放行。
if err := s.gov().AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor, true); err != nil {
writeDocGovError(w, r, err)
return
}
holderID := fmt.Sprintf("%d", user.ID)
if user.ID <= 0 {
holderID = "human_anon"
}
defer func() {
_, _ = s.gov().ReleaseLease(context.Background(), docID, string(vocabulary.LeaseHolderHuman), holderID)
}()
}
// 2) Bootstrap if needed, then commit ledger. No fact-source mutation yet.
if _, stErr := rev.GetRevisionState(r.Context(), docID); stErr != nil {
md := body.Markdown
if ref != nil && strings.TrimSpace(ref.Markdown) != "" {
md = ref.Markdown
}
if strings.TrimSpace(md) == "" {
writeDocRevisionError(w, docrevision.ErrNeedsContent)
return
}
if _, err := rev.EnsureBootstrap(r.Context(), docrevision.BootstrapRequest{
DocID: docID, Kind: kind, Markdown: md, ProjectID: projectID, WorkDir: workDir,
LifecycleState: lifecycle,
Actor: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"},
}); err != nil {
writeDocRevisionError(w, err)
return
}
}
cmds := s.docflowCommands()
if cmds == nil || cmds.Rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
res, err := cmds.HumanEdit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleAuthor}, docflow.HumanEditCommand{
DocID: docID,
Request: 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: false,
ClientBlockMap: body.ClientBlockMap,
},
})
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
}
// 3) Project fact-source AFTER ledger commit. The projection intent was inserted in the
// same transaction as the ledger (document_content_projections). Failure keeps that row
// pending and returns DOCUMENT_PROJECTION_FAILED. The same Idempotency-Key retries
// projection without a second revision. Only a successful projection acks the row.
if kind == "controlled" && s.gov() != nil {
if _, err := s.gov().SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
DocID: docID,
ActorID: user.ID,
ActorRole: vocabulary.RoleAuthor,
ActorName: user.Name,
MarkdownBody: body.Markdown,
}); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但 DocGov 正文/审计投影失败;请用同一 Idempotency-Key 重试保存。账本 head 仍为权威。",
"state": res.State,
})
return
}
} else if workDir != "" {
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但工作区投影失败;请重试保存。账本 head 仍为权威。",
"state": res.State,
})
return
}
}
if kind == "freeform" && s.freeDoc() != nil {
md := body.Markdown
if _, err := s.freeDoc().UpdateDocument(r.Context(), int(user.ID), docID, freedoc.UpdateFreeformDocRequest{
ContentMarkdown: &md,
ChangeSummary: "revision ledger human save",
}); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但自由文档正文投影失败;请重试保存。账本 head 仍为权威。",
"state": res.State,
})
return
}
}
if res.State.HeadSnapshotID != 0 {
if markErr := rev.MarkContentProjected(r.Context(), docID, res.State.HeadSnapshotID); markErr != nil {
slog.Warn("content projection ack failed", "doc_id", docID, "snapshot_id", res.State.HeadSnapshotID, "err", markErr)
}
}
writeJSONResponse(w, http.StatusOK, res)
}
func (s *Server) handleDocRevisionGetCandidate(w http.ResponseWriter, r *http.Request) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
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)
c, err := rev.GetCandidate(r.Context(), docID, cid)
if err != nil {
writeDocRevisionError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, c)
}
func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *http.Request) {
user, docID, _, ok := s.requireDocRevisionAccess(w, r, true)
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
}
cand, _ := rev.GetCandidate(r.Context(), docID, cid)
cmds := s.docflowCommands()
if cmds == nil || cmds.Rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
st, err := cmds.AgentCommit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleAuthor}, docflow.AgentCommitCommand{
DocID: docID, CandidateID: cid,
Decision: 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
}
// Always project ledger head to fact source after decision:
// commit → new head; reject → keep/restore previous head (never leave squad draft on disk).
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "候选决策已写入账本,但工作区交付物投影失败;请重试决策或人工修复。",
"state": st,
})
return
}
if s.gov() != nil && st != nil {
if err := s.gov().MarkDocumentContentUpdated(r.Context(), docID, st.HeadSHA256, false); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "候选决策已写入账本,但 document_records 哈希投影失败;请重试。",
"state": st,
})
return
}
if body.Decision == "commit" && cand != nil && strings.TrimSpace(cand.ProposalID) != "" {
if err := s.gov().MarkProposalApplied(r.Context(), cand.ProposalID); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_GOVERNANCE_FAILED",
"error": err.Error(),
"hint": "候选账本已提交且事实源已投影,但变更提案标记失败。请用同一 decision(相同 expected_*_sha)重试:已 committed 候选幂等,仅补 MarkProposalApplied;勿换候选或改正文。",
"state": st,
})
return
}
}
}
writeJSONResponse(w, http.StatusOK, st)
}
func (s *Server) handleDocRevisionGetReport(w http.ResponseWriter, r *http.Request) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !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(), docID, 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.ErrInvalidDocID):
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"code": docrevision.ErrCodeInvalidDocID, "error": err.Error()})
case errors.Is(err, docrevision.ErrNotFound):
writeJSONResponse(w, http.StatusNotFound, map[string]any{"code": docrevision.ErrCodeNotFound, "error": err.Error()})
case errors.Is(err, docrevision.ErrNeedsContent):
writeJSONResponse(w, http.StatusConflict, map[string]any{
"code": docrevision.ErrCodeNeedsContent,
"error": err.Error(),
"hint": "文档尚无服务端正文事实源;请先保存正文或注册自由文档后再读取版本账本",
})
case errors.Is(err, docrevision.ErrHeadConflict):
writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeHeadConflict, "error": err.Error()})
case errors.Is(err, docrevision.ErrLifecycleLocked):
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), errors.Is(err, uow.ErrPermissionDenied):
writeJSONResponse(w, http.StatusForbidden, map[string]any{"code": docrevision.ErrCodeEditForbidden, "error": err.Error()})
case errors.Is(err, docrevision.ErrIdempotencyConflict), errors.Is(err, uow.ErrDigestConflict):
writeJSONResponse(w, http.StatusConflict, map[string]any{"code": "DOCUMENT_IDEMPOTENCY_CONFLICT", "error": err.Error()})
case errors.Is(err, uow.ErrBusy):
writeJSONResponse(w, http.StatusConflict, map[string]any{"code": "DOCUMENT_BUSY", "error": err.Error()})
default:
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
}
}