Browse Source

Merge branch 'master' of https://source.viettech.asia/unitel/ESIM

trunghieubui 4 weeks ago
parent
commit
181e2e3648

+ 78 - 76
EsimLao/Common/Constant/CommonConstant.cs

@@ -170,6 +170,8 @@ public static class ApiUrlConstant
     public const String CustomerReviewCreateUrl = "/apis/content/review/create";
     public const String FaqCategoryLoadUrl = "/apis/content/faq-category";
     public const String FaqLoadUrl = "/apis/content/faq";
+    public const String DeviceCompatibilityLoadUrl = "/apis/content/device-compatibility";
+    public const String DeviceMetadataUrl = "/apis/content/device-metadata";
 }
 
 public static class CommonErrorCode
@@ -177,118 +179,118 @@ public static class CommonErrorCode
     // ============================================
     // SUCCESS CODES (0 - 99)
     // ============================================
-    public const int Success = 0;
-    public const int LoginCompleted = 1;
-    public const int RegistrationCompleted = 2;
-    public const int OtpSentSuccessfully = 3;
-    public const int OtpVerifiedSuccessfully = 4;
-    public const int EmailSentSuccessfully = 5;
+    public const string Success = "0";
+    public const string LoginCompleted = "1";
+    public const string RegistrationCompleted = "2";
+    public const string OtpSentSuccessfully = "3";
+    public const string OtpVerifiedSuccessfully = "4";
+    public const string EmailSentSuccessfully = "5";
 
     // ============================================
     // GENERAL ERRORS (-1 to -99)
     // ============================================
-    public const int Error = -1;
-    public const int InvalidRequest = -2;
-    public const int InvalidParameter = -3;
-    public const int DataNotFound = -4;
-    public const int DatabaseError = -5;
-    public const int SystemError = -6;
-    public const int ServiceUnavailable = -7;
-    public const int RateLimitExceeded = -8;
-    public const int OperationFailed = -9;
+    public const string Error = "-1";
+    public const string InvalidRequest = "-2";
+    public const string InvalidParameter = "-3";
+    public const string DataNotFound = "-4";
+    public const string DatabaseError = "-5";
+    public const string SystemError = "-6";
+    public const string ServiceUnavailable = "-7";
+    public const string RateLimitExceeded = "-8";
+    public const string OperationFailed = "-9";
 
     // ============================================
     // AUTHENTICATION ERRORS (-100 to -199)
     // ============================================
-    public const int LoginFails = -100;
-    public const int LoginRequired = -101;
-    public const int SessionExpired = -102;
-    public const int InvalidCredentials = -103;
-    public const int AccountLocked = -104;
-    public const int AccountDisabled = -105;
-    public const int TokenExpired = -106;
-    public const int TokenInvalid = -107;
-    public const int RefreshTokenExpired = -108;
-    public const int RefreshTokenInvalid = -109;
-    public const int UnauthorizedAccess = -110;
-    public const int PermissionDenied = -111;
+    public const string LoginFails = "-100";
+    public const string LoginRequired = "-101";
+    public const string SessionExpired = "-102";
+    public const string InvalidCredentials = "-103";
+    public const string AccountLocked = "-104";
+    public const string AccountDisabled = "-105";
+    public const string TokenExpired = "-106";
+    public const string TokenInvalid = "-107";
+    public const string RefreshTokenExpired = "-108";
+    public const string RefreshTokenInvalid = "-109";
+    public const string UnauthorizedAccess = "-110";
+    public const string PermissionDenied = "-111";
 
     // ============================================
     // OTP ERRORS (-200 to -299)
     // ============================================
-    public const int OtpRequired = -200;
-    public const int OtpInvalid = -201;
-    public const int OtpExpired = -202;
-    public const int OtpAlreadyUsed = -203;
-    public const int OtpMaxAttemptsExceeded = -204;
-    public const int OtpSendFailed = -205;
-    public const int OtpTooManyRequests = -206;
-    public const int OtpNotFound = -207;
+    public const string OtpRequired = "-200";
+    public const string OtpInvalid = "-201";
+    public const string OtpExpired = "-202";
+    public const string OtpAlreadyUsed = "-203";
+    public const string OtpMaxAttemptsExceeded = "-204";
+    public const string OtpSendFailed = "-205";
+    public const string OtpTooManyRequests = "-206";
+    public const string OtpNotFound = "-207";
 
     // ============================================
     // USER/CUSTOMER ERRORS (-300 to -399)
     // ============================================
-    public const int UserNotFound = -300;
-    public const int UserAlreadyExists = -301;
-    public const int UserNotVerified = -302;
-    public const int UserDisabled = -303;
-    public const int InvalidEmail = -304;
-    public const int InvalidPhone = -305;
-    public const int ProfileUpdateFailed = -306;
-    public const int PasswordTooWeak = -307;
-    public const int PasswordMismatch = -308;
+    public const string UserNotFound = "-300";
+    public const string UserAlreadyExists = "-301";
+    public const string UserNotVerified = "-302";
+    public const string UserDisabled = "-303";
+    public const string InvalidEmail = "-304";
+    public const string InvalidPhone = "-305";
+    public const string ProfileUpdateFailed = "-306";
+    public const string PasswordTooWeak = "-307";
+    public const string PasswordMismatch = "-308";
 
     // ============================================
     // EMAIL ERRORS (-400 to -499)
     // ============================================
-    public const int EmailNotConfigured = -400;
-    public const int EmailSendFailed = -401;
-    public const int EmailTemplateNotFound = -402;
-    public const int EmailInvalidRecipient = -403;
-    public const int EmailQueueFailed = -404;
-    public const int SmtpConnectionFailed = -405;
+    public const string EmailNotConfigured = "-400";
+    public const string EmailSendFailed = "-401";
+    public const string EmailTemplateNotFound = "-402";
+    public const string EmailInvalidRecipient = "-403";
+    public const string EmailQueueFailed = "-404";
+    public const string SmtpConnectionFailed = "-405";
 
     // ============================================
     // CAMPAIGN/MISSION ERRORS (-500 to -599)
     // ============================================
-    public const int NoCampaignAvailable = -500;
-    public const int CampaignExpired = -501;
-    public const int CampaignNotStarted = -502;
-    public const int MissionCompleted = -503;
-    public const int MissionNotFound = -504;
-    public const int NeedCompleteQuest = -505;
-    public const int PrizeAlreadyClaimed = -506;
-    public const int PrizeClaimFailed = -507;
+    public const string NoCampaignAvailable = "-500";
+    public const string CampaignExpired = "-501";
+    public const string CampaignNotStarted = "-502";
+    public const string MissionCompleted = "-503";
+    public const string MissionNotFound = "-504";
+    public const string NeedCompleteQuest = "-505";
+    public const string PrizeAlreadyClaimed = "-506";
+    public const string PrizeClaimFailed = "-507";
 
     // ============================================
     // PACKAGE/SUBSCRIPTION ERRORS (-600 to -699)
     // ============================================
-    public const int VendorPackageRequired = -600;
-    public const int PackageNotFound = -601;
-    public const int PackageExpired = -602;
-    public const int PackageAlreadyActive = -603;
-    public const int InsufficientBalance = -604;
-    public const int PaymentFailed = -605;
-    public const int SubscriptionFailed = -606;
+    public const string VendorPackageRequired = "-600";
+    public const string PackageNotFound = "-601";
+    public const string PackageExpired = "-602";
+    public const string PackageAlreadyActive = "-603";
+    public const string InsufficientBalance = "-604";
+    public const string PaymentFailed = "-605";
+    public const string SubscriptionFailed = "-606";
 
     // ============================================
     // EXTERNAL SERVICE ERRORS (-700 to -799)
     // ============================================
-    public const int ExternalServiceError = -700;
-    public const int ExternalServiceTimeout = -701;
-    public const int ExternalServiceUnavailable = -702;
-    public const int MpsRegistered = -703;
-    public const int MpsError = -704;
-    public const int SmsGatewayError = -705;
+    public const string ExternalServiceError = "-700";
+    public const string ExternalServiceTimeout = "-701";
+    public const string ExternalServiceUnavailable = "-702";
+    public const string MpsRegistered = "-703";
+    public const string MpsError = "-704";
+    public const string SmsGatewayError = "-705";
 
     // ============================================
     // VALIDATION ERRORS (-800 to -899)
     // ============================================
-    public const int ValidationFailed = -800;
-    public const int RequiredFieldMissing = -801;
-    public const int InvalidFormat = -802;
-    public const int ValueOutOfRange = -803;
-    public const int DuplicateEntry = -804;
+    public const string ValidationFailed = "-800";
+    public const string RequiredFieldMissing = "-801";
+    public const string InvalidFormat = "-802";
+    public const string ValueOutOfRange = "-803";
+    public const string DuplicateEntry = "-804";
 
     // Prize Types (kept for backward compatibility)
     public static readonly List<string> ListPrizeType = new List<string>

+ 52 - 0
EsimLao/Common/Http/ApiResponseHelper.cs

@@ -0,0 +1,52 @@
+using log4net;
+using Microsoft.AspNetCore.Mvc;
+using Newtonsoft.Json;
+
+namespace Common.Http
+{
+    /// <summary>
+    /// Helper class to build API responses with string error codes
+    /// </summary>
+    public static class ApiResponseHelper
+    {
+        /// <summary>
+        /// Build HTTP response with string error code
+        /// </summary>
+        /// <param name="log">Logger instance</param>
+        /// <param name="url">Request URL</param>
+        /// <param name="request">Request JSON</param>
+        /// <param name="errorCode">Error code as string (e.g., "0", "-801")</param>
+        /// <param name="message">Response message</param>
+        /// <param name="data">Response data object</param>
+        /// <returns>IActionResult</returns>
+        public static IActionResult BuildResponse(
+            ILog log,
+            string url,
+            string request,
+            string errorCode,
+            string message,
+            object data
+        )
+        {
+            // Create response object with string errorCode
+            var response = new
+            {
+                errorCode = errorCode,  // String type
+                message = message,
+                data = data
+            };
+
+            // Log the response
+            string responseJson = JsonConvert.SerializeObject(response);
+            log.Info($"URL: {url} | Request: {request} | Response: {responseJson}");
+
+            // Return as JSON result with proper content type
+            return new ContentResult
+            {
+                Content = responseJson,
+                ContentType = "application/json",
+                StatusCode = 200
+            };
+        }
+    }
+}

+ 52 - 0
EsimLao/Common/Http/ContentRequest.cs

@@ -0,0 +1,52 @@
+using System;
+
+namespace Common.Http
+{
+    // =============== BANNER ===============
+    public class BannerLoadReq
+    {
+        public string? lang { get; set; } = "lo";
+        public int pageNumber { get; set; } = 0;
+        public int pageSize { get; set; } = 10;
+        /// <summary>Position filter: "home", "sidebar", etc.</summary>
+        public string? position { get; set; }
+    }
+
+    // =============== CUSTOMER REVIEW ===============
+    public class CustomerReviewLoadReq
+    {
+        public string? lang { get; set; } = "lo";
+        public int pageNumber { get; set; } = 0;
+        public int pageSize { get; set; } = 10;
+        /// <summary>Filter featured reviews only</summary>
+        public bool? isFeatured { get; set; }
+    }
+
+    public class CustomerReviewCreateReq
+    {
+        public string? lang { get; set; } = "lo";
+        public string customerName { get; set; } = null!;
+        public string reviewContent { get; set; } = null!;
+        public string? destination { get; set; }
+        public int rating { get; set; }
+    }
+
+    // =============== FAQ ===============
+    public class FaqCategoryLoadReq
+    {
+        public string? lang { get; set; } = "lo";
+        public int pageNumber { get; set; } = 0;
+        public int pageSize { get; set; } = 10;
+    }
+
+    public class FaqLoadReq
+    {
+        public string? lang { get; set; } = "lo";
+        public int pageNumber { get; set; } = 0;
+        public int pageSize { get; set; } = 10;
+        /// <summary>Filter by category ID</summary>
+        public int? categoryId { get; set; }
+        /// <summary>Filter featured FAQs only</summary>
+        public bool? isFeatured { get; set; }
+    }
+}

+ 1 - 1
EsimLao/Database/Database/CustomerReview.cs

@@ -11,7 +11,7 @@ public partial class CustomerReview
 
     public string? AvatarUrl { get; set; }
 
-    public bool? Rating { get; set; }
+    public int Rating { get; set; }
 
     public string ReviewContent { get; set; } = null!;
 

+ 36 - 0
EsimLao/Database/Database/DeviceEsimCompatibility.cs

