Forráskód Böngészése

국제 시세 DB 캐시 도입 + 차트 bar 타입 전환

- ApiCache 테이블에 Alpha Vantage 마지막 성공 응답 영구 보관
  (재배포/일일 쿼터 초과 시에도 마지막 데이터 표시)
- 시작 시 economy DB·ApiCache 테이블 자동 생성 (DB 장애에도 앱 기동)
- 미니 차트 line → bar 전환

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 napja
szülő
commit
3fb55056e9

+ 5 - 3
Controllers/Price/GlobalController.cs

@@ -17,6 +17,7 @@ namespace economy.Controllers.Price.Global
         private readonly Alpha_API _alphaAPI;
         private readonly KoreaEximGoKR _koreaEximGoKR;
         private readonly IMemoryCache _cache;
+        private readonly EconomyContext _context;
         private Dictionary<string, string> _queryString;
 
         // 커모디티 화이트리스트 (Alpha Vantage 함수, 화면 제목, 원화 표시 여부)
@@ -31,11 +32,12 @@ namespace economy.Controllers.Price.Global
             ["allcommodities"] = ("ALL_COMMODITIES", "원자재 종합지수", false), // 지수(2016=100)라 원화 환산 없음
         };
 
-        public GlobalController(Alpha_API alphaAPI, KoreaEximGoKR koreaEximGoKR, IMemoryCache cache)
+        public GlobalController(Alpha_API alphaAPI, KoreaEximGoKR koreaEximGoKR, IMemoryCache cache, EconomyContext context)
         {
             _alphaAPI = alphaAPI;
             _koreaEximGoKR = koreaEximGoKR;
             _cache = cache;
+            _context = context;
             _queryString = [];
         }
 
@@ -112,7 +114,7 @@ namespace economy.Controllers.Price.Global
 
             int exchangeValue = await GetExchangeValueAsync();
 
-            GlobalModel globalModel = new GlobalModel(_alphaAPI, _cache);
+            GlobalModel globalModel = new GlobalModel(_alphaAPI, _cache, _context);
             NaturalGas.Response itemList = await globalModel.GetNaturalGasPriceInfo(request);
 
             ChartData? chart = null;
@@ -186,7 +188,7 @@ namespace economy.Controllers.Price.Global
 
             int exchangeValue = meta.HasKrw ? await GetExchangeValueAsync() : 0;
 
-            GlobalModel globalModel = new GlobalModel(_alphaAPI, _cache);
+            GlobalModel globalModel = new GlobalModel(_alphaAPI, _cache, _context);
             Commodity.Response itemList = await globalModel.GetCommodityPriceInfo(meta.Function, request.Interval);
 
             ChartData? chart = null;

+ 16 - 0
Models/ApiCacheEntry.cs

@@ -0,0 +1,16 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace economy.Models
+{
+    // 외부 API 마지막 성공 응답 보관 (재배포/호출 한도 초과 시에도 시세 표시 유지)
+    public class ApiCacheEntry
+    {
+        [Key]
+        [StringLength(100)]
+        public string CacheKey { get; set; } = "";
+
+        public string Payload { get; set; } = "";
+
+        public DateTime UpdatedAt { get; set; }
+    }
+}

+ 2 - 0
Models/EconomyContext.cs

@@ -8,5 +8,7 @@ namespace economy.Models
         {
             System.Console.WriteLine("Complete Database");
         }
+
+        public DbSet<ApiCacheEntry> ApiCache { get; set; }
     }
 }

+ 75 - 15
Models/Price/GlobalModel.cs

@@ -7,11 +7,13 @@ namespace economy.Models.Price.Global
     {
         private readonly Alpha_API _alphaAPI;
         private readonly IMemoryCache _cache;
+        private readonly EconomyContext? _context;
 
-        public GlobalModel(Alpha_API alphaAPI, IMemoryCache cache)
+        public GlobalModel(Alpha_API alphaAPI, IMemoryCache cache, EconomyContext? context = null)
         {
             _alphaAPI = alphaAPI;
             _cache = cache;
+            _context = context;
         }
 
         // 천연가스 시세 조회
@@ -43,14 +45,14 @@ namespace economy.Models.Price.Global
 
                     parseData = JsonSerializer.Deserialize<NaturalGas.Response>(jsonString);
 
-                    // 호출 한도 초과 시 HTTP 200 + Information 응답이 와서 Data가 null이 됨 — 빈 응답은 캐시하지 않음
-                    if (parseData.Data is null)
+                    if (parseData.Data is not null)
                     {
-                        return new NaturalGas.Response();
-                    }
+                        // 캐시에 저장 (메모리 1일 + DB에 마지막 성공 응답 영구 보관)
+                        _cache.Set(cacheKey, parseData, TimeSpan.FromDays(1));
+                        await SaveDbCacheAsync(cacheKey, jsonString);
 
-                    // 캐시에 저장 (1일 동안 유지)
-                    _cache.Set(cacheKey, parseData, TimeSpan.FromDays(1));
+                        return parseData;
+                    }
                 }
 
                 response.EnsureSuccessStatusCode();
@@ -60,7 +62,10 @@ namespace economy.Models.Price.Global
                 Console.WriteLine($"Request error: {e.Message}");
             }
 
