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

151
internal/pkg/jpush/vendor/params.go vendored Normal file
View File

@@ -0,0 +1,151 @@
// Package vendor 提供厂商无关的消息参数抽象与各厂商推送字段转换。
//
// 设计要点:
// - 调用方只需提供厂商无关的 MessageParams不感知厂商差异。
// - 由于无法预知目标设备的具体厂商JPush 要求所有厂商私有配置全数下发,
// 由 JPush 服务端按目标设备厂商路由。
// - 双层架构TextRenderer基础层产出 Android/iOS 通用通知)+
// PrivateConverter附加层产出各厂商 third_party_channel 私有字段)。
package vendor
import (
"with_you/internal/config"
"with_you/internal/pkg/jpush"
)
// 场景常量
const (
// SceneSystem 系统消息/通知场景
SceneSystem = "system"
// SceneChat 私聊/群聊消息场景
SceneChat = "chat"
)
// MessageParams 厂商无关的统一消息参数(超集,用不上的字段厂商可忽略)
type MessageParams struct {
// Scene 决定使用哪类 channel/模板SceneSystem 或 SceneChat
Scene string
// Title 通知标题(如发送者昵称、群名:发送者)
Title string
// Body 通知正文(消息内容预览)
Body string
// SenderName 发送者昵称
SenderName string
// SenderID 发送者 ID
SenderID string
// ConversationName 会话名称(群聊为群名)
ConversationName string
// ConversationID 会话 ID
ConversationID string
// MessageType 消息类型标识chat_message / 系统通知类型),填充到 extras.notification_type
MessageType string
// ActionTime 操作时间RFC3339
ActionTime string
// LargeIcon 大图标 URL群头像/发送者头像)
LargeIcon string
// Extra 兜底扩展字段,合并进 android/ios 的 extras
Extra map[string]any
}
// PushPayload 工厂统一产物client 据此组装最终推送 payload
type PushPayload struct {
// Notification 含 android + ios由 TextRenderer 产出
Notification *jpush.Notification
// TPC 各厂商私有字段options.third_party_channel由 PrivateConverter 产出
TPC *jpush.ThirdPartyChannel
}
// TextRenderer 通用文本渲染器(基础层)
// 产出所有厂商共享的通用通知Android 通用通知 + iOS 通用通知
type TextRenderer interface {
// RenderAndroid 渲染 Android 通用通知(所有 Android 厂商共享)
RenderAndroid(params MessageParams, cfg config.ChannelConfig) *jpush.AndroidNotif
// RenderIOS 渲染 iOS 通用通知(按场景设置 thread-id 分组)
RenderIOS(params MessageParams, cfg config.ChannelConfig) *jpush.IOSNotif
}
// PrivateConverter 厂商私有转换器(附加层)
// 产出 third_party_channel.{vendor} 的私有字段channel_id、模板等
type PrivateConverter interface {
// VendorName 返回厂商标识,对应 third_party_channel 的 keyxiaomi/oppo/huawei/...
VendorName() string
// Convert 根据消息参数 + 全局配置产出该厂商私有字段 map
// 厂商专属 channel_id 为空时回退到全局 cfg.System/Chat
// 返回 nil 表示该厂商无需下发(如最终 channel_id 与模板均未配置)
Convert(params MessageParams, cfg config.ChannelConfig) map[string]any
}
// Factory 持有通用渲染器与全部厂商私有转换器
type Factory struct {
renderer TextRenderer
privateConverters []PrivateConverter
}
// NewFactory 创建工厂,注册默认渲染器与全部厂商转换器
func NewFactory() *Factory {
return &Factory{
renderer: newDefaultRenderer(),
privateConverters: []PrivateConverter{
xiaomiConverter{},
huaweiConverter{},
oppoConverter{},
vivoConverter{},
meizuConverter{},
honorConverter{},
fcmConverter{},
},
}
}
// NewFactoryWith 自定义渲染器与转换器创建工厂(便于测试)
func NewFactoryWith(renderer TextRenderer, converters []PrivateConverter) *Factory {
return &Factory{renderer: renderer, privateConverters: converters}
}
// Build 根据统一参数与配置,组装完整的 PushPayload
// 调用通用渲染器产出 Notification调用各私有转换器产出 ThirdPartyChannel
func (f *Factory) Build(params MessageParams, cfg config.ChannelConfig) *PushPayload {
android := f.renderer.RenderAndroid(params, cfg)
ios := f.renderer.RenderIOS(params, cfg)
notification := &jpush.Notification{
Alert: params.Body,
Android: android,
IOS: ios,
}
tpc := &jpush.ThirdPartyChannel{}
for _, c := range f.privateConverters {
m := c.Convert(params, cfg)
if m == nil {
continue
}
switch c.VendorName() {
case "xiaomi":
tpc.Xiaomi = m
case "huawei":
tpc.Huawei = m
case "oppo":
tpc.OPPO = m
case "vivo":
tpc.VIVO = m
case "meizu":
tpc.Meizu = m
case "honor":
tpc.Honor = m
case "fcm":
tpc.FCM = m
}
}
// 全部厂商字段为空则置 nil避免序列化出空的 third_party_channel
if tpc.Xiaomi == nil && tpc.Huawei == nil && tpc.OPPO == nil &&
tpc.VIVO == nil && tpc.Meizu == nil && tpc.Honor == nil && tpc.FCM == nil {
tpc = nil
}
return &PushPayload{
Notification: notification,
TPC: tpc,
}
}