@@ -0,0 +1,36 @@
+using System;
+
+namespace Database.Database;
+
+public partial class DeviceEsimCompatibility
+{
+    public int Id { get; set; }
+
+    public string Brand { get; set; } = null!;
+
+    public string ModelName { get; set; } = null!;
+
+    public string? ModelNameEn { get; set; }
+
+    public string? ModelNameLo { get; set; }
+
+    public bool? SupportsEsim { get; set; }
+
+    public string? Category { get; set; }
+
+    public string? Notes { get; set; }
+
+    public string? NotesEn { get; set; }
+
+    public string? NotesLo { get; set; }
+
+    public bool? IsPopular { get; set; }
+
+    public int? DisplayOrder { get; set; }
+
+    public bool? Status { get; set; }
+
+    public DateTime? CreatedDate { get; set; }
+
+    public DateTime? UpdatedDate { get; set; }
+}

+ 72 - 3
EsimLao/Database/Database/ModelContext.cs

@@ -39,6 +39,8 @@ public partial class ModelContext : DbContext
 
     public virtual DbSet<CustomerReview> CustomerReviews { get; set; }
 
+    public virtual DbSet<DeviceEsimCompatibility> DeviceEsimCompatibilities { get; set; }
+
     public virtual DbSet<Faq> Faqs { get; set; }
 
     public virtual DbSet<FaqCategory> FaqCategories { get; set; }
@@ -623,9 +625,9 @@ public partial class ModelContext : DbContext
 
         modelBuilder.Entity<CustomerInfo>(entity =>
         {
-            entity
-                .HasNoKey()
-                .ToTable("CUSTOMER_INFO");
+            entity.HasKey(e => e.Id).HasName("CUSTOMER_INFO_PK");
+
+            entity.ToTable("CUSTOMER_INFO");
 
             entity.HasIndex(e => e.Email, "CUSTOMER_INFO_EMAIL_UQ").IsUnique();
 
@@ -646,6 +648,7 @@ public partial class ModelContext : DbContext
                 .HasColumnName("GOOGLE_ID");
             entity.Property(e => e.Id)
                 .HasPrecision(10)
+                .ValueGeneratedNever()
                 .HasColumnName("ID");
             entity.Property(e => e.IsVerified)
                 .HasPrecision(1)
@@ -741,6 +744,72 @@ public partial class ModelContext : DbContext
                 .HasColumnName("STATUS");
         });
 
+        modelBuilder.Entity<DeviceEsimCompatibility>(entity =>
+        {
+            entity.HasKey(e => e.Id).HasName("DEVICE_ESIM_COMPATIBILITY_PK");
+
+            entity.ToTable("DEVICE_ESIM_COMPATIBILITY");
+
+            entity.HasIndex(e => e.Brand, "IDX_DEVICE_BRAND");
+            entity.HasIndex(e => e.ModelName, "IDX_DEVICE_MODEL");
+            entity.HasIndex(e => new { e.IsPopular, e.Status }, "IDX_DEVICE_POPULAR");
+            entity.HasIndex(e => new { e.Category, e.Status }, "IDX_DEVICE_CATEGORY");
+
+            entity.Property(e => e.Id)
+                .HasPrecision(10)
+                .ValueGeneratedNever()
+                .HasColumnName("ID");
+            entity.Property(e => e.Brand)
+                .HasMaxLength(100)
+                .HasColumnName("BRAND");
+            entity.Property(e => e.ModelName)
+                .HasMaxLength(200)
+                .HasColumnName("MODEL_NAME");
+            entity.Property(e => e.ModelNameEn)
+                .HasMaxLength(200)
+                .HasColumnName("MODEL_NAME_EN");
+            entity.Property(e => e.ModelNameLo)
+                .HasMaxLength(200)
+                .HasColumnName("MODEL_NAME_LO");
+            entity.Property(e => e.SupportsEsim)
+                .HasPrecision(1)
+                .HasDefaultValueSql("1")
+                .HasColumnName("SUPPORTS_ESIM");
+            entity.Property(e => e.Category)
+                .HasMaxLength(50)
+                .IsUnicode(false)
+                .HasDefaultValueSql("'Phone'")
+                .HasColumnName("CATEGORY");
+            entity.Property(e => e.Notes)
+                .HasMaxLength(1000)
+                .HasColumnName("NOTES");
+            entity.Property(e => e.NotesEn)
+                .HasMaxLength(1000)
+                .HasColumnName("NOTES_EN");
+            entity.Property(e => e.NotesLo)
+                .HasMaxLength(1000)
+                .HasColumnName("NOTES_LO");
+            entity.Property(e => e.IsPopular)
+                .HasPrecision(1)
+                .HasDefaultValueSql("0")
+                .HasColumnName("IS_POPULAR");
+            entity.Property(e => e.DisplayOrder)
+                .HasPrecision(10)
+                .HasDefaultValueSql("999")
+                .HasColumnName("DISPLAY_ORDER");
+            entity.Property(e => e.Status)
+                .HasPrecision(1)
+                .HasDefaultValueSql("1")
+                .HasColumnName("STATUS");
+            entity.Property(e => e.CreatedDate)
+                .HasDefaultValueSql("SYSDATE")
+                .HasColumnType("DATE")
+                .HasColumnName("CREATED_DATE");
+            entity.Property(e => e.UpdatedDate)
+                .HasColumnType("DATE")
+                .HasColumnName("UPDATED_DATE");
+        });
+
         modelBuilder.Entity<Faq>(entity =>
         {
             entity.HasKey(e => e.Id).HasName("FAQ_PK");

+ 8 - 8
EsimLao/Esim.Apis/Business/Article/ArticleBusinessImpl.cs

@@ -84,7 +84,7 @@ namespace Esim.Apis.Business
                     })
                     .ToList();
 
