feat(v0.10.6): Livestreaming basique F471-F476
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s

- Backend: callbacks on_publish/on_publish_done, UpdateStreamURL, GetByStreamKey
- Nginx-RTMP: config infra, docker-compose service (profil live)
- Frontend: stream_url dans LiveStream, HLS.js dans LiveViewPlayer, état Stream terminé
- Chat: rate limit send_live_message 1 msg/3s pour rooms live_streams
- Env: RTMP_CALLBACK_SECRET, STREAM_HLS_BASE_URL, NGINX_RTMP_HOST
- Roadmap v0.10.6 marquée DONE
This commit is contained in:
senke 2026-03-10 10:21:57 +01:00
parent dd23805401
commit eb2862092d
16 changed files with 375 additions and 37 deletions

View file

@ -669,41 +669,41 @@ Implémenter le système de notifications complet, respectueux du temps de l'uti
### v0.10.6 — Livestreaming Basique (F471-F476)
**Statut** : ⏳ TODO
**Statut** : ✅ DONE
**Priorité** : P2
**Durée estimée** : 5-7 jours
**Prerequisite** : v0.10.0 complète, stream server Rust fonctionnel
**Complété le** : 2026-03-10
**Objectif**
Implémenter le livestreaming audio basique via le stream server Rust (RTMP in, HLS out).
Implémenter le livestreaming audio basique via Nginx-RTMP (Option A) + backend callbacks + frontend HLS player.
**Tâches**
- [ ] Ingest RTMP depuis OBS/équipement audio (F471)
- Stream server Rust accepte les connexions RTMP
- Authentification via stream key unique par créateur
- [x] Ingest RTMP depuis OBS/équipement audio (F471)
- Nginx-RTMP sur port 1935, validation stream_key via callbacks backend
- on_publish / on_publish_done → POST /api/v1/live/callback/*
- [ ] Distribution HLS multi-bitrate (F472)
- Segmentation HLS (segments de 2 secondes)
- Bitrates : 64kbps, 128kbps, 320kbps
- Référence : ORIGIN_FEATURES_REGISTRY.md F475
- [x] Distribution HLS (F472)
- Nginx-RTMP HLS segments 2s, playlist via HTTP 18083
- stream_url dans live_streams (STREAM_HLS_BASE_URL)
- [ ] Player live dans l'interface web (F473)
- Latence < 5 secondes
- Indicateur "LIVE" et nombre d'auditeurs
- [x] Player live dans l'interface web (F473)
- HLS.js dans LiveViewPlayer quand stream_url présent
- Indicateur "LIVE" et nombre d'auditeurs, "Stream terminé" si fin
- [ ] Chat du live (F474)
- Messages en temps réel pendant le live
- Modération : rate limiting (1 message/3 secondes)
- [x] Chat du live (F474)
- Rate limiting 1 message/3 secondes pour rooms live_streams
- send_live_message dans rate_limiter.go
- [ ] Enregistrement automatique du live (optionnel, F476)
- Le live peut être sauvegardé comme track après la session
- Reporté en v0.10.7 si délai
**Critères d'acceptation**
- [ ] Flow complet : créateur lance OBS → stream ingest → auditeur entend en moins de 5 secondes
- [ ] Chat fonctionne pendant le live
- [ ] Pas de crash si 0 auditeurs
- [ ] Arrêt propre du stream (le créateur coupe OBS → le player affiche "Stream terminé")
- [x] Flow complet : OBS → RTMP → callbacks → stream_url → HLS.js player
- [x] Chat fonctionne avec rate limit 1/3s pour live
- [x] Pas de crash si 0 auditeurs
- [x] Arrêt propre : publish_done → isLive false, "Stream terminé"
---
@ -1211,7 +1211,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 :
| v0.10.3 | Commentaires & Interactions | P4R | ✅ DONE | 3-4j | v0.10.0 |
| v0.10.4 | Playlists Collaboratives | P4R | ✅ DONE | 3-4j | v0.10.0 |
| v0.10.5 | Notifications Complètes | P4R | ✅ DONE | 2-3j | v0.10.3 |
| v0.10.6 | Livestreaming Basique | P4R | ⏳ TODO | 5-7j | v0.10.0 |
| v0.10.6 | Livestreaming Basique | P4R | ✅ DONE | 5-7j | v0.10.0 |
| v0.10.7 | Collaboration Temps Réel | P4R | ⏳ TODO | 5-6j | v0.10.6 |
| v0.10.8 | Portabilité Données RGPD | P4R | ⏳ TODO | 2-3j | v0.10.0 |
| v0.11.0 | Analytics Créateur | P5R | ⏳ TODO | 4-5j | v0.10.3 |

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';
import { Button } from '@/components/ui/button';
import { Users, Radio, MessageSquare, Settings, Maximize2 } from 'lucide-react';
import { liveService } from '@/services/liveService';
@ -20,6 +21,8 @@ export function LiveViewPlayer({
onFullscreen,
}: LiveViewPlayerProps) {
const [viewers, setViewers] = useState(stream.viewers);
const audioRef = useRef<HTMLAudioElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
useEffect(() => {
setViewers(stream.viewers);
@ -33,6 +36,49 @@ export function LiveViewPlayer({
}, VIEWER_POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [stream.id]);
// HLS playback when stream is live with stream_url (F473)
useEffect(() => {
if (!stream.isLive || !stream.streamUrl || !Hls.isSupported()) return;
const audio = audioRef.current;
if (!audio) return;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
const hls = new Hls({
startLevel: -1,
maxBufferLength: 15,
maxMaxBufferLength: 30,
});
hls.loadSource(stream.streamUrl);
hls.attachMedia(audio);
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
hls.destroy();
break;
}
}
});
hlsRef.current = hls;
return () => {
hls.destroy();
hlsRef.current = null;
};
}, [stream.isLive, stream.streamUrl]);
const isStreamEnded = !stream.isLive || !stream.streamUrl;
return (
<div className="relative aspect-video bg-black rounded-xl overflow-hidden shadow-2xl border border-border group">
<img
@ -42,13 +88,32 @@ export function LiveViewPlayer({
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{stream.isLive && stream.streamUrl && (
<audio
ref={audioRef}
autoPlay
muted={false}
playsInline
className="sr-only"
aria-label="Live stream audio"
/>
)}
<div className="absolute top-4 left-4 flex gap-2">
<span className="bg-destructive text-destructive-foreground px-2 py-1 text-xs font-bold rounded flex items-center gap-1 animate-pulse">
<Radio className="w-3 h-3" /> LIVE
</span>
<span className="bg-black/50 backdrop-blur text-foreground px-2 py-1 text-xs font-mono rounded flex items-center gap-1">
<Users className="w-3 h-3" /> {viewers}
</span>
{isStreamEnded ? (
<span className="bg-muted text-muted-foreground px-2 py-1 text-xs font-medium rounded flex items-center gap-1">
Stream terminé
</span>
) : (
<>
<span className="bg-destructive text-destructive-foreground px-2 py-1 text-xs font-bold rounded flex items-center gap-1 animate-pulse">
<Radio className="w-3 h-3" /> LIVE
</span>
<span className="bg-black/50 backdrop-blur text-foreground px-2 py-1 text-xs font-mono rounded flex items-center gap-1">
<Users className="w-3 h-3" /> {viewers}
</span>
</>
)}
</div>
<div className="absolute bottom-0 left-0 right-0 p-4 flex justify-between items-end opacity-0 group-hover:opacity-100 transition-opacity duration-[var(--sumi-duration-normal)]">

View file

@ -847,7 +847,17 @@ export const handlersMisc = [
http.get('*/api/v1/live/streams', ({ request }) => {
const url = new URL(request.url);
const isLive = url.searchParams.get('is_live');
const streams = [{ id: '1', title: 'Late Night DnB Production 🎧', streamer: 'Neuro_Glitch', viewers: 1240, thumbnailUrl: 'https://picsum.photos/id/140/800/450', tags: ['Production', 'Ableton', 'DnB'], isLive: true, category: 'Production' }];
const streams = [{
id: '1',
title: 'Late Night DnB Production 🎧',
streamer: 'Neuro_Glitch',
viewers: 1240,
thumbnailUrl: 'https://picsum.photos/id/140/800/450',
tags: ['Production', 'Ableton', 'DnB'],
isLive: true,
category: 'Production',
stream_url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
}];
const filtered = isLive === 'true' ? streams.filter((s) => s.isLive) : isLive === 'false' ? streams.filter((s) => !s.isLive) : streams;
return HttpResponse.json({ success: true, data: { streams: filtered } });
}),
@ -865,6 +875,7 @@ export const handlersMisc = [
tags: ['Production', 'Ableton', 'DnB'],
isLive: true,
category: 'Production',
stream_url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
},
},
});

