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
This commit is contained in:
@@ -124,7 +124,7 @@ func InitializeApp() (*App, error) {
|
|||||||
roleService := wire.ProvideRoleService(roleRepository, casbinService)
|
roleService := wire.ProvideRoleService(roleRepository, casbinService)
|
||||||
roleHandler := handler.NewRoleHandler(roleService)
|
roleHandler := handler.NewRoleHandler(roleService)
|
||||||
adminUserService := wire.ProvideAdminUserService(userRepository)
|
adminUserService := wire.ProvideAdminUserService(userRepository)
|
||||||
adminUserHandler := handler.NewAdminUserHandler(adminUserService)
|
adminUserHandler := handler.NewAdminUserHandler(adminUserService, pushService)
|
||||||
adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService)
|
adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService)
|
||||||
adminPostHandler := handler.NewAdminPostHandler(adminPostService)
|
adminPostHandler := handler.NewAdminPostHandler(adminPostService)
|
||||||
adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository)
|
adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository)
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
|||||||
@@ -499,6 +499,22 @@ type DeviceTokenResponse struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
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 ====================
|
// ==================== 推送记录 DTOs ====================
|
||||||
|
|
||||||
// PushRecordResponse 推送记录响应
|
// PushRecordResponse 推送记录响应
|
||||||
|
|||||||
@@ -199,3 +199,34 @@ func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse
|
|||||||
}
|
}
|
||||||
return result
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import (
|
|||||||
// AdminUserHandler 管理端用户处理器
|
// AdminUserHandler 管理端用户处理器
|
||||||
type AdminUserHandler struct {
|
type AdminUserHandler struct {
|
||||||
adminUserService service.AdminUserService
|
adminUserService service.AdminUserService
|
||||||
|
pushService service.PushService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminUserHandler 创建管理端用户处理器
|
// NewAdminUserHandler 创建管理端用户处理器
|
||||||
func NewAdminUserHandler(adminUserService service.AdminUserService) *AdminUserHandler {
|
func NewAdminUserHandler(adminUserService service.AdminUserService, pushService service.PushService) *AdminUserHandler {
|
||||||
return &AdminUserHandler{
|
return &AdminUserHandler{
|
||||||
adminUserService: adminUserService,
|
adminUserService: adminUserService,
|
||||||
|
pushService: pushService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,3 +105,22 @@ func (h *AdminUserHandler) UpdateUserStatus(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, userDetail)
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type DeviceTokenRepository interface {
|
|||||||
GetActiveByUserID(userID string) ([]*model.DeviceToken, error)
|
GetActiveByUserID(userID string) ([]*model.DeviceToken, error)
|
||||||
GetByDeviceID(deviceID string) (*model.DeviceToken, error)
|
GetByDeviceID(deviceID string) (*model.DeviceToken, error)
|
||||||
GetByPushToken(pushToken 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
|
DeactivateAllExcept(userID int64, deviceID string) error
|
||||||
Upsert(token *model.DeviceToken) error
|
Upsert(token *model.DeviceToken) error
|
||||||
UpdateLastUsed(deviceID string) error
|
UpdateLastUsed(deviceID string) error
|
||||||
@@ -104,6 +108,15 @@ func (r *deviceTokenRepository) GetByPushToken(pushToken string) (*model.DeviceT
|
|||||||
return &token, nil
|
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 登出其他设备(停用除指定设备外的所有设备)
|
// DeactivateAllExcept 登出其他设备(停用除指定设备外的所有设备)
|
||||||
func (r *deviceTokenRepository) DeactivateAllExcept(userID int64, deviceID string) error {
|
func (r *deviceTokenRepository) DeactivateAllExcept(userID int64, deviceID string) error {
|
||||||
return r.db.Model(&model.DeviceToken{}).
|
return r.db.Model(&model.DeviceToken{}).
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ func (r *Router) setupRoutes() {
|
|||||||
admin.GET("/users", r.AdminUserHandler.GetUserList)
|
admin.GET("/users", r.AdminUserHandler.GetUserList)
|
||||||
admin.GET("/users/:id", r.AdminUserHandler.GetUserDetail)
|
admin.GET("/users/:id", r.AdminUserHandler.GetUserDetail)
|
||||||
admin.PUT("/users/:id/status", r.AdminUserHandler.UpdateUserStatus)
|
admin.PUT("/users/:id/status", r.AdminUserHandler.UpdateUserStatus)
|
||||||
|
admin.GET("/users/:id/devices", r.AdminUserHandler.GetUserDevices)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 帖子管理
|
// 帖子管理
|
||||||
|
|||||||
@@ -627,13 +627,9 @@ func (s *chatServiceImpl) inlinePush(ctx context.Context, conv *model.Conversati
|
|||||||
}
|
}
|
||||||
allTargetIDs = append(allTargetIDs, p.UserID)
|
allTargetIDs = append(allTargetIDs, p.UserID)
|
||||||
}
|
}
|
||||||
var offlineTargetIDs []string
|
// 直接对所有参与者推送(不按 WS 在线过滤):
|
||||||
if s.wsHub != nil && len(allTargetIDs) > 0 {
|
// 多设备场景下某端在线不代表所有端在线,由 PushChatMessage 推送到用户所有移动设备
|
||||||
_, offlineTargetIDs = s.wsHub.FilterOnline(allTargetIDs)
|
if len(allTargetIDs) > 0 {
|
||||||
} else {
|
|
||||||
offlineTargetIDs = allTargetIDs
|
|
||||||
}
|
|
||||||
if len(offlineTargetIDs) > 0 {
|
|
||||||
go func(ids []string, sender ChatMessageSender, gid string, gavatar string) {
|
go func(ids []string, sender ChatMessageSender, gid string, gavatar string) {
|
||||||
for _, uid := range ids {
|
for _, uid := range ids {
|
||||||
if pushErr := s.pushSvc.PushChatMessage(context.Background(), uid, conversationID, &sender, convType, convName, message, gid, gavatar); pushErr != nil {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ type PushService interface {
|
|||||||
PushToUser(ctx context.Context, userID string, message *model.Message, priority int) error
|
PushToUser(ctx context.Context, userID string, message *model.Message, priority int) error
|
||||||
|
|
||||||
// 聊天消息推送(含免打扰检查)
|
// 聊天消息推送(含免打扰检查)
|
||||||
// 该方法会:1. 检查用户是否在线(在线则跳过);2. 检查会话免打扰(免打扰则跳过);3. 通过 JPush 推送
|
// 该方法会: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
|
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 注册设备
|
// RegisterDevice 注册设备
|
||||||
|
// 处理同设备多账号场景:registration_id(pushToken)是设备级标识,
|
||||||
|
// 同一物理设备登录新账号时 pushToken 不变,需先失效其他用户对该 pushToken 的关联,
|
||||||
|
// 避免给一个账号推送时同设备的其他账号也收到通知。
|
||||||
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)
|
existing, err := s.deviceRepo.GetByUserID(userID)
|
||||||
if err == nil && len(existing) >= MaxDevicesPerUser {
|
if err == nil && len(existing) >= MaxDevicesPerUser {
|
||||||
return fmt.Errorf("exceeded maximum device limit (%d)", 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{
|
deviceToken := &model.DeviceToken{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
DeviceID: deviceID,
|
DeviceID: deviceID,
|
||||||
@@ -739,14 +755,16 @@ func (s *pushServiceImpl) doRetry() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PushChatMessage 推送聊天消息给离线用户
|
// 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 {
|
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 收到消息)
|
// 0. 不给消息发送者自己推送(纵深防御,上游应已排除)
|
||||||
if s.wsHub != nil && s.wsHub.HasClients(userID) {
|
if sender != nil && userID == sender.ID {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查免打扰状态
|
// 1. 检查免打扰状态
|
||||||
participant, err := s.messageRepo.GetParticipant(conversationID, userID)
|
participant, err := s.messageRepo.GetParticipant(conversationID, userID)
|
||||||
if err == nil && participant.NotificationMuted {
|
if err == nil && participant.NotificationMuted {
|
||||||
zap.L().Debug("push chat message skipped: notification muted",
|
zap.L().Debug("push chat message skipped: notification muted",
|
||||||
|
|||||||
@@ -342,14 +342,9 @@ func (w *PushWorker) handleMsg(streamMsg redis.XMessage) error {
|
|||||||
allTargetIDs = append(allTargetIDs, p.UserID)
|
allTargetIDs = append(allTargetIDs, p.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var offlineTargetIDs []string
|
// 直接对所有参与者推送(不按 WS 在线过滤):
|
||||||
if w.publisher != nil && len(allTargetIDs) > 0 {
|
// 多设备场景下某端在线不代表所有端在线,由 PushChatMessage 推送到用户所有移动设备
|
||||||
_, offlineTargetIDs = w.publisher.FilterOnline(allTargetIDs)
|
for _, uid := range allTargetIDs {
|
||||||
} else {
|
|
||||||
offlineTargetIDs = allTargetIDs
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, uid := range offlineTargetIDs {
|
|
||||||
if pushErr := w.pushSvc.PushChatMessage(ctx, uid, pushMsg.ConversationID, &sender, conv.Type, convName, message, groupID, groupAvatar); pushErr != nil {
|
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.L().Debug("push worker: JPush chat message failed",
|
||||||
zap.String("userID", uid),
|
zap.String("userID", uid),
|
||||||
|
|||||||
Reference in New Issue
Block a user