From cb85c62093fa6f1ac62dd6e21ba9e8fcb193c503 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 18 Jun 2026 20:07:24 +0800 Subject: [PATCH] feat(admin): add admin endpoint to list user devices with JPush registration IDs - Add AdminDeviceTokenResponse DTO including push_token (RegistrationID) for push debugging - Add DeviceTokenToAdminResponse and DeviceTokensToAdminResponse converters - Inject PushService into AdminUserHandler for retrieving user devices - Register GET /api/v1/admin/users/:id/devices route for querying target user's device tokens --- cmd/server/wire_gen.go | 2 +- go.sum | 2 ++ internal/dto/dto.go | 16 ++++++++++++ internal/dto/message_converter.go | 31 ++++++++++++++++++++++++ internal/handler/admin_user_handler.go | 23 +++++++++++++++++- internal/repository/device_token_repo.go | 13 ++++++++++ internal/router/router.go | 1 + internal/service/chat_service.go | 12 +++------ internal/service/push_service.go | 28 +++++++++++++++++---- internal/service/push_worker.go | 11 +++------ 10 files changed, 116 insertions(+), 23 deletions(-) diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index d326c26..ce9b043 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -124,7 +124,7 @@ func InitializeApp() (*App, error) { roleService := wire.ProvideRoleService(roleRepository, casbinService) roleHandler := handler.NewRoleHandler(roleService) adminUserService := wire.ProvideAdminUserService(userRepository) - adminUserHandler := handler.NewAdminUserHandler(adminUserService) + adminUserHandler := handler.NewAdminUserHandler(adminUserService, pushService) adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService) adminPostHandler := handler.NewAdminPostHandler(adminPostService) adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository) diff --git a/go.sum b/go.sum index 0ba4593..6e88ad7 100644 --- a/go.sum +++ b/go.sum @@ -162,6 +162,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/dto/dto.go b/internal/dto/dto.go index 8cb62cc..3f5c34d 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -499,6 +499,22 @@ type DeviceTokenResponse struct { CreatedAt time.Time `json:"created_at"` } +// AdminDeviceTokenResponse 管理端设备Token响应 +// 相比 DeviceTokenResponse,额外包含 user_id 与 push_token(即 JPush RegistrationID), +// 供后台查询用户设备 registration_id 使用。 +type AdminDeviceTokenResponse struct { + ID int64 `json:"id"` + UserID string `json:"user_id"` + DeviceID string `json:"device_id"` + DeviceType string `json:"device_type"` + PushToken string `json:"push_token,omitempty"` // JPush RegistrationID + IsActive bool `json:"is_active"` + DeviceName string `json:"device_name,omitempty"` + LastUsedAt time.Time `json:"last_used_at,omitzero"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // ==================== 推送记录 DTOs ==================== // PushRecordResponse 推送记录响应 diff --git a/internal/dto/message_converter.go b/internal/dto/message_converter.go index f00e4cb..aa47cd9 100644 --- a/internal/dto/message_converter.go +++ b/internal/dto/message_converter.go @@ -199,3 +199,34 @@ func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse } return result } + +// DeviceTokenToAdminResponse 将DeviceToken转换为管理端响应(含 registration_id) +func DeviceTokenToAdminResponse(token *model.DeviceToken) *AdminDeviceTokenResponse { + if token == nil { + return nil + } + resp := &AdminDeviceTokenResponse{ + ID: token.ID, + UserID: token.UserID, + DeviceID: token.DeviceID, + DeviceType: string(token.DeviceType), + PushToken: token.PushToken, + IsActive: token.IsActive, + DeviceName: token.DeviceName, + CreatedAt: token.CreatedAt, + UpdatedAt: token.UpdatedAt, + } + if token.LastUsedAt != nil { + resp.LastUsedAt = *token.LastUsedAt + } + return resp +} + +// DeviceTokensToAdminResponse 将DeviceToken列表转换为管理端响应列表 +func DeviceTokensToAdminResponse(tokens []*model.DeviceToken) []*AdminDeviceTokenResponse { + result := make([]*AdminDeviceTokenResponse, 0, len(tokens)) + for _, token := range tokens { + result = append(result, DeviceTokenToAdminResponse(token)) + } + return result +} diff --git a/internal/handler/admin_user_handler.go b/internal/handler/admin_user_handler.go index 3bcccd0..f29696e 100644 --- a/internal/handler/admin_user_handler.go +++ b/internal/handler/admin_user_handler.go @@ -14,12 +14,14 @@ import ( // AdminUserHandler 管理端用户处理器 type AdminUserHandler struct { adminUserService service.AdminUserService + pushService service.PushService } // NewAdminUserHandler 创建管理端用户处理器 -func NewAdminUserHandler(adminUserService service.AdminUserService) *AdminUserHandler { +func NewAdminUserHandler(adminUserService service.AdminUserService, pushService service.PushService) *AdminUserHandler { return &AdminUserHandler{ adminUserService: adminUserService, + pushService: pushService, } } @@ -103,3 +105,22 @@ func (h *AdminUserHandler) UpdateUserStatus(c *gin.Context) { response.Success(c, userDetail) } + +// GetUserDevices 获取用户设备列表(含 registration_id) +// GET /api/v1/admin/users/:id/devices +// 供后台查询目标用户各设备的 JPush RegistrationID(push_token),用于排查推送问题或手动推送测试。 +func (h *AdminUserHandler) GetUserDevices(c *gin.Context) { + userID := c.Param("id") + if userID == "" { + response.BadRequest(c, "user id is required") + return + } + + devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID) + if err != nil { + response.InternalServerError(c, "failed to get user devices") + return + } + + response.Success(c, dto.DeviceTokensToAdminResponse(devices)) +} diff --git a/internal/repository/device_token_repo.go b/internal/repository/device_token_repo.go index 0391121..47e084e 100644 --- a/internal/repository/device_token_repo.go +++ b/internal/repository/device_token_repo.go @@ -18,6 +18,10 @@ type DeviceTokenRepository interface { GetActiveByUserID(userID string) ([]*model.DeviceToken, error) GetByDeviceID(deviceID string) (*model.DeviceToken, error) GetByPushToken(pushToken string) (*model.DeviceToken, error) + // DeactivateByPushToken 失效指定 pushToken 的所有记录 + // 用于处理同设备多账号场景:registration_id 是设备级标识, + // 当新账号在同一设备注册时,需失效旧账号对该 pushToken 的关联,避免跨账号误推 + DeactivateByPushToken(pushToken string) error DeactivateAllExcept(userID int64, deviceID string) error Upsert(token *model.DeviceToken) error UpdateLastUsed(deviceID string) error @@ -104,6 +108,15 @@ func (r *deviceTokenRepository) GetByPushToken(pushToken string) (*model.DeviceT return &token, nil } +// DeactivateByPushToken 失效指定 pushToken 的所有记录 +// registration_id(pushToken)是设备级标识,同一物理设备登录不同账号时 pushToken 不变。 +// 失效旧关联可防止给账号 A 推送时,同设备的账号 B 也收到通知。 +func (r *deviceTokenRepository) DeactivateByPushToken(pushToken string) error { + return r.db.Model(&model.DeviceToken{}). + Where("push_token = ?", pushToken). + Update("is_active", false).Error +} + // DeactivateAllExcept 登出其他设备(停用除指定设备外的所有设备) func (r *deviceTokenRepository) DeactivateAllExcept(userID int64, deviceID string) error { return r.db.Model(&model.DeviceToken{}). diff --git a/internal/router/router.go b/internal/router/router.go index 9e29332..25ceed5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -546,6 +546,7 @@ func (r *Router) setupRoutes() { admin.GET("/users", r.AdminUserHandler.GetUserList) admin.GET("/users/:id", r.AdminUserHandler.GetUserDetail) admin.PUT("/users/:id/status", r.AdminUserHandler.UpdateUserStatus) + admin.GET("/users/:id/devices", r.AdminUserHandler.GetUserDevices) } // 帖子管理 diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 1c91928..61a6be2 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -627,13 +627,9 @@ func (s *chatServiceImpl) inlinePush(ctx context.Context, conv *model.Conversati } allTargetIDs = append(allTargetIDs, p.UserID) } - var offlineTargetIDs []string - if s.wsHub != nil && len(allTargetIDs) > 0 { - _, offlineTargetIDs = s.wsHub.FilterOnline(allTargetIDs) - } else { - offlineTargetIDs = allTargetIDs - } - if len(offlineTargetIDs) > 0 { + // 直接对所有参与者推送(不按 WS 在线过滤): + // 多设备场景下某端在线不代表所有端在线,由 PushChatMessage 推送到用户所有移动设备 + if len(allTargetIDs) > 0 { go func(ids []string, sender ChatMessageSender, gid string, gavatar string) { for _, uid := range ids { if pushErr := s.pushSvc.PushChatMessage(context.Background(), uid, conversationID, &sender, convType, convName, message, gid, gavatar); pushErr != nil { @@ -644,7 +640,7 @@ func (s *chatServiceImpl) inlinePush(ctx context.Context, conv *model.Conversati ) } } - }(offlineTargetIDs, sender, groupID, groupAvatar) + }(allTargetIDs, sender, groupID, groupAvatar) } } } diff --git a/internal/service/push_service.go b/internal/service/push_service.go index 5dd4e4b..2c93851 100644 --- a/internal/service/push_service.go +++ b/internal/service/push_service.go @@ -55,7 +55,8 @@ type PushService interface { PushToUser(ctx context.Context, userID string, message *model.Message, priority int) error // 聊天消息推送(含免打扰检查) - // 该方法会:1. 检查用户是否在线(在线则跳过);2. 检查会话免打扰(免打扰则跳过);3. 通过 JPush 推送 + // 该方法会:1. 不给发送者自己推送;2. 检查会话免打扰(免打扰则跳过);3. 通过 JPush 推送到用户所有移动设备 + // 不检查 WS 在线状态:多设备场景下直接推送到所有活跃移动设备 PushChatMessage(ctx context.Context, userID string, conversationID string, sender *ChatMessageSender, convType model.ConversationType, convName string, message *model.Message, groupID string, groupAvatar string) error // 系统消息推送 @@ -382,12 +383,27 @@ func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, params } // RegisterDevice 注册设备 +// 处理同设备多账号场景:registration_id(pushToken)是设备级标识, +// 同一物理设备登录新账号时 pushToken 不变,需先失效其他用户对该 pushToken 的关联, +// 避免给一个账号推送时同设备的其他账号也收到通知。 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) } + // 失效其他用户/设备对同一 pushToken 的关联(防止跨账号误推) + if pushToken != "" { + if dErr := s.deviceRepo.DeactivateByPushToken(pushToken); dErr != nil { + // 失效失败不阻断注册流程,仅记录日志 + zap.L().Warn("deactivate devices by push token failed", + zap.String("userID", userID), + zap.String("deviceID", deviceID), + zap.Error(dErr), + ) + } + } + deviceToken := &model.DeviceToken{ UserID: userID, DeviceID: deviceID, @@ -739,14 +755,16 @@ func (s *pushServiceImpl) doRetry() int { } // PushChatMessage 推送聊天消息给离线用户 -// 检查用户在线状态和免打扰设置,仅对离线且未免打扰的用户通过 JPush 推送 +// PushChatMessage 推送聊天消息给用户的所有移动设备 +// 不检查 WebSocket 在线状态:多设备场景下某端在线不代表所有端都在线, +// 直接推送到用户所有活跃移动设备,避免 PC 在线时手机收不到通知 func (s *pushServiceImpl) PushChatMessage(ctx context.Context, userID string, conversationID string, sender *ChatMessageSender, convType model.ConversationType, convName string, message *model.Message, groupID string, groupAvatar string) error { - // 1. 用户在线则跳过(已通过 WebSocket 收到消息) - if s.wsHub != nil && s.wsHub.HasClients(userID) { + // 0. 不给消息发送者自己推送(纵深防御,上游应已排除) + if sender != nil && userID == sender.ID { return nil } - // 2. 检查免打扰状态 + // 1. 检查免打扰状态 participant, err := s.messageRepo.GetParticipant(conversationID, userID) if err == nil && participant.NotificationMuted { zap.L().Debug("push chat message skipped: notification muted", diff --git a/internal/service/push_worker.go b/internal/service/push_worker.go index cacf682..4ec5147 100644 --- a/internal/service/push_worker.go +++ b/internal/service/push_worker.go @@ -342,14 +342,9 @@ func (w *PushWorker) handleMsg(streamMsg redis.XMessage) error { allTargetIDs = append(allTargetIDs, p.UserID) } - var offlineTargetIDs []string - if w.publisher != nil && len(allTargetIDs) > 0 { - _, offlineTargetIDs = w.publisher.FilterOnline(allTargetIDs) - } else { - offlineTargetIDs = allTargetIDs - } - - for _, uid := range offlineTargetIDs { + // 直接对所有参与者推送(不按 WS 在线过滤): + // 多设备场景下某端在线不代表所有端在线,由 PushChatMessage 推送到用户所有移动设备 + for _, uid := range allTargetIDs { if pushErr := w.pushSvc.PushChatMessage(ctx, uid, pushMsg.ConversationID, &sender, conv.Type, convName, message, groupID, groupAvatar); pushErr != nil { zap.L().Debug("push worker: JPush chat message failed", zap.String("userID", uid),