Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,160 @@
using System.Text.Json;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Telegram;
/// <summary>
/// Читает, расшифровывает и сохраняет ключи приложения Telegram из глобальной (системной)
/// настройки оператора (ТЗ §4.1/§8.1).
/// </summary>
/// <remarks>
/// Источник ключей — операторский уровень: <c>public.global_settings</c> (ключ
/// <see cref="GlobalSettingsKeys.TelegramKeys"/>), единый для всех тенантов; тенант ключи не задаёт.
/// apiHash хранится зашифрованным (<c>enc:</c> — Ruling 2), apiId — открытым (не секрет). Наружу
/// (командам входа/статусу) отдаётся расшифрованный снимок, операторским ручкам — маскированный
/// (apiHash не раскрывается). Повреждённая строка/сбой расшифровки — пустые ключи (мягкая семантика).
/// Scoped: IGlobalSettingsStore живёт на системном DealDbContext запроса.
/// </remarks>
/// <param name="store">KV-хранилище глобальных настроек оператора (таблица public.global_settings).</param>
/// <param name="cipher">Шифр секретов (AES-256-GCM, формат <c>enc:</c>).</param>
public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCipher cipher)
{
/// <summary>
/// Минимальная длина api_id приложения Telegram (только цифры).
/// </summary>
public const int ApiIdMinDigits = 5;
/// <summary>
/// Максимальная длина api_id приложения Telegram (только цифры).
/// </summary>
public const int ApiIdMaxDigits = 9;
// Символ-заполнитель маски секрета (U+2026, «1234…5678»).
private const string MaskEllipsis = "…";
// Префикс зашифрованного значения (маркер формата в хранилище, Ruling 2).
private const string EncryptedPrefix = "enc:";
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
private static readonly JsonSerializerOptions KeysJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Читает текущие ключи приложения: apiId (открыт в БД) + расшифрованный apiHash.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Снимок ключей (пустые — настройка не задана/повреждена).</returns>
public async Task<TgKeysSnapshot> GetAsync(CancellationToken ct)
{
SettingValue? row = await store.GetAsync(GlobalSettingsKeys.TelegramKeys, ct).ConfigureAwait(false);
if (row is null)
{
return new TgKeysSnapshot(string.Empty, string.Empty);
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement;
string apiId = ReadString(root, "apiId");
string apiHash = ReadString(root, "apiHash");
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
}
catch (JsonException)
{
// Повреждённая строка telegramKeys — пустые ключи (мягкая семантика, как SettingsService).
return new TgKeysSnapshot(string.Empty, string.Empty);
}
}
/// <summary>
/// Маскированная форма ключей для операторской ручки: apiId открыт, apiHash — маска.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>DTO с флагом keysSet (оба ключа заданы) и маской apiHash.</returns>
public async Task<TelegramKeysMaskedDto> GetMaskedAsync(CancellationToken ct)
{
TgKeysSnapshot snapshot = await GetAsync(ct).ConfigureAwait(false);
return new TelegramKeysMaskedDto(
ApiId: snapshot.ApiId,
ApiHash: MaskSecret(snapshot.ApiHash),
KeysSet: snapshot.KeysSet);
}
/// <summary>
/// Сохраняет глобальные ключи приложения: apiId открытым, apiHash — зашифрованным.
/// </summary>
/// <param name="apiId">api_id приложения (5..9 цифр).</param>
/// <param name="apiHash">api_hash приложения (непустой секрет).</param>
/// <param name="ct">Токен отмены.</param>
/// <exception cref="ArgumentException">Значения не прошли валидацию (см. <see cref="IsValidApiId"/>/<see cref="IsValidApiHash"/>).</exception>
public async Task SaveAsync(string apiId, string apiHash, CancellationToken ct)
{
string trimmedApiId = (apiId ?? string.Empty).Trim();
string trimmedApiHash = (apiHash ?? string.Empty).Trim();
if (!IsValidApiId(trimmedApiId))
{
throw new ArgumentException("api_id должен состоять из 5–9 цифр.", nameof(apiId));
}
if (!IsValidApiHash(trimmedApiHash))
{
throw new ArgumentException("api_hash непустой, без маски и без префикса enc:.", nameof(apiHash));
}
var value = new TelegramKeysValue(trimmedApiId, cipher.Encrypt(trimmedApiHash));
string valueJson = JsonSerializer.Serialize(value, KeysJsonOptions);
await store.SetAsync(GlobalSettingsKeys.TelegramKeys, valueJson, ct).ConfigureAwait(false);
}
/// <summary>
/// Валиден ли api_id: только ASCII-цифры, длина 5..9 (ТЗ §4.1/§8.1).
/// </summary>
/// <param name="apiId">Проверяемое значение (уже без пробелов).</param>
/// <returns>True — значение допустимо.</returns>
public static bool IsValidApiId(string apiId) =>
apiId.Length is >= ApiIdMinDigits and <= ApiIdMaxDigits && apiId.All(char.IsAsciiDigit);
/// <summary>
/// Валиден ли api_hash: непустой, без маски (…), без префикса <c>enc:</c>.
/// </summary>
/// <param name="apiHash">Проверяемое значение (уже без пробелов).</param>
/// <returns>True — значение допустимо к шифрованию и сохранению.</returns>
public static bool IsValidApiHash(string apiHash) =>
apiHash.Length > 0
&& !apiHash.Contains(MaskEllipsis, StringComparison.Ordinal)
&& !apiHash.StartsWith(EncryptedPrefix, StringComparison.Ordinal);
// Читает строковое поле JSON-объекта (пусто при отсутствии/не-строке).
// root: Корень JSON значения telegramKeys.
// propertyName: Имя поля (camelCase).
// Возвращает: Значение поля (trim) или пустая строка.
private static string ReadString(JsonElement root, string propertyName)
{
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty(propertyName, out JsonElement element)
|| element.ValueKind != JsonValueKind.String)
{
return string.Empty;
}
return (element.GetString() ?? string.Empty).Trim();
}
// Маска СЕКРЕТА (apiHash): всегда скрывает, кроме пустого. Длина ≤ 8 → «x…», иначе «1234…5678».
// value: Открытый секрет (не null).
// Возвращает: Маскированная строка.
private static string MaskSecret(string value)
{
return value.Length switch
{
0 => string.Empty,
<= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis),
_ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)),
};
}
}