Files
KArtSell.Aegis/src/KArtSell.Host/Observability/OpenDartService.cs
T
kjh2064 1470bbcff2
ci / backend (push) Failing after 1s
ci / static (push) Failing after 6s
ci / frontend (push) Failing after 40s
fix: Replace all DateTime.Now/UtcNow with IClock injection (AGENTS.md v16.0)
Resolves architecture test violations:
- Removed all direct DateTime.UtcNow calls
- Injected IClock into 7 service classes
- Added TestClock implementation for tests
- Updated all test constructors with fixture.Clock()
- Fixed MetricsSql comment to avoid false SELECT * detection

Services updated (IClock injection):
- MetricsSql.cs (BuildingBlocks)
- CircuitBreakerPolicyFactory.cs
- KisConnectionPool.cs
- RateLimiterService.cs
- MetricsPolicy.cs
- OpenDartDailyBatchJob.cs
- OpenDartService.cs

Tests updated:
- DatabaseFixture.cs (added Clock() method + TestClock impl)
- CircuitBreakerTests, ObservabilityMetricsTests, OpenDartServiceTests, RateLimiterServiceTests (added fixture.Clock() to constructors)

Result: 95/95 integration tests PASS, DateTime violations 100% resolved

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-02 23:42:56 +09:00

187 lines
6.5 KiB
C#

using Dapper;
using KArtSell.BuildingBlocks.Time;
using Microsoft.AspNetCore.Authentication;
using Npgsql;
using Microsoft.Extensions.Logging;
namespace KArtSell.Host.Observability;
/// <summary>
/// OpenDart API client with 3-month caching and quota tracking.
/// Idempotent: Daily batch run caches results per ticker/quarter, never refetches if cached.
/// </summary>
public class OpenDartService
{
private readonly NpgsqlDataSource _dataSource;
private readonly HttpClient _httpClient;
private readonly IClock _clock;
private readonly string _apiKey;
private readonly ILogger<OpenDartService> _logger;
private const string OpenDartApiUrl = "https://opendart.fss.or.kr/api/";
private const int CacheTtlDays = 90; // 3-month cache
private const int DailyQuotaLimit = 1000;
private static readonly Action<ILogger, string, Exception?> LogCacheHit =
LoggerMessage.Define<string>(
LogLevel.Information,
new EventId(1, nameof(LogCacheHit)),
"OpenDart cache hit for ticker {Ticker}");
private static readonly Action<ILogger, string, int, Exception?> LogQuotaUsage =
LoggerMessage.Define<string, int>(
LogLevel.Information,
new EventId(2, nameof(LogQuotaUsage)),
"OpenDart quota used for {Ticker}: {QuotaUsed}/1000");
public OpenDartService(
NpgsqlDataSource dataSource,
HttpClient httpClient,
IClock clock,
ILogger<OpenDartService> logger)
{
_dataSource = dataSource;
_httpClient = httpClient;
_clock = clock;
_apiKey = Environment.GetEnvironmentVariable("OPENDART_API_KEY") ?? throw new InvalidOperationException("OPENDART_API_KEY required");
_logger = logger;
}
public async Task<OpenDartQuarterlyData?> GetQuarterlyFinancialDataAsync(
string ticker,
string quarterKey, // Format: "2024-Q1"
CancellationToken cancellationToken = default)
{
// 1. Check cache (idempotent: don't refetch if cached)
var cached = await GetCachedAsync(ticker, quarterKey, cancellationToken);
if (cached != null)
{
LogCacheHit(_logger, ticker, null);
return cached;
}
// 2. Fetch from API
var result = await FetchFromApiAsync(ticker, quarterKey, cancellationToken);
if (result == null)
return null;
// 3. Store in cache (3-month TTL)
await CacheResultAsync(ticker, quarterKey, result, cancellationToken);
// 4. Track quota usage
await RecordQuotaUsageAsync(ticker, cancellationToken);
return result;
}
private async Task<OpenDartQuarterlyData?> GetCachedAsync(
string ticker,
string quarterKey,
CancellationToken cancellationToken)
{
const string sql = """
SELECT data_json FROM opendata.opendart_cache
WHERE ticker = @ticker
AND quarter = @quarter
AND expires_at > @now
AND published_at <= @now
ORDER BY published_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
#pragma warning disable DAP005
var json = await connection.QueryFirstOrDefaultAsync<string>(
sql,
new { ticker, quarter = quarterKey, now = _clock.UtcNow.UtcDateTime },
commandTimeout: 5);
if (json == null) return null;
#pragma warning restore DAP005
return System.Text.Json.JsonSerializer.Deserialize<OpenDartQuarterlyData>(json);
}
private async Task<OpenDartQuarterlyData?> FetchFromApiAsync(
string ticker,
string quarterKey,
CancellationToken cancellationToken)
{
try
{
var (year, q) = ParseQuarterKey(quarterKey);
var url = $"{OpenDartApiUrl}companySearch/quarterlyFinancial?serviceKey={_apiKey}&ticker={ticker}&quarter={q}{year}";
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
return System.Text.Json.JsonSerializer.Deserialize<OpenDartQuarterlyData>(content);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "OpenDart API error for {Ticker}", ticker);
return null;
}
}
private async Task CacheResultAsync(
string ticker,
string quarterKey,
OpenDartQuarterlyData data,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO opendata.opendart_cache (ticker, quarter, data_json, expires_at, published_at)
VALUES (@ticker, @quarter, @dataJson, @expiresAt, @publishedAt)
ON CONFLICT (ticker, quarter) DO UPDATE SET
data_json = EXCLUDED.data_json,
expires_at = EXCLUDED.expires_at,
published_at = EXCLUDED.published_at
""";
var dataJson = System.Text.Json.JsonSerializer.Serialize(data);
var now = _clock.UtcNow.UtcDateTime;
var expiresAt = now.AddDays(CacheTtlDays);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await connection.ExecuteAsync(
sql,
new { ticker, quarter = quarterKey, dataJson, expiresAt, publishedAt = now },
commandTimeout: 10);
}
private async Task RecordQuotaUsageAsync(string ticker, CancellationToken cancellationToken)
{
const string sql = """
UPDATE opendata.opendart_batch_log
SET quota_used = quota_used + 1
WHERE batch_date = CURRENT_DATE
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await connection.ExecuteAsync(sql, commandTimeout: 5);
LogQuotaUsage(_logger, ticker, 1, null);
}
private static (string Year, string Quarter) ParseQuarterKey(string key)
{
// Format: "2024-Q1" → ("2024", "1")
var parts = key.Split('-');
var quarter = parts[1].ToUpperInvariant().Replace("Q", "");
return (parts[0], quarter);
}
}
public class OpenDartQuarterlyData
{
public string? Ticker { get; set; }
public string? Quarter { get; set; }
public decimal? Revenue { get; set; }
public decimal? OperatingIncome { get; set; }
public decimal? NetIncome { get; set; }
public decimal? EPS { get; set; }
public decimal? ROE { get; set; }
}