feat(server): implement chat file TTL cleanup and enhance JPush vendor channel support
Add FileCleanupWorker for automatic expiration of chat files with configurable retention period and batch processing. Files uploaded via `/api/v1/uploads/files` are tracked in `uploaded_files` table with expiration timestamps, then deleted from S3 and database upon expiry. Expired file URLs are injected as `"expired": true` in message responses. Extend JPush push notification configuration with vendor-specific channel_id support (xiaomi, huawei, oppo, vivo, meizu, honor, fcm) for differentiating system vs chat notifications. Add iOS APNs thread-id grouping configuration for notification categorization.
This commit is contained in:
@@ -6,9 +6,11 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/jpush"
|
||||
"with_you/internal/pkg/jpush/vendor"
|
||||
"with_you/internal/pkg/ws"
|
||||
"with_you/internal/repository"
|
||||
|
||||
@@ -85,6 +87,11 @@ type pushServiceImpl struct {
|
||||
wsHub ws.MessagePublisher
|
||||
jpushClient *jpush.Client
|
||||
|
||||
// channelConfig 厂商通道 channel_id 配置(系统消息/私聊消息分别注册)
|
||||
channelConfig config.ChannelConfig
|
||||
// vendorFactory 厂商转换器工厂,按统一参数产出各厂商推送字段
|
||||
vendorFactory *vendor.Factory
|
||||
|
||||
pushQueue chan *pushTask
|
||||
stopChan chan struct{}
|
||||
}
|
||||
@@ -104,15 +111,18 @@ func NewPushService(
|
||||
messageRepo repository.MessageRepository,
|
||||
publisher ws.MessagePublisher,
|
||||
jpushClient *jpush.Client,
|
||||
channelConfig config.ChannelConfig,
|
||||
) PushService {
|
||||
return &pushServiceImpl{
|
||||
pushRepo: pushRepo,
|
||||
deviceRepo: deviceRepo,
|
||||
messageRepo: messageRepo,
|
||||
wsHub: publisher,
|
||||
jpushClient: jpushClient,
|
||||
pushQueue: make(chan *pushTask, PushQueueSize),
|
||||
stopChan: make(chan struct{}),
|
||||
pushRepo: pushRepo,
|
||||
deviceRepo: deviceRepo,
|
||||
messageRepo: messageRepo,
|
||||
wsHub: publisher,
|
||||
jpushClient: jpushClient,
|
||||
channelConfig: channelConfig,
|
||||
vendorFactory: vendor.NewFactory(),
|
||||
pushQueue: make(chan *pushTask, PushQueueSize),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +153,8 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
|
||||
if err == nil && len(devices) > 0 {
|
||||
mobileDevices := getMobileDevices(devices)
|
||||
if len(mobileDevices) > 0 {
|
||||
payload := buildMessagePayload(message)
|
||||
pushErr := s.pushViaJPushBatch(mobileDevices, payload.Title, payload.Content, payload.Extras)
|
||||
params := s.messageToParams(message)
|
||||
pushErr := s.pushViaJPushBatch(mobileDevices, params)
|
||||
if pushErr == nil {
|
||||
s.batchCreatePushedRecords(ctx, userID, message.ID, mobileDevices)
|
||||
return nil
|
||||
@@ -296,7 +306,8 @@ func buildMessagePayload(message *model.Message) messagePayload {
|
||||
}
|
||||
|
||||
// pushViaJPush 通过极光推送发送通知
|
||||
func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.DeviceToken, title, content string, extras map[string]any) error {
|
||||
// params 为厂商无关统一参数,由 vendor 工厂转换为各厂商推送字段
|
||||
func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.DeviceToken, params vendor.MessageParams) error {
|
||||
if !s.isJPushAvailable() {
|
||||
return errors.New("jpush client not configured")
|
||||
}
|
||||
@@ -305,8 +316,8 @@ func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.Device
|
||||
return errors.New("device has no registration ID")
|
||||
}
|
||||
|
||||
notification := jpush.BuildNotification(title, content, "message", extras)
|
||||
_, err := s.jpushClient.PushByRegistrationIDs([]string{device.PushToken}, notification)
|
||||
payload := s.buildPayload(params)
|
||||
_, err := s.jpushClient.PushByRegistrationIDs([]string{device.PushToken}, payload.Notification, payload.TPC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jpush push failed: %w", err)
|
||||
}
|
||||
@@ -343,7 +354,9 @@ func (s *pushServiceImpl) batchCreatePushedRecords(ctx context.Context, userID s
|
||||
}
|
||||
|
||||
// pushViaJPushBatch 批量通过极光推送发送通知
|
||||
func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, title, content string, extras map[string]any) error {
|
||||
// pushViaJPushBatch 批量通过极光推送发送通知
|
||||
// params 为厂商无关统一参数,由 vendor 工厂转换为各厂商推送字段
|
||||
func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, params vendor.MessageParams) error {
|
||||
if !s.isJPushAvailable() {
|
||||
return errors.New("jpush client not configured")
|
||||
}
|
||||
@@ -359,8 +372,8 @@ func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, title,
|
||||
return errors.New("no valid registration IDs")
|
||||
}
|
||||
|
||||
notification := jpush.BuildNotification(title, content, "message", extras)
|
||||
_, err := s.jpushClient.PushByRegistrationIDs(regIDs, notification)
|
||||
payload := s.buildPayload(params)
|
||||
_, err := s.jpushClient.PushByRegistrationIDs(regIDs, payload.Notification, payload.TPC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jpush batch push failed: %w", err)
|
||||
}
|
||||
@@ -520,7 +533,7 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
||||
return
|
||||
}
|
||||
|
||||
payload := buildMessagePayload(task.message)
|
||||
params := s.messageToParams(task.message)
|
||||
|
||||
// 尝试使用极光推送(统一推送到所有移动设备)
|
||||
mobileDevices := getMobileDevices(devices)
|
||||
@@ -528,7 +541,7 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
||||
if len(mobileDevices) > 0 {
|
||||
// 优先使用极光推送
|
||||
if s.isJPushAvailable() {
|
||||
err := s.pushViaJPushBatch(mobileDevices, payload.Title, payload.Content, payload.Extras)
|
||||
err := s.pushViaJPushBatch(mobileDevices, params)
|
||||
if err == nil {
|
||||
s.batchCreatePushedRecords(ctx, task.userID, task.message.ID, mobileDevices)
|
||||
// 原始 pending 记录标记为已推送(被批量记录替代)
|
||||
@@ -549,7 +562,7 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
||||
|
||||
var pushErr error
|
||||
if s.isJPushAvailable() {
|
||||
pushErr = s.pushViaJPush(ctx, device, payload.Title, payload.Content, payload.Extras)
|
||||
pushErr = s.pushViaJPush(ctx, device, params)
|
||||
} else {
|
||||
pushErr = errors.New("jpush not configured")
|
||||
}
|
||||
@@ -583,6 +596,46 @@ func (s *pushServiceImpl) isJPushAvailable() bool {
|
||||
return s.jpushClient != nil
|
||||
}
|
||||
|
||||
// resolveChannelScene 根据消息类型判断 channel 场景
|
||||
// 系统消息/通知/公告 -> vendor.SceneSystem,其余(普通聊天)-> vendor.SceneChat
|
||||
func (s *pushServiceImpl) resolveChannelScene(message *model.Message) string {
|
||||
if message == nil {
|
||||
return vendor.SceneChat
|
||||
}
|
||||
if message.IsSystemMessage() ||
|
||||
message.Category == model.CategoryNotification ||
|
||||
message.Category == model.CategoryAnnouncement {
|
||||
return vendor.SceneSystem
|
||||
}
|
||||
return vendor.SceneChat
|
||||
}
|
||||
|
||||
// buildPayload 根据统一参数,通过厂商工厂产出完整推送片段(Notification + ThirdPartyChannel)
|
||||
func (s *pushServiceImpl) buildPayload(params vendor.MessageParams) *vendor.PushPayload {
|
||||
return s.vendorFactory.Build(params, s.channelConfig)
|
||||
}
|
||||
|
||||
// messageToParams 从 model.Message 提取厂商无关的统一参数
|
||||
// 用于 PushToUser / worker / retry 等通用路径
|
||||
func (s *pushServiceImpl) messageToParams(message *model.Message) vendor.MessageParams {
|
||||
_ = message.Decrypt()
|
||||
payload := buildMessagePayload(message)
|
||||
scene := s.resolveChannelScene(message)
|
||||
|
||||
senderName := ""
|
||||
if message.ExtraData != nil {
|
||||
senderName = message.ExtraData.ActorName
|
||||
}
|
||||
return vendor.MessageParams{
|
||||
Scene: scene,
|
||||
Title: payload.Title,
|
||||
Body: payload.Content,
|
||||
SenderName: senderName,
|
||||
MessageType: "message",
|
||||
Extra: payload.Extras,
|
||||
}
|
||||
}
|
||||
|
||||
// getMobileDevices 从设备列表中筛选出支持手机推送的活跃设备
|
||||
func getMobileDevices(devices []*model.DeviceToken) []*model.DeviceToken {
|
||||
mobileDevices := make([]*model.DeviceToken, 0, len(devices))
|
||||
@@ -668,8 +721,8 @@ func (s *pushServiceImpl) doRetry() int {
|
||||
|
||||
var pushErr error
|
||||
if s.isJPushAvailable() && device.SupportsMobilePush() && device.PushToken != "" {
|
||||
payload := buildMessagePayload(message)
|
||||
pushErr = s.pushViaJPush(ctx, device, payload.Title, payload.Content, payload.Extras)
|
||||
params := s.messageToParams(message)
|
||||
pushErr = s.pushViaJPush(ctx, device, params)
|
||||
}
|
||||
|
||||
if pushErr != nil {
|
||||
@@ -760,14 +813,25 @@ func (s *pushServiceImpl) PushChatMessage(ctx context.Context, userID string, co
|
||||
extras["target_type"] = message.ExtraData.TargetType
|
||||
}
|
||||
|
||||
notification := jpush.BuildNotification(title, content, "chat_message", extras)
|
||||
// 群聊用群头像,私聊用发送者头像
|
||||
largeIcon := sender.Avatar
|
||||
if convType == model.ConversationTypeGroup && groupAvatar != "" {
|
||||
notification.Android.LargeIcon = groupAvatar
|
||||
} else if sender.Avatar != "" {
|
||||
notification.Android.LargeIcon = sender.Avatar
|
||||
largeIcon = groupAvatar
|
||||
}
|
||||
if _, pushErr := s.jpushClient.PushByRegistrationIDs(regIDs, notification); pushErr != nil {
|
||||
params := vendor.MessageParams{
|
||||
Scene: s.resolveChannelScene(message),
|
||||
Title: title,
|
||||
Body: content,
|
||||
SenderName: sender.Name,
|
||||
SenderID: message.SenderID,
|
||||
ConversationName: convName,
|
||||
ConversationID: message.ConversationID,
|
||||
MessageType: "chat_message",
|
||||
LargeIcon: largeIcon,
|
||||
Extra: extras,
|
||||
}
|
||||
payload := s.buildPayload(params)
|
||||
if _, pushErr := s.jpushClient.PushByRegistrationIDs(regIDs, payload.Notification, payload.TPC); pushErr != nil {
|
||||
zap.L().Error("jpush push chat message failed",
|
||||
zap.String("userID", userID),
|
||||
zap.String("conversationID", conversationID),
|
||||
@@ -847,10 +911,19 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
|
||||
extras["target_type"] = notification.ExtraData.TargetType
|
||||
}
|
||||
notifType := string(notification.Type)
|
||||
jpushNotif := jpush.BuildNotification(title, content, notifType, extras)
|
||||
largeIcon := ""
|
||||
if notification.ExtraData != nil && notification.ExtraData.AvatarURL != "" {
|
||||
jpushNotif.Android.LargeIcon = notification.ExtraData.AvatarURL
|
||||
largeIcon = notification.ExtraData.AvatarURL
|
||||
}
|
||||
params := vendor.MessageParams{
|
||||
Scene: vendor.SceneSystem,
|
||||
Title: title,
|
||||
Body: content,
|
||||
MessageType: notifType,
|
||||
LargeIcon: largeIcon,
|
||||
Extra: extras,
|
||||
}
|
||||
payload := s.buildPayload(params)
|
||||
_, pushErr := s.jpushClient.PushByRegistrationIDs(
|
||||
func() []string {
|
||||
ids := make([]string, 0, len(mobileDevices))
|
||||
@@ -859,7 +932,8 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
|
||||
}
|
||||
return ids
|
||||
}(),
|
||||
jpushNotif,
|
||||
payload.Notification,
|
||||
payload.TPC,
|
||||
)
|
||||
if pushErr == nil {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user