From 0b323b91ee06c92f7c977466a01f7218b3fd5fde Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 28 Apr 2026 22:37:19 +0800 Subject: [PATCH] feat(notify): http handler for inbox list / unread count / mark read --- internal/infra/notify/handler.go | 151 +++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 internal/infra/notify/handler.go diff --git a/internal/infra/notify/handler.go b/internal/infra/notify/handler.go new file mode 100644 index 0000000..34a9298 --- /dev/null +++ b/internal/infra/notify/handler.go @@ -0,0 +1,151 @@ +// Package notify 内的 handler.go 是通知 inbox 的 HTTP 接入层。它把已经 +// 鉴权完成的请求路由到 Repository 的对应方法,并按统一的 response.go +// 风格(Google API 风格错误体)返回。 +// +// 端点(统一前缀 /api/v1/notifications,全部需要登录): +// +// GET / → 列表,参数 ?unread=true&limit=N +// GET /unread-count → 未读计数 +// PATCH /read-all → 全部标已读 +// PATCH /{id}/read → 单条标已读 +package notify + +import ( + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// Handler 暴露 notify 模块的 HTTP 接口;依赖 Repository 与 SessionResolver。 +// 不直接持有 Dispatcher——发送通知由其它模块通过 Dispatcher 完成,handler +// 仅服务 inbox 读取与状态变更。 +type Handler struct { + repo Repository + resolver middleware.SessionResolver +} + +// NewHandler 构造 notify 模块的 HTTP handler。 +func NewHandler(repo Repository, resolver middleware.SessionResolver) *Handler { + return &Handler{repo: repo, resolver: resolver} +} + +// Mount 把 inbox 路由挂到给定 chi.Router 上。Auth 中间件在子路由内统一注入。 +func (h *Handler) Mount(r chi.Router) { + r.Route("/api/v1/notifications", func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + r.Get("/", h.list) + r.Get("/unread-count", h.unreadCount) + r.Patch("/read-all", h.readAll) + r.Patch("/{id}/read", h.markRead) + }) +} + +// notificationDTO 是 inbox 单条通知的对外 JSON 形态。created_at/read_at +// 统一序列化为 RFC3339 字符串;read_at 为 nil 时省略(omitempty)。 +type notificationDTO struct { + ID string `json:"id"` + Topic string `json:"topic"` + Severity string `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Link string `json:"link,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + ReadAt *string `json:"read_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeUnauthorized, "未认证")) + return + } + unread := r.URL.Query().Get("unread") == "true" + limit := 50 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { + limit = n + } + } + msgs, err := h.repo.List(r.Context(), uid, unread, limit) + if err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) + return + } + out := make([]notificationDTO, 0, len(msgs)) + for _, m := range msgs { + dto := notificationDTO{ + ID: m.ID.String(), + Topic: m.Topic, + Severity: string(m.Severity), + Title: m.Title, + Body: m.Body, + Link: m.Link, + Metadata: m.Metadata, + CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339), + } + if m.ReadAt != nil { + s := m.ReadAt.UTC().Format(time.RFC3339) + dto.ReadAt = &s + } + out = append(out, dto) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": out}) +} + +func (h *Handler) unreadCount(w http.ResponseWriter, r *http.Request) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeUnauthorized, "未认证")) + return + } + n, err := h.repo.CountUnread(r.Context(), uid) + if err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"count": n}) +} + +func (h *Handler) markRead(w http.ResponseWriter, r *http.Request) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeUnauthorized, "未认证")) + return + } + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeInvalidInput, "id 格式错误")) + return + } + if err := h.repo.MarkRead(r.Context(), uid, id); err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) readAll(w http.ResponseWriter, r *http.Request) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeUnauthorized, "未认证")) + return + } + if err := h.repo.MarkAllRead(r.Context(), uid); err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) + return + } + w.WriteHeader(http.StatusNoContent) +}