Rebuild dev backend so message visibility uses scope-aware filtering instead of dropping messages without stage_key. Map HTTP 401/403 on chat stream to actionable auth errors instead of generic NETWORK_ERROR. Co-authored-by: Cursor <cursoragent@cursor.com>
252 lines
6.8 KiB
Go
252 lines
6.8 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sort"
|
|
|
|
"chat-agent-system/internal/auth"
|
|
"chat-agent-system/internal/ctxmgr"
|
|
"chat-agent-system/internal/projectinstance"
|
|
)
|
|
|
|
// chatHistoryAuth 会话历史读取授权上下文。
|
|
type chatHistoryAuth struct {
|
|
SessionID string
|
|
SessionOwnerID int64
|
|
ProjectID int64
|
|
ViewerUserID int64
|
|
StageAccess *projectinstance.StageAccess
|
|
}
|
|
|
|
func (s *Server) authorizeSessionRead(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
sessionID string,
|
|
user *auth.UserAccount,
|
|
) (*chatHistoryAuth, bool) {
|
|
if user == nil {
|
|
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized)
|
|
return nil, false
|
|
}
|
|
if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) {
|
|
return nil, false
|
|
}
|
|
sessionOwnerID := user.ID
|
|
if s.svc != nil && s.svc.ContextManager() != nil {
|
|
if ownerID, err := s.svc.ContextManager().GetSessionOwnerID(r.Context(), sessionID); err == nil && ownerID > 0 {
|
|
sessionOwnerID = ownerID
|
|
}
|
|
}
|
|
authCtx := &chatHistoryAuth{
|
|
SessionID: sessionID,
|
|
SessionOwnerID: sessionOwnerID,
|
|
ViewerUserID: user.ID,
|
|
}
|
|
if s.svc != nil && s.svc.ContextManager() != nil {
|
|
if projectID, err := s.svc.ContextManager().GetSessionProjectID(r.Context(), sessionID); err == nil {
|
|
authCtx.ProjectID = projectID
|
|
}
|
|
}
|
|
if authCtx.ProjectID > 0 {
|
|
access, err := s.resolveStageAccess(r.Context(), authCtx.ProjectID, user.ID)
|
|
if err != nil && s.failClosedAuthz() {
|
|
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
|
"error_code": "WORKFLOW_ACCESS_DENIED",
|
|
"message": "无权访问该阶段内容",
|
|
})
|
|
return nil, false
|
|
}
|
|
authCtx.StageAccess = access
|
|
}
|
|
return authCtx, true
|
|
}
|
|
|
|
func (a *chatHistoryAuth) messageVisible(msg ctxmgr.ChatMessageRecord) bool {
|
|
if a == nil {
|
|
return false
|
|
}
|
|
return projectinstance.MessageRecordVisible(
|
|
msg.ScopeKind,
|
|
msg.StageKey,
|
|
msg.Metadata,
|
|
a.StageAccess,
|
|
a.SessionOwnerID,
|
|
a.ViewerUserID,
|
|
)
|
|
}
|
|
|
|
func (s *Server) filterChatMessagesByAuth(
|
|
authCtx *chatHistoryAuth,
|
|
msgs []ctxmgr.ChatMessageRecord,
|
|
) []ctxmgr.ChatMessageRecord {
|
|
if authCtx == nil || len(msgs) == 0 {
|
|
return msgs
|
|
}
|
|
out := make([]ctxmgr.ChatMessageRecord, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
if authCtx.messageVisible(msg) {
|
|
out = append(out, msg)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) filterChatSearchHitsByAuth(
|
|
authCtx *chatHistoryAuth,
|
|
hits []ctxmgr.ChatSearchHit,
|
|
) []ctxmgr.ChatSearchHit {
|
|
if authCtx == nil || len(hits) == 0 {
|
|
return hits
|
|
}
|
|
out := make([]ctxmgr.ChatSearchHit, 0, len(hits))
|
|
for _, hit := range hits {
|
|
meta := map[string]any{"stage_key": hit.StageKey}
|
|
if projectinstance.MessageRecordVisible("", hit.StageKey, meta, authCtx.StageAccess, authCtx.SessionOwnerID, authCtx.ViewerUserID) {
|
|
out = append(out, hit)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) applyChatWindowAuthFilter(authCtx *chatHistoryAuth, res *ctxmgr.ChatWindowResult) {
|
|
if res == nil || authCtx == nil {
|
|
return
|
|
}
|
|
filtered := make([]ctxmgr.ChatDayBucket, 0, len(res.Days))
|
|
for i := range res.Days {
|
|
msgs := s.filterChatMessagesByAuth(authCtx, res.Days[i].Messages)
|
|
if len(msgs) == 0 {
|
|
continue
|
|
}
|
|
res.Days[i].Messages = msgs
|
|
res.Days[i].LoadedCount = len(msgs)
|
|
res.Days[i].Count = len(msgs)
|
|
res.Days[i].FirstMessageID = msgs[0].ID
|
|
res.Days[i].LastMessageID = msgs[len(msgs)-1].ID
|
|
filtered = append(filtered, res.Days[i])
|
|
}
|
|
res.Days = filtered
|
|
if len(filtered) > 0 {
|
|
res.OldestLoadedDay = filtered[0].DayKey
|
|
} else {
|
|
res.OldestLoadedDay = ""
|
|
}
|
|
}
|
|
|
|
func aggregateDateStats(msgs []ctxmgr.ChatMessageRecord) []ctxmgr.ChatDateStat {
|
|
statMap := make(map[string]*ctxmgr.ChatDateStat)
|
|
var orderedKeys []string
|
|
for _, msg := range msgs {
|
|
dKey := msg.DayKey
|
|
if dKey == "" {
|
|
continue
|
|
}
|
|
st, ok := statMap[dKey]
|
|
if !ok {
|
|
st = &ctxmgr.ChatDateStat{
|
|
DayKey: dKey,
|
|
Categories: make(map[string]int),
|
|
}
|
|
statMap[dKey] = st
|
|
orderedKeys = append(orderedKeys, dKey)
|
|
}
|
|
st.Count++
|
|
cat := msg.Category
|
|
if cat == "" {
|
|
cat = ctxmgr.CategoryUncategorized
|
|
}
|
|
st.Categories[cat]++
|
|
}
|
|
sort.Strings(orderedKeys)
|
|
res := make([]ctxmgr.ChatDateStat, 0, len(orderedKeys))
|
|
for _, k := range orderedKeys {
|
|
res = append(res, *statMap[k])
|
|
}
|
|
return res
|
|
}
|
|
|
|
func aggregateCategoryStats(msgs []ctxmgr.ChatMessageRecord) []ctxmgr.ChatCategoryStat {
|
|
statMap := make(map[string]*ctxmgr.ChatCategoryStat)
|
|
for _, msg := range msgs {
|
|
cat := msg.Category
|
|
if cat == "" {
|
|
cat = ctxmgr.CategoryUncategorized
|
|
}
|
|
st, ok := statMap[cat]
|
|
if !ok {
|
|
st = &ctxmgr.ChatCategoryStat{
|
|
Category: cat,
|
|
Label: ctxmgr.GetCategoryLabel(cat),
|
|
}
|
|
statMap[cat] = st
|
|
}
|
|
st.Count++
|
|
if st.FirstMessageID == 0 || msg.ID < st.FirstMessageID {
|
|
st.FirstMessageID = msg.ID
|
|
}
|
|
if msg.ID > st.LastMessageID {
|
|
st.LastMessageID = msg.ID
|
|
}
|
|
}
|
|
res := make([]ctxmgr.ChatCategoryStat, 0, len(statMap))
|
|
for _, st := range statMap {
|
|
res = append(res, *st)
|
|
}
|
|
sort.Slice(res, func(i, j int) bool {
|
|
return res[i].Count > res[j].Count
|
|
})
|
|
return res
|
|
}
|
|
|
|
func (s *Server) listVisibleSessionMessages(ctx context.Context, authCtx *chatHistoryAuth) ([]ctxmgr.ChatMessageRecord, error) {
|
|
if s.svc == nil || s.svc.ContextManager() == nil || authCtx == nil {
|
|
return nil, nil
|
|
}
|
|
msgs, err := s.svc.ContextManager().ListSessionMessagesForAuth(ctx, authCtx.SessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.filterChatMessagesByAuth(authCtx, msgs), nil
|
|
}
|
|
|
|
func (s *Server) resolveTaskMessageScope(ctx context.Context, projectID int64, taskKey string) (*projectinstance.MessageScope, error) {
|
|
if s.instSvc == nil || !s.instSvc.Available() {
|
|
if s.cfg != nil && s.cfg.AllowInMemory {
|
|
return &projectinstance.MessageScope{ScopeKind: projectinstance.ScopePreActivation}, nil
|
|
}
|
|
return nil, projectinstance.ErrTaskScopeNotResolved
|
|
}
|
|
return s.instSvc.ResolveMessageScopeForTask(ctx, projectID, taskKey)
|
|
}
|
|
|
|
func (s *Server) resolveChatMessageScope(ctx context.Context, projectID int64, activeStageKey string) (*projectinstance.MessageScope, error) {
|
|
if s.instSvc == nil || !s.instSvc.Available() {
|
|
if projectID <= 0 {
|
|
return &projectinstance.MessageScope{ScopeKind: projectinstance.ScopeNonProjectPrivate}, nil
|
|
}
|
|
return &projectinstance.MessageScope{ScopeKind: projectinstance.ScopePreActivation}, nil
|
|
}
|
|
return s.instSvc.ResolveMessageScopeForChat(ctx, projectID, activeStageKey)
|
|
}
|
|
|
|
func (s *Server) applyScopeToMeta(ctx context.Context, meta map[string]any, projectID int64, activeStageKey, taskKey string) error {
|
|
if meta == nil {
|
|
return nil
|
|
}
|
|
var scope *projectinstance.MessageScope
|
|
var err error
|
|
if taskKey != "" {
|
|
scope, err = s.resolveTaskMessageScope(ctx, projectID, taskKey)
|
|
} else {
|
|
scope, err = s.resolveChatMessageScope(ctx, projectID, activeStageKey)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if scope != nil {
|
|
projectinstance.ApplyScopeToMetadata(meta, scope)
|
|
}
|
|
return nil
|
|
}
|