veza/veza-backend-api/internal/handlers/health.go
senke 022770ef9f feat(v0.701): AdminTransfers page/route, MSW, stories, Deep Health, API ref, docs, scope v0.702
- Step 13: AdminTransfersPage, LazyAdminTransfers, route /admin/transfers
- Step 14: MSW handlers admin transfers
- Step 15: AdminTransfersView stories (Default, Empty, WithFailedTransfers, Error, Loading)
- Step 16-17: DeepHealth handler (disk, config), GET /health/deep
- Step 19: health_deep_test.go (4 tests)
- Step 20: docs/API_REFERENCE.md
- Step 21: Archive V0_604, MIGRATIONS.md migration 116
- Step 22: CHANGELOG, PROJECT_STATE, FEATURE_STATUS v0.701
- Step 23: RETROSPECTIVE_V0701, V0_702 placeholder, SCOPE_CONTROL, .cursorrules
- Step 24: Archive V0_701_RELEASE_SCOPE
- Fix: AdminTransfersView Select component (use options API)
2026-02-23 23:42:02 +01:00

613 lines
17 KiB
Go

package handlers
import (
"context"
"net/http"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/database"
"veza-backend-api/internal/eventbus"
)
// DeepHealthConfig holds config summary for /health/deep (v0.701)
type DeepHealthConfig struct {
JWTSecretSet bool
StripeConnectEnabled bool
PlatformFeeRate float64
TransferRetryEnabled bool
}
// HealthResponse représente la réponse du health check
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
Checks map[string]HealthCheck `json:"checks"`
Message string `json:"message,omitempty"` // MOD-P1-006: Added for degraded status message
}
// HealthCheck représente le résultat d'un check individuel
type HealthCheck struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Duration float64 `json:"duration_ms,omitempty"`
Threshold float64 `json:"threshold_ms,omitempty"`
Details map[string]interface{} `json:"details,omitempty"` // BE-SVC-016: Additional details
}
// HealthHandler gère les health checks
type HealthHandler struct {
db *gorm.DB
logger *zap.Logger
redis *redis.Client // Typé avec le vrai type Redis
rabbitMQEventBus *eventbus.RabbitMQEventBus // Instance de l'EventBus RabbitMQ
environment string // Env (development, production, etc.)
s3Service interface{} // BE-SVC-016: S3 storage service (optional)
jobWorker interface{} // BE-SVC-016: Job worker (optional)
emailSender interface{} // BE-SVC-016: Email sender (optional)
deepHealthConfig *DeepHealthConfig // v0.701: config summary for /health/deep
}
// NewHealthHandler crée un nouveau handler de health
func NewHealthHandler(db *gorm.DB, logger *zap.Logger, redisClient interface{}, rabbitMQEventBus interface{}, env string) *HealthHandler {
h := &HealthHandler{
db: db,
logger: logger,
environment: env,
}
// Type assertion for Redis
if r, ok := redisClient.(*redis.Client); ok {
h.redis = r
}
// Type assertion for RabbitMQ
if eb, ok := rabbitMQEventBus.(*eventbus.RabbitMQEventBus); ok {
h.rabbitMQEventBus = eb
}
return h
}
// NewHealthHandlerWithServices crée un nouveau handler de health avec services additionnels (BE-SVC-016)
func NewHealthHandlerWithServices(
db *gorm.DB,
logger *zap.Logger,
redisClient interface{},
rabbitMQEventBus interface{},
env string,
s3Service interface{},
jobWorker interface{},
emailSender interface{},
) *HealthHandler {
h := NewHealthHandler(db, logger, redisClient, rabbitMQEventBus, env)
h.s3Service = s3Service
h.jobWorker = jobWorker
h.emailSender = emailSender
return h
}
// SetDeepHealthConfig sets the config summary for DeepHealth (v0.701)
func (h *HealthHandler) SetDeepHealthConfig(cfg *DeepHealthConfig) {
h.deepHealthConfig = cfg
}
// NewHealthHandlerSimple crée un nouveau handler de health simple (sans logger/redis)
// Pour compatibilité avec la spécification T0012
func NewHealthHandlerSimple(db *gorm.DB) *HealthHandler {
return &HealthHandler{
db: db,
}
}
// Check vérifie l'état de la base de données et retourne un status simple
// Cette méthode implémente la spécification T0012
// Route /health - Stateless, sans dépendances externes
func (h *HealthHandler) Check(c *gin.Context) {
// Route /health simplifiée - toujours retourner {status: "ok"}
// Stateless, sans vérification de dépendances
RespondSuccess(c, http.StatusOK, gin.H{
"status": "ok",
})
}
// Health check endpoint (/health)
func (h *HealthHandler) Health(c *gin.Context) {
response := HealthResponse{
Status: "ok",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Checks: make(map[string]HealthCheck),
}
// Check database
dbCheck := h.checkDatabase()
response.Checks["database"] = dbCheck
// Check Redis
redisCheck := h.checkRedis()
response.Checks["redis"] = redisCheck
// Check RabbitMQ
rabbitMQCheck := h.checkRabbitMQ()
response.Checks["rabbitmq"] = rabbitMQCheck
// BE-SVC-016: Check S3 storage (if enabled)
if h.s3Service != nil {
s3Check := h.checkS3()
response.Checks["s3_storage"] = s3Check
}
// BE-SVC-016: Check Job Worker (if enabled)
if h.jobWorker != nil {
jobWorkerCheck := h.checkJobWorker()
response.Checks["job_worker"] = jobWorkerCheck
}
// BE-SVC-016: Check Email Sender (if enabled)
if h.emailSender != nil {
emailCheck := h.checkEmailSender()
response.Checks["email_sender"] = emailCheck
}
// Déterminer le statut global
for _, check := range response.Checks {
if check.Status == "error" {
response.Status = "degraded"
break
}
if check.Status == "slow" {
if response.Status != "degraded" {
response.Status = "degraded"
}
}
}
statusCode := http.StatusOK
if response.Status == "degraded" {
statusCode = http.StatusServiceUnavailable
}
RespondSuccess(c, statusCode, response)
}
// Readiness check endpoint (/ready)
func (h *HealthHandler) Readiness(c *gin.Context) {
response := HealthResponse{
Status: "ready",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Checks: make(map[string]HealthCheck),
}
// Vérifier que la DB est accessible
dbCheck := h.checkDatabase()
response.Checks["database"] = dbCheck
// DB is critical in ALL environments
if dbCheck.Status == "error" {
response.Status = "not_ready"
c.JSON(http.StatusServiceUnavailable, response)
return
}
// MOD-P1-006: Redis and RabbitMQ are optional services
// DB is critical, but Redis/RabbitMQ can be down (degraded mode)
// Vérifier que Redis est accessible
redisCheck := h.checkRedis()
response.Checks["redis"] = redisCheck
// Vérifier que RabbitMQ est accessible (si activé)
rabbitMQCheck := h.checkRabbitMQ()
response.Checks["rabbitmq"] = rabbitMQCheck
// BE-SVC-016: Check optional services
if h.s3Service != nil {
s3Check := h.checkS3()
response.Checks["s3_storage"] = s3Check
}
if h.jobWorker != nil {
jobWorkerCheck := h.checkJobWorker()
response.Checks["job_worker"] = jobWorkerCheck
}
if h.emailSender != nil {
emailCheck := h.checkEmailSender()
response.Checks["email_sender"] = emailCheck
}
// MOD-P1-006: Determine overall status
// - DB error = not_ready (critical service)
// - Redis/RabbitMQ error = degraded (optional services)
// - All OK = ready
hasOptionalServiceError := false
for key, check := range response.Checks {
// Skip database check (already handled above)
if key == "database" {
continue
}
if check.Status == "error" {
hasOptionalServiceError = true
break
}
}
if hasOptionalServiceError {
response.Status = "degraded"
response.Message = "Service is operational but some optional services are unavailable"
// MOD-P1-006: Log degraded status at warn level
if h.logger != nil {
h.logger.Warn("Readiness probe: degraded mode",
zap.String("status", "degraded"),
zap.Any("checks", response.Checks),
)
}
} else {
response.Status = "ready"
}
// MOD-P1-006: Return 200 OK even if degraded (DB is OK, optional services down)
// Kubernetes will not kill the pod if readiness returns 200
RespondSuccess(c, http.StatusOK, response)
}
// Liveness check endpoint (/live)
func (h *HealthHandler) Liveness(c *gin.Context) {
RespondSuccess(c, http.StatusOK, gin.H{
"status": "alive",
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// DeepHealth check endpoint (/health/deep) — v0.701
// Returns healthy, degraded (non-critical services down), or unhealthy (DB down).
// HTTP: 200 for healthy/degraded, 503 for unhealthy.
func (h *HealthHandler) DeepHealth(c *gin.Context) {
response := HealthResponse{
Status: "healthy",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Checks: make(map[string]HealthCheck),
}
// Critical: DB
dbCheck := h.checkDatabase()
response.Checks["database"] = dbCheck
if dbCheck.Status == "error" {
response.Status = "unhealthy"
response.Message = "Database is down"
c.JSON(http.StatusServiceUnavailable, gin.H{"success": true, "data": response})
return
}
// Non-critical: Redis, RabbitMQ, S3, JobWorker, Email
response.Checks["redis"] = h.checkRedis()
response.Checks["rabbitmq"] = h.checkRabbitMQ()
if h.s3Service != nil {
response.Checks["s3_storage"] = h.checkS3()
}
if h.jobWorker != nil {
response.Checks["job_worker"] = h.checkJobWorker()
}
if h.emailSender != nil {
response.Checks["email_sender"] = h.checkEmailSender()
}
// Disk space
diskCheck := h.checkDiskSpace()
response.Checks["disk"] = diskCheck
// Config summary (v0.701)
configSummary := make(map[string]interface{})
if h.deepHealthConfig != nil {
configSummary["jwt_secret_set"] = h.deepHealthConfig.JWTSecretSet
configSummary["stripe_connect_enabled"] = h.deepHealthConfig.StripeConnectEnabled
configSummary["platform_fee_rate"] = h.deepHealthConfig.PlatformFeeRate
configSummary["transfer_retry_enabled"] = h.deepHealthConfig.TransferRetryEnabled
}
// Determine overall status
for key, check := range response.Checks {
if key == "database" {
continue
}
if check.Status == "error" {
response.Status = "degraded"
break
}
}
// Build response with config field
resp := gin.H{
"status": response.Status,
"timestamp": response.Timestamp,
"checks": response.Checks,
"config": configSummary,
}
if response.Message != "" {
resp["message"] = response.Message
}
RespondSuccess(c, http.StatusOK, resp)
}
// checkDiskSpace returns free disk space in GB (v0.701)
func (h *HealthHandler) checkDiskSpace() HealthCheck {
var stat syscall.Statfs_t
if err := syscall.Statfs("/", &stat); err != nil {
return HealthCheck{
Status: "error",
Message: err.Error(),
}
}
// Block size * free blocks / (1024^3) = GB
freeGB := float64(stat.Bfree) * float64(stat.Bsize) / (1024 * 1024 * 1024)
return HealthCheck{
Status: "ok",
Message: "disk space available",
Details: map[string]interface{}{
"free_gb": freeGB,
},
}
}
// SimpleHealthCheck est une fonction simple pour le health check endpoint public
func SimpleHealthCheck(c *gin.Context) {
RespondSuccess(c, http.StatusOK, gin.H{
"status": "healthy",
"service": "veza-backend-api",
})
}
// checkDatabase vérifie la connexion à la base de données avec pool stats
func (h *HealthHandler) checkDatabase() HealthCheck {
start := time.Now()
// Utiliser IsConnectionHealthy avec timeout de 5 secondes
err := database.IsConnectionHealthy(h.db, 5*time.Second)
duration := time.Since(start)
if err != nil {
return HealthCheck{
Status: "error",
Message: err.Error(),
Duration: float64(duration.Nanoseconds()) / 1e6,
}
}
threshold := 100.0 // 100ms threshold
status := "ok"
if duration.Milliseconds() > int64(threshold) {
status = "slow"
}
// BE-SVC-016: Récupérer les statistiques détaillées du pool
poolStats, statsErr := database.GetPoolStats(h.db)
details := make(map[string]interface{})
if statsErr == nil {
details["pool_open_connections"] = poolStats.OpenConnections
details["pool_in_use"] = poolStats.InUse
details["pool_idle"] = poolStats.Idle
details["pool_wait_count"] = poolStats.WaitCount
details["pool_wait_duration"] = poolStats.WaitDuration.String()
details["pool_max_idle_closed"] = poolStats.MaxIdleClosed
details["pool_max_idle_time_closed"] = poolStats.MaxIdleTimeClosed
details["pool_max_lifetime_closed"] = poolStats.MaxLifetimeClosed
}
return HealthCheck{
Status: status,
Message: "database connection healthy",
Duration: float64(duration.Nanoseconds()) / 1e6, // Convert to ms
Threshold: threshold,
Details: details,
}
}
// checkRedis vérifie la connexion à Redis
func (h *HealthHandler) checkRedis() HealthCheck {
start := time.Now()
threshold := 50.0 // 50ms threshold
if h.redis == nil {
return HealthCheck{
Status: "error",
Message: "Redis connection not configured",
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := h.redis.Ping(ctx).Result()
duration := time.Since(start)
if err != nil {
return HealthCheck{
Status: "error",
Message: err.Error(),
Duration: float64(duration.Nanoseconds()) / 1e6,
}
}
status := "ok"
if duration.Milliseconds() > int64(threshold) {
status = "slow"
}
return HealthCheck{
Status: status,
Duration: float64(duration.Nanoseconds()) / 1e6,
Threshold: threshold,
}
}
// checkRabbitMQ vérifie la connexion à RabbitMQ (Event Bus)
func (h *HealthHandler) checkRabbitMQ() HealthCheck {
start := time.Now()
threshold := 100.0 // 100ms threshold
// Vérifier si l'EventBus est configuré
if h.rabbitMQEventBus == nil {
return HealthCheck{
Status: "error",
Message: "RabbitMQ EventBus not configured",
}
}
// Vérifier si l'EventBus est activé via le champ booléen
if !h.rabbitMQEventBus.IsEnabled {
return HealthCheck{
Status: "disabled",
Message: "RabbitMQ EventBus is disabled by configuration",
}
}
// Tenter un Health Check réel
if err := h.rabbitMQEventBus.Health(); err != nil {
duration := time.Since(start)
return HealthCheck{
Status: "error",
Message: err.Error(),
Duration: float64(duration.Nanoseconds()) / 1e6,
}
}
duration := time.Since(start)
status := "ok"
if duration.Milliseconds() > int64(threshold) {
status = "slow"
}
return HealthCheck{
Status: status,
Duration: float64(duration.Nanoseconds()) / 1e6,
Threshold: threshold,
}
}
// checkS3 vérifie l'état du service S3 (BE-SVC-016)
func (h *HealthHandler) checkS3() HealthCheck {
start := time.Now()
threshold := 500.0 // 500ms threshold for S3
if h.s3Service == nil {
return HealthCheck{
Status: "disabled",
Message: "S3 storage service not configured",
}
}
// Type assertion pour vérifier si le service a une méthode Health
// Pour l'instant, on vérifie juste que le service existe
// Une implémentation complète nécessiterait une interface HealthChecker
details := make(map[string]interface{})
details["service"] = "s3_storage"
details["configured"] = true
duration := time.Since(start)
status := "ok"
if duration.Milliseconds() > int64(threshold) {
status = "slow"
}
return HealthCheck{
Status: status,
Message: "S3 storage service configured",
Duration: float64(duration.Nanoseconds()) / 1e6,
Threshold: threshold,
Details: details,
}
}
// checkJobWorker vérifie l'état du Job Worker (BE-SVC-016)
func (h *HealthHandler) checkJobWorker() HealthCheck {
start := time.Now()
threshold := 50.0 // 50ms threshold
if h.jobWorker == nil {
return HealthCheck{
Status: "disabled",
Message: "Job worker not configured",
}
}
// Vérifier que le JobWorker est actif en vérifiant la DB pour les jobs
// Pour une vérification complète, on pourrait compter les jobs en attente/processing
details := make(map[string]interface{})
details["service"] = "job_worker"
details["configured"] = true
// Si on a accès à la DB, on peut compter les jobs
if h.db != nil {
var pendingCount, processingCount, failedCount int64
h.db.Table("jobs").Where("status = ?", "pending").Count(&pendingCount)
h.db.Table("jobs").Where("status = ?", "processing").Count(&processingCount)
h.db.Table("jobs").Where("status = ?", "failed").Count(&failedCount)
details["jobs_pending"] = pendingCount
details["jobs_processing"] = processingCount
details["jobs_failed"] = failedCount
}
duration := time.Since(start)
status := "ok"
if duration.Milliseconds() > int64(threshold) {
status = "slow"
}
return HealthCheck{
Status: status,
Message: "Job worker configured",
Duration: float64(duration.Nanoseconds()) / 1e6,
Threshold: threshold,
Details: details,
}
}
// checkEmailSender vérifie l'état du service d'envoi d'emails (BE-SVC-016)
func (h *HealthHandler) checkEmailSender() HealthCheck {
start := time.Now()
threshold := 100.0 // 100ms threshold
if h.emailSender == nil {
return HealthCheck{
Status: "disabled",
Message: "Email sender not configured",
}
}
// Vérifier la configuration SMTP
// Pour une vérification complète, on pourrait tester la connexion SMTP
details := make(map[string]interface{})
details["service"] = "email_sender"
details["configured"] = true
// Type assertion pour vérifier si c'est un SMTPEmailSender
// et récupérer la config si possible
if smtpSender, ok := h.emailSender.(interface {
GetConfig() interface{}
}); ok {
details["smtp_configured"] = true
_ = smtpSender // Utiliser dans le futur
} else {
details["smtp_configured"] = false
}
duration := time.Since(start)
status := "ok"
if duration.Milliseconds() > int64(threshold) {
status = "slow"
}
return HealthCheck{
Status: status,
Message: "Email sender configured",
Duration: float64(duration.Nanoseconds()) / 1e6,
Threshold: threshold,
Details: details,
}
}