package handler import ( "errors" "net/http" "strconv" "time" "github.com/freefire/jiang13-bbs/middleware" "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/realtime" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) // chatErrToStatus 群聊业务错误 → HTTP 状态 func chatErrToStatus(err error) int { switch { case errors.Is(err, service.ErrChatNotFound), errors.Is(err, service.ErrChatMsgNotFound): return http.StatusNotFound case errors.Is(err, service.ErrChatNotMember), errors.Is(err, service.ErrChatMuted), errors.Is(err, service.ErrChatOwnerOnly), errors.Is(err, service.ErrChatPrivateInvite), errors.Is(err, service.ErrChatRecallDenied), errors.Is(err, service.ErrChatDefaultLeave), errors.Is(err, service.ErrChatDefaultDissolve), errors.Is(err, service.ErrChatDMDissolve), errors.Is(err, service.ErrChatHallPinFixed), errors.Is(err, service.ErrChatSiteOwnerOnly), errors.Is(err, service.ErrChatMuteDenied), errors.Is(err, service.ErrChatCannotMuteOwner), errors.Is(err, service.ErrChatAdminAccess): return http.StatusForbidden case errors.Is(err, service.ErrChatDMSelf), errors.Is(err, service.ErrChatUserGone), errors.Is(err, service.ErrChatInvalidRole): return http.StatusBadRequest default: return http.StatusBadRequest } } func chatRoomID(c *gin.Context) (uint, bool) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "群 ID 无效"}) return 0, false } return uint(id), true } // formatMuteDurationCN 禁言时长中文格式化,供系统提示消息使用 func formatMuteDurationCN(minutes int) string { switch { case minutes >= 30*24*60: return "30 天" case minutes >= 7*24*60: return "7 天" case minutes >= 3*24*60: return "3 天" case minutes >= 24*60: return "1 天" case minutes >= 12*60: return "12 小时" case minutes >= 60: return "1 小时" default: return strconv.Itoa(minutes) + " 分钟" } } func (h *Handlers) chatOversee(userID uint) bool { actor, err := h.Auth.LoadActor(userID) if err != nil || actor == nil { return false } return service.CanOverseeChat(actor.Role) } // RequireMessagesOpen 前台群聊/私聊闸门;关闭后一律 403。管理端 /api/admin/chat 不受影响。 func (h *Handlers) RequireMessagesOpen(c *gin.Context) { if h.Setting == nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "消息暂时不可用"}) return } allow, err := h.Setting.AllowMessages() if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "消息暂时不可用"}) return } if !allow { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "本站不支持消息"}) return } c.Next() } // CreateChatRoom 建群(可公开/私密,私密可带初始成员) func (h *Handlers) CreateChatRoom(c *gin.Context) { claims := middleware.CurrentUser(c) var req struct { Name string `json:"name"` Description string `json:"description"` IsPrivate bool `json:"is_private"` MemberIDs []uint `json:"member_ids"` MemberUsernames []string `json:"member_usernames"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) return } memberIDs := append([]uint{}, req.MemberIDs...) for _, name := range req.MemberUsernames { if uid := h.Auth.GetUserIDByUsername(name); uid > 0 && uid != claims.ID { memberIDs = append(memberIDs, uid) } } room, err := h.Chat.CreateRoom(claims.ID, req.Name, req.Description, req.IsPrivate, memberIDs) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } // 通知被拉入的初始成员 seen := map[uint]bool{} for _, uid := range memberIDs { if uid == 0 || uid == claims.ID || seen[uid] { continue } seen[uid] = true h.Hub.BroadcastUser(uid, realtime.Envelope{ Type: realtime.EventChatMembership, Data: gin.H{"room_id": room.ID, "status": "invited"}, }) } c.JSON(http.StatusOK, gin.H{"room": room}) } // ListChatRooms 会话列表(默认 IM 侧栏仅已加入;scope=all 可浏览公开群) func (h *Handlers) ListChatRooms(c *gin.Context) { claims := middleware.CurrentUser(c) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) joinedOnly := c.DefaultQuery("scope", "joined") != "all" oversee := h.chatOversee(claims.ID) rooms, total, err := h.Chat.ListRooms(claims.ID, oversee, joinedOnly, c.Query("q"), page, size) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取会话列表失败"}) return } c.JSON(http.StatusOK, gin.H{"rooms": nonNilSlice(rooms), "total": total, "page": page, "size": size}) } // GetChatRoom 群详情(公开非成员可预览;私密非成员 404) func (h *Handlers) GetChatRoom(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } oversee := h.chatOversee(claims.ID) room, err := h.Chat.RoomDetail(claims.ID, roomID, oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"room": room}) } // UpdateChatRoom 群主改群名/简介 func (h *Handlers) UpdateChatRoom(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } var req struct { Name string `json:"name"` Description string `json:"description"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) return } room, err := h.Chat.UpdateRoom(claims.ID, roomID, req.Name, req.Description) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"room": room}) } // PinChatRoom 当前用户置顶/取消置顶会话 func (h *Handlers) PinChatRoom(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } var req struct { Pinned bool `json:"pinned"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) return } room, err := h.Chat.SetRoomPinned(claims.ID, roomID, req.Pinned) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"room": room}) } // DissolveChatRoom 群主/监管解散群 func (h *Handlers) DissolveChatRoom(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } oversee := h.chatOversee(claims.ID) members, err := h.Chat.ListMembers(claims.ID, roomID, oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } if err := h.Chat.Dissolve(claims.ID, roomID, oversee); err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } roomName := realtime.RoomChat(roomID) for _, m := range members { h.Hub.RemoveUserRoom(m.UserID, roomName) h.Hub.BroadcastUser(m.UserID, realtime.Envelope{ Type: realtime.EventChatMembership, Data: gin.H{"room_id": roomID, "status": "dissolved"}, }) } c.JSON(http.StatusOK, gin.H{"ok": true}) } // JoinChatRoom 加入公开群 func (h *Handlers) JoinChatRoom(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } _, _, err := h.Chat.Join(claims.ID, roomID) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // LeaveChatRoom 退出群 func (h *Handlers) LeaveChatRoom(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } if err := h.Chat.Leave(claims.ID, roomID); err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } h.Hub.RemoveUserRoom(claims.ID, realtime.RoomChat(roomID)) c.JSON(http.StatusOK, gin.H{"ok": true}) } // InviteChatMembers 群主/监管邀请入群 func (h *Handlers) InviteChatMembers(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } var req struct { UserIDs []uint `json:"user_ids"` Usernames []string `json:"usernames"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要邀请的用户"}) return } ids := append([]uint{}, req.UserIDs...) for _, name := range req.Usernames { if uid := h.Auth.GetUserIDByUsername(name); uid > 0 { ids = append(ids, uid) } } if len(ids) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要邀请的用户"}) return } oversee := h.chatOversee(claims.ID) added, err := h.Chat.InviteMembers(claims.ID, roomID, ids, oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } for _, uid := range added { h.Hub.BroadcastUser(uid, realtime.Envelope{ Type: realtime.EventChatMembership, Data: gin.H{"room_id": roomID, "status": "invited"}, }) } c.JSON(http.StatusOK, gin.H{"added": added}) } // ListChatMembers 成员列表 func (h *Handlers) ListChatMembers(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } oversee := h.chatOversee(claims.ID) list, err := h.Chat.ListMembers(claims.ID, roomID, oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"members": nonNilSlice(list)}) } // KickChatMember 群主踢人 func (h *Handlers) KickChatMember(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64) if err != nil || targetID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"}) return } if err := h.Chat.Kick(claims.ID, roomID, uint(targetID), h.chatOversee(claims.ID)); err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } h.Hub.RemoveUserRoom(uint(targetID), realtime.RoomChat(roomID)) h.Hub.BroadcastUser(uint(targetID), realtime.Envelope{ Type: realtime.EventChatMembership, Data: gin.H{"room_id": roomID, "status": "kicked"}, }) c.JSON(http.StatusOK, gin.H{"ok": true}) } // SetChatMemberMute 禁言/解禁成员;duration_minutes 可选:0/缺省=永久,>0=指定分钟数 func (h *Handlers) SetChatMemberMute(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64) if err != nil || targetID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"}) return } var body struct { Muted bool `json:"muted"` DurationMinutes int `json:"duration_minutes"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) return } duration := time.Duration(body.DurationMinutes) * time.Minute targetName, err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, duration, h.chatOversee(claims.ID)) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } // 向房间内广播禁言/解禁事件,客户端据此更新输入框状态与成员列表 var mutedUntil *time.Time if body.Muted && body.DurationMinutes > 0 { t := time.Now().Add(duration) mutedUntil = &t } // 落库一条系统提示消息,刷新后仍可见(走 SendSystemMessage 统一处理房间 last_message_id 等) var sysContent string if body.Muted { if body.DurationMinutes > 0 { sysContent = targetName + " 已被禁言 " + formatMuteDurationCN(body.DurationMinutes) } else { sysContent = targetName + " 已被禁言 永久" } } else { sysContent = targetName + " 的禁言已被解除" } sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent) // 广播系统消息到房间(sender_id=0,前端按系统消息渲染) if sysMsg != nil && sysMsg.ID > 0 { h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ Type: realtime.EventChatMessage, Data: gin.H{ "message": sysMsg, }, }) } h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ Type: realtime.EventChatMemberMuted, Data: gin.H{ "room_id": roomID, "user_id": uint(targetID), "muted": body.Muted, "muted_until": mutedUntil, "duration_minutes": body.DurationMinutes, "target_name": targetName, "operator_id": claims.ID, }, }) c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted, "duration_minutes": body.DurationMinutes}) } // SetChatMemberRole 站长任命/撤销群管理员 func (h *Handlers) SetChatMemberRole(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } targetID, err := strconv.ParseUint(c.Param("uid"), 10, 64) if err != nil || targetID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "用户 ID 无效"}) return } var body struct { Role string `json:"role"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) return } actor := h.loadActor(claims.ID) role := model.RoleUser if actor != nil { role = actor.Role } if err := h.Chat.SetMemberRole(role, roomID, uint(targetID), body.Role); err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "role": body.Role}) } // ListChatMessages 历史消息 func (h *Handlers) ListChatMessages(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } beforeID, _ := strconv.ParseUint(c.Query("before_id"), 10, 64) size, _ := strconv.Atoi(c.DefaultQuery("size", "30")) oversee := h.chatOversee(claims.ID) msgs, hasMore, err := h.Chat.ListMessages(claims.ID, roomID, uint(beforeID), size, oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"messages": nonNilSlice(msgs), "has_more": hasMore}) } // SendChatMessage 发消息 func (h *Handlers) SendChatMessage(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } var req struct { Content string `json:"content"` ReplyToID uint `json:"reply_to_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) return } msg, _, err := h.Chat.SendMessage(claims.ID, roomID, req.Content, req.ReplyToID) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ Type: realtime.EventChatMessage, Data: msg, }) // 向未在房内订阅的成员推未读增量(Header / 会话列表实时角标) if memberIDs, mErr := h.Chat.ListMemberIDs(roomID); mErr == nil { preview := req.Content runes := []rune(preview) if len(runes) > 80 { preview = string(runes[:80]) } for _, uid := range memberIDs { if uid == claims.ID { continue } h.Hub.BroadcastUser(uid, realtime.Envelope{ Type: realtime.EventChatUnread, Data: gin.H{ "room_id": roomID, "message_id": msg.ID, "sender_id": claims.ID, "preview": preview, "delta": 1, }, }) } } // @ 通知的 WS 红点由 Notification.CreateMention → OnNotifyNew 统一推送 c.JSON(http.StatusOK, gin.H{"message": msg}) } // RecallChatMessage 撤回消息 func (h *Handlers) RecallChatMessage(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } mid, err := strconv.ParseUint(c.Param("mid"), 10, 64) if err != nil || mid == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"}) return } oversee := h.chatOversee(claims.ID) msg, newly, err := h.Chat.RecallMessage(claims.ID, roomID, uint(mid), oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } h.broadcastChatRecalled(roomID, msg, newly) // 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传 if !oversee && msg.SenderID != claims.ID { msg.Content = "" } c.JSON(http.StatusOK, gin.H{"message": msg}) } // MarkChatRead 推进已读水位 func (h *Handlers) MarkChatRead(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) if !ok { return } var req struct { MessageID uint `json:"message_id"` } _ = c.ShouldBindJSON(&req) if err := h.Chat.MarkRead(claims.ID, roomID, req.MessageID); err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // OpenDirectChat 获取或创建与指定用户的私聊 func (h *Handlers) OpenDirectChat(c *gin.Context) { claims := middleware.CurrentUser(c) var req struct { UserID uint `json:"user_id"` } if err := c.ShouldBindJSON(&req); err != nil || req.UserID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "请指定用户"}) return } room, err := h.Chat.GetOrCreateDM(claims.ID, req.UserID) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"room": room}) } // SearchChatUsers 消息页搜索用户以发起私聊 func (h *Handlers) SearchChatUsers(c *gin.Context) { claims := middleware.CurrentUser(c) users, err := h.Chat.SearchUsersForChat(claims.ID, c.Query("q"), 10) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"}) return } c.JSON(http.StatusOK, gin.H{"users": nonNilSlice(users)}) } // ChatUnreadSummary 未读汇总 func (h *Handlers) ChatUnreadSummary(c *gin.Context) { claims := middleware.CurrentUser(c) total, rooms, err := h.Chat.UnreadSummary(claims.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取未读失败"}) return } c.JSON(http.StatusOK, gin.H{"total": total, "rooms": rooms}) } // AdminChatMessages 管理端消息监管队列;room_type=group|direct func (h *Handlers) AdminChatMessages(c *gin.Context) { claims := middleware.CurrentUser(c) actor := h.loadActor(claims.ID) if actor == nil || !h.Chat.CanAccessAdminMessages(actor) { c.JSON(http.StatusForbidden, gin.H{"error": "无权管理消息"}) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) q := c.Query("q") roomType := c.Query("room_type") msgs, total, err := h.Chat.AdminListMessages(actor, page, q, roomType) if err != nil { if errors.Is(err, service.ErrChatAdminAccess) { c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } if err.Error() == "房间类型无效" { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "加载聊天消息失败"}) return } c.JSON(http.StatusOK, gin.H{ "messages": msgs, "total": total, "page": page, "can_oversee": service.CanOverseeChat(actor.Role), "can_recall": true, "site_messages": actor.HasSiteMessagePerm(), }) } // AdminRecallChatMessage 管理端撤回群聊消息 func (h *Handlers) AdminRecallChatMessage(c *gin.Context) { claims := middleware.CurrentUser(c) actor := h.loadActor(claims.ID) if actor == nil || !h.Chat.CanAccessAdminMessages(actor) { c.JSON(http.StatusForbidden, gin.H{"error": "无权管理消息"}) return } roomID, err := strconv.ParseUint(c.Param("roomId"), 10, 64) if err != nil || roomID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "房间 ID 无效"}) return } mid, err := strconv.ParseUint(c.Param("mid"), 10, 64) if err != nil || mid == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"}) return } oversee := service.CanOverseeChat(actor.Role) msg, newly, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } h.broadcastChatRecalled(uint(roomID), msg, newly) c.JSON(http.StatusOK, gin.H{ "ok": true, "id": msg.ID, "room_id": roomID, "recalled_at": msg.RecalledAt, }) } // broadcastChatRecalled 会话内撤回与管理端撤回共用:先落库再推 chat:{roomID} func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage, newly bool) { if h.Hub == nil || msg == nil { return } data := gin.H{ "id": msg.ID, "room_id": roomID, "sender_id": msg.SenderID, "recalled_by": msg.RecalledBy, "recalled_at": msg.RecalledAt, } if msg.Recaller != nil && msg.Recaller.ID > 0 { data["recaller"] = gin.H{ "id": msg.Recaller.ID, "username": msg.Recaller.Username, "nickname": msg.Recaller.Nickname, "avatar": msg.Recaller.Avatar, "role": msg.Recaller.Role, } } env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data} h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env) // 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览 if h.Chat == nil { return } if memberIDs, err := h.Chat.ListMemberIDs(roomID); err == nil { for _, uid := range memberIDs { h.Hub.BroadcastUser(uid, env) } } // 发送时已给未读者 +1;撤回后必须回退,否则角标会停在一条已撤回的「新消息」上 if newly { h.broadcastChatUnreadDecrement(roomID, msg) } } // broadcastChatUnreadDecrement 与发信对称:给除发送者外的全体成员推 delta:-1。 // 不能按 last_read 过滤——发信时 +1 也是全员推的;另一标签页已读后首页角标仍需回退。 func (h *Handlers) broadcastChatUnreadDecrement(roomID uint, msg *model.ChatMessage) { if h.Hub == nil || h.Chat == nil || msg == nil { return } ids, err := h.Chat.ListMemberIDs(roomID) if err != nil { return } env := realtime.Envelope{ Type: realtime.EventChatUnread, Data: gin.H{ "room_id": roomID, "message_id": msg.ID, "sender_id": msg.SenderID, "delta": -1, }, } for _, uid := range ids { if uid == msg.SenderID { continue } h.Hub.BroadcastUser(uid, env) } }