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