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.
624 lines
17 KiB
Go
624 lines
17 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"`
|
||
}
|
||
|
||
// 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 的 key(xiaomi/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
|
||
}
|