feat(push): add JPush integration for offline message push
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled

Add JPush (极光推送) integration to enable push notifications for offline users. This includes:
- New jpush client package with API for batch push notifications
- Configuration for jpush (enabled, app_key, master_secret, production mode)
- Updated push service to send messages via JPush when users are offline
- Added PushChatMessage method with do-not-disturb checking
- Integrated push service with chat service to notify offline users of new messages
- Updated deployment workflow with JPush and related environment variables
This commit is contained in:
lafay
2026-04-27 23:20:24 +08:00
parent f03bbf6faa
commit fb85c9c20a
18 changed files with 919 additions and 66 deletions

View File

@@ -1,4 +1,4 @@
package service
package service
import (
"context"

View File

@@ -61,6 +61,7 @@ type chatServiceImpl struct {
userRepo repository.UserRepository
sensitive SensitiveService
wsHub *ws.Hub
pushSvc PushService
// 缓存相关字段
conversationCache *cache.ConversationCache
@@ -75,6 +76,7 @@ func NewChatService(
wsHub *ws.Hub,
cacheBackend cache.Cache,
uploadService *UploadService,
pushSvc PushService,
) ChatService {
// 创建适配器
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
@@ -93,6 +95,7 @@ func NewChatService(
userRepo: userRepo,
sensitive: sensitive,
wsHub: wsHub,
pushSvc: pushSvc,
conversationCache: conversationCache,
uploadService: uploadService,
}
@@ -384,6 +387,34 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
}
}
// 对离线用户通过 JPush 推送聊天消息通知
if s.pushSvc != nil && len(participants) > 0 {
sender := ChatMessageSender{ID: senderID}
if senderUser, sErr := s.userRepo.GetByID(senderID); sErr == nil {
sender.Name = senderUser.Nickname
sender.Avatar = senderUser.Avatar
}
convType := conv.Type
convName := ""
if conv.Type == model.ConversationTypeGroup && conv.Group != nil {
convName = conv.Group.Name
}
for _, p := range participants {
if p.UserID == senderID {
continue
}
go func(userID string, sender ChatMessageSender) {
if pushErr := s.pushSvc.PushChatMessage(context.Background(), userID, conversationID, &sender, convType, convName, message); pushErr != nil {
zap.L().Debug("push chat message skipped or failed",
zap.String("userID", userID),
zap.String("conversationID", conversationID),
zap.Error(pushErr),
)
}
}(p.UserID, sender)
}
}
return message, nil
}

View File

