feat(news): dynamic settings, IFinlyticLogger, live log streaming, and EF migration

This commit is contained in:
2026-08-15 21:30:01 +02:00
parent a1f2b888f6
commit a94c36a878
12 changed files with 800 additions and 555 deletions
+48 -116
View File
@@ -1,6 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FinlyticCore.Dtos.News;
using FinlyticCore.Services;
using FinlyticNews.Database;
using FinlyticNews.Entities;
using FinlyticNews.Util;
using Microsoft.EntityFrameworkCore;
namespace FinlyticNews.Services;
@@ -39,22 +45,11 @@ public interface INewsDbService
/// </summary>
Task<NewsArticleEntity?> SaveArticleClassificationAsync(Guid id, N8nResponsePayload payload, List<MatchedAssetEntity> matchedAssets);
/// <summary>
/// Retrieves articles ready for historical sync or sentiment processing.
/// </summary>
Task<List<NewsArticleEntity>> GetCompletedArticlesAsync(int limit, int offset, string? isin = null);
Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(string status);
/// <summary>
/// Fetches articles matching specific lifecycle statuses (e.g. "Pending", "Scraping" for Phase 1 retry).
/// </summary>
Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(params string[] statuses);
/// <summary>
/// Fetches public daily news for API endpoints, filtering out intermediate or failed lifecycle states by default.
/// </summary>
Task<List<NewsArticleEntity>> GetFilteredNewsAsync(
int limit = 20,
int offset = 0,
int limit,
int offset,
string? isin = null,
DateTime? date = null,
string? status = null,
@@ -67,15 +62,12 @@ public interface INewsDbService
public class NewsDbService : INewsDbService
{
private readonly NewsDbContext _context;
private readonly ILogger<NewsDbService> _logger;
private readonly IFinlyticLogger<NewsDbService> _finlyticLogger;
/// <summary>
/// Initializes a new instance of the <see cref="NewsDbService"/> class.
/// </summary>
public NewsDbService(NewsDbContext context, ILogger<NewsDbService> logger)
public NewsDbService(NewsDbContext context, IFinlyticLogger<NewsDbService> finlyticLogger)
{
_context = context;
_logger = logger;
_finlyticLogger = finlyticLogger;
}
/// <inheritdoc />
@@ -103,9 +95,6 @@ public class NewsDbService : INewsDbService
}
/// <inheritdoc />
/// <summary>
/// Lifecycle Step 1: Creates a new article in 'Pending' state as an immediate lock.
/// </summary>
public async Task<NewsArticleEntity> CreatePendingArticleAsync(
string url,
List<string>? discoveredIsins = null,
@@ -121,7 +110,7 @@ public class NewsDbService : INewsDbService
if (existingArticle != null)
{
_logger.LogDebug("[Lifecycle] Article URL already exists (Duplicate hit): {Url}", trimmedUrl);
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Article URL already exists (Duplicate hit): {Url}", trimmedUrl);
return existingArticle;
}
@@ -146,7 +135,7 @@ public class NewsDbService : INewsDbService
Language = language,
ScrapedAt = DateTime.UtcNow,
PublishedAt = finalPublishedAt,
Status = "Pending" // 1. Pending State
Status = "Pending"
};
if (discoveredIsins != null && discoveredIsins.Count > 0)
@@ -167,11 +156,11 @@ public class NewsDbService : INewsDbService
{
_context.NewsArticles.Add(article);
await _context.SaveChangesAsync();
_logger.LogDebug("[Lifecycle] Registered new article with status 'Pending'. ID: {Id}, Url: {Url}", article.Id, trimmedUrl);
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Registered new article with status 'Pending'. ID: {Id}, Url: {Url}", article.Id, trimmedUrl);
}
catch (DbUpdateConcurrencyException)
{
_logger.LogWarning("[{Channel}] Concurrency hit during insert for URL: {Url}. Fetching existing fallback.", "NewsChannel", trimmedUrl);
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Concurrency hit during insert for URL: {Url}. Fetching existing fallback.", trimmedUrl);
return await _context.NewsArticles.FirstAsync(a => a.SourceUrl == trimmedUrl);
}
@@ -179,9 +168,6 @@ public class NewsDbService : INewsDbService
}
/// <inheritdoc />
/// <summary>
/// Lifecycle Step 2 & 5: Updates state (e.g. Pending -> Processing -> Scraping / Failed / Analyzed).
/// </summary>
public async Task UpdateArticleStatusAsync(Guid id, string status)
{
var rowsAffected = await _context.NewsArticles
@@ -190,11 +176,11 @@ public class NewsDbService : INewsDbService
if (rowsAffected == 0)
{
_logger.LogWarning("[{Channel}] Attempted status transition for non-existing article. ID: {Id}", "NewsChannel", id);
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Attempted status transition for non-existing article. ID: {Id}", id);
}
else
{
_logger.LogDebug("[Lifecycle] Transitioned article {Id} to status '{Status}'", id, status);
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Transitioned article {Id} to status '{Status}'", id, status);
}
}
@@ -207,18 +193,15 @@ public class NewsDbService : INewsDbService
if (rowsAffected == 0)
{
_logger.LogWarning("[{Channel}] Attempted URL update for non-existing article. ID: {Id}", "NewsChannel", id);
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Attempted URL update for non-existing article. ID: {Id}", id);
}
else
{
_logger.LogDebug("[Lifecycle] Resolved redirect for article {Id} -> New URL: {Url}", id, resolvedUrl);
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Resolved redirect for article {Id} -> New URL: {Url}", id, resolvedUrl);
}
}
/// <inheritdoc />
/// <summary>
/// Lifecycle Step 4: Persists n8n classification and transitions status to 'Completed'.
/// </summary>
public async Task<NewsArticleEntity?> SaveArticleClassificationAsync(Guid id, N8nResponsePayload payload, List<MatchedAssetEntity> matchedAssets)
{
var article = await _context.NewsArticles
@@ -226,7 +209,7 @@ public class NewsDbService : INewsDbService
if (article == null)
{
_logger.LogWarning("[{Channel}] Article with ID {Id} not found for classification update.", "NewsChannel", id);
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Article with ID {Id} not found for classification update.", id);
return null;
}
@@ -235,8 +218,6 @@ public class NewsDbService : INewsDbService
article.Summary = payload.Summary;
article.ContentRaw = payload.ContentRaw;
article.Language = payload.Language;
// 🎯 Step 4: Classification finished -> Transition to 'Completed' (triggers MQTT broadcast)
article.Status = "Completed";
if (DateTime.TryParse(payload.PublishedAt, out var publishedDate))
@@ -251,7 +232,6 @@ public class NewsDbService : INewsDbService
article.ScrapedAt = scrapedDate.ToUniversalTime();
}
// Clean up previous temporary assets
await _context.MatchedAssets.Where(m => m.NewsArticleId == id).ExecuteDeleteAsync();
article.MatchedAssets = new List<MatchedAssetEntity>();
@@ -267,113 +247,65 @@ public class NewsDbService : INewsDbService
}
await _context.SaveChangesAsync();
_logger.LogInformation("[Lifecycle] Article {Id} successfully classified and marked 'Completed'. Title: '{Title}'", article.Id, article.Title);
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[Lifecycle] Article {Id} successfully classified and marked 'Completed'. Title: '{Title}'", article.Id, article.Title);
return article;
}
/// <inheritdoc />
public async Task<List<NewsArticleEntity>> GetCompletedArticlesAsync(int limit, int offset, string? isin = null)
public async Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(string status)
{
var query = _context.NewsArticles
return await _context.NewsArticles
.Include(a => a.MatchedAssets)
.Where(a => a.Status == "Completed" || a.Status == "Analyzed");
if (!string.IsNullOrWhiteSpace(isin))
{
var cleanIsin = isin.Trim();
query = query.Where(a => a.MatchedAssets.Any(m => m.Isin == cleanIsin || m.Name == cleanIsin));
}
return await query
.Where(a => a.Status == status)
.OrderByDescending(a => a.PublishedAt)
.ThenByDescending(a => a.ScrapedAt)
.ThenByDescending(a => a.Id)
.Skip(offset)
.Take(limit)
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc />
/// <summary>
/// Lifecycle Helper: Retrieves articles by target lifecycle status (e.g. "Pending", "Scraping" for Phase 1 Retry).
/// </summary>
public async Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(params string[] statuses)
{
IQueryable<NewsArticleEntity> query = _context.NewsArticles
.Include(a => a.MatchedAssets)
.AsNoTracking();
if (statuses != null && statuses.Length > 0)
{
var cleanStatuses = statuses.Select(s => s.Trim()).ToList();
query = query.Where(a => cleanStatuses.Contains(a.Status));
}
else
{
query = query.Where(a => a.Status != "Failed" && a.Status != "Duplicate");
}
return await query.ToListAsync();
}
/// <inheritdoc />
/// <summary>
/// API Gateway Helper: Retrieves articles for UI rendering, excluding intermediate/failed states by default.
/// </summary>
public async Task<List<NewsArticleEntity>> GetFilteredNewsAsync(
int limit = 20,
int offset = 0,
int limit,
int offset,
string? isin = null,
DateTime? date = null,
string? status = null,
string? searchQuery = null)
{
IQueryable<NewsArticleEntity> query = _context.NewsArticles
var query = _context.NewsArticles
.Include(a => a.MatchedAssets)
.AsNoTracking();
.AsNoTracking()
.AsQueryable();
// 1. Status Filter
if (!string.IsNullOrWhiteSpace(status))
{
var targetStatus = status.Trim();
query = query.Where(a => a.Status == targetStatus);
}
else
{
// By default, only show fully processed articles to the API/UI
query = query.Where(a => a.Status == "Completed" || a.Status == "Analyzed");
}
// 2. Date Filter
if (date.HasValue)
{
// Erstelle ein exaktes UTC-Datum von 00:00:00 Uhr am gebuchten Tag
var targetDate = new DateTime(date.Value.Year, date.Value.Month, date.Value.Day, 0, 0, 0, DateTimeKind.Utc);
var nextDate = targetDate.AddDays(1);
query = query.Where(a => a.PublishedAt >= targetDate && a.PublishedAt < nextDate);
}
// 3. ISIN / Symbol Filter
if (!string.IsNullOrWhiteSpace(isin))
{
var cleanIsin = isin.Trim();
query = query.Where(a => a.MatchedAssets.Any(m => m.Isin == cleanIsin || m.Name == cleanIsin));
query = query.Where(a => a.MatchedAssets.Any(m => m.Isin == cleanIsin));
}
if (date.HasValue)
{
var startUtc = date.Value.Date.ToUniversalTime();
var endUtc = startUtc.AddDays(1);
query = query.Where(a => a.PublishedAt >= startUtc && a.PublishedAt < endUtc);
}
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(a => a.Status == status);
}
// 4. Search Term
if (!string.IsNullOrWhiteSpace(searchQuery))
{
var q = searchQuery.Trim();
query = query.Where(a => EF.Functions.ILike(a.Title, $"%{q}%") || (a.Summary != null && EF.Functions.ILike(a.Summary, $"%{q}%")));
var cleanSearch = searchQuery.Trim().ToLower();
query = query.Where(a =>
a.Title.ToLower().Contains(cleanSearch) ||
a.Summary.ToLower().Contains(cleanSearch) ||
a.MatchedAssets.Any(m => m.Name.ToLower().Contains(cleanSearch)));
}
return await query
.OrderByDescending(a => a.PublishedAt)
.ThenByDescending(a => a.ScrapedAt)
.ThenByDescending(a => a.Id)
.Skip(offset)
.Take(limit)
.ToListAsync();