-            return parseData;
+            // 호출 실패/한도 초과 시 DB에 보관된 마지막 성공 응답으로 대체
+            var fallback = await LoadDbCacheAsync<NaturalGas.Response>(cacheKey);
+
+            return fallback ?? new NaturalGas.Response();
         }
 
         // 국제 원자재 시세 조회 (구리, 알루미늄, 밀, 섬유(면화), 설탕, 커피, 원자재 종합지수)
@@ -92,14 +97,14 @@ namespace economy.Models.Price.Global
 
                     parseData = JsonSerializer.Deserialize<Commodity.Response>(jsonString);
 
-                    // 호출 한도 초과 시 HTTP 200 + Information 응답이 와서 Data가 null이 됨 — 빈 응답은 캐시하지 않음
-                    if (parseData.Data is null)
+                    if (parseData.Data is not null)
                     {
-                        return new Commodity.Response();
-                    }
+                        // 캐시에 저장 (메모리 1일 + DB에 마지막 성공 응답 영구 보관)
+                        _cache.Set(cacheKey, parseData, TimeSpan.FromDays(1));
+                        await SaveDbCacheAsync(cacheKey, jsonString);
 
-                    // 캐시에 저장 (1일 동안 유지)
-                    _cache.Set(cacheKey, parseData, TimeSpan.FromDays(1));
+                        return parseData;
+                    }
                 }
 
                 response.EnsureSuccessStatusCode();
@@ -109,7 +114,62 @@ namespace economy.Models.Price.Global
                 Console.WriteLine($"Request error: {e.Message}");
             }
 
-            return parseData;
+            // 호출 실패/한도 초과 시 DB에 보관된 마지막 성공 응답으로 대체
+            var fallback = await LoadDbCacheAsync<Commodity.Response>(cacheKey);
+
+            return fallback ?? new Commodity.Response();
+        }
+
+        // 성공 응답을 DB에 upsert (DB 장애 시에도 서비스에는 영향 없음)
+        private async Task SaveDbCacheAsync(string cacheKey, string payload)
+        {
+            if (_context is null)
+            {
+                return;
+            }
+
+            try
+            {
+                var row = await _context.ApiCache.FindAsync(cacheKey);
+
+                if (row is null)
+                {
+                    _context.ApiCache.Add(new ApiCacheEntry { CacheKey = cacheKey, Payload = payload, UpdatedAt = DateTime.Now });
+                }
+                else
+                {
+                    row.Payload = payload;
+                    row.UpdatedAt = DateTime.Now;
+                }
+
+                await _context.SaveChangesAsync();
+            }
+            catch (Exception e)
+            {
+                Console.WriteLine($"ApiCache save error: {e.Message}");
+            }
+        }
+
+        // DB에 보관된 마지막 성공 응답 조회 (없거나 DB 장애면 null)
+        private async Task<T?> LoadDbCacheAsync<T>(string cacheKey) where T : class
+        {
+            if (_context is null)
+            {
+                return null;
+            }
+
+            try
+            {
+                var row = await _context.ApiCache.FindAsync(cacheKey);
+
+                return row is null ? null : JsonSerializer.Deserialize<T>(row.Payload);
+            }
+            catch (Exception e)
+            {
+                Console.WriteLine($"ApiCache load error: {e.Message}");
+
+                return null;
+            }
         }
     }
 }

+ 29 - 0
Program.cs

@@ -25,6 +25,35 @@ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // 
 
 var app = builder.Build();
 
+// economy database bootstrap (creates once on first run)
+try
+{
+    var masterCsb = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(builder.Configuration.GetConnectionString("DefaultConnection")) { InitialCatalog = "master" };
+    using var masterConn = new Microsoft.Data.SqlClient.SqlConnection(masterCsb.ConnectionString);
+    masterConn.Open();
+    using var createDbCmd = masterConn.CreateCommand();
+    createDbCmd.CommandText = "IF DB_ID(N'economy') IS NULL CREATE DATABASE economy;";
+    createDbCmd.ExecuteNonQuery();
+}
+catch (Exception e)
+{
+    Console.WriteLine($"Database init error: {e.Message}");
+}
+
+// ApiCache table bootstrap (creates once; app still boots if DB is down)
+using (var scope = app.Services.CreateScope())
+{
+    try
+    {
+        var context = scope.ServiceProvider.GetRequiredService<EconomyContext>();
+        context.Database.ExecuteSqlRaw("IF OBJECT_ID(N'dbo.ApiCache', N'U') IS NULL CREATE TABLE dbo.ApiCache (CacheKey NVARCHAR(100) NOT NULL PRIMARY KEY, Payload NVARCHAR(MAX) NOT NULL, UpdatedAt DATETIME2 NOT NULL);");
+    }
+    catch (Exception e)
+    {
+        Console.WriteLine($"ApiCache init error: {e.Message}");
+    }
+}
+
 // Configure the HTTP request pipeline.
 if (!app.Environment.IsDevelopment())
 {

+ 2 - 5
wwwroot/js/price-chart.js

@@ -8,16 +8,13 @@ class PriceChart {
         const data = JSON.parse(el.textContent);
 
         new Chart(document.getElementById("priceChart"), {
-            type: "line",
+            type: "bar",
             data: {
                 labels: data.labels,
                 datasets: data.series.map((s) => ({
                     label: s.name,
                     data: s.values,
-                    borderWidth: 2,
-                    pointRadius: 2,
-                    tension: 0.2,
-                    spanGaps: true
+                    borderWidth: 1
                 }))
             },
             options: {