@@ -8,8 +8,11 @@ import (
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/jpush"
"with_you/internal/pkg/ws"
"with_you/internal/repository"
"go.uber.org/zap"
)
// 推送相关常量
@@ -24,6 +27,13 @@ const (
PushQueueSize = 1000
)
// ChatMessageSender 聊天消息发送者信息
type ChatMessageSender struct {
ID string
Name string
Avatar string
}
// PushPriority 推送优先级
type PushPriority int
@@ -40,6 +50,10 @@ type PushService interface {
PushMessage(ctx context.Context, userID string, message *model.Message) error
PushToUser(ctx context.Context, userID string, message *model.Message, priority int) error
// 聊天消息推送(含免打扰检查)
// 该方法会1. 检查用户是否在线在线则跳过2. 检查会话免打扰免打扰则跳过3. 通过 JPush 推送
PushChatMessage(ctx context.Context, userID string, conversationID string, sender *ChatMessageSender, convType model.ConversationType, convName string, message *model.Message) error
// 系统消息推送
PushSystemMessage(ctx context.Context, userID string, msgType, title, content string, data map[string]any) error
@@ -54,6 +68,7 @@ type PushService interface {
// 推送记录管理
CreatePushRecord(ctx context.Context, userID string, messageID string, channel model.PushChannel) (*model.PushRecord, error)
GetPendingPushes(ctx context.Context, userID string) ([]*model.PushRecord, error)
GetUserDevices(ctx context.Context, userID string) ([]*model.DeviceToken, error)
// 后台任务
StartPushWorker(ctx context.Context)
@@ -66,8 +81,8 @@ type pushServiceImpl struct {
deviceRepo repository.DeviceTokenRepository
messageRepo repository.MessageRepository
wsHub *ws.Hub
jpushClient *jpush.Client
// 推送队列
pushQueue chan *pushTask
stopChan chan struct{}
}
@@ -85,12 +100,14 @@ func NewPushService(
deviceRepo repository.DeviceTokenRepository,
messageRepo repository.MessageRepository,
wsHub *ws.Hub,
jpushClient *jpush.Client,
) PushService {
return &pushServiceImpl{
pushRepo: pushRepo,
deviceRepo: deviceRepo,
messageRepo: messageRepo,
wsHub: wsHub,
jpushClient: jpushClient,
pushQueue: make(chan *pushTask, PushQueueSize),
stopChan: make(chan struct{}),
}
@@ -117,7 +134,56 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
return nil
}
// WebSocket推送失败加入推送队列等待移动端推
// 用户不在线,尝试通过极光推送直接发
if s.jpushClient != nil {
devices, err := s.deviceRepo.GetActiveByUserID(userID)
if err == nil && len(devices) > 0 {
mobileDevices := make([]*model.DeviceToken, 0)
for _, d := range devices {
if d.SupportsMobilePush() && d.PushToken != "" {
mobileDevices = append(mobileDevices, d)
}
}
if len(mobileDevices) > 0 {
title := "新消息"
content := dto.ExtractTextContentFromModel(message.Segments)
if message.IsSystemMessage() || message.Category == model.CategoryNotification {
if message.ExtraData != nil && message.ExtraData.ActorName != "" {
title = message.ExtraData.ActorName
} else {
title = "系统通知"
}
}
extras := map[string]any{
"message_id": message.ID,
"conversation_id": message.ConversationID,
"sender_id": message.SenderID,
"category": string(message.Category),
}
if message.ExtraData != nil {
extras["actor_name"] = message.ExtraData.ActorName
extras["avatar_url"] = message.ExtraData.AvatarURL
extras["target_id"] = message.ExtraData.TargetID
extras["target_type"] = message.ExtraData.TargetType
}
pushErr := s.pushViaJPushBatch(mobileDevices, title, content, extras)
if pushErr == nil {
for _, device := range mobileDevices {
record, rerr := s.CreatePushRecord(ctx, userID, message.ID, model.PushChannelJPush)
if rerr == nil {
record.DeviceToken = device.PushToken
record.DeviceType = string(device.DeviceType)
record.MarkPushed()
s.pushRepo.Update(record)
}
}
return nil
}
}
}
}
// 极光推送也失败,加入推送队列等待重试
select {
case s.pushQueue <- &pushTask{
userID: userID,
@@ -127,7 +193,7 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
return nil
default:
// 队列已满,直接创建待推送记录
_, err := s.CreatePushRecord(ctx, userID, message.ID, model.PushChannelFCM)
_, err := s.CreatePushRecord(ctx, userID, message.ID, model.PushChannelJPush)
if err != nil {
return fmt.Errorf("failed to create pending push record: %w", err)
}
@@ -209,22 +275,49 @@ func (s *pushServiceImpl) pushViaWebSocket(ctx context.Context, userID string, m
return true
}
// pushViaFCM 通过FCM推送预留接口
func (s *pushServiceImpl) pushViaFCM(ctx context.Context, deviceToken *model.DeviceToken, message *model.Message) error {
// TODO: 实现FCM推送
// 1. 构建FCM消息
// 2. 调用Firebase Admin SDK发送消息
// 3. 处理发送结果
return errors.New("FCM push not implemented")
// pushViaJPush 通过极光推送发送通知
func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.DeviceToken, title, content string, extras map[string]any) error {
if s.jpushClient == nil {
return errors.New("jpush client not configured")
}
if device.PushToken == "" {
return errors.New("device has no registration ID")
}
notification := jpush.BuildNotification(title, content, "message", extras)
_, err := s.jpushClient.PushByRegistrationIDs([]string{device.PushToken}, notification, extras)
if err != nil {
return fmt.Errorf("jpush push failed: %w", err)
}
return nil
}
// pushViaAPNs 通过APNs推送预留接口
func (s *pushServiceImpl) pushViaAPNs(ctx context.Context, deviceToken *model.DeviceToken, message *model.Message) error {
// TODO: 实现APNs推送
// 1. 构建APNs消息
// 2. 调用APNs SDK发送消息
// 3. 处理发送结果
return errors.New("APNs push not implemented")
// pushViaJPushBatch 批量通过极光推送发送通知
func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, title, content string, extras map[string]any) error {
if s.jpushClient == nil {
return errors.New("jpush client not configured")
}
var regIDs []string
for _, d := range devices {
if d.PushToken != "" {
regIDs = append(regIDs, d.PushToken)
}
}
if len(regIDs) == 0 {
return errors.New("no valid registration IDs")
}
notification := jpush.BuildNotification(title, content, "message", extras)
_, err := s.jpushClient.PushByRegistrationIDs(regIDs, notification, extras)
if err != nil {
return fmt.Errorf("jpush batch push failed: %w", err)
}
return nil
}
// RegisterDevice 注册设备
@@ -283,6 +376,11 @@ func (s *pushServiceImpl) GetPendingPushes(ctx context.Context, userID string) (
return s.pushRepo.GetByUserID(userID, 100, 0)
}
// GetUserDevices 获取用户设备列表
func (s *pushServiceImpl) GetUserDevices(ctx context.Context, userID string) ([]*model.DeviceToken, error) {
return s.deviceRepo.GetByUserID(userID)
}
// StartPushWorker 启动推送工作协程
func (s *pushServiceImpl) StartPushWorker(ctx context.Context) {
go s.processPushQueue()
@@ -315,34 +413,88 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
devices, err := s.deviceRepo.GetActiveByUserID(task.userID)
if err != nil || len(devices) == 0 {
// 没有可用设备,创建待推送记录
s.CreatePushRecord(ctx, task.userID, task.message.ID, model.PushChannelFCM)
s.CreatePushRecord(ctx, task.userID, task.message.ID, model.PushChannelJPush)
return
}
// 对每个设备创建推送记录并尝试推送
for _, device := range devices {
record, err := s.CreatePushRecord(ctx, task.userID, task.message.ID, s.getChannelForDevice(device))
if err != nil {
continue
}
var pushErr error
switch {
case device.IsIOS():
pushErr = s.pushViaAPNs(ctx, device, task.message)
case device.IsAndroid():
pushErr = s.pushViaFCM(ctx, device, task.message)
default:
// Web设备只支持WebSocket
continue
}
if pushErr != nil {
record.MarkFailed(pushErr.Error())
// 提取消息标题和内容
_ = task.message.Decrypt()
title := "新消息"
content := dto.ExtractTextContentFromModel(task.message.Segments)
if task.message.IsSystemMessage() || task.message.Category == model.CategoryNotification {
if task.message.ExtraData != nil && task.message.ExtraData.ActorName != "" {
title = task.message.ExtraData.ActorName
} else {
record.MarkPushed()
title = "系统通知"
}
}
extras := map[string]any{
"message_id": task.message.ID,
"conversation_id": task.message.ConversationID,
"sender_id": task.message.SenderID,
"category": string(task.message.Category),
}
if task.message.ExtraData != nil {
extras["actor_name"] = task.message.ExtraData.ActorName
extras["avatar_url"] = task.message.ExtraData.AvatarURL
extras["target_id"] = task.message.ExtraData.TargetID
extras["target_type"] = task.message.ExtraData.TargetType
}
// 尝试使用极光推送(统一推送到所有移动设备)
mobileDevices := make([]*model.DeviceToken, 0)
for _, device := range devices {
if device.SupportsMobilePush() && device.PushToken != "" {
mobileDevices = append(mobileDevices, device)
}
}
if len(mobileDevices) > 0 {
// 优先使用极光推送
if s.jpushClient != nil {
err := s.pushViaJPushBatch(mobileDevices, title, content, extras)
if err == nil {
// 极光推送成功,为每个设备创建推送记录
for _, device := range mobileDevices {
record, rerr := s.CreatePushRecord(ctx, task.userID, task.message.ID, model.PushChannelJPush)
if rerr != nil {
continue
}
record.DeviceToken = device.PushToken
record.DeviceType = string(device.DeviceType)
record.MarkPushed()
s.pushRepo.Update(record)
}
return
}
// 极光推送失败,记录错误,逐个设备重试
}
// 极光推送不可用或批量推送失败,逐个设备处理
for _, device := range mobileDevices {
channel := s.getChannelForDevice(device)
record, rerr := s.CreatePushRecord(ctx, task.userID, task.message.ID, channel)
if rerr != nil {
continue
}
record.DeviceToken = device.PushToken
record.DeviceType = string(device.DeviceType)
var pushErr error
if s.jpushClient != nil {
pushErr = s.pushViaJPush(ctx, device, title, content, extras)
} else {
pushErr = errors.New("jpush not configured")
}
if pushErr != nil {
record.MarkFailed(pushErr.Error())
} else {
record.MarkPushed()
}
s.pushRepo.Update(record)
}
s.pushRepo.Update(record)
}
}
@@ -350,9 +502,9 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
func (s *pushServiceImpl) getChannelForDevice(device *model.DeviceToken) model.PushChannel {
switch device.DeviceType {
case model.DeviceTypeIOS:
return model.PushChannelAPNs
return model.PushChannelJPush
case model.DeviceTypeAndroid:
return model.PushChannelFCM
return model.PushChannelJPush
default:
return model.PushChannelWebSocket
}
@@ -416,11 +568,30 @@ func (s *pushServiceImpl) doRetry() {
}
var pushErr error
switch {
case device.IsIOS():
pushErr = s.pushViaAPNs(ctx, device, message)
case device.IsAndroid():
pushErr = s.pushViaFCM(ctx, device, message)
if s.jpushClient != nil && device.SupportsMobilePush() && device.PushToken != "" {
_ = message.Decrypt()
title := "新消息"
content := dto.ExtractTextContentFromModel(message.Segments)
if message.IsSystemMessage() || message.Category == model.CategoryNotification {
if message.ExtraData != nil && message.ExtraData.ActorName != "" {
title = message.ExtraData.ActorName
} else {
title = "系统通知"
}
}
extras := map[string]any{
"message_id": message.ID,
"conversation_id": message.ConversationID,
"sender_id": message.SenderID,
"category": string(message.Category),
}
if message.ExtraData != nil {
extras["actor_name"] = message.ExtraData.ActorName
extras["avatar_url"] = message.ExtraData.AvatarURL
extras["target_id"] = message.ExtraData.TargetID
extras["target_type"] = message.ExtraData.TargetType
}
pushErr = s.pushViaJPush(ctx, device, title, content, extras)
}
if pushErr != nil {
@@ -433,6 +604,99 @@ func (s *pushServiceImpl) doRetry() {
}
}
// PushChatMessage 推送聊天消息给离线用户
// 检查用户在线状态和免打扰设置,仅对离线且未免打扰的用户通过 JPush 推送
func (s *pushServiceImpl) PushChatMessage(ctx context.Context, userID string, conversationID string, sender *ChatMessageSender, convType model.ConversationType, convName string, message *model.Message) error {
// 1. 用户在线则跳过(已通过 WebSocket 收到消息)
if s.wsHub != nil && s.wsHub.HasClients(userID) {
return nil
}
// 2. 检查免打扰状态
participant, err := s.messageRepo.GetParticipant(conversationID, userID)
if err == nil && participant.NotificationMuted {
zap.L().Debug("push chat message skipped: notification muted",
zap.String("userID", userID),
zap.String("conversationID", conversationID),
)
return nil
}
// 3. 获取用户的移动设备
if s.jpushClient == nil {
return nil
}
devices, err := s.deviceRepo.GetActiveByUserID(userID)
if err != nil || len(devices) == 0 {
return nil
}
var regIDs []string
for _, d := range devices {
if d.SupportsMobilePush() && d.PushToken != "" {
regIDs = append(regIDs, d.PushToken)
}
}
if len(regIDs) == 0 {
return nil
}
// 4. 构建通知内容
title := sender.Name
if title == "" {
title = "新消息"
}
if convType == model.ConversationTypeGroup && convName != "" {
title = convName + ": " + sender.Name
}
content := dto.ExtractTextContentFromModel(message.Segments)
if content == "" {
content = "[非文本消息]"
}
extras := map[string]any{
"notification_type": "chat_message",
"message_id": message.ID,
"conversation_id": message.ConversationID,
"conversation_type": string(convType),
"sender_id": message.SenderID,
"sender_name": sender.Name,
"sender_avatar": sender.Avatar,
"category": string(message.Category),
}
if message.Category == model.CategoryNotification && message.ExtraData != nil {
title = message.ExtraData.ActorName
if title == "" {
title = "系统通知"
}
extras["actor_name"] = message.ExtraData.ActorName
extras["actor_id"] = message.ExtraData.ActorID
extras["avatar_url"] = message.ExtraData.AvatarURL
extras["target_id"] = message.ExtraData.TargetID
extras["target_type"] = message.ExtraData.TargetType
}
notification := jpush.BuildNotification(title, content, "chat_message", extras)
if sender.Avatar != "" {
notification.Android.LargeIcon = sender.Avatar
}
if _, pushErr := s.jpushClient.PushByRegistrationIDs(regIDs, notification, extras); pushErr != nil {
zap.L().Error("jpush push chat message failed",
zap.String("userID", userID),
zap.String("conversationID", conversationID),
zap.Error(pushErr),
)
return pushErr
}
zap.L().Debug("jpush push chat message sent",
zap.String("userID", userID),
zap.String("conversationID", conversationID),
zap.String("convType", string(convType)),
zap.Int("device_count", len(regIDs)),
)
return nil
}
// PushSystemMessage 推送系统消息
func (s *pushServiceImpl) PushSystemMessage(ctx context.Context, userID string, msgType, title, content string, data map[string]any) error {
// 首先尝试WebSocket推送
@@ -472,7 +736,57 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
return nil
}
// 用户不在线,系统通知已存储在数据库中,用户上线后会主动拉取
// 用户不在线,尝试极光推送
if s.jpushClient != nil {
devices, err := s.deviceRepo.GetActiveByUserID(userID)
if err == nil && len(devices) > 0 {
mobileDevices := make([]*model.DeviceToken, 0)
for _, d := range devices {
if d.SupportsMobilePush() && d.PushToken != "" {
mobileDevices = append(mobileDevices, d)
}
}
if len(mobileDevices) > 0 {
content := notification.Content
title := notification.Title
if title == "" {
title = string(notification.Type)
}
extras := map[string]any{
"notification_id": fmt.Sprintf("%d", notification.ID),
"notification_type": string(notification.Type),
}
if notification.ExtraData != nil {
extras["actor_id"] = notification.ExtraData.ActorIDStr
extras["actor_name"] = notification.ExtraData.ActorName
extras["avatar_url"] = notification.ExtraData.AvatarURL
extras["target_id"] = notification.ExtraData.TargetID
extras["target_type"] = notification.ExtraData.TargetType
}
notifType := string(notification.Type)
jpushNotif := jpush.BuildNotification(title, content, notifType, extras)
if notification.ExtraData != nil && notification.ExtraData.AvatarURL != "" {
jpushNotif.Android.LargeIcon = notification.ExtraData.AvatarURL
}
_, pushErr := s.jpushClient.PushByRegistrationIDs(
func() []string {
ids := make([]string, 0, len(mobileDevices))
for _, d := range mobileDevices {
ids = append(ids, d.PushToken)
}
return ids
}(),
jpushNotif,
extras,
)
if pushErr == nil {
return nil
}
}
}
}
// 系统通知已持久化到数据库中,用户上线后会主动拉取
return nil
}

View File

@@ -60,4 +60,4 @@ func (s *setupService) SetupSuperAdmin(ctx context.Context, userID, setupSecret
}
return s.casbinSvc.AddRoleForUser(ctx, user.ID, model.RoleSuperAdmin)
}
}