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_PORT=50051 \
|
||||
-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 }}" \
|
||||
${IMAGE}
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ func InitializeApp() (*App, error) {
|
||||
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
||||
messageRepository := repository.NewMessageRepository(db)
|
||||
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)
|
||||
client := wire.ProvideRedisClient(config)
|
||||
cache := wire.ProvideCache(config, client)
|
||||
@@ -42,7 +44,6 @@ func InitializeApp() (*App, error) {
|
||||
operationLogRepository := repository.NewOperationLogRepository(db)
|
||||
loginLogRepository := repository.NewLoginLogRepository(db)
|
||||
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
||||
logger := wire.ProvideLogger()
|
||||
asyncLogManager := wire.ProvideAsyncLogManager(operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
|
||||
operationLogService := wire.ProvideOperationLogService(asyncLogManager, operationLogRepository)
|
||||
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
|
||||
@@ -77,7 +78,7 @@ func InitializeApp() (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
groupRepository := repository.NewGroupRepository(db)
|
||||
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
||||
|
||||
@@ -263,6 +263,15 @@ casbin:
|
||||
report:
|
||||
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
|
||||
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package config
|
||||
package config
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
@@ -33,6 +33,7 @@ type Config struct {
|
||||
WebRTC WebRTCConfig `mapstructure:"webrtc"`
|
||||
Report ReportConfig `mapstructure:"report"`
|
||||
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
||||
JPush JPushConfig `mapstructure:"jpush"`
|
||||
SetupSecret string `mapstructure:"setup_secret"`
|
||||
}
|
||||
|
||||
@@ -155,6 +156,13 @@ func Load(configPath string) (*Config, error) {
|
||||
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"})
|
||||
// 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 默认值
|
||||
viper.SetDefault("encryption.enabled", false)
|
||||
viper.SetDefault("encryption.key", "")
|
||||
@@ -171,6 +179,10 @@ func Load(configPath string) (*Config, error) {
|
||||
})
|
||||
// Report 默认值
|
||||
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", "")
|
||||
|
||||
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.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.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)
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 这里需要从DeviceTokenRepository获取设备列表
|
||||
// 由于PushService接口没有提供获取设备列表的方法,我们暂时返回空列表
|
||||
// TODO: 在PushService接口中添加GetUserDevices方法
|
||||
_ = userID // 避免未使用变量警告
|
||||
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get devices")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, []*dto.DeviceTokenResponse{})
|
||||
response.Success(c, dto.DeviceTokensToResponse(devices))
|
||||
}
|
||||
|
||||
// GetPushRecords 获取推送记录
|
||||
@@ -140,7 +141,7 @@ func (h *PushHandler) UpdateDeviceToken(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PushToken string `json:"push_token" binding:"required"`
|
||||
PushToken string `json:"push_token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package middleware
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"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格式)
|
||||
DeviceID string `gorm:"type:varchar(100);not null" json:"device_id"` // 设备唯一标识
|
||||
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"` // 是否活跃
|
||||
DeviceName string `gorm:"type:varchar(100)" json:"device_name,omitempty"` // 设备名称(可选)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
PushChannelFCM PushChannel = "fcm" // Firebase Cloud Messaging
|
||||
PushChannelAPNs PushChannel = "apns" // Apple Push Notification service
|
||||
PushChannelHuawei PushChannel = "huawei" // 华为推送
|
||||
PushChannelJPush PushChannel = "jpush" // 极光推送
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 更新现有记录
|
||||
// 更新现有记录(包括 user_id,设备可能在用户间转移)
|
||||
return r.db.Model(&existing).Updates(map[string]any{
|
||||
"user_id": token.UserID,
|
||||
"push_token": token.PushToken,
|
||||
"device_type": token.DeviceType,
|
||||
"is_active": true,
|
||||
"device_name": token.DeviceName,
|
||||
"last_used_at": time.Now(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -61,6 +61,7 @@ type chatServiceImpl struct {
|
||||
userRepo repository.UserRepository
|
||||
sensitive SensitiveService
|
||||
wsHub *ws.Hub
|
||||
pushSvc PushService
|
||||
|
||||
// 缓存相关字段
|
||||
conversationCache *cache.ConversationCache
|
||||
@@ -75,6 +76,7 @@ func NewChatService(
|
||||
wsHub *ws.Hub,
|
||||
cacheBackend cache.Cache,
|
||||
uploadService *UploadService,
|
||||
pushSvc PushService,
|
||||
) ChatService {
|
||||
// 创建适配器
|
||||
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
|
||||
@@ -93,6 +95,7 @@ func NewChatService(
|
||||
userRepo: userRepo,
|
||||
sensitive: sensitive,
|
||||
wsHub: wsHub,
|
||||
pushSvc: pushSvc,
|
||||
conversationCache: conversationCache,
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/jpush"
|
||||
"with_you/internal/pkg/ws"
|
||||
"with_you/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 推送相关常量
|
||||
@@ -24,6 +27,13 @@ const (
|
||||
PushQueueSize = 1000
|
||||
)
|
||||
|
||||
// ChatMessageSender 聊天消息发送者信息
|
||||
type ChatMessageSender struct {
|
||||
ID string
|
||||
Name string
|
||||
Avatar string
|
||||
}
|
||||
|
||||
// PushPriority 推送优先级
|
||||
type PushPriority int
|
||||
|
||||
@@ -40,6 +50,10 @@ type PushService interface {
|
||||
PushMessage(ctx context.Context, userID string, message *model.Message) 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
|
||||
|
||||
@@ -54,6 +68,7 @@ type PushService interface {
|
||||
// 推送记录管理
|
||||
CreatePushRecord(ctx context.Context, userID string, messageID string, channel model.PushChannel) (*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)
|
||||
@@ -66,8 +81,8 @@ type pushServiceImpl struct {
|
||||
deviceRepo repository.DeviceTokenRepository
|
||||
messageRepo repository.MessageRepository
|
||||
wsHub *ws.Hub
|
||||
jpushClient *jpush.Client
|
||||
|
||||
// 推送队列
|
||||
pushQueue chan *pushTask
|
||||
stopChan chan struct{}
|
||||
}
|
||||
@@ -85,12 +100,14 @@ func NewPushService(
|
||||
deviceRepo repository.DeviceTokenRepository,
|
||||
messageRepo repository.MessageRepository,
|
||||
wsHub *ws.Hub,
|
||||
jpushClient *jpush.Client,
|
||||
) PushService {
|
||||
return &pushServiceImpl{
|
||||
pushRepo: pushRepo,
|
||||
deviceRepo: deviceRepo,
|
||||
messageRepo: messageRepo,
|
||||
wsHub: wsHub,
|
||||
jpushClient: jpushClient,
|
||||
pushQueue: make(chan *pushTask, PushQueueSize),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
@@ -117,7 +134,56 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
|
||||
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 {
|
||||
case s.pushQueue <- &pushTask{
|
||||
userID: userID,
|
||||
@@ -127,7 +193,7 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
|
||||
return nil
|
||||
default:
|
||||
// 队列已满,直接创建待推送记录
|
||||
_, err := s.CreatePushRecord(ctx, userID, message.ID, model.PushChannelFCM)
|
||||
_, err := s.CreatePushRecord(ctx, userID, message.ID, model.PushChannelJPush)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// pushViaFCM 通过FCM推送(预留接口)
|
||||
func (s *pushServiceImpl) pushViaFCM(ctx context.Context, deviceToken *model.DeviceToken, message *model.Message) error {
|
||||
// TODO: 实现FCM推送
|
||||
// 1. 构建FCM消息
|
||||
// 2. 调用Firebase Admin SDK发送消息
|
||||
// 3. 处理发送结果
|
||||
return errors.New("FCM push not implemented")
|
||||
// pushViaJPush 通过极光推送发送通知
|
||||
func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.DeviceToken, title, content string, extras map[string]any) error {
|
||||
if s.jpushClient == nil {
|
||||
return errors.New("jpush client not configured")
|
||||
}
|
||||
|
||||
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推送(预留接口)
|
||||
func (s *pushServiceImpl) pushViaAPNs(ctx context.Context, deviceToken *model.DeviceToken, message *model.Message) error {
|
||||
// TODO: 实现APNs推送
|
||||
// 1. 构建APNs消息
|
||||
// 2. 调用APNs SDK发送消息
|
||||
// 3. 处理发送结果
|
||||
return errors.New("APNs push not implemented")
|
||||
// pushViaJPushBatch 批量通过极光推送发送通知
|
||||
func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, title, content string, extras map[string]any) error {
|
||||
if s.jpushClient == nil {
|
||||
return errors.New("jpush client not configured")
|
||||
}
|
||||
|
||||
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 注册设备
|
||||
@@ -283,6 +376,11 @@ func (s *pushServiceImpl) GetPendingPushes(ctx context.Context, userID string) (
|
||||
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 启动推送工作协程
|
||||
func (s *pushServiceImpl) StartPushWorker(ctx context.Context) {
|
||||
go s.processPushQueue()
|
||||
@@ -315,34 +413,88 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
||||
devices, err := s.deviceRepo.GetActiveByUserID(task.userID)
|
||||
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
|
||||
}
|
||||
|
||||
// 对每个设备创建推送记录并尝试推送
|
||||
for _, device := range devices {
|
||||
record, err := s.CreatePushRecord(ctx, task.userID, task.message.ID, s.getChannelForDevice(device))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var pushErr error
|
||||
switch {
|
||||
case device.IsIOS():
|
||||
pushErr = s.pushViaAPNs(ctx, device, task.message)
|
||||
case device.IsAndroid():
|
||||
pushErr = s.pushViaFCM(ctx, device, task.message)
|
||||
default:
|
||||
// Web设备只支持WebSocket
|
||||
continue
|
||||
}
|
||||
|
||||
if pushErr != nil {
|
||||
record.MarkFailed(pushErr.Error())
|
||||
// 提取消息标题和内容
|
||||
_ = task.message.Decrypt()
|
||||
title := "新消息"
|
||||
content := dto.ExtractTextContentFromModel(task.message.Segments)
|
||||
if task.message.IsSystemMessage() || task.message.Category == model.CategoryNotification {
|
||||
if task.message.ExtraData != nil && task.message.ExtraData.ActorName != "" {
|
||||
title = task.message.ExtraData.ActorName
|
||||
} else {
|
||||
record.MarkPushed()
|
||||
title = "系统通知"
|
||||
}
|
||||
}
|
||||
|
||||
extras := map[string]any{
|
||||
"message_id": task.message.ID,
|
||||
"conversation_id": task.message.ConversationID,
|
||||
"sender_id": task.message.SenderID,
|
||||
"category": string(task.message.Category),
|
||||
}
|
||||
if task.message.ExtraData != nil {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
record.MarkFailed(pushErr.Error())
|
||||
} else {
|
||||
record.MarkPushed()
|
||||
}
|
||||
s.pushRepo.Update(record)
|
||||
}
|
||||
s.pushRepo.Update(record)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,9 +502,9 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
||||
func (s *pushServiceImpl) getChannelForDevice(device *model.DeviceToken) model.PushChannel {
|
||||
switch device.DeviceType {
|
||||
case model.DeviceTypeIOS:
|
||||
return model.PushChannelAPNs
|
||||
return model.PushChannelJPush
|
||||
case model.DeviceTypeAndroid:
|
||||
return model.PushChannelFCM
|
||||
return model.PushChannelJPush
|
||||
default:
|
||||
return model.PushChannelWebSocket
|
||||
}
|
||||
@@ -416,11 +568,30 @@ func (s *pushServiceImpl) doRetry() {
|
||||
}
|
||||
|
||||
var pushErr error
|
||||
switch {
|
||||
case device.IsIOS():
|
||||
pushErr = s.pushViaAPNs(ctx, device, message)
|
||||
case device.IsAndroid():
|
||||
pushErr = s.pushViaFCM(ctx, device, message)
|
||||
if s.jpushClient != nil && device.SupportsMobilePush() && device.PushToken != "" {
|
||||
_ = message.Decrypt()
|
||||
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.pushViaJPush(ctx, device, title, content, extras)
|
||||
}
|
||||
|
||||
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 推送系统消息
|
||||
func (s *pushServiceImpl) PushSystemMessage(ctx context.Context, userID string, msgType, title, content string, data map[string]any) error {
|
||||
// 首先尝试WebSocket推送
|
||||
@@ -472,7 +736,57 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package wire
|
||||
package wire
|
||||
|
||||
import (
|
||||
"with_you/internal/cache"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"with_you/internal/pkg/crypto"
|
||||
"with_you/internal/pkg/email"
|
||||
"with_you/internal/pkg/hook"
|
||||
"with_you/internal/pkg/jpush"
|
||||
"with_you/internal/pkg/openai"
|
||||
"with_you/internal/pkg/redis"
|
||||
"with_you/internal/pkg/s3"
|
||||
@@ -53,6 +54,7 @@ var InfrastructureSet = wire.NewSet(
|
||||
ProvideTencentClient,
|
||||
ProvideEmailClient,
|
||||
ProvideS3Client,
|
||||
ProvideJPushClient,
|
||||
|
||||
// 消息加密器
|
||||
ProvideMessageEncryptor,
|
||||
@@ -193,6 +195,23 @@ func ProvideS3Client(cfg *config.Config) (*s3.Client, error) {
|
||||
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 提供事务管理器
|
||||
func ProvideTransactionManager(db *gorm.DB) repository.TransactionManager {
|
||||
return repository.NewTransactionManager(db)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"with_you/internal/grpc/runner"
|
||||
"with_you/internal/pkg/email"
|
||||
"with_you/internal/pkg/hook"
|
||||
"with_you/internal/pkg/jpush"
|
||||
"with_you/internal/pkg/openai"
|
||||
"with_you/internal/pkg/redis"
|
||||
"with_you/internal/pkg/s3"
|
||||
@@ -105,8 +106,9 @@ func ProvidePushService(
|
||||
deviceTokenRepo repository.DeviceTokenRepository,
|
||||
messageRepo repository.MessageRepository,
|
||||
wsHub *ws.Hub,
|
||||
jpushClient *jpush.Client,
|
||||
) service.PushService {
|
||||
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, wsHub)
|
||||
return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, wsHub, jpushClient)
|
||||
}
|
||||
|
||||
// ProvideSystemMessageService 提供系统消息服务
|
||||
@@ -206,8 +208,9 @@ func ProvideChatService(
|
||||
wsHub *ws.Hub,
|
||||
cacheBackend cache.Cache,
|
||||
uploadService *service.UploadService,
|
||||
pushService service.PushService,
|
||||
) service.ChatService {
|
||||
return service.NewChatService(messageRepo, userRepo, nil, wsHub, cacheBackend, uploadService)
|
||||
return service.NewChatService(messageRepo, userRepo, nil, wsHub, cacheBackend, uploadService, pushService)
|
||||
}
|
||||
|
||||
// ProvideScheduleService 提供日程服务
|
||||
|
||||
Reference in New Issue
Block a user