458 lines
13 KiB
Go
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)
|
|
}
|