chore: build
This commit is contained in:
@@ -3,17 +3,13 @@ package services
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/utils"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
@@ -64,25 +60,24 @@ func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionServi
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// POINT D'ENTRÉE PRINCIPAL
|
||||
// ============================================
|
||||
|
||||
// ResolveAddress tente de géocoder une adresse avec correction automatique.
|
||||
// Retourne toujours une suggestion, même approximative.
|
||||
// Ordre de résolution :
|
||||
// 1. Géocodage exact → succès immédiat
|
||||
// 2. Nominatim fuzzy search (addressdetails + limit=5)
|
||||
// 3. Décomposition structurée de l'adresse
|
||||
// 4. Erreur explicite avec suggestions si dispo
|
||||
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
|
||||
rawAddress = strings.TrimSpace(rawAddress)
|
||||
if rawAddress == "" {
|
||||
return nil, fmt.Errorf("adresse vide")
|
||||
}
|
||||
|
||||
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
|
||||
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
|
||||
if loc, err := acs.geoService.getFromCache(rawAddress); err == nil {
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: rawAddress,
|
||||
CorrectedAddress: rawAddress,
|
||||
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
|
||||
Confidence: 1.0,
|
||||
CorrectionApplied: false,
|
||||
Source: "exact",
|
||||
}, nil
|
||||
}
|
||||
if loc, err := acs.geoService.fetchFromNominatim(rawAddress); err == nil {
|
||||
acs.geoService.saveToCache(rawAddress, loc)
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: rawAddress,
|
||||
CorrectedAddress: rawAddress,
|
||||
@@ -106,11 +101,6 @@ func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*Address
|
||||
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
|
||||
// ============================================
|
||||
|
||||
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
|
||||
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
|
||||
variants := buildAddressVariants(address)
|
||||
|
||||
@@ -131,7 +121,7 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
|
||||
CorrectedAddress: corrected,
|
||||
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
||||
Confidence: confidence,
|
||||
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)),
|
||||
CorrectionApplied: !strings.EqualFold(utils.NormalizeAddress(address), utils.NormalizeAddress(corrected)),
|
||||
Source: "fuzzy",
|
||||
}, nil
|
||||
}
|
||||
@@ -140,7 +130,6 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
|
||||
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
|
||||
}
|
||||
|
||||
// queryNominatim exécute une requête vers l'API Nominatim
|
||||
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
@@ -172,7 +161,7 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("nominatim status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -196,7 +185,6 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
|
||||
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
|
||||
parts := parseAddressParts(address)
|
||||
|
||||
// Essai 1 : numéro + rue + ville (sans code postal)
|
||||
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
|
||||
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
|
||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||
@@ -206,7 +194,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
|
||||
}
|
||||
}
|
||||
|
||||
// Essai 2 : rue + code postal uniquement
|
||||
if parts.streetName != "" && parts.postcode != "" {
|
||||
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
|
||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||
@@ -216,7 +203,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
|
||||
}
|
||||
}
|
||||
|
||||
// Essai 3 : ville + code postal comme zone de repli
|
||||
if parts.city != "" && parts.postcode != "" {
|
||||
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
|
||||
suggestions, err := acs.queryNominatim(q, 3)
|
||||
@@ -243,7 +229,7 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
|
||||
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
|
||||
func buildAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
normalized := normalize(address)
|
||||
normalized := utils.NormalizeAddress(address)
|
||||
|
||||
// Variante sans accents
|
||||
if normalized != address {
|
||||
@@ -387,8 +373,8 @@ func parseAddressParts(address string) addressParts {
|
||||
|
||||
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
|
||||
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
|
||||
origNorm := normalize(strings.ToLower(original))
|
||||
suggNorm := normalize(strings.ToLower(suggested))
|
||||
origNorm := utils.NormalizeAddress(strings.ToLower(original))
|
||||
suggNorm := utils.NormalizeAddress(strings.ToLower(suggested))
|
||||
|
||||
// Score de similarité sur les mots communs
|
||||
origWords := strings.Fields(origNorm)
|
||||
@@ -449,13 +435,6 @@ func formatNominatimAddress(s NominatimSuggestion) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// normalize supprime les accents et normalise les espaces
|
||||
func normalize(s string) string {
|
||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
result, _, _ := transform.String(t, s)
|
||||
return strings.Join(strings.Fields(result), " ")
|
||||
}
|
||||
|
||||
// isPostcode retourne true si le mot ressemble à un code postal français
|
||||
func isPostcode(s string) bool {
|
||||
if len(s) != 5 {
|
||||
|
||||
@@ -72,19 +72,14 @@ func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 2. TomTom (primaire — plus fiable que Nominatim pour les adresses FR)
|
||||
if location, err := GeocodeWithTomTom(address); err == nil {
|
||||
gs.saveToCache(address, location)
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 3. Fallback Nominatim
|
||||
// 2. Tentative directe via Nominatim
|
||||
if location, err := gs.fetchFromNominatim(address); err == nil {
|
||||
gs.saveToCache(address, location)
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 4. ── Correction automatique de l'adresse ────────────────────────────
|
||||
// 3. ── NOUVEAU : correction automatique de l'adresse ──────────────────
|
||||
// Déclenché uniquement si le géocodage direct a échoué.
|
||||
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
|
||||
|
||||
suggestion, err := gs.correctionService.ResolveAddress(address)
|
||||
@@ -216,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string {
|
||||
return fmt.Sprintf("geocode:cache:%s", address)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CALCULS GÉOGRAPHIQUES
|
||||
// ============================================
|
||||
|
||||
// CalculateDistance calcule la distance entre deux points (formule Haversine)
|
||||
func CalculateDistance(from, to Coordinates) float64 {
|
||||
// Conversion en radians
|
||||
@@ -228,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 {
|
||||
lat2Rad := toRadians(to.Latitude)
|
||||
lon2Rad := toRadians(to.Longitude)
|
||||
|
||||
// Différences
|
||||
dLat := lat2Rad - lat1Rad
|
||||
dLon := lon2Rad - lon1Rad
|
||||
|
||||
@@ -244,13 +234,10 @@ func CalculateDistance(from, to Coordinates) float64 {
|
||||
|
||||
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
|
||||
func CalculateETA(distanceKm float64) int {
|
||||
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
|
||||
if distanceKm < 0.1 {
|
||||
return MinETA // Très proche: minimum 3 minutes
|
||||
return MinETA
|
||||
}
|
||||
|
||||
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
|
||||
// Plus réaliste que 30 km/h
|
||||
travelTime := (distanceKm / 25.0) * 60.0
|
||||
|
||||
// Ajouter une marge pour le trafic (environ 20%)
|
||||
@@ -267,8 +254,6 @@ func CalculateETA(distanceKm float64) int {
|
||||
return totalMinutes
|
||||
}
|
||||
|
||||
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
|
||||
// Retourne (etaMinutes, distanceKm, error)
|
||||
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
||||
if len(tomTomKeys.keys) == 0 {
|
||||
distance := CalculateDistance(from, to)
|
||||
|
||||
@@ -42,7 +42,7 @@ func (s *LBTelegramService) IsConfigured() bool {
|
||||
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
|
||||
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
|
||||
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"user_id": chatID,
|
||||
"username": username,
|
||||
"role": role,
|
||||
@@ -68,7 +68,7 @@ func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) erro
|
||||
// SendNotification envoie un message via la gateway LBTelegram.
|
||||
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
|
||||
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"user_id": userID,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type S3Service struct {
|
||||
client *s3.Client
|
||||
bucketName string
|
||||
}
|
||||
|
||||
type S3Credentials struct {
|
||||
S3KeyId string
|
||||
S3AccessKey string
|
||||
}
|
||||
|
||||
// NewS3Service initialise le client S3 pointant vers RustFS (accessible via VPN).
|
||||
func NewS3Service(region, bucketName, endpoint string, creds S3Credentials) (*S3Service, error) {
|
||||
var cfg aws.Config
|
||||
var err error
|
||||
|
||||
if creds.S3KeyId != "" && creds.S3AccessKey != "" {
|
||||
cfg, err = config.LoadDefaultConfig(context.TODO(),
|
||||
config.WithRegion(region),
|
||||
config.WithCredentialsProvider(
|
||||
credentials.NewStaticCredentialsProvider(creds.S3KeyId, creds.S3AccessKey, ""),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur chargement config AWS: %w", err)
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
if endpoint != "" {
|
||||
o.BaseEndpoint = aws.String(endpoint) // ex: http://10.x.x.x:9000 (IP interne VPN de RustFS)
|
||||
o.UsePathStyle = true
|
||||
}
|
||||
})
|
||||
|
||||
return &S3Service{
|
||||
client: client,
|
||||
bucketName: bucketName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadFile upload un fichier et renvoie sa clé S3 (pas d'URL publique, RustFS est privé).
|
||||
func (s *S3Service) UploadFile(fileHeader *multipart.FileHeader, folder string) (key string, err error) {
|
||||
ext := filepath.Ext(fileHeader.Filename)
|
||||
fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext)
|
||||
return s.UploadFileWithName(fileHeader, folder, fileName)
|
||||
}
|
||||
|
||||
// UploadFileWithName upload un fichier avec un nom déjà déterminé et renvoie la clé S3.
|
||||
func (s *S3Service) UploadFileWithName(fileHeader *multipart.FileHeader, folder, fileName string) (key string, err error) {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("erreur ouverture fichier: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if _, err := buf.ReadFrom(file); err != nil {
|
||||
return "", fmt.Errorf("erreur lecture fichier: %w", err)
|
||||
}
|
||||
|
||||
key = fmt.Sprintf("%s/%s", folder, fileName)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(buf.Bytes()),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("erreur upload RustFS: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GetFile récupère un objet depuis RustFS (stream + content-type) pour le proxy.
|
||||
// Le contexte doit rester actif pendant toute la lecture du body par l'appelant.
|
||||
func (s *S3Service) GetFile(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("erreur lecture RustFS: %w", err)
|
||||
}
|
||||
|
||||
contentType := "application/octet-stream"
|
||||
if out.ContentType != nil {
|
||||
contentType = *out.ContentType
|
||||
}
|
||||
return out.Body, contentType, nil
|
||||
}
|
||||
|
||||
// DeleteFile supprime un fichier à partir de sa clé S3.
|
||||
func (s *S3Service) DeleteFile(key string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur suppression RustFS: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/utils"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Storage abstrait l'emplacement de stockage des médias produits (local ou S3),
|
||||
// pour que tous les points d'upload/suppression respectent le même driver.
|
||||
type Storage interface {
|
||||
// Upload sauvegarde le fichier et renvoie l'URL à persister en DB (models.Media.URL)
|
||||
// et la clé interne (vide pour local, clé S3 sinon — models.Media.Key).
|
||||
Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error)
|
||||
// Delete supprime le fichier. url et key sont ceux stockés en DB pour ce média :
|
||||
// chaque implémentation ignore celui qui ne la concerne pas.
|
||||
Delete(url string, key string) error
|
||||
}
|
||||
|
||||
// LocalStorage stocke les fichiers sur le disque local, sous baseDir (ex: "uploads").
|
||||
type LocalStorage struct {
|
||||
baseDir string
|
||||
}
|
||||
|
||||
func NewLocalStorage(baseDir string) *LocalStorage {
|
||||
return &LocalStorage{baseDir: baseDir}
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
|
||||
destFolder := filepath.Join(s.baseDir, folder)
|
||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
||||
return "", "", fmt.Errorf("erreur création dossier: %w", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(destFolder, fileName)
|
||||
safeFilePath, err := utils.SanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("chemin invalide: %w", err)
|
||||
}
|
||||
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("erreur ouverture fichier: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.OpenFile(safeFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("erreur création fichier: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
os.Remove(safeFilePath)
|
||||
return "", "", fmt.Errorf("erreur écriture fichier: %w", err)
|
||||
}
|
||||
|
||||
return "/" + filepath.ToSlash(safeFilePath), "", nil
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Delete(url string, key string) error {
|
||||
filePath := ""
|
||||
if len(url) > 0 && url[0] == '/' {
|
||||
filePath = url[1:]
|
||||
} else {
|
||||
filePath = url
|
||||
}
|
||||
|
||||
safeFilePath, err := utils.SanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("chemin invalide: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("erreur suppression: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// S3Storage adapte le S3Service existant (RustFS) à l'interface Storage.
|
||||
type S3Storage struct {
|
||||
s3 *S3Service
|
||||
}
|
||||
|
||||
func NewS3Storage(s3 *S3Service) *S3Storage {
|
||||
return &S3Storage{s3: s3}
|
||||
}
|
||||
|
||||
func (s *S3Storage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
|
||||
key, err = s.s3.UploadFileWithName(fileHeader, folder, fileName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return "/media/" + key, key, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Delete(url string, key string) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("clé S3 manquante pour suppression")
|
||||
}
|
||||
return s.s3.DeleteFile(key)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
|
||||
return fmt.Errorf("telegram non configuré")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
@@ -107,11 +107,11 @@ func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, butt
|
||||
row = append(row, map[string]string{"text": b[0], "url": b[1]})
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": map[string]interface{}{
|
||||
"reply_markup": map[string]any{
|
||||
"inline_keyboard": [][]map[string]string{row},
|
||||
},
|
||||
}
|
||||
@@ -147,7 +147,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
|
||||
return fmt.Errorf("telegram non configuré")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"url": webhookURL,
|
||||
"allowed_updates": []string{"message"},
|
||||
}
|
||||
|
||||
@@ -15,72 +15,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// GeocodeWithTomTom géocode une adresse via l'API TomTom Search.
|
||||
func GeocodeWithTomTom(address string) (*GeoLocation, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
buildReq := func(key string) (*http.Request, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.tomtom.com",
|
||||
Path: fmt.Sprintf("/search/2/geocode/%s.json", url.PathEscape(address)),
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("key", key)
|
||||
q.Set("countrySet", "FR")
|
||||
q.Set("limit", "1")
|
||||
u.RawQuery = q.Encode()
|
||||
return http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
}
|
||||
|
||||
resp, err := tomTomKeys.Do(client, buildReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TomTom geocode: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("TomTom geocode %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TomTom geocode lecture: %w", err)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Results []struct {
|
||||
Position struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
} `json:"position"`
|
||||
Address struct {
|
||||
FreeformAddress string `json:"freeformAddress"`
|
||||
} `json:"address"`
|
||||
MatchConfidence struct {
|
||||
Score float64 `json:"score"`
|
||||
} `json:"matchConfidence"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("TomTom geocode parsing: %w", err)
|
||||
}
|
||||
if len(parsed.Results) == 0 {
|
||||
return nil, fmt.Errorf("TomTom geocode: aucun résultat pour '%s'", address)
|
||||
}
|
||||
|
||||
r := parsed.Results[0]
|
||||
log.Printf("📍 [GEO] TomTom geocode '%s' → %s (%.6f, %.6f) conf=%.2f",
|
||||
address, r.Address.FreeformAddress, r.Position.Lat, r.Position.Lon, r.MatchConfidence.Score)
|
||||
|
||||
return &GeoLocation{
|
||||
Latitude: r.Position.Lat,
|
||||
Longitude: r.Position.Lon,
|
||||
DisplayName: r.Address.FreeformAddress,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ func (m *tomTomKeyManager) rotate(fromIdx int) {
|
||||
}
|
||||
|
||||
// Do exécute la requête en rotant automatiquement sur 403/429.
|
||||
// buildReq doit construire une nouvelle *http.Request pour la clé donnée.
|
||||
func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) {
|
||||
n := len(m.keys)
|
||||
if n == 0 {
|
||||
@@ -67,27 +68,23 @@ func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*h
|
||||
|
||||
_, startIdx := m.currentKey()
|
||||
|
||||
for attempt := 0; attempt < n; attempt++ {
|
||||
for attempt := range n {
|
||||
idx := (startIdx + attempt) % n
|
||||
key := m.keys[idx]
|
||||
|
||||
req, err := buildReq(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
m.rotate(idx)
|
||||
continue
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user