From 89b59c7b912424f4db863dcff82ff955212ca11f Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Wed, 16 Sep 2026 02:45:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=A0=87=E8=AE=B0=E5=B7=B2=E8=AF=BB=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/mysqldao/notification.go | 16 ++++++++++++++++ internal/routers/api/v1/notification.go | 16 ++++++++++++++++ internal/routers/router.go | 2 ++ internal/service/notification.go | 20 ++++++++++++++++++++ 接口文档.md | 24 ++++++++++++++++++++++-- 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/internal/mysqldao/notification.go b/internal/mysqldao/notification.go index 150d8be..453a778 100644 --- a/internal/mysqldao/notification.go +++ b/internal/mysqldao/notification.go @@ -60,6 +60,22 @@ func (d *NotificationDao) NotificationUpdateReadByUUIDIfUnread(ctx context.Conte return res.RowsAffected > 0, nil } +// NotificationUpdateReadAllByUserID 将该用户全部未读通知标记为已读,返回影响行数。 +// 条件带 is_read = 1,保证只统计本次真正从未读变为已读的条数(幂等,重复调用返回 0)。 +func (d *NotificationDao) NotificationUpdateReadAllByUserID(ctx context.Context, userID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationUpdateReadAllByUserID") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}). + Where("user_id = ? AND is_read = ?", userID, int8(1)). + Update("is_read", int8(2)) + if res.Error != nil { + span.RecordError(res.Error) + return 0, res.Error + } + return res.RowsAffected, nil +} + // NotificationGetByUUID 按 UUID 查询通知 func (d *NotificationDao) NotificationGetByUUID(ctx context.Context, notificationUUID string) (*mysqlmodel.UserNotification, error) { ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationGetByUUID") diff --git a/internal/routers/api/v1/notification.go b/internal/routers/api/v1/notification.go index 982bad9..b2eb7a2 100644 --- a/internal/routers/api/v1/notification.go +++ b/internal/routers/api/v1/notification.go @@ -77,6 +77,22 @@ func (h *NotificationHandler) Get(c *gin.Context) { response.ToResponse(out) } +// ReadAll 将当前用户全部通知标记为已读(前端进入通知页时调用)。 +func (h *NotificationHandler) ReadAll(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + out, e := h.svc.ReadAll(c.Request.Context(), userID) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + // Read 标记通知已读(uuid 来自路径参数)。 func (h *NotificationHandler) Read(c *gin.Context) { response := app.NewResponse(c) diff --git a/internal/routers/router.go b/internal/routers/router.go index c0e69dc..1e9d77b 100644 --- a/internal/routers/router.go +++ b/internal/routers/router.go @@ -121,6 +121,8 @@ func NewRouter() *gin.Engine { // 通知 auth.GET("/notification", notificationHandler.List) auth.GET("/notification/unread", notificationHandler.Unread) + // 静态路径 /notification/read-all 与 /notification/:uuid/read 段数不同,不会互相抢占 + auth.POST("/notification/read-all", notificationHandler.ReadAll) auth.GET("/notification/:uuid", notificationHandler.Get) auth.POST("/notification/:uuid/read", notificationHandler.Read) diff --git a/internal/service/notification.go b/internal/service/notification.go index 7c07023..4b9d3b5 100644 --- a/internal/service/notification.go +++ b/internal/service/notification.go @@ -108,6 +108,26 @@ func (s *NotificationService) Read(ctx context.Context, userID int64, request *d return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil } +// ReadAll 将当前用户全部未读通知标记为已读,并回填未读数缓存。 +// 与单条已读一致:以 MySQL 为权威重新统计后返回,避免 Redis 计数偏差。 +func (s *NotificationService) ReadAll(ctx context.Context, userID int64) (*dto.NotificationUnreadResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.NotificationReadAll") + defer span.End() + + affected, err := s.notificationDao.NotificationUpdateReadAllByUserID(ctx, userID) + if err != nil { + slog.ErrorContext(ctx, "mark all notifications read failed", "user_id", userID, "error", err) + return nil, errcode.DatabaseError + } + slog.InfoContext(ctx, "mark all notifications read", "user_id", userID, "affected", affected) + + cnt, cerr := s.unreadCount(ctx, userID) + if cerr != nil { + return nil, cerr + } + return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil +} + // Unread 获取当前用户未读数(以 MySQL 为权威)。 func (s *NotificationService) Unread(ctx context.Context, userID int64) (*dto.NotificationUnreadResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.NotificationUnread") diff --git a/接口文档.md b/接口文档.md index 101a149..cdb0466 100644 --- a/接口文档.md +++ b/接口文档.md @@ -334,7 +334,7 @@ Authorization: Bearer | POST | `/like` | 点赞 / 取消点赞 | | GET | `/like/:targetUUID` | 查询目标点赞状态与数量 | -**通知(4)** +**通知(5)** | 方法 | 路径 | 说明 | | --- | --- | --- | @@ -342,6 +342,7 @@ Authorization: Bearer | GET | `/notification/unread` | 未读通知数 | | GET | `/notification/:uuid` | 通知详情 | | POST | `/notification/:uuid/read` | 标记单条已读 | +| POST | `/notification/read-all` | 全部标记已读(前端进入通知页时调用) | **订单(4)** @@ -1637,7 +1638,7 @@ Authorization: Bearer **业务逻辑** - **条件更新**(仅"未读→已读"),防并发重复扣减未读数;已读时直接返回当前未读数,幂等。 -- ⚠️ **没有"全部已读"接口**,需要全标已读时前端需逐条调用。 +- ⚠️ 本接口只影响该条;需要**全部已读**请用 `POST /notification/read-all`(见下节),无需逐条调用。 **请求**(path) @@ -1651,6 +1652,25 @@ Authorization: Bearer --- +#### POST `/notification/read-all` — 全部标记已读(鉴权) + +**业务逻辑** + +- 将当前用户**全部未读**通知一次性置为已读(`is_read` 1 → 2);条件更新(仅未读→已读),**幂等**,重复调用不影响结果。 +- ⚠️ **无请求体、无 query 参数**,作用于当前登录用户的全部通知,不受列表页 `msg_type` / `is_read` 筛选影响。 +- 以 MySQL 为权威重新统计未读数并回填 Redis 缓存后返回,前端可直接用返回值刷新红点,**无需再调** `GET /notification/unread`。 +- 前端约定:`pages/notification` 在**列表渲染完成后**调用(先拉列表保留本次进入的未读标记,再清零服务端状态与红点),实现「点开通知页即全部已读」。 + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| unread_count | int64 | 标记后的未读通知数(正常为 `0`) | + +**错误码**:`19001` 数据库错误 · `19002` 缓存错误 + +--- + ### 5.11 商品模块(公开,无需登录) **`goods_type` 枚举**:`1`=会员包 `2`=上传包 `3`=AI 推荐包 `4`=衣柜分享包 `5`=搭配分享包