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

318 lines
11 KiB
Go

package server
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/traceimpact"
)
func (s *Server) handleProjectTrace(w http.ResponseWriter, r *http.Request) {
projectID, user, ok := s.traceProject(w, r)
if !ok {
return
}
items, err := s.rt.Trace.ListItems(r.Context(), projectID)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
visible, hidden := traceimpact.RedactItems(items, func(docID string) bool {
return s.traceDocVisible(r, projectID, user.ID, docID)
})
index, err := s.rt.Trace.ListIndex(r.Context(), projectID)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "")
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
candidates, err := s.rt.Trace.ListCandidateEdges(r.Context(), projectID)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"items": visible,
"hidden_count": hidden,
"index": index,
"recheck_marks": marks,
"candidate_edges": candidates,
})
}
func (s *Server) handleProjectTraceIndex(w http.ResponseWriter, r *http.Request) {
projectID, _, ok := s.traceProject(w, r)
if !ok {
return
}
var body traceimpact.DocRef
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
st, err := s.rt.Trace.IndexDocument(r.Context(), projectID, body)
if err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "TRACE_INDEX_FAILED", "message": err.Error(), "index": st})
return
}
writeJSONResponse(w, http.StatusOK, st)
}
func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Request) {
projectID, user, ok := s.traceProject(w, r)
if !ok {
return
}
var body struct {
traceimpact.DocRef
Others []traceimpact.DocRef `json:"others"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
rep, err := s.rt.Trace.Analyze(traceimpact.WithActor(r.Context(), user.ID), projectID, body.DocRef, body.Others)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, rep)
}
func (s *Server) handleProjectTraceDecision(w http.ResponseWriter, r *http.Request) {
projectID, user, ok := s.traceProject(w, r)
if !ok {
return
}
edgeID, err := strconv.ParseInt(r.PathValue("edgeID"), 10, 64)
if err != nil || edgeID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid edge id"}`)
return
}
var body struct {
DownstreamDoc string `json:"downstream_doc"`
DownstreamAuthor int64 `json:"downstream_author"`
Decision string `json:"decision"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
if !s.traceDocVisible(r, projectID, user.ID, body.DownstreamDoc) {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"看不见下游文档,不能裁决"}`)
return
}
creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID)
if err := s.rt.Trace.DecideCandidate(r.Context(), edgeID, user.ID, creator, body.DownstreamDoc, body.DownstreamAuthor, body.Decision, body.Reason); err != nil {
writeJSONResponse(w, http.StatusForbidden, map[string]string{"error": "TRACE_DECISION_DENIED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, map[string]string{"status": "recorded"})
}
func (s *Server) handleProjectTraceReopen(w http.ResponseWriter, r *http.Request) {
projectID, user, ok := s.traceProject(w, r)
if !ok {
return
}
var req traceimpact.ReopenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
req.ProjectID = projectID
req.RequestedBy = user.ID
creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID)
if !s.callerMayRequestReopen(r, projectID, user.ID, creator, req) {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"只有该阶段文档职责人或创建人可以申请再打开"}`)
return
}
stored, err := s.rt.Trace.RequestReopen(r.Context(), req, creator)
if err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_REOPEN_FAILED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, stored)
}
func (s *Server) handleProjectTraceReopenApprove(w http.ResponseWriter, r *http.Request) {
projectID, user, ok := s.traceProject(w, r)
if !ok {
return
}
requestID, err := strconv.ParseInt(r.PathValue("requestID"), 10, 64)
if err != nil || requestID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid request id"}`)
return
}
existing, err := s.rt.Trace.GetReopen(r.Context(), requestID)
if err != nil || existing.ProjectID != projectID {
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND","message":"申请不属于该项目"}`)
return
}
creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID)
if !s.callerMayApproveReopen(r.Context(), projectID, user.ID, creator, existing) {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"仅原批准人或(批准人缺位时)项目创建人可批准再打开"}`)
return
}
stored, err := s.rt.Trace.ApproveReopen(r.Context(), requestID, user.ID)
if err != nil {
writeJSONResponse(w, http.StatusConflict, map[string]string{"error": "TRACE_REOPEN_FAILED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, stored)
}
func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.Request) {
projectID, _, ok := s.traceProject(w, r)
if !ok {
return
}
markID, err := strconv.ParseInt(r.PathValue("markID"), 10, 64)
if err != nil || markID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid mark id"}`)
return
}
var body struct {
Reason string `json:"reason"`
MessageID int64 `json:"message_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
marks, err := s.rt.Trace.ListOpenMarks(r.Context(), projectID, "")
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
found := false
for _, m := range marks {
if m.ID == markID {
found = true
break
}
}
if !found {
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND","message":"未找到该项目的打开标记"}`)
return
}
if err := s.rt.Trace.ClearRecheck(r.Context(), markID, body.Reason); err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_RECHECK_CLEAR_FAILED", "message": err.Error()})
return
}
if body.MessageID > 0 && s.ctxMgr() != nil {
_ = s.ctxMgr().SetInteractiveCardStatus(r.Context(), body.MessageID, "resolved", "已标记为无需变更")
}
writeJSONResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
}
func (s *Server) traceProject(w http.ResponseWriter, r *http.Request) (int64, *auth.UserAccount, bool) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return 0, nil, false
}
if s.rt == nil || s.rt.Trace == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"追踪索引不可用"}`)
return 0, nil, false
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return 0, nil, false
}
return projectID, user, true
}
func (s *Server) callerMayRequestReopen(r *http.Request, projectID, userID int64, creator bool, req traceimpact.ReopenRequest) bool {
if creator {
return true
}
if s.instSvc() == nil {
return false
}
settings, err := s.instSvc().GetSettings(r.Context(), projectID, userID, false)
if err != nil || settings == nil || settings.Plan == nil {
return false
}
var seats []traceimpact.DutySeat
for _, stage := range settings.Plan.Stages {
if stage.StageKey != req.StageKey {
continue
}
for _, task := range stage.DocTasks {
seats = append(seats, traceimpact.DutySeat{
TaskKey: task.TaskKey, AuthorID: task.Duties.AuthorUserID,
ReviewerID: task.Duties.ReviewerUserID, ApproverID: task.Duties.ApproverUserID,
})
}
}
return traceimpact.MayRequestReopen(userID, false, req.DocTasks, seats)
}
// callerMayApproveReopen: document ApproverID first; if all approvers are missing/exited, fall back to project creator.
func (s *Server) callerMayApproveReopen(ctx context.Context, projectID, userID int64, creator bool, req traceimpact.ReopenRequest) bool {
hasActiveApprover := false
userIsApprover := false
if s.gov() != nil {
for _, taskKey := range req.DocTasks {
docID := fmt.Sprintf("doc_proj%d_%s", projectID, taskKey)
doc, err := s.gov().GetDocument(ctx, docID)
if err != nil || doc == nil || doc.ApproverID <= 0 {
continue
}
if !s.projectMemberActive(ctx, projectID, doc.ApproverID) {
continue
}
hasActiveApprover = true
if doc.ApproverID == userID {
userIsApprover = true
}
}
}
if userIsApprover {
return true
}
if !hasActiveApprover && creator {
return true
}
return false
}
func (s *Server) projectMemberActive(ctx context.Context, projectID, userID int64) bool {
if s.authorizer() == nil || userID <= 0 {
return userID > 0
}
rec, err := s.authorizer().GetProjectMember(ctx, projectID, userID)
if err != nil || rec == nil {
return false
}
status := strings.TrimSpace(rec.Status)
return status == "" || status == "active"
}
func (s *Server) traceDocVisible(r *http.Request, projectID, userID int64, docID string) bool {
if docID == "" || docID == "chat" {
return true
}
parsedProject, taskKey, ok := parseCanonicalWorkflowDocID(docID)
if !ok {
return false
}
if parsedProject != projectID {
return false
}
return s.allowWorkflowDocTaskAccess(r.Context(), projectID, userID, taskKey)
}