Files
IdP/apps/media-ws/main.go
2026-06-24 14:37:15 +03:00

274 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, &notification); 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)
}