# Дизайн: единый контракт источника + общий Storage-сервис данных Дата: 2026-09-11. Статус: реализовано в ядре (домен, Storage-сервис, персистентность, конвейер, wire, фронт); адаптер/провайдер telegram-сервиса и Storage-выгрузка — следующие шаги. Контракт не плодит типы вложений; файлы — в общем Storage. ## 1. Принцип 1. **Единый строго типизированный контракт.** Любой источник (Telegram, WhatsApp, Avito, сайт, файл, Excel) через адаптер приводит данные к одному типу `SourceItem`. Ядро, AI и ML работают только с ним. 2. **Данные файлов — в общем Storage-сервисе.** Каждый сервис-источник сам выгружает свои данные (картинки, видео, аудио, документы, любые файлы) в общий Storage с **токеном валидации**. Storage сам определяет тип и метаданные. В контракте хранится **ссылка** на файл, а не сам файл. 3. **Никаких подтипов вложений в контракте.** Не плодим `ImagePart/VideoPart/...`; есть универсальный `DataRef` с полем `Kind`, которое заполняет Storage. 4. Ссылки, контакты и прочее, что **не является файлом**, идут отдельными полями контента. 5. В ядре нет Telegram-полей и слова Telegram (только в telegram-сервисе); в комментариях нет упоминаний задач/этапов/ТЗ. ## 2. Единый контракт (Deal.Modules.Cards) ```csharp public sealed record SourceItem { public required SourceRef Source { get; init; } public required SourceContent Content { get; init; } } public sealed record SourceRef { public required string Kind { get; init; } // "telegram", "whatsapp", "avito", "file", "excel", ... public string? ExternalId { get; init; } // id в источнике (сообщение/строка/файл) public string? DisplayName { get; init; } // подпись в UI public string? OriginRef { get; init; } // url / deep-link / путь public string? Author { get; init; } public DateTimeOffset ReceivedAt { get; init; } public IReadOnlyDictionary? Extra { get; init; } } public sealed record SourceContent { public string? Text { get; init; } // основной текст public string? Html { get; init; } // разметка (если есть) public string? Author { get; init; } // отправитель public string? Subject { get; init; } // тема/заголовок public IReadOnlyList Data { get; init; } = []; // ссылки на файлы в Storage public IReadOnlyList? Links { get; init; } // ссылки (не файлы) public IReadOnlyList? Contacts { get; init; } // контакты public IReadOnlyDictionary? Extra { get; init; } // прочее (не файл/не ссылка/не контакт) } ``` `DataRef` — ссылка на объект в Storage; тип и метаданные определил Storage (nullable, чтобы не плодить типы): ```csharp public sealed record DataRef { public required string Id { get; init; } // идентификатор объекта в Storage public required string Ref { get; init; } // ссылка (url/путь) для скачивания/отображения public string? Kind { get; init; } // определил Storage: image/video/audio/document/archive/other public string? MimeType { get; init; } public string? FileName { get; init; } public long? Size { get; init; } public int? Width { get; init; } public int? Height { get; init; } public double? DurationSec { get; init; } public string? PreviewRef { get; init; } // превью/thumbnail public string? Caption { get; init; } public int? Order { get; init; } public IReadOnlyDictionary? Meta { get; init; } // прочие метаданные от Storage } ``` `ContactRef`: `Name?`, `Phone?`, `Email?`, `Url?`, `Kind?` (контакт может быть квалифицирован). ## 3. Storage-сервис (общий) Отдельный сервис (как ai/ml/telegram), владелец — данные. Источники и ядро только ссылаются на объекты. - **Загрузка:** `Upload(stream, token, fileName?) → DataRef`. Каждый сервис-источник выгружает свои данные **сам**, передавая **токен валидации** (сервисный токен/mTLS — уже есть в gRPC-обвязке). - **Определение типа:** Storage сам решает `Kind`/`MimeType`/размеры/длительность (контент-снифинг); контракт типы не задаёт. - **Чтение:** `Get(id) → (stream, DataRef)` либо выдача ссылки/временного URL. - **Бэкенд:** объектное хранилище (MinIO/S3). Путь/бакет — по тенанту. - **Владение:** единый общий сервис; каждый источник пишет в него со своим токеном, ядро/AI/ML читают по ссылке. ## 4. Адаптеры источников ```csharp public interface ISourceAdapter { string Kind { get; } SourceItem Normalize(object native); } ``` Владельцы: `telegram` → telegram-сервис; `local` → ручное создание (Cards); `whatsapp`/`avito`/`web`/`file`/ `excel` → соответствующий сервис. Файлы адаптер сам выгружает в Storage и кладёт в контракт `DataRef`. ## 5. Загрузка исходника карточки Единый способ: по `SourceRef.Kind` — провайдер, возвращающий `SourceContent` (для файла — через Storage по `DataRef.Ref`, для сообщения — у источника). `ISourceContentProvider { Kind; LoadAsync(SourceRef) }` + реестр. API ядра: `GET /api/cards/{id}/source` → generic контент. ## 6. Персистентность В карточках вместо плоских Telegram-колонок: - `SourceKind` (text); `SourceJson` (jsonb, `SourceRef`); - `ContentJson` (jsonb, `SourceContent` — текст + `DataRef`-ссылки + прочее); - `SourceText` (text, FTS); - `SourceRefUrl` (text?, `OriginRef`). Конвертер контента общий (без per-source сериализаторов). Миграции: старые удаляем → новый init с нуля. ## 7. Wire и фронт - `CardDto.Source` = `{ kind, displayName?, originRef?, receivedAt }`. - `GET /api/cards/{id}/source` → `{ text?, html?, author?, subject?, data[], links[], contacts[], extra? }`. - Фронт: generic блок источника + универсальный просмотрщик (по `DataRef.Kind` — картинка/видео/аудио/файл; ссылки/контакты — списками). ## 8. Этапы 1. Домен: `SourceItem/SourceRef/SourceContent/DataRef/ContactRef`; удалить Telegram-маркеры из Cards. 2. Storage-сервис: контракт gRPC, определение типа, токен валидации, бэкенд MinIO; регистрация. 3. Персистентность: `SourceKind/SourceJson/ContentJson/SourceText/SourceRefUrl`, общий конвертер, новый init, маппинг KanbanStore. 4. Pipeline: приём `SourceItem`, загрузка вложений в Storage адаптером, без Telegram-полей. 5. Wire/API: generic `Source` в `CardDto`, `GET /api/cards/{id}/source`, провайдеры. 6. Frontend: generic источник + универсальный просмотрщик. 7. Telegram: адаптер + провайдер исходника (только в telegram-сервисе) + выгрузка в Storage. 8. Комментарии: убрать упоминания Telegram из ядра и задачи/этапы — везде. ## 9. Реализация: зафиксированные сигнатуры ### Ядро: домен - `SourceRefs` (Deal.Modules.Cards/Application/Sources): `Empty`, `DefaultHue = "#666"`, `HueKey = "hue"`, расширения `DedupeKey()` (вид|оригинал|внешний id), `ResolveHue()`. - `CardSnapshot`: вместо `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId` — `SourceRef Source` + `SourceContent Content`; `ReceivedAt` остаётся. - `CardDto`: вместо `Channel`/`SourceMsg`/`SourceDialogId`/`SourceMsgId`/прежнего `Source` — `SourceRef Source` + `SourceContent Content`; `ReceivedAtMs` остаётся. `CardChannelDto`/`CardSourceDto` удалены. - `ICardStore.GetCardBySourceAsync(SourceRef source, CancellationToken ct)`. ### Ядро: конвейер - `QueuedMessage { required SourceItem Item; bool Force; }`. - `QueueItemDto { string Id; SourceRef Source; SourceContent Content; string Text; string Status; long MsgAtMs; long QueuedAtMs; bool Force; }` (JsonIgnore на Force). - `RejectRecord { SourceRef Source; SourceContent Content; string Text; long MsgAtMs; string DecidedBy; string Stage; string Reason; string Kw; string? DeterministicId; }` (`DeterministicId = r_{Kind}_{OriginRef}_{ExternalId}`). - `RejectedItemDto`: `Source`/`Content`, `DecidedBy`/`DecidedByLabel` (решение), остальное как было. - `IPipelineStore.ExistsDuplicateAsync(SourceRef source, CancellationToken ct)`. - `PipelineChannelDto` удалён. ### Схема БД (схема тенанта) - Cards: удалить `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId`; добавить `SourceKind`, `SourceExternalId`, `SourceOriginRef` (text, для запросов), `SourceJson` (text), `ContentJson` (text), `SourceText` (text). FTS: Title+Summary+SourceText+Contact. - QueueItems: удалить `DialogId/ChannelName/ChannelHandle/ChannelHue/MsgId`; добавить `SourceKey` (text, уникальный ключ дедупа), `SourceJson`, `ContentJson`. `Text/MsgAt/Status/Force/CreatedAt/UpdatedAt` остаются. - RejectedItems: удалить `DialogId/MsgId/ChannelName/ChannelHandle/ChannelHue`; добавить `SourceKey`, `SourceJson`, `ContentJson`. FTS — по `Text`. - Миграции tenant: старые удалить, сгенерировать новый init с нуля (данных нет). ### Маппинг источника - Адаптер Telegram (в ядре — тонкий край приёма): `Kind="telegram"`, `ExternalId=MsgId`, `OriginRef=DialogId`, `DisplayName=ChannelName`, `Extra["hue"]=ChannelHue` (иначе дефолт), `ReceivedAt=msgAt`, `Content.Text=Text`, `Content.Author=ChannelName`. - Дашборды/карточки/конвейер работают только с `SourceRef`/`SourceContent`; Telegram-поля не проходят дальше адаптера.