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:
@@ -19,6 +19,7 @@ type Config struct {
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
Upload UploadConfig `mapstructure:"upload"`
|
||||
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
|
||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
||||
Email EmailConfig `mapstructure:"email"`
|
||||
@@ -95,6 +96,11 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("rate_limit.requests_per_minute", 60)
|
||||
viper.SetDefault("upload.max_file_size", 10485760)
|
||||
viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"})
|
||||
// 文件过期清理默认值
|
||||
viper.SetDefault("file_cleanup.enabled", true)
|
||||
viper.SetDefault("file_cleanup.retention_days", 7)
|
||||
viper.SetDefault("file_cleanup.interval_minutes", 360)
|
||||
viper.SetDefault("file_cleanup.batch_size", 100)
|
||||
viper.SetDefault("s3.endpoint", "")
|
||||
viper.SetDefault("s3.access_key", "")
|
||||
viper.SetDefault("s3.secret_key", "")
|
||||
@@ -174,6 +180,59 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("jpush.app_key", "")
|
||||
viper.SetDefault("jpush.master_secret", "")
|
||||
viper.SetDefault("jpush.production", false)
|
||||
// JPush 厂商通道 channel_id 默认值
|
||||
// 各厂商 channel_id 通过环境变量配置(见下方 BindEnv)
|
||||
viper.SetDefault("jpush.channel.system", "")
|
||||
viper.SetDefault("jpush.channel.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.huawei.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.huawei.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.meizu.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.meizu.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.honor.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.honor.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.fcm.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.fcm.chat", "")
|
||||
// 小米/OPPO 模板 id 默认值
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.mi_system_template_id", "")
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.mi_chat_template_id", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_private_template_id", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_private_template_id", "")
|
||||
// iOS 通知分组 thread-id 默认值
|
||||
viper.SetDefault("jpush.channel.ios.chat_thread_id", "")
|
||||
viper.SetDefault("jpush.channel.ios.system_thread_id", "")
|
||||
// JPush 厂商 channel_id 环境变量显式绑定
|
||||
// AutomaticEnv 对深层嵌套 key 不可靠,故逐个 BindEnv
|
||||
// 命名规则:APP_JPUSH_CHANNEL_{VENDOR}_{SYSTEM|CHAT}
|
||||
viper.BindEnv("jpush.channel.system", "APP_JPUSH_CHANNEL_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.chat", "APP_JPUSH_CHANNEL_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.system", "APP_JPUSH_CHANNEL_XIAOMI_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.chat", "APP_JPUSH_CHANNEL_XIAOMI_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.huawei.system", "APP_JPUSH_CHANNEL_HUAWEI_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.huawei.chat", "APP_JPUSH_CHANNEL_HUAWEI_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.system", "APP_JPUSH_CHANNEL_OPPO_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.chat", "APP_JPUSH_CHANNEL_OPPO_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.system", "APP_JPUSH_CHANNEL_VIVO_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.chat", "APP_JPUSH_CHANNEL_VIVO_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.meizu.system", "APP_JPUSH_CHANNEL_MEIZU_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.meizu.chat", "APP_JPUSH_CHANNEL_MEIZU_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.honor.system", "APP_JPUSH_CHANNEL_HONOR_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.honor.chat", "APP_JPUSH_CHANNEL_HONOR_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.fcm.system", "APP_JPUSH_CHANNEL_FCM_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.fcm.chat", "APP_JPUSH_CHANNEL_FCM_CHAT")
|
||||
// 小米/OPPO 模板 id 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.mi_system_template_id", "APP_JPUSH_CHANNEL_XIAOMI_MI_SYSTEM_TEMPLATE_ID")
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.mi_chat_template_id", "APP_JPUSH_CHANNEL_XIAOMI_MI_CHAT_TEMPLATE_ID")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_private_template_id", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_PRIVATE_TEMPLATE_ID")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_private_template_id", "APP_JPUSH_CHANNEL_OPPO_CHAT_PRIVATE_TEMPLATE_ID")
|
||||
// iOS thread-id 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.ios.chat_thread_id", "APP_JPUSH_CHANNEL_IOS_CHAT_THREAD_ID")
|
||||
viper.BindEnv("jpush.channel.ios.system_thread_id", "APP_JPUSH_CHANNEL_IOS_SYSTEM_THREAD_ID")
|
||||
viper.SetDefault("setup_secret", "")
|
||||
// WebSocket 默认值
|
||||
viper.SetDefault("websocket.mode", "standalone")
|
||||
|
||||
@@ -1,8 +1,63 @@
|
||||
package config
|
||||
|
||||
type JPushConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
AppKey string `mapstructure:"app_key"`
|
||||
MasterSecret string `mapstructure:"master_secret"`
|
||||
Production bool `mapstructure:"production"`
|
||||
}
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
AppKey string `mapstructure:"app_key"`
|
||||
MasterSecret string `mapstructure:"master_secret"`
|
||||
Production bool `mapstructure:"production"`
|
||||
// Channel 厂商通道 channel_id 配置
|
||||
// 通过 options.third_party_channel 下发到各厂商(小米/华为/OPPO 等)
|
||||
Channel ChannelConfig `mapstructure:"channel"`
|
||||
}
|
||||
|
||||
// ChannelConfig 厂商通道 channel_id 配置
|
||||
// 区分「系统消息」与「私聊/群聊消息」两类 channel
|
||||
type ChannelConfig struct {
|
||||
// System 系统消息/通知默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||
System string `mapstructure:"system"`
|
||||
// Chat 私聊/群聊消息默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||
Chat string `mapstructure:"chat"`
|
||||
// Vendor 各厂商 channel_id 覆盖配置(留空则回退到 System/Chat)
|
||||
Vendor VendorChannel `mapstructure:"vendor"`
|
||||
// iOS APNs 通知场景区分配置(按 thread-id 分组)
|
||||
IOS IOSChannelConfig `mapstructure:"ios"`
|
||||
}
|
||||
|
||||
// IOSChannelConfig iOS APNs 通知场景区分配置
|
||||
// iOS 走 APNs 无 channel_id 概念,通过 thread-id 实现通知分组
|
||||
type IOSChannelConfig struct {
|
||||
// ChatThreadID 私聊/群聊消息通知分组 thread-id
|
||||
ChatThreadID string `mapstructure:"chat_thread_id"`
|
||||
// SystemThreadID 系统消息/通知分组 thread-id
|
||||
SystemThreadID string `mapstructure:"system_thread_id"`
|
||||
}
|
||||
|
||||
// VendorChannel 各厂商通道 channel_id 覆盖
|
||||
// 每个厂商区分 System(系统消息)与 Chat(私聊消息)两个 channel_id
|
||||
type VendorChannel struct {
|
||||
Xiaomi VendorChannelID `mapstructure:"xiaomi"`
|
||||
Huawei VendorChannelID `mapstructure:"huawei"`
|
||||
OPPO VendorChannelID `mapstructure:"oppo"`
|
||||
VIVO VendorChannelID `mapstructure:"vivo"`
|
||||
Meizu VendorChannelID `mapstructure:"meizu"`
|
||||
Honor VendorChannelID `mapstructure:"honor"`
|
||||
FCM VendorChannelID `mapstructure:"fcm"`
|
||||
}
|
||||
|
||||
// VendorChannelID 单个厂商的两类 channel 配置
|
||||
// 每个 channel 可携带厂商私有模板配置(如小米 mi_template_id、OPPO private_msg_template_id)
|
||||
type VendorChannelID struct {
|
||||
// channel_id
|
||||
System string `mapstructure:"system"`
|
||||
Chat string `mapstructure:"chat"`
|
||||
|
||||
// 小米消息模板(可选,配置后私信消息下发时携带 channel_id 及 mi_template_id)
|
||||
// 见:小米关于消息模板推送新规的更新通知
|
||||
MiSystemTemplateID string `mapstructure:"mi_system_template_id"` // 系统消息模板 id
|
||||
MiChatTemplateID string `mapstructure:"mi_chat_template_id"` // 私聊消息模板 id
|
||||
|
||||
// OPPO 私信模板(可选,仅 OPPO 厂商,配置后下发私信时携带)
|
||||
// 见:OPUSH 私信模版校验能力接入说明
|
||||
OppoSystemPrivateTemplateID string `mapstructure:"oppo_system_private_template_id"` // 系统消息私信模板 id
|
||||
OppoChatPrivateTemplateID string `mapstructure:"oppo_chat_private_template_id"` // 私聊消息私信模板 id
|
||||
}
|
||||
|
||||
@@ -61,3 +61,11 @@ type UploadConfig struct {
|
||||
MaxFileSize int64 `mapstructure:"max_file_size"`
|
||||
AllowedTypes []string `mapstructure:"allowed_types"`
|
||||
}
|
||||
|
||||
// FileCleanupConfig 聊天文件 TTL 过期清理配置
|
||||
type FileCleanupConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker
|
||||
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 7
|
||||
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||
BatchSize int `mapstructure:"batch_size"` // 单次扫描处理上限,默认 100
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@ func autoMigrate(db *gorm.DB) error {
|
||||
&model.TradeItem{},
|
||||
&model.TradeImage{},
|
||||
&model.TradeFavorite{},
|
||||
|
||||
// 文件上传记录(用于聊天文件 TTL 过期清理)
|
||||
&model.UploadedFile{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,13 @@ import (
|
||||
|
||||
// ConvertMessageToResponse 将Message转换为MessageResponse
|
||||
func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||
return ConvertMessageToResponseWithExpiry(message, nil)
|
||||
}
|
||||
|
||||
// ConvertMessageToResponseWithExpiry 将Message转换为MessageResponse,并对已过期的 file segment 注入 expired 标记。
|
||||
// expiredURLSet 为已从 S3 清理的文件 URL 集合(可为 nil,表示不标记)。
|
||||
// 注意:注入时对 file segment 的 Data 做深拷贝,避免污染消息缓存。
|
||||
func ConvertMessageToResponseWithExpiry(message *model.Message, expiredURLSet map[string]struct{}) *MessageResponse {
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +22,19 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||
// 直接使用 segments,不需要解析
|
||||
segments := make(model.MessageSegments, len(message.Segments))
|
||||
for i, seg := range message.Segments {
|
||||
// 对 file 类型且 URL 已过期的 segment,深拷贝 Data 并注入 expired 标记
|
||||
if seg.Type == string(model.ContentTypeFile) && len(expiredURLSet) > 0 {
|
||||
url, _ := seg.Data["url"].(string)
|
||||
if _, expired := expiredURLSet[url]; expired {
|
||||
newData := make(map[string]any, len(seg.Data)+1)
|
||||
for k, v := range seg.Data {
|
||||
newData[k] = v
|
||||
}
|
||||
newData["expired"] = true
|
||||
segments[i] = model.MessageSegment{Type: seg.Type, Data: newData}
|
||||
continue
|
||||
}
|
||||
}
|
||||
segments[i] = model.MessageSegment{
|
||||
Type: seg.Type,
|
||||
Data: seg.Data,
|
||||
@@ -107,6 +127,15 @@ func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse {
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertMessagesToResponseWithExpiry 将Message列表转换为响应列表,并标记过期文件
|
||||
func ConvertMessagesToResponseWithExpiry(messages []*model.Message, expiredURLSet map[string]struct{}) []*MessageResponse {
|
||||
result := make([]*MessageResponse, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
result = append(result, ConvertMessageToResponseWithExpiry(msg, expiredURLSet))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// ==================== PushRecord 转换 ====================
|
||||
|
||||
|
||||
@@ -90,20 +90,37 @@ type MessageHandler struct {
|
||||
messageService service.MessageService
|
||||
userService service.UserService
|
||||
groupService service.GroupService
|
||||
uploadService service.UploadService
|
||||
wsPublisher ws.MessagePublisher
|
||||
}
|
||||
|
||||
// NewMessageHandler 创建消息处理器
|
||||
func NewMessageHandler(chatService service.ChatService, messageService service.MessageService, userService service.UserService, groupService service.GroupService, wsPublisher ws.MessagePublisher) *MessageHandler {
|
||||
func NewMessageHandler(chatService service.ChatService, messageService service.MessageService, userService service.UserService, groupService service.GroupService, uploadService service.UploadService, wsPublisher ws.MessagePublisher) *MessageHandler {
|
||||
return &MessageHandler{
|
||||
chatService: chatService,
|
||||
messageService: messageService,
|
||||
userService: userService,
|
||||
groupService: groupService,
|
||||
uploadService: uploadService,
|
||||
wsPublisher: wsPublisher,
|
||||
}
|
||||
}
|
||||
|
||||
// convertMessagesWithExpiry 将消息列表转换为响应,并对已过期的文件 segment 注入 expired 标记。
|
||||
// 若 uploadService 不可用或查询失败,降级为不带过期标记的普通转换。
|
||||
func (h *MessageHandler) convertMessagesWithExpiry(ctx context.Context, messages []*model.Message) []*dto.MessageResponse {
|
||||
var expiredSet map[string]struct{}
|
||||
if h.uploadService != nil {
|
||||
if es, err := h.uploadService.GetExpiredURLSet(ctx); err == nil {
|
||||
expiredSet = es
|
||||
}
|
||||
}
|
||||
if len(expiredSet) == 0 {
|
||||
return dto.ConvertMessagesToResponse(messages)
|
||||
}
|
||||
return dto.ConvertMessagesToResponseWithExpiry(messages, expiredSet)
|
||||
}
|
||||
|
||||
// HandleTyping 输入状态上报
|
||||
// POST /api/v1/conversations/typing
|
||||
func (h *MessageHandler) HandleTyping(c *gin.Context) {
|
||||
@@ -287,8 +304,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -315,8 +332,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -335,8 +352,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
@@ -857,8 +874,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -885,8 +902,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -905,8 +922,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
@@ -1067,8 +1084,8 @@ func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
items := dto.ConvertMessagesToResponse(result.Items)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
items := h.convertMessagesWithExpiry(c.Request.Context(), result.Items)
|
||||
|
||||
response.Success(c, &dto.MessageCursorPageResponse{
|
||||
Items: items,
|
||||
|
||||
@@ -92,3 +92,39 @@ func (h *UploadHandler) UploadCover(c *gin.Context) {
|
||||
|
||||
response.Success(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// UploadFile 上传聊天/通用文件(非图片类资源)
|
||||
// form 字段:file(必填)、folder(可选,默认 chat)
|
||||
func (h *UploadHandler) UploadFile(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "file is required")
|
||||
return
|
||||
}
|
||||
|
||||
folder := c.DefaultPostForm("folder", "chat")
|
||||
|
||||
result, err := h.uploadService.UploadFile(c.Request.Context(), file, folder, userID)
|
||||
if err != nil {
|
||||
// 参数类错误(大小/类型)返回 400,其余返回 500
|
||||
if file.Size > 0 && file.Size <= 100<<20 {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to upload file")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"url": result.URL,
|
||||
"name": result.Name,
|
||||
"size": result.Size,
|
||||
"mime_type": result.MimeType,
|
||||
})
|
||||
}
|
||||
|
||||
26
internal/model/uploaded_file.go
Normal file
26
internal/model/uploaded_file.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UploadedFile 聊天/通用文件上传记录
|
||||
// 用于跟踪 files/ 前缀下的 S3 对象,配合 FileCleanupWorker 实现 TTL 过期清理。
|
||||
// 注意:images/avatars/covers 等内容寻址资源不记录本表,不会被自动清理。
|
||||
type UploadedFile struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
URL string `gorm:"size:512;index;not null" json:"url"` // 完整访问 URL,DTO 标记失效用
|
||||
ObjectName string `gorm:"size:256;index;not null" json:"object_name"` // S3 对象名 files/{folder}/{hash}{ext}
|
||||
Name string `gorm:"size:255" json:"name"` // 原始文件名
|
||||
Size int64 `json:"size"` // 字节数
|
||||
MimeType string `gorm:"size:128" json:"mime_type"`
|
||||
Folder string `gorm:"size:32;index" json:"folder"` // chat / post 等来源
|
||||
UploaderID string `gorm:"size:50;index" json:"uploader_id"` // 上传者 UUID
|
||||
Expired bool `gorm:"index;default:false" json:"expired"` // 是否已从 S3 清理
|
||||
ExpiredAt *time.Time `json:"expired_at,omitempty"` // 清理时间
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"` // 上传时间,TTL 计算基准
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (UploadedFile) TableName() string {
|
||||
return "uploaded_files"
|
||||
}
|
||||
@@ -66,6 +66,18 @@ type Notification struct {
|
||||
IOS *IOSNotif `json:"ios,omitempty"`
|
||||
}
|
||||
|
||||
// ThirdPartyChannel 厂商通道配置,置于 push payload 的 options.third_party_channel
|
||||
// 用于向各厂商(小米/华为/OPPO/VIVO/魅族/FCM/荣耀)下发 channel_id 等厂商私有参数
|
||||
type ThirdPartyChannel struct {
|
||||
Xiaomi map[string]any `json:"xiaomi,omitempty"`
|
||||
Huawei map[string]any `json:"huawei,omitempty"`
|
||||
OPPO map[string]any `json:"oppo,omitempty"`
|
||||
VIVO map[string]any `json:"vivo,omitempty"`
|
||||
Meizu map[string]any `json:"meizu,omitempty"`
|
||||
FCM map[string]any `json:"fcm,omitempty"`
|
||||
Honor map[string]any `json:"honor,omitempty"`
|
||||
}
|
||||
|
||||
type AndroidNotif struct {
|
||||
Alert string `json:"alert"`
|
||||
Title string `json:"title,omitempty"`
|
||||
@@ -244,7 +256,7 @@ func (c *Client) Push(payload map[string]any) (*PushResponse, *RateLimitInfo, er
|
||||
return result, rateLimit, parseErr
|
||||
}
|
||||
|
||||
func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *Notification) (*PushResponse, error) {
|
||||
func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *Notification, tpc *ThirdPartyChannel) (*PushResponse, error) {
|
||||
if len(registrationIDs) == 0 {
|
||||
return nil, fmt.Errorf("registration IDs cannot be empty")
|
||||
}
|
||||
@@ -262,10 +274,7 @@ func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *N
|
||||
"platform": "all",
|
||||
"audience": map[string]any{"registration_id": registrationIDs},
|
||||
"notification": notification,
|
||||
"options": map[string]any{
|
||||
"time_to_live": 86400,
|
||||
"apns_production": c.production,
|
||||
},
|
||||
"options": c.buildOptions(tpc),
|
||||
}
|
||||
|
||||
var result *PushResponse
|
||||
@@ -292,7 +301,7 @@ func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *N
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) PushByAliases(aliases []string, notification *Notification) (*PushResponse, error) {
|
||||
func (c *Client) PushByAliases(aliases []string, notification *Notification, tpc *ThirdPartyChannel) (*PushResponse, error) {
|
||||
if len(aliases) == 0 {
|
||||
return nil, fmt.Errorf("aliases cannot be empty")
|
||||
}
|
||||
@@ -310,10 +319,7 @@ func (c *Client) PushByAliases(aliases []string, notification *Notification) (*P
|
||||
"platform": "all",
|
||||
"audience": map[string]any{"alias": aliases},
|
||||
"notification": notification,
|
||||
"options": map[string]any{
|
||||
"time_to_live": 86400,
|
||||
"apns_production": c.production,
|
||||
},
|
||||
"options": c.buildOptions(tpc),
|
||||
}
|
||||
|
||||
result, _, err := c.Push(payload)
|
||||
@@ -332,7 +338,7 @@ func (c *Client) PushByAliases(aliases []string, notification *Notification) (*P
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) PushAll(notification *Notification) (*PushResponse, error) {
|
||||
func (c *Client) PushAll(notification *Notification, tpc *ThirdPartyChannel) (*PushResponse, error) {
|
||||
c.logger.Info("jpush push all",
|
||||
zap.String("title", notification.Android.Title),
|
||||
zap.String("alert", notification.Alert),
|
||||
@@ -342,10 +348,7 @@ func (c *Client) PushAll(notification *Notification) (*PushResponse, error) {
|
||||
"platform": "all",
|
||||
"audience": "all",
|
||||
"notification": notification,
|
||||
"options": map[string]any{
|
||||
"time_to_live": 86400,
|
||||
"apns_production": c.production,
|
||||
},
|
||||
"options": c.buildOptions(tpc),
|
||||
}
|
||||
|
||||
result, _, err := c.Push(payload)
|
||||
@@ -362,41 +365,24 @@ func (c *Client) PushAll(notification *Notification) (*PushResponse, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildNotification(title, body string, notificationType string, extras map[string]any) *Notification {
|
||||
androidExtras := map[string]any{
|
||||
"notification_type": notificationType,
|
||||
// buildOptions 构造 push payload 的 options 部分
|
||||
// 当 tpc 非空时加入 third_party_channel 字段,用于向各厂商下发 channel_id
|
||||
func (c *Client) buildOptions(tpc *ThirdPartyChannel) map[string]any {
|
||||
opts := map[string]any{
|
||||
"time_to_live": 86400,
|
||||
"apns_production": c.production,
|
||||
}
|
||||
for k, v := range extras {
|
||||
androidExtras[k] = v
|
||||
}
|
||||
|
||||
iosExtras := map[string]any{
|
||||
"notification_type": notificationType,
|
||||
}
|
||||
for k, v := range extras {
|
||||
iosExtras[k] = v
|
||||
}
|
||||
|
||||
return &Notification{
|
||||
Alert: body,
|
||||
Android: &AndroidNotif{
|
||||
Alert: body,
|
||||
Title: title,
|
||||
Extras: androidExtras,
|
||||
Priority: 1,
|
||||
},
|
||||
IOS: &IOSNotif{
|
||||
Alert: map[string]any{
|
||||
"title": title,
|
||||
"body": body,
|
||||
},
|
||||
Sound: "default",
|
||||
Badge: "+1",
|
||||
Extras: iosExtras,
|
||||
},
|
||||
if tpc != nil {
|
||||
opts["third_party_channel"] = tpc
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// 通知与厂商通道的构建逻辑已迁移至 internal/pkg/jpush/vendor 子包:
|
||||
// - 通用通知(Android/iOS)由 vendor.TextRenderer 产出
|
||||
// - 各厂商私有字段(channel_id / 小米 mi_template / OPPO 私信模板)由 vendor.PrivateConverter 产出
|
||||
// - 组装入口为 vendor.Factory.Build,产物 *vendor.PushPayload 含 Notification 与 ThirdPartyChannel
|
||||
|
||||
type DeviceInfo struct {
|
||||
Tags []string `json:"tags"`
|
||||
Alias string `json:"alias"`
|
||||
|
||||
156
internal/pkg/jpush/vendor/converters.go
vendored
Normal file
156
internal/pkg/jpush/vendor/converters.go
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
package vendor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"with_you/internal/config"
|
||||
)
|
||||
|
||||
// resolveChannelID 按场景选择 channel_id:厂商专属值优先,空则回退到全局默认
|
||||
// scene 为 SceneSystem 时取 system 配置,其余取 chat 配置
|
||||
func resolveChannelID(scene, vendorSys, vendorChat, defaultSys, defaultChat string) string {
|
||||
if scene == SceneSystem {
|
||||
if vendorSys != "" {
|
||||
return vendorSys
|
||||
}
|
||||
return defaultSys
|
||||
}
|
||||
if vendorChat != "" {
|
||||
return vendorChat
|
||||
}
|
||||
return defaultChat
|
||||
}
|
||||
|
||||
// channelOnlyMap 仅 channel_id 厂商的通用转换逻辑(厂商空则回退全局默认)
|
||||
// 最终 channel_id 为空时返回 nil(不下发该厂商)
|
||||
func channelOnlyMap(scene, vendorSys, vendorChat, defaultSys, defaultChat string) map[string]any {
|
||||
cid := resolveChannelID(scene, vendorSys, vendorChat, defaultSys, defaultChat)
|
||||
if cid == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"channel_id": cid}
|
||||
}
|
||||
|
||||
// huaweiConverter 华为厂商转换器(仅 channel_id)
|
||||
type huaweiConverter struct{}
|
||||
|
||||
func (huaweiConverter) VendorName() string { return "huawei" }
|
||||
func (huaweiConverter) Convert(params MessageParams, cfg config.ChannelConfig) map[string]any {
|
||||
return channelOnlyMap(params.Scene, cfg.Vendor.Huawei.System, cfg.Vendor.Huawei.Chat, cfg.System, cfg.Chat)
|
||||
}
|
||||
|
||||
// vivoConverter VIVO 厂商转换器(仅 channel_id)
|
||||
type vivoConverter struct{}
|
||||
|
||||
func (vivoConverter) VendorName() string { return "vivo" }
|
||||
func (vivoConverter) Convert(params MessageParams, cfg config.ChannelConfig) map[string]any {
|
||||
return channelOnlyMap(params.Scene, cfg.Vendor.VIVO.System, cfg.Vendor.VIVO.Chat, cfg.System, cfg.Chat)
|
||||
}
|
||||
|
||||
// meizuConverter 魅族厂商转换器(仅 channel_id)
|
||||
type meizuConverter struct{}
|
||||
|
||||
func (meizuConverter) VendorName() string { return "meizu" }
|
||||
func (meizuConverter) Convert(params MessageParams, cfg config.ChannelConfig) map[string]any {
|
||||
return channelOnlyMap(params.Scene, cfg.Vendor.Meizu.System, cfg.Vendor.Meizu.Chat, cfg.System, cfg.Chat)
|
||||
}
|
||||
|
||||
// honorConverter 荣耀厂商转换器(仅 channel_id)
|
||||
type honorConverter struct{}
|
||||
|
||||
func (honorConverter) VendorName() string { return "honor" }
|
||||
func (honorConverter) Convert(params MessageParams, cfg config.ChannelConfig) map[string]any {
|
||||
return channelOnlyMap(params.Scene, cfg.Vendor.Honor.System, cfg.Vendor.Honor.Chat, cfg.System, cfg.Chat)
|
||||
}
|
||||
|
||||
// fcmConverter FCM 厂商转换器(仅 channel_id)
|
||||
type fcmConverter struct{}
|
||||
|
||||
func (fcmConverter) VendorName() string { return "fcm" }
|
||||
func (fcmConverter) Convert(params MessageParams, cfg config.ChannelConfig) map[string]any {
|
||||
return channelOnlyMap(params.Scene, cfg.Vendor.FCM.System, cfg.Vendor.FCM.Chat, cfg.System, cfg.Chat)
|
||||
}
|
||||
|
||||
// xiaomiConverter 小米厂商转换器
|
||||
// 配置 mi_template_id 时,私信消息下发时携带 channel_id 及 mi_template_id + mi_template_param
|
||||
type xiaomiConverter struct{}
|
||||
|
||||
func (xiaomiConverter) VendorName() string { return "xiaomi" }
|
||||
|
||||
func (xiaomiConverter) Convert(params MessageParams, cfg config.ChannelConfig) map[string]any {
|
||||
x := cfg.Vendor.Xiaomi
|
||||
cid := resolveChannelID(params.Scene, x.System, x.Chat, cfg.System, cfg.Chat)
|
||||
// 小米模板按场景选择
|
||||
templateID := x.MiChatTemplateID
|
||||
if params.Scene == SceneSystem {
|
||||
templateID = x.MiSystemTemplateID
|
||||
}
|
||||
|
||||
// 最终 channel_id 与模板均未配置则跳过
|
||||
if cid == "" && templateID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := map[string]any{}
|
||||
if cid != "" {
|
||||
m["channel_id"] = cid
|
||||
}
|
||||
if templateID != "" {
|
||||
m["mi_template_id"] = templateID
|
||||
// mi_template_param 为模板参数的 JSON 字符串
|
||||
m["mi_template_param"] = buildMiTemplateParam(params)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// buildMiTemplateParam 生成小米消息模板参数 JSON 字符串
|
||||
// 默认占位符:sender / title / content,可按实际小米模板占位符在集中处调整
|
||||
func buildMiTemplateParam(params MessageParams) string {
|
||||
param := map[string]any{
|
||||
"sender": params.SenderName,
|
||||
"title": params.Title,
|
||||
"content": params.Body,
|
||||
}
|
||||
b, err := json.Marshal(param)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// oppoConverter OPPO 厂商转换器
|
||||
// 配置 private_msg_template_id 时,私信消息下发时携带 private_msg_template_id + private_title_parameters + private_content_parameters
|
||||
type oppoConverter struct{}
|
||||
|
||||
func (oppoConverter) VendorName() string { return "oppo" }
|
||||
|
||||
func (oppoConverter) Convert(params MessageParams, cfg config.ChannelConfig) map[string]any {
|
||||
o := cfg.Vendor.OPPO
|
||||
cid := resolveChannelID(params.Scene, o.System, o.Chat, cfg.System, cfg.Chat)
|
||||
// OPPO 私信模板按场景选择
|
||||
templateID := o.OppoChatPrivateTemplateID
|
||||
if params.Scene == SceneSystem {
|
||||
templateID = o.OppoSystemPrivateTemplateID
|
||||
}
|
||||
|
||||
if cid == "" && templateID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := map[string]any{}
|
||||
if cid != "" {
|
||||
m["channel_id"] = cid
|
||||
}
|
||||
if templateID != "" {
|
||||
m["private_msg_template_id"] = templateID
|
||||
// private_title_parameters / private_content_parameters 为 JSONObject
|
||||
m["private_title_parameters"] = map[string]any{
|
||||
"title": params.Title,
|
||||
}
|
||||
m["private_content_parameters"] = map[string]any{
|
||||
"content": params.Body,
|
||||
"sender": params.SenderName,
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
151
internal/pkg/jpush/vendor/params.go
vendored
Normal file
151
internal/pkg/jpush/vendor/params.go
vendored
Normal 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 的 key(xiaomi/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,
|
||||
}
|
||||
}
|
||||
135
internal/pkg/jpush/vendor/payload_compat_test.go
vendored
Normal file
135
internal/pkg/jpush/vendor/payload_compat_test.go
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
package vendor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"with_you/internal/config"
|
||||
)
|
||||
|
||||
// TestPayloadEmptyConfigMatchesLegacy 验证:未配置任何 channel/模板时,
|
||||
// Factory 产出的推送 payload 与改动前 BuildNotification 行为一致。
|
||||
//
|
||||
// 改动前 BuildNotification(title, body, "message", extras) 产出:
|
||||
// notification: { alert, android:{alert,title,extras:{notification_type}+extras,priority:1}, ios:{alert:{title,body},sound:default,badge:+1,extras} }
|
||||
// options: { time_to_live: 86400, apns_production } // 无 third_party_channel
|
||||
func TestPayloadEmptyConfigMatchesLegacy(t *testing.T) {
|
||||
factory := NewFactory()
|
||||
cfg := config.ChannelConfig{} // 全空配置
|
||||
params := MessageParams{
|
||||
Scene: SceneChat,
|
||||
Title: "张三",
|
||||
Body: "你好",
|
||||
MessageType: "message",
|
||||
Extra: map[string]any{
|
||||
"message_id": "m1",
|
||||
"conversation_id": "c1",
|
||||
"sender_id": "u1",
|
||||
"category": "chat",
|
||||
},
|
||||
}
|
||||
|
||||
payload := factory.Build(params, cfg)
|
||||
|
||||
// 1. TPC 必须为 nil(无 third_party_channel 下发,与改动前一致)
|
||||
if payload.TPC != nil {
|
||||
t.Fatalf("TPC should be nil when all channel config empty, got: %+v", payload.TPC)
|
||||
}
|
||||
|
||||
notif := payload.Notification
|
||||
if notif == nil || notif.Android == nil || notif.IOS == nil {
|
||||
t.Fatalf("notification/android/ios must not be nil")
|
||||
}
|
||||
|
||||
// 2. Android 字段对比
|
||||
if notif.Android.Alert != "你好" || notif.Android.Title != "张三" || notif.Android.Priority != 1 {
|
||||
t.Errorf("Android basic fields mismatch: %+v", notif.Android)
|
||||
}
|
||||
if notif.Android.ChannelID != "" {
|
||||
t.Errorf("Android.ChannelID = %q, want empty", notif.Android.ChannelID)
|
||||
}
|
||||
if notif.Android.Extras["notification_type"] != "message" || notif.Android.Extras["message_id"] != "m1" {
|
||||
t.Errorf("Android.Extras mismatch: %+v", notif.Android.Extras)
|
||||
}
|
||||
|
||||
// 3. iOS 字段对比
|
||||
if notif.IOS.Sound != "default" || notif.IOS.Badge != "+1" {
|
||||
t.Errorf("IOS basic fields mismatch: %+v", notif.IOS)
|
||||
}
|
||||
if notif.IOS.ThreadID != "" {
|
||||
t.Errorf("IOS.ThreadID = %q, want empty", notif.IOS.ThreadID)
|
||||
}
|
||||
if notif.IOS.Extras["notification_type"] != "message" {
|
||||
t.Errorf("IOS.Extras[notification_type] mismatch: %+v", notif.IOS.Extras)
|
||||
}
|
||||
|
||||
// 4. 序列化 JSON 确认厂商私有字段不出现
|
||||
jsonBytes, err := json.Marshal(notif)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
jsonStr := string(jsonBytes)
|
||||
for _, key := range []string{`"channel_id"`, `"thread-id"`, `"third_party_channel"`, `"mi_template"`, `"private_msg_template_id"`} {
|
||||
if strings.Contains(jsonStr, key) {
|
||||
t.Errorf("notification JSON should not contain %s when config empty, got: %s", key, jsonStr)
|
||||
}
|
||||
}
|
||||
t.Logf("notification JSON: %s", jsonStr)
|
||||
}
|
||||
|
||||
// TestPayloadWithChannelConfig 验证:配置 channel_id 后正确下发,且不影响通用通知字段
|
||||
func TestPayloadWithChannelConfig(t *testing.T) {
|
||||
factory := NewFactory()
|
||||
cfg := config.ChannelConfig{
|
||||
System: "sys_channel",
|
||||
Chat: "chat_channel",
|
||||
}
|
||||
params := MessageParams{Scene: SceneChat, Title: "T", Body: "B", MessageType: "chat_message"}
|
||||
|
||||
payload := factory.Build(params, cfg)
|
||||
|
||||
// 通用通知仍正常
|
||||
if payload.Notification.Android.Title != "T" {
|
||||
t.Errorf("Android.Title = %q, want T", payload.Notification.Android.Title)
|
||||
}
|
||||
// Android channel_id 跟随场景(chat)
|
||||
if payload.Notification.Android.ChannelID != "chat_channel" {
|
||||
t.Errorf("Android.ChannelID = %q, want chat_channel", payload.Notification.Android.ChannelID)
|
||||
}
|
||||
// TPC 各厂商 channel_id 下发(华为/VIVO 等回退全局)
|
||||
if payload.TPC == nil {
|
||||
t.Fatal("TPC should not be nil when channel config present")
|
||||
}
|
||||
if payload.TPC.Huawei["channel_id"] != "chat_channel" {
|
||||
t.Errorf("Huawei channel_id = %v, want chat_channel", payload.TPC.Huawei["channel_id"])
|
||||
}
|
||||
t.Logf("TPC JSON: %s", mustJSON(payload.TPC))
|
||||
}
|
||||
|
||||
// TestPayloadXiaomiTemplateOptional 验证:仅配 channel_id 不配模板时,小米不下发模板字段
|
||||
func TestPayloadXiaomiTemplateOptional(t *testing.T) {
|
||||
factory := NewFactory()
|
||||
cfg := config.ChannelConfig{
|
||||
Vendor: config.VendorChannel{
|
||||
Xiaomi: config.VendorChannelID{Chat: "mi_chat"},
|
||||
},
|
||||
}
|
||||
params := MessageParams{Scene: SceneChat, Title: "T", Body: "B"}
|
||||
|
||||
payload := factory.Build(params, cfg)
|
||||
if payload.TPC == nil || payload.TPC.Xiaomi == nil {
|
||||
t.Fatal("Xiaomi TPC should exist")
|
||||
}
|
||||
if _, ok := payload.TPC.Xiaomi["mi_template_id"]; ok {
|
||||
t.Error("Xiaomi should NOT contain mi_template_id when template not configured")
|
||||
}
|
||||
if payload.TPC.Xiaomi["channel_id"] != "mi_chat" {
|
||||
t.Errorf("Xiaomi channel_id = %v, want mi_chat", payload.TPC.Xiaomi["channel_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
61
internal/pkg/jpush/vendor/text_renderer.go
vendored
Normal file
61
internal/pkg/jpush/vendor/text_renderer.go
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
package vendor
|
||||
|
||||
import (
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/pkg/jpush"
|
||||
)
|
||||
|
||||
// defaultRenderer 默认通用文本渲染器
|
||||
type defaultRenderer struct{}
|
||||
|
||||
func newDefaultRenderer() *defaultRenderer {
|
||||
return &defaultRenderer{}
|
||||
}
|
||||
|
||||
// mergeExtras 合并基础 extras 与额外 extras,返回新 map
|
||||
func mergeExtras(messageType string, extra map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(extra)+1)
|
||||
if messageType != "" {
|
||||
out["notification_type"] = messageType
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RenderAndroid 渲染 Android 通用通知
|
||||
// 所有 Android 厂商共享:alert/title/channel_id/extras/large_icon/priority
|
||||
func (r *defaultRenderer) RenderAndroid(params MessageParams, cfg config.ChannelConfig) *jpush.AndroidNotif {
|
||||
channelID := cfg.Chat
|
||||
if params.Scene == SceneSystem {
|
||||
channelID = cfg.System
|
||||
}
|
||||
return &jpush.AndroidNotif{
|
||||
Alert: params.Body,
|
||||
Title: params.Title,
|
||||
ChannelID: channelID,
|
||||
LargeIcon: params.LargeIcon,
|
||||
Extras: mergeExtras(params.MessageType, params.Extra),
|
||||
Priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// RenderIOS 渲染 iOS 通用通知
|
||||
// iOS 走 APNs,通过 thread-id 实现按场景的通知分组
|
||||
func (r *defaultRenderer) RenderIOS(params MessageParams, cfg config.ChannelConfig) *jpush.IOSNotif {
|
||||
threadID := cfg.IOS.ChatThreadID
|
||||
if params.Scene == SceneSystem {
|
||||
threadID = cfg.IOS.SystemThreadID
|
||||
}
|
||||
return &jpush.IOSNotif{
|
||||
Alert: map[string]any{
|
||||
"title": params.Title,
|
||||
"body": params.Body,
|
||||
},
|
||||
Sound: "default",
|
||||
Badge: "+1",
|
||||
ThreadID: threadID,
|
||||
Extras: mergeExtras(params.MessageType, params.Extra),
|
||||
}
|
||||
}
|
||||
91
internal/repository/uploaded_file_repo.go
Normal file
91
internal/repository/uploaded_file_repo.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"with_you/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UploadedFileRepository 文件上传记录仓储接口
|
||||
type UploadedFileRepository interface {
|
||||
// Create 写入一条上传记录
|
||||
Create(ctx context.Context, file *model.UploadedFile) error
|
||||
// GetByURL 按 URL 查询记录(DTO 失效标记用)
|
||||
GetByURL(ctx context.Context, url string) (*model.UploadedFile, error)
|
||||
// ListPendingCleanup 取 created_at < before 且 expired=false 的记录,分批
|
||||
ListPendingCleanup(ctx context.Context, before time.Time, limit int) ([]model.UploadedFile, error)
|
||||
// MarkExpired 标记某条记录已清理
|
||||
MarkExpired(ctx context.Context, id uint, at time.Time) error
|
||||
// GetExpiredURLSet 批量查询已标记过期的 URL 集合(DTO 注入用)
|
||||
// 注意:这是全量扫描 expired=true 的记录,适用于上传量不大的场景;
|
||||
// 若 uploaded_files 表增长过大,可改为按时间窗口分页。
|
||||
GetExpiredURLSet(ctx context.Context) (map[string]struct{}, error)
|
||||
}
|
||||
|
||||
// uploadedFileRepository 文件上传记录仓储实现
|
||||
type uploadedFileRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewUploadedFileRepository 创建文件上传记录仓储
|
||||
func NewUploadedFileRepository(db *gorm.DB) UploadedFileRepository {
|
||||
return &uploadedFileRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 写入一条上传记录
|
||||
func (r *uploadedFileRepository) Create(ctx context.Context, file *model.UploadedFile) error {
|
||||
return r.db.WithContext(ctx).Create(file).Error
|
||||
}
|
||||
|
||||
// GetByURL 按 URL 查询记录
|
||||
func (r *uploadedFileRepository) GetByURL(ctx context.Context, url string) (*model.UploadedFile, error) {
|
||||
var file model.UploadedFile
|
||||
if err := r.db.WithContext(ctx).Where("url = ?", url).First(&file).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
// ListPendingCleanup 取待清理记录
|
||||
func (r *uploadedFileRepository) ListPendingCleanup(ctx context.Context, before time.Time, limit int) ([]model.UploadedFile, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var files []model.UploadedFile
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("created_at < ? AND expired = ?", before, false).
|
||||
Order("created_at ASC").
|
||||
Limit(limit).
|
||||
Find(&files).Error
|
||||
return files, err
|
||||
}
|
||||
|
||||
// MarkExpired 标记已清理
|
||||
func (r *uploadedFileRepository) MarkExpired(ctx context.Context, id uint, at time.Time) error {
|
||||
return r.db.WithContext(ctx).Model(&model.UploadedFile{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"expired": true,
|
||||
"expired_at": at,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetExpiredURLSet 批量查询已过期 URL 集合
|
||||
func (r *uploadedFileRepository) GetExpiredURLSet(ctx context.Context) (map[string]struct{}, error) {
|
||||
var urls []string
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.UploadedFile{}).
|
||||
Where("expired = ?", true).
|
||||
Pluck("url", &urls).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := make(map[string]struct{}, len(urls))
|
||||
for _, u := range urls {
|
||||
set[u] = struct{}{}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
@@ -436,6 +436,7 @@ func (r *Router) setupRoutes() {
|
||||
uploads := v1.Group("/uploads")
|
||||
{
|
||||
uploads.POST("/images", authMiddleware, r.UploadHandler.UploadImage)
|
||||
uploads.POST("/files", authMiddleware, r.UploadHandler.UploadFile)
|
||||
}
|
||||
|
||||
// 推送相关路由
|
||||
|
||||
261
internal/service/file_cleanup_worker.go
Normal file
261
internal/service/file_cleanup_worker.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/pkg/s3"
|
||||
"with_you/internal/repository"
|
||||
|
||||
redislib "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// FileCleanupWorker 聊天文件 TTL 过期清理 worker
|
||||
// 周期性扫描 uploaded_files 表,将超过保留期的文件从 S3 删除并标记 expired。
|
||||
// 多实例部署时通过 Redis 分布式锁保证仅主实例执行清理。
|
||||
type FileCleanupWorker struct {
|
||||
cfg *config.Config
|
||||
uploadedRepo repository.UploadedFileRepository
|
||||
s3Client *s3.Client
|
||||
rdb *redislib.Client // 用于分布式锁(可为 nil,降级为本地运行)
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
}
|
||||
|
||||
const (
|
||||
// fileCleanupLockKey Redis 分布式锁键
|
||||
fileCleanupLockKey = "file_cleanup:leader_lock"
|
||||
// fileCleanupLockTTL 锁持有时间(略大于扫描间隔,确保单实例持有)
|
||||
fileCleanupLockTTL = 7 * time.Hour
|
||||
// fileCleanupLockRetryInterval 竞争锁的重试间隔
|
||||
fileCleanupLockRetryInterval = 1 * time.Minute
|
||||
)
|
||||
|
||||
// NewFileCleanupWorker 创建文件清理 worker
|
||||
func NewFileCleanupWorker(
|
||||
cfg *config.Config,
|
||||
uploadedRepo repository.UploadedFileRepository,
|
||||
s3Client *s3.Client,
|
||||
rdb *redislib.Client,
|
||||
) *FileCleanupWorker {
|
||||
return &FileCleanupWorker{
|
||||
cfg: cfg,
|
||||
uploadedRepo: uploadedRepo,
|
||||
s3Client: s3Client,
|
||||
rdb: rdb,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// tryAcquireLock 尝试获取分布式锁,返回是否成功
|
||||
func (w *FileCleanupWorker) tryAcquireLock(ctx context.Context) bool {
|
||||
if w.rdb == nil {
|
||||
return true // 无 Redis 时降级为本地运行
|
||||
}
|
||||
ok, err := w.rdb.SetNX(ctx, fileCleanupLockKey, "1", fileCleanupLockTTL).Result()
|
||||
if err != nil {
|
||||
zap.L().Warn("file cleanup: failed to acquire leader lock, running locally", zap.Error(err))
|
||||
return true
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// keepLockAlive 周期性续期分布式锁
|
||||
func (w *FileCleanupWorker) keepLockAlive(ctx context.Context) {
|
||||
if w.rdb == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(fileCleanupLockTTL / 2)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-w.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.rdb.Expire(ctx, fileCleanupLockKey, fileCleanupLockTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// releaseLock 释放分布式锁
|
||||
func (w *FileCleanupWorker) releaseLock() {
|
||||
if w.rdb == nil {
|
||||
return
|
||||
}
|
||||
w.rdb.Del(context.Background(), fileCleanupLockKey)
|
||||
}
|
||||
|
||||
// Start 启动清理 worker
|
||||
func (w *FileCleanupWorker) Start(ctx context.Context) {
|
||||
if w == nil || w.cfg == nil || !w.cfg.FileCleanup.Enabled {
|
||||
close(w.doneCh)
|
||||
return
|
||||
}
|
||||
if w.uploadedRepo == nil || w.s3Client == nil {
|
||||
zap.L().Warn("file cleanup: missing dependencies, worker disabled")
|
||||
close(w.doneCh)
|
||||
return
|
||||
}
|
||||
|
||||
// 扫描间隔,默认 6 小时,最小 5 分钟
|
||||
interval := time.Duration(w.cfg.FileCleanup.IntervalMinutes) * time.Minute
|
||||
if interval < 5*time.Minute {
|
||||
interval = 6 * time.Hour
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(w.doneCh)
|
||||
|
||||
isLeader := false
|
||||
lockCtx, lockCancel := context.WithCancel(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
if isLeader {
|
||||
w.releaseLock()
|
||||
}
|
||||
lockCancel()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
acquired := w.tryAcquireLock(ctx)
|
||||
if acquired {
|
||||
if !isLeader {
|
||||
zap.L().Info("file cleanup: acquired leader lock, becoming primary")
|
||||
isLeader = true
|
||||
go w.keepLockAlive(lockCtx)
|
||||
|
||||
// 主实例启动后立即执行一次清理
|
||||
w.runOnce(ctx)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
ticker.Stop()
|
||||
lockCancel()
|
||||
w.releaseLock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
zap.L().Debug("file cleanup: another instance holds leader lock, retrying later")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
lockCancel()
|
||||
return
|
||||
case <-time.After(fileCleanupLockRetryInterval):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止 worker
|
||||
func (w *FileCleanupWorker) Stop() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.stopOnce.Do(func() {
|
||||
close(w.stopCh)
|
||||
})
|
||||
<-w.doneCh
|
||||
w.releaseLock()
|
||||
}
|
||||
|
||||
// runOnce 执行一次清理扫描
|
||||
func (w *FileCleanupWorker) runOnce(ctx context.Context) {
|
||||
cfg := w.cfg.FileCleanup
|
||||
retentionDays := cfg.RetentionDays
|
||||
if retentionDays <= 0 {
|
||||
retentionDays = 7
|
||||
}
|
||||
batchSize := cfg.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
|
||||
before := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour)
|
||||
totalDeleted := 0
|
||||
totalFailed := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
files, err := w.uploadedRepo.ListPendingCleanup(ctx, before, batchSize)
|
||||
if err != nil {
|
||||
zap.L().Warn("file cleanup: list pending failed", zap.Error(err))
|
||||
break
|
||||
}
|
||||
if len(files) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// 删除 S3 对象
|
||||
if derr := w.s3Client.Delete(ctx, f.ObjectName); derr != nil {
|
||||
// 删除失败不标记,下次重试
|
||||
totalFailed++
|
||||
zap.L().Warn("file cleanup: delete object failed, will retry",
|
||||
zap.String("object", f.ObjectName),
|
||||
zap.String("url", f.URL),
|
||||
zap.Error(derr),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 标记已清理
|
||||
now := time.Now()
|
||||
if merr := w.uploadedRepo.MarkExpired(ctx, f.ID, now); merr != nil {
|
||||
zap.L().Warn("file cleanup: mark expired failed",
|
||||
zap.Uint("id", f.ID),
|
||||
zap.String("url", f.URL),
|
||||
zap.Error(merr),
|
||||
)
|
||||
totalFailed++
|
||||
} else {
|
||||
totalDeleted++
|
||||
}
|
||||
}
|
||||
|
||||
// 本批次不足 batchSize,说明已无待清理记录
|
||||
if len(files) < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if totalDeleted > 0 || totalFailed > 0 {
|
||||
zap.L().Info("file cleanup sweep done",
|
||||
zap.Int("deleted", totalDeleted),
|
||||
zap.Int("failed", totalFailed),
|
||||
zap.Int("retention_days", retentionDays),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:保证 errors 包被引用(若未来 runOnce 增加错误判断)
|
||||
var _ = errors.Is
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/s3"
|
||||
"with_you/internal/repository"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"go.uber.org/zap"
|
||||
@@ -26,22 +27,34 @@ import (
|
||||
_ "golang.org/x/image/tiff"
|
||||
)
|
||||
|
||||
// UploadFileResult 通用文件上传结果
|
||||
type UploadFileResult struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
// UploadService 上传服务接口
|
||||
type UploadService interface {
|
||||
UploadImage(ctx context.Context, file *multipart.FileHeader) (string, string, string, error)
|
||||
UploadImageBytes(ctx context.Context, raw []byte, filename string) (url, previewURL, previewURLLarge string, err error)
|
||||
UploadAvatar(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
|
||||
UploadCover(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
|
||||
UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string) (*UploadFileResult, error)
|
||||
GetURL(ctx context.Context, objectName string) (string, error)
|
||||
Delete(ctx context.Context, objectName string) error
|
||||
GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error)
|
||||
ValidateChatMessageImageSegments(segments model.MessageSegments) error
|
||||
// GetExpiredURLSet 返回所有已从 S3 清理的文件 URL 集合,供 DTO 层标记失效
|
||||
GetExpiredURLSet(ctx context.Context) (map[string]struct{}, error)
|
||||
}
|
||||
|
||||
// uploadService 上传服务实现
|
||||
type uploadService struct {
|
||||
s3Client *s3.Client
|
||||
userService UserService
|
||||
s3Client *s3.Client
|
||||
userService UserService
|
||||
uploadedRepo repository.UploadedFileRepository
|
||||
}
|
||||
|
||||
// 预览图配置
|
||||
@@ -52,10 +65,11 @@ const (
|
||||
)
|
||||
|
||||
// NewUploadService 创建上传服务
|
||||
func NewUploadService(s3Client *s3.Client, userService UserService) UploadService {
|
||||
func NewUploadService(s3Client *s3.Client, userService UserService, uploadedRepo repository.UploadedFileRepository) UploadService {
|
||||
return &uploadService{
|
||||
s3Client: s3Client,
|
||||
userService: userService,
|
||||
s3Client: s3Client,
|
||||
userService: userService,
|
||||
uploadedRepo: uploadedRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +230,210 @@ func (s *uploadService) UploadCover(ctx context.Context, userID string, file *mu
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// 聊天文件上传限制
|
||||
const (
|
||||
MaxChatFileSize = 50 << 20 // 50MB
|
||||
chatFileFolderLimit = 32 // folder 名长度上限,避免路径注入
|
||||
)
|
||||
|
||||
// chatFileAllowedMime 允许上传的文件 MIME 白名单(按类型前缀匹配)。
|
||||
// 不在白名单内的类型一律拒绝,防止上传可执行脚本等高危文件。
|
||||
var chatFileAllowedMime = map[string]bool{
|
||||
// 文档
|
||||
"application/pdf": true,
|
||||
"application/msword": true,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||
"application/vnd.ms-excel": true,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
||||
"application/vnd.ms-powerpoint": true,
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
|
||||
"application/rtf": true,
|
||||
"text/plain": true,
|
||||
"text/csv": true,
|
||||
"text/markdown": true,
|
||||
"application/json": true,
|
||||
"application/xml": true,
|
||||
"text/xml": true,
|
||||
"application/zip": true,
|
||||
"application/x-zip-compressed": true,
|
||||
"application/x-rar-compressed": true,
|
||||
"application/x-7z-compressed": true,
|
||||
"application/gzip": true,
|
||||
"application/x-tar": true,
|
||||
// 音视频/图片(聊天文件入口)
|
||||
"audio/mpeg": true,
|
||||
"audio/mp3": true,
|
||||
"audio/wav": true,
|
||||
"audio/x-wav": true,
|
||||
"audio/aac": true,
|
||||
"audio/x-m4a": true,
|
||||
"audio/flac": true,
|
||||
"video/mp4": true,
|
||||
"video/quicktime": true,
|
||||
"video/x-msvideo": true,
|
||||
"video/webm": true,
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/bmp": true,
|
||||
// 其他常用类型
|
||||
"application/octet-stream": true, // 部分客户端(如 Windows)上传无明确类型,需结合扩展名二次校验
|
||||
}
|
||||
|
||||
// chatFileExtFallback 当 MIME 为 octet-stream 时,按扩展名推断的真实类型
|
||||
var chatFileExtFallback = map[string]string{
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".zip": "application/zip",
|
||||
".rar": "application/x-rar-compressed",
|
||||
".7z": "application/x-7z-compressed",
|
||||
".gz": "application/gzip",
|
||||
".tar": "application/x-tar",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".avi": "video/x-msvideo",
|
||||
".json": "application/json",
|
||||
".csv": "text/csv",
|
||||
".md": "text/markdown",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
// normalizeChatFileMime 校正文件 MIME 类型:
|
||||
// 1. 解析 multipart header 的 Content-Type;
|
||||
// 2. 若为通用 octet-stream,依据文件名扩展名回退到更具体的类型;
|
||||
// 3. 全部兜底为 application/octet-stream。
|
||||
func normalizeChatFileMime(headerType, filename string) string {
|
||||
baseType, _, err := mime.ParseMediaType(strings.TrimSpace(headerType))
|
||||
if err != nil || baseType == "" {
|
||||
baseType = "application/octet-stream"
|
||||
}
|
||||
baseType = strings.ToLower(baseType)
|
||||
|
||||
if baseType == "application/octet-stream" {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if real, ok := chatFileExtFallback[ext]; ok {
|
||||
return real
|
||||
}
|
||||
}
|
||||
return baseType
|
||||
}
|
||||
|
||||
// validateChatFile 校验聊天文件大小与 MIME 白名单
|
||||
func validateChatFile(size int64, mimeType string) error {
|
||||
if size <= 0 {
|
||||
return fmt.Errorf("文件为空")
|
||||
}
|
||||
if size > MaxChatFileSize {
|
||||
return fmt.Errorf("文件大小超过限制(最大 %d MB)", MaxChatFileSize>>20)
|
||||
}
|
||||
if !chatFileAllowedMime[mimeType] {
|
||||
return fmt.Errorf("不支持的文件类型: %s", mimeType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadFile 上传聊天文件(非图片,按文件类型原样存储到 S3)
|
||||
// folder 用于隔离不同来源(如 chat/post),仅允许字母数字与下划线。
|
||||
// uploaderID 为上传者用户 ID,用于审计。
|
||||
func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string) (*UploadFileResult, error) {
|
||||
if file == nil {
|
||||
return nil, fmt.Errorf("文件为空")
|
||||
}
|
||||
if file.Size > MaxChatFileSize {
|
||||
return nil, fmt.Errorf("文件大小超过限制(最大 %d MB)", MaxChatFileSize>>20)
|
||||
}
|
||||
|
||||
// 校验与规范化 folder,避免路径穿越
|
||||
folder = strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, folder)
|
||||
if folder == "" || len(folder) > chatFileFolderLimit {
|
||||
folder = "chat"
|
||||
}
|
||||
|
||||
// 校验 MIME 类型
|
||||
mimeType := normalizeChatFileMime(file.Header.Get("Content-Type"), file.Filename)
|
||||
if err := validateChatFile(file.Size, mimeType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开文件失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
|
||||
if err := validateChatFile(int64(len(data)), mimeType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 以内容哈希命名,避免重复存储;保留原始扩展名(便于浏览器识别)
|
||||
hash := sha256.Sum256(data)
|
||||
hashStr := fmt.Sprintf("%x", hash)
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if ext == "" {
|
||||
// 从 MIME 推断扩展名(mime.ExtensionsByType 在新版本返回 (exts, err))
|
||||
if es, _ := mime.ExtensionsByType(mimeType); len(es) > 0 {
|
||||
ext = es[0]
|
||||
} else {
|
||||
ext = ".bin"
|
||||
}
|
||||
}
|
||||
|
||||
objectName := fmt.Sprintf("files/%s/%s%s", folder, hashStr, ext)
|
||||
url, err := s.s3Client.UploadData(ctx, objectName, data, mimeType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("上传到 S3 失败: %w", err)
|
||||
}
|
||||
|
||||
// 写入上传记录,用于 7 天 TTL 过期清理(失败仅记日志,不影响上传)
|
||||
if s.uploadedRepo != nil {
|
||||
record := &model.UploadedFile{
|
||||
URL: url,
|
||||
ObjectName: objectName,
|
||||
Name: file.Filename,
|
||||
Size: file.Size,
|
||||
MimeType: mimeType,
|
||||
Folder: folder,
|
||||
UploaderID: uploaderID,
|
||||
}
|
||||
if rerr := s.uploadedRepo.Create(ctx, record); rerr != nil {
|
||||
zap.L().Warn("写入文件上传记录失败", zap.String("url", url), zap.Error(rerr))
|
||||
}
|
||||
}
|
||||
|
||||
return &UploadFileResult{
|
||||
URL: url,
|
||||
Name: file.Filename,
|
||||
Size: file.Size,
|
||||
MimeType: mimeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetExpiredURLSet 返回所有已从 S3 清理的文件 URL 集合,供 DTO 层标记失效
|
||||
func (s *uploadService) GetExpiredURLSet(ctx context.Context) (map[string]struct{}, error) {
|
||||
if s.uploadedRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.uploadedRepo.GetExpiredURLSet(ctx)
|
||||
}
|
||||
|
||||
// GetURL 获取文件URL
|
||||
func (s *uploadService) GetURL(ctx context.Context, objectName string) (string, error) {
|
||||
return s.s3Client.GetURL(ctx, objectName)
|
||||
|
||||
@@ -68,9 +68,10 @@ func ProvideMessageHandler(
|
||||
messageService service.MessageService,
|
||||
userService service.UserService,
|
||||
groupService service.GroupService,
|
||||
uploadService service.UploadService,
|
||||
publisher ws.MessagePublisher,
|
||||
) *handler.MessageHandler {
|
||||
return handler.NewMessageHandler(chatService, messageService, userService, groupService, publisher)
|
||||
return handler.NewMessageHandler(chatService, messageService, userService, groupService, uploadService, publisher)
|
||||
}
|
||||
|
||||
// ProvideSystemMessageHandler 提供系统消息处理器
|
||||
|
||||
@@ -55,6 +55,9 @@ var RepositorySet = wire.NewSet(
|
||||
|
||||
// 版本日志同步
|
||||
repository.NewConversationVersionLogRepository,
|
||||
|
||||
// 文件上传记录(聊天文件 TTL 过期清理)
|
||||
repository.NewUploadedFileRepository,
|
||||
)
|
||||
|
||||
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
||||
|
||||
@@ -60,6 +60,7 @@ var ServiceSet = wire.NewSet(
|
||||
ProvideAdminDashboardService,
|
||||
ProvideQRCodeLoginService,
|
||||
ProvideHotRankWorker,
|
||||
ProvideFileCleanupWorker,
|
||||
ProvideMaterialService,
|
||||
ProvideCallService,
|
||||
ProvideLiveKitService,
|
||||
@@ -112,8 +113,9 @@ func ProvidePushService(
|
||||
messageRepo repository.MessageRepository,
|
||||
publisher ws.MessagePublisher,
|
||||
jpushClient *jpush.Client,
|
||||
cfg *config.Config,
|
||||
) service.PushService {
|
||||
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, publisher, jpushClient)
|
||||
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, publisher, jpushClient, cfg.JPush.Channel)
|
||||
}
|
||||
|
||||
// ProvideSystemMessageService 提供系统消息服务
|
||||
@@ -291,8 +293,23 @@ func ProvideGroupService(
|
||||
func ProvideUploadService(
|
||||
s3Client *s3.Client,
|
||||
userService service.UserService,
|
||||
uploadedRepo repository.UploadedFileRepository,
|
||||
) service.UploadService {
|
||||
return service.NewUploadService(s3Client, userService)
|
||||
return service.NewUploadService(s3Client, userService, uploadedRepo)
|
||||
}
|
||||
|
||||
// ProvideFileCleanupWorker 提供聊天文件过期清理 worker
|
||||
func ProvideFileCleanupWorker(
|
||||
cfg *config.Config,
|
||||
uploadedRepo repository.UploadedFileRepository,
|
||||
s3Client *s3.Client,
|
||||
redisClient *redis.Client,
|
||||
) *service.FileCleanupWorker {
|
||||
var rdb *redisPkg.Client
|
||||
if redisClient != nil {
|
||||
rdb = redisClient.GetClient()
|
||||
}
|
||||
return service.NewFileCleanupWorker(cfg, uploadedRepo, s3Client, rdb)
|
||||
}
|
||||
|
||||
// ProvideUserActivityService 提供用户活跃统计服务
|
||||
|
||||
Reference in New Issue
Block a user