feat(server): implement chat file TTL cleanup and enhance JPush vendor channel support
All checks were successful
Build Backend / build (push) Successful in 3m1s
Build Backend / build-docker (push) Successful in 2m10s

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:
lafay
2026-06-17 20:41:55 +08:00
parent d9aa4b46c3
commit a0e210feab
24 changed files with 1573 additions and 127 deletions

View File

@@ -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