Ввести единый контракт источника в домен Cards

Добавлены SourceItem/SourceRef/SourceContent/DataRef/ContactRef
(строго типизированный контракт без подтипов вложений; файлы —
ссылки на общий сервис данных). Удалены Telegram-ориентированные
source-интерфейсы из Cards; Card/ICard переведены на SourceRef.
This commit is contained in:
Rustam Khalimov
2026-09-11 14:23:33 +03:00
parent bf4c021ca9
commit e1aebd1d78
20 changed files with 308 additions and 244 deletions
@@ -0,0 +1,129 @@
# Дизайн: единый контракт источника + общий Storage-сервис данных
Дата: 2026-09-11. Статус: на согласовании (контракт не плодит типы вложений; файлы — в общем 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<string, string>? 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<DataRef> Data { get; init; } = []; // ссылки на файлы в Storage
public IReadOnlyList<string>? Links { get; init; } // ссылки (не файлы)
public IReadOnlyList<ContactRef>? Contacts { get; init; } // контакты
public IReadOnlyDictionary<string, string>? 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<string, string>? 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 из ядра и задачи/этапы — везде.