refactor: unify code formatting and improve push/search implementations
All checks were successful
Build Backend / build (push) Successful in 3m54s
Build Backend / build-docker (push) Successful in 1m9s

- Remove BOM from all Go source files
- Standardize import ordering and whitespace across handlers and services
- Replace PostgreSQL full-text search with ILIKE pattern matching in post and user repositories
- Enhance JPush client with TLS transport, rate limit monitoring, and simplified API
- Refactor PushService to include userID in device management methods
- Add MaxDevicesPerUser limit and extract helper functions for push payload construction
This commit is contained in:
lafay
2026-04-28 14:53:04 +08:00
parent 179e468131
commit 67a5660952
67 changed files with 356 additions and 351 deletions

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,10 +1,10 @@
package handler package handler
import ( import (
"strconv"
"with_you/internal/dto" "with_you/internal/dto"
"with_you/internal/pkg/response" "with_you/internal/pkg/response"
"with_you/internal/service" "with_you/internal/service"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"with_you/internal/model" "with_you/internal/model"
@@ -120,4 +120,3 @@ func (h *ChannelHandler) AdminDelete(c *gin.Context) {
} }
response.Success(c, gin.H{"success": true}) response.Success(c, gin.H{"success": true})
} }

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"

View File

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

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"with_you/internal/dto" "with_you/internal/dto"
@@ -67,7 +67,7 @@ func (h *PushHandler) UnregisterDevice(c *gin.Context) {
return return
} }
err := h.pushService.UnregisterDevice(c.Request.Context(), deviceID) err := h.pushService.UnregisterDevice(c.Request.Context(), userID, deviceID)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to unregister device") response.InternalServerError(c, "failed to unregister device")
return return
@@ -148,7 +148,7 @@ func (h *PushHandler) UpdateDeviceToken(c *gin.Context) {
return return
} }
err := h.pushService.UpdateDeviceToken(c.Request.Context(), deviceID, req.PushToken) err := h.pushService.UpdateDeviceToken(c.Request.Context(), userID, deviceID, req.PushToken)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to update device token") response.InternalServerError(c, "failed to update device token")
return return

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"with_you/internal/dto" "with_you/internal/dto"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"strconv" "strconv"

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"

View File

