Добавить загрузку исходника карточки по провайдерам

Введены ISourceContentProvider и SourceContentResolver, эндпоинт GET /api/cards/{id}/source отдаёт содержимое источника (провайдер по kind либо сохранённое в карточке).
This commit is contained in:
Rustam Khalimov
2026-09-11 15:01:22 +03:00
parent ebc761d40e
commit 3327bf48b0
10 changed files with 190 additions and 7 deletions
@@ -4,6 +4,7 @@ using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Tenants.Application.Models;
@@ -45,6 +46,9 @@ public static class CardDetailsEndpoints
// Вложенный путь установки/снятия напоминания.
private const string ReminderPath = "/{cardId}/reminder";
// Вложенный путь ленивой загрузки содержимого источника.
private const string SourcePath = "/{cardId}/source";
// Вложенный путь «напомнить позже».
private const string ReminderSnoozePath = "/{cardId}/reminder/snooze";
@@ -94,6 +98,7 @@ public static class CardDetailsEndpoints
cards.MapPost(FilesPath, UploadFilesAsync);
cards.MapGet(FileDownloadPath, DownloadFileAsync);
cards.MapDelete(FileItemPath, RemoveFileAsync);
cards.MapGet(SourcePath, ReadSourceAsync);
cards.MapPost(ReminderPath, SetReminderAsync);
cards.MapDelete(ReminderPath, ClearReminderAsync);
cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync);
@@ -408,6 +413,27 @@ public static class CardDetailsEndpoints
// cardId: Id карточки.
// ct: Токен отмены.
// Возвращает: 200 с единой карточкой либо 404.
// Читает содержимое источника карточки: провайдер по виду источника либо сохранённое в карточке.
// context: Контекст запроса (для резолва CardsService).
// cardId: Id карточки.
// ct: Токен отмены.
// Возвращает: 200 с SourceContent либо 404.
private static async Task<IResult> ReadSourceAsync(
HttpContext context,
string cardId,
CancellationToken ct)
{
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
SourceContent content = await cardsService.ResolveSourceAsync(card, ct);
return Results.Ok(content);
}
private static async Task<IResult> ReadCardAsync(
HttpContext context,
string cardId,
@@ -0,0 +1,20 @@
namespace Deal.Modules.Cards.Application.Sources;
/// <summary>
/// Загрузка содержимого записи источника по ссылке.
/// </summary>
public interface ISourceContentProvider
{
/// <summary>
/// Вид источника, который обслуживает провайдер.
/// </summary>
public string Kind { get; }
/// <summary>
/// Загружает содержимое записи источника.
/// </summary>
/// <param name="source">Ссылка на источник.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Содержимое записи либо null, если источник не отдал данные.</returns>
public Task<SourceContent?> LoadAsync(SourceRef source, CancellationToken ct);
}
@@ -0,0 +1,42 @@
namespace Deal.Modules.Cards.Application.Sources;
/// <summary>
/// Выбирает провайдера содержимого по виду источника.
/// </summary>
public sealed class SourceContentResolver
{
private readonly Dictionary<string, ISourceContentProvider> _providers;
/// <summary>
/// Создаёт реестр провайдеров содержимого.
/// </summary>
/// <param name="providers">Зарегистрированные провайдеры; последний по виду перекрывает предыдущих.</param>
public SourceContentResolver(IEnumerable<ISourceContentProvider> providers)
{
_providers = new Dictionary<string, ISourceContentProvider>(StringComparer.OrdinalIgnoreCase);
foreach (ISourceContentProvider provider in providers)
{
_providers[provider.Kind] = provider;
}
}
/// <summary>
/// Есть ли провайдер для вида источника.
/// </summary>
/// <param name="source">Ссылка на источник.</param>
/// <returns>true — содержимое можно загрузить лениво.</returns>
public bool CanResolve(SourceRef source) => _providers.ContainsKey(source.Kind);
/// <summary>
/// Загружает содержимое записи источника.
/// </summary>
/// <param name="source">Ссылка на источник.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Содержимое записи либо null — провайдера нет/источник не отдал данные.</returns>
public Task<SourceContent?> ResolveAsync(SourceRef source, CancellationToken ct)
{
return _providers.TryGetValue(source.Kind, out ISourceContentProvider? provider)
? provider.LoadAsync(source, ct)
: Task.FromResult<SourceContent?>(null);
}
}
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Microsoft.Extensions.DependencyInjection;
@@ -17,6 +18,7 @@ public static class KanbanModuleRegistrar
services.AddScoped<ContainersService>();
services.AddScoped<CardsService>();
services.AddScoped<StorageTickService>();
services.AddScoped<SourceContentResolver>();
services.AddScoped<IRatesChangedListener, ConversionRecomputer>();
return services;
@@ -0,0 +1,30 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Содержимое источника карточки — partial-часть <see cref="CardsService"/>
/// </summary>
public sealed partial class CardsService
{
/// <summary>
/// Загружает содержимое источника карточки.
/// </summary>
/// <param name="card">Карточка, для которой нужно содержимое.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Содержимое от провайдера источника либо сохранённое в карточке.</returns>
public async Task<SourceContent> ResolveSourceAsync(CardDto card, CancellationToken ct)
{
if (_sourceResolver is not null && _sourceResolver.CanResolve(card.Source))
{
SourceContent? loaded = await _sourceResolver.ResolveAsync(card.Source, ct);
if (loaded is not null)
{
return loaded;
}
}
return card.Content;
}
}
@@ -1,4 +1,5 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Settings.Application.Abstractions;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
@@ -19,6 +20,7 @@ public sealed partial class CardsService
private readonly ISettingsStore _settings;
private readonly IMlClient _mlClient;
private readonly IFileStorage _storage;
private readonly SourceContentResolver? _sourceResolver;
/// <summary>
/// Создаёт сервис карточек над портами модуля
@@ -27,16 +29,19 @@ public sealed partial class CardsService
/// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param>
/// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param>
/// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param>
/// <param name="sourceResolver">Реестр провайдеров содержимого источника; null — только содержимое карточки.</param>
public CardsService(
ICardStore store,
ISettingsStore settings,
IMlClient mlClient,
IFileStorage storage)
IFileStorage storage,
SourceContentResolver? sourceResolver = null)
{
_store = store;
_settings = settings;
_mlClient = mlClient;
_storage = storage;
_sourceResolver = sourceResolver;
}
@@ -0,0 +1,56 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты выбора провайдера содержимого источника — SourceContentResolver.
/// </summary>
public sealed class SourceContentResolverTests
{
[Fact]
public async Task ResolveAsync_KnownKind_ReturnsProviderContent()
{
var provider = new FakeProvider("test");
var resolver = new SourceContentResolver([provider]);
SourceRef source = SourceItemFactory.Source();
SourceContent expected = SourceItemFactory.Content("из провайдера");
provider.Content = expected;
Assert.True(resolver.CanResolve(source));
SourceContent? actual = await resolver.ResolveAsync(source, CancellationToken.None);
Assert.Same(expected, actual);
}
[Fact]
public async Task ResolveAsync_UnknownKind_ReturnsNull()
{
var resolver = new SourceContentResolver([]);
SourceRef source = SourceItemFactory.Source();
Assert.False(resolver.CanResolve(source));
Assert.Null(await resolver.ResolveAsync(source, CancellationToken.None));
}
[Fact]
public async Task ResolveAsync_KindMatchIsCaseInsensitive()
{
var provider = new FakeProvider("Telegram");
var resolver = new SourceContentResolver([provider]);
SourceRef source = SourceItemFactory.Source(kind: "telegram");
provider.Content = SourceItemFactory.Content("из провайдера");
Assert.True(resolver.CanResolve(source));
Assert.NotNull(await resolver.ResolveAsync(source, CancellationToken.None));
}
private sealed class FakeProvider(string kind) : ISourceContentProvider
{
public string Kind { get; } = kind;
public SourceContent? Content { get; set; }
public Task<SourceContent?> LoadAsync(SourceRef source, CancellationToken ct) =>
Task.FromResult(Content);
}
}