feat(jpush): add vendor-specific message classification support for push notifications
All checks were successful
Build Backend / build (push) Successful in 3m4s
Build Backend / build-docker (push) Successful in 1m15s

- Add OPPO category/notify_level support (2024.11.20 regulation: category required when notify_level present)
- Add Honor importance field (NORMAL=service/LOW=marketing) for notification classification
- Add vivo category field (IM/ACCOUNT) for message scenario identification
- Set Xiaomi channel/template defaults in code (system=153609, chat=153608, templates P10761/M10289)
- Add classification option to JPush push payloads (0=operation, 1=system)
- Update xiaomi template keywords mapping to match actual template placeholders
- Add keyword search support for GetFollowers and GetFollowing endpoints
- Bind new config fields to environment variables for OPPO/Honor/vivo
This commit is contained in:
lafay
2026-06-18 19:41:32 +08:00
parent 5b83f98fb8
commit 821e066446
13 changed files with 637 additions and 66 deletions

View File

@@ -292,7 +292,7 @@ func (c *Client) Push(payload map[string]any) (*PushResponse, *RateLimitInfo, er
return result, rateLimit, parseErr
}
func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *Notification, tpc *ThirdPartyChannel) (*PushResponse, error) {
func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *Notification, classification int, tpc *ThirdPartyChannel) (*PushResponse, error) {
if len(registrationIDs) == 0 {
return nil, fmt.Errorf("registration IDs cannot be empty")
}
@@ -310,7 +310,7 @@ func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *N
"platform": "all",
"audience": map[string]any{"registration_id": registrationIDs},
"notification": notification,
"options": c.buildOptions(tpc),
"options": c.buildOptions(classification, tpc),
}
var result *PushResponse
@@ -337,7 +337,7 @@ func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *N
return result, nil
}
func (c *Client) PushByAliases(aliases []string, notification *Notification, tpc *ThirdPartyChannel) (*PushResponse, error) {
func (c *Client) PushByAliases(aliases []string, notification *Notification, classification int, tpc *ThirdPartyChannel) (*PushResponse, error) {
if len(aliases) == 0 {
return nil, fmt.Errorf("aliases cannot be empty")
}
@@ -355,7 +355,7 @@ func (c *Client) PushByAliases(aliases []string, notification *Notification, tpc
"platform": "all",
"audience": map[string]any{"alias": aliases},
"notification": notification,
"options": c.buildOptions(tpc),
"options": c.buildOptions(classification, tpc),
}
result, _, err := c.Push(payload)
@@ -374,7 +374,7 @@ func (c *Client) PushByAliases(aliases []string, notification *Notification, tpc
return result, nil
}
func (c *Client) PushAll(notification *Notification, tpc *ThirdPartyChannel) (*PushResponse, error) {
func (c *Client) PushAll(notification *Notification, classification int, tpc *ThirdPartyChannel) (*PushResponse, error) {
c.logger.Info("jpush push all",
zap.String("title", notification.Android.Title),
zap.String("alert", notification.Alert),
@@ -384,7 +384,7 @@ func (c *Client) PushAll(notification *Notification, tpc *ThirdPartyChannel) (*P
"platform": "all",
"audience": "all",
"notification": notification,
"options": c.buildOptions(tpc),
"options": c.buildOptions(classification, tpc),
}
result, _, err := c.Push(payload)
@@ -402,9 +402,11 @@ func (c *Client) PushAll(notification *Notification, tpc *ThirdPartyChannel) (*P
}
// buildOptions 构造 push payload 的 options 部分
// 当 tpc 非空时加入 third_party_channel 字段,用于向各厂商下发 channel_id
func (c *Client) buildOptions(tpc *ThirdPartyChannel) map[string]any {
// classification 为消息分类0=运营消息1=系统消息)
// tpc 非空时加入 third_party_channel 字段,用于向各厂商下发 channel_id
func (c *Client) buildOptions(classification int, tpc *ThirdPartyChannel) map[string]any {
opts := map[string]any{
"classification": classification,
"time_to_live": 86400,
"apns_production": c.production,
}

View File

@@ -39,12 +39,32 @@ func (huaweiConverter) Convert(params MessageParams, cfg config.ChannelConfig) m
return channelOnlyMap(params.Scene, cfg.Vendor.Huawei.System, cfg.Vendor.Huawei.Chat, cfg.System, cfg.Chat)
}
// vivoConverter VIVO 厂商转换器(仅 channel_id
// vivoConverter VIVO 厂商转换器
// channel_id + category厂商消息场景标识
// classification=1 时 category 必须为系统消息类,不携带 category 会默认按运营消息下发受频控限制
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)
v := cfg.Vendor.VIVO
cid := resolveChannelID(params.Scene, v.System, v.Chat, cfg.System, cfg.Chat)
category := v.VivoChatCategory
if params.Scene == SceneSystem {
category = v.VivoSystemCategory
}
if cid == "" && category == "" {
return nil
}
m := map[string]any{}
if cid != "" {
m["channel_id"] = cid
}
if category != "" {
m["category"] = category
}
return m
}
// meizuConverter 魅族厂商转换器(仅 channel_id
@@ -55,12 +75,33 @@ func (meizuConverter) Convert(params MessageParams, cfg config.ChannelConfig) ma
return channelOnlyMap(params.Scene, cfg.Vendor.Meizu.System, cfg.Vendor.Meizu.Chat, cfg.System, cfg.Chat)
}
// honorConverter 荣耀厂商转换器(仅 channel_id
// honorConverter 荣耀厂商转换器
// channel_id + importance通知栏消息智能分类
// importance 取值:"NORMAL"=服务与通讯,"LOW"=资讯营销
// 注classification 优先级更高,会覆盖 importance 设置的值
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)
h := cfg.Vendor.Honor
cid := resolveChannelID(params.Scene, h.System, h.Chat, cfg.System, cfg.Chat)
importance := h.HonorChatImportance
if params.Scene == SceneSystem {
importance = h.HonorSystemImportance
}
if cid == "" && importance == "" {
return nil
}
m := map[string]any{}
if cid != "" {
m["channel_id"] = cid
}
if importance != "" {
m["importance"] = importance
}
return m
}
// fcmConverter FCM 厂商转换器(仅 channel_id
@@ -104,12 +145,22 @@ func (xiaomiConverter) Convert(params MessageParams, cfg config.ChannelConfig) m
}
// buildMiTemplateParam 生成小米消息模板参数 JSON 字符串
// 默认占位符sender / title / content可按实际小米模板占位符在集中处调整
// 占位符严格匹配小米模板定义的 keywords
// - 私聊模板M10289 好友聊天): keywords1=title, keywords2=发送者, keywords3=内容
// - 系统模板P10761 系统消息): keywords1=标题, keywords2=内容
func buildMiTemplateParam(params MessageParams) string {
param := map[string]any{
"sender": params.SenderName,
"title": params.Title,
"content": params.Body,
var param map[string]any
if params.Scene == SceneSystem {
param = map[string]any{
"keywords1": params.Title,
"keywords2": params.Body,
}
} else {
param = map[string]any{
"keywords1": params.Title,
"keywords2": params.SenderName,
"keywords3": params.Body,
}
}
b, err := json.Marshal(param)
if err != nil {
@@ -129,11 +180,15 @@ func (oppoConverter) Convert(params MessageParams, cfg config.ChannelConfig) map
cid := resolveChannelID(params.Scene, o.System, o.Chat, cfg.System, cfg.Chat)
// OPPO 私信模板按场景选择
templateID := o.OppoChatPrivateTemplateID
category := o.OppoChatCategory
notifyLevel := o.OppoChatNotifyLevel
if params.Scene == SceneSystem {
templateID = o.OppoSystemPrivateTemplateID
category = o.OppoSystemCategory
notifyLevel = o.OppoSystemNotifyLevel
}
if cid == "" && templateID == "" {
if cid == "" && templateID == "" && category == "" && notifyLevel == 0 {
return nil
}
@@ -152,5 +207,14 @@ func (oppoConverter) Convert(params MessageParams, cfg config.ChannelConfig) map
"sender": params.SenderName,
}
}
// OPPO 2024.11.20 新规category 消息场景标识notify_level 提醒等级
// 文档约束:使用 notify_level 时 category 必传,否则 OPPO 校验失败
// 这里做保护notify_level 非零但 category 为空时跳过 notify_level
if category != "" {
m["category"] = category
if notifyLevel != 0 {
m["notify_level"] = notifyLevel
}
}
return m
}

View File

@@ -47,12 +47,24 @@ type MessageParams struct {
Extra map[string]any
}
// 消息分类常量(对应 JPush options.classification
const (
// ClassificationOperation 运营消息0营销/公告类,受厂商限速
ClassificationOperation = 0
// ClassificationSystem 系统消息1即时聊天/系统通知/互动通知,高优先级及时送达
// 注:聊天即时消息也归为系统消息,避免被厂商运营消息限速影响送达
ClassificationSystem = 1
)
// PushPayload 工厂统一产物client 据此组装最终推送 payload
type PushPayload struct {
// Notification 含 android + ios由 TextRenderer 产出
Notification *jpush.Notification
// TPC 各厂商私有字段options.third_party_channel由 PrivateConverter 产出
TPC *jpush.ThirdPartyChannel
// Classification 消息分类options.classification0=运营消息1=系统消息
// 聊天和系统通知统一用 1避免厂商运营消息限速
Classification int
}
// TextRenderer 通用文本渲染器(基础层)
@@ -124,7 +136,16 @@ func (f *Factory) Build(params MessageParams, cfg config.ChannelConfig) *PushPay
}
return &PushPayload{
Notification: notification,
TPC: tpc.Normalize(),
Notification: notification,
TPC: tpc.Normalize(),
Classification: resolveClassification(params.Scene),
}
}
// resolveClassification 根据场景映射 JPush options.classification
// 聊天即时消息与系统通知统一归为系统消息1避免厂商运营消息限速影响送达
// 未来若引入营销/公告推送可扩展为运营消息0
func resolveClassification(scene string) int {
// system 与 chat 场景均使用系统消息分类
return ClassificationSystem
}

