- 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)
613 lines
17 KiB
Go
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,
|
|
}
|
|
}
|