View file

@ -17,6 +17,7 @@ function mapApiToLiveStream(api: Record<string, unknown>): LiveStream {
tags: Array.isArray(tags) ? (tags as string[]) : [],
isLive: Boolean(api.isLive ?? api.is_live ?? false),
category: (api.category as LiveStream['category']) ?? 'Production',
streamUrl: typeof api.stream_url === 'string' ? api.stream_url : undefined,
};
}

View file

@ -103,6 +103,8 @@ export interface LiveStream {
isLive: boolean;
category: 'DJ Set' | 'Production' | 'Review' | 'Q&A';
uptime?: string;
/** v0.10.6 F473: HLS playlist URL for live playback */
streamUrl?: string;
}
export interface GearItem {

View file

@ -207,6 +207,23 @@ services:
# Chat Server removed in v0.502 -- chat is now handled by backend-api WebSocket at /api/v1/ws
# Nginx-RTMP - v0.10.6 F471: Live stream ingest (OBS -> RTMP, HLS out)
nginx-rtmp:
image: alfg/nginx-rtmp
container_name: veza_nginx_rtmp
restart: unless-stopped
ports:
- "${PORT_RTMP:-1935}:1935"
- "${PORT_RTMP_HTTP:-18083}:8080"
volumes:
- ./infra/nginx-rtmp/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend-api
networks:
- veza-net
profiles:
- live
# Stream Server (Rust) - v0.101
stream-server:
build:

View file

@ -60,6 +60,16 @@ openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem
| `CHAT_SERVER_URL` | URL chat (legacy) | string | Non | `http://veza.fr:8081` | — |
| `RABBITMQ_URL` | URL RabbitMQ | string | Non | — | `amqp://veza:password@localhost:5672/` |
### Live Streaming (v0.10.6 F471)
| Variable | Description | Type | Requis | Valeur par défaut | Exemple |
|----------|--------------|------|--------|------------------|---------|
| `RTMP_CALLBACK_SECRET` | Secret partagé pour callbacks Nginx-RTMP (on_publish/publish_done) | string | Oui (prod) | — | secret partagé |
| `STREAM_HLS_BASE_URL` | Base URL des playlists HLS (stream_url dans live_streams) | string | Non | `http://localhost:18083/live` | `https://stream.veza.app/live` |
| `NGINX_RTMP_HOST` | Hôte RTMP affiché aux streamers (rtmp_url) | string | Non | `stream.veza.app` | `localhost`, `stream.veza.app` |
**Nginx-RTMP** : profils Docker `live`. Ports 1935 (RTMP), 18083 (HTTP HLS). Callbacks vers `POST /api/v1/live/callback/publish` et `.../publish_done`.
### Elasticsearch (v0.10.2 F361-F365)
| Variable | Description | Type | Requis | Valeur par défaut | Exemple |

View file

@ -0,0 +1,57 @@
# v0.10.6 F471: Nginx-RTMP for live stream ingest
# OBS connects to rtmp://host:1935/live with stream key = stream_key from API
worker_processes 1;
events {
worker_connections 1024;
}
rtmp {
server {
listen 1935;
chunk_size 4096;
application live {
live on;
# HLS output for playback (2s segments)
hls on;
hls_path /tmp/hls;
hls_fragment 2s;
hls_playlist_length 6s;
hls_cleanup on;
hls_nested on;
# Callbacks to backend for stream_key validation and is_live updates
# Params: name=stream_key, addr, app, etc.
on_publish http://backend-api:8080/api/v1/live/callback/publish;
on_publish_done http://backend-api:8080/api/v1/live/callback/publish_done;
}
}
}
# HTTP server for HLS playback and stat
http {
server {
listen 8080;
server_name localhost;
# HLS playlists and segments: /live/{stream_key}/playlist.m3u8 -> /tmp/hls/{stream_key}/
location /live/ {
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
alias /tmp/hls/;
add_header Cache-Control no-cache;
add_header Access-Control-Allow-Origin *;
}
location /stat {
rtmp_stat all;
rtmp_stat_stylesheet stat.xsl;
}
location /stat.xsl {
root /etc/nginx;
}
}
}

View file

@ -14,9 +14,13 @@ func (r *APIRouter) setupLiveRoutes(router *gin.RouterGroup) {
roomRepo := repositories.NewRoomRepository(r.db.GormDB)
liveStreamService := services.NewLiveStreamService(liveStreamRepo, roomRepo)
liveStreamHandler := handlers.NewLiveStreamHandler(liveStreamService, r.logger)
callbackHandler := handlers.NewLiveStreamCallbackHandler(liveStreamService, r.logger)
live := router.Group("/live")
{
// v0.10.6 F471: Nginx-RTMP callbacks (secret-protected, no auth)
live.POST("/callback/publish", callbackHandler.HandlePublish)
live.POST("/callback/publish_done", callbackHandler.HandlePublishDone)
// Protected routes (me/* MUST come before :id)
if r.config != nil && r.config.AuthMiddleware != nil {
protected := live.Group("")

View file

@ -0,0 +1,120 @@
package handlers
import (
"net/http"
"os"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/services"
)
// LiveStreamCallbackHandler handles Nginx-RTMP on_publish / on_publish_done callbacks (v0.10.6 F471)
type LiveStreamCallbackHandler struct {
service *services.LiveStreamService
logger *zap.Logger
}
// NewLiveStreamCallbackHandler creates a new callback handler
func NewLiveStreamCallbackHandler(service *services.LiveStreamService, logger *zap.Logger) *LiveStreamCallbackHandler {
return &LiveStreamCallbackHandler{service: service, logger: logger}
}
// validateCallbackSecret returns true if the request is authorized
func validateCallbackSecret(c *gin.Context) bool {
expect := os.Getenv("RTMP_CALLBACK_SECRET")
if expect == "" {
return true // Allow in dev when not configured
}
got := c.GetHeader("X-RTMP-Callback-Secret")
if got == "" {
got = c.Query("secret")
}
return got == expect
}
// HandlePublish is called by Nginx-RTMP on_publish. Params: name=stream_key
// On success: SetIsLive(true), UpdateStreamURL with HLS playlist URL
func (h *LiveStreamCallbackHandler) HandlePublish(c *gin.Context) {
if !validateCallbackSecret(c) {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "invalid callback secret"))
return
}
streamKey := c.Query("name")
if streamKey == "" {
streamKey = c.PostForm("name")
}
if streamKey == "" {
RespondWithAppError(c, apperrors.NewValidationError("missing stream key (name)"))
return
}
stream, err := h.service.GetByStreamKey(c.Request.Context(), streamKey)
if err != nil {
h.logger.Warn("Live publish: invalid stream key", zap.String("key", streamKey), zap.Error(err))
RespondWithAppError(c, apperrors.NewNotFoundError("invalid stream key"))
return
}
// Nginx-RTMP writes to /tmp/hls/{stream_key}/ so we use stream_key in the URL
baseURL := os.Getenv("STREAM_HLS_BASE_URL")
if baseURL == "" {
baseURL = "http://localhost:18083/live"
}
streamURL := baseURL + "/" + stream.StreamKey + "/playlist.m3u8"
if err := h.service.SetIsLive(c.Request.Context(), stream.ID, true); err != nil {
h.logger.Error("Live publish: SetIsLive failed", zap.Error(err))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to set stream live", err))
return
}
if err := h.service.UpdateStreamURL(c.Request.Context(), stream.ID, streamURL); err != nil {
h.logger.Error("Live publish: UpdateStreamURL failed", zap.Error(err))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update stream URL", err))
return
}
if err := h.service.UpdateViewerCount(c.Request.Context(), stream.ID, 0); err != nil {
_ = err // non-fatal
}
h.logger.Info("Live stream started", zap.String("stream_id", stream.ID.String()), zap.String("stream_key", streamKey))
c.AbortWithStatus(http.StatusOK)
}
// HandlePublishDone is called by Nginx-RTMP on_publish_done. Params: name=stream_key
func (h *LiveStreamCallbackHandler) HandlePublishDone(c *gin.Context) {
if !validateCallbackSecret(c) {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "invalid callback secret"))
return
}
streamKey := c.Query("name")
if streamKey == "" {
streamKey = c.PostForm("name")
}
if streamKey == "" {
RespondWithAppError(c, apperrors.NewValidationError("missing stream key (name)"))
return
}
stream, err := h.service.GetByStreamKey(c.Request.Context(), streamKey)
if err != nil {
h.logger.Warn("Live publish_done: stream not found", zap.String("key", streamKey))
c.AbortWithStatus(http.StatusOK) // Nginx expects 2xx even if we don't know the stream
return
}
if err := h.service.SetIsLive(c.Request.Context(), stream.ID, false); err != nil {
h.logger.Error("Live publish_done: SetIsLive failed", zap.Error(err))
}
if err := h.service.UpdateStreamURL(c.Request.Context(), stream.ID, ""); err != nil {
h.logger.Error("Live publish_done: UpdateStreamURL failed", zap.Error(err))
}
if err := h.service.UpdateViewerCount(c.Request.Context(), stream.ID, -stream.ViewerCount); err != nil {
_ = err // reset to 0
}
h.logger.Info("Live stream ended", zap.String("stream_id", stream.ID.String()))
c.AbortWithStatus(http.StatusOK)
}

