Files
backend/internal/pkg/jpush/client.go

438 lines
11 KiB
Go
Raw Normal View History

package jpush
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"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
}
func NewClient(appKey, masterSecret string, production bool, logger *zap.Logger) *Client {
return &Client{
appKey: appKey,
masterSecret: masterSecret,
production: production,
httpClient: &http.Client{Timeout: 30 * time.Second},
logger: logger,
}
}
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 int64 `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 {
return nil, rateLimit, err
}
result, parseErr := c.parseResponse(resp, respBody)
return result, rateLimit, parseErr
}
func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *Notification, extras map[string]any) (*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")
}
mergeExtras(notification, extras)
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,
},
}
result, _, err := c.Push(payload)
return result, err
}
func (c *Client) PushByAliases(aliases []string, notification *Notification, extras map[string]any) (*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")
}
mergeExtras(notification, extras)
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)
return result, err
}
func (c *Client) PushAll(notification *Notification) (*PushResponse, error) {
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)
return result, err
}
func mergeExtras(notification *Notification, extras map[string]any) {
if extras == nil {
return
}
if notification.Android != nil {
if notification.Android.Extras == nil {
notification.Android.Extras = make(map[string]any)
}
for k, v := range extras {
notification.Android.Extras[k] = v
}
}
if notification.IOS != nil {
if notification.IOS.Extras == nil {
notification.IOS.Extras = make(map[string]any)
}
for k, v := range extras {
notification.IOS.Extras[k] = v
}
}
}
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: body,
Sound: "default",
Badge: "+1",
MutableContent: true,
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) {
path := DeviceAPIBaseURL + "/v3/devices/" + url.PathEscape(registrationID)
resp, body, _, err := c.doRequest("GET", path, nil)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
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)
}
return &info, nil
}
func (c *Client) SetDeviceAlias(registrationID string, alias string) error {
path := DeviceAPIBaseURL + "/v3/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 {
return fmt.Errorf("jpush set device alias failed: HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) SetDeviceTags(registrationID string, addTags, removeTags []string) error {
path := DeviceAPIBaseURL + "/v3/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 {
return fmt.Errorf("jpush set device tags failed: HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) GetAliasRegistrationIDs(alias string) ([]string, error) {
path := DeviceAPIBaseURL + "/v3/aliases/" + url.PathEscape(alias)
resp, body, _, err := c.doRequest("GET", path, nil)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
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)
}
return result.RegistrationIDs, nil
}
func (c *Client) DeleteAlias(alias string) error {
path := DeviceAPIBaseURL + "/v3/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 {
return fmt.Errorf("jpush delete alias failed: HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) RemoveAliasDevices(alias string, registrationIDs []string) error {
path := DeviceAPIBaseURL + "/v3/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 {
return fmt.Errorf("jpush remove alias devices failed: HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}