Saturn : API et données
Saturn porte le domaine consigne côté central, en architecture hexagonale :
| Couche | Fichier |
|---|---|
| Domaine | internal/domain/consigne.go |
| Port | internal/ports/ports.go (ConsigneRepository) |
| Service | internal/application/consigne_service.go |
| PostgreSQL | internal/adapters/postgres/consigne.go |
| SMTP + QR | internal/adapters/mailer/smtp.go |
| REST | internal/adapters/httpapi/consigne_handler.go |
| gRPC entrant | internal/adapters/grpcapi/server.go |
| gRPC sortant (node) | internal/adapters/nodeagentapi/repository.go |
| gRPC sortant (Venus outbox) | internal/adapters/venusgrpcapi/client.go |
Outbox vers Venus (retry)
Chaque insertion d'événement consigne dans consigne_events ajoute aussi un message
dans consigne_outbox (transaction unique).
Le worker ConsigneOutboxDispatcher:
- claim les messages
pendingavecnext_attempt_at <= NOW() - publie vers Venus en gRPC
- marque
sentsi ACK positif ({"ok":true}) - sinon remet en
pendingaveclast_erroret prochain essai différé
Variables d'environnement:
VENUS_GRPC_ADDR(vide par défaut, outbox désactivé)VENUS_GRPC_METHOD(défaut:/leukos.VenusService/ConsigneEvent)VENUS_GRPC_TIMEOUT_MS(défaut:5000)CONSIGNE_OUTBOX_RETRY_SECONDS(défaut:20)CONSIGNE_OUTBOX_BATCH_SIZE(défaut:50)
API REST
Base : http://central-saturn:8130 (exposé via Traefik sur central.app.server/api).
| Méthode | Route | Description |
|---|---|---|
GET | /api/consignes | Liste des consignes (compteurs, en ligne si sync < 2 min) |
GET | /api/consignes/{node_id} | Détail : device, casiers, 200 derniers colis, 100 événements |
POST | /api/consignes/{node_id}/parcels | Crée un colis |
POST | /api/consignes/{node_id}/sync-full | Déclenche une synchronisation complète demandée au node (gRPC first, REST fallback) |
GET | /api/consignes/parcels?node_id= | Liste des colis |
POST | /api/consignes/sync | Synchronisation node (fallback REST du gRPC) |
GET | /api/consignes/smtp | Configuration SMTP (sans mot de passe) |
PUT | /api/consignes/smtp | Enregistre la configuration SMTP |
POST | /api/consignes/smtp/test | Envoie un email de test |
Créer un colis
curl -s -X POST http://localhost:8130/api/consignes/node-0001/parcels \
-H 'Content-Type: application/json' \
-d '{"locker":3,"recipient_name":"Awa Diop","recipient_email":"awa@example.com"}'
{
"parcel": {
"id": "p_8f801d7580ed9f15",
"node_id": "node-0001",
"locker": 3,
"recipient_name": "Awa Diop",
"recipient_email": "awa@example.com",
"status": "deposited",
"email_status": "sent",
"node_delivery": "grpc"
}
}
Erreurs 400 possibles : recipient_email is invalid, consigne X is not in consigne mode,
locker 3 is occupied, locker 99 does not exist on node-0001.
Un échec d'email ou de livraison au node n'annule pas le colis : les statuts
email_status / node_delivery (+ email_error / node_error) permettent le diagnostic,
et la livraison au node est réessayée automatiquement.
Configurer le SMTP
curl -s -X PUT http://localhost:8130/api/consignes/smtp \
-H 'Content-Type: application/json' --data-binary @- <<'EOF'
{"host":"smtp.gmail.com","port":587,"user":"compte@gmail.com",
"pass":"<mot de passe d'application>","from":"compte@gmail.com",
"secure":false,"logo_url":""}
EOF
passvide ⇒ le mot de passe stocké est conservé.- La réponse ne contient jamais le mot de passe, seulement
pass_set: true. secure: true⇒ TLS implicite (port 465) ; sinon STARTTLS si le serveur l'annonce.
Génération et vérification du code
// Alphabet sans caractères ambigus (0/O/1/I/L) pour la saisie manuelle.
const pickupCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
const pickupCodeLength = 8
func generatePickupCode() (string, error) {
var b strings.Builder
max := big.NewInt(int64(len(pickupCodeAlphabet)))
for i := 0; i < pickupCodeLength; i++ {
n, err := rand.Int(rand.Reader, max) // crypto/rand
if err != nil {
return "", err
}
b.WriteByte(pickupCodeAlphabet[n.Int64()])
}
return b.String(), nil
}
func HashPickupCode(code string) string {
sum := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code))))
return hex.EncodeToString(sum[:])
}
31⁸ ≈ 8,5 × 10¹¹ combinaisons. Seul le hash est stocké (consigne_parcels.code_hash)
et transmis au node ; le code en clair n'existe que dans l'email.
Livraison au node : gRPC puis REST
func (s *ConsigneService) deliverToNode(ctx context.Context, nodeIP string,
p domain.ConsigneParcel, codeHash string) (string, string) {
payload := nodeagentapi.JSONMessage{
"id": p.ID, "locker": p.Locker,
"recipient_name": p.RecipientName, "recipient_email": p.RecipientEmail,
"code_hash": codeHash,
}
for _, port := range dedupePorts(s.nodeGRPCPort, 9080) {
addr := net.JoinHostPort(nodeIP, fmt.Sprint(port))
if err := s.agent.ConsigneDeposit(ctx, addr, payload); err == nil {
return domain.DeliveryGRPC, ""
}
}
// ... POST http://node:8080/consigne/parcels
return domain.DeliveryFailed, strings.Join(errs, "; ")
}
Serveur gRPC leukos.SaturnService (:9130)
Le daemon C++ utilise un grpc::GenericStub qui envoie du JSON brut avec le
content-type application/grpc (sans sous-type). Saturn force donc un codec JSON côté
serveur :
s.grpc = grpc.NewServer(grpc.ForceServerCodec(rawJSONCodec{}))
s.grpc.RegisterService(&grpc.ServiceDesc{
ServiceName: "leukos.SaturnService",
HandlerType: (*saturnServer)(nil),
Methods: []grpc.MethodDesc{
{MethodName: "Health", Handler: s.health},
{MethodName: "ConsigneSync", Handler: s.consigneSync},
},
}, s)
| Méthode | Requête | Réponse |
|---|---|---|
/leukos.SaturnService/Health | {} | {"service":"up"} |
/leukos.SaturnService/ConsigneSync | ConsigneSyncRequest | {"ok":true} ou {"ok":false,"error":"..."} |
L'adresse IP du node est déduite du pair gRPC (peer.FromContext) ou de
RemoteAddr en REST ; le node est aussi enregistré dans la table nodes pour rester
adressable.
Charge utile ConsigneSync
{
"node_id": "node-0001",
"name": "luckOS",
"static": {
"address": "12 rue des Amandiers, 44000 Nantes"
},
"enabled": true,
"lockers": [
{"number": 1, "status": "empty", "parcel_id": ""},
{"number": 3, "status": "occupied", "parcel_id": "p_8f801d7580ed9f15"}
],
"parcels": [
{"id": "p_2a...", "status": "picked_up", "picked_up_ts": 1791000000}
],
"events": [
{"ts": 1791000000, "level": "info", "kind": "status",
"message": "Consigne active: 18 libres / 1 occupés / 19 casiers"}
]
}
Le bloc static contient les données statiques de configuration (ex: address) :
- le node l'envoie automatiquement au premier sync après démarrage (enregistrement initial),
- Saturn met à jour
consigne_devices.addressseulement si la valeur reçue est non vide, - un déclenchement manuel
sync-fullforce aussi l'envoi de ce bloc.
Les horodatages invalides (horloge du node non synchronisée, avant 2020 ou dans le
futur) sont remplacés par l'heure de réception Saturn (plausibleUnix).
Schéma PostgreSQL
Les tables sont créées automatiquement au démarrage (InitSchema →
initConsigneSchema). consigne_lockers est entièrement remplacée à chaque
synchronisation : le node fait autorité.
Email envoyé
Message multipart/related contenant :
multipart/alternative: texte brut + HTML (nom, consigne, casier, code) ;- une image PNG
qrcode.png(QR du code, 320 px) référencée encid:qrcode.
Les valeurs dynamiques sont échappées (html.EscapeString) et les adresses validées
avec net/mail.ParseAddress.
Variables d'environnement
| Variable | Défaut | Rôle |
|---|---|---|
PORT | 8130 | API REST |
GRPC_PORT | 9130 | Serveur gRPC SaturnService |
NODE_GRPC_PORT | 9080 | Port gRPC des nodes |
NODE_API_PORT | 8080 | Port REST des nodes (fallback) |
PG_DSN | — | Connexion PostgreSQL |