feat(push): add JPush integration for offline message push
Add JPush (极光推送) integration to enable push notifications for offline users. This includes: - New jpush client package with API for batch push notifications - Configuration for jpush (enabled, app_key, master_secret, production mode) - Updated push service to send messages via JPush when users are offline - Added PushChatMessage method with do-not-disturb checking - Integrated push service with chat service to notify offline users of new messages - Updated deployment workflow with JPush and related environment variables
This commit is contained in:
@@ -110,6 +110,17 @@ jobs:
|
|||||||
-e APP_GRPC_ENABLED=true \
|
-e APP_GRPC_ENABLED=true \
|
||||||
-e APP_GRPC_PORT=50051 \
|
-e APP_GRPC_PORT=50051 \
|
||||||
-e APP_SERVER_MODE=release \
|
-e APP_SERVER_MODE=release \
|
||||||
|
-e APP_TENCENT_TMS_ENABLED=false \
|
||||||
|
-e "APP_TENCENT_TMS_SECRET_ID=${{ secrets.TENCENT_TMS_SECRET_ID }}" \
|
||||||
|
-e "APP_TENCENT_TMS_SECRET_KEY=${{ secrets.TENCENT_TMS_SECRET_KEY }}" \
|
||||||
|
-e APP_TENCENT_TMS_REGION=ap-guangzhou \
|
||||||
|
-e APP_TENCENT_TMS_TIMEOUT=30 \
|
||||||
|
-e APP_JPUSH_ENABLED=true \
|
||||||
|
-e "APP_JPUSH_APP_KEY=${{ secrets.JPUSH_APP_KEY }}" \
|
||||||
|
-e "APP_JPUSH_MASTER_SECRET=${{ secrets.JPUSH_MASTER_SECRET }}" \
|
||||||
|
-e APP_JPUSH_PRODUCTION=true \
|
||||||
|
-e APP_SENSITIVE_ENABLED=true \
|
||||||
|
-e APP_REPORT_AUTO_HIDE_THRESHOLD=3 \
|
||||||
-e "APP_SETUP_SECRET=${{ secrets.SETUP_SECRET }}" \
|
-e "APP_SETUP_SECRET=${{ secrets.SETUP_SECRET }}" \
|
||||||
${IMAGE}
|
${IMAGE}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ func InitializeApp() (*App, error) {
|
|||||||
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
||||||
messageRepository := repository.NewMessageRepository(db)
|
messageRepository := repository.NewMessageRepository(db)
|
||||||
hub := wire.ProvideWSHub()
|
hub := wire.ProvideWSHub()
|
||||||
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, hub)
|
logger := wire.ProvideLogger()
|
||||||
|
jpushClient := wire.ProvideJPushClient(config, logger)
|
||||||
|
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, hub, jpushClient)
|
||||||
postRepository := repository.NewPostRepository(db)
|
postRepository := repository.NewPostRepository(db)
|
||||||
client := wire.ProvideRedisClient(config)
|
client := wire.ProvideRedisClient(config)
|
||||||
cache := wire.ProvideCache(config, client)
|
cache := wire.ProvideCache(config, client)
|
||||||
@@ -42,7 +44,6 @@ func InitializeApp() (*App, error) {
|
|||||||
operationLogRepository := repository.NewOperationLogRepository(db)
|
operationLogRepository := repository.NewOperationLogRepository(db)
|
||||||
loginLogRepository := repository.NewLoginLogRepository(db)
|
loginLogRepository := repository.NewLoginLogRepository(db)
|
||||||
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
||||||
logger := wire.ProvideLogger()
|
|
||||||
asyncLogManager := wire.ProvideAsyncLogManager(operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
|
asyncLogManager := wire.ProvideAsyncLogManager(operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
|
||||||
operationLogService := wire.ProvideOperationLogService(asyncLogManager, operationLogRepository)
|
operationLogService := wire.ProvideOperationLogService(asyncLogManager, operationLogRepository)
|
||||||
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
|
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
|
||||||
@@ -77,7 +78,7 @@ func InitializeApp() (*App, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
uploadService := wire.ProvideUploadService(s3Client, userService)
|
uploadService := wire.ProvideUploadService(s3Client, userService)
|
||||||
chatService := wire.ProvideChatService(messageRepository, userRepository, hub, cache, uploadService)
|
chatService := wire.ProvideChatService(messageRepository, userRepository, hub, cache, uploadService, pushService)
|
||||||
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
|
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
|
||||||
groupRepository := repository.NewGroupRepository(db)
|
groupRepository := repository.NewGroupRepository(db)
|
||||||
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
||||||
|
|||||||
@@ -263,6 +263,15 @@ casbin:
|
|||||||
report:
|
report:
|
||||||
auto_hide_threshold: 3 # 自动隐藏阈值,举报次数达到此数值时自动隐藏内容
|
auto_hide_threshold: 3 # 自动隐藏阈值,举报次数达到此数值时自动隐藏内容
|
||||||
|
|
||||||
|
# 极光推送配置
|
||||||
|
# 环境变量:
|
||||||
|
# APP_JPUSH_ENABLED, APP_JPUSH_APP_KEY, APP_JPUSH_MASTER_SECRET, APP_JPUSH_PRODUCTION
|
||||||
|
jpush:
|
||||||
|
enabled: false
|
||||||
|
app_key: ""
|
||||||
|
master_secret: ""
|
||||||
|
production: false # true: 生产环境, false: 开发环境
|
||||||
|
|
||||||
# 初始化超级管理员密钥
|
# 初始化超级管理员密钥
|
||||||
# 环境变量: APP_SETUP_SECRET
|
# 环境变量: APP_SETUP_SECRET
|
||||||
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
|
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
@@ -33,6 +33,7 @@ type Config struct {
|
|||||||
WebRTC WebRTCConfig `mapstructure:"webrtc"`
|
WebRTC WebRTCConfig `mapstructure:"webrtc"`
|
||||||
Report ReportConfig `mapstructure:"report"`
|
Report ReportConfig `mapstructure:"report"`
|
||||||
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
||||||
|
JPush JPushConfig `mapstructure:"jpush"`
|
||||||
SetupSecret string `mapstructure:"setup_secret"`
|
SetupSecret string `mapstructure:"setup_secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +156,13 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("casbin.enable_cache", true)
|
viper.SetDefault("casbin.enable_cache", true)
|
||||||
viper.SetDefault("casbin.cache_ttl", 300)
|
viper.SetDefault("casbin.cache_ttl", 300)
|
||||||
viper.SetDefault("casbin.skip_routes", []string{"/health", "/api/v1/auth/login", "/api/v1/auth/register"})
|
viper.SetDefault("casbin.skip_routes", []string{"/health", "/api/v1/auth/login", "/api/v1/auth/register"})
|
||||||
|
// Casbin 默认值
|
||||||
|
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
|
||||||
|
viper.SetDefault("casbin.auto_save", true)
|
||||||
|
viper.SetDefault("casbin.auto_load", true)
|
||||||
|
viper.SetDefault("casbin.enable_cache", true)
|
||||||
|
viper.SetDefault("casbin.cache_ttl", 300)
|
||||||
|
viper.SetDefault("casbin.skip_routes", []string{"/health", "/api/v1/auth/login", "/api/v1/auth/register"})
|
||||||
// Encryption 默认值
|
// Encryption 默认值
|
||||||
viper.SetDefault("encryption.enabled", false)
|
viper.SetDefault("encryption.enabled", false)
|
||||||
viper.SetDefault("encryption.key", "")
|
viper.SetDefault("encryption.key", "")
|
||||||
@@ -171,6 +179,10 @@ func Load(configPath string) (*Config, error) {
|
|||||||
})
|
})
|
||||||
// Report 默认值
|
// Report 默认值
|
||||||
viper.SetDefault("report.auto_hide_threshold", 3)
|
viper.SetDefault("report.auto_hide_threshold", 3)
|
||||||
|
viper.SetDefault("jpush.enabled", false)
|
||||||
|
viper.SetDefault("jpush.app_key", "")
|
||||||
|
viper.SetDefault("jpush.master_secret", "")
|
||||||
|
viper.SetDefault("jpush.production", false)
|
||||||
viper.SetDefault("setup_secret", "")
|
viper.SetDefault("setup_secret", "")
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
@@ -270,6 +282,10 @@ func Load(configPath string) (*Config, error) {
|
|||||||
cfg.Sensitive.LoadFromDB, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_DB", fmt.Sprintf("%t", cfg.Sensitive.LoadFromDB)))
|
cfg.Sensitive.LoadFromDB, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_DB", fmt.Sprintf("%t", cfg.Sensitive.LoadFromDB)))
|
||||||
cfg.Sensitive.LoadFromRedis, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_REDIS", fmt.Sprintf("%t", cfg.Sensitive.LoadFromRedis)))
|
cfg.Sensitive.LoadFromRedis, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_REDIS", fmt.Sprintf("%t", cfg.Sensitive.LoadFromRedis)))
|
||||||
cfg.Sensitive.RedisKeyPrefix = getEnvOrDefault("APP_SENSITIVE_REDIS_KEY_PREFIX", cfg.Sensitive.RedisKeyPrefix)
|
cfg.Sensitive.RedisKeyPrefix = getEnvOrDefault("APP_SENSITIVE_REDIS_KEY_PREFIX", cfg.Sensitive.RedisKeyPrefix)
|
||||||
|
cfg.JPush.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_JPUSH_ENABLED", fmt.Sprintf("%t", cfg.JPush.Enabled)))
|
||||||
|
cfg.JPush.AppKey = getEnvOrDefault("APP_JPUSH_APP_KEY", cfg.JPush.AppKey)
|
||||||
|
cfg.JPush.MasterSecret = getEnvOrDefault("APP_JPUSH_MASTER_SECRET", cfg.JPush.MasterSecret)
|
||||||
|
cfg.JPush.Production, _ = strconv.ParseBool(getEnvOrDefault("APP_JPUSH_PRODUCTION", fmt.Sprintf("%t", cfg.JPush.Production)))
|
||||||
|
|
||||||
cfg.SetupSecret = getEnvOrDefault("APP_SETUP_SECRET", cfg.SetupSecret)
|
cfg.SetupSecret = getEnvOrDefault("APP_SETUP_SECRET", cfg.SetupSecret)
|
||||||
|
|
||||||
|
|||||||
8
internal/config/jpush.go
Normal file
8
internal/config/jpush.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type JPushConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
AppKey string `mapstructure:"app_key"`
|
||||||
|
MasterSecret string `mapstructure:"master_secret"`
|
||||||
|
Production bool `mapstructure:"production"`
|
||||||
|
}
|
||||||
@@ -85,12 +85,13 @@ func (h *PushHandler) GetMyDevices(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这里需要从DeviceTokenRepository获取设备列表
|
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||||
// 由于PushService接口没有提供获取设备列表的方法,我们暂时返回空列表
|
if err != nil {
|
||||||
// TODO: 在PushService接口中添加GetUserDevices方法
|
response.InternalServerError(c, "failed to get devices")
|
||||||
_ = userID // 避免未使用变量警告
|
return
|
||||||
|
}
|
||||||
|
|
||||||
response.Success(c, []*dto.DeviceTokenResponse{})
|
response.Success(c, dto.DeviceTokensToResponse(devices))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPushRecords 获取推送记录
|
// GetPushRecords 获取推送记录
|
||||||
@@ -140,7 +141,7 @@ func (h *PushHandler) UpdateDeviceToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
PushToken string `json:"push_token" binding:"required"`
|
PushToken string `json:"push_token"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type DeviceToken struct {
|
|||||||
UserID string `gorm:"column:user_id;type:varchar(50);index;not null" json:"user_id"` // 用户ID (UUID格式)
|
UserID string `gorm:"column:user_id;type:varchar(50);index;not null" json:"user_id"` // 用户ID (UUID格式)
|
||||||
DeviceID string `gorm:"type:varchar(100);not null" json:"device_id"` // 设备唯一标识
|
DeviceID string `gorm:"type:varchar(100);not null" json:"device_id"` // 设备唯一标识
|
||||||
DeviceType DeviceType `gorm:"type:varchar(20);not null" json:"device_type"` // 设备类型
|
DeviceType DeviceType `gorm:"type:varchar(20);not null" json:"device_type"` // 设备类型
|
||||||
PushToken string `gorm:"type:varchar(256);not null" json:"push_token"` // 推送Token(FCM/APNs等)
|
PushToken string `gorm:"type:varchar(256)" json:"push_token,omitempty"` // 推送Token(JPush RegistrationID等,Web端可为空)
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"` // 是否活跃
|
IsActive bool `gorm:"default:true" json:"is_active"` // 是否活跃
|
||||||
DeviceName string `gorm:"type:varchar(100)" json:"device_name,omitempty"` // 设备名称(可选)
|
DeviceName string `gorm:"type:varchar(100)" json:"device_name,omitempty"` // 设备名称(可选)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
PushChannelFCM PushChannel = "fcm" // Firebase Cloud Messaging
|
PushChannelFCM PushChannel = "fcm" // Firebase Cloud Messaging
|
||||||
PushChannelAPNs PushChannel = "apns" // Apple Push Notification service
|
PushChannelAPNs PushChannel = "apns" // Apple Push Notification service
|
||||||
PushChannelHuawei PushChannel = "huawei" // 华为推送
|
PushChannelHuawei PushChannel = "huawei" // 华为推送
|
||||||
|
PushChannelJPush PushChannel = "jpush" // 极光推送
|
||||||
)
|
)
|
||||||
|
|
||||||
// PushStatus 推送状态
|
// PushStatus 推送状态
|
||||||
|
|||||||
437
internal/pkg/jpush/client.go
Normal file
437
internal/pkg/jpush/client.go
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -124,9 +124,11 @@ func (r *deviceTokenRepository) Upsert(token *model.DeviceToken) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新现有记录
|
// 更新现有记录(包括 user_id,设备可能在用户间转移)
|
||||||
return r.db.Model(&existing).Updates(map[string]any{
|
return r.db.Model(&existing).Updates(map[string]any{
|
||||||
|
"user_id": token.UserID,
|
||||||
"push_token": token.PushToken,
|
"push_token": token.PushToken,
|
||||||
|
"device_type": token.DeviceType,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"device_name": token.DeviceName,
|
"device_name": token.DeviceName,
|
||||||
"last_used_at": time.Now(),
|
"last_used_at": time.Now(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type chatServiceImpl struct {
|
|||||||
userRepo repository.UserRepository
|
userRepo repository.UserRepository
|
||||||
sensitive SensitiveService
|
sensitive SensitiveService
|
||||||
wsHub *ws.Hub
|
wsHub *ws.Hub
|
||||||
|
pushSvc PushService
|
||||||
|
|
||||||
// 缓存相关字段
|
// 缓存相关字段
|
||||||
conversationCache *cache.ConversationCache
|
conversationCache *cache.ConversationCache
|
||||||
@@ -75,6 +76,7 @@ func NewChatService(
|
|||||||
wsHub *ws.Hub,
|
wsHub *ws.Hub,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
uploadService *UploadService,
|
uploadService *UploadService,
|
||||||
|
pushSvc PushService,
|
||||||
) ChatService {
|
) ChatService {
|
||||||
// 创建适配器
|
// 创建适配器
|
||||||
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
|
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
|
||||||
@@ -93,6 +95,7 @@ func NewChatService(
|
|||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
sensitive: sensitive,
|
sensitive: sensitive,
|
||||||
wsHub: wsHub,
|
wsHub: wsHub,
|
||||||
|
pushSvc: pushSvc,
|
||||||
conversationCache: conversationCache,
|
conversationCache: conversationCache,
|
||||||
uploadService: uploadService,
|
uploadService: uploadService,
|
||||||
}
|
}
|
||||||
@@ -384,6 +387,34 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对离线用户通过 JPush 推送聊天消息通知
|
||||||
|
if s.pushSvc != nil && len(participants) > 0 {
|
||||||
|
sender := ChatMessageSender{ID: senderID}
|
||||||
|
if senderUser, sErr := s.userRepo.GetByID(senderID); sErr == nil {
|
||||||
|
sender.Name = senderUser.Nickname
|
||||||
|
sender.Avatar = senderUser.Avatar
|
||||||
|
}
|
||||||
|
convType := conv.Type
|
||||||
|
convName := ""
|
||||||
|
if conv.Type == model.ConversationTypeGroup && conv.Group != nil {
|
||||||
|
convName = conv.Group.Name
|
||||||
|
}
|
||||||
|
for _, p := range participants {
|
||||||
|
if p.UserID == senderID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func(userID string, sender ChatMessageSender) {
|
||||||
|
if pushErr := s.pushSvc.PushChatMessage(context.Background(), userID, conversationID, &sender, convType, convName, message); pushErr != nil {
|
||||||
|
zap.L().Debug("push chat message skipped or failed",
|
||||||
|
zap.String("userID", userID),
|
||||||
|
zap.String("conversationID", conversationID),
|
||||||
|
zap.Error(pushErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}(p.UserID, sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return message, nil
|
return message, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import (
|
|||||||
|
|
||||||
"with_you/internal/dto"
|
"with_you/internal/dto"
|
||||||
"with_you/internal/model"
|
"with_you/internal/model"
|
||||||
|
"with_you/internal/pkg/jpush"
|
||||||
"with_you/internal/pkg/ws"
|
"with_you/internal/pkg/ws"
|
||||||
"with_you/internal/repository"
|
"with_you/internal/repository"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 推送相关常量
|
// 推送相关常量
|
||||||
@@ -24,6 +27,13 @@ const (
|
|||||||
PushQueueSize = 1000
|
PushQueueSize = 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ChatMessageSender 聊天消息发送者信息
|
||||||
|
type ChatMessageSender struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Avatar string
|
||||||
|
}
|
||||||
|
|
||||||
// PushPriority 推送优先级
|
// PushPriority 推送优先级
|
||||||
type PushPriority int
|
type PushPriority int
|
||||||
|
|
||||||
@@ -40,6 +50,10 @@ type PushService interface {
|
|||||||
PushMessage(ctx context.Context, userID string, message *model.Message) error
|
PushMessage(ctx context.Context, userID string, message *model.Message) error
|
||||||
PushToUser(ctx context.Context, userID string, message *model.Message, priority int) error
|
PushToUser(ctx context.Context, userID string, message *model.Message, priority int) error
|
||||||
|
|
||||||
|
// 聊天消息推送(含免打扰检查)
|
||||||
|
// 该方法会:1. 检查用户是否在线(在线则跳过);2. 检查会话免打扰(免打扰则跳过);3. 通过 JPush 推送
|
||||||
|
PushChatMessage(ctx context.Context, userID string, conversationID string, sender *ChatMessageSender, convType model.ConversationType, convName string, message *model.Message) error
|
||||||
|
|
||||||
// 系统消息推送
|
// 系统消息推送
|
||||||
PushSystemMessage(ctx context.Context, userID string, msgType, title, content string, data map[string]any) error
|
PushSystemMessage(ctx context.Context, userID string, msgType, title, content string, data map[string]any) error
|
||||||
|
|
||||||
@@ -54,6 +68,7 @@ type PushService interface {
|
|||||||
// 推送记录管理
|
// 推送记录管理
|
||||||
CreatePushRecord(ctx context.Context, userID string, messageID string, channel model.PushChannel) (*model.PushRecord, error)
|
CreatePushRecord(ctx context.Context, userID string, messageID string, channel model.PushChannel) (*model.PushRecord, error)
|
||||||
GetPendingPushes(ctx context.Context, userID string) ([]*model.PushRecord, error)
|
GetPendingPushes(ctx context.Context, userID string) ([]*model.PushRecord, error)
|
||||||
|
GetUserDevices(ctx context.Context, userID string) ([]*model.DeviceToken, error)
|
||||||
|
|
||||||
// 后台任务
|
// 后台任务
|
||||||
StartPushWorker(ctx context.Context)
|
StartPushWorker(ctx context.Context)
|
||||||
@@ -66,8 +81,8 @@ type pushServiceImpl struct {
|
|||||||
deviceRepo repository.DeviceTokenRepository
|
deviceRepo repository.DeviceTokenRepository
|
||||||
messageRepo repository.MessageRepository
|
messageRepo repository.MessageRepository
|
||||||
wsHub *ws.Hub
|
wsHub *ws.Hub
|
||||||
|
jpushClient *jpush.Client
|
||||||
|
|
||||||
// 推送队列
|
|
||||||
pushQueue chan *pushTask
|
pushQueue chan *pushTask
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
}
|
}
|
||||||
@@ -85,12 +100,14 @@ func NewPushService(
|
|||||||
deviceRepo repository.DeviceTokenRepository,
|
deviceRepo repository.DeviceTokenRepository,
|
||||||
messageRepo repository.MessageRepository,
|
messageRepo repository.MessageRepository,
|
||||||
wsHub *ws.Hub,
|
wsHub *ws.Hub,
|
||||||
|
jpushClient *jpush.Client,
|
||||||
) PushService {
|
) PushService {
|
||||||
return &pushServiceImpl{
|
return &pushServiceImpl{
|
||||||
pushRepo: pushRepo,
|
pushRepo: pushRepo,
|
||||||
deviceRepo: deviceRepo,
|
deviceRepo: deviceRepo,
|
||||||
messageRepo: messageRepo,
|
messageRepo: messageRepo,
|
||||||
wsHub: wsHub,
|
wsHub: wsHub,
|
||||||
|
jpushClient: jpushClient,
|
||||||
pushQueue: make(chan *pushTask, PushQueueSize),
|
pushQueue: make(chan *pushTask, PushQueueSize),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
@@ -117,7 +134,56 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket推送失败,加入推送队列等待移动端推送
|
// 用户不在线,尝试通过极光推送直接发送
|
||||||
|
if s.jpushClient != nil {
|
||||||
|
devices, err := s.deviceRepo.GetActiveByUserID(userID)
|
||||||
|
if err == nil && len(devices) > 0 {
|
||||||
|
mobileDevices := make([]*model.DeviceToken, 0)
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.SupportsMobilePush() && d.PushToken != "" {
|
||||||
|
mobileDevices = append(mobileDevices, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mobileDevices) > 0 {
|
||||||
|
title := "新消息"
|
||||||
|
content := dto.ExtractTextContentFromModel(message.Segments)
|
||||||
|
if message.IsSystemMessage() || message.Category == model.CategoryNotification {
|
||||||
|
if message.ExtraData != nil && message.ExtraData.ActorName != "" {
|
||||||
|
title = message.ExtraData.ActorName
|
||||||
|
} else {
|
||||||
|
title = "系统通知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extras := map[string]any{
|
||||||
|
"message_id": message.ID,
|
||||||
|
"conversation_id": message.ConversationID,
|
||||||
|
"sender_id": message.SenderID,
|
||||||
|
"category": string(message.Category),
|
||||||
|
}
|
||||||
|
if message.ExtraData != nil {
|
||||||
|
extras["actor_name"] = message.ExtraData.ActorName
|
||||||
|
extras["avatar_url"] = message.ExtraData.AvatarURL
|
||||||
|
extras["target_id"] = message.ExtraData.TargetID
|
||||||
|
extras["target_type"] = message.ExtraData.TargetType
|
||||||
|
}
|
||||||
|
pushErr := s.pushViaJPushBatch(mobileDevices, title, content, extras)
|
||||||
|
if pushErr == nil {
|
||||||
|
for _, device := range mobileDevices {
|
||||||
|
record, rerr := s.CreatePushRecord(ctx, userID, message.ID, model.PushChannelJPush)
|
||||||
|
if rerr == nil {
|
||||||
|
record.DeviceToken = device.PushToken
|
||||||
|
record.DeviceType = string(device.DeviceType)
|
||||||
|
record.MarkPushed()
|
||||||
|
s.pushRepo.Update(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 极光推送也失败,加入推送队列等待重试
|
||||||
select {
|
select {
|
||||||
case s.pushQueue <- &pushTask{
|
case s.pushQueue <- &pushTask{
|
||||||
userID: userID,
|
userID: userID,
|
||||||
@@ -127,7 +193,7 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
|
|||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
// 队列已满,直接创建待推送记录
|
// 队列已满,直接创建待推送记录
|
||||||
_, err := s.CreatePushRecord(ctx, userID, message.ID, model.PushChannelFCM)
|
_, err := s.CreatePushRecord(ctx, userID, message.ID, model.PushChannelJPush)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create pending push record: %w", err)
|
return fmt.Errorf("failed to create pending push record: %w", err)
|
||||||
}
|
}
|
||||||
@@ -209,22 +275,49 @@ func (s *pushServiceImpl) pushViaWebSocket(ctx context.Context, userID string, m
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushViaFCM 通过FCM推送(预留接口)
|
// pushViaJPush 通过极光推送发送通知
|
||||||
func (s *pushServiceImpl) pushViaFCM(ctx context.Context, deviceToken *model.DeviceToken, message *model.Message) error {
|
func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.DeviceToken, title, content string, extras map[string]any) error {
|
||||||
// TODO: 实现FCM推送
|
if s.jpushClient == nil {
|
||||||
// 1. 构建FCM消息
|
return errors.New("jpush client not configured")
|
||||||
// 2. 调用Firebase Admin SDK发送消息
|
}
|
||||||
// 3. 处理发送结果
|
|
||||||
return errors.New("FCM push not implemented")
|
if device.PushToken == "" {
|
||||||
|
return errors.New("device has no registration ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
notification := jpush.BuildNotification(title, content, "message", extras)
|
||||||
|
_, err := s.jpushClient.PushByRegistrationIDs([]string{device.PushToken}, notification, extras)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("jpush push failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushViaAPNs 通过APNs推送(预留接口)
|
// pushViaJPushBatch 批量通过极光推送发送通知
|
||||||
func (s *pushServiceImpl) pushViaAPNs(ctx context.Context, deviceToken *model.DeviceToken, message *model.Message) error {
|
func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, title, content string, extras map[string]any) error {
|
||||||
// TODO: 实现APNs推送
|
if s.jpushClient == nil {
|
||||||
// 1. 构建APNs消息
|
return errors.New("jpush client not configured")
|
||||||
// 2. 调用APNs SDK发送消息
|
}
|
||||||
// 3. 处理发送结果
|
|
||||||
return errors.New("APNs push not implemented")
|
var regIDs []string
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.PushToken != "" {
|
||||||
|
regIDs = append(regIDs, d.PushToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(regIDs) == 0 {
|
||||||
|
return errors.New("no valid registration IDs")
|
||||||
|
}
|
||||||
|
|
||||||
|
notification := jpush.BuildNotification(title, content, "message", extras)
|
||||||
|
_, err := s.jpushClient.PushByRegistrationIDs(regIDs, notification, extras)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("jpush batch push failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterDevice 注册设备
|
// RegisterDevice 注册设备
|
||||||
@@ -283,6 +376,11 @@ func (s *pushServiceImpl) GetPendingPushes(ctx context.Context, userID string) (
|
|||||||
return s.pushRepo.GetByUserID(userID, 100, 0)
|
return s.pushRepo.GetByUserID(userID, 100, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserDevices 获取用户设备列表
|
||||||
|
func (s *pushServiceImpl) GetUserDevices(ctx context.Context, userID string) ([]*model.DeviceToken, error) {
|
||||||
|
return s.deviceRepo.GetByUserID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
// StartPushWorker 启动推送工作协程
|
// StartPushWorker 启动推送工作协程
|
||||||
func (s *pushServiceImpl) StartPushWorker(ctx context.Context) {
|
func (s *pushServiceImpl) StartPushWorker(ctx context.Context) {
|
||||||
go s.processPushQueue()
|
go s.processPushQueue()
|
||||||
@@ -315,27 +413,80 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
|||||||
devices, err := s.deviceRepo.GetActiveByUserID(task.userID)
|
devices, err := s.deviceRepo.GetActiveByUserID(task.userID)
|
||||||
if err != nil || len(devices) == 0 {
|
if err != nil || len(devices) == 0 {
|
||||||
// 没有可用设备,创建待推送记录
|
// 没有可用设备,创建待推送记录
|
||||||
s.CreatePushRecord(ctx, task.userID, task.message.ID, model.PushChannelFCM)
|
s.CreatePushRecord(ctx, task.userID, task.message.ID, model.PushChannelJPush)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对每个设备创建推送记录并尝试推送
|
// 提取消息标题和内容
|
||||||
for _, device := range devices {
|
_ = task.message.Decrypt()
|
||||||
record, err := s.CreatePushRecord(ctx, task.userID, task.message.ID, s.getChannelForDevice(device))
|
title := "新消息"
|
||||||
if err != nil {
|
content := dto.ExtractTextContentFromModel(task.message.Segments)
|
||||||
continue
|
if task.message.IsSystemMessage() || task.message.Category == model.CategoryNotification {
|
||||||
|
if task.message.ExtraData != nil && task.message.ExtraData.ActorName != "" {
|
||||||
|
title = task.message.ExtraData.ActorName
|
||||||
|
} else {
|
||||||
|
title = "系统通知"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var pushErr error
|
extras := map[string]any{
|
||||||
switch {
|
"message_id": task.message.ID,
|
||||||
case device.IsIOS():
|
"conversation_id": task.message.ConversationID,
|
||||||
pushErr = s.pushViaAPNs(ctx, device, task.message)
|
"sender_id": task.message.SenderID,
|
||||||
case device.IsAndroid():
|
"category": string(task.message.Category),
|
||||||
pushErr = s.pushViaFCM(ctx, device, task.message)
|
}
|
||||||
default:
|
if task.message.ExtraData != nil {
|
||||||
// Web设备只支持WebSocket
|
extras["actor_name"] = task.message.ExtraData.ActorName
|
||||||
|
extras["avatar_url"] = task.message.ExtraData.AvatarURL
|
||||||
|
extras["target_id"] = task.message.ExtraData.TargetID
|
||||||
|
extras["target_type"] = task.message.ExtraData.TargetType
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试使用极光推送(统一推送到所有移动设备)
|
||||||
|
mobileDevices := make([]*model.DeviceToken, 0)
|
||||||
|
for _, device := range devices {
|
||||||
|
if device.SupportsMobilePush() && device.PushToken != "" {
|
||||||
|
mobileDevices = append(mobileDevices, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mobileDevices) > 0 {
|
||||||
|
// 优先使用极光推送
|
||||||
|
if s.jpushClient != nil {
|
||||||
|
err := s.pushViaJPushBatch(mobileDevices, title, content, extras)
|
||||||
|
if err == nil {
|
||||||
|
// 极光推送成功,为每个设备创建推送记录
|
||||||
|
for _, device := range mobileDevices {
|
||||||
|
record, rerr := s.CreatePushRecord(ctx, task.userID, task.message.ID, model.PushChannelJPush)
|
||||||
|
if rerr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
record.DeviceToken = device.PushToken
|
||||||
|
record.DeviceType = string(device.DeviceType)
|
||||||
|
record.MarkPushed()
|
||||||
|
s.pushRepo.Update(record)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 极光推送失败,记录错误,逐个设备重试
|
||||||
|
}
|
||||||
|
|
||||||
|
// 极光推送不可用或批量推送失败,逐个设备处理
|
||||||
|
for _, device := range mobileDevices {
|
||||||
|
channel := s.getChannelForDevice(device)
|
||||||
|
record, rerr := s.CreatePushRecord(ctx, task.userID, task.message.ID, channel)
|
||||||
|
if rerr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
record.DeviceToken = device.PushToken
|
||||||
|
record.DeviceType = string(device.DeviceType)
|
||||||
|
|
||||||
|
var pushErr error
|
||||||
|
if s.jpushClient != nil {
|
||||||
|
pushErr = s.pushViaJPush(ctx, device, title, content, extras)
|
||||||
|
} else {
|
||||||
|
pushErr = errors.New("jpush not configured")
|
||||||
|
}
|
||||||
|
|
||||||
if pushErr != nil {
|
if pushErr != nil {
|
||||||
record.MarkFailed(pushErr.Error())
|
record.MarkFailed(pushErr.Error())
|
||||||
@@ -344,15 +495,16 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
|||||||
}
|
}
|
||||||
s.pushRepo.Update(record)
|
s.pushRepo.Update(record)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getChannelForDevice 根据设备类型获取推送通道
|
// getChannelForDevice 根据设备类型获取推送通道
|
||||||
func (s *pushServiceImpl) getChannelForDevice(device *model.DeviceToken) model.PushChannel {
|
func (s *pushServiceImpl) getChannelForDevice(device *model.DeviceToken) model.PushChannel {
|
||||||
switch device.DeviceType {
|
switch device.DeviceType {
|
||||||
case model.DeviceTypeIOS:
|
case model.DeviceTypeIOS:
|
||||||
return model.PushChannelAPNs
|
return model.PushChannelJPush
|
||||||
case model.DeviceTypeAndroid:
|
case model.DeviceTypeAndroid:
|
||||||
return model.PushChannelFCM
|
return model.PushChannelJPush
|
||||||
default:
|
default:
|
||||||
return model.PushChannelWebSocket
|
return model.PushChannelWebSocket
|
||||||
}
|
}
|
||||||
@@ -416,11 +568,30 @@ func (s *pushServiceImpl) doRetry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pushErr error
|
var pushErr error
|
||||||
switch {
|
if s.jpushClient != nil && device.SupportsMobilePush() && device.PushToken != "" {
|
||||||
case device.IsIOS():
|
_ = message.Decrypt()
|
||||||
pushErr = s.pushViaAPNs(ctx, device, message)
|
title := "新消息"
|
||||||
case device.IsAndroid():
|
content := dto.ExtractTextContentFromModel(message.Segments)
|
||||||
pushErr = s.pushViaFCM(ctx, device, message)
|
if message.IsSystemMessage() || message.Category == model.CategoryNotification {
|
||||||
|
if message.ExtraData != nil && message.ExtraData.ActorName != "" {
|
||||||
|
title = message.ExtraData.ActorName
|
||||||
|
} else {
|
||||||
|
title = "系统通知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extras := map[string]any{
|
||||||
|
"message_id": message.ID,
|
||||||
|
"conversation_id": message.ConversationID,
|
||||||
|
"sender_id": message.SenderID,
|
||||||
|
"category": string(message.Category),
|
||||||
|
}
|
||||||
|
if message.ExtraData != nil {
|
||||||
|
extras["actor_name"] = message.ExtraData.ActorName
|
||||||
|
extras["avatar_url"] = message.ExtraData.AvatarURL
|
||||||
|
extras["target_id"] = message.ExtraData.TargetID
|
||||||
|
extras["target_type"] = message.ExtraData.TargetType
|
||||||
|
}
|
||||||
|
pushErr = s.pushViaJPush(ctx, device, title, content, extras)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pushErr != nil {
|
if pushErr != nil {
|
||||||
@@ -433,6 +604,99 @@ func (s *pushServiceImpl) doRetry() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PushChatMessage 推送聊天消息给离线用户
|
||||||
|
// 检查用户在线状态和免打扰设置,仅对离线且未免打扰的用户通过 JPush 推送
|
||||||
|
func (s *pushServiceImpl) PushChatMessage(ctx context.Context, userID string, conversationID string, sender *ChatMessageSender, convType model.ConversationType, convName string, message *model.Message) error {
|
||||||
|
// 1. 用户在线则跳过(已通过 WebSocket 收到消息)
|
||||||
|
if s.wsHub != nil && s.wsHub.HasClients(userID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查免打扰状态
|
||||||
|
participant, err := s.messageRepo.GetParticipant(conversationID, userID)
|
||||||
|
if err == nil && participant.NotificationMuted {
|
||||||
|
zap.L().Debug("push chat message skipped: notification muted",
|
||||||
|
zap.String("userID", userID),
|
||||||
|
zap.String("conversationID", conversationID),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取用户的移动设备
|
||||||
|
if s.jpushClient == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
devices, err := s.deviceRepo.GetActiveByUserID(userID)
|
||||||
|
if err != nil || len(devices) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var regIDs []string
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.SupportsMobilePush() && d.PushToken != "" {
|
||||||
|
regIDs = append(regIDs, d.PushToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(regIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 构建通知内容
|
||||||
|
title := sender.Name
|
||||||
|
if title == "" {
|
||||||
|
title = "新消息"
|
||||||
|
}
|
||||||
|
if convType == model.ConversationTypeGroup && convName != "" {
|
||||||
|
title = convName + ": " + sender.Name
|
||||||
|
}
|
||||||
|
content := dto.ExtractTextContentFromModel(message.Segments)
|
||||||
|
if content == "" {
|
||||||
|
content = "[非文本消息]"
|
||||||
|
}
|
||||||
|
extras := map[string]any{
|
||||||
|
"notification_type": "chat_message",
|
||||||
|
"message_id": message.ID,
|
||||||
|
"conversation_id": message.ConversationID,
|
||||||
|
"conversation_type": string(convType),
|
||||||
|
"sender_id": message.SenderID,
|
||||||
|
"sender_name": sender.Name,
|
||||||
|
"sender_avatar": sender.Avatar,
|
||||||
|
"category": string(message.Category),
|
||||||
|
}
|
||||||
|
if message.Category == model.CategoryNotification && message.ExtraData != nil {
|
||||||
|
title = message.ExtraData.ActorName
|
||||||
|
if title == "" {
|
||||||
|
title = "系统通知"
|
||||||
|
}
|
||||||
|
extras["actor_name"] = message.ExtraData.ActorName
|
||||||
|
extras["actor_id"] = message.ExtraData.ActorID
|
||||||
|
extras["avatar_url"] = message.ExtraData.AvatarURL
|
||||||
|
extras["target_id"] = message.ExtraData.TargetID
|
||||||
|
extras["target_type"] = message.ExtraData.TargetType
|
||||||
|
}
|
||||||
|
|
||||||
|
notification := jpush.BuildNotification(title, content, "chat_message", extras)
|
||||||
|
if sender.Avatar != "" {
|
||||||
|
notification.Android.LargeIcon = sender.Avatar
|
||||||
|
}
|
||||||
|
if _, pushErr := s.jpushClient.PushByRegistrationIDs(regIDs, notification, extras); pushErr != nil {
|
||||||
|
zap.L().Error("jpush push chat message failed",
|
||||||
|
zap.String("userID", userID),
|
||||||
|
zap.String("conversationID", conversationID),
|
||||||
|
zap.Error(pushErr),
|
||||||
|
)
|
||||||
|
return pushErr
|
||||||
|
}
|
||||||
|
|
||||||
|
zap.L().Debug("jpush push chat message sent",
|
||||||
|
zap.String("userID", userID),
|
||||||
|
zap.String("conversationID", conversationID),
|
||||||
|
zap.String("convType", string(convType)),
|
||||||
|
zap.Int("device_count", len(regIDs)),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// PushSystemMessage 推送系统消息
|
// PushSystemMessage 推送系统消息
|
||||||
func (s *pushServiceImpl) PushSystemMessage(ctx context.Context, userID string, msgType, title, content string, data map[string]any) error {
|
func (s *pushServiceImpl) PushSystemMessage(ctx context.Context, userID string, msgType, title, content string, data map[string]any) error {
|
||||||
// 首先尝试WebSocket推送
|
// 首先尝试WebSocket推送
|
||||||
@@ -472,7 +736,57 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户不在线,系统通知已存储在数据库中,用户上线后会主动拉取
|
// 用户不在线,尝试极光推送
|
||||||
|
if s.jpushClient != nil {
|
||||||
|
devices, err := s.deviceRepo.GetActiveByUserID(userID)
|
||||||
|
if err == nil && len(devices) > 0 {
|
||||||
|
mobileDevices := make([]*model.DeviceToken, 0)
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.SupportsMobilePush() && d.PushToken != "" {
|
||||||
|
mobileDevices = append(mobileDevices, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mobileDevices) > 0 {
|
||||||
|
content := notification.Content
|
||||||
|
title := notification.Title
|
||||||
|
if title == "" {
|
||||||
|
title = string(notification.Type)
|
||||||
|
}
|
||||||
|
extras := map[string]any{
|
||||||
|
"notification_id": fmt.Sprintf("%d", notification.ID),
|
||||||
|
"notification_type": string(notification.Type),
|
||||||
|
}
|
||||||
|
if notification.ExtraData != nil {
|
||||||
|
extras["actor_id"] = notification.ExtraData.ActorIDStr
|
||||||
|
extras["actor_name"] = notification.ExtraData.ActorName
|
||||||
|
extras["avatar_url"] = notification.ExtraData.AvatarURL
|
||||||
|
extras["target_id"] = notification.ExtraData.TargetID
|
||||||
|
extras["target_type"] = notification.ExtraData.TargetType
|
||||||
|
}
|
||||||
|
notifType := string(notification.Type)
|
||||||
|
jpushNotif := jpush.BuildNotification(title, content, notifType, extras)
|
||||||
|
if notification.ExtraData != nil && notification.ExtraData.AvatarURL != "" {
|
||||||
|
jpushNotif.Android.LargeIcon = notification.ExtraData.AvatarURL
|
||||||
|
}
|
||||||
|
_, pushErr := s.jpushClient.PushByRegistrationIDs(
|
||||||
|
func() []string {
|
||||||
|
ids := make([]string, 0, len(mobileDevices))
|
||||||
|
for _, d := range mobileDevices {
|
||||||
|
ids = append(ids, d.PushToken)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}(),
|
||||||
|
jpushNotif,
|
||||||
|
extras,
|
||||||
|
)
|
||||||
|
if pushErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统通知已持久化到数据库中,用户上线后会主动拉取
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package wire
|
package wire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"with_you/internal/cache"
|
"with_you/internal/cache"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"with_you/internal/pkg/crypto"
|
"with_you/internal/pkg/crypto"
|
||||||
"with_you/internal/pkg/email"
|
"with_you/internal/pkg/email"
|
||||||
"with_you/internal/pkg/hook"
|
"with_you/internal/pkg/hook"
|
||||||
|
"with_you/internal/pkg/jpush"
|
||||||
"with_you/internal/pkg/openai"
|
"with_you/internal/pkg/openai"
|
||||||
"with_you/internal/pkg/redis"
|
"with_you/internal/pkg/redis"
|
||||||
"with_you/internal/pkg/s3"
|
"with_you/internal/pkg/s3"
|
||||||
@@ -53,6 +54,7 @@ var InfrastructureSet = wire.NewSet(
|
|||||||
ProvideTencentClient,
|
ProvideTencentClient,
|
||||||
ProvideEmailClient,
|
ProvideEmailClient,
|
||||||
ProvideS3Client,
|
ProvideS3Client,
|
||||||
|
ProvideJPushClient,
|
||||||
|
|
||||||
// 消息加密器
|
// 消息加密器
|
||||||
ProvideMessageEncryptor,
|
ProvideMessageEncryptor,
|
||||||
@@ -193,6 +195,23 @@ func ProvideS3Client(cfg *config.Config) (*s3.Client, error) {
|
|||||||
return s3.New(&cfg.S3)
|
return s3.New(&cfg.S3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvideJPushClient 提供极光推送客户端
|
||||||
|
func ProvideJPushClient(cfg *config.Config, logger *zap.Logger) *jpush.Client {
|
||||||
|
if !cfg.JPush.Enabled {
|
||||||
|
logger.Info("JPush is disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg.JPush.AppKey == "" || cfg.JPush.MasterSecret == "" {
|
||||||
|
logger.Warn("JPush is enabled but app_key or master_secret is not configured")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client := jpush.NewClient(cfg.JPush.AppKey, cfg.JPush.MasterSecret, cfg.JPush.Production, logger)
|
||||||
|
logger.Info("JPush client initialized",
|
||||||
|
zap.Bool("production", cfg.JPush.Production),
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
// ProvideTransactionManager 提供事务管理器
|
// ProvideTransactionManager 提供事务管理器
|
||||||
func ProvideTransactionManager(db *gorm.DB) repository.TransactionManager {
|
func ProvideTransactionManager(db *gorm.DB) repository.TransactionManager {
|
||||||
return repository.NewTransactionManager(db)
|
return repository.NewTransactionManager(db)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"with_you/internal/grpc/runner"
|
"with_you/internal/grpc/runner"
|
||||||
"with_you/internal/pkg/email"
|
"with_you/internal/pkg/email"
|
||||||
"with_you/internal/pkg/hook"
|
"with_you/internal/pkg/hook"
|
||||||
|
"with_you/internal/pkg/jpush"
|
||||||
"with_you/internal/pkg/openai"
|
"with_you/internal/pkg/openai"
|
||||||
"with_you/internal/pkg/redis"
|
"with_you/internal/pkg/redis"
|
||||||
"with_you/internal/pkg/s3"
|
"with_you/internal/pkg/s3"
|
||||||
@@ -105,8 +106,9 @@ func ProvidePushService(
|
|||||||
deviceTokenRepo repository.DeviceTokenRepository,
|
deviceTokenRepo repository.DeviceTokenRepository,
|
||||||
messageRepo repository.MessageRepository,
|
messageRepo repository.MessageRepository,
|
||||||
wsHub *ws.Hub,
|
wsHub *ws.Hub,
|
||||||
|
jpushClient *jpush.Client,
|
||||||
) service.PushService {
|
) service.PushService {
|
||||||
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, wsHub)
|
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, wsHub, jpushClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideSystemMessageService 提供系统消息服务
|
// ProvideSystemMessageService 提供系统消息服务
|
||||||
@@ -206,8 +208,9 @@ func ProvideChatService(
|
|||||||
wsHub *ws.Hub,
|
wsHub *ws.Hub,
|
||||||
cacheBackend cache.Cache,
|
cacheBackend cache.Cache,
|
||||||
uploadService *service.UploadService,
|
uploadService *service.UploadService,
|
||||||
|
pushService service.PushService,
|
||||||
) service.ChatService {
|
) service.ChatService {
|
||||||
return service.NewChatService(messageRepo, userRepo, nil, wsHub, cacheBackend, uploadService)
|
return service.NewChatService(messageRepo, userRepo, nil, wsHub, cacheBackend, uploadService, pushService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideScheduleService 提供日程服务
|
// ProvideScheduleService 提供日程服务
|
||||||
|
|||||||
Reference in New Issue
Block a user