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

Введены 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
+1 -1
View File
@@ -57,7 +57,7 @@
| TD-DBL-CLICK | Двойная перезагрузка доски у инициатора batch-reclassify (ответ + SSE) | этап 12, E | P3 | TECHDEBT |
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT |
| TD-SOURCE-PROVIDER | `GET /api/cards/{id}/source` + `ISourceContentProvider` + реестр провайдеров; ленивая догрузка `SourceContent` (сейчас контент отдаётся прямо в `CardDto`) | generic source 2026-09-11 | P1 | BACKLOG |
| TD-SOURCE-PROVIDER | `ISourceContentProvider`/`SourceContentResolver` и `GET /api/cards/{id}/source` добавлены; осталось — реализовать провайдеры источников (telegram/local/file) с ленивой догрузкой | generic source 2026-09-11 | P1 | BACKLOG |
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером + `TelegramSourceContentProvider` (только telegram-сервис) + рендер `DataRef` в UI | generic source 2026-09-11 | P1 | BACKLOG |
| TD-TG-CORE-SPLIT | Перенести оставшуюся Telegram-специфику ядра (`TelegramStore`, таблицы `Dialogs`/`TgMessages`, Discovery, ingress-proto) в telegram-сервис; generic ingress-контракт источников | generic source 2026-09-11 | P2 | TECHDEBT |
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
+5 -4
View File
@@ -162,6 +162,7 @@
| `POST /{cardId}/files` | Загрузить файлы (multipart, поле `files`) | FormData `files` | → карточка (с обновлённым `files`) |
| `GET /{cardId}/files/{fileId}/download` | Скачать (stream из MinIO/локального store) | — | `application/octet-stream`, `Content-Disposition: attachment`; 410/404 |
| `DELETE /{cardId}/files/{fileId}` | Открепить файл | — | → карточка |
| `GET /{cardId}/source` | Содержимое источника: провайдер по `source.kind` либо сохранённое в карточке | — | `SourceContent`; 404 «Карточка не найдена» |
| `POST /{cardId}/reminder` | Напоминание карточке | `{at: <epoch ms>}` | → карточка; 400 «Поле at (epoch-ms) обязательно» |
| `DELETE /{cardId}/reminder` | Снять напоминание | — | → карточка |
| `POST /{cardId}/reminder/snooze` | Отложить на +24 ч | — | → карточка |
@@ -318,12 +319,12 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
### 4.5 Очередь и отсев (вкладка «Обработка»)
Очередь (`GET /pipeline/queue` item): `{id, dialogId, msgId: int|null, text, status: "new"|"filtered", ch:{name,handle,hue}, msgAt: ms, queuedAt: ms}` фронт читает все (кроме dialogId/msgId/queuedAt, которые используются только как справочные; UI показывает text, статус-бейдж, канал).
Очередь (`GET /pipeline/queue` item): `{id, source: SourceRef, content: SourceContent, text, status: "new"|"filtered", msgAt: ms, queuedAt: ms}` — UI показывает `text`, статус-бейдж и подпись источника (`source.displayName`, цвет `source.extra.hue`).
Отсев (`GET /pipeline/rejected` item): `{id, dialogId, msgId, text, stage, stageLabel, reason, kw, source, sourceLabel, ch:{name,handle,hue}, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`.
Отсев (`GET /pipeline/rejected` item): `{id, source: SourceRef, content: SourceContent, text, stage, stageLabel, reason, kw, decidedBy, decidedByLabel, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`.
- `stage``length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup`; `stageLabel` — подпись («короткое сообщение», «стоп-фраза», «спам (ML)», …).
- `source``stop|ml|ai|stale|dup`; `sourceLabel` ∈ «правила|ML|ИИ|система».
- Фронт читает: `id, stageLabel, kw, reason, source, sourceLabel, text, ch, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `source==='dup'` или `returned`.
- `decidedBy``stop|ml|ai|stale|dup`; `decidedByLabel` ∈ «правила|ML|ИИ|система».
- Фронт читает: `id, stageLabel, kw, reason, decidedBy, decidedByLabel, text, source, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `decidedBy==='dup'` или `returned`.
### 4.6 Настройки (settings) — все ключи ответа `GET/PATCH /api/settings` (camelCase; значения по умолчанию из `constants.DEFAULT_SETTINGS`)
+2 -1
View File
@@ -7,7 +7,8 @@
> персистентность, конвейер, wire, фронт) переведено с Telegram-полей карточки на generic-тип
> `SourceItem` (`SourceRef` + `SourceContent`, вложения — `DataRef` → общий Storage). Дашборды/канбан/конвейер
> больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции
> пересозданы с нуля (init). Ядро: build 4 sln 0/0, `Deal.Tests.Unit` **1277/1277 PASS**, фронт `build` +
> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и
> `GET /api/cards/{id}/source`. Ядро: build 4 sln 0/0, `Deal.Tests.Unit` **1280/1280 PASS**, фронт `build` +
> `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
@@ -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);
}
}