topfans/backend/gateway/controller/notification_controller.go

576 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package controller
import (
"context"
"net/http"
"strconv"
"time"
"dubbo.apache.org/dubbo-go/v3/client"
"dubbo.apache.org/dubbo-go/v3/common/constant"
"github.com/gin-gonic/gin"
pbNotif "github.com/topfans/backend/pkg/proto/notification"
"github.com/topfans/backend/gateway/pkg/response"
"github.com/topfans/backend/pkg/logger"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
)
// NotificationController 通知相关控制器
type NotificationController struct {
notifService pbNotif.NotificationService
}
// NewNotificationController 创建通知控制器
func NewNotificationController(dubboClient *client.Client) (*NotificationController, error) {
notifService, err := pbNotif.NewNotificationService(dubboClient)
if err != nil {
return nil, err
}
return &NotificationController{
notifService: notifService,
}, nil
}
// ========== 设备注册(推送 cid 上报)==========
// App 端启动时调用,把 uni.getPushClientId() 拿到的 cid 上报后端;
// 后续 CreateNotification 触发推送时,后端按 user_id 查这里写入的 cid 列表。
//
// 接口约定:
// - POST /api/v1/notifications/devices body: { cid, platform, appVersion, deviceModel }
// - POST /api/v1/notifications/devices/unregister body: { cid } // cid 为空 = 注销当前用户全部
//
// 鉴权:依赖路由组 AuthMiddleware,从 JWT 提取 user_id 后写入 metadata。
// device_model 在 iOS 上是 sysinfo.model,Android 上是 sysinfo.model。
// registerDeviceRequest HTTP DTO。
type registerDeviceRequest struct {
CID string `json:"cid" binding:"required,min=1,max=128"`
Platform string `json:"platform" binding:"omitempty,oneof=ios android harmony"`
AppVersion string `json:"app_version" binding:"omitempty,max=32"`
DeviceModel string `json:"device_model" binding:"omitempty,max=64"`
}
// unregisterDeviceRequest HTTP DTO。
type unregisterDeviceRequest struct {
CID string `json:"cid" binding:"omitempty,max=128"`
}
// RegisterDevice 注册/更新当前用户的推送设备。
// @Summary 注册推送设备
// @Description 将 uni.getPushClientId() 拿到的 cid 上报给后端;同 cid 重复注册为更新。
// @Tags notifications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body registerDeviceRequest true "设备信息"
// @Success 200 {object} response.Response
// @Router /api/v1/notifications/devices [post]
func (ctrl *NotificationController) RegisterDevice(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
var req registerDeviceRequest
if err := g.ShouldBindJSON(&req); err != nil {
response.Error(g, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.notifService.RegisterDevice(ctx, &pbNotif.RegisterDeviceRequest{
Cid: req.CID,
Platform: req.Platform,
AppVersion: req.AppVersion,
DeviceModel: req.DeviceModel,
})
if err != nil {
logger.Logger.Error("RegisterDevice RPC failed",
zap.Int64("user_id", userID.(int64)),
zap.Error(err))
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(g, gin.H{
"id": resp.Id,
"cid": req.CID,
})
}
// UnregisterDevice 注销当前用户指定 cid 的推送;cid 为空时注销所有设备。
// @Summary 注销推送设备
// @Description 注销推送 cid;cid 为空 = 注销当前用户全部设备(用于主动登出)。
// @Tags notifications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body unregisterDeviceRequest true "注销请求"
// @Success 200 {object} response.Response
// @Router /api/v1/notifications/devices/unregister [post]
func (ctrl *NotificationController) UnregisterDevice(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
var req unregisterDeviceRequest
// 允许 body 为空,所以不用 ShouldBindJSON 强制要求;读不到也不报错。
_ = g.ShouldBindJSON(&req)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.notifService.UnregisterDevice(ctx, &pbNotif.UnregisterDeviceRequest{
Cid: req.CID,
})
if err != nil {
logger.Logger.Error("UnregisterDevice RPC failed",
zap.Int64("user_id", userID.(int64)),
zap.Error(err))
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(g, gin.H{"affected": resp.Affected})
}
// parseInt 解析 query string 为 int, 失败或空返回默认值
func parseInt(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
// GetNotifications 获取通知列表
// @Summary 获取通知列表
// @Description 获取当前用户的通知列表(支持 type/tab 分页)
// @Tags notifications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param type query string false "通知类型过滤: like / system / activity"
// @Param tab query string false "列表 tab: unread / read / all"
// @Param page query int false "页码默认1"
// @Param page_size query int false "每页数量默认20"
// @Success 200 {object} response.Response
// @Router /api/v1/notifications [get]
func (ctrl *NotificationController) GetNotifications(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.notifService.GetNotifications(ctx, &pbNotif.GetNotificationsRequest{
Type: g.Query("type"),
Tab: g.Query("tab"),
Page: int32(parseInt(g.Query("page"), 1)),
PageSize: int32(parseInt(g.Query("page_size"), 20)),
})
if err != nil {
logger.Logger.Error("GetNotifications RPC failed",
zap.Int64("user_id", userID.(int64)),
zap.Error(err),
)
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
// 转换为 map 列表(Notification 含 structpb.Struct 序列化友好)
items := make([]map[string]interface{}, 0, len(resp.Items))
for _, n := range resp.Items {
items = append(items, convertNotification(n))
}
response.Success(g, gin.H{
"items": items,
"total": resp.Total,
"page": resp.Page,
"page_size": resp.PageSize,
})
}
// GetUnreadCount 获取未读通知数
// @Summary 获取未读通知数
// @Description 按类型返回未读数量(like/system/activity/total)
// @Tags notifications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response
// @Router /api/v1/notifications/unread-count [get]
func (ctrl *NotificationController) GetUnreadCount(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.notifService.GetUnreadCount(ctx, &pbNotif.GetUnreadCountRequest{})
if err != nil {
logger.Logger.Error("GetUnreadCount RPC failed",
zap.Int64("user_id", userID.(int64)),
zap.Error(err),
)
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
counts := gin.H{"like": 0, "system": 0, "activity": 0, "total": 0}
if resp.Counts != nil {
counts = gin.H{
"like": resp.Counts.Like,
"system": resp.Counts.System,
"activity": resp.Counts.Activity,
"total": resp.Counts.Total,
}
}
response.Success(g, counts)
}
// MarkAsRead 标记单条通知已读
// @Summary 标记单条通知已读
// @Description 根据通知ID标记为已读
// @Tags notifications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "通知ID"
// @Success 200 {object} response.Response
// @Router /api/v1/notifications/{id}/read [post]
func (ctrl *NotificationController) MarkAsRead(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
idStr := g.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
response.Error(g, http.StatusBadRequest, "参数错误: id 必须为正整数")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.notifService.MarkAsRead(ctx, &pbNotif.MarkAsReadRequest{Id: id})
if err != nil {
logger.Logger.Error("MarkAsRead RPC failed",
zap.Int64("user_id", userID.(int64)),
zap.Int64("notification_id", id),
zap.Error(err),
)
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(g, gin.H{"id": id})
}
// MarkAsReadByTarget 按 target_id 标记已读
// @Summary 按目标ID标记已读
// @Description 将同一 target 下的所有通知标记为已读
// @Tags notifications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param target_id path int true "目标ID(如藏品ID)"
// @Success 200 {object} response.Response
// @Router /api/v1/notifications/targets/{target_id}/read [post]
func (ctrl *NotificationController) MarkAsReadByTarget(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
targetIDStr := g.Param("target_id")
targetID, err := strconv.ParseInt(targetIDStr, 10, 64)
if err != nil || targetID <= 0 {
response.Error(g, http.StatusBadRequest, "参数错误: target_id 必须为正整数")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.notifService.MarkAsReadByTarget(ctx, &pbNotif.MarkAsReadByTargetRequest{
TargetId: targetID,
})
if err != nil {
logger.Logger.Error("MarkAsReadByTarget RPC failed",
zap.Int64("user_id", userID.(int64)),
zap.Int64("target_id", targetID),
zap.Error(err),
)
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(g, gin.H{
"target_id": targetID,
"affected": resp.Affected,
})
}
// MarkAllAsRead 全部已读
// @Summary 全部已读
// @Description 将当前用户某类型或全部通知标记为已读
// @Tags notifications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param type query string false "通知类型过滤: like / system / activity; 留空表示全部"
// @Success 200 {object} response.Response
// @Router /api/v1/notifications/read-all [post]
func (ctrl *NotificationController) MarkAllAsRead(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.notifService.MarkAllAsRead(ctx, &pbNotif.MarkAllAsReadRequest{
Type: g.Query("type"),
})
if err != nil {
logger.Logger.Error("MarkAllAsRead RPC failed",
zap.Int64("user_id", userID.(int64)),
zap.String("type", g.Query("type")),
zap.Error(err),
)
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(g, gin.H{
"affected": resp.Affected,
})
}
// DeleteNotification 删除单条通知
// @Summary 删除单条通知
// @Description 根据ID删除通知
// @Tags notifications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "通知ID"
// @Success 200 {object} response.Response
// @Router /api/v1/notifications/{id} [delete]
func (ctrl *NotificationController) DeleteNotification(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
idStr := g.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
response.Error(g, http.StatusBadRequest, "参数错误: id 必须为正整数")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.notifService.DeleteNotification(ctx, &pbNotif.DeleteNotificationRequest{Id: id})
if err != nil {
logger.Logger.Error("DeleteNotification RPC failed",
zap.Int64("user_id", userID.(int64)),
zap.Int64("notification_id", id),
zap.Error(err),
)
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(g, gin.H{"id": id})
}
// DeleteByTarget 按 target_id 删除通知
// @Summary 按目标ID删除通知
// @Description 删除同一 target 下的所有通知
// @Tags notifications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param target_id path int true "目标ID(如藏品ID)"
// @Success 200 {object} response.Response
// @Router /api/v1/notifications/targets/{target_id} [delete]
func (ctrl *NotificationController) DeleteByTarget(g *gin.Context) {
userID, _ := g.Get("user_id")
starID, _ := g.Get("star_id")
targetIDStr := g.Param("target_id")
targetID, err := strconv.ParseInt(targetIDStr, 10, 64)
if err != nil || targetID <= 0 {
response.Error(g, http.StatusBadRequest, "参数错误: target_id 必须为正整数")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
resp, err := ctrl.notifService.DeleteByTarget(ctx, &pbNotif.DeleteByTargetRequest{
TargetId: targetID,
})
if err != nil {
logger.Logger.Error("DeleteByTarget RPC failed",
zap.Int64("user_id", userID.(int64)),
zap.Int64("target_id", targetID),
zap.Error(err),
)
response.Error(g, http.StatusInternalServerError, "服务调用失败")
return
}
if resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(g, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(g, gin.H{
"target_id": targetID,
"affected": resp.Affected,
})
}
// convertNotification 将 *pbNotif.Notification 转为前端友好的 map
// (proto 序列化时 structpb.Struct 不友好, 转成 map[string]interface{})
func convertNotification(n *pbNotif.Notification) map[string]interface{} {
if n == nil {
return nil
}
item := map[string]interface{}{
"id": n.Id,
"user_id": n.UserId,
"star_id": n.StarId,
"type": n.Type,
"title": n.Title,
"content": n.Content,
"is_read": n.IsRead,
"created_at": n.CreatedAt,
"read_at": n.ReadAt,
"target_id": n.TargetId,
"aggregated": n.Aggregated,
"total_count": n.TotalCount,
}
// data 字段: *structpb.Struct → map
if n.Data != nil {
item["data"] = n.Data.AsMap()
}
// actors 字段
if len(n.Actors) > 0 {
actors := make([]map[string]interface{}, 0, len(n.Actors))
for _, a := range n.Actors {
if a == nil {
continue
}
actors = append(actors, map[string]interface{}{
"user_id": a.UserId,
"nickname": a.Nickname,
"avatar": a.Avatar,
"liked_at": a.LikedAt,
})
}
item["actors"] = actors
}
return item
}