Добавить загрузку исходника карточки по провайдерам
Введены ISourceContentProvider и SourceContentResolver, эндпоинт GET /api/cards/{id}/source отдаёт содержимое источника (провайдер по kind либо сохранённое в карточке).
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user