2026-09-12 17:32:45 +02:00
|
|
|
# iot-alarm-system
|
|
|
|
|
|
|
|
|
|
Système d'alarme événementiel pour capteurs IoT.
|
|
|
|
|
**Java 21 · Spring Boot 3 · Apache Kafka · Architecture hexagonale · CQRS · DDD**
|
|
|
|
|
|
|
|
|
|
🔗 **Démonstration en ligne : https://video-seurveillance.belkacemi.me**
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Le contexte
|
|
|
|
|
|
|
|
|
|
Mon studio a été cambriolé.
|
|
|
|
|
|
|
|
|
|
Les offres de vidéosurveillance du marché se ressemblent toutes : une caméra à
|
|
|
|
|
80 €, puis un abonnement mensuel pour avoir le droit d'accéder à ses propres
|
|
|
|
|
images. J'avais un vieil iPhone dans un tiroir et une bibliothèque bien remplie.
|
|
|
|
|
L'iPhone est désormais calé entre deux livres, objectif vers la porte d'entrée.
|
|
|
|
|
|
|
|
|
|
Ce dépôt est le backend qui écoute ce capteur : il ingère les détections de
|
|
|
|
|
mouvement, décide lesquelles constituent une intrusion, et pousse l'alerte en
|
|
|
|
|
temps réel sur une console de supervision.
|
|
|
|
|
|
|
|
|
|
Le problème est volontairement simple. Ce qui est travaillé ici, c'est la
|
|
|
|
|
**structure** : un domaine métier isolé du framework, un bus d'événements, une
|
|
|
|
|
séparation lecture/écriture, et des règles d'architecture vérifiées par les
|
|
|
|
|
tests plutôt que par la discipline.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Le flux
|
|
|
|
|
|
2026-09-12 18:08:34 +02:00
|
|
|
### Deux flux, deux chemins
|
|
|
|
|
|
|
|
|
|
Le système transporte deux choses de nature très différente, et elles ne
|
|
|
|
|
suivent volontairement pas le même chemin :
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
┌─────────────────────┐
|
|
|
|
|
│ iPhone (capteur) │
|
|
|
|
|
└──────────┬──────────┘
|
|
|
|
|
événements │ vidéo
|
|
|
|
|
(quelques octets) │ (800 kbps continu)
|
|
|
|
|
┌──────────┴──────────┐
|
|
|
|
|
▼ ▼
|
|
|
|
|
┌───────────────┐ ┌────────────────┐
|
|
|
|
|
│ Spring Boot │ │ MediaMTX │
|
|
|
|
|
│ Kafka · JPA │ │ SRT → WebRTC │
|
|
|
|
|
│ domaine │ │ transport pur │
|
|
|
|
|
└───────┬───────┘ └────────┬───────┘
|
|
|
|
|
└──────────┬───────────┘
|
|
|
|
|
▼
|
|
|
|
|
Console web (une seule page)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**La vidéo ne traverse jamais la JVM.** Faire transiter 800 kbps continus par
|
|
|
|
|
Spring Boot n'apporterait rien et coûterait un thread par spectateur. Le média
|
|
|
|
|
est un problème de transport, résolu par un service dédié de 83 Mo ; la JVM
|
|
|
|
|
garde le domaine métier. Les deux se rejoignent seulement dans le navigateur.
|
|
|
|
|
|
|
|
|
|
Corollaire pratique : MediaMTX peut tomber sans que l'alarme cesse de
|
|
|
|
|
fonctionner, et inversement.
|
|
|
|
|
|
|
|
|
|
### Le flux d'événements en détail
|
|
|
|
|
|
2026-09-12 17:32:45 +02:00
|
|
|
```
|
|
|
|
|
┌──────────────────┐
|
|
|
|
|
│ iPhone 6s │ Détection de mouvement
|
|
|
|
|
│ "iphone-biblio" │ {"deviceId":"iphone-biblio","type":"MOTION_DETECTED"}
|
|
|
|
|
└────────┬─────────┘
|
|
|
|
|
│ POST /api/v1/sensor-events
|
|
|
|
|
▼
|
|
|
|
|
┌────────────────────────────┐
|
|
|
|
|
│ SensorIngestController │ ADAPTER ENTRANT
|
|
|
|
|
│ → 202 Accepted │ Répond avant tout traitement métier :
|
|
|
|
|
└────────┬───────────────────┘ le capteur n'attend pas la base.
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌────────────────────────────┐
|
|
|
|
|
│ SensorEventKafkaPublisher │ ADAPTER SORTANT
|
|
|
|
|
└────────┬───────────────────┘ Clé de partition = deviceId → ordre garanti
|
|
|
|
|
│ par capteur.
|
|
|
|
|
▼
|
|
|
|
|
╔═══════════════════════╗
|
|
|
|
|
║ KAFKA sensor-events ║ 3 partitions · acks=all · idempotent
|
|
|
|
|
╚═══════════┬═══════════╝ (poison pill → sensor-events-dlq)
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌────────────────────────────┐
|
|
|
|
|
│ SensorEventKafkaConsumer │ ADAPTER ENTRANT
|
|
|
|
|
└────────┬───────────────────┘ Désérialise, délègue, instrumente. Zéro règle.
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌────────────────────────────────────────────────────────┐
|
|
|
|
|
│ AlarmSystem.evaluate() ◄── LE CŒUR DU SYSTÈME│
|
|
|
|
|
│ │
|
|
|
|
|
│ 1. alarme désarmée ? → Ignored(ALARM_DISARMED)
|
|
|
|
|
│ 2. heartbeat ? → Ignored(NOT_INTRUSION_RELEVANT)
|
|
|
|
|
│ 3. délai de sortie en cours ? → Ignored(WITHIN_EXIT_DELAY)
|
|
|
|
|
│ 4. confiance < seuil ? → Ignored(CONFIDENCE_TOO_LOW)
|
|
|
|
|
│ 5. alerte récente ? → Ignored(WITHIN_COOLDOWN)
|
|
|
|
|
│ │
|
|
|
|
|
│ sinon → TRIGGERED + Alert + IntrusionDetected │
|
|
|
|
|
└────────┬───────────────────────────────────────────────┘
|
|
|
|
|
│
|
|
|
|
|
┌─────┴─────────────────────────┐
|
|
|
|
|
▼ ▼
|
|
|
|
|
┌──────────────────┐ ┌──────────────────────────┐
|
|
|
|
|
│ PostgreSQL │ │ WebSocket STOMP │ après commit
|
|
|
|
|
│ (write model) │ │ /topic/alerts │ uniquement
|
|
|
|
|
└──────────────────┘ └────────────┬─────────────┘
|
|
|
|
|
▼
|
|
|
|
|
┌──────────────────────────┐
|
|
|
|
|
│ Console : fond rouge, │
|
|
|
|
|
│ "INTRUSION DÉTECTÉE" │
|
|
|
|
|
└──────────────────────────┘
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## L'architecture
|
|
|
|
|
|
|
|
|
|
### Hexagonale (ports & adapters)
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
domain/ Le métier. Zéro import de framework.
|
|
|
|
|
model/ AlarmSystem (agrégat racine), Alert, SensorEvent,
|
|
|
|
|
AlarmPolicy, AlarmDecision (sealed)
|
|
|
|
|
event/ DomainEvent (sealed) : IntrusionDetected, AlarmArmed…
|
|
|
|
|
port/in/ Ports primaires — ce que le système sait faire
|
|
|
|
|
port/out/ Ports secondaires — ce dont il a besoin (SPI)
|
|
|
|
|
|
|
|
|
|
application/ Orchestration. Charge, appelle, persiste, notifie.
|
|
|
|
|
command/ Côté écriture du CQRS
|
|
|
|
|
query/ Côté lecture — son propre port, son propre modèle
|
|
|
|
|
|
|
|
|
|
infrastructure/ Les adapters. C'est ici, et seulement ici, qu'on trouve
|
|
|
|
|
les mots « Kafka », « JPA », « WebSocket ».
|
|
|
|
|
|
|
|
|
|
presentation/ REST + WebSocket. Traduit HTTP en commandes.
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Les dépendances pointent **vers l'intérieur**. Le domaine ne connaît ni Spring,
|
|
|
|
|
ni Kafka, ni PostgreSQL — il déclare des interfaces (`SensorEventPublisher`,
|
|
|
|
|
`AlertRepository`) que l'infrastructure implémente.
|
|
|
|
|
|
|
|
|
|
Conséquence mesurable : les 13 tests de `AlarmSystemTest` couvrent l'intégralité
|
|
|
|
|
de la logique de détection et s'exécutent en **48 millisecondes**, sans
|
|
|
|
|
conteneur, sans base, sans `Thread.sleep`.
|
|
|
|
|
|
|
|
|
|
### Ces règles sont testées, pas documentées
|
|
|
|
|
|
|
|
|
|
Un schéma dans un README dérive en trois sprints. Celles-ci cassent le build :
|
|
|
|
|
|
|
|
|
|
```java
|
|
|
|
|
@Test
|
|
|
|
|
void domainHasNoFrameworkDependency() {
|
|
|
|
|
noClasses().that().resideInAPackage("..domain..")
|
|
|
|
|
.should().dependOnClassesThat().resideInAnyPackage(
|
|
|
|
|
"org.springframework..", "jakarta.persistence..",
|
|
|
|
|
"org.apache.kafka..", "io.micrometer..")
|
|
|
|
|
.check(classes);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
7 règles ArchUnit s'exécutent à chaque `mvn test` — **et à chaque `docker build`**.
|
|
|
|
|
Une violation de l'architecture hexagonale ne franchit pas l'étape de packaging.
|
|
|
|
|
|
|
|
|
|
### CQRS
|
|
|
|
|
|
|
|
|
|
La séparation n'est pas cosmétique : les deux côtés ne partagent aucun modèle.
|
|
|
|
|
|
|
|
|
|
| | Écriture | Lecture |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| Entrée | `ArmAlarmCommand`, `IngestSensorEventCommand` | `AlarmQueryService` |
|
|
|
|
|
| Chemin | passe par l'agrégat, applique les invariants | requête directe, ne charge jamais l'agrégat |
|
|
|
|
|
| Modèle | `Alert` (entité, comportement) | `AlertView` (record, libellés pré-calculés) |
|
|
|
|
|
| Port | `domain.port.out` | `application.query.port` |
|
|
|
|
|
| Transaction | `@Transactional` | `@Transactional(readOnly = true)` |
|
|
|
|
|
|
|
|
|
|
Compter les alertes non acquittées se fait par un `COUNT(*)`, pas en
|
|
|
|
|
réhydratant un agrégat dont on n'appellerait aucune méthode. Brancher un cache
|
|
|
|
|
Redis ou un index Elasticsearch sur la lecture ne demanderait qu'une nouvelle
|
|
|
|
|
implémentation de `AlarmReadModelPort`.
|
|
|
|
|
|
|
|
|
|
### DDD
|
|
|
|
|
|
|
|
|
|
- **Agrégat racine** — `AlarmSystem` est le seul point d'entrée pour modifier
|
|
|
|
|
l'état. Aucun service ne décide à sa place.
|
|
|
|
|
- **Value Objects** — `DeviceId` valide son format à la construction ;
|
|
|
|
|
au-delà de la frontière, aucune `String` non validée ne circule.
|
|
|
|
|
- **Entité** — `Alert` a une identité (`AlertId`, générée par le domaine et non
|
|
|
|
|
par la base) et un cycle de vie (acquittement à usage unique).
|
|
|
|
|
- **Interfaces scellées** — `AlarmDecision` est `Triggered | Ignored(reason)`.
|
|
|
|
|
Le `switch` qui la consomme est vérifié exhaustivement par le compilateur :
|
|
|
|
|
ajouter un troisième cas casse la compilation au lieu de tomber dans un `else`.
|
|
|
|
|
- **Un rejet dit pourquoi** — `IgnoreReason` alimente à la fois les logs et un
|
|
|
|
|
tag Micrometer. « Pourquoi l'alarme n'a pas sonné cette nuit ? » se répond par
|
|
|
|
|
une requête PromQL, pas par une lecture de logs.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-09-12 18:08:34 +02:00
|
|
|
## Le flux vidéo
|
|
|
|
|
|
|
|
|
|
### Le téléphone pousse, on ne l'interroge pas
|
|
|
|
|
|
|
|
|
|
C'est la contrainte qui a dicté toute cette partie. Un iPhone n'a **aucune
|
|
|
|
|
adresse joignable depuis Internet** : en 4G il est derrière le CGNAT de
|
|
|
|
|
l'opérateur, sur le WiFi du studio derrière le NAT de la box, et l'application
|
|
|
|
|
signale `UPnP unavailable`. Le serveur RTSP intégré à l'application caméra est
|
|
|
|
|
donc inutilisable de l'extérieur, quelle que soit la configuration.
|
|
|
|
|
|
|
|
|
|
La seule direction qui traverse ces deux NAT est la sortante. Le téléphone
|
|
|
|
|
**pousse** son flux vers le serveur :
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
iPhone ──SRT (UDP 8890)──▶ MediaMTX ──┬──▶ WebRTC < 1 s
|
|
|
|
|
└─RTMP (TCP 1935)──▶ └──▶ HLS ~ 6 s
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**SRT plutôt que RTMP** quand le choix est possible : RTMP est du TCP, et sur
|
|
|
|
|
un lien mobile un paquet perdu déclenche une retransmission qui bloque tout le
|
|
|
|
|
reste. SRT transporte en UDP avec sa propre retransmission sélective — c'est
|
|
|
|
|
exactement le protocole conçu pour ce cas.
|
|
|
|
|
|
|
|
|
|
**WebRTC plutôt que HLS** côté lecture : voir quelqu'un six secondes après son
|
|
|
|
|
passage n'a pas le même intérêt. La console tente WebRTC (WHEP) d'abord et
|
|
|
|
|
bascule sur HLS si l'UDP est filtré — ce qui arrive précisément sur les réseaux
|
|
|
|
|
d'entreprise.
|
|
|
|
|
|
|
|
|
|
### Ce qui est protégé, ce qui ne l'est pas
|
|
|
|
|
|
|
|
|
|
| | |
|
|
|
|
|
|---|---|
|
|
|
|
|
| **Lecture** | publique — le flux est une vitrine, c'est assumé |
|
|
|
|
|
| **Publication** | authentifiée — sans ça, n'importe qui sur Internet pousserait sa propre vidéo sur le flux `biblio` |
|
|
|
|
|
|
|
|
|
|
### Brancher un vrai téléphone
|
|
|
|
|
|
|
|
|
|
Application : **IP Camera Lite** (iOS), menu *Live Push*.
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
SRT srt://<serveur>:8890?streamid=publish:biblio:iphone:<mot-de-passe>
|
|
|
|
|
RTMP rtmp://<serveur>:1935/biblio?user=iphone&pass=<mot-de-passe>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Le mot de passe est généré au déploiement dans le `.env` du serveur
|
|
|
|
|
(`STREAM_PUBLISH_PASSWORD`) et n'existe nulle part dans ce dépôt.
|
|
|
|
|
|
|
|
|
|
Pour valider la chaîne sans téléphone, une mire suffit :
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
docker run --rm --network iot-alarm-internal jrottenberg/ffmpeg:6.1-alpine \
|
|
|
|
|
-re -f lavfi -i testsrc=size=640x360:rate=25 \
|
|
|
|
|
-c:v libx264 -preset ultrafast -tune zerolatency -b:v 800k -pix_fmt yuv420p \
|
|
|
|
|
-f flv "rtmp://mediamtx:1935/biblio?user=iphone&pass=$PASS"
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Le piège du reverse proxy
|
|
|
|
|
|
|
|
|
|
MediaMTX répond d'abord une redirection vers un chemin **absolu**
|
|
|
|
|
(`/biblio/index.m3u8?cookieCheck=1`). Derrière un `handle_path /hls/*` qui
|
|
|
|
|
retire le préfixe, le navigateur suivait cette redirection jusqu'à
|
|
|
|
|
l'application Spring Boot et recevait du JSON là où il attendait une playlist.
|
|
|
|
|
Le `header_down Location` du [snippet Caddy](deploy/Caddyfile.snippet) réécrit
|
|
|
|
|
le préfixe. Symptôme trompeur : `curl -I` renvoyait un 404 sans rapport, le
|
|
|
|
|
serveur HLS ne gérant simplement pas la méthode HEAD.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-09-12 17:32:45 +02:00
|
|
|
## Les décisions non évidentes
|
|
|
|
|
|
|
|
|
|
Les choix ci-dessous ne sont pas les plus courts. Ils sont justifiés ici parce
|
|
|
|
|
que c'est précisément ce qui distingue un projet de démonstration d'un système
|
|
|
|
|
qu'on accepte de laisser tourner.
|
|
|
|
|
|
|
|
|
|
**`202 Accepted` et non `201 Created`** — à cet instant, rien n'a été décidé.
|
|
|
|
|
L'événement est sur le bus ; le traitement suivra. Le code de statut dit la
|
|
|
|
|
vérité sur ce qui s'est passé.
|
|
|
|
|
|
|
|
|
|
**Notification WebSocket après commit** — pousser l'alerte dans la transaction
|
|
|
|
|
signifierait afficher « INTRUSION » sur une écriture qui peut encore partir en
|
|
|
|
|
rollback. `AfterCommit` diffère l'effet de bord jusqu'au commit effectif.
|
|
|
|
|
|
|
|
|
|
**Entité JPA ≠ entité du domaine** — annoter directement `Alert` aurait imposé
|
|
|
|
|
un constructeur sans argument, des setters et des champs mutables : exactement
|
|
|
|
|
ce que l'encapsulation cherche à empêcher. Le prix est un mapper ; le bénéfice
|
|
|
|
|
est un domaine testable sans base.
|
|
|
|
|
|
|
|
|
|
**`SensorEventMessage` ≠ `SensorEvent`** — le format Kafka est un contrat public
|
|
|
|
|
partagé avec un producteur qu'on ne maîtrise pas (l'app iPhone). Sérialiser
|
|
|
|
|
l'objet du domaine ferait qu'un renommage de champ casse le capteur en
|
|
|
|
|
production.
|
|
|
|
|
|
|
|
|
|
**Anti-rafale dans l'agrégat** — sans ce filtre, un intrus qui traverse la pièce
|
|
|
|
|
génère quarante alertes en dix secondes. C'est une règle métier, elle est donc
|
|
|
|
|
dans le domaine, pas dans le consumer.
|
|
|
|
|
|
|
|
|
|
**Délai de sortie** — la différence entre un système utilisable et un système
|
|
|
|
|
qu'on finit par laisser désarmé en permanence.
|
|
|
|
|
|
|
|
|
|
**`ErrorHandlingDeserializer` + DLQ** — sans lui, *un seul* message malformé met
|
|
|
|
|
le consumer dans une boucle de replay infinie et bloque tous les événements
|
|
|
|
|
suivants, y compris une vraie intrusion.
|
|
|
|
|
|
|
|
|
|
**`Clock` injectée** — `Instant.now()` en dur rendrait le délai de sortie et
|
|
|
|
|
l'anti-rafale intestables autrement qu'avec des `Thread.sleep`.
|
|
|
|
|
|
|
|
|
|
**KRaft, pas Zookeeper** — Zookeeper est déprécié depuis Kafka 3.5 et supprimé
|
|
|
|
|
en 4.0. Le conserver ajouterait un conteneur, ~400 Mo de RAM et une pièce à
|
|
|
|
|
maintenir, pour une fonction que le broker assure désormais lui-même.
|
|
|
|
|
|
|
|
|
|
**`ddl-auto: validate`** — le schéma appartient à Flyway. L'application refuse
|
|
|
|
|
de démarrer si une entité et sa table ont divergé, plutôt que de tomber en
|
|
|
|
|
marche sur la première requête.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Démarrage
|
|
|
|
|
|
|
|
|
|
### Tout en conteneurs
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-09-12 17:41:05 +02:00
|
|
|
grep -v '^DB_PASSWORD=' .env.example > .env
|
|
|
|
|
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" >> .env && chmod 600 .env
|
2026-09-12 17:32:45 +02:00
|
|
|
|
|
|
|
|
docker compose up -d --build
|
|
|
|
|
docker compose logs -f app
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Console sur http://localhost:8080 — le simulateur de capteur produit du trafic
|
|
|
|
|
dès le démarrage.
|
|
|
|
|
|
|
|
|
|
Pour un frontal Nginx en plus (http://localhost:8081) :
|
|
|
|
|
`docker compose --profile standalone up -d`
|
|
|
|
|
|
|
|
|
|
### En développement
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
docker compose up -d kafka # le broker seul
|
|
|
|
|
mvn spring-boot:run -Dspring-boot.run.profiles=local # H2 en mémoire
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Tests
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
mvn test # unitaires + ArchUnit — aucune infra requise
|
|
|
|
|
mvn verify -Pintegration # Testcontainers (Kafka + PostgreSQL réels)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## API
|
|
|
|
|
|
|
|
|
|
| Méthode | Route | Rôle |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| `POST` | `/api/v1/sensor-events` | Ingestion capteur → Kafka (202) |
|
|
|
|
|
| `POST` | `/api/v1/alarm/arm` | **Commande** — armer |
|
|
|
|
|
| `POST` | `/api/v1/alarm/disarm` | **Commande** — désarmer |
|
|
|
|
|
| `POST` | `/api/v1/alerts/{id}/acknowledge` | **Commande** — acquitter |
|
|
|
|
|
| `GET` | `/api/v1/alarm/status` | **Requête** — état courant |
|
|
|
|
|
| `GET` | `/api/v1/alerts?limit=50` | **Requête** — historique |
|
|
|
|
|
| `WS` | `/ws` → `/topic/alerts` | Push temps réel (STOMP) |
|
2026-09-12 18:08:34 +02:00
|
|
|
| `POST` | `/webrtc/biblio/whep` | Négociation WebRTC (servi par MediaMTX) |
|
|
|
|
|
| `GET` | `/hls/biblio/index.m3u8` | Repli HLS (servi par MediaMTX) |
|
2026-09-12 17:32:45 +02:00
|
|
|
|
|
|
|
|
Documentation OpenAPI : `/swagger-ui.html`
|
|
|
|
|
|
|
|
|
|
Déclencher une intrusion à la main :
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
curl -X POST http://localhost:8080/api/v1/alarm/arm
|
|
|
|
|
sleep 31 # laisser passer le délai de sortie
|
|
|
|
|
|
|
|
|
|
curl -X POST http://localhost:8080/api/v1/sensor-events \
|
|
|
|
|
-H 'Content-Type: application/json' \
|
|
|
|
|
-d '{"deviceId":"iphone-biblio","type":"MOTION_DETECTED","confidence":95}'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Les erreurs suivent la RFC 7807 (`ProblemDetail`). Une règle métier violée
|
|
|
|
|
renvoie **409** et non 400 : la requête est bien formée, c'est l'état qui s'y
|
|
|
|
|
oppose.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Production
|
|
|
|
|
|
|
|
|
|
Déployé sur un serveur Hetzner (Ubuntu, 2 vCPU, 4 Go) mutualisé avec d'autres
|
|
|
|
|
services.
|
|
|
|
|
|
|
|
|
|
- **TLS** — Caddy, déjà en place sur la machine et détenteur des ports 80/443,
|
|
|
|
|
obtient et renouvelle les certificats Let's Encrypt automatiquement.
|
|
|
|
|
Y démarrer un second frontal les aurait mis en conflit et coupé les services
|
|
|
|
|
existants. Le bloc à ajouter est dans [`deploy/Caddyfile.snippet`](deploy/Caddyfile.snippet).
|
|
|
|
|
- **Isolation réseau** — Kafka et PostgreSQL vivent sur un réseau Docker privé
|
|
|
|
|
sans aucun port publié sur l'hôte. Seule l'application rejoint le réseau
|
|
|
|
|
partagé du reverse proxy.
|
|
|
|
|
- **Empreinte mémoire** — limites de conteneur explicites (app 512 Mo, Kafka
|
|
|
|
|
800 Mo, PostgreSQL 256 Mo). La JVM utilise `MaxRAMPercentage` plutôt que
|
|
|
|
|
`-Xmx` : le réglage suit la limite du cgroup sans être défini à deux endroits.
|
|
|
|
|
`SerialGC` sur 2 vCPU partagés, où G1 coûte plus en threads qu'il ne rapporte.
|
|
|
|
|
- **Supervision** — `/actuator/prometheus` exposé au Prometheus de la machine,
|
|
|
|
|
bloqué côté public.
|
|
|
|
|
- **Conteneur non-root**, healthchecks sur les trois services, arrêt gracieux.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stack
|
|
|
|
|
|
|
|
|
|
| | |
|
|
|
|
|
|---|---|
|
|
|
|
|
| Langage | Java 21 (records, sealed interfaces, pattern matching, virtual threads) |
|
|
|
|
|
| Framework | Spring Boot 3.4 |
|
|
|
|
|
| Messagerie | Apache Kafka 3.9 (KRaft) |
|
|
|
|
|
| Persistance | PostgreSQL 16 · Spring Data JPA · Flyway |
|
|
|
|
|
| Temps réel | WebSocket / STOMP / SockJS |
|
2026-09-12 18:08:34 +02:00
|
|
|
| Vidéo | MediaMTX · SRT / RTMP en entrée · WebRTC (WHEP) / HLS en sortie |
|
2026-09-12 17:32:45 +02:00
|
|
|
| Build | Maven · Docker multi-stage |
|
|
|
|
|
| Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers |
|
|
|
|
|
| Observabilité | Actuator · Micrometer / Prometheus |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
*Adam Belkacemi — le studio est toujours debout.*
|