318 lines
11 KiB
Go
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)
|
|
}
|