Ввести единый контракт источника в домен 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
@@ -1,32 +0,0 @@
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
/// Источник «ИИ»: карточка создана/сгенерирована ИИ
/// </summary>
public interface IAiSource : ISource
{
/// <summary>
/// Id провайдера ИИ
/// </summary>
public string ProviderId { get; }
/// <summary>
/// Модель провайдера.
/// </summary>
public string Model { get; }
/// <summary>
/// Id ИИ-агента
/// </summary>
public string? AgentId { get; }
/// <summary>
/// Ссылка на промпт/шаблон
/// </summary>
public string? PromptRef { get; }
/// <summary>
/// API, через который работал агент
/// </summary>
public IApiSource? ViaApi { get; }
}
@@ -1,14 +0,0 @@
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
/// Источник «внешний API»
/// </summary>
public interface IApiSource : ISource
{
public string ProviderId { get; }
/// <summary>
/// Адрес эндпоинта
/// </summary>
public string? Endpoint { get; }
}
@@ -1,3 +1,5 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
@@ -18,5 +20,5 @@ public interface ICard
/// <summary>
/// Источник: откуда карточка пришла
/// </summary>
public ISource Source { get; }
public SourceRef Source { get; }
}
@@ -1,17 +0,0 @@
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
/// Составной источник
/// </summary>
public interface ICompositeSource : ISource
{
/// <summary>
/// Первоисточник контента.
/// </summary>
public ISource Origin { get; }
/// <summary>
/// Цепочка обработки
/// </summary>
public IReadOnlyList<ISource> Pipeline { get; }
}
@@ -1,19 +0,0 @@
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
/// Источник «файл»
/// </summary>
public interface IFileSource : ISource
{
public string ObjectKey { get; }
/// <summary>
/// Имя файла.
/// </summary>
public string FileName { get; }
/// <summary>
/// Размер файла в байтах.
/// </summary>
public long SizeBytes { get; }
}
@@ -1,12 +0,0 @@
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
/// Источник «создано вручную/локально»
/// </summary>
public interface ILocalSource : ISource
{
/// <summary>
/// Id автора (оператора тенанта), создавшего карточку; null — неизвестен.
/// </summary>
public string? AuthorId { get; }
}
@@ -1,22 +0,0 @@
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
/// Источник «импорт данных»
/// </summary>
public interface IRowSource : ISource
{
/// <summary>
/// Id таблицы/импорта
/// </summary>
public string TableId { get; }
/// <summary>
/// Id строки в таблице
/// </summary>
public string RowId { get; }
/// <summary>
/// Id колонки, из которой взят заголовок/содержимое; null — колонка не применима.
/// </summary>
public string? ColumnId { get; }
}
@@ -1,27 +0,0 @@
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
/// Источник карточки
/// </summary>
public interface ISource
{
/// <summary>
/// Подпись источника в UI
/// </summary>
public string DisplayName { get; }
/// <summary>
/// Ссылка на оригинал
/// </summary>
public string? OriginRef { get; }
/// <summary>
/// Сырое содержимое
/// </summary>
public string? RawPayload { get; }
/// <summary>
/// Время получения/создания исходных данных.
/// </summary>
public DateTimeOffset ReceivedAt { get; }
}
@@ -1,32 +0,0 @@
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
/// Источник «Telegram»
/// </summary>
public interface ITelegramSource : ISource
{
/// <summary>
/// Id диалога (peer) в Telegram.
/// </summary>
public string DialogId { get; }
/// <summary>
/// Id сообщения в диалоге.
/// </summary>
public long MessageId { get; }
/// <summary>
/// Handle канала
/// </summary>
public string? PeerHandle { get; }
/// <summary>
/// Имя канала/группы/чата для отображения.
/// </summary>
public string PeerName { get; }
/// <summary>
/// Id темы форума
/// </summary>
public string? TopicId { get; }
}
@@ -1,17 +0,0 @@
namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary>
/// Источник «ссылка на сайт/объявление»
/// </summary>
public interface IWebSource : ISource
{
/// <summary>
/// Адрес страницы/объявления.
/// </summary>
public string Url { get; }
/// <summary>
/// Имя сайта (домен/бренд); null — не определено.
/// </summary>
public string? SiteName { get; }
}
@@ -1,4 +1,5 @@
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Cards.Application.Models;
@@ -26,7 +27,7 @@ public sealed class Card :
public string Title { get; init; } = string.Empty;
/// <inheritdoc />
public ISource Source { get; init; } = null!;
public SourceRef Source { get; init; } = new() { Kind = SourceKinds.Local };
// ── Модуль «содержимое» ──
@@ -0,0 +1,20 @@
namespace Deal.Modules.Cards.Application.Sources;
/// <summary>
/// Контакт, извлечённый из источника или указанный вручную.
/// </summary>
public sealed record ContactRef
{
/// <summary>
/// Вид контакта (например, телефон/почта/мессенджер/сайт).
/// </summary>
public string? Kind { get; init; }
public string? Name { get; init; }
public string? Phone { get; init; }
public string? Email { get; init; }
public string? Url { get; init; }
}
@@ -0,0 +1,48 @@
namespace Deal.Modules.Cards.Application.Sources;
/// <summary>
/// Ссылка на файл в общем сервисе данных.
/// </summary>
public sealed record DataRef
{
/// <summary>
/// Идентификатор объекта в сервисе данных.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Ссылка на скачивание/отображение.
/// </summary>
public required string Ref { get; init; }
/// <summary>
/// Тип, определённый сервисом данных (image/video/audio/document/archive/other).
/// </summary>
public string? Kind { get; init; }
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; }
/// <summary>
/// Ссылка на превью (если сервис данных сгенерировал).
/// </summary>
public string? PreviewRef { get; init; }
public string? Caption { get; init; }
public int? Order { get; init; }
/// <summary>
/// Прочие метаданные от сервиса данных.
/// </summary>
public IReadOnlyDictionary<string, string>? Meta { get; init; }
}
@@ -0,0 +1,35 @@
namespace Deal.Modules.Cards.Application.Sources;
/// <summary>
/// Содержимое первичного сообщения источника.
/// </summary>
public sealed record SourceContent
{
public string? Text { get; init; }
/// <summary>
/// Форматированный текст (если источник его предоставил).
/// </summary>
public string? Html { get; init; }
public string? Author { get; init; }
public string? Subject { get; init; }
/// <summary>
/// Вложения: ссылки на файлы в общем сервисе данных.
/// </summary>
public IReadOnlyList<DataRef> Data { get; init; } = [];
/// <summary>
/// Ссылки, не являющиеся файлами.
/// </summary>
public IReadOnlyList<string>? Links { get; init; }
public IReadOnlyList<ContactRef>? Contacts { get; init; }
/// <summary>
/// Прочие поля, не подходящие под текст/файл/ссылку/контакт.
/// </summary>
public IReadOnlyDictionary<string, string>? Extra { get; init; }
}
@@ -0,0 +1,11 @@
namespace Deal.Modules.Cards.Application.Sources;
/// <summary>
/// Единый контракт входящей записи: источник + содержимое.
/// </summary>
public sealed record SourceItem
{
public required SourceRef Source { get; init; }
public required SourceContent Content { get; init; }
}
@@ -0,0 +1,47 @@
namespace Deal.Modules.Cards.Application.Sources;
/// <summary>
/// Источник данных: откуда пришла запись.
/// </summary>
public sealed record SourceRef
{
/// <summary>
/// Дискриминатор источника (значение задаёт сервис-владелец).
/// </summary>
public required string Kind { get; init; }
/// <summary>
/// Идентификатор записи в источнике (сообщение/строка/файл).
/// </summary>
public string? ExternalId { get; init; }
/// <summary>
/// Подпись источника для интерфейса.
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Ссылка на оригинал (url, deep-link, путь).
/// </summary>
public string? OriginRef { get; init; }
public string? Author { get; init; }
public DateTimeOffset ReceivedAt { get; init; }
/// <summary>
/// Прочие метаданные источника.
/// </summary>
public IReadOnlyDictionary<string, string>? Extra { get; init; }
}
/// <summary>
/// Дискриминаторы источников, владельцем которых является модуль.
/// </summary>
public static class SourceKinds
{
/// <summary>
/// Запись создана вручную в системе.
/// </summary>
public const string Local = "local";
}
@@ -1,6 +1,7 @@
using Deal.Modules.Cards;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Tests.Unit.Modules.Cards;
@@ -65,7 +66,7 @@ public sealed class CardsDomainTests
[Fact]
public void Card_ImplementsCoreAndModuleRoles()
{
Card card = new() { Id = "c_1", Title = "Заголовок", Source = new LocalSourceStub("u_1") };
Card card = new() { Id = "c_1", Title = "Заголовок", Source = new SourceRef { Kind = SourceKinds.Local } };
Assert.IsAssignableFrom<ICard>(card);
Assert.IsAssignableFrom<IContentCard>(card);
@@ -84,7 +85,7 @@ public sealed class CardsDomainTests
[Fact]
public void Card_NewCard_DefaultsToInboxAndEmptyModules()
{
Card card = new() { Id = "c_2", Title = "Новая", Source = new LocalSourceStub("u_1") };
Card card = new() { Id = "c_2", Title = "Новая", Source = new SourceRef { Kind = SourceKinds.Local } };
Assert.Equal(CardIds.Inbox, card.ContainerId);
Assert.True(card.IsNew);
@@ -101,12 +102,17 @@ public sealed class CardsDomainTests
}
[Fact]
public void Card_Source_IsPolymorphic()
public void Card_Source_IsStored()
{
Card card = new() { Id = "c_3", Title = "Из TG", Source = new TelegramSourceStub("100", 5, "@chan", "Канал", null) };
Card card = new()
{
Id = "c_3",
Title = "Из источника",
Source = new SourceRef { Kind = "external", ExternalId = "42", DisplayName = "Канал" },
};
ISource source = card.Source;
Assert.IsType<TelegramSourceStub>(source);
Assert.Equal("@chan", ((ITelegramSource)source).PeerHandle);
Assert.Equal("external", card.Source.Kind);
Assert.Equal("42", card.Source.ExternalId);
Assert.Equal("Канал", card.Source.DisplayName);
}
}
@@ -1,16 +0,0 @@
using Deal.Modules.Cards.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Cards;
internal sealed class LocalSourceStub(string authorId) : ILocalSource
{
public string? AuthorId { get; } = authorId;
public string DisplayName => "создано вручную";
public string? OriginRef => null;
public string? RawPayload => null;
public DateTimeOffset ReceivedAt { get; } = DateTimeOffset.UtcNow;
}
@@ -1,27 +0,0 @@
using Deal.Modules.Cards.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Cards;
internal sealed class TelegramSourceStub(string dialogId, long messageId, string? peerHandle, string peerName, string? topicId)
: ITelegramSource
{
public string DialogId { get; } = dialogId;
public long MessageId { get; } = messageId;
public string? PeerHandle { get; } = peerHandle;
public string PeerName { get; } = peerName;
public string? TopicId { get; } = topicId;
public string DisplayName => PeerHandle is null ? PeerName : "@" + PeerHandle;
public string? OriginRef => PeerHandle is null
? $"https://t.me/c/{DialogId.TrimStart('-', '1', '0', '0')}/{MessageId}"
: $"https://t.me/{PeerHandle}/{MessageId}";
public string? RawPayload => null;
public DateTimeOffset ReceivedAt { get; } = DateTimeOffset.UtcNow;
}