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

458 lines
13 KiB
Go

package server
import (
"chat-agent-system/internal/bizdev"
"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/httpx"
"chat-agent-system/internal/projectinstance"
)
// -----------------------------------------------------------------------------
// 受控文档编制、审核、批准与认领生命周期 RESTful 控制器
// -----------------------------------------------------------------------------
// handleDocGovGetDocument 查询指定受控文档详情与治理状态
func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("doc_id")
if docID == "" {
writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param")
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
return
}
doc, err := s.gov().GetDocument(r.Context(), docID)
if err != nil {
writeDomainError(w, r, http.StatusNotFound, err)
return
}
writeJSONResponse(w, http.StatusOK, doc)
}
// handleDocGovClaim 查阅并认领受控文档 (非模板状态查看方可认领)
func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) {
var req DocGovClaimRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if req.DocID == "" {
writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty")
return
}
user, ok := s.requireDocGovDocAccess(w, r, req.DocID)
if !ok {
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
doc, err := s.gov().RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, doc)
}
// handleDocGovSubmitReview 编制人将文档送审 (drafting -> reviewing, 正文受控锁定)
func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
var req DocGovSubmitReviewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionSubmit) {
return
}
doc, err := s.gov().SubmitForReview(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, user.Name)
if err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
writeJSONResponse(w, http.StatusOK, doc)
}
// handleDocGovReviewDecision 审核人决议 (通过流转至 approving,驳回必须填理由并回退 drafting 解锁)
func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
var req DocGovDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionReview) {
return
}
action := strings.ToLower(strings.TrimSpace(req.Action))
var doc *docgov.DocumentRecord
var err error
if action == "approve" {
doc, err = s.gov().ReviewApprove(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, req.Comment)
} else if action == "reject" {
reason := req.Reason
if reason == "" {
reason = req.Comment
}
doc, err = s.gov().ReviewReject(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, reason)
} else {
writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject")
return
}
if err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
writeJSONResponse(w, http.StatusOK, doc)
}
// handleDocGovApproverDecision 批准人决议 (通过流转至 approved 终态归档,驳回必填理由并回退)
func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
var req DocGovDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionApprove) {
return
}
action := strings.ToLower(strings.TrimSpace(req.Action))
cmds := s.docflowCommands()
if cmds == nil || cmds.Gov == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleApprover}
var doc *docgov.DocumentRecord
var err error
if action == "approve" {
doc, err = cmds.Approve(r.Context(), p, docflow.DecisionCommand{DocID: req.DocID, Comment: req.Comment})
} else if action == "reject" {
reason := req.Reason
if reason == "" {
reason = req.Comment
}
doc, err = cmds.Reject(r.Context(), p, docflow.DecisionCommand{
DocID: req.DocID, RejectReason: reason, RollbackTo: vocabulary.DocumentState(req.RollbackTo),
})
} else {
writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject")
return
}
if err != nil {
slog.Warn("docgov approver decision failed",
"request_id", httpx.RequestID(r), "doc_id", req.DocID,
"action", action, "user_id", user.ID, "error", err.Error())
if errors.Is(err, uow.ErrPermissionDenied) {
writeDomainError(w, r, http.StatusForbidden, err)
return
}
if errors.Is(err, uow.ErrBusy) {
writeDomainError(w, r, http.StatusConflict, err)
return
}
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
writeJSONResponse(w, http.StatusOK, doc)
}
// handleDocGovProposeModification 对已认领文档发起变更提案(写库 + 审计)
func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
var req ProposeModificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if strings.TrimSpace(req.DocID) == "" {
writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty")
return
}
if strings.TrimSpace(req.ProposedChanges) == "" {
writeSafeError(w, r, http.StatusBadRequest, "proposed_changes cannot be empty")
return
}
if _, ok := s.requireDocGovDocAccess(w, r, req.DocID); !ok {
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
triggeredBy := strings.TrimSpace(req.TriggeredBy)
if triggeredBy == "" {
triggeredBy = fmt.Sprintf("user:%d", user.ID)
}
proposal, err := s.gov().ProposeModification(
r.Context(),
req.DocID,
triggeredBy,
req.ProposedChanges,
req.ImpactAnalysis,
req.RiskAssessment,
)
if err != nil {
if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound {
writeSafeError(w, r, status, "document not found")
return
}
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
writeJSONResponse(w, http.StatusOK, proposal)
}
// handleDocGovDecideProposal 编制负责人拍板决定是否采纳变更提案
func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
var req DecideProposalRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if strings.TrimSpace(req.ProposalID) == "" {
writeSafeError(w, r, http.StatusBadRequest, "proposal_id cannot be empty")
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
proposal, err := s.gov().DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason)
if err != nil {
if status, ok := domainHTTPStatus(err); ok {
switch status {
case http.StatusNotFound:
writeSafeError(w, r, status, "proposal not found")
case http.StatusForbidden:
writeDomainError(w, r, status, err)
default:
writeDomainError(w, r, status, err)
}
return
}
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if req.Approve && s.svc() != nil && proposal != nil {
// Auto-apply as author for human/supplement-triggered proposals only.
triggered := strings.TrimSpace(proposal.TriggeredBy)
if strings.HasPrefix(triggered, "user:") || strings.Contains(triggered, "supplement") {
base := bizdev.TaskRunRequest{DocID: proposal.DocID, ProjectID: proposal.ProjectID, ForceDirectEdit: true, UserID: user.ID, AuthorID: user.ID, AuthorName: user.Name}
go func(prop *docgov.ChangeProposal, uid int64, req bizdev.TaskRunRequest) {
_, _ = s.svc().AfterProposalApproved(context.Background(), prop, uid, req)
}(proposal, user.ID, base)
}
}
writeJSONResponse(w, http.StatusOK, proposal)
}
// handleDocGovListComments 查询批注与修改建议清单
func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("doc_id")
if docID == "" {
writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param")
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
return
}
comments, err := s.gov().ListComments(r.Context(), docID)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, comments)
}
// handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议
// actor 身份一律取认证 principal,禁止请求体伪造 user_id/user_name/user_role。
func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) {
var req AddCommentRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
user, ok := s.requireDocGovDocAccess(w, r, req.DocID)
if !ok {
return
}
cType := vocabulary.CommentType(req.CommentType)
if cType == "" {
cType = vocabulary.CommentGeneral
}
comment, err := s.gov().AddComment(
r.Context(), req.DocID, user.ID, user.Name,
s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content,
)
if err != nil {
writeDocGovError(w, r, err)
return
}
writeJSONResponse(w, http.StatusOK, comment)
}
// handleDocGovResolveComment marks a comment resolved or reopens it.
func (s *Server) handleDocGovResolveComment(w http.ResponseWriter, r *http.Request) {
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
commentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || commentID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, "invalid comment id")
return
}
var req struct {
DocID string `json:"doc_id"`
Resolve *bool `json:"resolve"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
resolve := true
if req.Resolve != nil {
resolve = *req.Resolve
}
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
docID := strings.TrimSpace(req.DocID)
if docID == "" {
writeSafeError(w, r, http.StatusBadRequest, "doc_id required")
return
}
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
return
}
role := s.resolveDocGovRole(r.Context(), docID, user)
comment, err := s.gov().ResolveComment(r.Context(), commentID, user.ID, role, resolve)
if err != nil {
writeDocGovError(w, r, err)
return
}
writeJSONResponse(w, http.StatusOK, comment)
}
// handleDocGovListAuditLogs 查询文档全生命周期审计轨迹流
func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("doc_id")
if docID == "" {
writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param")
return
}
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
return
}
logs, err := s.gov().ListAuditLogs(r.Context(), docID)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, logs)
}