Files
backend/internal/pkg/jpush/client.go
lafay 755392e999
All checks were successful
Build Backend / build (push) Successful in 2m20s
Build Backend / build-docker (push) Successful in 1m15s
refactor(jpush): move vendor field handling into ThirdPartyChannel struct
Move the vendor switch logic into the ThirdPartyChannel struct as Set/IsEmpty/Normalize
methods to improve encapsulation and eliminate code duplication in the vendor params
builder. Also simplify messageToParams to reuse buildMessagePayload results and remove
unused notification_type extras from push notifications.
2026-06-18 00:02:00 +08:00

624 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package jpush
import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"time"
"with_you/internal/pkg/circuitbreaker"
"go.uber.org/zap"
)
const (
PushAPIBaseURL = "https://api.jpush.cn/v3"
DeviceAPIBaseURL = "https://device.jpush.cn/v3"
ReportAPIBaseURL = "https://report.jpush.cn/v3"
)
type Client struct {
appKey string
masterSecret string
production bool
httpClient *http.Client
logger *zap.Logger
lastRateLimit *RateLimitInfo
breaker *circuitbreaker.Breaker
}
func NewClient(appKey, masterSecret string, production bool, logger *zap.Logger) *Client {
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
return &Client{
appKey: appKey,
masterSecret: masterSecret,
production: production,
httpClient: &http.Client{Timeout: 30 * time.Second, Transport: transport},
logger: logger,
breaker: circuitbreaker.New(circuitbreaker.Config{
FailureThreshold: 5,
SuccessThreshold: 3,
Timeout: 30 * time.Second,
Name: "jpush",
}),
}
}
type Notification struct {
Alert string `json:"alert,omitempty"`
Android *AndroidNotif `json:"android,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"`
}
// Set 按厂商标识写入对应厂商的私有字段
// vendor 对应 third_party_channel 的 keyxiaomi/huawei/oppo/vivo/meizu/fcm/honor
// 未知厂商将被忽略
func (t *ThirdPartyChannel) Set(vendor string, m map[string]any) {
switch vendor {
case "xiaomi":
t.Xiaomi = m
case "huawei":
t.Huawei = m
case "oppo":
t.OPPO = m
case "vivo":
t.VIVO = m
case "meizu":
t.Meizu = m
case "fcm":
t.FCM = m
case "honor":
t.Honor = m
}
}
// IsEmpty 判断是否所有厂商字段均为空
func (t *ThirdPartyChannel) IsEmpty() bool {
return t.Xiaomi == nil && t.Huawei == nil && t.OPPO == nil &&
t.VIVO == nil && t.Meizu == nil && t.Honor == nil && t.FCM == nil
}
// Normalize 当所有厂商字段为空时返回 nil避免序列化出空的 third_party_channel 对象
func (t *ThirdPartyChannel) Normalize() *ThirdPartyChannel {
if t == nil || t.IsEmpty() {
return nil
}
return t
}
type AndroidNotif struct {
Alert string `json:"alert"`
Title string `json:"title,omitempty"`
BuilderID int `json:"builder_id,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
Category string `json:"category,omitempty"`
Priority int `json:"priority,omitempty"`
Style int `json:"style,omitempty"`
AlertType int `json:"alert_type,omitempty"`
BigText string `json:"big_text,omitempty"`
BigPicPath string `json:"big_pic_path,omitempty"`
LargeIcon string `json:"large_icon,omitempty"`
Sound string `json:"sound,omitempty"`
Intent *IntentConfig `json:"intent,omitempty"`
BadgeAddNum int `json:"badge_add_num,omitempty"`
BadgeSetNum int `json:"badge_set_num,omitempty"`
Extras map[string]any `json:"extras,omitempty"`
}
type IntentConfig struct {
URL string `json:"url"`
}
type IOSNotif struct {
Alert any `json:"alert,omitempty"`
Sound any `json:"sound,omitempty"`
Badge string `json:"badge,omitempty"`
MutableContent *bool `json:"mutable-content,omitempty"`
ContentAvailable *bool `json:"content-available,omitempty"`
Category string `json:"category,omitempty"`
ThreadID string `json:"thread-id,omitempty"`
Extras map[string]any `json:"extras,omitempty"`
}
type PushOptions struct {
TimeToLive int `json:"time_to_live,omitempty"`
ApnsProduction bool `json:"apns_production"`
ApnsCollapseID string `json:"apns_collapse_id,omitempty"`
}
type PushResponse struct {
MsgID string `json:"msg_id"`
SendNo string `json:"sendno"`
}
type RateLimitInfo struct {
Limit int
Remaining int
Reset int
}
type APIError struct {
ErrCode int `json:"code"`
ErrMessage string `json:"message"`
ErrDetail struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func (e *APIError) Error() string {
if e.ErrDetail.Code != 0 {
return fmt.Sprintf("jpush error %d: %s", e.ErrDetail.Code, e.ErrDetail.Message)
}
return fmt.Sprintf("jpush error %d: %s", e.ErrCode, e.ErrMessage)
}
func (e *APIError) Code() int {
if e.ErrDetail.Code != 0 {
return e.ErrDetail.Code
}
return e.ErrCode
}
func (c *Client) generateAuth() string {
auth := base64.StdEncoding.EncodeToString([]byte(c.appKey + ":" + c.masterSecret))
return "Basic " + auth
}
func (c *Client) doRequest(method, fullPath string, payload any) (*http.Response, []byte, *RateLimitInfo, error) {
var bodyBytes []byte
var err error
if payload != nil {
bodyBytes, err = json.Marshal(payload)
if err != nil {
return nil, nil, nil, fmt.Errorf("marshal payload: %w", err)
}
}
req, err := http.NewRequest(method, fullPath, bytes.NewReader(bodyBytes))
if err != nil {
return nil, nil, nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", c.generateAuth())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, nil, nil, fmt.Errorf("send request: %w", err)
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, nil, nil, fmt.Errorf("read response: %w", err)
}
rateLimit := &RateLimitInfo{}
if v := resp.Header.Get("X-Rate-Limit-Limit"); v != "" {
rateLimit.Limit, _ = strconv.Atoi(v)
}
if v := resp.Header.Get("X-Rate-Limit-Remaining"); v != "" {
rateLimit.Remaining, _ = strconv.Atoi(v)
}
if v := resp.Header.Get("X-Rate-Limit-Reset"); v != "" {
rateLimit.Reset, _ = strconv.Atoi(v)
}
return resp, respBody, rateLimit, nil
}
func (c *Client) parseResponse(resp *http.Response, body []byte) (*PushResponse, error) {
if resp.StatusCode == http.StatusOK {
var result PushResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return &result, nil
}
var apiErr APIError
if json.Unmarshal(body, &apiErr) == nil && apiErr.Code() != 0 {
c.logger.Error("jpush api error",
zap.Int("status", resp.StatusCode),
zap.Int("code", apiErr.Code()),
)
return nil, &apiErr
}
return nil, fmt.Errorf("jpush HTTP %d: %s", resp.StatusCode, string(body))
}
func (c *Client) Push(payload map[string]any) (*PushResponse, *RateLimitInfo, error) {
body, _ := json.Marshal(payload)
c.logger.Info("jpush push request", zap.String("body", string(body)))
resp, respBody, rateLimit, err := c.doRequest("POST", PushAPIBaseURL+"/push", payload)
if err != nil {
c.logger.Error("jpush push request failed",
zap.Error(err),
)
return nil, rateLimit, err
}
c.lastRateLimit = rateLimit
if rateLimit != nil && rateLimit.Limit > 0 && rateLimit.Remaining < rateLimit.Limit/10 {
c.logger.Warn("jpush rate limit approaching",
zap.Int("remaining", rateLimit.Remaining),
zap.Int("limit", rateLimit.Limit),
)
}
result, parseErr := c.parseResponse(resp, respBody)
if parseErr != nil {
c.logger.Error("jpush push response parse failed",
zap.Int("status", resp.StatusCode),
zap.Error(parseErr),
)
} else {
c.logger.Debug("jpush push response",
zap.String("msg_id", result.MsgID),
zap.String("send_no", result.SendNo),
)
}
return result, rateLimit, parseErr
}
func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *Notification, tpc *ThirdPartyChannel) (*PushResponse, error) {
if len(registrationIDs) == 0 {
return nil, fmt.Errorf("registration IDs cannot be empty")
}
if len(registrationIDs) > 1000 {
return nil, fmt.Errorf("registration IDs exceed 1000 limit")
}
c.logger.Debug("jpush push by registration_ids",
zap.Int("count", len(registrationIDs)),
zap.String("title", notification.Android.Title),
zap.String("alert", notification.Alert),
)
payload := map[string]any{
"platform": "all",
"audience": map[string]any{"registration_id": registrationIDs},
"notification": notification,
"options": c.buildOptions(tpc),
}
var result *PushResponse
err := c.breaker.Execute(func() error {
resp, _, pushErr := c.Push(payload)
if pushErr != nil {
return pushErr
}
result = resp
return nil
})
if err != nil {
c.logger.Error("jpush push by registration_ids failed",
zap.Int("count", len(registrationIDs)),
zap.Error(err),
)
return nil, err
}
c.logger.Info("jpush push by registration_ids success",
zap.Int("count", len(registrationIDs)),
zap.String("msg_id", result.MsgID),
)
return result, nil
}
func (c *Client) PushByAliases(aliases []string, notification *Notification, tpc *ThirdPartyChannel) (*PushResponse, error) {
if len(aliases) == 0 {
return nil, fmt.Errorf("aliases cannot be empty")
}
if len(aliases) > 1000 {
return nil, fmt.Errorf("aliases exceed 1000 limit")
}
c.logger.Debug("jpush push by aliases",
zap.Int("count", len(aliases)),
zap.String("title", notification.Android.Title),
zap.String("alert", notification.Alert),
)
payload := map[string]any{
"platform": "all",
"audience": map[string]any{"alias": aliases},
"notification": notification,
"options": c.buildOptions(tpc),
}
result, _, err := c.Push(payload)
if err != nil {
c.logger.Error("jpush push by aliases failed",
zap.Int("count", len(aliases)),
zap.Error(err),
)
return nil, err
}
c.logger.Info("jpush push by aliases success",
zap.Int("count", len(aliases)),
zap.String("msg_id", result.MsgID),
)
return result, nil
}
func (c *Client) PushAll(notification *Notification, tpc *ThirdPartyChannel) (*PushResponse, error) {
c.logger.Info("jpush push all",
zap.String("title", notification.Android.Title),
zap.String("alert", notification.Alert),
)
payload := map[string]any{
"platform": "all",
"audience": "all",
"notification": notification,
"options": c.buildOptions(tpc),
}
result, _, err := c.Push(payload)
if err != nil {
c.logger.Error("jpush push all failed",
zap.Error(err),
)
return nil, err
}
c.logger.Info("jpush push all success",
zap.String("msg_id", result.MsgID),
)
return result, nil
}
// buildOptions 构造 push payload 的 options 部分
// 当 tpc 非空时加入 third_party_channel 字段,用于向各厂商下发 channel_id
func (c *Client) buildOptions(tpc *ThirdPartyChannel) map[string]any {
opts := map[string]any{
"time_to_live": 86400,
"apns_production": c.production,
}
if tpc != nil {
opts["third_party_channel"] = tpc
}
return opts
}
// 通知与厂商通道的构建逻辑已迁移至 internal/pkg/jpush/vendor 子包:
// - 通用通知Android/iOS由 vendor.TextRenderer 产出
// - 各厂商私有字段channel_id / 小米 mi_template / OPPO 私信模板)由 vendor.PrivateConverter 产出
// - 组装入口为 vendor.Factory.Build产物 *vendor.PushPayload 含 Notification 与 ThirdPartyChannel
type DeviceInfo struct {
Tags []string `json:"tags"`
Alias string `json:"alias"`
Mobile string `json:"mobile"`
}
func (c *Client) GetDeviceInfo(registrationID string) (*DeviceInfo, error) {
c.logger.Debug("jpush get device info",
zap.String("registration_id", registrationID),
)
path := DeviceAPIBaseURL + "/devices/" + url.PathEscape(registrationID)
resp, body, _, err := c.doRequest("GET", path, nil)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
c.logger.Error("jpush get device info failed",
zap.String("registration_id", registrationID),
zap.Int("status", resp.StatusCode),
)
return nil, fmt.Errorf("jpush get device info failed: HTTP %d", resp.StatusCode)
}
var info DeviceInfo
if err := json.Unmarshal(body, &info); err != nil {
return nil, fmt.Errorf("unmarshal device info: %w", err)
}
c.logger.Debug("jpush get device info success",
zap.String("registration_id", registrationID),
zap.String("alias", info.Alias),
zap.Strings("tags", info.Tags),
)
return &info, nil
}
func (c *Client) SetDeviceAlias(registrationID string, alias string) error {
c.logger.Info("jpush set device alias",
zap.String("registration_id", registrationID),
zap.String("alias", alias),
)
path := DeviceAPIBaseURL + "/devices/" + url.PathEscape(registrationID)
payload := map[string]any{
"alias": alias,
}
resp, body, _, err := c.doRequest("POST", path, payload)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
c.logger.Error("jpush set device alias failed",
zap.String("registration_id", registrationID),
zap.String("alias", alias),
zap.Int("status", resp.StatusCode),
zap.String("response", string(body)),
)
return fmt.Errorf("jpush set device alias failed: HTTP %d: %s", resp.StatusCode, string(body))
}
c.logger.Debug("jpush set device alias success",
zap.String("registration_id", registrationID),
zap.String("alias", alias),
)
return nil
}
func (c *Client) SetDeviceTags(registrationID string, addTags, removeTags []string) error {
c.logger.Info("jpush set device tags",
zap.String("registration_id", registrationID),
zap.Strings("add_tags", addTags),
zap.Strings("remove_tags", removeTags),
)
path := DeviceAPIBaseURL + "/devices/" + url.PathEscape(registrationID)
tags := map[string]any{}
if len(addTags) > 0 {
tags["add"] = addTags
}
if len(removeTags) > 0 {
tags["remove"] = removeTags
}
payload := map[string]any{
"tags": tags,
}
resp, body, _, err := c.doRequest("POST", path, payload)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
c.logger.Error("jpush set device tags failed",
zap.String("registration_id", registrationID),
zap.Int("status", resp.StatusCode),
zap.String("response", string(body)),
)
return fmt.Errorf("jpush set device tags failed: HTTP %d: %s", resp.StatusCode, string(body))
}
c.logger.Debug("jpush set device tags success",
zap.String("registration_id", registrationID),
)
return nil
}
func (c *Client) GetAliasRegistrationIDs(alias string) ([]string, error) {
c.logger.Debug("jpush get alias",
zap.String("alias", alias),
)
path := DeviceAPIBaseURL + "/aliases/" + url.PathEscape(alias)
resp, body, _, err := c.doRequest("GET", path, nil)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
c.logger.Error("jpush get alias failed",
zap.String("alias", alias),
zap.Int("status", resp.StatusCode),
)
return nil, fmt.Errorf("jpush get alias failed: HTTP %d", resp.StatusCode)
}
var result struct {
RegistrationIDs []string `json:"registration_ids"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("unmarshal alias response: %w", err)
}
c.logger.Debug("jpush get alias success",
zap.String("alias", alias),
zap.Int("count", len(result.RegistrationIDs)),
)
return result.RegistrationIDs, nil
}
func (c *Client) DeleteAlias(alias string) error {
c.logger.Info("jpush delete alias",
zap.String("alias", alias),
)
path := DeviceAPIBaseURL + "/aliases/" + url.PathEscape(alias)
resp, body, _, err := c.doRequest("DELETE", path, nil)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
c.logger.Error("jpush delete alias failed",
zap.String("alias", alias),
zap.Int("status", resp.StatusCode),
zap.String("response", string(body)),
)
return fmt.Errorf("jpush delete alias failed: HTTP %d: %s", resp.StatusCode, string(body))
}
c.logger.Debug("jpush delete alias success",
zap.String("alias", alias),
)
return nil
}
func (c *Client) RemoveAliasDevices(alias string, registrationIDs []string) error {
c.logger.Info("jpush remove alias devices",
zap.String("alias", alias),
zap.Int("count", len(registrationIDs)),
)
path := DeviceAPIBaseURL + "/aliases/" + url.PathEscape(alias)
payload := map[string]any{
"registration_ids": map[string]any{
"remove": registrationIDs,
},
}
resp, body, _, err := c.doRequest("POST", path, payload)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
c.logger.Error("jpush remove alias devices failed",
zap.String("alias", alias),
zap.Int("status", resp.StatusCode),
zap.String("response", string(body)),
)
return fmt.Errorf("jpush remove alias devices failed: HTTP %d: %s", resp.StatusCode, string(body))
}
c.logger.Debug("jpush remove alias devices success",
zap.String("alias", alias),
)
return nil
}