-                return DotnetLib.Http.HttpResponse.BuildResponse(
+                return ApiResponseHelper.BuildResponse(
                     log,
                     url,
                     json,
@@ -92,7 +92,7 @@ namespace Esim.Apis.Business
                     ConfigManager.Instance.GetConfigWebValue("LOAD_SUCCESS", lang),
                     new
                     {
-                        items = categories,
+                        categories = categories,
                         pagination = new
                         {
                             pageNumber,
@@ -107,7 +107,7 @@ namespace Esim.Apis.Business
             {
                 log.Error("Exception: ", exception);
             }
-            return DotnetLib.Http.HttpResponse.BuildResponse(
+            return ApiResponseHelper.BuildResponse(
                 log,
                 url,
                 json,
@@ -165,7 +165,7 @@ namespace Esim.Apis.Business
 
                     if (article == null)
                     {
-                        return DotnetLib.Http.HttpResponse.BuildResponse(
+                        return ApiResponseHelper.BuildResponse(
                             log,
                             url,
                             json,
@@ -183,7 +183,7 @@ namespace Esim.Apis.Business
                         await dbContext.SaveChangesAsync();
                     }
 
-                    return DotnetLib.Http.HttpResponse.BuildResponse(
+                    return ApiResponseHelper.BuildResponse(
                         log,
                         url,
                         json,
@@ -239,7 +239,7 @@ namespace Esim.Apis.Business
                     })
                     .ToList();
 
-                return DotnetLib.Http.HttpResponse.BuildResponse(
+                return ApiResponseHelper.BuildResponse(
                     log,
                     url,
                     json,
@@ -247,7 +247,7 @@ namespace Esim.Apis.Business
                     ConfigManager.Instance.GetConfigWebValue("LOAD_SUCCESS", lang),
                     new
                     {
-                        items = articles,
+                        articles = articles,
                         pagination = new
                         {
                             pageNumber,
@@ -262,7 +262,7 @@ namespace Esim.Apis.Business
             {
                 log.Error("Exception: ", exception);
             }
-            return DotnetLib.Http.HttpResponse.BuildResponse(
+            return ApiResponseHelper.BuildResponse(
                 log,
                 url,
                 json,

+ 178 - 16
EsimLao/Esim.Apis/Business/Content/ContentBusinessImpl.cs

@@ -2,6 +2,7 @@ using Common;
 using Common.Constant;
 using Common.Http;
 using Common.Logic;
+using Esim.Apis.DTO.Content;
 using Esim.Apis.Singleton;
 using Database.Database;
 using log4net;
@@ -72,18 +73,18 @@ namespace Esim.Apis.Business
                     })
                     .ToList();
 
-                return DotnetLib.Http.HttpResponse.BuildResponse(
+                return ApiResponseHelper.BuildResponse(
                     log, url, json,
                     CommonErrorCode.Success,
                     ConfigManager.Instance.GetConfigWebValue("LOAD_SUCCESS", lang),
-                    new { items = banners, pagination = new { pageNumber, pageSize, totalCount, totalPages } }
+                    new { banners = banners, pagination = new { pageNumber, pageSize, totalCount, totalPages } }
                 );
             }
             catch (Exception ex)
             {
                 log.Error("Exception: ", ex);
             }
-            return DotnetLib.Http.HttpResponse.BuildResponse(
+            return ApiResponseHelper.BuildResponse(
                 log, url, json,
                 CommonErrorCode.SystemError,
                 ConfigManager.Instance.GetConfigWebValue("SYSTEM_FAILURE"),
@@ -135,18 +136,18 @@ namespace Esim.Apis.Business
                     })
                     .ToList();
 
-                return DotnetLib.Http.HttpResponse.BuildResponse(
+                return ApiResponseHelper.BuildResponse(
                     log, url, json,
                     CommonErrorCode.Success,
                     ConfigManager.Instance.GetConfigWebValue("LOAD_SUCCESS", lang),
-                    new { items = reviews, pagination = new { pageNumber, pageSize, totalCount, totalPages } }
+                    new { reviews = reviews, pagination = new { pageNumber, pageSize, totalCount, totalPages } }
                 );
             }
             catch (Exception ex)
             {
                 log.Error("Exception: ", ex);
             }
-            return DotnetLib.Http.HttpResponse.BuildResponse(
+            return ApiResponseHelper.BuildResponse(
                 log, url, json,
                 CommonErrorCode.SystemError,
                 ConfigManager.Instance.GetConfigWebValue("SYSTEM_FAILURE"),
@@ -166,7 +167,7 @@ namespace Esim.Apis.Business
                 // Validate required fields
                 if (string.IsNullOrEmpty(request.customerName) || string.IsNullOrEmpty(request.reviewContent))
                 {
-                    return DotnetLib.Http.HttpResponse.BuildResponse(
+                    return ApiResponseHelper.BuildResponse(
                         log, url, json,
                         CommonErrorCode.RequiredFieldMissing,
                         ConfigManager.Instance.GetConfigWebValue("REQUIRED_FIELD_MISSING", lang),
@@ -180,7 +181,7 @@ namespace Esim.Apis.Business
                     CustomerName = request.customerName,
                     ReviewContent = request.reviewContent,
                     Destination = request.destination,
-                    Rating = request.rating > 0,
+                    Rating = request.rating > 0 ? request.rating : 1,
                     Status = false, // Pending approval
                     IsFeatured = false,
                     CreatedDate = DateTime.Now
@@ -189,7 +190,7 @@ namespace Esim.Apis.Business
                 dbContext.CustomerReviews.Add(review);
                 await dbContext.SaveChangesAsync();
 
-                return DotnetLib.Http.HttpResponse.BuildResponse(
+                return ApiResponseHelper.BuildResponse(
                     log, url, json,
                     CommonErrorCode.Success,
                     ConfigManager.Instance.GetConfigWebValue("REVIEW_SUBMITTED", lang),
@@ -200,7 +201,7 @@ namespace Esim.Apis.Business
             {
                 log.Error("Exception: ", ex);
             }
-            return DotnetLib.Http.HttpResponse.BuildResponse(
+            return ApiResponseHelper.BuildResponse(
                 log, url, json,
                 CommonErrorCode.SystemError,
                 ConfigManager.Instance.GetConfigWebValue("SYSTEM_FAILURE"),
@@ -245,18 +246,18 @@ namespace Esim.Apis.Business
                     })
                     .ToList();
 
-                return DotnetLib.Http.HttpResponse.BuildResponse(
+                return ApiResponseHelper.BuildResponse(
                     log, url, json,
                     CommonErrorCode.Success,
                     ConfigManager.Instance.GetConfigWebValue("LOAD_SUCCESS", lang),
-                    new { items = categories, pagination = new { pageNumber, pageSize, totalCount, totalPages } }
+                    new { categories = categories, pagination = new { pageNumber, pageSize, totalCount, totalPages } }
                 );
             }
             catch (Exception ex)
             {
                 log.Error("Exception: ", ex);
             }
-            return DotnetLib.Http.HttpResponse.BuildResponse(
+            return ApiResponseHelper.BuildResponse(
                 log, url, json,
                 CommonErrorCode.SystemError,
                 ConfigManager.Instance.GetConfigWebValue("SYSTEM_FAILURE"),
@@ -311,18 +312,18 @@ namespace Esim.Apis.Business
                     })
                     .ToList();
 
-                return DotnetLib.Http.HttpResponse.BuildResponse(
+                return ApiResponseHelper.BuildResponse(
                     log, url, json,
                     CommonErrorCode.Success,
                     ConfigManager.Instance.GetConfigWebValue("LOAD_SUCCESS", lang),
-                    new { items = faqs, pagination = new { pageNumber, pageSize, totalCount, totalPages } }
+                    new { faqs = faqs, pagination = new { pageNumber, pageSize, totalCount, totalPages } }
                 );
             }
             catch (Exception ex)
             {
                 log.Error("Exception: ", ex);
             }
-            return DotnetLib.Http.HttpResponse.BuildResponse(
+            return ApiResponseHelper.BuildResponse(
                 log, url, json,
                 CommonErrorCode.SystemError,
                 ConfigManager.Instance.GetConfigWebValue("SYSTEM_FAILURE"),
@@ -330,5 +331,166 @@ namespace Esim.Apis.Business
             );
         }
 
+        /// <summary>
+        /// Load device eSIM compatibility list
+        /// </summary>
+        public async Task<IActionResult> DeviceCompatibilityLoad(HttpRequest httpRequest, DeviceCompatibilityReq request)
+        {
+            var url = httpRequest.Path;
+            var json = JsonConvert.SerializeObject(request);
+            log.Debug("URL: " + url + " => Request: " + json);
+            try
+            {
+                string lang = CommonLogic.GetLanguage(httpRequest, request.lang);
+                int pageNumber = request.pageNumber < 0 ? 0 : request.pageNumber;
+                int pageSize = request.pageSize <= 0 ? 50 : request.pageSize;
+
+                // Base query - only active devices
+                var query = dbContext.DeviceEsimCompatibilities
+                    .Where(d => d.Status == true && d.SupportsEsim == true);
+
+                // Filter by brand
+                if (!string.IsNullOrEmpty(request.brand))
+                {
+                    query = query.Where(d => d.Brand == request.brand);
+                }
+
+                // Filter by category
+                if (!string.IsNullOrEmpty(request.category))
+                {
+                    query = query.Where(d => d.Category == request.category);
+                }
+
+                // Filter by popular flag
+                if (request.isPopular.HasValue && request.isPopular.Value)
+                {
+                    query = query.Where(d => d.IsPopular == true);
+                }
+
+                // Search by keyword in model name (all language variants)
+                if (!string.IsNullOrEmpty(request.searchKeyword))
+                {
+                    var keyword = request.searchKeyword.ToLower();
+                    query = query.Where(d =>
+                        d.ModelName.ToLower().Contains(keyword) ||
+                        (d.ModelNameEn != null && d.ModelNameEn.ToLower().Contains(keyword)) ||
+                        (d.ModelNameLo != null && d.ModelNameLo.ToLower().Contains(keyword))
+                    );
+                }
+
+                // Get total count for pagination
+                int totalCount = query.Count();
+                int totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
+
+                // Apply pagination and ordering
+                var devices = query
+                    .OrderBy(d => d.DisplayOrder)
+                    .ThenBy(d => d.Brand)
+                    .ThenBy(d => d.ModelName)
+                    .Skip(pageNumber * pageSize)
+                    .Take(pageSize)
+                    .Select(d => new
+                    {
+                        d.Id,
+                        d.Brand,
+                        modelName = lang == "en"
+                            ? (d.ModelNameEn ?? d.ModelName)
+                            : (d.ModelNameLo ?? d.ModelName),
+                        d.Category,
+                        notes = lang == "en"
+                            ? (d.NotesEn ?? d.Notes)
+                            : (d.NotesLo ?? d.Notes),
+                        supportsEsim = d.SupportsEsim ?? true,
+                        isPopular = d.IsPopular ?? false,
+                        displayOrder = d.DisplayOrder ?? 999
+                    })
+                    .ToList();
+
+                return ApiResponseHelper.BuildResponse(
+                    log, url, json,
+                    CommonErrorCode.Success,
+                    ConfigManager.Instance.GetConfigWebValue("LOAD_SUCCESS", lang),
+                    new
+                    {
+                        devices = devices,
+                        pagination = new
+                        {
+                            pageNumber,
+                            pageSize,
+                            totalCount,
+                            totalPages
+                        }
+                    }
+                );
+            }
+            catch (Exception exception)
+            {
+                log.Error("Exception: ", exception);
+            }
+            return ApiResponseHelper.BuildResponse(
+                log, url, json,
+                CommonErrorCode.SystemError,
+                ConfigManager.Instance.GetConfigWebValue("SYSTEM_FAILURE"),
+                new { }
+            );
+        }
+
+        /// <summary>
+        /// Get list of device brands and categories (for filters/tabs)
+        /// </summary>
+        public async Task<IActionResult> GetDeviceBrandsAndCategories(HttpRequest httpRequest)
+        {
+            var url = httpRequest.Path;
+            log.Debug("URL: " + url);
+            try
+            {
+                // Get distinct brands with device count
+                var brands = dbContext.DeviceEsimCompatibilities
+                    .Where(d => d.Status == true && d.SupportsEsim == true)
+                    .GroupBy(d => d.Brand)
+                    .Select(g => new
+                    {
+                        brand = g.Key,
+                        deviceCount = g.Count(),
+                        popularCount = g.Count(d => d.IsPopular == true)
+                    })
+                    .OrderBy(b => b.brand)
+                    .ToList();
+
+                // Get distinct categories with device count
+                var categories = dbContext.DeviceEsimCompatibilities
+                    .Where(d => d.Status == true && d.SupportsEsim == true)
+                    .GroupBy(d => d.Category)
+                    .Select(g => new
+                    {
+                        category = g.Key,
+                        deviceCount = g.Count()
+                    })
+                    .OrderBy(c => c.category)
+                    .ToList();
+
+                return ApiResponseHelper.BuildResponse(
+                    log, url, "",
+                    CommonErrorCode.Success,
+                    ConfigManager.Instance.GetConfigWebValue("LOAD_SUCCESS"),
+                    new
+                    {
+                        brands = brands,
+                        categories = categories
+                    }
+                );
+            }
+            catch (Exception exception)
+            {
+                log.Error("Exception: ", exception);
+            }
+            return ApiResponseHelper.BuildResponse(
+                log, url, "",
+                CommonErrorCode.SystemError,
+                ConfigManager.Instance.GetConfigWebValue("SYSTEM_FAILURE"),
+                new { }
+            );
+        }
+
     }
 }

+ 5 - 0
EsimLao/Esim.Apis/Business/Content/IContentBusiness.cs

@@ -1,5 +1,7 @@
+
 using System;
 using Common.Http;
+using Esim.Apis.DTO.Content;
 using Microsoft.AspNetCore.Mvc;
 
 namespace Esim.Apis.Business
@@ -13,5 +15,8 @@ namespace Esim.Apis.Business
 
         Task<IActionResult> FaqCategoryLoad(HttpRequest httpRequest, FaqCategoryLoadReq request);
         Task<IActionResult> FaqLoad(HttpRequest httpRequest, FaqLoadReq request);
+
+        Task<IActionResult> DeviceCompatibilityLoad(HttpRequest httpRequest, DeviceCompatibilityReq request);
+        Task<IActionResult> GetDeviceBrandsAndCategories(HttpRequest httpRequest);
     }
 }

+ 74 - 62
EsimLao/Esim.Apis/Business/User/UserBusinessImpl.cs

@@ -49,7 +49,7 @@ namespace Esim.Apis.Business
             {
                 if (string.IsNullOrEmpty(request.email))
                 {
-                    return DotnetLib.Http.HttpResponse.BuildResponse(
+                    return ApiResponseHelper.BuildResponse(
                         log,
                         url,
                         json,
@@ -59,8 +59,9 @@ namespace Esim.Apis.Business
                     );
                 }
 
-                // Generate 6-digit OTP
-                string otpCode = GenerateOtp();
+                // Generate 6-digit OTP (fixed 111111 for test account abc@gmail.com)
+                bool isTestAccount = request.email.ToLower() == "abc@gmail.com";
+                string otpCode = isTestAccount ? "111111" : GenerateOtp();
 
                 // Check if customer exists, if not create new
                 var customer = dbContext.CustomerInfos
@@ -73,10 +74,16 @@ namespace Esim.Apis.Business
                 {
                     // Create new customer record - manually get ID from Oracle sequence
                     var newCustomerId = await Database.DbLogic.GenIdAsync(dbContext, "CUSTOMER_INFO_SEQ");
+                    
+                    // Extract name from email (part before @)
+                    string emailUsername = request.email.Split('@')[0];
+                    
                     var newCustomer = new CustomerInfo
                     {
                         Id = newCustomerId,
                         Email = request.email,
+                        SurName = emailUsername,
+                        LastName = emailUsername,
                         Status = true,
                         IsVerified = false,
                         CreatedDate = DateTime.Now,
@@ -99,10 +106,10 @@ namespace Esim.Apis.Business
 
                 // Create new OTP record
                 int otpExpireMinutes = 5;
-                //var otpId = (int)await Database.DbLogic.GenIdAsync(dbContext, "OTP_VERIFICATION_SEQ");
+                var otpId = (int)await Database.DbLogic.GenIdAsync(dbContext, "OTP_VERIFICATION_SEQ");
                 var otpVerification = new OtpVerification
                 {
-                    //Id = otpId,
+                    Id = otpId,
                     CustomerId = customerId,
                     UserEmail = request.email,
                     OtpCode = otpCode,
@@ -116,62 +123,67 @@ namespace Esim.Apis.Business
                 dbContext.OtpVerifications.Add(otpVerification);
                 await dbContext.SaveChangesAsync();
 
-                // Add to MESSAGE_QUEUE for background email sending
-                // Resolve template content now so Worker only needs to send email
-                string lang = CommonLogic.GetLanguage(httpRequest, request.lang);
-                string templateCode = "OTP_LOGIN";
-
-                // Query template and get language-specific content
-                var template = dbContext.MessageTemplates
-                    .FirstOrDefault(t => t.TemplateCode == templateCode && t.Status == true);
-
-                if (template == null)
+                // Skip email sending for test account
+                if (!isTestAccount)
                 {
-                    log.Error($"Template '{templateCode}' not found in MESSAGE_TEMPLATE");
-                    throw new Exception($"Email template '{templateCode}' not found");
-                }
-
-                // Get subject based on language (fallback to default column if _LO/_EN is null)
-                string emailSubject = lang == "en"
-                    ? (template.SubjectEn ?? template.Subject ?? "")
-                    : (template.SubjectLo ?? template.Subject ?? "");
-
-                // Get content based on language (fallback to default column if _LO/_EN is null)
-                string emailContent = lang == "en"
-                    ? (template.ContentEn ?? template.Content ?? "")
-                    : (template.ContentLo ?? template.Content ?? "");
+                    // Add to MESSAGE_QUEUE for background email sending
+                    // Resolve template content now so Worker only needs to send email
+                    string lang = CommonLogic.GetLanguage(httpRequest, request.lang);
+                    string templateCode = "OTP_LOGIN";
 
-                // Replace placeholders in content
-                emailContent = emailContent
-                    .Replace("{{OTP_CODE}}", otpCode)
-                    .Replace("{{EXPIRE_MINUTES}}", otpExpireMinutes.ToString());
+                    // Query template and get language-specific content
+                    var template = dbContext.MessageTemplates
+                        .FirstOrDefault(t => t.TemplateCode == templateCode && t.Status == true);
 
-                // Replace placeholders in subject (if any)
-                emailSubject = emailSubject
-                    .Replace("{{OTP_CODE}}", otpCode)
-                    .Replace("{{EXPIRE_MINUTES}}", otpExpireMinutes.ToString());
+                    if (template == null)
+                    {
+                        log.Error($"Template '{templateCode}' not found in MESSAGE_TEMPLATE");
+                        throw new Exception($"Email template '{templateCode}' not found");
+                    }
 
-                var emailMessage = new MessageQueue
-                {
-                    MessageType = 1, // Email
-                    Recipient = request.email,
-                    Subject = emailSubject,     // Pre-resolved subject
-                    Content = emailContent,     // Pre-resolved content
-                    Priority = true, // High priority
-                    Status = 0, // Pending
-                    ScheduledAt = DateTime.Now,
-                    RetryCount = 0,
-                    MaxRetry = 3,
-                    CreatedBy = customerId,
-                    CreatedDate = DateTime.Now
-                };
+                    // Get subject based on language (fallback to default column if _LO/_EN is null)
+                    string emailSubject = lang == "en"
+                        ? (template.SubjectEn ?? template.Subject ?? "")
+                        : (template.SubjectLo ?? template.Subject ?? "");
+
+                    // Get content based on language (fallback to default column if _LO/_EN is null)
+                    string emailContent = lang == "en"
+                        ? (template.ContentEn ?? template.Content ?? "")
+                        : (template.ContentLo ?? template.Content ?? "");
+
+                    // Replace placeholders in content
+                    emailContent = emailContent
+                        .Replace("{{OTP_CODE}}", otpCode)
+                        .Replace("{{EXPIRE_MINUTES}}", otpExpireMinutes.ToString());
+
+                    // Replace placeholders in subject (if any)
+                    emailSubject = emailSubject
+                        .Replace("{{OTP_CODE}}", otpCode)
+                        .Replace("{{EXPIRE_MINUTES}}", otpExpireMinutes.ToString());
+                    var emailMessageID = (int)await Database.DbLogic.GenIdAsync(dbContext, "MESSAGE_QUEUE_SEQ");
+                    var emailMessage = new MessageQueue
+                    {
+                        Id = emailMessageID,
+                        MessageType = 1, // Email
+                        Recipient = request.email,
+                        Subject = emailSubject,     // Pre-resolved subject
+                        Content = emailContent,     // Pre-resolved content
+                        Priority = true, // High priority
+                        Status = 0, // Pending
+                        ScheduledAt = DateTime.Now,
+                        RetryCount = 0,
+                        MaxRetry = 3,
+                        CreatedBy = customerId,
+                        CreatedDate = DateTime.Now
+                    };
 
-                dbContext.MessageQueues.Add(emailMessage);
-                await dbContext.SaveChangesAsync();
+                    dbContext.MessageQueues.Add(emailMessage);
+                    await dbContext.SaveChangesAsync();
+                }
 
-                log.Info($"OTP generated for {request.email}: {otpCode} - Email queued");
+                log.Info($"OTP generated for {request.email}: {otpCode} - {(isTestAccount ? "Test account, no email sent" : "Email queued")}");
 
-                return DotnetLib.Http.HttpResponse.BuildResponse(
+                return ApiResponseHelper.BuildResponse(
                     log,
                     url,
                     json,
@@ -188,7 +200,7 @@ namespace Esim.Apis.Business
             {
                 log.Error("Exception: ", exception);
             }
-            return DotnetLib.Http.HttpResponse.BuildResponse(
+            return ApiResponseHelper.BuildResponse(
                 log,
                 url,
                 json,
@@ -211,7 +223,7 @@ namespace Esim.Apis.Business
                 if (string.IsNullOrEmpty(request.email) || string.IsNullOrEmpty(request.otpCode))
                 {
                     string lang = CommonLogic.GetLanguage(httpRequest, request.lang);
-                    return DotnetLib.Http.HttpResponse.BuildResponse(
+                    return ApiResponseHelper.BuildResponse(
                         log,
                         url,
                         json,
@@ -244,7 +256,7 @@ namespace Esim.Apis.Business
                     {
                         if (anyOtp.IsUsed == true)
                         {
-                            return DotnetLib.Http.HttpResponse.BuildResponse(
+                            return ApiResponseHelper.BuildResponse(
                                 log,
                                 url,
                                 json,
@@ -255,7 +267,7 @@ namespace Esim.Apis.Business
                         }
                         if (anyOtp.ExpiredAt <= DateTime.Now)
                         {
-                            return DotnetLib.Http.HttpResponse.BuildResponse(
+                            return ApiResponseHelper.BuildResponse(
                                 log,
                                 url,
                                 json,
@@ -266,7 +278,7 @@ namespace Esim.Apis.Business
                         }
                     }
 
-                    return DotnetLib.Http.HttpResponse.BuildResponse(
+                    return ApiResponseHelper.BuildResponse(
                         log,
                         url,
                         json,
@@ -286,7 +298,7 @@ namespace Esim.Apis.Business
 
                 if (customer == null)
                 {
-                    return DotnetLib.Http.HttpResponse.BuildResponse(
+                    return ApiResponseHelper.BuildResponse(
                         log,
                         url,
                         json,
@@ -341,7 +353,7 @@ namespace Esim.Apis.Business
                 dbContext.UserTokens.Add(userToken);
                 await dbContext.SaveChangesAsync();
 
-                return DotnetLib.Http.HttpResponse.BuildResponse(
+                return ApiResponseHelper.BuildResponse(
                     log,
                     url,
                     json,
@@ -363,7 +375,7 @@ namespace Esim.Apis.Business
             {
                 log.Error("Exception: ", exception);
             }
-            return DotnetLib.Http.HttpResponse.BuildResponse(
+            return ApiResponseHelper.BuildResponse(
                 log,
                 url,
                 json,

+ 104 - 0
EsimLao/Esim.Apis/Controllers/ContentController.cs

@@ -0,0 +1,104 @@
+using System;
+using Common;
+using Common.Constant;
+using Common.Http;
+using Esim.Apis.Business;
+using Esim.Apis.DTO.Content;
+using Database.Database;
+using Microsoft.AspNetCore.Mvc;
+
+namespace RevoSystem.Apis.Controllers
+{
+    [ApiController]
+    public class ContentController : Controller
+    {
+        private static readonly log4net.ILog log = log4net.LogManager.GetLogger(
+            typeof(ContentController)
+        );
+
+        IContentBusiness contentBusiness;
+
+        public ContentController(IContentBusiness contentBusiness)
+        {
+            this.contentBusiness = contentBusiness;
+        }
+
+        /// <summary>
+        /// Load banners
+        /// POST /apis/content/banner
+        /// </summary>
+        [HttpPost]
+        [Route(ApiUrlConstant.BannerLoadUrl)]
+        public async Task<IActionResult> BannerLoad(BannerLoadReq request)
+        {
+            return await contentBusiness.BannerLoad(HttpContext.Request, request);
+        }
+
+        /// <summary>
+        /// Load customer reviews
+        /// POST /apis/content/review
+        /// </summary>
+        [HttpPost]
+        [Route(ApiUrlConstant.CustomerReviewLoadUrl)]
+        public async Task<IActionResult> CustomerReviewLoad(CustomerReviewLoadReq request)
+        {
+            return await contentBusiness.CustomerReviewLoad(HttpContext.Request, request);
+        }
+
+        /// <summary>
+        /// Create customer review
+        /// POST /apis/content/review/create
+        /// </summary>
+        [HttpPost]
+        [Route(ApiUrlConstant.CustomerReviewCreateUrl)]
+        public async Task<IActionResult> CustomerReviewCreate(CustomerReviewCreateReq request)
+        {
+            return await contentBusiness.CustomerReviewCreate(HttpContext.Request, request);
+        }
+
+        /// <summary>
+        /// Load FAQ categories
+        /// POST /apis/content/faq-category
+        /// </summary>
+        [HttpPost]
+        [Route(ApiUrlConstant.FaqCategoryLoadUrl)]
+        public async Task<IActionResult> FaqCategoryLoad(FaqCategoryLoadReq request)
+        {
+            return await contentBusiness.FaqCategoryLoad(HttpContext.Request, request);
+        }
+
+        /// <summary>
+        /// Load FAQs
+        /// POST /apis/content/faq
+        /// </summary>
+        [HttpPost]
+        [Route(ApiUrlConstant.FaqLoadUrl)]
+        public async Task<IActionResult> FaqLoad(FaqLoadReq request)
+        {
+            return await contentBusiness.FaqLoad(HttpContext.Request, request);
+        }
+
+        /// <summary>
+        /// Load device eSIM compatibility list
+        /// POST /apis/content/device-compatibility
+        /// </summary>
+        [HttpPost]
+        [Route(ApiUrlConstant.DeviceCompatibilityLoadUrl)]
+        public async Task<IActionResult> DeviceCompatibilityLoad(DeviceCompatibilityReq request)
+        {
+            return await contentBusiness.DeviceCompatibilityLoad(HttpContext.Request, request);
+        }
+
+        /// <summary>
+        /// Get device brands and categories list (for filters)
+        /// GET /apis/content/device-metadata
+        /// </summary>
+        [HttpGet]
+        [Route(ApiUrlConstant.DeviceMetadataUrl)]
+        public async Task<IActionResult> GetDeviceBrandsAndCategories()
+        {
+            return await contentBusiness.GetDeviceBrandsAndCategories(HttpContext.Request);
+        }
+
+    }
+}

+ 52 - 0
EsimLao/Esim.Apis/DTO/Content/DeviceCompatibilityReq.cs

@@ -0,0 +1,52 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Esim.Apis.DTO.Content
+{
+    /// <summary>
+    /// Request DTO for device eSIM compatibility check
+    /// </summary>
+    public class DeviceCompatibilityReq
+    {
+        /// <summary>
+        /// Brand filter (Apple, Samsung, Google, etc.)
+        /// null = all brands
+        /// </summary>
+        public string? brand { get; set; }
+
+        /// <summary>
+        /// Device category filter (Phone, Tablet, Laptop, Watch)
+        /// null = all categories
+        /// </summary>
+        public string? category { get; set; }
+
+        /// <summary>
+        /// Search keyword for model name
+        /// Searches in MODEL_NAME, MODEL_NAME_EN, MODEL_NAME_LO
+        /// </summary>
+        public string? searchKeyword { get; set; }
+
+        /// <summary>
+        /// Filter popular devices only (for quick lookup tabs)
+        /// true = only popular devices
+        /// false/null = all devices
+        /// </summary>
+        public bool? isPopular { get; set; }
+
+        /// <summary>
+        /// Language for response (lo, en, vi)
+        /// Default: "lo"
+        /// </summary>
+        public string? lang { get; set; }
+
+        /// <summary>
+        /// Page number (0-indexed)
+        /// </summary>
+        public int pageNumber { get; set; } = 0;
+
+        /// <summary>
+        /// Page size
+        /// Default: 50 (higher than other APIs for device list)
+        /// </summary>
+        public int pageSize { get; set; } = 50;
+    }
+}

+ 40 - 0
EsimLao/Esim.Apis/Program.cs

@@ -24,6 +24,24 @@ builder.Services.AddScoped<IUserBusiness, UserBusinessImpl>();
 builder.Services.AddScoped<IArticleBusiness, ArticleBusinessImpl>();
 builder.Services.AddScoped<IContentBusiness, ContentBusinessImpl>();
 
+// Configure CORS - Allow frontend to access APIs
+builder.Services.AddCors(options =>
+{
+    options.AddPolicy("AllowFrontend", policy =>
+    {
+        policy.WithOrigins(
+                "http://localhost:3000",      // React development
+                "http://localhost:5173",      // Vite development
+                "http://localhost:4200",      // Angular development
+                "https://infigate.vn",        // Production domain
+                "https://www.infigate.vn"     // Production www domain
+            )
+            .AllowAnyMethod()
+            .AllowAnyHeader()
+            .AllowCredentials();
+    });
+});
+
 // Configure JWT Authentication
 var jwtKey = builder.Configuration["Jwt:Key"] ?? "EsimLaoSecretKey12345678901234567890";
 var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "EsimLao";
@@ -53,6 +71,25 @@ builder.Services.AddEndpointsApiExplorer();
 builder.Services.AddSwaggerGen();
 
 var app = builder.Build();
+
+// Initialize ConfigManager to load configs from database
+using (var scope = app.Services.CreateScope())
+{
+    var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
+    try
+    {
+        logger.LogInformation("Initializing ConfigManager...");
+        Esim.Apis.Singleton.ConfigManager.Instance.Initialize();
+        logger.LogInformation("ConfigManager initialized successfully");
+        
+        // Start background refresh task
+        Task.Run(() => Esim.Apis.Singleton.ConfigManager.Instance.RefreshConfigs());
+    }
+    catch (Exception ex)
+    {
+        logger.LogError(ex, "Failed to initialize ConfigManager");
+    }
+}
 app.UseSwagger();
 app.UseSwaggerUI();
 // Configure the HTTP request pipeline.
@@ -71,6 +108,9 @@ app.UseSwaggerUI();
 app.UseHttpsRedirection();
 app.UseRouting();
 
+// Enable CORS - MUST be after UseRouting and before UseAuthentication
+app.UseCors("AllowFrontend");
+
 app.UseAuthentication();
 app.UseAuthorization();
 

+ 197 - 60
EsimLao/Esim.SendMail/MessageQueueWorker.cs

@@ -7,12 +7,12 @@ namespace Esim.SendMail;
 
 /// <summary>
 /// High-performance background worker for processing message queue.
-/// Uses native .NET BackgroundService for maximum .NET 9 compatibility.
 /// Features:
-/// - Configurable polling interval
-/// - Batch processing
-/// - Automatic retry handling
-/// - Graceful shutdown
+/// - Row-level locking to prevent duplicate processing
+/// - Configurable batch size and interval
+/// - Graceful shutdown with in-flight message handling
+/// - Automatic retry with exponential backoff
+/// - Metrics logging
 /// </summary>
 public class MessageQueueWorker : BackgroundService
 {
@@ -21,6 +21,7 @@ public class MessageQueueWorker : BackgroundService
     private readonly IEmailService _emailService;
     private readonly int _intervalSeconds;
     private readonly int _maxMessagesPerRun;
+    private readonly int _metricsLogIntervalSeconds;
 
     // Message types
     private const int MESSAGE_TYPE_EMAIL = 1;
@@ -33,6 +34,11 @@ public class MessageQueueWorker : BackgroundService
     private const int STATUS_SUCCESS = 2;
     private const int STATUS_FAILED = 3;
 
+    private DateTime _lastMetricsLog = DateTime.MinValue;
+    private long _totalProcessed = 0;
+    private long _totalSuccess = 0;
+    private long _totalFailed = 0;
+
     public MessageQueueWorker(
         ILogger<MessageQueueWorker> logger,
         IServiceProvider serviceProvider,
@@ -44,6 +50,7 @@ public class MessageQueueWorker : BackgroundService
         _emailService = emailService;
         _intervalSeconds = int.Parse(configuration["Job:IntervalSeconds"] ?? "10");
         _maxMessagesPerRun = int.Parse(configuration["Job:MaxMessagesPerRun"] ?? "500");
+        _metricsLogIntervalSeconds = int.Parse(configuration["Job:MetricsLogIntervalSeconds"] ?? "60");
     }
 
     protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -51,42 +58,68 @@ public class MessageQueueWorker : BackgroundService
         _logger.LogInformation("MessageQueueWorker started. Interval: {Interval}s, MaxPerRun: {Max}", 
             _intervalSeconds, _maxMessagesPerRun);
 
+        // Wait a bit for initialization
+        await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
+
         while (!stoppingToken.IsCancellationRequested)
         {
             try
             {
                 await ProcessMessagesAsync(stoppingToken);
+                
+                // Log metrics periodically
+                if ((DateTime.Now - _lastMetricsLog).TotalSeconds >= _metricsLogIntervalSeconds)
+                {
+                    LogMetrics();
+                    _lastMetricsLog = DateTime.Now;
+                }
+            }
+            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+            {
+                break;
             }
             catch (Exception ex)
             {
                 _logger.LogError(ex, "Error in message processing loop");
             }
 
-            await Task.Delay(TimeSpan.FromSeconds(_intervalSeconds), stoppingToken);
+            try
+            {
+                await Task.Delay(TimeSpan.FromSeconds(_intervalSeconds), stoppingToken);
+            }
+            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+            {
+                break;
+            }
         }
 
-        _logger.LogInformation("MessageQueueWorker stopped");
+        _logger.LogInformation("MessageQueueWorker stopped. Total processed: {Total}, Success: {Success}, Failed: {Failed}",
+            _totalProcessed, _totalSuccess, _totalFailed);
+    }
+
+    private void LogMetrics()
+    {
+        var emailMetrics = _emailService.GetMetrics();
+        _logger.LogInformation(
+            "Metrics: Processed={Processed} | Success={Success} | Failed={Failed} | " +
+            "EmailRate={Rate}/min | AvgLatency={Latency:F0}ms",
+            _totalProcessed, _totalSuccess, _totalFailed,
+            emailMetrics.EmailsLastMinute, emailMetrics.AverageLatencyMs);
     }
 
     private async Task ProcessMessagesAsync(CancellationToken stoppingToken)
     {
         var startTime = DateTime.Now;
 
-        // Create a new scope for DbContext (transient)
+        // Create a new scope for DbContext
         using var scope = _serviceProvider.CreateScope();
         var dbContext = scope.ServiceProvider.GetRequiredService<ModelContext>();
 
         try
         {
-            // Get pending messages
-            var pendingMessages = await dbContext.MessageQueues
-                .Where(m => m.Status == STATUS_PENDING
-                    && (m.ScheduledAt == null || m.ScheduledAt <= DateTime.Now)
-                    && (m.RetryCount == null || m.RetryCount < m.MaxRetry))
-                .OrderBy(m => m.Priority)
-                .ThenBy(m => m.CreatedDate)
-                .Take(_maxMessagesPerRun)
-                .ToListAsync(stoppingToken);
+            // Get and lock pending messages using Oracle's FOR UPDATE SKIP LOCKED
+            // This prevents duplicate processing when running multiple workers
+            var pendingMessages = await GetAndLockPendingMessagesAsync(dbContext, stoppingToken);
 
             if (!pendingMessages.Any())
             {
@@ -96,35 +129,36 @@ public class MessageQueueWorker : BackgroundService
 
             _logger.LogInformation("Processing {Count} messages", pendingMessages.Count);
 
-            // Mark all as processing
-            var messageIds = pendingMessages.Select(m => m.Id).ToList();
-            await dbContext.Database.ExecuteSqlRawAsync(
-                $"UPDATE MESSAGE_QUEUE SET STATUS = {STATUS_PROCESSING} WHERE ID IN ({string.Join(",", messageIds)})",
-                stoppingToken);
-
             // Process by message type
             var emailMessages = pendingMessages.Where(m => m.MessageType == MESSAGE_TYPE_EMAIL).ToList();
+            var otherMessages = pendingMessages.Where(m => m.MessageType != MESSAGE_TYPE_EMAIL).ToList();
 
-            // Process emails
+            // Process emails asynchronously
             if (emailMessages.Any())
             {
                 await ProcessEmailsAsync(dbContext, emailMessages, stoppingToken);
             }
 
             // Mark SMS/Push as not implemented
-            foreach (var msg in pendingMessages.Where(m => m.MessageType != MESSAGE_TYPE_EMAIL))
+            foreach (var msg in otherMessages)
             {
                 msg.Status = STATUS_FAILED;
                 msg.ErrorMessage = "Message type not implemented";
                 msg.ProcessedAt = DateTime.Now;
+                Interlocked.Increment(ref _totalFailed);
             }
 
             await dbContext.SaveChangesAsync(stoppingToken);
 
             // Move completed messages to history
-            await MoveToHistoryAsync(dbContext, 
-                pendingMessages.Where(m => m.Status == STATUS_SUCCESS || m.Status == STATUS_FAILED).ToList(),
-                stoppingToken);
+            var completedMessages = pendingMessages
+                .Where(m => m.Status == STATUS_SUCCESS || m.Status == STATUS_FAILED)
+                .ToList();
+            
+            if (completedMessages.Any())
+            {
+                await MoveToHistoryAsync(dbContext, completedMessages, stoppingToken);
+            }
 
             var elapsed = DateTime.Now - startTime;
             _logger.LogInformation("Processed {Count} messages in {Elapsed:F0}ms", 
@@ -140,44 +174,128 @@ public class MessageQueueWorker : BackgroundService
         }
     }
 
-    private async Task ProcessEmailsAsync(ModelContext dbContext, List<MessageQueue> messages, CancellationToken stoppingToken)
+    private async Task<List<MessageQueue>> GetAndLockPendingMessagesAsync(
+        ModelContext dbContext, 
+        CancellationToken stoppingToken)
     {
-        _logger.LogInformation("Processing {Count} emails", messages.Count);
+        // Using Oracle's FOR UPDATE SKIP LOCKED to prevent duplicate processing
+        // This allows multiple workers to run safely
+        var sql = $@"
+            SELECT ID, MESSAGE_TYPE, RECIPIENT, SUBJECT, CONTENT, TEMPLATE_CODE, TEMPLATE_DATA,
+                   PRIORITY, STATUS, SCHEDULED_AT, PROCESSED_AT, RETRY_COUNT, MAX_RETRY,
+                   ERROR_MESSAGE, CREATED_BY, CREATED_DATE
+            FROM MESSAGE_QUEUE
+            WHERE STATUS = {STATUS_PENDING}
+              AND (SCHEDULED_AT IS NULL OR SCHEDULED_AT <= SYSDATE)
+              AND (RETRY_COUNT IS NULL OR RETRY_COUNT < NVL(MAX_RETRY, 3))
+            ORDER BY PRIORITY, CREATED_DATE
+            FETCH FIRST {_maxMessagesPerRun} ROWS ONLY
+            FOR UPDATE SKIP LOCKED";
 
-        var tasks = messages.Select(async msg =>
+        try
         {
-            if (stoppingToken.IsCancellationRequested) return;
+            var messages = await dbContext.MessageQueues
+                .FromSqlRaw(sql)
+                .ToListAsync(stoppingToken);
 
-            try
+            // Mark as processing immediately
+            if (messages.Any())
+            {
+                foreach (var msg in messages)
+                {
+                    msg.Status = STATUS_PROCESSING;
+                }
+                await dbContext.SaveChangesAsync(stoppingToken);
+            }
+
+            return messages;
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning("Failed to get messages with row-locking, falling back: {Message}", ex.Message);
+            
+            // Fallback to simple query if FOR UPDATE fails
+            var messages = await dbContext.MessageQueues
+                .Where(m => m.Status == STATUS_PENDING
+                    && (m.ScheduledAt == null || m.ScheduledAt <= DateTime.Now)
+                    && (m.RetryCount == null || m.RetryCount < m.MaxRetry))
+                .OrderBy(m => m.Priority)
+                .ThenBy(m => m.CreatedDate)
+                .Take(_maxMessagesPerRun)
+                .ToListAsync(stoppingToken);
+
+            // Mark as processing
+            if (messages.Any())
             {
-                // Subject and Content are pre-resolved at insert time
-                var subject = msg.Subject ?? "No Subject";
-                var content = msg.Content ?? "";
+                var ids = string.Join(",", messages.Select(m => m.Id));
+                await dbContext.Database.ExecuteSqlRawAsync(
+                    $"UPDATE MESSAGE_QUEUE SET STATUS = {STATUS_PROCESSING} WHERE ID IN ({ids})",
+                    stoppingToken);
+            }
 
-                bool success = await _emailService.SendEmailAsync(
-                    msg.Recipient ?? "",
-                    subject,
-                    content,
-                    true);
+            return messages;
+        }
+    }
 
-                _logger.LogDebug("Email sent to {Recipient}", msg.Recipient);
+    private async Task ProcessEmailsAsync(
+        ModelContext dbContext, 
+        List<MessageQueue> messages, 
+        CancellationToken stoppingToken)
+    {
+        _logger.LogInformation("Processing {Count} emails", messages.Count);
 
-                if (success)
+        // Convert to EmailMessage for batch processing
+        var emailMessages = messages.Select(m => new EmailMessage
+        {
+            Id = m.Id,
+            To = m.Recipient ?? "",
+            Subject = m.Subject ?? "No Subject",
+            Body = m.Content ?? "",
+            IsHtml = true
+        }).ToList();
+
+        // Create a lookup for original messages
+        var messageDict = messages.ToDictionary(m => m.Id);
+
+        // Process in parallel using semaphore for concurrency control
+        var semaphore = new SemaphoreSlim(10); // Max 10 concurrent
+        var tasks = emailMessages.Select(async email =>
+        {
+            await semaphore.WaitAsync(stoppingToken);
+            try
+            {
+                var success = await _emailService.SendEmailAsync(email.To, email.Subject, email.Body, email.IsHtml);
+                
+                if (messageDict.TryGetValue((int)email.Id, out var msg))
                 {
-                    msg.Status = STATUS_SUCCESS;
-                    msg.ProcessedAt = DateTime.Now;
-                    msg.ErrorMessage = null;
+                    if (success)
+                    {
+                        msg.Status = STATUS_SUCCESS;
+                        msg.ProcessedAt = DateTime.Now;
+                        msg.ErrorMessage = null;
+                        Interlocked.Increment(ref _totalSuccess);
+                    }
+                    else
+                    {
+                        HandleFailure(msg);
+                    }
                 }
-                else
+                
+                Interlocked.Increment(ref _totalProcessed);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Failed to send email {Id}", email.Id);
+                if (messageDict.TryGetValue((int)email.Id, out var msg))
                 {
+                    msg.ErrorMessage = ex.Message;
                     HandleFailure(msg);
                 }
+                Interlocked.Increment(ref _totalProcessed);
             }
-            catch (Exception ex)
+            finally
             {
-                _logger.LogError(ex, "Failed to send email {Id}", msg.Id);
-                msg.ErrorMessage = ex.Message;
-                HandleFailure(msg);
+                semaphore.Release();
             }
         });
 
@@ -189,27 +307,38 @@ public class MessageQueueWorker : BackgroundService
         message.RetryCount = (byte?)((message.RetryCount ?? 0) + 1);
         message.ProcessedAt = DateTime.Now;
 
-        if (message.RetryCount >= message.MaxRetry)
+        var maxRetry = message.MaxRetry ?? 3;
+        if (message.RetryCount >= maxRetry)
         {
             message.Status = STATUS_FAILED;
-            _logger.LogWarning("Message {Id} failed after max retries", message.Id);
+            Interlocked.Increment(ref _totalFailed);
+            _logger.LogWarning("Message {Id} failed after {RetryCount} retries: {Error}", 
+                message.Id, message.RetryCount, message.ErrorMessage);
         }
         else
         {
+            // Set back to pending for retry with exponential backoff delay
             message.Status = STATUS_PENDING;
-            _logger.LogDebug("Message {Id} will retry ({RetryCount}/{MaxRetry})", 
-                message.Id, message.RetryCount, message.MaxRetry);
+            var backoffDelay = _emailService.CalculateBackoffDelay(message.RetryCount ?? 0);
+            message.ScheduledAt = DateTime.Now.AddMilliseconds(backoffDelay);
+            
+            _logger.LogDebug("Message {Id} will retry in {DelayMs}ms ({RetryCount}/{MaxRetry})", 
+                message.Id, backoffDelay, message.RetryCount, maxRetry);
         }
     }
 
-    private async Task MoveToHistoryAsync(ModelContext dbContext, List<MessageQueue> completedMessages, CancellationToken stoppingToken)
+    private async Task MoveToHistoryAsync(
+        ModelContext dbContext, 
+        List<MessageQueue> completedMessages, 
+        CancellationToken stoppingToken)
     {
         if (!completedMessages.Any()) return;
 
         try
         {
-            var idsString = string.Join(",", completedMessages.Select(m => m.Id));
+            var ids = string.Join(",", completedMessages.Select(m => m.Id));
 
+            // Insert to history
             var insertSql = $@"
                 INSERT INTO MESSAGE_QUEUE_HIS 
                 (ID, MESSAGE_TYPE, RECIPIENT, SUBJECT, CONTENT, TEMPLATE_CODE, TEMPLATE_DATA, 
@@ -220,18 +349,26 @@ public class MessageQueueWorker : BackgroundService
                     PRIORITY, STATUS, SCHEDULED_AT, PROCESSED_AT, RETRY_COUNT, MAX_RETRY,
                     ERROR_MESSAGE, CREATED_BY, CREATED_DATE, SYSDATE
                 FROM MESSAGE_QUEUE
-                WHERE ID IN ({idsString})";
+                WHERE ID IN ({ids})";
 
             await dbContext.Database.ExecuteSqlRawAsync(insertSql, stoppingToken);
 
-            var deleteSql = $"DELETE FROM MESSAGE_QUEUE WHERE ID IN ({idsString})";
+            // Delete from main queue
+            var deleteSql = $"DELETE FROM MESSAGE_QUEUE WHERE ID IN ({ids})";
             await dbContext.Database.ExecuteSqlRawAsync(deleteSql, stoppingToken);
 
-            _logger.LogInformation("Moved {Count} messages to history", completedMessages.Count);
+            _logger.LogDebug("Moved {Count} messages to history", completedMessages.Count);
         }
         catch (Exception ex)
         {
             _logger.LogError(ex, "Failed to move messages to history");
         }
     }
+
+    public override async Task StopAsync(CancellationToken cancellationToken)
+    {
+        _logger.LogInformation("MessageQueueWorker stopping, waiting for in-flight messages...");
+        await base.StopAsync(cancellationToken);
+        _logger.LogInformation("MessageQueueWorker stopped gracefully");
+    }
 }

+ 1 - 1
EsimLao/Esim.SendMail/Properties/PublishProfiles/FolderProfile.pubxml

@@ -11,6 +11,6 @@
     <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
     <SelfContained>true</SelfContained>
     <PublishSingleFile>false</PublishSingleFile>
-    <PublishTrimmed>true</PublishTrimmed>
+    <PublishTrimmed>false</PublishTrimmed>
   </PropertyGroup>
 </Project>

+ 212 - 48
EsimLao/Esim.SendMail/Services/EmailService.cs

@@ -1,4 +1,5 @@
 using System.Collections.Concurrent;
+using System.Threading.Channels;
 using MailKit.Net.Smtp;
 using MailKit.Security;
 using MimeKit;
@@ -9,12 +10,14 @@ namespace Esim.SendMail.Services;
 
 /// <summary>
 /// High-performance email service optimized for millions of messages
-/// Uses MailKit with connection pooling and batch processing
+/// Features: Connection pooling, rate limiting, exponential backoff
 /// </summary>
 public interface IEmailService
 {
     Task<bool> SendEmailAsync(string to, string subject, string body, bool isHtml = true);
-    Task<int> SendBatchAsync(IEnumerable<EmailMessage> messages);
+    Task<int> SendBatchAsync(IEnumerable<EmailMessage> messages, CancellationToken cancellationToken = default);
+    int CalculateBackoffDelay(int retryCount);
+    EmailMetrics GetMetrics();
     void Dispose();
 }
 
@@ -29,9 +32,21 @@ public class EmailMessage
 
 public class EmailResult
 {
-    public int MessageId { get; set; }
+    public decimal MessageId { get; set; }
     public bool Success { get; set; }
     public string? ErrorMessage { get; set; }
+    public bool IsRetryable { get; set; }
+}
+
+public class EmailMetrics
+{
+    public long TotalSent { get; set; }
+    public long TotalSuccess { get; set; }
+    public long TotalFailed { get; set; }
+    public long TotalRetried { get; set; }
+    public double AverageLatencyMs { get; set; }
+    public int EmailsLastMinute { get; set; }
+    public DateTime LastSentAt { get; set; }
 }
 
 public class HighPerformanceEmailService : IEmailService, IDisposable
@@ -46,32 +61,63 @@ public class HighPerformanceEmailService : IEmailService, IDisposable
     private readonly bool _enableSsl;
     private readonly int _connectionPoolSize;
     private readonly int _maxConcurrentSends;
-    private readonly int _retryDelayMs;
+    private readonly int _maxEmailsPerMinute;
+    private readonly int _baseRetryDelayMs;
+    private readonly int _maxRetryDelayMs;
 
     // Connection pool for SMTP clients
     private readonly ConcurrentBag<SmtpClient> _connectionPool;
     private readonly SemaphoreSlim _poolSemaphore;
+    
+    // Rate limiting
+    private readonly SemaphoreSlim _rateLimitSemaphore;
+    private readonly ConcurrentQueue<DateTime> _sentTimestamps;
+    private readonly object _rateLimitLock = new();
+
+    // Metrics tracking
+    private long _totalSent;
+    private long _totalSuccess;
+    private long _totalFailed;
+    private long _totalRetried;
+    private long _totalLatencyMs;
+    private DateTime _lastSentAt;
+
     private bool _disposed = false;
 
     public HighPerformanceEmailService(ILogger<HighPerformanceEmailService> logger, IConfiguration configuration)
     {
         _logger = logger;
         _configuration = configuration;
+        
+        // SMTP configuration
         _smtpServer = _configuration["Email:SmtpServer"] ?? "smtp.gmail.com";
         _smtpPort = int.Parse(_configuration["Email:SmtpPort"] ?? "587");
         _senderEmail = _configuration["Email:SenderEmail"] ?? "";
         _senderName = _configuration["Email:SenderName"] ?? "EsimLao";
         _senderPassword = _configuration["Email:SenderPassword"] ?? "";
         _enableSsl = bool.Parse(_configuration["Email:EnableSsl"] ?? "true");
+        
+        // Performance configuration
         _connectionPoolSize = int.Parse(_configuration["Email:ConnectionPoolSize"] ?? "5");
         _maxConcurrentSends = int.Parse(_configuration["Email:MaxConcurrentSends"] ?? "10");
-        _retryDelayMs = int.Parse(_configuration["Email:RetryDelayMs"] ?? "1000");
+        _maxEmailsPerMinute = int.Parse(_configuration["Email:MaxEmailsPerMinute"] ?? "30");
+        _baseRetryDelayMs = int.Parse(_configuration["Email:BaseRetryDelayMs"] ?? "2000");
+        _maxRetryDelayMs = int.Parse(_configuration["Email:MaxRetryDelayMs"] ?? "60000");
 
+        // Initialize pools
         _connectionPool = new ConcurrentBag<SmtpClient>();
         _poolSemaphore = new SemaphoreSlim(_connectionPoolSize, _connectionPoolSize);
+        
+        // Initialize rate limiting
+        _rateLimitSemaphore = new SemaphoreSlim(_maxConcurrentSends, _maxConcurrentSends);
+        _sentTimestamps = new ConcurrentQueue<DateTime>();
 
         // Pre-initialize connection pool
         InitializeConnectionPool();
+        
+        _logger.LogInformation(
+            "EmailService initialized: Pool={PoolSize}, Concurrent={Concurrent}, RateLimit={RateLimit}/min",
+            _connectionPoolSize, _maxConcurrentSends, _maxEmailsPerMinute);
     }
 
     private void InitializeConnectionPool()
@@ -102,12 +148,9 @@ public class HighPerformanceEmailService : IEmailService, IDisposable
         try
         {
             var client = new SmtpClient();
-            
-            // Connect with timeout
             client.Timeout = 30000; // 30 seconds
             client.Connect(_smtpServer, _smtpPort, _enableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
             client.Authenticate(_senderEmail, _senderPassword);
-            
             return client;
         }
         catch (Exception ex)
@@ -117,27 +160,20 @@ public class HighPerformanceEmailService : IEmailService, IDisposable
         }
     }
 
-    private async Task<SmtpClient?> GetClientFromPoolAsync()
+    private async Task<SmtpClient?> GetClientFromPoolAsync(CancellationToken cancellationToken = default)
     {
-        await _poolSemaphore.WaitAsync();
+        await _poolSemaphore.WaitAsync(cancellationToken);
 
         if (_connectionPool.TryTake(out var client))
         {
-            // Check if connection is still alive
             if (client.IsConnected)
             {
                 return client;
             }
             
-            // Connection dead, try to reconnect
-            try
-            {
-                client.Dispose();
-            }
-            catch { }
+            try { client.Dispose(); } catch { }
         }
 
-        // Create new connection
         return CreateConnectedClient();
     }
 
@@ -149,7 +185,6 @@ public class HighPerformanceEmailService : IEmailService, IDisposable
         }
         else
         {
-            // Try to create a new connection
             try
             {
                 var newClient = CreateConnectedClient();
@@ -164,27 +199,119 @@ public class HighPerformanceEmailService : IEmailService, IDisposable
         _poolSemaphore.Release();
     }
 
+    /// <summary>
+    /// Apply rate limiting - waits if we've exceeded emails per minute
+    /// </summary>
+    private async Task ApplyRateLimitAsync(CancellationToken cancellationToken = default)
+    {
+        var now = DateTime.UtcNow;
+        var oneMinuteAgo = now.AddMinutes(-1);
+
+        // Clean up old timestamps
+        while (_sentTimestamps.TryPeek(out var oldestTime) && oldestTime < oneMinuteAgo)
+        {
+            _sentTimestamps.TryDequeue(out _);
+        }
+
+        // Check if we need to wait
+        while (_sentTimestamps.Count >= _maxEmailsPerMinute)
+        {
+            if (_sentTimestamps.TryPeek(out var oldestTime))
+            {
+                var waitTime = oldestTime.AddMinutes(1) - DateTime.UtcNow;
+                if (waitTime > TimeSpan.Zero)
+                {
+                    _logger.LogDebug("Rate limit reached, waiting {WaitMs}ms", waitTime.TotalMilliseconds);
+                    await Task.Delay(waitTime, cancellationToken);
+                }
+            }
+
+            // Clean up again
+            now = DateTime.UtcNow;
+            oneMinuteAgo = now.AddMinutes(-1);
+            while (_sentTimestamps.TryPeek(out var time) && time < oneMinuteAgo)
+            {
+                _sentTimestamps.TryDequeue(out _);
+            }
+        }
+
+        // Record this send
+        _sentTimestamps.Enqueue(DateTime.UtcNow);
+    }
+
+    /// <summary>
+    /// Calculate exponential backoff delay
+    /// </summary>
+    public int CalculateBackoffDelay(int retryCount)
+    {
+        var delay = _baseRetryDelayMs * (int)Math.Pow(2, retryCount);
+        return Math.Min(delay, _maxRetryDelayMs);
+    }
+
+    /// <summary>
+    /// Determine if an error is retryable
+    /// </summary>
+    private static bool IsRetryableError(Exception ex)
+    {
+        // Network errors are retryable
+        if (ex is System.Net.Sockets.SocketException) return true;
+        if (ex is TimeoutException) return true;
+        if (ex is OperationCanceledException) return false;
+        
+        // SMTP errors
+        if (ex is SmtpCommandException smtpEx)
+        {
+            // 4xx errors are temporary, 5xx are permanent
+            return smtpEx.StatusCode < MailKit.Net.Smtp.SmtpStatusCode.MailboxNameNotAllowed;
+        }
+        
+        return true; // Default to retryable
+    }
+
     public async Task<bool> SendEmailAsync(string to, string subject, string body, bool isHtml = true)
     {
+        var startTime = DateTime.UtcNow;
         SmtpClient? client = null;
         
         try
         {
-            client = await GetClientFromPoolAsync();
-            if (client == null)
+            // Apply rate limiting
+            await ApplyRateLimitAsync();
+            
+            // Wait for concurrent send slot
+            await _rateLimitSemaphore.WaitAsync();
+            
+            try
             {
-                _logger.LogError("Failed to get SMTP client from pool");
-                return false;
-            }
+                client = await GetClientFromPoolAsync();
+                if (client == null)
+                {
+                    _logger.LogError("Failed to get SMTP client from pool for {To}", to);
+                    Interlocked.Increment(ref _totalFailed);
+                    return false;
+                }
 
-            var message = CreateMimeMessage(to, subject, body, isHtml);
-            await client.SendAsync(message);
-            
-            _logger.LogDebug("Email sent to {To}", to);
-            return true;
+                var message = CreateMimeMessage(to, subject, body, isHtml);
+                await client.SendAsync(message);
+                
+                // Update metrics
+                Interlocked.Increment(ref _totalSent);
+                Interlocked.Increment(ref _totalSuccess);
+                Interlocked.Add(ref _totalLatencyMs, (long)(DateTime.UtcNow - startTime).TotalMilliseconds);
+                _lastSentAt = DateTime.UtcNow;
+                
+                _logger.LogDebug("Email sent to {To} in {ElapsedMs}ms", to, (DateTime.UtcNow - startTime).TotalMilliseconds);
+                return true;
+            }
+            finally
+            {
+                _rateLimitSemaphore.Release();
+            }
         }
         catch (Exception ex)
         {
+            Interlocked.Increment(ref _totalSent);
+            Interlocked.Increment(ref _totalFailed);
             _logger.LogError("Failed to send email to {To}: {Message}", to, ex.Message);
             
             // Force reconnect on error
@@ -204,41 +331,55 @@ public class HighPerformanceEmailService : IEmailService, IDisposable
     }
 
     /// <summary>
-    /// Send batch of emails with parallel processing
+    /// Send batch of emails with parallel processing and rate limiting
     /// Returns number of successfully sent emails
     /// </summary>
-    public async Task<int> SendBatchAsync(IEnumerable<EmailMessage> messages)
+    public async Task<int> SendBatchAsync(IEnumerable<EmailMessage> messages, CancellationToken cancellationToken = default)
     {
         var messageList = messages.ToList();
         if (!messageList.Any()) return 0;
 
         _logger.LogInformation("Sending batch of {Count} emails", messageList.Count);
         
-        int successCount = 0;
-        var semaphore = new SemaphoreSlim(_maxConcurrentSends);
-        var tasks = new List<Task<bool>>();
+        var successCount = 0;
+        var channel = Channel.CreateBounded<EmailMessage>(new BoundedChannelOptions(_maxConcurrentSends * 2)
+        {
+            FullMode = BoundedChannelFullMode.Wait
+        });
 
-        foreach (var msg in messageList)
+        // Producer task - writes messages to channel
+        var producerTask = Task.Run(async () =>
         {
-            await semaphore.WaitAsync();
-            
-            var task = Task.Run(async () =>
+            foreach (var msg in messageList)
+            {
+                if (cancellationToken.IsCancellationRequested) break;
+                await channel.Writer.WriteAsync(msg, cancellationToken);
+            }
+            channel.Writer.Complete();
+        }, cancellationToken);
+
+        // Consumer tasks - reads from channel and sends emails
+        var consumerTasks = Enumerable.Range(0, _maxConcurrentSends).Select(async _ =>
+        {
+            var localSuccess = 0;
+            await foreach (var msg in channel.Reader.ReadAllAsync(cancellationToken))
             {
                 try
                 {
-                    return await SendEmailAsync(msg.To, msg.Subject, msg.Body, msg.IsHtml);
+                    var result = await SendEmailAsync(msg.To, msg.Subject, msg.Body, msg.IsHtml);
+                    if (result) localSuccess++;
                 }
-                finally
+                catch (Exception ex)
                 {
-                    semaphore.Release();
+                    _logger.LogError(ex, "Failed to send email {Id}", msg.Id);
                 }
-            });
-            
-            tasks.Add(task);
-        }
+            }
+            return localSuccess;
+        }).ToList();
 
-        var results = await Task.WhenAll(tasks);
-        successCount = results.Count(r => r);
+        await producerTask;
+        var results = await Task.WhenAll(consumerTasks);
+        successCount = results.Sum();
         
         _logger.LogInformation("Batch complete: {SuccessCount}/{TotalCount} successful", successCount, messageList.Count);
         return successCount;
@@ -265,6 +406,27 @@ public class HighPerformanceEmailService : IEmailService, IDisposable
         return message;
     }
 
+    public EmailMetrics GetMetrics()
+    {
+        var emailsLastMinute = 0;
+        var oneMinuteAgo = DateTime.UtcNow.AddMinutes(-1);
+        foreach (var timestamp in _sentTimestamps)
+        {
+            if (timestamp >= oneMinuteAgo) emailsLastMinute++;
+        }
+
+        return new EmailMetrics
+        {
+            TotalSent = Interlocked.Read(ref _totalSent),
+            TotalSuccess = Interlocked.Read(ref _totalSuccess),
+            TotalFailed = Interlocked.Read(ref _totalFailed),
+            TotalRetried = Interlocked.Read(ref _totalRetried),
+            AverageLatencyMs = _totalSent > 0 ? (double)Interlocked.Read(ref _totalLatencyMs) / _totalSent : 0,
+            EmailsLastMinute = emailsLastMinute,
+            LastSentAt = _lastSentAt
+        };
+    }
+
     public void Dispose()
     {
         if (_disposed) return;
@@ -286,6 +448,8 @@ public class HighPerformanceEmailService : IEmailService, IDisposable
         }
 
         _poolSemaphore.Dispose();
-        _logger.LogInformation("Email service disposed");
+        _rateLimitSemaphore.Dispose();
+        _logger.LogInformation("Email service disposed. Final metrics: Sent={Sent}, Success={Success}, Failed={Failed}",
+            _totalSent, _totalSuccess, _totalFailed);
     }
 }

+ 6 - 4
EsimLao/Esim.SendMail/appsettings.json

@@ -4,17 +4,19 @@
     "SmtpServer": "smtp.gmail.com",
     "SmtpPort": 587,
     "SenderEmail": "trongduc02@gmail.com",
-    "SenderName": "EsimLao",
+    "SenderName": "trongduc02",
     "SenderPassword": "cjav iour aepq balt",
     "EnableSsl": true,
     "MaxConcurrentSends": 10,
     "ConnectionPoolSize": 5,
-    "BatchSize": 100,
-    "RetryDelayMs": 1000
+    "MaxEmailsPerMinute": 30,
+    "BaseRetryDelayMs": 2000,
+    "MaxRetryDelayMs": 60000
   },
   "Job": {
     "IntervalSeconds": 10,
-    "MaxMessagesPerRun": 500
+    "MaxMessagesPerRun": 500,
+    "MetricsLogIntervalSeconds": 60
   },
   "Logging": {
     "LogLevel": {

+ 138 - 42
EsimLao/docs/api_auth_otp.txt

@@ -36,7 +36,7 @@ POST /apis/auth/request-otp
 ### Response Success (200)
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "message": "<Config: OTP_SENT_SUCCESS>",
     "data": {
         "email": "user@example.com",
@@ -49,14 +49,14 @@ POST /apis/auth/request-otp
 ```json
 // Email không được cung cấp
 {
-    "errorCode": -801,
+    "errorCode": "-801",
     "message": "<Config: EMAIL_REQUIRED>",
     "data": {}
 }
 
 // Lỗi hệ thống
 {
-    "errorCode": -6,
+    "errorCode": "-6",
     "message": "<Config: SYSTEM_FAILURE>",
     "data": {}
 }
@@ -65,7 +65,7 @@ POST /apis/auth/request-otp
 ### Response Fields
 | Field | Type | Description |
 |-------|------|-------------|
-| errorCode| int | 0 = Success, khác 0 = Error (xem Error Codes) |
+| errorCode| string | "0" = Success, khác "0" = Error (xem Error Codes) |
 | message | string | Thông báo từ CONFIG table (theo ngôn ngữ) |
 | data.email | string | Email đã gửi OTP |
 | data.expireInSeconds | int | Thời gian OTP hết hạn (giây) |
@@ -111,7 +111,7 @@ POST /apis/auth/verify-otp
 ### Response Success (200)
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "message": "<Config: LOGIN_SUCCESS>",
     "data": {
         "userId": 12345,
@@ -129,42 +129,42 @@ POST /apis/auth/verify-otp
 ```json
 // Thiếu email hoặc OTP
 {
-    "errorCode": -801,
+    "errorCode": "-801",
     "message": "<Config: EMAIL_OTP_REQUIRED>",
     "data": {}
 }
 
 // OTP không hợp lệ
 {
-    "errorCode": -201,
+    "errorCode": "-201",
     "message": "<Config: OTP_INVALID>",
     "data": {}
 }
 
 // OTP đã được sử dụng
 {
-    "errorCode": -203,
+    "errorCode": "-203",
     "message": "<Config: OTP_ALREADY_USED>",
     "data": {}
 }
 
 // OTP đã hết hạn
 {
-    "errorCode": -202,
+    "errorCode": "-202",
     "message": "<Config: OTP_EXPIRED>",
     "data": {}
 }
 
 // Không tìm thấy người dùng
 {
-    "errorCode": -300,
+    "errorCode": "-300",
     "message": "<Config: USER_NOT_FOUND>",
     "data": {}
 }
 
 // Lỗi hệ thống
 {
-    "errorCode": -6,
+    "errorCode": "-6",
     "message": "<Config: SYSTEM_FAILURE>",
     "data": {}
 }
@@ -173,7 +173,7 @@ POST /apis/auth/verify-otp
 ### Response Fields
 | Field | Type | Description |
 |-------|------|-------------|
-| errorCode| int | 0 = Success, khác 0 = Error (xem Error Codes) |
+| errorCode| string | "0" = Success, khác "0" = Error (xem Error Codes) |
 | message | string | Thông báo từ CONFIG table (theo ngôn ngữ) |
 | data.userId | int | ID người dùng |
 | data.email | string | Email người dùng |
@@ -195,35 +195,35 @@ POST /apis/auth/verify-otp
 ### Success
 | errorCode| Constant | Description |
 |------|----------|-------------|
-| 0 | Success | Thành công (mọi request thành công đều trả về 0) |
+| "0" | Success | Thành công (mọi request thành công đều trả về "0") |
 
 ### General Errors (-1 to -99)
 | errorCode| Constant | Description |
 |------|----------|-------------|
-| -1 | Error | Lỗi chung |
-| -6 | SystemError | Lỗi hệ thống |
+| "-1" | Error | Lỗi chung |
+| "-6" | SystemError | Lỗi hệ thống |
 
 ### OTP Errors (-200 to -299)
 | errorCode| Constant | Description |
 |------|----------|-------------|
-| -200 | OtpRequired | Yêu cầu OTP |
-| -201 | OtpInvalid | OTP không hợp lệ |
-| -202 | OtpExpired | OTP đã hết hạn |
-| -203 | OtpAlreadyUsed | OTP đã được sử dụng |
-| -204 | OtpMaxAttemptsExceeded | Vượt quá số lần thử |
-| -205 | OtpSendFailed | Gửi OTP thất bại |
-| -206 | OtpTooManyRequests | Request quá nhiều |
+| "-200" | OtpRequired | Yêu cầu OTP |
+| "-201" | OtpInvalid | OTP không hợp lệ |
+| "-202" | OtpExpired | OTP đã hết hạn |
+| "-203" | OtpAlreadyUsed | OTP đã được sử dụng |
+| "-204" | OtpMaxAttemptsExceeded | Vượt quá số lần thử |
+| "-205" | OtpSendFailed | Gửi OTP thất bại |
+| "-206" | OtpTooManyRequests | Request quá nhiều |
 
 ### User Errors (-300 to -399)
 | errorCode| Constant | Description |
 |------|----------|-------------|
-| -300 | UserNotFound | Không tìm thấy người dùng |
-| -304 | InvalidEmail | Email không hợp lệ |
+| "-300" | UserNotFound | Không tìm thấy người dùng |
+| "-304" | InvalidEmail | Email không hợp lệ |
 
 ### Validation Errors (-800 to -899)
 | errorCode| Constant | Description |
 |------|----------|-------------|
-| -801 | RequiredFieldMissing | Thiếu trường bắt buộc |
+| "-801" | RequiredFieldMissing | Thiếu trường bắt buộc |
 
 ---
 
@@ -309,7 +309,7 @@ Lấy danh sách danh mục bài viết.
 
 ### Endpoint
 ```
-POST /apis/auth/article-category-load
+POST /apis/article/category
 ```
 
 ### Request
@@ -332,10 +332,10 @@ POST /apis/auth/article-category-load
 ### Response Success
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "message": "Success",
     "data": {
-        "items": [
+        "categories": [
             {
                 "id": 1,
                 "categoryName": "Cẩm nang du lịch",
@@ -356,6 +356,25 @@ POST /apis/auth/article-category-load
 }
 ```
 
+### Response Fields - categories[]
+| Field | Type | DB Column | Description |
+|-------|------|-----------|-------------|
+| id | int | ARTICLE_CATEGORY.ID | ID danh mục bài viết (Primary Key) |
+| categoryName | string | ARTICLE_CATEGORY.CATEGORY_NAME<br/>CATEGORY_NAME_EN<br/>CATEGORY_NAME_LO | Tên danh mục (theo ngôn ngữ được chọn) |
+| categorySlug | string | ARTICLE_CATEGORY.CATEGORY_SLUG | URL-friendly slug cho danh mục |
+| description | string | ARTICLE_CATEGORY.DESCRIPTION<br/>DESCRIPTION_EN<br/>DESCRIPTION_LO | Mô tả chi tiết danh mục |
+| iconUrl | string | ARTICLE_CATEGORY.ICON_URL | Đường dẫn icon của danh mục |
+| parentId | int? | ARTICLE_CATEGORY.PARENT_ID | ID danh mục cha (null = danh mục gốc) |
+| displayOrder | int | ARTICLE_CATEGORY.DISPLAY_ORDER | Thứ tự hiển thị (số nhỏ hơn hiển thị trước) |
+
+### Response Fields - pagination
+| Field | Type | Description |
+|-------|------|-------------|
+| pageNumber | int | Trang hiện tại (bắt đầu từ 0) |
+| pageSize | int | Số items trên mỗi trang |
+| totalCount | int | Tổng số items trong database |
+| totalPages | int | Tổng số trang (= ceiling(totalCount / pageSize)) |
+
 ---
 
 ## 4. Article Load
@@ -364,7 +383,7 @@ Lấy danh sách bài viết hoặc chi tiết 1 bài viết.
 
 ### Endpoint
 ```
-POST /apis/auth/article-load
+POST /apis/article/load
 ```
 
 ### Request (danh sách)
@@ -398,10 +417,10 @@ POST /apis/auth/article-load
 ### Response Success (danh sách)
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "message": "Success",
     "data": {
-        "items": [
+        "articles": [
             {
                 "id": 1,
                 "title": "Hướng dẫn cài đặt eSIM",
@@ -423,7 +442,7 @@ POST /apis/auth/article-load
 ### Response Success (chi tiết)
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "message": "Success",
     "data": {
         "article": {
@@ -446,6 +465,30 @@ POST /apis/auth/article-load
 }
 ```
 
+### Response Fields - articles[] (List)
+| Field | Type | DB Column | Description |
+|-------|------|-----------|-------------|
+| id | int | ARTICLE.ID | ID bài viết (Primary Key) |
+| title | string | ARTICLE.TITLE<br/>TITLE_EN<br/>TITLE_LO | Tiêu đề bài viết (theo ngôn ngữ) |
+| slug | string | ARTICLE.SLUG | URL-friendly slug (unique) |
+| summary | string | ARTICLE.SUMMARY<br/>SUMMARY_EN<br/>SUMMARY_LO | Tóm tắt ngắn gọn |
+| thumbnailUrl | string | ARTICLE.THUMBNAIL_URL | Ảnh thumbnail (dùng cho list) |
+| categoryId | int | ARTICLE.CATEGORY_ID | ID danh mục (FK → ARTICLE_CATEGORY) |
+| viewCount | int | ARTICLE.VIEW_COUNT | Số lượt xem |
+| isFeatured | bool | ARTICLE.IS_FEATURED | Bài viết nổi bật (true/false) |
+| isPinned | bool | ARTICLE.IS_PINNED | Bài viết được ghim (true/false) |
+| publishedDate | datetime | ARTICLE.PUBLISHED_DATE | Ngày xuất bản |
+
+### Response Fields - article (Detail)
+| Field | Type | DB Column | Description |
+|-------|------|-----------|-------------|
+| *Tất cả fields từ List + thêm:* | | | |
+| content | string | ARTICLE.CONTENT<br/>CONTENT_EN<br/>CONTENT_LO | Nội dung HTML đầy đủ |
+| coverImageUrl | string | ARTICLE.COVER_IMAGE_URL | Ảnh bìa (dùng cho detail page) |
+| metaDescription | string | ARTICLE.META_DESCRIPTION<br/>META_DESCRIPTION_EN<br/>META_DESCRIPTION_LO | SEO meta description |
+| metaKeywords | string | ARTICLE.META_KEYWORDS | SEO keywords (comma-separated) |
+| createdDate | datetime | ARTICLE.CREATED_DATE | Ngày tạo bài viết |
+
 ---
 
 ## 5. Banner Load
@@ -477,9 +520,9 @@ POST /apis/content/banner
 ### Response
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "data": {
-        "items": [
+        "banners": [
             {
                 "id": 1,
                 "title": "Banner Title",
@@ -497,6 +540,21 @@ POST /apis/content/banner
 }
 ```
 
+### Response Fields - banners[]
+| Field | Type | DB Column | Description |
+|-------|------|-----------|-------------|
+| id | int | BANNER.ID | ID banner (Primary Key) |
+| title | string | BANNER.TITLE<br/>TITLE_EN<br/>TITLE_LO | Tiêu đề banner (theo ngôn ngữ) |
+| subtitle | string | BANNER.SUBTITLE<br/>SUBTITLE_EN<br/>SUBTITLE_LO | Phụ đề banner |
+| imageUrl | string | BANNER.IMAGE_URL | Ảnh banner (desktop) |
+| imageMobileUrl | string | BANNER.IMAGE_MOBILE_URL | Ảnh banner (mobile) |
+| linkUrl | string | BANNER.LINK_URL | URL đích khi click banner |
+| linkTarget | string | BANNER.LINK_TARGET | Target (_self, _blank,...) |
+| position | string | BANNER.POSITION | Vị trí hiển thị (home, category,...) |
+| displayOrder | int | BANNER.DISPLAY_ORDER | Thứ tự hiển thị |
+
+**Lọc tự động**: Chỉ trả về banners với `STATUS = 1` và trong khoảng `START_DATE ≤ now ≤ END_DATE`
+
 ---
 
 ## 6. Customer Review Load
@@ -528,14 +586,14 @@ POST /apis/content/review
 ### Response
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "data": {
-        "items": [
+        "reviews": [
             {
                 "id": 1,
                 "customerName": "Nguyen Van A",
                 "avatarUrl": "/avatars/user1.jpg",
-                "rating": true,
+                "rating": 1,
                 "reviewContent": "Dich vu rat tot...",
                 "destination": "Vientiane, Laos",
                 "isFeatured": true,
@@ -547,6 +605,20 @@ POST /apis/content/review
 }
 ```
 
+### Response Fields - reviews[]
+| Field | Type | DB Column | Description |
+|-------|------|-----------|-------------|
+| id | int | CUSTOMER_REVIEW.ID | ID đánh giá (Primary Key) |
+| customerName | string | CUSTOMER_REVIEW.CUSTOMER_NAME | Tên khách hàng |
+| avatarUrl | string | CUSTOMER_REVIEW.AVATAR_URL | Ảnh đại diện (nullable) |
+| rating | int | CUSTOMER_REVIEW.RATING | Số sao (1-5) |
+| reviewContent | string | CUSTOMER_REVIEW.REVIEW_CONTENT<br/>REVIEW_CONTENT_EN<br/>REVIEW_CONTENT_LO | Nội dung đánh giá (theo ngôn ngữ) |
+| destination | string | CUSTOMER_REVIEW.DESTINATION<br/>DESTINATION_EN<br/>DESTINATION_LO | Địa điểm du lịch |
+| isFeatured | bool | CUSTOMER_REVIEW.IS_FEATURED | Review nổi bật |
+| createdDate | datetime | CUSTOMER_REVIEW.CREATED_DATE | Ngày tạo review |
+
+**Lọc tự động**: Chỉ trả về reviews với `STATUS = 1` (đã duyệt)
+
 ---
 
 ## 6.1 Customer Review Create
@@ -580,7 +652,7 @@ POST /apis/content/review/create
 ### Response Success
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "message": "Review submitted successfully",
     "data": {
         "reviewId": 123
@@ -614,9 +686,9 @@ POST /apis/content/faq-category
 ### Response
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "data": {
-        "items": [
+        "categories": [
             {
                 "id": 1,
                 "categoryName": "Cài đặt eSIM",
@@ -631,6 +703,16 @@ POST /apis/content/faq-category
 }
 ```
 
+### Response Fields - categories[]
+| Field | Type | DB Column | Description |
+|-------|------|-----------|-------------|
+| id | int | FAQ_CATEGORY.ID | ID danh mục FAQ (Primary Key) |
+| categoryName | string | FAQ_CATEGORY.CATEGORY_NAME<br/>CATEGORY_NAME_EN<br/>CATEGORY_NAME_LO | Tên danh mục FAQ |
+| categorySlug | string | FAQ_CATEGORY.CATEGORY_SLUG | URL-friendly slug |
+| description | string | FAQ_CATEGORY.DESCRIPTION<br/>DESCRIPTION_EN<br/>DESCRIPTION_LO | Mô tả danh mục |
+| iconUrl | string | FAQ_CATEGORY.ICON_URL | Icon của danh mục |
+| displayOrder | int | FAQ_CATEGORY.DISPLAY_ORDER | Thứ tự hiển thị |
+
 ---
 
 ## 8. FAQ Load
@@ -664,9 +746,9 @@ POST /apis/content/faq
 ### Response
 ```json
 {
-    "errorCode": 0,
+    "errorCode": "0",
     "data": {
-        "items": [
+        "faqs": [
             {
                 "id": 1,
                 "question": "Làm sao để cài đặt eSIM?",
@@ -680,3 +762,17 @@ POST /apis/content/faq
     }
 }
 ```
+
+### Response Fields - faqs[]
+| Field | Type | DB Column | Description |
+|-------|------|-----------|-------------|
+| id | int | FAQ.ID | ID câu hỏi (Primary Key) |
+| question | string | FAQ.QUESTION<br/>QUESTION_EN<br/>QUESTION_LO | Câu hỏi (theo ngôn ngữ) |
+| answer | string | FAQ.ANSWER<br/>ANSWER_EN<br/>ANSWER_LO | Câu trả lời (HTML format) |
+| categoryId | int | FAQ.CATEGORY_ID | ID danh mục (FK → FAQ_CATEGORY) |
+| viewCount | int | FAQ.VIEW_COUNT | Số lượt xem |
+| isFeatured | bool | FAQ.IS_FEATURED | FAQ nổi bật |
+
+**Lọc tự động**: Chỉ trả về FAQs với `STATUS = 1`
+
+---