Aller au contenu principal

Saturn : API et données

Saturn porte le domaine consigne côté central, en architecture hexagonale :

CoucheFichier
Domaineinternal/domain/consigne.go
Portinternal/ports/ports.go (ConsigneRepository)
Serviceinternal/application/consigne_service.go
PostgreSQLinternal/adapters/postgres/consigne.go
SMTP + QRinternal/adapters/mailer/smtp.go
RESTinternal/adapters/httpapi/consigne_handler.go
gRPC entrantinternal/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 pending avec next_attempt_at <= NOW()
  • publie vers Venus en gRPC
  • marque sent si ACK positif ({"ok":true})
  • sinon remet en pending avec last_error et 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éthodeRouteDescription
GET/api/consignesListe 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}/parcelsCrée un colis
POST/api/consignes/{node_id}/sync-fullDé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/syncSynchronisation node (fallback REST du gRPC)
GET/api/consignes/smtpConfiguration SMTP (sans mot de passe)
PUT/api/consignes/smtpEnregistre la configuration SMTP
POST/api/consignes/smtp/testEnvoie 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.

remarque

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
  • pass vide ⇒ 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éthodeRequêteRéponse
/leukos.SaturnService/Health{}{"service":"up"}
/leukos.SaturnService/ConsigneSyncConsigneSyncRequest{"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.address seulement si la valeur reçue est non vide,
  • un déclenchement manuel sync-full force 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 :

  1. multipart/alternative : texte brut + HTML (nom, consigne, casier, code) ;
  2. une image PNG qrcode.png (QR du code, 320 px) référencée en cid:qrcode.

Les valeurs dynamiques sont échappées (html.EscapeString) et les adresses validées avec net/mail.ParseAddress.

Variables d'environnement​

VariableDéfautRôle
PORT8130API REST
GRPC_PORT9130Serveur gRPC SaturnService
NODE_GRPC_PORT9080Port gRPC des nodes
NODE_API_PORT8080Port REST des nodes (fallback)
PG_DSN—Connexion PostgreSQL