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

247 lines
7.3 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"chat-agent-system/internal/notification"
)
func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
if s.notifySvc() == nil || !s.notifySvc().Available() {
writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable")
return
}
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return
}
sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := s.notifySvc().ListProjectNotifications(r.Context(), user.ID, projectID, sinceID, limit)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"items": items,
})
}
// userNotificationView is the all-scope inbox row (includes severity / digest when migrated).
type userNotificationView struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
ProjectID int64 `json:"project_id"`
EventID int64 `json:"event_id,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
Status string `json:"status"`
Severity string `json:"severity,omitempty"`
DigestID int64 `json:"digest_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Payload map[string]any `json:"payload,omitempty"`
}
// handleListUserNotifications GET /api/v1/notifications — cross-project inbox.
func (s *Server) handleListUserNotifications(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
if s.notifySvc() == nil || !s.notifySvc().Available() {
writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable")
return
}
sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
items, err := s.listAllScopeNotifications(r.Context(), user.ID, sinceID, limit)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"items": items,
})
}
func (s *Server) listAllScopeNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) {
// Prefer free-collab ListUserNotifications when the method exists on *notification.Service.
type userLister interface {
ListUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]notification.Notification, error)
}
if lister, ok := any(s.notifySvc()).(userLister); ok {
raw, err := lister.ListUserNotifications(ctx, userID, sinceID, limit)
if err != nil {
return nil, err
}
out := make([]userNotificationView, 0, len(raw))
for _, n := range raw {
out = append(out, userNotificationViewFrom(n))
}
return out, nil
}
// Direct SQL via runtime pool when severity columns are available.
if s.rt != nil && s.rt.Pool != nil {
items, err := s.queryUserNotifications(ctx, userID, sinceID, limit)
if err == nil {
return items, nil
}
// Fall through to project fan-out if columns are missing pre-migration.
}
return s.fanOutProjectNotifications(ctx, userID, sinceID, limit)
}
func userNotificationViewFrom(n notification.Notification) userNotificationView {
var v userNotificationView
raw, err := json.Marshal(n)
if err != nil {
return userNotificationView{
ID: n.ID, UserID: n.UserID, ProjectID: n.ProjectID, EventID: n.EventID,
Type: n.Type, Title: n.Title, Body: n.Body, Status: n.Status,
CreatedAt: n.CreatedAt,
}
}
_ = json.Unmarshal(raw, &v)
return v
}
func (s *Server) queryUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) {
query := `
SELECT n.id, n.user_id, n.project_id, COALESCE(n.event_id, 0), n.type, n.title, n.body, n.status,
COALESCE(n.severity, 'info'), COALESCE(n.digest_id, 0), n.created_at,
COALESCE(e.payload, '{}'::jsonb),
COALESCE(d.doc_id, ''),
COALESCE((
SELECT elem->>'proposal_id'
FROM jsonb_array_elements(COALESCE(d.item_summaries, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord)
WHERE COALESCE(elem->>'proposal_id', '') <> ''
ORDER BY ord DESC
LIMIT 1
), '')
FROM notifications n
LEFT JOIN outbox_events e ON e.id = n.event_id
LEFT JOIN notification_digests d ON d.id = n.digest_id
WHERE n.user_id = $1
`
args := []any{userID}
if sinceID > 0 {
query += ` AND n.id > $2`
args = append(args, sinceID)
}
query += ` ORDER BY CASE WHEN COALESCE(n.severity, 'info') = 'action_required' THEN 0 ELSE 1 END, n.id DESC`
query += ` LIMIT $` + strconv.Itoa(len(args)+1)
args = append(args, limit)
rows, err := s.rt.Pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]userNotificationView, 0)
for rows.Next() {
var n userNotificationView
var payloadRaw []byte
var digestDocID, digestProposalID string
if err := rows.Scan(
&n.ID, &n.UserID, &n.ProjectID, &n.EventID, &n.Type, &n.Title, &n.Body, &n.Status,
&n.Severity, &n.DigestID, &n.CreatedAt, &payloadRaw, &digestDocID, &digestProposalID,
); err != nil {
return nil, err
}
if len(payloadRaw) > 0 {
_ = json.Unmarshal(payloadRaw, &n.Payload)
}
if n.Payload == nil {
n.Payload = map[string]any{}
}
if digestDocID != "" {
if _, ok := n.Payload["doc_id"]; !ok || n.Payload["doc_id"] == "" || n.Payload["doc_id"] == nil {
n.Payload["doc_id"] = digestDocID
}
}
if digestProposalID != "" {
if _, ok := n.Payload["proposal_id"]; !ok || n.Payload["proposal_id"] == "" || n.Payload["proposal_id"] == nil {
n.Payload["proposal_id"] = digestProposalID
}
}
out = append(out, n)
}
return out, rows.Err()
}
func (s *Server) fanOutProjectNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) {
if s.pwSvc() == nil {
return []userNotificationView{}, nil
}
projects, err := s.pwSvc().ListProjects(ctx, userID)
if err != nil {
return nil, err
}
raw := make([]notification.Notification, 0)
per := limit
if per < 20 {
per = 20
}
for _, p := range projects {
if p == nil {
continue
}
chunk, listErr := s.notifySvc().ListProjectNotifications(ctx, userID, p.ID, sinceID, per)
if listErr != nil {
continue
}
raw = append(raw, chunk...)
}
sort.SliceStable(raw, func(i, j int) bool {
si, sj := inboxSeverityRank(raw[i].Type), inboxSeverityRank(raw[j].Type)
if si != sj {
return si < sj
}
return raw[i].ID > raw[j].ID
})
if len(raw) > limit {
raw = raw[:limit]
}
out := make([]userNotificationView, 0, len(raw))
for _, n := range raw {
v := userNotificationViewFrom(n)
if inboxSeverityRank(n.Type) == 0 {
v.Severity = "action_required"
} else if v.Severity == "" {
v.Severity = "info"
}
out = append(out, v)
}
return out, nil
}
func inboxSeverityRank(typ string) int {
if strings.Contains(typ, "proposal_required") {
return 0
}
return 1
}