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

Add FileCleanupWorker for automatic expiration of chat files with configurable retention period and batch processing. Files uploaded via `/api/v1/uploads/files` are tracked in `uploaded_files` table with expiration timestamps, then deleted from S3 and database upon expiry. Expired file URLs are injected as `"expired": true` in message responses.

Extend JPush push notification configuration with vendor-specific channel_id support (xiaomi, huawei, oppo, vivo, meizu, honor, fcm) for differentiating system vs chat notifications. Add iOS APNs thread-id grouping configuration for notification categorization.
This commit is contained in:
lafay
2026-06-17 20:41:55 +08:00
parent d9aa4b46c3
commit a0e210feab
24 changed files with 1573 additions and 127 deletions

156
internal/pkg/jpush/vendor/converters.go vendored Normal file
View 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
View File

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

View 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)
}

View 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),
}
}