Change the `Alert` field in `IOSNotif` from a string to a map containing `title` and `body` to support structured notification content.
602 lines
16 KiB
Go
602 lines
16 KiB
Go
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"`
|
|
}
|
|
|
|
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.Debug("jpush push request", zap.Int("body_size", len(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) (*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": map[string]any{
|
|
"time_to_live": 86400,
|
|
"apns_production": c.production,
|
|
},
|
|
}
|
|
|
|
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) (*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": map[string]any{
|
|
"time_to_live": 86400,
|
|
"apns_production": c.production,
|
|
},
|
|
}
|
|
|
|
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) (*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": map[string]any{
|
|
"time_to_live": 86400,
|
|
"apns_production": c.production,
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func BuildNotification(title, body string, notificationType string, extras map[string]any) *Notification {
|
|
androidExtras := map[string]any{
|
|
"notification_type": notificationType,
|
|
}
|
|
for k, v := range extras {
|
|
androidExtras[k] = v
|
|
}
|
|
|
|
iosExtras := map[string]any{
|
|
"notification_type": notificationType,
|
|
}
|
|
for k, v := range extras {
|
|
iosExtras[k] = v
|
|
}
|
|
|
|
return &Notification{
|
|
Alert: body,
|
|
Android: &AndroidNotif{
|
|
Alert: body,
|
|
Title: title,
|
|
Extras: androidExtras,
|
|
Priority: 1,
|
|
},
|
|
IOS: &IOSNotif{
|
|
Alert: map[string]any{
|
|
"title": title,
|
|
"body": body,
|
|
},
|
|
Sound: "default",
|
|
Badge: "+1",
|
|
Extras: iosExtras,
|
|
},
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|