@@ -2,10 +2,12 @@ package jpush
import ( import (
"bytes" "bytes"
"crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -26,14 +28,25 @@ type Client struct {
production bool production bool
httpClient *http.Client httpClient *http.Client
logger *zap.Logger logger *zap.Logger
lastRateLimit *RateLimitInfo
} }
func NewClient(appKey, masterSecret string, production bool, logger *zap.Logger) *Client { func NewClient(appKey, masterSecret string, production bool, logger *zap.Logger) *Client {
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
return &Client{ return &Client{
appKey: appKey, appKey: appKey,
masterSecret: masterSecret, masterSecret: masterSecret,
production: production, production: production,
httpClient: &http.Client{Timeout: 30 * time.Second}, httpClient: &http.Client{Timeout: 30 * time.Second, Transport: transport},
logger: logger, logger: logger,
} }
} }
@@ -199,6 +212,14 @@ func (c *Client) Push(payload map[string]any) (*PushResponse, *RateLimitInfo, er
return nil, rateLimit, err return nil, rateLimit, err
} }
c.lastRateLimit = rateLimit
if rateLimit != nil && rateLimit.Limit > 0 && rateLimit.Remaining < rateLimit.Limit/10 {
c.logger.Warn("jpush rate limit approaching",
zap.Int("remaining", rateLimit.Remaining),
zap.Int("limit", rateLimit.Limit),
)
}
result, parseErr := c.parseResponse(resp, respBody) result, parseErr := c.parseResponse(resp, respBody)
if parseErr != nil { if parseErr != nil {
c.logger.Error("jpush push response parse failed", c.logger.Error("jpush push response parse failed",
@@ -214,7 +235,7 @@ func (c *Client) Push(payload map[string]any) (*PushResponse, *RateLimitInfo, er
return result, rateLimit, parseErr return result, rateLimit, parseErr
} }
func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *Notification, extras map[string]any) (*PushResponse, error) { func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *Notification) (*PushResponse, error) {
if len(registrationIDs) == 0 { if len(registrationIDs) == 0 {
return nil, fmt.Errorf("registration IDs cannot be empty") return nil, fmt.Errorf("registration IDs cannot be empty")
} }
@@ -228,8 +249,6 @@ func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *N
zap.String("alert", notification.Alert), zap.String("alert", notification.Alert),
) )
mergeExtras(notification, extras)
payload := map[string]any{ payload := map[string]any{
"platform": "all", "platform": "all",
"audience": map[string]any{"registration_id": registrationIDs}, "audience": map[string]any{"registration_id": registrationIDs},
@@ -256,7 +275,7 @@ func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *N
return result, nil return result, nil
} }
func (c *Client) PushByAliases(aliases []string, notification *Notification, extras map[string]any) (*PushResponse, error) { func (c *Client) PushByAliases(aliases []string, notification *Notification) (*PushResponse, error) {
if len(aliases) == 0 { if len(aliases) == 0 {
return nil, fmt.Errorf("aliases cannot be empty") return nil, fmt.Errorf("aliases cannot be empty")
} }
@@ -270,8 +289,6 @@ func (c *Client) PushByAliases(aliases []string, notification *Notification, ext
zap.String("alert", notification.Alert), zap.String("alert", notification.Alert),
) )
mergeExtras(notification, extras)
payload := map[string]any{ payload := map[string]any{
"platform": "all", "platform": "all",
"audience": map[string]any{"alias": aliases}, "audience": map[string]any{"alias": aliases},
@@ -295,7 +312,7 @@ func (c *Client) PushByAliases(aliases []string, notification *Notification, ext
zap.Int("count", len(aliases)), zap.Int("count", len(aliases)),
zap.String("msg_id", result.MsgID), zap.String("msg_id", result.MsgID),
) )
return result, err return result, nil
} }
func (c *Client) PushAll(notification *Notification) (*PushResponse, error) { func (c *Client) PushAll(notification *Notification) (*PushResponse, error) {
@@ -325,29 +342,7 @@ func (c *Client) PushAll(notification *Notification) (*PushResponse, error) {
c.logger.Info("jpush push all success", c.logger.Info("jpush push all success",
zap.String("msg_id", result.MsgID), zap.String("msg_id", result.MsgID),
) )
return result, err return result, nil
}
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 { func BuildNotification(title, body string, notificationType string, extras map[string]any) *Notification {
@@ -394,7 +389,7 @@ func (c *Client) GetDeviceInfo(registrationID string) (*DeviceInfo, error) {
zap.String("registration_id", registrationID), zap.String("registration_id", registrationID),
) )
path := DeviceAPIBaseURL + "/v3/devices/" + url.PathEscape(registrationID) path := DeviceAPIBaseURL + "/devices/" + url.PathEscape(registrationID)
resp, body, _, err := c.doRequest("GET", path, nil) resp, body, _, err := c.doRequest("GET", path, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -427,7 +422,7 @@ func (c *Client) SetDeviceAlias(registrationID string, alias string) error {
zap.String("alias", alias), zap.String("alias", alias),
) )
path := DeviceAPIBaseURL + "/v3/devices/" + url.PathEscape(registrationID) path := DeviceAPIBaseURL + "/devices/" + url.PathEscape(registrationID)
payload := map[string]any{ payload := map[string]any{
"alias": alias, "alias": alias,
} }
@@ -461,7 +456,7 @@ func (c *Client) SetDeviceTags(registrationID string, addTags, removeTags []stri
zap.Strings("remove_tags", removeTags), zap.Strings("remove_tags", removeTags),
) )
path := DeviceAPIBaseURL + "/v3/devices/" + url.PathEscape(registrationID) path := DeviceAPIBaseURL + "/devices/" + url.PathEscape(registrationID)
tags := map[string]any{} tags := map[string]any{}
if len(addTags) > 0 { if len(addTags) > 0 {
tags["add"] = addTags tags["add"] = addTags
@@ -499,7 +494,7 @@ func (c *Client) GetAliasRegistrationIDs(alias string) ([]string, error) {
zap.String("alias", alias), zap.String("alias", alias),
) )
path := DeviceAPIBaseURL + "/v3/aliases/" + url.PathEscape(alias) path := DeviceAPIBaseURL + "/aliases/" + url.PathEscape(alias)
resp, body, _, err := c.doRequest("GET", path, nil) resp, body, _, err := c.doRequest("GET", path, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -532,7 +527,7 @@ func (c *Client) DeleteAlias(alias string) error {
zap.String("alias", alias), zap.String("alias", alias),
) )
path := DeviceAPIBaseURL + "/v3/aliases/" + url.PathEscape(alias) path := DeviceAPIBaseURL + "/aliases/" + url.PathEscape(alias)
resp, body, _, err := c.doRequest("DELETE", path, nil) resp, body, _, err := c.doRequest("DELETE", path, nil)
if err != nil { if err != nil {
return err return err
@@ -559,7 +554,7 @@ func (c *Client) RemoveAliasDevices(alias string, registrationIDs []string) erro
zap.Int("count", len(registrationIDs)), zap.Int("count", len(registrationIDs)),
) )
path := DeviceAPIBaseURL + "/v3/aliases/" + url.PathEscape(alias) path := DeviceAPIBaseURL + "/aliases/" + url.PathEscape(alias)
payload := map[string]any{ payload := map[string]any{
"registration_ids": map[string]any{ "registration_ids": map[string]any{
"remove": registrationIDs, "remove": registrationIDs,

View File

@@ -510,11 +510,8 @@ func (r *postRepository) Search(keyword string, page, pageSize int) ([]*model.Po
// 搜索标题和内容 // 搜索标题和内容
if keyword != "" { if keyword != "" {
if r.db.Dialector.Name() == "postgres" { if r.db.Dialector.Name() == "postgres" {
// PostgreSQL 使用全文检索表达式,为 pg_trgm/GIN 索引升级预留路径 searchPattern := "%" + keyword + "%"
query = query.Where( query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
"to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(content, '')) @@ plainto_tsquery('simple', ?)",
keyword,
)
} else { } else {
searchPattern := "%" + keyword + "%" searchPattern := "%" + keyword + "%"
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern) query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
@@ -880,10 +877,8 @@ func (r *postRepository) AdminList(query AdminPostListQuery) ([]*model.Post, int
// 关键词搜索(标题或内容) // 关键词搜索(标题或内容)
if query.Keyword != "" { if query.Keyword != "" {
if r.db.Dialector.Name() == "postgres" { if r.db.Dialector.Name() == "postgres" {
db = db.Where( searchPattern := "%" + query.Keyword + "%"
"to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(content, '')) @@ plainto_tsquery('simple', ?)", db = db.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
query.Keyword,
)
} else { } else {
searchPattern := "%" + query.Keyword + "%" searchPattern := "%" + query.Keyword + "%"
db = db.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern) db = db.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
@@ -1130,11 +1125,8 @@ func (r *postRepository) SearchPostsByCursor(ctx context.Context, keyword string
// 搜索标题和内容 // 搜索标题和内容
if keyword != "" { if keyword != "" {
if r.db.Dialector.Name() == "postgres" { if r.db.Dialector.Name() == "postgres" {
// PostgreSQL 使用全文检索表达式 searchPattern := "%" + keyword + "%"
query = query.Where( query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern)
"to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(content, '')) @@ plainto_tsquery('simple', ?)",
keyword,
)
} else { } else {
searchPattern := "%" + keyword + "%" searchPattern := "%" + keyword + "%"
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern) query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)

View File

@@ -439,9 +439,10 @@ func (r *userRepository) Search(keyword string, page, pageSize int) ([]*model.Us
// 搜索用户名、昵称、简介 // 搜索用户名、昵称、简介
if keyword != "" { if keyword != "" {
if r.db.Dialector.Name() == "postgres" { if r.db.Dialector.Name() == "postgres" {
searchPattern := "%" + keyword + "%"
query = query.Where( query = query.Where(
"to_tsvector('simple', COALESCE(username, '') || ' ' || COALESCE(nickname, '') || ' ' || COALESCE(bio, '')) @@ plainto_tsquery('simple', ?)", "username ILIKE ? OR nickname ILIKE ? OR bio ILIKE ?",
keyword, searchPattern, searchPattern, searchPattern,
) )
} else { } else {
// SQLite: 使用 LOWER() 实现大小写不敏感的模糊搜索 // SQLite: 使用 LOWER() 实现大小写不敏感的模糊搜索

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package service package service
import ( import (
"time" "time"
@@ -126,4 +126,3 @@ func (s *channelServiceImpl) Delete(id string) error {
s.invalidateChannelListCache() s.invalidateChannelListCache()
return nil return nil
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
package service package service
import ( import (
"with_you/internal/pkg/jwt"
"time" "time"
"with_you/internal/pkg/jwt"
) )
// JWTService JWT服务 // JWTService JWT服务

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,8 @@ const (
DefaultExpiredTime = 24 * time.Hour DefaultExpiredTime = 24 * time.Hour
// PushQueueSize 推送队列大小 // PushQueueSize 推送队列大小
PushQueueSize = 1000 PushQueueSize = 1000
// MaxDevicesPerUser 每个用户最大设备数
MaxDevicesPerUser = 10
) )
// ChatMessageSender 聊天消息发送者信息 // ChatMessageSender 聊天消息发送者信息
@@ -62,8 +64,8 @@ type PushService interface {
// 设备管理 // 设备管理
RegisterDevice(ctx context.Context, userID string, deviceID string, deviceType model.DeviceType, pushToken string) error RegisterDevice(ctx context.Context, userID string, deviceID string, deviceType model.DeviceType, pushToken string) error
UnregisterDevice(ctx context.Context, deviceID string) error UnregisterDevice(ctx context.Context, userID string, deviceID string) error
UpdateDeviceToken(ctx context.Context, deviceID string, newPushToken string) error UpdateDeviceToken(ctx context.Context, userID string, deviceID string, newPushToken string) error
// 推送记录管理 // 推送记录管理
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)
@@ -135,48 +137,15 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
} }
// 用户不在线,尝试通过极光推送直接发送 // 用户不在线,尝试通过极光推送直接发送
if s.jpushClient != nil { if s.isJPushAvailable() {
devices, err := s.deviceRepo.GetActiveByUserID(userID) devices, err := s.deviceRepo.GetActiveByUserID(userID)
if err == nil && len(devices) > 0 { if err == nil && len(devices) > 0 {
mobileDevices := make([]*model.DeviceToken, 0) mobileDevices := getMobileDevices(devices)
for _, d := range devices {
if d.SupportsMobilePush() && d.PushToken != "" {
mobileDevices = append(mobileDevices, d)
}
}
if len(mobileDevices) > 0 { if len(mobileDevices) > 0 {
title := "新消息" payload := buildMessagePayload(message)
content := dto.ExtractTextContentFromModel(message.Segments) pushErr := s.pushViaJPushBatch(mobileDevices, payload.Title, payload.Content, payload.Extras)
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 { if pushErr == nil {
for _, device := range mobileDevices { s.batchCreatePushedRecords(ctx, userID, message.ID, 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 return nil
} }
} }
@@ -247,11 +216,10 @@ func (s *pushServiceImpl) pushViaWebSocket(ctx context.Context, userID string, m
} }
// 构建普通聊天消息的 WebSocket 消息 - 使用新的 WSEventResponse 格式 // 构建普通聊天消息的 WebSocket 消息 - 使用新的 WSEventResponse 格式
// 获取会话类型 (private/group) // 根据 Category 推断 detailType群聊会话需要查询 Conversation 表获取准确类型
detailType := "private" detailType := "private"
if message.ConversationID != "" { if message.Category == model.CategoryNotification || message.IsSystemMessage() {
// 从会话中获取类型,需要查询数据库或从消息中判断 detailType = "system"
// 这里暂时默认为 privategroup 类型需要额外逻辑
} }
// 直接使用 message.Segments // 直接使用 message.Segments
@@ -275,9 +243,50 @@ func (s *pushServiceImpl) pushViaWebSocket(ctx context.Context, userID string, m
return true return true
} }
// messagePayload 推送消息的标题、内容和附加数据
type messagePayload struct {
Title string
Content string
Extras map[string]any
}
// buildMessagePayload 从消息中构建推送所需的标题、内容和附加数据
func buildMessagePayload(message *model.Message) messagePayload {
_ = 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
}
return messagePayload{
Title: title,
Content: content,
Extras: extras,
}
}
// pushViaJPush 通过极光推送发送通知 // pushViaJPush 通过极光推送发送通知
func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.DeviceToken, title, content string, extras map[string]any) error { func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.DeviceToken, title, content string, extras map[string]any) error {
if s.jpushClient == nil { if !s.isJPushAvailable() {
return errors.New("jpush client not configured") return errors.New("jpush client not configured")
} }
@@ -286,7 +295,7 @@ func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.Device
} }
notification := jpush.BuildNotification(title, content, "message", extras) notification := jpush.BuildNotification(title, content, "message", extras)
_, err := s.jpushClient.PushByRegistrationIDs([]string{device.PushToken}, notification, extras) _, err := s.jpushClient.PushByRegistrationIDs([]string{device.PushToken}, notification)
if err != nil { if err != nil {
return fmt.Errorf("jpush push failed: %w", err) return fmt.Errorf("jpush push failed: %w", err)
} }
@@ -294,9 +303,37 @@ func (s *pushServiceImpl) pushViaJPush(ctx context.Context, device *model.Device
return nil return nil
} }
// batchCreatePushedRecords 批量创建已推送成功的记录
func (s *pushServiceImpl) batchCreatePushedRecords(ctx context.Context, userID string, messageID string, devices []*model.DeviceToken) {
expiredAt := time.Now().Add(DefaultExpiredTime)
records := make([]*model.PushRecord, 0, len(devices))
for _, device := range devices {
now := time.Now()
record := &model.PushRecord{
UserID: userID,
MessageID: messageID,
PushChannel: model.PushChannelJPush,
PushStatus: model.PushStatusPushed,
DeviceToken: device.PushToken,
DeviceType: string(device.DeviceType),
MaxRetry: MaxRetryCount,
ExpiredAt: &expiredAt,
PushedAt: &now,
}
records = append(records, record)
}
if err := s.pushRepo.BatchCreate(records); err != nil {
zap.L().Error("batch create pushed records failed",
zap.String("userID", userID),
zap.String("messageID", messageID),
zap.Error(err),
)
}
}
// pushViaJPushBatch 批量通过极光推送发送通知 // pushViaJPushBatch 批量通过极光推送发送通知
func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, title, content string, extras map[string]any) error { func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, title, content string, extras map[string]any) error {
if s.jpushClient == nil { if !s.isJPushAvailable() {
return errors.New("jpush client not configured") return errors.New("jpush client not configured")
} }
@@ -312,7 +349,7 @@ func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, title,
} }
notification := jpush.BuildNotification(title, content, "message", extras) notification := jpush.BuildNotification(title, content, "message", extras)
_, err := s.jpushClient.PushByRegistrationIDs(regIDs, notification, extras) _, err := s.jpushClient.PushByRegistrationIDs(regIDs, notification)
if err != nil { if err != nil {
return fmt.Errorf("jpush batch push failed: %w", err) return fmt.Errorf("jpush batch push failed: %w", err)
} }
@@ -322,6 +359,11 @@ func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, title,
// RegisterDevice 注册设备 // RegisterDevice 注册设备
func (s *pushServiceImpl) RegisterDevice(ctx context.Context, userID string, deviceID string, deviceType model.DeviceType, pushToken string) error { func (s *pushServiceImpl) RegisterDevice(ctx context.Context, userID string, deviceID string, deviceType model.DeviceType, pushToken string) error {
existing, err := s.deviceRepo.GetByUserID(userID)
if err == nil && len(existing) >= MaxDevicesPerUser {
return fmt.Errorf("exceeded maximum device limit (%d)", MaxDevicesPerUser)
}
deviceToken := &model.DeviceToken{ deviceToken := &model.DeviceToken{
UserID: userID, UserID: userID,
DeviceID: deviceID, DeviceID: deviceID,
@@ -335,16 +377,26 @@ func (s *pushServiceImpl) RegisterDevice(ctx context.Context, userID string, dev
} }
// UnregisterDevice 注销设备 // UnregisterDevice 注销设备
func (s *pushServiceImpl) UnregisterDevice(ctx context.Context, deviceID string) error { func (s *pushServiceImpl) UnregisterDevice(ctx context.Context, userID string, deviceID string) error {
device, err := s.deviceRepo.GetByDeviceID(deviceID)
if err != nil {
return fmt.Errorf("device not found: %w", err)
}
if device.UserID != userID {
return fmt.Errorf("device does not belong to current user")
}
return s.deviceRepo.Deactivate(deviceID) return s.deviceRepo.Deactivate(deviceID)
} }
// UpdateDeviceToken 更新设备Token // UpdateDeviceToken 更新设备Token
func (s *pushServiceImpl) UpdateDeviceToken(ctx context.Context, deviceID string, newPushToken string) error { func (s *pushServiceImpl) UpdateDeviceToken(ctx context.Context, userID string, deviceID string, newPushToken string) error {
deviceToken, err := s.deviceRepo.GetByDeviceID(deviceID) deviceToken, err := s.deviceRepo.GetByDeviceID(deviceID)
if err != nil { if err != nil {
return fmt.Errorf("device not found: %w", err) return fmt.Errorf("device not found: %w", err)
} }
if deviceToken.UserID != userID {
return fmt.Errorf("device does not belong to current user")
}
deviceToken.PushToken = newPushToken deviceToken.PushToken = newPushToken
deviceToken.Activate() deviceToken.Activate()
@@ -417,58 +469,19 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
return return
} }
// 提取消息标题和内容 payload := buildMessagePayload(task.message)
_ = 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 {
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) mobileDevices := getMobileDevices(devices)
for _, device := range devices {
if device.SupportsMobilePush() && device.PushToken != "" {
mobileDevices = append(mobileDevices, device)
}
}
if len(mobileDevices) > 0 { if len(mobileDevices) > 0 {
// 优先使用极光推送 // 优先使用极光推送
if s.jpushClient != nil { if s.isJPushAvailable() {
err := s.pushViaJPushBatch(mobileDevices, title, content, extras) err := s.pushViaJPushBatch(mobileDevices, payload.Title, payload.Content, payload.Extras)
if err == nil { if err == nil {
// 极光推送成功,为每个设备创建推送记录 s.batchCreatePushedRecords(ctx, task.userID, task.message.ID, mobileDevices)
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 return
} }
// 极光推送失败,记录错误,逐个设备重试
} }
// 极光推送不可用或批量推送失败,逐个设备处理 // 极光推送不可用或批量推送失败,逐个设备处理
@@ -482,8 +495,8 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
record.DeviceType = string(device.DeviceType) record.DeviceType = string(device.DeviceType)
var pushErr error var pushErr error
if s.jpushClient != nil { if s.isJPushAvailable() {
pushErr = s.pushViaJPush(ctx, device, title, content, extras) pushErr = s.pushViaJPush(ctx, device, payload.Title, payload.Content, payload.Extras)
} else { } else {
pushErr = errors.New("jpush not configured") pushErr = errors.New("jpush not configured")
} }
@@ -510,9 +523,29 @@ func (s *pushServiceImpl) getChannelForDevice(device *model.DeviceToken) model.P
} }
} }
// retryFailedPushes 重试失败的推送 // isJPushAvailable 检查极光推送是否可用
func (s *pushServiceImpl) isJPushAvailable() bool {
return s.jpushClient != nil
}
// getMobileDevices 从设备列表中筛选出支持手机推送的活跃设备
func getMobileDevices(devices []*model.DeviceToken) []*model.DeviceToken {
mobileDevices := make([]*model.DeviceToken, 0, len(devices))
for _, d := range devices {
if d.SupportsMobilePush() && d.PushToken != "" {
mobileDevices = append(mobileDevices, d)
}
}
return mobileDevices
}
// retryFailedPushes 重试失败的推送(使用指数退避)
func (s *pushServiceImpl) retryFailedPushes() { func (s *pushServiceImpl) retryFailedPushes() {
ticker := time.NewTicker(5 * time.Minute) baseInterval := 5 * time.Minute
maxInterval := 30 * time.Minute
currentInterval := baseInterval
ticker := time.NewTicker(currentInterval)
defer ticker.Stop() defer ticker.Stop()
for { for {
@@ -520,21 +553,31 @@ func (s *pushServiceImpl) retryFailedPushes() {
case <-s.stopChan: case <-s.stopChan:
return return
case <-ticker.C: case <-ticker.C:
s.doRetry() retryCount := s.doRetry()
if retryCount > 0 {
currentInterval = baseInterval
} else {
currentInterval = currentInterval * 2
if currentInterval > maxInterval {
currentInterval = maxInterval
}
}
ticker.Reset(currentInterval)
} }
} }
} }
// doRetry 执行重试 // doRetry 执行重试,返回本次成功重试的数量
func (s *pushServiceImpl) doRetry() { func (s *pushServiceImpl) doRetry() int {
ctx := context.Background() ctx := context.Background()
// 获取失败待重试的推送 // 获取失败待重试的推送
records, err := s.pushRepo.GetFailedPushesForRetry(100) records, err := s.pushRepo.GetFailedPushesForRetry(100)
if err != nil { if err != nil {
return return 0
} }
retriedCount := 0
for _, record := range records { for _, record := range records {
// 检查是否过期 // 检查是否过期
if record.IsExpired() { if record.IsExpired() {
@@ -555,6 +598,7 @@ func (s *pushServiceImpl) doRetry() {
if s.pushViaWebSocket(ctx, record.UserID, message) { if s.pushViaWebSocket(ctx, record.UserID, message) {
record.MarkDelivered() record.MarkDelivered()
s.pushRepo.Update(record) s.pushRepo.Update(record)
retriedCount++
continue continue
} }
@@ -568,40 +612,21 @@ func (s *pushServiceImpl) doRetry() {
} }
var pushErr error var pushErr error
if s.jpushClient != nil && device.SupportsMobilePush() && device.PushToken != "" { if s.isJPushAvailable() && device.SupportsMobilePush() && device.PushToken != "" {
_ = message.Decrypt() payload := buildMessagePayload(message)
title := "新消息" pushErr = s.pushViaJPush(ctx, device, payload.Title, payload.Content, payload.Extras)
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 { if pushErr != nil {
record.MarkFailed(pushErr.Error()) record.MarkFailed(pushErr.Error())
} else { } else {
record.MarkPushed() record.MarkPushed()
retriedCount++
} }
s.pushRepo.Update(record) s.pushRepo.Update(record)
} }
} }
return retriedCount
} }
// PushChatMessage 推送聊天消息给离线用户 // PushChatMessage 推送聊天消息给离线用户
@@ -623,7 +648,7 @@ func (s *pushServiceImpl) PushChatMessage(ctx context.Context, userID string, co
} }
// 3. 获取用户的移动设备 // 3. 获取用户的移动设备
if s.jpushClient == nil { if !s.isJPushAvailable() {
return nil return nil
} }
devices, err := s.deviceRepo.GetActiveByUserID(userID) devices, err := s.deviceRepo.GetActiveByUserID(userID)
@@ -631,16 +656,16 @@ func (s *pushServiceImpl) PushChatMessage(ctx context.Context, userID string, co
return nil return nil
} }
var regIDs []string mobileDevices := getMobileDevices(devices)
for _, d := range devices { if len(mobileDevices) == 0 {
if d.SupportsMobilePush() && d.PushToken != "" {
regIDs = append(regIDs, d.PushToken)
}
}
if len(regIDs) == 0 {
return nil return nil
} }
var regIDs []string
for _, d := range mobileDevices {
regIDs = append(regIDs, d.PushToken)
}
// 4. 构建通知内容 // 4. 构建通知内容
title := sender.Name title := sender.Name
if title == "" { if title == "" {
@@ -679,7 +704,7 @@ func (s *pushServiceImpl) PushChatMessage(ctx context.Context, userID string, co
if sender.Avatar != "" { if sender.Avatar != "" {
notification.Android.LargeIcon = sender.Avatar notification.Android.LargeIcon = sender.Avatar
} }
if _, pushErr := s.jpushClient.PushByRegistrationIDs(regIDs, notification, extras); pushErr != nil { if _, pushErr := s.jpushClient.PushByRegistrationIDs(regIDs, notification); pushErr != nil {
zap.L().Error("jpush push chat message failed", zap.L().Error("jpush push chat message failed",
zap.String("userID", userID), zap.String("userID", userID),
zap.String("conversationID", conversationID), zap.String("conversationID", conversationID),
@@ -737,15 +762,10 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
} }
// 用户不在线,尝试极光推送 // 用户不在线,尝试极光推送
if s.jpushClient != nil { if s.isJPushAvailable() {
devices, err := s.deviceRepo.GetActiveByUserID(userID) devices, err := s.deviceRepo.GetActiveByUserID(userID)
if err == nil && len(devices) > 0 { if err == nil && len(devices) > 0 {
mobileDevices := make([]*model.DeviceToken, 0) mobileDevices := getMobileDevices(devices)
for _, d := range devices {
if d.SupportsMobilePush() && d.PushToken != "" {
mobileDevices = append(mobileDevices, d)
}
}
if len(mobileDevices) > 0 { if len(mobileDevices) > 0 {
content := notification.Content content := notification.Content
title := notification.Title title := notification.Title
@@ -777,7 +797,6 @@ func (s *pushServiceImpl) PushSystemNotification(ctx context.Context, userID str
return ids return ids
}(), }(),
jpushNotif, jpushNotif,
extras,
) )
if pushErr == nil { if pushErr == nil {
return nil return nil

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package service package service
import ( import (
"encoding/json" "encoding/json"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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