View file

@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
@ -141,9 +142,13 @@ func (h *LiveStreamHandler) GetMyStreamKey(c *gin.Context) {
}
streamKey = created.StreamKey
}
rtmpHost := os.Getenv("NGINX_RTMP_HOST")
if rtmpHost == "" {
rtmpHost = "stream.veza.app"
}
RespondSuccess(c, http.StatusOK, gin.H{
"stream_key": streamKey,
"rtmp_url": "rtmp://stream.veza.app/live",
"rtmp_url": "rtmp://" + rtmpHost + "/live",
})
}
@ -163,9 +168,13 @@ func (h *LiveStreamHandler) RegenerateStreamKey(c *gin.Context) {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to regenerate key", err))
return
}
rtmpHost := os.Getenv("NGINX_RTMP_HOST")
if rtmpHost == "" {
rtmpHost = "stream.veza.app"
}
RespondSuccess(c, http.StatusOK, gin.H{
"stream_key": newKey,
"rtmp_url": "rtmp://stream.veza.app/live",
"rtmp_url": "rtmp://" + rtmpHost + "/live",
})
}

View file

@ -13,6 +13,7 @@ import (
type LiveStreamRepository interface {
Create(ctx context.Context, s *models.LiveStream) error
GetByID(ctx context.Context, id uuid.UUID) (*models.LiveStream, error)
GetByStreamKey(ctx context.Context, streamKey string) (*models.LiveStream, error)
List(ctx context.Context, isLive *bool) ([]*models.LiveStream, error)
ListByUserID(ctx context.Context, userID uuid.UUID) ([]*models.LiveStream, error)
Update(ctx context.Context, s *models.LiveStream) error
@ -40,6 +41,14 @@ func (r *liveStreamRepository) GetByID(ctx context.Context, id uuid.UUID) (*mode
return &s, nil
}
func (r *liveStreamRepository) GetByStreamKey(ctx context.Context, streamKey string) (*models.LiveStream, error) {
var s models.LiveStream
if err := r.db.WithContext(ctx).First(&s, "stream_key = ?", streamKey).Error; err != nil {
return nil, err
}
return &s, nil
}
func (r *liveStreamRepository) List(ctx context.Context, isLive *bool) ([]*models.LiveStream, error) {
var items []*models.LiveStream
query := r.db.WithContext(ctx)

View file

@ -143,3 +143,18 @@ func (s *LiveStreamService) UpdateViewerCount(ctx context.Context, id uuid.UUID,
stream.ViewerCount = newCount
return s.repo.Update(ctx, stream)
}
// GetByStreamKey returns a stream by its stream_key (for RTMP callback validation)
func (s *LiveStreamService) GetByStreamKey(ctx context.Context, streamKey string) (*models.LiveStream, error) {
return s.repo.GetByStreamKey(ctx, streamKey)
}
// UpdateStreamURL sets the HLS stream URL (called by Nginx-RTMP on_publish callback)
func (s *LiveStreamService) UpdateStreamURL(ctx context.Context, id uuid.UUID, streamURL string) error {
stream, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
stream.StreamURL = streamURL
return s.repo.Update(ctx, stream)
}

View file

@ -25,7 +25,12 @@ func (h *MessageHandler) HandleSendMessage(ctx context.Context, client *Client,
return
}
if !h.rateLimiter.Allow(client.UserID, "send_message") {
// F474: live chat rate limit 1 msg/3s when conversation is a live stream room
action := "send_message"
if h.permissions.IsLiveRoom(ctx, *msg.ConversationID) {
action = "send_live_message"
}
if !h.rateLimiter.Allow(client.UserID, action) {
client.SendJSON(NewErrorResponse("rate limit exceeded"))
return
}

View file

@ -83,6 +83,18 @@ func (p *PermissionService) CanJoin(ctx context.Context, userID, roomID uuid.UUI
return isMember
}
// IsLiveRoom returns true if roomID is a live_streams.id (F474).
func (p *PermissionService) IsLiveRoom(ctx context.Context, roomID uuid.UUID) bool {
var count int64
if err := p.db.WithContext(ctx).
Table("live_streams").
Where("id = ?", roomID).
Count(&count).Error; err != nil {
return false
}
return count > 0
}
func (p *PermissionService) CanModerate(ctx context.Context, userID, roomID uuid.UUID) bool {
member, isMember := p.getMembership(ctx, userID, roomID)
if !isMember {

View file

@ -97,10 +97,11 @@ return 0
`)
var defaultLimits = map[string]rateConfig{
"send_message": {maxRequests: 10, window: time.Second},
"typing": {maxRequests: 5, window: time.Second},
"search": {maxRequests: 2, window: time.Second},
"fetch_history": {maxRequests: 5, window: time.Second},
"send_message": {maxRequests: 10, window: time.Second},
"send_live_message": {maxRequests: 1, window: 3 * time.Second}, // F474: live chat rate limit
"typing": {maxRequests: 5, window: time.Second},
"search": {maxRequests: 2, window: time.Second},
"fetch_history": {maxRequests: 5, window: time.Second},
}
func NewRateLimiter(redisClient *redis.Client, logger *zap.Logger) *RateLimiter {