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, classification int, 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(classification, 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, classification int, 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(classification, 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, classification int, 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(classification, 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 部分 // classification 为消息分类(0=运营消息,1=系统消息) // tpc 非空时加入 third_party_channel 字段,用于向各厂商下发 channel_id func (c *Client) buildOptions(classification int, tpc *ThirdPartyChannel) map[string]any { opts := map[string]any{ "classification": classification, "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 }