View File

@@ -76,6 +76,26 @@ func TestPayloadEmptyConfigMatchesLegacy(t *testing.T) {
}
}
t.Logf("notification JSON: %s", jsonStr)
// 5. classification 必须为系统消息1聊天即时消息统一归系统消息避免限速
if payload.Classification != ClassificationSystem {
t.Errorf("Classification = %d, want %d (system)", payload.Classification, ClassificationSystem)
}
}
// TestClassificationByScene 验证system 与 chat 场景的 classification 均为 1系统消息
// 聊天即时消息归为系统消息,避免厂商运营消息限速影响送达
func TestClassificationByScene(t *testing.T) {
factory := NewFactory()
cfg := config.ChannelConfig{}
for _, scene := range []string{SceneSystem, SceneChat} {
params := MessageParams{Scene: scene, Title: "T", Body: "B"}
payload := factory.Build(params, cfg)
if payload.Classification != ClassificationSystem {
t.Errorf("scene=%s: Classification = %d, want %d", scene, payload.Classification, ClassificationSystem)
}
}
}
// TestPayloadWithChannelConfig 验证:配置 channel_id 后正确下发,且不影响通用通知字段
@@ -129,6 +149,177 @@ func TestPayloadXiaomiTemplateOptional(t *testing.T) {
}
}
// TestOppoCategoryNotifyLevel 验证OPPO category/notify_level 按场景区分,且 notify_level 必须伴 category
func TestOppoCategoryNotifyLevel(t *testing.T) {
factory := NewFactory()
// 场景1chat 用 IM + 16强提醒
cfgChat := config.ChannelConfig{
Vendor: config.VendorChannel{
OPPO: config.VendorChannelID{
Chat: "oppo_chat",
OppoChatCategory: "IM",
OppoChatNotifyLevel: 16,
},
},
}
payload := factory.Build(MessageParams{Scene: SceneChat, Title: "T", Body: "B"}, cfgChat)
if payload.TPC == nil || payload.TPC.OPPO == nil {
t.Fatal("OPPO TPC should exist for chat scene")
}
if payload.TPC.OPPO["category"] != "IM" {
t.Errorf("OPPO chat category = %v, want IM", payload.TPC.OPPO["category"])
}
if payload.TPC.OPPO["notify_level"] != 16 {
t.Errorf("OPPO chat notify_level = %v, want 16", payload.TPC.OPPO["notify_level"])
}
// 场景2system 用 ACCOUNT + 2通知栏+锁屏)
cfgSys := config.ChannelConfig{
Vendor: config.VendorChannel{
OPPO: config.VendorChannelID{
System: "oppo_sys",
OppoSystemCategory: "ACCOUNT",
OppoSystemNotifyLevel: 2,
},
},
}
payload = factory.Build(MessageParams{Scene: SceneSystem, Title: "T", Body: "B"}, cfgSys)
if payload.TPC.OPPO["category"] != "ACCOUNT" {
t.Errorf("OPPO system category = %v, want ACCOUNT", payload.TPC.OPPO["category"])
}
if payload.TPC.OPPO["notify_level"] != 2 {
t.Errorf("OPPO system notify_level = %v, want 2", payload.TPC.OPPO["notify_level"])
}
}
// TestOppoNotifyLevelRequiresCategory 验证notify_level 配置但 category 未配置时,跳过 notify_level避免 OPPO 校验失败)
func TestOppoNotifyLevelRequiresCategory(t *testing.T) {
factory := NewFactory()
cfg := config.ChannelConfig{
Vendor: config.VendorChannel{
OPPO: config.VendorChannelID{
Chat: "oppo_chat",
OppoChatNotifyLevel: 16, // 只配 notify_level不配 category
},
},
}
payload := factory.Build(MessageParams{Scene: SceneChat, Title: "T", Body: "B"}, cfg)
if payload.TPC == nil || payload.TPC.OPPO == nil {
t.Fatal("OPPO TPC should exist (has channel_id)")
}
if _, ok := payload.TPC.OPPO["notify_level"]; ok {
t.Error("OPPO should NOT contain notify_level when category not configured")
}
if _, ok := payload.TPC.OPPO["category"]; ok {
t.Error("OPPO should NOT contain category when not configured")
}
// channel_id 仍正常下发
if payload.TPC.OPPO["channel_id"] != "oppo_chat" {
t.Errorf("OPPO channel_id = %v, want oppo_chat", payload.TPC.OPPO["channel_id"])
}
}
// TestHonorImportanceByScene 验证:荣耀 importance 按场景区分chat=NORMAL, system=LOW
func TestHonorImportanceByScene(t *testing.T) {
factory := NewFactory()
cfg := config.ChannelConfig{
Vendor: config.VendorChannel{
Honor: config.VendorChannelID{
Chat: "honor_chat",
System: "honor_sys",
HonorChatImportance: "NORMAL",
HonorSystemImportance: "LOW",
},
},
}
// chat 场景 -> NORMAL
payload := factory.Build(MessageParams{Scene: SceneChat, Title: "T", Body: "B"}, cfg)
if payload.TPC == nil || payload.TPC.Honor == nil {
t.Fatal("Honor TPC should exist for chat scene")
}
if payload.TPC.Honor["importance"] != "NORMAL" {
t.Errorf("Honor chat importance = %v, want NORMAL", payload.TPC.Honor["importance"])
}
// system 场景 -> LOW
payload = factory.Build(MessageParams{Scene: SceneSystem, Title: "T", Body: "B"}, cfg)
if payload.TPC.Honor["importance"] != "LOW" {
t.Errorf("Honor system importance = %v, want LOW", payload.TPC.Honor["importance"])
}
}
// TestHonorImportanceOptional 验证:未配置 importance 时不下发,仅 channel_id
func TestHonorImportanceOptional(t *testing.T) {
factory := NewFactory()
cfg := config.ChannelConfig{
Vendor: config.VendorChannel{
Honor: config.VendorChannelID{Chat: "honor_chat"},
},
}
payload := factory.Build(MessageParams{Scene: SceneChat, Title: "T", Body: "B"}, cfg)
if payload.TPC == nil || payload.TPC.Honor == nil {
t.Fatal("Honor TPC should exist (has channel_id)")
}
if _, ok := payload.TPC.Honor["importance"]; ok {
t.Error("Honor should NOT contain importance when not configured")
}
if payload.TPC.Honor["channel_id"] != "honor_chat" {
t.Errorf("Honor channel_id = %v, want honor_chat", payload.TPC.Honor["channel_id"])
}
}
// TestVivoCategoryByScene 验证vivo category 按场景区分
func TestVivoCategoryByScene(t *testing.T) {
factory := NewFactory()
cfg := config.ChannelConfig{
Vendor: config.VendorChannel{
VIVO: config.VendorChannelID{
Chat: "vivo_chat",
System: "vivo_sys",
VivoChatCategory: "IM",
VivoSystemCategory: "ACCOUNT",
},
},
}
// chat 场景 -> IM
payload := factory.Build(MessageParams{Scene: SceneChat, Title: "T", Body: "B"}, cfg)
if payload.TPC == nil || payload.TPC.VIVO == nil {
t.Fatal("VIVO TPC should exist for chat scene")
}
if payload.TPC.VIVO["category"] != "IM" {
t.Errorf("VIVO chat category = %v, want IM", payload.TPC.VIVO["category"])
}
// system 场景 -> ACCOUNT
payload = factory.Build(MessageParams{Scene: SceneSystem, Title: "T", Body: "B"}, cfg)
if payload.TPC.VIVO["category"] != "ACCOUNT" {
t.Errorf("VIVO system category = %v, want ACCOUNT", payload.TPC.VIVO["category"])
}
}
// TestVivoCategoryOptional 验证:未配置 category 时不下发,仅 channel_id
func TestVivoCategoryOptional(t *testing.T) {
factory := NewFactory()
cfg := config.ChannelConfig{
Vendor: config.VendorChannel{
VIVO: config.VendorChannelID{Chat: "vivo_chat"},
},
}
payload := factory.Build(MessageParams{Scene: SceneChat, Title: "T", Body: "B"}, cfg)
if payload.TPC == nil || payload.TPC.VIVO == nil {
t.Fatal("VIVO TPC should exist (has channel_id)")
}
if _, ok := payload.TPC.VIVO["category"]; ok {
t.Error("VIVO should NOT contain category when not configured")
}
if payload.TPC.VIVO["channel_id"] != "vivo_chat" {
t.Errorf("VIVO channel_id = %v, want vivo_chat", payload.TPC.VIVO["channel_id"])
}
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)

