Files
backend/internal/pkg/jpush/client.go
lafay 179e468131
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m33s
fix(jpush): correct SendNo field type from int64 to string in PushResponse
The JPush API returns sendno as a string value in the JSON response, but the
struct field was incorrectly typed as int64. This fix updates both the struct
field type and the corresponding zap logger call to use string type, ensuring
proper JSON parsing and logging.
2026-04-28 00:18:51 +08:00

588 lines
15 KiB
Go

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 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
}
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, 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")
}
c.logger.Debug("jpush push by registration_ids",
zap.Int("count", len(registrationIDs)),
zap.String("title", notification.Android.Title),
zap.String("alert", notification.Alert),
)
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)
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, 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")
}
c.logger.Debug("jpush push by aliases",
zap.Int("count", len(aliases)),
zap.String("title", notification.Android.Title),
zap.String("alert", notification.Alert),
)
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)
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, err
}
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, 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) {
c.logger.Debug("jpush get device info",
zap.String("registration_id", registrationID),
)
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 {
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 + "/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 {
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 + "/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 {
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 + "/v3/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 + "/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 {
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 + "/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 {
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
}