829 lines
26 KiB
Go
829 lines
26 KiB
Go
package controller
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"sync"
|
||
"time"
|
||
|
||
"dubbo.apache.org/dubbo-go/v3/client"
|
||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/topfans/backend/gateway/pkg/response"
|
||
"github.com/topfans/backend/pkg/database"
|
||
pbNotif "github.com/topfans/backend/pkg/proto/notification"
|
||
"github.com/topfans/backend/pkg/logger"
|
||
"go.uber.org/zap"
|
||
"google.golang.org/grpc/codes"
|
||
"google.golang.org/protobuf/types/known/structpb"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// notifNameCache 按 "userID:starID" 缓存 (mobile, nickname, star_name) 拉取结果,
|
||
// 避免列表页对同一 (user, star) 组合重复打 3 次 SELECT。
|
||
// 用 sync.Map 是因为 controller 是高并发 handler 调用,且原写法 (StatsMap 等) 同模式。
|
||
type notifNameEntry struct {
|
||
mobile string
|
||
nickname string
|
||
starName string
|
||
}
|
||
|
||
var notifNameCache sync.Map // key: string ("userID:starID") -> notifNameEntry
|
||
|
||
// 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 序列化友好)
|
||
db := database.GetDB()
|
||
items := make([]map[string]interface{}, 0, len(resp.Items))
|
||
for _, n := range resp.Items {
|
||
items = append(items, convertNotification(db, 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, "feedback_replied": 0, "report_resolved": 0, "target_reported": 0, "total": 0}
|
||
if resp.Counts != nil {
|
||
counts = gin.H{
|
||
"like": resp.Counts.Like,
|
||
"system": resp.Counts.System,
|
||
"activity": resp.Counts.Activity,
|
||
"feedback_replied": resp.Counts.FeedbackReplied,
|
||
"report_resolved": resp.Counts.ReportResolved,
|
||
"target_reported": resp.Counts.TargetReported,
|
||
"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,
|
||
})
|
||
}
|
||
|
||
// ClearByType 按 type 软删通知(站内邮箱聚合页"清空"按钮)
|
||
// @Summary 按 type 软删通知
|
||
// @Description 软删当前用户指定 type 的全部通知;type 取值 activity | feedback_replied | report_resolved | target_reported | all
|
||
// @Tags notifications
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param type query string true "通知类型: activity | feedback_replied | report_resolved | target_reported | all"
|
||
// @Success 200 {object} response.Response
|
||
// @Failure 400 {object} response.Response "type 缺失"
|
||
// @Router /api/v1/notifications/clear [delete]
|
||
func (ctrl *NotificationController) ClearByType(g *gin.Context) {
|
||
userID, _ := g.Get("user_id")
|
||
starID, _ := g.Get("star_id")
|
||
|
||
typeStr := g.Query("type")
|
||
if typeStr == "" {
|
||
response.Error(g, http.StatusBadRequest, "参数错误: type 必填")
|
||
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.ClearByType(ctx, &pbNotif.ClearByTypeRequest{
|
||
Type: typeStr,
|
||
})
|
||
|
||
if err != nil {
|
||
logger.Logger.Error("ClearByType RPC failed",
|
||
zap.Int64("user_id", userID.(int64)),
|
||
zap.String("type", typeStr),
|
||
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{})
|
||
// 同时按 (user_id, star_id) 补上 user_mobile / user_nickname / star_name 三项,
|
||
// 注入到 data map 里,Postcard 渲染时直接从 data 取。
|
||
func convertNotification(db *gorm.DB, 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;旧端 key 全部保留,只追加新 key。
|
||
data := map[string]interface{}{}
|
||
if n.Data != nil {
|
||
data = n.Data.AsMap()
|
||
}
|
||
item["data"] = data
|
||
|
||
// 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
|
||
}
|
||
|
||
// 附加 user_mobile / user_nickname / star_name (DB join, 带缓存)
|
||
if db != nil && n.UserId > 0 {
|
||
mobile, nickname, starName := lookupNames(db, n.UserId, n.StarId)
|
||
if mobile != "" {
|
||
data["user_mobile"] = mobile
|
||
}
|
||
if nickname != "" {
|
||
data["user_nickname"] = nickname
|
||
}
|
||
if starName != "" {
|
||
data["star_name"] = starName
|
||
}
|
||
}
|
||
|
||
return item
|
||
}
|
||
|
||
// maskMobile 把 11 位手机号中间 4 位脱敏 -> "139****0001"。长度不足返回原值。
|
||
func maskMobile(m string) string {
|
||
if len(m) < 7 {
|
||
return m
|
||
}
|
||
return m[:3] + "****" + m[len(m)-4:]
|
||
}
|
||
|
||
// lookupNames 查 (user_id, star_id) 对应的 masked_mobile / nickname / star_name,
|
||
// 命中 sync.Map 缓存的 (userID:starID) 键直接返回,未命中走 3 次轻量 SELECT。
|
||
// 任意一次失败 (record not found / db nil) 都返回空串,不阻断主流程。
|
||
func lookupNames(db *gorm.DB, userID, starID int64) (mobile, nickname, starName string) {
|
||
if db == nil || userID <= 0 {
|
||
return "", "", ""
|
||
}
|
||
key := fmt.Sprintf("%d:%d", userID, starID)
|
||
if v, ok := notifNameCache.Load(key); ok {
|
||
entry, _ := v.(notifNameEntry)
|
||
return entry.mobile, entry.nickname, entry.starName
|
||
}
|
||
|
||
type userRow struct {
|
||
Mobile string `gorm:"column:mobile"`
|
||
}
|
||
var ur userRow
|
||
if err := db.Table("users").Select("mobile").Where("id = ?", userID).Take(&ur).Error; err == nil {
|
||
mobile = maskMobile(ur.Mobile)
|
||
}
|
||
|
||
// fan_profiles 是 (user_id, star_id) 联合唯一键;nickname 是同 star 下相对唯一的 ID 化昵称。
|
||
type fpRow struct {
|
||
Nickname string `gorm:"column:nickname"`
|
||
}
|
||
var fpr fpRow
|
||
if starID > 0 {
|
||
if err := db.Table("fan_profiles").Select("nickname").
|
||
Where("user_id = ? AND star_id = ?", userID, starID).Take(&fpr).Error; err == nil {
|
||
nickname = fpr.Nickname
|
||
}
|
||
}
|
||
|
||
if starID > 0 {
|
||
type starRow struct {
|
||
Name string `gorm:"column:name"`
|
||
}
|
||
var sr starRow
|
||
if err := db.Table("stars").Select("name").Where("star_id = ?", starID).Take(&sr).Error; err == nil {
|
||
starName = sr.Name
|
||
}
|
||
}
|
||
|
||
notifNameCache.Store(key, notifNameEntry{mobile: mobile, nickname: nickname, starName: starName})
|
||
return mobile, nickname, starName
|
||
}
|
||
|
||
// ========== Admin 入口(无鉴权,内网部署) ==========
|
||
//
|
||
// AdminCreateNotification 供 Python admin (8081) 调用的批量发送入口。
|
||
// 设计要点:
|
||
// - **不带鉴权**(路由在 router.go 的 admin 组,无 AuthMiddleware)。
|
||
// 仅依赖部署侧网络隔离;生产环境必须用 Nginx/防火墙限制 8080 只接受 8081 段。
|
||
// - 接收方由 admin 侧解析(查 fan_profiles),本接口只接受已解析的 user_ids 列表,
|
||
// gateway 不再重读 fan_profiles,避免 Go/Python 两边重复实现接收方解析。
|
||
// - 循环调 notifService.CreateNotification,后者在事务内写 notifications + 累加 stats,
|
||
// 并在 commit 后异步触发 triggerPush -> uniCloud sendMessage -> 手机通知栏。
|
||
// - 单条失败仅 warn,不影响整批(全部失败时 affected=0)。
|
||
|
||
// adminCreateNotificationRequest HTTP DTO。无 user_id 字段——user_ids 是解析后的结果。
|
||
type adminCreateNotificationRequest struct {
|
||
UserIDs []int64 `json:"user_ids" binding:"required,min=1,max=10000,dive,gt=0"`
|
||
Type string `json:"type" binding:"required,oneof=system activity like"`
|
||
Title string `json:"title" binding:"required,min=1,max=200"`
|
||
Content string `json:"content" binding:"omitempty,max=500"`
|
||
Data map[string]interface{} `json:"data"`
|
||
StarID int64 `json:"star_id"`
|
||
}
|
||
|
||
// adminCreateNotificationResponse 响应 DTO,字段对齐 admin 的 CreateSystemNotificationResp。
|
||
// Errors 字段收集失败 user_id + 首条 error 字符串(至多前 5 条),让 admin 端能感知
|
||
// 单 user 失败(以前只 logger.Warn + continue,Python 端拿不到任何信号)。
|
||
type adminCreateNotificationResponse struct {
|
||
Affected int64 `json:"affected"`
|
||
TargetCount int `json:"target_count"`
|
||
Errors []adminNotifError `json:"errors,omitempty"`
|
||
}
|
||
|
||
type adminNotifError struct {
|
||
UserID int64 `json:"user_id"`
|
||
Err string `json:"err"`
|
||
}
|
||
|
||
// AdminCreateNotification 批量创建通知 + 触发手机推送。
|
||
// @Summary admin 批量发送通知
|
||
// @Description 内部接口,无 JWT 鉴权,供 admin 后台(Python 8081)调用。user_ids 由调用方解析(查 fan_profiles)。
|
||
// @Tags admin
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body adminCreateNotificationRequest true "通知 payload(已解析的 user_ids)"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/admin/notifications [post]
|
||
func (ctrl *NotificationController) AdminCreateNotification(g *gin.Context) {
|
||
var req adminCreateNotificationRequest
|
||
if err := g.ShouldBindJSON(&req); err != nil {
|
||
response.Error(g, http.StatusBadRequest, "参数错误: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// data: map -> structpb.Struct(go service 的 CreateNotification 接收 structpb)
|
||
var dataStruct *structpb.Struct
|
||
if len(req.Data) > 0 {
|
||
s, err := structpb.NewStruct(req.Data)
|
||
if err != nil {
|
||
response.Error(g, http.StatusBadRequest, "invalid data: "+err.Error())
|
||
return
|
||
}
|
||
dataStruct = s
|
||
}
|
||
|
||
// 30s 是为大批量 (上万个用户) 留的余量;实际每条 CreateNotification 一次 RPC。
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
var success int64
|
||
var errs []adminNotifError
|
||
for _, uid := range req.UserIDs {
|
||
_, err := ctrl.notifService.CreateNotification(ctx, &pbNotif.CreateNotificationRequest{
|
||
UserId: uid,
|
||
StarId: req.StarID,
|
||
Type: req.Type,
|
||
Title: req.Title,
|
||
Content: req.Content,
|
||
Data: dataStruct,
|
||
})
|
||
if err != nil {
|
||
logger.Logger.Warn("AdminCreateNotification: single user failed",
|
||
zap.Int64("user_id", uid),
|
||
zap.String("type", req.Type),
|
||
zap.Error(err))
|
||
// 把前 5 条失败 user 塞到响应里,让 admin 端能感知(以前只 log,Python 拿不到)
|
||
if len(errs) < 5 {
|
||
errs = append(errs, adminNotifError{UserID: uid, Err: err.Error()})
|
||
}
|
||
continue
|
||
}
|
||
success++
|
||
}
|
||
|
||
logger.Logger.Info("admin notification batch done",
|
||
zap.Int("requested", len(req.UserIDs)),
|
||
zap.Int64("affected", success),
|
||
zap.String("type", req.Type),
|
||
zap.Int("errors", len(errs)))
|
||
|
||
resp := adminCreateNotificationResponse{
|
||
Affected: success,
|
||
TargetCount: len(req.UserIDs),
|
||
Errors: errs,
|
||
}
|
||
if len(errs) == 0 {
|
||
resp.Errors = nil // 全部成功时不要带空数组,保持响应轻量
|
||
}
|
||
response.Success(g, resp)
|
||
}
|