feat(push): add JPush integration for offline message push
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled

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:
lafay
2026-04-27 23:20:24 +08:00
parent f03bbf6faa
commit fb85c9c20a
18 changed files with 919 additions and 66 deletions

View File

@@ -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}

View File

@@ -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)

View File

@@ -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 接口初始化第一位超级管理员

View File

@@ -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
View 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"`
}

View File

@@ -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())

View File

@@ -1,4 +1,4 @@
package middleware
package middleware
import (
"slices"

View File

@@ -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"` // 推送TokenFCM/APNs等
PushToken string `gorm:"type:varchar(256)" json:"push_token,omitempty"` // 推送TokenJPush RegistrationID等Web端可为空
IsActive bool `gorm:"default:true" json:"is_active"` // 是否活跃
DeviceName string `gorm:"type:varchar(100)" json:"device_name,omitempty"` // 设备名称(可选)

View File

@@ -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 推送状态

View 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
}

View File

@@ -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(),

View File

@@ -1,4 +1,4 @@
package service
package service
import (
"context"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,4 +1,4 @@
package wire
package wire
import (
"with_you/internal/cache"

View File

@@ -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)

View File

@@ -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 提供日程服务