package main import ( "context" "encoding/json" "errors" "log" "net/http" "os" "os/signal" "strings" "sync" "syscall" "time" "github.com/golang-jwt/jwt/v5" "github.com/gorilla/websocket" "github.com/rabbitmq/amqp091-go" "github.com/redis/go-redis/v9" ) type Notification struct { UserID string `json:"userId"` Type string `json:"type"` Title string `json:"title"` Message string `json:"message"` Payload json.RawMessage `json:"payload,omitempty"` } type Hub struct { mu sync.RWMutex connections map[string]map[*websocket.Conn]struct{} redis *redis.Client } func NewHub(redisClient *redis.Client) *Hub { return &Hub{ connections: make(map[string]map[*websocket.Conn]struct{}), redis: redisClient, } } func (h *Hub) Add(ctx context.Context, userID string, conn *websocket.Conn) { h.mu.Lock() defer h.mu.Unlock() if h.connections[userID] == nil { h.connections[userID] = make(map[*websocket.Conn]struct{}) } h.connections[userID][conn] = struct{}{} _ = h.redis.SAdd(ctx, "ws:online_users", userID).Err() _ = h.redis.Set(ctx, "ws:user:"+userID, "online", 90*time.Second).Err() } func (h *Hub) Remove(ctx context.Context, userID string, conn *websocket.Conn) { h.mu.Lock() defer h.mu.Unlock() delete(h.connections[userID], conn) if len(h.connections[userID]) == 0 { delete(h.connections, userID) _ = h.redis.SRem(ctx, "ws:online_users", userID).Err() _ = h.redis.Del(ctx, "ws:user:"+userID).Err() } } func (h *Hub) Send(userID string, notification Notification) { h.mu.RLock() conns := h.connections[userID] h.mu.RUnlock() for conn := range conns { if err := conn.WriteJSON(notification); err != nil { log.Printf("Не удалось отправить уведомление пользователю %s: %v", userID, err) } } } func (h *Hub) Broadcast(notification Notification) { h.mu.RLock() defer h.mu.RUnlock() for userID, conns := range h.connections { for conn := range conns { if err := conn.WriteJSON(notification); err != nil { log.Printf("Не удалось отправить широковещательное уведомление пользователю %s: %v", userID, err) } } } } func env(key, fallback string) string { if value := os.Getenv(key); value != "" { return value } return fallback } func resolveUserID(r *http.Request) (string, error) { if token := strings.TrimSpace(r.URL.Query().Get("token")); token != "" { secret := env("JWT_ACCESS_SECRET", "docker-access-secret") parsed, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { if token.Method != jwt.SigningMethodHS256 { return nil, errors.New("неподдерживаемый алгоритм JWT") } return []byte(secret), nil }) if err != nil || !parsed.Valid { return "", err } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok { return "", errors.New("некорректные claims JWT") } sub, _ := claims["sub"].(string) if sub == "" { return "", errors.New("JWT не содержит sub") } return sub, nil } userID := strings.TrimSpace(r.URL.Query().Get("user_id")) if userID == "" { return "", errors.New("не передан token или user_id") } return userID, nil } func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() redisClient := redis.NewClient(&redis.Options{Addr: env("REDIS_ADDR", "localhost:6379")}) if err := redisClient.Ping(ctx).Err(); err != nil { log.Printf("Redis недоступен при старте, сервис продолжит переподключаться: %v", err) } hub := NewHub(redisClient) upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { userID, err := resolveUserID(r) if err != nil || userID == "" { http.Error(w, "Не удалось авторизовать WebSocket", http.StatusUnauthorized) return } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("Не удалось открыть WebSocket: %v", err) return } hub.Add(r.Context(), userID, conn) defer func() { hub.Remove(context.Background(), userID, conn) _ = conn.Close() }() for { if err := conn.SetReadDeadline(time.Now().Add(2 * time.Minute)); err != nil { log.Printf("Не удалось обновить таймаут чтения: %v", err) return } _, _, err := conn.ReadMessage() if err != nil { return } _ = redisClient.Set(r.Context(), "ws:user:"+userID, "online", 90*time.Second).Err() } }) go consumeRabbit(ctx, hub) server := &http.Server{ Addr: ":" + env("PORT", "8085"), ReadHeaderTimeout: 10 * time.Second, } go func() { log.Printf("Media WS запущен на %s", server.Addr) if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Ошибка HTTP сервера: %v", err) } }() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { log.Printf("Не удалось корректно остановить сервер: %v", err) } _ = redisClient.Close() } func consumeRabbit(ctx context.Context, hub *Hub) { url := env("RABBITMQ_URL", "amqp://lendry:lendry_password@localhost:5672/") for { if ctx.Err() != nil { return } conn, err := amqp091.Dial(url) if err != nil { log.Printf("RabbitMQ недоступен, повтор через 5 секунд: %v", err) time.Sleep(5 * time.Second) continue } channel, err := conn.Channel() if err != nil { log.Printf("Не удалось открыть RabbitMQ channel: %v", err) _ = conn.Close() time.Sleep(5 * time.Second) continue } queue, err := channel.QueueDeclare("id.notifications", true, false, false, false, nil) if err != nil { log.Printf("Не удалось объявить очередь уведомлений: %v", err) _ = channel.Close() _ = conn.Close() time.Sleep(5 * time.Second) continue } deliveries, err := channel.Consume(queue.Name, "media-ws", false, false, false, false, nil) if err != nil { log.Printf("Не удалось подписаться на уведомления: %v", err) _ = channel.Close() _ = conn.Close() time.Sleep(5 * time.Second) continue } for { select { case <-ctx.Done(): _ = channel.Close() _ = conn.Close() return case delivery, ok := <-deliveries: if !ok { _ = channel.Close() _ = conn.Close() time.Sleep(2 * time.Second) break } handleDelivery(hub, delivery) } } } } func handleDelivery(hub *Hub, delivery amqp091.Delivery) { var notification Notification if err := json.Unmarshal(delivery.Body, ¬ification); err != nil { log.Printf("Некорректное уведомление: %v", err) _ = delivery.Nack(false, false) return } if notification.UserID == "" { hub.Broadcast(notification) } else { hub.Send(notification.UserID, notification) } _ = delivery.Ack(false) }