View File

@@ -0,0 +1,131 @@
package vendor
import (
"encoding/json"
"strings"
"testing"
"with_you/internal/config"
)
// TestXiaomiConversion 验证:小米转换器对 channel_id/模板 id/keywords 映射的转换逻辑正确。
// 注默认值153608/153609 + M10289/P10761由 config.go 的 viper.SetDefault 提供,
// 其生效性由 internal/config.TestXiaomiDefaultValuesFromViper 覆盖。
// 本测试显式传入值,专注验证 vendor 转换逻辑(场景区分 + keywords 映射)。
func TestXiaomiConversion(t *testing.T) {
factory := NewFactory()
// 使用与代码默认值一致的配置(也可被环境变量覆盖为其他值)
cfg := config.ChannelConfig{
Vendor: config.VendorChannel{
Xiaomi: config.VendorChannelID{
System: "153609",
Chat: "153608",
MiSystemTemplateID: "P10761",
MiChatTemplateID: "M10289",
},
},
}
// === 场景1私聊消息 ===
chatParams := MessageParams{
Scene: SceneChat,
Title: "张三",
Body: "你好,在吗?",
SenderName: "张三",
}
chatPayload := factory.Build(chatParams, cfg)
if chatPayload.TPC == nil || chatPayload.TPC.Xiaomi == nil {
t.Fatal("Xiaomi TPC should exist for chat scene with default config")
}
xiaomi := chatPayload.TPC.Xiaomi
if xiaomi["channel_id"] != "153608" {
t.Errorf("chat channel_id = %v, want 153608 (default)", xiaomi["channel_id"])
}
if xiaomi["mi_template_id"] != "M10289" {
t.Errorf("chat mi_template_id = %v, want M10289 (default)", xiaomi["mi_template_id"])
}
// 验证 mi_template_param 的 keywords 映射M10289: keywords1=title, keywords2=发送者, keywords3=内容)
paramStr, ok := xiaomi["mi_template_param"].(string)
if !ok {
t.Fatalf("mi_template_param should be string, got %T", xiaomi["mi_template_param"])
}
var param map[string]any
if err := json.Unmarshal([]byte(paramStr), &param); err != nil {
t.Fatalf("mi_template_param not valid JSON: %v", err)
}
if param["keywords1"] != "张三" {
t.Errorf("keywords1 = %v, want 张三", param["keywords1"])
}
if param["keywords2"] != "张三" {
t.Errorf("keywords2 = %v, want 张三", param["keywords2"])
}
if param["keywords3"] != "你好,在吗?" {
t.Errorf("keywords3 = %v, want 你好,在吗?", param["keywords3"])
}
t.Logf("chat 小米 payload: %s", mustJSON(xiaomi))
// === 场景2系统消息 ===
sysParams := MessageParams{
Scene: SceneSystem,
Title: "点赞通知",
Body: "李四 赞了你的帖子",
}
sysPayload := factory.Build(sysParams, cfg)
xiaomiSys := sysPayload.TPC.Xiaomi
if xiaomiSys["channel_id"] != "153609" {
t.Errorf("system channel_id = %v, want 153609 (default)", xiaomiSys["channel_id"])
}
if xiaomiSys["mi_template_id"] != "P10761" {
t.Errorf("system mi_template_id = %v, want P10761 (default)", xiaomiSys["mi_template_id"])
}
// 验证系统模板参数P10761: keywords1=标题, keywords2=内容)
sysParamStr := xiaomiSys["mi_template_param"].(string)
var sysParam map[string]any
json.Unmarshal([]byte(sysParamStr), &sysParam)
if sysParam["keywords1"] != "点赞通知" {
t.Errorf("system keywords1 = %v, want 点赞通知", sysParam["keywords1"])
}
if sysParam["keywords2"] != "李四 赞了你的帖子" {
t.Errorf("system keywords2 = %v, want 李四 赞了你的帖子", sysParam["keywords2"])
}
if _, ok := sysParam["keywords3"]; ok {
t.Error("system template should NOT have keywords3")
}
t.Logf("system 小米 payload: %s", mustJSON(xiaomiSys))
// === 验证完整 third_party_channel 结构 ===
fullJSON := mustJSON(chatPayload.TPC)
if !strings.Contains(fullJSON, `"channel_id":"153608"`) {
t.Errorf("full TPC JSON missing channel_id 153608: %s", fullJSON)
}
if !strings.Contains(fullJSON, `"mi_template_id":"M10289"`) {
t.Errorf("full TPC JSON missing mi_template_id M10289: %s", fullJSON)
}
t.Logf("完整 third_party_channel: %s", fullJSON)
}
// TestXiaomiEnvOverride 验证:环境变量/配置值优先于代码默认值
func TestXiaomiEnvOverride(t *testing.T) {
factory := NewFactory()
// 显式配置覆盖默认值(模拟环境变量覆盖)
cfg := config.ChannelConfig{
Vendor: config.VendorChannel{
Xiaomi: config.VendorChannelID{
System: "999999",
Chat: "888888",
MiChatTemplateID: "CUSTOM_TPL",
MiSystemTemplateID: "SYS_TPL",
},
},
}
payload := factory.Build(MessageParams{Scene: SceneChat, Title: "T", Body: "B"}, cfg)
if payload.TPC.Xiaomi["channel_id"] != "888888" {
t.Errorf("override channel_id = %v, want 888888", payload.TPC.Xiaomi["channel_id"])
}
if payload.TPC.Xiaomi["mi_template_id"] != "CUSTOM_TPL" {
t.Errorf("override mi_template_id = %v, want CUSTOM_TPL", payload.TPC.Xiaomi["mi_template_id"])
}
}