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:
@@ -18,17 +18,18 @@ import (
|
|||||||
|
|
||||||
// App 应用程序结构体
|
// App 应用程序结构体
|
||||||
type App struct {
|
type App struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Router *router.Router
|
Router *router.Router
|
||||||
PushService service.PushService
|
PushService service.PushService
|
||||||
HotRankWorker *service.HotRankWorker
|
HotRankWorker *service.HotRankWorker
|
||||||
GRPCServer *runner.Server
|
FileCleanupWorker *service.FileCleanupWorker
|
||||||
Server *http.Server
|
GRPCServer *runner.Server
|
||||||
Publisher ws.MessagePublisher
|
Server *http.Server
|
||||||
TaskDispatcher runner.TaskDispatcher
|
Publisher ws.MessagePublisher
|
||||||
Logger *zap.Logger
|
TaskDispatcher runner.TaskDispatcher
|
||||||
PushWorker *service.PushWorker
|
Logger *zap.Logger
|
||||||
|
PushWorker *service.PushWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp 创建应用程序
|
// NewApp 创建应用程序
|
||||||
@@ -38,6 +39,7 @@ func NewApp(
|
|||||||
r *router.Router,
|
r *router.Router,
|
||||||
pushService service.PushService,
|
pushService service.PushService,
|
||||||
hotRankWorker *service.HotRankWorker,
|
hotRankWorker *service.HotRankWorker,
|
||||||
|
fileCleanupWorker *service.FileCleanupWorker,
|
||||||
grpcServer *runner.Server,
|
grpcServer *runner.Server,
|
||||||
publisher ws.MessagePublisher,
|
publisher ws.MessagePublisher,
|
||||||
taskDispatcher runner.TaskDispatcher,
|
taskDispatcher runner.TaskDispatcher,
|
||||||
@@ -45,16 +47,17 @@ func NewApp(
|
|||||||
pushWorker *service.PushWorker,
|
pushWorker *service.PushWorker,
|
||||||
) *App {
|
) *App {
|
||||||
return &App{
|
return &App{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
DB: db,
|
DB: db,
|
||||||
Router: r,
|
Router: r,
|
||||||
PushService: pushService,
|
PushService: pushService,
|
||||||
HotRankWorker: hotRankWorker,
|
HotRankWorker: hotRankWorker,
|
||||||
GRPCServer: grpcServer,
|
FileCleanupWorker: fileCleanupWorker,
|
||||||
Publisher: publisher,
|
GRPCServer: grpcServer,
|
||||||
TaskDispatcher: taskDispatcher,
|
Publisher: publisher,
|
||||||
Logger: logger,
|
TaskDispatcher: taskDispatcher,
|
||||||
PushWorker: pushWorker,
|
Logger: logger,
|
||||||
|
PushWorker: pushWorker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +79,11 @@ func (a *App) Start(ctx context.Context) error {
|
|||||||
a.HotRankWorker.Start(ctx)
|
a.HotRankWorker.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.1.1 聊天文件过期清理
|
||||||
|
if a.FileCleanupWorker != nil {
|
||||||
|
a.FileCleanupWorker.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// 2.2 启动 PushWorker(Redis Stream 异步推送)
|
// 2.2 启动 PushWorker(Redis Stream 异步推送)
|
||||||
if a.PushWorker != nil {
|
if a.PushWorker != nil {
|
||||||
a.PushWorker.Start(ctx)
|
a.PushWorker.Start(ctx)
|
||||||
@@ -156,6 +164,11 @@ func (a *App) Stop(ctx context.Context) error {
|
|||||||
a.HotRankWorker.Stop()
|
a.HotRankWorker.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.FileCleanupWorker != nil {
|
||||||
|
a.Logger.Info("Stopping file cleanup worker")
|
||||||
|
a.FileCleanupWorker.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
// 5.1 停止 PushWorker
|
// 5.1 停止 PushWorker
|
||||||
if a.PushWorker != nil {
|
if a.PushWorker != nil {
|
||||||
a.Logger.Info("Stopping push worker (stream)")
|
a.Logger.Info("Stopping push worker (stream)")
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func InitializeApp() (*App, error) {
|
|||||||
messagePublisher := wire.ProvideWSMessagePublisher(config, client)
|
messagePublisher := wire.ProvideWSMessagePublisher(config, client)
|
||||||
logger := wire.ProvideLogger()
|
logger := wire.ProvideLogger()
|
||||||
jpushClient := wire.ProvideJPushClient(config, logger)
|
jpushClient := wire.ProvideJPushClient(config, logger)
|
||||||
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, messagePublisher, jpushClient)
|
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, messagePublisher, jpushClient, config)
|
||||||
postRepository := repository.NewPostRepository(db)
|
postRepository := repository.NewPostRepository(db)
|
||||||
cache := wire.ProvideCache(config, client)
|
cache := wire.ProvideCache(config, client)
|
||||||
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
|
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
|
||||||
@@ -77,7 +77,8 @@ func InitializeApp() (*App, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
uploadService := wire.ProvideUploadService(s3Client, userService)
|
uploadedFileRepository := repository.NewUploadedFileRepository(db)
|
||||||
|
uploadService := wire.ProvideUploadService(s3Client, userService, uploadedFileRepository)
|
||||||
seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache)
|
seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache)
|
||||||
pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository)
|
pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository)
|
||||||
conversationVersionLogRepository := repository.NewConversationVersionLogRepository(db)
|
conversationVersionLogRepository := repository.NewConversationVersionLogRepository(db)
|
||||||
@@ -86,7 +87,7 @@ func InitializeApp() (*App, error) {
|
|||||||
groupRepository := repository.NewGroupRepository(db)
|
groupRepository := repository.NewGroupRepository(db)
|
||||||
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
||||||
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
|
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
|
||||||
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, messagePublisher)
|
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, uploadService, messagePublisher)
|
||||||
notificationRepository := repository.NewNotificationRepository(db)
|
notificationRepository := repository.NewNotificationRepository(db)
|
||||||
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
||||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||||
@@ -163,10 +164,11 @@ func InitializeApp() (*App, error) {
|
|||||||
liveKitHandler := wire.ProvideLiveKitHandler(liveKitService, callService, config, logger)
|
liveKitHandler := wire.ProvideLiveKitHandler(liveKitService, callService, config, logger)
|
||||||
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, emptyClassroomHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, userService, wsHandler, liveKitHandler)
|
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, emptyClassroomHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, userService, wsHandler, liveKitHandler)
|
||||||
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client)
|
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client)
|
||||||
|
fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client)
|
||||||
server := wire.ProvideGRPCServer(config, logger, runnerHub)
|
server := wire.ProvideGRPCServer(config, logger, runnerHub)
|
||||||
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
|
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
|
||||||
taskDispatcher := wire.ProvideTaskDispatcher(runnerHub, runnerRegistry, config, client, logger)
|
taskDispatcher := wire.ProvideTaskDispatcher(runnerHub, runnerRegistry, config, client, logger)
|
||||||
app := NewApp(config, db, router, pushService, hotRankWorker, server, messagePublisher, taskDispatcher, logger, pushWorker)
|
app := NewApp(config, db, router, pushService, hotRankWorker, fileCleanupWorker, server, messagePublisher, taskDispatcher, logger, pushWorker)
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,11 +288,54 @@ report:
|
|||||||
# 极光推送配置
|
# 极光推送配置
|
||||||
# 环境变量:
|
# 环境变量:
|
||||||
# APP_JPUSH_ENABLED, APP_JPUSH_APP_KEY, APP_JPUSH_MASTER_SECRET, APP_JPUSH_PRODUCTION
|
# APP_JPUSH_ENABLED, APP_JPUSH_APP_KEY, APP_JPUSH_MASTER_SECRET, APP_JPUSH_PRODUCTION
|
||||||
|
# 厂商通道 channel_id(区分系统消息与私聊消息,通过 options.third_party_channel 下发):
|
||||||
|
# APP_JPUSH_CHANNEL_SYSTEM, APP_JPUSH_CHANNEL_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_XIAOMI_SYSTEM, APP_JPUSH_CHANNEL_XIAOMI_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_HUAWEI_SYSTEM, APP_JPUSH_CHANNEL_HUAWEI_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_OPPO_SYSTEM, APP_JPUSH_CHANNEL_OPPO_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_VIVO_SYSTEM, APP_JPUSH_CHANNEL_VIVO_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_MEIZU_SYSTEM, APP_JPUSH_CHANNEL_MEIZU_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_HONOR_SYSTEM, APP_JPUSH_CHANNEL_HONOR_CHAT
|
||||||
|
# APP_JPUSH_CHANNEL_FCM_SYSTEM, APP_JPUSH_CHANNEL_FCM_CHAT
|
||||||
|
# 小米消息模板(可选,配置后私信消息携带 channel_id 与 mi_template_id):
|
||||||
|
# APP_JPUSH_CHANNEL_XIAOMI_MI_SYSTEM_TEMPLATE_ID, APP_JPUSH_CHANNEL_XIAOMI_MI_CHAT_TEMPLATE_ID
|
||||||
|
# OPPO 私信模板(可选,仅 OPPO,配置后下发私信时携带):
|
||||||
|
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_PRIVATE_TEMPLATE_ID, APP_JPUSH_CHANNEL_OPPO_CHAT_PRIVATE_TEMPLATE_ID
|
||||||
|
# iOS 通知分组 thread-id:
|
||||||
|
# APP_JPUSH_CHANNEL_IOS_CHAT_THREAD_ID, APP_JPUSH_CHANNEL_IOS_SYSTEM_THREAD_ID
|
||||||
jpush:
|
jpush:
|
||||||
enabled: false
|
enabled: false
|
||||||
app_key: ""
|
app_key: ""
|
||||||
master_secret: ""
|
master_secret: ""
|
||||||
production: false # true: 生产环境, false: 开发环境
|
production: false # true: 生产环境, false: 开发环境
|
||||||
|
# 厂商通道 channel_id 配置(全部可选,留空则不下发对应厂商字段)
|
||||||
|
# channel.system / channel.chat 为各厂商默认 channel_id
|
||||||
|
# channel.vendor.{vendor}.{system|chat} 可单独覆盖某厂商,留空则回退到默认值
|
||||||
|
# 小米/OPPO 额外支持消息模板 id(按场景区分),配置后私信消息下发时携带
|
||||||
|
# 其余厂商(华为/VIVO/魅族/荣耀/FCM)仅 channel_id
|
||||||
|
channel:
|
||||||
|
system: "" # 系统消息/通知默认 channel_id
|
||||||
|
chat: "" # 私聊/群聊消息默认 channel_id
|
||||||
|
vendor:
|
||||||
|
xiaomi:
|
||||||
|
system: ""
|
||||||
|
chat: ""
|
||||||
|
mi_system_template_id: "" # 小米系统消息模板 id
|
||||||
|
mi_chat_template_id: "" # 小米私聊消息模板 id
|
||||||
|
huawei: { system: "", chat: "" }
|
||||||
|
oppo:
|
||||||
|
system: ""
|
||||||
|
chat: ""
|
||||||
|
oppo_system_private_template_id: "" # OPPO 系统消息私信模板 id
|
||||||
|
oppo_chat_private_template_id: "" # OPPO 私聊消息私信模板 id
|
||||||
|
vivo: { system: "", chat: "" }
|
||||||
|
meizu: { system: "", chat: "" }
|
||||||
|
honor: { system: "", chat: "" }
|
||||||
|
fcm: { system: "", chat: "" }
|
||||||
|
# iOS APNs 通知分组(按 thread-id 区分系统/私聊通知,不配置则不分组)
|
||||||
|
ios:
|
||||||
|
chat_thread_id: "" # 私聊/群聊通知分组 thread-id
|
||||||
|
system_thread_id: "" # 系统消息/通知分组 thread-id
|
||||||
|
|
||||||
# 初始化超级管理员密钥
|
# 初始化超级管理员密钥
|
||||||
# 环境变量: APP_SETUP_SECRET
|
# 环境变量: APP_SETUP_SECRET
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Config struct {
|
|||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
Upload UploadConfig `mapstructure:"upload"`
|
Upload UploadConfig `mapstructure:"upload"`
|
||||||
|
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
|
||||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||||
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
||||||
Email EmailConfig `mapstructure:"email"`
|
Email EmailConfig `mapstructure:"email"`
|
||||||
@@ -95,6 +96,11 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("rate_limit.requests_per_minute", 60)
|
viper.SetDefault("rate_limit.requests_per_minute", 60)
|
||||||
viper.SetDefault("upload.max_file_size", 10485760)
|
viper.SetDefault("upload.max_file_size", 10485760)
|
||||||
viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"})
|
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.endpoint", "")
|
||||||
viper.SetDefault("s3.access_key", "")
|
viper.SetDefault("s3.access_key", "")
|
||||||
viper.SetDefault("s3.secret_key", "")
|
viper.SetDefault("s3.secret_key", "")
|
||||||
@@ -174,6 +180,59 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("jpush.app_key", "")
|
viper.SetDefault("jpush.app_key", "")
|
||||||
viper.SetDefault("jpush.master_secret", "")
|
viper.SetDefault("jpush.master_secret", "")
|
||||||
viper.SetDefault("jpush.production", false)
|
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", "")
|
viper.SetDefault("setup_secret", "")
|
||||||
// WebSocket 默认值
|
// WebSocket 默认值
|
||||||
viper.SetDefault("websocket.mode", "standalone")
|
viper.SetDefault("websocket.mode", "standalone")
|
||||||
|
|||||||
@@ -1,8 +1,63 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
type JPushConfig struct {
|
type JPushConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
AppKey string `mapstructure:"app_key"`
|
AppKey string `mapstructure:"app_key"`
|
||||||
MasterSecret string `mapstructure:"master_secret"`
|
MasterSecret string `mapstructure:"master_secret"`
|
||||||
Production bool `mapstructure:"production"`
|
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"`
|
MaxFileSize int64 `mapstructure:"max_file_size"`
|
||||||
AllowedTypes []string `mapstructure:"allowed_types"`
|
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.TradeItem{},
|
||||||
&model.TradeImage{},
|
&model.TradeImage{},
|
||||||
&model.TradeFavorite{},
|
&model.TradeFavorite{},
|
||||||
|
|
||||||
|
// 文件上传记录(用于聊天文件 TTL 过期清理)
|
||||||
|
&model.UploadedFile{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import (
|
|||||||
|
|
||||||
// ConvertMessageToResponse 将Message转换为MessageResponse
|
// ConvertMessageToResponse 将Message转换为MessageResponse
|
||||||
func ConvertMessageToResponse(message *model.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 {
|
if message == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -15,6 +22,19 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
|||||||
// 直接使用 segments,不需要解析
|
// 直接使用 segments,不需要解析
|
||||||
segments := make(model.MessageSegments, len(message.Segments))
|
segments := make(model.MessageSegments, len(message.Segments))
|
||||||
for i, seg := range 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{
|
segments[i] = model.MessageSegment{
|
||||||
Type: seg.Type,
|
Type: seg.Type,
|
||||||
Data: seg.Data,
|
Data: seg.Data,
|
||||||
@@ -107,6 +127,15 @@ func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse {
|
|||||||
return result
|
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 转换 ====================
|
// ==================== PushRecord 转换 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -90,20 +90,37 @@ type MessageHandler struct {
|
|||||||
messageService service.MessageService
|
messageService service.MessageService
|
||||||
userService service.UserService
|
userService service.UserService
|
||||||
groupService service.GroupService
|
groupService service.GroupService
|
||||||
|
uploadService service.UploadService
|
||||||
wsPublisher ws.MessagePublisher
|
wsPublisher ws.MessagePublisher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageHandler 创建消息处理器
|
// 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{
|
return &MessageHandler{
|
||||||
chatService: chatService,
|
chatService: chatService,
|
||||||
messageService: messageService,
|
messageService: messageService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
groupService: groupService,
|
groupService: groupService,
|
||||||
|
uploadService: uploadService,
|
||||||
wsPublisher: wsPublisher,
|
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 输入状态上报
|
// HandleTyping 输入状态上报
|
||||||
// POST /api/v1/conversations/typing
|
// POST /api/v1/conversations/typing
|
||||||
func (h *MessageHandler) HandleTyping(c *gin.Context) {
|
func (h *MessageHandler) HandleTyping(c *gin.Context) {
|
||||||
@@ -287,8 +304,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -315,8 +332,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -335,8 +352,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Paginated(c, result, total, page, pageSize)
|
response.Paginated(c, result, total, page, pageSize)
|
||||||
}
|
}
|
||||||
@@ -857,8 +874,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -885,8 +902,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -905,8 +922,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Paginated(c, result, total, page, pageSize)
|
response.Paginated(c, result, total, page, pageSize)
|
||||||
}
|
}
|
||||||
@@ -1067,8 +1084,8 @@ func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
items := dto.ConvertMessagesToResponse(result.Items)
|
items := h.convertMessagesWithExpiry(c.Request.Context(), result.Items)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageCursorPageResponse{
|
response.Success(c, &dto.MessageCursorPageResponse{
|
||||||
Items: items,
|
Items: items,
|
||||||
|
|||||||
@@ -92,3 +92,39 @@ func (h *UploadHandler) UploadCover(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, gin.H{"url": url})
|
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"`
|
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 {
|
type AndroidNotif struct {
|
||||||
Alert string `json:"alert"`
|
Alert string `json:"alert"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
@@ -244,7 +256,7 @@ func (c *Client) Push(payload map[string]any) (*PushResponse, *RateLimitInfo, er
|
|||||||
return result, rateLimit, parseErr
|
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 {
|
if len(registrationIDs) == 0 {
|
||||||
return nil, fmt.Errorf("registration IDs cannot be empty")
|
return nil, fmt.Errorf("registration IDs cannot be empty")
|
||||||
}
|
}
|
||||||
@@ -262,10 +274,7 @@ func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *N
|
|||||||
"platform": "all",
|
"platform": "all",
|
||||||
"audience": map[string]any{"registration_id": registrationIDs},
|
"audience": map[string]any{"registration_id": registrationIDs},
|
||||||
"notification": notification,
|
"notification": notification,
|
||||||
"options": map[string]any{
|
"options": c.buildOptions(tpc),
|
||||||
"time_to_live": 86400,
|
|
||||||
"apns_production": c.production,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result *PushResponse
|
var result *PushResponse
|
||||||
@@ -292,7 +301,7 @@ func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *N
|
|||||||
return result, nil
|
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 {
|
if len(aliases) == 0 {
|
||||||
return nil, fmt.Errorf("aliases cannot be empty")
|
return nil, fmt.Errorf("aliases cannot be empty")
|
||||||
}
|
}
|
||||||
@@ -310,10 +319,7 @@ func (c *Client) PushByAliases(aliases []string, notification *Notification) (*P
|
|||||||
"platform": "all",
|
"platform": "all",
|
||||||
"audience": map[string]any{"alias": aliases},
|
"audience": map[string]any{"alias": aliases},
|
||||||
"notification": notification,
|
"notification": notification,
|
||||||
"options": map[string]any{
|
"options": c.buildOptions(tpc),
|
||||||
"time_to_live": 86400,
|
|
||||||
"apns_production": c.production,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _, err := c.Push(payload)
|
result, _, err := c.Push(payload)
|
||||||
@@ -332,7 +338,7 @@ func (c *Client) PushByAliases(aliases []string, notification *Notification) (*P
|
|||||||
return result, nil
|
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",
|
c.logger.Info("jpush push all",
|
||||||
zap.String("title", notification.Android.Title),
|
zap.String("title", notification.Android.Title),
|
||||||
zap.String("alert", notification.Alert),
|
zap.String("alert", notification.Alert),
|
||||||
@@ -342,10 +348,7 @@ func (c *Client) PushAll(notification *Notification) (*PushResponse, error) {
|
|||||||
"platform": "all",
|
"platform": "all",
|
||||||
"audience": "all",
|
"audience": "all",
|
||||||
"notification": notification,
|
"notification": notification,
|
||||||
"options": map[string]any{
|
"options": c.buildOptions(tpc),
|
||||||
"time_to_live": 86400,
|
|
||||||
"apns_production": c.production,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _, err := c.Push(payload)
|
result, _, err := c.Push(payload)
|
||||||
@@ -362,41 +365,24 @@ func (c *Client) PushAll(notification *Notification) (*PushResponse, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildNotification(title, body string, notificationType string, extras map[string]any) *Notification {
|
// buildOptions 构造 push payload 的 options 部分
|
||||||
androidExtras := map[string]any{
|
// 当 tpc 非空时加入 third_party_channel 字段,用于向各厂商下发 channel_id
|
||||||
"notification_type": notificationType,
|
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 {
|
if tpc != nil {
|
||||||
androidExtras[k] = v
|
opts["third_party_channel"] = tpc
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
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 {
|
type DeviceInfo struct {
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Alias string `json:"alias"`
|
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 := v1.Group("/uploads")
|
||||||
{
|
{
|
||||||
uploads.POST("/images", authMiddleware, r.UploadHandler.UploadImage)
|
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"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"with_you/internal/config"
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/jpush"
|
"with_you/internal/pkg/jpush"
|
||||||
|
"with_you/internal/pkg/jpush/vendor"
|
||||||
"with_you/internal/pkg/ws"
|
"with_you/internal/pkg/ws"
|
||||||
"with_you/internal/repository"
|
"with_you/internal/repository"
|
||||||
|
|
||||||
@@ -85,6 +87,11 @@ type pushServiceImpl struct {
|
|||||||
wsHub ws.MessagePublisher
|
wsHub ws.MessagePublisher
|
||||||
jpushClient *jpush.Client
|
jpushClient *jpush.Client
|
||||||
|
|
||||||
|
// channelConfig 厂商通道 channel_id 配置(系统消息/私聊消息分别注册)
|
||||||
|
channelConfig config.ChannelConfig
|
||||||
|
// vendorFactory 厂商转换器工厂,按统一参数产出各厂商推送字段
|
||||||
|
vendorFactory *vendor.Factory
|
||||||
|
|
||||||
pushQueue chan *pushTask
|
pushQueue chan *pushTask
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
}
|
}
|
||||||
@@ -104,15 +111,18 @@ func NewPushService(
|
|||||||
messageRepo repository.MessageRepository,
|
messageRepo repository.MessageRepository,
|
||||||
publisher ws.MessagePublisher,
|
publisher ws.MessagePublisher,
|
||||||
jpushClient *jpush.Client,
|
jpushClient *jpush.Client,
|
||||||
|
channelConfig config.ChannelConfig,
|
||||||
) PushService {
|
) PushService {
|
||||||
return &pushServiceImpl{
|
return &pushServiceImpl{
|
||||||
pushRepo: pushRepo,
|
pushRepo: pushRepo,
|
||||||
deviceRepo: deviceRepo,
|
deviceRepo: deviceRepo,
|
||||||
messageRepo: messageRepo,
|
messageRepo: messageRepo,
|
||||||
wsHub: publisher,
|
wsHub: publisher,
|
||||||
jpushClient: jpushClient,
|
jpushClient: jpushClient,
|
||||||
pushQueue: make(chan *pushTask, PushQueueSize),
|
channelConfig: channelConfig,
|
||||||
stopChan: make(chan struct{}),
|
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 {
|
if err == nil && len(devices) > 0 {
|
||||||
mobileDevices := getMobileDevices(devices)
|
mobileDevices := getMobileDevices(devices)
|
||||||
if len(mobileDevices) > 0 {
|
if len(mobileDevices) > 0 {
|
||||||
payload := buildMessagePayload(message)
|
params := s.messageToParams(message)
|
||||||
pushErr := s.pushViaJPushBatch(mobileDevices, payload.Title, payload.Content, payload.Extras)
|
pushErr := s.pushViaJPushBatch(mobileDevices, params)
|
||||||
if pushErr == nil {
|
if pushErr == nil {
|
||||||
s.batchCreatePushedRecords(ctx, userID, message.ID, mobileDevices)
|
s.batchCreatePushedRecords(ctx, userID, message.ID, mobileDevices)
|
||||||
return nil
|
return nil
|
||||||
@@ -296,7 +306,8 @@ func buildMessagePayload(message *model.Message) messagePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pushViaJPush 通过极光推送发送通知
|
// 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() {
|
if !s.isJPushAvailable() {
|
||||||
return errors.New("jpush client not configured")
|
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")
|
return errors.New("device has no registration ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
notification := jpush.BuildNotification(title, content, "message", extras)
|
payload := s.buildPayload(params)
|
||||||
_, err := s.jpushClient.PushByRegistrationIDs([]string{device.PushToken}, notification)
|
_, err := s.jpushClient.PushByRegistrationIDs([]string{device.PushToken}, payload.Notification, payload.TPC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("jpush push failed: %w", err)
|
return fmt.Errorf("jpush push failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -343,7 +354,9 @@ func (s *pushServiceImpl) batchCreatePushedRecords(ctx context.Context, userID s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pushViaJPushBatch 批量通过极光推送发送通知
|
// 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() {
|
if !s.isJPushAvailable() {
|
||||||
return errors.New("jpush client not configured")
|
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")
|
return errors.New("no valid registration IDs")
|
||||||
}
|
}
|
||||||
|
|
||||||
notification := jpush.BuildNotification(title, content, "message", extras)
|
payload := s.buildPayload(params)
|
||||||
_, err := s.jpushClient.PushByRegistrationIDs(regIDs, notification)
|
_, err := s.jpushClient.PushByRegistrationIDs(regIDs, payload.Notification, payload.TPC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("jpush batch push failed: %w", err)
|
return fmt.Errorf("jpush batch push failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -520,7 +533,7 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := buildMessagePayload(task.message)
|
params := s.messageToParams(task.message)
|
||||||
|
|
||||||
// 尝试使用极光推送(统一推送到所有移动设备)
|
// 尝试使用极光推送(统一推送到所有移动设备)
|
||||||
mobileDevices := getMobileDevices(devices)
|
mobileDevices := getMobileDevices(devices)
|
||||||
@@ -528,7 +541,7 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
|||||||
if len(mobileDevices) > 0 {
|
if len(mobileDevices) > 0 {
|
||||||
// 优先使用极光推送
|
// 优先使用极光推送
|
||||||
if s.isJPushAvailable() {
|
if s.isJPushAvailable() {
|
||||||
err := s.pushViaJPushBatch(mobileDevices, payload.Title, payload.Content, payload.Extras)
|
err := s.pushViaJPushBatch(mobileDevices, params)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.batchCreatePushedRecords(ctx, task.userID, task.message.ID, mobileDevices)
|
s.batchCreatePushedRecords(ctx, task.userID, task.message.ID, mobileDevices)
|
||||||
// 原始 pending 记录标记为已推送(被批量记录替代)
|
// 原始 pending 记录标记为已推送(被批量记录替代)
|
||||||
@@ -549,7 +562,7 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
|||||||
|
|
||||||
var pushErr error
|
var pushErr error
|
||||||
if s.isJPushAvailable() {
|
if s.isJPushAvailable() {
|
||||||
pushErr = s.pushViaJPush(ctx, device, payload.Title, payload.Content, payload.Extras)
|
pushErr = s.pushViaJPush(ctx, device, params)
|
||||||
} else {
|
} else {
|
||||||
pushErr = errors.New("jpush not configured")
|
pushErr = errors.New("jpush not configured")
|
||||||
}
|
}
|
||||||
@@ -583,6 +596,46 @@ func (s *pushServiceImpl) isJPushAvailable() bool {
|
|||||||
return s.jpushClient != nil
|
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 从设备列表中筛选出支持手机推送的活跃设备
|
// getMobileDevices 从设备列表中筛选出支持手机推送的活跃设备
|
||||||
func getMobileDevices(devices []*model.DeviceToken) []*model.DeviceToken {
|
func getMobileDevices(devices []*model.DeviceToken) []*model.DeviceToken {
|
||||||
mobileDevices := make([]*model.DeviceToken, 0, len(devices))
|
mobileDevices := make([]*model.DeviceToken, 0, len(devices))
|
||||||
@@ -668,8 +721,8 @@ func (s *pushServiceImpl) doRetry() int {
|
|||||||
|
|
||||||
var pushErr error
|
var pushErr error
|
||||||
if s.isJPushAvailable() && device.SupportsMobilePush() && device.PushToken != "" {
|
if s.isJPushAvailable() && device.SupportsMobilePush() && device.PushToken != "" {
|
||||||
payload := buildMessagePayload(message)
|
params := s.messageToParams(message)
|
||||||
pushErr = s.pushViaJPush(ctx, device, payload.Title, payload.Content, payload.Extras)
|
pushErr = s.pushViaJPush(ctx, device, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pushErr != nil {
|
if pushErr != nil {
|
||||||
@@ -760,14 +813,25 @@ func (s *pushServiceImpl) PushChatMessage(ctx context.Context, userID string, co
|
|||||||
extras["target_type"] = message.ExtraData.TargetType
|
extras["target_type"] = message.ExtraData.TargetType
|
||||||
}
|
}
|
||||||
|
|
||||||
notification := jpush.BuildNotification(title, content, "chat_message", extras)
|
|
||||||
// 群聊用群头像,私聊用发送者头像
|
// 群聊用群头像,私聊用发送者头像
|
||||||
|
largeIcon := sender.Avatar
|
||||||
if convType == model.ConversationTypeGroup && groupAvatar != "" {
|
if convType == model.ConversationTypeGroup && groupAvatar != "" {
|
||||||
notification.Android.LargeIcon = groupAvatar
|
largeIcon = groupAvatar
|
||||||
} else if sender.Avatar != "" {
|
|
||||||
notification.Android.LargeIcon = sender.Avatar
|
|
||||||
}
|
}
|
||||||
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.L().Error("jpush push chat message failed",
|
||||||
zap.String("userID", userID),
|
zap.String("userID", userID),
|
||||||
zap.String("conversationID", conversationID),
|
zap.String("conversationID", conversationID),
|
||||||
@@ -847,10 +911,19 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
|
|||||||
extras["target_type"] = notification.ExtraData.TargetType
|
extras["target_type"] = notification.ExtraData.TargetType
|
||||||
}
|
}
|
||||||
notifType := string(notification.Type)
|
notifType := string(notification.Type)
|
||||||
jpushNotif := jpush.BuildNotification(title, content, notifType, extras)
|
largeIcon := ""
|
||||||
if notification.ExtraData != nil && notification.ExtraData.AvatarURL != "" {
|
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(
|
_, pushErr := s.jpushClient.PushByRegistrationIDs(
|
||||||
func() []string {
|
func() []string {
|
||||||
ids := make([]string, 0, len(mobileDevices))
|
ids := make([]string, 0, len(mobileDevices))
|
||||||
@@ -859,7 +932,8 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
|
|||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}(),
|
}(),
|
||||||
jpushNotif,
|
payload.Notification,
|
||||||
|
payload.TPC,
|
||||||
)
|
)
|
||||||
if pushErr == nil {
|
if pushErr == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
"with_you/internal/pkg/s3"
|
"with_you/internal/pkg/s3"
|
||||||
|
"with_you/internal/repository"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -26,22 +27,34 @@ import (
|
|||||||
_ "golang.org/x/image/tiff"
|
_ "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 上传服务接口
|
// UploadService 上传服务接口
|
||||||
type UploadService interface {
|
type UploadService interface {
|
||||||
UploadImage(ctx context.Context, file *multipart.FileHeader) (string, string, string, error)
|
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)
|
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)
|
UploadAvatar(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
|
||||||
UploadCover(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)
|
GetURL(ctx context.Context, objectName string) (string, error)
|
||||||
Delete(ctx context.Context, objectName string) error
|
Delete(ctx context.Context, objectName string) error
|
||||||
GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error)
|
GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error)
|
||||||
ValidateChatMessageImageSegments(segments model.MessageSegments) error
|
ValidateChatMessageImageSegments(segments model.MessageSegments) error
|
||||||
|
// GetExpiredURLSet 返回所有已从 S3 清理的文件 URL 集合,供 DTO 层标记失效
|
||||||
|
GetExpiredURLSet(ctx context.Context) (map[string]struct{}, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadService 上传服务实现
|
// uploadService 上传服务实现
|
||||||
type uploadService struct {
|
type uploadService struct {
|
||||||
s3Client *s3.Client
|
s3Client *s3.Client
|
||||||
userService UserService
|
userService UserService
|
||||||
|
uploadedRepo repository.UploadedFileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预览图配置
|
// 预览图配置
|
||||||
@@ -52,10 +65,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewUploadService 创建上传服务
|
// NewUploadService 创建上传服务
|
||||||
func NewUploadService(s3Client *s3.Client, userService UserService) UploadService {
|
func NewUploadService(s3Client *s3.Client, userService UserService, uploadedRepo repository.UploadedFileRepository) UploadService {
|
||||||
return &uploadService{
|
return &uploadService{
|
||||||
s3Client: s3Client,
|
s3Client: s3Client,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
|
uploadedRepo: uploadedRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +230,210 @@ func (s *uploadService) UploadCover(ctx context.Context, userID string, file *mu
|
|||||||
return url, nil
|
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
|
// GetURL 获取文件URL
|
||||||
func (s *uploadService) GetURL(ctx context.Context, objectName string) (string, error) {
|
func (s *uploadService) GetURL(ctx context.Context, objectName string) (string, error) {
|
||||||
return s.s3Client.GetURL(ctx, objectName)
|
return s.s3Client.GetURL(ctx, objectName)
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ func ProvideMessageHandler(
|
|||||||
messageService service.MessageService,
|
messageService service.MessageService,
|
||||||
userService service.UserService,
|
userService service.UserService,
|
||||||
groupService service.GroupService,
|
groupService service.GroupService,
|
||||||
|
uploadService service.UploadService,
|
||||||
publisher ws.MessagePublisher,
|
publisher ws.MessagePublisher,
|
||||||
) *handler.MessageHandler {
|
) *handler.MessageHandler {
|
||||||
return handler.NewMessageHandler(chatService, messageService, userService, groupService, publisher)
|
return handler.NewMessageHandler(chatService, messageService, userService, groupService, uploadService, publisher)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideSystemMessageHandler 提供系统消息处理器
|
// ProvideSystemMessageHandler 提供系统消息处理器
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ var RepositorySet = wire.NewSet(
|
|||||||
|
|
||||||
// 版本日志同步
|
// 版本日志同步
|
||||||
repository.NewConversationVersionLogRepository,
|
repository.NewConversationVersionLogRepository,
|
||||||
|
|
||||||
|
// 文件上传记录(聊天文件 TTL 过期清理)
|
||||||
|
repository.NewUploadedFileRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ var ServiceSet = wire.NewSet(
|
|||||||
ProvideAdminDashboardService,
|
ProvideAdminDashboardService,
|
||||||
ProvideQRCodeLoginService,
|
ProvideQRCodeLoginService,
|
||||||
ProvideHotRankWorker,
|
ProvideHotRankWorker,
|
||||||
|
ProvideFileCleanupWorker,
|
||||||
ProvideMaterialService,
|
ProvideMaterialService,
|
||||||
ProvideCallService,
|
ProvideCallService,
|
||||||
ProvideLiveKitService,
|
ProvideLiveKitService,
|
||||||
@@ -112,8 +113,9 @@ func ProvidePushService(
|
|||||||
messageRepo repository.MessageRepository,
|
messageRepo repository.MessageRepository,
|
||||||
publisher ws.MessagePublisher,
|
publisher ws.MessagePublisher,
|
||||||
jpushClient *jpush.Client,
|
jpushClient *jpush.Client,
|
||||||
|
cfg *config.Config,
|
||||||
) service.PushService {
|
) service.PushService {
|
||||||
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, publisher, jpushClient)
|
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, publisher, jpushClient, cfg.JPush.Channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideSystemMessageService 提供系统消息服务
|
// ProvideSystemMessageService 提供系统消息服务
|
||||||
@@ -291,8 +293,23 @@ func ProvideGroupService(
|
|||||||
func ProvideUploadService(
|
func ProvideUploadService(
|
||||||
s3Client *s3.Client,
|
s3Client *s3.Client,
|
||||||
userService service.UserService,
|
userService service.UserService,
|
||||||
|
uploadedRepo repository.UploadedFileRepository,
|
||||||
) service.UploadService {
|
) 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 提供用户活跃统计服务
|
// ProvideUserActivityService 提供用户活跃统计服务
|
||||||
|
|||||||
Reference in New Issue
Block a user